feat(API): 부실채권, 재고, 입고 기능 개선

- BadDebt 컨트롤러/서비스 기능 확장
- StockService 재고 조회 로직 개선
- ProcessReceivingRequest 검증 규칙 수정
- Item, Order, CommonCode, Shipment 모델 업데이트
- TodayIssueObserverService 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-23 21:32:23 +09:00
parent 5104a6641c
commit 09db0da43b
10 changed files with 282 additions and 73 deletions

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서 # 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-01-23 10:19:57 > **자동 생성**: 2026-01-23 15:57:29
> **소스**: Eloquent 모델 관계 분석 > **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황 ## 📊 모델별 관계 현황

View File

@@ -18,14 +18,15 @@ public function __construct(
) {} ) {}
/** /**
* 악성채권 목록 * 악성채권 목록 (거래처 기준)
*
* 거래처별 활성 악성채권 집계 목록을 반환
*/ */
public function index(Request $request) public function index(Request $request)
{ {
$params = $request->only([ $params = $request->only([
'client_id', 'client_id',
'status', 'status',
'is_active',
'search', 'search',
'sort_by', 'sort_by',
'sort_dir', 'sort_dir',
@@ -33,9 +34,54 @@ public function index(Request $request)
'page', 'page',
]); ]);
$badDebts = $this->service->index($params); $clients = $this->service->index($params);
return ApiResponse::success($badDebts, __('message.fetched')); // 거래처 기준 응답으로 변환
$transformedData = $clients->through(function ($client) {
// 가장 최근 활성 악성채권 정보
$latestBadDebt = $client->activeBadDebts->first();
return [
'id' => $client->id,
'client_id' => $client->id,
'client_code' => $client->client_code,
'client_name' => $client->name,
'business_no' => $client->business_no,
'contact_person' => $client->contact_person,
'phone' => $client->phone,
'mobile' => $client->mobile,
'email' => $client->email,
'address' => $client->address,
'client_type' => $client->client_type,
// 집계 데이터
'total_debt_amount' => (float) ($client->total_debt_amount ?? 0),
'max_overdue_days' => (int) ($client->max_overdue_days ?? 0),
'bad_debt_count' => (int) ($client->active_bad_debt_count ?? 0),
// 대표 상태 (가장 최근 악성채권의 상태)
'status' => $latestBadDebt?->status ?? 'collecting',
'is_active' => true, // 리스트에 나온 건 모두 활성
// 담당자 (가장 최근 악성채권의 담당자)
'assigned_user' => $latestBadDebt?->assignedUser ? [
'id' => $latestBadDebt->assignedUser->id,
'name' => $latestBadDebt->assignedUser->name,
] : null,
// 개별 악성채권 목록
'bad_debts' => $client->activeBadDebts->map(fn ($bd) => [
'id' => $bd->id,
'debt_amount' => (float) $bd->debt_amount,
'status' => $bd->status,
'overdue_days' => $bd->overdue_days,
'is_active' => $bd->is_active,
'occurred_at' => $bd->occurred_at?->toDateString(),
'assigned_user' => $bd->assignedUser ? [
'id' => $bd->assignedUser->id,
'name' => $bd->assignedUser->name,
] : null,
])->toArray(),
];
});
return ApiResponse::success($transformedData, __('message.fetched'));
} }
/** /**

View File

@@ -18,7 +18,7 @@ public function rules(): array
'receiving_date' => ['nullable', 'date'], 'receiving_date' => ['nullable', 'date'],
'lot_no' => ['nullable', 'string', 'max:50'], 'lot_no' => ['nullable', 'string', 'max:50'],
'supplier_lot' => ['nullable', 'string', 'max:50'], 'supplier_lot' => ['nullable', 'string', 'max:50'],
'receiving_location' => ['required', 'string', 'max:100'], 'receiving_location' => ['nullable', 'string', 'max:100'],
'receiving_manager' => ['nullable', 'string', 'max:50'], 'receiving_manager' => ['nullable', 'string', 'max:50'],
'remark' => ['nullable', 'string', 'max:1000'], 'remark' => ['nullable', 'string', 'max:1000'],
]; ];
@@ -30,7 +30,6 @@ public function messages(): array
'receiving_qty.required' => __('validation.required', ['attribute' => '입고수량']), 'receiving_qty.required' => __('validation.required', ['attribute' => '입고수량']),
'receiving_qty.numeric' => __('validation.numeric', ['attribute' => '입고수량']), 'receiving_qty.numeric' => __('validation.numeric', ['attribute' => '입고수량']),
'receiving_qty.min' => __('validation.min.numeric', ['attribute' => '입고수량', 'min' => 0]), 'receiving_qty.min' => __('validation.min.numeric', ['attribute' => '입고수량', 'min' => 0]),
'receiving_location.required' => __('validation.required', ['attribute' => '입고위치']),
]; ];
} }
} }

View File

@@ -85,6 +85,14 @@ public function details()
return $this->hasOne(ItemDetail::class); return $this->hasOne(ItemDetail::class);
} }
/**
* 재고 정보 (1:1)
*/
public function stock()
{
return $this->hasOne(\App\Models\Tenants\Stock::class);
}
/** /**
* 카테고리 * 카테고리
*/ */
@@ -130,26 +138,29 @@ public function tags()
/** /**
* 특정 타입 필터 * 특정 타입 필터
* join 시 ambiguous 에러 방지를 위해 테이블 prefix 사용
*/ */
public function scopeType($query, string $type) public function scopeType($query, string $type)
{ {
return $query->where('item_type', strtoupper($type)); return $query->where('items.item_type', strtoupper($type));
} }
/** /**
* Products 타입만 (FG, PT) * Products 타입만 (FG, PT)
* join 시 ambiguous 에러 방지를 위해 테이블 prefix 사용
*/ */
public function scopeProducts($query) public function scopeProducts($query)
{ {
return $query->whereIn('item_type', self::PRODUCT_TYPES); return $query->whereIn('items.item_type', self::PRODUCT_TYPES);
} }
/** /**
* Materials 타입만 (SM, RM, CS) * Materials 타입만 (SM, RM, CS)
* join 시 ambiguous 에러 방지를 위해 테이블 prefix 사용
*/ */
public function scopeMaterials($query) public function scopeMaterials($query)
{ {
return $query->whereIn('item_type', self::MATERIAL_TYPES); return $query->whereIn('items.item_type', self::MATERIAL_TYPES);
} }
/** /**

View File

@@ -4,6 +4,7 @@
use App\Models\Items\Item; use App\Models\Items\Item;
use App\Models\Production\WorkOrder; use App\Models\Production\WorkOrder;
use App\Models\Products\CommonCode;
use App\Models\Quote\Quote; use App\Models\Quote\Quote;
use App\Models\Tenants\Sale; use App\Models\Tenants\Sale;
use App\Models\Tenants\Shipment; use App\Models\Tenants\Shipment;
@@ -144,6 +145,13 @@ class Order extends Model
'deleted_at' => 'datetime', 'deleted_at' => 'datetime',
]; ];
/**
* JSON 응답에 자동 포함할 accessor
*/
protected $appends = [
'delivery_method_label',
];
/** /**
* 수주 상세 품목 * 수주 상세 품목
*/ */
@@ -240,6 +248,14 @@ public function getSalesRecognitionLabelAttribute(): string
return self::SALES_RECOGNITION_TYPES[$this->sales_recognition] ?? '출하완료 시'; return self::SALES_RECOGNITION_TYPES[$this->sales_recognition] ?? '출하완료 시';
} }
/**
* 배송방식 라벨 (common_codes 테이블에서 조회)
*/
public function getDeliveryMethodLabelAttribute(): string
{
return CommonCode::getLabel('delivery_method', $this->delivery_method_code);
}
/** /**
* 수주확정 시 매출 생성 여부 * 수주확정 시 매출 생성 여부
*/ */

