From 638e87b05d9ff88b12a9c390528e80ddc70a3130 Mon Sep 17 00:00:00 2001 From: kent Date: Thu, 25 Dec 2025 03:48:32 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B8=89=EC=97=AC=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20API=20=EB=B0=8F=20=EB=8D=94=EB=AF=B8=20=EC=8B=9C=EB=8D=94=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 급여 관리 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 --- .serena/.gitignore | 1 + .serena/project.yml | 84 +++++ LOGICAL_RELATIONSHIPS.md | 8 +- .../Controllers/Api/V1/SalaryController.php | 126 +++++++ .../V1/Salary/BulkUpdateStatusRequest.php | 32 ++ .../Requests/V1/Salary/StoreSalaryRequest.php | 44 +++ .../V1/Salary/UpdateSalaryRequest.php | 33 ++ app/Models/Members/User.php | 12 + app/Models/Tenants/Department.php | 3 +- app/Models/Tenants/Salary.php | 165 +++++++++ app/Services/CardService.php | 15 +- app/Services/DepartmentService.php | 38 ++- app/Services/SalaryService.php | 277 ++++++++++++++++ ...025_12_25_004032_create_salaries_table.php | 60 ++++ database/seeders/ApprovalTestDataSeeder.php | 312 ++++++++++++++++++ .../seeders/Dummy/DummyAttendanceSeeder.php | 191 +++++++++++ .../Dummy/DummyAttendanceSettingSeeder.php | 34 ++ database/seeders/Dummy/DummyBadDebtSeeder.php | 7 + .../seeders/Dummy/DummyBankAccountSeeder.php | 7 + database/seeders/Dummy/DummyBillSeeder.php | 7 + .../seeders/Dummy/DummyClientGroupSeeder.php | 7 + database/seeders/Dummy/DummyClientSeeder.php | 7 + .../seeders/Dummy/DummyDepartmentSeeder.php | 133 ++++++++ database/seeders/Dummy/DummyDepositSeeder.php | 7 + .../seeders/Dummy/DummyLeaveGrantSeeder.php | 95 ++++++ database/seeders/Dummy/DummyLeaveSeeder.php | 135 ++++++++ database/seeders/Dummy/DummyPaymentSeeder.php | 9 + database/seeders/Dummy/DummyPopupSeeder.php | 7 + .../seeders/Dummy/DummyPurchaseSeeder.php | 7 + database/seeders/Dummy/DummySaleSeeder.php | 7 + database/seeders/Dummy/DummyUserSeeder.php | 89 +++++ .../seeders/Dummy/DummyWithdrawalSeeder.php | 7 + .../seeders/Dummy/DummyWorkSettingSeeder.php | 39 +++ database/seeders/DummyDataSeeder.php | 63 +++- routes/api.php | 13 + 35 files changed, 2057 insertions(+), 24 deletions(-) create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 app/Http/Controllers/Api/V1/SalaryController.php create mode 100644 app/Http/Requests/V1/Salary/BulkUpdateStatusRequest.php create mode 100644 app/Http/Requests/V1/Salary/StoreSalaryRequest.php create mode 100644 app/Http/Requests/V1/Salary/UpdateSalaryRequest.php create mode 100644 app/Models/Tenants/Salary.php create mode 100644 app/Services/SalaryService.php create mode 100644 database/migrations/2025_12_25_004032_create_salaries_table.php create mode 100644 database/seeders/ApprovalTestDataSeeder.php create mode 100644 database/seeders/Dummy/DummyAttendanceSeeder.php create mode 100644 database/seeders/Dummy/DummyAttendanceSettingSeeder.php create mode 100644 database/seeders/Dummy/DummyDepartmentSeeder.php create mode 100644 database/seeders/Dummy/DummyLeaveGrantSeeder.php create mode 100644 database/seeders/Dummy/DummyLeaveSeeder.php create mode 100644 database/seeders/Dummy/DummyUserSeeder.php create mode 100644 database/seeders/Dummy/DummyWorkSettingSeeder.php diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..e98761b --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,84 @@ +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp csharp_omnisharp +# dart elixir elm erlang fortran go +# haskell java julia kotlin lua markdown +# nix perl php python python_jedi r +# rego ruby ruby_solargraph rust scala swift +# terraform typescript typescript_vts yaml zig +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# Special requirements: +# - csharp: Requires the presence of a .sln file in the project folder. +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- php + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# whether to use the project's gitignore file to ignore files +# Added on 2025-04-07 +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore +# same syntax as gitignore, so you can use * and ** +# Was previously called `ignored_dirs`, please update your config if you are using that. +# Added (renamed) on 2025-04-07 +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +project_name: "api" +included_optional_tools: [] diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 29ec5b0..77ccda0 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2025-12-24 19:31:13 +> **자동 생성**: 2025-12-25 00:41:11 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -553,6 +553,12 @@ ### leave_balances - **user()**: belongsTo → `users` +### leave_grants +**모델**: `App\Models\Tenants\LeaveGrant` + +- **user()**: belongsTo → `users` +- **creator()**: belongsTo → `users` + ### loans **모델**: `App\Models\Tenants\Loan` diff --git a/app/Http/Controllers/Api/V1/SalaryController.php b/app/Http/Controllers/Api/V1/SalaryController.php new file mode 100644 index 0000000..12946c2 --- /dev/null +++ b/app/Http/Controllers/Api/V1/SalaryController.php @@ -0,0 +1,126 @@ +only([ + 'search', + 'year', + 'month', + 'status', + 'employee_id', + 'start_date', + 'end_date', + 'sort_by', + 'sort_dir', + 'per_page', + 'page', + ]); + + $salaries = $this->service->index($params); + + return ApiResponse::success($salaries, __('message.fetched')); + } + + /** + * 급여 등록 + */ + public function store(StoreSalaryRequest $request) + { + $salary = $this->service->store($request->validated()); + + return ApiResponse::success($salary, __('message.created'), [], 201); + } + + /** + * 급여 상세 + */ + public function show(int $id) + { + $salary = $this->service->show($id); + + return ApiResponse::success($salary, __('message.fetched')); + } + + /** + * 급여 수정 + */ + public function update(int $id, UpdateSalaryRequest $request) + { + $salary = $this->service->update($id, $request->validated()); + + return ApiResponse::success($salary, __('message.updated')); + } + + /** + * 급여 삭제 + */ + public function destroy(int $id) + { + $this->service->destroy($id); + + return ApiResponse::success(null, __('message.deleted')); + } + + /** + * 급여 상태 변경 (지급완료/지급예정) + */ + public function updateStatus(int $id, Request $request) + { + $status = $request->input('status', 'completed'); + $salary = $this->service->updateStatus($id, $status); + + return ApiResponse::success($salary, __('message.updated')); + } + + /** + * 급여 일괄 상태 변경 + */ + public function bulkUpdateStatus(BulkUpdateStatusRequest $request) + { + $count = $this->service->bulkUpdateStatus( + $request->input('ids'), + $request->input('status') + ); + + return ApiResponse::success( + ['updated_count' => $count], + __('message.bulk_updated') + ); + } + + /** + * 급여 통계 조회 + */ + public function statistics(Request $request) + { + $params = $request->only([ + 'year', + 'month', + 'start_date', + 'end_date', + ]); + + $stats = $this->service->getStatistics($params); + + return ApiResponse::success($stats, __('message.fetched')); + } +} \ No newline at end of file diff --git a/app/Http/Requests/V1/Salary/BulkUpdateStatusRequest.php b/app/Http/Requests/V1/Salary/BulkUpdateStatusRequest.php new file mode 100644 index 0000000..485c452 --- /dev/null +++ b/app/Http/Requests/V1/Salary/BulkUpdateStatusRequest.php @@ -0,0 +1,32 @@ + ['required', 'array', 'min:1'], + 'ids.*' => ['required', 'integer'], + 'status' => ['required', 'string', 'in:scheduled,completed'], + ]; + } + + public function messages(): array + { + return [ + 'ids.required' => __('validation.required', ['attribute' => 'ID 목록']), + 'ids.min' => __('validation.min.array', ['attribute' => 'ID 목록', 'min' => 1]), + 'status.required' => __('validation.required', ['attribute' => '상태']), + 'status.in' => __('validation.in', ['attribute' => '상태']), + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/V1/Salary/StoreSalaryRequest.php b/app/Http/Requests/V1/Salary/StoreSalaryRequest.php new file mode 100644 index 0000000..b23f1d1 --- /dev/null +++ b/app/Http/Requests/V1/Salary/StoreSalaryRequest.php @@ -0,0 +1,44 @@ + ['required', 'integer', 'exists:users,id'], + 'year' => ['required', 'integer', 'min:2020', 'max:2100'], + 'month' => ['required', 'integer', 'min:1', 'max:12'], + 'base_salary' => ['required', 'numeric', 'min:0'], + 'total_allowance' => ['nullable', 'numeric', 'min:0'], + 'total_overtime' => ['nullable', 'numeric', 'min:0'], + 'total_bonus' => ['nullable', 'numeric', 'min:0'], + 'total_deduction' => ['nullable', 'numeric', 'min:0'], + 'allowance_details' => ['nullable', 'array'], + 'allowance_details.*' => ['nullable', 'array'], + 'deduction_details' => ['nullable', 'array'], + 'deduction_details.*' => ['nullable', 'array'], + 'payment_date' => ['nullable', 'date'], + 'status' => ['nullable', 'string', 'in:scheduled,completed'], + ]; + } + + public function messages(): array + { + return [ + 'employee_id.required' => __('validation.required', ['attribute' => '직원']), + 'employee_id.exists' => __('validation.exists', ['attribute' => '직원']), + 'year.required' => __('validation.required', ['attribute' => '년도']), + 'month.required' => __('validation.required', ['attribute' => '월']), + 'base_salary.required' => __('validation.required', ['attribute' => '기본급']), + ]; + } +} diff --git a/app/Http/Requests/V1/Salary/UpdateSalaryRequest.php b/app/Http/Requests/V1/Salary/UpdateSalaryRequest.php new file mode 100644 index 0000000..769eb2a --- /dev/null +++ b/app/Http/Requests/V1/Salary/UpdateSalaryRequest.php @@ -0,0 +1,33 @@ + ['nullable', 'integer', 'exists:users,id'], + 'year' => ['nullable', 'integer', 'min:2020', 'max:2100'], + 'month' => ['nullable', 'integer', 'min:1', 'max:12'], + 'base_salary' => ['nullable', 'numeric', 'min:0'], + 'total_allowance' => ['nullable', 'numeric', 'min:0'], + 'total_overtime' => ['nullable', 'numeric', 'min:0'], + 'total_bonus' => ['nullable', 'numeric', 'min:0'], + 'total_deduction' => ['nullable', 'numeric', 'min:0'], + 'allowance_details' => ['nullable', 'array'], + 'allowance_details.*' => ['nullable', 'array'], + 'deduction_details' => ['nullable', 'array'], + 'deduction_details.*' => ['nullable', 'array'], + 'payment_date' => ['nullable', 'date'], + 'status' => ['nullable', 'string', 'in:scheduled,completed'], + ]; + } +} \ No newline at end of file diff --git a/app/Models/Members/User.php b/app/Models/Members/User.php index 4be158f..8ba42bb 100644 --- a/app/Models/Members/User.php +++ b/app/Models/Members/User.php @@ -46,6 +46,18 @@ class User extends Authenticatable 'deleted_at', ]; + protected $appends = [ + 'has_account', + ]; + + /** + * 시스템 계정(비밀번호) 보유 여부 + */ + public function getHasAccountAttribute(): bool + { + return ! empty($this->password); + } + public function userTenants() { return $this->hasMany(UserTenant::class); diff --git a/app/Models/Tenants/Department.php b/app/Models/Tenants/Department.php index f1def19..13a8191 100644 --- a/app/Models/Tenants/Department.php +++ b/app/Models/Tenants/Department.php @@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\Permission\Traits\HasRoles; class Department extends Model { - use HasRoles, ModelTrait; // 부서도 권한/역할을 가짐 + use HasRoles, ModelTrait, SoftDeletes; // 부서도 권한/역할을 가짐 protected $table = 'departments'; diff --git a/app/Models/Tenants/Salary.php b/app/Models/Tenants/Salary.php new file mode 100644 index 0000000..3abb1fb --- /dev/null +++ b/app/Models/Tenants/Salary.php @@ -0,0 +1,165 @@ + 'decimal:2', + 'total_allowance' => 'decimal:2', + 'total_overtime' => 'decimal:2', + 'total_bonus' => 'decimal:2', + 'total_deduction' => 'decimal:2', + 'net_payment' => 'decimal:2', + 'allowance_details' => 'array', + 'deduction_details' => 'array', + 'payment_date' => 'date', + ]; + + protected $attributes = [ + 'status' => 'scheduled', + 'base_salary' => 0, + 'total_allowance' => 0, + 'total_overtime' => 0, + 'total_bonus' => 0, + 'total_deduction' => 0, + 'net_payment' => 0, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 직원 + */ + public function employee(): BelongsTo + { + return $this->belongsTo(User::class, 'employee_id'); + } + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 지급 완료 여부 + */ + public function isCompleted(): bool + { + return $this->status === 'completed'; + } + + /** + * 지급 예정 여부 + */ + public function isScheduled(): bool + { + return $this->status === 'scheduled'; + } + + /** + * 상태 변경 + */ + public function markAsCompleted(): void + { + $this->status = 'completed'; + } + + /** + * 상태 변경 + */ + public function markAsScheduled(): void + { + $this->status = 'scheduled'; + } + + /** + * 실지급액 계산 + */ + public function calculateNetPayment(): float + { + return $this->base_salary + + $this->total_allowance + + $this->total_overtime + + $this->total_bonus + - $this->total_deduction; + } + + /** + * 년월 표시 + */ + public function getPeriodLabel(): string + { + return sprintf('%d년 %d월', $this->year, $this->month); + } +} \ No newline at end of file diff --git a/app/Services/CardService.php b/app/Services/CardService.php index fd14373..e8a545c 100644 --- a/app/Services/CardService.php +++ b/app/Services/CardService.php @@ -16,10 +16,11 @@ public function index(array $params): LengthAwarePaginator $tenantId = $this->tenantId(); $query = Card::query() - ->where('tenant_id', $tenantId); + ->where('tenant_id', $tenantId) + ->with(['assignedUser:id,name,email', 'assignedUser.department', 'assignedUser.position']); // 검색 필터 - if (! empty($params['search'])) { + if (!empty($params['search'])) { $search = $params['search']; $query->where(function ($q) use ($search) { $q->where('card_name', 'like', "%{$search}%") @@ -29,12 +30,12 @@ public function index(array $params): LengthAwarePaginator } // 상태 필터 - if (! empty($params['status'])) { + if (!empty($params['status'])) { $query->where('status', $params['status']); } // 담당자 필터 - if (! empty($params['assigned_user_id'])) { + if (!empty($params['assigned_user_id'])) { $query->where('assigned_user_id', $params['assigned_user_id']); } @@ -58,7 +59,7 @@ public function show(int $id): Card return Card::query() ->where('tenant_id', $tenantId) - ->with(['assignedUser:id,name']) + ->with(['assignedUser:id,name,email', 'assignedUser.department', 'assignedUser.position']) ->findOrFail($id); } @@ -82,7 +83,7 @@ public function store(array $data): Card $card->created_by = $userId; $card->updated_by = $userId; - if (! empty($data['card_password'])) { + if (!empty($data['card_password'])) { $card->setCardPassword($data['card_password']); } @@ -193,7 +194,7 @@ public function getActiveCards(): array 'id' => $card->id, 'card_name' => $card->card_name, 'card_company' => $card->card_company, - 'display_number' => '****-'.$card->card_number_last4, + 'display_number' => '****-' . $card->card_number_last4, ]; }) ->toArray(); diff --git a/app/Services/DepartmentService.php b/app/Services/DepartmentService.php index d5bbaa2..fbe26ef 100644 --- a/app/Services/DepartmentService.php +++ b/app/Services/DepartmentService.php @@ -111,6 +111,7 @@ public function store(array $params) $userId = $this->apiUserId(); $p = $this->v($params, [ + 'parent_id' => 'nullable|integer|min:1', 'code' => 'nullable|string|max:50', 'name' => 'required|string|max:100', 'description' => 'nullable|string|max:255', @@ -125,6 +126,14 @@ public function store(array $params) return $p; } + // parent_id 유효성 검사 + if (! empty($p['parent_id'])) { + $parent = Department::query()->find($p['parent_id']); + if (! $parent) { + return ['error' => '상위 부서를 찾을 수 없습니다.', 'code' => 404]; + } + } + if (! empty($p['code'])) { $exists = Department::query()->where('code', $p['code'])->exists(); if ($exists) { @@ -134,6 +143,7 @@ public function store(array $params) $dept = Department::create([ 'tenant_id' => $tenantId, + 'parent_id' => $p['parent_id'] ?? null, 'code' => $p['code'] ?? null, 'name' => $p['name'], 'description' => $p['description'] ?? null, @@ -169,6 +179,7 @@ public function update(int $id, array $params) } $p = $this->v($params, [ + 'parent_id' => 'nullable|integer|min:0', // 0이면 최상위로 이동 'code' => 'nullable|string|max:50', 'name' => 'nullable|string|max:100', 'description' => 'nullable|string|max:255', @@ -188,6 +199,22 @@ public function update(int $id, array $params) return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404]; } + // parent_id 유효성 검사 + if (array_key_exists('parent_id', $p)) { + $parentId = $p['parent_id']; + if ($parentId === 0) { + $parentId = null; // 0이면 최상위로 이동 + } elseif ($parentId) { + if ($parentId === $id) { + return ['error' => '자기 자신을 상위 부서로 설정할 수 없습니다.', 'code' => 422]; + } + $parent = Department::query()->find($parentId); + if (! $parent) { + return ['error' => '상위 부서를 찾을 수 없습니다.', 'code' => 404]; + } + } + } + if (array_key_exists('code', $p) && ! is_null($p['code'])) { $exists = Department::query() ->where('code', $p['code']) @@ -198,14 +225,21 @@ public function update(int $id, array $params) } } - $dept->fill([ + $fillData = [ 'code' => array_key_exists('code', $p) ? $p['code'] : $dept->code, 'name' => $p['name'] ?? $dept->name, 'description' => $p['description'] ?? $dept->description, 'is_active' => isset($p['is_active']) ? (int) $p['is_active'] : $dept->is_active, 'sort_order' => $p['sort_order'] ?? $dept->sort_order, 'updated_by' => $p['updated_by'] ?? $dept->updated_by, - ])->save(); + ]; + + // parent_id 업데이트 + if (array_key_exists('parent_id', $p)) { + $fillData['parent_id'] = $p['parent_id'] === 0 ? null : $p['parent_id']; + } + + $dept->fill($fillData)->save(); return $dept->fresh(); } diff --git a/app/Services/SalaryService.php b/app/Services/SalaryService.php new file mode 100644 index 0000000..6747582 --- /dev/null +++ b/app/Services/SalaryService.php @@ -0,0 +1,277 @@ +tenantId(); + + $query = Salary::query() + ->where('tenant_id', $tenantId) + ->with(['employee:id,name,employee_number', 'employee.department', 'employee.position', 'employee.rank']); + + // 검색 필터 (직원명) + if (!empty($params['search'])) { + $search = $params['search']; + $query->whereHas('employee', function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%"); + }); + } + + // 연도 필터 + if (!empty($params['year'])) { + $query->where('year', $params['year']); + } + + // 월 필터 + if (!empty($params['month'])) { + $query->where('month', $params['month']); + } + + // 상태 필터 + if (!empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 기간 필터 + if (!empty($params['start_date']) && !empty($params['end_date'])) { + $query->whereBetween('payment_date', [$params['start_date'], $params['end_date']]); + } + + // 직원 ID 필터 + if (!empty($params['employee_id'])) { + $query->where('employee_id', $params['employee_id']); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'year'; + $sortDir = $params['sort_dir'] ?? 'desc'; + + if ($sortBy === 'year') { + $query->orderBy('year', $sortDir) + ->orderBy('month', $sortDir); + } else { + $query->orderBy($sortBy, $sortDir); + } + + // 페이지네이션 + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 급여 상세 조회 + */ + public function show(int $id): Salary + { + $tenantId = $this->tenantId(); + + return Salary::query() + ->where('tenant_id', $tenantId) + ->with(['employee:id,name,employee_number', 'employee.department', 'employee.position', 'employee.rank']) + ->findOrFail($id); + } + + /** + * 급여 등록 + */ + public function store(array $data): Salary + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + $salary = new Salary; + $salary->tenant_id = $tenantId; + $salary->employee_id = $data['employee_id']; + $salary->year = $data['year']; + $salary->month = $data['month']; + $salary->base_salary = $data['base_salary'] ?? 0; + $salary->total_allowance = $data['total_allowance'] ?? 0; + $salary->total_overtime = $data['total_overtime'] ?? 0; + $salary->total_bonus = $data['total_bonus'] ?? 0; + $salary->total_deduction = $data['total_deduction'] ?? 0; + $salary->allowance_details = $data['allowance_details'] ?? null; + $salary->deduction_details = $data['deduction_details'] ?? null; + $salary->payment_date = $data['payment_date'] ?? null; + $salary->status = $data['status'] ?? 'scheduled'; + $salary->created_by = $userId; + $salary->updated_by = $userId; + + // 실지급액 계산 + $salary->net_payment = $salary->calculateNetPayment(); + + $salary->save(); + + return $salary->load('employee:id,name,email'); + }); + } + + /** + * 급여 수정 + */ + public function update(int $id, array $data): Salary + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $salary = Salary::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (isset($data['employee_id'])) { + $salary->employee_id = $data['employee_id']; + } + if (isset($data['year'])) { + $salary->year = $data['year']; + } + if (isset($data['month'])) { + $salary->month = $data['month']; + } + if (isset($data['base_salary'])) { + $salary->base_salary = $data['base_salary']; + } + if (isset($data['total_allowance'])) { + $salary->total_allowance = $data['total_allowance']; + } + if (isset($data['total_overtime'])) { + $salary->total_overtime = $data['total_overtime']; + } + if (isset($data['total_bonus'])) { + $salary->total_bonus = $data['total_bonus']; + } + if (isset($data['total_deduction'])) { + $salary->total_deduction = $data['total_deduction']; + } + if (array_key_exists('allowance_details', $data)) { + $salary->allowance_details = $data['allowance_details']; + } + if (array_key_exists('deduction_details', $data)) { + $salary->deduction_details = $data['deduction_details']; + } + if (array_key_exists('payment_date', $data)) { + $salary->payment_date = $data['payment_date']; + } + if (isset($data['status'])) { + $salary->status = $data['status']; + } + + // 실지급액 재계산 + $salary->net_payment = $salary->calculateNetPayment(); + + $salary->updated_by = $userId; + $salary->save(); + + return $salary->fresh()->load(['employee:id,name,employee_number', 'employee.department', 'employee.position', 'employee.rank']); + }); + } + + /** + * 급여 삭제 + */ + public function destroy(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $salary = Salary::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $salary->deleted_by = $userId; + $salary->save(); + $salary->delete(); + + return true; + }); + } + + /** + * 급여 상태 변경 (지급완료/지급예정) + */ + public function updateStatus(int $id, string $status): Salary + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $status, $tenantId, $userId) { + $salary = Salary::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $salary->status = $status; + $salary->updated_by = $userId; + $salary->save(); + + return $salary->load(['employee:id,name,employee_number', 'employee.department', 'employee.position', 'employee.rank']); + }); + } + + /** + * 급여 일괄 상태 변경 + */ + public function bulkUpdateStatus(array $ids, string $status): int + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($ids, $status, $tenantId, $userId) { + return Salary::query() + ->where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->update([ + 'status' => $status, + 'updated_by' => $userId, + 'updated_at' => now(), + ]); + }); + } + + /** + * 급여 통계 조회 + */ + public function getStatistics(array $params): array + { + $tenantId = $this->tenantId(); + + $query = Salary::query() + ->where('tenant_id', $tenantId); + + // 연도/월 필터 + if (!empty($params['year'])) { + $query->where('year', $params['year']); + } + if (!empty($params['month'])) { + $query->where('month', $params['month']); + } + + // 기간 필터 + if (!empty($params['start_date']) && !empty($params['end_date'])) { + $query->whereBetween('payment_date', [$params['start_date'], $params['end_date']]); + } + + return [ + 'total_net_payment' => (float) $query->sum('net_payment'), + 'total_base_salary' => (float) $query->sum('base_salary'), + 'total_allowance' => (float) $query->sum('total_allowance'), + 'total_overtime' => (float) $query->sum('total_overtime'), + 'total_bonus' => (float) $query->sum('total_bonus'), + 'total_deduction' => (float) $query->sum('total_deduction'), + 'count' => $query->count(), + 'completed_count' => (clone $query)->where('status', 'completed')->count(), + 'scheduled_count' => (clone $query)->where('status', 'scheduled')->count(), + ]; + } +} \ No newline at end of file diff --git a/database/migrations/2025_12_25_004032_create_salaries_table.php b/database/migrations/2025_12_25_004032_create_salaries_table.php new file mode 100644 index 0000000..881d4dc --- /dev/null +++ b/database/migrations/2025_12_25_004032_create_salaries_table.php @@ -0,0 +1,60 @@ +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'); + } +}; \ No newline at end of file diff --git a/database/seeders/ApprovalTestDataSeeder.php b/database/seeders/ApprovalTestDataSeeder.php new file mode 100644 index 0000000..ca378af --- /dev/null +++ b/database/seeders/ApprovalTestDataSeeder.php @@ -0,0 +1,312 @@ +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 []; + } + } +} diff --git a/database/seeders/Dummy/DummyAttendanceSeeder.php b/database/seeders/Dummy/DummyAttendanceSeeder.php new file mode 100644 index 0000000..d849535 --- /dev/null +++ b/database/seeders/Dummy/DummyAttendanceSeeder.php @@ -0,0 +1,191 @@ +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, + }; + } +} \ No newline at end of file diff --git a/database/seeders/Dummy/DummyAttendanceSettingSeeder.php b/database/seeders/Dummy/DummyAttendanceSettingSeeder.php new file mode 100644 index 0000000..2cefa58 --- /dev/null +++ b/database/seeders/Dummy/DummyAttendanceSettingSeeder.php @@ -0,0 +1,34 @@ +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건 생성'); + } +} \ No newline at end of file diff --git a/database/seeders/Dummy/DummyBadDebtSeeder.php b/database/seeders/Dummy/DummyBadDebtSeeder.php index ca1b72c..37bbd8a 100644 --- a/database/seeders/Dummy/DummyBadDebtSeeder.php +++ b/database/seeders/Dummy/DummyBadDebtSeeder.php @@ -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']) diff --git a/database/seeders/Dummy/DummyBankAccountSeeder.php b/database/seeders/Dummy/DummyBankAccountSeeder.php index edfefa7..bf19966 100644 --- a/database/seeders/Dummy/DummyBankAccountSeeder.php +++ b/database/seeders/Dummy/DummyBankAccountSeeder.php @@ -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], diff --git a/database/seeders/Dummy/DummyBillSeeder.php b/database/seeders/Dummy/DummyBillSeeder.php index 99222f9..559a883 100644 --- a/database/seeders/Dummy/DummyBillSeeder.php +++ b/database/seeders/Dummy/DummyBillSeeder.php @@ -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'); diff --git a/database/seeders/Dummy/DummyClientGroupSeeder.php b/database/seeders/Dummy/DummyClientGroupSeeder.php index 9197406..a124d9a 100644 --- a/database/seeders/Dummy/DummyClientGroupSeeder.php +++ b/database/seeders/Dummy/DummyClientGroupSeeder.php @@ -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], diff --git a/database/seeders/Dummy/DummyClientSeeder.php b/database/seeders/Dummy/DummyClientSeeder.php index 3ebb7e1..fd5b6d4 100644 --- a/database/seeders/Dummy/DummyClientSeeder.php +++ b/database/seeders/Dummy/DummyClientSeeder.php @@ -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') diff --git a/database/seeders/Dummy/DummyDepartmentSeeder.php b/database/seeders/Dummy/DummyDepartmentSeeder.php new file mode 100644 index 0000000..35bb632 --- /dev/null +++ b/database/seeders/Dummy/DummyDepartmentSeeder.php @@ -0,0 +1,133 @@ +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(), + ]); + } + } + } +} \ No newline at end of file diff --git a/database/seeders/Dummy/DummyDepositSeeder.php b/database/seeders/Dummy/DummyDepositSeeder.php index 7da5e61..a5650ec 100644 --- a/database/seeders/Dummy/DummyDepositSeeder.php +++ b/database/seeders/Dummy/DummyDepositSeeder.php @@ -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']) diff --git a/database/seeders/Dummy/DummyLeaveGrantSeeder.php b/database/seeders/Dummy/DummyLeaveGrantSeeder.php new file mode 100644 index 0000000..3aa3a16 --- /dev/null +++ b/database/seeders/Dummy/DummyLeaveGrantSeeder.php @@ -0,0 +1,95 @@ +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 . '건 생성'); + } +} \ No newline at end of file diff --git a/database/seeders/Dummy/DummyLeaveSeeder.php b/database/seeders/Dummy/DummyLeaveSeeder.php new file mode 100644 index 0000000..368b1e4 --- /dev/null +++ b/database/seeders/Dummy/DummyLeaveSeeder.php @@ -0,0 +1,135 @@ +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 => '개인 사유', + }; + } +} \ No newline at end of file diff --git a/database/seeders/Dummy/DummyPaymentSeeder.php b/database/seeders/Dummy/DummyPaymentSeeder.php index 9b532fd..871ac63 100644 --- a/database/seeders/Dummy/DummyPaymentSeeder.php +++ b/database/seeders/Dummy/DummyPaymentSeeder.php @@ -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); diff --git a/database/seeders/Dummy/DummyPopupSeeder.php b/database/seeders/Dummy/DummyPopupSeeder.php index 6eeb177..b8b0639 100644 --- a/database/seeders/Dummy/DummyPopupSeeder.php +++ b/database/seeders/Dummy/DummyPopupSeeder.php @@ -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', diff --git a/database/seeders/Dummy/DummyPurchaseSeeder.php b/database/seeders/Dummy/DummyPurchaseSeeder.php index 5b29940..2b7fb22 100644 --- a/database/seeders/Dummy/DummyPurchaseSeeder.php +++ b/database/seeders/Dummy/DummyPurchaseSeeder.php @@ -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']) diff --git a/database/seeders/Dummy/DummySaleSeeder.php b/database/seeders/Dummy/DummySaleSeeder.php index 1c9594a..64806db 100644 --- a/database/seeders/Dummy/DummySaleSeeder.php +++ b/database/seeders/Dummy/DummySaleSeeder.php @@ -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']) diff --git a/database/seeders/Dummy/DummyUserSeeder.php b/database/seeders/Dummy/DummyUserSeeder.php new file mode 100644 index 0000000..9985b26 --- /dev/null +++ b/database/seeders/Dummy/DummyUserSeeder.php @@ -0,0 +1,89 @@ + '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 . '명 생성 (테넌트 연결 완료)'); + } +} diff --git a/database/seeders/Dummy/DummyWithdrawalSeeder.php b/database/seeders/Dummy/DummyWithdrawalSeeder.php index d61573b..3fa9dc2 100644 --- a/database/seeders/Dummy/DummyWithdrawalSeeder.php +++ b/database/seeders/Dummy/DummyWithdrawalSeeder.php @@ -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']) diff --git a/database/seeders/Dummy/DummyWorkSettingSeeder.php b/database/seeders/Dummy/DummyWorkSettingSeeder.php new file mode 100644 index 0000000..4428c15 --- /dev/null +++ b/database/seeders/Dummy/DummyWorkSettingSeeder.php @@ -0,0 +1,39 @@ +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건 생성'); + } +} \ No newline at end of file diff --git a/database/seeders/DummyDataSeeder.php b/database/seeders/DummyDataSeeder.php index 2bbe049..80998ac 100644 --- a/database/seeders/DummyDataSeeder.php +++ b/database/seeders/DummyDataSeeder.php @@ -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'], ] ); } diff --git a/routes/api.php b/routes/api.php index 3d7dc3e..a536b44 100644 --- a/routes/api.php +++ b/routes/api.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Api\V1\BarobillSettingController; use App\Http\Controllers\Api\V1\BoardController; use App\Http\Controllers\Api\V1\CardController; +use App\Http\Controllers\Api\V1\SalaryController; use App\Http\Controllers\Api\V1\CategoryController; use App\Http\Controllers\Api\V1\CategoryFieldController; use App\Http\Controllers\Api\V1\CategoryLogController; @@ -431,6 +432,18 @@ Route::get('/{id}/payslip', [PayrollController::class, 'payslip'])->whereNumber('id')->name('v1.payrolls.payslip'); }); + // Salary API (급여 관리 - React 연동) + Route::prefix('salaries')->group(function () { + Route::get('', [SalaryController::class, 'index'])->name('v1.salaries.index'); + Route::post('', [SalaryController::class, 'store'])->name('v1.salaries.store'); + Route::get('/statistics', [SalaryController::class, 'statistics'])->name('v1.salaries.statistics'); + Route::post('/bulk-update-status', [SalaryController::class, 'bulkUpdateStatus'])->name('v1.salaries.bulk-update-status'); + Route::get('/{id}', [SalaryController::class, 'show'])->whereNumber('id')->name('v1.salaries.show'); + Route::put('/{id}', [SalaryController::class, 'update'])->whereNumber('id')->name('v1.salaries.update'); + Route::delete('/{id}', [SalaryController::class, 'destroy'])->whereNumber('id')->name('v1.salaries.destroy'); + Route::patch('/{id}/status', [SalaryController::class, 'updateStatus'])->whereNumber('id')->name('v1.salaries.update-status'); + }); + // Loan API (가지급금 관리) Route::prefix('loans')->group(function () { Route::get('', [LoanController::class, 'index'])->name('v1.loans.index');