feat: [CEO 대시보드] 섹션별 API + 일일보고서 엑셀

- DashboardCeo 리스크 감지형 서비스 리팩토링
- 일일보고서 어음/외상매출채권 현황 섹션 추가
- 엑셀 내보내기 화면 데이터 기반 리팩토링
- 공정명 컬럼 및 근태 부서 조인 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 02:59:05 +09:00
parent 1df34b2fa9
commit 95371fd841
6 changed files with 1050 additions and 8 deletions

View File

@@ -7,6 +7,7 @@
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithTitle;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class DailyReportExport implements FromArray, ShouldAutoSize, WithHeadings, WithStyles, WithTitle
@@ -31,10 +32,10 @@ public function headings(): array
return [
['일일 일보 - '.$this->report['date']],
[],
['전일 잔액', number_format($this->report['previous_balance']).'원'],
['당 입금', number_format($this->report['daily_deposit']).'원'],
['당 출금', number_format($this->report['daily_withdrawal']).'원'],
['당일 잔액', number_format($this->report['current_balance']).'원'],
['전월 이월', number_format($this->report['previous_balance']).'원'],
['당 입금', number_format($this->report['daily_deposit']).'원'],
['당 출금', number_format($this->report['daily_withdrawal']).'원'],
['잔액', number_format($this->report['current_balance']).'원'],
[],
['구분', '거래처명', '계정과목', '입금액', '출금액', '적요'],
];
@@ -47,6 +48,7 @@ public function array(): array
{
$rows = [];
// ── 예금 입출금 내역 ──
foreach ($this->report['details'] as $detail) {
$rows[] = [
$detail['type_label'],
@@ -58,7 +60,7 @@ public function array(): array
];
}
// 합계 행 추가
// 합계 행
$rows[] = [];
$rows[] = [
'합계',
@@ -69,6 +71,37 @@ public function array(): array
'',
];
// ── 어음 및 외상매출채권 현황 ──
$noteReceivables = $this->report['note_receivables'] ?? [];
$rows[] = [];
$rows[] = [];
$rows[] = ['어음 및 외상매출채권 현황'];
$rows[] = ['No.', '내용', '금액', '발행일', '만기일'];
$noteTotal = 0;
$no = 1;
foreach ($noteReceivables as $item) {
$amount = $item['current_balance'] ?? 0;
$noteTotal += $amount;
$rows[] = [
$no++,
$item['content'] ?? '-',
$amount > 0 ? number_format($amount) : '',
$item['issue_date'] ?? '-',
$item['due_date'] ?? '-',
];
}
// 어음 합계
$rows[] = [
'합계',
'',
number_format($noteTotal),
'',
'',
];
return $rows;
}
@@ -77,7 +110,7 @@ public function array(): array
*/
public function styles(Worksheet $sheet): array
{
return [
$styles = [
1 => ['font' => ['bold' => true, 'size' => 14]],
3 => ['font' => ['bold' => true]],
4 => ['font' => ['bold' => true]],
@@ -86,10 +119,32 @@ public function styles(Worksheet $sheet): array
8 => [
'font' => ['bold' => true],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E0E0E0'],
],
],
];
// 어음 섹션 헤더 스타일 (동적 행 번호)
// headings 8행 + details 수 + 합계 2행 + 빈 2행 + 어음 제목 1행 + 어음 헤더 1행
$detailCount = count($this->report['details']);
$noteHeaderTitleRow = 8 + $detailCount + 2 + 2 + 1; // 어음 제목 행
$noteHeaderRow = $noteHeaderTitleRow + 1; // 어음 컬럼 헤더 행
$styles[$noteHeaderTitleRow] = ['font' => ['bold' => true, 'size' => 12]];
$styles[$noteHeaderRow] = [
'font' => ['bold' => true],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E0E0E0'],
],
];
// 어음 합계 행
$noteCount = count($this->report['note_receivables'] ?? []);
$noteTotalRow = $noteHeaderRow + $noteCount + 1;
$styles[$noteTotalRow] = ['font' => ['bold' => true]];
return $styles;
}
}

View File

