feat: CEO 대시보드 API 구현 및 DB 컬럼 오류 수정

- StatusBoardService: 현황판 8개 항목 집계 API
- CalendarService: 캘린더 일정 조회 API (작업지시/계약/휴가)
- TodayIssueService: 오늘의 이슈 리스트 API
- VatService: 부가세 신고 현황 API
- EntertainmentService: 접대비 현황 API
- WelfareService: 복리후생 현황 API

버그 수정:
- orders 테이블 status → status_code 컬럼명 수정
- users 테이블 department 관계 → tenantProfile.department로 수정
- Swagger 문서 및 라우트 추가
This commit is contained in:
2026-01-21 10:25:18 +09:00
parent 637ebe2e7f
commit f7850e43a7
20 changed files with 2712 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\CalendarService;
use Carbon\Carbon;
use Illuminate\Http\Request;
/**
* CEO 대시보드 캘린더 컨트롤러
*/
class CalendarController extends Controller
{
public function __construct(
private readonly CalendarService $calendarService
) {}
/**
* 캘린더 일정 조회
*
* @param Request $request
* - start_date: 조회 시작일 (Y-m-d, 기본: 이번 달 1일)
* - end_date: 조회 종료일 (Y-m-d, 기본: 이번 달 말일)
* - type: 일정 타입 필터 (schedule|order|construction|null=전체)
* - department_filter: 부서 필터 (all|department|personal, 기본: all)
*/
public function summary(Request $request)
{
$validated = $request->validate([
'start_date' => 'nullable|date_format:Y-m-d',
'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date',
'type' => 'nullable|in:schedule,order,construction,other',
'department_filter' => 'nullable|in:all,department,personal',
]);
// 기본값 설정: 이번 달 전체
$today = Carbon::today();
$startDate = $validated['start_date'] ?? $today->copy()->startOfMonth()->format('Y-m-d');
$endDate = $validated['end_date'] ?? $today->copy()->endOfMonth()->format('Y-m-d');
$type = $validated['type'] ?? null;
$departmentFilter = $validated['department_filter'] ?? 'all';
$data = $this->calendarService->getSchedules(
$startDate,
$endDate,
$type,
$departmentFilter
);
return ApiResponse::handle(
data: $data,
message: __('message.fetched')
);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\EntertainmentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 접대비 현황 컨트롤러
*
* CEO 대시보드용 접대비 현황 데이터를 제공합니다.
*/
class EntertainmentController extends Controller
{
public function __construct(
private readonly EntertainmentService $entertainmentService
) {}
/**
* 접대비 현황 요약 조회
*
* @param Request $request
* @return JsonResponse
*/
public function summary(Request $request): JsonResponse
{
$limitType = $request->query('limit_type', 'quarterly');
$companyType = $request->query('company_type', 'medium');
$year = $request->query('year') ? (int) $request->query('year') : null;
$quarter = $request->query('quarter') ? (int) $request->query('quarter') : null;
$data = $this->entertainmentService->getSummary($limitType, $companyType, $year, $quarter);
return ApiResponse::handle($data, __('message.fetched'));
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\StatusBoardService;
/**
* CEO 대시보드 현황판 컨트롤러
*/
class StatusBoardController extends Controller
{
public function __construct(
private readonly StatusBoardService $statusBoardService
) {}
/**
* 현황판 요약 데이터 조회
*/
public function summary()
{
$data = $this->statusBoardService->summary();
return ApiResponse::handle(
data: $data,
message: __('message.fetched')
);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Responses\ApiResponse;
use App\Services\TodayIssueService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TodayIssueController extends Controller
{
public function __construct(
private readonly TodayIssueService $todayIssueService
) {}
/**
* 오늘의 이슈 리스트 조회
*/
public function summary(Request $request): JsonResponse
{
$limit = $request->input('limit', 30);
$data = $this->todayIssueService->summary((int) $limit);
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\VatService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 부가세 현황 컨트롤러
*
* CEO 대시보드용 부가세 현황 데이터를 제공합니다.
*/
class VatController extends Controller
{
public function __construct(
private readonly VatService $vatService
) {}
/**
* 부가세 현황 요약 조회
*
* @param Request $request
* @return JsonResponse
*/
public function summary(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;
$data = $this->vatService->getSummary($periodType, $year, $period);
return ApiResponse::handle($data, __('message.fetched'));
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\WelfareService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 복리후생비 현황 컨트롤러
*
* CEO 대시보드용 복리후생비 현황 데이터를 제공합니다.
*/
class WelfareController extends Controller
{
public function __construct(
private readonly WelfareService $welfareService
) {}
/**
* 복리후생비 현황 요약 조회
*
* @param Request $request
* @return JsonResponse
*/
public function summary(Request $request): JsonResponse
{
$limitType = $request->query('limit_type', 'quarterly');
$calculationType = $request->query('calculation_type', 'fixed');
$fixedAmountPerMonth = $request->query('fixed_amount_per_month')
? (int) $request->query('fixed_amount_per_month')
: 200000;
$ratio = $request->query('ratio')
? (float) $request->query('ratio')
: 0.05;
$year = $request->query('year') ? (int) $request->query('year') : null;
$quarter = $request->query('quarter') ? (int) $request->query('quarter') : null;
$data = $this->welfareService->getSummary(
$limitType,
$calculationType,
$fixedAmountPerMonth,
$ratio,
$year,
$quarter
);
return ApiResponse::handle($data, __('message.fetched'));
}
}

View File

@@ -0,0 +1,220 @@
<?php
namespace App\Services;
use App\Models\Construction\Contract;
use App\Models\Production\WorkOrder;
use App\Models\Tenants\Leave;
use Carbon\Carbon;
use Illuminate\Support\Collection;
/**
* CEO 대시보드 캘린더 서비스
*
* 각 카테고리별 일정을 집계하여 캘린더 데이터 제공
* - 작업지시(WorkOrder): 생산 일정
* - 계약(Contract): 시공 일정
* - 휴가(Leave): 직원 휴가 일정
*/
class CalendarService extends Service
{
/**
* 캘린더 일정 조회
*
* @param string $startDate 조회 시작일 (Y-m-d)
* @param string $endDate 조회 종료일 (Y-m-d)
* @param string|null $type 일정 타입 필터 (schedule|order|construction|other|null=전체)
* @param string|null $departmentFilter 부서 필터 (all|department|personal)
*/
public function getSchedules(
string $startDate,
string $endDate,
?string $type = null,
?string $departmentFilter = 'all'
): array {
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$schedules = collect();
// 타입 필터에 따라 데이터 수집
if ($type === null || $type === 'order') {
$schedules = $schedules->merge(
$this->getWorkOrderSchedules($tenantId, $startDate, $endDate, $departmentFilter, $userId)
);
}
if ($type === null || $type === 'construction') {
$schedules = $schedules->merge(
$this->getContractSchedules($tenantId, $startDate, $endDate, $departmentFilter, $userId)
);
}
if ($type === null || $type === 'schedule') {
$schedules = $schedules->merge(
$this->getLeaveSchedules($tenantId, $startDate, $endDate, $departmentFilter, $userId)
);
}
// startDate 기준 정렬
$sortedSchedules = $schedules
->sortBy('startDate')
->values()
->toArray();
return [
'items' => $sortedSchedules,
'total_count' => count($sortedSchedules),
];
}
/**
* 작업지시(발주) 일정 조회
*/
private function getWorkOrderSchedules(
int $tenantId,
string $startDate,
string $endDate,
string $departmentFilter,
int $userId
): Collection {
$query = WorkOrder::query()
->where('tenant_id', $tenantId)
->where('is_active', true)
->whereNotNull('scheduled_date')
->where('scheduled_date', '>=', $startDate)
->where('scheduled_date', '<=', $endDate)
->with(['assignee:id,name', 'assignee.tenantProfile:id,user_id,department_id', 'assignee.tenantProfile.department:id,name']);
// 부서 필터 적용
if ($departmentFilter === 'personal') {
$query->where('assignee_id', $userId);
}
// department 필터는 부서별 필터링 로직 추가 필요 (현재는 전체)
$workOrders = $query->orderBy('scheduled_date')->limit(100)->get();
return $workOrders->map(function ($wo) {
$assigneeName = $wo->assignee?->name;
$departmentName = $wo->assignee?->tenantProfile?->department?->name;
return [
'id' => 'wo_'.$wo->id,
'title' => $wo->project_name ?? $wo->work_order_no,
'startDate' => $wo->scheduled_date?->format('Y-m-d'),
'endDate' => $wo->scheduled_date?->format('Y-m-d'),
'startTime' => null,
'endTime' => null,
'isAllDay' => true,
'type' => 'order',
'department' => $departmentName,
'personName' => $assigneeName,
'color' => null,
];
});
}
/**
* 계약(시공) 일정 조회
*/
private function getContractSchedules(
int $tenantId,
string $startDate,
string $endDate,
string $departmentFilter,
int $userId
): Collection {
$query = Contract::query()
->where('tenant_id', $tenantId)
->where('is_active', true)
->whereNotNull('contract_start_date')
->where(function ($q) use ($startDate, $endDate) {
// 기간이 겹치는 계약 조회
$q->where(function ($sub) use ($startDate, $endDate) {
$sub->where('contract_start_date', '<=', $endDate)
->where(function ($inner) use ($startDate) {
$inner->where('contract_end_date', '>=', $startDate)
->orWhereNull('contract_end_date');
});
});
})
->with(['constructionPm:id,name', 'constructionPm.tenantProfile:id,user_id,department_id', 'constructionPm.tenantProfile.department:id,name']);
// 부서 필터 적용
if ($departmentFilter === 'personal') {
$query->where('construction_pm_id', $userId);
}
$contracts = $query->orderBy('contract_start_date')->limit(100)->get();
return $contracts->map(function ($contract) {
$pmName = $contract->constructionPm?->name;
$departmentName = $contract->constructionPm?->tenantProfile?->department?->name;
return [
'id' => 'contract_'.$contract->id,
'title' => $contract->project_name ?? $contract->contract_code,
'startDate' => $contract->contract_start_date?->format('Y-m-d'),
'endDate' => $contract->contract_end_date?->format('Y-m-d') ?? $contract->contract_start_date?->format('Y-m-d'),
'startTime' => null,
'endTime' => null,
'isAllDay' => true,
'type' => 'construction',
'department' => $departmentName,
'personName' => $pmName,
'color' => null,
];
});
}
/**
* 휴가 일정 조회
*/
private function getLeaveSchedules(
int $tenantId,
string $startDate,
string $endDate,
string $departmentFilter,
int $userId
): Collection {
$query = Leave::query()
->where('tenant_id', $tenantId)
->where('status', 'approved')
->where(function ($q) use ($startDate, $endDate) {
// 기간이 겹치는 휴가 조회
$q->where('start_date', '<=', $endDate)
->where('end_date', '>=', $startDate);
})
->with(['user:id,name', 'user.tenantProfile:id,user_id,department_id', 'user.tenantProfile.department:id,name']);
// 부서 필터 적용
if ($departmentFilter === 'personal') {
$query->where('user_id', $userId);
}
$leaves = $query->orderBy('start_date')->limit(100)->get();
return $leaves->map(function ($leave) {
$userName = $leave->user?->name;
$departmentName = $leave->user?->tenantProfile?->department?->name;
$leaveType = $leave->type ?? 'leave';
$title = $userName
? __('message.calendar.leave_title', ['name' => $userName])
: __('message.calendar.leave_default');
return [
'id' => 'leave_'.$leave->id,
'title' => $title,
'startDate' => $leave->start_date?->format('Y-m-d'),
'endDate' => $leave->end_date?->format('Y-m-d'),
'startTime' => null,
'endTime' => null,
'isAllDay' => true,
'type' => 'schedule',
'department' => $departmentName,
'personName' => $userName,
'color' => null,
];
});
}
}

View File

@@ -0,0 +1,258 @@
<?php
namespace App\Services;
use App\Models\Tenants\ExpenseAccount;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/**
* 접대비 현황 서비스
*
* CEO 대시보드용 접대비 데이터를 제공합니다.
*/
class EntertainmentService extends Service
{
// 접대비 기본 한도율 (중소기업 기준: 매출의 0.3%)
private const DEFAULT_LIMIT_RATE = 0.003;
// 기업 규모별 기본 한도 (연간)
private const COMPANY_TYPE_LIMITS = [
'large' => 36000000, // 대기업: 연 3,600만원
'medium' => 36000000, // 중견기업: 연 3,600만원
'small' => 24000000, // 중소기업: 연 2,400만원
];
/**
* 접대비 현황 요약 조회
*
* @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(
?string $limitType = 'quarterly',
?string $companyType = 'medium',
?int $year = null,
?int $quarter = null
): array {
$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);
// 카드 데이터 구성
$cards = [
[
'id' => 'et_sales',
'label' => '매출',
'amount' => (int) $annualSales,
],
[
'id' => 'et_limit',
'label' => "{{$periodLabel}} 접대비 총 한도",
'amount' => (int) $periodLimit,
],
[
'id' => 'et_remaining',
'label' => "{{$periodLabel}} 접대비 잔여한도",
'amount' => (int) $remainingLimit,
],
[
'id' => 'et_used',
'label' => "{{$periodLabel}} 접대비 사용금액",
'amount' => (int) $usedAmount,
],
];
// 체크포인트 생성
$checkPoints = $this->generateCheckPoints(
$periodLabel,
$periodLimit,
$usedAmount,
$remainingLimit,
$tenantId,
$startDate,
$endDate
);
return [
'cards' => $cards,
'check_points' => $checkPoints,
];
}
/**
* 연간 매출액 조회
*/
private function getAnnualSales(int $tenantId, string $startDate, string $endDate): float
{
// TODO: 실제 매출 테이블에서 조회
// 현재는 임시로 고정값 반환 (orders 테이블 또는 invoices 테이블에서 합계)
return DB::table('orders')
->where('tenant_id', $tenantId)
->whereBetween('order_date', [$startDate, $endDate])
->whereNull('deleted_at')
->sum('total_amount') ?: 30530000000;
}
/**
* 접대비 한도 계산
*/
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')
->where('tenant_id', $tenantId)
->where('account_type', 'entertainment')
->whereBetween('expense_date', [$startDate, $endDate])
->whereNull('deleted_at')
->sum('amount');
return $amount ?: 10000000; // 임시 기본값
}
/**
* 거래처 누락 건수 조회
*/
private function getMissingVendorCount(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')
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
->first();
return [
'count' => $result->count ?? 0,
'total' => $result->total ?? 0,
];
}
/**
* 체크포인트 생성
*/
private function generateCheckPoints(
string $periodLabel,
float $limit,
float $used,
float $remaining,
int $tenantId,
string $startDate,
string $endDate
): array {
$checkPoints = [];
$usageRate = $limit > 0 ? ($used / $limit) * 100 : 0;
$usedFormatted = number_format($used / 10000);
$limitFormatted = number_format($limit / 10000);
$remainingFormatted = number_format($remaining / 10000);
// 사용률에 따른 체크포인트
if ($usageRate <= 75) {
// 정상 운영
$remainingRate = 100 - $usageRate;
$checkPoints[] = [
'id' => 'et_cp_normal',
'type' => 'success',
'message' => "{{$periodLabel}} 접대비 사용 {$usedFormatted}만원 / 한도 {$limitFormatted}만원 ({$remainingRate:.0f}%). 여유 있게 운영 중입니다.",
'highlights' => [
['text' => "{$usedFormatted}만원", 'color' => 'green'],
['text' => "{$limitFormatted}만원 ({$remainingRate:.0f}%)", 'color' => 'green'],
],
];
} elseif ($usageRate <= 100) {
// 주의 (85% 이상)
$checkPoints[] = [
'id' => 'et_cp_warning',
'type' => 'warning',
'message' => "접대비 한도 {$usageRate:.0f}% 도달. 잔여 한도 {$remainingFormatted}만원입니다. 사용 계획을 점검해 주세요.",
'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'],
],
];
}
// 거래처 정보 누락 체크
$missingVendor = $this->getMissingVendorCount($tenantId, $startDate, $endDate);
if ($missingVendor['count'] > 0) {
$missingTotal = number_format($missingVendor['total'] / 10000);
$checkPoints[] = [
'id' => 'et_cp_missing',
'type' => 'error',
'message' => "접대비 사용 중 {$missingVendor['count']}건({$missingTotal}만원)의 거래처 정보가 누락되었습니다. 기록을 보완해 주세요.",
'highlights' => [
['text' => "{$missingVendor['count']}건({$missingTotal}만원)", 'color' => 'red'],
['text' => '거래처 정보가 누락', 'color' => 'red'],
],
];
}
return $checkPoints;
}
}

View File

@@ -0,0 +1,231 @@
<?php
namespace App\Services;
use App\Models\BadDebts\BadDebt;
use App\Models\Orders\Client;
use App\Models\Orders\Order;
use App\Models\Tenants\Approval;
use App\Models\Tenants\ApprovalStep;
use App\Models\Tenants\Leave;
use App\Models\Tenants\Purchase;
use App\Models\Tenants\Stock;
use Carbon\Carbon;
/**
* CEO 대시보드 현황판(StatusBoard) 서비스
*
* 각 카테고리별 건수를 집계하여 현황판 데이터 제공
*/
class StatusBoardService extends Service
{
/**
* 현황판 전체 데이터 조회
*/
public function summary(): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$today = Carbon::today();
return [
'items' => [
$this->getOrdersStatus($tenantId, $today),
$this->getBadDebtStatus($tenantId),
$this->getSafetyStockStatus($tenantId),
$this->getTaxDeadlineStatus($tenantId, $today),
$this->getNewClientStatus($tenantId, $today),
$this->getLeaveStatus($tenantId, $today),
$this->getPurchaseStatus($tenantId),
$this->getApprovalStatus($tenantId, $userId),
],
];
}
/**
* 수주 현황 (오늘 신규 수주 건수)
*/
private function getOrdersStatus(int $tenantId, Carbon $today): array
{
$count = Order::query()
->where('tenant_id', $tenantId)
->whereDate('created_at', $today)
->where('status_code', 'confirmed') // 확정된 수주만
->count();
return [
'id' => 'orders',
'label' => __('message.status_board.orders'),
'count' => $count,
'path' => '/sales/order-management-sales',
'isHighlighted' => false,
];
}
/**
* 채권 추심 현황 (추심 진행 중인 건수)
*/
private function getBadDebtStatus(int $tenantId): array
{
$count = BadDebt::query()
->where('tenant_id', $tenantId)
->where('status', 'in_progress') // 추심 진행 중
->count();
return [
'id' => 'bad_debts',
'label' => __('message.status_board.bad_debts'),
'count' => $count,
'path' => '/accounting/bad-debt-collection',
'isHighlighted' => false,
];
}
/**
* 안전 재고 현황 (안전재고 미달 품목 수)
*/
private function getSafetyStockStatus(int $tenantId): array
{
$count = Stock::query()
->where('tenant_id', $tenantId)
->where('safety_stock', '>', 0) // 안전재고 설정된 품목만
->whereColumn('stock_qty', '<', 'safety_stock')
->count();
$isHighlighted = $count > 0; // 미달 품목 있으면 강조
return [
'id' => 'safety_stock',
'label' => __('message.status_board.safety_stock'),
'count' => $count,
'path' => '/material/stock-status',
'isHighlighted' => $isHighlighted,
];
}
/**
* 세금 신고 현황 (부가세 신고 D-day)
*/
private function getTaxDeadlineStatus(int $tenantId, Carbon $today): array
{
// 부가세 신고 마감일 계산 (분기별: 1/25, 4/25, 7/25, 10/25)
$quarter = $today->quarter;
$deadlineMonth = match ($quarter) {
1 => 1, // 1분기 → 1월 25일
2 => 4, // 2분기 → 4월 25일
3 => 7, // 3분기 → 7월 25일
4 => 10, // 4분기 → 10월 25일
};
$deadlineYear = $today->year;
// 1분기 마감일이 지났으면 다음 분기 마감일
if ($today->month > $deadlineMonth || ($today->month == $deadlineMonth && $today->day > 25)) {
$deadlineMonth = match ($quarter) {
1 => 4,
2 => 7,
3 => 10,
4 => 1, // 다음 해
};
if ($deadlineMonth == 1) {
$deadlineYear++;
}
}
$deadline = Carbon::create($deadlineYear, $deadlineMonth, 25);
$daysUntil = $today->diffInDays($deadline, false);
$countText = $daysUntil >= 0
? __('message.status_board.tax_d_day', ['days' => $daysUntil])
: __('message.status_board.tax_overdue', ['days' => abs($daysUntil)]);
return [
'id' => 'tax_deadline',
'label' => __('message.status_board.tax_deadline'),
'count' => $countText,
'path' => '/accounting/tax',
'isHighlighted' => $daysUntil <= 7 && $daysUntil >= 0,
];
}
/**
* 신규 업체 등록 현황 (최근 7일 신규 거래처)
*/
private function getNewClientStatus(int $tenantId, Carbon $today): array
{
$count = Client::query()
->where('tenant_id', $tenantId)
->where('created_at', '>=', $today->copy()->subDays(7))
->count();
return [
'id' => 'new_clients',
'label' => __('message.status_board.new_clients'),
'count' => $count,
'path' => '/accounting/vendors',
'isHighlighted' => false,
];
}
/**
* 연차 현황 (오늘 휴가 중인 인원)
*/
private function getLeaveStatus(int $tenantId, Carbon $today): array
{
$count = Leave::query()
->where('tenant_id', $tenantId)
->where('status', 'approved')
->whereDate('start_date', '<=', $today)
->whereDate('end_date', '>=', $today)
->count();
return [
'id' => 'leaves',
'label' => __('message.status_board.leaves'),
'count' => $count,
'path' => '/hr/vacation-management',
'isHighlighted' => false,
];
}
/**
* 발주 현황 (발주 대기 건수)
*/
private function getPurchaseStatus(int $tenantId): array
{
$count = Purchase::query()
->where('tenant_id', $tenantId)
->where('status', 'pending') // 대기 중인 발주
->count();
return [
'id' => 'purchases',
'label' => __('message.status_board.purchases'),
'count' => $count,
'path' => '/construction/order/order-management',
'isHighlighted' => false,
];
}
/**
* 결재 요청 현황 (나의 결재 대기 건수)
*/
private function getApprovalStatus(int $tenantId, int $userId): array
{
$count = ApprovalStep::query()
->whereHas('approval', function ($query) use ($tenantId) {
$query->where('tenant_id', $tenantId)
->where('status', 'pending');
})
->where('approver_id', $userId)
->where('status', 'pending')
->count();
return [
'id' => 'approvals',
'label' => __('message.status_board.approvals'),
'count' => $count,
'path' => '/approval/inbox',
'isHighlighted' => $count > 0,
];
}
}

View File

@@ -0,0 +1,384 @@
<?php
namespace App\Services;
use App\Models\BadDebts\BadDebt;
use App\Models\Orders\Client;
use App\Models\Orders\Order;
use App\Models\Tenants\Approval;
use App\Models\Tenants\ApprovalStep;
use App\Models\Tenants\ExpectedExpense;
use App\Models\Tenants\Leave;
use App\Models\Tenants\Stock;
use Carbon\Carbon;
/**
* CEO 대시보드 오늘의 이슈 리스트 서비스
*
* 각 카테고리별 상세 알림 항목을 집계하여 리스트 형태로 제공
*/
class TodayIssueService extends Service
{
/**
* 오늘의 이슈 리스트 조회
*
* @param int $limit 조회할 최대 항목 수 (기본 30)
*/
public function summary(int $limit = 30): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$today = Carbon::today();
// 각 카테고리별 이슈 수집
$issues = collect();
// 1. 수주 성공 (최근 7일)
$issues = $issues->merge($this->getOrderSuccessIssues($tenantId, $today));
// 2. 미수금 이슈 (주식 이슈 - 연체 미수금)
$issues = $issues->merge($this->getReceivableIssues($tenantId));
// 3. 재고 이슈 (직정 제고 - 안전재고 미달)
$issues = $issues->merge($this->getStockIssues($tenantId));
// 4. 지출예상내역서 (승인 대기 건)
$issues = $issues->merge($this->getExpectedExpenseIssues($tenantId));
// 5. 세금 신고 (부가세 D-day)
$issues = $issues->merge($this->getTaxIssues($tenantId, $today));
// 6. 결재 요청 (내 결재 대기 건)
$issues = $issues->merge($this->getApprovalIssues($tenantId, $userId));
// 7. 기타 (신규 거래처 등록)
$issues = $issues->merge($this->getOtherIssues($tenantId, $today));
// 날짜 기준 내림차순 정렬 후 limit 적용
$sortedIssues = $issues
->sortByDesc('created_at')
->take($limit)
->values()
->map(function ($item) {
// created_at 필드 제거 (정렬용으로만 사용)
unset($item['created_at']);
return $item;
})
->toArray();
return [
'items' => $sortedIssues,
'total_count' => $issues->count(),
];
}
/**
* 수주 성공 이슈 (최근 7일 확정 수주)
*/
private function getOrderSuccessIssues(int $tenantId, Carbon $today): array
{
$orders = Order::query()
->where('tenant_id', $tenantId)
->where('status_code', 'confirmed')
->where('created_at', '>=', $today->copy()->subDays(7))
->with('client:id,name')
->orderByDesc('created_at')
->limit(10)
->get();
return $orders->map(function ($order) {
$clientName = $order->client?->name ?? __('message.today_issue.unknown_client');
$amount = number_format($order->total_amount ?? 0);
return [
'id' => 'order_'.$order->id,
'badge' => '수주 성공',
'content' => __('message.today_issue.order_success', [
'client' => $clientName,
'amount' => $amount,
]),
'time' => $this->formatRelativeTime($order->created_at),
'date' => $order->created_at?->toDateString(),
'needsApproval' => false,
'path' => '/sales/order-management-sales',
'created_at' => $order->created_at,
];
})->toArray();
}
/**
* 미수금 이슈 (주식 이슈 - 연체 미수금)
*/
private function getReceivableIssues(int $tenantId): array
{
// BadDebt 모델에서 추심 진행 중인 건 조회
$badDebts = BadDebt::query()
->where('tenant_id', $tenantId)
->whereIn('status', ['in_progress', 'legal_action'])
->with('client:id,name')
->orderByDesc('created_at')
->limit(10)
->get();
return $badDebts->map(function ($debt) {
$clientName = $debt->client?->name ?? __('message.today_issue.unknown_client');
$amount = number_format($debt->total_amount ?? 0);
$days = $debt->overdue_days ?? 0;
return [
'id' => 'receivable_'.$debt->id,
'badge' => '주식 이슈',
'content' => __('message.today_issue.receivable_overdue', [
'client' => $clientName,
'amount' => $amount,
'days' => $days,
]),
'time' => $this->formatRelativeTime($debt->created_at),
'date' => $debt->created_at?->toDateString(),
'needsApproval' => false,
'path' => '/accounting/receivables-status',
'created_at' => $debt->created_at,
];
})->toArray();
}
/**
* 재고 이슈 (직정 제고 - 안전재고 미달)
*/
private function getStockIssues(int $tenantId): array
{
$stocks = Stock::query()
->where('tenant_id', $tenantId)
->where('safety_stock', '>', 0)
->whereColumn('stock_qty', '<', 'safety_stock')
->with('item:id,name,code')
->orderByDesc('updated_at')
->limit(10)
->get();
return $stocks->map(function ($stock) {
$itemName = $stock->item?->name ?? $stock->item?->code ?? __('message.today_issue.unknown_item');
return [
'id' => 'stock_'.$stock->id,
'badge' => '직정 제고',
'content' => __('message.today_issue.stock_below_safety', [
'item' => $itemName,
]),
'time' => $this->formatRelativeTime($stock->updated_at),
'date' => $stock->updated_at?->toDateString(),
'needsApproval' => false,
'path' => '/material/stock-status',
'created_at' => $stock->updated_at,
];
})->toArray();
}
/**
* 지출예상내역서 이슈 (승인 대기)
*/
private function getExpectedExpenseIssues(int $tenantId): array
{
$expenses = ExpectedExpense::query()
->where('tenant_id', $tenantId)
->where('payment_status', 'pending')
->orderByDesc('created_at')
->limit(10)
->get();
// 그룹화: 같은 날짜의 품의서들을 묶어서 표시
if ($expenses->isEmpty()) {
return [];
}
$totalCount = $expenses->count();
$totalAmount = $expenses->sum('amount');
$firstExpense = $expenses->first();
$title = $firstExpense->description ?? __('message.today_issue.expense_item');
$content = $totalCount > 1
? __('message.today_issue.expense_pending_multiple', [
'title' => $title,
'count' => $totalCount - 1,
'amount' => number_format($totalAmount),
])
: __('message.today_issue.expense_pending_single', [
'title' => $title,
'amount' => number_format($totalAmount),
]);
return [
[
'id' => 'expense_summary',
'badge' => '지출예상내역서',
'content' => $content,
'time' => $this->formatRelativeTime($firstExpense->created_at),
'date' => $firstExpense->created_at?->toDateString(),
'needsApproval' => true,
'path' => '/approval/inbox',
'created_at' => $firstExpense->created_at,
],
];
}
/**
* 세금 신고 이슈 (부가세 D-day)
*/
private function getTaxIssues(int $tenantId, Carbon $today): array
{
// 부가세 신고 마감일 계산 (분기별: 1/25, 4/25, 7/25, 10/25)
$quarter = $today->quarter;
$deadlineMonth = match ($quarter) {
1 => 1,
2 => 4,
3 => 7,
4 => 10,
};
$deadlineYear = $today->year;
if ($today->month > $deadlineMonth || ($today->month == $deadlineMonth && $today->day > 25)) {
$deadlineMonth = match ($quarter) {
1 => 4,
2 => 7,
3 => 10,
4 => 1,
};
if ($deadlineMonth == 1) {
$deadlineYear++;
}
}
$deadline = Carbon::create($deadlineYear, $deadlineMonth, 25);
$daysUntil = $today->diffInDays($deadline, false);
// D-30 이내인 경우에만 표시
if ($daysUntil > 30 || $daysUntil < 0) {
return [];
}
$quarterName = match ($deadlineMonth) {
1 => '4',
4 => '1',
7 => '2',
10 => '3',
};
return [
[
'id' => 'tax_vat_'.$deadlineYear.'_'.$deadlineMonth,
'badge' => '세금 신고',
'content' => __('message.today_issue.tax_vat_deadline', [
'quarter' => $quarterName,
'days' => $daysUntil,
]),
'time' => $this->formatRelativeTime($today),
'date' => $today->toDateString(),
'needsApproval' => false,
'path' => '/accounting/tax',
'created_at' => $today,
],
];
}
/**
* 결재 요청 이슈 (내 결재 대기 건)
*/
private function getApprovalIssues(int $tenantId, int $userId): array
{
$steps = ApprovalStep::query()
->whereHas('approval', function ($query) use ($tenantId) {
$query->where('tenant_id', $tenantId)
->where('status', 'pending');
})
->where('approver_id', $userId)
->where('status', 'pending')
->with(['approval' => function ($query) {
$query->with('drafter:id,name');
}])
->orderByDesc('created_at')
->limit(10)
->get();
return $steps->map(function ($step) {
$drafterName = $step->approval->drafter?->name ?? __('message.today_issue.unknown_user');
$title = $step->approval->title ?? __('message.today_issue.approval_request');
return [
'id' => 'approval_'.$step->approval->id,
'badge' => '결재 요청',
'content' => __('message.today_issue.approval_pending', [
'title' => $title,
'drafter' => $drafterName,
]),
'time' => $this->formatRelativeTime($step->approval->created_at),
'date' => $step->approval->created_at?->toDateString(),
'needsApproval' => true,
'path' => '/approval/inbox',
'created_at' => $step->approval->created_at,
];
})->toArray();
}
/**
* 기타 이슈 (신규 거래처 등록 등)
*/
private function getOtherIssues(int $tenantId, Carbon $today): array
{
// 최근 7일 신규 거래처
$clients = Client::query()
->where('tenant_id', $tenantId)
->where('created_at', '>=', $today->copy()->subDays(7))
->orderByDesc('created_at')
->limit(5)
->get();
return $clients->map(function ($client) {
return [
'id' => 'client_'.$client->id,
'badge' => '기타',
'content' => __('message.today_issue.new_client', [
'name' => $client->name,
]),
'time' => $this->formatRelativeTime($client->created_at),
'date' => $client->created_at?->toDateString(),
'needsApproval' => false,
'path' => '/accounting/vendors',
'created_at' => $client->created_at,
];
})->toArray();
}
/**
* 상대 시간 포맷팅
*/
private function formatRelativeTime(?Carbon $datetime): string
{
if (! $datetime) {
return '';
}
$now = Carbon::now();
$diffInMinutes = $now->diffInMinutes($datetime);
$diffInHours = $now->diffInHours($datetime);
$diffInDays = $now->diffInDays($datetime);
if ($diffInMinutes < 60) {
return __('message.today_issue.time_minutes_ago', ['minutes' => max(1, $diffInMinutes)]);
}
if ($diffInHours < 24) {
return __('message.today_issue.time_hours_ago', ['hours' => $diffInHours]);
}
if ($diffInDays == 1) {
return __('message.today_issue.time_yesterday');
}
if ($diffInDays < 7) {
return __('message.today_issue.time_days_ago', ['days' => $diffInDays]);
}
return $datetime->format('Y-m-d');
}
}

261
app/Services/VatService.php Normal file
View File

@@ -0,0 +1,261 @@
<?php
namespace App\Services;
use App\Models\Tenants\TaxInvoice;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/**
* 부가세 현황 서비스
*
* CEO 대시보드용 부가세 데이터를 제공합니다.
*/
class VatService extends Service
{
/**
* 부가세 현황 요약 조회
*
* @param string|null $periodType 기간 타입 (quarter|half|year, 기본: quarter)
* @param int|null $year 연도 (기본: 현재 연도)
* @param int|null $period 기간 번호 (quarter: 1-4, half: 1-2, 기본: 현재 기간)
* @return array{cards: array, check_points: array}
*/
public function getSummary(?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);
// 발행 완료된 세금계산서만 계산 (status: issued, sent)
$validStatuses = [TaxInvoice::STATUS_ISSUED, TaxInvoice::STATUS_SENT];
// 매출세액 (sales)
$salesTaxAmount = TaxInvoice::where('tenant_id', $tenantId)
->where('direction', TaxInvoice::DIRECTION_SALES)
->whereIn('status', $validStatuses)
->whereBetween('issue_date', [$startDate, $endDate])
->sum('tax_amount');
// 매입세액 (purchases)
$purchasesTaxAmount = TaxInvoice::where('tenant_id', $tenantId)
->where('direction', TaxInvoice::DIRECTION_PURCHASES)
->whereIn('status', $validStatuses)
->whereBetween('issue_date', [$startDate, $endDate])
->sum('tax_amount');
// 예상 납부세액 (매출세액 - 매입세액)
$estimatedPayment = $salesTaxAmount - $purchasesTaxAmount;
// 미발행 세금계산서 건수 (전체 기간, status: draft)
$unissuedCount = TaxInvoice::where('tenant_id', $tenantId)
->where('status', TaxInvoice::STATUS_DRAFT)
->count();
// 카드 데이터 구성
$cards = [
[
'id' => 'vat_sales_tax',
'label' => '매출세액',
'amount' => (int) $salesTaxAmount,
],
[
'id' => 'vat_purchases_tax',
'label' => '매입세액',
'amount' => (int) $purchasesTaxAmount,
],
[
'id' => 'vat_estimated_payment',
'label' => '예상 납부세액',
'amount' => (int) abs($estimatedPayment),
'subLabel' => $estimatedPayment < 0 ? '환급' : null,
],
[
'id' => 'vat_unissued',
'label' => '세금계산서 미발행',
'amount' => $unissuedCount,
'unit' => '건',
],
];
// 체크포인트 생성
$checkPoints = $this->generateCheckPoints(
$year,
$periodType,
$period,
$salesTaxAmount,
$purchasesTaxAmount,
$estimatedPayment,
$tenantId
);
return [
'cards' => $cards,
'check_points' => $checkPoints,
];
}
/**
* 현재 기간 계산
*/
private function getCurrentPeriod(string $periodType, Carbon $date): int
{
return match ($periodType) {
'quarter' => $date->quarter,
'half' => $date->month <= 6 ? 1 : 2,
'year' => 1,
default => $date->quarter,
};
}
/**
* 기간 범위 날짜 계산
*
* @return array{0: string, 1: string} [startDate, endDate]
*/
private function getPeriodDateRange(int $year, string $periodType, int $period): array
{
return match ($periodType) {
'quarter' => [
Carbon::create($year, ($period - 1) * 3 + 1, 1)->format('Y-m-d'),
Carbon::create($year, $period * 3, 1)->endOfMonth()->format('Y-m-d'),
],
'half' => [
Carbon::create($year, ($period - 1) * 6 + 1, 1)->format('Y-m-d'),
Carbon::create($year, $period * 6, 1)->endOfMonth()->format('Y-m-d'),
],
'year' => [
Carbon::create($year, 1, 1)->format('Y-m-d'),
Carbon::create($year, 12, 31)->format('Y-m-d'),
],
default => [
Carbon::create($year, ($period - 1) * 3 + 1, 1)->format('Y-m-d'),
Carbon::create($year, $period * 3, 1)->endOfMonth()->format('Y-m-d'),
],
};
}
/**
* 체크포인트 생성
*/
private function generateCheckPoints(
int $year,
string $periodType,
int $period,
float $salesTaxAmount,
float $purchasesTaxAmount,
float $estimatedPayment,
int $tenantId
): array {
$checkPoints = [];
$periodLabel = $this->getPeriodLabel($year, $periodType, $period);
// 이전 기간 데이터 조회 (전기 대비 비교용)
$previousPeriod = $this->getPreviousPeriod($year, $periodType, $period);
[$prevStartDate, $prevEndDate] = $this->getPeriodDateRange(
$previousPeriod['year'],
$periodType,
$previousPeriod['period']
);
$validStatuses = [TaxInvoice::STATUS_ISSUED, TaxInvoice::STATUS_SENT];
$prevSalesTax = TaxInvoice::where('tenant_id', $tenantId)
->where('direction', TaxInvoice::DIRECTION_SALES)
->whereIn('status', $validStatuses)
->whereBetween('issue_date', [$prevStartDate, $prevEndDate])
->sum('tax_amount');
$prevPurchasesTax = TaxInvoice::where('tenant_id', $tenantId)
->where('direction', TaxInvoice::DIRECTION_PURCHASES)
->whereIn('status', $validStatuses)
->whereBetween('issue_date', [$prevStartDate, $prevEndDate])
->sum('tax_amount');
$prevEstimatedPayment = $prevSalesTax - $prevPurchasesTax;
// 납부/환급 여부에 따른 메시지 생성
if ($estimatedPayment < 0) {
// 환급
$refundAmount = number_format(abs($estimatedPayment));
$message = "{$periodLabel} 기준, 예상 환급세액은 {$refundAmount}원입니다.";
// 원인 분석 추가
if ($purchasesTaxAmount > $salesTaxAmount) {
$message .= ' 매입세액이 매출세액을 초과하여 환급이 예상됩니다.';
}
$checkPoints[] = [
'id' => 'vat_cp_refund',
'type' => 'success',
'message' => $message,
'highlights' => [
['text' => "{$periodLabel} 기준, 예상 환급세액은 {$refundAmount}원입니다.", 'color' => 'blue'],
],
];
} else {
// 납부
$paymentAmount = number_format($estimatedPayment);
$message = "{$periodLabel} 기준, 예상 납부세액은 {$paymentAmount}원입니다.";
// 전기 대비 변동률 계산
if ($prevEstimatedPayment > 0) {
$changeRate = (($estimatedPayment - $prevEstimatedPayment) / $prevEstimatedPayment) * 100;
$changeDirection = $changeRate >= 0 ? '증가' : '감소';
$message .= sprintf(' 전기 대비 %.1f%% %s했습니다.', abs($changeRate), $changeDirection);
}
$checkPoints[] = [
'id' => 'vat_cp_payment',
'type' => 'success',
'message' => $message,
'highlights' => [
['text' => "{$periodLabel} 기준, 예상 납부세액은 {$paymentAmount}원입니다.", 'color' => 'red'],
],
];
}
return $checkPoints;
}
/**
* 기간 라벨 생성
*/
private function getPeriodLabel(int $year, string $periodType, int $period): string
{
return match ($periodType) {
'quarter' => "{$year}{$period}기 예정신고",
'half' => "{$year}" . ($period === 1 ? '상반기' : '하반기') . ' 확정신고',
'year' => "{$year}년 연간",
default => "{$year}{$period}",
};
}
/**
* 이전 기간 계산
*
* @return array{year: int, period: int}
*/
private function getPreviousPeriod(int $year, string $periodType, int $period): array
{
return match ($periodType) {
'quarter' => $period === 1
? ['year' => $year - 1, 'period' => 4]
: ['year' => $year, 'period' => $period - 1],
'half' => $period === 1
? ['year' => $year - 1, 'period' => 2]
: ['year' => $year, 'period' => 1],
'year' => ['year' => $year - 1, 'period' => 1],
default => $period === 1
? ['year' => $year - 1, 'period' => 4]
: ['year' => $year, 'period' => $period - 1],
};
}
}

View File

@@ -0,0 +1,252 @@
<?php
namespace App\Services;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/**
* 복리후생비 현황 서비스
*
* CEO 대시보드용 복리후생비 데이터를 제공합니다.
*/
class WelfareService extends Service
{
// 비과세 식대 한도 (월)
private const TAX_FREE_MEAL_LIMIT = 200000;
// 1인당 월 복리후생비 업계 평균 범위
private const INDUSTRY_AVG_MIN = 150000;
private const INDUSTRY_AVG_MAX = 250000;
/**
* 복리후생비 현황 요약 조회
*
* @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(
?string $limitType = 'quarterly',
?string $calculationType = 'fixed',
?int $fixedAmountPerMonth = 200000,
?float $ratio = 0.05,
?int $year = null,
?int $quarter = null
): array {
$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);
// 카드 데이터 구성
$cards = [
[
'id' => 'wf_annual_limit',
'label' => '당해년도 복리후생비 한도',
'amount' => (int) $annualLimit,
],
[
'id' => 'wf_period_limit',
'label' => "{{$periodLabel}} 복리후생비 총 한도",
'amount' => (int) $periodLimit,
],
[
'id' => 'wf_remaining',
'label' => "{{$periodLabel}} 복리후생비 잔여한도",
'amount' => (int) $remainingLimit,
],
[
'id' => 'wf_used',
'label' => "{{$periodLabel}} 복리후생비 사용금액",
'amount' => (int) $usedAmount,
],
];
// 체크포인트 생성
$checkPoints = $this->generateCheckPoints(
$tenantId,
$employeeCount,
$usedAmount,
$monthCount,
$startDate,
$endDate
);
return [
'cards' => $cards,
'check_points' => $checkPoints,
];
}
/**
* 직원 수 조회
*/
private function getEmployeeCount(int $tenantId): int
{
$count = DB::table('users')
->join('user_tenants', 'users.id', '=', 'user_tenants.user_id')
->where('user_tenants.tenant_id', $tenantId)
->where('user_tenants.is_active', true)
->whereNull('users.deleted_at')
->count();
return $count ?: 50; // 임시 기본값
}
/**
* 연간 급여 총액 조회
*/
private function getTotalSalary(int $tenantId, int $year): float
{
// TODO: 실제 급여 테이블에서 조회
// payroll 또는 salary_histories에서 연간 급여 합계
return 2000000000; // 임시 기본값 (20억)
}
/**
* 복리후생비 사용액 조회
*/
private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float
{
// TODO: 실제 복리후생비 계정과목에서 조회
$amount = DB::table('expense_accounts')
->where('tenant_id', $tenantId)
->where('account_type', 'welfare')
->whereBetween('expense_date', [$startDate, $endDate])
->whereNull('deleted_at')
->sum('amount');
return $amount ?: 5123000; // 임시 기본값
}
/**
* 월 식대 조회
*/
private function getMonthlyMealAmount(int $tenantId, string $startDate, string $endDate): float
{
// TODO: 식대 항목 조회
$amount = 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');
return $amount ?: 0;
}
/**
* 체크포인트 생성
*/
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;
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="Calendar",
* description="CEO 대시보드 캘린더 API - 일정 데이터 조회"
* )
*/
/**
* @OA\Schema(
* schema="CalendarScheduleItem",
* description="캘린더 일정 아이템",
*
* @OA\Property(property="id", type="string", description="일정 ID (타입_ID 형식)", example="wo_123"),
* @OA\Property(property="title", type="string", description="일정 제목", example="스크린 생산"),
* @OA\Property(property="startDate", type="string", format="date", description="시작일", example="2026-01-20"),
* @OA\Property(property="endDate", type="string", format="date", description="종료일", example="2026-01-20"),
* @OA\Property(property="startTime", type="string", nullable=true, description="시작 시간 (HH:mm)", example="09:00"),
* @OA\Property(property="endTime", type="string", nullable=true, description="종료 시간 (HH:mm)", example="18:00"),
* @OA\Property(property="isAllDay", type="boolean", description="종일 여부", example=true),
* @OA\Property(
* property="type",
* type="string",
* enum={"schedule", "order", "construction", "other"},
* description="일정 타입 (schedule: 휴가/일반, order: 작업지시/발주, construction: 시공/계약)",
* example="order"
* ),
* @OA\Property(property="department", type="string", nullable=true, description="부서명", example="생산팀"),
* @OA\Property(property="personName", type="string", nullable=true, description="담당자명", example="홍길동"),
* @OA\Property(property="color", type="string", nullable=true, description="일정 색상", example="blue")
* )
*
* @OA\Schema(
* schema="CalendarScheduleSummary",
* description="캘린더 일정 요약 데이터",
*
* @OA\Property(
* property="items",
* type="array",
* description="일정 목록",
*
* @OA\Items(ref="#/components/schemas/CalendarScheduleItem")
* ),
* @OA\Property(property="total_count", type="integer", description="총 일정 수", example=15)
* )
*/
class CalendarApi
{
/**
* @OA\Get(
* path="/api/v1/calendar/schedules",
* operationId="getCalendarSchedules",
* tags={"Calendar"},
* summary="캘린더 일정 조회",
* description="CEO 대시보드 캘린더의 일정 데이터를 조회합니다.
* 데이터 소스:
* - 작업지시(order): 생산 예정일 기준
* - 계약/시공(construction): 계약 기간 기준
* - 휴가(schedule): 승인된 휴가 기간 기준
* 필터:
* - 기간 필터: start_date ~ end_date
* - 타입 필터: schedule(휴가), order(작업지시), construction(시공)
* - 부서 필터: all(전체), department(부서), personal(개인)",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(
* name="start_date",
* in="query",
* description="조회 시작일 (Y-m-d, 기본: 이번 달 1일)",
* required=false,
*
* @OA\Schema(type="string", format="date", example="2026-01-01")
* ),
*
* @OA\Parameter(
* name="end_date",
* in="query",
* description="조회 종료일 (Y-m-d, 기본: 이번 달 말일)",
* required=false,
*
* @OA\Schema(type="string", format="date", example="2026-01-31")
* ),
*
* @OA\Parameter(
* name="type",
* in="query",
* description="일정 타입 필터 (미지정 시 전체 조회)",
* required=false,
*
* @OA\Schema(type="string", enum={"schedule", "order", "construction", "other"})
* ),
*
* @OA\Parameter(
* name="department_filter",
* in="query",
* description="부서 필터 (기본: all)",
* required=false,
*
* @OA\Schema(type="string", enum={"all", "department", "personal"}, default="all")
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="조회 성공"),
* @OA\Property(
* property="data",
* ref="#/components/schemas/CalendarScheduleSummary"
* )
* )
* ),
*
* @OA\Response(
* response=401,
* description="인증 실패",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="인증이 필요합니다.")
* )
* ),
*
* @OA\Response(
* response=422,
* description="유효성 검증 실패",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="유효성 검증에 실패했습니다.")
* )
* )
* )
*/
public function summary() {}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="Entertainment",
* description="접대비 현황 API"
* )
*/
/**
* @OA\Schema(
* schema="EntertainmentAmountCard",
* type="object",
* description="접대비 금액 카드",
* required={"id", "label", "amount"},
*
* @OA\Property(property="id", type="string", description="카드 ID", example="et_sales"),
* @OA\Property(property="label", type="string", description="카드 라벨", example="매출"),
* @OA\Property(property="amount", type="integer", description="금액", example=30530000000),
* @OA\Property(property="subLabel", type="string", nullable=true, description="부가 라벨", example=null),
* @OA\Property(property="unit", type="string", nullable=true, description="단위", example=null)
* )
*
* @OA\Schema(
* schema="EntertainmentHighlightItem",
* type="object",
* description="체크포인트 하이라이트 아이템",
* required={"text", "color"},
*
* @OA\Property(property="text", type="string", description="하이라이트 텍스트", example="1,000만원"),
* @OA\Property(property="color", type="string", description="색상 (red, green, orange 등)", example="green")
* )
*
* @OA\Schema(
* schema="EntertainmentCheckPoint",
* type="object",
* description="접대비 체크포인트",
* required={"id", "type", "message"},
*
* @OA\Property(property="id", type="string", description="체크포인트 ID", example="et_cp_normal"),
* @OA\Property(property="type", type="string", description="타입 (success, warning, error)", example="success"),
* @OA\Property(property="message", type="string", description="메시지", example="{1사분기} 접대비 사용 1,000만원 / 한도 4,012만원 (75%). 여유 있게 운영 중입니다."),
* @OA\Property(
* property="highlights",
* type="array",
* description="하이라이트 아이템 목록",
*
* @OA\Items(ref="#/components/schemas/EntertainmentHighlightItem")
* )
* )
*
* @OA\Schema(
* schema="EntertainmentSummaryResponse",
* type="object",
* description="접대비 현황 요약 응답",
* required={"cards", "check_points"},
*
* @OA\Property(
* property="cards",
* type="array",
* description="금액 카드 목록",
*
* @OA\Items(ref="#/components/schemas/EntertainmentAmountCard")
* ),
* @OA\Property(
* property="check_points",
* type="array",
* description="체크포인트 목록",
*
* @OA\Items(ref="#/components/schemas/EntertainmentCheckPoint")
* )
* )
*/
class EntertainmentApi
{
/**
* @OA\Get(
* path="/api/v1/entertainment/summary",
* operationId="getEntertainmentSummary",
* tags={"Entertainment"},
* summary="접대비 현황 요약 조회",
* description="CEO 대시보드용 접대비 현황 요약 데이터를 조회합니다. 매출액, 한도, 사용금액, 잔여한도를 포함합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(
* name="limit_type",
* in="query",
* required=false,
* description="기간 타입 (annual: 연간, quarterly: 분기)",
*
* @OA\Schema(type="string", enum={"annual", "quarterly"}, default="quarterly")
* ),
*
* @OA\Parameter(
* name="company_type",
* in="query",
* required=false,
* description="기업 유형 (large: 대기업, medium: 중견기업, small: 중소기업)",
*
* @OA\Schema(type="string", enum={"large", "medium", "small"}, default="medium")
* ),
*
* @OA\Parameter(
* name="year",
* in="query",
* required=false,
* description="연도 (기본: 현재 연도)",
*
* @OA\Schema(type="integer", example=2026)
* ),
*
* @OA\Parameter(
* name="quarter",
* in="query",
* required=false,
* description="분기 번호 (1-4, 기본: 현재 분기)",
*
* @OA\Schema(type="integer", minimum=1, maximum=4, example=1)
* ),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="조회되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/EntertainmentSummaryResponse")
* )
* ),
*
* @OA\Response(
* response=401,
* description="인증 실패"
* ),
* @OA\Response(
* response=403,
* description="권한 없음"
* )
* )
*/
public function summary() {}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="StatusBoard",
* description="CEO 대시보드 현황판 API - 주요 업무 현황 카드 조회"
* )
*/
/**
* @OA\Schema(
* schema="StatusBoardItem",
* description="현황판 카드 아이템",
*
* @OA\Property(property="id", type="string", description="카드 ID", example="orders"),
* @OA\Property(property="label", type="string", description="카드 라벨", example="수주"),
* @OA\Property(
* property="count",
* oneOf={
*
* @OA\Schema(type="integer", example=3),
* @OA\Schema(type="string", example="부가세 신고 D-15")
* },
* description="건수 또는 텍스트"
* ),
* @OA\Property(property="path", type="string", description="이동 경로", example="/sales/order-management-sales"),
* @OA\Property(property="isHighlighted", type="boolean", description="강조 표시 여부", example=false)
* )
*
* @OA\Schema(
* schema="StatusBoardSummary",
* description="현황판 요약 데이터",
*
* @OA\Property(
* property="items",
* type="array",
* description="현황판 카드 목록",
*
* @OA\Items(ref="#/components/schemas/StatusBoardItem")
* )
* )
*/
class StatusBoardApi
{
/**
* @OA\Get(
* path="/api/v1/status-board/summary",
* operationId="getStatusBoardSummary",
* tags={"StatusBoard"},
* summary="현황판 요약 조회",
* description="CEO 대시보드 현황판의 주요 업무 현황 카드 데이터를 조회합니다.
* 포함 항목:
* - 수주: 오늘 신규 확정 수주 건수
* - 채권 추심: 추심 진행 중인 건수
* - 안전 재고: 안전재고 미달 품목 수 (강조 표시)
* - 세금 신고: 부가세 신고 D-day (7일 이내 강조)
* - 신규 업체 등록: 최근 7일 신규 거래처 수
* - 연차: 오늘 휴가 중인 인원 수
* - 발주: 발주 대기 건수
* - 결재 요청: 나의 결재 대기 건수 (강조 표시)",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="조회 성공"),
* @OA\Property(
* property="data",
* ref="#/components/schemas/StatusBoardSummary"
* )
* )
* ),
*
* @OA\Response(
* response=401,
* description="인증 실패",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="인증이 필요합니다.")
* )
* )
* )
*/
public function summary() {}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="TodayIssue",
* description="CEO 대시보드 - 오늘의 이슈 리스트 API"
* )
*
* @OA\Schema(
* schema="TodayIssueItem",
* type="object",
* description="오늘의 이슈 항목",
* required={"id", "badge", "content", "time"},
* @OA\Property(property="id", type="string", example="order_123", description="항목 고유 ID"),
* @OA\Property(
* property="badge",
* type="string",
* enum={"수주 성공", "주식 이슈", "직정 제고", "지출예상내역서", "세금 신고", "결재 요청", "기타"},
* example="수주 성공",
* description="이슈 카테고리 뱃지"
* ),
* @OA\Property(property="content", type="string", example="A전자 신규 수주 450,000,000원 확정", description="이슈 내용"),
* @OA\Property(property="time", type="string", example="10분 전", description="상대 시간"),
* @OA\Property(property="date", type="string", format="date", example="2026-01-20", description="날짜 (ISO 형식)"),
* @OA\Property(property="needsApproval", type="boolean", example=false, description="승인/반려 버튼 표시 여부"),
* @OA\Property(property="path", type="string", example="/sales/order-management-sales", description="클릭 시 이동할 경로")
* )
*
* @OA\Schema(
* schema="TodayIssueSummaryResponse",
* type="object",
* description="오늘의 이슈 리스트 응답",
* @OA\Property(
* property="items",
* type="array",
* description="이슈 항목 리스트",
* @OA\Items(ref="#/components/schemas/TodayIssueItem")
* ),
* @OA\Property(property="total_count", type="integer", example=25, description="전체 이슈 건수")
* )
*/
class TodayIssueApi
{
/**
* @OA\Get(
* path="/api/v1/today-issues/summary",
* operationId="getTodayIssueSummary",
* tags={"TodayIssue"},
* summary="오늘의 이슈 리스트 조회",
* description="CEO 대시보드용 오늘의 이슈 리스트를 조회합니다. 수주 성공, 미수금 이슈, 재고 이슈, 지출예상내역서, 세금 신고, 결재 요청, 기타 카테고리의 알림을 집계합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(
* name="limit",
* in="query",
* description="조회할 최대 항목 수",
* required=false,
* @OA\Schema(type="integer", default=30, minimum=1, maximum=100)
* ),
*
* @OA\Response(
* response=200,
* description="성공",
* @OA\JsonContent(
* type="object",
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
* @OA\Property(
* property="data",
* ref="#/components/schemas/TodayIssueSummaryResponse"
* )
* )
* ),
*
* @OA\Response(
* response=401,
* description="인증 실패",
* @OA\JsonContent(
* type="object",
* @OA\Property(property="success", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="인증에 실패했습니다.")
* )
* )
* )
*/
public function summary() {}
}

138
app/Swagger/v1/VatApi.php Normal file
View File

@@ -0,0 +1,138 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="Vat",
* description="부가세 현황 API"
* )
*/
/**
* @OA\Schema(
* schema="VatAmountCard",
* type="object",
* description="부가세 금액 카드",
* required={"id", "label", "amount"},
*
* @OA\Property(property="id", type="string", description="카드 ID", example="vat_sales_tax"),
* @OA\Property(property="label", type="string", description="카드 라벨", example="매출세액"),
* @OA\Property(property="amount", type="integer", description="금액", example=3050000000),
* @OA\Property(property="subLabel", type="string", nullable=true, description="부가 라벨 (환급 등)", example="환급"),
* @OA\Property(property="unit", type="string", nullable=true, description="단위 (건, 원 등)", example="건")
* )
*
* @OA\Schema(
* schema="VatHighlightItem",
* type="object",
* description="체크포인트 하이라이트 아이템",
* required={"text", "color"},
*
* @OA\Property(property="text", type="string", description="하이라이트 텍스트", example="2026년 1기 예정신고 기준, 예상 납부세액은 110,100,000원입니다."),
* @OA\Property(property="color", type="string", description="색상 (red, blue, green 등)", example="red")
* )
*
* @OA\Schema(
* schema="VatCheckPoint",
* type="object",
* description="부가세 체크포인트",
* required={"id", "type", "message"},
*
* @OA\Property(property="id", type="string", description="체크포인트 ID", example="vat_cp_payment"),
* @OA\Property(property="type", type="string", description="타입 (success, warning, error)", example="success"),
* @OA\Property(property="message", type="string", description="메시지", example="2026년 1기 예정신고 기준, 예상 납부세액은 110,100,000원입니다. 전기 대비 12.9% 증가했습니다."),
* @OA\Property(
* property="highlights",
* type="array",
* description="하이라이트 아이템 목록",
*
* @OA\Items(ref="#/components/schemas/VatHighlightItem")
* )
* )
*
* @OA\Schema(
* schema="VatSummaryResponse",
* type="object",
* description="부가세 현황 요약 응답",
* required={"cards", "check_points"},
*
* @OA\Property(
* property="cards",
* type="array",
* description="금액 카드 목록",
*
* @OA\Items(ref="#/components/schemas/VatAmountCard")
* ),
* @OA\Property(
* property="check_points",
* type="array",
* description="체크포인트 목록",
*
* @OA\Items(ref="#/components/schemas/VatCheckPoint")
* )
* )
*/
class VatApi
{
/**
* @OA\Get(
* path="/api/v1/vat/summary",
* operationId="getVatSummary",
* tags={"Vat"},
* summary="부가세 현황 요약 조회",
* description="CEO 대시보드용 부가세 현황 요약 데이터를 조회합니다. 매출세액, 매입세액, 예상 납부세액, 미발행 세금계산서 건수를 포함합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(
* name="period_type",
* in="query",
* required=false,
* description="기간 타입 (quarter: 분기, half: 반기, year: 연간)",
*
* @OA\Schema(type="string", enum={"quarter", "half", "year"}, default="quarter")
* ),
*
* @OA\Parameter(
* name="year",
* in="query",
* required=false,
* description="연도 (기본: 현재 연도)",
*
* @OA\Schema(type="integer", example=2026)
* ),
*
* @OA\Parameter(
* name="period",
* in="query",
* required=false,
* description="기간 번호 (quarter: 1-4, half: 1-2, 기본: 현재 기간)",
*
* @OA\Schema(type="integer", minimum=1, maximum=4, example=1)
* ),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="조회되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/VatSummaryResponse")
* )
* ),
*
* @OA\Response(
* response=401,
* description="인증 실패"
* ),
* @OA\Response(
* response=403,
* description="권한 없음"
* )
* )
*/
public function summary() {}
}

View File

@@ -0,0 +1,165 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="Welfare",
* description="복리후생비 현황 API"
* )
*/
/**
* @OA\Schema(
* schema="WelfareAmountCard",
* type="object",
* description="복리후생비 금액 카드",
* required={"id", "label", "amount"},
*
* @OA\Property(property="id", type="string", description="카드 ID", example="wf_annual_limit"),
* @OA\Property(property="label", type="string", description="카드 라벨", example="당해년도 복리후생비 한도"),
* @OA\Property(property="amount", type="integer", description="금액", example=30123000),
* @OA\Property(property="subLabel", type="string", nullable=true, description="부가 라벨", example=null),
* @OA\Property(property="unit", type="string", nullable=true, description="단위", example=null)
* )
*
* @OA\Schema(
* schema="WelfareHighlightItem",
* type="object",
* description="체크포인트 하이라이트 아이템",
* required={"text", "color"},
*
* @OA\Property(property="text", type="string", description="하이라이트 텍스트", example="1인당 월 복리후생비 20만원"),
* @OA\Property(property="color", type="string", description="색상 (red, green, orange 등)", example="green")
* )
*
* @OA\Schema(
* schema="WelfareCheckPoint",
* type="object",
* description="복리후생비 체크포인트",
* required={"id", "type", "message"},
*
* @OA\Property(property="id", type="string", description="체크포인트 ID", example="wf_cp_normal"),
* @OA\Property(property="type", type="string", description="타입 (success, warning, error)", example="success"),
* @OA\Property(property="message", type="string", description="메시지", example="1인당 월 복리후생비 20만원. 업계 평균(15~25만원) 내 정상 운영 중입니다."),
* @OA\Property(
* property="highlights",
* type="array",
* description="하이라이트 아이템 목록",
*
* @OA\Items(ref="#/components/schemas/WelfareHighlightItem")
* )
* )
*
* @OA\Schema(
* schema="WelfareSummaryResponse",
* type="object",
* description="복리후생비 현황 요약 응답",
* required={"cards", "check_points"},
*
* @OA\Property(
* property="cards",
* type="array",
* description="금액 카드 목록",
*
* @OA\Items(ref="#/components/schemas/WelfareAmountCard")
* ),
* @OA\Property(
* property="check_points",
* type="array",
* description="체크포인트 목록",
*
* @OA\Items(ref="#/components/schemas/WelfareCheckPoint")
* )
* )
*/
class WelfareApi
{
/**
* @OA\Get(
* path="/api/v1/welfare/summary",
* operationId="getWelfareSummary",
* tags={"Welfare"},
* summary="복리후생비 현황 요약 조회",
* description="CEO 대시보드용 복리후생비 현황 요약 데이터를 조회합니다. 연간/분기별 한도, 사용금액, 잔여한도를 포함합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(
* name="limit_type",
* in="query",
* required=false,
* description="기간 타입 (annual: 연간, quarterly: 분기)",
*
* @OA\Schema(type="string", enum={"annual", "quarterly"}, default="quarterly")
* ),
*
* @OA\Parameter(
* name="calculation_type",
* in="query",
* required=false,
* description="계산 방식 (fixed: 1인당 정액, ratio: 급여 대비 비율)",
*
* @OA\Schema(type="string", enum={"fixed", "ratio"}, default="fixed")
* ),
*
* @OA\Parameter(
* name="fixed_amount_per_month",
* in="query",
* required=false,
* description="1인당 월 정액 (calculation_type=fixed일 때 사용, 기본: 200000)",
*
* @OA\Schema(type="integer", example=200000)
* ),
*
* @OA\Parameter(
* name="ratio",
* in="query",
* required=false,
* description="급여 대비 비율 (calculation_type=ratio일 때 사용, 기본: 0.05)",
*
* @OA\Schema(type="number", format="float", example=0.05)
* ),
*
* @OA\Parameter(
* name="year",
* in="query",
* required=false,
* description="연도 (기본: 현재 연도)",
*
* @OA\Schema(type="integer", example=2026)
* ),
*
* @OA\Parameter(
* name="quarter",
* in="query",
* required=false,
* description="분기 번호 (1-4, 기본: 현재 분기)",
*
* @OA\Schema(type="integer", minimum=1, maximum=4, example=1)
* ),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="조회되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/WelfareSummaryResponse")
* )
* ),
*
* @OA\Response(
* response=401,
* description="인증 실패"
* ),
* @OA\Response(
* response=403,
* description="권한 없음"
* )
* )
*/
public function summary() {}
}

View File

@@ -481,4 +481,64 @@
'status_updated' => '입찰 상태가 변경되었습니다.',
'converted' => '견적이 입찰로 변환되었습니다.',
],
// CEO 대시보드 현황판
'status_board' => [
'orders' => '수주',
'bad_debts' => '채권 추심',
'safety_stock' => '안전 재고',
'tax_deadline' => '세금 신고',
'tax_d_day' => '부가세 신고 D-:days',
'tax_overdue' => '부가세 신고 :days일 초과',
'new_clients' => '신규 업체 등록',
'leaves' => '연차',
'purchases' => '발주',
'approvals' => '결재 요청',
],
// CEO 대시보드 오늘의 이슈 리스트
'today_issue' => [
'unknown_client' => '미확인 거래처',
'unknown_item' => '미확인 품목',
'unknown_user' => '미확인 사용자',
'expense_item' => '품의서',
'approval_request' => '결재 요청',
// 이슈 내용 메시지
'order_success' => ':client 신규 수주 :amount원 확정',
'receivable_overdue' => ':client 미수금 :amount원 연체 :days일',
'stock_below_safety' => ':item 재고 부족 경고',
'expense_pending_multiple' => ':title 외 :count건 (:amount원)',
'expense_pending_single' => ':title (:amount원)',
'tax_vat_deadline' => ':quarter분기 부가세 신고 D-:days',
'approval_pending' => ':title 승인 요청 (:drafter)',
'new_client' => '신규 거래처 :name 등록 완료',
// 상대 시간
'time_minutes_ago' => ':minutes분 전',
'time_hours_ago' => ':hours시간 전',
'time_yesterday' => '어제',
'time_days_ago' => ':days일 전',
],
// CEO 대시보드 캘린더
'calendar' => [
'leave_title' => ':name 휴가',
'leave_default' => '휴가',
],
// CEO 대시보드 부가세 현황
'vat' => [
'sales_tax' => '매출세액',
'purchases_tax' => '매입세액',
'estimated_payment' => '예상 납부세액',
'unissued_invoices' => '세금계산서 미발행',
'refund' => '환급',
'payment' => '납부',
'period_quarter' => ':year년 :period기 예정신고',
'period_half' => ':year년 :half 확정신고',
'period_year' => ':year년 연간',
'first_half' => '상반기',
'second_half' => '하반기',
],
];

