Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
2026-02-04 20:26:01 +09:00
36 changed files with 4031 additions and 1753 deletions

View File

@@ -427,6 +427,7 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri
$totalAmount = 0;
$approvalCount = 0;
$cancelCount = 0;
$totalTax = 0;
foreach ($cardList as $card) {
if (!is_object($card)) continue;
@@ -484,6 +485,7 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri
$totalAmount += $parsed['summary']['totalAmount'];
$approvalCount += $parsed['summary']['approvalCount'];
$cancelCount += $parsed['summary']['cancelCount'];
$totalTax += $parsed['summary']['totalTax'] ?? 0;
}
}
}
@@ -493,6 +495,24 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri
return strcmp($b['useDt'] ?? '', $a['useDt'] ?? '');
});
// 전체 데이터에서 공제/불공제 통계 계산
$deductibleAmount = 0;
$deductibleCount = 0;
$nonDeductibleAmount = 0;
$nonDeductibleCount = 0;
foreach ($allLogs as $log) {
$type = $log['deductionType'] ?? 'non_deductible';
$amount = abs($log['approvalAmount'] ?? 0);
if ($type === 'deductible') {
$deductibleAmount += $amount;
$deductibleCount++;
} else {
$nonDeductibleAmount += $amount;
$nonDeductibleCount++;
}
}
// 페이지네이션
$totalCount = count($allLogs);
$maxPageNum = (int)ceil($totalCount / $limit);
@@ -513,7 +533,12 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri
'totalAmount' => $totalAmount,
'count' => $totalCount,
'approvalCount' => $approvalCount,
'cancelCount' => $cancelCount
'cancelCount' => $cancelCount,
'totalTax' => $totalTax,
'deductibleAmount' => $deductibleAmount,
'deductibleCount' => $deductibleCount,
'nonDeductibleAmount' => $nonDeductibleAmount,
'nonDeductibleCount' => $nonDeductibleCount,
]
]
]);
@@ -528,6 +553,11 @@ private function parseTransactionLogs($resultData, $savedData = null): array
$totalAmount = 0;
$approvalCount = 0;
$cancelCount = 0;
$totalTax = 0;
$deductibleAmount = 0;
$deductibleCount = 0;
$nonDeductibleAmount = 0;
$nonDeductibleCount = 0;
$rawLogs = [];
if (isset($resultData->CardLogList) && isset($resultData->CardLogList->CardApprovalLog)) {
@@ -626,6 +656,18 @@ private function parseTransactionLogs($resultData, $savedData = null): array
'isSaved' => $savedItem !== null,
];
// 공제/불공제 통계 집계
$deductionType = $logItem['deductionType'];
$absAmount = abs($amount);
$totalTax += abs(floatval($log->Tax ?? 0));
if ($deductionType === 'deductible') {
$deductibleAmount += $absAmount;
$deductibleCount++;
} else {
$nonDeductibleAmount += $absAmount;
$nonDeductibleCount++;
}
$logs[] = $logItem;
}
@@ -635,7 +677,12 @@ private function parseTransactionLogs($resultData, $savedData = null): array
'totalAmount' => $totalAmount,
'count' => count($logs),
'approvalCount' => $approvalCount,
'cancelCount' => $cancelCount
'cancelCount' => $cancelCount,
'totalTax' => $totalTax,
'deductibleAmount' => $deductibleAmount,
'deductibleCount' => $deductibleCount,
'nonDeductibleAmount' => $nonDeductibleAmount,
'nonDeductibleCount' => $nonDeductibleCount,
]
];
}

View File

