feat:홈택스 매출/매입 수동입력, 분개, 카드내역 참조 기능 추가

- 수동입력: MAN-YYYYMMDD-NNN 형식 자동채번, 생성/수정/삭제
- 분개: 세금계산서에서 일반전표 자동 생성 (매출/매입 패턴)
- 카드내역 참조: 수동입력 시 카드사용내역에서 금액/거래처 자동채움
- 테이블에 액션 컬럼 추가 (분개/수정/삭제 버튼)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-06 17:29:02 +09:00
parent 1bd071cbfa
commit a629cb6fcd
3 changed files with 996 additions and 7 deletions

View File

@@ -5,11 +5,16 @@
use App\Http\Controllers\Controller;
use App\Models\Barobill\BarobillConfig;
use App\Models\Barobill\BarobillMember;
use App\Models\Barobill\CardTransaction as BarobillCardTransaction;
use App\Models\Barobill\HometaxInvoice;
use App\Models\Finance\JournalEntry;
use App\Models\Finance\JournalEntryLine;
use App\Models\Tenants\Tenant;
use App\Services\Barobill\HometaxSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
@@ -1401,4 +1406,319 @@ public function toggleChecked(Request $request, HometaxSyncService $syncService)
]);
}
}
/**
* 수동 세금계산서 저장
*/
public function manualStore(Request $request): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$validated = $request->validate([
'invoice_type' => 'required|in:sales,purchase',
'write_date' => 'required|date',
'invoicer_corp_name' => 'required|string|max:200',
'invoicer_corp_num' => 'nullable|string|max:20',
'invoicee_corp_name' => 'required|string|max:200',
'invoicee_corp_num' => 'nullable|string|max:20',
'supply_amount' => 'required|numeric|min:0',
'tax_amount' => 'required|numeric|min:0',
'item_name' => 'nullable|string|max:200',
'remark' => 'nullable|string|max:500',
'tax_type' => 'nullable|integer|in:1,2,3',
'purpose_type' => 'nullable|integer|in:1,2',
]);
// MAN-YYYYMMDD-NNN 형식 자동채번
$dateStr = date('Ymd', strtotime($validated['write_date']));
$lastNum = HometaxInvoice::where('tenant_id', $tenantId)
->where('nts_confirm_num', 'like', "MAN-{$dateStr}-%")
->orderByRaw('CAST(SUBSTRING_INDEX(nts_confirm_num, "-", -1) AS UNSIGNED) DESC')
->value('nts_confirm_num');
$seq = 1;
if ($lastNum) {
$parts = explode('-', $lastNum);
$seq = (int)end($parts) + 1;
}
$ntsConfirmNum = sprintf('MAN-%s-%03d', $dateStr, $seq);
$totalAmount = (float)$validated['supply_amount'] + (float)$validated['tax_amount'];
$invoice = HometaxInvoice::create([
'tenant_id' => $tenantId,
'nts_confirm_num' => $ntsConfirmNum,
'invoice_type' => $validated['invoice_type'],
'write_date' => $validated['write_date'],
'issue_date' => $validated['write_date'],
'invoicer_corp_name' => $validated['invoicer_corp_name'],
'invoicer_corp_num' => $validated['invoicer_corp_num'] ?? '',
'invoicee_corp_name' => $validated['invoicee_corp_name'],
'invoicee_corp_num' => $validated['invoicee_corp_num'] ?? '',
'supply_amount' => $validated['supply_amount'],
'tax_amount' => $validated['tax_amount'],
'total_amount' => $totalAmount,
'item_name' => $validated['item_name'] ?? '',
'remark' => $validated['remark'] ?? '',
'tax_type' => $validated['tax_type'] ?? 1,
'purpose_type' => $validated['purpose_type'] ?? 1,
'synced_at' => now(),
]);
return response()->json([
'success' => true,
'message' => '수동 세금계산서가 등록되었습니다.',
'data' => $invoice,
]);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'success' => false,
'error' => '입력값 오류: ' . implode(', ', $e->validator->errors()->all()),
], 422);
} catch (\Throwable $e) {
Log::error('수동 세금계산서 저장 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'error' => '저장 오류: ' . $e->getMessage(),
]);
}
}
/**
* 수동 세금계산서 수정 (MAN- 건만 가능)
*/
public function manualUpdate(Request $request, int $id): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$invoice = HometaxInvoice::where('id', $id)
->where('tenant_id', $tenantId)
->firstOrFail();
if (!str_starts_with($invoice->nts_confirm_num, 'MAN-')) {
return response()->json([
'success' => false,
'error' => '수동 입력 건만 수정할 수 있습니다.',
], 403);
}
$validated = $request->validate([
'invoice_type' => 'sometimes|in:sales,purchase',
'write_date' => 'sometimes|date',
'invoicer_corp_name' => 'sometimes|string|max:200',
'invoicer_corp_num' => 'nullable|string|max:20',
'invoicee_corp_name' => 'sometimes|string|max:200',
'invoicee_corp_num' => 'nullable|string|max:20',
'supply_amount' => 'sometimes|numeric|min:0',
'tax_amount' => 'sometimes|numeric|min:0',
'item_name' => 'nullable|string|max:200',
'remark' => 'nullable|string|max:500',
'tax_type' => 'nullable|integer|in:1,2,3',
'purpose_type' => 'nullable|integer|in:1,2',
]);
if (isset($validated['supply_amount']) || isset($validated['tax_amount'])) {
$supply = $validated['supply_amount'] ?? $invoice->supply_amount;
$tax = $validated['tax_amount'] ?? $invoice->tax_amount;
$validated['total_amount'] = (float)$supply + (float)$tax;
}
$invoice->update($validated);
return response()->json([
'success' => true,
'message' => '수정되었습니다.',
'data' => $invoice->fresh(),
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json([
'success' => false,
'error' => '해당 세금계산서를 찾을 수 없습니다.',
], 404);
} catch (\Throwable $e) {
Log::error('수동 세금계산서 수정 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'error' => '수정 오류: ' . $e->getMessage(),
]);
}
}
/**
* 수동 세금계산서 삭제 (MAN- 건만 가능)
*/
public function manualDestroy(int $id): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$invoice = HometaxInvoice::where('id', $id)
->where('tenant_id', $tenantId)
->firstOrFail();
if (!str_starts_with($invoice->nts_confirm_num, 'MAN-')) {
return response()->json([
'success' => false,
'error' => '수동 입력 건만 삭제할 수 있습니다.',
], 403);
}
$invoice->delete();
return response()->json([
'success' => true,
'message' => '삭제되었습니다.',
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json([
'success' => false,
'error' => '해당 세금계산서를 찾을 수 없습니다.',
], 404);
} catch (\Throwable $e) {
Log::error('수동 세금계산서 삭제 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'error' => '삭제 오류: ' . $e->getMessage(),
]);
}
}
/**
* 세금계산서에서 분개(일반전표) 생성
*/
public function createJournalEntry(Request $request): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$validated = $request->validate([
'invoice_id' => 'required|integer',
'lines' => 'required|array|min:1',
'lines.*.dc_type' => 'required|in:debit,credit',
'lines.*.account_code' => 'required|string',
'lines.*.account_name' => 'required|string',
'lines.*.debit_amount' => 'required|numeric|min:0',
'lines.*.credit_amount' => 'required|numeric|min:0',
'lines.*.description' => 'nullable|string',
]);
$invoice = HometaxInvoice::where('id', $validated['invoice_id'])
->where('tenant_id', $tenantId)
->firstOrFail();
$result = DB::transaction(function () use ($tenantId, $invoice, $validated) {
$entryNo = JournalEntry::generateEntryNo($tenantId, $invoice->write_date);
$totalDebit = collect($validated['lines'])->sum('debit_amount');
$totalCredit = collect($validated['lines'])->sum('credit_amount');
$tradingPartner = $invoice->invoice_type === 'sales'
? $invoice->invoicee_corp_name
: $invoice->invoicer_corp_name;
$entry = JournalEntry::create([
'tenant_id' => $tenantId,
'entry_no' => $entryNo,
'entry_date' => $invoice->write_date,
'entry_type' => 'general',
'description' => "[홈택스-{$invoice->nts_confirm_num}] {$tradingPartner}",
'total_debit' => $totalDebit,
'total_credit' => $totalCredit,
'status' => 'confirmed',
'created_by_name' => auth()->user()?->name ?? '시스템',
]);
foreach ($validated['lines'] as $i => $line) {
JournalEntryLine::create([
'tenant_id' => $tenantId,
'journal_entry_id' => $entry->id,
'line_no' => $i + 1,
'dc_type' => $line['dc_type'],
'account_code' => $line['account_code'],
'account_name' => $line['account_name'],
'trading_partner_name' => $tradingPartner,
'debit_amount' => $line['debit_amount'],
'credit_amount' => $line['credit_amount'],
'description' => $line['description'] ?? '',
]);
}
return $entry;
});
return response()->json([
'success' => true,
'message' => "전표 {$result->entry_no}가 생성되었습니다.",
'data' => $result->load('lines'),
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json([
'success' => false,
'error' => '해당 세금계산서를 찾을 수 없습니다.',
], 404);
} catch (\Throwable $e) {
Log::error('분개 생성 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'error' => '분개 생성 오류: ' . $e->getMessage(),
]);
}
}
/**
* 카드내역 조회 (수동입력 참조용)
*/
public function cardTransactions(Request $request): JsonResponse
{
try {
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
$startDate = $request->input('startDate', date('Y-m-d', strtotime('-1 month')));
$endDate = $request->input('endDate', date('Y-m-d'));
$search = $request->input('search', '');
$query = BarobillCardTransaction::where('tenant_id', $tenantId)
->whereBetween('use_date', [$startDate, $endDate])
->orderByDesc('use_date')
->orderByDesc('use_time');
if (!empty($search)) {
$query->where(function ($q) use ($search) {
$q->where('merchant_name', 'like', "%{$search}%")
->orWhere('merchant_biz_num', 'like', "%{$search}%")
->orWhere('approval_num', 'like', "%{$search}%");
});
}
$transactions = $query->limit(100)->get()->map(function ($t) {
return [
'id' => $t->id,
'useDate' => $t->use_date,
'useTime' => $t->use_time,
'merchantName' => $t->merchant_name,
'merchantBizNum' => $t->merchant_biz_num,
'approvalNum' => $t->approval_num,
'approvalAmount' => (float)$t->approval_amount,
'approvalAmountFormatted' => number_format($t->approval_amount),
'tax' => (float)($t->tax ?? 0),
'supplyAmount' => (float)($t->modified_supply_amount ?: ($t->approval_amount - ($t->tax ?? 0))),
'cardNum' => $t->card_num ? substr($t->card_num, -4) : '',
'cardCompanyName' => $t->card_company_name ?? '',
];
});
return response()->json([
'success' => true,
'data' => $transactions,
]);
} catch (\Throwable $e) {
Log::error('카드내역 조회 오류: ' . $e->getMessage());
return response()->json([
'success' => false,
'error' => '조회 오류: ' . $e->getMessage(),
]);
}
}
}