- Bill 확장 필드 (V8), Loan 상품권 카테고리/접대비 자동 연동 - GeneralJournalEntry CRUD, AccountSubject API - 접대비/복리후생비 날짜 필터, 매출채권 soft delete 제외 - 바로빌 연동 API 엔드포인트 추가 - 부가세 상세 조회 API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
394 lines
15 KiB
PHP
394 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Tenants\TaxInvoice;
|
|
use Carbon\Carbon;
|
|
|
|
/**
|
|
* 부가세 현황 서비스
|
|
*
|
|
* CEO 대시보드용 부가세 데이터를 제공합니다.
|
|
*/
|
|
class VatService extends Service
|
|
{
|
|
/**
|
|
* 부가세 현황 요약 조회
|
|
*
|
|
* @param string|null $periodType 기간 타입 (quarter|half|year, 기본: quarter)
|
|
* @param int|null $year 연도 (기본: 현재 연도)
|
|
* @param int|null $period 기간 번호 (quarter: 1-4, half: 1-2, 기본: 현재 기간)
|
|
* @return array{cards: array, check_points: array}
|
|
*/
|
|
public function getSummary(?string $periodType = 'quarter', ?int $year = null, ?int $period = null): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$now = Carbon::now();
|
|
|
|
// 기본값 설정
|
|
$year = $year ?? $now->year;
|
|
$periodType = $periodType ?? 'quarter';
|
|
$period = $period ?? $this->getCurrentPeriod($periodType, $now);
|
|
|
|
// 기간 범위 계산
|
|
[$startDate, $endDate] = $this->getPeriodDateRange($year, $periodType, $period);
|
|
|
|
// 발행 완료된 세금계산서만 계산 (status: issued, sent)
|
|
$validStatuses = [TaxInvoice::STATUS_ISSUED, TaxInvoice::STATUS_SENT];
|
|
|
|
// 매출세액 (sales)
|
|
$salesTaxAmount = TaxInvoice::where('tenant_id', $tenantId)
|
|
->where('direction', TaxInvoice::DIRECTION_SALES)
|
|
->whereIn('status', $validStatuses)
|
|
->whereBetween('issue_date', [$startDate, $endDate])
|
|
->sum('tax_amount');
|
|
|
|
// 매입세액 (purchases)
|
|
$purchasesTaxAmount = TaxInvoice::where('tenant_id', $tenantId)
|
|
->where('direction', TaxInvoice::DIRECTION_PURCHASES)
|
|
->whereIn('status', $validStatuses)
|
|
->whereBetween('issue_date', [$startDate, $endDate])
|
|
->sum('tax_amount');
|
|
|
|
// 예상 납부세액 (매출세액 - 매입세액)
|
|
$estimatedPayment = $salesTaxAmount - $purchasesTaxAmount;
|
|
|
|
// 미발행 세금계산서 건수 (전체 기간, status: draft)
|
|
$unissuedCount = TaxInvoice::where('tenant_id', $tenantId)
|
|
->where('status', TaxInvoice::STATUS_DRAFT)
|
|
->count();
|
|
|
|
// 카드 데이터 구성
|
|
$cards = [
|
|
[
|
|
'id' => 'vat_sales_tax',
|
|
'label' => '매출세액',
|
|
'amount' => (int) $salesTaxAmount,
|
|
],
|
|
[
|
|
'id' => 'vat_purchases_tax',
|
|
'label' => '매입세액',
|
|
'amount' => (int) $purchasesTaxAmount,
|
|
],
|
|
[
|
|
'id' => 'vat_estimated_payment',
|
|
'label' => '예상 납부세액',
|
|
'amount' => (int) abs($estimatedPayment),
|
|
'subLabel' => $estimatedPayment < 0 ? '환급' : null,
|
|
],
|
|
[
|
|
'id' => 'vat_unissued',
|
|
'label' => '세금계산서 미발행',
|
|
'amount' => $unissuedCount,
|
|
'unit' => '건',
|
|
],
|
|
];
|
|
|
|
// 체크포인트 생성
|
|
$checkPoints = $this->generateCheckPoints(
|
|
$year,
|
|
$periodType,
|
|
$period,
|
|
$salesTaxAmount,
|
|
$purchasesTaxAmount,
|
|
$estimatedPayment,
|
|
$tenantId
|
|
);
|
|
|
|
return [
|
|
'cards' => $cards,
|
|
'check_points' => $checkPoints,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 현재 기간 계산
|
|
*/
|
|
private function getCurrentPeriod(string $periodType, Carbon $date): int
|
|
{
|
|
return match ($periodType) {
|
|
'quarter' => $date->quarter,
|
|
'half' => $date->month <= 6 ? 1 : 2,
|
|
'year' => 1,
|
|
default => $date->quarter,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 기간 범위 날짜 계산
|
|
*
|
|
* @return array{0: string, 1: string} [startDate, endDate]
|
|
*/
|
|
private function getPeriodDateRange(int $year, string $periodType, int $period): array
|
|
{
|
|
return match ($periodType) {
|
|
'quarter' => [
|
|
Carbon::create($year, ($period - 1) * 3 + 1, 1)->format('Y-m-d'),
|
|
Carbon::create($year, $period * 3, 1)->endOfMonth()->format('Y-m-d'),
|
|
],
|
|
'half' => [
|
|
Carbon::create($year, ($period - 1) * 6 + 1, 1)->format('Y-m-d'),
|
|
Carbon::create($year, $period * 6, 1)->endOfMonth()->format('Y-m-d'),
|
|
],
|
|
'year' => [
|
|
Carbon::create($year, 1, 1)->format('Y-m-d'),
|
|
Carbon::create($year, 12, 31)->format('Y-m-d'),
|
|
],
|
|
default => [
|
|
Carbon::create($year, ($period - 1) * 3 + 1, 1)->format('Y-m-d'),
|
|
Carbon::create($year, $period * 3, 1)->endOfMonth()->format('Y-m-d'),
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 체크포인트 생성
|
|
*/
|
|
private function generateCheckPoints(
|
|
int $year,
|
|
string $periodType,
|
|
int $period,
|
|
float $salesTaxAmount,
|
|
float $purchasesTaxAmount,
|
|
float $estimatedPayment,
|
|
int $tenantId
|
|
): array {
|
|
$checkPoints = [];
|
|
$periodLabel = $this->getPeriodLabel($year, $periodType, $period);
|
|
|
|
// 이전 기간 데이터 조회 (전기 대비 비교용)
|
|
$previousPeriod = $this->getPreviousPeriod($year, $periodType, $period);
|
|
[$prevStartDate, $prevEndDate] = $this->getPeriodDateRange(
|
|
$previousPeriod['year'],
|
|
$periodType,
|
|
$previousPeriod['period']
|
|
);
|
|
|
|
$validStatuses = [TaxInvoice::STATUS_ISSUED, TaxInvoice::STATUS_SENT];
|
|
|
|
$prevSalesTax = TaxInvoice::where('tenant_id', $tenantId)
|
|
->where('direction', TaxInvoice::DIRECTION_SALES)
|
|
->whereIn('status', $validStatuses)
|
|
->whereBetween('issue_date', [$prevStartDate, $prevEndDate])
|
|
->sum('tax_amount');
|
|
|
|
$prevPurchasesTax = TaxInvoice::where('tenant_id', $tenantId)
|
|
->where('direction', TaxInvoice::DIRECTION_PURCHASES)
|
|
->whereIn('status', $validStatuses)
|
|
->whereBetween('issue_date', [$prevStartDate, $prevEndDate])
|
|
->sum('tax_amount');
|
|
|
|
$prevEstimatedPayment = $prevSalesTax - $prevPurchasesTax;
|
|
|
|
// 납부/환급 여부에 따른 메시지 생성
|
|
if ($estimatedPayment < 0) {
|
|
// 환급
|
|
$refundAmount = number_format(abs($estimatedPayment));
|
|
$message = "{$periodLabel} 기준, 예상 환급세액은 {$refundAmount}원입니다.";
|
|
|
|
// 원인 분석 추가
|
|
if ($purchasesTaxAmount > $salesTaxAmount) {
|
|
$message .= ' 매입세액이 매출세액을 초과하여 환급이 예상됩니다.';
|
|
}
|
|
|
|
$checkPoints[] = [
|
|
'id' => 'vat_cp_refund',
|
|
'type' => 'success',
|
|
'message' => $message,
|
|
'highlights' => [
|
|
['text' => "{$periodLabel} 기준, 예상 환급세액은 {$refundAmount}원입니다.", 'color' => 'blue'],
|
|
],
|
|
];
|
|
} else {
|
|
// 납부
|
|
$paymentAmount = number_format($estimatedPayment);
|
|
$message = "{$periodLabel} 기준, 예상 납부세액은 {$paymentAmount}원입니다.";
|
|
|
|
// 전기 대비 변동률 계산
|
|
if ($prevEstimatedPayment > 0) {
|
|
$changeRate = (($estimatedPayment - $prevEstimatedPayment) / $prevEstimatedPayment) * 100;
|
|
$changeDirection = $changeRate >= 0 ? '증가' : '감소';
|
|
$message .= sprintf(' 전기 대비 %.1f%% %s했습니다.', abs($changeRate), $changeDirection);
|
|
}
|
|
|
|
$checkPoints[] = [
|
|
'id' => 'vat_cp_payment',
|
|
'type' => 'success',
|
|
'message' => $message,
|
|
'highlights' => [
|
|
['text' => "{$periodLabel} 기준, 예상 납부세액은 {$paymentAmount}원입니다.", 'color' => 'red'],
|
|
],
|
|
];
|
|
}
|
|
|
|
return $checkPoints;
|
|
}
|
|
|
|
/**
|
|
* 기간 라벨 생성
|
|
*/
|
|
private function getPeriodLabel(int $year, string $periodType, int $period): string
|
|
{
|
|
return match ($periodType) {
|
|
'quarter' => "{$year}년 {$period}기 예정신고",
|
|
'half' => "{$year}년 ".($period === 1 ? '상반기' : '하반기').' 확정신고',
|
|
'year' => "{$year}년 연간",
|
|
default => "{$year}년 {$period}기",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 부가세 상세 조회 (모달용)
|
|
*
|
|
* @param string|null $periodType 기간 타입 (quarter|half|year)
|
|
* @param int|null $year 연도
|
|
* @param int|null $period 기간 번호
|
|
* @return array
|
|
*/
|
|
public function getDetail(?string $periodType = 'quarter', ?int $year = null, ?int $period = null): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$now = Carbon::now();
|
|
|
|
$year = $year ?? $now->year;
|
|
$periodType = $periodType ?? 'quarter';
|
|
$period = $period ?? $this->getCurrentPeriod($periodType, $now);
|
|
|
|
[$startDate, $endDate] = $this->getPeriodDateRange($year, $periodType, $period);
|
|
$periodLabel = $this->getPeriodLabel($year, $periodType, $period);
|
|
|
|
$validStatuses = [TaxInvoice::STATUS_ISSUED, TaxInvoice::STATUS_SENT];
|
|
|
|
// 매출 공급가액 + 세액
|
|
$salesData = TaxInvoice::where('tenant_id', $tenantId)
|
|
->where('direction', TaxInvoice::DIRECTION_SALES)
|
|
->whereIn('status', $validStatuses)
|
|
->whereBetween('issue_date', [$startDate, $endDate])
|
|
->selectRaw('COALESCE(SUM(supply_amount), 0) as supply_amount, COALESCE(SUM(tax_amount), 0) as tax_amount')
|
|
->first();
|
|
|
|
// 매입 공급가액 + 세액
|
|
$purchasesData = TaxInvoice::where('tenant_id', $tenantId)
|
|
->where('direction', TaxInvoice::DIRECTION_PURCHASES)
|
|
->whereIn('status', $validStatuses)
|
|
->whereBetween('issue_date', [$startDate, $endDate])
|
|
->selectRaw('COALESCE(SUM(supply_amount), 0) as supply_amount, COALESCE(SUM(tax_amount), 0) as tax_amount')
|
|
->first();
|
|
|
|
$salesSupplyAmount = (int) ($salesData->supply_amount ?? 0);
|
|
$salesTaxAmount = (int) ($salesData->tax_amount ?? 0);
|
|
$purchasesSupplyAmount = (int) ($purchasesData->supply_amount ?? 0);
|
|
$purchasesTaxAmount = (int) ($purchasesData->tax_amount ?? 0);
|
|
$estimatedPayment = $salesTaxAmount - $purchasesTaxAmount;
|
|
|
|
// 신고기간 옵션 생성
|
|
$periodOptions = $this->generatePeriodOptions($year, $periodType, $period);
|
|
|
|
// 부가세 요약 테이블 (direction + invoice_type 별 GROUP BY)
|
|
$referenceTable = TaxInvoice::where('tenant_id', $tenantId)
|
|
->whereIn('status', $validStatuses)
|
|
->whereBetween('issue_date', [$startDate, $endDate])
|
|
->selectRaw("
|
|
direction,
|
|
invoice_type,
|
|
COALESCE(SUM(supply_amount), 0) as supply_amount,
|
|
COALESCE(SUM(tax_amount), 0) as tax_amount
|
|
")
|
|
->groupBy('direction', 'invoice_type')
|
|
->get()
|
|
->map(fn ($row) => [
|
|
'direction' => $row->direction,
|
|
'direction_label' => $row->direction === TaxInvoice::DIRECTION_SALES ? '매출' : '매입',
|
|
'invoice_type' => $row->invoice_type,
|
|
'invoice_type_label' => match ($row->invoice_type) {
|
|
TaxInvoice::TYPE_TAX_INVOICE => '전자세금계산서',
|
|
TaxInvoice::TYPE_INVOICE => '계산서',
|
|
TaxInvoice::TYPE_MODIFIED_TAX_INVOICE => '수정세금계산서',
|
|
default => $row->invoice_type,
|
|
},
|
|
'supply_amount' => (int) $row->supply_amount,
|
|
'tax_amount' => (int) $row->tax_amount,
|
|
])
|
|
->toArray();
|
|
|
|
// 미발행/미수취 세금계산서 목록 (status=draft)
|
|
$unissuedInvoices = TaxInvoice::where('tenant_id', $tenantId)
|
|
->where('status', TaxInvoice::STATUS_DRAFT)
|
|
->orderBy('issue_date', 'desc')
|
|
->limit(100)
|
|
->get()
|
|
->map(fn ($invoice) => [
|
|
'id' => $invoice->id,
|
|
'direction' => $invoice->direction,
|
|
'direction_label' => $invoice->direction === TaxInvoice::DIRECTION_SALES ? '매출' : '매입',
|
|
'issue_date' => $invoice->issue_date,
|
|
'vendor_name' => $invoice->direction === TaxInvoice::DIRECTION_SALES
|
|
? ($invoice->buyer_corp_name ?? '-')
|
|
: ($invoice->supplier_corp_name ?? '-'),
|
|
'tax_amount' => (int) $invoice->tax_amount,
|
|
'status' => $invoice->direction === TaxInvoice::DIRECTION_SALES ? '미발행' : '미수취',
|
|
])
|
|
->toArray();
|
|
|
|
return [
|
|
'period_label' => $periodLabel,
|
|
'period_options' => $periodOptions,
|
|
'summary' => [
|
|
'sales_supply_amount' => $salesSupplyAmount,
|
|
'sales_tax_amount' => $salesTaxAmount,
|
|
'purchases_supply_amount' => $purchasesSupplyAmount,
|
|
'purchases_tax_amount' => $purchasesTaxAmount,
|
|
'estimated_payment' => (int) abs($estimatedPayment),
|
|
'is_refund' => $estimatedPayment < 0,
|
|
],
|
|
'reference_table' => $referenceTable,
|
|
'unissued_invoices' => $unissuedInvoices,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 신고기간 드롭다운 옵션 생성
|
|
* 현재 기간 포함 최근 8개 기간
|
|
*/
|
|
private function generatePeriodOptions(int $currentYear, string $periodType, int $currentPeriod): array
|
|
{
|
|
$options = [];
|
|
$year = $currentYear;
|
|
$period = $currentPeriod;
|
|
|
|
for ($i = 0; $i < 8; $i++) {
|
|
$label = $this->getPeriodLabel($year, $periodType, $period);
|
|
$value = "{$year}-{$periodType}-{$period}";
|
|
$options[] = ['value' => $value, 'label' => $label];
|
|
|
|
// 이전 기간으로 이동
|
|
$prev = $this->getPreviousPeriod($year, $periodType, $period);
|
|
$year = $prev['year'];
|
|
$period = $prev['period'];
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* 이전 기간 계산
|
|
*
|
|
* @return array{year: int, period: int}
|
|
*/
|
|
private function getPreviousPeriod(int $year, string $periodType, int $period): array
|
|
{
|
|
return match ($periodType) {
|
|
'quarter' => $period === 1
|
|
? ['year' => $year - 1, 'period' => 4]
|
|
: ['year' => $year, 'period' => $period - 1],
|
|
'half' => $period === 1
|
|
? ['year' => $year - 1, 'period' => 2]
|
|
: ['year' => $year, 'period' => 1],
|
|
'year' => ['year' => $year - 1, 'period' => 1],
|
|
default => $period === 1
|
|
? ['year' => $year - 1, 'period' => 4]
|
|
: ['year' => $year, 'period' => $period - 1],
|
|
};
|
|
}
|
|
}
|