@@ -339,6 +339,82 @@ public function sendToNts(Request $request): JsonResponse
}
}
/**
* 공급자 기초정보 조회
*/
public function getSupplier(): JsonResponse
{
$tenantId = session('selected_tenant_id');
if (!$tenantId) {
return response()->json(['success' => false, 'error' => '테넌트가 선택되지 않았습니다.'], 400);
}
$member = BarobillMember::where('tenant_id', $tenantId)->first();
if (!$member) {
return response()->json(['success' => false, 'error' => '바로빌 회원사 정보가 없습니다.'], 404);
}
return response()->json([
'success' => true,
'supplier' => [
'bizno' => $member->biz_no,
'name' => $member->corp_name ?? '',
'ceo' => $member->ceo_name ?? '',
'addr' => $member->addr ?? '',
'bizType' => $member->biz_type ?? '',
'bizClass' => $member->biz_class ?? '',
'contact' => $member->manager_name ?? '',
'contactPhone' => $member->manager_hp ?? '',
'email' => $member->manager_email ?? '',
],
]);
}
/**
* 공급자 기초정보 수정
*/
public function updateSupplier(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id');
if (!$tenantId) {
return response()->json(['success' => false, 'error' => '테넌트가 선택되지 않았습니다.'], 400);
}
$member = BarobillMember::where('tenant_id', $tenantId)->first();
if (!$member) {
return response()->json(['success' => false, 'error' => '바로빌 회원사 정보가 없습니다.'], 404);
}
$validated = $request->validate([
'corp_name' => 'required|string|max:100',
'ceo_name' => 'required|string|max:50',
'addr' => 'nullable|string|max:255',
'biz_type' => 'nullable|string|max:100',
'biz_class' => 'nullable|string|max:100',
'manager_name' => 'nullable|string|max:50',
'manager_email' => 'nullable|email|max:100',
'manager_hp' => 'nullable|string|max:20',
]);
$member->update($validated);
return response()->json([
'success' => true,
'message' => '공급자 정보가 수정되었습니다.',
'supplier' => [
'bizno' => $member->biz_no,
'name' => $member->corp_name ?? '',
'ceo' => $member->ceo_name ?? '',
'addr' => $member->addr ?? '',
'bizType' => $member->biz_type ?? '',
'bizClass' => $member->biz_class ?? '',
'contact' => $member->manager_name ?? '',
'contactPhone' => $member->manager_hp ?? '',
'email' => $member->manager_email ?? '',
],
]);
}
/**
* 세금계산서 삭제
*/
@@ -501,9 +577,17 @@ private function issueTaxInvoice(array $invoiceData): array
'TaxInvoiceTradeLineItems' => ['TaxInvoiceTradeLineItem' => []],
];
$year = substr($invoiceData['supplyDate'] ?? date('Y-m-d'), 0, 4);
foreach ($invoiceData['items'] ?? [] as $item) {
$month = str_pad($item['month'] ?? '', 2, '0', STR_PAD_LEFT);
$day = str_pad($item['day'] ?? '', 2, '0', STR_PAD_LEFT);
$purchaseExpiry = ($month && $day && $month !== '00' && $day !== '00')
? $year . $month . $day
: '';
$taxInvoice['TaxInvoiceTradeLineItems']['TaxInvoiceTradeLineItem'][] = [
'PurchaseExpiry' => '', // 공제기한
'PurchaseExpiry' => $purchaseExpiry, // 거래일자 (YYYYMMDD)
'Name' => $item['name'] ?? '', // 품명
'Information' => $item['spec'] ?? '', // 규격
'ChargeableUnit' => $item['qty'] ?? '1', // 수량

View File

@@ -6,6 +6,7 @@
use App\Models\Barobill\BarobillConfig;
use App\Models\Barobill\BarobillMember;
use App\Models\Tenants\Tenant;
use App\Services\Barobill\HometaxSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@@ -1174,4 +1175,230 @@ private function xmlToObject(\SimpleXMLElement $xml): object
return $result;
}
/**
* 로컬 DB에서 매출 세금계산서 조회
*/
public function localSales(Request $request, HometaxSyncService $syncService): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$startDate = $request->input('startDate');
$endDate = $request->input('endDate');
// YYYYMMDD 형식을 Y-m-d로 변환
if (strlen($startDate) === 8) {
$startDate = substr($startDate, 0, 4) . '-' . substr($startDate, 4, 2) . '-' . substr($startDate, 6, 2);
}
if (strlen($endDate) === 8) {
$endDate = substr($endDate, 0, 4) . '-' . substr($endDate, 4, 2) . '-' . substr($endDate, 6, 2);
}
$dateType = $request->input('dateType', 1) == 1 ? 'write' : 'issue';
$searchCorp = $request->input('searchCorp');
$data = $syncService->getLocalInvoices(
$tenantId,
'sales',
$startDate,
$endDate,
$dateType,
$searchCorp
);
return response()->json([
'success' => true,
'data' => $data,
'lastSyncAt' => $syncService->getLastSyncTime($tenantId, 'sales'),
]);
} catch (\Throwable $e) {
Log::error('로컬 매출 조회 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'error' => '조회 오류: ' . $e->getMessage()
]);
}
}
/**
* 로컬 DB에서 매입 세금계산서 조회
*/
public function localPurchases(Request $request, HometaxSyncService $syncService): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$startDate = $request->input('startDate');
$endDate = $request->input('endDate');
// YYYYMMDD 형식을 Y-m-d로 변환
if (strlen($startDate) === 8) {
$startDate = substr($startDate, 0, 4) . '-' . substr($startDate, 4, 2) . '-' . substr($startDate, 6, 2);
}
if (strlen($endDate) === 8) {
$endDate = substr($endDate, 0, 4) . '-' . substr($endDate, 4, 2) . '-' . substr($endDate, 6, 2);
}
$dateType = $request->input('dateType', 1) == 1 ? 'write' : 'issue';
$searchCorp = $request->input('searchCorp');
$data = $syncService->getLocalInvoices(
$tenantId,
'purchase',
$startDate,
$endDate,
$dateType,
$searchCorp
);
return response()->json([
'success' => true,
'data' => $data,
'lastSyncAt' => $syncService->getLastSyncTime($tenantId, 'purchase'),
]);
} catch (\Throwable $e) {
Log::error('로컬 매입 조회 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'error' => '조회 오류: ' . $e->getMessage()
]);
}
}
/**
* 바로빌 API에서 데이터를 가져와 로컬 DB에 동기화
*/
public function sync(Request $request, HometaxSyncService $syncService): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$type = $request->input('type', 'all'); // 'sales', 'purchase', 'all'
$startDate = $request->input('startDate', date('Ymd', strtotime('-1 month')));
$endDate = $request->input('endDate', date('Ymd'));
$dateType = (int)$request->input('dateType', 1);
$results = [];
// 매출 동기화
if ($type === 'all' || $type === 'sales') {
$salesRequest = new Request([
'startDate' => $startDate,
'endDate' => $endDate,
'dateType' => $dateType,
'limit' => 500,
]);
$salesResponse = $this->sales($salesRequest);
$salesData = json_decode($salesResponse->getContent(), true);
if ($salesData['success'] && !empty($salesData['data']['invoices'])) {
$results['sales'] = $syncService->syncInvoices(
$salesData['data']['invoices'],
$tenantId,
'sales'
);
} else {
$results['sales'] = [
'inserted' => 0,
'updated' => 0,
'failed' => 0,
'total' => 0,
'error' => $salesData['error'] ?? null,
];
}
}
// 매입 동기화
if ($type === 'all' || $type === 'purchase') {
$purchaseRequest = new Request([
'startDate' => $startDate,
'endDate' => $endDate,
'dateType' => $dateType,
'limit' => 500,
]);
$purchaseResponse = $this->purchases($purchaseRequest);
$purchaseData = json_decode($purchaseResponse->getContent(), true);
if ($purchaseData['success'] && !empty($purchaseData['data']['invoices'])) {
$results['purchase'] = $syncService->syncInvoices(
$purchaseData['data']['invoices'],
$tenantId,
'purchase'
);
} else {
$results['purchase'] = [
'inserted' => 0,
'updated' => 0,
'failed' => 0,
'total' => 0,
'error' => $purchaseData['error'] ?? null,
];
}
}
// 총 결과 계산
$totalInserted = ($results['sales']['inserted'] ?? 0) + ($results['purchase']['inserted'] ?? 0);
$totalUpdated = ($results['sales']['updated'] ?? 0) + ($results['purchase']['updated'] ?? 0);
return response()->json([
'success' => true,
'message' => "동기화 완료: {$totalInserted}건 추가, {$totalUpdated}건 갱신",
'data' => $results,
]);
} catch (\Throwable $e) {
Log::error('홈택스 동기화 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'error' => '동기화 오류: ' . $e->getMessage()
]);
}
}
/**
* 세금계산서 메모 업데이트
*/
public function updateMemo(Request $request, HometaxSyncService $syncService): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$id = $request->input('id');
$memo = $request->input('memo');
$success = $syncService->updateMemo($id, $tenantId, $memo);
return response()->json([
'success' => $success,
'message' => $success ? '메모가 저장되었습니다.' : '저장에 실패했습니다.',
]);
} catch (\Throwable $e) {
return response()->json([
'success' => false,
'error' => '오류: ' . $e->getMessage()
]);
}
}
/**
* 세금계산서 확인 여부 토글
*/
public function toggleChecked(Request $request, HometaxSyncService $syncService): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$id = $request->input('id');
$success = $syncService->toggleChecked($id, $tenantId);
return response()->json([
'success' => $success,
]);
} catch (\Throwable $e) {
return response()->json([
'success' => false,
'error' => '오류: ' . $e->getMessage()
]);
}
}
}