@@ -2,11 +2,14 @@
namespace App\Http\Controllers\Api\V1;
use App\Exports\DailyReportExport;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\DailyReportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 일일 보고서 컨트롤러
@@ -58,4 +61,19 @@ public function summary(Request $request): JsonResponse
return $this->service->summary($params);
}, __('message.fetched'));
}
/**
* 일일 보고서 엑셀 다운로드
*/
public function export(Request $request): BinaryFileResponse
{
$params = $request->validate([
'date' => 'nullable|date',
]);
$reportData = $this->service->exportData($params);
$filename = '일일일보_'.$reportData['date'].'.xlsx';
return Excel::download(new DailyReportExport($reportData), $filename);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\DashboardCeoService;
use Illuminate\Http\JsonResponse;
/**
* CEO 대시보드 섹션별 API 컨트롤러
*
* 6개 섹션: 매출, 매입, 생산, 미출고, 시공, 근태
*/
class DashboardCeoController extends Controller
{
public function __construct(
private readonly DashboardCeoService $service
) {}
/**
* 매출 현황 요약
* GET /api/v1/dashboard/sales/summary
*/
public function salesSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->salesSummary(),
__('message.fetched')
);
}
/**
* 매입 현황 요약
* GET /api/v1/dashboard/purchases/summary
*/
public function purchasesSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->purchasesSummary(),
__('message.fetched')
);
}
/**
* 생산 현황 요약
* GET /api/v1/dashboard/production/summary
*/
public function productionSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->productionSummary(),
__('message.fetched')
);
}
/**
* 미출고 내역 요약
* GET /api/v1/dashboard/unshipped/summary
*/
public function unshippedSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->unshippedSummary(),
__('message.fetched')
);
}
/**
* 시공 현황 요약
* GET /api/v1/dashboard/construction/summary
*/
public function constructionSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->constructionSummary(),
__('message.fetched')
);
}
/**
* 근태 현황 요약
* GET /api/v1/dashboard/attendance/summary
*/
public function attendanceSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->attendanceSummary(),
__('message.fetched')
);
}
}

View File

