feat(API): 입금/출금 알림 Observer 추가 및 LoanController 수정

- DepositIssueObserver, WithdrawalIssueObserver 신규 추가
- TodayIssueObserverService에 입금/출금 핸들러 및 디버그 로그 추가
- TodayIssue 모델에 입금/출금 상수 추가
- AppServiceProvider에 Observer 등록
- ApprovalService에 기존 결재선 사용 시 수동 알림 트리거 추가
- LoanController ApiResponse::handle() → ApiResponse::success() 수정

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-23 10:05:50 +09:00
parent fabf302e1f
commit d75f6f5bd1
9 changed files with 232 additions and 22 deletions

View File

@@ -25,7 +25,7 @@ public function index(LoanIndexRequest $request): JsonResponse
{
$result = $this->loanService->index($request->validated());
return ApiResponse::handle('message.fetched', $result);
return ApiResponse::success($result, __('message.fetched'));
}
/**
@@ -36,7 +36,7 @@ public function summary(LoanIndexRequest $request): JsonResponse
$userId = $request->validated()['user_id'] ?? null;
$result = $this->loanService->summary($userId);
return ApiResponse::handle('message.fetched', $result);
return ApiResponse::success($result, __('message.fetched'));
}
/**
@@ -46,7 +46,7 @@ public function dashboard(): JsonResponse
{
$result = $this->loanService->dashboard();
return ApiResponse::handle('message.fetched', $result);
return ApiResponse::success($result, __('message.fetched'));
}
/**
@@ -56,7 +56,7 @@ public function store(LoanStoreRequest $request): JsonResponse
{
$result = $this->loanService->store($request->validated());
return ApiResponse::handle('message.created', $result, 201);
return ApiResponse::success($result, __('message.created'), [], 201);
}
/**
@@ -66,7 +66,7 @@ public function show(int $id): JsonResponse
{
$result = $this->loanService->show($id);
return ApiResponse::handle('message.fetched', $result);
return ApiResponse::success($result, __('message.fetched'));
}
/**
@@ -76,7 +76,7 @@ public function update(LoanUpdateRequest $request, int $id): JsonResponse
{
$result = $this->loanService->update($id, $request->validated());
return ApiResponse::handle('message.updated', $result);
return ApiResponse::success($result, __('message.updated'));
}
/**
@@ -86,7 +86,7 @@ public function destroy(int $id): JsonResponse
{
$this->loanService->destroy($id);
return ApiResponse::handle('message.deleted');
return ApiResponse::success(null, __('message.deleted'));
}
/**
@@ -96,7 +96,7 @@ public function settle(LoanSettleRequest $request, int $id): JsonResponse
{
$result = $this->loanService->settle($id, $request->validated());
return ApiResponse::handle('message.loan.settled', $result);
return ApiResponse::success($result, __('message.loan.settled'));
}
/**
@@ -110,7 +110,7 @@ public function calculateInterest(LoanCalculateInterestRequest $request): JsonRe
$validated['user_id'] ?? null
);
return ApiResponse::handle('message.fetched', $result);
return ApiResponse::success($result, __('message.fetched'));
}
/**
@@ -120,7 +120,7 @@ public function interestReport(int $year): JsonResponse
{
$result = $this->loanService->interestReport($year);
return ApiResponse::handle('message.fetched', $result);
return ApiResponse::success($result, __('message.fetched'));
}
/**
@@ -131,6 +131,6 @@ public function taxSimulation(LoanCalculateInterestRequest $request): JsonRespon
$validated = $request->validated();
$result = $this->loanService->taxSimulation($validated['year']);
return ApiResponse::handle('message.fetched', $result);
return ApiResponse::success($result, __('message.fetched'));
}
}

View File

@@ -57,6 +57,10 @@ class TodayIssue extends Model
public const SOURCE_CLIENT = 'client';
public const SOURCE_DEPOSIT = 'deposit';
public const SOURCE_WITHDRAWAL = 'withdrawal';
// 뱃지 타입 상수
public const BADGE_ORDER_REGISTER = '수주등록';
@@ -72,6 +76,10 @@ class TodayIssue extends Model
public const BADGE_NEW_CLIENT = '신규거래처';
public const BADGE_DEPOSIT = '입금';
public const BADGE_WITHDRAWAL = '출금';
// 뱃지 → notification_type 매핑
public const BADGE_TO_NOTIFICATION_TYPE = [
self::BADGE_ORDER_REGISTER => 'sales_order',
@@ -81,13 +89,17 @@ class TodayIssue extends Model
self::BADGE_SAFETY_STOCK => 'safety_stock',
self::BADGE_EXPENSE_PENDING => 'expected_expense',
self::BADGE_TAX_REPORT => 'vat_report',
self::BADGE_DEPOSIT => 'deposit',
self::BADGE_WITHDRAWAL => 'withdrawal',
];
// 중요 알림 (푸시 알림음) - 수주등록, 신규거래처, 결재요청
// 중요 알림 (푸시 알림음) - 수주등록, 신규거래처, 결재요청, 입금, 출금
public const IMPORTANT_NOTIFICATIONS = [
'sales_order',
'new_vendor',
'approval_request',
'deposit',
'withdrawal',
];
/**

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Observers\TodayIssue;
use App\Models\Tenants\Deposit;
use App\Services\TodayIssueObserverService;
use Illuminate\Support\Facades\Log;
/**
* Deposit 모델의 TodayIssue Observer
*/
class DepositIssueObserver
{
public function __construct(
protected TodayIssueObserverService $service
) {}
public function created(Deposit $deposit): void
{
Log::info('DepositIssueObserver::created CALLED', [
'deposit_id' => $deposit->id,
'tenant_id' => $deposit->tenant_id,
]);
$this->safeExecute(fn () => $this->service->handleDepositCreated($deposit));
}
public function deleted(Deposit $deposit): void
{
$this->safeExecute(fn () => $this->service->handleDepositDeleted($deposit));
}
protected function safeExecute(callable $callback): void
{
try {
$callback();
} catch (\Throwable $e) {
Log::warning('TodayIssue DepositObserver failed', [
'error' => $e->getMessage(),
]);
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Observers\TodayIssue;
use App\Models\Tenants\Withdrawal;
use App\Services\TodayIssueObserverService;
use Illuminate\Support\Facades\Log;
/**
* Withdrawal 모델의 TodayIssue Observer
*/
class WithdrawalIssueObserver
{
public function __construct(
protected TodayIssueObserverService $service
) {}
public function created(Withdrawal $withdrawal): void
{
Log::info('WithdrawalIssueObserver::created CALLED', [
'withdrawal_id' => $withdrawal->id,
'tenant_id' => $withdrawal->tenant_id,
]);
$this->safeExecute(fn () => $this->service->handleWithdrawalCreated($withdrawal));
}
public function deleted(Withdrawal $withdrawal): void
{
$this->safeExecute(fn () => $this->service->handleWithdrawalDeleted($withdrawal));
}
protected function safeExecute(callable $callback): void
{
try {
$callback();
} catch (\Throwable $e) {
Log::warning('TodayIssue WithdrawalObserver failed', [
'error' => $e->getMessage(),
]);
}
}
}

View File

@@ -9,17 +9,21 @@
use App\Models\Orders\Client;
use App\Models\Orders\Order;
use App\Models\Tenants\ApprovalStep;
use App\Models\Tenants\Deposit;
use App\Models\Tenants\ExpectedExpense;
use App\Models\Tenants\Stock;
use App\Models\Tenants\Tenant;
use App\Models\Tenants\Withdrawal;
use App\Observers\MenuObserver;
use App\Observers\TenantObserver;
use App\Observers\TodayIssue\ApprovalStepIssueObserver;
use App\Observers\TodayIssue\BadDebtIssueObserver;
use App\Observers\TodayIssue\ClientIssueObserver;
use App\Observers\TodayIssue\DepositIssueObserver;
use App\Observers\TodayIssue\ExpectedExpenseIssueObserver;
use App\Observers\TodayIssue\OrderIssueObserver;
use App\Observers\TodayIssue\StockIssueObserver;
use App\Observers\TodayIssue\WithdrawalIssueObserver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\DB;
@@ -77,5 +81,7 @@ public function boot(): void
ExpectedExpense::observe(ExpectedExpenseIssueObserver::class);
ApprovalStep::observe(ApprovalStepIssueObserver::class);
Client::observe(ClientIssueObserver::class);
Deposit::observe(DepositIssueObserver::class);
Withdrawal::observe(WithdrawalIssueObserver::class);
}
}

View File

@@ -14,6 +14,10 @@
class ApprovalService extends Service
{
public function __construct(
protected TodayIssueObserverService $todayIssueService
) {}
// =========================================================================
// 결재 양식 관리
// =========================================================================
@@ -730,13 +734,8 @@ public function submit(int $id, array $data): Approval
throw new BadRequestHttpException(__('error.approval.not_submittable'));
}
// steps가 있으면 새로 생성, 없으면 기존 결재선 사용
if (! empty($data['steps'])) {
// 기존 결재선 삭제 후 새로 생성
$approval->steps()->delete();
$this->createApprovalSteps($approval, $data['steps']);
} else {
// 기존 결재선이 없으면 에러
// 기존 결재선 확인 (steps 없이 상신하는 경우)
if (empty($data['steps'])) {
$existingSteps = $approval->steps()
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
->count();
@@ -746,12 +745,31 @@ public function submit(int $id, array $data): Approval
}
}
// 먼저 approval을 pending으로 변경 (Observer가 올바른 상태로 트리거되도록)
$approval->status = Approval::STATUS_PENDING;
$approval->drafted_at = now();
$approval->current_step = 1;
$approval->updated_by = $userId;
$approval->save();
// steps가 있으면 새로 생성 (approval이 pending 상태일 때 생성해야 알림 발송)
if (! empty($data['steps'])) {
// 기존 결재선 삭제 후 새로 생성
$approval->steps()->delete();
$this->createApprovalSteps($approval, $data['steps']);
} else {
// 기존 결재선 사용 시, Observer가 트리거되지 않으므로 수동으로 알림 발송
$firstPendingStep = $approval->steps()
->where('status', ApprovalStep::STATUS_PENDING)
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
->orderBy('step_order')
->first();
if ($firstPendingStep) {
$this->todayIssueService->handleApprovalStepChange($firstPendingStep);
}
}
return $approval->fresh([
'form:id,name,code,category',
'drafter:id,name',

View File

@@ -134,9 +134,7 @@ public function store(array $data)
$client = Client::create($data);
// 신규 거래처 등록 푸시 발송
app(PushNotificationService::class)
->notifyNewClient($client->id, $client->name, $tenantId);
// 신규 거래처 등록 푸시는 ClientIssueObserver가 자동 처리
return $client;
}

View File

@@ -8,9 +8,11 @@
use App\Models\Orders\Order;
use App\Models\PushDeviceToken;
use App\Models\Tenants\ApprovalStep;
use App\Models\Tenants\Deposit;
use App\Models\Tenants\ExpectedExpense;
use App\Models\Tenants\Stock;
use App\Models\Tenants\TodayIssue;
use App\Models\Tenants\Withdrawal;
use App\Services\Fcm\FcmSender;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
@@ -261,6 +263,92 @@ public function handleClientDeleted(Client $client): void
TodayIssue::removeBySource($client->tenant_id, TodayIssue::SOURCE_CLIENT, $client->id);
}
/**
* 입금 등록 이슈 생성
*/
public function handleDepositCreated(Deposit $deposit): void
{
Log::info('TodayIssue: Deposit created detected', [
'deposit_id' => $deposit->id,
'tenant_id' => $deposit->tenant_id,
'amount' => $deposit->amount,
]);
$clientName = $deposit->display_client_name ?: __('message.today_issue.unknown_client');
$amount = number_format($deposit->amount ?? 0);
Log::info('TodayIssue: Creating deposit issue', [
'deposit_id' => $deposit->id,
'client' => $clientName,
'amount' => $amount,
]);
$this->createIssueWithFcm(
tenantId: $deposit->tenant_id,
sourceType: TodayIssue::SOURCE_DEPOSIT,
sourceId: $deposit->id,
badge: TodayIssue::BADGE_DEPOSIT,
content: __('message.today_issue.deposit_registered', [
'client' => $clientName,
'amount' => $amount,
]),
path: '/accounting/deposit-management',
needsApproval: false,
expiresAt: Carbon::now()->addDays(7)
);
}
/**
* 입금 삭제 시 이슈 삭제
*/
public function handleDepositDeleted(Deposit $deposit): void
{
TodayIssue::removeBySource($deposit->tenant_id, TodayIssue::SOURCE_DEPOSIT, $deposit->id);
}
/**
* 출금 등록 이슈 생성
*/
public function handleWithdrawalCreated(Withdrawal $withdrawal): void
{
Log::info('TodayIssue: Withdrawal created detected', [
'withdrawal_id' => $withdrawal->id,
'tenant_id' => $withdrawal->tenant_id,
'amount' => $withdrawal->amount,
]);
$clientName = $withdrawal->display_client_name ?: $withdrawal->merchant_name ?: __('message.today_issue.unknown_client');
$amount = number_format($withdrawal->amount ?? 0);
Log::info('TodayIssue: Creating withdrawal issue', [
'withdrawal_id' => $withdrawal->id,
'client' => $clientName,
'amount' => $amount,
]);
$this->createIssueWithFcm(
tenantId: $withdrawal->tenant_id,
sourceType: TodayIssue::SOURCE_WITHDRAWAL,
sourceId: $withdrawal->id,
badge: TodayIssue::BADGE_WITHDRAWAL,
content: __('message.today_issue.withdrawal_registered', [
'client' => $clientName,
'amount' => $amount,
]),
path: '/accounting/withdrawal-management',
needsApproval: false,
expiresAt: Carbon::now()->addDays(7)
);
}
/**
* 출금 삭제 시 이슈 삭제
*/
public function handleWithdrawalDeleted(Withdrawal $withdrawal): void
{
TodayIssue::removeBySource($withdrawal->tenant_id, TodayIssue::SOURCE_WITHDRAWAL, $withdrawal->id);
}
/**
* 세금 신고 이슈 업데이트 (스케줄러에서 호출)
* 이 메서드는 일일 스케줄러에서 호출하여 세금 신고 이슈를 업데이트합니다.

View File

@@ -513,6 +513,8 @@
'tax_vat_deadline' => ':quarter분기 부가세 신고 D-:days',
'approval_pending' => ':title 승인 요청 (:drafter)',
'new_client' => '신규 거래처 :name 등록 완료',
'deposit_registered' => ':client 입금 :amount원',
'withdrawal_registered' => ':client 출금 :amount원',
// 하위 호환성 (deprecated)
'order_success' => ':client 신규 수주 :amount원 확정',