feat: [재무] 어음 V8 + 상품권 접대비 연동 + 일반전표/계정과목 API
- Bill 확장 필드 (V8), Loan 상품권 카테고리/접대비 자동 연동 - GeneralJournalEntry CRUD, AccountSubject API - 접대비/복리후생비 날짜 필터, 매출채권 soft delete 제외 - 바로빌 연동 API 엔드포인트 추가 - 부가세 상세 조회 API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
60
app/Http/Controllers/Api/V1/AccountSubjectController.php
Normal file
60
app/Http/Controllers/Api/V1/AccountSubjectController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\AccountSubject\StoreAccountSubjectRequest;
|
||||
use App\Services\AccountCodeService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountSubjectController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccountCodeService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 계정과목 목록 조회
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$params = $request->only(['search', 'category']);
|
||||
|
||||
$subjects = $this->service->index($params);
|
||||
|
||||
return ApiResponse::success($subjects, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 등록
|
||||
*/
|
||||
public function store(StoreAccountSubjectRequest $request)
|
||||
{
|
||||
$subject = $this->service->store($request->validated());
|
||||
|
||||
return ApiResponse::success($subject, __('message.created'), [], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 활성/비활성 토글
|
||||
*/
|
||||
public function toggleStatus(int $id, Request $request)
|
||||
{
|
||||
$isActive = (bool) $request->input('is_active', true);
|
||||
|
||||
$subject = $this->service->toggleStatus($id, $isActive);
|
||||
|
||||
return ApiResponse::success($subject, __('message.toggled'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 삭제
|
||||
*/
|
||||
public function destroy(int $id)
|
||||
{
|
||||
$this->service->destroy($id);
|
||||
|
||||
return ApiResponse::success(null, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
144
app/Http/Controllers/Api/V1/BarobillController.php
Normal file
144
app/Http/Controllers/Api/V1/BarobillController.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\BarobillService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BarobillController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private BarobillService $barobillService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 연동 현황 조회
|
||||
*/
|
||||
public function status()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$setting = $this->barobillService->getSetting();
|
||||
|
||||
return [
|
||||
'bank_service_count' => 0,
|
||||
'account_link_count' => 0,
|
||||
'member' => $setting ? [
|
||||
'barobill_id' => $setting->barobill_id,
|
||||
'biz_no' => $setting->corp_num,
|
||||
'status' => $setting->isVerified() ? 'active' : 'inactive',
|
||||
'server_mode' => config('services.barobill.test_mode', true) ? 'test' : 'production',
|
||||
] : null,
|
||||
];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 로그인 정보 등록
|
||||
*/
|
||||
public function login(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'barobill_id' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($data) {
|
||||
return $this->barobillService->saveSetting([
|
||||
'barobill_id' => $data['barobill_id'],
|
||||
]);
|
||||
}, __('message.saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 회원가입 정보 등록
|
||||
*/
|
||||
public function signup(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'business_number' => 'required|string|size:10',
|
||||
'company_name' => 'required|string',
|
||||
'ceo_name' => 'required|string',
|
||||
'business_type' => 'nullable|string',
|
||||
'business_category' => 'nullable|string',
|
||||
'address' => 'nullable|string',
|
||||
'barobill_id' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
'manager_name' => 'nullable|string',
|
||||
'manager_phone' => 'nullable|string',
|
||||
'manager_email' => 'nullable|email',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($data) {
|
||||
return $this->barobillService->saveSetting([
|
||||
'corp_num' => $data['business_number'],
|
||||
'corp_name' => $data['company_name'],
|
||||
'ceo_name' => $data['ceo_name'],
|
||||
'biz_type' => $data['business_type'] ?? null,
|
||||
'biz_class' => $data['business_category'] ?? null,
|
||||
'addr' => $data['address'] ?? null,
|
||||
'barobill_id' => $data['barobill_id'],
|
||||
'contact_name' => $data['manager_name'] ?? null,
|
||||
'contact_tel' => $data['manager_phone'] ?? null,
|
||||
'contact_id' => $data['manager_email'] ?? null,
|
||||
]);
|
||||
}, __('message.saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 은행 빠른조회 서비스 URL 조회
|
||||
*/
|
||||
public function bankServiceUrl(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Bank/BankAccountService'];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 계좌 연동 등록 URL 조회
|
||||
*/
|
||||
public function accountLinkUrl()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Bank/AccountLink'];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 연동 등록 URL 조회
|
||||
*/
|
||||
public function cardLinkUrl()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Card/CardLink'];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 공인인증서 등록 URL 조회
|
||||
*/
|
||||
public function certificateUrl()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Certificate/Register'];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
@@ -33,4 +33,20 @@ public function summary(Request $request): JsonResponse
|
||||
return $this->entertainmentService->getSummary($limitType, $companyType, $year, $quarter);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 접대비 상세 조회 (모달용)
|
||||
*/
|
||||
public function detail(Request $request): JsonResponse
|
||||
{
|
||||
$companyType = $request->query('company_type', 'medium');
|
||||
$year = $request->query('year') ? (int) $request->query('year') : null;
|
||||
$quarter = $request->query('quarter') ? (int) $request->query('quarter') : null;
|
||||
$startDate = $request->query('start_date');
|
||||
$endDate = $request->query('end_date');
|
||||
|
||||
return ApiResponse::handle(function () use ($companyType, $year, $quarter, $startDate, $endDate) {
|
||||
return $this->entertainmentService->getDetail($companyType, $year, $quarter, $startDate, $endDate);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,13 +128,16 @@ public function summary(Request $request)
|
||||
/**
|
||||
* 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용)
|
||||
*
|
||||
* @param Request $request transaction_type 쿼리 파라미터 (purchase, card, bill, null=전체)
|
||||
* @param Request $request transaction_type (purchase, card, bill, null=전체), start_date, end_date, search
|
||||
*/
|
||||
public function dashboardDetail(Request $request)
|
||||
{
|
||||
$transactionType = $request->query('transaction_type');
|
||||
$startDate = $request->query('start_date');
|
||||
$endDate = $request->query('end_date');
|
||||
$search = $request->query('search');
|
||||
|
||||
$data = $this->service->dashboardDetail($transactionType);
|
||||
$data = $this->service->dashboardDetail($transactionType, $startDate, $endDate, $search);
|
||||
|
||||
return ApiResponse::success($data, __('message.fetched'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\GeneralJournalEntry\StoreManualJournalRequest;
|
||||
use App\Http\Requests\V1\GeneralJournalEntry\UpdateJournalRequest;
|
||||
use App\Services\GeneralJournalEntryService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GeneralJournalEntryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GeneralJournalEntryService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 일반전표 통합 목록 조회
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$params = $request->only([
|
||||
'start_date', 'end_date', 'search', 'page', 'per_page',
|
||||
]);
|
||||
|
||||
$result = $this->service->index($params);
|
||||
|
||||
return ApiResponse::success($result, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계
|
||||
*/
|
||||
public function summary(Request $request)
|
||||
{
|
||||
$params = $request->only([
|
||||
'start_date', 'end_date', 'search',
|
||||
]);
|
||||
|
||||
$summary = $this->service->summary($params);
|
||||
|
||||
return ApiResponse::success($summary, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수기전표 등록
|
||||
*/
|
||||
public function store(StoreManualJournalRequest $request)
|
||||
{
|
||||
$entry = $this->service->store($request->validated());
|
||||
|
||||
return ApiResponse::success($entry, __('message.created'), [], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표 상세 조회 (분개 수정 모달용)
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
$detail = $this->service->show($id);
|
||||
|
||||
return ApiResponse::success($detail, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 수정
|
||||
*/
|
||||
public function updateJournal(int $id, UpdateJournalRequest $request)
|
||||
{
|
||||
$entry = $this->service->updateJournal($id, $request->validated());
|
||||
|
||||
return ApiResponse::success($entry, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 삭제
|
||||
*/
|
||||
public function destroyJournal(int $id)
|
||||
{
|
||||
$this->service->destroyJournal($id);
|
||||
|
||||
return ApiResponse::success(null, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
use App\Http\Requests\Loan\LoanUpdateRequest;
|
||||
use App\Services\LoanService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LoanController extends Controller
|
||||
{
|
||||
@@ -33,8 +34,10 @@ public function index(LoanIndexRequest $request): JsonResponse
|
||||
*/
|
||||
public function summary(LoanIndexRequest $request): JsonResponse
|
||||
{
|
||||
$userId = $request->validated()['user_id'] ?? null;
|
||||
$result = $this->loanService->summary($userId);
|
||||
$validated = $request->validated();
|
||||
$userId = $validated['user_id'] ?? null;
|
||||
$category = $validated['category'] ?? null;
|
||||
$result = $this->loanService->summary($userId, $category);
|
||||
|
||||
return ApiResponse::success($result, __('message.fetched'));
|
||||
}
|
||||
@@ -42,9 +45,12 @@ public function summary(LoanIndexRequest $request): JsonResponse
|
||||
/**
|
||||
* 가지급금 대시보드
|
||||
*/
|
||||
public function dashboard(): JsonResponse
|
||||
public function dashboard(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->loanService->dashboard();
|
||||
$startDate = $request->query('start_date');
|
||||
$endDate = $request->query('end_date');
|
||||
|
||||
$result = $this->loanService->dashboard($startDate, $endDate);
|
||||
|
||||
return ApiResponse::success($result, __('message.fetched'));
|
||||
}
|
||||
|
||||
@@ -32,4 +32,18 @@ public function summary(Request $request): JsonResponse
|
||||
return $this->vatService->getSummary($periodType, $year, $period);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 부가세 상세 조회 (모달용)
|
||||
*/
|
||||
public function detail(Request $request): JsonResponse
|
||||
{
|
||||
$periodType = $request->query('period_type', 'quarter');
|
||||
$year = $request->query('year') ? (int) $request->query('year') : null;
|
||||
$period = $request->query('period') ? (int) $request->query('period') : null;
|
||||
|
||||
return ApiResponse::handle(function () use ($periodType, $year, $period) {
|
||||
return $this->vatService->getDetail($periodType, $year, $period);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ public function rules(): array
|
||||
return [
|
||||
'user_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)],
|
||||
'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)],
|
||||
'start_date' => ['nullable', 'date', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
|
||||
'search' => ['nullable', 'string', 'max:100'],
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Http\Requests\Loan;
|
||||
|
||||
use App\Models\Tenants\Loan;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class LoanStoreRequest extends FormRequest
|
||||
{
|
||||
@@ -21,12 +23,27 @@ public function authorize(): bool
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$isGiftCertificate = $this->input('category') === Loan::CATEGORY_GIFT_CERTIFICATE;
|
||||
|
||||
return [
|
||||
'user_id' => ['required', 'integer', 'exists:users,id'],
|
||||
'user_id' => [$isGiftCertificate ? 'nullable' : 'required', 'integer', 'exists:users,id'],
|
||||
'loan_date' => ['required', 'date', 'date_format:Y-m-d'],
|
||||
'amount' => ['required', 'numeric', 'min:0', 'max:999999999999.99'],
|
||||
'purpose' => ['nullable', 'string', 'max:1000'],
|
||||
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
|
||||
'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)],
|
||||
'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
'metadata.serial_number' => ['nullable', 'string', 'max:100'],
|
||||
'metadata.cert_name' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.vendor_id' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.vendor_name' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.recipient_name' => ['nullable', 'string', 'max:100'],
|
||||
'metadata.recipient_organization' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.usage_description' => ['nullable', 'string', 'max:1000'],
|
||||
'metadata.memo' => ['nullable', 'string', 'max:2000'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Http\Requests\Loan;
|
||||
|
||||
use App\Models\Tenants\Loan;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class LoanUpdateRequest extends FormRequest
|
||||
{
|
||||
@@ -27,6 +29,20 @@ public function rules(): array
|
||||
'amount' => ['sometimes', 'numeric', 'min:0', 'max:999999999999.99'],
|
||||
'purpose' => ['nullable', 'string', 'max:1000'],
|
||||
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
|
||||
'category' => ['sometimes', 'string', Rule::in(Loan::CATEGORIES)],
|
||||
'status' => ['sometimes', 'string', Rule::in(Loan::STATUSES)],
|
||||
'settlement_date' => ['nullable', 'date', 'date_format:Y-m-d'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
'metadata.serial_number' => ['nullable', 'string', 'max:100'],
|
||||
'metadata.cert_name' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.vendor_id' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.vendor_name' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.recipient_name' => ['nullable', 'string', 'max:100'],
|
||||
'metadata.recipient_organization' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.usage_description' => ['nullable', 'string', 'max:1000'],
|
||||
'metadata.memo' => ['nullable', 'string', 'max:2000'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\AccountSubject;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreAccountSubjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => ['required', 'string', 'max:10'],
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'category' => ['nullable', 'string', 'in:asset,liability,capital,revenue,expense'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'code.required' => '계정과목 코드를 입력하세요.',
|
||||
'name.required' => '계정과목명을 입력하세요.',
|
||||
'category.in' => '유효한 분류를 선택하세요.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public function rules(): array
|
||||
$tenantId = app('tenant_id') ?? 0;
|
||||
|
||||
return [
|
||||
// === 기존 필드 ===
|
||||
'bill_number' => [
|
||||
'nullable',
|
||||
'string',
|
||||
@@ -30,16 +31,99 @@ public function rules(): array
|
||||
'client_name' => ['nullable', 'string', 'max:100'],
|
||||
'amount' => ['required', 'numeric', 'min:0'],
|
||||
'issue_date' => ['required', 'date'],
|
||||
'maturity_date' => ['required', 'date', 'after_or_equal:issue_date'],
|
||||
'status' => ['nullable', 'string', 'in:stored,maturityAlert,maturityResult,paymentComplete,dishonored,collectionRequest,collectionComplete,suing'],
|
||||
'maturity_date' => ['nullable', 'date', 'after_or_equal:issue_date'],
|
||||
'status' => ['nullable', 'string', 'max:30'],
|
||||
'reason' => ['nullable', 'string', 'max:255'],
|
||||
'installment_count' => ['nullable', 'integer', 'min:0'],
|
||||
'note' => ['nullable', 'string', 'max:1000'],
|
||||
'is_electronic' => ['nullable', 'boolean'],
|
||||
'bank_account_id' => ['nullable', 'integer', 'exists:bank_accounts,id'],
|
||||
|
||||
// === V8 증권종류/매체/구분 ===
|
||||
'instrument_type' => ['nullable', 'string', 'in:promissory,exchange,cashierCheck,currentCheck'],
|
||||
'medium' => ['nullable', 'string', 'in:electronic,paper'],
|
||||
'bill_category' => ['nullable', 'string', 'in:commercial,other'],
|
||||
|
||||
// === 전자어음 ===
|
||||
'electronic_bill_no' => ['nullable', 'string', 'max:100'],
|
||||
'registration_org' => ['nullable', 'string', 'in:kftc,bank'],
|
||||
|
||||
// === 환어음 ===
|
||||
'drawee' => ['nullable', 'string', 'max:100'],
|
||||
'acceptance_status' => ['nullable', 'string', 'in:accepted,pending,refused'],
|
||||
'acceptance_date' => ['nullable', 'date'],
|
||||
'acceptance_refusal_date' => ['nullable', 'date'],
|
||||
'acceptance_refusal_reason' => ['nullable', 'string', 'max:50'],
|
||||
|
||||
// === 받을어음 전용 ===
|
||||
'endorsement' => ['nullable', 'string', 'in:endorsable,nonEndorsable'],
|
||||
'endorsement_order' => ['nullable', 'string', 'max:5'],
|
||||
'storage_place' => ['nullable', 'string', 'in:safe,bank,other'],
|
||||
'issuer_bank' => ['nullable', 'string', 'max:100'],
|
||||
|
||||
// 할인
|
||||
'is_discounted' => ['nullable', 'boolean'],
|
||||
'discount_date' => ['nullable', 'date'],
|
||||
'discount_bank' => ['nullable', 'string', 'max:100'],
|
||||
'discount_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
'discount_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
|
||||
// 배서양도
|
||||
'endorsement_date' => ['nullable', 'date'],
|
||||
'endorsee' => ['nullable', 'string', 'max:100'],
|
||||
'endorsement_reason' => ['nullable', 'string', 'in:payment,guarantee,collection,other'],
|
||||
|
||||
// 추심
|
||||
'collection_bank' => ['nullable', 'string', 'max:100'],
|
||||
'collection_request_date' => ['nullable', 'date'],
|
||||
'collection_fee' => ['nullable', 'numeric', 'min:0'],
|
||||
'collection_complete_date' => ['nullable', 'date'],
|
||||
'collection_result' => ['nullable', 'string', 'in:success,partial,failed,pending'],
|
||||
'collection_deposit_date' => ['nullable', 'date'],
|
||||
'collection_deposit_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
|
||||
// === 지급어음 전용 ===
|
||||
'settlement_bank' => ['nullable', 'string', 'max:100'],
|
||||
'payment_method' => ['nullable', 'string', 'in:autoTransfer,currentAccount,other'],
|
||||
'actual_payment_date' => ['nullable', 'date'],
|
||||
|
||||
// === 공통 ===
|
||||
'payment_place' => ['nullable', 'string', 'max:30'],
|
||||
'payment_place_detail' => ['nullable', 'string', 'max:200'],
|
||||
|
||||
// 개서
|
||||
'renewal_date' => ['nullable', 'date'],
|
||||
'renewal_new_bill_no' => ['nullable', 'string', 'max:50'],
|
||||
'renewal_reason' => ['nullable', 'string', 'in:maturityExtension,amountChange,conditionChange,other'],
|
||||
|
||||
// 소구
|
||||
'recourse_date' => ['nullable', 'date'],
|
||||
'recourse_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'recourse_target' => ['nullable', 'string', 'max:100'],
|
||||
'recourse_reason' => ['nullable', 'string', 'in:endorsedDishonor,discountDishonor,other'],
|
||||
|
||||
// 환매
|
||||
'buyback_date' => ['nullable', 'date'],
|
||||
'buyback_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'buyback_bank' => ['nullable', 'string', 'max:100'],
|
||||
|
||||
// 부도/법적절차
|
||||
'dishonored_date' => ['nullable', 'date'],
|
||||
'dishonored_reason' => ['nullable', 'string', 'max:30'],
|
||||
'has_protest' => ['nullable', 'boolean'],
|
||||
'protest_date' => ['nullable', 'date'],
|
||||
'recourse_notice_date' => ['nullable', 'date'],
|
||||
'recourse_notice_deadline' => ['nullable', 'date'],
|
||||
|
||||
// 분할배서
|
||||
'is_split' => ['nullable', 'boolean'],
|
||||
|
||||
// === 차수 관리 ===
|
||||
'installments' => ['nullable', 'array'],
|
||||
'installments.*.date' => ['required_with:installments', 'date'],
|
||||
'installments.*.amount' => ['required_with:installments', 'numeric', 'min:0'],
|
||||
'installments.*.type' => ['nullable', 'string', 'max:30'],
|
||||
'installments.*.counterparty' => ['nullable', 'string', 'max:100'],
|
||||
'installments.*.note' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public function authorize(): bool
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// === 기존 필드 ===
|
||||
'bill_number' => ['nullable', 'string', 'max:50'],
|
||||
'bill_type' => ['nullable', 'string', 'in:received,issued'],
|
||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||
@@ -21,15 +22,72 @@ public function rules(): array
|
||||
'amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'issue_date' => ['nullable', 'date'],
|
||||
'maturity_date' => ['nullable', 'date', 'after_or_equal:issue_date'],
|
||||
'status' => ['nullable', 'string', 'in:stored,maturityAlert,maturityResult,paymentComplete,dishonored,collectionRequest,collectionComplete,suing'],
|
||||
'status' => ['nullable', 'string', 'max:30'],
|
||||
'reason' => ['nullable', 'string', 'max:255'],
|
||||
'installment_count' => ['nullable', 'integer', 'min:0'],
|
||||
'note' => ['nullable', 'string', 'max:1000'],
|
||||
'is_electronic' => ['nullable', 'boolean'],
|
||||
'bank_account_id' => ['nullable', 'integer', 'exists:bank_accounts,id'],
|
||||
|
||||
// === V8 확장 ===
|
||||
'instrument_type' => ['nullable', 'string', 'in:promissory,exchange,cashierCheck,currentCheck'],
|
||||
'medium' => ['nullable', 'string', 'in:electronic,paper'],
|
||||
'bill_category' => ['nullable', 'string', 'in:commercial,other'],
|
||||
'electronic_bill_no' => ['nullable', 'string', 'max:100'],
|
||||
'registration_org' => ['nullable', 'string', 'in:kftc,bank'],
|
||||
'drawee' => ['nullable', 'string', 'max:100'],
|
||||
'acceptance_status' => ['nullable', 'string', 'in:accepted,pending,refused'],
|
||||
'acceptance_date' => ['nullable', 'date'],
|
||||
'acceptance_refusal_date' => ['nullable', 'date'],
|
||||
'acceptance_refusal_reason' => ['nullable', 'string', 'max:50'],
|
||||
'endorsement' => ['nullable', 'string', 'in:endorsable,nonEndorsable'],
|
||||
'endorsement_order' => ['nullable', 'string', 'max:5'],
|
||||
'storage_place' => ['nullable', 'string', 'in:safe,bank,other'],
|
||||
'issuer_bank' => ['nullable', 'string', 'max:100'],
|
||||
'is_discounted' => ['nullable', 'boolean'],
|
||||
'discount_date' => ['nullable', 'date'],
|
||||
'discount_bank' => ['nullable', 'string', 'max:100'],
|
||||
'discount_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
'discount_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'endorsement_date' => ['nullable', 'date'],
|
||||
'endorsee' => ['nullable', 'string', 'max:100'],
|
||||
'endorsement_reason' => ['nullable', 'string', 'in:payment,guarantee,collection,other'],
|
||||
'collection_bank' => ['nullable', 'string', 'max:100'],
|
||||
'collection_request_date' => ['nullable', 'date'],
|
||||
'collection_fee' => ['nullable', 'numeric', 'min:0'],
|
||||
'collection_complete_date' => ['nullable', 'date'],
|
||||
'collection_result' => ['nullable', 'string', 'in:success,partial,failed,pending'],
|
||||
'collection_deposit_date' => ['nullable', 'date'],
|
||||
'collection_deposit_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'settlement_bank' => ['nullable', 'string', 'max:100'],
|
||||
'payment_method' => ['nullable', 'string', 'in:autoTransfer,currentAccount,other'],
|
||||
'actual_payment_date' => ['nullable', 'date'],
|
||||
'payment_place' => ['nullable', 'string', 'max:30'],
|
||||
'payment_place_detail' => ['nullable', 'string', 'max:200'],
|
||||
'renewal_date' => ['nullable', 'date'],
|
||||
'renewal_new_bill_no' => ['nullable', 'string', 'max:50'],
|
||||
'renewal_reason' => ['nullable', 'string', 'in:maturityExtension,amountChange,conditionChange,other'],
|
||||
'recourse_date' => ['nullable', 'date'],
|
||||
'recourse_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'recourse_target' => ['nullable', 'string', 'max:100'],
|
||||
'recourse_reason' => ['nullable', 'string', 'in:endorsedDishonor,discountDishonor,other'],
|
||||
'buyback_date' => ['nullable', 'date'],
|
||||
'buyback_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'buyback_bank' => ['nullable', 'string', 'max:100'],
|
||||
'dishonored_date' => ['nullable', 'date'],
|
||||
'dishonored_reason' => ['nullable', 'string', 'max:30'],
|
||||
'has_protest' => ['nullable', 'boolean'],
|
||||
'protest_date' => ['nullable', 'date'],
|
||||
'recourse_notice_date' => ['nullable', 'date'],
|
||||
'recourse_notice_deadline' => ['nullable', 'date'],
|
||||
'is_split' => ['nullable', 'boolean'],
|
||||
|
||||
// === 차수 관리 ===
|
||||
'installments' => ['nullable', 'array'],
|
||||
'installments.*.date' => ['required_with:installments', 'date'],
|
||||
'installments.*.amount' => ['required_with:installments', 'numeric', 'min:0'],
|
||||
'installments.*.type' => ['nullable', 'string', 'max:30'],
|
||||
'installments.*.counterparty' => ['nullable', 'string', 'max:100'],
|
||||
'installments.*.note' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\GeneralJournalEntry;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreManualJournalRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'journal_date' => ['required', 'date'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'rows' => ['required', 'array', 'min:2'],
|
||||
'rows.*.side' => ['required', 'in:debit,credit'],
|
||||
'rows.*.account_subject_id' => ['required', 'string', 'max:10'],
|
||||
'rows.*.vendor_id' => ['nullable', 'integer'],
|
||||
'rows.*.debit_amount' => ['required', 'integer', 'min:0'],
|
||||
'rows.*.credit_amount' => ['required', 'integer', 'min:0'],
|
||||
'rows.*.memo' => ['nullable', 'string', 'max:300'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'journal_date.required' => '전표일자를 입력하세요.',
|
||||
'rows.required' => '분개 행을 입력하세요.',
|
||||
'rows.min' => '최소 2개 이상의 분개 행이 필요합니다.',
|
||||
'rows.*.side.required' => '차/대 구분을 선택하세요.',
|
||||
'rows.*.side.in' => '차/대 구분이 올바르지 않습니다.',
|
||||
'rows.*.account_subject_id.required' => '계정과목을 선택하세요.',
|
||||
'rows.*.debit_amount.required' => '차변 금액을 입력하세요.',
|
||||
'rows.*.credit_amount.required' => '대변 금액을 입력하세요.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\GeneralJournalEntry;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateJournalRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'journal_memo' => ['sometimes', 'nullable', 'string', 'max:1000'],
|
||||
'rows' => ['sometimes', 'array', 'min:1'],
|
||||
'rows.*.side' => ['required_with:rows', 'in:debit,credit'],
|
||||
'rows.*.account_subject_id' => ['required_with:rows', 'string', 'max:10'],
|
||||
'rows.*.vendor_id' => ['nullable', 'integer'],
|
||||
'rows.*.debit_amount' => ['required_with:rows', 'integer', 'min:0'],
|
||||
'rows.*.credit_amount' => ['required_with:rows', 'integer', 'min:0'],
|
||||
'rows.*.memo' => ['nullable', 'string', 'max:300'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'rows.*.side.required_with' => '차/대 구분을 선택하세요.',
|
||||
'rows.*.side.in' => '차/대 구분이 올바르지 않습니다.',
|
||||
'rows.*.account_subject_id.required_with' => '계정과목을 선택하세요.',
|
||||
'rows.*.debit_amount.required_with' => '차변 금액을 입력하세요.',
|
||||
'rows.*.credit_amount.required_with' => '대변 금액을 입력하세요.',
|
||||
];
|
||||
}
|
||||
}
|
||||
49
app/Models/Tenants/AccountCode.php
Normal file
49
app/Models/Tenants/AccountCode.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AccountCode extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'code',
|
||||
'name',
|
||||
'category',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// Categories
|
||||
public const CATEGORY_ASSET = 'asset';
|
||||
public const CATEGORY_LIABILITY = 'liability';
|
||||
public const CATEGORY_CAPITAL = 'capital';
|
||||
public const CATEGORY_REVENUE = 'revenue';
|
||||
public const CATEGORY_EXPENSE = 'expense';
|
||||
|
||||
public const CATEGORIES = [
|
||||
self::CATEGORY_ASSET => '자산',
|
||||
self::CATEGORY_LIABILITY => '부채',
|
||||
self::CATEGORY_CAPITAL => '자본',
|
||||
self::CATEGORY_REVENUE => '수익',
|
||||
self::CATEGORY_EXPENSE => '비용',
|
||||
];
|
||||
|
||||
/**
|
||||
* 활성 계정과목만 조회
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,58 @@ class Bill extends Model
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
// V8 확장 필드
|
||||
'instrument_type',
|
||||
'medium',
|
||||
'bill_category',
|
||||
'electronic_bill_no',
|
||||
'registration_org',
|
||||
'drawee',
|
||||
'acceptance_status',
|
||||
'acceptance_date',
|
||||
'acceptance_refusal_date',
|
||||
'acceptance_refusal_reason',
|
||||
'endorsement',
|
||||
'endorsement_order',
|
||||
'storage_place',
|
||||
'issuer_bank',
|
||||
'is_discounted',
|
||||
'discount_date',
|
||||
'discount_bank',
|
||||
'discount_rate',
|
||||
'discount_amount',
|
||||
'endorsement_date',
|
||||
'endorsee',
|
||||
'endorsement_reason',
|
||||
'collection_bank',
|
||||
'collection_request_date',
|
||||
'collection_fee',
|
||||
'collection_complete_date',
|
||||
'collection_result',
|
||||
'collection_deposit_date',
|
||||
'collection_deposit_amount',
|
||||
'settlement_bank',
|
||||
'payment_method',
|
||||
'actual_payment_date',
|
||||
'payment_place',
|
||||
'payment_place_detail',
|
||||
'renewal_date',
|
||||
'renewal_new_bill_no',
|
||||
'renewal_reason',
|
||||
'recourse_date',
|
||||
'recourse_amount',
|
||||
'recourse_target',
|
||||
'recourse_reason',
|
||||
'buyback_date',
|
||||
'buyback_amount',
|
||||
'buyback_bank',
|
||||
'dishonored_date',
|
||||
'dishonored_reason',
|
||||
'has_protest',
|
||||
'protest_date',
|
||||
'recourse_notice_date',
|
||||
'recourse_notice_deadline',
|
||||
'is_split',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -41,21 +93,57 @@ class Bill extends Model
|
||||
'bank_account_id' => 'integer',
|
||||
'installment_count' => 'integer',
|
||||
'is_electronic' => 'boolean',
|
||||
// V8 확장 casts
|
||||
'acceptance_date' => 'date',
|
||||
'acceptance_refusal_date' => 'date',
|
||||
'discount_date' => 'date',
|
||||
'discount_rate' => 'decimal:2',
|
||||
'discount_amount' => 'decimal:2',
|
||||
'endorsement_date' => 'date',
|
||||
'collection_request_date' => 'date',
|
||||
'collection_fee' => 'decimal:2',
|
||||
'collection_complete_date' => 'date',
|
||||
'collection_deposit_date' => 'date',
|
||||
'collection_deposit_amount' => 'decimal:2',
|
||||
'actual_payment_date' => 'date',
|
||||
'renewal_date' => 'date',
|
||||
'recourse_date' => 'date',
|
||||
'recourse_amount' => 'decimal:2',
|
||||
'buyback_date' => 'date',
|
||||
'buyback_amount' => 'decimal:2',
|
||||
'dishonored_date' => 'date',
|
||||
'protest_date' => 'date',
|
||||
'recourse_notice_date' => 'date',
|
||||
'recourse_notice_deadline' => 'date',
|
||||
'is_discounted' => 'boolean',
|
||||
'has_protest' => 'boolean',
|
||||
'is_split' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 배열/JSON 변환 시 날짜 형식 지정
|
||||
*/
|
||||
/**
|
||||
* 날짜 cast 필드 목록 (toArray에서 Y-m-d 형식 변환용)
|
||||
*/
|
||||
private const DATE_FIELDS = [
|
||||
'issue_date', 'maturity_date',
|
||||
'acceptance_date', 'acceptance_refusal_date',
|
||||
'discount_date', 'endorsement_date',
|
||||
'collection_request_date', 'collection_complete_date', 'collection_deposit_date',
|
||||
'actual_payment_date',
|
||||
'renewal_date', 'recourse_date', 'buyback_date',
|
||||
'dishonored_date', 'protest_date', 'recourse_notice_date', 'recourse_notice_deadline',
|
||||
];
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
$array = parent::toArray();
|
||||
|
||||
// 날짜 필드를 Y-m-d 형식으로 변환
|
||||
if (isset($array['issue_date']) && $this->issue_date) {
|
||||
$array['issue_date'] = $this->issue_date->format('Y-m-d');
|
||||
}
|
||||
if (isset($array['maturity_date']) && $this->maturity_date) {
|
||||
$array['maturity_date'] = $this->maturity_date->format('Y-m-d');
|
||||
foreach (self::DATE_FIELDS as $field) {
|
||||
if (isset($array[$field]) && $this->{$field}) {
|
||||
$array[$field] = $this->{$field}->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
return $array;
|
||||
@@ -69,14 +157,42 @@ public function toArray(): array
|
||||
'issued' => '발행',
|
||||
];
|
||||
|
||||
/**
|
||||
* 증권종류
|
||||
*/
|
||||
public const INSTRUMENT_TYPES = [
|
||||
'promissory' => '약속어음',
|
||||
'exchange' => '환어음',
|
||||
'cashierCheck' => '자기앞수표',
|
||||
'currentCheck' => '당좌수표',
|
||||
];
|
||||
|
||||
/**
|
||||
* 수취 어음 상태 목록
|
||||
*/
|
||||
public const RECEIVED_STATUSES = [
|
||||
'stored' => '보관중',
|
||||
'endorsed' => '배서양도',
|
||||
'discounted' => '할인',
|
||||
'collectionRequest' => '추심의뢰',
|
||||
'collectionComplete' => '추심완료',
|
||||
'maturityDeposit' => '만기입금',
|
||||
'paymentComplete' => '결제완료',
|
||||
'dishonored' => '부도',
|
||||
'renewed' => '개서',
|
||||
'buyback' => '환매',
|
||||
// 하위호환
|
||||
'maturityAlert' => '만기입금(7일전)',
|
||||
'maturityResult' => '만기결과',
|
||||
'paymentComplete' => '결제완료',
|
||||
];
|
||||
|
||||
/**
|
||||
* 수취 수표 상태 목록
|
||||
*/
|
||||
public const RECEIVED_CHECK_STATUSES = [
|
||||
'stored' => '보관중',
|
||||
'endorsed' => '배서양도',
|
||||
'deposited' => '입금',
|
||||
'dishonored' => '부도',
|
||||
];
|
||||
|
||||
@@ -85,10 +201,25 @@ public function toArray(): array
|
||||
*/
|
||||
public const ISSUED_STATUSES = [
|
||||
'stored' => '보관중',
|
||||
'issued' => '지급대기',
|
||||
'maturityPayment' => '만기결제',
|
||||
'paymentComplete' => '결제완료',
|
||||
'dishonored' => '부도',
|
||||
'renewed' => '개서',
|
||||
// 하위호환
|
||||
'maturityAlert' => '만기입금(7일전)',
|
||||
'collectionRequest' => '추심의뢰',
|
||||
'collectionComplete' => '추심완료',
|
||||
'suing' => '추소중',
|
||||
];
|
||||
|
||||
/**
|
||||
* 발행 수표 상태 목록
|
||||
*/
|
||||
public const ISSUED_CHECK_STATUSES = [
|
||||
'stored' => '보관중',
|
||||
'issued' => '지급대기',
|
||||
'cashed' => '현금화',
|
||||
'dishonored' => '부도',
|
||||
];
|
||||
|
||||
@@ -149,11 +280,25 @@ public function getBillTypeLabelAttribute(): string
|
||||
*/
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
$isCheck = in_array($this->instrument_type, ['cashierCheck', 'currentCheck']);
|
||||
|
||||
if ($this->bill_type === 'received') {
|
||||
return self::RECEIVED_STATUSES[$this->status] ?? $this->status;
|
||||
$statuses = $isCheck ? self::RECEIVED_CHECK_STATUSES : self::RECEIVED_STATUSES;
|
||||
|
||||
return $statuses[$this->status] ?? self::RECEIVED_STATUSES[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
return self::ISSUED_STATUSES[$this->status] ?? $this->status;
|
||||
$statuses = $isCheck ? self::ISSUED_CHECK_STATUSES : self::ISSUED_STATUSES;
|
||||
|
||||
return $statuses[$this->status] ?? self::ISSUED_STATUSES[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 증권종류 라벨
|
||||
*/
|
||||
public function getInstrumentTypeLabelAttribute(): string
|
||||
{
|
||||
return self::INSTRUMENT_TYPES[$this->instrument_type] ?? $this->instrument_type ?? '약속어음';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,8 +12,10 @@ class BillInstallment extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'bill_id',
|
||||
'type',
|
||||
'installment_date',
|
||||
'amount',
|
||||
'counterparty',
|
||||
'note',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
@@ -34,6 +34,7 @@ class ExpenseAccount extends Model
|
||||
'vendor_name',
|
||||
'payment_method',
|
||||
'card_no',
|
||||
'loan_id',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
@@ -53,6 +54,9 @@ class ExpenseAccount extends Model
|
||||
|
||||
public const TYPE_OFFICE = 'office';
|
||||
|
||||
// 세부 유형 상수 (접대비)
|
||||
public const SUB_TYPE_GIFT_CERTIFICATE = 'gift_certificate';
|
||||
|
||||
// 세부 유형 상수 (복리후생)
|
||||
public const SUB_TYPE_MEAL = 'meal';
|
||||
|
||||
|
||||
53
app/Models/Tenants/JournalEntry.php
Normal file
53
app/Models/Tenants/JournalEntry.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class JournalEntry extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'entry_no',
|
||||
'entry_date',
|
||||
'entry_type',
|
||||
'description',
|
||||
'total_debit',
|
||||
'total_credit',
|
||||
'status',
|
||||
'source_type',
|
||||
'source_key',
|
||||
'created_by_name',
|
||||
'attachment_note',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'entry_date' => 'date',
|
||||
'total_debit' => 'integer',
|
||||
'total_credit' => 'integer',
|
||||
];
|
||||
|
||||
// Status
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_CONFIRMED = 'confirmed';
|
||||
|
||||
// Source type
|
||||
public const SOURCE_MANUAL = 'manual';
|
||||
public const SOURCE_BANK_TRANSACTION = 'bank_transaction';
|
||||
|
||||
// Entry type
|
||||
public const TYPE_GENERAL = 'general';
|
||||
|
||||
/**
|
||||
* 분개 행 관계
|
||||
*/
|
||||
public function lines(): HasMany
|
||||
{
|
||||
return $this->hasMany(JournalEntryLine::class)->orderBy('line_no');
|
||||
}
|
||||
}
|
||||
45
app/Models/Tenants/JournalEntryLine.php
Normal file
45
app/Models/Tenants/JournalEntryLine.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class JournalEntryLine extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'journal_entry_id',
|
||||
'line_no',
|
||||
'dc_type',
|
||||
'account_code',
|
||||
'account_name',
|
||||
'trading_partner_id',
|
||||
'trading_partner_name',
|
||||
'debit_amount',
|
||||
'credit_amount',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'line_no' => 'integer',
|
||||
'debit_amount' => 'integer',
|
||||
'credit_amount' => 'integer',
|
||||
'trading_partner_id' => 'integer',
|
||||
];
|
||||
|
||||
// DC Type
|
||||
public const DC_DEBIT = 'debit';
|
||||
public const DC_CREDIT = 'credit';
|
||||
|
||||
/**
|
||||
* 전표 관계
|
||||
*/
|
||||
public function journalEntry(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(JournalEntry::class);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,12 @@ class Loan extends Model
|
||||
|
||||
public const STATUS_PARTIAL = 'partial'; // 부분정산
|
||||
|
||||
public const STATUS_HOLDING = 'holding'; // 보유 (상품권)
|
||||
|
||||
public const STATUS_USED = 'used'; // 사용 (상품권)
|
||||
|
||||
public const STATUS_DISPOSED = 'disposed'; // 폐기 (상품권)
|
||||
|
||||
/**
|
||||
* 상태 목록
|
||||
*/
|
||||
@@ -35,6 +41,40 @@ class Loan extends Model
|
||||
self::STATUS_OUTSTANDING,
|
||||
self::STATUS_SETTLED,
|
||||
self::STATUS_PARTIAL,
|
||||
self::STATUS_HOLDING,
|
||||
self::STATUS_USED,
|
||||
self::STATUS_DISPOSED,
|
||||
];
|
||||
|
||||
/**
|
||||
* 카테고리 상수 (D1.7 기획서)
|
||||
*/
|
||||
public const CATEGORY_CARD = 'card'; // 카드
|
||||
|
||||
public const CATEGORY_CONGRATULATORY = 'congratulatory'; // 경조사
|
||||
|
||||
public const CATEGORY_GIFT_CERTIFICATE = 'gift_certificate'; // 상품권
|
||||
|
||||
public const CATEGORY_ENTERTAINMENT = 'entertainment'; // 접대비
|
||||
|
||||
/**
|
||||
* 카테고리 목록
|
||||
*/
|
||||
public const CATEGORIES = [
|
||||
self::CATEGORY_CARD,
|
||||
self::CATEGORY_CONGRATULATORY,
|
||||
self::CATEGORY_GIFT_CERTIFICATE,
|
||||
self::CATEGORY_ENTERTAINMENT,
|
||||
];
|
||||
|
||||
/**
|
||||
* 카테고리 라벨 매핑
|
||||
*/
|
||||
public const CATEGORY_LABELS = [
|
||||
self::CATEGORY_CARD => '카드',
|
||||
self::CATEGORY_CONGRATULATORY => '경조사',
|
||||
self::CATEGORY_GIFT_CERTIFICATE => '상품권',
|
||||
self::CATEGORY_ENTERTAINMENT => '접대비',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -72,6 +112,8 @@ class Loan extends Model
|
||||
'settlement_date',
|
||||
'settlement_amount',
|
||||
'status',
|
||||
'category',
|
||||
'metadata',
|
||||
'withdrawal_id',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
@@ -83,6 +125,7 @@ class Loan extends Model
|
||||
'settlement_date' => 'date',
|
||||
'amount' => 'decimal:2',
|
||||
'settlement_amount' => 'decimal:2',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
@@ -134,10 +177,21 @@ public function getStatusLabelAttribute(): string
|
||||
self::STATUS_OUTSTANDING => '미정산',
|
||||
self::STATUS_SETTLED => '정산완료',
|
||||
self::STATUS_PARTIAL => '부분정산',
|
||||
self::STATUS_HOLDING => '보유',
|
||||
self::STATUS_USED => '사용',
|
||||
self::STATUS_DISPOSED => '폐기',
|
||||
default => $this->status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 라벨
|
||||
*/
|
||||
public function getCategoryLabelAttribute(): string
|
||||
{
|
||||
return self::CATEGORY_LABELS[$this->category] ?? $this->category ?? '카드';
|
||||
}
|
||||
|
||||
/**
|
||||
* 미정산 잔액
|
||||
*/
|
||||
@@ -165,19 +219,33 @@ public function getElapsedDaysAttribute(): int
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 수정 가능 여부 (미정산 상태만)
|
||||
* 수정 가능 여부 (미정산 상태 또는 상품권)
|
||||
*/
|
||||
public function isEditable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_OUTSTANDING;
|
||||
if ($this->category === self::CATEGORY_GIFT_CERTIFICATE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($this->status, [
|
||||
self::STATUS_OUTSTANDING,
|
||||
self::STATUS_HOLDING,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 가능 여부 (미정산 상태만)
|
||||
* 삭제 가능 여부 (미정산/보유 상태 또는 상품권)
|
||||
*/
|
||||
public function isDeletable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_OUTSTANDING;
|
||||
if ($this->category === self::CATEGORY_GIFT_CERTIFICATE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($this->status, [
|
||||
self::STATUS_OUTSTANDING,
|
||||
self::STATUS_HOLDING,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
109
app/Services/AccountCodeService.php
Normal file
109
app/Services/AccountCodeService.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenants\AccountCode;
|
||||
use App\Models\Tenants\JournalEntryLine;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class AccountCodeService extends Service
|
||||
{
|
||||
/**
|
||||
* 계정과목 목록 조회
|
||||
*/
|
||||
public function index(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$query = AccountCode::query()
|
||||
->where('tenant_id', $tenantId);
|
||||
|
||||
// 검색 (코드/이름)
|
||||
if (! empty($params['search'])) {
|
||||
$search = $params['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('code', 'like', "%{$search}%")
|
||||
->orWhere('name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 분류 필터
|
||||
if (! empty($params['category'])) {
|
||||
$query->where('category', $params['category']);
|
||||
}
|
||||
|
||||
return $query->orderBy('sort_order')->orderBy('code')->get()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 등록
|
||||
*/
|
||||
public function store(array $data): AccountCode
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
// 중복 코드 체크
|
||||
$exists = AccountCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $data['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw new BadRequestHttpException(__('error.account_subject.duplicate_code'));
|
||||
}
|
||||
|
||||
$accountCode = new AccountCode;
|
||||
$accountCode->tenant_id = $tenantId;
|
||||
$accountCode->code = $data['code'];
|
||||
$accountCode->name = $data['name'];
|
||||
$accountCode->category = $data['category'] ?? null;
|
||||
$accountCode->sort_order = $data['sort_order'] ?? 0;
|
||||
$accountCode->is_active = true;
|
||||
$accountCode->save();
|
||||
|
||||
return $accountCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 활성/비활성 토글
|
||||
*/
|
||||
public function toggleStatus(int $id, bool $isActive): AccountCode
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$accountCode = AccountCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
$accountCode->is_active = $isActive;
|
||||
$accountCode->save();
|
||||
|
||||
return $accountCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 삭제 (사용 중이면 차단)
|
||||
*/
|
||||
public function destroy(int $id): bool
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$accountCode = AccountCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// 전표에서 사용 중인지 확인
|
||||
$inUse = JournalEntryLine::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_code', $accountCode->code)
|
||||
->exists();
|
||||
|
||||
if ($inUse) {
|
||||
throw new BadRequestHttpException(__('error.account_subject.in_use'));
|
||||
}
|
||||
|
||||
$accountCode->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,16 @@ public function index(array $params): LengthAwarePaginator
|
||||
$query->where('client_id', $params['client_id']);
|
||||
}
|
||||
|
||||
// 증권종류 필터
|
||||
if (! empty($params['instrument_type'])) {
|
||||
$query->where('instrument_type', $params['instrument_type']);
|
||||
}
|
||||
|
||||
// 매체 필터
|
||||
if (! empty($params['medium'])) {
|
||||
$query->where('medium', $params['medium']);
|
||||
}
|
||||
|
||||
// 전자어음 필터
|
||||
if (isset($params['is_electronic']) && $params['is_electronic'] !== '') {
|
||||
$query->where('is_electronic', (bool) $params['is_electronic']);
|
||||
@@ -113,32 +123,23 @@ public function store(array $data): Bill
|
||||
$bill->client_name = $data['client_name'] ?? null;
|
||||
$bill->amount = $data['amount'];
|
||||
$bill->issue_date = $data['issue_date'];
|
||||
$bill->maturity_date = $data['maturity_date'];
|
||||
$bill->maturity_date = $data['maturity_date'] ?? null;
|
||||
$bill->status = $data['status'] ?? 'stored';
|
||||
$bill->reason = $data['reason'] ?? null;
|
||||
$bill->installment_count = $data['installment_count'] ?? 0;
|
||||
$bill->note = $data['note'] ?? null;
|
||||
$bill->is_electronic = $data['is_electronic'] ?? false;
|
||||
$bill->bank_account_id = $data['bank_account_id'] ?? null;
|
||||
|
||||
// V8 확장 필드
|
||||
$this->assignV8Fields($bill, $data);
|
||||
|
||||
$bill->created_by = $userId;
|
||||
$bill->updated_by = $userId;
|
||||
$bill->save();
|
||||
|
||||
// 차수 관리 저장
|
||||
if (! empty($data['installments'])) {
|
||||
foreach ($data['installments'] as $installment) {
|
||||
BillInstallment::create([
|
||||
'bill_id' => $bill->id,
|
||||
'installment_date' => $installment['date'],
|
||||
'amount' => $installment['amount'],
|
||||
'note' => $installment['note'] ?? null,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
// 차수 카운트 업데이트
|
||||
$bill->installment_count = count($data['installments']);
|
||||
$bill->save();
|
||||
}
|
||||
$this->syncInstallments($bill, $data['installments'] ?? [], $userId);
|
||||
|
||||
return $bill->load(['client:id,name', 'bankAccount:id,bank_name,account_name', 'installments']);
|
||||
});
|
||||
@@ -157,6 +158,7 @@ public function update(int $id, array $data): Bill
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// 기존 필드
|
||||
if (isset($data['bill_number'])) {
|
||||
$bill->bill_number = $data['bill_number'];
|
||||
}
|
||||
@@ -175,7 +177,7 @@ public function update(int $id, array $data): Bill
|
||||
if (isset($data['issue_date'])) {
|
||||
$bill->issue_date = $data['issue_date'];
|
||||
}
|
||||
if (isset($data['maturity_date'])) {
|
||||
if (array_key_exists('maturity_date', $data)) {
|
||||
$bill->maturity_date = $data['maturity_date'];
|
||||
}
|
||||
if (isset($data['status'])) {
|
||||
@@ -194,27 +196,15 @@ public function update(int $id, array $data): Bill
|
||||
$bill->bank_account_id = $data['bank_account_id'];
|
||||
}
|
||||
|
||||
// V8 확장 필드
|
||||
$this->assignV8Fields($bill, $data);
|
||||
|
||||
$bill->updated_by = $userId;
|
||||
$bill->save();
|
||||
|
||||
// 차수 관리 업데이트 (전체 교체)
|
||||
if (isset($data['installments'])) {
|
||||
// 기존 차수 삭제
|
||||
$bill->installments()->delete();
|
||||
|
||||
// 새 차수 추가
|
||||
foreach ($data['installments'] as $installment) {
|
||||
BillInstallment::create([
|
||||
'bill_id' => $bill->id,
|
||||
'installment_date' => $installment['date'],
|
||||
'amount' => $installment['amount'],
|
||||
'note' => $installment['note'] ?? null,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
// 차수 카운트 업데이트
|
||||
$bill->installment_count = count($data['installments']);
|
||||
$bill->save();
|
||||
$this->syncInstallments($bill, $data['installments'], $userId);
|
||||
}
|
||||
|
||||
return $bill->fresh(['client:id,name', 'bankAccount:id,bank_name,account_name', 'installments']);
|
||||
@@ -440,6 +430,68 @@ public function dashboardDetail(): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* V8 확장 필드를 Bill 모델에 할당
|
||||
*/
|
||||
private function assignV8Fields(Bill $bill, array $data): void
|
||||
{
|
||||
$v8Fields = [
|
||||
'instrument_type', 'medium', 'bill_category',
|
||||
'electronic_bill_no', 'registration_org',
|
||||
'drawee', 'acceptance_status', 'acceptance_date',
|
||||
'acceptance_refusal_date', 'acceptance_refusal_reason',
|
||||
'endorsement', 'endorsement_order', 'storage_place', 'issuer_bank',
|
||||
'is_discounted', 'discount_date', 'discount_bank', 'discount_rate', 'discount_amount',
|
||||
'endorsement_date', 'endorsee', 'endorsement_reason',
|
||||
'collection_bank', 'collection_request_date', 'collection_fee',
|
||||
'collection_complete_date', 'collection_result', 'collection_deposit_date', 'collection_deposit_amount',
|
||||
'settlement_bank', 'payment_method', 'actual_payment_date',
|
||||
'payment_place', 'payment_place_detail',
|
||||
'renewal_date', 'renewal_new_bill_no', 'renewal_reason',
|
||||
'recourse_date', 'recourse_amount', 'recourse_target', 'recourse_reason',
|
||||
'buyback_date', 'buyback_amount', 'buyback_bank',
|
||||
'dishonored_date', 'dishonored_reason', 'has_protest', 'protest_date',
|
||||
'recourse_notice_date', 'recourse_notice_deadline',
|
||||
'is_split',
|
||||
];
|
||||
|
||||
foreach ($v8Fields as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$bill->{$field} = $data[$field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차수(이력) 동기화 — 기존 삭제 후 새로 생성
|
||||
*/
|
||||
private function syncInstallments(Bill $bill, array $installments, int $userId): void
|
||||
{
|
||||
if (empty($installments)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 차수 삭제
|
||||
$bill->installments()->delete();
|
||||
|
||||
// 새 차수 추가
|
||||
foreach ($installments as $installment) {
|
||||
BillInstallment::create([
|
||||
'bill_id' => $bill->id,
|
||||
'type' => $installment['type'] ?? 'other',
|
||||
'installment_date' => $installment['date'],
|
||||
'amount' => $installment['amount'],
|
||||
'counterparty' => $installment['counterparty'] ?? null,
|
||||
'note' => $installment['note'] ?? null,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
// 차수 카운트 업데이트
|
||||
$bill->installment_count = count($installments);
|
||||
$bill->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 어음번호 자동 생성
|
||||
*/
|
||||
|
||||
@@ -6,29 +6,35 @@
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 접대비 현황 서비스
|
||||
* 접대비 현황 서비스 (D1.7 리스크 감지형)
|
||||
*
|
||||
* CEO 대시보드용 접대비 데이터를 제공합니다.
|
||||
* CEO 대시보드용 접대비 리스크 데이터를 제공합니다.
|
||||
* 카드 4개: 주말/심야, 기피업종, 고액결제, 증빙미비
|
||||
*/
|
||||
class EntertainmentService extends Service
|
||||
{
|
||||
// 접대비 기본 한도율 (중소기업 기준: 매출의 0.3%)
|
||||
private const DEFAULT_LIMIT_RATE = 0.003;
|
||||
// 고액 결제 기준 (1회 50만원 초과)
|
||||
private const HIGH_AMOUNT_THRESHOLD = 500000;
|
||||
|
||||
// 기업 규모별 기본 한도 (연간)
|
||||
private const COMPANY_TYPE_LIMITS = [
|
||||
'large' => 36000000, // 대기업: 연 3,600만원
|
||||
'medium' => 36000000, // 중견기업: 연 3,600만원
|
||||
'small' => 24000000, // 중소기업: 연 2,400만원
|
||||
// 기피업종 MCC 코드 (유흥, 귀금속, 숙박 등)
|
||||
private const PROHIBITED_MCC_CODES = [
|
||||
'5813', // 음주업소
|
||||
'7011', // 숙박업
|
||||
'5944', // 귀금속
|
||||
'7941', // 레저/스포츠
|
||||
'7992', // 골프장
|
||||
'7273', // 데이트서비스
|
||||
'5932', // 골동품
|
||||
];
|
||||
|
||||
// 심야 시간대 (22시 ~ 06시)
|
||||
private const LATE_NIGHT_START = 22;
|
||||
|
||||
private const LATE_NIGHT_END = 6;
|
||||
|
||||
/**
|
||||
* 접대비 현황 요약 조회
|
||||
* 접대비 리스크 현황 요약 조회 (D1.7)
|
||||
*
|
||||
* @param string|null $limitType 기간 타입 (annual|quarterly, 기본: quarterly)
|
||||
* @param string|null $companyType 기업 유형 (large|medium|small, 기본: medium)
|
||||
* @param int|null $year 연도 (기본: 현재 연도)
|
||||
* @param int|null $quarter 분기 (1-4, 기본: 현재 분기)
|
||||
* @return array{cards: array, check_points: array}
|
||||
*/
|
||||
public function getSummary(
|
||||
@@ -40,73 +46,58 @@ public function getSummary(
|
||||
$tenantId = $this->tenantId();
|
||||
$now = Carbon::now();
|
||||
|
||||
// 기본값 설정
|
||||
$year = $year ?? $now->year;
|
||||
$limitType = $limitType ?? 'quarterly';
|
||||
$companyType = $companyType ?? 'medium';
|
||||
$quarter = $quarter ?? $now->quarter;
|
||||
|
||||
// 기간 범위 계산
|
||||
if ($limitType === 'annual') {
|
||||
$startDate = Carbon::create($year, 1, 1)->format('Y-m-d');
|
||||
$endDate = Carbon::create($year, 12, 31)->format('Y-m-d');
|
||||
$periodLabel = "{$year}년";
|
||||
} else {
|
||||
$startDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d');
|
||||
$endDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d');
|
||||
$periodLabel = "{$quarter}사분기";
|
||||
}
|
||||
|
||||
// 연간 시작일 (매출 계산용)
|
||||
$yearStartDate = Carbon::create($year, 1, 1)->format('Y-m-d');
|
||||
$yearEndDate = Carbon::create($year, 12, 31)->format('Y-m-d');
|
||||
|
||||
// 매출액 조회 (연간)
|
||||
$annualSales = $this->getAnnualSales($tenantId, $yearStartDate, $yearEndDate);
|
||||
|
||||
// 접대비 한도 계산
|
||||
$annualLimit = $this->calculateLimit($annualSales, $companyType);
|
||||
$periodLimit = $limitType === 'annual' ? $annualLimit : ($annualLimit / 4);
|
||||
|
||||
// 접대비 사용액 조회
|
||||
$usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate);
|
||||
|
||||
// 잔여 한도
|
||||
$remainingLimit = max(0, $periodLimit - $usedAmount);
|
||||
// 리스크 감지 쿼리
|
||||
$weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $startDate, $endDate);
|
||||
$prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $startDate, $endDate);
|
||||
$highAmount = $this->getHighAmountRisk($tenantId, $startDate, $endDate);
|
||||
$missingReceipt = $this->getMissingReceiptRisk($tenantId, $startDate, $endDate);
|
||||
|
||||
// 카드 데이터 구성
|
||||
$cards = [
|
||||
[
|
||||
'id' => 'et_sales',
|
||||
'label' => '매출',
|
||||
'amount' => (int) $annualSales,
|
||||
'id' => 'et_weekend',
|
||||
'label' => '주말/심야',
|
||||
'amount' => (int) $weekendLateNight['total'],
|
||||
'subLabel' => "{$weekendLateNight['count']}건",
|
||||
],
|
||||
[
|
||||
'id' => 'et_limit',
|
||||
'label' => "{{$periodLabel}} 접대비 총 한도",
|
||||
'amount' => (int) $periodLimit,
|
||||
'id' => 'et_prohibited',
|
||||
'label' => '기피업종',
|
||||
'amount' => (int) $prohibitedBiz['total'],
|
||||
'subLabel' => $prohibitedBiz['count'] > 0 ? "불인정 {$prohibitedBiz['count']}건" : '0건',
|
||||
],
|
||||
[
|
||||
'id' => 'et_remaining',
|
||||
'label' => "{{$periodLabel}} 접대비 잔여한도",
|
||||
'amount' => (int) $remainingLimit,
|
||||
'id' => 'et_high_amount',
|
||||
'label' => '고액 결제',
|
||||
'amount' => (int) $highAmount['total'],
|
||||
'subLabel' => "{$highAmount['count']}건",
|
||||
],
|
||||
[
|
||||
'id' => 'et_used',
|
||||
'label' => "{{$periodLabel}} 접대비 사용금액",
|
||||
'amount' => (int) $usedAmount,
|
||||
'id' => 'et_no_receipt',
|
||||
'label' => '증빙 미비',
|
||||
'amount' => (int) $missingReceipt['total'],
|
||||
'subLabel' => "{$missingReceipt['count']}건",
|
||||
],
|
||||
];
|
||||
|
||||
// 체크포인트 생성
|
||||
$checkPoints = $this->generateCheckPoints(
|
||||
$periodLabel,
|
||||
$periodLimit,
|
||||
$usedAmount,
|
||||
$remainingLimit,
|
||||
$tenantId,
|
||||
$startDate,
|
||||
$endDate
|
||||
$checkPoints = $this->generateRiskCheckPoints(
|
||||
$weekendLateNight,
|
||||
$prohibitedBiz,
|
||||
$highAmount,
|
||||
$missingReceipt
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -116,65 +107,83 @@ public function getSummary(
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 매출액 조회
|
||||
* 주말/심야 사용 리스크 조회
|
||||
* expense_date가 주말(토/일) OR barobill join으로 use_time 22~06시
|
||||
*/
|
||||
private function getAnnualSales(int $tenantId, string $startDate, string $endDate): float
|
||||
private function getWeekendLateNightRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
// orders 테이블에서 확정된 수주 합계 조회
|
||||
$amount = DB::table('orders')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status_code', 'confirmed')
|
||||
->whereBetween('received_at', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->sum('total_amount');
|
||||
|
||||
return $amount ?: 30530000000; // 임시 기본값 (305억)
|
||||
}
|
||||
|
||||
/**
|
||||
* 접대비 한도 계산
|
||||
*/
|
||||
private function calculateLimit(float $annualSales, string $companyType): float
|
||||
{
|
||||
// 기본 한도 (기업 규모별)
|
||||
$baseLimit = self::COMPANY_TYPE_LIMITS[$companyType] ?? self::COMPANY_TYPE_LIMITS['medium'];
|
||||
|
||||
// 매출 기반 한도 (0.3%)
|
||||
$salesBasedLimit = $annualSales * self::DEFAULT_LIMIT_RATE;
|
||||
|
||||
// 기본 한도 + 매출 기반 한도 (실제 세법은 더 복잡하지만 간소화)
|
||||
return $baseLimit + $salesBasedLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 접대비 사용액 조회
|
||||
*/
|
||||
private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float
|
||||
{
|
||||
// TODO: 실제 접대비 계정과목에서 조회
|
||||
// expense_accounts 또는 card_transactions에서 접대비 항목 합계
|
||||
$amount = DB::table('expense_accounts')
|
||||
// 주말 사용 (토요일=7, 일요일=1 in MySQL DAYOFWEEK)
|
||||
$weekendResult = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'entertainment')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->sum('amount');
|
||||
->whereRaw('DAYOFWEEK(expense_date) IN (1, 7)')
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
return $amount ?: 10000000; // 임시 기본값
|
||||
// 심야 사용 (barobill 카드 거래 내역에서 시간 확인)
|
||||
$lateNightResult = DB::table('expense_accounts as ea')
|
||||
->leftJoin('barobill_card_transactions as bct', function ($join) {
|
||||
$join->on('ea.receipt_no', '=', 'bct.approval_num')
|
||||
->on('ea.tenant_id', '=', 'bct.tenant_id');
|
||||
})
|
||||
->where('ea.tenant_id', $tenantId)
|
||||
->where('ea.account_type', 'entertainment')
|
||||
->whereBetween('ea.expense_date', [$startDate, $endDate])
|
||||
->whereNull('ea.deleted_at')
|
||||
->whereRaw('DAYOFWEEK(ea.expense_date) NOT IN (1, 7)') // 주말 제외 (중복 방지)
|
||||
->whereNotNull('bct.use_time')
|
||||
->where(function ($q) {
|
||||
$q->whereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) >= ?', [self::LATE_NIGHT_START])
|
||||
->orWhereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) < ?', [self::LATE_NIGHT_END]);
|
||||
})
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total')
|
||||
->first();
|
||||
|
||||
$totalCount = ($weekendResult->count ?? 0) + ($lateNightResult->count ?? 0);
|
||||
$totalAmount = ($weekendResult->total ?? 0) + ($lateNightResult->total ?? 0);
|
||||
|
||||
return ['count' => $totalCount, 'total' => $totalAmount];
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처 누락 건수 조회
|
||||
* 기피업종 사용 리스크 조회
|
||||
* barobill의 merchant_biz_type가 MCC 코드 매칭
|
||||
*/
|
||||
private function getMissingVendorCount(int $tenantId, string $startDate, string $endDate): array
|
||||
private function getProhibitedBizTypeRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
$result = DB::table('expense_accounts as ea')
|
||||
->join('barobill_card_transactions as bct', function ($join) {
|
||||
$join->on('ea.receipt_no', '=', 'bct.approval_num')
|
||||
->on('ea.tenant_id', '=', 'bct.tenant_id');
|
||||
})
|
||||
->where('ea.tenant_id', $tenantId)
|
||||
->where('ea.account_type', 'entertainment')
|
||||
->whereBetween('ea.expense_date', [$startDate, $endDate])
|
||||
->whereNull('ea.deleted_at')
|
||||
->whereIn('bct.merchant_biz_type', self::PROHIBITED_MCC_CODES)
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'count' => $result->count ?? 0,
|
||||
'total' => $result->total ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 고액 결제 리스크 조회
|
||||
* 1회 50만원 초과 결제
|
||||
*/
|
||||
private function getHighAmountRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
// TODO: 거래처 정보 누락 건수 조회
|
||||
$result = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'entertainment')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('vendor_id')
|
||||
->whereNull('deleted_at')
|
||||
->where('amount', '>', self::HIGH_AMOUNT_THRESHOLD)
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
@@ -185,72 +194,436 @@ private function getMissingVendorCount(int $tenantId, string $startDate, string
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크포인트 생성
|
||||
* 증빙 미비 리스크 조회
|
||||
* receipt_no가 NULL 또는 빈 값
|
||||
*/
|
||||
private function generateCheckPoints(
|
||||
string $periodLabel,
|
||||
float $limit,
|
||||
float $used,
|
||||
float $remaining,
|
||||
int $tenantId,
|
||||
string $startDate,
|
||||
string $endDate
|
||||
private function getMissingReceiptRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
$result = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'entertainment')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('receipt_no')
|
||||
->orWhere('receipt_no', '');
|
||||
})
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'count' => $result->count ?? 0,
|
||||
'total' => $result->total ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 접대비 상세 정보 조회 (모달용)
|
||||
*
|
||||
* @param string|null $companyType 법인 유형 (large|medium|small, 기본: medium)
|
||||
* @param int|null $year 연도 (기본: 현재 연도)
|
||||
* @param int|null $quarter 분기 (1-4, 기본: 현재 분기)
|
||||
*/
|
||||
public function getDetail(
|
||||
?string $companyType = 'medium',
|
||||
?int $year = null,
|
||||
?int $quarter = null,
|
||||
?string $startDate = null,
|
||||
?string $endDate = null
|
||||
): array {
|
||||
$tenantId = $this->tenantId();
|
||||
$now = Carbon::now();
|
||||
|
||||
$year = $year ?? $now->year;
|
||||
$companyType = $companyType ?? 'medium';
|
||||
$quarter = $quarter ?? $now->quarter;
|
||||
|
||||
// 연간 기간 범위 (summary, calculation, quarterly, monthly_usage용 - 항상 연간)
|
||||
$annualStartDate = Carbon::create($year, 1, 1)->format('Y-m-d');
|
||||
$annualEndDate = Carbon::create($year, 12, 31)->format('Y-m-d');
|
||||
|
||||
// 거래/리스크 필터 기간 (start_date/end_date 전달 시 사용, 없으면 분기 기본)
|
||||
if ($startDate && $endDate) {
|
||||
$filterStartDate = $startDate;
|
||||
$filterEndDate = $endDate;
|
||||
} else {
|
||||
$filterStartDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d');
|
||||
$filterEndDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d');
|
||||
}
|
||||
|
||||
// 기본한도 계산 (중소기업: 3,600만, 일반법인: 1,200만)
|
||||
$baseLimit = $companyType === 'large' ? 12000000 : 36000000;
|
||||
|
||||
// 수입금액 조회 (sales 테이블)
|
||||
$revenue = $this->getAnnualRevenue($tenantId, $year);
|
||||
|
||||
// 수입금액별 추가한도 계산
|
||||
$revenueAdditional = $this->calculateRevenueAdditionalLimit($revenue);
|
||||
|
||||
// 연간 총 한도
|
||||
$annualLimit = $baseLimit + $revenueAdditional;
|
||||
$quarterlyLimit = $annualLimit / 4;
|
||||
|
||||
// 연간/분기 사용액 조회
|
||||
$annualUsed = $this->getUsedAmount($tenantId, $annualStartDate, $annualEndDate);
|
||||
$quarterlyUsed = $this->getUsedAmount($tenantId, $filterStartDate, $filterEndDate);
|
||||
|
||||
// 잔여/초과 계산
|
||||
$annualRemaining = max(0, $annualLimit - $annualUsed);
|
||||
$annualExceeded = max(0, $annualUsed - $annualLimit);
|
||||
|
||||
// 1. 요약 데이터
|
||||
$summary = [
|
||||
'annual_limit' => (int) $annualLimit,
|
||||
'annual_remaining' => (int) $annualRemaining,
|
||||
'annual_used' => (int) $annualUsed,
|
||||
'annual_exceeded' => (int) $annualExceeded,
|
||||
];
|
||||
|
||||
// 2. 리스크 검토 카드 (날짜 필터 적용)
|
||||
$weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $filterStartDate, $filterEndDate);
|
||||
$prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $filterStartDate, $filterEndDate);
|
||||
$highAmount = $this->getHighAmountRisk($tenantId, $filterStartDate, $filterEndDate);
|
||||
$missingReceipt = $this->getMissingReceiptRisk($tenantId, $filterStartDate, $filterEndDate);
|
||||
|
||||
$riskReview = [
|
||||
['label' => '주말/심야', 'amount' => (int) $weekendLateNight['total'], 'count' => $weekendLateNight['count']],
|
||||
['label' => '기피업종', 'amount' => (int) $prohibitedBiz['total'], 'count' => $prohibitedBiz['count']],
|
||||
['label' => '고액 결제', 'amount' => (int) $highAmount['total'], 'count' => $highAmount['count']],
|
||||
['label' => '증빙 미비', 'amount' => (int) $missingReceipt['total'], 'count' => $missingReceipt['count']],
|
||||
];
|
||||
|
||||
// 3. 월별 사용 추이
|
||||
$monthlyUsage = $this->getMonthlyUsageTrend($tenantId, $year);
|
||||
|
||||
// 4. 사용자별 분포 (날짜 필터 적용)
|
||||
$userDistribution = $this->getUserDistribution($tenantId, $filterStartDate, $filterEndDate);
|
||||
|
||||
// 5. 거래 내역 (날짜 필터 적용)
|
||||
$transactions = $this->getTransactions($tenantId, $filterStartDate, $filterEndDate);
|
||||
|
||||
// 6. 손금한도 계산 정보
|
||||
$calculation = [
|
||||
'company_type' => $companyType,
|
||||
'base_limit' => (int) $baseLimit,
|
||||
'revenue' => (int) $revenue,
|
||||
'revenue_additional' => (int) $revenueAdditional,
|
||||
'annual_limit' => (int) $annualLimit,
|
||||
];
|
||||
|
||||
// 7. 분기별 현황
|
||||
$quarterly = $this->getQuarterlyStatus($tenantId, $year, $quarterlyLimit);
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'risk_review' => $riskReview,
|
||||
'monthly_usage' => $monthlyUsage,
|
||||
'user_distribution' => $userDistribution,
|
||||
'transactions' => $transactions,
|
||||
'calculation' => $calculation,
|
||||
'quarterly' => $quarterly,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 접대비 사용액 조회
|
||||
*/
|
||||
private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float
|
||||
{
|
||||
return DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'entertainment')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->sum('amount') ?: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 수입금액(매출) 조회
|
||||
*/
|
||||
private function getAnnualRevenue(int $tenantId, int $year): float
|
||||
{
|
||||
return DB::table('sales')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereYear('sale_date', $year)
|
||||
->whereNull('deleted_at')
|
||||
->sum('total_amount') ?: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수입금액별 추가한도 계산 (세법 기준)
|
||||
* 100억 이하: 수입금액 × 0.2%
|
||||
* 100억 초과 ~ 500억 이하: 2,000만 + (수입금액 - 100억) × 0.1%
|
||||
* 500억 초과: 6,000만 + (수입금액 - 500억) × 0.03%
|
||||
*/
|
||||
private function calculateRevenueAdditionalLimit(float $revenue): float
|
||||
{
|
||||
$b10 = 10000000000; // 100억
|
||||
$b50 = 50000000000; // 500억
|
||||
|
||||
if ($revenue <= $b10) {
|
||||
return $revenue * 0.002;
|
||||
} elseif ($revenue <= $b50) {
|
||||
return 20000000 + ($revenue - $b10) * 0.001;
|
||||
} else {
|
||||
return 60000000 + ($revenue - $b50) * 0.0003;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 사용 추이 조회
|
||||
*/
|
||||
private function getMonthlyUsageTrend(int $tenantId, int $year): array
|
||||
{
|
||||
$monthlyData = DB::table('expense_accounts')
|
||||
->select(DB::raw('MONTH(expense_date) as month'), DB::raw('SUM(amount) as amount'))
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'entertainment')
|
||||
->whereYear('expense_date', $year)
|
||||
->whereNull('deleted_at')
|
||||
->groupBy(DB::raw('MONTH(expense_date)'))
|
||||
->orderBy('month')
|
||||
->get();
|
||||
|
||||
$result = [];
|
||||
for ($i = 1; $i <= 12; $i++) {
|
||||
$found = $monthlyData->firstWhere('month', $i);
|
||||
$result[] = [
|
||||
'month' => $i,
|
||||
'label' => $i . '월',
|
||||
'amount' => $found ? (int) $found->amount : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자별 분포 조회
|
||||
*/
|
||||
private function getUserDistribution(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
$colors = ['#60A5FA', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#FB923C'];
|
||||
|
||||
$distribution = DB::table('expense_accounts as ea')
|
||||
->leftJoin('users as u', 'ea.created_by', '=', 'u.id')
|
||||
->select('u.name as user_name', DB::raw('SUM(ea.amount) as amount'))
|
||||
->where('ea.tenant_id', $tenantId)
|
||||
->where('ea.account_type', 'entertainment')
|
||||
->whereBetween('ea.expense_date', [$startDate, $endDate])
|
||||
->whereNull('ea.deleted_at')
|
||||
->groupBy('ea.created_by', 'u.name')
|
||||
->orderByDesc('amount')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
$total = $distribution->sum('amount');
|
||||
$result = [];
|
||||
$idx = 0;
|
||||
|
||||
foreach ($distribution as $item) {
|
||||
$result[] = [
|
||||
'user_name' => $item->user_name ?? '사용자',
|
||||
'amount' => (int) $item->amount,
|
||||
'percentage' => $total > 0 ? round(($item->amount / $total) * 100, 1) : 0,
|
||||
'color' => $colors[$idx % count($colors)],
|
||||
];
|
||||
$idx++;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 내역 조회
|
||||
*/
|
||||
private function getTransactions(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
$transactions = DB::table('expense_accounts as ea')
|
||||
->leftJoin('users as u', 'ea.created_by', '=', 'u.id')
|
||||
->leftJoin('barobill_card_transactions as bct', function ($join) {
|
||||
$join->on('ea.receipt_no', '=', 'bct.approval_num')
|
||||
->on('ea.tenant_id', '=', 'bct.tenant_id');
|
||||
})
|
||||
->select([
|
||||
'ea.id',
|
||||
'ea.card_no',
|
||||
'u.name as user_name',
|
||||
'ea.expense_date',
|
||||
'ea.vendor_name',
|
||||
'ea.amount',
|
||||
'ea.receipt_no',
|
||||
'bct.use_time',
|
||||
'bct.merchant_biz_type',
|
||||
])
|
||||
->where('ea.tenant_id', $tenantId)
|
||||
->where('ea.account_type', 'entertainment')
|
||||
->whereBetween('ea.expense_date', [$startDate, $endDate])
|
||||
->whereNull('ea.deleted_at')
|
||||
->orderByDesc('ea.expense_date')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
$result = [];
|
||||
foreach ($transactions as $t) {
|
||||
$riskType = $this->detectTransactionRiskType($t);
|
||||
|
||||
$result[] = [
|
||||
'id' => $t->id,
|
||||
'card_name' => $t->card_no ? '카드 *' . substr($t->card_no, -4) : '카드명',
|
||||
'user_name' => $t->user_name ?? '사용자',
|
||||
'expense_date' => Carbon::parse($t->expense_date)->format('Y-m-d H:i'),
|
||||
'vendor_name' => $t->vendor_name ?? '가맹점명',
|
||||
'amount' => (int) $t->amount,
|
||||
'risk_type' => $riskType,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 건별 리스크 유형 감지
|
||||
*/
|
||||
private function detectTransactionRiskType(object $transaction): string
|
||||
{
|
||||
// 기피업종
|
||||
if ($transaction->merchant_biz_type && in_array($transaction->merchant_biz_type, self::PROHIBITED_MCC_CODES)) {
|
||||
return '기피업종';
|
||||
}
|
||||
|
||||
// 고액 결제
|
||||
if ($transaction->amount > self::HIGH_AMOUNT_THRESHOLD) {
|
||||
return '고액 결제';
|
||||
}
|
||||
|
||||
// 증빙 미비
|
||||
if (empty($transaction->receipt_no)) {
|
||||
return '증빙 미비';
|
||||
}
|
||||
|
||||
// 주말/심야 감지
|
||||
$expenseDate = Carbon::parse($transaction->expense_date);
|
||||
if ($expenseDate->isWeekend()) {
|
||||
return '주말/심야';
|
||||
}
|
||||
if ($transaction->use_time) {
|
||||
$hour = (int) substr($transaction->use_time, 0, 2);
|
||||
if ($hour >= self::LATE_NIGHT_START || $hour < self::LATE_NIGHT_END) {
|
||||
return '주말/심야';
|
||||
}
|
||||
}
|
||||
|
||||
return '정상';
|
||||
}
|
||||
|
||||
/**
|
||||
* 분기별 현황 조회
|
||||
*/
|
||||
private function getQuarterlyStatus(int $tenantId, int $year, float $quarterlyLimit): array
|
||||
{
|
||||
$result = [];
|
||||
$previousRemaining = 0;
|
||||
|
||||
for ($q = 1; $q <= 4; $q++) {
|
||||
$startDate = Carbon::create($year, ($q - 1) * 3 + 1, 1)->format('Y-m-d');
|
||||
$endDate = Carbon::create($year, $q * 3, 1)->endOfMonth()->format('Y-m-d');
|
||||
|
||||
$used = $this->getUsedAmount($tenantId, $startDate, $endDate);
|
||||
$carryover = $previousRemaining > 0 ? $previousRemaining : 0;
|
||||
$totalLimit = $quarterlyLimit + $carryover;
|
||||
$remaining = max(0, $totalLimit - $used);
|
||||
$exceeded = max(0, $used - $totalLimit);
|
||||
|
||||
$result[] = [
|
||||
'quarter' => $q,
|
||||
'limit' => (int) $quarterlyLimit,
|
||||
'carryover' => (int) $carryover,
|
||||
'used' => (int) $used,
|
||||
'remaining' => (int) $remaining,
|
||||
'exceeded' => (int) $exceeded,
|
||||
];
|
||||
|
||||
$previousRemaining = $remaining;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스크 감지 체크포인트 생성
|
||||
*/
|
||||
private function generateRiskCheckPoints(
|
||||
array $weekendLateNight,
|
||||
array $prohibitedBiz,
|
||||
array $highAmount,
|
||||
array $missingReceipt
|
||||
): array {
|
||||
$checkPoints = [];
|
||||
$usageRate = $limit > 0 ? ($used / $limit) * 100 : 0;
|
||||
$usedFormatted = number_format($used / 10000);
|
||||
$limitFormatted = number_format($limit / 10000);
|
||||
$remainingFormatted = number_format($remaining / 10000);
|
||||
$totalRiskCount = $weekendLateNight['count'] + $prohibitedBiz['count']
|
||||
+ $highAmount['count'] + $missingReceipt['count'];
|
||||
|
||||
// 사용률에 따른 체크포인트
|
||||
if ($usageRate <= 75) {
|
||||
// 정상 운영
|
||||
$remainingRate = round(100 - $usageRate);
|
||||
// 주말/심야
|
||||
if ($weekendLateNight['count'] > 0) {
|
||||
$amountFormatted = number_format($weekendLateNight['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_normal',
|
||||
'type' => 'success',
|
||||
'message' => "{$periodLabel} 접대비 사용 {$usedFormatted}만원 / 한도 {$limitFormatted}만원 ({$remainingRate}%). 여유 있게 운영 중입니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$usedFormatted}만원", 'color' => 'green'],
|
||||
['text' => "{$limitFormatted}만원 ({$remainingRate}%)", 'color' => 'green'],
|
||||
],
|
||||
];
|
||||
} elseif ($usageRate <= 100) {
|
||||
// 주의 (85% 이상)
|
||||
$usageRateRounded = round($usageRate);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_warning',
|
||||
'id' => 'et_cp_weekend',
|
||||
'type' => 'warning',
|
||||
'message' => "접대비 한도 {$usageRateRounded}% 도달. 잔여 한도 {$remainingFormatted}만원입니다. 사용 계획을 점검해 주세요.",
|
||||
'message' => "주말/심야 사용 {$weekendLateNight['count']}건({$amountFormatted}만원) 감지. 업무관련성 소명자료로 증빙해주세요.",
|
||||
'highlights' => [
|
||||
['text' => "잔여 한도 {$remainingFormatted}만원", 'color' => 'orange'],
|
||||
],
|
||||
];
|
||||
} else {
|
||||
// 한도 초과
|
||||
$overAmount = $used - $limit;
|
||||
$overFormatted = number_format($overAmount / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_over',
|
||||
'type' => 'error',
|
||||
'message' => "접대비 한도 초과 {$overFormatted}만원 발생. 초과분은 손금불산입되어 법인세 부담이 증가합니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$overFormatted}만원 발생", 'color' => 'red'],
|
||||
['text' => "{$weekendLateNight['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 거래처 정보 누락 체크
|
||||
$missingVendor = $this->getMissingVendorCount($tenantId, $startDate, $endDate);
|
||||
if ($missingVendor['count'] > 0) {
|
||||
$missingTotal = number_format($missingVendor['total'] / 10000);
|
||||
// 기피업종
|
||||
if ($prohibitedBiz['count'] > 0) {
|
||||
$amountFormatted = number_format($prohibitedBiz['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_missing',
|
||||
'id' => 'et_cp_prohibited',
|
||||
'type' => 'error',
|
||||
'message' => "접대비 사용 중 {$missingVendor['count']}건({$missingTotal}만원)의 거래처 정보가 누락되었습니다. 기록을 보완해 주세요.",
|
||||
'message' => "기피업종 사용 {$prohibitedBiz['count']}건({$amountFormatted}만원) 감지. 유흥업종 결제는 접대비 불인정 사유입니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$missingVendor['count']}건({$missingTotal}만원)", 'color' => 'red'],
|
||||
['text' => '거래처 정보가 누락', 'color' => 'red'],
|
||||
['text' => "{$prohibitedBiz['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
||||
['text' => '접대비 불인정', 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 고액 결제
|
||||
if ($highAmount['count'] > 0) {
|
||||
$amountFormatted = number_format($highAmount['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_high',
|
||||
'type' => 'warning',
|
||||
'message' => "고액 결제 {$highAmount['count']}건({$amountFormatted}만원) 감지. 1회 50만원 초과 결제입니다. 증빙이 필요합니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$highAmount['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 증빙 미비
|
||||
if ($missingReceipt['count'] > 0) {
|
||||
$amountFormatted = number_format($missingReceipt['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_receipt',
|
||||
'type' => 'error',
|
||||
'message' => "미증빙 {$missingReceipt['count']}건({$amountFormatted}만원) 감지. 증빙이 필요합니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$missingReceipt['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 리스크 0건이면 정상 메시지
|
||||
if ($totalRiskCount === 0) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_normal',
|
||||
'type' => 'success',
|
||||
'message' => '접대비 사용 현황이 정상입니다.',
|
||||
'highlights' => [
|
||||
['text' => '정상', 'color' => 'green'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -304,34 +304,41 @@ public function summary(array $params): array
|
||||
* 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용)
|
||||
*
|
||||
* @param string|null $transactionType 거래유형 필터 (purchase, card, bill, null=전체)
|
||||
* @return array{
|
||||
* summary: array{
|
||||
* total_amount: float,
|
||||
* previous_month_amount: float,
|
||||
* change_rate: float,
|
||||
* remaining_balance: float,
|
||||
* item_count: int
|
||||
* },
|
||||
* monthly_trend: array,
|
||||
* vendor_distribution: array,
|
||||
* items: array,
|
||||
* footer_summary: array
|
||||
* }
|
||||
* @param string|null $startDate 조회 시작일 (null이면 당월 1일)
|
||||
* @param string|null $endDate 조회 종료일 (null이면 당월 말일)
|
||||
* @param string|null $search 검색어 (거래처명, 적요)
|
||||
*/
|
||||
public function dashboardDetail(?string $transactionType = null): array
|
||||
{
|
||||
public function dashboardDetail(
|
||||
?string $transactionType = null,
|
||||
?string $startDate = null,
|
||||
?string $endDate = null,
|
||||
?string $search = null
|
||||
): array {
|
||||
$tenantId = $this->tenantId();
|
||||
$currentMonthStart = now()->startOfMonth()->toDateString();
|
||||
$currentMonthEnd = now()->endOfMonth()->toDateString();
|
||||
$previousMonthStart = now()->subMonth()->startOfMonth()->toDateString();
|
||||
$previousMonthEnd = now()->subMonth()->endOfMonth()->toDateString();
|
||||
|
||||
// 기본 쿼리 빌더 (transaction_type 필터 적용)
|
||||
$baseQuery = function () use ($tenantId, $transactionType) {
|
||||
// 날짜 범위: 파라미터 우선, 없으면 당월 기본값
|
||||
$currentMonthStart = $startDate ?? now()->startOfMonth()->toDateString();
|
||||
$currentMonthEnd = $endDate ?? now()->endOfMonth()->toDateString();
|
||||
|
||||
// 전월 대비: 조회 기간과 동일한 길이의 이전 기간 계산
|
||||
$startCarbon = \Carbon\Carbon::parse($currentMonthStart);
|
||||
$endCarbon = \Carbon\Carbon::parse($currentMonthEnd);
|
||||
$daysDiff = $startCarbon->diffInDays($endCarbon) + 1;
|
||||
$previousMonthStart = $startCarbon->copy()->subDays($daysDiff)->toDateString();
|
||||
$previousMonthEnd = $startCarbon->copy()->subDay()->toDateString();
|
||||
|
||||
// 기본 쿼리 빌더 (transaction_type + search 필터 적용)
|
||||
$baseQuery = function () use ($tenantId, $transactionType, $search) {
|
||||
$query = ExpectedExpense::query()->where('tenant_id', $tenantId);
|
||||
if ($transactionType) {
|
||||
$query->where('transaction_type', $transactionType);
|
||||
}
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('client_name', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
};
|
||||
@@ -361,10 +368,10 @@ public function dashboardDetail(?string $transactionType = null): array
|
||||
// 2. 월별 추이 (최근 7개월)
|
||||
$monthlyTrend = $this->getMonthlyTrend($tenantId, $transactionType);
|
||||
|
||||
// 3. 거래처별 분포 (당월, 상위 5개)
|
||||
// 3. 거래처별 분포 (조회 기간, 상위 5개)
|
||||
$vendorDistribution = $this->getVendorDistribution($tenantId, $transactionType, $currentMonthStart, $currentMonthEnd);
|
||||
|
||||
// 4. 지출예상 목록 (당월, 지급일 순)
|
||||
// 4. 지출예상 목록 (조회 기간, 지급일 순)
|
||||
$itemsQuery = ExpectedExpense::query()
|
||||
->select([
|
||||
'expected_expenses.id',
|
||||
@@ -385,6 +392,13 @@ public function dashboardDetail(?string $transactionType = null): array
|
||||
$itemsQuery->where('expected_expenses.transaction_type', $transactionType);
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$itemsQuery->where(function ($q) use ($search) {
|
||||
$q->where('expected_expenses.client_name', 'like', "%{$search}%")
|
||||
->orWhere('expected_expenses.description', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$items = $itemsQuery
|
||||
->orderBy('expected_expenses.expected_payment_date', 'asc')
|
||||
->get()
|
||||
|
||||
576
app/Services/GeneralJournalEntryService.php
Normal file
576
app/Services/GeneralJournalEntryService.php
Normal file
@@ -0,0 +1,576 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenants\AccountCode;
|
||||
use App\Models\Tenants\JournalEntry;
|
||||
use App\Models\Tenants\JournalEntryLine;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class GeneralJournalEntryService extends Service
|
||||
{
|
||||
/**
|
||||
* 일반전표입력 통합 목록 (입금 + 출금 + 수기전표)
|
||||
* deposits/withdrawals는 계좌이체 건만, LEFT JOIN journal_entries로 분개 여부 표시
|
||||
*/
|
||||
public function index(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$startDate = $params['start_date'] ?? null;
|
||||
$endDate = $params['end_date'] ?? null;
|
||||
$search = $params['search'] ?? null;
|
||||
$perPage = (int) ($params['per_page'] ?? 20);
|
||||
$page = (int) ($params['page'] ?? 1);
|
||||
|
||||
// 1) 입금(transfer) UNION 출금(transfer) UNION 수기전표
|
||||
$depositsQuery = DB::table('deposits')
|
||||
->leftJoin('journal_entries', function ($join) use ($tenantId) {
|
||||
$join->on('journal_entries.source_key', '=', DB::raw("CONCAT('deposit_', deposits.id)"))
|
||||
->where('journal_entries.tenant_id', $tenantId)
|
||||
->where('journal_entries.source_type', JournalEntry::SOURCE_BANK_TRANSACTION)
|
||||
->whereNull('journal_entries.deleted_at');
|
||||
})
|
||||
->where('deposits.tenant_id', $tenantId)
|
||||
->where('deposits.payment_method', 'transfer')
|
||||
->whereNull('deposits.deleted_at')
|
||||
->select([
|
||||
'deposits.id',
|
||||
'deposits.deposit_date as date',
|
||||
DB::raw("'deposit' as division"),
|
||||
'deposits.amount',
|
||||
'deposits.description',
|
||||
DB::raw('COALESCE(journal_entries.description, NULL) as journal_description'),
|
||||
'deposits.amount as deposit_amount',
|
||||
DB::raw('0 as withdrawal_amount'),
|
||||
DB::raw('COALESCE(journal_entries.total_debit, 0) as debit_amount'),
|
||||
DB::raw('COALESCE(journal_entries.total_credit, 0) as credit_amount'),
|
||||
DB::raw("'linked' as source"),
|
||||
'deposits.created_at',
|
||||
'deposits.updated_at',
|
||||
DB::raw('journal_entries.id as journal_entry_id'),
|
||||
]);
|
||||
|
||||
$withdrawalsQuery = DB::table('withdrawals')
|
||||
->leftJoin('journal_entries', function ($join) use ($tenantId) {
|
||||
$join->on('journal_entries.source_key', '=', DB::raw("CONCAT('withdrawal_', withdrawals.id)"))
|
||||
->where('journal_entries.tenant_id', $tenantId)
|
||||
->where('journal_entries.source_type', JournalEntry::SOURCE_BANK_TRANSACTION)
|
||||
->whereNull('journal_entries.deleted_at');
|
||||
})
|
||||
->where('withdrawals.tenant_id', $tenantId)
|
||||
->where('withdrawals.payment_method', 'transfer')
|
||||
->whereNull('withdrawals.deleted_at')
|
||||
->select([
|
||||
'withdrawals.id',
|
||||
'withdrawals.withdrawal_date as date',
|
||||
DB::raw("'withdrawal' as division"),
|
||||
'withdrawals.amount',
|
||||
'withdrawals.description',
|
||||
DB::raw('COALESCE(journal_entries.description, NULL) as journal_description'),
|
||||
DB::raw('0 as deposit_amount'),
|
||||
'withdrawals.amount as withdrawal_amount',
|
||||
DB::raw('COALESCE(journal_entries.total_debit, 0) as debit_amount'),
|
||||
DB::raw('COALESCE(journal_entries.total_credit, 0) as credit_amount'),
|
||||
DB::raw("'linked' as source"),
|
||||
'withdrawals.created_at',
|
||||
'withdrawals.updated_at',
|
||||
DB::raw('journal_entries.id as journal_entry_id'),
|
||||
]);
|
||||
|
||||
$manualQuery = DB::table('journal_entries')
|
||||
->where('journal_entries.tenant_id', $tenantId)
|
||||
->where('journal_entries.source_type', JournalEntry::SOURCE_MANUAL)
|
||||
->whereNull('journal_entries.deleted_at')
|
||||
->select([
|
||||
'journal_entries.id',
|
||||
'journal_entries.entry_date as date',
|
||||
DB::raw("'transfer' as division"),
|
||||
'journal_entries.total_debit as amount',
|
||||
'journal_entries.description',
|
||||
'journal_entries.description as journal_description',
|
||||
DB::raw('0 as deposit_amount'),
|
||||
DB::raw('0 as withdrawal_amount'),
|
||||
'journal_entries.total_debit as debit_amount',
|
||||
'journal_entries.total_credit as credit_amount',
|
||||
DB::raw("'manual' as source"),
|
||||
'journal_entries.created_at',
|
||||
'journal_entries.updated_at',
|
||||
DB::raw('journal_entries.id as journal_entry_id'),
|
||||
]);
|
||||
|
||||
// 날짜 필터
|
||||
if ($startDate) {
|
||||
$depositsQuery->where('deposits.deposit_date', '>=', $startDate);
|
||||
$withdrawalsQuery->where('withdrawals.withdrawal_date', '>=', $startDate);
|
||||
$manualQuery->where('journal_entries.entry_date', '>=', $startDate);
|
||||
}
|
||||
if ($endDate) {
|
||||
$depositsQuery->where('deposits.deposit_date', '<=', $endDate);
|
||||
$withdrawalsQuery->where('withdrawals.withdrawal_date', '<=', $endDate);
|
||||
$manualQuery->where('journal_entries.entry_date', '<=', $endDate);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if ($search) {
|
||||
$depositsQuery->where(function ($q) use ($search) {
|
||||
$q->where('deposits.description', 'like', "%{$search}%")
|
||||
->orWhere('deposits.client_name', 'like', "%{$search}%");
|
||||
});
|
||||
$withdrawalsQuery->where(function ($q) use ($search) {
|
||||
$q->where('withdrawals.description', 'like', "%{$search}%")
|
||||
->orWhere('withdrawals.client_name', 'like', "%{$search}%");
|
||||
});
|
||||
$manualQuery->where('journal_entries.description', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
// UNION
|
||||
$unionQuery = $depositsQuery
|
||||
->unionAll($withdrawalsQuery)
|
||||
->unionAll($manualQuery);
|
||||
|
||||
// 전체 건수
|
||||
$totalCount = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table"))
|
||||
->mergeBindings($unionQuery)
|
||||
->count();
|
||||
|
||||
// 날짜순 정렬 + 페이지네이션
|
||||
$items = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table"))
|
||||
->mergeBindings($unionQuery)
|
||||
->orderBy('date', 'desc')
|
||||
->orderBy('created_at', 'desc')
|
||||
->offset(($page - 1) * $perPage)
|
||||
->limit($perPage)
|
||||
->get();
|
||||
|
||||
// 누적잔액 계산 (해당 기간 전체 기준)
|
||||
$allForBalance = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table"))
|
||||
->mergeBindings($unionQuery)
|
||||
->orderBy('date', 'asc')
|
||||
->orderBy('created_at', 'asc')
|
||||
->get(['deposit_amount', 'withdrawal_amount']);
|
||||
|
||||
$runningBalance = 0;
|
||||
$balanceMap = [];
|
||||
foreach ($allForBalance as $idx => $row) {
|
||||
$runningBalance += (int) $row->deposit_amount - (int) $row->withdrawal_amount;
|
||||
$balanceMap[$idx] = $runningBalance;
|
||||
}
|
||||
|
||||
// 역순이므로 현재 페이지에 해당하는 잔액을 매핑
|
||||
$totalItems = count($allForBalance);
|
||||
$items = $items->map(function ($item, $index) use ($balanceMap, $totalItems, $page, $perPage) {
|
||||
// 역순 인덱스 → 정순 인덱스
|
||||
$reverseIdx = $totalItems - 1 - (($page - 1) * $perPage + $index);
|
||||
$item->balance = $reverseIdx >= 0 ? ($balanceMap[$reverseIdx] ?? 0) : 0;
|
||||
|
||||
return $item;
|
||||
});
|
||||
|
||||
return [
|
||||
'data' => $items->toArray(),
|
||||
'current_page' => $page,
|
||||
'last_page' => (int) ceil($totalCount / $perPage),
|
||||
'per_page' => $perPage,
|
||||
'total' => $totalCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계
|
||||
*/
|
||||
public function summary(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$startDate = $params['start_date'] ?? null;
|
||||
$endDate = $params['end_date'] ?? null;
|
||||
$search = $params['search'] ?? null;
|
||||
|
||||
// 입금 통계
|
||||
$depositQuery = DB::table('deposits')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('payment_method', 'transfer')
|
||||
->whereNull('deleted_at');
|
||||
|
||||
// 출금 통계
|
||||
$withdrawalQuery = DB::table('withdrawals')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('payment_method', 'transfer')
|
||||
->whereNull('deleted_at');
|
||||
|
||||
if ($startDate) {
|
||||
$depositQuery->where('deposit_date', '>=', $startDate);
|
||||
$withdrawalQuery->where('withdrawal_date', '>=', $startDate);
|
||||
}
|
||||
if ($endDate) {
|
||||
$depositQuery->where('deposit_date', '<=', $endDate);
|
||||
$withdrawalQuery->where('withdrawal_date', '<=', $endDate);
|
||||
}
|
||||
if ($search) {
|
||||
$depositQuery->where(function ($q) use ($search) {
|
||||
$q->where('description', 'like', "%{$search}%")
|
||||
->orWhere('client_name', 'like', "%{$search}%");
|
||||
});
|
||||
$withdrawalQuery->where(function ($q) use ($search) {
|
||||
$q->where('description', 'like', "%{$search}%")
|
||||
->orWhere('client_name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$depositCount = (clone $depositQuery)->count();
|
||||
$depositAmount = (int) (clone $depositQuery)->sum('amount');
|
||||
$withdrawalCount = (clone $withdrawalQuery)->count();
|
||||
$withdrawalAmount = (int) (clone $withdrawalQuery)->sum('amount');
|
||||
|
||||
// 분개 완료/미완료 건수 (journal_entries가 연결된 입출금 수)
|
||||
$journalCompleteCount = DB::table('journal_entries')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('source_type', JournalEntry::SOURCE_BANK_TRANSACTION)
|
||||
->whereNull('deleted_at')
|
||||
->when($startDate, fn ($q) => $q->where('entry_date', '>=', $startDate))
|
||||
->when($endDate, fn ($q) => $q->where('entry_date', '<=', $endDate))
|
||||
->count();
|
||||
|
||||
$totalCount = $depositCount + $withdrawalCount;
|
||||
$journalIncompleteCount = max(0, $totalCount - $journalCompleteCount);
|
||||
|
||||
return [
|
||||
'total_count' => $totalCount,
|
||||
'deposit_count' => $depositCount,
|
||||
'deposit_amount' => $depositAmount,
|
||||
'withdrawal_count' => $withdrawalCount,
|
||||
'withdrawal_amount' => $withdrawalAmount,
|
||||
'journal_complete_count' => $journalCompleteCount,
|
||||
'journal_incomplete_count' => $journalIncompleteCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표 상세 조회 (분개 수정 모달용)
|
||||
*/
|
||||
public function show(int $id): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$entry = JournalEntry::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with('lines')
|
||||
->findOrFail($id);
|
||||
|
||||
// source_type에 따라 원본 거래 정보 조회
|
||||
$sourceInfo = $this->getSourceInfo($entry);
|
||||
|
||||
return [
|
||||
'id' => $entry->id,
|
||||
'date' => $entry->entry_date->format('Y-m-d'),
|
||||
'division' => $sourceInfo['division'],
|
||||
'amount' => $sourceInfo['amount'],
|
||||
'description' => $sourceInfo['description'] ?? $entry->description,
|
||||
'bank_name' => $sourceInfo['bank_name'] ?? '',
|
||||
'account_number' => $sourceInfo['account_number'] ?? '',
|
||||
'journal_memo' => $entry->description,
|
||||
'rows' => $entry->lines->map(function ($line) {
|
||||
return [
|
||||
'id' => $line->id,
|
||||
'side' => $line->dc_type,
|
||||
'account_subject_id' => $line->account_code,
|
||||
'account_subject_name' => $line->account_name,
|
||||
'vendor_id' => $line->trading_partner_id,
|
||||
'vendor_name' => $line->trading_partner_name ?? '',
|
||||
'debit_amount' => (int) $line->debit_amount,
|
||||
'credit_amount' => (int) $line->credit_amount,
|
||||
'memo' => $line->description ?? '',
|
||||
];
|
||||
})->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 수기전표 등록
|
||||
*/
|
||||
public function store(array $data): JournalEntry
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId) {
|
||||
// 차대 균형 검증
|
||||
$this->validateDebitCreditBalance($data['rows']);
|
||||
|
||||
// 전표번호 생성
|
||||
$entryNo = $this->generateEntryNo($tenantId, $data['journal_date']);
|
||||
|
||||
// 합계 계산
|
||||
$totalDebit = 0;
|
||||
$totalCredit = 0;
|
||||
foreach ($data['rows'] as $row) {
|
||||
$totalDebit += (int) ($row['debit_amount'] ?? 0);
|
||||
$totalCredit += (int) ($row['credit_amount'] ?? 0);
|
||||
}
|
||||
|
||||
// 전표 생성
|
||||
$entry = new JournalEntry;
|
||||
$entry->tenant_id = $tenantId;
|
||||
$entry->entry_no = $entryNo;
|
||||
$entry->entry_date = $data['journal_date'];
|
||||
$entry->entry_type = JournalEntry::TYPE_GENERAL;
|
||||
$entry->description = $data['description'] ?? null;
|
||||
$entry->total_debit = $totalDebit;
|
||||
$entry->total_credit = $totalCredit;
|
||||
$entry->status = JournalEntry::STATUS_CONFIRMED;
|
||||
$entry->source_type = JournalEntry::SOURCE_MANUAL;
|
||||
$entry->source_key = null;
|
||||
$entry->save();
|
||||
|
||||
// 분개 행 생성
|
||||
$this->createLines($entry, $data['rows'], $tenantId);
|
||||
|
||||
return $entry->load('lines');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 수정 (lines 전체 교체)
|
||||
*/
|
||||
public function updateJournal(int $id, array $data): JournalEntry
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return DB::transaction(function () use ($id, $data, $tenantId) {
|
||||
$entry = JournalEntry::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// 메모 업데이트
|
||||
if (array_key_exists('journal_memo', $data)) {
|
||||
$entry->description = $data['journal_memo'];
|
||||
}
|
||||
|
||||
// rows가 있으면 lines 교체
|
||||
if (isset($data['rows']) && ! empty($data['rows'])) {
|
||||
$this->validateDebitCreditBalance($data['rows']);
|
||||
|
||||
// 기존 lines 삭제
|
||||
JournalEntryLine::query()
|
||||
->where('journal_entry_id', $entry->id)
|
||||
->delete();
|
||||
|
||||
// 새 lines 생성
|
||||
$this->createLines($entry, $data['rows'], $tenantId);
|
||||
|
||||
// 합계 재계산
|
||||
$totalDebit = 0;
|
||||
$totalCredit = 0;
|
||||
foreach ($data['rows'] as $row) {
|
||||
$totalDebit += (int) ($row['debit_amount'] ?? 0);
|
||||
$totalCredit += (int) ($row['credit_amount'] ?? 0);
|
||||
}
|
||||
|
||||
$entry->total_debit = $totalDebit;
|
||||
$entry->total_credit = $totalCredit;
|
||||
}
|
||||
|
||||
$entry->save();
|
||||
|
||||
return $entry->load('lines');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표 삭제 (soft delete, lines는 FK CASCADE)
|
||||
*/
|
||||
public function destroyJournal(int $id): bool
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return DB::transaction(function () use ($id, $tenantId) {
|
||||
$entry = JournalEntry::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// lines 먼저 삭제 (soft delete가 아니므로 물리 삭제)
|
||||
JournalEntryLine::query()
|
||||
->where('journal_entry_id', $entry->id)
|
||||
->delete();
|
||||
|
||||
$entry->delete(); // soft delete
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표번호 생성: JE-YYYYMMDD-NNN (동시성 안전)
|
||||
*/
|
||||
private function generateEntryNo(int $tenantId, string $date): string
|
||||
{
|
||||
$dateStr = str_replace('-', '', substr($date, 0, 10));
|
||||
$prefix = "JE-{$dateStr}-";
|
||||
|
||||
// SELECT ... FOR UPDATE 락으로 동시성 안전 보장
|
||||
$lastEntry = DB::table('journal_entries')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('entry_no', 'like', "{$prefix}%")
|
||||
->lockForUpdate()
|
||||
->orderBy('entry_no', 'desc')
|
||||
->first(['entry_no']);
|
||||
|
||||
if ($lastEntry) {
|
||||
$lastSeq = (int) substr($lastEntry->entry_no, -3);
|
||||
$nextSeq = $lastSeq + 1;
|
||||
} else {
|
||||
$nextSeq = 1;
|
||||
}
|
||||
|
||||
return $prefix . str_pad($nextSeq, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 차대 균형 검증
|
||||
*/
|
||||
private function validateDebitCreditBalance(array $rows): void
|
||||
{
|
||||
$totalDebit = 0;
|
||||
$totalCredit = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$totalDebit += (int) ($row['debit_amount'] ?? 0);
|
||||
$totalCredit += (int) ($row['credit_amount'] ?? 0);
|
||||
}
|
||||
|
||||
if ($totalDebit !== $totalCredit) {
|
||||
throw new BadRequestHttpException(__('error.journal_entry.debit_credit_mismatch'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 행 생성
|
||||
*/
|
||||
private function createLines(JournalEntry $entry, array $rows, int $tenantId): void
|
||||
{
|
||||
foreach ($rows as $index => $row) {
|
||||
$accountCode = $row['account_subject_id'] ?? '';
|
||||
$accountName = $this->resolveAccountName($tenantId, $accountCode);
|
||||
$vendorName = $this->resolveVendorName($row['vendor_id'] ?? null);
|
||||
|
||||
$line = new JournalEntryLine;
|
||||
$line->tenant_id = $tenantId;
|
||||
$line->journal_entry_id = $entry->id;
|
||||
$line->line_no = $index + 1;
|
||||
$line->dc_type = $row['side'];
|
||||
$line->account_code = $accountCode;
|
||||
$line->account_name = $accountName;
|
||||
$line->trading_partner_id = ! empty($row['vendor_id']) ? (int) $row['vendor_id'] : null;
|
||||
$line->trading_partner_name = $vendorName;
|
||||
$line->debit_amount = (int) ($row['debit_amount'] ?? 0);
|
||||
$line->credit_amount = (int) ($row['credit_amount'] ?? 0);
|
||||
$line->description = $row['memo'] ?? null;
|
||||
$line->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 코드 → 이름 조회
|
||||
*/
|
||||
private function resolveAccountName(int $tenantId, string $code): string
|
||||
{
|
||||
if (empty($code)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$account = AccountCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $code)
|
||||
->first(['name']);
|
||||
|
||||
return $account ? $account->name : $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처 ID → 이름 조회
|
||||
*/
|
||||
private function resolveVendorName(?int $vendorId): string
|
||||
{
|
||||
if (! $vendorId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$vendor = DB::table('clients')
|
||||
->where('id', $vendorId)
|
||||
->first(['name']);
|
||||
|
||||
return $vendor ? $vendor->name : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 원본 거래 정보 조회 (입금/출금)
|
||||
*/
|
||||
private function getSourceInfo(JournalEntry $entry): array
|
||||
{
|
||||
if ($entry->source_type === JournalEntry::SOURCE_MANUAL) {
|
||||
return [
|
||||
'division' => 'transfer',
|
||||
'amount' => $entry->total_debit,
|
||||
'description' => $entry->description,
|
||||
'bank_name' => '',
|
||||
'account_number' => '',
|
||||
];
|
||||
}
|
||||
|
||||
// bank_transaction → deposit_123 / withdrawal_456
|
||||
if ($entry->source_key && str_starts_with($entry->source_key, 'deposit_')) {
|
||||
$sourceId = (int) str_replace('deposit_', '', $entry->source_key);
|
||||
$deposit = DB::table('deposits')
|
||||
->leftJoin('bank_accounts', 'deposits.bank_account_id', '=', 'bank_accounts.id')
|
||||
->where('deposits.id', $sourceId)
|
||||
->first([
|
||||
'deposits.amount',
|
||||
'deposits.description',
|
||||
'bank_accounts.bank_name',
|
||||
'bank_accounts.account_number',
|
||||
]);
|
||||
|
||||
if ($deposit) {
|
||||
return [
|
||||
'division' => 'deposit',
|
||||
'amount' => (int) $deposit->amount,
|
||||
'description' => $deposit->description,
|
||||
'bank_name' => $deposit->bank_name ?? '',
|
||||
'account_number' => $deposit->account_number ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($entry->source_key && str_starts_with($entry->source_key, 'withdrawal_')) {
|
||||
$sourceId = (int) str_replace('withdrawal_', '', $entry->source_key);
|
||||
$withdrawal = DB::table('withdrawals')
|
||||
->leftJoin('bank_accounts', 'withdrawals.bank_account_id', '=', 'bank_accounts.id')
|
||||
->where('withdrawals.id', $sourceId)
|
||||
->first([
|
||||
'withdrawals.amount',
|
||||
'withdrawals.description',
|
||||
'bank_accounts.bank_name',
|
||||
'bank_accounts.account_number',
|
||||
]);
|
||||
|
||||
if ($withdrawal) {
|
||||
return [
|
||||
'division' => 'withdrawal',
|
||||
'amount' => (int) $withdrawal->amount,
|
||||
'description' => $withdrawal->description,
|
||||
'bank_name' => $withdrawal->bank_name ?? '',
|
||||
'account_number' => $withdrawal->account_number ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'division' => 'transfer',
|
||||
'amount' => $entry->total_debit,
|
||||
'description' => $entry->description,
|
||||
'bank_name' => '',
|
||||
'account_number' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenants\ExpenseAccount;
|
||||
use App\Models\Tenants\Loan;
|
||||
use App\Models\Tenants\Withdrawal;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@@ -25,6 +26,11 @@ public function index(array $params): LengthAwarePaginator
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['user:id,name,email', 'creator:id,name']);
|
||||
|
||||
// 카테고리 필터
|
||||
if (! empty($params['category'])) {
|
||||
$query->where('category', $params['category']);
|
||||
}
|
||||
|
||||
// 사용자 필터
|
||||
if (! empty($params['user_id'])) {
|
||||
$query->where('user_id', $params['user_id']);
|
||||
@@ -84,7 +90,7 @@ public function show(int $id): Loan
|
||||
/**
|
||||
* 가지급금 요약 (특정 사용자 또는 전체)
|
||||
*/
|
||||
public function summary(?int $userId = null): array
|
||||
public function summary(?int $userId = null, ?string $category = null): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
@@ -95,7 +101,14 @@ public function summary(?int $userId = null): array
|
||||
$query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
$stats = $query->selectRaw('
|
||||
if ($category) {
|
||||
$query->where('category', $category);
|
||||
}
|
||||
|
||||
// 상품권 카테고리: holding/used/disposed 상태별 집계 추가
|
||||
$isGiftCertificate = $category === Loan::CATEGORY_GIFT_CERTIFICATE;
|
||||
|
||||
$selectRaw = '
|
||||
COUNT(*) as total_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as settled_count,
|
||||
@@ -103,10 +116,27 @@ public function summary(?int $userId = null): array
|
||||
SUM(amount) as total_amount,
|
||||
SUM(COALESCE(settlement_amount, 0)) as total_settled,
|
||||
SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding
|
||||
', [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL])
|
||||
->first();
|
||||
';
|
||||
$bindings = [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL];
|
||||
|
||||
return [
|
||||
if ($isGiftCertificate) {
|
||||
$selectRaw .= ',
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as holding_count,
|
||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as holding_amount,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as used_count,
|
||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as used_amount,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as disposed_count
|
||||
';
|
||||
$bindings = array_merge($bindings, [
|
||||
Loan::STATUS_HOLDING, Loan::STATUS_HOLDING,
|
||||
Loan::STATUS_USED, Loan::STATUS_USED,
|
||||
Loan::STATUS_DISPOSED,
|
||||
]);
|
||||
}
|
||||
|
||||
$stats = $query->selectRaw($selectRaw, $bindings)->first();
|
||||
|
||||
$result = [
|
||||
'total_count' => (int) $stats->total_count,
|
||||
'outstanding_count' => (int) $stats->outstanding_count,
|
||||
'settled_count' => (int) $stats->settled_count,
|
||||
@@ -115,6 +145,27 @@ public function summary(?int $userId = null): array
|
||||
'total_settled' => (float) $stats->total_settled,
|
||||
'total_outstanding' => (float) $stats->total_outstanding,
|
||||
];
|
||||
|
||||
if ($isGiftCertificate) {
|
||||
$result['holding_count'] = (int) $stats->holding_count;
|
||||
$result['holding_amount'] = (float) $stats->holding_amount;
|
||||
$result['used_count'] = (int) $stats->used_count;
|
||||
$result['used_amount'] = (float) $stats->used_amount;
|
||||
$result['disposed_count'] = (int) $stats->disposed_count;
|
||||
|
||||
// 접대비 해당 집계 (expense_accounts 테이블에서 조회)
|
||||
$entertainmentStats = ExpenseAccount::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', ExpenseAccount::TYPE_ENTERTAINMENT)
|
||||
->where('sub_type', ExpenseAccount::SUB_TYPE_GIFT_CERTIFICATE)
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as amount')
|
||||
->first();
|
||||
|
||||
$result['entertainment_count'] = (int) ($entertainmentStats->count ?? 0);
|
||||
$result['entertainment_amount'] = (float) ($entertainmentStats->amount ?? 0);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -144,17 +195,34 @@ public function store(array $data): Loan
|
||||
$withdrawalId = $withdrawal->id;
|
||||
}
|
||||
|
||||
return Loan::create([
|
||||
// 상품권: user_id 미지정 시 현재 사용자로 대체
|
||||
$loanUserId = $data['user_id'] ?? $userId;
|
||||
|
||||
// 상태 결정: 상품권은 holding, 그 외는 outstanding
|
||||
$category = $data['category'] ?? null;
|
||||
$status = $data['status']
|
||||
?? ($category === Loan::CATEGORY_GIFT_CERTIFICATE ? Loan::STATUS_HOLDING : Loan::STATUS_OUTSTANDING);
|
||||
|
||||
$loan = Loan::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $data['user_id'],
|
||||
'user_id' => $loanUserId,
|
||||
'loan_date' => $data['loan_date'],
|
||||
'amount' => $data['amount'],
|
||||
'purpose' => $data['purpose'] ?? null,
|
||||
'status' => Loan::STATUS_OUTSTANDING,
|
||||
'status' => $status,
|
||||
'category' => $category,
|
||||
'metadata' => $data['metadata'] ?? null,
|
||||
'withdrawal_id' => $withdrawalId,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
// 상품권 → 접대비 자동 연동
|
||||
if ($category === Loan::CATEGORY_GIFT_CERTIFICATE) {
|
||||
$this->syncGiftCertificateExpense($loan);
|
||||
}
|
||||
|
||||
return $loan;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,20 +254,83 @@ public function update(int $id, array $data): Loan
|
||||
}
|
||||
}
|
||||
|
||||
$loan->fill([
|
||||
$fillData = [
|
||||
'user_id' => $data['user_id'] ?? $loan->user_id,
|
||||
'loan_date' => $data['loan_date'] ?? $loan->loan_date,
|
||||
'amount' => $data['amount'] ?? $loan->amount,
|
||||
'purpose' => $data['purpose'] ?? $loan->purpose,
|
||||
'withdrawal_id' => $data['withdrawal_id'] ?? $loan->withdrawal_id,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
];
|
||||
|
||||
if (isset($data['category'])) {
|
||||
$fillData['category'] = $data['category'];
|
||||
}
|
||||
if (array_key_exists('metadata', $data)) {
|
||||
$fillData['metadata'] = $data['metadata'];
|
||||
}
|
||||
if (isset($data['status'])) {
|
||||
$fillData['status'] = $data['status'];
|
||||
}
|
||||
if (array_key_exists('settlement_date', $data)) {
|
||||
$fillData['settlement_date'] = $data['settlement_date'];
|
||||
}
|
||||
|
||||
$loan->fill($fillData);
|
||||
|
||||
$loan->save();
|
||||
|
||||
// 상품권 → 접대비 자동 연동
|
||||
if ($loan->category === Loan::CATEGORY_GIFT_CERTIFICATE) {
|
||||
$this->syncGiftCertificateExpense($loan);
|
||||
}
|
||||
|
||||
return $loan->fresh(['user:id,name,email', 'creator:id,name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품권 → 접대비 자동 연동
|
||||
*
|
||||
* 상태가 'used' + entertainment_expense='applicable' → expense_accounts에 INSERT
|
||||
* 그 외 → 기존 연결된 expense_accounts 삭제
|
||||
*/
|
||||
private function syncGiftCertificateExpense(Loan $loan): void
|
||||
{
|
||||
$metadata = $loan->metadata ?? [];
|
||||
$isEntertainment = ($loan->status === Loan::STATUS_USED)
|
||||
&& ($metadata['entertainment_expense'] ?? '') === 'applicable';
|
||||
|
||||
if ($isEntertainment) {
|
||||
// upsert: loan_id 기준으로 있으면 업데이트, 없으면 생성
|
||||
ExpenseAccount::query()
|
||||
->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $loan->tenant_id,
|
||||
'loan_id' => $loan->id,
|
||||
],
|
||||
[
|
||||
'account_type' => ExpenseAccount::TYPE_ENTERTAINMENT,
|
||||
'sub_type' => ExpenseAccount::SUB_TYPE_GIFT_CERTIFICATE,
|
||||
'expense_date' => $loan->settlement_date ?? $loan->loan_date,
|
||||
'amount' => $loan->amount,
|
||||
'description' => ($metadata['cert_name'] ?? '상품권') . ' 접대비 전환',
|
||||
'receipt_no' => $metadata['serial_number'] ?? null,
|
||||
'vendor_name' => $metadata['vendor_name'] ?? null,
|
||||
'vendor_id' => ! empty($metadata['vendor_id']) ? (int) $metadata['vendor_id'] : null,
|
||||
'payment_method' => ExpenseAccount::PAYMENT_CASH,
|
||||
'created_by' => $loan->updated_by ?? $loan->created_by,
|
||||
'updated_by' => $loan->updated_by ?? $loan->created_by,
|
||||
]
|
||||
);
|
||||
} else {
|
||||
// 접대비 해당이 아니면 연결된 레코드 삭제
|
||||
ExpenseAccount::query()
|
||||
->where('tenant_id', $loan->tenant_id)
|
||||
->where('loan_id', $loan->id)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 가지급금 삭제
|
||||
*/
|
||||
@@ -216,6 +347,14 @@ public function destroy(int $id): bool
|
||||
throw new BadRequestHttpException(__('error.loan.not_deletable'));
|
||||
}
|
||||
|
||||
// 상품권 연결 접대비 레코드도 삭제
|
||||
if ($loan->category === Loan::CATEGORY_GIFT_CERTIFICATE) {
|
||||
ExpenseAccount::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('loan_id', $loan->id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
$loan->deleted_by = $userId;
|
||||
$loan->save();
|
||||
$loan->delete();
|
||||
@@ -365,7 +504,8 @@ public function calculateInterest(int $year, ?int $userId = null): array
|
||||
/**
|
||||
* 가지급금 대시보드 데이터
|
||||
*
|
||||
* CEO 대시보드 카드/가지급금 관리 섹션(cm2) 모달용 데이터 제공
|
||||
* CEO 대시보드 카드/가지급금 관리 섹션 데이터 제공
|
||||
* D1.7: category_breakdown 추가 (카드/경조사/상품권/접대비 분류)
|
||||
*
|
||||
* @return array{
|
||||
* summary: array{
|
||||
@@ -373,38 +513,79 @@ public function calculateInterest(int $year, ?int $userId = null): array
|
||||
* recognized_interest: float,
|
||||
* outstanding_count: int
|
||||
* },
|
||||
* category_breakdown: array<string, array{
|
||||
* outstanding_amount: float,
|
||||
* total_count: int,
|
||||
* unverified_count: int
|
||||
* }>,
|
||||
* loans: array
|
||||
* }
|
||||
*/
|
||||
public function dashboard(): array
|
||||
public function dashboard(?string $startDate = null, ?string $endDate = null): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$currentYear = now()->year;
|
||||
|
||||
// 1. Summary 데이터
|
||||
$summaryData = $this->summary();
|
||||
// 날짜 필터 조건 클로저
|
||||
$applyDateFilter = function ($query) use ($startDate, $endDate) {
|
||||
if ($startDate) {
|
||||
$query->where('loan_date', '>=', $startDate);
|
||||
}
|
||||
if ($endDate) {
|
||||
$query->where('loan_date', '<=', $endDate);
|
||||
}
|
||||
return $query;
|
||||
};
|
||||
|
||||
// 2. 인정이자 계산 (현재 연도 기준)
|
||||
// 상품권 중 used/disposed 제외 조건 (접대비로 전환됨)
|
||||
$excludeUsedGiftCert = function ($query) {
|
||||
$query->whereNot(function ($q) {
|
||||
$q->where('category', Loan::CATEGORY_GIFT_CERTIFICATE)
|
||||
->whereIn('status', [Loan::STATUS_USED, Loan::STATUS_DISPOSED]);
|
||||
});
|
||||
};
|
||||
|
||||
// 1. Summary 데이터 (날짜 필터 적용)
|
||||
$summaryQuery = Loan::query()->where('tenant_id', $tenantId);
|
||||
$applyDateFilter($summaryQuery);
|
||||
$excludeUsedGiftCert($summaryQuery);
|
||||
|
||||
$stats = $summaryQuery->selectRaw('
|
||||
COUNT(*) as total_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count,
|
||||
SUM(amount) as total_amount,
|
||||
SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding
|
||||
', [Loan::STATUS_OUTSTANDING])
|
||||
->first();
|
||||
|
||||
// 2. 인정이자 계산 (현재 연도 기준, 날짜 필터 무관)
|
||||
$interestData = $this->calculateInterest($currentYear);
|
||||
$recognizedInterest = $interestData['summary']['total_recognized_interest'] ?? 0;
|
||||
|
||||
// 3. 가지급금 목록 (최근 10건, 미정산 우선)
|
||||
$loans = Loan::query()
|
||||
// 3. 카테고리별 집계 (날짜 필터 적용)
|
||||
$categoryBreakdown = $this->getCategoryBreakdown($tenantId, $startDate, $endDate);
|
||||
|
||||
// 4. 가지급금 목록 (미정산 우선, 날짜 필터 적용, used/disposed 상품권 제외)
|
||||
$loansQuery = Loan::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['user:id,name,email', 'withdrawal'])
|
||||
->with(['user:id,name,email', 'withdrawal']);
|
||||
$applyDateFilter($loansQuery);
|
||||
$excludeUsedGiftCert($loansQuery);
|
||||
|
||||
$loans = $loansQuery
|
||||
->orderByRaw('CASE WHEN status = ? THEN 0 WHEN status = ? THEN 1 ELSE 2 END', [
|
||||
Loan::STATUS_OUTSTANDING,
|
||||
Loan::STATUS_PARTIAL,
|
||||
])
|
||||
->orderByDesc('loan_date')
|
||||
->limit(10)
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(function ($loan) {
|
||||
return [
|
||||
'id' => $loan->id,
|
||||
'loan_date' => $loan->loan_date->format('Y-m-d'),
|
||||
'user_name' => $loan->user?->name ?? '미지정',
|
||||
'category' => $loan->withdrawal_id ? '카드' : '계좌',
|
||||
'category' => $loan->category_label,
|
||||
'amount' => (float) $loan->amount,
|
||||
'status' => $loan->status,
|
||||
'content' => $loan->purpose ?? '',
|
||||
@@ -414,14 +595,70 @@ public function dashboard(): array
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'total_outstanding' => (float) $summaryData['total_outstanding'],
|
||||
'total_outstanding' => (float) ($stats->total_outstanding ?? 0),
|
||||
'recognized_interest' => (float) $recognizedInterest,
|
||||
'outstanding_count' => (int) $summaryData['outstanding_count'],
|
||||
'outstanding_count' => (int) ($stats->outstanding_count ?? 0),
|
||||
],
|
||||
'category_breakdown' => $categoryBreakdown,
|
||||
'loans' => $loans,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 가지급금 집계
|
||||
*
|
||||
* @return array<string, array{outstanding_amount: float, total_count: int, unverified_count: int}>
|
||||
*/
|
||||
private function getCategoryBreakdown(int $tenantId, ?string $startDate = null, ?string $endDate = null): array
|
||||
{
|
||||
// 기본값: 4개 카테고리 모두 0으로 초기화
|
||||
$breakdown = [];
|
||||
foreach (Loan::CATEGORIES as $category) {
|
||||
$breakdown[$category] = [
|
||||
'outstanding_amount' => 0.0,
|
||||
'total_count' => 0,
|
||||
'unverified_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 카테고리별 집계 (날짜 필터 적용)
|
||||
// 상품권 중 used/disposed는 접대비로 전환되므로 가지급금 집계에서 제외
|
||||
$query = Loan::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNot(function ($q) {
|
||||
$q->where('category', Loan::CATEGORY_GIFT_CERTIFICATE)
|
||||
->whereIn('status', [Loan::STATUS_USED, Loan::STATUS_DISPOSED]);
|
||||
});
|
||||
|
||||
if ($startDate) {
|
||||
$query->where('loan_date', '>=', $startDate);
|
||||
}
|
||||
if ($endDate) {
|
||||
$query->where('loan_date', '<=', $endDate);
|
||||
}
|
||||
|
||||
// NOTE: SQL alias를 'cat_outstanding'으로 사용 — Loan 모델의
|
||||
// getOutstandingAmountAttribute() accessor와 이름 충돌 방지
|
||||
$stats = $query
|
||||
->selectRaw('category, COUNT(*) as total_count, SUM(amount - COALESCE(settlement_amount, 0)) as cat_outstanding')
|
||||
->selectRaw('SUM(CASE WHEN purpose IS NULL OR purpose = \'\' THEN 1 ELSE 0 END) as unverified_count')
|
||||
->groupBy('category')
|
||||
->get();
|
||||
|
||||
foreach ($stats as $stat) {
|
||||
$cat = $stat->category ?? Loan::CATEGORY_CARD;
|
||||
if (isset($breakdown[$cat])) {
|
||||
$breakdown[$cat] = [
|
||||
'outstanding_amount' => (float) $stat->cat_outstanding,
|
||||
'total_count' => (int) $stat->total_count,
|
||||
'unverified_count' => (int) $stat->unverified_count,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $breakdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금 시뮬레이션 데이터
|
||||
*
|
||||
|
||||
@@ -117,11 +117,14 @@ public function index(array $params): array
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계 조회
|
||||
* 요약 통계 조회 (D1.7 cards + check_points 구조)
|
||||
*
|
||||
* @return array{cards: array, check_points: array}
|
||||
*/
|
||||
public function summary(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$now = Carbon::now();
|
||||
$recentYear = $params['recent_year'] ?? false;
|
||||
$year = $params['year'] ?? date('Y');
|
||||
|
||||
@@ -137,19 +140,19 @@ public function summary(array $params): array
|
||||
$totalCarryForward = $this->getTotalCarryForwardBalance($tenantId, $carryForwardDate);
|
||||
|
||||
// 기간 내 총 매출
|
||||
$totalSales = Sale::where('tenant_id', $tenantId)
|
||||
$totalSales = (float) Sale::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereBetween('sale_date', [$startDate, $endDate])
|
||||
->sum('total_amount');
|
||||
|
||||
// 기간 내 총 입금
|
||||
$totalDeposits = Deposit::where('tenant_id', $tenantId)
|
||||
$totalDeposits = (float) Deposit::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereBetween('deposit_date', [$startDate, $endDate])
|
||||
->sum('amount');
|
||||
|
||||
// 기간 내 총 어음
|
||||
$totalBills = Bill::where('tenant_id', $tenantId)
|
||||
$totalBills = (float) Bill::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->where('bill_type', 'received')
|
||||
->whereBetween('issue_date', [$startDate, $endDate])
|
||||
@@ -158,26 +161,242 @@ public function summary(array $params): array
|
||||
// 총 미수금 (이월잔액 + 매출 - 입금 - 어음)
|
||||
$totalReceivables = $totalCarryForward + $totalSales - $totalDeposits - $totalBills;
|
||||
|
||||
// 당월 미수금
|
||||
$currentMonthStart = $now->copy()->startOfMonth()->format('Y-m-d');
|
||||
$currentMonthEnd = $now->copy()->endOfMonth()->format('Y-m-d');
|
||||
|
||||
$currentMonthSales = (float) Sale::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereBetween('sale_date', [$currentMonthStart, $currentMonthEnd])
|
||||
->sum('total_amount');
|
||||
|
||||
$currentMonthDeposits = (float) Deposit::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereBetween('deposit_date', [$currentMonthStart, $currentMonthEnd])
|
||||
->sum('amount');
|
||||
|
||||
$currentMonthReceivables = $currentMonthSales - $currentMonthDeposits;
|
||||
|
||||
// 거래처 수
|
||||
$vendorCount = Client::where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
|
||||
// 연체 거래처 수 (미수금이 양수인 거래처)
|
||||
// 연체 거래처 수
|
||||
$overdueVendorCount = Client::where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->where('is_overdue', true)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total_carry_forward' => (float) $totalCarryForward,
|
||||
'total_sales' => (float) $totalSales,
|
||||
'total_deposits' => (float) $totalDeposits,
|
||||
'total_bills' => (float) $totalBills,
|
||||
'total_receivables' => (float) $totalReceivables,
|
||||
'vendor_count' => $vendorCount,
|
||||
'overdue_vendor_count' => $overdueVendorCount,
|
||||
// 악성채권 건수
|
||||
$badDebtCount = $this->getBadDebtCount($tenantId);
|
||||
|
||||
// Top 3 미수금 거래처
|
||||
$topVendors = $this->getTopReceivableVendors($tenantId, 3);
|
||||
|
||||
// 카드 데이터 구성
|
||||
$cards = [
|
||||
[
|
||||
'id' => 'rv_cumulative',
|
||||
'label' => '누적 미수금',
|
||||
'amount' => (int) $totalReceivables,
|
||||
'sub_items' => [
|
||||
['label' => '매출', 'value' => (int) $totalSales],
|
||||
['label' => '입금', 'value' => (int) $totalDeposits],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'rv_monthly',
|
||||
'label' => '당월 미수금',
|
||||
'amount' => (int) $currentMonthReceivables,
|
||||
'sub_items' => [
|
||||
['label' => '매출', 'value' => (int) $currentMonthSales],
|
||||
['label' => '입금', 'value' => (int) $currentMonthDeposits],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'rv_vendors',
|
||||
'label' => '미수금 거래처',
|
||||
'amount' => $vendorCount,
|
||||
'unit' => '건',
|
||||
'subLabel' => "연체 {$overdueVendorCount}건" . ($badDebtCount > 0 ? " · 악성채권 {$badDebtCount}건" : ''),
|
||||
],
|
||||
[
|
||||
'id' => 'rv_top3',
|
||||
'label' => '미수금 Top 3',
|
||||
'amount' => ! empty($topVendors) ? (int) $topVendors[0]['amount'] : 0,
|
||||
'top_items' => $topVendors,
|
||||
],
|
||||
];
|
||||
|
||||
// 체크포인트 생성
|
||||
$checkPoints = $this->generateSummaryCheckPoints(
|
||||
$tenantId,
|
||||
$totalReceivables,
|
||||
$overdueVendorCount,
|
||||
$topVendors,
|
||||
$vendorCount
|
||||
);
|
||||
|
||||
return [
|
||||
'cards' => $cards,
|
||||
'check_points' => $checkPoints,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 악성채권 건수 조회
|
||||
*/
|
||||
private function getBadDebtCount(int $tenantId): int
|
||||
{
|
||||
// bad_debts 테이블이 존재하면 사용, 없으면 0
|
||||
try {
|
||||
return \DB::table('bad_debts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', ['collecting', 'legal_action'])
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
} catch (\Exception $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미수금 Top N 거래처 조회
|
||||
*/
|
||||
private function getTopReceivableVendors(int $tenantId, int $limit = 3): array
|
||||
{
|
||||
$salesSub = \DB::table('sales')
|
||||
->select('client_id', \DB::raw('SUM(total_amount) as total'))
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('client_id');
|
||||
|
||||
$depositsSub = \DB::table('deposits')
|
||||
->select('client_id', \DB::raw('SUM(amount) as total'))
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('client_id');
|
||||
|
||||
$billsSub = \DB::table('bills')
|
||||
->select('client_id', \DB::raw('SUM(amount) as total'))
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->whereNull('deleted_at')
|
||||
->where('bill_type', 'received')
|
||||
->groupBy('client_id');
|
||||
|
||||
$results = \DB::table('clients as c')
|
||||
->leftJoinSub($salesSub, 's', 'c.id', '=', 's.client_id')
|
||||
->leftJoinSub($depositsSub, 'd', 'c.id', '=', 'd.client_id')
|
||||
->leftJoinSub($billsSub, 'b', 'c.id', '=', 'b.client_id')
|
||||
->select(
|
||||
'c.name',
|
||||
\DB::raw('(COALESCE(s.total, 0) - COALESCE(d.total, 0) - COALESCE(b.total, 0)) as receivable')
|
||||
)
|
||||
->where('c.tenant_id', $tenantId)
|
||||
->where('c.is_active', true)
|
||||
->having('receivable', '>', 0)
|
||||
->orderByDesc('receivable')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
return $results->map(fn ($v) => [
|
||||
'name' => $v->name,
|
||||
'amount' => (int) $v->receivable,
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 요약 체크포인트 생성
|
||||
*/
|
||||
private function generateSummaryCheckPoints(
|
||||
int $tenantId,
|
||||
float $totalReceivables,
|
||||
int $overdueVendorCount,
|
||||
array $topVendors,
|
||||
int $vendorCount
|
||||
): array {
|
||||
$checkPoints = [];
|
||||
|
||||
// 연체 거래처 경고
|
||||
if ($overdueVendorCount > 0) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'rv_cp_overdue',
|
||||
'type' => 'warning',
|
||||
'message' => "연체 거래처 {$overdueVendorCount}곳. 회수 조치가 필요합니다.",
|
||||
'highlights' => [
|
||||
['text' => "연체 거래처 {$overdueVendorCount}곳", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 90일 이상 장기 미수금 체크
|
||||
$longTermCount = $this->getLongTermReceivableCount($tenantId, 90);
|
||||
if ($longTermCount > 0) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'rv_cp_longterm',
|
||||
'type' => 'error',
|
||||
'message' => "90일 이상 장기 미수금 {$longTermCount}건 감지. 악성채권 전환 위험이 있습니다.",
|
||||
'highlights' => [
|
||||
['text' => "90일 이상 장기 미수금 {$longTermCount}건", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Top1 거래처 집중도 경고
|
||||
if (! empty($topVendors) && $totalReceivables > 0) {
|
||||
$top1Ratio = round(($topVendors[0]['amount'] / $totalReceivables) * 100);
|
||||
if ($top1Ratio >= 50) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'rv_cp_concentration',
|
||||
'type' => 'warning',
|
||||
'message' => "{$topVendors[0]['name']} 미수금이 전체의 {$top1Ratio}%를 차지합니다. 리스크 분산이 필요합니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$topVendors[0]['name']}", 'color' => 'orange'],
|
||||
['text' => "전체의 {$top1Ratio}%", 'color' => 'orange'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 정상 상태 메시지
|
||||
if (empty($checkPoints)) {
|
||||
$totalFormatted = number_format($totalReceivables / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'rv_cp_normal',
|
||||
'type' => 'success',
|
||||
'message' => "총 미수금 {$totalFormatted}만원. 정상적으로 관리되고 있습니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$totalFormatted}만원", 'color' => 'green'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $checkPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* N일 이상 장기 미수금 거래처 수 조회
|
||||
*/
|
||||
private function getLongTermReceivableCount(int $tenantId, int $days): int
|
||||
{
|
||||
$cutoffDate = Carbon::now()->subDays($days)->format('Y-m-d');
|
||||
|
||||
// 연체 상태이면서 오래된 매출이 있는 거래처 수
|
||||
$clientIds = Sale::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->where('sale_date', '<=', $cutoffDate)
|
||||
->distinct()
|
||||
->pluck('client_id');
|
||||
|
||||
return Client::where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->where('is_overdue', true)
|
||||
->whereIn('id', $clientIds)
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -237,6 +237,139 @@ private function getPeriodLabel(int $year, string $periodType, int $period): str
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 부가세 상세 조회 (모달용)
|
||||
*
|
||||
* @param string|null $periodType 기간 타입 (quarter|half|year)
|
||||
* @param int|null $year 연도
|
||||
* @param int|null $period 기간 번호
|
||||
* @return array
|
||||
*/
|
||||
public function getDetail(?string $periodType = 'quarter', ?int $year = null, ?int $period = null): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$now = Carbon::now();
|
||||
|
||||
$year = $year ?? $now->year;
|
||||
$periodType = $periodType ?? 'quarter';
|
||||
$period = $period ?? $this->getCurrentPeriod($periodType, $now);
|
||||
|
||||
[$startDate, $endDate] = $this->getPeriodDateRange($year, $periodType, $period);
|
||||
$periodLabel = $this->getPeriodLabel($year, $periodType, $period);
|
||||
|
||||
$validStatuses = [TaxInvoice::STATUS_ISSUED, TaxInvoice::STATUS_SENT];
|
||||
|
||||
// 매출 공급가액 + 세액
|
||||
$salesData = TaxInvoice::where('tenant_id', $tenantId)
|
||||
->where('direction', TaxInvoice::DIRECTION_SALES)
|
||||
->whereIn('status', $validStatuses)
|
||||
->whereBetween('issue_date', [$startDate, $endDate])
|
||||
->selectRaw('COALESCE(SUM(supply_amount), 0) as supply_amount, COALESCE(SUM(tax_amount), 0) as tax_amount')
|
||||
->first();
|
||||
|
||||
// 매입 공급가액 + 세액
|
||||
$purchasesData = TaxInvoice::where('tenant_id', $tenantId)
|
||||
->where('direction', TaxInvoice::DIRECTION_PURCHASES)
|
||||
->whereIn('status', $validStatuses)
|
||||
->whereBetween('issue_date', [$startDate, $endDate])
|
||||
->selectRaw('COALESCE(SUM(supply_amount), 0) as supply_amount, COALESCE(SUM(tax_amount), 0) as tax_amount')
|
||||
->first();
|
||||
|
||||
$salesSupplyAmount = (int) ($salesData->supply_amount ?? 0);
|
||||
$salesTaxAmount = (int) ($salesData->tax_amount ?? 0);
|
||||
$purchasesSupplyAmount = (int) ($purchasesData->supply_amount ?? 0);
|
||||
$purchasesTaxAmount = (int) ($purchasesData->tax_amount ?? 0);
|
||||
$estimatedPayment = $salesTaxAmount - $purchasesTaxAmount;
|
||||
|
||||
// 신고기간 옵션 생성
|
||||
$periodOptions = $this->generatePeriodOptions($year, $periodType, $period);
|
||||
|
||||
// 부가세 요약 테이블 (direction + invoice_type 별 GROUP BY)
|
||||
$referenceTable = TaxInvoice::where('tenant_id', $tenantId)
|
||||
->whereIn('status', $validStatuses)
|
||||
->whereBetween('issue_date', [$startDate, $endDate])
|
||||
->selectRaw("
|
||||
direction,
|
||||
invoice_type,
|
||||
COALESCE(SUM(supply_amount), 0) as supply_amount,
|
||||
COALESCE(SUM(tax_amount), 0) as tax_amount
|
||||
")
|
||||
->groupBy('direction', 'invoice_type')
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'direction' => $row->direction,
|
||||
'direction_label' => $row->direction === TaxInvoice::DIRECTION_SALES ? '매출' : '매입',
|
||||
'invoice_type' => $row->invoice_type,
|
||||
'invoice_type_label' => match ($row->invoice_type) {
|
||||
TaxInvoice::TYPE_TAX_INVOICE => '전자세금계산서',
|
||||
TaxInvoice::TYPE_INVOICE => '계산서',
|
||||
TaxInvoice::TYPE_MODIFIED_TAX_INVOICE => '수정세금계산서',
|
||||
default => $row->invoice_type,
|
||||
},
|
||||
'supply_amount' => (int) $row->supply_amount,
|
||||
'tax_amount' => (int) $row->tax_amount,
|
||||
])
|
||||
->toArray();
|
||||
|
||||
// 미발행/미수취 세금계산서 목록 (status=draft)
|
||||
$unissuedInvoices = TaxInvoice::where('tenant_id', $tenantId)
|
||||
->where('status', TaxInvoice::STATUS_DRAFT)
|
||||
->orderBy('issue_date', 'desc')
|
||||
->limit(100)
|
||||
->get()
|
||||
->map(fn ($invoice) => [
|
||||
'id' => $invoice->id,
|
||||
'direction' => $invoice->direction,
|
||||
'direction_label' => $invoice->direction === TaxInvoice::DIRECTION_SALES ? '매출' : '매입',
|
||||
'issue_date' => $invoice->issue_date,
|
||||
'vendor_name' => $invoice->direction === TaxInvoice::DIRECTION_SALES
|
||||
? ($invoice->buyer_corp_name ?? '-')
|
||||
: ($invoice->supplier_corp_name ?? '-'),
|
||||
'tax_amount' => (int) $invoice->tax_amount,
|
||||
'status' => $invoice->direction === TaxInvoice::DIRECTION_SALES ? '미발행' : '미수취',
|
||||
])
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'period_label' => $periodLabel,
|
||||
'period_options' => $periodOptions,
|
||||
'summary' => [
|
||||
'sales_supply_amount' => $salesSupplyAmount,
|
||||
'sales_tax_amount' => $salesTaxAmount,
|
||||
'purchases_supply_amount' => $purchasesSupplyAmount,
|
||||
'purchases_tax_amount' => $purchasesTaxAmount,
|
||||
'estimated_payment' => (int) abs($estimatedPayment),
|
||||
'is_refund' => $estimatedPayment < 0,
|
||||
],
|
||||
'reference_table' => $referenceTable,
|
||||
'unissued_invoices' => $unissuedInvoices,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 신고기간 드롭다운 옵션 생성
|
||||
* 현재 기간 포함 최근 8개 기간
|
||||
*/
|
||||
private function generatePeriodOptions(int $currentYear, string $periodType, int $currentPeriod): array
|
||||
{
|
||||
$options = [];
|
||||
$year = $currentYear;
|
||||
$period = $currentPeriod;
|
||||
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$label = $this->getPeriodLabel($year, $periodType, $period);
|
||||
$value = "{$year}-{$periodType}-{$period}";
|
||||
$options[] = ['value' => $value, 'label' => $label];
|
||||
|
||||
// 이전 기간으로 이동
|
||||
$prev = $this->getPreviousPeriod($year, $periodType, $period);
|
||||
$year = $prev['year'];
|
||||
$period = $prev['period'];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 기간 계산
|
||||
*
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 복리후생비 현황 서비스
|
||||
* 복리후생비 현황 서비스 (D1.7 리스크 감지형)
|
||||
*
|
||||
* CEO 대시보드용 복리후생비 데이터를 제공합니다.
|
||||
* CEO 대시보드용 복리후생비 리스크 데이터를 제공합니다.
|
||||
* 카드 4개: 비과세 한도 초과, 사적 사용 의심, 특정인 편중, 항목별 한도 초과
|
||||
*/
|
||||
class WelfareService extends Service
|
||||
{
|
||||
@@ -20,15 +21,22 @@ class WelfareService extends Service
|
||||
|
||||
private const INDUSTRY_AVG_MAX = 250000;
|
||||
|
||||
// 특정인 편중 기준 (전체 대비 5% 초과)
|
||||
private const CONCENTRATION_THRESHOLD = 0.05;
|
||||
|
||||
// 항목별 1인당 월 기준 금액
|
||||
private const SUB_TYPE_LIMITS = [
|
||||
'meal' => 200000, // 식대 20만원
|
||||
'transportation' => 100000, // 교통비 10만원
|
||||
'congratulation' => 50000, // 경조사 5만원
|
||||
'health_check' => 30000, // 건강검진 3만원
|
||||
'education' => 80000, // 교육비 8만원
|
||||
'welfare_point' => 100000, // 복지포인트 10만원
|
||||
];
|
||||
|
||||
/**
|
||||
* 복리후생비 현황 요약 조회
|
||||
* 복리후생비 리스크 현황 요약 조회 (D1.7)
|
||||
*
|
||||
* @param string|null $limitType 기간 타입 (annual|quarterly, 기본: quarterly)
|
||||
* @param string|null $calculationType 계산 방식 (fixed|ratio, 기본: fixed)
|
||||
* @param int|null $fixedAmountPerMonth 1인당 월 정액 (기본: 200000)
|
||||
* @param float|null $ratio 급여 대비 비율 (기본: 0.05)
|
||||
* @param int|null $year 연도 (기본: 현재 연도)
|
||||
* @param int|null $quarter 분기 (1-4, 기본: 현재 분기)
|
||||
* @return array{cards: array, check_points: array}
|
||||
*/
|
||||
public function getSummary(
|
||||
@@ -42,79 +50,68 @@ public function getSummary(
|
||||
$tenantId = $this->tenantId();
|
||||
$now = Carbon::now();
|
||||
|
||||
// 기본값 설정
|
||||
$year = $year ?? $now->year;
|
||||
$limitType = $limitType ?? 'quarterly';
|
||||
$calculationType = $calculationType ?? 'fixed';
|
||||
$fixedAmountPerMonth = $fixedAmountPerMonth ?? 200000;
|
||||
$ratio = $ratio ?? 0.05;
|
||||
$quarter = $quarter ?? $now->quarter;
|
||||
|
||||
// 기간 범위 계산
|
||||
if ($limitType === 'annual') {
|
||||
$startDate = Carbon::create($year, 1, 1)->format('Y-m-d');
|
||||
$endDate = Carbon::create($year, 12, 31)->format('Y-m-d');
|
||||
$periodLabel = "{$year}년";
|
||||
$monthCount = 12;
|
||||
} else {
|
||||
$startDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d');
|
||||
$endDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d');
|
||||
$periodLabel = "{$quarter}사분기";
|
||||
$monthCount = 3;
|
||||
}
|
||||
|
||||
// 직원 수 조회
|
||||
$employeeCount = $this->getEmployeeCount($tenantId);
|
||||
|
||||
// 한도 계산
|
||||
if ($calculationType === 'fixed') {
|
||||
$annualLimit = $fixedAmountPerMonth * 12 * $employeeCount;
|
||||
} else {
|
||||
// 급여 총액 기반 비율 계산
|
||||
$totalSalary = $this->getTotalSalary($tenantId, $year);
|
||||
$annualLimit = $totalSalary * $ratio;
|
||||
}
|
||||
|
||||
$periodLimit = $limitType === 'annual' ? $annualLimit : ($annualLimit / 4);
|
||||
|
||||
// 복리후생비 사용액 조회
|
||||
$usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate);
|
||||
|
||||
// 잔여 한도
|
||||
$remainingLimit = max(0, $periodLimit - $usedAmount);
|
||||
// 리스크 감지 쿼리
|
||||
$taxFreeExcess = $this->getTaxFreeExcessRisk($tenantId, $startDate, $endDate, $employeeCount, $monthCount);
|
||||
$privateUse = $this->getPrivateUseRisk($tenantId, $startDate, $endDate);
|
||||
$concentration = $this->getConcentrationRisk($tenantId, $startDate, $endDate);
|
||||
$categoryExcess = $this->getCategoryExcessRisk($tenantId, $startDate, $endDate, $employeeCount, $monthCount);
|
||||
|
||||
// 카드 데이터 구성
|
||||
$cards = [
|
||||
[
|
||||
'id' => 'wf_annual_limit',
|
||||
'label' => '당해년도 복리후생비 한도',
|
||||
'amount' => (int) $annualLimit,
|
||||
'id' => 'wf_tax_excess',
|
||||
'label' => '비과세 한도 초과',
|
||||
'amount' => (int) $taxFreeExcess['total'],
|
||||
'subLabel' => "{$taxFreeExcess['count']}건",
|
||||
],
|
||||
[
|
||||
'id' => 'wf_period_limit',
|
||||
'label' => "{{$periodLabel}} 복리후생비 총 한도",
|
||||
'amount' => (int) $periodLimit,
|
||||
'id' => 'wf_private_use',
|
||||
'label' => '사적 사용 의심',
|
||||
'amount' => (int) $privateUse['total'],
|
||||
'subLabel' => "{$privateUse['count']}건",
|
||||
],
|
||||
[
|
||||
'id' => 'wf_remaining',
|
||||
'label' => "{{$periodLabel}} 복리후생비 잔여한도",
|
||||
'amount' => (int) $remainingLimit,
|
||||
'id' => 'wf_concentration',
|
||||
'label' => '특정인 편중',
|
||||
'amount' => (int) $concentration['total'],
|
||||
'subLabel' => "{$concentration['count']}건",
|
||||
],
|
||||
[
|
||||
'id' => 'wf_used',
|
||||
'label' => "{{$periodLabel}} 복리후생비 사용금액",
|
||||
'amount' => (int) $usedAmount,
|
||||
'id' => 'wf_category_excess',
|
||||
'label' => '항목별 한도 초과',
|
||||
'amount' => (int) $categoryExcess['total'],
|
||||
'subLabel' => "{$categoryExcess['count']}건",
|
||||
],
|
||||
];
|
||||
|
||||
// 체크포인트 생성
|
||||
$checkPoints = $this->generateCheckPoints(
|
||||
$checkPoints = $this->generateRiskCheckPoints(
|
||||
$tenantId,
|
||||
$employeeCount,
|
||||
$usedAmount,
|
||||
$monthCount,
|
||||
$startDate,
|
||||
$endDate
|
||||
$endDate,
|
||||
$taxFreeExcess,
|
||||
$privateUse,
|
||||
$concentration,
|
||||
$categoryExcess
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -123,6 +120,260 @@ public function getSummary(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 비과세 한도 초과 리스크 조회
|
||||
* sub_type='meal' 1인당 월 > 200,000원
|
||||
*/
|
||||
private function getTaxFreeExcessRisk(int $tenantId, string $startDate, string $endDate, int $employeeCount, int $monthCount): array
|
||||
{
|
||||
if ($employeeCount <= 0) {
|
||||
return ['count' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
// 식대 총액 조회
|
||||
$mealTotal = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'welfare')
|
||||
->where('sub_type', 'meal')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->sum('amount');
|
||||
|
||||
$perPersonMonthly = $mealTotal / $employeeCount / max(1, $monthCount);
|
||||
$excessAmount = max(0, $perPersonMonthly - self::TAX_FREE_MEAL_LIMIT) * $employeeCount * $monthCount;
|
||||
|
||||
if ($excessAmount > 0) {
|
||||
// 초과 건수 (식대 건수 기준)
|
||||
$count = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'welfare')
|
||||
->where('sub_type', 'meal')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
return ['count' => $count, 'total' => (int) $excessAmount];
|
||||
}
|
||||
|
||||
return ['count' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 사적 사용 의심 리스크 조회
|
||||
* 주말/심야 사용 (접대비와 동일 로직, account_type='welfare')
|
||||
*/
|
||||
private function getPrivateUseRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
// 주말 사용
|
||||
$weekendResult = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'welfare')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->whereRaw('DAYOFWEEK(expense_date) IN (1, 7)')
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
// 심야 사용 (barobill 조인)
|
||||
$lateNightResult = DB::table('expense_accounts as ea')
|
||||
->leftJoin('barobill_card_transactions as bct', function ($join) {
|
||||
$join->on('ea.receipt_no', '=', 'bct.approval_num')
|
||||
->on('ea.tenant_id', '=', 'bct.tenant_id');
|
||||
})
|
||||
->where('ea.tenant_id', $tenantId)
|
||||
->where('ea.account_type', 'welfare')
|
||||
->whereBetween('ea.expense_date', [$startDate, $endDate])
|
||||
->whereNull('ea.deleted_at')
|
||||
->whereRaw('DAYOFWEEK(ea.expense_date) NOT IN (1, 7)')
|
||||
->whereNotNull('bct.use_time')
|
||||
->where(function ($q) {
|
||||
$q->whereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) >= 22')
|
||||
->orWhereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) < 6');
|
||||
})
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'count' => ($weekendResult->count ?? 0) + ($lateNightResult->count ?? 0),
|
||||
'total' => ($weekendResult->total ?? 0) + ($lateNightResult->total ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정인 편중 리스크 조회
|
||||
* 1인 사용비율 > 전체의 5%
|
||||
*/
|
||||
private function getConcentrationRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
// 전체 복리후생비 사용액
|
||||
$totalAmount = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'welfare')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->sum('amount');
|
||||
|
||||
if ($totalAmount <= 0) {
|
||||
return ['count' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
$threshold = $totalAmount * self::CONCENTRATION_THRESHOLD;
|
||||
|
||||
// 사용자별 사용액 조회 (편중된 사용자)
|
||||
$concentrated = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'welfare')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('created_by')
|
||||
->havingRaw('SUM(amount) > ?', [$threshold])
|
||||
->selectRaw('COUNT(*) as count, SUM(amount) as total')
|
||||
->get();
|
||||
|
||||
$totalConcentrated = $concentrated->sum('total');
|
||||
$userCount = $concentrated->count();
|
||||
|
||||
return ['count' => $userCount, 'total' => (int) $totalConcentrated];
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목별 한도 초과 리스크 조회
|
||||
* 각 sub_type별 1인당 월 기준금액 초과
|
||||
*/
|
||||
private function getCategoryExcessRisk(int $tenantId, string $startDate, string $endDate, int $employeeCount, int $monthCount): array
|
||||
{
|
||||
if ($employeeCount <= 0) {
|
||||
return ['count' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
$totalExcess = 0;
|
||||
$excessCount = 0;
|
||||
|
||||
foreach (self::SUB_TYPE_LIMITS as $subType => $monthlyLimit) {
|
||||
$amount = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'welfare')
|
||||
->where('sub_type', $subType)
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->sum('amount');
|
||||
|
||||
$perPersonMonthly = $amount / $employeeCount / max(1, $monthCount);
|
||||
if ($perPersonMonthly > $monthlyLimit) {
|
||||
$excess = ($perPersonMonthly - $monthlyLimit) * $employeeCount * $monthCount;
|
||||
$totalExcess += $excess;
|
||||
$excessCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return ['count' => $excessCount, 'total' => (int) $totalExcess];
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스크 감지 체크포인트 생성
|
||||
*/
|
||||
private function generateRiskCheckPoints(
|
||||
int $tenantId,
|
||||
int $employeeCount,
|
||||
int $monthCount,
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
array $taxFreeExcess,
|
||||
array $privateUse,
|
||||
array $concentration,
|
||||
array $categoryExcess
|
||||
): array {
|
||||
$checkPoints = [];
|
||||
|
||||
// 1인당 월 복리후생비 계산 (업계 평균 비교)
|
||||
$usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate);
|
||||
$perPersonMonthly = $employeeCount > 0 && $monthCount > 0
|
||||
? $usedAmount / $employeeCount / $monthCount
|
||||
: 0;
|
||||
$perPersonFormatted = number_format($perPersonMonthly / 10000);
|
||||
|
||||
if ($perPersonMonthly >= self::INDUSTRY_AVG_MIN && $perPersonMonthly <= self::INDUSTRY_AVG_MAX) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'wf_cp_avg',
|
||||
'type' => 'success',
|
||||
'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$perPersonFormatted}만원", 'color' => 'green'],
|
||||
],
|
||||
];
|
||||
} elseif ($perPersonMonthly > self::INDUSTRY_AVG_MAX) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'wf_cp_avg_high',
|
||||
'type' => 'warning',
|
||||
'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 높습니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$perPersonFormatted}만원", 'color' => 'orange'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 식대 비과세 한도 체크
|
||||
$mealAmount = $this->getMonthlyMealAmount($tenantId, $startDate, $endDate);
|
||||
$perPersonMeal = $employeeCount > 0 && $monthCount > 0
|
||||
? $mealAmount / $employeeCount / $monthCount
|
||||
: 0;
|
||||
|
||||
if ($perPersonMeal > self::TAX_FREE_MEAL_LIMIT) {
|
||||
$mealFormatted = number_format($perPersonMeal / 10000);
|
||||
$limitFormatted = number_format(self::TAX_FREE_MEAL_LIMIT / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'wf_cp_meal',
|
||||
'type' => 'error',
|
||||
'message' => "식대가 월 {$mealFormatted}만원으로 비과세 한도({$limitFormatted}만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.",
|
||||
'highlights' => [
|
||||
['text' => "월 {$mealFormatted}만원", 'color' => 'red'],
|
||||
['text' => '초과', 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 사적 사용 의심
|
||||
if ($privateUse['count'] > 0) {
|
||||
$amountFormatted = number_format($privateUse['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'wf_cp_private',
|
||||
'type' => 'warning',
|
||||
'message' => "주말/심야 사용 {$privateUse['count']}건({$amountFormatted}만원) 감지. 사적 사용 여부를 확인해주세요.",
|
||||
'highlights' => [
|
||||
['text' => "{$privateUse['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 특정인 편중
|
||||
if ($concentration['count'] > 0) {
|
||||
$amountFormatted = number_format($concentration['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'wf_cp_concentration',
|
||||
'type' => 'warning',
|
||||
'message' => "특정인 편중 {$concentration['count']}명({$amountFormatted}만원). 전체의 5% 초과 사용자가 있습니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$concentration['count']}명({$amountFormatted}만원)", 'color' => 'orange'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 리스크 0건이면 정상
|
||||
$totalRisk = $taxFreeExcess['count'] + $privateUse['count'] + $concentration['count'] + $categoryExcess['count'];
|
||||
if ($totalRisk === 0 && empty($checkPoints)) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'wf_cp_normal',
|
||||
'type' => 'success',
|
||||
'message' => '복리후생비 사용 현황이 정상입니다.',
|
||||
'highlights' => [
|
||||
['text' => '정상', 'color' => 'green'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $checkPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 수 조회 (급여 대상 직원 기준)
|
||||
*
|
||||
@@ -506,73 +757,4 @@ private function getQuarterlyStatus(int $tenantId, int $year, float $quarterlyLi
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크포인트 생성
|
||||
*/
|
||||
private function generateCheckPoints(
|
||||
int $tenantId,
|
||||
int $employeeCount,
|
||||
float $usedAmount,
|
||||
int $monthCount,
|
||||
string $startDate,
|
||||
string $endDate
|
||||
): array {
|
||||
$checkPoints = [];
|
||||
|
||||
// 1인당 월 복리후생비 계산
|
||||
$perPersonMonthly = $employeeCount > 0 && $monthCount > 0
|
||||
? $usedAmount / $employeeCount / $monthCount
|
||||
: 0;
|
||||
$perPersonFormatted = number_format($perPersonMonthly / 10000);
|
||||
|
||||
// 업계 평균 비교
|
||||
if ($perPersonMonthly >= self::INDUSTRY_AVG_MIN && $perPersonMonthly <= self::INDUSTRY_AVG_MAX) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'wf_cp_normal',
|
||||
'type' => 'success',
|
||||
'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.",
|
||||
'highlights' => [
|
||||
['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'green'],
|
||||
],
|
||||
];
|
||||
} elseif ($perPersonMonthly < self::INDUSTRY_AVG_MIN) {
|
||||
$checkPoints[] = [
|
||||
'id' => 'wf_cp_low',
|
||||
'type' => 'warning',
|
||||
'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 낮습니다.",
|
||||
'highlights' => [
|
||||
['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'orange'],
|
||||
],
|
||||
];
|
||||
} else {
|
||||
$checkPoints[] = [
|
||||
'id' => 'wf_cp_high',
|
||||
'type' => 'warning',
|
||||
'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 높습니다.",
|
||||
'highlights' => [
|
||||
['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'orange'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 식대 비과세 한도 체크
|
||||
$mealAmount = $this->getMonthlyMealAmount($tenantId, $startDate, $endDate);
|
||||
$perPersonMeal = $employeeCount > 0 ? $mealAmount / $employeeCount : 0;
|
||||
|
||||
if ($perPersonMeal > self::TAX_FREE_MEAL_LIMIT) {
|
||||
$mealFormatted = number_format($perPersonMeal / 10000);
|
||||
$limitFormatted = number_format(self::TAX_FREE_MEAL_LIMIT / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'wf_cp_meal',
|
||||
'type' => 'error',
|
||||
'message' => "식대가 월 {$mealFormatted}만원으로 비과세 한도({$limitFormatted}만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.",
|
||||
'highlights' => [
|
||||
['text' => "식대가 월 {$mealFormatted}만원으로", 'color' => 'red'],
|
||||
['text' => '초과', 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $checkPoints;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 가지급금 카테고리 컬럼 추가
|
||||
* D1.7 기획서: 카드/경조사/상품권/접대비 4개 카테고리 분류
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('loans', function (Blueprint $table) {
|
||||
$table->string('category', 30)
|
||||
->default('card')
|
||||
->after('status')
|
||||
->comment('카테고리: card, congratulatory, gift_certificate, entertainment');
|
||||
|
||||
$table->index(['tenant_id', 'category'], 'idx_tenant_category');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('loans', function (Blueprint $table) {
|
||||
$table->dropIndex('idx_tenant_category');
|
||||
$table->dropColumn('category');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 어음/수표 V8 확장 마이그레이션
|
||||
*
|
||||
* 프로토타입 v8의 ~45개 필드 지원을 위해 bills 테이블에 신규 컬럼 추가.
|
||||
* 기존 컬럼(bill_number, bill_type, client_id, amount 등)은 그대로 유지.
|
||||
*
|
||||
* 주요 변경:
|
||||
* - 증권종류(instrument_type), 매체(medium), 어음구분(bill_category) 추가
|
||||
* - 전자어음 관리번호, 등록기관 추가
|
||||
* - 환어음 정보(지급인, 인수여부) 추가
|
||||
* - 받을어음: 배서, 보관장소, 할인, 추심 관련 필드 추가
|
||||
* - 지급어음: 결제방법, 실제결제일 추가
|
||||
* - 지급장소, 개서, 소구, 환매, 부도 법적절차 필드 추가
|
||||
* - 분할배서, 이력관리 확장 (bill_installments에 type, counterparty 추가)
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('bills', function (Blueprint $table) {
|
||||
// === 증권종류/매체/구분 ===
|
||||
$table->string('instrument_type', 30)->default('promissory')->after('bill_type')
|
||||
->comment('증권종류: promissory/exchange/cashierCheck/currentCheck');
|
||||
$table->string('medium', 20)->default('paper')->after('instrument_type')
|
||||
->comment('매체: electronic/paper');
|
||||
$table->string('bill_category', 30)->nullable()->after('medium')
|
||||
->comment('어음구분: commercial/other');
|
||||
|
||||
// === 전자어음 정보 ===
|
||||
$table->string('electronic_bill_no', 100)->nullable()->after('is_electronic')
|
||||
->comment('전자어음 관리번호');
|
||||
$table->string('registration_org', 30)->nullable()->after('electronic_bill_no')
|
||||
->comment('등록기관: kftc/bank');
|
||||
|
||||
// === 환어음 정보 ===
|
||||
$table->string('drawee', 100)->nullable()->after('registration_org')
|
||||
->comment('환어음 지급인 (Drawee)');
|
||||
$table->string('acceptance_status', 20)->nullable()->after('drawee')
|
||||
->comment('인수여부: accepted/pending/refused');
|
||||
$table->date('acceptance_date')->nullable()->after('acceptance_status')
|
||||
->comment('인수일자');
|
||||
$table->date('acceptance_refusal_date')->nullable()->after('acceptance_date')
|
||||
->comment('인수거절일');
|
||||
$table->string('acceptance_refusal_reason', 50)->nullable()->after('acceptance_refusal_date')
|
||||
->comment('인수거절사유');
|
||||
|
||||
// === 받을어음 전용 ===
|
||||
$table->string('endorsement', 30)->nullable()->after('acceptance_refusal_reason')
|
||||
->comment('배서여부: endorsable/nonEndorsable');
|
||||
$table->string('endorsement_order', 5)->nullable()->after('endorsement')
|
||||
->comment('배서차수: 1~20');
|
||||
$table->string('storage_place', 30)->nullable()->after('endorsement_order')
|
||||
->comment('보관장소: safe/bank/other');
|
||||
$table->string('issuer_bank', 100)->nullable()->after('storage_place')
|
||||
->comment('발행은행');
|
||||
|
||||
// 할인 정보
|
||||
$table->boolean('is_discounted')->default(false)->after('issuer_bank')
|
||||
->comment('할인여부');
|
||||
$table->date('discount_date')->nullable()->after('is_discounted')
|
||||
->comment('할인일자');
|
||||
$table->string('discount_bank', 100)->nullable()->after('discount_date')
|
||||
->comment('할인처 (은행)');
|
||||
$table->decimal('discount_rate', 5, 2)->nullable()->after('discount_bank')
|
||||
->comment('할인율 (%)');
|
||||
$table->decimal('discount_amount', 15, 2)->nullable()->after('discount_rate')
|
||||
->comment('할인금액');
|
||||
|
||||
// 배서양도 정보
|
||||
$table->date('endorsement_date')->nullable()->after('discount_amount')
|
||||
->comment('배서일자');
|
||||
$table->string('endorsee', 100)->nullable()->after('endorsement_date')
|
||||
->comment('피배서인 (양수인)');
|
||||
$table->string('endorsement_reason', 30)->nullable()->after('endorsee')
|
||||
->comment('배서사유: payment/guarantee/collection/other');
|
||||
|
||||
// 추심 정보
|
||||
$table->string('collection_bank', 100)->nullable()->after('endorsement_reason')
|
||||
->comment('추심은행');
|
||||
$table->date('collection_request_date')->nullable()->after('collection_bank')
|
||||
->comment('추심의뢰일');
|
||||
$table->decimal('collection_fee', 15, 2)->nullable()->after('collection_request_date')
|
||||
->comment('추심수수료');
|
||||
$table->date('collection_complete_date')->nullable()->after('collection_fee')
|
||||
->comment('추심완료일');
|
||||
$table->string('collection_result', 20)->nullable()->after('collection_complete_date')
|
||||
->comment('추심결과: success/partial/failed/pending');
|
||||
$table->date('collection_deposit_date')->nullable()->after('collection_result')
|
||||
->comment('추심입금일');
|
||||
$table->decimal('collection_deposit_amount', 15, 2)->nullable()->after('collection_deposit_date')
|
||||
->comment('추심입금액 (수수료 차감후)');
|
||||
|
||||
// === 지급어음 전용 ===
|
||||
$table->string('settlement_bank', 100)->nullable()->after('collection_deposit_amount')
|
||||
->comment('결제은행');
|
||||
$table->string('payment_method', 30)->nullable()->after('settlement_bank')
|
||||
->comment('결제방법: autoTransfer/currentAccount/other');
|
||||
$table->date('actual_payment_date')->nullable()->after('payment_method')
|
||||
->comment('실제결제일');
|
||||
|
||||
// === 공통 ===
|
||||
$table->string('payment_place', 30)->nullable()->after('actual_payment_date')
|
||||
->comment('지급장소: issuerBank/issuerBankBranch/payerAddress/designatedBank/other');
|
||||
$table->string('payment_place_detail', 200)->nullable()->after('payment_place')
|
||||
->comment('지급장소 상세 (기타 선택 시)');
|
||||
|
||||
// === 개서 정보 ===
|
||||
$table->date('renewal_date')->nullable()->after('payment_place_detail')
|
||||
->comment('개서일자');
|
||||
$table->string('renewal_new_bill_no', 50)->nullable()->after('renewal_date')
|
||||
->comment('신어음번호');
|
||||
$table->string('renewal_reason', 30)->nullable()->after('renewal_new_bill_no')
|
||||
->comment('개서사유: maturityExtension/amountChange/conditionChange/other');
|
||||
|
||||
// === 소구 정보 ===
|
||||
$table->date('recourse_date')->nullable()->after('renewal_reason')
|
||||
->comment('소구일자');
|
||||
$table->decimal('recourse_amount', 15, 2)->nullable()->after('recourse_date')
|
||||
->comment('소구금액');
|
||||
$table->string('recourse_target', 100)->nullable()->after('recourse_amount')
|
||||
->comment('소구대상 (청구인)');
|
||||
$table->string('recourse_reason', 30)->nullable()->after('recourse_target')
|
||||
->comment('소구사유: endorsedDishonor/discountDishonor/other');
|
||||
|
||||
// === 환매 정보 ===
|
||||
$table->date('buyback_date')->nullable()->after('recourse_reason')
|
||||
->comment('환매일자');
|
||||
$table->decimal('buyback_amount', 15, 2)->nullable()->after('buyback_date')
|
||||
->comment('환매금액');
|
||||
$table->string('buyback_bank', 100)->nullable()->after('buyback_amount')
|
||||
->comment('환매요청 은행');
|
||||
|
||||
// === 부도/법적절차 ===
|
||||
$table->date('dishonored_date')->nullable()->after('buyback_bank')
|
||||
->comment('부도일자');
|
||||
$table->string('dishonored_reason', 30)->nullable()->after('dishonored_date')
|
||||
->comment('부도사유');
|
||||
$table->boolean('has_protest')->default(false)->after('dishonored_reason')
|
||||
->comment('거절증서 작성 여부');
|
||||
$table->date('protest_date')->nullable()->after('has_protest')
|
||||
->comment('거절증서 작성일');
|
||||
$table->date('recourse_notice_date')->nullable()->after('protest_date')
|
||||
->comment('소구 통지일');
|
||||
$table->date('recourse_notice_deadline')->nullable()->after('recourse_notice_date')
|
||||
->comment('소구 통지 기한 (부도일+4영업일)');
|
||||
|
||||
// === 분할배서 ===
|
||||
$table->boolean('is_split')->default(false)->after('recourse_notice_deadline')
|
||||
->comment('분할배서 허용 여부');
|
||||
});
|
||||
|
||||
// bill_installments 에 처리구분, 상대처 추가
|
||||
Schema::table('bill_installments', function (Blueprint $table) {
|
||||
$table->string('type', 30)->default('other')->after('bill_id')
|
||||
->comment('처리구분: received/endorsement/splitEndorsement/collection/...');
|
||||
$table->string('counterparty', 100)->nullable()->after('amount')
|
||||
->comment('상대처 (거래처/은행)');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('bill_installments', function (Blueprint $table) {
|
||||
$table->dropColumn(['type', 'counterparty']);
|
||||
});
|
||||
|
||||
Schema::table('bills', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'instrument_type', 'medium', 'bill_category',
|
||||
'electronic_bill_no', 'registration_org',
|
||||
'drawee', 'acceptance_status', 'acceptance_date',
|
||||
'acceptance_refusal_date', 'acceptance_refusal_reason',
|
||||
'endorsement', 'endorsement_order', 'storage_place', 'issuer_bank',
|
||||
'is_discounted', 'discount_date', 'discount_bank', 'discount_rate', 'discount_amount',
|
||||
'endorsement_date', 'endorsee', 'endorsement_reason',
|
||||
'collection_bank', 'collection_request_date', 'collection_fee',
|
||||
'collection_complete_date', 'collection_result', 'collection_deposit_date', 'collection_deposit_amount',
|
||||
'settlement_bank', 'payment_method', 'actual_payment_date',
|
||||
'payment_place', 'payment_place_detail',
|
||||
'renewal_date', 'renewal_new_bill_no', 'renewal_reason',
|
||||
'recourse_date', 'recourse_amount', 'recourse_target', 'recourse_reason',
|
||||
'buyback_date', 'buyback_amount', 'buyback_bank',
|
||||
'dishonored_date', 'dishonored_reason', 'has_protest', 'protest_date',
|
||||
'recourse_notice_date', 'recourse_notice_deadline',
|
||||
'is_split',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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::table('loans', function (Blueprint $table) {
|
||||
$table->json('metadata')->nullable()->after('category');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('loans', function (Blueprint $table) {
|
||||
$table->dropColumn('metadata');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?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::table('expense_accounts', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('loan_id')->nullable()->after('card_no')
|
||||
->comment('연결된 가지급금 ID (상품권→접대비 전환 시)');
|
||||
|
||||
$table->index('loan_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('expense_accounts', function (Blueprint $table) {
|
||||
$table->dropIndex(['loan_id']);
|
||||
$table->dropColumn('loan_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -12,9 +12,11 @@
|
||||
* - 대시보드/보고서
|
||||
*/
|
||||
|
||||
use App\Http\Controllers\Api\V1\AccountSubjectController;
|
||||
use App\Http\Controllers\Api\V1\BadDebtController;
|
||||
use App\Http\Controllers\Api\V1\BankAccountController;
|
||||
use App\Http\Controllers\Api\V1\BankTransactionController;
|
||||
use App\Http\Controllers\Api\V1\BarobillController;
|
||||
use App\Http\Controllers\Api\V1\BarobillSettingController;
|
||||
use App\Http\Controllers\Api\V1\BillController;
|
||||
use App\Http\Controllers\Api\V1\CalendarController;
|
||||
@@ -25,6 +27,7 @@
|
||||
use App\Http\Controllers\Api\V1\DepositController;
|
||||
use App\Http\Controllers\Api\V1\EntertainmentController;
|
||||
use App\Http\Controllers\Api\V1\ExpectedExpenseController;
|
||||
use App\Http\Controllers\Api\V1\GeneralJournalEntryController;
|
||||
use App\Http\Controllers\Api\V1\LoanController;
|
||||
use App\Http\Controllers\Api\V1\PaymentController;
|
||||
use App\Http\Controllers\Api\V1\PayrollController;
|
||||
@@ -182,6 +185,7 @@
|
||||
Route::get('/note-receivables', [DailyReportController::class, 'noteReceivables'])->name('v1.daily-report.note-receivables');
|
||||
Route::get('/daily-accounts', [DailyReportController::class, 'dailyAccounts'])->name('v1.daily-report.daily-accounts');
|
||||
Route::get('/summary', [DailyReportController::class, 'summary'])->name('v1.daily-report.summary');
|
||||
Route::get('/export', [DailyReportController::class, 'export'])->name('v1.daily-report.export');
|
||||
});
|
||||
|
||||
// Comprehensive Analysis API (종합 분석 보고서)
|
||||
@@ -199,12 +203,17 @@
|
||||
|
||||
// Calendar API (CEO 대시보드 캘린더)
|
||||
Route::get('/calendar/schedules', [CalendarController::class, 'summary'])->name('v1.calendar.schedules');
|
||||
Route::post('/calendar/schedules', [CalendarController::class, 'store'])->name('v1.calendar.schedules.store');
|
||||
Route::put('/calendar/schedules/{id}', [CalendarController::class, 'update'])->whereNumber('id')->name('v1.calendar.schedules.update');
|
||||
Route::delete('/calendar/schedules/{id}', [CalendarController::class, 'destroy'])->whereNumber('id')->name('v1.calendar.schedules.destroy');
|
||||
|
||||
// Vat API (CEO 대시보드 부가세 현황)
|
||||
Route::get('/vat/summary', [VatController::class, 'summary'])->name('v1.vat.summary');
|
||||
Route::get('/vat/detail', [VatController::class, 'detail'])->name('v1.vat.detail');
|
||||
|
||||
// Entertainment API (CEO 대시보드 접대비 현황)
|
||||
Route::get('/entertainment/summary', [EntertainmentController::class, 'summary'])->name('v1.entertainment.summary');
|
||||
Route::get('/entertainment/detail', [EntertainmentController::class, 'detail'])->name('v1.entertainment.detail');
|
||||
|
||||
// Welfare API (CEO 대시보드 복리후생비 현황)
|
||||
Route::get('/welfare/summary', [WelfareController::class, 'summary'])->name('v1.welfare.summary');
|
||||
@@ -255,6 +264,17 @@
|
||||
Route::post('/test-connection', [BarobillSettingController::class, 'testConnection'])->name('v1.barobill-settings.test-connection');
|
||||
});
|
||||
|
||||
// Barobill Integration API (바로빌 연동)
|
||||
Route::prefix('barobill')->group(function () {
|
||||
Route::get('/status', [BarobillController::class, 'status'])->name('v1.barobill.status');
|
||||
Route::post('/login', [BarobillController::class, 'login'])->name('v1.barobill.login');
|
||||
Route::post('/signup', [BarobillController::class, 'signup'])->name('v1.barobill.signup');
|
||||
Route::get('/bank-service-url', [BarobillController::class, 'bankServiceUrl'])->name('v1.barobill.bank-service-url');
|
||||
Route::get('/account-link-url', [BarobillController::class, 'accountLinkUrl'])->name('v1.barobill.account-link-url');
|
||||
Route::get('/card-link-url', [BarobillController::class, 'cardLinkUrl'])->name('v1.barobill.card-link-url');
|
||||
Route::get('/certificate-url', [BarobillController::class, 'certificateUrl'])->name('v1.barobill.certificate-url');
|
||||
});
|
||||
|
||||
// Tax Invoice API (세금계산서)
|
||||
Route::prefix('tax-invoices')->group(function () {
|
||||
Route::get('', [TaxInvoiceController::class, 'index'])->name('v1.tax-invoices.index');
|
||||
@@ -286,6 +306,24 @@
|
||||
Route::delete('/{id}/memos/{memoId}', [BadDebtController::class, 'removeMemo'])->whereNumber(['id', 'memoId'])->name('v1.bad-debts.memos.destroy');
|
||||
});
|
||||
|
||||
// General Journal Entry API (일반전표입력)
|
||||
Route::prefix('general-journal-entries')->group(function () {
|
||||
Route::get('', [GeneralJournalEntryController::class, 'index'])->name('v1.general-journal-entries.index');
|
||||
Route::get('/summary', [GeneralJournalEntryController::class, 'summary'])->name('v1.general-journal-entries.summary');
|
||||
Route::post('', [GeneralJournalEntryController::class, 'store'])->name('v1.general-journal-entries.store');
|
||||
Route::get('/{id}', [GeneralJournalEntryController::class, 'show'])->whereNumber('id')->name('v1.general-journal-entries.show');
|
||||
Route::put('/{id}/journal', [GeneralJournalEntryController::class, 'updateJournal'])->whereNumber('id')->name('v1.general-journal-entries.update-journal');
|
||||
Route::delete('/{id}/journal', [GeneralJournalEntryController::class, 'destroyJournal'])->whereNumber('id')->name('v1.general-journal-entries.destroy-journal');
|
||||
});
|
||||
|
||||
// Account Subject API (계정과목)
|
||||
Route::prefix('account-subjects')->group(function () {
|
||||
Route::get('', [AccountSubjectController::class, 'index'])->name('v1.account-subjects.index');
|
||||
Route::post('', [AccountSubjectController::class, 'store'])->name('v1.account-subjects.store');
|
||||
Route::patch('/{id}/status', [AccountSubjectController::class, 'toggleStatus'])->whereNumber('id')->name('v1.account-subjects.toggle-status');
|
||||
Route::delete('/{id}', [AccountSubjectController::class, 'destroy'])->whereNumber('id')->name('v1.account-subjects.destroy');
|
||||
});
|
||||
|
||||
// Bill API (어음관리)
|
||||
Route::prefix('bills')->group(function () {
|
||||
Route::get('', [BillController::class, 'index'])->name('v1.bills.index');
|
||||
|
||||
Reference in New Issue
Block a user