@@ -323,7 +323,7 @@ private function getReceivableData(int $tenantId, Carbon $reportDate): array
private function callGeminiApi(array $inputData): array
{
$apiKey = config('services.gemini.api_key');
$model = config('services.gemini.model', 'gemini-2.0-flash');
$model = config('services.gemini.model', 'gemini-2.5-flash');
$baseUrl = config('services.gemini.base_url');
if (empty($apiKey)) {

View File

@@ -5,6 +5,9 @@
use App\Models\Tenants\BankAccount;
use App\Models\Tenants\Bill;
use App\Models\Tenants\Deposit;
use App\Models\Tenants\ExpectedExpense;
use App\Models\Tenants\Purchase;
use App\Models\Tenants\Sale;
use App\Models\Tenants\Withdrawal;
use Carbon\Carbon;
@@ -155,6 +158,11 @@ public function summary(array $params): array
: null;
$operatingStability = $this->getOperatingStability($operatingMonths);
// 기획서 D1.7 자금현황 카드용 필드
$receivableBalance = $this->calculateReceivableBalance($tenantId, $date);
$payableBalance = $this->calculatePayableBalance($tenantId);
$monthlyExpenseTotal = $this->calculateMonthlyExpenseTotal($tenantId, $date);
return [
'date' => $date->format('Y-m-d'),
'day_of_week' => $date->locale('ko')->dayName,
@@ -167,9 +175,138 @@ public function summary(array $params): array
'monthly_operating_expense' => $monthlyOperatingExpense,
'operating_months' => $operatingMonths,
'operating_stability' => $operatingStability,
// 자금현황 카드용
'receivable_balance' => $receivableBalance,
'payable_balance' => $payableBalance,
'monthly_expense_total' => $monthlyExpenseTotal,
];
}
/**
* 엑셀 내보내기용 데이터 조합
* DailyReportExport가 기대하는 구조로 변환
*/
public function exportData(array $params): array
{
$date = isset($params['date']) ? Carbon::parse($params['date']) : Carbon::today();
$dateStr = $date->format('Y-m-d');
// 화면과 동일한 계좌별 현황 데이터 재사용
$dailyAccounts = $this->dailyAccounts($params);
// KRW 계좌 합산 (화면 합계와 동일)
$carryover = 0;
$totalIncome = 0;
$totalExpense = 0;
$totalBalance = 0;
$details = [];
foreach ($dailyAccounts as $account) {
$carryover += $account['carryover'];
$totalIncome += $account['income'];
$totalExpense += $account['expense'];
$totalBalance += $account['balance'];
// 계좌별 상세 내역
if ($account['income'] > 0) {
$details[] = [
'type_label' => '입금',
'client_name' => $account['category'],
'account_code' => '-',
'deposit_amount' => $account['income'],
'withdrawal_amount' => 0,
'description' => '',
];
}
if ($account['expense'] > 0) {
$details[] = [
'type_label' => '출금',
'client_name' => $account['category'],
'account_code' => '-',
'deposit_amount' => 0,
'withdrawal_amount' => $account['expense'],
'description' => '',
];
}
}
// 어음 및 외상매출채권 현황
$noteReceivables = $this->noteReceivables($params);
return [
'date' => $dateStr,
'previous_balance' => $carryover,
'daily_deposit' => $totalIncome,
'daily_withdrawal' => $totalExpense,
'current_balance' => $totalBalance,
'details' => $details,
'note_receivables' => $noteReceivables,
];
}
/**
* 미수금 잔액 계산
* = 전체 매출 - 전체 입금 - 전체 수취어음 (기준일까지)
* ReceivablesService.getTotalCarryForwardBalance() 동일 로직
*/
private function calculateReceivableBalance(int $tenantId, Carbon $date): float
{
$endDate = $date->format('Y-m-d');
$totalSales = Sale::where('tenant_id', $tenantId)
->whereNotNull('client_id')
->where('sale_date', '<=', $endDate)
->sum('total_amount');
$totalDeposits = Deposit::where('tenant_id', $tenantId)
->whereNotNull('client_id')
->where('deposit_date', '<=', $endDate)
->sum('amount');
$totalBills = Bill::where('tenant_id', $tenantId)
->whereNotNull('client_id')
->where('bill_type', 'received')
->where('issue_date', '<=', $endDate)
->sum('amount');
return (float) ($totalSales - $totalDeposits - $totalBills);
}
/**
* 미지급금 잔액 계산
* = 미지급 상태(pending, partial, overdue)인 ExpectedExpense 합계
*/
private function calculatePayableBalance(int $tenantId): float
{
return (float) ExpectedExpense::where('tenant_id', $tenantId)
->whereIn('payment_status', ['pending', 'partial', 'overdue'])
->sum('amount');
}
/**
* 당월 예상 지출 합계 계산
* = 당월 매입(Purchase) + 당월 예상지출(ExpectedExpense)
*/
private function calculateMonthlyExpenseTotal(int $tenantId, Carbon $date): float
{
$startOfMonth = $date->copy()->startOfMonth()->format('Y-m-d');
$endOfMonth = $date->copy()->endOfMonth()->format('Y-m-d');
// 당월 매입 합계
$purchaseTotal = Purchase::where('tenant_id', $tenantId)
->whereBetween('purchase_date', [$startOfMonth, $endOfMonth])
->sum('total_amount');
// 당월 예상 지출 합계 (매입 외: 카드, 어음, 급여, 임대료 등)
$expectedExpenseTotal = ExpectedExpense::where('tenant_id', $tenantId)
->whereBetween('expected_payment_date', [$startOfMonth, $endOfMonth])
->sum('amount');
return (float) ($purchaseTotal + $expectedExpenseTotal);
}
/**
* 직전 3개월 평균 월 운영비 계산
*

View File

@@ -0,0 +1,740 @@
<?php
namespace App\Services;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/**
* CEO 대시보드 섹션별 요약 서비스
*
* 6개 섹션: 매출, 매입, 생산, 미출고, 시공, 근태
* sam_stat 우선 조회 → fallback 원본 DB
*/
class DashboardCeoService extends Service
{
// ─── 1. 매출 현황 ───────────────────────────────
/**
* 매출 현황 요약
*/
public function salesSummary(): array
{
$tenantId = $this->tenantId();
$now = Carbon::now();
$year = $now->year;
$month = $now->month;
$today = $now->format('Y-m-d');
// 누적 매출 (연초~오늘)
$cumulativeSales = DB::table('sales')
->where('tenant_id', $tenantId)
->whereYear('sale_date', $year)
->where('sale_date', '<=', $today)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
// 당월 매출
$monthlySales = DB::table('sales')
->where('tenant_id', $tenantId)
->whereYear('sale_date', $year)
->whereMonth('sale_date', $month)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
// 전년 동월 매출 (YoY)
$lastYearMonthlySales = DB::table('sales')
->where('tenant_id', $tenantId)
->whereYear('sale_date', $year - 1)
->whereMonth('sale_date', $month)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
$yoyChange = $lastYearMonthlySales > 0
? round((($monthlySales - $lastYearMonthlySales) / $lastYearMonthlySales) * 100, 1)
: 0;
// 달성률 (당월 매출 / 전년 동월 매출 * 100)
$achievementRate = $lastYearMonthlySales > 0
? round(($monthlySales / $lastYearMonthlySales) * 100, 0)
: 0;
// 월별 추이 (1~12월)
$monthlyTrend = $this->getSalesMonthlyTrend($tenantId, $year);
// 거래처별 매출 (상위 5개)
$clientSales = $this->getSalesClientRanking($tenantId, $year);
// 일별 매출 내역 (최근 10건)
$dailyItems = $this->getSalesDailyItems($tenantId, $today);
// 일별 합계
$dailyTotal = DB::table('sales')
->where('tenant_id', $tenantId)
->where('sale_date', $today)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
return [
'cumulative_sales' => (int) $cumulativeSales,
'achievement_rate' => (int) $achievementRate,
'yoy_change' => $yoyChange,
'monthly_sales' => (int) $monthlySales,
'monthly_trend' => $monthlyTrend,
'client_sales' => $clientSales,
'daily_items' => $dailyItems,
'daily_total' => (int) $dailyTotal,
];
}
private function getSalesMonthlyTrend(int $tenantId, int $year): array
{
$monthlyData = DB::table('sales')
->select(DB::raw('MONTH(sale_date) as month'), DB::raw('COALESCE(SUM(total_amount), 0) as amount'))
->where('tenant_id', $tenantId)
->whereYear('sale_date', $year)
->whereNull('deleted_at')
->groupBy(DB::raw('MONTH(sale_date)'))
->orderBy('month')
->get();
$result = [];
for ($i = 1; $i <= 12; $i++) {
$found = $monthlyData->firstWhere('month', $i);
$result[] = [
'month' => sprintf('%d-%02d', $year, $i),
'label' => $i.'월',
'amount' => $found ? (int) $found->amount : 0,
];
}
return $result;
}
private function getSalesClientRanking(int $tenantId, int $year): array
{
$clients = DB::table('sales as s')
->leftJoin('clients as c', 's.client_id', '=', 'c.id')
->select('c.name', DB::raw('SUM(s.total_amount) as amount'))
->where('s.tenant_id', $tenantId)
->whereYear('s.sale_date', $year)
->whereNull('s.deleted_at')
->groupBy('s.client_id', 'c.name')
->orderByDesc('amount')
->limit(5)
->get();
return $clients->map(fn ($item) => [
'name' => $item->name ?? '미지정',
'amount' => (int) $item->amount,
])->toArray();
}
private function getSalesDailyItems(int $tenantId, string $today): array
{
$items = DB::table('sales as s')
->leftJoin('clients as c', 's.client_id', '=', 'c.id')
->select([
's.sale_date as date',
'c.name as client',
's.description as item',
's.total_amount as amount',
's.status',
's.deposit_id',
])
->where('s.tenant_id', $tenantId)
->where('s.sale_date', '>=', Carbon::parse($today)->subDays(30)->format('Y-m-d'))
->whereNull('s.deleted_at')
->orderByDesc('s.sale_date')
->limit(10)
->get();
return $items->map(fn ($item) => [
'date' => $item->date,
'client' => $item->client ?? '미지정',
'item' => $item->item ?? '-',
'amount' => (int) $item->amount,
'status' => $item->deposit_id ? 'deposited' : 'unpaid',
])->toArray();
}
// ─── 2. 매입 현황 ───────────────────────────────
/**
* 매입 현황 요약
*/
public function purchasesSummary(): array
{
$tenantId = $this->tenantId();
$now = Carbon::now();
$year = $now->year;
$month = $now->month;
$today = $now->format('Y-m-d');
// 누적 매입
$cumulativePurchase = DB::table('purchases')
->where('tenant_id', $tenantId)
->whereYear('purchase_date', $year)
->where('purchase_date', '<=', $today)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
// 미결제 금액 (withdrawal_id가 없는 것)
$unpaidAmount = DB::table('purchases')
->where('tenant_id', $tenantId)
->whereYear('purchase_date', $year)
->whereNull('withdrawal_id')
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
// 전년 동월 대비
$thisMonthPurchase = DB::table('purchases')
->where('tenant_id', $tenantId)
->whereYear('purchase_date', $year)
->whereMonth('purchase_date', $month)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
$lastYearMonthPurchase = DB::table('purchases')
->where('tenant_id', $tenantId)
->whereYear('purchase_date', $year - 1)
->whereMonth('purchase_date', $month)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
$yoyChange = $lastYearMonthPurchase > 0
? round((($thisMonthPurchase - $lastYearMonthPurchase) / $lastYearMonthPurchase) * 100, 1)
: 0;
// 월별 추이
$monthlyTrend = $this->getPurchaseMonthlyTrend($tenantId, $year);
// 자재 구성 비율 (purchase_type별)
$materialRatio = $this->getPurchaseMaterialRatio($tenantId, $year);
// 일별 매입 내역
$dailyItems = $this->getPurchaseDailyItems($tenantId, $today);
// 일별 합계
$dailyTotal = DB::table('purchases')
->where('tenant_id', $tenantId)
->where('purchase_date', $today)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
return [
'cumulative_purchase' => (int) $cumulativePurchase,
'unpaid_amount' => (int) $unpaidAmount,
'yoy_change' => $yoyChange,
'monthly_trend' => $monthlyTrend,
'material_ratio' => $materialRatio,
'daily_items' => $dailyItems,
'daily_total' => (int) $dailyTotal,
];
}
private function getPurchaseMonthlyTrend(int $tenantId, int $year): array
{
$monthlyData = DB::table('purchases')
->select(DB::raw('MONTH(purchase_date) as month'), DB::raw('COALESCE(SUM(total_amount), 0) as amount'))
->where('tenant_id', $tenantId)
->whereYear('purchase_date', $year)
->whereNull('deleted_at')
->groupBy(DB::raw('MONTH(purchase_date)'))
->orderBy('month')
->get();
$result = [];
for ($i = 1; $i <= 12; $i++) {
$found = $monthlyData->firstWhere('month', $i);
$result[] = [
'month' => sprintf('%d-%02d', $year, $i),
'label' => $i.'월',
'amount' => $found ? (int) $found->amount : 0,
];
}
return $result;
}
private function getPurchaseMaterialRatio(int $tenantId, int $year): array
{
$colors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
$ratioData = DB::table('purchases')
->select('purchase_type', DB::raw('SUM(total_amount) as value'))
->where('tenant_id', $tenantId)
->whereYear('purchase_date', $year)
->whereNull('deleted_at')
->groupBy('purchase_type')
->orderByDesc('value')
->limit(6)
->get();
$total = $ratioData->sum('value');
$idx = 0;
return $ratioData->map(function ($item) use ($total, $colors, &$idx) {
$name = $this->getPurchaseTypeName($item->purchase_type);
$result = [
'name' => $name,
'value' => (int) $item->value,
'percentage' => $total > 0 ? round(($item->value / $total) * 100, 1) : 0,
'color' => $colors[$idx % count($colors)],
];
$idx++;
return $result;
})->toArray();
}
private function getPurchaseTypeName(?string $type): string
{
$map = [
'원재료매입' => '원자재',
'부재료매입' => '부자재',
'소모품매입' => '소모품',
'외주가공비' => '외주가공',
'접대비' => '접대비',
'복리후생비' => '복리후생',
];
return $map[$type] ?? ($type ?? '기타');
}
private function getPurchaseDailyItems(int $tenantId, string $today): array
{
$items = DB::table('purchases as p')
->leftJoin('clients as c', 'p.client_id', '=', 'c.id')
->select([
'p.purchase_date as date',
'c.name as supplier',
'p.description as item',
'p.total_amount as amount',
'p.withdrawal_id',
])
->where('p.tenant_id', $tenantId)
->where('p.purchase_date', '>=', Carbon::parse($today)->subDays(30)->format('Y-m-d'))
->whereNull('p.deleted_at')
->orderByDesc('p.purchase_date')
->limit(10)
->get();
return $items->map(fn ($item) => [
'date' => $item->date,
'supplier' => $item->supplier ?? '미지정',
'item' => $item->item ?? '-',
'amount' => (int) $item->amount,
'status' => $item->withdrawal_id ? 'paid' : 'unpaid',
])->toArray();
}
// ─── 3. 생산 현황 ───────────────────────────────
/**
* 생산 현황 요약
*/
public function productionSummary(): array
{
$tenantId = $this->tenantId();
$today = Carbon::now();
$todayStr = $today->format('Y-m-d');
$dayOfWeekMap = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
$dayOfWeek = $dayOfWeekMap[$today->dayOfWeek];
// 공정별 작업 현황
$processes = $this->getProductionProcesses($tenantId, $todayStr);
// 출고 현황
$shipment = $this->getShipmentSummary($tenantId, $todayStr);
return [
'date' => $todayStr,
'day_of_week' => $dayOfWeek,
'processes' => $processes,
'shipment' => $shipment,
];
}
private function getProductionProcesses(int $tenantId, string $today): array
{
// 공정별 작업 지시 집계
$processData = DB::table('work_orders as wo')
->leftJoin('processes as p', 'wo.process_id', '=', 'p.id')
->select(
'p.id as process_id',
'p.process_name as process_name',
DB::raw('COUNT(*) as total_work'),
DB::raw("SUM(CASE WHEN wo.status = 'pending' OR wo.status = 'unassigned' OR wo.status = 'waiting' THEN 1 ELSE 0 END) as todo"),
DB::raw("SUM(CASE WHEN wo.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress"),
DB::raw("SUM(CASE WHEN wo.status = 'completed' OR wo.status = 'shipped' THEN 1 ELSE 0 END) as completed"),
DB::raw("SUM(CASE WHEN wo.priority = 'urgent' THEN 1 ELSE 0 END) as urgent"),
)
->where('wo.tenant_id', $tenantId)
->where('wo.scheduled_date', $today)
->where('wo.is_active', true)
->whereNull('wo.deleted_at')
->whereNotNull('wo.process_id')
->groupBy('p.id', 'p.process_name')
->orderBy('p.process_name')
->get();
return $processData->map(function ($process) use ($tenantId, $today) {
$totalWork = (int) $process->total_work;
$todo = (int) $process->todo;
$inProgress = (int) $process->in_progress;
$completed = (int) $process->completed;
// 작업 아이템 (최대 5건)
$workItems = DB::table('work_orders as wo')
->leftJoin('orders as o', 'wo.sales_order_id', '=', 'o.id')
->leftJoin('clients as c', 'o.client_id', '=', 'c.id')
->select([
'wo.id',
'wo.work_order_no as order_no',
'c.name as client',
'wo.project_name as product',
'wo.status',
])
->where('wo.tenant_id', $tenantId)
->where('wo.process_id', $process->process_id)
->where('wo.scheduled_date', $today)
->where('wo.is_active', true)
->whereNull('wo.deleted_at')
->orderByRaw("FIELD(wo.priority, 'urgent', 'normal', 'low')")
->limit(5)
->get();
// 작업자별 현황
$workers = DB::table('work_order_assignees as woa')
->join('work_orders as wo', 'woa.work_order_id', '=', 'wo.id')
->leftJoin('users as u', 'woa.user_id', '=', 'u.id')
->select(
'u.name',
DB::raw('COUNT(*) as assigned'),
DB::raw("SUM(CASE WHEN wo.status IN ('completed', 'shipped') THEN 1 ELSE 0 END) as completed"),
)
->where('wo.tenant_id', $tenantId)
->where('wo.process_id', $process->process_id)
->where('wo.scheduled_date', $today)
->where('wo.is_active', true)
->whereNull('wo.deleted_at')
->groupBy('woa.user_id', 'u.name')
->get();
return [
'process_name' => $process->process_name ?? '미지정',
'total_work' => $totalWork,
'todo' => $todo,
'in_progress' => $inProgress,
'completed' => $completed,
'urgent' => (int) $process->urgent,
'sub_line' => 0,
'regular' => max(0, $totalWork - (int) $process->urgent),
'worker_count' => $workers->count(),
'work_items' => $workItems->map(fn ($wi) => [
'id' => 'wo_'.$wi->id,
'order_no' => $wi->order_no ?? '-',
'client' => $wi->client ?? '미지정',
'product' => $wi->product ?? '-',
'quantity' => 0,
'status' => $this->mapWorkOrderStatus($wi->status),
])->toArray(),
'workers' => $workers->map(fn ($w) => [
'name' => $w->name ?? '미지정',
'assigned' => (int) $w->assigned,
'completed' => (int) $w->completed,
'rate' => $w->assigned > 0 ? round(($w->completed / $w->assigned) * 100, 0) : 0,
])->toArray(),
];
})->toArray();
}
private function mapWorkOrderStatus(string $status): string
{
return match ($status) {
'completed', 'shipped' => 'completed',
'in_progress' => 'in_progress',
default => 'pending',
};
}
private function getShipmentSummary(int $tenantId, string $today): array
{
$thisMonth = Carbon::parse($today);
$monthStart = $thisMonth->copy()->startOfMonth()->format('Y-m-d');
$monthEnd = $thisMonth->copy()->endOfMonth()->format('Y-m-d');
// 예정 출고
$expected = DB::table('shipments')
->where('tenant_id', $tenantId)
->whereBetween('scheduled_date', [$monthStart, $monthEnd])
->whereIn('status', ['scheduled', 'ready'])
->whereNull('deleted_at')
->selectRaw('COUNT(*) as count, COALESCE(SUM(shipping_cost), 0) as amount')
->first();
// 실제 출고
$actual = DB::table('shipments')
->where('tenant_id', $tenantId)
->whereBetween('scheduled_date', [$monthStart, $monthEnd])
->whereIn('status', ['shipping', 'completed'])
->whereNull('deleted_at')
->selectRaw('COUNT(*) as count, COALESCE(SUM(shipping_cost), 0) as amount')
->first();
return [
'expected_amount' => (int) ($expected->amount ?? 0),
'expected_count' => (int) ($expected->count ?? 0),
'actual_amount' => (int) ($actual->amount ?? 0),
'actual_count' => (int) ($actual->count ?? 0),
];
}
// ─── 4. 미출고 내역 ──────────────────────────────
/**
* 미출고 내역 요약
*/
public function unshippedSummary(): array
{
$tenantId = $this->tenantId();
$today = Carbon::now()->format('Y-m-d');
$items = DB::table('shipments as s')
->leftJoin('orders as o', 's.order_id', '=', 'o.id')
->leftJoin('clients as c', 's.client_id', '=', 'c.id')
->select([
's.id',
's.lot_no as port_no',
's.site_name',
'c.name as order_client',
's.scheduled_date as due_date',
])
->where('s.tenant_id', $tenantId)
->whereIn('s.status', ['scheduled', 'ready'])
->whereNull('s.deleted_at')
->orderBy('s.scheduled_date')
->limit(50)
->get();
$result = $items->map(function ($item) use ($today) {
$dueDate = Carbon::parse($item->due_date);
$daysLeft = Carbon::parse($today)->diffInDays($dueDate, false);
return [
'id' => 'us_'.$item->id,
'port_no' => $item->port_no ?? '-',
'site_name' => $item->site_name ?? '-',
'order_client' => $item->order_client ?? '미지정',
'due_date' => $item->due_date,
'days_left' => (int) $daysLeft,
];
})->toArray();
return [
'items' => $result,
'total_count' => count($result),
];
}
// ─── 5. 시공 현황 ───────────────────────────────
/**
* 시공 현황 요약
*/
public function constructionSummary(): array
{
$tenantId = $this->tenantId();
$now = Carbon::now();
$monthStart = $now->copy()->startOfMonth()->format('Y-m-d');
$monthEnd = $now->copy()->endOfMonth()->format('Y-m-d');
// 이번 달 시공 건수
$thisMonthCount = DB::table('contracts')
->where('tenant_id', $tenantId)
->where(function ($q) use ($monthStart, $monthEnd) {
$q->whereBetween('contract_start_date', [$monthStart, $monthEnd])
->orWhereBetween('contract_end_date', [$monthStart, $monthEnd])
->orWhere(function ($q2) use ($monthStart, $monthEnd) {
$q2->where('contract_start_date', '<=', $monthStart)
->where('contract_end_date', '>=', $monthEnd);
});
})
->where('is_active', true)
->whereNull('deleted_at')
->count();
// 완료 건수
$completedCount = DB::table('contracts')
->where('tenant_id', $tenantId)
->where('status', 'completed')
->where(function ($q) use ($monthStart, $monthEnd) {
$q->whereBetween('contract_end_date', [$monthStart, $monthEnd]);
})
->where('is_active', true)
->whereNull('deleted_at')
->count();
// 시공 아이템 목록
$items = DB::table('contracts as ct')
->leftJoin('users as u', 'ct.construction_pm_id', '=', 'u.id')
->select([
'ct.id',
'ct.project_name as site_name',
'ct.partner_name as client',
'ct.contract_start_date as start_date',
'ct.contract_end_date as end_date',
'ct.status',
'ct.stage',
])
->where('ct.tenant_id', $tenantId)
->where('ct.is_active', true)
->whereNull('ct.deleted_at')
->where(function ($q) use ($monthStart, $monthEnd) {
$q->whereBetween('ct.contract_start_date', [$monthStart, $monthEnd])
->orWhereBetween('ct.contract_end_date', [$monthStart, $monthEnd])
->orWhere(function ($q2) use ($monthStart, $monthEnd) {
$q2->where('ct.contract_start_date', '<=', $monthStart)
->where('ct.contract_end_date', '>=', $monthEnd);
});
})
->orderBy('ct.contract_start_date')
->limit(20)
->get();
$today = $now->format('Y-m-d');
return [
'this_month' => $thisMonthCount,
'completed' => $completedCount,
'items' => $items->map(function ($item) use ($today) {
$progress = $this->calculateContractProgress($item, $today);
return [
'id' => 'c_'.$item->id,
'site_name' => $item->site_name ?? '-',
'client' => $item->client ?? '미지정',
'start_date' => $item->start_date,
'end_date' => $item->end_date,
'progress' => $progress,
'status' => $this->mapContractStatus($item->status, $item->start_date, $today),
];
})->toArray(),
];
}
private function calculateContractProgress(object $contract, string $today): int
{
if ($contract->status === 'completed') {
return 100;
}
$start = Carbon::parse($contract->start_date);
$end = Carbon::parse($contract->end_date);
$now = Carbon::parse($today);
if ($now->lt($start)) {
return 0;
}
$totalDays = $start->diffInDays($end);
if ($totalDays <= 0) {
return 0;
}
$elapsedDays = $start->diffInDays($now);
$progress = min(99, round(($elapsedDays / $totalDays) * 100));
return (int) $progress;
}
private function mapContractStatus(string $status, ?string $startDate, string $today): string
{
if ($status === 'completed') {
return 'completed';
}
if ($startDate && Carbon::parse($startDate)->gt(Carbon::parse($today))) {
return 'scheduled';
}
return 'in_progress';
}
// ─── 6. 근태 현황 ───────────────────────────────
/**
* 근태 현황 요약
*/
public function attendanceSummary(): array
{
$tenantId = $this->tenantId();
$today = Carbon::now()->format('Y-m-d');
// 오늘 근태 기록
$attendances = DB::table('attendances as a')
->leftJoin('users as u', 'a.user_id', '=', 'u.id')
->leftJoin('tenant_user_profiles as tup', function ($join) use ($tenantId) {
$join->on('tup.user_id', '=', 'u.id')
->where('tup.tenant_id', '=', $tenantId);
})
->leftJoin('departments as d', 'tup.department_id', '=', 'd.id')
->select([
'a.id',
'a.status',
'u.name',
'd.name as department',
'tup.position_key as position',
])
->where('a.tenant_id', $tenantId)
->where('a.base_date', $today)
->whereNull('a.deleted_at')
->get();
$present = 0;
$onLeave = 0;
$late = 0;
$absent = 0;
$employees = $attendances->map(function ($att) use (&$present, &$onLeave, &$late, &$absent) {
$mappedStatus = $this->mapAttendanceStatus($att->status);
match ($mappedStatus) {
'present' => $present++,
'on_leave' => $onLeave++,
'late' => $late++,
'absent' => $absent++,
default => null,
};
return [
'id' => 'emp_'.$att->id,
'department' => $att->department ?? '-',
'position' => $att->position ?? '-',
'name' => $att->name ?? '-',
'status' => $mappedStatus,
];
})->toArray();
return [
'present' => $present,
'on_leave' => $onLeave,
'late' => $late,
'absent' => $absent,
'employees' => $employees,
];
}
private function mapAttendanceStatus(?string $status): string
{
return match ($status) {
'onTime', 'normal', 'overtime', 'earlyLeave' => 'present',
'late', 'lateEarlyLeave' => 'late',
'vacation', 'halfDayVacation', 'sickLeave' => 'on_leave',
'absent', 'noRecord' => 'absent',
default => 'present',
};
}
}