feat:홈택스 세금계산서 로컬 저장 및 동기화 기능 구현
- HometaxInvoice 모델 생성 (로컬 DB 조회/저장) - HometaxSyncService 서비스 생성 (API 데이터 동기화) - HometaxController에 로컬 조회/동기화 메서드 추가 - 라우트 추가: local-sales, local-purchases, sync, update-memo, toggle-checked - UI: 데이터소스 선택 (로컬 DB/바로빌 API), 동기화 버튼 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
use App\Models\Barobill\BarobillConfig;
|
use App\Models\Barobill\BarobillConfig;
|
||||||
use App\Models\Barobill\BarobillMember;
|
use App\Models\Barobill\BarobillMember;
|
||||||
use App\Models\Tenants\Tenant;
|
use App\Models\Tenants\Tenant;
|
||||||
|
use App\Services\Barobill\HometaxSyncService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
@@ -1174,4 +1175,230 @@ private function xmlToObject(\SimpleXMLElement $xml): object
|
|||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로컬 DB에서 매출 세금계산서 조회
|
||||||
|
*/
|
||||||
|
public function localSales(Request $request, HometaxSyncService $syncService): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||||
|
|
||||||
|
$startDate = $request->input('startDate');
|
||||||
|
$endDate = $request->input('endDate');
|
||||||
|
|
||||||
|
// YYYYMMDD 형식을 Y-m-d로 변환
|
||||||
|
if (strlen($startDate) === 8) {
|
||||||
|
$startDate = substr($startDate, 0, 4) . '-' . substr($startDate, 4, 2) . '-' . substr($startDate, 6, 2);
|
||||||
|
}
|
||||||
|
if (strlen($endDate) === 8) {
|
||||||
|
$endDate = substr($endDate, 0, 4) . '-' . substr($endDate, 4, 2) . '-' . substr($endDate, 6, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateType = $request->input('dateType', 1) == 1 ? 'write' : 'issue';
|
||||||
|
$searchCorp = $request->input('searchCorp');
|
||||||
|
|
||||||
|
$data = $syncService->getLocalInvoices(
|
||||||
|
$tenantId,
|
||||||
|
'sales',
|
||||||
|
$startDate,
|
||||||
|
$endDate,
|
||||||
|
$dateType,
|
||||||
|
$searchCorp
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
'lastSyncAt' => $syncService->getLastSyncTime($tenantId, 'sales'),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('로컬 매출 조회 오류: ' . $e->getMessage());
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => '조회 오류: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로컬 DB에서 매입 세금계산서 조회
|
||||||
|
*/
|
||||||
|
public function localPurchases(Request $request, HometaxSyncService $syncService): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||||
|
|
||||||
|
$startDate = $request->input('startDate');
|
||||||
|
$endDate = $request->input('endDate');
|
||||||
|
|
||||||
|
// YYYYMMDD 형식을 Y-m-d로 변환
|
||||||
|
if (strlen($startDate) === 8) {
|
||||||
|
$startDate = substr($startDate, 0, 4) . '-' . substr($startDate, 4, 2) . '-' . substr($startDate, 6, 2);
|
||||||
|
}
|
||||||
|
if (strlen($endDate) === 8) {
|
||||||
|
$endDate = substr($endDate, 0, 4) . '-' . substr($endDate, 4, 2) . '-' . substr($endDate, 6, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateType = $request->input('dateType', 1) == 1 ? 'write' : 'issue';
|
||||||
|
$searchCorp = $request->input('searchCorp');
|
||||||
|
|
||||||
|
$data = $syncService->getLocalInvoices(
|
||||||
|
$tenantId,
|
||||||
|
'purchase',
|
||||||
|
$startDate,
|
||||||
|
$endDate,
|
||||||
|
$dateType,
|
||||||
|
$searchCorp
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data,
|
||||||
|
'lastSyncAt' => $syncService->getLastSyncTime($tenantId, 'purchase'),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('로컬 매입 조회 오류: ' . $e->getMessage());
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => '조회 오류: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 바로빌 API에서 데이터를 가져와 로컬 DB에 동기화
|
||||||
|
*/
|
||||||
|
public function sync(Request $request, HometaxSyncService $syncService): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||||
|
$type = $request->input('type', 'all'); // 'sales', 'purchase', 'all'
|
||||||
|
$startDate = $request->input('startDate', date('Ymd', strtotime('-1 month')));
|
||||||
|
$endDate = $request->input('endDate', date('Ymd'));
|
||||||
|
$dateType = (int)$request->input('dateType', 1);
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
// 매출 동기화
|
||||||
|
if ($type === 'all' || $type === 'sales') {
|
||||||
|
$salesRequest = new Request([
|
||||||
|
'startDate' => $startDate,
|
||||||
|
'endDate' => $endDate,
|
||||||
|
'dateType' => $dateType,
|
||||||
|
'limit' => 500,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$salesResponse = $this->sales($salesRequest);
|
||||||
|
$salesData = json_decode($salesResponse->getContent(), true);
|
||||||
|
|
||||||
|
if ($salesData['success'] && !empty($salesData['data']['invoices'])) {
|
||||||
|
$results['sales'] = $syncService->syncInvoices(
|
||||||
|
$salesData['data']['invoices'],
|
||||||
|
$tenantId,
|
||||||
|
'sales'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$results['sales'] = [
|
||||||
|
'inserted' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'total' => 0,
|
||||||
|
'error' => $salesData['error'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매입 동기화
|
||||||
|
if ($type === 'all' || $type === 'purchase') {
|
||||||
|
$purchaseRequest = new Request([
|
||||||
|
'startDate' => $startDate,
|
||||||
|
'endDate' => $endDate,
|
||||||
|
'dateType' => $dateType,
|
||||||
|
'limit' => 500,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$purchaseResponse = $this->purchases($purchaseRequest);
|
||||||
|
$purchaseData = json_decode($purchaseResponse->getContent(), true);
|
||||||
|
|
||||||
|
if ($purchaseData['success'] && !empty($purchaseData['data']['invoices'])) {
|
||||||
|
$results['purchase'] = $syncService->syncInvoices(
|
||||||
|
$purchaseData['data']['invoices'],
|
||||||
|
$tenantId,
|
||||||
|
'purchase'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$results['purchase'] = [
|
||||||
|
'inserted' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'total' => 0,
|
||||||
|
'error' => $purchaseData['error'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 총 결과 계산
|
||||||
|
$totalInserted = ($results['sales']['inserted'] ?? 0) + ($results['purchase']['inserted'] ?? 0);
|
||||||
|
$totalUpdated = ($results['sales']['updated'] ?? 0) + ($results['purchase']['updated'] ?? 0);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => "동기화 완료: {$totalInserted}건 추가, {$totalUpdated}건 갱신",
|
||||||
|
'data' => $results,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('홈택스 동기화 오류: ' . $e->getMessage());
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => '동기화 오류: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 메모 업데이트
|
||||||
|
*/
|
||||||
|
public function updateMemo(Request $request, HometaxSyncService $syncService): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||||
|
$id = $request->input('id');
|
||||||
|
$memo = $request->input('memo');
|
||||||
|
|
||||||
|
$success = $syncService->updateMemo($id, $tenantId, $memo);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => $success,
|
||||||
|
'message' => $success ? '메모가 저장되었습니다.' : '저장에 실패했습니다.',
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => '오류: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 확인 여부 토글
|
||||||
|
*/
|
||||||
|
public function toggleChecked(Request $request, HometaxSyncService $syncService): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||||
|
$id = $request->input('id');
|
||||||
|
|
||||||
|
$success = $syncService->toggleChecked($id, $tenantId);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => $success,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => '오류: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
266
app/Models/Barobill/HometaxInvoice.php
Normal file
266
app/Models/Barobill/HometaxInvoice.php
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Barobill;
|
||||||
|
|
||||||
|
use App\Models\Tenants\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈택스 세금계산서 모델
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property int $tenant_id
|
||||||
|
* @property string $nts_confirm_num 국세청승인번호
|
||||||
|
* @property string $invoice_type 매출/매입 (sales/purchase)
|
||||||
|
* @property \Carbon\Carbon $write_date 작성일자
|
||||||
|
* @property \Carbon\Carbon|null $issue_date 발급일자
|
||||||
|
* @property \Carbon\Carbon|null $send_date 전송일자
|
||||||
|
* @property string $invoicer_corp_num 공급자 사업자번호
|
||||||
|
* @property string $invoicer_corp_name 공급자 상호
|
||||||
|
* @property string|null $invoicer_ceo_name 공급자 대표자
|
||||||
|
* @property string $invoicee_corp_num 공급받는자 사업자번호
|
||||||
|
* @property string $invoicee_corp_name 공급받는자 상호
|
||||||
|
* @property string|null $invoicee_ceo_name 공급받는자 대표자
|
||||||
|
* @property int $supply_amount 공급가액
|
||||||
|
* @property int $tax_amount 세액
|
||||||
|
* @property int $total_amount 합계
|
||||||
|
* @property int $tax_type 과세유형 (1:과세, 2:영세, 3:면세)
|
||||||
|
* @property int $purpose_type 영수/청구 (1:영수, 2:청구)
|
||||||
|
* @property int $issue_type 발급유형 (1:정발행, 2:역발행)
|
||||||
|
* @property string|null $item_name 품목명
|
||||||
|
* @property string|null $remark 비고
|
||||||
|
* @property string|null $memo 내부 메모
|
||||||
|
* @property string|null $category 분류 태그
|
||||||
|
* @property bool $is_checked 확인 여부
|
||||||
|
* @property \Carbon\Carbon|null $synced_at 마지막 동기화 시간
|
||||||
|
*/
|
||||||
|
class HometaxInvoice extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'hometax_invoices';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'nts_confirm_num',
|
||||||
|
'invoice_type',
|
||||||
|
'write_date',
|
||||||
|
'issue_date',
|
||||||
|
'send_date',
|
||||||
|
'invoicer_corp_num',
|
||||||
|
'invoicer_corp_name',
|
||||||
|
'invoicer_ceo_name',
|
||||||
|
'invoicee_corp_num',
|
||||||
|
'invoicee_corp_name',
|
||||||
|
'invoicee_ceo_name',
|
||||||
|
'supply_amount',
|
||||||
|
'tax_amount',
|
||||||
|
'total_amount',
|
||||||
|
'tax_type',
|
||||||
|
'purpose_type',
|
||||||
|
'issue_type',
|
||||||
|
'item_name',
|
||||||
|
'remark',
|
||||||
|
'memo',
|
||||||
|
'category',
|
||||||
|
'is_checked',
|
||||||
|
'synced_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'tenant_id' => 'integer',
|
||||||
|
'write_date' => 'date',
|
||||||
|
'issue_date' => 'date',
|
||||||
|
'send_date' => 'date',
|
||||||
|
'supply_amount' => 'integer',
|
||||||
|
'tax_amount' => 'integer',
|
||||||
|
'total_amount' => 'integer',
|
||||||
|
'tax_type' => 'integer',
|
||||||
|
'purpose_type' => 'integer',
|
||||||
|
'issue_type' => 'integer',
|
||||||
|
'is_checked' => 'boolean',
|
||||||
|
'synced_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 과세유형 상수
|
||||||
|
public const TAX_TYPE_TAXABLE = 1; // 과세
|
||||||
|
public const TAX_TYPE_ZERO_RATE = 2; // 영세
|
||||||
|
public const TAX_TYPE_EXEMPT = 3; // 면세
|
||||||
|
|
||||||
|
// 영수/청구 상수
|
||||||
|
public const PURPOSE_TYPE_RECEIPT = 1; // 영수
|
||||||
|
public const PURPOSE_TYPE_CLAIM = 2; // 청구
|
||||||
|
|
||||||
|
// 발급유형 상수
|
||||||
|
public const ISSUE_TYPE_NORMAL = 1; // 정발행
|
||||||
|
public const ISSUE_TYPE_REVERSE = 2; // 역발행
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테넌트 관계
|
||||||
|
*/
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매출 스코프
|
||||||
|
*/
|
||||||
|
public function scopeSales($query)
|
||||||
|
{
|
||||||
|
return $query->where('invoice_type', 'sales');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매입 스코프
|
||||||
|
*/
|
||||||
|
public function scopePurchase($query)
|
||||||
|
{
|
||||||
|
return $query->where('invoice_type', 'purchase');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기간 스코프
|
||||||
|
*/
|
||||||
|
public function scopePeriod($query, string $startDate, string $endDate, string $dateType = 'write')
|
||||||
|
{
|
||||||
|
$column = match($dateType) {
|
||||||
|
'issue' => 'issue_date',
|
||||||
|
'send' => 'send_date',
|
||||||
|
default => 'write_date',
|
||||||
|
};
|
||||||
|
|
||||||
|
return $query->whereBetween($column, [$startDate, $endDate]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 거래처 검색 스코프
|
||||||
|
*/
|
||||||
|
public function scopeSearchCorp($query, string $keyword, string $invoiceType = 'sales')
|
||||||
|
{
|
||||||
|
if (empty($keyword)) {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매출이면 공급받는자, 매입이면 공급자 검색
|
||||||
|
if ($invoiceType === 'sales') {
|
||||||
|
return $query->where(function ($q) use ($keyword) {
|
||||||
|
$q->where('invoicee_corp_name', 'like', "%{$keyword}%")
|
||||||
|
->orWhere('invoicee_corp_num', 'like', "%{$keyword}%");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return $query->where(function ($q) use ($keyword) {
|
||||||
|
$q->where('invoicer_corp_name', 'like', "%{$keyword}%")
|
||||||
|
->orWhere('invoicer_corp_num', 'like', "%{$keyword}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 과세유형 라벨
|
||||||
|
*/
|
||||||
|
public function getTaxTypeNameAttribute(): string
|
||||||
|
{
|
||||||
|
return match($this->tax_type) {
|
||||||
|
self::TAX_TYPE_TAXABLE => '과세',
|
||||||
|
self::TAX_TYPE_ZERO_RATE => '영세',
|
||||||
|
self::TAX_TYPE_EXEMPT => '면세',
|
||||||
|
default => '-',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영수/청구 라벨
|
||||||
|
*/
|
||||||
|
public function getPurposeTypeNameAttribute(): string
|
||||||
|
{
|
||||||
|
return match($this->purpose_type) {
|
||||||
|
self::PURPOSE_TYPE_RECEIPT => '영수',
|
||||||
|
self::PURPOSE_TYPE_CLAIM => '청구',
|
||||||
|
default => '-',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 발급유형 라벨
|
||||||
|
*/
|
||||||
|
public function getIssueTypeNameAttribute(): string
|
||||||
|
{
|
||||||
|
return match($this->issue_type) {
|
||||||
|
self::ISSUE_TYPE_NORMAL => '정발급',
|
||||||
|
self::ISSUE_TYPE_REVERSE => '역발급',
|
||||||
|
default => '-',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포맷된 공급가액
|
||||||
|
*/
|
||||||
|
public function getFormattedSupplyAmountAttribute(): string
|
||||||
|
{
|
||||||
|
return number_format($this->supply_amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포맷된 세액
|
||||||
|
*/
|
||||||
|
public function getFormattedTaxAmountAttribute(): string
|
||||||
|
{
|
||||||
|
return number_format($this->tax_amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포맷된 합계
|
||||||
|
*/
|
||||||
|
public function getFormattedTotalAmountAttribute(): string
|
||||||
|
{
|
||||||
|
return number_format($this->total_amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 응답 데이터를 모델 데이터로 변환
|
||||||
|
*/
|
||||||
|
public static function fromApiData(array $apiData, int $tenantId, string $invoiceType): array
|
||||||
|
{
|
||||||
|
// 작성일자 파싱
|
||||||
|
$writeDate = null;
|
||||||
|
if (!empty($apiData['writeDate']) && strlen($apiData['writeDate']) >= 8) {
|
||||||
|
$writeDate = substr($apiData['writeDate'], 0, 4) . '-' .
|
||||||
|
substr($apiData['writeDate'], 4, 2) . '-' .
|
||||||
|
substr($apiData['writeDate'], 6, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 발급일자 파싱
|
||||||
|
$issueDate = null;
|
||||||
|
if (!empty($apiData['issueDT']) && strlen($apiData['issueDT']) >= 8) {
|
||||||
|
$issueDate = substr($apiData['issueDT'], 0, 4) . '-' .
|
||||||
|
substr($apiData['issueDT'], 4, 2) . '-' .
|
||||||
|
substr($apiData['issueDT'], 6, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'nts_confirm_num' => $apiData['ntsConfirmNum'] ?? '',
|
||||||
|
'invoice_type' => $invoiceType,
|
||||||
|
'write_date' => $writeDate,
|
||||||
|
'issue_date' => $issueDate,
|
||||||
|
'invoicer_corp_num' => $apiData['invoicerCorpNum'] ?? '',
|
||||||
|
'invoicer_corp_name' => $apiData['invoicerCorpName'] ?? '',
|
||||||
|
'invoicer_ceo_name' => $apiData['invoicerCEOName'] ?? null,
|
||||||
|
'invoicee_corp_num' => $apiData['invoiceeCorpNum'] ?? '',
|
||||||
|
'invoicee_corp_name' => $apiData['invoiceeCorpName'] ?? '',
|
||||||
|
'invoicee_ceo_name' => $apiData['invoiceeCEOName'] ?? null,
|
||||||
|
'supply_amount' => (int)($apiData['supplyAmount'] ?? 0),
|
||||||
|
'tax_amount' => (int)($apiData['taxAmount'] ?? 0),
|
||||||
|
'total_amount' => (int)($apiData['totalAmount'] ?? 0),
|
||||||
|
'tax_type' => (int)($apiData['taxType'] ?? 1),
|
||||||
|
'purpose_type' => (int)($apiData['purposeType'] ?? 1),
|
||||||
|
'issue_type' => 1, // 기본값: 정발행
|
||||||
|
'item_name' => $apiData['itemName'] ?? null,
|
||||||
|
'remark' => $apiData['remark'] ?? null,
|
||||||
|
'synced_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
229
app/Services/Barobill/HometaxSyncService.php
Normal file
229
app/Services/Barobill/HometaxSyncService.php
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Barobill;
|
||||||
|
|
||||||
|
use App\Models\Barobill\HometaxInvoice;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈택스 세금계산서 동기화 서비스
|
||||||
|
*/
|
||||||
|
class HometaxSyncService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* API 응답 데이터를 로컬 DB에 동기화
|
||||||
|
*
|
||||||
|
* @param array $invoices API에서 받은 세금계산서 목록
|
||||||
|
* @param int $tenantId 테넌트 ID
|
||||||
|
* @param string $invoiceType 'sales' 또는 'purchase'
|
||||||
|
* @return array 동기화 결과 ['inserted' => int, 'updated' => int, 'failed' => int]
|
||||||
|
*/
|
||||||
|
public function syncInvoices(array $invoices, int $tenantId, string $invoiceType): array
|
||||||
|
{
|
||||||
|
$result = [
|
||||||
|
'inserted' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'total' => count($invoices),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (empty($invoices)) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach ($invoices as $apiData) {
|
||||||
|
// 국세청승인번호가 없으면 스킵
|
||||||
|
if (empty($apiData['ntsConfirmNum'])) {
|
||||||
|
$result['failed']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$modelData = HometaxInvoice::fromApiData($apiData, $tenantId, $invoiceType);
|
||||||
|
|
||||||
|
// upsert (있으면 업데이트, 없으면 삽입)
|
||||||
|
$existing = HometaxInvoice::where('tenant_id', $tenantId)
|
||||||
|
->where('nts_confirm_num', $modelData['nts_confirm_num'])
|
||||||
|
->where('invoice_type', $invoiceType)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
// 기존 데이터 업데이트 (자체 관리 필드는 유지)
|
||||||
|
$existing->update([
|
||||||
|
'write_date' => $modelData['write_date'],
|
||||||
|
'issue_date' => $modelData['issue_date'],
|
||||||
|
'invoicer_corp_num' => $modelData['invoicer_corp_num'],
|
||||||
|
'invoicer_corp_name' => $modelData['invoicer_corp_name'],
|
||||||
|
'invoicer_ceo_name' => $modelData['invoicer_ceo_name'],
|
||||||
|
'invoicee_corp_num' => $modelData['invoicee_corp_num'],
|
||||||
|
'invoicee_corp_name' => $modelData['invoicee_corp_name'],
|
||||||
|
'invoicee_ceo_name' => $modelData['invoicee_ceo_name'],
|
||||||
|
'supply_amount' => $modelData['supply_amount'],
|
||||||
|
'tax_amount' => $modelData['tax_amount'],
|
||||||
|
'total_amount' => $modelData['total_amount'],
|
||||||
|
'tax_type' => $modelData['tax_type'],
|
||||||
|
'purpose_type' => $modelData['purpose_type'],
|
||||||
|
'item_name' => $modelData['item_name'],
|
||||||
|
'remark' => $modelData['remark'],
|
||||||
|
'synced_at' => now(),
|
||||||
|
]);
|
||||||
|
$result['updated']++;
|
||||||
|
} else {
|
||||||
|
// 새 데이터 삽입
|
||||||
|
HometaxInvoice::create($modelData);
|
||||||
|
$result['inserted']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
Log::info('[HometaxSync] 동기화 완료', [
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'invoice_type' => $invoiceType,
|
||||||
|
'result' => $result,
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
Log::error('[HometaxSync] 동기화 실패', [
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'invoice_type' => $invoiceType,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로컬 DB에서 세금계산서 목록 조회
|
||||||
|
*
|
||||||
|
* @param int $tenantId 테넌트 ID
|
||||||
|
* @param string $invoiceType 'sales' 또는 'purchase'
|
||||||
|
* @param string $startDate 시작일 (Y-m-d)
|
||||||
|
* @param string $endDate 종료일 (Y-m-d)
|
||||||
|
* @param string $dateType 날짜 타입 ('write', 'issue', 'send')
|
||||||
|
* @param string|null $searchCorp 거래처 검색어
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getLocalInvoices(
|
||||||
|
int $tenantId,
|
||||||
|
string $invoiceType,
|
||||||
|
string $startDate,
|
||||||
|
string $endDate,
|
||||||
|
string $dateType = 'write',
|
||||||
|
?string $searchCorp = null
|
||||||
|
): array {
|
||||||
|
$query = HometaxInvoice::where('tenant_id', $tenantId)
|
||||||
|
->where('invoice_type', $invoiceType)
|
||||||
|
->period($startDate, $endDate, $dateType);
|
||||||
|
|
||||||
|
if (!empty($searchCorp)) {
|
||||||
|
$query->searchCorp($searchCorp, $invoiceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoices = $query->orderByDesc('write_date')->get();
|
||||||
|
|
||||||
|
// API 응답 형식에 맞게 변환
|
||||||
|
$formattedInvoices = $invoices->map(function ($inv) {
|
||||||
|
return [
|
||||||
|
'id' => $inv->id,
|
||||||
|
'ntsConfirmNum' => $inv->nts_confirm_num,
|
||||||
|
'writeDate' => $inv->write_date?->format('Ymd'),
|
||||||
|
'writeDateFormatted' => $inv->write_date?->format('Y-m-d'),
|
||||||
|
'issueDT' => $inv->issue_date?->format('Ymd'),
|
||||||
|
'issueDateFormatted' => $inv->issue_date?->format('Y-m-d'),
|
||||||
|
'invoicerCorpNum' => $inv->invoicer_corp_num,
|
||||||
|
'invoicerCorpName' => $inv->invoicer_corp_name,
|
||||||
|
'invoicerCEOName' => $inv->invoicer_ceo_name,
|
||||||
|
'invoiceeCorpNum' => $inv->invoicee_corp_num,
|
||||||
|
'invoiceeCorpName' => $inv->invoicee_corp_name,
|
||||||
|
'invoiceeCEOName' => $inv->invoicee_ceo_name,
|
||||||
|
'supplyAmount' => $inv->supply_amount,
|
||||||
|
'supplyAmountFormatted' => $inv->formatted_supply_amount,
|
||||||
|
'taxAmount' => $inv->tax_amount,
|
||||||
|
'taxAmountFormatted' => $inv->formatted_tax_amount,
|
||||||
|
'totalAmount' => $inv->total_amount,
|
||||||
|
'totalAmountFormatted' => $inv->formatted_total_amount,
|
||||||
|
'taxType' => $inv->tax_type,
|
||||||
|
'taxTypeName' => $inv->tax_type_name,
|
||||||
|
'purposeType' => $inv->purpose_type,
|
||||||
|
'purposeTypeName' => $inv->purpose_type_name,
|
||||||
|
'issueTypeName' => $inv->issue_type_name,
|
||||||
|
'itemName' => $inv->item_name,
|
||||||
|
'remark' => $inv->remark,
|
||||||
|
'memo' => $inv->memo,
|
||||||
|
'category' => $inv->category,
|
||||||
|
'isChecked' => $inv->is_checked,
|
||||||
|
'syncedAt' => $inv->synced_at?->format('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
// 요약 계산
|
||||||
|
$summary = [
|
||||||
|
'totalAmount' => $invoices->sum('supply_amount'),
|
||||||
|
'totalTax' => $invoices->sum('tax_amount'),
|
||||||
|
'totalSum' => $invoices->sum('total_amount'),
|
||||||
|
'count' => $invoices->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'invoices' => $formattedInvoices,
|
||||||
|
'summary' => $summary,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마지막 동기화 시간 조회
|
||||||
|
*/
|
||||||
|
public function getLastSyncTime(int $tenantId, string $invoiceType): ?string
|
||||||
|
{
|
||||||
|
$lastSync = HometaxInvoice::where('tenant_id', $tenantId)
|
||||||
|
->where('invoice_type', $invoiceType)
|
||||||
|
->orderByDesc('synced_at')
|
||||||
|
->value('synced_at');
|
||||||
|
|
||||||
|
return $lastSync?->format('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메모 업데이트
|
||||||
|
*/
|
||||||
|
public function updateMemo(int $id, int $tenantId, ?string $memo): bool
|
||||||
|
{
|
||||||
|
return HometaxInvoice::where('id', $id)
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->update(['memo' => $memo]) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 확인 여부 토글
|
||||||
|
*/
|
||||||
|
public function toggleChecked(int $id, int $tenantId): bool
|
||||||
|
{
|
||||||
|
$invoice = HometaxInvoice::where('id', $id)
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$invoice) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice->is_checked = !$invoice->is_checked;
|
||||||
|
return $invoice->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분류 태그 업데이트
|
||||||
|
*/
|
||||||
|
public function updateCategory(int $id, int $tenantId, ?string $category): bool
|
||||||
|
{
|
||||||
|
return HometaxInvoice::where('id', $id)
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->update(['category' => $category]) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,11 @@
|
|||||||
const API = {
|
const API = {
|
||||||
sales: '{{ route("barobill.hometax.sales") }}',
|
sales: '{{ route("barobill.hometax.sales") }}',
|
||||||
purchases: '{{ route("barobill.hometax.purchases") }}',
|
purchases: '{{ route("barobill.hometax.purchases") }}',
|
||||||
|
localSales: '{{ route("barobill.hometax.local-sales") }}',
|
||||||
|
localPurchases: '{{ route("barobill.hometax.local-purchases") }}',
|
||||||
|
sync: '{{ route("barobill.hometax.sync") }}',
|
||||||
|
updateMemo: '{{ route("barobill.hometax.update-memo") }}',
|
||||||
|
toggleChecked: '{{ route("barobill.hometax.toggle-checked") }}',
|
||||||
requestCollect: '{{ route("barobill.hometax.request-collect") }}',
|
requestCollect: '{{ route("barobill.hometax.request-collect") }}',
|
||||||
collectStatus: '{{ route("barobill.hometax.collect-status") }}',
|
collectStatus: '{{ route("barobill.hometax.collect-status") }}',
|
||||||
export: '{{ route("barobill.hometax.export") }}',
|
export: '{{ route("barobill.hometax.export") }}',
|
||||||
@@ -330,6 +335,9 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded
|
|||||||
const [dateTo, setDateTo] = useState(currentMonth.to);
|
const [dateTo, setDateTo] = useState(currentMonth.to);
|
||||||
const [dateType, setDateType] = useState('write'); // 'write': 작성일자, 'issue': 발급일자
|
const [dateType, setDateType] = useState('write'); // 'write': 작성일자, 'issue': 발급일자
|
||||||
const [searchCorpName, setSearchCorpName] = useState(''); // 거래처 검색
|
const [searchCorpName, setSearchCorpName] = useState(''); // 거래처 검색
|
||||||
|
const [dataSource, setDataSource] = useState('local'); // 'local': 로컬 DB, 'api': 바로빌 API
|
||||||
|
const [syncing, setSyncing] = useState(false); // 동기화 중 여부
|
||||||
|
const [lastSyncAt, setLastSyncAt] = useState({ sales: null, purchase: null }); // 마지막 동기화 시간
|
||||||
|
|
||||||
// 진단 관련 상태
|
// 진단 관련 상태
|
||||||
const [showDiagnoseModal, setShowDiagnoseModal] = useState(false);
|
const [showDiagnoseModal, setShowDiagnoseModal] = useState(false);
|
||||||
@@ -377,11 +385,15 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded
|
|||||||
startDate: dateFrom.replace(/-/g, ''),
|
startDate: dateFrom.replace(/-/g, ''),
|
||||||
endDate: dateTo.replace(/-/g, ''),
|
endDate: dateTo.replace(/-/g, ''),
|
||||||
dateType: dateTypeCode,
|
dateType: dateTypeCode,
|
||||||
limit: 100
|
searchCorp: searchCorpName,
|
||||||
|
limit: 500
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 데이터소스에 따라 API 선택
|
||||||
|
const apiUrl = dataSource === 'local' ? API.localSales : API.sales;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API.sales}?${params}`);
|
const res = await fetch(`${apiUrl}?${params}`);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
|
|
||||||
if (json.success) {
|
if (json.success) {
|
||||||
@@ -391,6 +403,10 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded
|
|||||||
pagination: json.data?.pagination || {},
|
pagination: json.data?.pagination || {},
|
||||||
loaded: true
|
loaded: true
|
||||||
});
|
});
|
||||||
|
// 마지막 동기화 시간 업데이트
|
||||||
|
if (json.lastSyncAt) {
|
||||||
|
setLastSyncAt(prev => ({ ...prev, sales: json.lastSyncAt }));
|
||||||
|
}
|
||||||
// 마지막 수집 시간 갱신
|
// 마지막 수집 시간 갱신
|
||||||
loadCollectStatus();
|
loadCollectStatus();
|
||||||
} else {
|
} else {
|
||||||
@@ -414,11 +430,15 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded
|
|||||||
startDate: dateFrom.replace(/-/g, ''),
|
startDate: dateFrom.replace(/-/g, ''),
|
||||||
endDate: dateTo.replace(/-/g, ''),
|
endDate: dateTo.replace(/-/g, ''),
|
||||||
dateType: dateTypeCode,
|
dateType: dateTypeCode,
|
||||||
limit: 100
|
searchCorp: searchCorpName,
|
||||||
|
limit: 500
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 데이터소스에 따라 API 선택
|
||||||
|
const apiUrl = dataSource === 'local' ? API.localPurchases : API.purchases;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API.purchases}?${params}`);
|
const res = await fetch(`${apiUrl}?${params}`);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
|
|
||||||
if (json.success) {
|
if (json.success) {
|
||||||
@@ -428,6 +448,10 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded
|
|||||||
pagination: json.data?.pagination || {},
|
pagination: json.data?.pagination || {},
|
||||||
loaded: true
|
loaded: true
|
||||||
});
|
});
|
||||||
|
// 마지막 동기화 시간 업데이트
|
||||||
|
if (json.lastSyncAt) {
|
||||||
|
setLastSyncAt(prev => ({ ...prev, purchase: json.lastSyncAt }));
|
||||||
|
}
|
||||||
// 마지막 수집 시간 갱신
|
// 마지막 수집 시간 갱신
|
||||||
loadCollectStatus();
|
loadCollectStatus();
|
||||||
} else {
|
} else {
|
||||||
@@ -449,6 +473,47 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 바로빌 API에서 로컬 DB로 동기화
|
||||||
|
const handleSync = async () => {
|
||||||
|
if (!confirm('바로빌에서 데이터를 가져와 로컬 DB에 저장합니다.\n계속하시겠습니까?')) return;
|
||||||
|
|
||||||
|
setSyncing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const dateTypeCode = dateType === 'write' ? 1 : 2;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(API.sync, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': CSRF_TOKEN
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'all',
|
||||||
|
startDate: dateFrom.replace(/-/g, ''),
|
||||||
|
endDate: dateTo.replace(/-/g, ''),
|
||||||
|
dateType: dateTypeCode
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
notify(data.message, 'success');
|
||||||
|
// 동기화 후 데이터 다시 로드
|
||||||
|
setSalesData(prev => ({ ...prev, loaded: false }));
|
||||||
|
setPurchaseData(prev => ({ ...prev, loaded: false }));
|
||||||
|
loadCurrentTabData();
|
||||||
|
} else {
|
||||||
|
notify(data.error || '동기화 실패', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notify('동기화 오류: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadCollectStatus = async () => {
|
const loadCollectStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API.collectStatus);
|
const res = await fetch(API.collectStatus);
|
||||||
@@ -716,7 +781,7 @@ className="ml-auto px-5 py-2 text-sm bg-[#0d6efd] text-white rounded hover:bg-[#
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* 거래처 행 */}
|
{/* 거래처 행 */}
|
||||||
<div className="flex flex-wrap items-center gap-3 px-5 py-4">
|
<div className="flex flex-wrap items-center gap-3 px-5 py-4 border-b border-[#dee2e6]">
|
||||||
<label className="text-sm font-medium text-[#495057] w-20 shrink-0">거래처</label>
|
<label className="text-sm font-medium text-[#495057] w-20 shrink-0">거래처</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -727,6 +792,55 @@ className="flex-1 max-w-md px-3 py-2 text-sm border border-[#ced4da] rounded bg-
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs text-[#6c757d]">(사업자번호 또는 사업자명)</span>
|
<span className="text-xs text-[#6c757d]">(사업자번호 또는 사업자명)</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 데이터소스 및 동기화 행 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 px-5 py-4">
|
||||||
|
<label className="text-sm font-medium text-[#495057] w-20 shrink-0">데이터</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="dataSource"
|
||||||
|
value="local"
|
||||||
|
checked={dataSource === 'local'}
|
||||||
|
onChange={(e) => setDataSource(e.target.value)}
|
||||||
|
className="w-4 h-4 text-[#0d6efd]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[#495057]">로컬 DB</span>
|
||||||
|
<span className="text-xs text-[#198754]">(빠름)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="dataSource"
|
||||||
|
value="api"
|
||||||
|
checked={dataSource === 'api'}
|
||||||
|
onChange={(e) => setDataSource(e.target.value)}
|
||||||
|
className="w-4 h-4 text-[#0d6efd]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[#495057]">바로빌 API</span>
|
||||||
|
<span className="text-xs text-[#6c757d]">(실시간)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSync}
|
||||||
|
disabled={syncing}
|
||||||
|
className="ml-auto px-4 py-2 text-sm bg-[#198754] text-white rounded hover:bg-[#157347] transition-colors font-medium disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{syncing ? (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="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>
|
||||||
|
{lastSyncAt[activeTab] && (
|
||||||
|
<span className="text-xs text-[#6c757d]">
|
||||||
|
마지막 동기화: {lastSyncAt[activeTab]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{/* 현재 조회 결과 */}
|
{/* 현재 조회 결과 */}
|
||||||
<div className="px-5 py-3 bg-[#f8f9fa] border-t border-[#dee2e6] flex items-center justify-between text-sm">
|
<div className="px-5 py-3 bg-[#f8f9fa] border-t border-[#dee2e6] flex items-center justify-between text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -474,6 +474,12 @@
|
|||||||
Route::get('/scrap-url', [\App\Http\Controllers\Barobill\HometaxController::class, 'getScrapRequestUrl'])->name('scrap-url');
|
Route::get('/scrap-url', [\App\Http\Controllers\Barobill\HometaxController::class, 'getScrapRequestUrl'])->name('scrap-url');
|
||||||
Route::post('/refresh-scrap', [\App\Http\Controllers\Barobill\HometaxController::class, 'refreshScrap'])->name('refresh-scrap');
|
Route::post('/refresh-scrap', [\App\Http\Controllers\Barobill\HometaxController::class, 'refreshScrap'])->name('refresh-scrap');
|
||||||
Route::get('/diagnose', [\App\Http\Controllers\Barobill\HometaxController::class, 'diagnose'])->name('diagnose');
|
Route::get('/diagnose', [\App\Http\Controllers\Barobill\HometaxController::class, 'diagnose'])->name('diagnose');
|
||||||
|
// 로컬 DB 조회 및 동기화
|
||||||
|
Route::get('/local-sales', [\App\Http\Controllers\Barobill\HometaxController::class, 'localSales'])->name('local-sales');
|
||||||
|
Route::get('/local-purchases', [\App\Http\Controllers\Barobill\HometaxController::class, 'localPurchases'])->name('local-purchases');
|
||||||
|
Route::post('/sync', [\App\Http\Controllers\Barobill\HometaxController::class, 'sync'])->name('sync');
|
||||||
|
Route::post('/update-memo', [\App\Http\Controllers\Barobill\HometaxController::class, 'updateMemo'])->name('update-memo');
|
||||||
|
Route::post('/toggle-checked', [\App\Http\Controllers\Barobill\HometaxController::class, 'toggleChecked'])->name('toggle-checked');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user