View File

@@ -44,4 +44,42 @@ public function children()
{ {
return $this->hasMany(self::class, 'parent_id'); return $this->hasMany(self::class, 'parent_id');
} }
/**
* 코드 그룹과 코드로 라벨(name) 조회
*
* @param string $codeGroup 코드 그룹 (예: 'delivery_method')
* @param string|null $code 코드값 (예: 'direct')
* @return string 라벨 (없으면 코드값 반환)
*/
public static function getLabel(string $codeGroup, ?string $code): string
{
if (empty($code)) {
return '';
}
$commonCode = static::withoutGlobalScopes()
->where('code_group', $codeGroup)
->where('code', $code)
->where('is_active', true)
->first();
return $commonCode?->name ?? $code;
}
/**
* 코드 그룹의 전체 코드 목록 조회 (code => name 배열)
*
* @param string $codeGroup 코드 그룹
* @return array<string, string> [code => name] 형태의 배열
*/
public static function getCodeMap(string $codeGroup): array
{
return static::withoutGlobalScopes()
->where('code_group', $codeGroup)
->where('is_active', true)
->orderBy('sort_order')
->pluck('name', 'code')
->toArray();
}
} }

View File

@@ -4,6 +4,7 @@
use App\Models\Orders\Order; use App\Models\Orders\Order;
use App\Models\Production\WorkOrder; use App\Models\Production\WorkOrder;
use App\Models\Products\CommonCode;
use App\Traits\BelongsToTenant; use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -77,6 +78,7 @@ class Shipment extends Model
*/ */
protected $appends = [ protected $appends = [
'order_info', 'order_info',
'delivery_method_label',
]; ];
/** /**
@@ -172,11 +174,11 @@ public function getPriorityLabelAttribute(): string
} }
/** /**
* 배송방식 라벨 * 배송방식 라벨 (common_codes 테이블에서 조회)
*/ */
public function getDeliveryMethodLabelAttribute(): string public function getDeliveryMethodLabelAttribute(): string
{ {
return self::DELIVERY_METHODS[$this->delivery_method] ?? $this->delivery_method; return CommonCode::getLabel('delivery_method', $this->delivery_method);
} }
/** /**

View File

@@ -5,50 +5,69 @@
use App\Models\BadDebts\BadDebt; use App\Models\BadDebts\BadDebt;
use App\Models\BadDebts\BadDebtDocument; use App\Models\BadDebts\BadDebtDocument;
use App\Models\BadDebts\BadDebtMemo; use App\Models\BadDebts\BadDebtMemo;
use App\Models\Orders\Client;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class BadDebtService extends Service class BadDebtService extends Service
{ {
/** /**
* 악성채권 목록 조회 * 악성채권 목록 조회 (거래처 기준)
*
* 거래처별로 is_active=true인 악성채권을 집계하여 조회
*/ */
public function index(array $params): LengthAwarePaginator public function index(array $params): LengthAwarePaginator
{ {
$tenantId = $this->tenantId(); $tenantId = $this->tenantId();
$query = BadDebt::query() $query = Client::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->with(['client:id,name,client_code', 'assignedUser:id,name']); // is_active=true인 악성채권이 있는 거래처만
->whereHas('badDebts', function ($q) {
$q->where('is_active', true);
})
// 활성 악성채권 eager loading (추심중/법적조치 + is_active=true)
->with(['activeBadDebts' => function ($q) {
$q->with('assignedUser:id,name')
->orderBy('created_at', 'desc');
}])
// 집계: 총 미수금액 (is_active=true인 건만)
->withSum(['badDebts as total_debt_amount' => function ($q) {
$q->where('is_active', true);
}], 'debt_amount')
// 집계: 최대 연체일수 (is_active=true인 건만)
->withMax(['badDebts as max_overdue_days' => function ($q) {
$q->where('is_active', true);
}], 'overdue_days')
// 집계: 악성채권 건수 (is_active=true인 건만)
->withCount(['badDebts as active_bad_debt_count' => function ($q) {
$q->where('is_active', true);
}]);
// 거래처 필터 // 거래처 필터
if (! empty($params['client_id'])) { if (! empty($params['client_id'])) {
$query->where('client_id', $params['client_id']); $query->where('id', $params['client_id']);
} }
// 상태 필터 // 상태 필터 (해당 상태의 악성채권이 있는 거래처만)
if (! empty($params['status'])) { if (! empty($params['status'])) {
$query->where('status', $params['status']); $query->whereHas('badDebts', function ($q) use ($params) {
} $q->where('is_active', true)
->where('status', $params['status']);
// 활성화 필터 });
if (isset($params['is_active'])) {
$query->where('is_active', $params['is_active']);
} }
// 검색어 필터 // 검색어 필터
if (! empty($params['search'])) { if (! empty($params['search'])) {
$search = $params['search']; $search = $params['search'];
$query->where(function ($q) use ($search) { $query->where(function ($q) use ($search) {
$q->whereHas('client', function ($q) use ($search) { $q->where('name', 'like', "%{$search}%")
$q->where('name', 'like', "%{$search}%") ->orWhere('client_code', 'like', "%{$search}%");
->orWhere('client_code', 'like', "%{$search}%");
});
}); });
} }
// 정렬 // 정렬
$sortBy = $params['sort_by'] ?? 'created_at'; $sortBy = $params['sort_by'] ?? 'total_debt_amount';
$sortDir = $params['sort_dir'] ?? 'desc'; $sortDir = $params['sort_dir'] ?? 'desc';
$query->orderBy($sortBy, $sortDir); $query->orderBy($sortBy, $sortDir);
@@ -59,14 +78,16 @@ public function index(array $params): LengthAwarePaginator
} }
/** /**
* 악성채권 요약 통계 * 악성채권 요약 통계 (is_active=true인 건만)
*/ */
public function summary(array $params = []): array public function summary(array $params = []): array
{ {
$tenantId = $this->tenantId(); $tenantId = $this->tenantId();
// is_active=true인 악성채권만 통계
$query = BadDebt::query() $query = BadDebt::query()
->where('tenant_id', $tenantId); ->where('tenant_id', $tenantId)
->where('is_active', true);
// 거래처 필터 // 거래처 필터
if (! empty($params['client_id'])) { if (! empty($params['client_id'])) {
@@ -82,12 +103,20 @@ public function summary(array $params = []): array
$recoveredAmount = (clone $query)->recovered()->sum('debt_amount'); $recoveredAmount = (clone $query)->recovered()->sum('debt_amount');
$badDebtAmount = (clone $query)->badDebt()->sum('debt_amount'); $badDebtAmount = (clone $query)->badDebt()->sum('debt_amount');
// 거래처 수 (is_active=true인 악성채권이 있는)
$clientCount = BadDebt::query()
->where('tenant_id', $tenantId)
->where('is_active', true)
->distinct('client_id')
->count('client_id');
return [ return [
'total_amount' => (float) $totalAmount, 'total_amount' => (float) $totalAmount,
'collecting_amount' => (float) $collectingAmount, 'collecting_amount' => (float) $collectingAmount,
'legal_action_amount' => (float) $legalActionAmount, 'legal_action_amount' => (float) $legalActionAmount,
'recovered_amount' => (float) $recoveredAmount, 'recovered_amount' => (float) $recoveredAmount,
'bad_debt_amount' => (float) $badDebtAmount, 'bad_debt_amount' => (float) $badDebtAmount,
'client_count' => $clientCount,
]; ];
} }

View File

@@ -2,50 +2,77 @@
namespace App\Services; namespace App\Services;
use App\Models\Items\Item;
use App\Models\Tenants\Stock; use App\Models\Tenants\Stock;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class StockService extends Service class StockService extends Service
{ {
/** /**
* 재고 목록 조회 * Item 타입 → 재고관리 라벨 매핑
*/
public const ITEM_TYPE_LABELS = [
'RM' => '원자재',
'SM' => '부자재',
'CS' => '소모품',
];
/**
* 재고 목록 조회 (Item 메인 + Stock LEFT JOIN)
*/ */
public function index(array $params): LengthAwarePaginator public function index(array $params): LengthAwarePaginator
{ {
$tenantId = $this->tenantId(); $tenantId = $this->tenantId();
$query = Stock::query() // Item 테이블이 메인 (materials 타입만: SM, RM, CS)
->where('tenant_id', $tenantId) $query = Item::query()
->with('item'); ->where('items.tenant_id', $tenantId)
->materials() // SM, RM, CS만
->with('stock');
// 검색어 필터 // 검색어 필터 (Item 기준)
if (! empty($params['search'])) { if (! empty($params['search'])) {
$search = $params['search']; $search = $params['search'];
$query->where(function ($q) use ($search) { $query->where(function ($q) use ($search) {
$q->where('item_code', 'like', "%{$search}%") $q->where('items.code', 'like', "%{$search}%")
->orWhere('item_name', 'like', "%{$search}%"); ->orWhere('items.name', 'like', "%{$search}%");
}); });
} }
// 품목유형 필터 // 품목유형 필터 (Item.item_type 기준: RM, SM, CS)
if (! empty($params['item_type'])) { if (! empty($params['item_type'])) {
$query->where('item_type', $params['item_type']); $query->where('items.item_type', strtoupper($params['item_type']));
} }
// 재고 상태 필터 // 재고 상태 필터 (Stock.status)
if (! empty($params['status'])) { if (! empty($params['status'])) {
$query->where('status', $params['status']); $query->whereHas('stock', function ($q) use ($params) {
$q->where('status', $params['status']);
});
} }
// 위치 필터 // 위치 필터 (Stock.location)
if (! empty($params['location'])) { if (! empty($params['location'])) {
$query->where('location', 'like', "%{$params['location']}%"); $query->whereHas('stock', function ($q) use ($params) {
$q->where('location', 'like', "%{$params['location']}%");
});
} }
// 정렬 // 정렬
$sortBy = $params['sort_by'] ?? 'item_code'; $sortBy = $params['sort_by'] ?? 'code';
$sortDir = $params['sort_dir'] ?? 'asc'; $sortDir = $params['sort_dir'] ?? 'asc';
$query->orderBy($sortBy, $sortDir);
// Item 테이블 기준 정렬 필드 매핑
$sortMapping = [
'item_code' => 'items.code',
'item_name' => 'items.name',
'item_type' => 'items.item_type',
'code' => 'items.code',
'name' => 'items.name',
];
$sortColumn = $sortMapping[$sortBy] ?? 'items.code';
$query->orderBy($sortColumn, $sortDir);
// 페이지네이션 // 페이지네이션
$perPage = $params['per_page'] ?? 20; $perPage = $params['per_page'] ?? 20;
@@ -54,24 +81,43 @@ public function index(array $params): LengthAwarePaginator
} }
/** /**
* 재고 통계 조회 * 재고 통계 조회 (Item 기준)
*/ */
public function stats(): array public function stats(): array
{ {
$tenantId = $this->tenantId(); $tenantId = $this->tenantId();
$totalItems = Stock::where('tenant_id', $tenantId)->count(); // 전체 자재 품목 수 (Item 기준)
$totalItems = Item::where('tenant_id', $tenantId)
$normalCount = Stock::where('tenant_id', $tenantId) ->materials()
->where('status', 'normal')
->count(); ->count();
$lowCount = Stock::where('tenant_id', $tenantId) // 재고 상태별 카운트 (Stock이 있는 Item 기준)
->where('status', 'low') $normalCount = Item::where('items.tenant_id', $tenantId)
->materials()
->whereHas('stock', function ($q) {
$q->where('status', 'normal');
})
->count(); ->count();
$outCount = Stock::where('tenant_id', $tenantId) $lowCount = Item::where('items.tenant_id', $tenantId)
->where('status', 'out') ->materials()
->whereHas('stock', function ($q) {
$q->where('status', 'low');
})
->count();
$outCount = Item::where('items.tenant_id', $tenantId)
->materials()
->whereHas('stock', function ($q) {
$q->where('status', 'out');
})
->count();
// 재고 정보가 없는 Item 수
$noStockCount = Item::where('items.tenant_id', $tenantId)
->materials()
->whereDoesntHave('stock')
->count(); ->count();
return [ return [
@@ -79,57 +125,73 @@ public function stats(): array
'normal_count' => $normalCount, 'normal_count' => $normalCount,
'low_count' => $lowCount, 'low_count' => $lowCount,
'out_count' => $outCount, 'out_count' => $outCount,
'no_stock_count' => $noStockCount,
]; ];
} }
/** /**
* 재고 상세 조회 (LOT 포함) * 재고 상세 조회 (Item 기준, LOT 포함)
*/ */
public function show(int $id): Stock public function show(int $id): Item
{ {
$tenantId = $this->tenantId(); $tenantId = $this->tenantId();
return Stock::query() return Item::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->with(['item', 'lots' => function ($query) { ->materials()
->with(['stock.lots' => function ($query) {
$query->orderBy('fifo_order'); $query->orderBy('fifo_order');
}]) }])
->findOrFail($id); ->findOrFail($id);
} }
/** /**
* 품목코드로 재고 조회 * 품목코드로 재고 조회 (Item 기준)
*/ */
public function findByItemCode(string $itemCode): ?Stock public function findByItemCode(string $itemCode): ?Item
{ {
$tenantId = $this->tenantId(); $tenantId = $this->tenantId();
return Stock::query() return Item::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->where('item_code', $itemCode) ->materials()
->where('code', $itemCode)
->with('stock')
->first(); ->first();
} }
/** /**
* 품목유형별 통계 * 품목유형별 통계 (Item.item_type 기준)
*/ */
public function statsByItemType(): array public function statsByItemType(): array
{ {
$tenantId = $this->tenantId(); $tenantId = $this->tenantId();
$stats = Stock::where('tenant_id', $tenantId) // Item 기준으로 통계 (materials 타입만)
->selectRaw('item_type, COUNT(*) as count, SUM(stock_qty) as total_qty') $stats = Item::where('tenant_id', $tenantId)
->materials()
->selectRaw('item_type, COUNT(*) as count')
->groupBy('item_type') ->groupBy('item_type')
->get() ->get()
->keyBy('item_type'); ->keyBy('item_type');
// 재고 수량 합계 (Stock이 있는 경우)
$stockQtys = Item::where('items.tenant_id', $tenantId)
->materials()
->join('stocks', 'items.id', '=', 'stocks.item_id')
->selectRaw('items.item_type, SUM(stocks.stock_qty) as total_qty')
->groupBy('items.item_type')
->get()
->keyBy('item_type');
$result = []; $result = [];
foreach (Stock::ITEM_TYPES as $key => $label) { foreach (self::ITEM_TYPE_LABELS as $key => $label) {
$data = $stats->get($key); $itemData = $stats->get($key);
$stockData = $stockQtys->get($key);
$result[$key] = [ $result[$key] = [
'label' => $label, 'label' => $label,
'count' => $data?->count ?? 0, 'count' => $itemData?->count ?? 0,
'total_qty' => $data?->total_qty ?? 0, 'total_qty' => $stockData?->total_qty ?? 0,
]; ];
} }

View File

@@ -87,14 +87,19 @@ public function handleOrderDeleted(Order $order): void
} }
/** /**
* 미수금(주식 이슈) 생성/삭제 * 미수금(추심 이슈) 생성/삭제
*
* is_active가 true로 설정(토글 ON)되면 오늘의 이슈에 추가
* is_active가 false로 설정(토글 OFF)되면 오늘의 이슈에서 삭제
*/ */
public function handleBadDebtChange(BadDebt $badDebt): void public function handleBadDebtChange(BadDebt $badDebt): void
{ {
// 추심 진행 중인 건만 이슈 생성 // is_active(설정 토글)이 켜져 있고, 추심중 또는 법적조치 상태인 경우 이슈 생성
if (in_array($badDebt->status, ['in_progress', 'legal_action'])) { $isActiveStatus = in_array($badDebt->status, [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION]);
if ($badDebt->is_active && $isActiveStatus) {
$clientName = $badDebt->client?->name ?? __('message.today_issue.unknown_client'); $clientName = $badDebt->client?->name ?? __('message.today_issue.unknown_client');
$amount = number_format($badDebt->total_amount ?? 0); $amount = number_format($badDebt->debt_amount ?? 0);
$days = $badDebt->overdue_days ?? 0; $days = $badDebt->overdue_days ?? 0;
$this->createIssueWithFcm( $this->createIssueWithFcm(
@@ -112,6 +117,7 @@ public function handleBadDebtChange(BadDebt $badDebt): void
expiresAt: null // 해결될 때까지 유지 expiresAt: null // 해결될 때까지 유지
); );
} else { } else {
// is_active가 false이거나 상태가 회수완료/대손처리인 경우 이슈 삭제
TodayIssue::removeBySource($badDebt->tenant_id, TodayIssue::SOURCE_BAD_DEBT, $badDebt->id); TodayIssue::removeBySource($badDebt->tenant_id, TodayIssue::SOURCE_BAD_DEBT, $badDebt->id);
} }
} }