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

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

84
.serena/project.yml Normal file
View File

@@ -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: []

View File

@@ -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`

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Salary\BulkUpdateStatusRequest;
use App\Http\Requests\V1\Salary\StoreSalaryRequest;
use App\Http\Requests\V1\Salary\UpdateSalaryRequest;
use App\Services\SalaryService;
use Illuminate\Http\Request;
class SalaryController extends Controller
{
public function __construct(
private readonly SalaryService $service
) {}
/**
* 급여 목록
*/
public function index(Request $request)
{
$params = $request->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'));
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\V1\Salary;
use Illuminate\Foundation\Http\FormRequest;
class BulkUpdateStatusRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'ids' => ['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' => '상태']),
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests\V1\Salary;
use Illuminate\Foundation\Http\FormRequest;
class StoreSalaryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'employee_id' => ['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' => '기본급']),
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests\V1\Salary;
use Illuminate\Foundation\Http\FormRequest;
class UpdateSalaryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'employee_id' => ['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'],
];
}
}

View File

@@ -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);

View File

@@ -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';

View File

@@ -0,0 +1,165 @@
<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 급여 모델
*
* @property int $id
* @property int $tenant_id
* @property int $employee_id
* @property int $year
* @property int $month
* @property float $base_salary
* @property float $total_allowance
* @property float $total_overtime
* @property float $total_bonus
* @property float $total_deduction
* @property float $net_payment
* @property array|null $allowance_details
* @property array|null $deduction_details
* @property string|null $payment_date
* @property string $status
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $deleted_by
*/
class Salary extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'salaries';
protected $fillable = [
'tenant_id',
'employee_id',
'year',
'month',
'base_salary',
'total_allowance',
'total_overtime',
'total_bonus',
'total_deduction',
'net_payment',
'allowance_details',
'deduction_details',
'payment_date',
'status',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'base_salary' => '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);
}
}

View File

@@ -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();

View File

@@ -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();
}

View File

@@ -0,0 +1,277 @@
<?php
namespace App\Services;
use App\Models\Tenants\Salary;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class SalaryService extends Service
{
/**
* 급여 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->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(),
];
}
}

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'],
]
);
}

View File

@@ -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');