feat: Phase 6.1 악성채권 추심관리 API 구현

- 테이블 3개: bad_debts, bad_debt_documents, bad_debt_memos
- 모델 3개: BadDebt, BadDebtDocument, BadDebtMemo
- BadDebtService: CRUD, 요약 통계, 서류/메모 관리
- API 엔드포인트 11개 (목록, 등록, 상세, 수정, 삭제, 토글, 서류/메모 CRUD)
- Swagger 문서 작성 완료
This commit is contained in:
2025-12-19 15:57:04 +09:00
parent 3020026abf
commit c0af888bed
15 changed files with 1498 additions and 1 deletions

View File

@@ -1,10 +1,31 @@
# 논리적 데이터베이스 관계 문서 # 논리적 데이터베이스 관계 문서
> **자동 생성**: 2025-12-19 10:11:32 > **자동 생성**: 2025-12-19 15:55:41
> **소스**: Eloquent 모델 관계 분석 > **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황 ## 📊 모델별 관계 현황
### bad_debts
**모델**: `App\Models\BadDebts\BadDebt`
- **client()**: belongsTo → `clients`
- **assignedUser()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
- **documents()**: hasMany → `bad_debt_documents`
- **memos()**: hasMany → `bad_debt_memos`
### bad_debt_documents
**모델**: `App\Models\BadDebts\BadDebtDocument`
- **badDebt()**: belongsTo → `bad_debts`
- **file()**: belongsTo → `files`
### bad_debt_memos
**모델**: `App\Models\BadDebts\BadDebtMemo`
- **badDebt()**: belongsTo → `bad_debts`
- **creator()**: belongsTo → `users`
### boards ### boards
**모델**: `App\Models\Boards\Board` **모델**: `App\Models\Boards\Board`
@@ -231,6 +252,10 @@ ### user_tenants
- **user()**: belongsTo → `users` - **user()**: belongsTo → `users`
- **tenant()**: belongsTo → `tenants` - **tenant()**: belongsTo → `tenants`
### notification_settings
**모델**: `App\Models\NotificationSetting`
### clients ### clients
**모델**: `App\Models\Orders\Client` **모델**: `App\Models\Orders\Client`
@@ -546,3 +571,10 @@ ### withdrawals
- **bankAccount()**: belongsTo → `bank_accounts` - **bankAccount()**: belongsTo → `bank_accounts`
### user_invitations
**모델**: `App\Models\UserInvitation`
- **tenant()**: belongsTo → `tenants`
- **role()**: belongsTo → `roles`
- **inviter()**: belongsTo → `users`

View File

