Files
sam-manage/app/Http/Controllers/Finance/VatRecordController.php
김보곤 4c9daf3633 fix:부가세 카드매입 분개 이중계산 버그 수정
분개가 있는 카드거래의 unique_key가 금액 변경/수동입력으로
달라질 경우 매칭 실패하여 원본+분개 이중 집계되는 문제 수정.
금액을 제외한 부분키(card_num|use_dt|approval_num)로
보조 매칭하여 분개가 있으면 원본 금액을 사용하지 않도록 개선.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:05:39 +09:00

390 lines
17 KiB
PHP

<?php
namespace App\Http\Controllers\Finance;
use App\Http\Controllers\Controller;
use App\Models\Finance\VatRecord;
use App\Models\Barobill\CardTransaction as BarobillCardTransaction;
use App\Models\Barobill\CardTransactionSplit;
use App\Models\Barobill\CardTransactionHide;
use App\Models\Barobill\HometaxInvoice;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VatRecordController extends Controller
{
public function index(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$period = $request->input('period');
// Step 1: 기간 → 날짜 범위 변환
[$startDate, $endDate] = $period ? $this->periodToDateRange($period) : [null, null];
$startDateYmd = $startDate ? str_replace('-', '', $startDate) : null;
$endDateYmd = $endDate ? str_replace('-', '', $endDate) : null;
$taxTypeMap = [
1 => 'taxable',
2 => 'zero_rated',
3 => 'exempt',
];
// Step 2: 홈택스 매출 조회
$hometaxSalesRecords = collect();
$hometaxPurchaseRecords = collect();
$cardRecords = collect();
if ($startDate && $endDate) {
// 홈택스 매출
$hometaxSales = HometaxInvoice::where('tenant_id', $tenantId)
->sales()
->period($startDate, $endDate)
->get();
$hometaxSalesRecords = $hometaxSales->map(function ($inv) use ($period, $taxTypeMap) {
return [
'id' => 'hometax_' . $inv->id,
'period' => $period,
'type' => 'sales',
'taxType' => $taxTypeMap[$inv->tax_type] ?? 'taxable',
'partnerName' => $inv->invoicee_corp_name,
'invoiceNo' => $inv->nts_confirm_num ?? '',
'invoiceDate' => $inv->write_date ? \Carbon\Carbon::parse($inv->write_date)->format('Y-m-d') : null,
'supplyAmount' => (int) $inv->supply_amount,
'vatAmount' => (int) $inv->tax_amount,
'totalAmount' => (int) $inv->total_amount,
'status' => 'filed',
'memo' => null,
'isCardTransaction' => false,
'isHometax' => true,
'source' => 'hometax',
];
});
// Step 3: 홈택스 매입 조회
$hometaxPurchases = HometaxInvoice::where('tenant_id', $tenantId)
->purchase()
->period($startDate, $endDate)
->get();
$hometaxPurchaseRecords = $hometaxPurchases->map(function ($inv) use ($period, $taxTypeMap) {
return [
'id' => 'hometax_' . $inv->id,
'period' => $period,
'type' => 'purchase',
'taxType' => $taxTypeMap[$inv->tax_type] ?? 'taxable',
'partnerName' => $inv->invoicer_corp_name,
'invoiceNo' => $inv->nts_confirm_num ?? '',
'invoiceDate' => $inv->write_date ? \Carbon\Carbon::parse($inv->write_date)->format('Y-m-d') : null,
'supplyAmount' => (int) $inv->supply_amount,
'vatAmount' => (int) $inv->tax_amount,
'totalAmount' => (int) $inv->total_amount,
'status' => 'filed',
'memo' => null,
'isCardTransaction' => false,
'isHometax' => true,
'source' => 'hometax',
];
});
// Step 4: 카드 공제분 조회
if ($startDateYmd && $endDateYmd) {
$hiddenKeys = CardTransactionHide::getHiddenKeys($tenantId, $startDateYmd, $endDateYmd);
$cardTransactions = BarobillCardTransaction::where('tenant_id', $tenantId)
->whereBetween('use_date', [$startDateYmd, $endDateYmd])
->orderBy('use_date', 'desc')
->get();
$splitsByKey = CardTransactionSplit::getByDateRange($tenantId, $startDateYmd, $endDateYmd);
// 분개가 존재하는 거래의 부분키(금액 제외) 인덱스 생성
// 수동입력 등으로 금액이 달라져도 매칭되도록 함
$splitsByPartialKey = [];
foreach ($splitsByKey as $fullKey => $splits) {
$parts = explode('|', $fullKey);
if (count($parts) >= 3) {
$partialKey = $parts[0] . '|' . $parts[1] . '|' . $parts[2];
$splitsByPartialKey[$partialKey] = $splits;
}
}
foreach ($cardTransactions as $card) {
// 숨김 처리된 거래는 skip
if ($hiddenKeys->contains($card->unique_key)) {
continue;
}
// 분개 매칭: 정확한 키 → 부분키(금액 제외) 순으로 시도
$splits = $splitsByKey[$card->unique_key] ?? null;
if (!$splits) {
$cardPartialKey = $card->card_num . '|' . $card->use_dt . '|' . $card->approval_num;
$splits = $splitsByPartialKey[$cardPartialKey] ?? null;
}
if ($splits && count($splits) > 0) {
// 분개가 있으면: deductible 분개만 포함
foreach ($splits as $split) {
if ($split->deduction_type === 'deductible') {
$cardRecords->push([
'id' => 'card_split_' . $split->id,
'period' => $period,
'type' => 'purchase',
'taxType' => 'taxable',
'partnerName' => $card->merchant_name,
'invoiceNo' => $card->approval_num ?? '',
'invoiceDate' => $card->use_date ? \Carbon\Carbon::createFromFormat('Ymd', $card->use_date)->format('Y-m-d') : null,
'supplyAmount' => (int) $split->split_supply_amount,
'vatAmount' => (int) $split->split_tax,
'totalAmount' => (int) $split->split_amount,
'status' => 'filed',
'memo' => $split->account_name ?? null,
'isCardTransaction' => true,
'isHometax' => false,
'source' => 'card',
]);
}
}
} else {
// 분개가 없으면: deduction_type='deductible'인 경우만 포함
if ($card->deduction_type === 'deductible') {
$effectiveSupply = $card->modified_supply_amount
?? ($card->approval_amount - $card->tax);
$effectiveTax = $card->modified_tax ?? $card->tax;
$cardRecords->push([
'id' => 'card_' . $card->id,
'period' => $period,
'type' => 'purchase',
'taxType' => 'taxable',
'partnerName' => $card->merchant_name,
'invoiceNo' => $card->approval_num ?? '',
'invoiceDate' => $card->use_date ? \Carbon\Carbon::createFromFormat('Ymd', $card->use_date)->format('Y-m-d') : null,
'supplyAmount' => (int) $effectiveSupply,
'vatAmount' => (int) $effectiveTax,
'totalAmount' => (int) ($effectiveSupply + $effectiveTax),
'status' => 'filed',
'memo' => $card->memo,
'isCardTransaction' => true,
'isHometax' => false,
'source' => 'card',
]);
}
}
}
}
}
// Step 5: 수동 입력 (vat_records) - 기존 유지
$manualQuery = VatRecord::forTenant($tenantId);
if ($period) {
$manualQuery->where('period', $period);
}
$manualRecords = $manualQuery->orderBy('invoice_date', 'desc')
->get()
->map(function ($record) {
return [
'id' => $record->id,
'period' => $record->period,
'type' => $record->type,
'taxType' => $record->tax_type ?? 'taxable',
'partnerName' => $record->partner_name,
'invoiceNo' => $record->invoice_no,
'invoiceDate' => $record->invoice_date?->format('Y-m-d'),
'supplyAmount' => $record->supply_amount,
'vatAmount' => $record->vat_amount,
'totalAmount' => $record->total_amount,
'status' => $record->status,
'memo' => $record->memo,
'isCardTransaction' => false,
'isHometax' => false,
'source' => 'manual',
];
});
// Step 6: 통합 및 통계
$allRecords = $hometaxSalesRecords
->concat($hometaxPurchaseRecords)
->concat($cardRecords)
->concat($manualRecords)
->values();
// 홈택스 매출 전자세금계산서 (과세 + 영세만, 면세 제외)
$hometaxSalesTaxable = $hometaxSalesRecords->whereIn('taxType', ['taxable', 'zero_rated']);
$hometaxSalesSupply = $hometaxSalesTaxable->sum('supplyAmount');
$hometaxSalesVat = $hometaxSalesTaxable->sum('vatAmount');
// 홈택스 매입 전자세금계산서 (과세 + 영세만, 면세 제외)
$hometaxPurchaseTaxable = $hometaxPurchaseRecords->whereIn('taxType', ['taxable', 'zero_rated']);
$hometaxPurchaseSupply = $hometaxPurchaseTaxable->sum('supplyAmount');
$hometaxPurchaseVat = $hometaxPurchaseTaxable->sum('vatAmount');
// 홈택스 면세 계산서 (매입 + 매출 모두)
$exemptSalesSupply = $hometaxSalesRecords->where('taxType', 'exempt')->sum('supplyAmount');
$exemptPurchaseSupply = $hometaxPurchaseRecords->where('taxType', 'exempt')->sum('supplyAmount');
// 카드 매입
$cardPurchaseSupply = $cardRecords->sum('supplyAmount');
$cardPurchaseVat = $cardRecords->sum('vatAmount');
// 수동입력 매출 종이세금계산서 (과세+영세)
$manualSalesTaxable = $manualRecords->where('type', 'sales')->whereIn('taxType', ['taxable', 'zero_rated']);
$manualSalesSupply = $manualSalesTaxable->sum('supplyAmount');
$manualSalesVat = $manualSalesTaxable->sum('vatAmount');
// 수동입력 매입 종이세금계산서 (과세+영세)
$manualPurchaseTaxable = $manualRecords->where('type', 'purchase')->whereIn('taxType', ['taxable', 'zero_rated']);
$manualPurchaseSupply = $manualPurchaseTaxable->sum('supplyAmount');
$manualPurchaseVat = $manualPurchaseTaxable->sum('vatAmount');
// 수동입력 면세 계산서
$manualExemptSalesSupply = $manualRecords->where('type', 'sales')->where('taxType', 'exempt')->sum('supplyAmount');
$manualExemptPurchaseSupply = $manualRecords->where('type', 'purchase')->where('taxType', 'exempt')->sum('supplyAmount');
// 면세 계산서 합계 (홈택스 + 수동)
$exemptSupply = $exemptSalesSupply + $exemptPurchaseSupply + $manualExemptSalesSupply + $manualExemptPurchaseSupply;
$stats = [
'salesSupply' => $hometaxSalesSupply + $manualSalesSupply,
'salesVat' => $hometaxSalesVat + $manualSalesVat,
'purchaseSupply' => $hometaxPurchaseSupply + $cardPurchaseSupply + $manualPurchaseSupply,
'purchaseVat' => $hometaxPurchaseVat + $cardPurchaseVat + $manualPurchaseVat,
'hometaxSalesSupply' => $hometaxSalesSupply,
'hometaxSalesVat' => $hometaxSalesVat,
'manualSalesSupply' => $manualSalesSupply,
'manualSalesVat' => $manualSalesVat,
'hometaxPurchaseSupply' => $hometaxPurchaseSupply,
'hometaxPurchaseVat' => $hometaxPurchaseVat,
'manualPurchaseSupply' => $manualPurchaseSupply,
'manualPurchaseVat' => $manualPurchaseVat,
'exemptSupply' => $exemptSupply,
'cardPurchaseSupply' => $cardPurchaseSupply,
'cardPurchaseVat' => $cardPurchaseVat,
'total' => $allRecords->count(),
];
// 사용 중인 기간 목록
$periods = VatRecord::forTenant($tenantId)
->select('period')
->distinct()
->orderBy('period', 'desc')
->pluck('period')
->toArray();
return response()->json([
'success' => true,
'data' => $allRecords,
'stats' => $stats,
'periods' => $periods,
]);
}
public function store(Request $request): JsonResponse
{
$request->validate([
'partnerName' => 'required|string|max:100',
'period' => 'required|string|max:20',
'type' => 'required|in:sales,purchase',
'taxType' => 'nullable|in:taxable,zero_rated,exempt',
'supplyAmount' => 'required|integer|min:0',
]);
$tenantId = session('selected_tenant_id', 1);
$record = VatRecord::create([
'tenant_id' => $tenantId,
'period' => $request->input('period'),
'type' => $request->input('type', 'sales'),
'tax_type' => $request->input('taxType', 'taxable'),
'partner_name' => $request->input('partnerName'),
'invoice_no' => $request->input('invoiceNo'),
'invoice_date' => $request->input('invoiceDate'),
'supply_amount' => $request->input('supplyAmount', 0),
'vat_amount' => $request->input('vatAmount', 0),
'total_amount' => $request->input('totalAmount', 0),
'status' => $request->input('status', 'pending'),
'memo' => $request->input('memo'),
]);
return response()->json([
'success' => true,
'message' => '세금계산서가 등록되었습니다.',
'data' => ['id' => $record->id],
]);
}
public function update(Request $request, int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$record = VatRecord::forTenant($tenantId)->findOrFail($id);
$request->validate([
'partnerName' => 'required|string|max:100',
'period' => 'required|string|max:20',
'type' => 'required|in:sales,purchase',
'taxType' => 'nullable|in:taxable,zero_rated,exempt',
'supplyAmount' => 'required|integer|min:0',
]);
$record->update([
'period' => $request->input('period'),
'type' => $request->input('type'),
'tax_type' => $request->input('taxType', $record->tax_type),
'partner_name' => $request->input('partnerName'),
'invoice_no' => $request->input('invoiceNo'),
'invoice_date' => $request->input('invoiceDate'),
'supply_amount' => $request->input('supplyAmount', 0),
'vat_amount' => $request->input('vatAmount', 0),
'total_amount' => $request->input('totalAmount', 0),
'status' => $request->input('status'),
'memo' => $request->input('memo'),
]);
return response()->json([
'success' => true,
'message' => '세금계산서가 수정되었습니다.',
]);
}
public function destroy(int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$record = VatRecord::forTenant($tenantId)->findOrFail($id);
$record->delete();
return response()->json([
'success' => true,
'message' => '세금계산서가 삭제되었습니다.',
]);
}
/**
* 부가세 신고기간을 날짜 범위로 변환
* YYYY-1P: 1기 예정 (0101~0331)
* YYYY-1C: 1기 확정 (0401~0630)
* YYYY-2P: 2기 예정 (0701~0930)
* YYYY-2C: 2기 확정 (1001~1231)
*/
private function periodToDateRange(string $period): array
{
$parts = explode('-', $period);
if (count($parts) !== 2) {
return [null, null];
}
$year = $parts[0];
$code = $parts[1];
$ranges = [
'1P' => ["{$year}-01-01", "{$year}-03-31"],
'1C' => ["{$year}-04-01", "{$year}-06-30"],
'2P' => ["{$year}-07-01", "{$year}-09-30"],
'2C' => ["{$year}-10-01", "{$year}-12-31"],
];
return $ranges[$code] ?? [null, null];
}
}