feat: [approval] 전자결재 모듈 API 구현

- 마이그레이션 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개 등록
This commit is contained in:
2025-12-17 23:23:20 +09:00
parent 77914da7b7
commit b43796a558
33 changed files with 4067 additions and 2 deletions

View File

@@ -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 개발
### 작업 목표

View File

@@ -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`

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Approval\InboxIndexRequest;
use App\Http\Requests\Approval\IndexRequest;
use App\Http\Requests\Approval\ReferenceIndexRequest;
use App\Http\Requests\Approval\RejectRequest;
use App\Http\Requests\Approval\StoreRequest;
use App\Http\Requests\Approval\SubmitRequest;
use App\Http\Requests\Approval\UpdateRequest;
use App\Services\ApprovalService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ApprovalController extends Controller
{
public function __construct(private ApprovalService $service) {}
/**
* 기안함 - 내가 기안한 문서 목록
* GET /v1/approvals/drafts
*/
public function drafts(IndexRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->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'));
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Approval\FormIndexRequest;
use App\Http\Requests\Approval\FormStoreRequest;
use App\Http\Requests\Approval\FormUpdateRequest;
use App\Services\ApprovalService;
use Illuminate\Http\JsonResponse;
class ApprovalFormController extends Controller
{
public function __construct(private ApprovalService $service) {}
/**
* 결재 양식 목록
* GET /v1/approval-forms
*/
public function index(FormIndexRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->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'));
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Approval\LineIndexRequest;
use App\Http\Requests\Approval\LineStoreRequest;
use App\Http\Requests\Approval\LineUpdateRequest;
use App\Services\ApprovalService;
use Illuminate\Http\JsonResponse;
class ApprovalLineController extends Controller
{
public function __construct(private ApprovalService $service) {}
/**
* 결재선 목록
* GET /v1/approval-lines
*/
public function index(LineIndexRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->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'));
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Approval;
use App\Models\Tenants\ApprovalForm;
use Illuminate\Foundation\Http\FormRequest;
class FormIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$categories = implode(',', ApprovalForm::CATEGORIES);
return [
'category' => "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',
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests\Approval;
use App\Models\Tenants\ApprovalForm;
use Illuminate\Foundation\Http\FormRequest;
class FormStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$categories = implode(',', ApprovalForm::CATEGORIES);
return [
'name' => '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' => '필드']),
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests\Approval;
use App\Models\Tenants\ApprovalForm;
use Illuminate\Foundation\Http\FormRequest;
class FormUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$categories = implode(',', ApprovalForm::CATEGORIES);
return [
'name' => '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' => '양식코드']),
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class InboxIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'status' => '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',
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Approval;
use App\Models\Tenants\Approval;
use Illuminate\Foundation\Http\FormRequest;
class IndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$statuses = implode(',', Approval::STATUSES);
return [
'status' => "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',
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class LineIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'search' => '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',
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests\Approval;
use App\Models\Tenants\ApprovalLine;
use Illuminate\Foundation\Http\FormRequest;
class LineStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$stepTypes = implode(',', ApprovalLine::STEP_TYPES);
return [
'name' => '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' => '결재자']),
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Approval;
use App\Models\Tenants\ApprovalLine;
use Illuminate\Foundation\Http\FormRequest;
class LineUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$stepTypes = implode(',', ApprovalLine::STEP_TYPES);
return [
'name' => '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' => '결재자']),
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class ReferenceIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'is_read' => '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',
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class RejectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'comment' => 'required|string|max:1000',
];
}
public function messages(): array
{
return [
'comment.required' => __('error.approval.reject_comment_required'),
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests\Approval;
use App\Models\Tenants\ApprovalLine;
use Illuminate\Foundation\Http\FormRequest;
class StoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$stepTypes = implode(',', ApprovalLine::STEP_TYPES);
return [
'form_id' => '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]),
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Approval;
use App\Models\Tenants\ApprovalLine;
use Illuminate\Foundation\Http\FormRequest;
class SubmitRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$stepTypes = implode(',', ApprovalLine::STEP_TYPES);
return [
'steps' => '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' => '결재자']),
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class UpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'form_id' => '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' => '결재양식']),
];
}
}

View File

@@ -0,0 +1,320 @@
<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 결재 문서 모델
*
* @property int $id
* @property int $tenant_id
* @property string $document_number
* @property int $form_id
* @property string $title
* @property array $content
* @property string $status
* @property int $drafter_id
* @property \Carbon\Carbon|null $drafted_at
* @property \Carbon\Carbon|null $completed_at
* @property int $current_step
* @property array|null $attachments
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $deleted_by
*/
class Approval extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'approvals';
protected $casts = [
'content' => '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,
];
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 결재 양식 모델
*
* @property int $id
* @property int $tenant_id
* @property string $name
* @property string $code
* @property string|null $category
* @property array $template
* @property bool $is_active
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $deleted_by
*/
class ApprovalForm extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'approval_forms';
protected $casts = [
'template' => '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 ?? '',
};
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 결재선 템플릿 모델
*
* @property int $id
* @property int $tenant_id
* @property string $name
* @property array $steps
* @property bool $is_default
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $deleted_by
*/
class ApprovalLine extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'approval_lines';
protected $casts = [
'steps' => '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();
}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 결재 단계 모델
*
* @property int $id
* @property int $approval_id
* @property int $step_order
* @property string $step_type
* @property int $approver_id
* @property string $status
* @property string|null $comment
* @property \Carbon\Carbon|null $acted_at
* @property bool $is_read
* @property \Carbon\Carbon|null $read_at
*/
class ApprovalStep extends Model
{
protected $table = 'approval_steps';
protected $casts = [
'step_order' => '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,
};
}
}

View File

@@ -0,0 +1,949 @@
<?php
namespace App\Services;
use App\Models\Tenants\Approval;
use App\Models\Tenants\ApprovalForm;
use App\Models\Tenants\ApprovalLine;
use App\Models\Tenants\ApprovalStep;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ApprovalService extends Service
{
// =========================================================================
// 결재 양식 관리
// =========================================================================
/**
* 결재 양식 목록
*/
public function formIndex(array $params): LengthAwarePaginator
{
$tenantId = $this->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,
]);
}
}
}

View File

@@ -0,0 +1,646 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Approvals", description="전자결재 문서 관리")
*
* @OA\Schema(
* schema="Approval",
* type="object",
* description="결재 문서 정보",
*
* @OA\Property(property="id", type="integer", example=1, description="문서 ID"),
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
* @OA\Property(property="form_id", type="integer", example=1, description="양식 ID"),
* @OA\Property(property="drafter_id", type="integer", example=10, description="기안자 ID"),
* @OA\Property(property="doc_number", type="string", example="APR-2024-0001", nullable=true, description="문서번호"),
* @OA\Property(property="title", type="string", example="휴가 신청", description="제목"),
* @OA\Property(property="content", type="object", description="문서 내용 JSON"),
* @OA\Property(property="status", type="string", enum={"draft","pending","approved","rejected","cancelled"}, example="pending", description="상태"),
* @OA\Property(property="current_step", type="integer", example=1, nullable=true, description="현재 결재 단계"),
* @OA\Property(property="submitted_at", type="string", format="date-time", nullable=true, description="상신일시"),
* @OA\Property(property="completed_at", type="string", format="date-time", nullable=true, description="완료일시"),
* @OA\Property(property="form", type="object", nullable=true, description="결재 양식 정보",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="품의서"),
* @OA\Property(property="code", type="string", example="REQUEST_01")
* ),
* @OA\Property(property="drafter", type="object", nullable=true, description="기안자 정보",
* @OA\Property(property="id", type="integer", example=10),
* @OA\Property(property="name", type="string", example="홍길동")
* ),
* @OA\Property(property="steps", type="array", description="결재 단계", @OA\Items(ref="#/components/schemas/ApprovalStepDetail")),
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-01T09:00:00Z"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-01T09:00:00Z")
* )
*
* @OA\Schema(
* schema="ApprovalStepDetail",
* type="object",
* description="결재 단계 상세 정보",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="step_order", type="integer", example=1, description="단계 순서"),
* @OA\Property(property="type", type="string", enum={"approval","agreement","reference"}, example="approval", description="단계 유형"),
* @OA\Property(property="user_id", type="integer", example=20, description="결재자 ID"),
* @OA\Property(property="status", type="string", enum={"pending","approved","rejected","skipped"}, example="pending", description="단계 상태"),
* @OA\Property(property="comment", type="string", nullable=true, description="결재 의견"),
* @OA\Property(property="acted_at", type="string", format="date-time", nullable=true, description="결재일시"),
* @OA\Property(property="is_read", type="boolean", example=false, description="열람 여부 (참조)"),
* @OA\Property(property="read_at", type="string", format="date-time", nullable=true, description="열람일시"),
* @OA\Property(property="user", type="object", nullable=true, description="결재자 정보",
* @OA\Property(property="id", type="integer", example=20),
* @OA\Property(property="name", type="string", example="김부장")
* )
* )
*
* @OA\Schema(
* schema="ApprovalStoreRequest",
* type="object",
* required={"form_id", "title"},
* description="결재 문서 생성/임시저장 요청",
*
* @OA\Property(property="form_id", type="integer", example=1, description="양식 ID"),
* @OA\Property(property="title", type="string", example="휴가 신청", description="제목"),
* @OA\Property(property="content", type="object", description="문서 내용 JSON"),
* @OA\Property(property="steps", type="array", description="결재 단계 (임시저장 시 선택)", @OA\Items(type="object",
* @OA\Property(property="type", type="string", enum={"approval","agreement","reference"}, example="approval"),
* @OA\Property(property="user_id", type="integer", example=20)
* ))
* )
*
* @OA\Schema(
* schema="ApprovalUpdateRequest",
* type="object",
* description="결재 문서 수정 요청 (임시저장 상태만 가능)",
*
* @OA\Property(property="title", type="string", example="휴가 신청 (수정)", description="제목"),
* @OA\Property(property="content", type="object", description="문서 내용 JSON"),
* @OA\Property(property="steps", type="array", description="결재 단계", @OA\Items(type="object"))
* )
*
* @OA\Schema(
* schema="ApprovalSubmitRequest",
* type="object",
* required={"steps"},
* description="결재 상신 요청",
*
* @OA\Property(property="steps", type="array", description="결재 단계 (필수)", @OA\Items(type="object",
* @OA\Property(property="type", type="string", enum={"approval","agreement","reference"}, example="approval"),
* @OA\Property(property="user_id", type="integer", example=20)
* ))
* )
*
* @OA\Schema(
* schema="ApprovalSummary",
* type="object",
* description="결재 현황 요약",
*
* @OA\Property(property="draft", type="integer", example=3, description="임시저장"),
* @OA\Property(property="pending", type="integer", example=5, description="진행중"),
* @OA\Property(property="approved", type="integer", example=10, description="승인완료"),
* @OA\Property(property="rejected", type="integer", example=2, description="반려")
* )
*
* @OA\Schema(
* schema="InboxSummary",
* type="object",
* description="결재함 현황 요약",
*
* @OA\Property(property="requested", type="integer", example=3, description="결재 요청"),
* @OA\Property(property="scheduled", type="integer", example=2, description="결재 예정"),
* @OA\Property(property="completed", type="integer", example=15, description="결재 완료"),
* @OA\Property(property="rejected", type="integer", example=1, description="반려")
* )
*/
class ApprovalApi
{
/**
* @OA\Get(
* path="/api/v1/approvals/drafts",
* tags={"Approvals"},
* summary="기안함 - 내가 작성한 문서 목록",
* description="로그인 사용자가 기안한 결재 문서 목록을 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="status", in="query", description="상태 필터", @OA\Schema(type="string", enum={"draft","pending","approved","rejected","cancelled"})),
* @OA\Parameter(name="search", in="query", description="검색어 (제목, 문서번호)", @OA\Schema(type="string")),
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준", @OA\Schema(type="string", enum={"created_at","submitted_at","completed_at","title"}, default="created_at")),
* @OA\Parameter(name="sort_dir", in="query", description="정렬 방향", @OA\Schema(type="string", enum={"asc","desc"}, default="desc")),
* @OA\Parameter(ref="#/components/parameters/Page"),
* @OA\Parameter(ref="#/components/parameters/Size"),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Approval")),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=15)
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function drafts() {}
/**
* @OA\Get(
* path="/api/v1/approvals/drafts/summary",
* tags={"Approvals"},
* summary="기안함 현황 카드",
* description="기안 문서의 상태별 건수를 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ApprovalSummary"))
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function draftsSummary() {}
/**
* @OA\Get(
* path="/api/v1/approvals/inbox",
* tags={"Approvals"},
* summary="결재함 - 내가 결재할/결재한 문서",
* description="로그인 사용자에게 결재 요청된 문서 목록을 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="filter", in="query", description="필터 (requested: 결재요청, scheduled: 결재예정, completed: 결재완료, rejected: 반려)", @OA\Schema(type="string", enum={"requested","scheduled","completed","rejected"})),
* @OA\Parameter(name="search", in="query", description="검색어 (제목, 문서번호, 기안자)", @OA\Schema(type="string")),
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준", @OA\Schema(type="string", enum={"created_at","submitted_at","title"}, default="submitted_at")),
* @OA\Parameter(name="sort_dir", in="query", description="정렬 방향", @OA\Schema(type="string", enum={"asc","desc"}, default="desc")),
* @OA\Parameter(ref="#/components/parameters/Page"),
* @OA\Parameter(ref="#/components/parameters/Size"),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Approval")),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=10)
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function inbox() {}
/**
* @OA\Get(
* path="/api/v1/approvals/inbox/summary",
* tags={"Approvals"},
* summary="결재함 현황 카드",
* description="결재함의 상태별 건수를 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/InboxSummary"))
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function inboxSummary() {}
/**
* @OA\Get(
* path="/api/v1/approvals/reference",
* tags={"Approvals"},
* summary="참조함 - 참조로 받은 문서",
* description="로그인 사용자가 참조자로 지정된 문서 목록을 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="is_read", in="query", description="열람 여부 필터", @OA\Schema(type="boolean")),
* @OA\Parameter(name="search", in="query", description="검색어 (제목, 문서번호, 기안자)", @OA\Schema(type="string")),
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준", @OA\Schema(type="string", enum={"created_at","submitted_at","title"}, default="submitted_at")),
* @OA\Parameter(name="sort_dir", in="query", description="정렬 방향", @OA\Schema(type="string", enum={"asc","desc"}, default="desc")),
* @OA\Parameter(ref="#/components/parameters/Page"),
* @OA\Parameter(ref="#/components/parameters/Size"),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Approval")),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=8)
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function reference() {}
/**
* @OA\Get(
* path="/api/v1/approvals/{id}",
* tags={"Approvals"},
* summary="결재 문서 상세 조회",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Approval"))
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/approvals",
* tags={"Approvals"},
* summary="결재 문서 생성 (임시저장)",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/ApprovalStoreRequest")
* ),
*
* @OA\Response(
* response=201,
* description="생성 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Approval"))
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="양식을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function store() {}
/**
* @OA\Patch(
* path="/api/v1/approvals/{id}",
* tags={"Approvals"},
* summary="결재 문서 수정",
* description="임시저장 상태의 문서만 수정할 수 있습니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/ApprovalUpdateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="수정 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Approval"))
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청 또는 수정 불가 상태", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/approvals/{id}",
* tags={"Approvals"},
* summary="결재 문서 삭제",
* description="임시저장 상태의 문서만 삭제할 수 있습니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="삭제 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="삭제 완료"),
* @OA\Property(property="data", type="boolean", example=true)
* )
* ),
*
* @OA\Response(response=400, description="삭제 불가 상태", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
/**
* @OA\Post(
* path="/api/v1/approvals/{id}/submit",
* tags={"Approvals"},
* summary="결재 상신",
* description="임시저장 문서를 결재선에 상신합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/ApprovalSubmitRequest")
* ),
*
* @OA\Response(
* response=200,
* description="상신 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Approval"))
* }
* )
* ),
*
* @OA\Response(response=400, description="상신 불가 상태", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function submit() {}
/**
* @OA\Post(
* path="/api/v1/approvals/{id}/approve",
* tags={"Approvals"},
* summary="결재 승인",
* description="현재 순서의 결재자가 문서를 승인합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=false,
*
* @OA\JsonContent(
*
* @OA\Property(property="comment", type="string", example="승인합니다.", description="결재 의견 (선택)")
* )
* ),
*
* @OA\Response(
* response=200,
* description="승인 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Approval"))
* }
* )
* ),
*
* @OA\Response(response=400, description="결재 불가 상태 또는 결재 순서 아님", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function approve() {}
/**
* @OA\Post(
* path="/api/v1/approvals/{id}/reject",
* tags={"Approvals"},
* summary="결재 반려",
* description="현재 순서의 결재자가 문서를 반려합니다. 반려 사유는 필수입니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(
* required={"comment"},
*
* @OA\Property(property="comment", type="string", example="서류가 미비합니다. 재작성 바랍니다.", description="반려 사유 (필수)")
* )
* ),
*
* @OA\Response(
* response=200,
* description="반려 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Approval"))
* }
* )
* ),
*
* @OA\Response(response=400, description="결재 불가 상태 또는 결재 순서 아님", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="반려 사유 누락", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function reject() {}
/**
* @OA\Post(
* path="/api/v1/approvals/{id}/cancel",
* tags={"Approvals"},
* summary="결재 회수",
* description="기안자가 진행중인 문서를 회수합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="회수 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Approval"))
* }
* )
* ),
*
* @OA\Response(response=400, description="회수 불가 상태 또는 기안자가 아님", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function cancel() {}
/**
* @OA\Post(
* path="/api/v1/approvals/{id}/read",
* tags={"Approvals"},
* summary="열람 처리",
* description="참조자가 문서를 열람 처리합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="열람 처리 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Approval"))
* }
* )
* ),
*
* @OA\Response(response=400, description="참조자가 아님", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function markRead() {}
/**
* @OA\Post(
* path="/api/v1/approvals/{id}/unread",
* tags={"Approvals"},
* summary="미열람 처리",
* description="참조자가 문서를 미열람 처리합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="문서 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="미열람 처리 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Approval"))
* }
* )
* ),
*
* @OA\Response(response=400, description="참조자가 아님", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="문서를 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function markUnread() {}
}

View File

@@ -0,0 +1,277 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Approval Forms", description="결재 양식 관리")
*
* @OA\Schema(
* schema="ApprovalForm",
* type="object",
* description="결재 양식 정보",
*
* @OA\Property(property="id", type="integer", example=1, description="양식 ID"),
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
* @OA\Property(property="name", type="string", example="품의서", description="양식명"),
* @OA\Property(property="code", type="string", example="REQUEST_01", description="양식 코드"),
* @OA\Property(property="category", type="string", enum={"request","expense","expense_estimate"}, example="request", nullable=true, description="카테고리"),
* @OA\Property(property="template", type="object", description="템플릿 JSON",
* @OA\Property(property="fields", type="array", @OA\Items(type="object",
* @OA\Property(property="name", type="string", example="title"),
* @OA\Property(property="type", type="string", example="text"),
* @OA\Property(property="label", type="string", example="제목"),
* @OA\Property(property="required", type="boolean", example=true)
* ))
* ),
* @OA\Property(property="is_active", type="boolean", example=true, description="활성 여부"),
* @OA\Property(property="creator", type="object", nullable=true, description="생성자 정보",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="관리자")
* ),
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-01T09:00:00Z"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-01T09:00:00Z")
* )
*
* @OA\Schema(
* schema="ApprovalFormCreateRequest",
* type="object",
* required={"name", "code", "template"},
* description="결재 양식 생성 요청",
*
* @OA\Property(property="name", type="string", example="품의서", description="양식명"),
* @OA\Property(property="code", type="string", example="REQUEST_01", description="양식 코드 (영문, 숫자, _, -)"),
* @OA\Property(property="category", type="string", enum={"request","expense","expense_estimate"}, example="request", description="카테고리"),
* @OA\Property(property="template", type="object", description="템플릿 JSON",
* @OA\Property(property="fields", type="array", @OA\Items(type="object"))
* ),
* @OA\Property(property="is_active", type="boolean", example=true, description="활성 여부")
* )
*
* @OA\Schema(
* schema="ApprovalFormUpdateRequest",
* type="object",
* description="결재 양식 수정 요청",
*
* @OA\Property(property="name", type="string", example="품의서", description="양식명"),
* @OA\Property(property="code", type="string", example="REQUEST_01", description="양식 코드"),
* @OA\Property(property="category", type="string", enum={"request","expense","expense_estimate"}, description="카테고리"),
* @OA\Property(property="template", type="object", description="템플릿 JSON"),
* @OA\Property(property="is_active", type="boolean", description="활성 여부")
* )
*/
class ApprovalFormApi
{
/**
* @OA\Get(
* path="/api/v1/approval-forms",
* tags={"Approval Forms"},
* summary="결재 양식 목록 조회",
* description="필터/검색/페이지네이션으로 결재 양식 목록을 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="category", in="query", description="카테고리 필터", @OA\Schema(type="string", enum={"request","expense","expense_estimate"})),
* @OA\Parameter(name="is_active", in="query", description="활성 상태 필터", @OA\Schema(type="boolean")),
* @OA\Parameter(name="search", in="query", description="검색어 (이름, 코드)", @OA\Schema(type="string")),
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준", @OA\Schema(type="string", enum={"created_at","name","code","category"}, default="created_at")),
* @OA\Parameter(name="sort_dir", in="query", description="정렬 방향", @OA\Schema(type="string", enum={"asc","desc"}, default="desc")),
* @OA\Parameter(ref="#/components/parameters/Page"),
* @OA\Parameter(ref="#/components/parameters/Size"),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/ApprovalForm")),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=10)
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/approval-forms/active",
* tags={"Approval Forms"},
* summary="활성 결재 양식 목록 (셀렉트박스용)",
* description="활성화된 결재 양식만 간략하게 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(
* property="data",
* type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="품의서"),
* @OA\Property(property="code", type="string", example="REQUEST_01"),
* @OA\Property(property="category", type="string", example="request")
* )
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function active() {}
/**
* @OA\Get(
* path="/api/v1/approval-forms/{id}",
* tags={"Approval Forms"},
* summary="결재 양식 상세 조회",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="양식 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ApprovalForm"))
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="양식을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/approval-forms",
* tags={"Approval Forms"},
* summary="결재 양식 생성",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/ApprovalFormCreateRequest")
* ),
*
* @OA\Response(
* response=201,
* description="생성 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ApprovalForm"))
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청 (코드 중복 등)", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function store() {}
/**
* @OA\Patch(
* path="/api/v1/approval-forms/{id}",
* tags={"Approval Forms"},
* summary="결재 양식 수정",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="양식 ID", @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/ApprovalFormUpdateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="수정 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ApprovalForm"))
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="양식을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/approval-forms/{id}",
* tags={"Approval Forms"},
* summary="결재 양식 삭제",
* description="사용 중인 양식은 삭제할 수 없습니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="양식 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="삭제 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="삭제 완료"),
* @OA\Property(property="data", type="boolean", example=true)
* )
* ),
*
* @OA\Response(response=400, description="삭제 불가 (사용 중)", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="양식을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
}

View File

@@ -0,0 +1,223 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Approval Lines", description="결재선 템플릿 관리")
*
* @OA\Schema(
* schema="ApprovalLine",
* type="object",
* description="결재선 템플릿 정보",
*
* @OA\Property(property="id", type="integer", example=1, description="결재선 ID"),
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
* @OA\Property(property="name", type="string", example="기본 결재선", description="결재선명"),
* @OA\Property(property="steps", type="array", description="결재 단계", @OA\Items(type="object",
* @OA\Property(property="type", type="string", enum={"approval","agreement","reference"}, example="approval", description="단계 유형"),
* @OA\Property(property="user_id", type="integer", example=10, description="결재자 ID")
* )),
* @OA\Property(property="is_default", type="boolean", example=false, description="기본 결재선 여부"),
* @OA\Property(property="step_count", type="integer", example=3, description="단계 수"),
* @OA\Property(property="creator", type="object", nullable=true, description="생성자 정보",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="관리자")
* ),
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-01T09:00:00Z"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-01T09:00:00Z")
* )
*
* @OA\Schema(
* schema="ApprovalLineCreateRequest",
* type="object",
* required={"name", "steps"},
* description="결재선 생성 요청",
*
* @OA\Property(property="name", type="string", example="기본 결재선", description="결재선명"),
* @OA\Property(property="steps", type="array", description="결재 단계", @OA\Items(type="object",
* @OA\Property(property="type", type="string", enum={"approval","agreement","reference"}, example="approval", description="단계 유형"),
* @OA\Property(property="user_id", type="integer", example=10, description="결재자 ID")
* )),
* @OA\Property(property="is_default", type="boolean", example=false, description="기본 결재선 여부")
* )
*
* @OA\Schema(
* schema="ApprovalLineUpdateRequest",
* type="object",
* description="결재선 수정 요청",
*
* @OA\Property(property="name", type="string", example="기본 결재선", description="결재선명"),
* @OA\Property(property="steps", type="array", description="결재 단계", @OA\Items(type="object")),
* @OA\Property(property="is_default", type="boolean", description="기본 결재선 여부")
* )
*/
class ApprovalLineApi
{
/**
* @OA\Get(
* path="/api/v1/approval-lines",
* tags={"Approval Lines"},
* summary="결재선 목록 조회",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="search", in="query", description="검색어 (이름)", @OA\Schema(type="string")),
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준", @OA\Schema(type="string", enum={"created_at","name","is_default"}, default="created_at")),
* @OA\Parameter(name="sort_dir", in="query", description="정렬 방향", @OA\Schema(type="string", enum={"asc","desc"}, default="desc")),
* @OA\Parameter(ref="#/components/parameters/Page"),
* @OA\Parameter(ref="#/components/parameters/Size"),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/ApprovalLine")),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=5)
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/approval-lines/{id}",
* tags={"Approval Lines"},
* summary="결재선 상세 조회",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="결재선 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ApprovalLine"))
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="결재선을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/approval-lines",
* tags={"Approval Lines"},
* summary="결재선 생성",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/ApprovalLineCreateRequest")
* ),
*
* @OA\Response(
* response=201,
* description="생성 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ApprovalLine"))
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function store() {}
/**
* @OA\Patch(
* path="/api/v1/approval-lines/{id}",
* tags={"Approval Lines"},
* summary="결재선 수정",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="결재선 ID", @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/ApprovalLineUpdateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="수정 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ApprovalLine"))
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="결재선을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/approval-lines/{id}",
* tags={"Approval Lines"},
* summary="결재선 삭제",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="결재선 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="삭제 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="삭제 완료"),
* @OA\Property(property="data", type="boolean", example=true)
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="결재선을 찾을 수 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
}

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('approval_forms', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('approval_lines', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('approvals', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('approval_steps', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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' => '유효하지 않은 근무 유형입니다.',

View File

@@ -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' => '근무 설정을 조회했습니다.',

View File

@@ -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');