- feat: [barobill] 바로빌 카드/은행/홈택스 REST API 구현 - feat: [equipment] 설비관리 API 백엔드 구현 - feat: [payroll] 급여관리 계산 엔진 및 일괄 처리 API - feat: [QMS] 점검표 템플릿 관리 + 로트심사 개선 - feat: [생산/출하] 수주 단위 출하 자동생성 + 상태 흐름 개선 - feat: [receiving] 입고 성적서 파일 연결 - feat: [견적] 제어기 타입 체계 변경 - feat: [email] 테넌트 메일 설정 마이그레이션 및 모델 - feat: [pmis] 시공관리 테이블 마이그레이션 - feat: [R2] 파일 업로드 커맨드 + filesystems 설정 - feat: [배포] Jenkinsfile 롤백 기능 추가 - fix: [approval] SAM API 규칙 준수 코드 개선 - fix: [account-codes] 계정과목 중복 데이터 정리 - fix: [payroll] 일괄 생성 시 삭제된 사용자 건너뛰기 - fix: [db] codebridge DB 분리 후 깨진 FK 제약조건 제거 - refactor: [barobill] 바로빌 연동 코드 전면 개선
1176 lines
44 KiB
PHP
1176 lines
44 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Tenants\IncomeTaxBracket;
|
|
use App\Models\Tenants\JournalEntry;
|
|
use App\Models\Tenants\Payroll;
|
|
use App\Models\Tenants\PayrollSetting;
|
|
use App\Models\Tenants\TenantUserProfile;
|
|
use App\Models\Tenants\Withdrawal;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
|
|
class PayrollService extends Service
|
|
{
|
|
private const TAX_TABLE_YEAR = 2024;
|
|
|
|
// =========================================================================
|
|
// 급여 목록/상세
|
|
// =========================================================================
|
|
|
|
public function index(array $params): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$query = Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with(['user:id,name,email', 'creator:id,name']);
|
|
|
|
if (! empty($params['year'])) {
|
|
$query->where('pay_year', $params['year']);
|
|
}
|
|
if (! empty($params['month'])) {
|
|
$query->where('pay_month', $params['month']);
|
|
}
|
|
if (! empty($params['user_id'])) {
|
|
$query->where('user_id', $params['user_id']);
|
|
}
|
|
if (! empty($params['status'])) {
|
|
$query->where('status', $params['status']);
|
|
}
|
|
if (! empty($params['search'])) {
|
|
$query->whereHas('user', function ($q) use ($params) {
|
|
$q->where('name', 'like', "%{$params['search']}%");
|
|
});
|
|
}
|
|
if (! empty($params['department_id'])) {
|
|
$deptId = $params['department_id'];
|
|
$query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) {
|
|
$q->where('tenant_id', $tenantId)->where('department_id', $deptId);
|
|
});
|
|
}
|
|
|
|
$sortBy = $params['sort_by'] ?? 'pay_year';
|
|
$sortDir = $params['sort_dir'] ?? 'desc';
|
|
|
|
if ($sortBy === 'period') {
|
|
$query->orderBy('pay_year', $sortDir)->orderBy('pay_month', $sortDir);
|
|
} else {
|
|
$query->orderBy($sortBy, $sortDir);
|
|
}
|
|
|
|
return $query->paginate($params['per_page'] ?? 20);
|
|
}
|
|
|
|
public function summary(int $year, int $month): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$stats = Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('pay_year', $year)
|
|
->where('pay_month', $month)
|
|
->selectRaw('
|
|
COUNT(*) as total_count,
|
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft_count,
|
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as confirmed_count,
|
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as paid_count,
|
|
SUM(gross_salary) as total_gross,
|
|
SUM(total_deductions) as total_deductions,
|
|
SUM(net_salary) as total_net
|
|
', [Payroll::STATUS_DRAFT, Payroll::STATUS_CONFIRMED, Payroll::STATUS_PAID])
|
|
->first();
|
|
|
|
return [
|
|
'year' => $year,
|
|
'month' => $month,
|
|
'total_count' => (int) $stats->total_count,
|
|
'draft_count' => (int) $stats->draft_count,
|
|
'confirmed_count' => (int) $stats->confirmed_count,
|
|
'paid_count' => (int) $stats->paid_count,
|
|
'total_gross' => (int) $stats->total_gross,
|
|
'total_deductions' => (int) $stats->total_deductions,
|
|
'total_net' => (int) $stats->total_net,
|
|
];
|
|
}
|
|
|
|
public function show(int $id): Payroll
|
|
{
|
|
return Payroll::query()
|
|
->where('tenant_id', $this->tenantId())
|
|
->with(['user:id,name,email', 'confirmer:id,name', 'withdrawal', 'creator:id,name'])
|
|
->findOrFail($id);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 급여 생성/수정/삭제
|
|
// =========================================================================
|
|
|
|
public function store(array $data): Payroll
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($data, $tenantId, $userId) {
|
|
// 중복 확인 (soft-deleted 포함)
|
|
$existing = Payroll::withTrashed()
|
|
->where('tenant_id', $tenantId)
|
|
->where('user_id', $data['user_id'])
|
|
->where('pay_year', $data['pay_year'])
|
|
->where('pay_month', $data['pay_month'])
|
|
->first();
|
|
|
|
if ($existing && ! $existing->trashed()) {
|
|
throw new BadRequestHttpException(__('error.payroll.already_exists'));
|
|
}
|
|
if ($existing && $existing->trashed()) {
|
|
$existing->forceDelete();
|
|
}
|
|
|
|
// 자동 계산
|
|
$settings = PayrollSetting::getOrCreate($tenantId);
|
|
$familyCount = $data['family_count'] ?? $this->resolveFamilyCount($data['user_id']);
|
|
$calculated = $this->calculateAmounts($data, $settings, $familyCount);
|
|
$this->applyDeductionOverrides($calculated, $data['deduction_overrides'] ?? null);
|
|
|
|
return Payroll::create([
|
|
'tenant_id' => $tenantId,
|
|
'user_id' => $data['user_id'],
|
|
'pay_year' => $data['pay_year'],
|
|
'pay_month' => $data['pay_month'],
|
|
'base_salary' => $data['base_salary'] ?? 0,
|
|
'overtime_pay' => $data['overtime_pay'] ?? 0,
|
|
'bonus' => $data['bonus'] ?? 0,
|
|
'allowances' => $data['allowances'] ?? null,
|
|
'gross_salary' => $calculated['gross_salary'],
|
|
'income_tax' => $calculated['income_tax'],
|
|
'resident_tax' => $calculated['resident_tax'],
|
|
'health_insurance' => $calculated['health_insurance'],
|
|
'long_term_care' => $calculated['long_term_care'],
|
|
'pension' => $calculated['pension'],
|
|
'employment_insurance' => $calculated['employment_insurance'],
|
|
'deductions' => $data['deductions'] ?? null,
|
|
'total_deductions' => $calculated['total_deductions'],
|
|
'net_salary' => $calculated['net_salary'],
|
|
'status' => Payroll::STATUS_DRAFT,
|
|
'note' => $data['note'] ?? null,
|
|
'created_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
});
|
|
}
|
|
|
|
public function update(int $id, array $data): Payroll
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$payroll = Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
$isSuperAdmin = $data['_is_super_admin'] ?? false;
|
|
if (! $payroll->isEditable($isSuperAdmin)) {
|
|
throw new BadRequestHttpException(__('error.payroll.not_editable'));
|
|
}
|
|
|
|
// 연월/사원 변경 시 중복 확인
|
|
$newYear = $data['pay_year'] ?? $payroll->pay_year;
|
|
$newMonth = $data['pay_month'] ?? $payroll->pay_month;
|
|
$newUserId = $data['user_id'] ?? $payroll->user_id;
|
|
|
|
if ($newYear != $payroll->pay_year || $newMonth != $payroll->pay_month || $newUserId != $payroll->user_id) {
|
|
$exists = Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('user_id', $newUserId)
|
|
->where('pay_year', $newYear)
|
|
->where('pay_month', $newMonth)
|
|
->where('id', '!=', $id)
|
|
->exists();
|
|
|
|
if ($exists) {
|
|
throw new BadRequestHttpException(__('error.payroll.already_exists'));
|
|
}
|
|
}
|
|
|
|
// 지급 항목 (신규 입력값 또는 기존값)
|
|
$baseSalary = (float) ($data['base_salary'] ?? $payroll->base_salary);
|
|
$overtimePay = (float) ($data['overtime_pay'] ?? $payroll->overtime_pay);
|
|
$bonus = (float) ($data['bonus'] ?? $payroll->bonus);
|
|
$allowances = array_key_exists('allowances', $data) ? $data['allowances'] : $payroll->allowances;
|
|
$deductions = array_key_exists('deductions', $data) ? $data['deductions'] : $payroll->deductions;
|
|
|
|
$allowancesTotal = 0;
|
|
$allowancesArr = is_string($allowances) ? json_decode($allowances, true) : $allowances;
|
|
foreach ($allowancesArr ?? [] as $allowance) {
|
|
$allowancesTotal += (float) ($allowance['amount'] ?? 0);
|
|
}
|
|
$grossSalary = (int) ($baseSalary + $overtimePay + $bonus + $allowancesTotal);
|
|
|
|
// 공제 항목 (수동 수정값 우선, 없으면 기존값 유지)
|
|
$overrides = $data['deduction_overrides'] ?? [];
|
|
$incomeTax = isset($overrides['income_tax']) ? (int) $overrides['income_tax'] : (int) $payroll->income_tax;
|
|
$residentTax = isset($overrides['resident_tax']) ? (int) $overrides['resident_tax'] : (int) $payroll->resident_tax;
|
|
$healthInsurance = isset($overrides['health_insurance']) ? (int) $overrides['health_insurance'] : (int) $payroll->health_insurance;
|
|
$longTermCare = isset($overrides['long_term_care']) ? (int) $overrides['long_term_care'] : (int) $payroll->long_term_care;
|
|
$pension = isset($overrides['pension']) ? (int) $overrides['pension'] : (int) $payroll->pension;
|
|
$employmentInsurance = isset($overrides['employment_insurance']) ? (int) $overrides['employment_insurance'] : (int) $payroll->employment_insurance;
|
|
|
|
$extraDeductions = 0;
|
|
$deductionsArr = is_string($deductions) ? json_decode($deductions, true) : $deductions;
|
|
foreach ($deductionsArr ?? [] as $deduction) {
|
|
$extraDeductions += (float) ($deduction['amount'] ?? 0);
|
|
}
|
|
|
|
$totalDeductions = (int) ($incomeTax + $residentTax + $healthInsurance + $longTermCare + $pension + $employmentInsurance + $extraDeductions);
|
|
$netSalary = (int) max(0, $grossSalary - $totalDeductions);
|
|
|
|
$payroll->update([
|
|
'user_id' => $newUserId,
|
|
'pay_year' => $newYear,
|
|
'pay_month' => $newMonth,
|
|
'base_salary' => $baseSalary,
|
|
'overtime_pay' => $overtimePay,
|
|
'bonus' => $bonus,
|
|
'allowances' => $allowances,
|
|
'gross_salary' => $grossSalary,
|
|
'income_tax' => $incomeTax,
|
|
'resident_tax' => $residentTax,
|
|
'health_insurance' => $healthInsurance,
|
|
'long_term_care' => $longTermCare,
|
|
'pension' => $pension,
|
|
'employment_insurance' => $employmentInsurance,
|
|
'deductions' => $deductions,
|
|
'total_deductions' => $totalDeductions,
|
|
'net_salary' => $netSalary,
|
|
'note' => $data['note'] ?? $payroll->note,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
return $payroll->fresh(['user:id,name,email', 'creator:id,name']);
|
|
}
|
|
|
|
public function destroy(int $id): bool
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$payroll = Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
if (! $payroll->isDeletable()) {
|
|
throw new BadRequestHttpException(__('error.payroll.not_deletable'));
|
|
}
|
|
|
|
$payroll->deleted_by = $userId;
|
|
$payroll->save();
|
|
$payroll->delete();
|
|
|
|
return true;
|
|
}
|
|
|
|
// =========================================================================
|
|
// 상태 관리 (확정/지급/취소)
|
|
// =========================================================================
|
|
|
|
public function confirm(int $id): Payroll
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$payroll = Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
if (! $payroll->isConfirmable()) {
|
|
throw new BadRequestHttpException(__('error.payroll.not_confirmable'));
|
|
}
|
|
|
|
$payroll->update([
|
|
'status' => Payroll::STATUS_CONFIRMED,
|
|
'confirmed_at' => now(),
|
|
'confirmed_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
return $payroll->fresh(['user:id,name,email', 'confirmer:id,name']);
|
|
}
|
|
|
|
public function unconfirm(int $id): Payroll
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$payroll = Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
if (! $payroll->isUnconfirmable()) {
|
|
throw new BadRequestHttpException(__('error.payroll.not_unconfirmable'));
|
|
}
|
|
|
|
$payroll->update([
|
|
'status' => Payroll::STATUS_DRAFT,
|
|
'confirmed_at' => null,
|
|
'confirmed_by' => null,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
return $payroll->fresh(['user:id,name,email']);
|
|
}
|
|
|
|
public function pay(int $id, ?int $withdrawalId = null): Payroll
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($id, $withdrawalId, $tenantId, $userId) {
|
|
$payroll = Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
if (! $payroll->isPayable()) {
|
|
throw new BadRequestHttpException(__('error.payroll.not_payable'));
|
|
}
|
|
|
|
if ($withdrawalId) {
|
|
$withdrawal = Withdrawal::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('id', $withdrawalId)
|
|
->first();
|
|
|
|
if (! $withdrawal) {
|
|
throw new BadRequestHttpException(__('error.payroll.invalid_withdrawal'));
|
|
}
|
|
}
|
|
|
|
$payroll->update([
|
|
'status' => Payroll::STATUS_PAID,
|
|
'paid_at' => now(),
|
|
'withdrawal_id' => $withdrawalId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
return $payroll->fresh(['user:id,name,email', 'withdrawal']);
|
|
});
|
|
}
|
|
|
|
public function unpay(int $id): Payroll
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$payroll = Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
if (! $payroll->isUnpayable()) {
|
|
throw new BadRequestHttpException(__('error.payroll.not_unpayable'));
|
|
}
|
|
|
|
$payroll->update([
|
|
'status' => Payroll::STATUS_DRAFT,
|
|
'confirmed_at' => null,
|
|
'confirmed_by' => null,
|
|
'paid_at' => null,
|
|
'withdrawal_id' => null,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
return $payroll->fresh(['user:id,name,email']);
|
|
}
|
|
|
|
public function bulkConfirm(int $year, int $month): int
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('pay_year', $year)
|
|
->where('pay_month', $month)
|
|
->where('status', Payroll::STATUS_DRAFT)
|
|
->update([
|
|
'status' => Payroll::STATUS_CONFIRMED,
|
|
'confirmed_at' => now(),
|
|
'confirmed_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 일괄 처리 (생성/복사/계산)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 재직사원 일괄 생성
|
|
*/
|
|
public function bulkGenerate(int $year, int $month): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
$settings = PayrollSetting::getOrCreate($tenantId);
|
|
$created = 0;
|
|
$skipped = 0;
|
|
|
|
$employees = TenantUserProfile::query()
|
|
->with('user:id,name')
|
|
->where('tenant_id', $tenantId)
|
|
->where('employee_status', 'active')
|
|
->whereHas('user')
|
|
->get();
|
|
|
|
DB::transaction(function () use ($employees, $tenantId, $year, $month, $settings, $userId, &$created, &$skipped) {
|
|
foreach ($employees as $employee) {
|
|
$existing = Payroll::withTrashed()
|
|
->where('tenant_id', $tenantId)
|
|
->where('user_id', $employee->user_id)
|
|
->forPeriod($year, $month)
|
|
->first();
|
|
|
|
if ($existing && ! $existing->trashed()) {
|
|
$skipped++;
|
|
|
|
continue;
|
|
}
|
|
if ($existing && $existing->trashed()) {
|
|
$existing->forceDelete();
|
|
}
|
|
|
|
// 연봉에서 월급 산출
|
|
$salaryInfo = $employee->json_extra['salary_info'] ?? $employee->json_extra ?? [];
|
|
$annualSalary = $salaryInfo['annual_salary'] ?? ($employee->json_extra['salary'] ?? 0);
|
|
$baseSalary = $annualSalary > 0 ? (int) round($annualSalary / 12) : 0;
|
|
|
|
$data = [
|
|
'base_salary' => $baseSalary,
|
|
'overtime_pay' => 0,
|
|
'bonus' => 0,
|
|
'allowances' => null,
|
|
'deductions' => null,
|
|
];
|
|
|
|
// 피부양자 기반 가족수 산출
|
|
$dependents = $employee->json_extra['dependents'] ?? [];
|
|
$familyCount = 1 + collect($dependents)
|
|
->where('is_dependent', true)->count();
|
|
|
|
$calculated = $this->calculateAmounts($data, $settings, $familyCount);
|
|
|
|
Payroll::create([
|
|
'tenant_id' => $tenantId,
|
|
'user_id' => $employee->user_id,
|
|
'pay_year' => $year,
|
|
'pay_month' => $month,
|
|
'base_salary' => $baseSalary,
|
|
'overtime_pay' => 0,
|
|
'bonus' => 0,
|
|
'allowances' => null,
|
|
'gross_salary' => $calculated['gross_salary'],
|
|
'income_tax' => $calculated['income_tax'],
|
|
'resident_tax' => $calculated['resident_tax'],
|
|
'health_insurance' => $calculated['health_insurance'],
|
|
'long_term_care' => $calculated['long_term_care'],
|
|
'pension' => $calculated['pension'],
|
|
'employment_insurance' => $calculated['employment_insurance'],
|
|
'deductions' => null,
|
|
'total_deductions' => $calculated['total_deductions'],
|
|
'net_salary' => $calculated['net_salary'],
|
|
'status' => Payroll::STATUS_DRAFT,
|
|
'created_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
$created++;
|
|
}
|
|
});
|
|
|
|
return ['created' => $created, 'skipped' => $skipped];
|
|
}
|
|
|
|
/**
|
|
* 전월 급여 복사
|
|
*/
|
|
public function copyFromPreviousMonth(int $year, int $month): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
$prevYear = $month === 1 ? $year - 1 : $year;
|
|
$prevMonth = $month === 1 ? 12 : $month - 1;
|
|
|
|
$previousPayrolls = Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->forPeriod($prevYear, $prevMonth)
|
|
->get();
|
|
|
|
if ($previousPayrolls->isEmpty()) {
|
|
throw new BadRequestHttpException(__('error.payroll.no_previous_month'));
|
|
}
|
|
|
|
$created = 0;
|
|
$skipped = 0;
|
|
|
|
DB::transaction(function () use ($previousPayrolls, $tenantId, $year, $month, $userId, &$created, &$skipped) {
|
|
foreach ($previousPayrolls as $prev) {
|
|
$existing = Payroll::withTrashed()
|
|
->where('tenant_id', $tenantId)
|
|
->where('user_id', $prev->user_id)
|
|
->forPeriod($year, $month)
|
|
->first();
|
|
|
|
if ($existing && ! $existing->trashed()) {
|
|
$skipped++;
|
|
|
|
continue;
|
|
}
|
|
if ($existing && $existing->trashed()) {
|
|
$existing->forceDelete();
|
|
}
|
|
|
|
Payroll::create([
|
|
'tenant_id' => $tenantId,
|
|
'user_id' => $prev->user_id,
|
|
'pay_year' => $year,
|
|
'pay_month' => $month,
|
|
'base_salary' => $prev->base_salary,
|
|
'overtime_pay' => $prev->overtime_pay,
|
|
'bonus' => $prev->bonus,
|
|
'allowances' => $prev->allowances,
|
|
'gross_salary' => $prev->gross_salary,
|
|
'income_tax' => $prev->income_tax,
|
|
'resident_tax' => $prev->resident_tax,
|
|
'health_insurance' => $prev->health_insurance,
|
|
'long_term_care' => $prev->long_term_care,
|
|
'pension' => $prev->pension,
|
|
'employment_insurance' => $prev->employment_insurance,
|
|
'deductions' => $prev->deductions,
|
|
'total_deductions' => $prev->total_deductions,
|
|
'net_salary' => $prev->net_salary,
|
|
'status' => Payroll::STATUS_DRAFT,
|
|
'created_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
$created++;
|
|
}
|
|
});
|
|
|
|
return ['created' => $created, 'skipped' => $skipped];
|
|
}
|
|
|
|
/**
|
|
* 급여 일괄 계산 (기존 draft 급여 재계산)
|
|
*/
|
|
public function calculate(int $year, int $month, ?array $userIds = null): Collection
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
$settings = PayrollSetting::getOrCreate($tenantId);
|
|
|
|
return DB::transaction(function () use ($year, $month, $userIds, $tenantId, $userId, $settings) {
|
|
$query = Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('pay_year', $year)
|
|
->where('pay_month', $month)
|
|
->where('status', Payroll::STATUS_DRAFT);
|
|
|
|
if ($userIds) {
|
|
$query->whereIn('user_id', $userIds);
|
|
}
|
|
|
|
$payrolls = $query->get();
|
|
|
|
foreach ($payrolls as $payroll) {
|
|
$familyCount = $this->resolveFamilyCount($payroll->user_id);
|
|
|
|
$data = [
|
|
'base_salary' => (float) $payroll->base_salary,
|
|
'overtime_pay' => (float) $payroll->overtime_pay,
|
|
'bonus' => (float) $payroll->bonus,
|
|
'allowances' => $payroll->allowances,
|
|
'deductions' => $payroll->deductions,
|
|
];
|
|
|
|
$calculated = $this->calculateAmounts($data, $settings, $familyCount);
|
|
|
|
$payroll->update([
|
|
'gross_salary' => $calculated['gross_salary'],
|
|
'income_tax' => $calculated['income_tax'],
|
|
'resident_tax' => $calculated['resident_tax'],
|
|
'health_insurance' => $calculated['health_insurance'],
|
|
'long_term_care' => $calculated['long_term_care'],
|
|
'pension' => $calculated['pension'],
|
|
'employment_insurance' => $calculated['employment_insurance'],
|
|
'total_deductions' => $calculated['total_deductions'],
|
|
'net_salary' => $calculated['net_salary'],
|
|
'updated_by' => $userId,
|
|
]);
|
|
}
|
|
|
|
return $payrolls->fresh(['user:id,name,email']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 계산 미리보기 (저장하지 않음)
|
|
*/
|
|
public function calculatePreview(array $data): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$settings = PayrollSetting::getOrCreate($tenantId);
|
|
$familyCount = 1;
|
|
|
|
if (! empty($data['user_id'])) {
|
|
$familyCount = $this->resolveFamilyCount((int) $data['user_id']);
|
|
}
|
|
|
|
$calculated = $this->calculateAmounts($data, $settings, $familyCount);
|
|
|
|
return array_merge($calculated, ['family_count' => $familyCount]);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 급여명세서
|
|
// =========================================================================
|
|
|
|
public function payslip(int $id): array
|
|
{
|
|
$payroll = $this->show($id);
|
|
|
|
$allowances = collect($payroll->allowances ?? [])->map(fn ($item) => [
|
|
'name' => $item['name'] ?? '',
|
|
'amount' => (int) ($item['amount'] ?? 0),
|
|
])->toArray();
|
|
|
|
$deductions = collect($payroll->deductions ?? [])->map(fn ($item) => [
|
|
'name' => $item['name'] ?? '',
|
|
'amount' => (int) ($item['amount'] ?? 0),
|
|
])->toArray();
|
|
|
|
return [
|
|
'payroll' => $payroll,
|
|
'period' => $payroll->period_label,
|
|
'employee' => [
|
|
'id' => $payroll->user->id,
|
|
'name' => $payroll->user->name,
|
|
'email' => $payroll->user->email,
|
|
],
|
|
'earnings' => [
|
|
'base_salary' => (int) $payroll->base_salary,
|
|
'overtime_pay' => (int) $payroll->overtime_pay,
|
|
'bonus' => (int) $payroll->bonus,
|
|
'allowances' => $allowances,
|
|
'allowances_total' => (int) $payroll->allowances_total,
|
|
'gross_total' => (int) $payroll->gross_salary,
|
|
],
|
|
'deductions' => [
|
|
'income_tax' => (int) $payroll->income_tax,
|
|
'resident_tax' => (int) $payroll->resident_tax,
|
|
'health_insurance' => (int) $payroll->health_insurance,
|
|
'long_term_care' => (int) $payroll->long_term_care,
|
|
'pension' => (int) $payroll->pension,
|
|
'employment_insurance' => (int) $payroll->employment_insurance,
|
|
'other_deductions' => $deductions,
|
|
'other_total' => (int) $payroll->deductions_total,
|
|
'total' => (int) $payroll->total_deductions,
|
|
],
|
|
'net_salary' => (int) $payroll->net_salary,
|
|
'status' => $payroll->status,
|
|
'status_label' => $payroll->status_label,
|
|
'paid_at' => $payroll->paid_at?->toIso8601String(),
|
|
];
|
|
}
|
|
|
|
// =========================================================================
|
|
// 급여 설정
|
|
// =========================================================================
|
|
|
|
public function getSettings(): PayrollSetting
|
|
{
|
|
return PayrollSetting::getOrCreate($this->tenantId());
|
|
}
|
|
|
|
public function updateSettings(array $data): PayrollSetting
|
|
{
|
|
$settings = PayrollSetting::getOrCreate($this->tenantId());
|
|
|
|
$settings->fill([
|
|
'income_tax_rate' => $data['income_tax_rate'] ?? $settings->income_tax_rate,
|
|
'resident_tax_rate' => $data['resident_tax_rate'] ?? $settings->resident_tax_rate,
|
|
'health_insurance_rate' => $data['health_insurance_rate'] ?? $settings->health_insurance_rate,
|
|
'long_term_care_rate' => $data['long_term_care_rate'] ?? $settings->long_term_care_rate,
|
|
'pension_rate' => $data['pension_rate'] ?? $settings->pension_rate,
|
|
'employment_insurance_rate' => $data['employment_insurance_rate'] ?? $settings->employment_insurance_rate,
|
|
'pension_max_salary' => $data['pension_max_salary'] ?? $settings->pension_max_salary,
|
|
'pension_min_salary' => $data['pension_min_salary'] ?? $settings->pension_min_salary,
|
|
'pay_day' => $data['pay_day'] ?? $settings->pay_day,
|
|
'auto_calculate' => $data['auto_calculate'] ?? $settings->auto_calculate,
|
|
'allowance_types' => $data['allowance_types'] ?? $settings->allowance_types,
|
|
'deduction_types' => $data['deduction_types'] ?? $settings->deduction_types,
|
|
]);
|
|
|
|
$settings->save();
|
|
|
|
return $settings;
|
|
}
|
|
|
|
// =========================================================================
|
|
// 계산 엔진
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 급여 금액 자동 계산
|
|
*
|
|
* 식대(bonus)는 비과세 항목으로, 총 지급액에는 포함되지만
|
|
* 4대보험 및 세금 산출 기준(과세표준)에서는 제외된다.
|
|
*/
|
|
public function calculateAmounts(array $data, ?PayrollSetting $settings = null, int $familyCount = 1): array
|
|
{
|
|
$settings = $settings ?? PayrollSetting::getOrCreate($this->tenantId());
|
|
|
|
$baseSalary = (float) ($data['base_salary'] ?? 0);
|
|
$overtimePay = (float) ($data['overtime_pay'] ?? 0);
|
|
$bonus = (float) ($data['bonus'] ?? 0);
|
|
|
|
$allowancesTotal = 0;
|
|
if (! empty($data['allowances'])) {
|
|
$allowances = is_string($data['allowances']) ? json_decode($data['allowances'], true) : $data['allowances'];
|
|
foreach ($allowances ?? [] as $allowance) {
|
|
$allowancesTotal += (float) ($allowance['amount'] ?? 0);
|
|
}
|
|
}
|
|
|
|
// 총 지급액 (비과세 포함)
|
|
$grossSalary = $baseSalary + $overtimePay + $bonus + $allowancesTotal;
|
|
|
|
// 과세표준 = 총 지급액 - 식대(비과세)
|
|
$taxableBase = $grossSalary - $bonus;
|
|
|
|
// 4대보험 (과세표준 기준)
|
|
$healthInsurance = $this->calcHealthInsurance($taxableBase, $settings);
|
|
$longTermCare = $this->calcLongTermCare($taxableBase, $settings);
|
|
$pension = $this->calcPension($taxableBase, $settings);
|
|
$employmentInsurance = $this->calcEmploymentInsurance($taxableBase, $settings);
|
|
|
|
// 근로소득세 (간이세액표, 가족수 반영)
|
|
$incomeTax = $this->calculateIncomeTax($taxableBase, $familyCount);
|
|
// 지방소득세 (근로소득세의 10%, 10원 단위 절삭)
|
|
$residentTax = (int) (floor($incomeTax * ($settings->resident_tax_rate / 100) / 10) * 10);
|
|
|
|
// 추가 공제 합계
|
|
$extraDeductions = 0;
|
|
if (! empty($data['deductions'])) {
|
|
$deductions = is_string($data['deductions']) ? json_decode($data['deductions'], true) : $data['deductions'];
|
|
foreach ($deductions ?? [] as $deduction) {
|
|
$extraDeductions += (float) ($deduction['amount'] ?? 0);
|
|
}
|
|
}
|
|
|
|
$totalDeductions = $incomeTax + $residentTax + $healthInsurance + $longTermCare + $pension + $employmentInsurance + $extraDeductions;
|
|
$netSalary = $grossSalary - $totalDeductions;
|
|
|
|
return [
|
|
'gross_salary' => (int) $grossSalary,
|
|
'taxable_base' => (int) $taxableBase,
|
|
'income_tax' => $incomeTax,
|
|
'resident_tax' => $residentTax,
|
|
'health_insurance' => $healthInsurance,
|
|
'long_term_care' => $longTermCare,
|
|
'pension' => $pension,
|
|
'employment_insurance' => $employmentInsurance,
|
|
'total_deductions' => (int) $totalDeductions,
|
|
'net_salary' => (int) max(0, $netSalary),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 수동 수정된 공제 항목 반영
|
|
*/
|
|
private function applyDeductionOverrides(array &$calculated, ?array $overrides): void
|
|
{
|
|
if (empty($overrides)) {
|
|
return;
|
|
}
|
|
|
|
$oldStatutory = $calculated['pension'] + $calculated['health_insurance'] + $calculated['long_term_care']
|
|
+ $calculated['employment_insurance'] + $calculated['income_tax'] + $calculated['resident_tax'];
|
|
$extraDeductions = max(0, $calculated['total_deductions'] - $oldStatutory);
|
|
|
|
$fields = ['pension', 'health_insurance', 'long_term_care', 'employment_insurance', 'income_tax', 'resident_tax'];
|
|
foreach ($fields as $field) {
|
|
if (isset($overrides[$field])) {
|
|
$calculated[$field] = (int) $overrides[$field];
|
|
}
|
|
}
|
|
|
|
$newStatutory = $calculated['pension'] + $calculated['health_insurance'] + $calculated['long_term_care']
|
|
+ $calculated['employment_insurance'] + $calculated['income_tax'] + $calculated['resident_tax'];
|
|
$calculated['total_deductions'] = (int) ($newStatutory + $extraDeductions);
|
|
$calculated['net_salary'] = (int) max(0, $calculated['gross_salary'] - $calculated['total_deductions']);
|
|
}
|
|
|
|
/**
|
|
* 근로소득세 계산 (2024 국세청 간이세액표 기반)
|
|
*/
|
|
public function calculateIncomeTax(float $taxableBase, int $familyCount = 1): int
|
|
{
|
|
if ($taxableBase <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
$salaryThousand = (int) floor($taxableBase / 1000);
|
|
$familyCount = max(1, min(11, $familyCount));
|
|
|
|
if ($salaryThousand < 770) {
|
|
return 0;
|
|
}
|
|
|
|
if ($salaryThousand > 10000) {
|
|
return $this->calculateHighIncomeTax($salaryThousand, $familyCount);
|
|
}
|
|
|
|
return IncomeTaxBracket::lookupTax(self::TAX_TABLE_YEAR, $salaryThousand, $familyCount);
|
|
}
|
|
|
|
/**
|
|
* 10,000천원 초과 구간 근로소득세 공식 계산 (소득세법 시행령 별표2)
|
|
*/
|
|
private function calculateHighIncomeTax(int $salaryThousand, int $familyCount): int
|
|
{
|
|
$baseTax = IncomeTaxBracket::where('tax_year', self::TAX_TABLE_YEAR)
|
|
->where('salary_from', 10000)
|
|
->whereColumn('salary_from', 'salary_to')
|
|
->where('family_count', $familyCount)
|
|
->value('tax_amount') ?? 0;
|
|
|
|
if ($salaryThousand <= 14000) {
|
|
$excessWon = ($salaryThousand - 10000) * 1000;
|
|
$tax = $baseTax + ($excessWon * 0.98 * 0.35) + 25000;
|
|
} elseif ($salaryThousand <= 28000) {
|
|
$excessWon = ($salaryThousand - 14000) * 1000;
|
|
$tax = $baseTax + 1397000 + ($excessWon * 0.98 * 0.38);
|
|
} elseif ($salaryThousand <= 30000) {
|
|
$excessWon = ($salaryThousand - 28000) * 1000;
|
|
$tax = $baseTax + 6610600 + ($excessWon * 0.98 * 0.40);
|
|
} elseif ($salaryThousand <= 45000) {
|
|
$excessWon = ($salaryThousand - 30000) * 1000;
|
|
$tax = $baseTax + 7394600 + ($excessWon * 0.40);
|
|
} elseif ($salaryThousand <= 87000) {
|
|
$excessWon = ($salaryThousand - 45000) * 1000;
|
|
$tax = $baseTax + 13394600 + ($excessWon * 0.42);
|
|
} else {
|
|
$excessWon = ($salaryThousand - 87000) * 1000;
|
|
$tax = $baseTax + 31034600 + ($excessWon * 0.45);
|
|
}
|
|
|
|
return (int) (floor($tax / 10) * 10);
|
|
}
|
|
|
|
private function calcHealthInsurance(float $taxableBase, PayrollSetting $settings): int
|
|
{
|
|
return (int) (floor($taxableBase * ($settings->health_insurance_rate / 100) / 10) * 10);
|
|
}
|
|
|
|
private function calcLongTermCare(float $taxableBase, PayrollSetting $settings): int
|
|
{
|
|
$healthInsurance = $taxableBase * ($settings->health_insurance_rate / 100);
|
|
|
|
return (int) (floor($healthInsurance * ($settings->long_term_care_rate / 100) / 10) * 10);
|
|
}
|
|
|
|
private function calcPension(float $taxableBase, PayrollSetting $settings): int
|
|
{
|
|
$base = min(max($taxableBase, (float) $settings->pension_min_salary), (float) $settings->pension_max_salary);
|
|
|
|
return (int) (floor($base * ($settings->pension_rate / 100) / 10) * 10);
|
|
}
|
|
|
|
private function calcEmploymentInsurance(float $taxableBase, PayrollSetting $settings): int
|
|
{
|
|
return (int) (floor($taxableBase * ($settings->employment_insurance_rate / 100) / 10) * 10);
|
|
}
|
|
|
|
/**
|
|
* user_id로 공제대상가족수 산출 (본인 1 + 피부양자)
|
|
*/
|
|
public function resolveFamilyCount(int $userId): int
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$profile = TenantUserProfile::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('user_id', $userId)
|
|
->first(['json_extra']);
|
|
|
|
if (! $profile) {
|
|
return 1;
|
|
}
|
|
|
|
$dependents = $profile->json_extra['dependents'] ?? [];
|
|
$dependentCount = collect($dependents)
|
|
->where('is_dependent', true)
|
|
->count();
|
|
|
|
return max(1, min(11, 1 + $dependentCount));
|
|
}
|
|
|
|
// =========================================================================
|
|
// 엑셀 내보내기
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 급여 엑셀 내보내기용 데이터
|
|
*/
|
|
public function getExportData(array $params): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$query = Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with(['user:id,name,email', 'user.tenantProfiles' => function ($q) use ($tenantId) {
|
|
$q->where('tenant_id', $tenantId)->with('department:id,name');
|
|
}]);
|
|
|
|
if (! empty($params['year'])) {
|
|
$query->where('pay_year', $params['year']);
|
|
}
|
|
if (! empty($params['month'])) {
|
|
$query->where('pay_month', $params['month']);
|
|
}
|
|
if (! empty($params['status'])) {
|
|
$query->where('status', $params['status']);
|
|
}
|
|
if (! empty($params['user_id'])) {
|
|
$query->where('user_id', $params['user_id']);
|
|
}
|
|
if (! empty($params['department_id'])) {
|
|
$deptId = $params['department_id'];
|
|
$query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) {
|
|
$q->where('tenant_id', $tenantId)->where('department_id', $deptId);
|
|
});
|
|
}
|
|
if (! empty($params['search'])) {
|
|
$query->whereHas('user', function ($q) use ($params) {
|
|
$q->where('name', 'like', "%{$params['search']}%");
|
|
});
|
|
}
|
|
|
|
$sortBy = $params['sort_by'] ?? 'pay_year';
|
|
$sortDir = $params['sort_dir'] ?? 'desc';
|
|
|
|
if ($sortBy === 'period') {
|
|
$query->orderBy('pay_year', $sortDir)->orderBy('pay_month', $sortDir);
|
|
} else {
|
|
$query->orderBy($sortBy, $sortDir);
|
|
}
|
|
|
|
$payrolls = $query->get();
|
|
|
|
$statusLabels = [
|
|
Payroll::STATUS_DRAFT => '작성중',
|
|
Payroll::STATUS_CONFIRMED => '확정',
|
|
Payroll::STATUS_PAID => '지급완료',
|
|
];
|
|
|
|
$data = $payrolls->map(function ($payroll) use ($statusLabels) {
|
|
$profile = $payroll->user?->tenantProfiles?->first();
|
|
$department = $profile?->department?->name ?? '-';
|
|
|
|
return [
|
|
$payroll->pay_year.'년 '.$payroll->pay_month.'월',
|
|
$payroll->user?->name ?? '-',
|
|
$department,
|
|
number_format($payroll->base_salary),
|
|
number_format($payroll->overtime_pay),
|
|
number_format($payroll->bonus),
|
|
number_format($payroll->gross_salary),
|
|
number_format($payroll->income_tax),
|
|
number_format($payroll->resident_tax),
|
|
number_format($payroll->health_insurance),
|
|
number_format($payroll->long_term_care),
|
|
number_format($payroll->pension),
|
|
number_format($payroll->employment_insurance),
|
|
number_format($payroll->total_deductions),
|
|
number_format($payroll->net_salary),
|
|
$statusLabels[$payroll->status] ?? $payroll->status,
|
|
];
|
|
})->toArray();
|
|
|
|
$headings = [
|
|
'급여월',
|
|
'직원명',
|
|
'부서',
|
|
'기본급',
|
|
'야근수당',
|
|
'상여금',
|
|
'총지급액',
|
|
'소득세',
|
|
'주민세',
|
|
'건강보험',
|
|
'장기요양',
|
|
'국민연금',
|
|
'고용보험',
|
|
'공제합계',
|
|
'실지급액',
|
|
'상태',
|
|
];
|
|
|
|
return [
|
|
'data' => $data,
|
|
'headings' => $headings,
|
|
];
|
|
}
|
|
|
|
// =========================================================================
|
|
// 전표 생성
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 급여 전표 일괄 생성
|
|
*
|
|
* 해당 연월의 확정/지급완료 급여를 합산하여 전표를 생성한다.
|
|
* - 차변: 급여 (총지급액)
|
|
* - 대변: 각 공제항목 + 미지급금(실지급액)
|
|
*/
|
|
public function createJournalEntries(int $year, int $month, ?string $entryDate = null): JournalEntry
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$payrolls = Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->forPeriod($year, $month)
|
|
->whereIn('status', [Payroll::STATUS_CONFIRMED, Payroll::STATUS_PAID])
|
|
->get();
|
|
|
|
if ($payrolls->isEmpty()) {
|
|
throw new BadRequestHttpException(__('error.payroll.no_confirmed_payrolls'));
|
|
}
|
|
|
|
// 합산
|
|
$totalGross = $payrolls->sum('gross_salary');
|
|
$totalIncomeTax = $payrolls->sum('income_tax');
|
|
$totalResidentTax = $payrolls->sum('resident_tax');
|
|
$totalHealthInsurance = $payrolls->sum('health_insurance');
|
|
$totalLongTermCare = $payrolls->sum('long_term_care');
|
|
$totalPension = $payrolls->sum('pension');
|
|
$totalEmploymentInsurance = $payrolls->sum('employment_insurance');
|
|
$totalNet = $payrolls->sum('net_salary');
|
|
|
|
// 전표일자: 지정값 또는 해당월 급여지급일
|
|
if (! $entryDate) {
|
|
$settings = PayrollSetting::getOrCreate($tenantId);
|
|
$payDay = min($settings->pay_day, 28);
|
|
$entryDate = sprintf('%04d-%02d-%02d', $year, $month, $payDay);
|
|
}
|
|
|
|
$sourceKey = "payroll_{$year}_{$month}";
|
|
$description = "{$year}년 {$month}월 급여";
|
|
|
|
// 분개 행 구성
|
|
$rows = [];
|
|
|
|
// 차변: 급여 (총지급액)
|
|
$rows[] = [
|
|
'side' => 'debit',
|
|
'account_code' => '51100',
|
|
'account_name' => '급여',
|
|
'debit_amount' => (int) $totalGross,
|
|
'credit_amount' => 0,
|
|
'memo' => $description,
|
|
];
|
|
|
|
// 대변: 소득세예수금
|
|
if ($totalIncomeTax > 0) {
|
|
$rows[] = [
|
|
'side' => 'credit',
|
|
'account_code' => '25500',
|
|
'account_name' => '예수금-소득세',
|
|
'debit_amount' => 0,
|
|
'credit_amount' => (int) $totalIncomeTax,
|
|
];
|
|
}
|
|
|
|
// 대변: 주민세예수금
|
|
if ($totalResidentTax > 0) {
|
|
$rows[] = [
|
|
'side' => 'credit',
|
|
'account_code' => '25501',
|
|
'account_name' => '예수금-주민세',
|
|
'debit_amount' => 0,
|
|
'credit_amount' => (int) $totalResidentTax,
|
|
];
|
|
}
|
|
|
|
// 대변: 건강보험예수금
|
|
if ($totalHealthInsurance > 0) {
|
|
$rows[] = [
|
|
'side' => 'credit',
|
|
'account_code' => '25502',
|
|
'account_name' => '예수금-건강보험',
|
|
'debit_amount' => 0,
|
|
'credit_amount' => (int) $totalHealthInsurance,
|
|
];
|
|
}
|
|
|
|
// 대변: 장기요양보험예수금
|
|
if ($totalLongTermCare > 0) {
|
|
$rows[] = [
|
|
'side' => 'credit',
|
|
'account_code' => '25503',
|
|
'account_name' => '예수금-장기요양',
|
|
'debit_amount' => 0,
|
|
'credit_amount' => (int) $totalLongTermCare,
|
|
];
|
|
}
|
|
|
|
// 대변: 국민연금예수금
|
|
if ($totalPension > 0) {
|
|
$rows[] = [
|
|
'side' => 'credit',
|
|
'account_code' => '25504',
|
|
'account_name' => '예수금-국민연금',
|
|
'debit_amount' => 0,
|
|
'credit_amount' => (int) $totalPension,
|
|
];
|
|
}
|
|
|
|
// 대변: 고용보험예수금
|
|
if ($totalEmploymentInsurance > 0) {
|
|
$rows[] = [
|
|
'side' => 'credit',
|
|
'account_code' => '25505',
|
|
'account_name' => '예수금-고용보험',
|
|
'debit_amount' => 0,
|
|
'credit_amount' => (int) $totalEmploymentInsurance,
|
|
];
|
|
}
|
|
|
|
// 대변: 미지급금 (실지급액)
|
|
if ($totalNet > 0) {
|
|
$rows[] = [
|
|
'side' => 'credit',
|
|
'account_code' => '25300',
|
|
'account_name' => '미지급금',
|
|
'debit_amount' => 0,
|
|
'credit_amount' => (int) $totalNet,
|
|
'memo' => "급여 실지급액 ({$payrolls->count()}명)",
|
|
];
|
|
}
|
|
|
|
$syncService = app(JournalSyncService::class);
|
|
|
|
return $syncService->saveForSource(
|
|
JournalEntry::SOURCE_PAYROLL,
|
|
$sourceKey,
|
|
$entryDate,
|
|
$description,
|
|
$rows
|
|
);
|
|
}
|
|
}
|