@@ -0,0 +1,142 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\BadDebt\StoreBadDebtDocumentRequest;
use App\Http\Requests\V1\BadDebt\StoreBadDebtMemoRequest;
use App\Http\Requests\V1\BadDebt\StoreBadDebtRequest;
use App\Http\Requests\V1\BadDebt\UpdateBadDebtRequest;
use App\Services\BadDebtService;
use Illuminate\Http\Request;
class BadDebtController extends Controller
{
public function __construct(
private readonly BadDebtService $service
) {}
/**
* 악성채권 목록
*/
public function index(Request $request)
{
$params = $request->only([
'client_id',
'status',
'is_active',
'search',
'sort_by',
'sort_dir',
'per_page',
'page',
]);
$badDebts = $this->service->index($params);
return ApiResponse::success($badDebts, __('message.fetched'));
}
/**
* 악성채권 요약 통계
*/
public function summary(Request $request)
{
$params = $request->only(['client_id']);
$summary = $this->service->summary($params);
return ApiResponse::success($summary, __('message.fetched'));
}
/**
* 악성채권 등록
*/
public function store(StoreBadDebtRequest $request)
{
$badDebt = $this->service->store($request->validated());
return ApiResponse::success($badDebt, __('message.created'), [], 201);
}
/**
* 악성채권 상세
*/
public function show(int $id)
{
$badDebt = $this->service->show($id);
return ApiResponse::success($badDebt, __('message.fetched'));
}
/**
* 악성채권 수정
*/
public function update(int $id, UpdateBadDebtRequest $request)
{
$badDebt = $this->service->update($id, $request->validated());
return ApiResponse::success($badDebt, __('message.updated'));
}
/**
* 악성채권 삭제
*/
public function destroy(int $id)
{
$this->service->destroy($id);
return ApiResponse::success(null, __('message.deleted'));
}
/**
* 설정 토글 (is_active)
*/
public function toggle(int $id)
{
$badDebt = $this->service->toggle($id);
return ApiResponse::success($badDebt, __('message.updated'));
}
/**
* 서류 첨부
*/
public function addDocument(int $id, StoreBadDebtDocumentRequest $request)
{
$document = $this->service->addDocument($id, $request->validated());
return ApiResponse::success($document, __('message.created'), [], 201);
}
/**
* 서류 삭제
*/
public function removeDocument(int $id, int $documentId)
{
$this->service->removeDocument($id, $documentId);
return ApiResponse::success(null, __('message.deleted'));
}
/**
* 메모 추가
*/
public function addMemo(int $id, StoreBadDebtMemoRequest $request)
{
$memo = $this->service->addMemo($id, $request->validated());
return ApiResponse::success($memo, __('message.created'), [], 201);
}
/**
* 메모 삭제
*/
public function removeMemo(int $id, int $memoId)
{
$this->service->removeMemo($id, $memoId);
return ApiResponse::success(null, __('message.deleted'));
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\V1\BadDebt;
use App\Models\BadDebts\BadDebtDocument;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreBadDebtDocumentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'document_type' => ['required', 'string', Rule::in(array_keys(BadDebtDocument::DOCUMENT_TYPES))],
'file_id' => ['required', 'integer', 'exists:files,id'],
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'document_type.required' => __('validation.required', ['attribute' => '서류유형']),
'document_type.in' => __('validation.in', ['attribute' => '서류유형']),
'file_id.required' => __('validation.required', ['attribute' => '파일']),
'file_id.exists' => __('validation.exists', ['attribute' => '파일']),
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\V1\BadDebt;
use Illuminate\Foundation\Http\FormRequest;
class StoreBadDebtMemoRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'content' => ['required', 'string', 'max:5000'],
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'content.required' => __('validation.required', ['attribute' => '메모 내용']),
'content.max' => __('validation.max.string', ['attribute' => '메모 내용', 'max' => 5000]),
];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Requests\V1\BadDebt;
use App\Models\BadDebts\BadDebt;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreBadDebtRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'client_id' => ['required', 'integer', 'exists:clients,id'],
'debt_amount' => ['required', 'numeric', 'min:0'],
'status' => ['nullable', 'string', Rule::in(array_keys(BadDebt::STATUSES))],
'overdue_days' => ['nullable', 'integer', 'min:0'],
'assigned_user_id' => ['nullable', 'integer', 'exists:users,id'],
'occurred_at' => ['nullable', 'date'],
'closed_at' => ['nullable', 'date', 'after_or_equal:occurred_at'],
'is_active' => ['nullable', 'boolean'],
'options' => ['nullable', 'array'],
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'client_id.required' => __('validation.required', ['attribute' => '거래처']),
'client_id.exists' => __('validation.exists', ['attribute' => '거래처']),
'debt_amount.required' => __('validation.required', ['attribute' => '채권금액']),
'debt_amount.numeric' => __('validation.numeric', ['attribute' => '채권금액']),
'status.in' => __('validation.in', ['attribute' => '상태']),
'closed_at.after_or_equal' => __('validation.after_or_equal', ['attribute' => '종료일', 'date' => '발생일']),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests\V1\BadDebt;
use App\Models\BadDebts\BadDebt;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateBadDebtRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'client_id' => ['sometimes', 'integer', 'exists:clients,id'],
'debt_amount' => ['sometimes', 'numeric', 'min:0'],
'status' => ['sometimes', 'string', Rule::in(array_keys(BadDebt::STATUSES))],
'overdue_days' => ['sometimes', 'integer', 'min:0'],
'assigned_user_id' => ['nullable', 'integer', 'exists:users,id'],
'occurred_at' => ['nullable', 'date'],
'closed_at' => ['nullable', 'date', 'after_or_equal:occurred_at'],
'is_active' => ['sometimes', 'boolean'],
'options' => ['nullable', 'array'],
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'client_id.exists' => __('validation.exists', ['attribute' => '거래처']),
'debt_amount.numeric' => __('validation.numeric', ['attribute' => '채권금액']),
'status.in' => __('validation.in', ['attribute' => '상태']),
'closed_at.after_or_equal' => __('validation.after_or_equal', ['attribute' => '종료일', 'date' => '발생일']),
];
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Models\BadDebts;
use App\Models\Members\User;
use App\Models\Orders\Client;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class BadDebt extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'client_id',
'debt_amount',
'status',
'overdue_days',
'assigned_user_id',
'occurred_at',
'closed_at',
'is_active',
'options',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'debt_amount' => 'decimal:2',
'overdue_days' => 'integer',
'is_active' => 'boolean',
'options' => 'array',
'occurred_at' => 'date',
'closed_at' => 'date',
];
/**
* 상태 상수
*/
public const STATUS_COLLECTING = 'collecting';
public const STATUS_LEGAL_ACTION = 'legal_action';
public const STATUS_RECOVERED = 'recovered';
public const STATUS_BAD_DEBT = 'bad_debt';
/**
* 상태 목록
*/
public const STATUSES = [
self::STATUS_COLLECTING => '추심중',
self::STATUS_LEGAL_ACTION => '법적조치',
self::STATUS_RECOVERED => '회수완료',
self::STATUS_BAD_DEBT => '대손처리',
];
/**
* 거래처 관계
*/
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
/**
* 담당자 관계
*/
public function assignedUser(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_user_id');
}
/**
* 생성자 관계
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 서류 관계
*/
public function documents(): HasMany
{
return $this->hasMany(BadDebtDocument::class);
}
/**
* 메모 관계
*/
public function memos(): HasMany
{
return $this->hasMany(BadDebtMemo::class)->orderBy('created_at', 'desc');
}
/**
* 상태 라벨 속성
*/
public function getStatusLabelAttribute(): string
{
return self::STATUSES[$this->status] ?? $this->status;
}
/**
* 활성 스코프
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* 상태별 스코프
*/
public function scopeStatus($query, string $status)
{
return $query->where('status', $status);
}
/**
* 추심중 스코프
*/
public function scopeCollecting($query)
{
return $query->where('status', self::STATUS_COLLECTING);
}
/**
* 법적조치 스코프
*/
public function scopeLegalAction($query)
{
return $query->where('status', self::STATUS_LEGAL_ACTION);
}
/**
* 회수완료 스코프
*/
public function scopeRecovered($query)
{
return $query->where('status', self::STATUS_RECOVERED);
}
/**
* 대손처리 스코프
*/
public function scopeBadDebt($query)
{
return $query->where('status', self::STATUS_BAD_DEBT);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Models\BadDebts;
use App\Models\Commons\File;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BadDebtDocument extends Model
{
protected $fillable = [
'bad_debt_id',
'document_type',
'file_id',
];
/**
* 서류 유형 상수
*/
public const TYPE_BUSINESS_LICENSE = 'business_license';
public const TYPE_TAX_INVOICE = 'tax_invoice';
public const TYPE_ADDITIONAL = 'additional';
/**
* 서류 유형 목록
*/
public const DOCUMENT_TYPES = [
self::TYPE_BUSINESS_LICENSE => '사업자등록증',
self::TYPE_TAX_INVOICE => '세금계산서',
self::TYPE_ADDITIONAL => '추가서류',
];
/**
* 악성채권 관계
*/
public function badDebt(): BelongsTo
{
return $this->belongsTo(BadDebt::class);
}
/**
* 파일 관계
*/
public function file(): BelongsTo
{
return $this->belongsTo(File::class);
}
/**
* 서류 유형 라벨 속성
*/
public function getDocumentTypeLabelAttribute(): string
{
return self::DOCUMENT_TYPES[$this->document_type] ?? $this->document_type;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models\BadDebts;
use App\Models\Members\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BadDebtMemo extends Model
{
protected $fillable = [
'bad_debt_id',
'content',
'created_by',
];
/**
* 악성채권 관계
*/
public function badDebt(): BelongsTo
{
return $this->belongsTo(BadDebt::class);
}
/**
* 작성자 관계
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}

View File

@@ -0,0 +1,307 @@
<?php
namespace App\Services;
use App\Models\BadDebts\BadDebt;
use App\Models\BadDebts\BadDebtDocument;
use App\Models\BadDebts\BadDebtMemo;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class BadDebtService extends Service
{
/**
* 악성채권 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$query = BadDebt::query()
->where('tenant_id', $tenantId)
->with(['client:id,name,client_code', 'assignedUser:id,name']);
// 거래처 필터
if (! empty($params['client_id'])) {
$query->where('client_id', $params['client_id']);
}
// 상태 필터
if (! empty($params['status'])) {
$query->where('status', $params['status']);
}
// 활성화 필터
if (isset($params['is_active'])) {
$query->where('is_active', $params['is_active']);
}
// 검색어 필터
if (! empty($params['search'])) {
$search = $params['search'];
$query->where(function ($q) use ($search) {
$q->whereHas('client', function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('client_code', 'like', "%{$search}%");
});
});
}
// 정렬
$sortBy = $params['sort_by'] ?? 'created_at';
$sortDir = $params['sort_dir'] ?? 'desc';
$query->orderBy($sortBy, $sortDir);
// 페이지네이션
$perPage = $params['per_page'] ?? 20;
return $query->paginate($perPage);
}
/**
* 악성채권 요약 통계
*/
public function summary(array $params = []): array
{
$tenantId = $this->tenantId();
$query = BadDebt::query()
->where('tenant_id', $tenantId);
// 거래처 필터
if (! empty($params['client_id'])) {
$query->where('client_id', $params['client_id']);
}
// 전체 합계
$totalAmount = (clone $query)->sum('debt_amount');
// 상태별 합계
$collectingAmount = (clone $query)->collecting()->sum('debt_amount');
$legalActionAmount = (clone $query)->legalAction()->sum('debt_amount');
$recoveredAmount = (clone $query)->recovered()->sum('debt_amount');
$badDebtAmount = (clone $query)->badDebt()->sum('debt_amount');
return [
'total_amount' => (float) $totalAmount,
'collecting_amount' => (float) $collectingAmount,
'legal_action_amount' => (float) $legalActionAmount,
'recovered_amount' => (float) $recoveredAmount,
'bad_debt_amount' => (float) $badDebtAmount,
];
}
/**
* 악성채권 상세 조회
*/
public function show(int $id): BadDebt
{
$tenantId = $this->tenantId();
return BadDebt::query()
->where('tenant_id', $tenantId)
->with([
'client',
'assignedUser:id,name',
'creator:id,name',
'documents.file',
'memos.creator:id,name',
])
->findOrFail($id);
}
/**
* 악성채권 등록
*/
public function store(array $data): BadDebt
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
$badDebt = new BadDebt;
$badDebt->tenant_id = $tenantId;
$badDebt->client_id = $data['client_id'];
$badDebt->debt_amount = $data['debt_amount'];
$badDebt->status = $data['status'] ?? BadDebt::STATUS_COLLECTING;
$badDebt->overdue_days = $data['overdue_days'] ?? 0;
$badDebt->assigned_user_id = $data['assigned_user_id'] ?? null;
$badDebt->occurred_at = $data['occurred_at'] ?? null;
$badDebt->closed_at = $data['closed_at'] ?? null;
$badDebt->is_active = $data['is_active'] ?? true;
$badDebt->options = $data['options'] ?? null;
$badDebt->created_by = $userId;
$badDebt->updated_by = $userId;
$badDebt->save();
return $badDebt->load(['client:id,name,client_code', 'assignedUser:id,name']);
});
}
/**
* 악성채권 수정
*/
public function update(int $id, array $data): BadDebt
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
$badDebt = BadDebt::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
if (isset($data['client_id'])) {
$badDebt->client_id = $data['client_id'];
}
if (isset($data['debt_amount'])) {
$badDebt->debt_amount = $data['debt_amount'];
}
if (isset($data['status'])) {
$badDebt->status = $data['status'];
}
if (isset($data['overdue_days'])) {
$badDebt->overdue_days = $data['overdue_days'];
}
if (array_key_exists('assigned_user_id', $data)) {
$badDebt->assigned_user_id = $data['assigned_user_id'];
}
if (array_key_exists('occurred_at', $data)) {
$badDebt->occurred_at = $data['occurred_at'];
}
if (array_key_exists('closed_at', $data)) {
$badDebt->closed_at = $data['closed_at'];
}
if (isset($data['is_active'])) {
$badDebt->is_active = $data['is_active'];
}
if (array_key_exists('options', $data)) {
$badDebt->options = $data['options'];
}
$badDebt->updated_by = $userId;
$badDebt->save();
return $badDebt->fresh(['client:id,name,client_code', 'assignedUser:id,name']);
});
}
/**
* 악성채권 삭제
*/
public function destroy(int $id): bool
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $tenantId, $userId) {
$badDebt = BadDebt::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
$badDebt->deleted_by = $userId;
$badDebt->save();
$badDebt->delete();
return true;
});
}
/**
* 설정 토글 (is_active)
*/
public function toggle(int $id): BadDebt
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $tenantId, $userId) {
$badDebt = BadDebt::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
$badDebt->is_active = ! $badDebt->is_active;
$badDebt->updated_by = $userId;
$badDebt->save();
return $badDebt->fresh(['client:id,name,client_code', 'assignedUser:id,name']);
});
}
/**
* 서류 첨부
*/
public function addDocument(int $id, array $data): BadDebtDocument
{
$tenantId = $this->tenantId();
$badDebt = BadDebt::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
$document = new BadDebtDocument;
$document->bad_debt_id = $badDebt->id;
$document->document_type = $data['document_type'];
$document->file_id = $data['file_id'];
$document->save();
return $document->load('file');
}
/**
* 서류 삭제
*/
public function removeDocument(int $id, int $documentId): bool
{
$tenantId = $this->tenantId();
$badDebt = BadDebt::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
$document = BadDebtDocument::query()
->where('bad_debt_id', $badDebt->id)
->findOrFail($documentId);
return $document->delete();
}
/**
* 메모 추가
*/
public function addMemo(int $id, array $data): BadDebtMemo
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$badDebt = BadDebt::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
$memo = new BadDebtMemo;
$memo->bad_debt_id = $badDebt->id;
$memo->content = $data['content'];
$memo->created_by = $userId;
$memo->save();
return $memo->load('creator:id,name');
}
/**
* 메모 삭제
*/
public function removeMemo(int $id, int $memoId): bool
{
$tenantId = $this->tenantId();
$badDebt = BadDebt::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
$memo = BadDebtMemo::query()
->where('bad_debt_id', $badDebt->id)
->findOrFail($memoId);
return $memo->delete();
}
}

