feat:바로빌 과금관리 시스템 구현
- 모델: BarobillSubscription, BarobillBillingRecord, BarobillMonthlySummary - 서비스: BarobillBillingService (구독/과금 처리 로직) - API 컨트롤러: BarobillBillingController (구독/과금 CRUD) - 뷰: 과금 현황 탭, 구독 관리 탭, 통계 카드, 상세 모달 - 라우트: 웹/API 라우트 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Barobill;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Barobill\BarobillBillingRecord;
|
||||
use App\Models\Barobill\BarobillMember;
|
||||
use App\Models\Barobill\BarobillMonthlySummary;
|
||||
use App\Models\Barobill\BarobillSubscription;
|
||||
use App\Services\Barobill\BarobillBillingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* 바로빌 과금 관리 API 컨트롤러
|
||||
*/
|
||||
class BarobillBillingController extends Controller
|
||||
{
|
||||
private const HEADQUARTERS_TENANT_ID = 1;
|
||||
|
||||
public function __construct(
|
||||
protected BarobillBillingService $billingService
|
||||
) {}
|
||||
|
||||
// ========================================
|
||||
// 구독 관리
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 구독 목록 조회
|
||||
*/
|
||||
public function subscriptions(Request $request): JsonResponse|Response
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID;
|
||||
$allTenants = $request->boolean('all_tenants', false);
|
||||
|
||||
$query = BarobillSubscription::with(['member.tenant'])
|
||||
->orderBy('member_id')
|
||||
->orderBy('service_type');
|
||||
|
||||
// 테넌트 필터링
|
||||
if (!$isHeadquarters && !$allTenants) {
|
||||
$query->whereHas('member', function ($q) use ($tenantId) {
|
||||
$q->where('tenant_id', $tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
$subscriptions = $query->get();
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(
|
||||
view('barobill.billing.partials.subscription-table', [
|
||||
'subscriptions' => $subscriptions,
|
||||
'allTenants' => $isHeadquarters || $allTenants,
|
||||
])->render(),
|
||||
200,
|
||||
['Content-Type' => 'text/html']
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $subscriptions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구독 등록/수정
|
||||
*/
|
||||
public function saveSubscription(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'member_id' => 'required|exists:barobill_members,id',
|
||||
'service_type' => 'required|in:bank_account,card,hometax',
|
||||
'monthly_fee' => 'nullable|integer|min:0',
|
||||
'started_at' => 'nullable|date',
|
||||
'ended_at' => 'nullable|date|after_or_equal:started_at',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'memo' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$subscription = $this->billingService->saveSubscription(
|
||||
$validated['member_id'],
|
||||
$validated['service_type'],
|
||||
$validated
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '구독이 저장되었습니다.',
|
||||
'data' => $subscription,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구독 해지
|
||||
*/
|
||||
public function cancelSubscription(int $id): JsonResponse
|
||||
{
|
||||
$result = $this->billingService->cancelSubscription($id);
|
||||
|
||||
if (!$result) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '구독을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '구독이 해지되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원사별 구독 현황 조회
|
||||
*/
|
||||
public function memberSubscriptions(int $memberId): JsonResponse|Response
|
||||
{
|
||||
$member = BarobillMember::with('tenant')->find($memberId);
|
||||
if (!$member) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '회원사를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$subscriptions = BarobillSubscription::where('member_id', $memberId)
|
||||
->orderBy('service_type')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'member' => $member,
|
||||
'subscriptions' => $subscriptions,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 과금 현황
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 월별 과금 현황 목록
|
||||
*/
|
||||
public function billingList(Request $request): JsonResponse|Response
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID;
|
||||
$allTenants = $request->boolean('all_tenants', false);
|
||||
|
||||
$billingMonth = $request->input('billing_month', now()->format('Y-m'));
|
||||
|
||||
// 테넌트 필터링
|
||||
$filterTenantId = (!$isHeadquarters && !$allTenants) ? $tenantId : null;
|
||||
|
||||
$summaries = BarobillMonthlySummary::with(['member.tenant'])
|
||||
->where('billing_month', $billingMonth)
|
||||
->when($filterTenantId, function ($q) use ($filterTenantId) {
|
||||
$q->whereHas('member', function ($q2) use ($filterTenantId) {
|
||||
$q2->where('tenant_id', $filterTenantId);
|
||||
});
|
||||
})
|
||||
->orderBy('grand_total', 'desc')
|
||||
->get();
|
||||
|
||||
$total = $this->billingService->getMonthlyTotal($billingMonth, $filterTenantId);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(
|
||||
view('barobill.billing.partials.billing-table', [
|
||||
'summaries' => $summaries,
|
||||
'total' => $total,
|
||||
'billingMonth' => $billingMonth,
|
||||
'allTenants' => $isHeadquarters || $allTenants,
|
||||
])->render(),
|
||||
200,
|
||||
['Content-Type' => 'text/html']
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $summaries,
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 과금 통계
|
||||
*/
|
||||
public function billingStats(Request $request): JsonResponse|Response
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID;
|
||||
$allTenants = $request->boolean('all_tenants', false);
|
||||
|
||||
$billingMonth = $request->input('billing_month', now()->format('Y-m'));
|
||||
|
||||
$filterTenantId = (!$isHeadquarters && !$allTenants) ? $tenantId : null;
|
||||
$stats = $this->billingService->getMonthlyTotal($billingMonth, $filterTenantId);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(
|
||||
view('barobill.billing.partials.billing-stats', compact('stats'))->render(),
|
||||
200,
|
||||
['Content-Type' => 'text/html']
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원사별 과금 상세
|
||||
*/
|
||||
public function memberBilling(Request $request, int $memberId): JsonResponse|Response
|
||||
{
|
||||
$member = BarobillMember::with('tenant')->find($memberId);
|
||||
if (!$member) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '회원사를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$billingMonth = $request->input('billing_month', now()->format('Y-m'));
|
||||
|
||||
$records = BarobillBillingRecord::where('member_id', $memberId)
|
||||
->where('billing_month', $billingMonth)
|
||||
->orderBy('service_type')
|
||||
->get();
|
||||
|
||||
$summary = BarobillMonthlySummary::where('member_id', $memberId)
|
||||
->where('billing_month', $billingMonth)
|
||||
->first();
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(
|
||||
view('barobill.billing.partials.member-billing-detail', [
|
||||
'member' => $member,
|
||||
'records' => $records,
|
||||
'summary' => $summary,
|
||||
'billingMonth' => $billingMonth,
|
||||
])->render(),
|
||||
200,
|
||||
['Content-Type' => 'text/html']
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'member' => $member,
|
||||
'records' => $records,
|
||||
'summary' => $summary,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 과금 처리 (수동 실행)
|
||||
*/
|
||||
public function processBilling(Request $request): JsonResponse
|
||||
{
|
||||
$billingMonth = $request->input('billing_month', now()->format('Y-m'));
|
||||
|
||||
$result = $this->billingService->processMonthlyBilling($billingMonth);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "과금 처리 완료: {$result['processed']}건 처리, {$result['skipped']}건 스킵",
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 추이 조회
|
||||
*/
|
||||
public function yearlyTrend(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID;
|
||||
$allTenants = $request->boolean('all_tenants', false);
|
||||
|
||||
$year = $request->input('year', now()->year);
|
||||
|
||||
$filterTenantId = (!$isHeadquarters && !$allTenants) ? $tenantId : null;
|
||||
$trend = $this->billingService->getYearlyTrend($year, $filterTenantId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $trend,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 다운로드
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID;
|
||||
$allTenants = $request->boolean('all_tenants', false);
|
||||
|
||||
$billingMonth = $request->input('billing_month', now()->format('Y-m'));
|
||||
|
||||
$filterTenantId = (!$isHeadquarters && !$allTenants) ? $tenantId : null;
|
||||
|
||||
$summaries = BarobillMonthlySummary::with(['member.tenant'])
|
||||
->where('billing_month', $billingMonth)
|
||||
->when($filterTenantId, function ($q) use ($filterTenantId) {
|
||||
$q->whereHas('member', function ($q2) use ($filterTenantId) {
|
||||
$q2->where('tenant_id', $filterTenantId);
|
||||
});
|
||||
})
|
||||
->orderBy('grand_total', 'desc')
|
||||
->get();
|
||||
|
||||
$total = $this->billingService->getMonthlyTotal($billingMonth, $filterTenantId);
|
||||
|
||||
$filename = "barobill_billing_{$billingMonth}.csv";
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
];
|
||||
|
||||
$callback = function () use ($summaries, $total, $isHeadquarters, $allTenants) {
|
||||
$file = fopen('php://output', 'w');
|
||||
fprintf($file, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
|
||||
// 헤더
|
||||
$headerRow = ['사업자번호', '상호', '계좌조회', '카드내역', '홈텍스', '월정액합계', '세금계산서(건)', '세금계산서(원)', '건별합계', '총합계'];
|
||||
if ($isHeadquarters || $allTenants) {
|
||||
array_unshift($headerRow, 'T-ID', '테넌트');
|
||||
}
|
||||
fputcsv($file, $headerRow);
|
||||
|
||||
// 데이터
|
||||
foreach ($summaries as $summary) {
|
||||
$row = [
|
||||
$summary->member->formatted_biz_no ?? '',
|
||||
$summary->member->corp_name ?? '',
|
||||
$summary->bank_account_fee,
|
||||
$summary->card_fee,
|
||||
$summary->hometax_fee,
|
||||
$summary->subscription_total,
|
||||
$summary->tax_invoice_count,
|
||||
$summary->tax_invoice_amount,
|
||||
$summary->usage_total,
|
||||
$summary->grand_total,
|
||||
];
|
||||
if ($isHeadquarters || $allTenants) {
|
||||
array_unshift($row, $summary->member->tenant_id ?? '', $summary->member->tenant->company_name ?? '');
|
||||
}
|
||||
fputcsv($file, $row);
|
||||
}
|
||||
|
||||
// 합계
|
||||
$totalRow = ['', '합계', $total['bank_account_fee'], $total['card_fee'], $total['hometax_fee'], $total['subscription_total'], $total['tax_invoice_count'], $total['tax_invoice_amount'], $total['usage_total'], $total['grand_total']];
|
||||
if ($isHeadquarters || $allTenants) {
|
||||
array_unshift($totalRow, '', '');
|
||||
}
|
||||
fputcsv($file, $totalRow);
|
||||
|
||||
fclose($file);
|
||||
};
|
||||
|
||||
return response()->stream($callback, 200, $headers);
|
||||
}
|
||||
}
|
||||
@@ -140,4 +140,26 @@ public function usage(Request $request): View|Response
|
||||
|
||||
return view('barobill.usage.index', compact('currentTenant', 'barobillMember', 'isHeadquarters'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 과금관리 페이지 (본사 전용)
|
||||
*/
|
||||
public function billing(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('barobill.billing.index'));
|
||||
}
|
||||
|
||||
// 현재 선택된 테넌트 정보
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$currentTenant = Tenant::find($tenantId);
|
||||
|
||||
// 본사(테넌트 1) 여부
|
||||
$isHeadquarters = $tenantId == 1;
|
||||
|
||||
// 테넌트 목록 (전체 테넌트 모드에서 선택용)
|
||||
$tenants = Tenant::select('id', 'company_name')->orderBy('company_name')->get();
|
||||
|
||||
return view('barobill.billing.index', compact('currentTenant', 'isHeadquarters', 'tenants'));
|
||||
}
|
||||
}
|
||||
|
||||
116
app/Models/Barobill/BarobillBillingRecord.php
Normal file
116
app/Models/Barobill/BarobillBillingRecord.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 바로빌 과금 기록 모델
|
||||
*/
|
||||
class BarobillBillingRecord extends Model
|
||||
{
|
||||
protected $table = 'barobill_billing_records';
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'billing_month',
|
||||
'service_type',
|
||||
'billing_type',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'total_amount',
|
||||
'billed_at',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'integer',
|
||||
'unit_price' => 'integer',
|
||||
'total_amount' => 'integer',
|
||||
'billed_at' => 'date',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 서비스 유형 라벨
|
||||
*/
|
||||
public const SERVICE_TYPES = [
|
||||
'tax_invoice' => '전자세금계산서',
|
||||
'bank_account' => '계좌조회',
|
||||
'card' => '카드내역',
|
||||
'hometax' => '홈텍스 매입/매출',
|
||||
];
|
||||
|
||||
/**
|
||||
* 과금 유형 라벨
|
||||
*/
|
||||
public const BILLING_TYPES = [
|
||||
'subscription' => '월정액',
|
||||
'usage' => '건별',
|
||||
];
|
||||
|
||||
/**
|
||||
* 건별 단가 (원)
|
||||
*/
|
||||
public const USAGE_UNIT_PRICES = [
|
||||
'tax_invoice' => 100, // 전자세금계산서 건당 100원
|
||||
];
|
||||
|
||||
/**
|
||||
* 바로빌 회원사 관계
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BarobillMember::class, 'member_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 유형 라벨
|
||||
*/
|
||||
public function getServiceTypeLabelAttribute(): string
|
||||
{
|
||||
return self::SERVICE_TYPES[$this->service_type] ?? $this->service_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 과금 유형 라벨
|
||||
*/
|
||||
public function getBillingTypeLabelAttribute(): string
|
||||
{
|
||||
return self::BILLING_TYPES[$this->billing_type] ?? $this->billing_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 월 조회
|
||||
*/
|
||||
public function scopeOfMonth($query, string $billingMonth)
|
||||
{
|
||||
return $query->where('billing_month', $billingMonth);
|
||||
}
|
||||
|
||||
/**
|
||||
* 월정액만 조회
|
||||
*/
|
||||
public function scopeSubscription($query)
|
||||
{
|
||||
return $query->where('billing_type', 'subscription');
|
||||
}
|
||||
|
||||
/**
|
||||
* 건별만 조회
|
||||
*/
|
||||
public function scopeUsage($query)
|
||||
{
|
||||
return $query->where('billing_type', 'usage');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 서비스 조회
|
||||
*/
|
||||
public function scopeOfService($query, string $serviceType)
|
||||
{
|
||||
return $query->where('service_type', $serviceType);
|
||||
}
|
||||
}
|
||||
141
app/Models/Barobill/BarobillMonthlySummary.php
Normal file
141
app/Models/Barobill/BarobillMonthlySummary.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 바로빌 월별 집계 모델
|
||||
*/
|
||||
class BarobillMonthlySummary extends Model
|
||||
{
|
||||
protected $table = 'barobill_monthly_summaries';
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'billing_month',
|
||||
'bank_account_fee',
|
||||
'card_fee',
|
||||
'hometax_fee',
|
||||
'subscription_total',
|
||||
'tax_invoice_count',
|
||||
'tax_invoice_amount',
|
||||
'usage_total',
|
||||
'grand_total',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'bank_account_fee' => 'integer',
|
||||
'card_fee' => 'integer',
|
||||
'hometax_fee' => 'integer',
|
||||
'subscription_total' => 'integer',
|
||||
'tax_invoice_count' => 'integer',
|
||||
'tax_invoice_amount' => 'integer',
|
||||
'usage_total' => 'integer',
|
||||
'grand_total' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 바로빌 회원사 관계
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BarobillMember::class, 'member_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 월 조회
|
||||
*/
|
||||
public function scopeOfMonth($query, string $billingMonth)
|
||||
{
|
||||
return $query->where('billing_month', $billingMonth);
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 데이터 갱신 또는 생성
|
||||
*/
|
||||
public static function updateOrCreateSummary(int $memberId, string $billingMonth): self
|
||||
{
|
||||
// 해당 월의 과금 기록 조회
|
||||
$records = BarobillBillingRecord::where('member_id', $memberId)
|
||||
->where('billing_month', $billingMonth)
|
||||
->get();
|
||||
|
||||
$data = [
|
||||
'bank_account_fee' => 0,
|
||||
'card_fee' => 0,
|
||||
'hometax_fee' => 0,
|
||||
'subscription_total' => 0,
|
||||
'tax_invoice_count' => 0,
|
||||
'tax_invoice_amount' => 0,
|
||||
'usage_total' => 0,
|
||||
'grand_total' => 0,
|
||||
];
|
||||
|
||||
foreach ($records as $record) {
|
||||
if ($record->billing_type === 'subscription') {
|
||||
switch ($record->service_type) {
|
||||
case 'bank_account':
|
||||
$data['bank_account_fee'] = $record->total_amount;
|
||||
break;
|
||||
case 'card':
|
||||
$data['card_fee'] = $record->total_amount;
|
||||
break;
|
||||
case 'hometax':
|
||||
$data['hometax_fee'] = $record->total_amount;
|
||||
break;
|
||||
}
|
||||
$data['subscription_total'] += $record->total_amount;
|
||||
} else {
|
||||
// 건별 사용
|
||||
if ($record->service_type === 'tax_invoice') {
|
||||
$data['tax_invoice_count'] = $record->quantity;
|
||||
$data['tax_invoice_amount'] = $record->total_amount;
|
||||
}
|
||||
$data['usage_total'] += $record->total_amount;
|
||||
}
|
||||
}
|
||||
|
||||
$data['grand_total'] = $data['subscription_total'] + $data['usage_total'];
|
||||
|
||||
return self::updateOrCreate(
|
||||
['member_id' => $memberId, 'billing_month' => $billingMonth],
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 회원사 월별 합계
|
||||
*/
|
||||
public static function getMonthlyTotal(string $billingMonth): array
|
||||
{
|
||||
$result = self::where('billing_month', $billingMonth)
|
||||
->selectRaw('
|
||||
COUNT(*) as member_count,
|
||||
SUM(bank_account_fee) as bank_account_fee,
|
||||
SUM(card_fee) as card_fee,
|
||||
SUM(hometax_fee) as hometax_fee,
|
||||
SUM(subscription_total) as subscription_total,
|
||||
SUM(tax_invoice_count) as tax_invoice_count,
|
||||
SUM(tax_invoice_amount) as tax_invoice_amount,
|
||||
SUM(usage_total) as usage_total,
|
||||
SUM(grand_total) as grand_total
|
||||
')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'member_count' => (int) ($result->member_count ?? 0),
|
||||
'bank_account_fee' => (int) ($result->bank_account_fee ?? 0),
|
||||
'card_fee' => (int) ($result->card_fee ?? 0),
|
||||
'hometax_fee' => (int) ($result->hometax_fee ?? 0),
|
||||
'subscription_total' => (int) ($result->subscription_total ?? 0),
|
||||
'tax_invoice_count' => (int) ($result->tax_invoice_count ?? 0),
|
||||
'tax_invoice_amount' => (int) ($result->tax_invoice_amount ?? 0),
|
||||
'usage_total' => (int) ($result->usage_total ?? 0),
|
||||
'grand_total' => (int) ($result->grand_total ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
119
app/Models/Barobill/BarobillSubscription.php
Normal file
119
app/Models/Barobill/BarobillSubscription.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 바로빌 월정액 구독 모델
|
||||
*/
|
||||
class BarobillSubscription extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'barobill_subscriptions';
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'service_type',
|
||||
'monthly_fee',
|
||||
'started_at',
|
||||
'ended_at',
|
||||
'is_active',
|
||||
'memo',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'monthly_fee' => 'integer',
|
||||
'started_at' => 'date',
|
||||
'ended_at' => 'date',
|
||||
'is_active' => 'boolean',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 서비스 유형 라벨
|
||||
*/
|
||||
public const SERVICE_TYPES = [
|
||||
'bank_account' => '계좌조회',
|
||||
'card' => '카드내역',
|
||||
'hometax' => '홈텍스 매입/매출',
|
||||
];
|
||||
|
||||
/**
|
||||
* 기본 월정액 (원)
|
||||
*/
|
||||
public const DEFAULT_MONTHLY_FEES = [
|
||||
'bank_account' => 10000,
|
||||
'card' => 10000,
|
||||
'hometax' => 10000,
|
||||
];
|
||||
|
||||
/**
|
||||
* 바로빌 회원사 관계
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BarobillMember::class, 'member_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 유형 라벨
|
||||
*/
|
||||
public function getServiceTypeLabelAttribute(): string
|
||||
{
|
||||
return self::SERVICE_TYPES[$this->service_type] ?? $this->service_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 라벨
|
||||
*/
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
if (!$this->is_active) {
|
||||
return '비활성';
|
||||
}
|
||||
if ($this->ended_at && $this->ended_at->isPast()) {
|
||||
return '종료';
|
||||
}
|
||||
return '구독중';
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 색상 클래스
|
||||
*/
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
if (!$this->is_active) {
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
if ($this->ended_at && $this->ended_at->isPast()) {
|
||||
return 'bg-red-100 text-red-800';
|
||||
}
|
||||
return 'bg-green-100 text-green-800';
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 구독만 조회
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('ended_at')
|
||||
->orWhere('ended_at', '>=', now()->toDateString());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 서비스 유형 조회
|
||||
*/
|
||||
public function scopeOfService($query, string $serviceType)
|
||||
{
|
||||
return $query->where('service_type', $serviceType);
|
||||
}
|
||||
}
|
||||
252
app/Services/Barobill/BarobillBillingService.php
Normal file
252
app/Services/Barobill/BarobillBillingService.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Barobill;
|
||||
|
||||
use App\Models\Barobill\BarobillBillingRecord;
|
||||
use App\Models\Barobill\BarobillMember;
|
||||
use App\Models\Barobill\BarobillMonthlySummary;
|
||||
use App\Models\Barobill\BarobillSubscription;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 바로빌 과금 서비스
|
||||
*
|
||||
* 월정액 구독 관리 및 과금 처리
|
||||
*/
|
||||
class BarobillBillingService
|
||||
{
|
||||
/**
|
||||
* 회원사의 구독 목록 조회
|
||||
*/
|
||||
public function getSubscriptions(int $memberId): array
|
||||
{
|
||||
return BarobillSubscription::where('member_id', $memberId)
|
||||
->orderBy('service_type')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 구독 등록/수정
|
||||
*/
|
||||
public function saveSubscription(int $memberId, string $serviceType, array $data): BarobillSubscription
|
||||
{
|
||||
return BarobillSubscription::updateOrCreate(
|
||||
[
|
||||
'member_id' => $memberId,
|
||||
'service_type' => $serviceType,
|
||||
],
|
||||
[
|
||||
'monthly_fee' => $data['monthly_fee'] ?? BarobillSubscription::DEFAULT_MONTHLY_FEES[$serviceType] ?? 0,
|
||||
'started_at' => $data['started_at'] ?? now()->toDateString(),
|
||||
'ended_at' => $data['ended_at'] ?? null,
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'memo' => $data['memo'] ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구독 해지
|
||||
*/
|
||||
public function cancelSubscription(int $subscriptionId): bool
|
||||
{
|
||||
$subscription = BarobillSubscription::find($subscriptionId);
|
||||
if (!$subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscription->update([
|
||||
'ended_at' => now()->toDateString(),
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 과금 처리 (배치용)
|
||||
*
|
||||
* 매월 1일에 실행하여 전월 구독료 과금
|
||||
*/
|
||||
public function processMonthlyBilling(?string $billingMonth = null): array
|
||||
{
|
||||
$billingMonth = $billingMonth ?? now()->format('Y-m');
|
||||
$billedAt = Carbon::parse($billingMonth . '-01');
|
||||
|
||||
$results = [
|
||||
'billing_month' => $billingMonth,
|
||||
'processed' => 0,
|
||||
'skipped' => 0,
|
||||
'errors' => [],
|
||||
];
|
||||
|
||||
// 활성 구독 조회
|
||||
$subscriptions = BarobillSubscription::active()
|
||||
->with('member')
|
||||
->get();
|
||||
|
||||
foreach ($subscriptions as $subscription) {
|
||||
try {
|
||||
// 이미 과금된 기록이 있는지 확인
|
||||
$exists = BarobillBillingRecord::where('member_id', $subscription->member_id)
|
||||
->where('billing_month', $billingMonth)
|
||||
->where('service_type', $subscription->service_type)
|
||||
->where('billing_type', 'subscription')
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$results['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 과금 기록 생성
|
||||
BarobillBillingRecord::create([
|
||||
'member_id' => $subscription->member_id,
|
||||
'billing_month' => $billingMonth,
|
||||
'service_type' => $subscription->service_type,
|
||||
'billing_type' => 'subscription',
|
||||
'quantity' => 1,
|
||||
'unit_price' => $subscription->monthly_fee,
|
||||
'total_amount' => $subscription->monthly_fee,
|
||||
'billed_at' => $billedAt,
|
||||
'description' => BarobillSubscription::SERVICE_TYPES[$subscription->service_type] . ' 월정액',
|
||||
]);
|
||||
|
||||
$results['processed']++;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$results['errors'][] = [
|
||||
'member_id' => $subscription->member_id,
|
||||
'service_type' => $subscription->service_type,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
Log::error('월정액 과금 처리 실패', [
|
||||
'subscription_id' => $subscription->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 월별 집계 갱신
|
||||
$this->updateMonthlySummaries($billingMonth);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 건별 사용량 과금 (세금계산서)
|
||||
*/
|
||||
public function recordUsage(int $memberId, string $serviceType, int $quantity, ?string $billingMonth = null): BarobillBillingRecord
|
||||
{
|
||||
$billingMonth = $billingMonth ?? now()->format('Y-m');
|
||||
$unitPrice = BarobillBillingRecord::USAGE_UNIT_PRICES[$serviceType] ?? 0;
|
||||
|
||||
$record = BarobillBillingRecord::updateOrCreate(
|
||||
[
|
||||
'member_id' => $memberId,
|
||||
'billing_month' => $billingMonth,
|
||||
'service_type' => $serviceType,
|
||||
'billing_type' => 'usage',
|
||||
],
|
||||
[
|
||||
'quantity' => $quantity,
|
||||
'unit_price' => $unitPrice,
|
||||
'total_amount' => $quantity * $unitPrice,
|
||||
'billed_at' => now()->toDateString(),
|
||||
'description' => BarobillBillingRecord::SERVICE_TYPES[$serviceType] . ' ' . $quantity . '건',
|
||||
]
|
||||
);
|
||||
|
||||
// 집계 갱신
|
||||
BarobillMonthlySummary::updateOrCreateSummary($memberId, $billingMonth);
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 집계 갱신
|
||||
*/
|
||||
public function updateMonthlySummaries(string $billingMonth): void
|
||||
{
|
||||
// 해당 월에 과금 기록이 있는 회원사 조회
|
||||
$memberIds = BarobillBillingRecord::where('billing_month', $billingMonth)
|
||||
->distinct()
|
||||
->pluck('member_id');
|
||||
|
||||
foreach ($memberIds as $memberId) {
|
||||
BarobillMonthlySummary::updateOrCreateSummary($memberId, $billingMonth);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 과금 현황 조회
|
||||
*/
|
||||
public function getMonthlyBillingList(string $billingMonth, ?int $tenantId = null): array
|
||||
{
|
||||
$query = BarobillMonthlySummary::with(['member.tenant'])
|
||||
->where('billing_month', $billingMonth);
|
||||
|
||||
if ($tenantId) {
|
||||
$query->whereHas('member', function ($q) use ($tenantId) {
|
||||
$q->where('tenant_id', $tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->orderBy('grand_total', 'desc')->get()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 합계 조회
|
||||
*/
|
||||
public function getMonthlyTotal(string $billingMonth, ?int $tenantId = null): array
|
||||
{
|
||||
$query = BarobillMonthlySummary::where('billing_month', $billingMonth);
|
||||
|
||||
if ($tenantId) {
|
||||
$query->whereHas('member', function ($q) use ($tenantId) {
|
||||
$q->where('tenant_id', $tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
$result = $query->selectRaw('
|
||||
COUNT(*) as member_count,
|
||||
SUM(bank_account_fee) as bank_account_fee,
|
||||
SUM(card_fee) as card_fee,
|
||||
SUM(hometax_fee) as hometax_fee,
|
||||
SUM(subscription_total) as subscription_total,
|
||||
SUM(tax_invoice_count) as tax_invoice_count,
|
||||
SUM(tax_invoice_amount) as tax_invoice_amount,
|
||||
SUM(usage_total) as usage_total,
|
||||
SUM(grand_total) as grand_total
|
||||
')->first();
|
||||
|
||||
return [
|
||||
'billing_month' => $billingMonth,
|
||||
'member_count' => (int) ($result->member_count ?? 0),
|
||||
'bank_account_fee' => (int) ($result->bank_account_fee ?? 0),
|
||||
'card_fee' => (int) ($result->card_fee ?? 0),
|
||||
'hometax_fee' => (int) ($result->hometax_fee ?? 0),
|
||||
'subscription_total' => (int) ($result->subscription_total ?? 0),
|
||||
'tax_invoice_count' => (int) ($result->tax_invoice_count ?? 0),
|
||||
'tax_invoice_amount' => (int) ($result->tax_invoice_amount ?? 0),
|
||||
'usage_total' => (int) ($result->usage_total ?? 0),
|
||||
'grand_total' => (int) ($result->grand_total ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 과금 추이 조회
|
||||
*/
|
||||
public function getYearlyTrend(int $year, ?int $tenantId = null): array
|
||||
{
|
||||
$months = [];
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
$billingMonth = sprintf('%d-%02d', $year, $m);
|
||||
$months[$billingMonth] = $this->getMonthlyTotal($billingMonth, $tenantId);
|
||||
}
|
||||
return $months;
|
||||
}
|
||||
}
|
||||
354
resources/views/barobill/billing/index.blade.php
Normal file
354
resources/views/barobill/billing/index.blade.php
Normal file
@@ -0,0 +1,354 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '과금관리')
|
||||
|
||||
@section('content')
|
||||
<!-- 현재 테넌트 정보 카드 -->
|
||||
@if($currentTenant)
|
||||
<div class="rounded-xl shadow-lg p-5 mb-6" style="background: linear-gradient(to right, #059669, #10b981); color: white;">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 rounded-xl" style="background: rgba(255,255,255,0.2);">
|
||||
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-bold" style="background: rgba(255,255,255,0.2);">T-ID: {{ $currentTenant->id }}</span>
|
||||
@if($isHeadquarters ?? false)
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-bold" style="background: #facc15; color: #713f12;">파트너사</span>
|
||||
@endif
|
||||
</div>
|
||||
<h2 class="text-xl font-bold">{{ $currentTenant->company_name }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-3 gap-3 text-sm">
|
||||
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
||||
<p class="text-xs" style="color: rgba(255,255,255,0.6);">조회 기준월</p>
|
||||
<p class="font-medium" id="displayBillingMonth">{{ now()->format('Y년 m월') }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
||||
<p class="text-xs" style="color: rgba(255,255,255,0.6);">과금 유형</p>
|
||||
<p class="font-medium">월정액 + 건별</p>
|
||||
</div>
|
||||
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
|
||||
<p class="text-xs" style="color: rgba(255,255,255,0.6);">결제일</p>
|
||||
<p class="font-medium">매월 1일</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6 flex-shrink-0">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">과금관리</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">바로빌 월정액 구독 및 과금 현황 관리</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick="processBilling()"
|
||||
class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
과금 처리
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="exportExcel()"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
엑셀
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 네비게이션 -->
|
||||
<div class="flex border-b border-gray-200 mb-6 flex-shrink-0">
|
||||
<button type="button" onclick="switchTab('billing')" id="tab-billing" class="px-6 py-3 text-sm font-medium border-b-2 border-blue-600 text-blue-600">
|
||||
과금 현황
|
||||
</button>
|
||||
<button type="button" onclick="switchTab('subscription')" id="tab-subscription" class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||||
구독 관리
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div id="stats-container"
|
||||
hx-get="/api/admin/barobill/billing/stats"
|
||||
hx-trigger="load, billingUpdated from:body"
|
||||
hx-include="#billingFilterForm"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||
class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6 flex-shrink-0">
|
||||
@include('barobill.billing.partials.stats-skeleton')
|
||||
</div>
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
<div class="flex-shrink-0">
|
||||
<x-filter-collapsible id="billingFilter">
|
||||
<form id="billingFilterForm" class="flex flex-wrap gap-2 sm:gap-4 items-center">
|
||||
<!-- 전체 테넌트 보기 토글 -->
|
||||
@if($isHeadquarters ?? false)
|
||||
<label class="flex items-center gap-2 px-3 py-2 bg-purple-50 border border-purple-200 rounded-lg cursor-pointer hover:bg-purple-100 transition-colors">
|
||||
<input type="checkbox"
|
||||
name="all_tenants"
|
||||
value="1"
|
||||
id="allTenantsToggle"
|
||||
checked
|
||||
class="w-4 h-4 rounded border-purple-300 text-purple-600 focus:ring-purple-500">
|
||||
<span class="text-sm font-medium text-purple-700">전체 테넌트</span>
|
||||
</label>
|
||||
@endif
|
||||
|
||||
<!-- 기준월 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="billingMonth" class="text-sm font-medium text-gray-700 whitespace-nowrap">기준월</label>
|
||||
<input type="month"
|
||||
name="billing_month"
|
||||
id="billingMonth"
|
||||
value="{{ now()->format('Y-m') }}"
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||
</div>
|
||||
|
||||
<!-- 빠른 선택 -->
|
||||
<div class="flex gap-1">
|
||||
<button type="button" onclick="setQuickMonth('thisMonth')" class="px-3 py-2 text-xs font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
|
||||
이번달
|
||||
</button>
|
||||
<button type="button" onclick="setQuickMonth('lastMonth')" class="px-3 py-2 text-xs font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
|
||||
지난달
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 조회 버튼 -->
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
조회
|
||||
</button>
|
||||
</form>
|
||||
</x-filter-collapsible>
|
||||
</div>
|
||||
|
||||
<!-- 과금 현황 탭 -->
|
||||
<div id="content-billing" class="flex-1 flex flex-col min-h-0">
|
||||
<div id="billing-table"
|
||||
hx-get="/api/admin/barobill/billing/list"
|
||||
hx-trigger="load, billingUpdated from:body"
|
||||
hx-include="#billingFilterForm"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||
class="bg-white rounded-lg shadow-sm overflow-hidden flex-1 flex flex-col min-h-0">
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 구독 관리 탭 -->
|
||||
<div id="content-subscription" class="hidden flex-1 flex flex-col min-h-0">
|
||||
<div id="subscription-table"
|
||||
hx-get="/api/admin/barobill/billing/subscriptions"
|
||||
hx-trigger="subscriptionUpdated from:body"
|
||||
hx-include="#billingFilterForm"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||
class="bg-white rounded-lg shadow-sm overflow-hidden flex-1 flex flex-col min-h-0">
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 모달 -->
|
||||
<div id="detailModal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="fixed inset-0 bg-black/50" onclick="closeDetailModal()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto" onclick="event.stopPropagation()">
|
||||
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between sticky top-0 bg-white">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">과금 상세</h3>
|
||||
<p class="text-sm text-gray-500" id="detailModalTitle"></p>
|
||||
</div>
|
||||
<button type="button" onclick="closeDetailModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="detailModalContent" class="p-6"></div>
|
||||
<div class="px-6 py-4 border-t border-gray-100">
|
||||
<button type="button" onclick="closeDetailModal()" class="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 -->
|
||||
<div id="toast" class="fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg transform translate-y-full opacity-0 transition-all duration-300 z-50"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// 현재 활성 탭
|
||||
let currentTab = 'billing';
|
||||
|
||||
// 탭 전환
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
|
||||
// 탭 버튼 스타일
|
||||
document.getElementById('tab-billing').classList.toggle('border-blue-600', tab === 'billing');
|
||||
document.getElementById('tab-billing').classList.toggle('text-blue-600', tab === 'billing');
|
||||
document.getElementById('tab-billing').classList.toggle('border-transparent', tab !== 'billing');
|
||||
document.getElementById('tab-billing').classList.toggle('text-gray-500', tab !== 'billing');
|
||||
|
||||
document.getElementById('tab-subscription').classList.toggle('border-blue-600', tab === 'subscription');
|
||||
document.getElementById('tab-subscription').classList.toggle('text-blue-600', tab === 'subscription');
|
||||
document.getElementById('tab-subscription').classList.toggle('border-transparent', tab !== 'subscription');
|
||||
document.getElementById('tab-subscription').classList.toggle('text-gray-500', tab !== 'subscription');
|
||||
|
||||
// 컨텐츠 표시/숨김
|
||||
document.getElementById('content-billing').classList.toggle('hidden', tab !== 'billing');
|
||||
document.getElementById('content-subscription').classList.toggle('hidden', tab !== 'subscription');
|
||||
|
||||
// 구독 탭 전환 시 데이터 로드
|
||||
if (tab === 'subscription') {
|
||||
htmx.trigger(document.body, 'subscriptionUpdated');
|
||||
}
|
||||
}
|
||||
|
||||
// 폼 제출
|
||||
document.getElementById('billingFilterForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
updateDisplayMonth();
|
||||
htmx.trigger(document.body, 'billingUpdated');
|
||||
});
|
||||
|
||||
// 기준월 표시 업데이트
|
||||
function updateDisplayMonth() {
|
||||
const month = document.getElementById('billingMonth').value;
|
||||
const [year, mon] = month.split('-');
|
||||
document.getElementById('displayBillingMonth').textContent = `${year}년 ${mon}월`;
|
||||
}
|
||||
|
||||
// 빠른 월 선택
|
||||
function setQuickMonth(period) {
|
||||
const today = new Date();
|
||||
let targetMonth;
|
||||
|
||||
if (period === 'thisMonth') {
|
||||
targetMonth = today;
|
||||
} else {
|
||||
targetMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
}
|
||||
|
||||
const year = targetMonth.getFullYear();
|
||||
const month = String(targetMonth.getMonth() + 1).padStart(2, '0');
|
||||
document.getElementById('billingMonth').value = `${year}-${month}`;
|
||||
|
||||
updateDisplayMonth();
|
||||
htmx.trigger(document.body, 'billingUpdated');
|
||||
}
|
||||
|
||||
// 과금 처리
|
||||
async function processBilling() {
|
||||
if (!confirm('선택한 월의 과금을 처리하시겠습니까?')) return;
|
||||
|
||||
const billingMonth = document.getElementById('billingMonth').value;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/barobill/billing/process', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
},
|
||||
body: JSON.stringify({ billing_month: billingMonth }),
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast(result.message, 'success');
|
||||
htmx.trigger(document.body, 'billingUpdated');
|
||||
} else {
|
||||
showToast(result.message || '처리 실패', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 엑셀 다운로드
|
||||
function exportExcel() {
|
||||
const form = document.getElementById('billingFilterForm');
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams(formData);
|
||||
window.location.href = `/api/admin/barobill/billing/export?${params.toString()}`;
|
||||
}
|
||||
|
||||
// 상세 모달
|
||||
async function showDetailModal(memberId, memberName) {
|
||||
document.getElementById('detailModalTitle').textContent = memberName;
|
||||
document.getElementById('detailModalContent').innerHTML = `
|
||||
<div class="flex justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('detailModal').classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const billingMonth = document.getElementById('billingMonth').value;
|
||||
const res = await fetch(`/api/admin/barobill/billing/member/${memberId}?billing_month=${billingMonth}`, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'HX-Request': 'true',
|
||||
},
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const html = await res.text();
|
||||
document.getElementById('detailModalContent').innerHTML = html;
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('detailModalContent').innerHTML = `
|
||||
<div class="text-center text-red-500 py-8">조회 실패</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detailModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 토스트
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message;
|
||||
toast.className = 'fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg transform transition-all duration-300 z-50';
|
||||
toast.classList.add(type === 'success' ? 'bg-green-600' : 'bg-red-600', 'text-white');
|
||||
toast.classList.remove('translate-y-full', 'opacity-0');
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('translate-y-full', 'opacity-0');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ESC 키
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeDetailModal();
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,69 @@
|
||||
<!-- 조회 회원사 -->
|
||||
<div class="bg-white rounded-lg p-6 shadow-sm border border-gray-100">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase">회원사</h3>
|
||||
<div class="p-1.5 rounded-lg bg-blue-50 text-blue-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ number_format($stats['member_count']) }}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">과금 대상</div>
|
||||
</div>
|
||||
|
||||
<!-- 월정액 합계 -->
|
||||
<div class="bg-white rounded-lg p-6 shadow-sm border border-gray-100">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase">월정액</h3>
|
||||
<div class="p-1.5 rounded-lg bg-green-50 text-green-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ number_format($stats['subscription_total']) }}<span class="text-sm font-normal text-gray-500">원</span></div>
|
||||
<div class="text-xs text-gray-400 mt-1">계좌+카드+홈텍스</div>
|
||||
</div>
|
||||
|
||||
<!-- 세금계산서 -->
|
||||
<div class="bg-white rounded-lg p-6 shadow-sm border border-gray-100">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase">세금계산서</h3>
|
||||
<div class="p-1.5 rounded-lg bg-purple-50 text-purple-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ number_format($stats['tax_invoice_count']) }}<span class="text-sm font-normal text-gray-500">건</span></div>
|
||||
<div class="text-xs text-gray-400 mt-1">{{ number_format($stats['tax_invoice_amount']) }}원</div>
|
||||
</div>
|
||||
|
||||
<!-- 건별 합계 -->
|
||||
<div class="bg-white rounded-lg p-6 shadow-sm border border-gray-100">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase">건별 합계</h3>
|
||||
<div class="p-1.5 rounded-lg bg-indigo-50 text-indigo-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ number_format($stats['usage_total']) }}<span class="text-sm font-normal text-gray-500">원</span></div>
|
||||
<div class="text-xs text-gray-400 mt-1">세금계산서 등</div>
|
||||
</div>
|
||||
|
||||
<!-- 총합계 -->
|
||||
<div class="bg-white rounded-lg p-6 shadow-sm border border-gray-100">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase">총 과금액</h3>
|
||||
<div class="p-1.5 rounded-lg bg-yellow-50 text-yellow-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ number_format($stats['grand_total']) }}<span class="text-sm font-normal text-gray-500">원</span></div>
|
||||
<div class="text-xs text-gray-400 mt-1">월정액 + 건별</div>
|
||||
</div>
|
||||
@@ -0,0 +1,156 @@
|
||||
@if($summaries->isEmpty())
|
||||
<div class="p-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">과금 내역이 없습니다</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ $billingMonth }} 기준 과금 내역이 없습니다.</p>
|
||||
<p class="mt-1 text-sm text-gray-500">"과금 처리" 버튼을 클릭하여 월정액을 처리하세요.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex-1 overflow-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50 sticky top-0 z-10">
|
||||
<tr>
|
||||
@if($allTenants ?? false)
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider w-16">
|
||||
T-ID
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
테넌트
|
||||
</th>
|
||||
@endif
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
사업자번호
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
상호명
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
계좌조회
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
카드내역
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
홈텍스
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
월정액
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
세금계산서
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
총합계
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider w-20">
|
||||
상세
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@foreach($summaries as $summary)
|
||||
<tr class="hover:bg-gray-50 transition-colors group">
|
||||
@if($allTenants ?? false)
|
||||
<td class="px-4 py-4 whitespace-nowrap text-center">
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold bg-indigo-100 text-indigo-700">
|
||||
{{ $summary->member->tenant_id ?? '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
|
||||
{{ $summary->member->tenant->company_name ?? '-' }}
|
||||
</span>
|
||||
</td>
|
||||
@endif
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-mono text-gray-500">{{ $summary->member->formatted_biz_no ?? '-' }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">{{ $summary->member->corp_name ?? '-' }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<span class="text-sm {{ $summary->bank_account_fee > 0 ? 'text-green-600 font-medium' : 'text-gray-400' }}">
|
||||
{{ number_format($summary->bank_account_fee) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<span class="text-sm {{ $summary->card_fee > 0 ? 'text-blue-600 font-medium' : 'text-gray-400' }}">
|
||||
{{ number_format($summary->card_fee) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<span class="text-sm {{ $summary->hometax_fee > 0 ? 'text-orange-600 font-medium' : 'text-gray-400' }}">
|
||||
{{ number_format($summary->hometax_fee) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<span class="text-sm font-medium text-gray-700">
|
||||
{{ number_format($summary->subscription_total) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
@if($summary->tax_invoice_count > 0)
|
||||
<div class="text-sm text-purple-600">
|
||||
<span class="font-medium">{{ number_format($summary->tax_invoice_count) }}건</span>
|
||||
<span class="text-xs text-gray-400">({{ number_format($summary->tax_invoice_amount) }})</span>
|
||||
</div>
|
||||
@else
|
||||
<span class="text-sm text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<span class="text-sm font-bold text-gray-900">
|
||||
{{ number_format($summary->grand_total) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<button
|
||||
type="button"
|
||||
onclick="showDetailModal({{ $summary->member_id }}, '{{ addslashes($summary->member->corp_name ?? '') }}')"
|
||||
class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg opacity-30 group-hover:opacity-100 transition"
|
||||
title="상세보기"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
<!-- 합계 행 -->
|
||||
<tfoot class="bg-gray-100 border-t-2 border-gray-300">
|
||||
<tr>
|
||||
@if($allTenants ?? false)
|
||||
<td class="px-4 py-4" colspan="2"></td>
|
||||
@endif
|
||||
<td class="px-6 py-4" colspan="2">
|
||||
<span class="text-sm font-bold text-gray-700">합계</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<span class="text-sm font-bold text-green-600">{{ number_format($total['bank_account_fee']) }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<span class="text-sm font-bold text-blue-600">{{ number_format($total['card_fee']) }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<span class="text-sm font-bold text-orange-600">{{ number_format($total['hometax_fee']) }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<span class="text-sm font-bold text-gray-700">{{ number_format($total['subscription_total']) }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<span class="text-sm font-bold text-purple-600">{{ number_format($total['tax_invoice_amount']) }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<span class="text-lg font-bold text-gray-900">{{ number_format($total['grand_total']) }}원</span>
|
||||
</td>
|
||||
<td class="px-6 py-4"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,103 @@
|
||||
{{-- 회원사 과금 상세 모달 내용 --}}
|
||||
<div class="space-y-6">
|
||||
<!-- 회원사 정보 -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">회원사 정보</h4>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">상호명</span>
|
||||
<p class="font-medium text-gray-900">{{ $member->corp_name }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">사업자번호</span>
|
||||
<p class="font-mono text-gray-900">{{ $member->formatted_biz_no }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">테넌트</span>
|
||||
<p class="font-medium text-gray-900">{{ $member->tenant->company_name ?? '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">과금 기준월</span>
|
||||
<p class="font-medium text-gray-900">{{ $billingMonth }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 과금 내역 -->
|
||||
@if($records->isNotEmpty())
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-gray-700">과금 내역</h4>
|
||||
|
||||
@foreach($records as $record)
|
||||
@php
|
||||
$colors = [
|
||||
'bank_account' => ['bg' => 'bg-green-50', 'border' => 'border-green-100', 'text' => 'text-green-700', 'icon' => 'text-green-600'],
|
||||
'card' => ['bg' => 'bg-blue-50', 'border' => 'border-blue-100', 'text' => 'text-blue-700', 'icon' => 'text-blue-600'],
|
||||
'hometax' => ['bg' => 'bg-orange-50', 'border' => 'border-orange-100', 'text' => 'text-orange-700', 'icon' => 'text-orange-600'],
|
||||
'tax_invoice' => ['bg' => 'bg-purple-50', 'border' => 'border-purple-100', 'text' => 'text-purple-700', 'icon' => 'text-purple-600'],
|
||||
];
|
||||
$color = $colors[$record->service_type] ?? ['bg' => 'bg-gray-50', 'border' => 'border-gray-100', 'text' => 'text-gray-700', 'icon' => 'text-gray-600'];
|
||||
@endphp
|
||||
<div class="flex items-center justify-between p-4 {{ $color['bg'] }} rounded-lg border {{ $color['border'] }}">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-white/50 rounded-lg">
|
||||
@if($record->service_type === 'tax_invoice')
|
||||
<svg class="w-5 h-5 {{ $color['icon'] }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-5 h-5 {{ $color['icon'] }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium {{ $color['text'] }}">{{ $record->service_type_label }}</p>
|
||||
<p class="text-xs {{ $color['icon'] }}">
|
||||
{{ $record->billing_type_label }}
|
||||
@if($record->billing_type === 'usage')
|
||||
({{ number_format($record->quantity) }}건 x {{ number_format($record->unit_price) }}원)
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xl font-bold {{ $color['text'] }}">{{ number_format($record->total_amount) }}원</p>
|
||||
<p class="text-xs text-gray-500">{{ $record->billed_at?->format('Y-m-d') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<p>이 기간에 과금 내역이 없습니다.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 집계 -->
|
||||
@if($summary)
|
||||
<div class="bg-yellow-50 rounded-lg p-4 border-2 border-yellow-200">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500">월정액 합계</p>
|
||||
<p class="text-lg font-bold text-gray-700">{{ number_format($summary->subscription_total) }}원</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500">건별 합계</p>
|
||||
<p class="text-lg font-bold text-gray-700">{{ number_format($summary->usage_total) }}원</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-4 border-t border-yellow-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-yellow-100 rounded-lg">
|
||||
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-lg font-semibold text-yellow-800">총 과금액</span>
|
||||
</div>
|
||||
<span class="text-2xl font-bold text-yellow-700">{{ number_format($summary->grand_total) }}원</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!-- 통계 카드 스켈레톤 -->
|
||||
@for($i = 0; $i < 5; $i++)
|
||||
<div class="bg-white rounded-lg p-6 shadow-sm border border-gray-100 animate-pulse">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="h-3 bg-gray-200 rounded w-20"></div>
|
||||
<div class="w-8 h-8 bg-gray-200 rounded-lg"></div>
|
||||
</div>
|
||||
<div class="h-8 bg-gray-200 rounded w-16 mb-1"></div>
|
||||
<div class="h-2 bg-gray-100 rounded w-24"></div>
|
||||
</div>
|
||||
@endfor
|
||||
@@ -0,0 +1,137 @@
|
||||
@if($subscriptions->isEmpty())
|
||||
<div class="p-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">등록된 구독이 없습니다</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">회원사에 월정액 서비스 구독을 등록해주세요.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex-1 overflow-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50 sticky top-0 z-10">
|
||||
<tr>
|
||||
@if($allTenants ?? false)
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider w-16">
|
||||
T-ID
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
테넌트
|
||||
</th>
|
||||
@endif
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
회원사
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
서비스
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
월정액
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
시작일
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
종료일
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
상태
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider w-20">
|
||||
관리
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@foreach($subscriptions as $subscription)
|
||||
<tr class="hover:bg-gray-50 transition-colors group">
|
||||
@if($allTenants ?? false)
|
||||
<td class="px-4 py-4 whitespace-nowrap text-center">
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold bg-indigo-100 text-indigo-700">
|
||||
{{ $subscription->member->tenant_id ?? '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
|
||||
{{ $subscription->member->tenant->company_name ?? '-' }}
|
||||
</span>
|
||||
</td>
|
||||
@endif
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">{{ $subscription->member->corp_name ?? '-' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ $subscription->member->formatted_biz_no ?? '' }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@php
|
||||
$serviceColors = [
|
||||
'bank_account' => 'bg-green-100 text-green-800',
|
||||
'card' => 'bg-blue-100 text-blue-800',
|
||||
'hometax' => 'bg-orange-100 text-orange-800',
|
||||
];
|
||||
@endphp
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $serviceColors[$subscription->service_type] ?? 'bg-gray-100 text-gray-800' }}">
|
||||
{{ $subscription->service_type_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<span class="text-sm font-medium text-gray-900">{{ number_format($subscription->monthly_fee) }}원</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span class="text-sm text-gray-600">{{ $subscription->started_at?->format('Y-m-d') ?? '-' }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span class="text-sm text-gray-600">{{ $subscription->ended_at?->format('Y-m-d') ?? '-' }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ $subscription->status_color }}">
|
||||
{{ $subscription->status_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="flex justify-end gap-1 opacity-30 group-hover:opacity-100 transition-opacity">
|
||||
@if($subscription->is_active && !$subscription->ended_at)
|
||||
<button
|
||||
type="button"
|
||||
onclick="cancelSubscription({{ $subscription->id }}, '{{ addslashes($subscription->member->corp_name ?? '') }}', '{{ $subscription->service_type_label }}')"
|
||||
class="p-2 text-red-600 hover:bg-red-50 rounded-lg"
|
||||
title="구독 해지"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<script>
|
||||
async function cancelSubscription(id, memberName, serviceName) {
|
||||
if (!confirm(`${memberName}의 ${serviceName} 구독을 해지하시겠습니까?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/barobill/billing/subscriptions/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast(result.message, 'success');
|
||||
htmx.trigger(document.body, 'subscriptionUpdated');
|
||||
} else {
|
||||
showToast(result.message || '처리 실패', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -165,6 +165,27 @@
|
||||
Route::get('/{memberId}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillUsageController::class, 'show'])->name('show');
|
||||
});
|
||||
|
||||
// 바로빌 과금관리 API
|
||||
Route::prefix('barobill/billing')->name('barobill.billing.')->group(function () {
|
||||
// 구독 관리
|
||||
Route::get('/subscriptions', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'subscriptions'])->name('subscriptions');
|
||||
Route::post('/subscriptions', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'saveSubscription'])->name('subscriptions.save');
|
||||
Route::delete('/subscriptions/{id}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'cancelSubscription'])->name('subscriptions.cancel');
|
||||
Route::get('/subscriptions/member/{memberId}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'memberSubscriptions'])->name('subscriptions.member');
|
||||
|
||||
// 과금 현황
|
||||
Route::get('/list', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'billingList'])->name('list');
|
||||
Route::get('/stats', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'billingStats'])->name('stats');
|
||||
Route::get('/member/{memberId}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'memberBilling'])->name('member');
|
||||
Route::get('/export', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'export'])->name('export');
|
||||
|
||||
// 과금 처리
|
||||
Route::post('/process', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'processBilling'])->name('process');
|
||||
|
||||
// 연간 추이
|
||||
Route::get('/yearly-trend', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'yearlyTrend'])->name('yearly-trend');
|
||||
});
|
||||
|
||||
// 테넌트 관리 API
|
||||
Route::prefix('tenants')->name('tenants.')->group(function () {
|
||||
// 고정 경로는 먼저 정의
|
||||
|
||||
@@ -320,6 +320,7 @@
|
||||
Route::get('/card-usage', [\App\Http\Controllers\Barobill\BarobillController::class, 'cardUsage'])->name('card-usage.index');
|
||||
// Route::get('/hometax', ...) - 아래 hometax 그룹으로 이동됨
|
||||
Route::get('/usage', [\App\Http\Controllers\Barobill\BarobillController::class, 'usage'])->name('usage.index');
|
||||
Route::get('/billing', [\App\Http\Controllers\Barobill\BarobillController::class, 'billing'])->name('billing.index');
|
||||
// 기존 config 라우트 (호환성)
|
||||
Route::get('/config', [\App\Http\Controllers\Barobill\BarobillController::class, 'config'])->name('config.index');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user