diff --git a/app/Http/Controllers/Api/V1/LoanController.php b/app/Http/Controllers/Api/V1/LoanController.php index 34ff161..276902e 100644 --- a/app/Http/Controllers/Api/V1/LoanController.php +++ b/app/Http/Controllers/Api/V1/LoanController.php @@ -34,8 +34,10 @@ public function index(LoanIndexRequest $request): JsonResponse */ public function summary(LoanIndexRequest $request): JsonResponse { - $userId = $request->validated()['user_id'] ?? null; - $result = $this->loanService->summary($userId); + $validated = $request->validated(); + $userId = $validated['user_id'] ?? null; + $category = $validated['category'] ?? null; + $result = $this->loanService->summary($userId, $category); return ApiResponse::success($result, __('message.fetched')); } diff --git a/app/Http/Requests/Loan/LoanIndexRequest.php b/app/Http/Requests/Loan/LoanIndexRequest.php index c493b1b..26282ef 100644 --- a/app/Http/Requests/Loan/LoanIndexRequest.php +++ b/app/Http/Requests/Loan/LoanIndexRequest.php @@ -29,6 +29,7 @@ public function rules(): array return [ 'user_id' => ['nullable', 'integer', 'exists:users,id'], 'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)], + 'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)], 'start_date' => ['nullable', 'date', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'], 'search' => ['nullable', 'string', 'max:100'], diff --git a/app/Http/Requests/Loan/LoanStoreRequest.php b/app/Http/Requests/Loan/LoanStoreRequest.php index 345baac..096c827 100644 --- a/app/Http/Requests/Loan/LoanStoreRequest.php +++ b/app/Http/Requests/Loan/LoanStoreRequest.php @@ -2,7 +2,9 @@ namespace App\Http\Requests\Loan; +use App\Models\Tenants\Loan; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class LoanStoreRequest extends FormRequest { @@ -21,12 +23,27 @@ public function authorize(): bool */ public function rules(): array { + $isGiftCertificate = $this->input('category') === Loan::CATEGORY_GIFT_CERTIFICATE; + return [ - 'user_id' => ['required', 'integer', 'exists:users,id'], + 'user_id' => [$isGiftCertificate ? 'nullable' : 'required', 'integer', 'exists:users,id'], 'loan_date' => ['required', 'date', 'date_format:Y-m-d'], 'amount' => ['required', 'numeric', 'min:0', 'max:999999999999.99'], 'purpose' => ['nullable', 'string', 'max:1000'], 'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'], + 'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)], + 'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)], + 'metadata' => ['nullable', 'array'], + 'metadata.serial_number' => ['nullable', 'string', 'max:100'], + 'metadata.cert_name' => ['nullable', 'string', 'max:200'], + 'metadata.vendor_id' => ['nullable', 'string', 'max:50'], + 'metadata.vendor_name' => ['nullable', 'string', 'max:200'], + 'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'], + 'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'], + 'metadata.recipient_name' => ['nullable', 'string', 'max:100'], + 'metadata.recipient_organization' => ['nullable', 'string', 'max:200'], + 'metadata.usage_description' => ['nullable', 'string', 'max:1000'], + 'metadata.memo' => ['nullable', 'string', 'max:2000'], ]; } diff --git a/app/Http/Requests/Loan/LoanUpdateRequest.php b/app/Http/Requests/Loan/LoanUpdateRequest.php index ba8449c..510089f 100644 --- a/app/Http/Requests/Loan/LoanUpdateRequest.php +++ b/app/Http/Requests/Loan/LoanUpdateRequest.php @@ -2,7 +2,9 @@ namespace App\Http\Requests\Loan; +use App\Models\Tenants\Loan; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class LoanUpdateRequest extends FormRequest { @@ -27,6 +29,20 @@ public function rules(): array 'amount' => ['sometimes', 'numeric', 'min:0', 'max:999999999999.99'], 'purpose' => ['nullable', 'string', 'max:1000'], 'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'], + 'category' => ['sometimes', 'string', Rule::in(Loan::CATEGORIES)], + 'status' => ['sometimes', 'string', Rule::in(Loan::STATUSES)], + 'settlement_date' => ['nullable', 'date', 'date_format:Y-m-d'], + 'metadata' => ['nullable', 'array'], + 'metadata.serial_number' => ['nullable', 'string', 'max:100'], + 'metadata.cert_name' => ['nullable', 'string', 'max:200'], + 'metadata.vendor_id' => ['nullable', 'string', 'max:50'], + 'metadata.vendor_name' => ['nullable', 'string', 'max:200'], + 'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'], + 'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'], + 'metadata.recipient_name' => ['nullable', 'string', 'max:100'], + 'metadata.recipient_organization' => ['nullable', 'string', 'max:200'], + 'metadata.usage_description' => ['nullable', 'string', 'max:1000'], + 'metadata.memo' => ['nullable', 'string', 'max:2000'], ]; } diff --git a/app/Http/Requests/V1/Bill/StoreBillRequest.php b/app/Http/Requests/V1/Bill/StoreBillRequest.php index 562dc98..3aaed47 100644 --- a/app/Http/Requests/V1/Bill/StoreBillRequest.php +++ b/app/Http/Requests/V1/Bill/StoreBillRequest.php @@ -17,6 +17,7 @@ public function rules(): array $tenantId = app('tenant_id') ?? 0; return [ + // === 기존 필드 === 'bill_number' => [ 'nullable', 'string', @@ -30,16 +31,99 @@ public function rules(): array 'client_name' => ['nullable', 'string', 'max:100'], 'amount' => ['required', 'numeric', 'min:0'], 'issue_date' => ['required', 'date'], - 'maturity_date' => ['required', 'date', 'after_or_equal:issue_date'], - 'status' => ['nullable', 'string', 'in:stored,maturityAlert,maturityResult,paymentComplete,dishonored,collectionRequest,collectionComplete,suing'], + 'maturity_date' => ['nullable', 'date', 'after_or_equal:issue_date'], + 'status' => ['nullable', 'string', 'max:30'], 'reason' => ['nullable', 'string', 'max:255'], 'installment_count' => ['nullable', 'integer', 'min:0'], 'note' => ['nullable', 'string', 'max:1000'], 'is_electronic' => ['nullable', 'boolean'], 'bank_account_id' => ['nullable', 'integer', 'exists:bank_accounts,id'], + + // === V8 증권종류/매체/구분 === + 'instrument_type' => ['nullable', 'string', 'in:promissory,exchange,cashierCheck,currentCheck'], + 'medium' => ['nullable', 'string', 'in:electronic,paper'], + 'bill_category' => ['nullable', 'string', 'in:commercial,other'], + + // === 전자어음 === + 'electronic_bill_no' => ['nullable', 'string', 'max:100'], + 'registration_org' => ['nullable', 'string', 'in:kftc,bank'], + + // === 환어음 === + 'drawee' => ['nullable', 'string', 'max:100'], + 'acceptance_status' => ['nullable', 'string', 'in:accepted,pending,refused'], + 'acceptance_date' => ['nullable', 'date'], + 'acceptance_refusal_date' => ['nullable', 'date'], + 'acceptance_refusal_reason' => ['nullable', 'string', 'max:50'], + + // === 받을어음 전용 === + 'endorsement' => ['nullable', 'string', 'in:endorsable,nonEndorsable'], + 'endorsement_order' => ['nullable', 'string', 'max:5'], + 'storage_place' => ['nullable', 'string', 'in:safe,bank,other'], + 'issuer_bank' => ['nullable', 'string', 'max:100'], + + // 할인 + 'is_discounted' => ['nullable', 'boolean'], + 'discount_date' => ['nullable', 'date'], + 'discount_bank' => ['nullable', 'string', 'max:100'], + 'discount_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'discount_amount' => ['nullable', 'numeric', 'min:0'], + + // 배서양도 + 'endorsement_date' => ['nullable', 'date'], + 'endorsee' => ['nullable', 'string', 'max:100'], + 'endorsement_reason' => ['nullable', 'string', 'in:payment,guarantee,collection,other'], + + // 추심 + 'collection_bank' => ['nullable', 'string', 'max:100'], + 'collection_request_date' => ['nullable', 'date'], + 'collection_fee' => ['nullable', 'numeric', 'min:0'], + 'collection_complete_date' => ['nullable', 'date'], + 'collection_result' => ['nullable', 'string', 'in:success,partial,failed,pending'], + 'collection_deposit_date' => ['nullable', 'date'], + 'collection_deposit_amount' => ['nullable', 'numeric', 'min:0'], + + // === 지급어음 전용 === + 'settlement_bank' => ['nullable', 'string', 'max:100'], + 'payment_method' => ['nullable', 'string', 'in:autoTransfer,currentAccount,other'], + 'actual_payment_date' => ['nullable', 'date'], + + // === 공통 === + 'payment_place' => ['nullable', 'string', 'max:30'], + 'payment_place_detail' => ['nullable', 'string', 'max:200'], + + // 개서 + 'renewal_date' => ['nullable', 'date'], + 'renewal_new_bill_no' => ['nullable', 'string', 'max:50'], + 'renewal_reason' => ['nullable', 'string', 'in:maturityExtension,amountChange,conditionChange,other'], + + // 소구 + 'recourse_date' => ['nullable', 'date'], + 'recourse_amount' => ['nullable', 'numeric', 'min:0'], + 'recourse_target' => ['nullable', 'string', 'max:100'], + 'recourse_reason' => ['nullable', 'string', 'in:endorsedDishonor,discountDishonor,other'], + + // 환매 + 'buyback_date' => ['nullable', 'date'], + 'buyback_amount' => ['nullable', 'numeric', 'min:0'], + 'buyback_bank' => ['nullable', 'string', 'max:100'], + + // 부도/법적절차 + 'dishonored_date' => ['nullable', 'date'], + 'dishonored_reason' => ['nullable', 'string', 'max:30'], + 'has_protest' => ['nullable', 'boolean'], + 'protest_date' => ['nullable', 'date'], + 'recourse_notice_date' => ['nullable', 'date'], + 'recourse_notice_deadline' => ['nullable', 'date'], + + // 분할배서 + 'is_split' => ['nullable', 'boolean'], + + // === 차수 관리 === 'installments' => ['nullable', 'array'], 'installments.*.date' => ['required_with:installments', 'date'], 'installments.*.amount' => ['required_with:installments', 'numeric', 'min:0'], + 'installments.*.type' => ['nullable', 'string', 'max:30'], + 'installments.*.counterparty' => ['nullable', 'string', 'max:100'], 'installments.*.note' => ['nullable', 'string', 'max:255'], ]; } diff --git a/app/Http/Requests/V1/Bill/UpdateBillRequest.php b/app/Http/Requests/V1/Bill/UpdateBillRequest.php index 029da50..dc0a3c9 100644 --- a/app/Http/Requests/V1/Bill/UpdateBillRequest.php +++ b/app/Http/Requests/V1/Bill/UpdateBillRequest.php @@ -14,6 +14,7 @@ public function authorize(): bool public function rules(): array { return [ + // === 기존 필드 === 'bill_number' => ['nullable', 'string', 'max:50'], 'bill_type' => ['nullable', 'string', 'in:received,issued'], 'client_id' => ['nullable', 'integer', 'exists:clients,id'], @@ -21,15 +22,72 @@ public function rules(): array 'amount' => ['nullable', 'numeric', 'min:0'], 'issue_date' => ['nullable', 'date'], 'maturity_date' => ['nullable', 'date', 'after_or_equal:issue_date'], - 'status' => ['nullable', 'string', 'in:stored,maturityAlert,maturityResult,paymentComplete,dishonored,collectionRequest,collectionComplete,suing'], + 'status' => ['nullable', 'string', 'max:30'], 'reason' => ['nullable', 'string', 'max:255'], 'installment_count' => ['nullable', 'integer', 'min:0'], 'note' => ['nullable', 'string', 'max:1000'], 'is_electronic' => ['nullable', 'boolean'], 'bank_account_id' => ['nullable', 'integer', 'exists:bank_accounts,id'], + + // === V8 확장 === + 'instrument_type' => ['nullable', 'string', 'in:promissory,exchange,cashierCheck,currentCheck'], + 'medium' => ['nullable', 'string', 'in:electronic,paper'], + 'bill_category' => ['nullable', 'string', 'in:commercial,other'], + 'electronic_bill_no' => ['nullable', 'string', 'max:100'], + 'registration_org' => ['nullable', 'string', 'in:kftc,bank'], + 'drawee' => ['nullable', 'string', 'max:100'], + 'acceptance_status' => ['nullable', 'string', 'in:accepted,pending,refused'], + 'acceptance_date' => ['nullable', 'date'], + 'acceptance_refusal_date' => ['nullable', 'date'], + 'acceptance_refusal_reason' => ['nullable', 'string', 'max:50'], + 'endorsement' => ['nullable', 'string', 'in:endorsable,nonEndorsable'], + 'endorsement_order' => ['nullable', 'string', 'max:5'], + 'storage_place' => ['nullable', 'string', 'in:safe,bank,other'], + 'issuer_bank' => ['nullable', 'string', 'max:100'], + 'is_discounted' => ['nullable', 'boolean'], + 'discount_date' => ['nullable', 'date'], + 'discount_bank' => ['nullable', 'string', 'max:100'], + 'discount_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'discount_amount' => ['nullable', 'numeric', 'min:0'], + 'endorsement_date' => ['nullable', 'date'], + 'endorsee' => ['nullable', 'string', 'max:100'], + 'endorsement_reason' => ['nullable', 'string', 'in:payment,guarantee,collection,other'], + 'collection_bank' => ['nullable', 'string', 'max:100'], + 'collection_request_date' => ['nullable', 'date'], + 'collection_fee' => ['nullable', 'numeric', 'min:0'], + 'collection_complete_date' => ['nullable', 'date'], + 'collection_result' => ['nullable', 'string', 'in:success,partial,failed,pending'], + 'collection_deposit_date' => ['nullable', 'date'], + 'collection_deposit_amount' => ['nullable', 'numeric', 'min:0'], + 'settlement_bank' => ['nullable', 'string', 'max:100'], + 'payment_method' => ['nullable', 'string', 'in:autoTransfer,currentAccount,other'], + 'actual_payment_date' => ['nullable', 'date'], + 'payment_place' => ['nullable', 'string', 'max:30'], + 'payment_place_detail' => ['nullable', 'string', 'max:200'], + 'renewal_date' => ['nullable', 'date'], + 'renewal_new_bill_no' => ['nullable', 'string', 'max:50'], + 'renewal_reason' => ['nullable', 'string', 'in:maturityExtension,amountChange,conditionChange,other'], + 'recourse_date' => ['nullable', 'date'], + 'recourse_amount' => ['nullable', 'numeric', 'min:0'], + 'recourse_target' => ['nullable', 'string', 'max:100'], + 'recourse_reason' => ['nullable', 'string', 'in:endorsedDishonor,discountDishonor,other'], + 'buyback_date' => ['nullable', 'date'], + 'buyback_amount' => ['nullable', 'numeric', 'min:0'], + 'buyback_bank' => ['nullable', 'string', 'max:100'], + 'dishonored_date' => ['nullable', 'date'], + 'dishonored_reason' => ['nullable', 'string', 'max:30'], + 'has_protest' => ['nullable', 'boolean'], + 'protest_date' => ['nullable', 'date'], + 'recourse_notice_date' => ['nullable', 'date'], + 'recourse_notice_deadline' => ['nullable', 'date'], + 'is_split' => ['nullable', 'boolean'], + + // === 차수 관리 === 'installments' => ['nullable', 'array'], 'installments.*.date' => ['required_with:installments', 'date'], 'installments.*.amount' => ['required_with:installments', 'numeric', 'min:0'], + 'installments.*.type' => ['nullable', 'string', 'max:30'], + 'installments.*.counterparty' => ['nullable', 'string', 'max:100'], 'installments.*.note' => ['nullable', 'string', 'max:255'], ]; } diff --git a/app/Models/Tenants/Bill.php b/app/Models/Tenants/Bill.php index 1200fa7..e14d7b8 100644 --- a/app/Models/Tenants/Bill.php +++ b/app/Models/Tenants/Bill.php @@ -31,6 +31,58 @@ class Bill extends Model 'created_by', 'updated_by', 'deleted_by', + // V8 확장 필드 + 'instrument_type', + 'medium', + 'bill_category', + 'electronic_bill_no', + 'registration_org', + 'drawee', + 'acceptance_status', + 'acceptance_date', + 'acceptance_refusal_date', + 'acceptance_refusal_reason', + 'endorsement', + 'endorsement_order', + 'storage_place', + 'issuer_bank', + 'is_discounted', + 'discount_date', + 'discount_bank', + 'discount_rate', + 'discount_amount', + 'endorsement_date', + 'endorsee', + 'endorsement_reason', + 'collection_bank', + 'collection_request_date', + 'collection_fee', + 'collection_complete_date', + 'collection_result', + 'collection_deposit_date', + 'collection_deposit_amount', + 'settlement_bank', + 'payment_method', + 'actual_payment_date', + 'payment_place', + 'payment_place_detail', + 'renewal_date', + 'renewal_new_bill_no', + 'renewal_reason', + 'recourse_date', + 'recourse_amount', + 'recourse_target', + 'recourse_reason', + 'buyback_date', + 'buyback_amount', + 'buyback_bank', + 'dishonored_date', + 'dishonored_reason', + 'has_protest', + 'protest_date', + 'recourse_notice_date', + 'recourse_notice_deadline', + 'is_split', ]; protected $casts = [ @@ -41,21 +93,57 @@ class Bill extends Model 'bank_account_id' => 'integer', 'installment_count' => 'integer', 'is_electronic' => 'boolean', + // V8 확장 casts + 'acceptance_date' => 'date', + 'acceptance_refusal_date' => 'date', + 'discount_date' => 'date', + 'discount_rate' => 'decimal:2', + 'discount_amount' => 'decimal:2', + 'endorsement_date' => 'date', + 'collection_request_date' => 'date', + 'collection_fee' => 'decimal:2', + 'collection_complete_date' => 'date', + 'collection_deposit_date' => 'date', + 'collection_deposit_amount' => 'decimal:2', + 'actual_payment_date' => 'date', + 'renewal_date' => 'date', + 'recourse_date' => 'date', + 'recourse_amount' => 'decimal:2', + 'buyback_date' => 'date', + 'buyback_amount' => 'decimal:2', + 'dishonored_date' => 'date', + 'protest_date' => 'date', + 'recourse_notice_date' => 'date', + 'recourse_notice_deadline' => 'date', + 'is_discounted' => 'boolean', + 'has_protest' => 'boolean', + 'is_split' => 'boolean', ]; /** * 배열/JSON 변환 시 날짜 형식 지정 */ + /** + * 날짜 cast 필드 목록 (toArray에서 Y-m-d 형식 변환용) + */ + private const DATE_FIELDS = [ + 'issue_date', 'maturity_date', + 'acceptance_date', 'acceptance_refusal_date', + 'discount_date', 'endorsement_date', + 'collection_request_date', 'collection_complete_date', 'collection_deposit_date', + 'actual_payment_date', + 'renewal_date', 'recourse_date', 'buyback_date', + 'dishonored_date', 'protest_date', 'recourse_notice_date', 'recourse_notice_deadline', + ]; + public function toArray(): array { $array = parent::toArray(); - // 날짜 필드를 Y-m-d 형식으로 변환 - if (isset($array['issue_date']) && $this->issue_date) { - $array['issue_date'] = $this->issue_date->format('Y-m-d'); - } - if (isset($array['maturity_date']) && $this->maturity_date) { - $array['maturity_date'] = $this->maturity_date->format('Y-m-d'); + foreach (self::DATE_FIELDS as $field) { + if (isset($array[$field]) && $this->{$field}) { + $array[$field] = $this->{$field}->format('Y-m-d'); + } } return $array; @@ -69,14 +157,42 @@ public function toArray(): array 'issued' => '발행', ]; + /** + * 증권종류 + */ + public const INSTRUMENT_TYPES = [ + 'promissory' => '약속어음', + 'exchange' => '환어음', + 'cashierCheck' => '자기앞수표', + 'currentCheck' => '당좌수표', + ]; + /** * 수취 어음 상태 목록 */ public const RECEIVED_STATUSES = [ 'stored' => '보관중', + 'endorsed' => '배서양도', + 'discounted' => '할인', + 'collectionRequest' => '추심의뢰', + 'collectionComplete' => '추심완료', + 'maturityDeposit' => '만기입금', + 'paymentComplete' => '결제완료', + 'dishonored' => '부도', + 'renewed' => '개서', + 'buyback' => '환매', + // 하위호환 'maturityAlert' => '만기입금(7일전)', 'maturityResult' => '만기결과', - 'paymentComplete' => '결제완료', + ]; + + /** + * 수취 수표 상태 목록 + */ + public const RECEIVED_CHECK_STATUSES = [ + 'stored' => '보관중', + 'endorsed' => '배서양도', + 'deposited' => '입금', 'dishonored' => '부도', ]; @@ -85,10 +201,25 @@ public function toArray(): array */ public const ISSUED_STATUSES = [ 'stored' => '보관중', + 'issued' => '지급대기', + 'maturityPayment' => '만기결제', + 'paymentComplete' => '결제완료', + 'dishonored' => '부도', + 'renewed' => '개서', + // 하위호환 'maturityAlert' => '만기입금(7일전)', 'collectionRequest' => '추심의뢰', 'collectionComplete' => '추심완료', 'suing' => '추소중', + ]; + + /** + * 발행 수표 상태 목록 + */ + public const ISSUED_CHECK_STATUSES = [ + 'stored' => '보관중', + 'issued' => '지급대기', + 'cashed' => '현금화', 'dishonored' => '부도', ]; @@ -149,11 +280,25 @@ public function getBillTypeLabelAttribute(): string */ public function getStatusLabelAttribute(): string { + $isCheck = in_array($this->instrument_type, ['cashierCheck', 'currentCheck']); + if ($this->bill_type === 'received') { - return self::RECEIVED_STATUSES[$this->status] ?? $this->status; + $statuses = $isCheck ? self::RECEIVED_CHECK_STATUSES : self::RECEIVED_STATUSES; + + return $statuses[$this->status] ?? self::RECEIVED_STATUSES[$this->status] ?? $this->status; } - return self::ISSUED_STATUSES[$this->status] ?? $this->status; + $statuses = $isCheck ? self::ISSUED_CHECK_STATUSES : self::ISSUED_STATUSES; + + return $statuses[$this->status] ?? self::ISSUED_STATUSES[$this->status] ?? $this->status; + } + + /** + * 증권종류 라벨 + */ + public function getInstrumentTypeLabelAttribute(): string + { + return self::INSTRUMENT_TYPES[$this->instrument_type] ?? $this->instrument_type ?? '약속어음'; } /** diff --git a/app/Models/Tenants/BillInstallment.php b/app/Models/Tenants/BillInstallment.php index 41656e2..926efc1 100644 --- a/app/Models/Tenants/BillInstallment.php +++ b/app/Models/Tenants/BillInstallment.php @@ -12,8 +12,10 @@ class BillInstallment extends Model protected $fillable = [ 'bill_id', + 'type', 'installment_date', 'amount', + 'counterparty', 'note', 'created_by', ]; diff --git a/app/Models/Tenants/Loan.php b/app/Models/Tenants/Loan.php index d2a88db..c769300 100644 --- a/app/Models/Tenants/Loan.php +++ b/app/Models/Tenants/Loan.php @@ -28,6 +28,12 @@ class Loan extends Model public const STATUS_PARTIAL = 'partial'; // 부분정산 + public const STATUS_HOLDING = 'holding'; // 보유 (상품권) + + public const STATUS_USED = 'used'; // 사용 (상품권) + + public const STATUS_DISPOSED = 'disposed'; // 폐기 (상품권) + /** * 상태 목록 */ @@ -35,6 +41,9 @@ class Loan extends Model self::STATUS_OUTSTANDING, self::STATUS_SETTLED, self::STATUS_PARTIAL, + self::STATUS_HOLDING, + self::STATUS_USED, + self::STATUS_DISPOSED, ]; /** @@ -104,6 +113,7 @@ class Loan extends Model 'settlement_amount', 'status', 'category', + 'metadata', 'withdrawal_id', 'created_by', 'updated_by', @@ -115,6 +125,7 @@ class Loan extends Model 'settlement_date' => 'date', 'amount' => 'decimal:2', 'settlement_amount' => 'decimal:2', + 'metadata' => 'array', ]; // ========================================================================= @@ -166,6 +177,9 @@ public function getStatusLabelAttribute(): string self::STATUS_OUTSTANDING => '미정산', self::STATUS_SETTLED => '정산완료', self::STATUS_PARTIAL => '부분정산', + self::STATUS_HOLDING => '보유', + self::STATUS_USED => '사용', + self::STATUS_DISPOSED => '폐기', default => $this->status, }; } @@ -209,15 +223,21 @@ public function getElapsedDaysAttribute(): int */ public function isEditable(): bool { - return $this->status === self::STATUS_OUTSTANDING; + return in_array($this->status, [ + self::STATUS_OUTSTANDING, + self::STATUS_HOLDING, + ]); } /** - * 삭제 가능 여부 (미정산 상태만) + * 삭제 가능 여부 (미정산/보유 상태만) */ public function isDeletable(): bool { - return $this->status === self::STATUS_OUTSTANDING; + return in_array($this->status, [ + self::STATUS_OUTSTANDING, + self::STATUS_HOLDING, + ]); } /** diff --git a/app/Services/BillService.php b/app/Services/BillService.php index b2d1512..e6a451a 100644 --- a/app/Services/BillService.php +++ b/app/Services/BillService.php @@ -48,6 +48,16 @@ public function index(array $params): LengthAwarePaginator $query->where('client_id', $params['client_id']); } + // 증권종류 필터 + if (! empty($params['instrument_type'])) { + $query->where('instrument_type', $params['instrument_type']); + } + + // 매체 필터 + if (! empty($params['medium'])) { + $query->where('medium', $params['medium']); + } + // 전자어음 필터 if (isset($params['is_electronic']) && $params['is_electronic'] !== '') { $query->where('is_electronic', (bool) $params['is_electronic']); @@ -113,32 +123,23 @@ public function store(array $data): Bill $bill->client_name = $data['client_name'] ?? null; $bill->amount = $data['amount']; $bill->issue_date = $data['issue_date']; - $bill->maturity_date = $data['maturity_date']; + $bill->maturity_date = $data['maturity_date'] ?? null; $bill->status = $data['status'] ?? 'stored'; $bill->reason = $data['reason'] ?? null; $bill->installment_count = $data['installment_count'] ?? 0; $bill->note = $data['note'] ?? null; $bill->is_electronic = $data['is_electronic'] ?? false; $bill->bank_account_id = $data['bank_account_id'] ?? null; + + // V8 확장 필드 + $this->assignV8Fields($bill, $data); + $bill->created_by = $userId; $bill->updated_by = $userId; $bill->save(); // 차수 관리 저장 - if (! empty($data['installments'])) { - foreach ($data['installments'] as $installment) { - BillInstallment::create([ - 'bill_id' => $bill->id, - 'installment_date' => $installment['date'], - 'amount' => $installment['amount'], - 'note' => $installment['note'] ?? null, - 'created_by' => $userId, - ]); - } - // 차수 카운트 업데이트 - $bill->installment_count = count($data['installments']); - $bill->save(); - } + $this->syncInstallments($bill, $data['installments'] ?? [], $userId); return $bill->load(['client:id,name', 'bankAccount:id,bank_name,account_name', 'installments']); }); @@ -157,6 +158,7 @@ public function update(int $id, array $data): Bill ->where('tenant_id', $tenantId) ->findOrFail($id); + // 기존 필드 if (isset($data['bill_number'])) { $bill->bill_number = $data['bill_number']; } @@ -175,7 +177,7 @@ public function update(int $id, array $data): Bill if (isset($data['issue_date'])) { $bill->issue_date = $data['issue_date']; } - if (isset($data['maturity_date'])) { + if (array_key_exists('maturity_date', $data)) { $bill->maturity_date = $data['maturity_date']; } if (isset($data['status'])) { @@ -194,27 +196,15 @@ public function update(int $id, array $data): Bill $bill->bank_account_id = $data['bank_account_id']; } + // V8 확장 필드 + $this->assignV8Fields($bill, $data); + $bill->updated_by = $userId; $bill->save(); // 차수 관리 업데이트 (전체 교체) if (isset($data['installments'])) { - // 기존 차수 삭제 - $bill->installments()->delete(); - - // 새 차수 추가 - foreach ($data['installments'] as $installment) { - BillInstallment::create([ - 'bill_id' => $bill->id, - 'installment_date' => $installment['date'], - 'amount' => $installment['amount'], - 'note' => $installment['note'] ?? null, - 'created_by' => $userId, - ]); - } - // 차수 카운트 업데이트 - $bill->installment_count = count($data['installments']); - $bill->save(); + $this->syncInstallments($bill, $data['installments'], $userId); } return $bill->fresh(['client:id,name', 'bankAccount:id,bank_name,account_name', 'installments']); @@ -440,6 +430,68 @@ public function dashboardDetail(): array ]; } + /** + * V8 확장 필드를 Bill 모델에 할당 + */ + private function assignV8Fields(Bill $bill, array $data): void + { + $v8Fields = [ + 'instrument_type', 'medium', 'bill_category', + 'electronic_bill_no', 'registration_org', + 'drawee', 'acceptance_status', 'acceptance_date', + 'acceptance_refusal_date', 'acceptance_refusal_reason', + 'endorsement', 'endorsement_order', 'storage_place', 'issuer_bank', + 'is_discounted', 'discount_date', 'discount_bank', 'discount_rate', 'discount_amount', + 'endorsement_date', 'endorsee', 'endorsement_reason', + 'collection_bank', 'collection_request_date', 'collection_fee', + 'collection_complete_date', 'collection_result', 'collection_deposit_date', 'collection_deposit_amount', + 'settlement_bank', 'payment_method', 'actual_payment_date', + 'payment_place', 'payment_place_detail', + 'renewal_date', 'renewal_new_bill_no', 'renewal_reason', + 'recourse_date', 'recourse_amount', 'recourse_target', 'recourse_reason', + 'buyback_date', 'buyback_amount', 'buyback_bank', + 'dishonored_date', 'dishonored_reason', 'has_protest', 'protest_date', + 'recourse_notice_date', 'recourse_notice_deadline', + 'is_split', + ]; + + foreach ($v8Fields as $field) { + if (array_key_exists($field, $data)) { + $bill->{$field} = $data[$field]; + } + } + } + + /** + * 차수(이력) 동기화 — 기존 삭제 후 새로 생성 + */ + private function syncInstallments(Bill $bill, array $installments, int $userId): void + { + if (empty($installments)) { + return; + } + + // 기존 차수 삭제 + $bill->installments()->delete(); + + // 새 차수 추가 + foreach ($installments as $installment) { + BillInstallment::create([ + 'bill_id' => $bill->id, + 'type' => $installment['type'] ?? 'other', + 'installment_date' => $installment['date'], + 'amount' => $installment['amount'], + 'counterparty' => $installment['counterparty'] ?? null, + 'note' => $installment['note'] ?? null, + 'created_by' => $userId, + ]); + } + + // 차수 카운트 업데이트 + $bill->installment_count = count($installments); + $bill->save(); + } + /** * 어음번호 자동 생성 */ diff --git a/app/Services/LoanService.php b/app/Services/LoanService.php index 40e290c..d674e11 100644 --- a/app/Services/LoanService.php +++ b/app/Services/LoanService.php @@ -25,6 +25,11 @@ public function index(array $params): LengthAwarePaginator ->where('tenant_id', $tenantId) ->with(['user:id,name,email', 'creator:id,name']); + // 카테고리 필터 + if (! empty($params['category'])) { + $query->where('category', $params['category']); + } + // 사용자 필터 if (! empty($params['user_id'])) { $query->where('user_id', $params['user_id']); @@ -84,7 +89,7 @@ public function show(int $id): Loan /** * 가지급금 요약 (특정 사용자 또는 전체) */ - public function summary(?int $userId = null): array + public function summary(?int $userId = null, ?string $category = null): array { $tenantId = $this->tenantId(); @@ -95,7 +100,14 @@ public function summary(?int $userId = null): array $query->where('user_id', $userId); } - $stats = $query->selectRaw(' + if ($category) { + $query->where('category', $category); + } + + // 상품권 카테고리: holding/used/disposed 상태별 집계 추가 + $isGiftCertificate = $category === Loan::CATEGORY_GIFT_CERTIFICATE; + + $selectRaw = ' COUNT(*) as total_count, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as settled_count, @@ -103,10 +115,27 @@ public function summary(?int $userId = null): array SUM(amount) as total_amount, SUM(COALESCE(settlement_amount, 0)) as total_settled, SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding - ', [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL]) - ->first(); + '; + $bindings = [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL]; - return [ + if ($isGiftCertificate) { + $selectRaw .= ', + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as holding_count, + SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as holding_amount, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as used_count, + SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as used_amount, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as disposed_count + '; + $bindings = array_merge($bindings, [ + Loan::STATUS_HOLDING, Loan::STATUS_HOLDING, + Loan::STATUS_USED, Loan::STATUS_USED, + Loan::STATUS_DISPOSED, + ]); + } + + $stats = $query->selectRaw($selectRaw, $bindings)->first(); + + $result = [ 'total_count' => (int) $stats->total_count, 'outstanding_count' => (int) $stats->outstanding_count, 'settled_count' => (int) $stats->settled_count, @@ -115,6 +144,16 @@ public function summary(?int $userId = null): array 'total_settled' => (float) $stats->total_settled, 'total_outstanding' => (float) $stats->total_outstanding, ]; + + if ($isGiftCertificate) { + $result['holding_count'] = (int) $stats->holding_count; + $result['holding_amount'] = (float) $stats->holding_amount; + $result['used_count'] = (int) $stats->used_count; + $result['used_amount'] = (float) $stats->used_amount; + $result['disposed_count'] = (int) $stats->disposed_count; + } + + return $result; } // ========================================================================= @@ -144,13 +183,23 @@ public function store(array $data): Loan $withdrawalId = $withdrawal->id; } + // 상품권: user_id 미지정 시 현재 사용자로 대체 + $loanUserId = $data['user_id'] ?? $userId; + + // 상태 결정: 상품권은 holding, 그 외는 outstanding + $category = $data['category'] ?? null; + $status = $data['status'] + ?? ($category === Loan::CATEGORY_GIFT_CERTIFICATE ? Loan::STATUS_HOLDING : Loan::STATUS_OUTSTANDING); + return Loan::create([ 'tenant_id' => $tenantId, - 'user_id' => $data['user_id'], + 'user_id' => $loanUserId, 'loan_date' => $data['loan_date'], 'amount' => $data['amount'], 'purpose' => $data['purpose'] ?? null, - 'status' => Loan::STATUS_OUTSTANDING, + 'status' => $status, + 'category' => $category, + 'metadata' => $data['metadata'] ?? null, 'withdrawal_id' => $withdrawalId, 'created_by' => $userId, 'updated_by' => $userId, @@ -186,14 +235,29 @@ public function update(int $id, array $data): Loan } } - $loan->fill([ + $fillData = [ 'user_id' => $data['user_id'] ?? $loan->user_id, 'loan_date' => $data['loan_date'] ?? $loan->loan_date, 'amount' => $data['amount'] ?? $loan->amount, 'purpose' => $data['purpose'] ?? $loan->purpose, 'withdrawal_id' => $data['withdrawal_id'] ?? $loan->withdrawal_id, 'updated_by' => $userId, - ]); + ]; + + if (isset($data['category'])) { + $fillData['category'] = $data['category']; + } + if (array_key_exists('metadata', $data)) { + $fillData['metadata'] = $data['metadata']; + } + if (isset($data['status'])) { + $fillData['status'] = $data['status']; + } + if (array_key_exists('settlement_date', $data)) { + $fillData['settlement_date'] = $data['settlement_date']; + } + + $loan->fill($fillData); $loan->save(); diff --git a/database/migrations/2026_03_05_100000_add_v8_fields_to_bills_table.php b/database/migrations/2026_03_05_100000_add_v8_fields_to_bills_table.php new file mode 100644 index 0000000..91bdad1 --- /dev/null +++ b/database/migrations/2026_03_05_100000_add_v8_fields_to_bills_table.php @@ -0,0 +1,195 @@ +string('instrument_type', 30)->default('promissory')->after('bill_type') + ->comment('증권종류: promissory/exchange/cashierCheck/currentCheck'); + $table->string('medium', 20)->default('paper')->after('instrument_type') + ->comment('매체: electronic/paper'); + $table->string('bill_category', 30)->nullable()->after('medium') + ->comment('어음구분: commercial/other'); + + // === 전자어음 정보 === + $table->string('electronic_bill_no', 100)->nullable()->after('is_electronic') + ->comment('전자어음 관리번호'); + $table->string('registration_org', 30)->nullable()->after('electronic_bill_no') + ->comment('등록기관: kftc/bank'); + + // === 환어음 정보 === + $table->string('drawee', 100)->nullable()->after('registration_org') + ->comment('환어음 지급인 (Drawee)'); + $table->string('acceptance_status', 20)->nullable()->after('drawee') + ->comment('인수여부: accepted/pending/refused'); + $table->date('acceptance_date')->nullable()->after('acceptance_status') + ->comment('인수일자'); + $table->date('acceptance_refusal_date')->nullable()->after('acceptance_date') + ->comment('인수거절일'); + $table->string('acceptance_refusal_reason', 50)->nullable()->after('acceptance_refusal_date') + ->comment('인수거절사유'); + + // === 받을어음 전용 === + $table->string('endorsement', 30)->nullable()->after('acceptance_refusal_reason') + ->comment('배서여부: endorsable/nonEndorsable'); + $table->string('endorsement_order', 5)->nullable()->after('endorsement') + ->comment('배서차수: 1~20'); + $table->string('storage_place', 30)->nullable()->after('endorsement_order') + ->comment('보관장소: safe/bank/other'); + $table->string('issuer_bank', 100)->nullable()->after('storage_place') + ->comment('발행은행'); + + // 할인 정보 + $table->boolean('is_discounted')->default(false)->after('issuer_bank') + ->comment('할인여부'); + $table->date('discount_date')->nullable()->after('is_discounted') + ->comment('할인일자'); + $table->string('discount_bank', 100)->nullable()->after('discount_date') + ->comment('할인처 (은행)'); + $table->decimal('discount_rate', 5, 2)->nullable()->after('discount_bank') + ->comment('할인율 (%)'); + $table->decimal('discount_amount', 15, 2)->nullable()->after('discount_rate') + ->comment('할인금액'); + + // 배서양도 정보 + $table->date('endorsement_date')->nullable()->after('discount_amount') + ->comment('배서일자'); + $table->string('endorsee', 100)->nullable()->after('endorsement_date') + ->comment('피배서인 (양수인)'); + $table->string('endorsement_reason', 30)->nullable()->after('endorsee') + ->comment('배서사유: payment/guarantee/collection/other'); + + // 추심 정보 + $table->string('collection_bank', 100)->nullable()->after('endorsement_reason') + ->comment('추심은행'); + $table->date('collection_request_date')->nullable()->after('collection_bank') + ->comment('추심의뢰일'); + $table->decimal('collection_fee', 15, 2)->nullable()->after('collection_request_date') + ->comment('추심수수료'); + $table->date('collection_complete_date')->nullable()->after('collection_fee') + ->comment('추심완료일'); + $table->string('collection_result', 20)->nullable()->after('collection_complete_date') + ->comment('추심결과: success/partial/failed/pending'); + $table->date('collection_deposit_date')->nullable()->after('collection_result') + ->comment('추심입금일'); + $table->decimal('collection_deposit_amount', 15, 2)->nullable()->after('collection_deposit_date') + ->comment('추심입금액 (수수료 차감후)'); + + // === 지급어음 전용 === + $table->string('settlement_bank', 100)->nullable()->after('collection_deposit_amount') + ->comment('결제은행'); + $table->string('payment_method', 30)->nullable()->after('settlement_bank') + ->comment('결제방법: autoTransfer/currentAccount/other'); + $table->date('actual_payment_date')->nullable()->after('payment_method') + ->comment('실제결제일'); + + // === 공통 === + $table->string('payment_place', 30)->nullable()->after('actual_payment_date') + ->comment('지급장소: issuerBank/issuerBankBranch/payerAddress/designatedBank/other'); + $table->string('payment_place_detail', 200)->nullable()->after('payment_place') + ->comment('지급장소 상세 (기타 선택 시)'); + + // === 개서 정보 === + $table->date('renewal_date')->nullable()->after('payment_place_detail') + ->comment('개서일자'); + $table->string('renewal_new_bill_no', 50)->nullable()->after('renewal_date') + ->comment('신어음번호'); + $table->string('renewal_reason', 30)->nullable()->after('renewal_new_bill_no') + ->comment('개서사유: maturityExtension/amountChange/conditionChange/other'); + + // === 소구 정보 === + $table->date('recourse_date')->nullable()->after('renewal_reason') + ->comment('소구일자'); + $table->decimal('recourse_amount', 15, 2)->nullable()->after('recourse_date') + ->comment('소구금액'); + $table->string('recourse_target', 100)->nullable()->after('recourse_amount') + ->comment('소구대상 (청구인)'); + $table->string('recourse_reason', 30)->nullable()->after('recourse_target') + ->comment('소구사유: endorsedDishonor/discountDishonor/other'); + + // === 환매 정보 === + $table->date('buyback_date')->nullable()->after('recourse_reason') + ->comment('환매일자'); + $table->decimal('buyback_amount', 15, 2)->nullable()->after('buyback_date') + ->comment('환매금액'); + $table->string('buyback_bank', 100)->nullable()->after('buyback_amount') + ->comment('환매요청 은행'); + + // === 부도/법적절차 === + $table->date('dishonored_date')->nullable()->after('buyback_bank') + ->comment('부도일자'); + $table->string('dishonored_reason', 30)->nullable()->after('dishonored_date') + ->comment('부도사유'); + $table->boolean('has_protest')->default(false)->after('dishonored_reason') + ->comment('거절증서 작성 여부'); + $table->date('protest_date')->nullable()->after('has_protest') + ->comment('거절증서 작성일'); + $table->date('recourse_notice_date')->nullable()->after('protest_date') + ->comment('소구 통지일'); + $table->date('recourse_notice_deadline')->nullable()->after('recourse_notice_date') + ->comment('소구 통지 기한 (부도일+4영업일)'); + + // === 분할배서 === + $table->boolean('is_split')->default(false)->after('recourse_notice_deadline') + ->comment('분할배서 허용 여부'); + }); + + // bill_installments 에 처리구분, 상대처 추가 + Schema::table('bill_installments', function (Blueprint $table) { + $table->string('type', 30)->default('other')->after('bill_id') + ->comment('처리구분: received/endorsement/splitEndorsement/collection/...'); + $table->string('counterparty', 100)->nullable()->after('amount') + ->comment('상대처 (거래처/은행)'); + }); + } + + public function down(): void + { + Schema::table('bill_installments', function (Blueprint $table) { + $table->dropColumn(['type', 'counterparty']); + }); + + Schema::table('bills', function (Blueprint $table) { + $table->dropColumn([ + 'instrument_type', 'medium', 'bill_category', + 'electronic_bill_no', 'registration_org', + 'drawee', 'acceptance_status', 'acceptance_date', + 'acceptance_refusal_date', 'acceptance_refusal_reason', + 'endorsement', 'endorsement_order', 'storage_place', 'issuer_bank', + 'is_discounted', 'discount_date', 'discount_bank', 'discount_rate', 'discount_amount', + 'endorsement_date', 'endorsee', 'endorsement_reason', + 'collection_bank', 'collection_request_date', 'collection_fee', + 'collection_complete_date', 'collection_result', 'collection_deposit_date', 'collection_deposit_amount', + 'settlement_bank', 'payment_method', 'actual_payment_date', + 'payment_place', 'payment_place_detail', + 'renewal_date', 'renewal_new_bill_no', 'renewal_reason', + 'recourse_date', 'recourse_amount', 'recourse_target', 'recourse_reason', + 'buyback_date', 'buyback_amount', 'buyback_bank', + 'dishonored_date', 'dishonored_reason', 'has_protest', 'protest_date', + 'recourse_notice_date', 'recourse_notice_deadline', + 'is_split', + ]); + }); + } +}; diff --git a/database/migrations/2026_03_05_200000_add_metadata_to_loans_table.php b/database/migrations/2026_03_05_200000_add_metadata_to_loans_table.php new file mode 100644 index 0000000..ea8deab --- /dev/null +++ b/database/migrations/2026_03_05_200000_add_metadata_to_loans_table.php @@ -0,0 +1,28 @@ +json('metadata')->nullable()->after('category'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('loans', function (Blueprint $table) { + $table->dropColumn('metadata'); + }); + } +};