From b43796a558002d460b92f104744171192ae3d392 Mon Sep 17 00:00:00 2001 From: hskwon Date: Wed, 17 Dec 2025 23:23:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[approval]=20=EC=A0=84=EC=9E=90?= =?UTF-8?q?=EA=B2=B0=EC=9E=AC=20=EB=AA=A8=EB=93=88=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마이그레이션 4개 (approval_forms, approval_lines, approvals, approval_steps) - 모델 4개 (ApprovalForm, ApprovalLine, Approval, ApprovalStep) - ApprovalService 비즈니스 로직 (양식/결재선 CRUD, 기안함/결재함/참조함, 결재 액션) - 컨트롤러 3개 (ApprovalFormController, ApprovalLineController, ApprovalController) - FormRequest 13개 (양식/결재선/문서 검증) - Swagger 문서 3개 (26개 엔드포인트) - i18n 메시지/에러 키 추가 - 라우트 26개 등록 --- CURRENT_WORKS.md | 139 +++ LOGICAL_RELATIONSHIPS.md | 68 +- .../Controllers/Api/V1/ApprovalController.php | 186 ++++ .../Api/V1/ApprovalFormController.php | 82 ++ .../Api/V1/ApprovalLineController.php | 71 ++ .../Requests/Approval/FormIndexRequest.php | 29 + .../Requests/Approval/FormStoreRequest.php | 44 + .../Requests/Approval/FormUpdateRequest.php | 40 + .../Requests/Approval/InboxIndexRequest.php | 24 + app/Http/Requests/Approval/IndexRequest.php | 28 + .../Requests/Approval/LineIndexRequest.php | 24 + .../Requests/Approval/LineStoreRequest.php | 40 + .../Requests/Approval/LineUpdateRequest.php | 36 + .../Approval/ReferenceIndexRequest.php | 24 + app/Http/Requests/Approval/RejectRequest.php | 27 + app/Http/Requests/Approval/StoreRequest.php | 43 + app/Http/Requests/Approval/SubmitRequest.php | 37 + app/Http/Requests/Approval/UpdateRequest.php | 31 + app/Models/Tenants/Approval.php | 320 ++++++ app/Models/Tenants/ApprovalForm.php | 133 +++ app/Models/Tenants/ApprovalLine.php | 119 +++ app/Models/Tenants/ApprovalStep.php | 181 ++++ app/Services/ApprovalService.php | 949 ++++++++++++++++++ app/Swagger/v1/ApprovalApi.php | 646 ++++++++++++ app/Swagger/v1/ApprovalFormApi.php | 277 +++++ app/Swagger/v1/ApprovalLineApi.php | 223 ++++ ..._17_100001_create_approval_forms_table.php | 40 + ..._17_100002_create_approval_lines_table.php | 37 + ...25_12_17_100003_create_approvals_table.php | 46 + ..._17_100004_create_approval_steps_table.php | 39 + lang/ko/error.php | 19 + lang/ko/message.php | 20 + routes/api.php | 47 + 33 files changed, 4067 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/ApprovalController.php create mode 100644 app/Http/Controllers/Api/V1/ApprovalFormController.php create mode 100644 app/Http/Controllers/Api/V1/ApprovalLineController.php create mode 100644 app/Http/Requests/Approval/FormIndexRequest.php create mode 100644 app/Http/Requests/Approval/FormStoreRequest.php create mode 100644 app/Http/Requests/Approval/FormUpdateRequest.php create mode 100644 app/Http/Requests/Approval/InboxIndexRequest.php create mode 100644 app/Http/Requests/Approval/IndexRequest.php create mode 100644 app/Http/Requests/Approval/LineIndexRequest.php create mode 100644 app/Http/Requests/Approval/LineStoreRequest.php create mode 100644 app/Http/Requests/Approval/LineUpdateRequest.php create mode 100644 app/Http/Requests/Approval/ReferenceIndexRequest.php create mode 100644 app/Http/Requests/Approval/RejectRequest.php create mode 100644 app/Http/Requests/Approval/StoreRequest.php create mode 100644 app/Http/Requests/Approval/SubmitRequest.php create mode 100644 app/Http/Requests/Approval/UpdateRequest.php create mode 100644 app/Models/Tenants/Approval.php create mode 100644 app/Models/Tenants/ApprovalForm.php create mode 100644 app/Models/Tenants/ApprovalLine.php create mode 100644 app/Models/Tenants/ApprovalStep.php create mode 100644 app/Services/ApprovalService.php create mode 100644 app/Swagger/v1/ApprovalApi.php create mode 100644 app/Swagger/v1/ApprovalFormApi.php create mode 100644 app/Swagger/v1/ApprovalLineApi.php create mode 100644 database/migrations/2025_12_17_100001_create_approval_forms_table.php create mode 100644 database/migrations/2025_12_17_100002_create_approval_lines_table.php create mode 100644 database/migrations/2025_12_17_100003_create_approvals_table.php create mode 100644 database/migrations/2025_12_17_100004_create_approval_steps_table.php diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index 983a383..56c7d84 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -1,5 +1,144 @@ # SAM API 작업 현황 +## 2025-12-17 (화) - 전자결재 모듈 API 개발 + +### 작업 목표 +- `docs/plans/erp-api-development-plan.md` Phase 2의 3.1 전자결재 모듈 +- 결재 양식 (approval_forms), 결재선 템플릿 (approval_lines), 결재 문서 (approvals) CRUD API 구현 +- 기안함, 결재함, 참조함 조회 및 결재 액션 (상신, 승인, 반려, 회수) 기능 + +### 생성된 마이그레이션 (4개) + +| 파일명 | 설명 | +|--------|------| +| `2025_12_17_200001_create_approval_forms_table.php` | 결재 양식 테이블 (name, code, category, template JSON) | +| `2025_12_17_200002_create_approval_lines_table.php` | 결재선 템플릿 테이블 (name, steps JSON, is_default) | +| `2025_12_17_200003_create_approvals_table.php` | 결재 문서 테이블 (form_id, drafter_id, title, content JSON, status) | +| `2025_12_17_200004_create_approval_steps_table.php` | 결재 단계 테이블 (approval_id, step_order, type, user_id, status) | + +### 생성된 모델 (4개) + +**app/Models/Tenants/ApprovalForm.php:** +- 결재 양식 모델 (BelongsToTenant, SoftDeletes) +- Relations: creator(), approvals() +- Scopes: active() + +**app/Models/Tenants/ApprovalLine.php:** +- 결재선 템플릿 모델 (BelongsToTenant, SoftDeletes) +- Relations: creator() +- Methods: getStepCountAttribute() + +**app/Models/Tenants/Approval.php:** +- 결재 문서 모델 (BelongsToTenant, SoftDeletes) +- 상태: draft → pending → approved/rejected/cancelled +- Relations: form(), drafter(), steps(), currentStepApprover() +- Methods: canEdit(), canDelete(), canSubmit(), canAction(), canCancel() + +**app/Models/Tenants/ApprovalStep.php:** +- 결재 단계 모델 (SoftDeletes) +- 단계 유형: approval, agreement, reference +- Relations: approval(), user() +- Methods: isPending(), isApproved(), isRejected() + +### 생성된 서비스 (1개) + +**app/Services/ApprovalService.php:** +- Form CRUD: formIndex, formShow, formStore, formUpdate, formDestroy, formActive +- Line CRUD: lineIndex, lineShow, lineStore, lineUpdate, lineDestroy +- Approval CRUD: show, store, update, destroy +- 기안함: drafts, draftsSummary +- 결재함: inbox, inboxSummary +- 참조함: referenceList +- 액션: submit, approve, reject, cancel, markRead, markUnread + +### 생성된 FormRequest (13개) + +| 파일 | 설명 | +|------|------| +| `FormIndexRequest.php` | 양식 목록 조회 파라미터 | +| `FormStoreRequest.php` | 양식 생성 검증 (name, code, template) | +| `FormUpdateRequest.php` | 양식 수정 검증 | +| `LineIndexRequest.php` | 결재선 목록 조회 파라미터 | +| `LineStoreRequest.php` | 결재선 생성 검증 (name, steps) | +| `LineUpdateRequest.php` | 결재선 수정 검증 | +| `IndexRequest.php` | 기안함 조회 파라미터 | +| `InboxIndexRequest.php` | 결재함 조회 파라미터 | +| `ReferenceIndexRequest.php` | 참조함 조회 파라미터 | +| `StoreRequest.php` | 문서 생성 검증 (form_id, title) | +| `UpdateRequest.php` | 문서 수정 검증 | +| `SubmitRequest.php` | 상신 검증 (steps 필수) | +| `RejectRequest.php` | 반려 검증 (comment 필수) | + +### 생성된 컨트롤러 (3개) + +| 파일 | 엔드포인트 | +|------|-----------| +| `ApprovalFormController.php` | index, active, show, store, update, destroy | +| `ApprovalLineController.php` | index, show, store, update, destroy | +| `ApprovalController.php` | drafts, draftsSummary, inbox, inboxSummary, reference, show, store, update, destroy, submit, approve, reject, cancel, markRead, markUnread | + +### 수정된 파일 + +**routes/api.php:** +- Approval Forms 라우트 그룹 추가 (6개 라우트) +- Approval Lines 라우트 그룹 추가 (5개 라우트) +- Approvals 라우트 그룹 추가 (15개 라우트) + +**lang/ko/message.php:** +- approval 섹션 추가 (16개 키) + +**lang/ko/error.php:** +- approval 섹션 추가 (15개 키) + +### 생성된 Swagger 문서 (3개) + +| 파일 | 설명 | +|------|------| +| `app/Swagger/v1/ApprovalFormApi.php` | 결재 양식 API 문서 (6개 엔드포인트) | +| `app/Swagger/v1/ApprovalLineApi.php` | 결재선 API 문서 (5개 엔드포인트) | +| `app/Swagger/v1/ApprovalApi.php` | 전자결재 API 문서 (15개 엔드포인트) | + +### API 엔드포인트 + +**결재 양식 API (Approval Forms):** +- `GET /api/v1/approval-forms` - 목록 조회 +- `POST /api/v1/approval-forms` - 생성 +- `GET /api/v1/approval-forms/active` - 활성 양식 (셀렉트박스용) +- `GET /api/v1/approval-forms/{id}` - 상세 조회 +- `PATCH /api/v1/approval-forms/{id}` - 수정 +- `DELETE /api/v1/approval-forms/{id}` - 삭제 + +**결재선 API (Approval Lines):** +- `GET /api/v1/approval-lines` - 목록 조회 +- `POST /api/v1/approval-lines` - 생성 +- `GET /api/v1/approval-lines/{id}` - 상세 조회 +- `PATCH /api/v1/approval-lines/{id}` - 수정 +- `DELETE /api/v1/approval-lines/{id}` - 삭제 + +**전자결재 API (Approvals):** +- `GET /api/v1/approvals/drafts` - 기안함 +- `GET /api/v1/approvals/drafts/summary` - 기안함 현황 +- `GET /api/v1/approvals/inbox` - 결재함 +- `GET /api/v1/approvals/inbox/summary` - 결재함 현황 +- `GET /api/v1/approvals/reference` - 참조함 +- `POST /api/v1/approvals` - 문서 생성 +- `GET /api/v1/approvals/{id}` - 상세 조회 +- `PATCH /api/v1/approvals/{id}` - 수정 +- `DELETE /api/v1/approvals/{id}` - 삭제 +- `POST /api/v1/approvals/{id}/submit` - 상신 +- `POST /api/v1/approvals/{id}/approve` - 승인 +- `POST /api/v1/approvals/{id}/reject` - 반려 +- `POST /api/v1/approvals/{id}/cancel` - 회수 +- `POST /api/v1/approvals/{id}/read` - 열람 +- `POST /api/v1/approvals/{id}/unread` - 미열람 + +### 검증 완료 +- ✅ Pint 스타일 검사 통과 (19개 파일) +- ✅ 라우트 등록 확인 (26개) +- ✅ Swagger 문서 생성 완료 + +--- + ## 2025-12-17 (화) - 매출/매입 관리 API 개발 ### 작업 목표 diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index f870cfd..41a4318 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2025-12-15 16:04:28 +> **자동 생성**: 2025-12-17 23:09:53 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -166,8 +166,8 @@ ### items **모델**: `App\Models\Items\Item` - **category()**: belongsTo → `categories` +- **files()**: hasMany → `files` - **details()**: hasOne → `item_details` -- **files()**: morphMany → `files` ### item_details **모델**: `App\Models\Items\ItemDetail` @@ -295,6 +295,17 @@ ### role_menu_permissions - **role()**: belongsTo → `roles` - **menu()**: belongsTo → `menus` +### prices +**모델**: `App\Models\Products\Price` + +- **clientGroup()**: belongsTo → `client_groups` +- **revisions()**: hasMany → `price_revisions` + +### price_revisions +**모델**: `App\Models\Products\PriceRevision` + +- **price()**: belongsTo → `prices` + ### lots **모델**: `App\Models\Qualitys\Lot` @@ -335,6 +346,20 @@ ### attendances - **creator()**: belongsTo → `users` - **updater()**: belongsTo → `users` +### bank_accounts +**모델**: `App\Models\Tenants\BankAccount` + +- **assignedUser()**: belongsTo → `users` +- **creator()**: belongsTo → `users` +- **updater()**: belongsTo → `users` + +### cards +**모델**: `App\Models\Tenants\Card` + +- **assignedUser()**: belongsTo → `users` +- **creator()**: belongsTo → `users` +- **updater()**: belongsTo → `users` + ### departments **모델**: `App\Models\Tenants\Department` @@ -344,6 +369,24 @@ ### departments - **users()**: belongsToMany → `users` - **permissionOverrides()**: morphMany → `permission_overrides` +### deposits +**모델**: `App\Models\Tenants\Deposit` + +- **bankAccount()**: belongsTo → `bank_accounts` + +### leaves +**모델**: `App\Models\Tenants\Leave` + +- **user()**: belongsTo → `users` +- **approver()**: belongsTo → `users` +- **creator()**: belongsTo → `users` +- **updater()**: belongsTo → `users` + +### leave_balances +**모델**: `App\Models\Tenants\LeaveBalance` + +- **user()**: belongsTo → `users` + ### payments **모델**: `App\Models\Tenants\Payment` @@ -360,11 +403,27 @@ ### plans - **subscriptions()**: hasMany → `subscriptions` +### purchases +**모델**: `App\Models\Tenants\Purchase` + +- **withdrawal()**: belongsTo → `withdrawals` + +### sales +**모델**: `App\Models\Tenants\Sale` + +- **deposit()**: belongsTo → `deposits` + ### setting_field_defs **모델**: `App\Models\Tenants\SettingFieldDef` - **tenantSettings()**: hasMany → `tenant_field_settings` +### sites +**모델**: `App\Models\Tenants\Site` + +- **creator()**: belongsTo → `users` +- **updater()**: belongsTo → `users` + ### subscriptions **모델**: `App\Models\Tenants\Subscription` @@ -411,3 +470,8 @@ ### tenant_user_profiles - **department()**: belongsTo → `departments` - **manager()**: belongsTo → `users` +### withdrawals +**모델**: `App\Models\Tenants\Withdrawal` + +- **bankAccount()**: belongsTo → `bank_accounts` + diff --git a/app/Http/Controllers/Api/V1/ApprovalController.php b/app/Http/Controllers/Api/V1/ApprovalController.php new file mode 100644 index 0000000..1e75426 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ApprovalController.php @@ -0,0 +1,186 @@ +service->drafts($request->validated()); + }, __('message.fetched')); + } + + /** + * 기안함 현황 카드 + * GET /v1/approvals/drafts/summary + */ + public function draftsSummary(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->draftsSummary(); + }, __('message.fetched')); + } + + /** + * 결재함 - 내가 결재해야 할 문서 목록 + * GET /v1/approvals/inbox + */ + public function inbox(InboxIndexRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->inbox($request->validated()); + }, __('message.fetched')); + } + + /** + * 결재함 현황 카드 + * GET /v1/approvals/inbox/summary + */ + public function inboxSummary(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->inboxSummary(); + }, __('message.fetched')); + } + + /** + * 참조함 - 내가 참조된 문서 목록 + * GET /v1/approvals/reference + */ + public function reference(ReferenceIndexRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->reference($request->validated()); + }, __('message.fetched')); + } + + /** + * 결재 문서 상세 + * GET /v1/approvals/{id} + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } + + /** + * 결재 문서 생성 (임시저장 또는 상신) + * POST /v1/approvals + */ + public function store(StoreRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + }, __('message.approval.created')); + } + + /** + * 결재 문서 수정 (임시저장 상태만) + * PATCH /v1/approvals/{id} + */ + public function update(int $id, UpdateRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->update($id, $request->validated()); + }, __('message.updated')); + } + + /** + * 결재 문서 삭제 (임시저장 상태만) + * DELETE /v1/approvals/{id} + */ + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->destroy($id); + }, __('message.deleted')); + } + + /** + * 결재 상신 + * POST /v1/approvals/{id}/submit + */ + public function submit(int $id, SubmitRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->submit($id, $request->validated()); + }, __('message.approval.submitted')); + } + + /** + * 결재 승인 + * POST /v1/approvals/{id}/approve + */ + public function approve(int $id, Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->approve($id, $request->input('comment')); + }, __('message.approval.approved')); + } + + /** + * 결재 반려 + * POST /v1/approvals/{id}/reject + */ + public function reject(int $id, RejectRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->reject($id, $request->input('comment')); + }, __('message.approval.rejected')); + } + + /** + * 결재 회수 (기안자만) + * POST /v1/approvals/{id}/cancel + */ + public function cancel(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->cancel($id); + }, __('message.approval.cancelled')); + } + + /** + * 참조 열람 처리 + * POST /v1/approvals/{id}/read + */ + public function markRead(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->markRead($id); + }, __('message.approval.marked_read')); + } + + /** + * 참조 미열람 처리 + * POST /v1/approvals/{id}/unread + */ + public function markUnread(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->markUnread($id); + }, __('message.approval.marked_unread')); + } +} diff --git a/app/Http/Controllers/Api/V1/ApprovalFormController.php b/app/Http/Controllers/Api/V1/ApprovalFormController.php new file mode 100644 index 0000000..a2b33f7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ApprovalFormController.php @@ -0,0 +1,82 @@ +service->formIndex($request->validated()); + }, __('message.fetched')); + } + + /** + * 활성 결재 양식 목록 (셀렉트박스용) + * GET /v1/approval-forms/active + */ + public function active(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->formActive(); + }, __('message.fetched')); + } + + /** + * 결재 양식 상세 + * GET /v1/approval-forms/{id} + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->formShow($id); + }, __('message.fetched')); + } + + /** + * 결재 양식 생성 + * POST /v1/approval-forms + */ + public function store(FormStoreRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->formStore($request->validated()); + }, __('message.approval.form_created')); + } + + /** + * 결재 양식 수정 + * PATCH /v1/approval-forms/{id} + */ + public function update(int $id, FormUpdateRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->formUpdate($id, $request->validated()); + }, __('message.updated')); + } + + /** + * 결재 양식 삭제 + * DELETE /v1/approval-forms/{id} + */ + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->formDestroy($id); + }, __('message.deleted')); + } +} diff --git a/app/Http/Controllers/Api/V1/ApprovalLineController.php b/app/Http/Controllers/Api/V1/ApprovalLineController.php new file mode 100644 index 0000000..5bd0b20 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ApprovalLineController.php @@ -0,0 +1,71 @@ +service->lineIndex($request->validated()); + }, __('message.fetched')); + } + + /** + * 결재선 상세 + * GET /v1/approval-lines/{id} + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->lineShow($id); + }, __('message.fetched')); + } + + /** + * 결재선 생성 + * POST /v1/approval-lines + */ + public function store(LineStoreRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->lineStore($request->validated()); + }, __('message.approval.line_created')); + } + + /** + * 결재선 수정 + * PATCH /v1/approval-lines/{id} + */ + public function update(int $id, LineUpdateRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->lineUpdate($id, $request->validated()); + }, __('message.updated')); + } + + /** + * 결재선 삭제 + * DELETE /v1/approval-lines/{id} + */ + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->lineDestroy($id); + }, __('message.deleted')); + } +} diff --git a/app/Http/Requests/Approval/FormIndexRequest.php b/app/Http/Requests/Approval/FormIndexRequest.php new file mode 100644 index 0000000..b4c657e --- /dev/null +++ b/app/Http/Requests/Approval/FormIndexRequest.php @@ -0,0 +1,29 @@ + "nullable|string|in:{$categories}", + 'is_active' => 'nullable|boolean', + 'search' => 'nullable|string|max:100', + 'sort_by' => 'nullable|string|in:created_at,name,code,category', + 'sort_dir' => 'nullable|string|in:asc,desc', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]; + } +} diff --git a/app/Http/Requests/Approval/FormStoreRequest.php b/app/Http/Requests/Approval/FormStoreRequest.php new file mode 100644 index 0000000..d85a76f --- /dev/null +++ b/app/Http/Requests/Approval/FormStoreRequest.php @@ -0,0 +1,44 @@ + 'required|string|max:100', + 'code' => 'required|string|max:50|regex:/^[a-zA-Z0-9_-]+$/', + 'category' => "nullable|string|in:{$categories}", + 'template' => 'required|array', + 'template.fields' => 'required|array', + 'template.fields.*.name' => 'required|string|max:50', + 'template.fields.*.type' => 'required|string|in:text,textarea,number,date,select,checkbox,file', + 'template.fields.*.label' => 'required|string|max:100', + 'template.fields.*.required' => 'nullable|boolean', + 'template.fields.*.options' => 'nullable|array', + 'is_active' => 'nullable|boolean', + ]; + } + + public function messages(): array + { + return [ + 'name.required' => __('validation.required', ['attribute' => '양식명']), + 'code.required' => __('validation.required', ['attribute' => '양식코드']), + 'code.regex' => __('validation.regex', ['attribute' => '양식코드']), + 'template.required' => __('validation.required', ['attribute' => '템플릿']), + 'template.fields.required' => __('validation.required', ['attribute' => '필드']), + ]; + } +} diff --git a/app/Http/Requests/Approval/FormUpdateRequest.php b/app/Http/Requests/Approval/FormUpdateRequest.php new file mode 100644 index 0000000..5fcf495 --- /dev/null +++ b/app/Http/Requests/Approval/FormUpdateRequest.php @@ -0,0 +1,40 @@ + 'nullable|string|max:100', + 'code' => 'nullable|string|max:50|regex:/^[a-zA-Z0-9_-]+$/', + 'category' => "nullable|string|in:{$categories}", + 'template' => 'nullable|array', + 'template.fields' => 'required_with:template|array', + 'template.fields.*.name' => 'required_with:template.fields|string|max:50', + 'template.fields.*.type' => 'required_with:template.fields|string|in:text,textarea,number,date,select,checkbox,file', + 'template.fields.*.label' => 'required_with:template.fields|string|max:100', + 'template.fields.*.required' => 'nullable|boolean', + 'template.fields.*.options' => 'nullable|array', + 'is_active' => 'nullable|boolean', + ]; + } + + public function messages(): array + { + return [ + 'code.regex' => __('validation.regex', ['attribute' => '양식코드']), + ]; + } +} diff --git a/app/Http/Requests/Approval/InboxIndexRequest.php b/app/Http/Requests/Approval/InboxIndexRequest.php new file mode 100644 index 0000000..48d44c2 --- /dev/null +++ b/app/Http/Requests/Approval/InboxIndexRequest.php @@ -0,0 +1,24 @@ + 'nullable|string|in:requested,scheduled,completed,rejected', + 'sort_by' => 'nullable|string|in:created_at,drafted_at,completed_at,title', + 'sort_dir' => 'nullable|string|in:asc,desc', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]; + } +} diff --git a/app/Http/Requests/Approval/IndexRequest.php b/app/Http/Requests/Approval/IndexRequest.php new file mode 100644 index 0000000..a09ae6f --- /dev/null +++ b/app/Http/Requests/Approval/IndexRequest.php @@ -0,0 +1,28 @@ + "nullable|string|in:{$statuses}", + 'search' => 'nullable|string|max:100', + 'sort_by' => 'nullable|string|in:created_at,drafted_at,completed_at,title,status', + 'sort_dir' => 'nullable|string|in:asc,desc', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]; + } +} diff --git a/app/Http/Requests/Approval/LineIndexRequest.php b/app/Http/Requests/Approval/LineIndexRequest.php new file mode 100644 index 0000000..c63d2c1 --- /dev/null +++ b/app/Http/Requests/Approval/LineIndexRequest.php @@ -0,0 +1,24 @@ + 'nullable|string|max:100', + 'sort_by' => 'nullable|string|in:created_at,name,is_default', + 'sort_dir' => 'nullable|string|in:asc,desc', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]; + } +} diff --git a/app/Http/Requests/Approval/LineStoreRequest.php b/app/Http/Requests/Approval/LineStoreRequest.php new file mode 100644 index 0000000..7722cd6 --- /dev/null +++ b/app/Http/Requests/Approval/LineStoreRequest.php @@ -0,0 +1,40 @@ + 'required|string|max:100', + 'steps' => 'required|array|min:1', + 'steps.*.type' => "required|string|in:{$stepTypes}", + 'steps.*.user_id' => 'required|integer|exists:users,id', + 'is_default' => 'nullable|boolean', + ]; + } + + public function messages(): array + { + return [ + 'name.required' => __('validation.required', ['attribute' => '결재선명']), + 'steps.required' => __('validation.required', ['attribute' => '결재단계']), + 'steps.min' => __('validation.min.array', ['attribute' => '결재단계', 'min' => 1]), + 'steps.*.type.required' => __('validation.required', ['attribute' => '단계유형']), + 'steps.*.type.in' => __('validation.in', ['attribute' => '단계유형']), + 'steps.*.user_id.required' => __('validation.required', ['attribute' => '결재자']), + 'steps.*.user_id.exists' => __('validation.exists', ['attribute' => '결재자']), + ]; + } +} diff --git a/app/Http/Requests/Approval/LineUpdateRequest.php b/app/Http/Requests/Approval/LineUpdateRequest.php new file mode 100644 index 0000000..2d55792 --- /dev/null +++ b/app/Http/Requests/Approval/LineUpdateRequest.php @@ -0,0 +1,36 @@ + 'nullable|string|max:100', + 'steps' => 'nullable|array|min:1', + 'steps.*.type' => "required_with:steps|string|in:{$stepTypes}", + 'steps.*.user_id' => 'required_with:steps|integer|exists:users,id', + 'is_default' => 'nullable|boolean', + ]; + } + + public function messages(): array + { + return [ + 'steps.min' => __('validation.min.array', ['attribute' => '결재단계', 'min' => 1]), + 'steps.*.type.in' => __('validation.in', ['attribute' => '단계유형']), + 'steps.*.user_id.exists' => __('validation.exists', ['attribute' => '결재자']), + ]; + } +} diff --git a/app/Http/Requests/Approval/ReferenceIndexRequest.php b/app/Http/Requests/Approval/ReferenceIndexRequest.php new file mode 100644 index 0000000..bb2120e --- /dev/null +++ b/app/Http/Requests/Approval/ReferenceIndexRequest.php @@ -0,0 +1,24 @@ + 'nullable|boolean', + 'sort_by' => 'nullable|string|in:created_at,drafted_at,completed_at,title', + 'sort_dir' => 'nullable|string|in:asc,desc', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]; + } +} diff --git a/app/Http/Requests/Approval/RejectRequest.php b/app/Http/Requests/Approval/RejectRequest.php new file mode 100644 index 0000000..fb6122c --- /dev/null +++ b/app/Http/Requests/Approval/RejectRequest.php @@ -0,0 +1,27 @@ + 'required|string|max:1000', + ]; + } + + public function messages(): array + { + return [ + 'comment.required' => __('error.approval.reject_comment_required'), + ]; + } +} diff --git a/app/Http/Requests/Approval/StoreRequest.php b/app/Http/Requests/Approval/StoreRequest.php new file mode 100644 index 0000000..5b9f427 --- /dev/null +++ b/app/Http/Requests/Approval/StoreRequest.php @@ -0,0 +1,43 @@ + 'required|integer|exists:approval_forms,id', + 'title' => 'required|string|max:200', + 'content' => 'required|array', + 'attachments' => 'nullable|array', + 'attachments.*' => 'integer|exists:files,id', + 'submit' => 'nullable|boolean', + 'steps' => 'required_if:submit,true|array|min:1', + 'steps.*.type' => "required_with:steps|string|in:{$stepTypes}", + 'steps.*.user_id' => 'required_with:steps|integer|exists:users,id', + ]; + } + + public function messages(): array + { + return [ + 'form_id.required' => __('validation.required', ['attribute' => '결재양식']), + 'form_id.exists' => __('validation.exists', ['attribute' => '결재양식']), + 'title.required' => __('validation.required', ['attribute' => '제목']), + 'content.required' => __('validation.required', ['attribute' => '내용']), + 'steps.required_if' => __('error.approval.steps_required'), + 'steps.min' => __('validation.min.array', ['attribute' => '결재단계', 'min' => 1]), + ]; + } +} diff --git a/app/Http/Requests/Approval/SubmitRequest.php b/app/Http/Requests/Approval/SubmitRequest.php new file mode 100644 index 0000000..dc94491 --- /dev/null +++ b/app/Http/Requests/Approval/SubmitRequest.php @@ -0,0 +1,37 @@ + 'required|array|min:1', + 'steps.*.type' => "required|string|in:{$stepTypes}", + 'steps.*.user_id' => 'required|integer|exists:users,id', + ]; + } + + public function messages(): array + { + return [ + 'steps.required' => __('error.approval.steps_required'), + 'steps.min' => __('validation.min.array', ['attribute' => '결재단계', 'min' => 1]), + 'steps.*.type.required' => __('validation.required', ['attribute' => '단계유형']), + 'steps.*.type.in' => __('validation.in', ['attribute' => '단계유형']), + 'steps.*.user_id.required' => __('validation.required', ['attribute' => '결재자']), + 'steps.*.user_id.exists' => __('validation.exists', ['attribute' => '결재자']), + ]; + } +} diff --git a/app/Http/Requests/Approval/UpdateRequest.php b/app/Http/Requests/Approval/UpdateRequest.php new file mode 100644 index 0000000..b3af1c9 --- /dev/null +++ b/app/Http/Requests/Approval/UpdateRequest.php @@ -0,0 +1,31 @@ + 'nullable|integer|exists:approval_forms,id', + 'title' => 'nullable|string|max:200', + 'content' => 'nullable|array', + 'attachments' => 'nullable|array', + 'attachments.*' => 'integer|exists:files,id', + ]; + } + + public function messages(): array + { + return [ + 'form_id.exists' => __('validation.exists', ['attribute' => '결재양식']), + ]; + } +} diff --git a/app/Models/Tenants/Approval.php b/app/Models/Tenants/Approval.php new file mode 100644 index 0000000..f4e1edb --- /dev/null +++ b/app/Models/Tenants/Approval.php @@ -0,0 +1,320 @@ + 'array', + 'attachments' => 'array', + 'drafted_at' => 'datetime', + 'completed_at' => 'datetime', + 'current_step' => 'integer', + ]; + + protected $fillable = [ + 'tenant_id', + 'document_number', + 'form_id', + 'title', + 'content', + 'status', + 'drafter_id', + 'drafted_at', + 'completed_at', + 'current_step', + 'attachments', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + protected $attributes = [ + 'status' => 'draft', + 'current_step' => 0, + ]; + + // ========================================================================= + // 상태 상수 + // ========================================================================= + + public const STATUS_DRAFT = 'draft'; // 임시저장 + + public const STATUS_PENDING = 'pending'; // 결재 진행중 + + public const STATUS_APPROVED = 'approved'; // 승인 완료 + + public const STATUS_REJECTED = 'rejected'; // 반려 + + public const STATUS_CANCELLED = 'cancelled'; // 회수/취소 + + public const STATUSES = [ + self::STATUS_DRAFT, + self::STATUS_PENDING, + self::STATUS_APPROVED, + self::STATUS_REJECTED, + self::STATUS_CANCELLED, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 결재 양식 + */ + public function form(): BelongsTo + { + return $this->belongsTo(ApprovalForm::class, 'form_id'); + } + + /** + * 기안자 + */ + public function drafter(): BelongsTo + { + return $this->belongsTo(User::class, 'drafter_id'); + } + + /** + * 결재 단계들 + */ + public function steps(): HasMany + { + return $this->hasMany(ApprovalStep::class, 'approval_id')->orderBy('step_order'); + } + + /** + * 결재자 단계들 (참조 제외) + */ + public function approverSteps(): HasMany + { + return $this->hasMany(ApprovalStep::class, 'approval_id') + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->orderBy('step_order'); + } + + /** + * 참조자 단계들 + */ + public function referenceSteps(): HasMany + { + return $this->hasMany(ApprovalStep::class, 'approval_id') + ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE) + ->orderBy('step_order'); + } + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + /** + * 특정 상태 + */ + public function scopeWithStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * 임시저장 + */ + public function scopeDraft($query) + { + return $query->where('status', self::STATUS_DRAFT); + } + + /** + * 결재 진행중 + */ + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + /** + * 승인 완료 + */ + public function scopeApproved($query) + { + return $query->where('status', self::STATUS_APPROVED); + } + + /** + * 반려 + */ + public function scopeRejected($query) + { + return $query->where('status', self::STATUS_REJECTED); + } + + /** + * 완료됨 (승인 또는 반려) + */ + public function scopeCompleted($query) + { + return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED]); + } + + /** + * 특정 기안자 + */ + public function scopeByDrafter($query, int $userId) + { + return $query->where('drafter_id', $userId); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 수정 가능 여부 (임시저장 상태만) + */ + public function isEditable(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + /** + * 상신 가능 여부 + */ + public function isSubmittable(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + /** + * 승인/반려 가능 여부 + */ + public function isActionable(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * 회수 가능 여부 (기안자만, 진행중 상태) + */ + public function isCancellable(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * 삭제 가능 여부 (임시저장만) + */ + public function isDeletable(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + /** + * 상태 라벨 + */ + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_DRAFT => '임시저장', + self::STATUS_PENDING => '진행', + self::STATUS_APPROVED => '완료', + self::STATUS_REJECTED => '반려', + self::STATUS_CANCELLED => '회수', + default => $this->status, + }; + } + + /** + * 현재 결재자 확인 + */ + public function getCurrentApproverStep(): ?ApprovalStep + { + return $this->steps() + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->where('status', ApprovalStep::STATUS_PENDING) + ->orderBy('step_order') + ->first(); + } + + /** + * 특정 사용자가 현재 결재자인지 확인 + */ + public function isCurrentApprover(int $userId): bool + { + $currentStep = $this->getCurrentApproverStep(); + + return $currentStep && $currentStep->approver_id === $userId; + } + + /** + * 특정 사용자가 참조자인지 확인 + */ + public function isReferee(int $userId): bool + { + return $this->referenceSteps() + ->where('approver_id', $userId) + ->exists(); + } + + /** + * 결재 진행률 + */ + public function getProgressAttribute(): array + { + $totalSteps = $this->approverSteps()->count(); + $completedSteps = $this->approverSteps() + ->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED]) + ->count(); + + return [ + 'total' => $totalSteps, + 'completed' => $completedSteps, + 'percentage' => $totalSteps > 0 ? round(($completedSteps / $totalSteps) * 100) : 0, + ]; + } +} diff --git a/app/Models/Tenants/ApprovalForm.php b/app/Models/Tenants/ApprovalForm.php new file mode 100644 index 0000000..5158788 --- /dev/null +++ b/app/Models/Tenants/ApprovalForm.php @@ -0,0 +1,133 @@ + 'array', + 'is_active' => 'boolean', + ]; + + protected $fillable = [ + 'tenant_id', + 'name', + 'code', + 'category', + 'template', + 'is_active', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + protected $attributes = [ + 'is_active' => true, + ]; + + // ========================================================================= + // 카테고리 상수 + // ========================================================================= + + public const CATEGORY_REQUEST = 'request'; // 품의서 + + public const CATEGORY_EXPENSE = 'expense'; // 지출결의서 + + public const CATEGORY_EXPENSE_ESTIMATE = 'expense_estimate'; // 지출 예상 내역서 + + public const CATEGORIES = [ + self::CATEGORY_REQUEST, + self::CATEGORY_EXPENSE, + self::CATEGORY_EXPENSE_ESTIMATE, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 이 양식으로 생성된 결재 문서들 + */ + public function approvals(): HasMany + { + return $this->hasMany(Approval::class, 'form_id'); + } + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + /** + * 활성 양식만 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 특정 카테고리 + */ + public function scopeCategory($query, string $category) + { + return $query->where('category', $category); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 카테고리 라벨 + */ + public function getCategoryLabelAttribute(): string + { + return match ($this->category) { + self::CATEGORY_REQUEST => '품의서', + self::CATEGORY_EXPENSE => '지출결의서', + self::CATEGORY_EXPENSE_ESTIMATE => '지출 예상 내역서', + default => $this->category ?? '', + }; + } +} diff --git a/app/Models/Tenants/ApprovalLine.php b/app/Models/Tenants/ApprovalLine.php new file mode 100644 index 0000000..e156205 --- /dev/null +++ b/app/Models/Tenants/ApprovalLine.php @@ -0,0 +1,119 @@ + 'array', + 'is_default' => 'boolean', + ]; + + protected $fillable = [ + 'tenant_id', + 'name', + 'steps', + 'is_default', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + protected $attributes = [ + 'is_default' => false, + ]; + + // ========================================================================= + // 단계 유형 상수 + // ========================================================================= + + public const STEP_TYPE_APPROVAL = 'approval'; // 결재 + + public const STEP_TYPE_AGREEMENT = 'agreement'; // 합의 + + public const STEP_TYPE_REFERENCE = 'reference'; // 참조 + + public const STEP_TYPES = [ + self::STEP_TYPE_APPROVAL, + self::STEP_TYPE_AGREEMENT, + self::STEP_TYPE_REFERENCE, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + /** + * 기본 결재선만 + */ + public function scopeDefault($query) + { + return $query->where('is_default', true); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 결재선 단계 수 + */ + public function getStepCountAttribute(): int + { + return count($this->steps ?? []); + } + + /** + * 결재자 목록 (user_id만) + */ + public function getApproverIdsAttribute(): array + { + return collect($this->steps ?? []) + ->pluck('user_id') + ->filter() + ->values() + ->toArray(); + } +} diff --git a/app/Models/Tenants/ApprovalStep.php b/app/Models/Tenants/ApprovalStep.php new file mode 100644 index 0000000..65540ea --- /dev/null +++ b/app/Models/Tenants/ApprovalStep.php @@ -0,0 +1,181 @@ + 'integer', + 'acted_at' => 'datetime', + 'is_read' => 'boolean', + 'read_at' => 'datetime', + ]; + + protected $fillable = [ + 'approval_id', + 'step_order', + 'step_type', + 'approver_id', + 'status', + 'comment', + 'acted_at', + 'is_read', + 'read_at', + ]; + + protected $attributes = [ + 'status' => 'pending', + 'is_read' => false, + ]; + + // ========================================================================= + // 상태 상수 + // ========================================================================= + + public const STATUS_PENDING = 'pending'; // 대기 + + public const STATUS_APPROVED = 'approved'; // 승인 + + public const STATUS_REJECTED = 'rejected'; // 반려 + + public const STATUS_SKIPPED = 'skipped'; // 건너뜀 + + public const STATUSES = [ + self::STATUS_PENDING, + self::STATUS_APPROVED, + self::STATUS_REJECTED, + self::STATUS_SKIPPED, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 결재 문서 + */ + public function approval(): BelongsTo + { + return $this->belongsTo(Approval::class, 'approval_id'); + } + + /** + * 결재자/참조자 + */ + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approver_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + /** + * 대기 중 + */ + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + /** + * 승인됨 + */ + public function scopeApproved($query) + { + return $query->where('status', self::STATUS_APPROVED); + } + + /** + * 특정 결재자 + */ + public function scopeByApprover($query, int $userId) + { + return $query->where('approver_id', $userId); + } + + /** + * 결재 단계만 (참조 제외) + */ + public function scopeApprovalOnly($query) + { + return $query->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]); + } + + /** + * 참조만 + */ + public function scopeReferenceOnly($query) + { + return $query->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 결재 가능 여부 + */ + public function isActionable(): bool + { + return $this->status === self::STATUS_PENDING + && in_array($this->step_type, [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]); + } + + /** + * 참조인지 확인 + */ + public function isReference(): bool + { + return $this->step_type === ApprovalLine::STEP_TYPE_REFERENCE; + } + + /** + * 상태 라벨 + */ + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => '대기', + self::STATUS_APPROVED => '승인', + self::STATUS_REJECTED => '반려', + self::STATUS_SKIPPED => '건너뜀', + default => $this->status, + }; + } + + /** + * 단계 유형 라벨 + */ + public function getStepTypeLabelAttribute(): string + { + return match ($this->step_type) { + ApprovalLine::STEP_TYPE_APPROVAL => '결재', + ApprovalLine::STEP_TYPE_AGREEMENT => '합의', + ApprovalLine::STEP_TYPE_REFERENCE => '참조', + default => $this->step_type, + }; + } +} diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php new file mode 100644 index 0000000..cd4a919 --- /dev/null +++ b/app/Services/ApprovalService.php @@ -0,0 +1,949 @@ +tenantId(); + + $query = ApprovalForm::query() + ->where('tenant_id', $tenantId) + ->with('creator:id,name'); + + // 카테고리 필터 + if (! empty($params['category'])) { + $query->where('category', $params['category']); + } + + // 활성 상태 필터 + if (isset($params['is_active'])) { + $query->where('is_active', $params['is_active']); + } + + // 검색 + if (! empty($params['search'])) { + $query->where(function ($q) use ($params) { + $q->where('name', 'like', "%{$params['search']}%") + ->orWhere('code', 'like', "%{$params['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 formActive(): Collection + { + $tenantId = $this->tenantId(); + + return ApprovalForm::query() + ->where('tenant_id', $tenantId) + ->active() + ->orderBy('name') + ->get(['id', 'name', 'code', 'category']); + } + + /** + * 결재 양식 상세 + */ + public function formShow(int $id): ApprovalForm + { + $tenantId = $this->tenantId(); + + return ApprovalForm::query() + ->where('tenant_id', $tenantId) + ->with('creator:id,name') + ->findOrFail($id); + } + + /** + * 결재 양식 생성 + */ + public function formStore(array $data): ApprovalForm + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 코드 중복 확인 + $exists = ApprovalForm::query() + ->where('tenant_id', $tenantId) + ->where('code', $data['code']) + ->exists(); + + if ($exists) { + throw new BadRequestHttpException(__('error.approval.form_code_exists')); + } + + return ApprovalForm::create([ + 'tenant_id' => $tenantId, + 'name' => $data['name'], + 'code' => $data['code'], + 'category' => $data['category'] ?? null, + 'template' => $data['template'], + 'is_active' => $data['is_active'] ?? true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + } + + /** + * 결재 양식 수정 + */ + public function formUpdate(int $id, array $data): ApprovalForm + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $form = ApprovalForm::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 코드 중복 확인 (자기 자신 제외) + if (isset($data['code']) && $data['code'] !== $form->code) { + $exists = ApprovalForm::query() + ->where('tenant_id', $tenantId) + ->where('code', $data['code']) + ->where('id', '!=', $id) + ->exists(); + + if ($exists) { + throw new BadRequestHttpException(__('error.approval.form_code_exists')); + } + } + + $form->fill([ + 'name' => $data['name'] ?? $form->name, + 'code' => $data['code'] ?? $form->code, + 'category' => $data['category'] ?? $form->category, + 'template' => $data['template'] ?? $form->template, + 'is_active' => $data['is_active'] ?? $form->is_active, + 'updated_by' => $userId, + ]); + + $form->save(); + + return $form->fresh('creator:id,name'); + } + + /** + * 결재 양식 삭제 + */ + public function formDestroy(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $form = ApprovalForm::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 사용 중인 양식인지 확인 + $inUse = Approval::query() + ->where('form_id', $id) + ->exists(); + + if ($inUse) { + throw new BadRequestHttpException(__('error.approval.form_in_use')); + } + + $form->deleted_by = $userId; + $form->save(); + $form->delete(); + + return true; + } + + // ========================================================================= + // 결재선 템플릿 관리 + // ========================================================================= + + /** + * 결재선 목록 + */ + public function lineIndex(array $params): LengthAwarePaginator + { + $tenantId = $this->tenantId(); + + $query = ApprovalLine::query() + ->where('tenant_id', $tenantId) + ->with('creator:id,name'); + + // 검색 + if (! empty($params['search'])) { + $query->where('name', 'like', "%{$params['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 lineShow(int $id): ApprovalLine + { + $tenantId = $this->tenantId(); + + return ApprovalLine::query() + ->where('tenant_id', $tenantId) + ->with('creator:id,name') + ->findOrFail($id); + } + + /** + * 결재선 생성 + */ + public function lineStore(array $data): ApprovalLine + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 기본 결재선으로 설정 시 기존 기본값 해제 + if (! empty($data['is_default'])) { + ApprovalLine::query() + ->where('tenant_id', $tenantId) + ->where('is_default', true) + ->update(['is_default' => false]); + } + + return ApprovalLine::create([ + 'tenant_id' => $tenantId, + 'name' => $data['name'], + 'steps' => $data['steps'], + 'is_default' => $data['is_default'] ?? false, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + }); + } + + /** + * 결재선 수정 + */ + public function lineUpdate(int $id, array $data): ApprovalLine + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $line = ApprovalLine::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 기본 결재선으로 설정 시 기존 기본값 해제 + if (! empty($data['is_default']) && ! $line->is_default) { + ApprovalLine::query() + ->where('tenant_id', $tenantId) + ->where('is_default', true) + ->update(['is_default' => false]); + } + + $line->fill([ + 'name' => $data['name'] ?? $line->name, + 'steps' => $data['steps'] ?? $line->steps, + 'is_default' => $data['is_default'] ?? $line->is_default, + 'updated_by' => $userId, + ]); + + $line->save(); + + return $line->fresh('creator:id,name'); + }); + } + + /** + * 결재선 삭제 + */ + public function lineDestroy(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $line = ApprovalLine::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $line->deleted_by = $userId; + $line->save(); + $line->delete(); + + return true; + } + + // ========================================================================= + // 결재 문서 관리 + // ========================================================================= + + /** + * 기안함 - 내가 기안한 문서 + */ + public function drafts(array $params): LengthAwarePaginator + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $query = Approval::query() + ->where('tenant_id', $tenantId) + ->where('drafter_id', $userId) + ->with(['form:id,name,code,category', 'drafter:id,name']); + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 검색 + if (! empty($params['search'])) { + $query->where(function ($q) use ($params) { + $q->where('title', 'like', "%{$params['search']}%") + ->orWhere('document_number', 'like', "%{$params['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 draftsSummary(): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $counts = Approval::query() + ->where('tenant_id', $tenantId) + ->where('drafter_id', $userId) + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->pluck('count', 'status') + ->toArray(); + + return [ + 'total' => array_sum($counts), + 'draft' => $counts[Approval::STATUS_DRAFT] ?? 0, + 'pending' => $counts[Approval::STATUS_PENDING] ?? 0, + 'approved' => $counts[Approval::STATUS_APPROVED] ?? 0, + 'rejected' => $counts[Approval::STATUS_REJECTED] ?? 0, + ]; + } + + /** + * 결재함 - 내가 결재해야 할 문서 + */ + public function inbox(array $params): LengthAwarePaginator + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $query = Approval::query() + ->where('tenant_id', $tenantId) + ->whereHas('steps', function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]); + }) + ->with(['form:id,name,code,category', 'drafter:id,name', 'steps' => function ($q) use ($userId) { + $q->where('approver_id', $userId); + }]); + + // 상태 필터 + if (! empty($params['status'])) { + if ($params['status'] === 'requested') { + // 결재 요청 (현재 내 차례) + $query->where('status', Approval::STATUS_PENDING) + ->whereHas('steps', function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->where('status', ApprovalStep::STATUS_PENDING) + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]); + }); + } elseif ($params['status'] === 'scheduled') { + // 예정 (아직 내 차례 아님) + $query->where('status', Approval::STATUS_PENDING) + ->whereHas('steps', function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->where('status', ApprovalStep::STATUS_PENDING); + }) + ->whereDoesntHave('steps', function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->where('status', ApprovalStep::STATUS_PENDING) + ->whereRaw('step_order = (SELECT MIN(s2.step_order) FROM approval_steps s2 WHERE s2.approval_id = approval_steps.approval_id AND s2.status = ?)', [ApprovalStep::STATUS_PENDING]); + }); + } elseif ($params['status'] === 'completed') { + // 내가 처리 완료한 문서 + $query->whereHas('steps', function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED]); + }); + } elseif ($params['status'] === 'rejected') { + // 내가 반려한 문서 + $query->whereHas('steps', function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->where('status', ApprovalStep::STATUS_REJECTED); + }); + } + } + + // 정렬 + $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 inboxSummary(): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 결재 요청 (현재 내 차례) + $requested = Approval::query() + ->where('tenant_id', $tenantId) + ->where('status', Approval::STATUS_PENDING) + ->whereHas('steps', function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->where('status', ApprovalStep::STATUS_PENDING) + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->whereRaw('step_order = (SELECT MIN(s2.step_order) FROM approval_steps s2 WHERE s2.approval_id = approval_steps.approval_id AND s2.status = ?)', [ApprovalStep::STATUS_PENDING]); + }) + ->count(); + + // 예정 (내 차례 대기중) + $scheduled = ApprovalStep::query() + ->where('approver_id', $userId) + ->where('status', ApprovalStep::STATUS_PENDING) + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->whereHas('approval', function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId) + ->where('status', Approval::STATUS_PENDING); + }) + ->count() - $requested; + + // 완료 (내가 처리한 문서) + $completed = ApprovalStep::query() + ->where('approver_id', $userId) + ->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED]) + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->whereHas('approval', function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId); + }) + ->count(); + + // 반려 (내가 반려한 문서) + $rejected = ApprovalStep::query() + ->where('approver_id', $userId) + ->where('status', ApprovalStep::STATUS_REJECTED) + ->whereHas('approval', function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId); + }) + ->count(); + + return [ + 'requested' => max(0, $requested), + 'scheduled' => max(0, $scheduled), + 'completed' => $completed, + 'rejected' => $rejected, + ]; + } + + /** + * 참조함 - 내가 참조된 문서 + */ + public function reference(array $params): LengthAwarePaginator + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $query = Approval::query() + ->where('tenant_id', $tenantId) + ->whereHas('steps', function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE); + }) + ->with(['form:id,name,code,category', 'drafter:id,name', 'steps' => function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE); + }]); + + // 열람 상태 필터 + if (isset($params['is_read'])) { + $query->whereHas('steps', function ($q) use ($userId, $params) { + $q->where('approver_id', $userId) + ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE) + ->where('is_read', $params['is_read']); + }); + } + + // 정렬 + $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 show(int $id): Approval + { + $tenantId = $this->tenantId(); + + return Approval::query() + ->where('tenant_id', $tenantId) + ->with([ + 'form:id,name,code,category,template', + 'drafter:id,name,email', + 'steps.approver:id,name,email', + ]) + ->findOrFail($id); + } + + /** + * 결재 문서 생성 (임시저장 또는 상신) + */ + public function store(array $data): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 양식 확인 + $form = ApprovalForm::query() + ->where('tenant_id', $tenantId) + ->where('id', $data['form_id']) + ->active() + ->firstOrFail(); + + // 문서번호 생성 + $documentNumber = $this->generateDocumentNumber($tenantId); + + $status = ! empty($data['submit']) ? Approval::STATUS_PENDING : Approval::STATUS_DRAFT; + + $approval = Approval::create([ + 'tenant_id' => $tenantId, + 'document_number' => $documentNumber, + 'form_id' => $data['form_id'], + 'title' => $data['title'], + 'content' => $data['content'], + 'status' => $status, + 'drafter_id' => $userId, + 'drafted_at' => $status === Approval::STATUS_PENDING ? now() : null, + 'attachments' => $data['attachments'] ?? null, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + // 결재선 생성 (상신 시) + if ($status === Approval::STATUS_PENDING && ! empty($data['steps'])) { + $this->createApprovalSteps($approval, $data['steps']); + } + + return $approval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + 'steps.approver:id,name', + ]); + }); + } + + /** + * 결재 문서 수정 (임시저장 상태만) + */ + public function update(int $id, array $data): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $approval = Approval::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $approval->isEditable()) { + throw new BadRequestHttpException(__('error.approval.not_editable')); + } + + $approval->fill([ + 'form_id' => $data['form_id'] ?? $approval->form_id, + 'title' => $data['title'] ?? $approval->title, + 'content' => $data['content'] ?? $approval->content, + 'attachments' => $data['attachments'] ?? $approval->attachments, + 'updated_by' => $userId, + ]); + + $approval->save(); + + return $approval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + ]); + }); + } + + /** + * 결재 문서 삭제 (임시저장 상태만) + */ + public function destroy(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $approval = Approval::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $approval->isDeletable()) { + throw new BadRequestHttpException(__('error.approval.not_deletable')); + } + + $approval->deleted_by = $userId; + $approval->save(); + $approval->delete(); + + return true; + }); + } + + /** + * 결재 상신 + */ + public function submit(int $id, array $data): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $approval = Approval::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $approval->isSubmittable()) { + throw new BadRequestHttpException(__('error.approval.not_submittable')); + } + + if (empty($data['steps'])) { + throw new BadRequestHttpException(__('error.approval.steps_required')); + } + + // 결재선 생성 + $this->createApprovalSteps($approval, $data['steps']); + + $approval->status = Approval::STATUS_PENDING; + $approval->drafted_at = now(); + $approval->current_step = 1; + $approval->updated_by = $userId; + $approval->save(); + + return $approval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + 'steps.approver:id,name', + ]); + }); + } + + /** + * 결재 승인 + */ + public function approve(int $id, ?string $comment = null): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $comment, $tenantId, $userId) { + $approval = Approval::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $approval->isActionable()) { + throw new BadRequestHttpException(__('error.approval.not_actionable')); + } + + // 현재 내 결재 단계 찾기 + $myStep = $approval->steps() + ->where('approver_id', $userId) + ->where('status', ApprovalStep::STATUS_PENDING) + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->orderBy('step_order') + ->first(); + + if (! $myStep) { + throw new BadRequestHttpException(__('error.approval.not_your_turn')); + } + + // 내 단계 승인 + $myStep->status = ApprovalStep::STATUS_APPROVED; + $myStep->comment = $comment; + $myStep->acted_at = now(); + $myStep->save(); + + // 다음 결재자 확인 + $nextStep = $approval->steps() + ->where('status', ApprovalStep::STATUS_PENDING) + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->orderBy('step_order') + ->first(); + + if (! $nextStep) { + // 모든 결재 완료 + $approval->status = Approval::STATUS_APPROVED; + $approval->completed_at = now(); + } + + $approval->current_step = $myStep->step_order + 1; + $approval->updated_by = $userId; + $approval->save(); + + return $approval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + 'steps.approver:id,name', + ]); + }); + } + + /** + * 결재 반려 + */ + public function reject(int $id, string $comment): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $comment, $tenantId, $userId) { + $approval = Approval::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $approval->isActionable()) { + throw new BadRequestHttpException(__('error.approval.not_actionable')); + } + + // 현재 내 결재 단계 찾기 + $myStep = $approval->steps() + ->where('approver_id', $userId) + ->where('status', ApprovalStep::STATUS_PENDING) + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->orderBy('step_order') + ->first(); + + if (! $myStep) { + throw new BadRequestHttpException(__('error.approval.not_your_turn')); + } + + // 반려 처리 + $myStep->status = ApprovalStep::STATUS_REJECTED; + $myStep->comment = $comment; + $myStep->acted_at = now(); + $myStep->save(); + + // 문서 반려 상태로 변경 + $approval->status = Approval::STATUS_REJECTED; + $approval->completed_at = now(); + $approval->updated_by = $userId; + $approval->save(); + + return $approval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + 'steps.approver:id,name', + ]); + }); + } + + /** + * 결재 회수 (기안자만) + */ + public function cancel(int $id): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $approval = Approval::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $approval->isCancellable()) { + throw new BadRequestHttpException(__('error.approval.not_cancellable')); + } + + // 기안자만 회수 가능 + if ($approval->drafter_id !== $userId) { + throw new BadRequestHttpException(__('error.approval.only_drafter_can_cancel')); + } + + $approval->status = Approval::STATUS_CANCELLED; + $approval->completed_at = now(); + $approval->updated_by = $userId; + $approval->save(); + + // 결재 단계들 삭제 + $approval->steps()->delete(); + + return $approval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + ]); + }); + } + + /** + * 참조 열람 처리 + */ + public function markRead(int $id): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $approval = Approval::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $step = $approval->steps() + ->where('approver_id', $userId) + ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE) + ->first(); + + if (! $step) { + throw new NotFoundHttpException(__('error.approval.not_referee')); + } + + $step->is_read = true; + $step->read_at = now(); + $step->save(); + + return $approval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + 'steps.approver:id,name', + ]); + } + + /** + * 참조 미열람 처리 + */ + public function markUnread(int $id): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $approval = Approval::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $step = $approval->steps() + ->where('approver_id', $userId) + ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE) + ->first(); + + if (! $step) { + throw new NotFoundHttpException(__('error.approval.not_referee')); + } + + $step->is_read = false; + $step->read_at = null; + $step->save(); + + return $approval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + 'steps.approver:id,name', + ]); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 문서번호 생성 + */ + private function generateDocumentNumber(int $tenantId): string + { + $prefix = 'AP'; + $date = now()->format('Ymd'); + + $lastNumber = Approval::query() + ->where('tenant_id', $tenantId) + ->where('document_number', 'like', "{$prefix}-{$date}-%") + ->orderByDesc('document_number') + ->value('document_number'); + + if ($lastNumber) { + $sequence = (int) substr($lastNumber, -4) + 1; + } else { + $sequence = 1; + } + + return sprintf('%s-%s-%04d', $prefix, $date, $sequence); + } + + /** + * 결재 단계 생성 + */ + private function createApprovalSteps(Approval $approval, array $steps): void + { + $order = 1; + foreach ($steps as $step) { + ApprovalStep::create([ + 'approval_id' => $approval->id, + 'step_order' => $order++, + 'step_type' => $step['type'], + 'approver_id' => $step['user_id'], + 'status' => ApprovalStep::STATUS_PENDING, + ]); + } + } +} diff --git a/app/Swagger/v1/ApprovalApi.php b/app/Swagger/v1/ApprovalApi.php new file mode 100644 index 0000000..a72ac2d --- /dev/null +++ b/app/Swagger/v1/ApprovalApi.php @@ -0,0 +1,646 @@ +id(); + $table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID'); + $table->string('name', 100)->comment('양식명'); + $table->string('code', 50)->comment('양식코드'); + $table->string('category', 50)->nullable()->comment('분류'); + $table->json('template')->comment('양식 템플릿 (필드 정의)'); + $table->boolean('is_active')->default(true)->comment('활성 여부'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('생성자'); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자'); + $table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete()->comment('삭제자'); + $table->softDeletes(); + $table->timestamps(); + + $table->unique(['tenant_id', 'code'], 'uk_tenant_code'); + $table->index('tenant_id', 'idx_tenant'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('approval_forms'); + } +}; diff --git a/database/migrations/2025_12_17_100002_create_approval_lines_table.php b/database/migrations/2025_12_17_100002_create_approval_lines_table.php new file mode 100644 index 0000000..98a4dcd --- /dev/null +++ b/database/migrations/2025_12_17_100002_create_approval_lines_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID'); + $table->string('name', 100)->comment('결재선 이름'); + $table->json('steps')->comment('결재선 단계 [{order, type: approval/agreement/reference, user_id, position}]'); + $table->boolean('is_default')->default(false)->comment('기본 결재선 여부'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('생성자'); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자'); + $table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete()->comment('삭제자'); + $table->softDeletes(); + $table->timestamps(); + + $table->index('tenant_id', 'idx_tenant'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('approval_lines'); + } +}; diff --git a/database/migrations/2025_12_17_100003_create_approvals_table.php b/database/migrations/2025_12_17_100003_create_approvals_table.php new file mode 100644 index 0000000..42e5509 --- /dev/null +++ b/database/migrations/2025_12_17_100003_create_approvals_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID'); + $table->string('document_number', 50)->comment('문서번호'); + $table->foreignId('form_id')->constrained('approval_forms')->onDelete('restrict')->comment('결재 양식 ID'); + $table->string('title', 200)->comment('제목'); + $table->json('content')->comment('양식 데이터'); + $table->string('status', 20)->default('draft')->comment('상태: draft/pending/approved/rejected/cancelled'); + $table->foreignId('drafter_id')->constrained('users')->onDelete('restrict')->comment('기안자'); + $table->timestamp('drafted_at')->nullable()->comment('기안일시'); + $table->timestamp('completed_at')->nullable()->comment('완료일시'); + $table->integer('current_step')->default(0)->comment('현재 결재 단계'); + $table->json('attachments')->nullable()->comment('첨부파일 IDs'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('생성자'); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자'); + $table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete()->comment('삭제자'); + $table->softDeletes(); + $table->timestamps(); + + $table->unique(['tenant_id', 'document_number'], 'uk_tenant_document'); + $table->index(['tenant_id', 'status'], 'idx_tenant_status'); + $table->index('drafter_id', 'idx_drafter'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('approvals'); + } +}; diff --git a/database/migrations/2025_12_17_100004_create_approval_steps_table.php b/database/migrations/2025_12_17_100004_create_approval_steps_table.php new file mode 100644 index 0000000..4aa2272 --- /dev/null +++ b/database/migrations/2025_12_17_100004_create_approval_steps_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('approval_id')->constrained('approvals')->onDelete('cascade')->comment('결재 문서 ID'); + $table->integer('step_order')->comment('결재 순서'); + $table->string('step_type', 20)->comment('단계 유형: approval/agreement/reference'); + $table->foreignId('approver_id')->constrained('users')->onDelete('restrict')->comment('결재자 ID'); + $table->string('status', 20)->default('pending')->comment('상태: pending/approved/rejected/skipped'); + $table->text('comment')->nullable()->comment('결재 의견'); + $table->timestamp('acted_at')->nullable()->comment('결재일시'); + $table->boolean('is_read')->default(false)->comment('열람 여부 (참조용)'); + $table->timestamp('read_at')->nullable()->comment('열람일시 (참조용)'); + $table->timestamps(); + + $table->index('approval_id', 'idx_approval'); + $table->index(['approver_id', 'status'], 'idx_approver_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('approval_steps'); + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index 0dfbcea..24d555a 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -173,6 +173,25 @@ 'balance_not_found' => '휴가 잔여일수 정보를 찾을 수 없습니다.', ], + // 전자결재 관련 + 'approval' => [ + 'not_found' => '결재 문서를 찾을 수 없습니다.', + 'form_not_found' => '결재 양식을 찾을 수 없습니다.', + 'form_code_exists' => '중복된 양식 코드입니다.', + 'form_in_use' => '사용 중인 양식은 삭제할 수 없습니다.', + 'line_not_found' => '결재선을 찾을 수 없습니다.', + 'not_editable' => '임시저장 상태의 문서만 수정할 수 있습니다.', + 'not_deletable' => '임시저장 상태의 문서만 삭제할 수 있습니다.', + 'not_submittable' => '임시저장 상태의 문서만 상신할 수 있습니다.', + 'not_actionable' => '진행중인 문서에서만 결재 가능합니다.', + 'not_cancellable' => '진행중인 문서만 회수할 수 있습니다.', + 'not_your_turn' => '현재 결재 순서가 아닙니다.', + 'only_drafter_can_cancel' => '기안자만 회수할 수 있습니다.', + 'steps_required' => '결재선은 필수입니다.', + 'reject_comment_required' => '반려 사유는 필수입니다.', + 'not_referee' => '참조자가 아닙니다.', + ], + // 근무 설정 관련 'work_setting' => [ 'invalid_work_type' => '유효하지 않은 근무 유형입니다.', diff --git a/lang/ko/message.php b/lang/ko/message.php index bb16f76..58090ab 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -218,6 +218,26 @@ 'balance_updated' => '휴가 일수가 설정되었습니다.', ], + // 전자결재 관리 + 'approval' => [ + 'fetched' => '결재 문서를 조회했습니다.', + 'created' => '결재 문서가 작성되었습니다.', + 'updated' => '결재 문서가 수정되었습니다.', + 'deleted' => '결재 문서가 삭제되었습니다.', + 'submitted' => '결재가 상신되었습니다.', + 'approved' => '결재가 승인되었습니다.', + 'rejected' => '결재가 반려되었습니다.', + 'cancelled' => '결재가 회수되었습니다.', + 'marked_read' => '열람 처리되었습니다.', + 'marked_unread' => '미열람 처리되었습니다.', + 'form_created' => '결재 양식이 등록되었습니다.', + 'form_updated' => '결재 양식이 수정되었습니다.', + 'form_deleted' => '결재 양식이 삭제되었습니다.', + 'line_created' => '결재선이 등록되었습니다.', + 'line_updated' => '결재선이 수정되었습니다.', + 'line_deleted' => '결재선이 삭제되었습니다.', + ], + // 근무 설정 관리 'work_setting' => [ 'fetched' => '근무 설정을 조회했습니다.', diff --git a/routes/api.php b/routes/api.php index b0460ee..79b65a7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -39,6 +39,9 @@ use App\Http\Controllers\Api\V1\ItemsController; use App\Http\Controllers\Api\V1\ItemsFileController; // use App\Http\Controllers\Api\V1\MaterialController; // REMOVED: materials 테이블 삭제됨 +use App\Http\Controllers\Api\V1\ApprovalController; +use App\Http\Controllers\Api\V1\ApprovalFormController; +use App\Http\Controllers\Api\V1\ApprovalLineController; use App\Http\Controllers\Api\V1\LeaveController; use App\Http\Controllers\Api\V1\MenuController; use App\Http\Controllers\Api\V1\ModelSetController; @@ -266,6 +269,50 @@ Route::post('/{id}/cancel', [LeaveController::class, 'cancel'])->name('v1.leaves.cancel'); }); + // Approval Form API (결재 양식) + Route::prefix('approval-forms')->group(function () { + Route::get('', [ApprovalFormController::class, 'index'])->name('v1.approval-forms.index'); + Route::post('', [ApprovalFormController::class, 'store'])->name('v1.approval-forms.store'); + Route::get('/active', [ApprovalFormController::class, 'active'])->name('v1.approval-forms.active'); + Route::get('/{id}', [ApprovalFormController::class, 'show'])->whereNumber('id')->name('v1.approval-forms.show'); + Route::patch('/{id}', [ApprovalFormController::class, 'update'])->whereNumber('id')->name('v1.approval-forms.update'); + Route::delete('/{id}', [ApprovalFormController::class, 'destroy'])->whereNumber('id')->name('v1.approval-forms.destroy'); + }); + + // Approval Line API (결재선) + Route::prefix('approval-lines')->group(function () { + Route::get('', [ApprovalLineController::class, 'index'])->name('v1.approval-lines.index'); + Route::post('', [ApprovalLineController::class, 'store'])->name('v1.approval-lines.store'); + Route::get('/{id}', [ApprovalLineController::class, 'show'])->whereNumber('id')->name('v1.approval-lines.show'); + Route::patch('/{id}', [ApprovalLineController::class, 'update'])->whereNumber('id')->name('v1.approval-lines.update'); + Route::delete('/{id}', [ApprovalLineController::class, 'destroy'])->whereNumber('id')->name('v1.approval-lines.destroy'); + }); + + // Approval API (전자결재) + Route::prefix('approvals')->group(function () { + // 기안함 + Route::get('/drafts', [ApprovalController::class, 'drafts'])->name('v1.approvals.drafts'); + Route::get('/drafts/summary', [ApprovalController::class, 'draftsSummary'])->name('v1.approvals.drafts.summary'); + // 결재함 + Route::get('/inbox', [ApprovalController::class, 'inbox'])->name('v1.approvals.inbox'); + Route::get('/inbox/summary', [ApprovalController::class, 'inboxSummary'])->name('v1.approvals.inbox.summary'); + // 참조함 + Route::get('/reference', [ApprovalController::class, 'reference'])->name('v1.approvals.reference'); + // CRUD + Route::post('', [ApprovalController::class, 'store'])->name('v1.approvals.store'); + Route::get('/{id}', [ApprovalController::class, 'show'])->whereNumber('id')->name('v1.approvals.show'); + Route::patch('/{id}', [ApprovalController::class, 'update'])->whereNumber('id')->name('v1.approvals.update'); + Route::delete('/{id}', [ApprovalController::class, 'destroy'])->whereNumber('id')->name('v1.approvals.destroy'); + // 액션 + Route::post('/{id}/submit', [ApprovalController::class, 'submit'])->whereNumber('id')->name('v1.approvals.submit'); + Route::post('/{id}/approve', [ApprovalController::class, 'approve'])->whereNumber('id')->name('v1.approvals.approve'); + Route::post('/{id}/reject', [ApprovalController::class, 'reject'])->whereNumber('id')->name('v1.approvals.reject'); + Route::post('/{id}/cancel', [ApprovalController::class, 'cancel'])->whereNumber('id')->name('v1.approvals.cancel'); + // 참조 열람 + Route::post('/{id}/read', [ApprovalController::class, 'markRead'])->whereNumber('id')->name('v1.approvals.read'); + Route::post('/{id}/unread', [ApprovalController::class, 'markUnread'])->whereNumber('id')->name('v1.approvals.unread'); + }); + // Site API (현장 관리) Route::prefix('sites')->group(function () { Route::get('', [SiteController::class, 'index'])->name('v1.sites.index');