View File

@@ -9,9 +9,6 @@
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class VehicleLogController extends Controller
{
@@ -21,82 +18,112 @@ public function index(Request $request): View|Response
return response('', 200)->header('HX-Redirect', route('finance.vehicle-logs'));
}
return view('finance.vehicle-logs');
}
/**
* 차량 목록 조회
*/
public function vehicles(Request $request): JsonResponse
{
$tenantId = session('tenant_id', 1);
$vehicles = CorporateVehicle::where('tenant_id', $tenantId)
->where('status', 'active')
->orderBy('plate_number')
->get();
return view('finance.vehicle-logs', [
'vehicles' => $vehicles,
'tripTypes' => VehicleLog::tripTypeLabels(),
'locationTypes' => VehicleLog::locationTypeLabels(),
return response()->json([
'success' => true,
'data' => $vehicles,
]);
}
/**
* 운행기록 목록 조회
*/
public function list(Request $request): JsonResponse
{
$tenantId = session('tenant_id', 1);
$request->validate([
'vehicle_id' => 'required|integer',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
]);
$query = VehicleLog::with('vehicle')
->where('tenant_id', $tenantId);
$vehicleId = $request->vehicle_id;
$startDate = $request->start_date;
$endDate = $request->end_date;
// 차량 필터
if ($request->filled('vehicle_id') && $request->vehicle_id !== 'all') {
$query->where('vehicle_id', $request->vehicle_id);
}
// 차량 정보
$vehicle = CorporateVehicle::where('tenant_id', $tenantId)
->findOrFail($vehicleId);
// 년/월 필터
if ($request->filled('year')) {
$query->whereYear('log_date', $request->year);
}
if ($request->filled('month')) {
$query->whereMonth('log_date', $request->month);
}
// 전체 운행기록 수 (해당 차량)
$totalCount = VehicleLog::where('tenant_id', $tenantId)
->where('vehicle_id', $vehicleId)
->count();
// 구분 필터
if ($request->filled('trip_type') && $request->trip_type !== 'all') {
$query->where('trip_type', $request->trip_type);
}
$logs = VehicleLog::where('tenant_id', $tenantId)
->where('vehicle_id', $vehicleId)
->whereBetween('log_date', [$startDate, $endDate])
->orderBy('log_date', 'desc')
// 검색어
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('driver_name', 'like', "%{$search}%")
->orWhere('department', 'like', "%{$search}%")
->orWhere('departure_name', 'like', "%{$search}%")
->orWhere('arrival_name', 'like', "%{$search}%")
->orWhere('note', 'like', "%{$search}%");
});
}
$logs = $query->orderBy('log_date', 'desc')
->orderBy('id', 'desc')
->get();
// 월별 합계
$totals = [
'business_km' => $logs->whereIn('trip_type', ['commute_to', 'commute_from', 'business', 'commute_round', 'business_round'])->sum('distance_km'),
'personal_km' => $logs->whereIn('trip_type', ['personal', 'personal_round'])->sum('distance_km'),
'total_km' => $logs->sum('distance_km'),
];
// 응답 포맷팅
$data = $logs->map(function ($log) {
return [
'id' => $log->id,
'logDate' => $log->log_date->format('Y-m-d'),
'vehicleId' => $log->vehicle_id,
'plateNumber' => $log->vehicle?->plate_number,
'model' => $log->vehicle?->model,
'department' => $log->department,
'driverName' => $log->driver_name,
'tripType' => $log->trip_type,
'departureType' => $log->departure_type,
'departureName' => $log->departure_name,
'departureAddress' => $log->departure_address,
'arrivalType' => $log->arrival_type,
'arrivalName' => $log->arrival_name,
'arrivalAddress' => $log->arrival_address,
'distanceKm' => $log->distance_km,
'note' => $log->note,
];
});
return response()->json([
'success' => true,
'data' => [
'vehicle' => $vehicle,
'logs' => $logs,
'totals' => $totals,
'totalCount' => $totalCount,
],
'data' => $data,
]);
}
/**
* 운행기록 등록
*/
public function store(Request $request): JsonResponse
{
$tenantId = session('tenant_id', 1);
$request->validate([
'vehicle_id' => 'required|integer|exists:corporate_vehicles,id',
'vehicle_id' => 'required|exists:corporate_vehicles,id',
'log_date' => 'required|date',
'driver_name' => 'required|string|max:50',
'trip_type' => 'required|in:commute_to,commute_from,business,personal,commute_round,business_round,personal_round',
'trip_type' => 'required|in:commute_to,commute_from,business,personal',
'distance_km' => 'required|integer|min:0',
]);
// 해당 차량이 현재 테넌트의 것인지 확인
CorporateVehicle::where('tenant_id', $tenantId)
->findOrFail($request->vehicle_id);
$tenantId = session('tenant_id', 1);
$log = VehicleLog::create([
'tenant_id' => $tenantId,
@@ -122,6 +149,9 @@ public function store(Request $request): JsonResponse
]);
}
/**
* 운행기록 수정
*/
public function update(Request $request, int $id): JsonResponse
{
$tenantId = session('tenant_id', 1);
@@ -129,13 +159,15 @@ public function update(Request $request, int $id): JsonResponse
$log = VehicleLog::where('tenant_id', $tenantId)->findOrFail($id);
$request->validate([
'vehicle_id' => 'required|exists:corporate_vehicles,id',
'log_date' => 'required|date',
'driver_name' => 'required|string|max:50',
'trip_type' => 'required|in:commute_to,commute_from,business,personal,commute_round,business_round,personal_round',
'trip_type' => 'required|in:commute_to,commute_from,business,personal',
'distance_km' => 'required|integer|min:0',
]);
$log->update([
'vehicle_id' => $request->vehicle_id,
'log_date' => $request->log_date,
'department' => $request->department,
'driver_name' => $request->driver_name,
@@ -157,6 +189,9 @@ public function update(Request $request, int $id): JsonResponse
]);
}
/**
* 운행기록 삭제
*/
public function destroy(int $id): JsonResponse
{
$tenantId = session('tenant_id', 1);
@@ -170,96 +205,65 @@ public function destroy(int $id): JsonResponse
]);
}
public function export(Request $request): StreamedResponse
/**
* 월간 통계 조회
*/
public function summary(Request $request): JsonResponse
{
$tenantId = session('tenant_id', 1);
$request->validate([
'vehicle_id' => 'required|integer',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
]);
$query = VehicleLog::where('tenant_id', $tenantId);
$vehicleId = $request->vehicle_id;
$startDate = $request->start_date;
$endDate = $request->end_date;
$vehicle = CorporateVehicle::where('tenant_id', $tenantId)
->findOrFail($vehicleId);
$logs = VehicleLog::where('tenant_id', $tenantId)
->where('vehicle_id', $vehicleId)
->whereBetween('log_date', [$startDate, $endDate])
->orderBy('log_date')
->orderBy('id')
->get();
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('운행기록부');
// 기본 정보
$sheet->setCellValue('A1', '업무용승용차 운행기록부');
$sheet->setCellValue('A3', '차량번호');
$sheet->setCellValue('B3', $vehicle->plate_number);
$sheet->setCellValue('C3', '차종');
$sheet->setCellValue('D3', $vehicle->model);
$sheet->setCellValue('E3', '구분');
$sheet->setCellValue('F3', $this->getOwnershipTypeLabel($vehicle->ownership_type));
$sheet->setCellValue('A4', '조회기간');
$sheet->setCellValue('B4', sprintf('%s ~ %s', $startDate, $endDate));
// 헤더
$headers = ['일자', '부서', '성명', '구분', '출발지', '도착지', '주행km', '비고'];
$col = 'A';
foreach ($headers as $header) {
$sheet->setCellValue($col . '6', $header);
$col++;
// 차량 필터
if ($request->filled('vehicle_id') && $request->vehicle_id !== 'all') {
$query->where('vehicle_id', $request->vehicle_id);
}
// 데이
$row = 7;
$tripTypeLabels = VehicleLog::tripTypeLabels();
foreach ($logs as $log) {
$sheet->setCellValue('A' . $row, $log->log_date->format('Y-m-d'));
$sheet->setCellValue('B' . $row, $log->department ?? '');
$sheet->setCellValue('C' . $row, $log->driver_name);
$sheet->setCellValue('D' . $row, $tripTypeLabels[$log->trip_type] ?? $log->trip_type);
$sheet->setCellValue('E' . $row, $log->departure_name ?? '');
$sheet->setCellValue('F' . $row, $log->arrival_name ?? '');
$sheet->setCellValue('G' . $row, $log->distance_km);
$sheet->setCellValue('H' . $row, $log->note ?? '');
$row++;
// 년/월 필
if ($request->filled('year')) {
$query->whereYear('log_date', $request->year);
}
if ($request->filled('month')) {
$query->whereMonth('log_date', $request->month);
}
// 합계
$businessKm = $logs->whereIn('trip_type', ['commute_to', 'commute_from', 'business', 'commute_round', 'business_round'])->sum('distance_km');
$personalKm = $logs->whereIn('trip_type', ['personal', 'personal_round'])->sum('distance_km');
$totalKm = $logs->sum('distance_km');
// 구분별 주행거리 합계
$summary = $query->selectRaw('
trip_type,
COUNT(*) as count,
SUM(distance_km) as total_distance
')
->groupBy('trip_type')
->get()
->keyBy('trip_type');
$sheet->setCellValue('A' . $row, '합계');
$sheet->setCellValue('F' . $row, '업무용: ' . number_format($businessKm) . 'km');
$sheet->setCellValue('G' . $row, number_format($totalKm));
$sheet->setCellValue('H' . $row, '비업무: ' . number_format($personalKm) . 'km');
$tripTypes = VehicleLog::getTripTypes();
$result = [];
$totalCount = 0;
$totalDistance = 0;
$filename = sprintf('운행기록부_%s_%s_%s.xlsx', $vehicle->plate_number, $startDate, $endDate);
foreach ($tripTypes as $type => $label) {
$data = $summary->get($type);
$count = $data ? $data->count : 0;
$distance = $data ? $data->total_distance : 0;
$result[$type] = [
'label' => $label,
'count' => $count,
'distance' => $distance,
];
$totalCount += $count;
$totalDistance += $distance;
}
return response()->streamDownload(function () use ($spreadsheet) {
$writer = new Xlsx($spreadsheet);
$writer->save('php://output');
}, $filename, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
return response()->json([
'success' => true,
'data' => [
'byType' => $result,
'total' => [
'count' => $totalCount,
'distance' => $totalDistance,
],
],
]);
}
private function getOwnershipTypeLabel(string $type): string
{
return match ($type) {
'corporate' => '회사',
'rent' => '렌트',
'lease' => '리스',
default => $type,
};
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace App\Http\Controllers\Finance;
use App\Http\Controllers\Controller;
use App\Models\CorporateVehicle;
use App\Models\VehicleMaintenance;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class VehicleMaintenanceController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.vehicle-maintenance'));
}
return view('finance.vehicle-maintenance');
}
/**
* 차량 목록 조회
*/
public function vehicles(Request $request): JsonResponse
{
$tenantId = session('tenant_id', 1);
$vehicles = CorporateVehicle::where('tenant_id', $tenantId)
->orderBy('plate_number')
->get();
return response()->json([
'success' => true,
'data' => $vehicles,
]);
}
/**
* 정비 이력 목록 조회
*/
public function list(Request $request): JsonResponse
{
$tenantId = session('tenant_id', 1);
$query = VehicleMaintenance::with('vehicle')
->where('tenant_id', $tenantId);
// 차량 필터
if ($request->filled('vehicle_id') && $request->vehicle_id !== 'all') {
$query->where('vehicle_id', $request->vehicle_id);
}
// 카테고리 필터
if ($request->filled('category') && $request->category !== 'all') {
$query->where('category', $request->category);
}
// 날짜 범위 필터
if ($request->filled('start_date')) {
$query->whereDate('date', '>=', $request->start_date);
}
if ($request->filled('end_date')) {
$query->whereDate('date', '<=', $request->end_date);
}
// 검색어
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('vendor', 'like', "%{$search}%")
->orWhere('memo', 'like', "%{$search}%");
});
}
$maintenances = $query->orderBy('date', 'desc')->get();
// 응답 포맷팅
$data = $maintenances->map(function ($m) {
return [
'id' => $m->id,
'date' => $m->date->format('Y-m-d'),
'vehicleId' => $m->vehicle_id,
'plateNumber' => $m->vehicle?->plate_number,
'model' => $m->vehicle?->model,
'category' => $m->category,
'description' => $m->description,
'amount' => $m->amount,
'mileage' => $m->mileage,
'vendor' => $m->vendor,
'memo' => $m->memo,
];
});
return response()->json([
'success' => true,
'data' => $data,
]);
}
/**
* 정비 이력 등록
*/
public function store(Request $request): JsonResponse
{
$request->validate([
'vehicle_id' => 'required|exists:corporate_vehicles,id',
'date' => 'required|date',
'category' => 'required|string|max:20',
'amount' => 'required|numeric|min:0',
]);
$tenantId = session('tenant_id', 1);
$maintenance = VehicleMaintenance::create([
'tenant_id' => $tenantId,
'vehicle_id' => $request->vehicle_id,
'date' => $request->date,
'category' => $request->category,
'description' => $request->description,
'amount' => $request->amount ?? 0,
'mileage' => $request->mileage,
'vendor' => $request->vendor,
'memo' => $request->memo,
]);
// 차량 주행거리 업데이트
if ($request->filled('mileage')) {
CorporateVehicle::where('id', $request->vehicle_id)
->where('tenant_id', $tenantId)
->update(['mileage' => $request->mileage]);
}
return response()->json([
'success' => true,
'message' => '정비 이력이 등록되었습니다.',
'data' => $maintenance,
]);
}
/**
* 정비 이력 수정
*/
public function update(Request $request, int $id): JsonResponse
{
$tenantId = session('tenant_id', 1);
$maintenance = VehicleMaintenance::where('tenant_id', $tenantId)->findOrFail($id);
$request->validate([
'vehicle_id' => 'required|exists:corporate_vehicles,id',
'date' => 'required|date',
'category' => 'required|string|max:20',
'amount' => 'required|numeric|min:0',
]);
$maintenance->update([
'vehicle_id' => $request->vehicle_id,
'date' => $request->date,
'category' => $request->category,
'description' => $request->description,
'amount' => $request->amount ?? 0,
'mileage' => $request->mileage,
'vendor' => $request->vendor,
'memo' => $request->memo,
]);
// 차량 주행거리 업데이트
if ($request->filled('mileage')) {
CorporateVehicle::where('id', $request->vehicle_id)
->where('tenant_id', $tenantId)
->update(['mileage' => $request->mileage]);
}
return response()->json([
'success' => true,
'message' => '정비 이력이 수정되었습니다.',
'data' => $maintenance,
]);
}
/**
* 정비 이력 삭제
*/
public function destroy(int $id): JsonResponse
{
$tenantId = session('tenant_id', 1);
$maintenance = VehicleMaintenance::where('tenant_id', $tenantId)->findOrFail($id);
$maintenance->delete();
return response()->json([
'success' => true,
'message' => '정비 이력이 삭제되었습니다.',
]);
}
}