View File

@@ -0,0 +1,433 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="BadDebt", description="악성채권 추심관리")
*
* @OA\Schema(
* schema="BadDebt",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="client_id", type="integer", example=1),
* @OA\Property(property="debt_amount", type="number", format="decimal", example="10000000.00"),
* @OA\Property(property="status", type="string", enum={"collecting", "legal_action", "recovered", "bad_debt"}, example="collecting"),
* @OA\Property(property="overdue_days", type="integer", example=100),
* @OA\Property(property="assigned_user_id", type="integer", nullable=true, example=1),
* @OA\Property(property="occurred_at", type="string", format="date", nullable=true, example="2025-12-12"),
* @OA\Property(property="closed_at", type="string", format="date", nullable=true, example=null),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="options", type="object", nullable=true),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time"),
* @OA\Property(
* property="client",
* type="object",
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="name", type="string"),
* @OA\Property(property="client_code", type="string")
* ),
* @OA\Property(
* property="assigned_user",
* type="object",
* nullable=true,
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="name", type="string")
* )
* )
*
* @OA\Schema(
* schema="BadDebtPagination",
* allOf={
* @OA\Schema(ref="#/components/schemas/PaginationMeta"),
* @OA\Schema(
*
* @OA\Property(
* property="data",
* type="array",
*
* @OA\Items(ref="#/components/schemas/BadDebt")
* )
* )
* }
* )
*
* @OA\Schema(
* schema="BadDebtSummary",
*
* @OA\Property(property="total_amount", type="number", example=50000000),
* @OA\Property(property="collecting_amount", type="number", example=30000000),
* @OA\Property(property="legal_action_amount", type="number", example=10000000),
* @OA\Property(property="recovered_amount", type="number", example=5000000),
* @OA\Property(property="bad_debt_amount", type="number", example=5000000)
* )
*
* @OA\Schema(
* schema="BadDebtCreateRequest",
* required={"client_id", "debt_amount"},
*
* @OA\Property(property="client_id", type="integer", example=1),
* @OA\Property(property="debt_amount", type="number", example=10000000),
* @OA\Property(property="status", type="string", enum={"collecting", "legal_action", "recovered", "bad_debt"}, example="collecting"),
* @OA\Property(property="overdue_days", type="integer", example=100),
* @OA\Property(property="assigned_user_id", type="integer", nullable=true, example=1),
* @OA\Property(property="occurred_at", type="string", format="date", nullable=true, example="2025-12-12"),
* @OA\Property(property="closed_at", type="string", format="date", nullable=true),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="options", type="object", nullable=true)
* )
*
* @OA\Schema(
* schema="BadDebtUpdateRequest",
*
* @OA\Property(property="client_id", type="integer", example=1),
* @OA\Property(property="debt_amount", type="number", example=10000000),
* @OA\Property(property="status", type="string", enum={"collecting", "legal_action", "recovered", "bad_debt"}, example="legal_action"),
* @OA\Property(property="overdue_days", type="integer", example=120),
* @OA\Property(property="assigned_user_id", type="integer", nullable=true, example=2),
* @OA\Property(property="occurred_at", type="string", format="date", nullable=true),
* @OA\Property(property="closed_at", type="string", format="date", nullable=true),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="options", type="object", nullable=true)
* )
*
* @OA\Schema(
* schema="BadDebtDocument",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="bad_debt_id", type="integer", example=1),
* @OA\Property(property="document_type", type="string", enum={"business_license", "tax_invoice", "additional"}, example="business_license"),
* @OA\Property(property="file_id", type="integer", example=1),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(
* property="file",
* type="object",
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="name", type="string"),
* @OA\Property(property="url", type="string")
* )
* )
*
* @OA\Schema(
* schema="BadDebtDocumentCreateRequest",
* required={"document_type", "file_id"},
*
* @OA\Property(property="document_type", type="string", enum={"business_license", "tax_invoice", "additional"}, example="business_license"),
* @OA\Property(property="file_id", type="integer", example=1)
* )
*
* @OA\Schema(
* schema="BadDebtMemo",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="bad_debt_id", type="integer", example=1),
* @OA\Property(property="content", type="string", example="2025-12-12 홍길동 과장님 메모 내용"),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(
* property="creator",
* type="object",
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="name", type="string")
* )
* )
*
* @OA\Schema(
* schema="BadDebtMemoCreateRequest",
* required={"content"},
*
* @OA\Property(property="content", type="string", example="메모 내용", maxLength=5000)
* )
*/
class BadDebtApi
{
/**
* @OA\Get(
* path="/api/v1/bad-debts",
* tags={"BadDebt"},
* summary="악성채권 목록 조회",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="client_id", in="query", @OA\Schema(type="integer"), description="거래처 ID"),
* @OA\Parameter(name="status", in="query", @OA\Schema(type="string", enum={"collecting", "legal_action", "recovered", "bad_debt"}), description="상태"),
* @OA\Parameter(name="is_active", in="query", @OA\Schema(type="boolean"), description="활성화 여부"),
* @OA\Parameter(name="search", in="query", @OA\Schema(type="string"), description="검색어 (거래처명, 거래처코드)"),
* @OA\Parameter(name="sort_by", in="query", @OA\Schema(type="string", default="created_at"), description="정렬 기준"),
* @OA\Parameter(name="sort_dir", in="query", @OA\Schema(type="string", enum={"asc", "desc"}, default="desc"), description="정렬 방향"),
* @OA\Parameter(name="per_page", in="query", @OA\Schema(type="integer", default=20), description="페이지당 항목 수"),
* @OA\Parameter(name="page", in="query", @OA\Schema(type="integer", default=1), description="페이지 번호"),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(ref="#/components/schemas/BadDebtPagination")
* )
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/bad-debts/summary",
* tags={"BadDebt"},
* summary="악성채권 요약 통계",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="client_id", in="query", @OA\Schema(type="integer"), description="거래처 ID (선택)"),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/BadDebtSummary")
* )
* )
* )
*/
public function summary() {}
/**
* @OA\Post(
* path="/api/v1/bad-debts",
* tags={"BadDebt"},
* summary="악성채권 등록",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/BadDebtCreateRequest")
* ),
*
* @OA\Response(
* response=201,
* description="생성 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/BadDebt")
* )
* )
* )
*/
public function store() {}
/**
* @OA\Get(
* path="/api/v1/bad-debts/{id}",
* tags={"BadDebt"},
* summary="악성채권 상세 조회",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/BadDebt")
* )
* )
* )
*/
public function show() {}
/**
* @OA\Put(
* path="/api/v1/bad-debts/{id}",
* tags={"BadDebt"},
* summary="악성채권 수정",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/BadDebtUpdateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="수정 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/BadDebt")
* )
* )
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/bad-debts/{id}",
* tags={"BadDebt"},
* summary="악성채권 삭제",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="삭제 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="null")
* )
* )
* )
*/
public function destroy() {}
/**
* @OA\Patch(
* path="/api/v1/bad-debts/{id}/toggle",
* tags={"BadDebt"},
* summary="악성채권 설정 ON/OFF 토글",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="토글 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/BadDebt")
* )
* )
* )
*/
public function toggle() {}
/**
* @OA\Post(
* path="/api/v1/bad-debts/{id}/documents",
* tags={"BadDebt"},
* summary="서류 첨부",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/BadDebtDocumentCreateRequest")
* ),
*
* @OA\Response(
* response=201,
* description="첨부 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/BadDebtDocument")
* )
* )
* )
*/
public function addDocument() {}
/**
* @OA\Delete(
* path="/api/v1/bad-debts/{id}/documents/{documentId}",
* tags={"BadDebt"},
* summary="서류 삭제",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Parameter(name="documentId", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="삭제 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="null")
* )
* )
* )
*/
public function removeDocument() {}
/**
* @OA\Post(
* path="/api/v1/bad-debts/{id}/memos",
* tags={"BadDebt"},
* summary="메모 추가",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/BadDebtMemoCreateRequest")
* ),
*
* @OA\Response(
* response=201,
* description="추가 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/BadDebtMemo")
* )
* )
* )
*/
public function addMemo() {}
/**
* @OA\Delete(
* path="/api/v1/bad-debts/{id}/memos/{memoId}",
* tags={"BadDebt"},
* summary="메모 삭제",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Parameter(name="memoId", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="삭제 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="null")
* )
* )
* )
*/
public function removeMemo() {}
}