View File

@@ -17,6 +17,7 @@
use App\Http\Controllers\Api\V1\BiddingController;
use App\Http\Controllers\Api\V1\BillController;
use App\Http\Controllers\Api\V1\BoardController;
use App\Http\Controllers\Api\V1\CalendarController;
use App\Http\Controllers\Api\V1\CardController;
use App\Http\Controllers\Api\V1\CardTransactionController;
use App\Http\Controllers\Api\V1\CategoryController;
@@ -42,6 +43,7 @@
use App\Http\Controllers\Api\V1\Design\DesignModelController;
use App\Http\Controllers\Api\V1\Design\ModelVersionController as DesignModelVersionController;
use App\Http\Controllers\Api\V1\EmployeeController;
use App\Http\Controllers\Api\V1\EntertainmentController;
use App\Http\Controllers\Api\V1\EstimateController;
use App\Http\Controllers\Api\V1\ExpectedExpenseController;
use App\Http\Controllers\Api\V1\FileStorageController;
@@ -94,7 +96,9 @@
use App\Http\Controllers\Api\V1\ShipmentController;
use App\Http\Controllers\Api\V1\SiteBriefingController;
use App\Http\Controllers\Api\V1\SiteController;
use App\Http\Controllers\Api\V1\StatusBoardController;
use App\Http\Controllers\Api\V1\StockController;
use App\Http\Controllers\Api\V1\TodayIssueController;
use App\Http\Controllers\Api\V1\SubscriptionController;
use App\Http\Controllers\Api\V1\SystemBoardController;
use App\Http\Controllers\Api\V1\SystemPostController;
@@ -110,7 +114,9 @@
use App\Http\Controllers\Api\V1\UserController;
use App\Http\Controllers\Api\V1\UserInvitationController;
use App\Http\Controllers\Api\V1\UserRoleController;
use App\Http\Controllers\Api\V1\VatController;
use App\Http\Controllers\Api\V1\VendorLedgerController;
use App\Http\Controllers\Api\V1\WelfareController;
use App\Http\Controllers\Api\V1\WithdrawalController;
use App\Http\Controllers\Api\V1\WorkOrderController;
use App\Http\Controllers\Api\V1\WorkResultController;
@@ -617,6 +623,24 @@
// Comprehensive Analysis API (종합 분석 보고서)
Route::get('/comprehensive-analysis', [ComprehensiveAnalysisController::class, 'index'])->name('v1.comprehensive-analysis.index');
// Status Board API (CEO 대시보드 현황판)
Route::get('/status-board/summary', [StatusBoardController::class, 'summary'])->name('v1.status-board.summary');
// Today Issue API (CEO 대시보드 오늘의 이슈 리스트)
Route::get('/today-issues/summary', [TodayIssueController::class, 'summary'])->name('v1.today-issues.summary');
// Calendar API (CEO 대시보드 캘린더)
Route::get('/calendar/schedules', [CalendarController::class, 'summary'])->name('v1.calendar.schedules');
// Vat API (CEO 대시보드 부가세 현황)
Route::get('/vat/summary', [VatController::class, 'summary'])->name('v1.vat.summary');
// Entertainment API (CEO 대시보드 접대비 현황)
Route::get('/entertainment/summary', [EntertainmentController::class, 'summary'])->name('v1.entertainment.summary');
// Welfare API (CEO 대시보드 복리후생비 현황)
Route::get('/welfare/summary', [WelfareController::class, 'summary'])->name('v1.welfare.summary');
// Plan API (요금제 관리)
Route::prefix('plans')->group(function () {
Route::get('', [PlanController::class, 'index'])->name('v1.plans.index');