View File

@@ -108,8 +108,9 @@ private function getIndexData(Request $request): array
});
}
// 상태 필터
if (!empty($filters['status'])) {
// 상태 필터 (progress_complete는 계산값 기반이므로 별도 처리)
$isProgressCompleteFilter = ($filters['status'] === 'progress_complete');
if (!empty($filters['status']) && !$isProgressCompleteFilter) {
$query->where('status', $filters['status']);
}
@@ -118,32 +119,87 @@ private function getIndexData(Request $request): array
$query->where('registered_by', $filters['registered_by']);
}
$prospects = $query->orderByDesc('created_at')->paginate(20);
// progress_complete 필터: 전체 조회 후 PHP에서 필터링
if ($isProgressCompleteFilter) {
$allProspects = $query->orderByDesc('created_at')->get();
// 각 가망고객의 진행률 계산 및 상태 자동 전환
foreach ($prospects as $prospect) {
$progress = SalesScenarioChecklist::getProspectProgress($prospect->id);
$prospect->sales_progress = $progress['sales']['percentage'];
$prospect->manager_progress = $progress['manager']['percentage'];
// 진행률 계산 및 부가정보 세팅
foreach ($allProspects as $prospect) {
$progress = SalesScenarioChecklist::getProspectProgress($prospect->id);
$prospect->sales_progress = $progress['sales']['percentage'];
$prospect->manager_progress = $progress['manager']['percentage'];
// 진행률 100% 시 상태 자동 전환 체크
if ($progress['sales']['percentage'] === 100 && $progress['manager']['percentage'] === 100) {
SalesScenarioChecklist::checkAndConvertProspectStatus($prospect->id);
$prospect->refresh();
if ($progress['sales']['percentage'] === 100 && $progress['manager']['percentage'] === 100) {
SalesScenarioChecklist::checkAndConvertProspectStatus($prospect->id);
$prospect->refresh();
}
$management = SalesTenantManagement::where('tenant_prospect_id', $prospect->id)->first();
$prospect->hq_status = $management?->hq_status ?? 'pending';
$prospect->hq_status_label = $management?->hq_status_label ?? '대기';
$prospect->manager_user = $management?->manager;
if ($management) {
$commission = SalesCommission::where('management_id', $management->id)->first();
$prospect->commission = $commission;
} else {
$prospect->commission = null;
}
}
// management 정보
$management = SalesTenantManagement::where('tenant_prospect_id', $prospect->id)->first();
$prospect->hq_status = $management?->hq_status ?? 'pending';
$prospect->hq_status_label = $management?->hq_status_label ?? '대기';
$prospect->manager_user = $management?->manager;
// 두 시나리오 모두 100%인 것만 필터링
$filtered = $allProspects->filter(function ($prospect) {
return $prospect->sales_progress === 100 && $prospect->manager_progress === 100;
});
// 수당 정보 (management가 있는 경우)
if ($management) {
$commission = SalesCommission::where('management_id', $management->id)->first();
$prospect->commission = $commission;
} else {
$prospect->commission = null;
// 수동 페이지네이션
$page = request()->get('page', 1);
$perPage = 20;
$prospects = new \Illuminate\Pagination\LengthAwarePaginator(
$filtered->forPage($page, $perPage)->values(),
$filtered->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
} else {
$prospects = $query->orderByDesc('created_at')->paginate(20);
// 각 가망고객의 진행률 계산 및 상태 자동 전환
foreach ($prospects as $prospect) {
$progress = SalesScenarioChecklist::getProspectProgress($prospect->id);
$prospect->sales_progress = $progress['sales']['percentage'];
$prospect->manager_progress = $progress['manager']['percentage'];
// 진행률 100% 시 상태 자동 전환 체크
if ($progress['sales']['percentage'] === 100 && $progress['manager']['percentage'] === 100) {
SalesScenarioChecklist::checkAndConvertProspectStatus($prospect->id);
$prospect->refresh();
}
// management 정보
$management = SalesTenantManagement::where('tenant_prospect_id', $prospect->id)->first();
$prospect->hq_status = $management?->hq_status ?? 'pending';
$prospect->hq_status_label = $management?->hq_status_label ?? '대기';
$prospect->manager_user = $management?->manager;
// 수당 정보 (management가 있는 경우)
if ($management) {
$commission = SalesCommission::where('management_id', $management->id)->first();
$prospect->commission = $commission;
} else {
$prospect->commission = null;
}
}
}
// 진행완료 건수 계산 (전체 prospect 중 두 시나리오 모두 100%인 건수)
$progressCompleteCount = 0;
$allForStats = TenantProspect::all();
foreach ($allForStats as $p) {
$prog = SalesScenarioChecklist::getProspectProgress($p->id);
if ($prog['sales']['percentage'] === 100 && $prog['manager']['percentage'] === 100) {
$progressCompleteCount++;
}
}
@@ -153,6 +209,7 @@ private function getIndexData(Request $request): array
'active' => TenantProspect::where('status', TenantProspect::STATUS_ACTIVE)->count(),
'expired' => TenantProspect::where('status', TenantProspect::STATUS_EXPIRED)->count(),
'converted' => TenantProspect::where('status', TenantProspect::STATUS_CONVERTED)->count(),
'progress_complete' => $progressCompleteCount,
];
// 영업파트너별 통계

View File

@@ -181,7 +181,8 @@ private function getDashboardData(Request $request): array
$totalMembershipFee += $handoverTotalRegFee;
$totalCommission = $partnerCommissionTotal + $managerCommissionTotal;
// 역할별 수당 업데이트
// 역할별 수당 업데이트 (실제 지급된 수당 기준)
// 참고: 예상 수당은 나중에 $totalExpectedCommission으로 별도 계산됨
$commissionByRole[0]['amount'] = $partnerCommissionTotal;
$commissionByRole[1]['amount'] = $managerCommissionTotal;
@@ -294,6 +295,10 @@ private function getDashboardData(Request $request): array
$paidCommission
);
// 역할별 수당을 예상 수당 기준으로 재설정 (1차+2차 수당과 일치하도록)
// 판매자 예상 수당 = 개발비 × 10% (개발 진행 중 + 인계 완료 미지급)
$commissionByRole[0]['amount'] = $totalExpectedCommission;
// 전환된 테넌트만 조회 (최신순, 페이지네이션)
$tenants = Tenant::whereIn('id', $convertedTenantIds)
->orderBy('created_at', 'desc')
@@ -653,11 +658,49 @@ private function calculatePartnerSummaryStats(array $partnerIds, int $currentUse
// 최종 예상 수당 (확정 + 예상 중 큰 값)
$expectedCommission = max($confirmedCommission, $expectedFromFee);
// 지급 완료된 매니저 수당
$paidManagerCommission = SalesCommission::where('manager_user_id', $currentUserId)
->whereHas('partner', function ($query) use ($partnerIds) {
$query->whereIn('user_id', $partnerIds);
})
->where('status', SalesCommission::STATUS_PAID)
->sum('manager_commission');
// 지급예정 (승인됨)
$scheduledManagerCommission = SalesCommission::where('manager_user_id', $currentUserId)
->whereHas('partner', function ($query) use ($partnerIds) {
$query->whereIn('user_id', $partnerIds);
})
->where('status', SalesCommission::STATUS_APPROVED)
->sum('manager_commission');
// 입금대기 = 총 예상 - 지급완료 - 지급예정
$pendingManagerCommission = max(0, $expectedCommission - $paidManagerCommission - $scheduledManagerCommission);
// 1차/2차 분할 (각 50%)
$halfExpected = $expectedCommission / 2;
$halfPending = $pendingManagerCommission / 2;
$halfScheduled = $scheduledManagerCommission / 2;
$halfPaid = $paidManagerCommission / 2;
return [
'partner_count' => $partnerCount,
'total_prospects' => $totalProspects,
'total_conversions' => $totalConversions,
'expected_commission' => $expectedCommission,
'paid_commission' => $paidManagerCommission,
'first_commission' => [
'total' => (int)$halfExpected,
'pending' => (int)$halfPending,
'scheduled' => (int)$halfScheduled,
'paid' => (int)$halfPaid,
],
'second_commission' => [
'total' => (int)$halfExpected,
'pending' => (int)$halfPending,
'scheduled' => (int)$halfScheduled,
'paid' => (int)$halfPaid,
],
];
}
@@ -891,6 +934,16 @@ public function helpGuide(): View
return view('sales.dashboard.partials.help-modal', compact('htmlContent'));
}
/**
* 가망고객 개별 행 반환 (HTMX 동적 업데이트용)
*/
public function getProspectRow(int $prospectId): View
{
$prospect = TenantProspect::findOrFail($prospectId);
return view('sales.dashboard.partials.prospect-row', compact('prospect'));
}
/**
* 예상 수당 요약 계산 (개발 진행중 + 인계완료 미지급)
*/
@@ -905,7 +958,7 @@ private function calculateExpectedCommissionSummary($commissions, int $totalExpe
->whereIn('status', [SalesCommission::STATUS_PENDING, SalesCommission::STATUS_APPROVED])
->sum('partner_commission');
// 입대기 금액 = 총 예상 수당 - 지급예정 - 지급완료
// 입대기 금액 = 총 예상 수당 - 지급예정 - 지급완료
$pendingAmount = max(0, $totalExpectedCommission - $scheduledAmount - $paidCommission);
// 1차/2차 분할 (각 50%)

View File

@@ -56,7 +56,11 @@ public function switch(Request $request)
$tenantId = $request->input('tenant_id');
if ($tenantId === 'all') {
$request->session()->forget('selected_tenant_id');
// "전체 보기" 대신 사용자의 HQ 테넌트로 설정
$hqTenant = auth()->user()->getHQTenant();
if ($hqTenant) {
$request->session()->put('selected_tenant_id', $hqTenant->id);
}
} else {
$request->session()->put('selected_tenant_id', $tenantId);
}