View File

@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bad_debts', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('client_id')->comment('거래처 ID');
$table->decimal('debt_amount', 15, 2)->default(0)->comment('채권 금액');
$table->string('status', 20)->default('collecting')->comment('상태: collecting(추심중), legal_action(법적조치), recovered(회수완료), bad_debt(대손처리)');
$table->integer('overdue_days')->default(0)->comment('연체일수');
$table->unsignedBigInteger('assigned_user_id')->nullable()->comment('담당자 ID');
$table->date('occurred_at')->nullable()->comment('악성채권 발생일');
$table->date('closed_at')->nullable()->comment('악성채권 종료일');
$table->boolean('is_active')->default(true)->comment('설정 활성화 여부');
$table->json('options')->nullable()->comment('추가 옵션 JSON');
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자 ID');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID');
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자 ID');
$table->timestamps();
$table->softDeletes();
$table->index('tenant_id');
$table->index('client_id');
$table->index('status');
$table->index('is_active');
$table->index(['tenant_id', 'status']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bad_debts');
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bad_debt_documents', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('bad_debt_id')->comment('악성채권 ID');
$table->string('document_type', 50)->comment('서류 유형: business_license(사업자등록증), tax_invoice(세금계산서), additional(추가서류)');
$table->unsignedBigInteger('file_id')->comment('파일 ID');
$table->timestamps();
$table->index('bad_debt_id');
$table->index('document_type');
$table->foreign('bad_debt_id')
->references('id')
->on('bad_debts')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bad_debt_documents');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bad_debt_memos', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('bad_debt_id')->comment('악성채권 ID');
$table->text('content')->comment('메모 내용');
$table->unsignedBigInteger('created_by')->nullable()->comment('작성자 ID');
$table->timestamps();
$table->index('bad_debt_id');
$table->foreign('bad_debt_id')
->references('id')
->on('bad_debts')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bad_debt_memos');
}
};

View File

@@ -9,6 +9,7 @@
use App\Http\Controllers\Api\V1\ApprovalFormController; use App\Http\Controllers\Api\V1\ApprovalFormController;
use App\Http\Controllers\Api\V1\ApprovalLineController; use App\Http\Controllers\Api\V1\ApprovalLineController;
use App\Http\Controllers\Api\V1\AttendanceController; use App\Http\Controllers\Api\V1\AttendanceController;
use App\Http\Controllers\Api\V1\BadDebtController;
use App\Http\Controllers\Api\V1\BankAccountController; use App\Http\Controllers\Api\V1\BankAccountController;
use App\Http\Controllers\Api\V1\BarobillSettingController; use App\Http\Controllers\Api\V1\BarobillSettingController;
use App\Http\Controllers\Api\V1\BoardController; use App\Http\Controllers\Api\V1\BoardController;
@@ -515,6 +516,23 @@
Route::get('/{id}/check-status', [TaxInvoiceController::class, 'checkStatus'])->whereNumber('id')->name('v1.tax-invoices.check-status'); Route::get('/{id}/check-status', [TaxInvoiceController::class, 'checkStatus'])->whereNumber('id')->name('v1.tax-invoices.check-status');
}); });
// Bad Debt API (악성채권 추심관리)
Route::prefix('bad-debts')->group(function () {
Route::get('', [BadDebtController::class, 'index'])->name('v1.bad-debts.index');
Route::post('', [BadDebtController::class, 'store'])->name('v1.bad-debts.store');
Route::get('/summary', [BadDebtController::class, 'summary'])->name('v1.bad-debts.summary');
Route::get('/{id}', [BadDebtController::class, 'show'])->whereNumber('id')->name('v1.bad-debts.show');
Route::put('/{id}', [BadDebtController::class, 'update'])->whereNumber('id')->name('v1.bad-debts.update');
Route::delete('/{id}', [BadDebtController::class, 'destroy'])->whereNumber('id')->name('v1.bad-debts.destroy');
Route::patch('/{id}/toggle', [BadDebtController::class, 'toggle'])->whereNumber('id')->name('v1.bad-debts.toggle');
// 서류
Route::post('/{id}/documents', [BadDebtController::class, 'addDocument'])->whereNumber('id')->name('v1.bad-debts.documents.store');
Route::delete('/{id}/documents/{documentId}', [BadDebtController::class, 'removeDocument'])->whereNumber(['id', 'documentId'])->name('v1.bad-debts.documents.destroy');
// 메모
Route::post('/{id}/memos', [BadDebtController::class, 'addMemo'])->whereNumber('id')->name('v1.bad-debts.memos.store');
Route::delete('/{id}/memos/{memoId}', [BadDebtController::class, 'removeMemo'])->whereNumber(['id', 'memoId'])->name('v1.bad-debts.memos.destroy');
});
// Report API (보고서) // Report API (보고서)
Route::prefix('reports')->group(function () { Route::prefix('reports')->group(function () {
Route::get('/daily', [ReportController::class, 'daily'])->name('v1.reports.daily'); Route::get('/daily', [ReportController::class, 'daily'])->name('v1.reports.daily');