diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 062e475..d88985c 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2025-12-22 19:39:30 +> **자동 생성**: 2025-12-23 22:25:44 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -150,6 +150,10 @@ ### estimate_items - **estimate()**: belongsTo → `estimates` +### fcm_send_logs +**모델**: `App\Models\FcmSendLog` + + ### file_share_links **모델**: `App\Models\FileShareLink` @@ -287,6 +291,8 @@ ### clients - **clientGroup()**: belongsTo → `client_groups` - **orders()**: hasMany → `orders` +- **badDebts()**: hasMany → `bad_debts` +- **activeBadDebts()**: hasMany → `bad_debts` ### client_groups **모델**: `App\Models\Orders\ClientGroup` @@ -489,6 +495,20 @@ ### barobill_settings - **creator()**: belongsTo → `users` - **updater()**: belongsTo → `users` +### bills +**모델**: `App\Models\Tenants\Bill` + +- **client()**: belongsTo → `clients` +- **bankAccount()**: belongsTo → `bank_accounts` +- **creator()**: belongsTo → `users` +- **installments()**: hasMany → `bill_installments` + +### bill_installments +**모델**: `App\Models\Tenants\BillInstallment` + +- **bill()**: belongsTo → `bills` +- **creator()**: belongsTo → `users` + ### cards **모델**: `App\Models\Tenants\Card` diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 67bffb6..434d377 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -3,6 +3,7 @@ namespace App\Exceptions; use Illuminate\Auth\AuthenticationException; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Support\Facades\Http; use Illuminate\Validation\ValidationException; @@ -114,7 +115,7 @@ public function render($request, Throwable $exception) ], 403); } - // 404 Not Found + // 404 Not Found - 라우트 없음 if ($exception instanceof NotFoundHttpException) { return response()->json([ 'success' => false, @@ -123,6 +124,15 @@ public function render($request, Throwable $exception) ], 404); } + // 404 Not Found - 모델 없음 (findOrFail 등) + if ($exception instanceof ModelNotFoundException) { + return response()->json([ + 'success' => false, + 'message' => '데이터를 찾을 수 없습니다', + 'data' => null, + ], 404); + } + // 405 Method Not Allowed if ($exception instanceof MethodNotAllowedHttpException) { return response()->json([ diff --git a/app/Http/Controllers/Api/V1/PlanController.php b/app/Http/Controllers/Api/V1/PlanController.php index 2968e7b..b6874af 100644 --- a/app/Http/Controllers/Api/V1/PlanController.php +++ b/app/Http/Controllers/Api/V1/PlanController.php @@ -2,11 +2,11 @@ namespace App\Http\Controllers\Api\V1; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\Plan\PlanIndexRequest; use App\Http\Requests\V1\Plan\PlanStoreRequest; use App\Http\Requests\V1\Plan\PlanUpdateRequest; -use App\Helpers\ApiResponse; use App\Services\PlanService; use Illuminate\Http\JsonResponse; @@ -21,9 +21,10 @@ public function __construct( */ public function index(PlanIndexRequest $request): JsonResponse { - $result = $this->planService->index($request->validated()); - - return ApiResponse::handle('message.fetched', $result); + return ApiResponse::handle( + fn () => $this->planService->index($request->validated()), + __('message.fetched') + ); } /** @@ -31,9 +32,10 @@ public function index(PlanIndexRequest $request): JsonResponse */ public function active(): JsonResponse { - $result = $this->planService->active(); - - return ApiResponse::handle('message.fetched', $result); + return ApiResponse::handle( + fn () => $this->planService->active(), + __('message.fetched') + ); } /** @@ -41,9 +43,10 @@ public function active(): JsonResponse */ public function store(PlanStoreRequest $request): JsonResponse { - $result = $this->planService->store($request->validated()); - - return ApiResponse::handle('message.created', $result, 201); + return ApiResponse::handle( + fn () => $this->planService->store($request->validated()), + __('message.created') + ); } /** @@ -51,9 +54,10 @@ public function store(PlanStoreRequest $request): JsonResponse */ public function show(int $id): JsonResponse { - $result = $this->planService->show($id); - - return ApiResponse::handle('message.fetched', $result); + return ApiResponse::handle( + fn () => $this->planService->show($id), + __('message.fetched') + ); } /** @@ -61,9 +65,10 @@ public function show(int $id): JsonResponse */ public function update(PlanUpdateRequest $request, int $id): JsonResponse { - $result = $this->planService->update($id, $request->validated()); - - return ApiResponse::handle('message.updated', $result); + return ApiResponse::handle( + fn () => $this->planService->update($id, $request->validated()), + __('message.updated') + ); } /** @@ -71,9 +76,10 @@ public function update(PlanUpdateRequest $request, int $id): JsonResponse */ public function destroy(int $id): JsonResponse { - $this->planService->destroy($id); - - return ApiResponse::handle('message.deleted'); + return ApiResponse::handle( + fn () => $this->planService->destroy($id), + __('message.deleted') + ); } /** @@ -81,8 +87,9 @@ public function destroy(int $id): JsonResponse */ public function toggle(int $id): JsonResponse { - $result = $this->planService->toggle($id); - - return ApiResponse::handle('message.updated', $result); + return ApiResponse::handle( + fn () => $this->planService->toggle($id), + __('message.updated') + ); } } diff --git a/app/Models/Orders/Client.php b/app/Models/Orders/Client.php index 5cba13a..2d40dc0 100644 --- a/app/Models/Orders/Client.php +++ b/app/Models/Orders/Client.php @@ -2,9 +2,11 @@ namespace App\Models\Orders; +use App\Models\BadDebts\BadDebt; use App\Traits\BelongsToTenant; use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; class Client extends Model { @@ -32,11 +34,6 @@ class Client extends Model 'tax_amount', 'tax_start_date', 'tax_end_date', - 'bad_debt', - 'bad_debt_amount', - 'bad_debt_receive_date', - 'bad_debt_end_date', - 'bad_debt_progress', 'memo', 'is_active', 'client_type', @@ -51,10 +48,6 @@ class Client extends Model 'tax_amount' => 'decimal:2', 'tax_start_date' => 'date', 'tax_end_date' => 'date', - 'bad_debt' => 'boolean', - 'bad_debt_amount' => 'decimal:2', - 'bad_debt_receive_date' => 'date', - 'bad_debt_end_date' => 'date', ]; protected $hidden = [ @@ -73,6 +66,20 @@ public function orders() return $this->hasMany(Order::class, 'client_id'); } + // 악성채권 관계 + public function badDebts(): HasMany + { + return $this->hasMany(BadDebt::class); + } + + // 활성 악성채권 관계 (추심중, 법적조치) + public function activeBadDebts(): HasMany + { + return $this->hasMany(BadDebt::class) + ->whereIn('status', [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION]) + ->where('is_active', true); + } + // 스코프 public function scopeActive($query) { diff --git a/app/Services/ClientService.php b/app/Services/ClientService.php index e504d04..bf44e33 100644 --- a/app/Services/ClientService.php +++ b/app/Services/ClientService.php @@ -2,7 +2,11 @@ namespace App\Services; +use App\Models\BadDebts\BadDebt; use App\Models\Orders\Client; +use App\Models\Tenants\Deposit; +use App\Models\Tenants\Sale; +use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -34,7 +38,51 @@ public function index(array $params) $query->orderBy('client_code')->orderBy('id'); - return $query->paginate($size, ['*'], 'page', $page); + $paginator = $query->paginate($size, ['*'], 'page', $page); + + // 미수금 계산: 매출 합계 - 입금 합계 + $clientIds = $paginator->pluck('id')->toArray(); + + if (! empty($clientIds)) { + // 거래처별 매출 합계 + $salesByClient = Sale::where('tenant_id', $tenantId) + ->whereIn('client_id', $clientIds) + ->whereNull('deleted_at') + ->groupBy('client_id') + ->select('client_id', DB::raw('SUM(total_amount) as total_sales')) + ->pluck('total_sales', 'client_id'); + + // 거래처별 입금 합계 + $depositsByClient = Deposit::where('tenant_id', $tenantId) + ->whereIn('client_id', $clientIds) + ->whereNull('deleted_at') + ->groupBy('client_id') + ->select('client_id', DB::raw('SUM(amount) as total_deposits')) + ->pluck('total_deposits', 'client_id'); + + // 거래처별 활성 악성채권 합계 (추심중, 법적조치) + $badDebtsByClient = BadDebt::where('tenant_id', $tenantId) + ->whereIn('client_id', $clientIds) + ->whereIn('status', [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION]) + ->where('is_active', true) + ->whereNull('deleted_at') + ->groupBy('client_id') + ->select('client_id', DB::raw('SUM(debt_amount) as total_bad_debt')) + ->pluck('total_bad_debt', 'client_id'); + + // 각 거래처에 미수금/악성채권 정보 추가 + $paginator->getCollection()->transform(function ($client) use ($salesByClient, $depositsByClient, $badDebtsByClient) { + $totalSales = $salesByClient[$client->id] ?? 0; + $totalDeposits = $depositsByClient[$client->id] ?? 0; + $client->outstanding_amount = max(0, $totalSales - $totalDeposits); + $client->bad_debt_total = $badDebtsByClient[$client->id] ?? 0; + $client->has_bad_debt = ($badDebtsByClient[$client->id] ?? 0) > 0; + + return $client; + }); + } + + return $paginator; } /** 단건 */ @@ -46,6 +94,30 @@ public function show(int $id) throw new NotFoundHttpException(__('error.not_found')); } + // 미수금 계산 + $totalSales = Sale::where('tenant_id', $tenantId) + ->where('client_id', $id) + ->whereNull('deleted_at') + ->sum('total_amount'); + + $totalDeposits = Deposit::where('tenant_id', $tenantId) + ->where('client_id', $id) + ->whereNull('deleted_at') + ->sum('amount'); + + $client->outstanding_amount = max(0, $totalSales - $totalDeposits); + + // 악성채권 정보 + $badDebtTotal = BadDebt::where('tenant_id', $tenantId) + ->where('client_id', $id) + ->whereIn('status', [BadDebt::STATUS_COLLECTING, BadDebt::STATUS_LEGAL_ACTION]) + ->where('is_active', true) + ->whereNull('deleted_at') + ->sum('debt_amount'); + + $client->bad_debt_total = $badDebtTotal; + $client->has_bad_debt = $badDebtTotal > 0; + return $client; } diff --git a/claudedocs/flow-tester-auth.json b/claudedocs/flow-tester-auth.json deleted file mode 100644 index 48aac9c..0000000 --- a/claudedocs/flow-tester-auth.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "name": "인증 플로우 테스트", - "description": "로그인, 프로필 조회, 토큰 갱신, 로그아웃 플로우를 테스트합니다.", - "version": "1.0", - "config": { - "apiKey": "42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a", - "baseUrl": "https://api.sam.kr/api/v1", - "timeout": 30000, - "stopOnFailure": true - }, - "variables": { - "user_id": "codebridgex", - "user_pwd": "code1234" - }, - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token", - "refreshToken": "$.refresh_token" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.access_token": "@isString" - } - } - }, - { - "id": "get_profile", - "name": "프로필 조회", - "method": "GET", - "endpoint": "/users/me", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["login"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "refresh_token", - "name": "토큰 갱신", - "method": "POST", - "endpoint": "/refresh", - "body": { - "refresh_token": "{{login.refreshToken}}" - }, - "dependsOn": ["get_profile"], - "extract": { - "newToken": "$.access_token" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.access_token": "@isString" - } - } - }, - { - "id": "logout", - "name": "로그아웃", - "method": "POST", - "endpoint": "/logout", - "headers": { - "Authorization": "Bearer {{refresh_token.newToken}}" - }, - "dependsOn": ["refresh_token"], - "expect": { - "status": [200, 204] - } - } - ] -} diff --git a/claudedocs/flow-tester-client.json b/claudedocs/flow-tester-client.json deleted file mode 100644 index ab3c254..0000000 --- a/claudedocs/flow-tester-client.json +++ /dev/null @@ -1,215 +0,0 @@ -{ - "name": "Client API CRUD 테스트", - "description": "거래처(Client) API 전체 CRUD 테스트 - 생성, 조회, 수정, 토글, 삭제 포함. business_no, business_type, business_item 신규 필드 검증 포함.", - "version": "1.0", - "config": { - "baseUrl": "https://api.sam.kr/api/v1", - "apiKey": "{{$env.FLOW_TESTER_API_KEY}}", - "timeout": 30000, - "stopOnFailure": true - }, - "variables": { - "user_id": "{{$env.FLOW_TESTER_USER_ID}}", - "user_pwd": "{{$env.FLOW_TESTER_USER_PWD}}", - "test_client_code": "TEST_CLIENT_{{$timestamp}}" - }, - "steps": [ - { - "id": "login", - "name": "1. 로그인 - 토큰 획득", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.access_token": "@isString" - } - }, - "extract": { - "token": "$.access_token" - } - }, - { - "id": "create_client", - "name": "2. 거래처 생성 (신규 필드 포함)", - "method": "POST", - "endpoint": "/clients", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "client_code": "{{variables.test_client_code}}", - "name": "테스트 거래처", - "contact_person": "홍길동", - "phone": "02-1234-5678", - "email": "test@example.com", - "address": "서울시 강남구 테헤란로 123", - "business_no": "123-45-67890", - "business_type": "제조업", - "business_item": "전자부품", - "is_active": "Y" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.id": "@isNumber", - "$.data.client_code": "{{variables.test_client_code}}", - "$.data.name": "테스트 거래처", - "$.data.business_no": "123-45-67890", - "$.data.business_type": "제조업", - "$.data.business_item": "전자부품" - } - }, - "extract": { - "client_id": "$.data.id" - } - }, - { - "id": "list_clients", - "name": "3. 거래처 목록 조회", - "method": "GET", - "endpoint": "/clients?page=1&size=20&q=테스트", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.data": "@isArray", - "$.data.current_page": 1 - } - } - }, - { - "id": "show_client", - "name": "4. 거래처 단건 조회", - "method": "GET", - "endpoint": "/clients/{{create_client.client_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.id": "{{create_client.client_id}}", - "$.data.client_code": "{{variables.test_client_code}}", - "$.data.business_no": "123-45-67890", - "$.data.business_type": "제조업", - "$.data.business_item": "전자부품" - } - } - }, - { - "id": "update_client", - "name": "5. 거래처 수정 (신규 필드 변경)", - "method": "PUT", - "endpoint": "/clients/{{create_client.client_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "name": "테스트 거래처 (수정됨)", - "contact_person": "김철수", - "business_no": "987-65-43210", - "business_type": "도소매업", - "business_item": "IT솔루션" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.name": "테스트 거래처 (수정됨)", - "$.data.contact_person": "김철수", - "$.data.business_no": "987-65-43210", - "$.data.business_type": "도소매업", - "$.data.business_item": "IT솔루션" - } - } - }, - { - "id": "toggle_client", - "name": "6. 거래처 활성/비활성 토글 (N으로)", - "method": "PATCH", - "endpoint": "/clients/{{create_client.client_id}}/toggle", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.is_active": false - } - } - }, - { - "id": "toggle_client_back", - "name": "7. 거래처 토글 복원 (Y로)", - "method": "PATCH", - "endpoint": "/clients/{{create_client.client_id}}/toggle", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.is_active": true - } - } - }, - { - "id": "list_active_only", - "name": "8. 활성 거래처만 조회", - "method": "GET", - "endpoint": "/clients?only_active=1", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.data": "@isArray" - } - } - }, - { - "id": "delete_client", - "name": "9. 거래처 삭제", - "method": "DELETE", - "endpoint": "/clients/{{create_client.client_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "verify_deleted", - "name": "10. 삭제 확인 (404 예상)", - "method": "GET", - "endpoint": "/clients/{{create_client.client_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [404], - "jsonPath": { - "$.success": false - } - } - } - ] -} \ No newline at end of file diff --git a/claudedocs/flow-tester-item-delete.json b/claudedocs/flow-tester-item-delete.json deleted file mode 100644 index e03e817..0000000 --- a/claudedocs/flow-tester-item-delete.json +++ /dev/null @@ -1,287 +0,0 @@ -{ - "name": "품목 삭제 API 테스트", - "description": "품목 삭제 시 참조 무결성 체크 및 soft delete 동작을 테스트합니다.", - "version": "1.0", - "config": { - "apiKey": "42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a", - "baseUrl": "https://api.sam.kr/api/v1", - "timeout": 30000, - "stopOnFailure": false - }, - "variables": { - "user_id": "codebridgex", - "user_pwd": "code1234", - "testProductCode": "TEST-DEL-001", - "testProductName": "삭제 테스트용 품목", - "testBomParentCode": "TEST-BOM-PARENT-001", - "testBomParentName": "BOM 부모 품목" - }, - "steps": [ - { - "id": "login", - "name": "로그인", - "description": "API 테스트를 위한 로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token", - "refreshToken": "$.refresh_token" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.access_token": "@isString" - } - } - }, - { - "id": "create_product_for_delete", - "name": "삭제 테스트용 품목 생성", - "description": "단순 삭제 테스트용 품목 생성 (BOM에 미사용)", - "method": "POST", - "endpoint": "/items", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "code": "{{variables.testProductCode}}", - "name": "{{variables.testProductName}}", - "unit": "EA", - "product_type": "FG", - "is_active": true - }, - "dependsOn": ["login"], - "extract": { - "createdProductId": "$.data.id", - "createdProductCode": "$.data.code" - }, - "expect": { - "status": [200, 201], - "jsonPath": { - "$.success": true, - "$.data.id": "@isNumber" - } - } - }, - { - "id": "get_items_list", - "name": "품목 목록 조회", - "description": "생성된 품목이 목록에 있는지 확인", - "method": "GET", - "endpoint": "/items?type=FG&search={{create_product_for_delete.createdProductCode}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["create_product_for_delete"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "delete_product_success", - "name": "품목 삭제 (정상)", - "description": "BOM에서 사용되지 않는 품목 삭제 - 성공해야 함", - "method": "DELETE", - "endpoint": "/items/{{create_product_for_delete.createdProductId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["get_items_list"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "verify_deleted_not_in_list", - "name": "삭제된 품목 목록 미포함 확인", - "description": "삭제된 품목이 기본 목록에서 제외되는지 확인", - "method": "GET", - "endpoint": "/items?type=FG&search={{create_product_for_delete.createdProductCode}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["delete_product_success"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - }, - "note": "data.total이 0이어야 함 (삭제된 품목 미포함)" - }, - { - "id": "delete_already_deleted_item", - "name": "이미 삭제된 품목 재삭제 시도", - "description": "soft delete된 품목을 다시 삭제하면 404 반환해야 함", - "method": "DELETE", - "endpoint": "/items/{{create_product_for_delete.createdProductId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["verify_deleted_not_in_list"], - "expect": { - "status": [404], - "jsonPath": { - "$.success": false - } - } - }, - { - "id": "create_bom_parent", - "name": "BOM 부모 품목 생성", - "description": "BOM 테스트를 위한 부모 품목 생성", - "method": "POST", - "endpoint": "/items", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "code": "{{variables.testBomParentCode}}", - "name": "{{variables.testBomParentName}}", - "unit": "EA", - "product_type": "FG", - "is_active": true - }, - "dependsOn": ["login"], - "extract": { - "bomParentId": "$.data.id" - }, - "expect": { - "status": [200, 201], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "create_bom_child", - "name": "BOM 자식 품목 생성", - "description": "BOM 구성품으로 사용될 품목 생성", - "method": "POST", - "endpoint": "/items", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "code": "TEST-BOM-CHILD-001", - "name": "BOM 자식 품목", - "unit": "EA", - "product_type": "PT", - "is_active": true - }, - "dependsOn": ["create_bom_parent"], - "extract": { - "bomChildId": "$.data.id" - }, - "expect": { - "status": [200, 201], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "add_bom_component", - "name": "BOM 구성품 추가", - "description": "부모 품목에 자식 품목을 BOM으로 등록", - "method": "POST", - "endpoint": "/items/{{create_bom_parent.bomParentId}}/bom", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "items": [ - { - "ref_type": "PRODUCT", - "ref_id": "{{create_bom_child.bomChildId}}", - "quantity": 2, - "sort_order": 1 - } - ] - }, - "dependsOn": ["create_bom_child"], - "expect": { - "status": [200, 201], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "delete_bom_used_item_fail", - "name": "BOM 사용 중인 품목 삭제 시도", - "description": "다른 BOM에서 구성품으로 사용 중인 품목 삭제 - 400 에러 반환해야 함", - "method": "DELETE", - "endpoint": "/items/{{create_bom_child.bomChildId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["add_bom_component"], - "expect": { - "status": [400], - "jsonPath": { - "$.success": false, - "$.message": "@contains:BOM" - } - } - }, - { - "id": "cleanup_bom", - "name": "BOM 구성품 제거", - "description": "테스트 정리 - BOM 구성품 제거", - "method": "DELETE", - "endpoint": "/items/{{create_bom_parent.bomParentId}}/bom", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["delete_bom_used_item_fail"], - "expect": { - "status": [200, 204] - } - }, - { - "id": "delete_bom_child_after_cleanup", - "name": "BOM 해제 후 자식 품목 삭제", - "description": "BOM에서 제거된 품목은 삭제 가능해야 함", - "method": "DELETE", - "endpoint": "/items/{{create_bom_child.bomChildId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["cleanup_bom"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "cleanup_bom_parent", - "name": "BOM 부모 품목 삭제", - "description": "테스트 정리 - 부모 품목 삭제", - "method": "DELETE", - "endpoint": "/items/{{create_bom_parent.bomParentId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["delete_bom_child_after_cleanup"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - } - ] -} \ No newline at end of file diff --git a/claudedocs/flow-tester-item-master.json b/claudedocs/flow-tester-item-master.json deleted file mode 100644 index 77663a7..0000000 --- a/claudedocs/flow-tester-item-master.json +++ /dev/null @@ -1,157 +0,0 @@ -{ - "name": "품목기준관리 통합 테스트", - "description": "품목기준관리 API의 전체 CRUD 플로우를 테스트합니다.", - "version": "1.0", - "config": { - "apiKey": "42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a", - "baseUrl": "https://api.sam.kr/api/v1", - "timeout": 30000, - "stopOnFailure": true - }, - "variables": { - "user_id": "codebridgex", - "user_pwd": "code1234", - "testItemCode": "TEST-ITEM-{{$timestamp}}", - "testItemName": "테스트 품목", - "testItemSpec": "100x100x10", - "updatedItemName": "수정된 테스트 품목", - "updatedItemSpec": "200x200x20" - }, - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.access_token": "@isString" - } - } - }, - { - "id": "create_item", - "name": "품목 생성", - "method": "POST", - "endpoint": "/item-master-data", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "item_code": "{{variables.testItemCode}}", - "item_name": "{{variables.testItemName}}", - "item_spec": "{{variables.testItemSpec}}", - "item_type": "PRODUCT", - "unit": "EA", - "is_active": true - }, - "dependsOn": ["login"], - "extract": { - "createdItemId": "$.data.id", - "createdItemCode": "$.data.item_code" - }, - "expect": { - "status": [201], - "jsonPath": { - "$.data.id": "@exists" - } - } - }, - { - "id": "get_item", - "name": "품목 단건 조회", - "method": "GET", - "endpoint": "/item-master-data/{{create_item.createdItemId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["create_item"], - "expect": { - "status": [200], - "jsonPath": { - "$.data.id": "@exists" - } - } - }, - { - "id": "list_items", - "name": "품목 목록 조회", - "method": "GET", - "endpoint": "/item-master-data", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["create_item"], - "expect": { - "status": [200], - "jsonPath": { - "$.data": "@isArray" - } - } - }, - { - "id": "update_item", - "name": "품목 수정", - "method": "PUT", - "endpoint": "/item-master-data/{{create_item.createdItemId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "item_name": "{{variables.updatedItemName}}", - "item_spec": "{{variables.updatedItemSpec}}" - }, - "dependsOn": ["get_item"], - "expect": { - "status": [200] - } - }, - { - "id": "verify_update", - "name": "수정 확인", - "method": "GET", - "endpoint": "/item-master-data/{{create_item.createdItemId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["update_item"], - "expect": { - "status": [200] - } - }, - { - "id": "delete_item", - "name": "품목 삭제", - "method": "DELETE", - "endpoint": "/item-master-data/{{create_item.createdItemId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["verify_update"], - "expect": { - "status": [200] - } - }, - { - "id": "verify_delete", - "name": "삭제 확인", - "method": "GET", - "endpoint": "/item-master-data/{{create_item.createdItemId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["delete_item"], - "expect": { - "status": [404] - } - } - ] -} \ No newline at end of file diff --git a/database/migrations/2025_12_22_222428_alter_payments_paid_at_nullable.php b/database/migrations/2025_12_22_222428_alter_payments_paid_at_nullable.php new file mode 100644 index 0000000..abdf65c --- /dev/null +++ b/database/migrations/2025_12_22_222428_alter_payments_paid_at_nullable.php @@ -0,0 +1,30 @@ +datetime('paid_at')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('payments', function (Blueprint $table) { + $table->datetime('paid_at')->nullable(false)->change(); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_12_22_222722_add_cancel_columns_to_subscriptions_table.php b/database/migrations/2025_12_22_222722_add_cancel_columns_to_subscriptions_table.php new file mode 100644 index 0000000..ddd452b --- /dev/null +++ b/database/migrations/2025_12_22_222722_add_cancel_columns_to_subscriptions_table.php @@ -0,0 +1,31 @@ +timestamp('cancelled_at')->nullable()->after('ended_at')->comment('취소 일시'); + $table->string('cancel_reason', 500)->nullable()->after('cancelled_at')->comment('취소 사유'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn(['cancelled_at', 'cancel_reason']); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_12_23_222356_remove_bad_debt_columns_from_clients_table.php b/database/migrations/2025_12_23_222356_remove_bad_debt_columns_from_clients_table.php new file mode 100644 index 0000000..3eaa33b --- /dev/null +++ b/database/migrations/2025_12_23_222356_remove_bad_debt_columns_from_clients_table.php @@ -0,0 +1,44 @@ +dropColumn([ + 'bad_debt', + 'bad_debt_amount', + 'bad_debt_receive_date', + 'bad_debt_end_date', + 'bad_debt_progress', + ]); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('clients', function (Blueprint $table) { + $table->boolean('bad_debt')->default(false)->after('tax_end_date')->comment('악성채권 여부'); + $table->decimal('bad_debt_amount', 15, 2)->nullable()->after('bad_debt')->comment('악성채권 금액'); + $table->date('bad_debt_receive_date')->nullable()->after('bad_debt_amount')->comment('악성채권 발생일'); + $table->date('bad_debt_end_date')->nullable()->after('bad_debt_receive_date')->comment('악성채권 종료일'); + $table->string('bad_debt_progress', 20)->nullable()->after('bad_debt_end_date')->comment('악성채권 진행상황'); + }); + } +}; \ No newline at end of file diff --git a/database/seeders/Dummy/DummyBadDebtSeeder.php b/database/seeders/Dummy/DummyBadDebtSeeder.php new file mode 100644 index 0000000..ca1b72c --- /dev/null +++ b/database/seeders/Dummy/DummyBadDebtSeeder.php @@ -0,0 +1,100 @@ +whereIn('client_type', ['SALES', 'BOTH']) + ->get(); + + if ($clients->isEmpty()) { + $this->command->warn(' ⚠ clients 데이터가 없습니다. DummyClientSeeder를 먼저 실행하세요.'); + return; + } + + // 상태별 분포 + $statuses = [ + 'collecting' => 6, // 추심중 + 'legal_action' => 4, // 법적조치 + 'recovered' => 5, // 회수완료 + 'bad_debt' => 3, // 대손처리 + ]; + + // 금액 범위 (채권 금액) + $amounts = [ + 'small' => [500000, 3000000], // 50만~300만 + 'medium' => [3000000, 15000000], // 300만~1500만 + 'large' => [15000000, 50000000], // 1500만~5000만 + ]; + + $count = 0; + $year = 2025; + $clientIndex = 0; + + foreach ($statuses as $status => $qty) { + for ($i = 0; $i < $qty; $i++) { + $client = $clients[$clientIndex % $clients->count()]; + $clientIndex++; + + // 발생월 (1~10월 사이 랜덤) + $month = rand(1, 10); + $day = rand(1, 28); + $occurredAt = sprintf('%04d-%02d-%02d', $year, $month, $day); + + // 연체일수 계산 (발생일로부터 현재까지) + $occurredDate = new \DateTime($occurredAt); + $now = new \DateTime('2025-12-23'); + $overdueDays = $occurredDate->diff($now)->days; + + // 금액 결정 (분포에 따라) + $rand = rand(1, 100); + if ($rand <= 40) { + $amount = rand($amounts['small'][0], $amounts['small'][1]); + } elseif ($rand <= 80) { + $amount = rand($amounts['medium'][0], $amounts['medium'][1]); + } else { + $amount = rand($amounts['large'][0], $amounts['large'][1]); + } + + // 종료일 (회수완료/대손처리인 경우만) + $closedAt = null; + if (in_array($status, ['recovered', 'bad_debt'])) { + $closedMonth = rand($month + 1, 12); + $closedDay = rand(1, 28); + $closedAt = sprintf('%04d-%02d-%02d', $year, $closedMonth, $closedDay); + } + + BadDebt::create([ + 'tenant_id' => $tenantId, + 'client_id' => $client->id, + 'debt_amount' => $amount, + 'status' => $status, + 'overdue_days' => $overdueDays, + 'assigned_user_id' => $userId, + 'occurred_at' => $occurredAt, + 'closed_at' => $closedAt, + 'is_active' => true, + 'options' => null, + 'created_by' => $userId, + ]); + + $count++; + } + } + + $this->command->info(' ✓ bad_debts: ' . $count . '건 생성'); + $this->command->info(' - collecting: 6건, legal_action: 4건, recovered: 5건, bad_debt: 3건'); + } +} \ No newline at end of file diff --git a/database/seeders/Dummy/DummyBankAccountSeeder.php b/database/seeders/Dummy/DummyBankAccountSeeder.php new file mode 100644 index 0000000..edfefa7 --- /dev/null +++ b/database/seeders/Dummy/DummyBankAccountSeeder.php @@ -0,0 +1,40 @@ + '004', 'bank_name' => 'KB국민은행', 'account_number' => '123-45-6789012', 'account_holder' => '프론트테스트', 'account_name' => '운영계좌', 'is_primary' => true], + ['bank_code' => '088', 'bank_name' => '신한은행', 'account_number' => '110-123-456789', 'account_holder' => '프론트테스트', 'account_name' => '급여계좌', 'is_primary' => false], + ['bank_code' => '020', 'bank_name' => '우리은행', 'account_number' => '1002-123-456789', 'account_holder' => '프론트테스트', 'account_name' => '예비계좌', 'is_primary' => false], + ['bank_code' => '081', 'bank_name' => '하나은행', 'account_number' => '123-456789-12345','account_holder' => '프론트테스트', 'account_name' => '법인카드', 'is_primary' => false], + ['bank_code' => '011', 'bank_name' => 'NH농협은행', 'account_number' => '351-1234-5678-12','account_holder' => '프론트테스트', 'account_name' => '비상금', 'is_primary' => false], + ]; + + foreach ($accounts as $account) { + BankAccount::create([ + 'tenant_id' => $tenantId, + 'bank_code' => $account['bank_code'], + 'bank_name' => $account['bank_name'], + 'account_number' => $account['account_number'], + 'account_holder' => $account['account_holder'], + 'account_name' => $account['account_name'], + 'status' => 'active', + 'is_primary' => $account['is_primary'], + 'created_by' => $userId, + ]); + } + + $this->command->info(' ✓ bank_accounts: ' . count($accounts) . '건 생성'); + } +} \ No newline at end of file diff --git a/database/seeders/Dummy/DummyClientGroupSeeder.php b/database/seeders/Dummy/DummyClientGroupSeeder.php new file mode 100644 index 0000000..9197406 --- /dev/null +++ b/database/seeders/Dummy/DummyClientGroupSeeder.php @@ -0,0 +1,37 @@ + 'VIP', 'group_name' => 'VIP 고객', 'price_rate' => 0.95], + ['group_code' => 'GOLD', 'group_name' => '골드 고객', 'price_rate' => 0.97], + ['group_code' => 'SILVER', 'group_name' => '실버 고객', 'price_rate' => 0.98], + ['group_code' => 'NORMAL', 'group_name' => '일반 고객', 'price_rate' => 1.00], + ['group_code' => 'NEW', 'group_name' => '신규 고객', 'price_rate' => 1.00], + ]; + + foreach ($groups as $group) { + ClientGroup::create([ + 'tenant_id' => $tenantId, + 'group_code' => $group['group_code'], + 'group_name' => $group['group_name'], + 'price_rate' => $group['price_rate'], + 'is_active' => true, + 'created_by' => $userId, + ]); + } + + $this->command->info(' ✓ client_groups: ' . count($groups) . '건 생성'); + } +} \ No newline at end of file diff --git a/database/seeders/Dummy/DummyClientSeeder.php b/database/seeders/Dummy/DummyClientSeeder.php new file mode 100644 index 0000000..3ebb7e1 --- /dev/null +++ b/database/seeders/Dummy/DummyClientSeeder.php @@ -0,0 +1,94 @@ +pluck('id', 'group_code') + ->toArray(); + + // 매출처 (SALES) - 10개 + $salesClients = [ + ['code' => 'S001', 'name' => '삼성전자', 'business_no' => '124-81-00998', 'group' => 'VIP', 'contact' => '김철수', 'phone' => '02-1234-5678', 'email' => 'kim@samsung.com'], + ['code' => 'S002', 'name' => 'LG전자', 'business_no' => '107-86-14075', 'group' => 'VIP', 'contact' => '이영희', 'phone' => '02-2345-6789', 'email' => 'lee@lg.com'], + ['code' => 'S003', 'name' => 'SK하이닉스', 'business_no' => '204-81-17169', 'group' => 'GOLD', 'contact' => '박민수', 'phone' => '031-123-4567', 'email' => 'park@skhynix.com'], + ['code' => 'S004', 'name' => '현대자동차', 'business_no' => '101-81-05765', 'group' => 'GOLD', 'contact' => '정은지', 'phone' => '02-3456-7890', 'email' => 'jung@hyundai.com'], + ['code' => 'S005', 'name' => '네이버', 'business_no' => '220-81-62517', 'group' => 'GOLD', 'contact' => '최준호', 'phone' => '031-234-5678', 'email' => 'choi@naver.com'], + ['code' => 'S006', 'name' => '카카오', 'business_no' => '120-87-65763', 'group' => 'SILVER', 'contact' => '강미래', 'phone' => '02-4567-8901', 'email' => 'kang@kakao.com'], + ['code' => 'S007', 'name' => '쿠팡', 'business_no' => '120-88-00767', 'group' => 'SILVER', 'contact' => '임도현', 'phone' => '02-5678-9012', 'email' => 'lim@coupang.com'], + ['code' => 'S008', 'name' => '토스', 'business_no' => '120-87-83139', 'group' => 'NORMAL', 'contact' => '윤서연', 'phone' => '02-6789-0123', 'email' => 'yoon@toss.im'], + ['code' => 'S009', 'name' => '배달의민족', 'business_no' => '220-87-93847', 'group' => 'NORMAL', 'contact' => '한지민', 'phone' => '02-7890-1234', 'email' => 'han@woowahan.com'], + ['code' => 'S010', 'name' => '당근마켓', 'business_no' => '815-87-01234', 'group' => 'NEW', 'contact' => '오태양', 'phone' => '02-8901-2345', 'email' => 'oh@daangn.com'], + ]; + + // 매입처 (PURCHASE) - 7개 + $purchaseClients = [ + ['code' => 'P001', 'name' => '한화솔루션', 'business_no' => '138-81-00610', 'group' => null, 'contact' => '김재원', 'phone' => '02-1111-2222', 'email' => 'kim@hanwha.com'], + ['code' => 'P002', 'name' => '포스코', 'business_no' => '506-81-08754', 'group' => null, 'contact' => '이현석', 'phone' => '054-111-2222', 'email' => 'lee@posco.com'], + ['code' => 'P003', 'name' => '롯데케미칼', 'business_no' => '301-81-07123', 'group' => null, 'contact' => '박서준', 'phone' => '02-2222-3333', 'email' => 'park@lottechem.com'], + ['code' => 'P004', 'name' => 'GS칼텍스', 'business_no' => '104-81-23858', 'group' => null, 'contact' => '정해인', 'phone' => '02-3333-4444', 'email' => 'jung@gscaltex.com'], + ['code' => 'P005', 'name' => '대한항공', 'business_no' => '110-81-14794', 'group' => null, 'contact' => '송민호', 'phone' => '02-4444-5555', 'email' => 'song@koreanair.com'], + ['code' => 'P006', 'name' => '현대제철', 'business_no' => '130-81-12345', 'group' => null, 'contact' => '강동원', 'phone' => '032-555-6666', 'email' => 'kang@hyundaisteel.com'], + ['code' => 'P007', 'name' => 'SK이노베이션', 'business_no' => '110-81-67890', 'group' => null, 'contact' => '유재석', 'phone' => '02-6666-7777', 'email' => 'yoo@skinnovation.com'], + ]; + + // 매출매입처 (BOTH) - 3개 + $bothClients = [ + ['code' => 'B001', 'name' => '두산에너빌리티', 'business_no' => '124-81-08628', 'group' => 'GOLD', 'contact' => '조인성', 'phone' => '02-7777-8888', 'email' => 'cho@doosan.com'], + ['code' => 'B002', 'name' => 'CJ대한통운', 'business_no' => '104-81-39849', 'group' => 'SILVER', 'contact' => '공유', 'phone' => '02-8888-9999', 'email' => 'gong@cjlogistics.com'], + ['code' => 'B003', 'name' => '삼성SDS', 'business_no' => '124-81-34567', 'group' => 'VIP', 'contact' => '이정재', 'phone' => '02-9999-0000', 'email' => 'lee@samsungsds.com'], + ]; + + $count = 0; + + // 매출처 생성 + foreach ($salesClients as $client) { + $this->createClient($client, 'SALES', $tenantId, $userId, $groups); + $count++; + } + + // 매입처 생성 + foreach ($purchaseClients as $client) { + $this->createClient($client, 'PURCHASE', $tenantId, $userId, $groups); + $count++; + } + + // 매출매입처 생성 + foreach ($bothClients as $client) { + $this->createClient($client, 'BOTH', $tenantId, $userId, $groups); + $count++; + } + + $this->command->info(' ✓ clients: ' . $count . '건 생성'); + } + + private function createClient(array $data, string $type, int $tenantId, int $userId, array $groups): void + { + Client::create([ + 'tenant_id' => $tenantId, + 'client_group_id' => $data['group'] ? ($groups[$data['group']] ?? null) : null, + 'client_code' => $data['code'], + 'name' => $data['name'], + 'client_type' => $type, + 'contact_person' => $data['contact'], + 'phone' => $data['phone'], + 'email' => $data['email'], + 'business_no' => $data['business_no'], + 'business_type' => '제조업', + 'business_item' => '전자제품', + 'is_active' => true, + ]); + } +} \ No newline at end of file diff --git a/database/seeders/Dummy/DummyDepositSeeder.php b/database/seeders/Dummy/DummyDepositSeeder.php new file mode 100644 index 0000000..7da5e61 --- /dev/null +++ b/database/seeders/Dummy/DummyDepositSeeder.php @@ -0,0 +1,94 @@ +whereIn('client_type', ['SALES', 'BOTH']) + ->get() + ->keyBy('name'); + + // 은행계좌 (대표계좌 우선) + $bankAccounts = BankAccount::where('tenant_id', $tenantId) + ->where('status', 'active') + ->orderByDesc('is_primary') + ->get(); + + $primaryBankId = $bankAccounts->first()?->id; + + // 결제수단 분포: transfer(70%), card(15%), cash(10%), check(5%) + $methods = array_merge( + array_fill(0, 14, 'transfer'), + array_fill(0, 3, 'card'), + array_fill(0, 2, 'cash'), + array_fill(0, 1, 'check') + ); + + // 거래처 순환 목록 + $clientNames = [ + '삼성전자', 'LG전자', 'SK하이닉스', '현대자동차', '네이버', + '카카오', '쿠팡', '토스', '배달의민족', '당근마켓', + '두산에너빌리티', 'CJ대한통운', '삼성SDS', + ]; + + // 금액 범위 + $amounts = [ + 'small' => [1000000, 5000000], // 30% + 'medium' => [5000000, 30000000], // 50% + 'large' => [30000000, 100000000], // 20% + ]; + + $count = 0; + $year = 2025; + + // 월별 5건씩, 총 60건 + for ($month = 1; $month <= 12; $month++) { + $daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year); + + for ($i = 0; $i < 5; $i++) { + $day = rand(1, $daysInMonth); + $clientName = $clientNames[($month * 5 + $i) % count($clientNames)]; + $client = $clients->get($clientName); + + // 금액 결정 (분포에 따라) + $rand = rand(1, 100); + if ($rand <= 30) { + $amount = rand($amounts['small'][0], $amounts['small'][1]); + } elseif ($rand <= 80) { + $amount = rand($amounts['medium'][0], $amounts['medium'][1]); + } else { + $amount = rand($amounts['large'][0], $amounts['large'][1]); + } + + Deposit::create([ + 'tenant_id' => $tenantId, + 'deposit_date' => sprintf('%04d-%02d-%02d', $year, $month, $day), + 'client_id' => $client?->id, + 'client_name' => $client ? null : $clientName, + 'bank_account_id' => $primaryBankId, + 'amount' => $amount, + 'payment_method' => $methods[array_rand($methods)], + 'description' => $clientName . ' 입금', + 'created_by' => $userId, + ]); + + $count++; + } + } + + $this->command->info(' ✓ deposits: ' . $count . '건 생성'); + } +} \ No newline at end of file diff --git a/database/seeders/Dummy/DummyPaymentSeeder.php b/database/seeders/Dummy/DummyPaymentSeeder.php new file mode 100644 index 0000000..9b532fd --- /dev/null +++ b/database/seeders/Dummy/DummyPaymentSeeder.php @@ -0,0 +1,219 @@ +createPlans($userId); + + // 2. 구독 생성 + $subscription = $this->createSubscription($tenantId, $plans['standard']->id, $userId); + + // 3. 결제 내역 생성 + $paymentCount = $this->createPayments($subscription, $userId); + + $this->command->info(' ✓ plans: '.count($plans).'건 생성'); + $this->command->info(' ✓ subscriptions: 1건 생성'); + $this->command->info(' ✓ payments: '.$paymentCount.'건 생성'); + } + + /** + * 요금제 생성 + */ + private function createPlans(int $userId): array + { + $plansData = [ + 'free' => [ + 'name' => '무료 체험', + 'code' => 'FREE', + 'description' => '14일 무료 체험', + 'price' => 0, + 'billing_cycle' => Plan::BILLING_MONTHLY, + 'features' => ['basic_features', 'limited_users'], + 'is_active' => true, + ], + 'starter' => [ + 'name' => '스타터', + 'code' => 'STARTER', + 'description' => '소규모 팀을 위한 기본 플랜', + 'price' => 29000, + 'billing_cycle' => Plan::BILLING_MONTHLY, + 'features' => ['basic_features', 'email_support', 'up_to_5_users'], + 'is_active' => true, + ], + 'standard' => [ + 'name' => '스탠다드', + 'code' => 'STANDARD', + 'description' => '성장하는 팀을 위한 표준 플랜', + 'price' => 79000, + 'billing_cycle' => Plan::BILLING_MONTHLY, + 'features' => ['all_features', 'priority_support', 'up_to_20_users', 'api_access'], + 'is_active' => true, + ], + 'professional' => [ + 'name' => '프로페셔널', + 'code' => 'PRO', + 'description' => '전문가 팀을 위한 고급 플랜', + 'price' => 149000, + 'billing_cycle' => Plan::BILLING_MONTHLY, + 'features' => ['all_features', 'dedicated_support', 'unlimited_users', 'api_access', 'custom_integrations'], + 'is_active' => true, + ], + 'enterprise' => [ + 'name' => '엔터프라이즈', + 'code' => 'ENTERPRISE', + 'description' => '대기업 맞춤형 플랜', + 'price' => 499000, + 'billing_cycle' => Plan::BILLING_MONTHLY, + 'features' => ['all_features', 'dedicated_support', 'unlimited_users', 'api_access', 'custom_integrations', 'sla', 'on_premise'], + 'is_active' => true, + ], + 'yearly_standard' => [ + 'name' => '스탠다드 (연간)', + 'code' => 'STANDARD_YEARLY', + 'description' => '연간 결제 시 20% 할인', + 'price' => 758400, // 79000 * 12 * 0.8 + 'billing_cycle' => Plan::BILLING_YEARLY, + 'features' => ['all_features', 'priority_support', 'up_to_20_users', 'api_access'], + 'is_active' => true, + ], + ]; + + $plans = []; + foreach ($plansData as $key => $data) { + $plans[$key] = Plan::firstOrCreate( + ['code' => $data['code']], + array_merge($data, ['created_by' => $userId]) + ); + } + + return $plans; + } + + /** + * 구독 생성 + */ + private function createSubscription(int $tenantId, int $planId, int $userId): Subscription + { + // 기존 활성 구독이 있으면 반환 + $existing = Subscription::where('tenant_id', $tenantId) + ->where('status', Subscription::STATUS_ACTIVE) + ->first(); + + if ($existing) { + return $existing; + } + + // 새 구독 생성 (12개월 전부터 시작) + return Subscription::create([ + 'tenant_id' => $tenantId, + 'plan_id' => $planId, + 'started_at' => now()->subMonths(12)->startOfMonth(), + 'ended_at' => now()->addMonths(1)->endOfMonth(), + 'status' => Subscription::STATUS_ACTIVE, + 'created_by' => $userId, + ]); + } + + /** + * 결제 내역 생성 + */ + private function createPayments(Subscription $subscription, int $userId): int + { + // 이미 결제 내역이 있으면 스킵 + if ($subscription->payments()->count() > 0) { + return 0; + } + + $plan = $subscription->plan; + $paymentMethods = [ + Payment::METHOD_CARD, + Payment::METHOD_CARD, + Payment::METHOD_CARD, // 카드 60% + Payment::METHOD_BANK, + Payment::METHOD_BANK, // 계좌이체 30% + Payment::METHOD_VIRTUAL, // 가상계좌 10% + ]; + + $statuses = [ + Payment::STATUS_COMPLETED, + Payment::STATUS_COMPLETED, + Payment::STATUS_COMPLETED, + Payment::STATUS_COMPLETED, + Payment::STATUS_COMPLETED, + Payment::STATUS_COMPLETED, + Payment::STATUS_COMPLETED, + Payment::STATUS_COMPLETED, // 완료 80% + Payment::STATUS_CANCELLED, // 취소 10% + Payment::STATUS_REFUNDED, // 환불 10% + ]; + + $count = 0; + + // 12개월치 결제 내역 생성 + for ($i = 12; $i >= 0; $i--) { + $paymentDate = now()->subMonths($i)->startOfMonth(); + + // 이번 달은 대기 상태로 + $status = $i === 0 + ? Payment::STATUS_PENDING + : $statuses[array_rand($statuses)]; + + // 금액 변동 (할인, 프로모션 등) + $baseAmount = $plan->price; + $amount = match (true) { + $i >= 10 => $baseAmount * 0.5, // 첫 3개월 50% 할인 + $i >= 6 => $baseAmount * 0.8, // 다음 4개월 20% 할인 + default => $baseAmount, // 이후 정가 + }; + + // 취소/환불은 금액 0 + if (in_array($status, [Payment::STATUS_CANCELLED, Payment::STATUS_REFUNDED])) { + $amount = $baseAmount; + } + + $paidAt = $status === Payment::STATUS_COMPLETED + ? $paymentDate->copy()->addDays(rand(1, 5)) + : null; + + $transactionId = $status === Payment::STATUS_COMPLETED + ? 'TXN'.$paymentDate->format('Ymd').str_pad(rand(1, 9999), 4, '0', STR_PAD_LEFT) + : null; + + $memo = match ($status) { + Payment::STATUS_CANCELLED => '고객 요청에 의한 취소', + Payment::STATUS_REFUNDED => '서비스 불만족으로 인한 환불', + Payment::STATUS_PENDING => '결제 대기 중', + default => null, + }; + + Payment::create([ + 'subscription_id' => $subscription->id, + 'amount' => $amount, + 'payment_method' => $paymentMethods[array_rand($paymentMethods)], + 'transaction_id' => $transactionId, + 'paid_at' => $paidAt, + 'status' => $status, + 'memo' => $memo, + 'created_by' => $userId, + 'created_at' => $paymentDate, + ]); + + $count++; + } + + return $count; + } +} diff --git a/database/seeders/Dummy/DummyPopupSeeder.php b/database/seeders/Dummy/DummyPopupSeeder.php new file mode 100644 index 0000000..6eeb177 --- /dev/null +++ b/database/seeders/Dummy/DummyPopupSeeder.php @@ -0,0 +1,107 @@ + 'all', + 'target_id' => null, + 'title' => '시스템 점검 안내', + 'content' => '
안녕하세요.
2025년 1월 15일(수) 02:00 ~ 06:00 동안 시스템 점검이 예정되어 있습니다.
점검 시간 동안 서비스 이용이 제한될 수 있으니 양해 부탁드립니다.
', + 'status' => 'active', + 'started_at' => now()->subDays(5), + 'ended_at' => now()->addDays(10), + ], + [ + 'target_type' => 'all', + 'target_id' => null, + 'title' => '신규 기능 업데이트 안내', + 'content' => '새로운 기능이 추가되었습니다!
자세한 내용은 도움말을 확인해 주세요.
', + 'status' => 'active', + 'started_at' => now()->subDays(3), + 'ended_at' => now()->addDays(30), + ], + [ + 'target_type' => 'all', + 'target_id' => null, + 'title' => '연말 휴무 안내', + 'content' => '2024년 연말 휴무 일정을 안내드립니다.
휴무 기간: 12월 30일(월) ~ 1월 1일(수)
새해 복 많이 받으세요!
', + 'status' => 'inactive', + 'started_at' => now()->subMonth(), + 'ended_at' => now()->subDays(20), + ], + [ + 'target_type' => 'department', + 'target_id' => 1, + 'title' => '부서 회의 안내', + 'content' => '이번 주 금요일 오후 2시에 정기 회의가 있습니다.
장소: 3층 회의실
안건: 1분기 실적 검토
', + 'status' => 'active', + 'started_at' => now(), + 'ended_at' => now()->addDays(7), + ], + [ + 'target_type' => 'all', + 'target_id' => null, + 'title' => '보안 업데이트 필수 안내', + 'content' => '중요!
보안 강화를 위해 비밀번호 변경이 필요합니다.
최근 3개월 이내 비밀번호를 변경하지 않으신 분은 마이페이지에서 변경해 주세요.
', + 'status' => 'active', + 'started_at' => now()->subDays(1), + 'ended_at' => now()->addDays(14), + ], + [ + 'target_type' => 'all', + 'target_id' => null, + 'title' => '서비스 이용약관 변경 안내', + 'content' => '서비스 이용약관이 2025년 2월 1일부터 변경됩니다.
주요 변경 사항:
변경된 약관은 공지사항에서 확인하실 수 있습니다.
', + 'status' => 'active', + 'started_at' => now(), + 'ended_at' => now()->addDays(45), + ], + [ + 'target_type' => 'department', + 'target_id' => 2, + 'title' => '영업팀 워크샵 안내', + 'content' => '영업팀 상반기 워크샵이 예정되어 있습니다.
일시: 2025년 2월 15일(토)
장소: 추후 공지
많은 참여 바랍니다!
', + 'status' => 'active', + 'started_at' => now()->addDays(5), + 'ended_at' => now()->addDays(50), + ], + [ + 'target_type' => 'all', + 'target_id' => null, + 'title' => '모바일 앱 출시 안내', + 'content' => 'SAM 모바일 앱이 출시되었습니다!
앱스토어와 구글플레이에서 "SAM"을 검색해 주세요.
모바일에서도 편리하게 업무를 처리하세요.
', + 'status' => 'inactive', + 'started_at' => now()->subMonths(2), + 'ended_at' => now()->subMonth(), + ], + ]; + + foreach ($popups as $popup) { + Popup::create([ + 'tenant_id' => $tenantId, + 'target_type' => $popup['target_type'], + 'target_id' => $popup['target_id'], + 'title' => $popup['title'], + 'content' => $popup['content'], + 'status' => $popup['status'], + 'started_at' => $popup['started_at'], + 'ended_at' => $popup['ended_at'], + 'created_by' => $userId, + ]); + } + + $this->command->info(' ✓ popups: ' . count($popups) . '건 생성'); + } +} \ No newline at end of file diff --git a/database/seeders/Dummy/DummyPurchaseSeeder.php b/database/seeders/Dummy/DummyPurchaseSeeder.php new file mode 100644 index 0000000..5b29940 --- /dev/null +++ b/database/seeders/Dummy/DummyPurchaseSeeder.php @@ -0,0 +1,91 @@ +whereIn('client_type', ['PURCHASE', 'BOTH']) + ->get() + ->keyBy('name'); + + $clientNames = [ + '한화솔루션', '포스코', '롯데케미칼', 'GS칼텍스', '대한항공', + '현대제철', 'SK이노베이션', 'CJ대한통운', '두산에너빌리티', + ]; + + $amounts = [ + 'small' => [1000000, 5000000], + 'medium' => [5000000, 30000000], + 'large' => [30000000, 80000000], + ]; + + // 월별 건수: 1~10월 5~6건, 11월 7건, 12월 6건 = 70건 + $monthlyCount = [5, 5, 6, 5, 6, 6, 6, 6, 6, 6, 7, 6]; + + $count = 0; + $year = 2025; + + for ($month = 1; $month <= 12; $month++) { + $daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year); + $purchaseCount = $monthlyCount[$month - 1]; + + for ($i = 0; $i < $purchaseCount; $i++) { + $day = (int) (($i + 1) * $daysInMonth / ($purchaseCount + 1)); + $day = max(1, min($day, $daysInMonth)); + + $clientName = $clientNames[($count) % count($clientNames)]; + $client = $clients->get($clientName); + + $rand = rand(1, 100); + if ($rand <= 30) { + $supply = rand($amounts['small'][0], $amounts['small'][1]); + } elseif ($rand <= 80) { + $supply = rand($amounts['medium'][0], $amounts['medium'][1]); + } else { + $supply = rand($amounts['large'][0], $amounts['large'][1]); + } + + // 상태 결정 + if ($month <= 10) { + $status = 'confirmed'; + } elseif ($month == 11) { + $status = $i < 4 ? 'confirmed' : 'draft'; + } else { + $status = $i < 1 ? 'confirmed' : 'draft'; + } + + $tax = round($supply * 0.1); + $total = $supply + $tax; + + Purchase::create([ + 'tenant_id' => $tenantId, + 'purchase_number' => sprintf('PUR-%04d%02d-%04d', $year, $month, $i + 1), + 'purchase_date' => sprintf('%04d-%02d-%02d', $year, $month, $day), + 'client_id' => $client->id, + 'supply_amount' => $supply, + 'tax_amount' => $tax, + 'total_amount' => $total, + 'description' => $clientName . ' 매입', + 'status' => $status, + 'created_by' => $userId, + ]); + + $count++; + } + } + + $this->command->info(' ✓ purchases: ' . $count . '건 생성'); + } +} \ No newline at end of file diff --git a/database/seeders/Dummy/DummySaleSeeder.php b/database/seeders/Dummy/DummySaleSeeder.php new file mode 100644 index 0000000..1c9594a --- /dev/null +++ b/database/seeders/Dummy/DummySaleSeeder.php @@ -0,0 +1,93 @@ +whereIn('client_type', ['SALES', 'BOTH']) + ->get() + ->keyBy('name'); + + $clientNames = [ + '삼성전자', 'LG전자', 'SK하이닉스', '현대자동차', '네이버', + '카카오', '쿠팡', '토스', '배달의민족', '당근마켓', + '두산에너빌리티', 'CJ대한통운', '삼성SDS', + ]; + + $amounts = [ + 'small' => [1000000, 5000000], + 'medium' => [5000000, 30000000], + 'large' => [30000000, 100000000], + ]; + + // 월별 건수: 1~10월 6~7건, 11월 8건, 12월 7건 = 80건 + $monthlyCount = [6, 6, 7, 6, 7, 6, 7, 6, 7, 7, 8, 7]; + + $count = 0; + $year = 2025; + + for ($month = 1; $month <= 12; $month++) { + $daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year); + $salesCount = $monthlyCount[$month - 1]; + + for ($i = 0; $i < $salesCount; $i++) { + $day = (int) (($i + 1) * $daysInMonth / ($salesCount + 1)); + $day = max(1, min($day, $daysInMonth)); + + $clientName = $clientNames[($count) % count($clientNames)]; + $client = $clients->get($clientName); + + // 금액 결정 + $rand = rand(1, 100); + if ($rand <= 30) { + $supply = rand($amounts['small'][0], $amounts['small'][1]); + } elseif ($rand <= 80) { + $supply = rand($amounts['medium'][0], $amounts['medium'][1]); + } else { + $supply = rand($amounts['large'][0], $amounts['large'][1]); + } + + // 상태 결정: 1~10월 invoiced/confirmed, 11~12월 draft 포함 + if ($month <= 10) { + $status = rand(0, 1) ? 'invoiced' : 'confirmed'; + } elseif ($month == 11) { + $status = $i < 5 ? (rand(0, 1) ? 'invoiced' : 'confirmed') : 'draft'; + } else { + $status = $i < 1 ? 'confirmed' : 'draft'; + } + + $tax = round($supply * 0.1); + $total = $supply + $tax; + + Sale::create([ + 'tenant_id' => $tenantId, + 'sale_number' => sprintf('SAL-%04d%02d-%04d', $year, $month, $i + 1), + 'sale_date' => sprintf('%04d-%02d-%02d', $year, $month, $day), + 'client_id' => $client->id, + 'supply_amount' => $supply, + 'tax_amount' => $tax, + 'total_amount' => $total, + 'description' => $clientName . ' 매출', + 'status' => $status, + 'created_by' => $userId, + ]); + + $count++; + } + } + + $this->command->info(' ✓ sales: ' . $count . '건 생성'); + } +} \ No newline at end of file diff --git a/database/seeders/Dummy/DummyWithdrawalSeeder.php b/database/seeders/Dummy/DummyWithdrawalSeeder.php new file mode 100644 index 0000000..d61573b --- /dev/null +++ b/database/seeders/Dummy/DummyWithdrawalSeeder.php @@ -0,0 +1,87 @@ +whereIn('client_type', ['PURCHASE', 'BOTH']) + ->get() + ->keyBy('name'); + + // 은행계좌 + $primaryBankId = BankAccount::where('tenant_id', $tenantId) + ->where('is_primary', true) + ->value('id'); + + // 결제수단 분포 + $methods = array_merge( + array_fill(0, 14, 'transfer'), + array_fill(0, 3, 'card'), + array_fill(0, 2, 'cash'), + array_fill(0, 1, 'check') + ); + + // 매입처 순환 목록 + $clientNames = [ + '한화솔루션', '포스코', '롯데케미칼', 'GS칼텍스', '대한항공', + '현대제철', 'SK이노베이션', 'CJ대한통운', '두산에너빌리티', + ]; + + $amounts = [ + 'small' => [1000000, 5000000], + 'medium' => [5000000, 30000000], + 'large' => [30000000, 80000000], + ]; + + $count = 0; + $year = 2025; + + for ($month = 1; $month <= 12; $month++) { + $daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year); + + for ($i = 0; $i < 5; $i++) { + $day = rand(1, $daysInMonth); + $clientName = $clientNames[($month * 5 + $i) % count($clientNames)]; + $client = $clients->get($clientName); + + $rand = rand(1, 100); + if ($rand <= 30) { + $amount = rand($amounts['small'][0], $amounts['small'][1]); + } elseif ($rand <= 80) { + $amount = rand($amounts['medium'][0], $amounts['medium'][1]); + } else { + $amount = rand($amounts['large'][0], $amounts['large'][1]); + } + + Withdrawal::create([ + 'tenant_id' => $tenantId, + 'withdrawal_date' => sprintf('%04d-%02d-%02d', $year, $month, $day), + 'client_id' => $client?->id, + 'client_name' => $client ? null : $clientName, + 'bank_account_id' => $primaryBankId, + 'amount' => $amount, + 'payment_method' => $methods[array_rand($methods)], + 'description' => $clientName . ' 지급', + 'created_by' => $userId, + ]); + + $count++; + } + } + + $this->command->info(' ✓ withdrawals: ' . $count . '건 생성'); + } +} \ No newline at end of file diff --git a/database/seeders/DummyDataSeeder.php b/database/seeders/DummyDataSeeder.php new file mode 100644 index 0000000..2bbe049 --- /dev/null +++ b/database/seeders/DummyDataSeeder.php @@ -0,0 +1,54 @@ +command->info('🌱 더미 데이터 시딩 시작...'); + $this->command->info(' 대상 테넌트: ID '.self::TENANT_ID); + + $this->call([ + Dummy\DummyClientGroupSeeder::class, + Dummy\DummyBankAccountSeeder::class, + Dummy\DummyClientSeeder::class, + Dummy\DummyDepositSeeder::class, + Dummy\DummyWithdrawalSeeder::class, + Dummy\DummySaleSeeder::class, + Dummy\DummyPurchaseSeeder::class, + Dummy\DummyPopupSeeder::class, + Dummy\DummyPaymentSeeder::class, + ]); + + $this->command->info(''); + $this->command->info('✅ 더미 데이터 시딩 완료!'); + $this->command->table( + ['테이블', '생성 수량'], + [ + ['client_groups', '5'], + ['bank_accounts', '5'], + ['clients', '20'], + ['deposits', '60'], + ['withdrawals', '60'], + ['sales', '80'], + ['purchases', '70'], + ['popups', '8'], + ['plans', '6'], + ['subscriptions', '1'], + ['payments', '13'], + ['총계', '~328'], + ] + ); + } +} diff --git a/docs/api-flows/item-fields-is-active-test.json b/docs/api-flows/item-fields-is-active-test.json deleted file mode 100644 index 450ed15..0000000 --- a/docs/api-flows/item-fields-is-active-test.json +++ /dev/null @@ -1,172 +0,0 @@ -{ - "name": "ItemField is_active 컬럼 검증 테스트", - "description": "item_fields 테이블에 추가된 is_active 컬럼의 기능을 검증합니다. 필드 생성 시 기본값(true), 수정, 조회 시 is_active 필드 포함 여부를 테스트합니다.", - "version": "1.0", - "config": { - "baseUrl": "https://api.sam.kr/api/v1", - "apiKey": "{{$env.FLOW_TESTER_API_KEY}}", - "timeout": 30000, - "stopOnFailure": true - }, - "variables": { - "user_id": "{{$env.FLOW_TESTER_USER_ID}}", - "user_pwd": "{{$env.FLOW_TESTER_USER_PWD}}" - }, - "steps": [ - { - "id": "login", - "name": "1. 로그인 - 인증 토큰 획득", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{user_id}}", - "user_pwd": "{{user_pwd}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.message": "로그인 성공", - "$.access_token": "@isString" - } - }, - "extract": { - "token": "$.access_token" - } - }, - { - "id": "get_fields_list", - "name": "2. 필드 목록 조회 - is_active 필드 포함 확인", - "method": "GET", - "endpoint": "/item-master/fields", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data": "@isArray" - } - }, - "extract": { - "existingFieldId": "$.data[0].id", - "fieldCount": "$.data.length" - } - }, - { - "id": "create_field", - "name": "3. 독립 필드 생성 - is_active 기본값 true 확인", - "method": "POST", - "endpoint": "/item-master/fields", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "field_name": "[테스트] is_active 검증 필드", - "field_type": "textbox", - "field_key": "test_is_active", - "is_required": false, - "placeholder": "is_active 기본값 테스트", - "description": "API Flow Tester에서 생성한 테스트 필드" - }, - "expect": { - "status": [200, 201], - "jsonPath": { - "$.success": true, - "$.data.id": "@isNumber", - "$.data.field_name": "[테스트] is_active 검증 필드", - "$.data.is_active": true - } - }, - "extract": { - "newFieldId": "$.data.id" - } - }, - { - "id": "verify_field_created", - "name": "4. 생성된 필드 상세 확인 - is_active=true", - "method": "GET", - "endpoint": "/item-master/fields", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - }, - "extract": { - "allFields": "$.data" - } - }, - { - "id": "update_field_inactive", - "name": "5. 필드 비활성화 - is_active=false로 수정", - "method": "PUT", - "endpoint": "/item-master/fields/{{create_field.newFieldId}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "is_active": false - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.is_active": false - } - } - }, - { - "id": "verify_field_inactive", - "name": "6. 비활성화 상태 확인", - "method": "GET", - "endpoint": "/item-master/fields", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "update_field_active", - "name": "7. 필드 재활성화 - is_active=true로 수정", - "method": "PUT", - "endpoint": "/item-master/fields/{{create_field.newFieldId}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "is_active": true - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.is_active": true - } - } - }, - { - "id": "delete_test_field", - "name": "8. 테스트 필드 삭제 (정리)", - "method": "DELETE", - "endpoint": "/item-master/fields/{{create_field.newFieldId}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - } - ] -} diff --git a/docs/api-flows/pricing-crud-flow.json b/docs/api-flows/pricing-crud-flow.json deleted file mode 100644 index 074543e..0000000 --- a/docs/api-flows/pricing-crud-flow.json +++ /dev/null @@ -1,277 +0,0 @@ -{ - "name": "단가 관리 CRUD 테스트", - "description": "단가(Pricing) API의 생성, 조회, 수정, 확정, 삭제 전체 플로우 테스트", - "version": "1.0", - "config": { - "baseUrl": "", - "timeout": 30000, - "stopOnFailure": true - }, - "variables": { - "user_id": "{{$env.FLOW_TESTER_USER_ID}}", - "user_pwd": "{{$env.FLOW_TESTER_USER_PWD}}" - }, - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{user_id}}", - "user_pwd": "{{user_pwd}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.message": "로그인 성공", - "$.access_token": "@isString" - } - }, - "extract": { - "token": "$.access_token" - } - }, - { - "id": "list_prices", - "name": "단가 목록 조회", - "method": "GET", - "endpoint": "/pricing", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "query": { - "per_page": 10, - "page": 1 - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.data": "@isArray" - } - } - }, - { - "id": "create_price", - "name": "단가 생성 (MATERIAL)", - "method": "POST", - "endpoint": "/pricing", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "item_type_code": "MATERIAL", - "item_id": 1, - "client_group_id": null, - "purchase_price": 10000, - "processing_cost": 500, - "loss_rate": 5, - "margin_rate": 20, - "sales_price": 12600, - "rounding_rule": "round", - "rounding_unit": 100, - "supplier": "테스트 공급업체", - "effective_from": "2025-01-01", - "effective_to": "2025-12-31", - "note": "API Flow 테스트용 단가", - "status": "draft" - }, - "expect": { - "status": [201], - "jsonPath": { - "$.success": true, - "$.data.id": "@isNumber", - "$.data.item_type_code": "MATERIAL", - "$.data.purchase_price": 10000, - "$.data.status": "draft" - } - }, - "extract": { - "price_id": "$.data.id" - } - }, - { - "id": "show_price", - "name": "생성된 단가 상세 조회", - "method": "GET", - "endpoint": "/pricing/{{create_price.price_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.id": "{{create_price.price_id}}", - "$.data.item_type_code": "MATERIAL", - "$.data.supplier": "테스트 공급업체" - } - } - }, - { - "id": "update_price", - "name": "단가 수정 (가격 변경)", - "method": "PUT", - "endpoint": "/pricing/{{create_price.price_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "purchase_price": 11000, - "processing_cost": 600, - "margin_rate": 25, - "sales_price": 14500, - "note": "단가 수정 테스트", - "change_reason": "원가 인상으로 인한 가격 조정", - "status": "active" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.purchase_price": 11000, - "$.data.processing_cost": 600, - "$.data.status": "active" - } - } - }, - { - "id": "get_revisions", - "name": "변경 이력 조회", - "method": "GET", - "endpoint": "/pricing/{{create_price.price_id}}/revisions", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data": "@isArray" - } - } - }, - { - "id": "get_cost", - "name": "원가 조회 (receipt > standard 폴백)", - "method": "GET", - "endpoint": "/pricing/cost", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "query": { - "item_type_code": "MATERIAL", - "item_id": 1, - "date": "2025-06-15" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.item_type_code": "MATERIAL", - "$.data.item_id": 1 - } - } - }, - { - "id": "by_items", - "name": "다중 품목 단가 조회", - "method": "POST", - "endpoint": "/pricing/by-items", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "items": [ - { - "item_type_code": "MATERIAL", - "item_id": 1 - } - ], - "date": "2025-06-15" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data": "@isArray" - } - } - }, - { - "id": "create_price_for_finalize", - "name": "확정 테스트용 단가 생성", - "method": "POST", - "endpoint": "/pricing", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "item_type_code": "PRODUCT", - "item_id": 1, - "purchase_price": 50000, - "sales_price": 70000, - "effective_from": "2025-01-01", - "status": "active" - }, - "expect": { - "status": [201], - "jsonPath": { - "$.success": true, - "$.data.id": "@isNumber" - } - }, - "extract": { - "finalize_price_id": "$.data.id" - } - }, - { - "id": "finalize_price", - "name": "가격 확정 (불변 처리)", - "method": "POST", - "endpoint": "/pricing/{{create_price_for_finalize.finalize_price_id}}/finalize", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.status": "finalized", - "$.data.is_final": true - } - } - }, - { - "id": "delete_price", - "name": "단가 삭제 (soft delete)", - "method": "DELETE", - "endpoint": "/pricing/{{create_price.price_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "verify_deleted", - "name": "삭제된 단가 조회 시 404 확인", - "method": "GET", - "endpoint": "/pricing/{{create_price.price_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [404], - "jsonPath": { - "$.success": false - } - } - } - ] -} diff --git a/docs/flow-tests/attendance-api-crud.json b/docs/flow-tests/attendance-api-crud.json deleted file mode 100644 index 4ebbc9d..0000000 --- a/docs/flow-tests/attendance-api-crud.json +++ /dev/null @@ -1,227 +0,0 @@ -{ - "name": "Attendance API 근태관리 테스트", - "description": "근태 CRUD, 출퇴근 기록, 월간 통계 테스트", - "version": "1.0", - "config": { - "baseUrl": "", - "timeout": 30000, - "stopOnFailure": true - }, - "variables": { - "user_id": "{{$env.FLOW_TESTER_USER_ID}}", - "user_pwd": "{{$env.FLOW_TESTER_USER_PWD}}", - "test_date": "{{$date}}" - }, - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{user_id}}", - "user_pwd": "{{user_pwd}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.message": "로그인 성공", - "$.access_token": "@isString" - } - }, - "extract": { - "token": "$.access_token", - "current_user_id": "$.user.id" - } - }, - { - "id": "check_in", - "name": "출근 기록", - "method": "POST", - "endpoint": "/attendances/check-in", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "check_in": "09:00:00", - "gps_data": { - "latitude": 37.5665, - "longitude": 126.978, - "accuracy": 10 - } - }, - "expect": { - "status": [200, 201], - "jsonPath": { - "$.success": true, - "$.data.id": "@isNumber", - "$.data.status": "@isString" - } - }, - "extract": { - "attendance_id": "$.data.id" - } - }, - { - "id": "show_attendance", - "name": "근태 상세 조회", - "method": "GET", - "endpoint": "/attendances/{{check_in.attendance_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.id": "{{check_in.attendance_id}}", - "$.data.base_date": "@isString" - } - } - }, - { - "id": "check_out", - "name": "퇴근 기록", - "method": "POST", - "endpoint": "/attendances/check-out", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "check_out": "18:00:00", - "gps_data": { - "latitude": 37.5665, - "longitude": 126.978, - "accuracy": 15 - } - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.id": "@isNumber" - } - } - }, - { - "id": "list_attendances", - "name": "근태 목록 조회", - "method": "GET", - "endpoint": "/attendances", - "query": { - "page": 1, - "per_page": 10, - "date": "{{test_date}}" - }, - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.data": "@isArray" - } - } - }, - { - "id": "monthly_stats", - "name": "월간 통계 조회", - "method": "GET", - "endpoint": "/attendances/monthly-stats", - "query": { - "year": 2025, - "month": 12 - }, - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "create_attendance", - "name": "근태 수동 등록 (관리자)", - "method": "POST", - "endpoint": "/attendances", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "user_id": "{{login.current_user_id}}", - "base_date": "2025-12-01", - "status": "onTime", - "json_details": { - "check_in": "09:00:00", - "check_out": "18:00:00", - "work_minutes": 480 - }, - "remarks": "Flow Tester 테스트 데이터" - }, - "expect": { - "status": [200, 201], - "jsonPath": { - "$.success": true, - "$.data.id": "@isNumber" - } - }, - "extract": { - "manual_attendance_id": "$.data.id" - } - }, - { - "id": "update_attendance", - "name": "근태 수정", - "method": "PATCH", - "endpoint": "/attendances/{{create_attendance.manual_attendance_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "status": "late", - "remarks": "수정된 테스트 데이터" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.status": "late" - } - } - }, - { - "id": "delete_manual_attendance", - "name": "수동 등록 근태 삭제", - "method": "DELETE", - "endpoint": "/attendances/{{create_attendance.manual_attendance_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "delete_checkin_attendance", - "name": "출퇴근 기록 삭제 (정리)", - "method": "DELETE", - "endpoint": "/attendances/{{check_in.attendance_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - } - ] -} diff --git a/docs/flow-tests/department-tree-api.json b/docs/flow-tests/department-tree-api.json deleted file mode 100644 index 503e11c..0000000 --- a/docs/flow-tests/department-tree-api.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "name": "Department Tree API 테스트", - "description": "부서 트리 조회 테스트", - "version": "1.0", - "config": { - "baseUrl": "", - "timeout": 30000, - "stopOnFailure": true - }, - "variables": { - "user_id": "{{$env.FLOW_TESTER_USER_ID}}", - "user_pwd": "{{$env.FLOW_TESTER_USER_PWD}}" - }, - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{user_id}}", - "user_pwd": "{{user_pwd}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.message": "로그인 성공", - "$.access_token": "@isString" - } - }, - "extract": { - "token": "$.access_token" - } - }, - { - "id": "get_tree", - "name": "부서 트리 조회", - "method": "GET", - "endpoint": "/departments/tree", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data": "@isArray" - } - } - }, - { - "id": "get_tree_with_users", - "name": "부서 트리 조회 (사용자 포함)", - "method": "GET", - "endpoint": "/departments/tree", - "query": { - "with_users": true - }, - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data": "@isArray" - } - } - }, - { - "id": "list_departments", - "name": "부서 목록 조회", - "method": "GET", - "endpoint": "/departments", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data": "@isArray" - } - } - } - ] -} diff --git a/docs/flow-tests/employee-api-crud.json b/docs/flow-tests/employee-api-crud.json deleted file mode 100644 index 9eaf608..0000000 --- a/docs/flow-tests/employee-api-crud.json +++ /dev/null @@ -1,188 +0,0 @@ -{ - "name": "Employee API CRUD 테스트", - "description": "사원 관리 API 전체 CRUD 및 계정 생성 테스트", - "version": "1.0", - "config": { - "baseUrl": "", - "timeout": 30000, - "stopOnFailure": true - }, - "variables": { - "user_id": "{{$env.FLOW_TESTER_USER_ID}}", - "user_pwd": "{{$env.FLOW_TESTER_USER_PWD}}", - "test_email": "test.employee.{{$timestamp}}@example.com", - "test_name": "테스트사원{{$random:4}}" - }, - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{user_id}}", - "user_pwd": "{{user_pwd}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.message": "로그인 성공", - "$.access_token": "@isString" - } - }, - "extract": { - "token": "$.access_token", - "current_user_id": "$.user.id" - } - }, - { - "id": "get_stats", - "name": "사원 통계 조회", - "method": "GET", - "endpoint": "/employees/stats", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.total": "@isNumber", - "$.data.active": "@isNumber", - "$.data.leave": "@isNumber", - "$.data.resigned": "@isNumber" - } - } - }, - { - "id": "list_employees", - "name": "사원 목록 조회", - "method": "GET", - "endpoint": "/employees", - "query": { - "page": 1, - "per_page": 10 - }, - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.data": "@isArray" - } - } - }, - { - "id": "create_employee", - "name": "사원 등록", - "method": "POST", - "endpoint": "/employees", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "name": "{{test_name}}", - "email": "{{test_email}}", - "phone": "010-1234-5678", - "employee_number": "EMP{{$random:6}}", - "employee_status": "active", - "position": "사원", - "hire_date": "{{$date}}", - "json_extra": { - "emergency_contact": "010-9999-8888", - "address": "서울시 강남구" - } - }, - "expect": { - "status": [200, 201], - "jsonPath": { - "$.success": true, - "$.data.id": "@isNumber", - "$.data.user.name": "{{test_name}}" - } - }, - "extract": { - "employee_id": "$.data.id", - "user_id": "$.data.user_id" - } - }, - { - "id": "show_employee", - "name": "사원 상세 조회", - "method": "GET", - "endpoint": "/employees/{{create_employee.employee_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.id": "{{create_employee.employee_id}}", - "$.data.employee_status": "active" - } - } - }, - { - "id": "update_employee", - "name": "사원 정보 수정", - "method": "PATCH", - "endpoint": "/employees/{{create_employee.employee_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "body": { - "position": "대리", - "employee_status": "active", - "json_extra": { - "emergency_contact": "010-1111-2222", - "address": "서울시 서초구", - "skills": ["Laravel", "React"] - } - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.id": "@isNumber" - } - } - }, - { - "id": "list_filtered", - "name": "사원 필터 조회 (재직자)", - "method": "GET", - "endpoint": "/employees", - "query": { - "status": "active", - "q": "{{test_name}}" - }, - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "delete_employee", - "name": "사원 삭제", - "method": "DELETE", - "endpoint": "/employees/{{create_employee.employee_id}}", - "headers": { - "Authorization": "Bearer {{login.token}}" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - } - ] -} diff --git a/docs/flow-tests/user-invitation-flow.json b/docs/flow-tests/user-invitation-flow.json deleted file mode 100644 index 4229113..0000000 --- a/docs/flow-tests/user-invitation-flow.json +++ /dev/null @@ -1,926 +0,0 @@ -{ - "name": "사용자 초대 플로우 테스트", - "description": "사용자 초대 전체 플로우 (발송/목록/수락/취소/재발송) 및 분기 상황 테스트", - "version": "1.0", - "config": { - "baseUrl": "https://api.sam.kr/api/v1", - "apiKey": "{{$env.FLOW_TESTER_API_KEY}}", - "timeout": 30000, - "stopOnFailure": false - }, - "variables": { - "user_id": "{{$env.FLOW_TESTER_USER_ID}}", - "user_pwd": "{{$env.FLOW_TESTER_USER_PWD}}", - "test_email_prefix": "flowtest", - "test_domain": "example.com" - }, - "setup": { - "description": "테스트 전 초기화 - 기존 테스트 초대 정리는 수동으로 진행" - }, - "flows": [ - { - "id": "flow_1", - "name": "🔴 P1: 기본 초대 플로우 (role=user)", - "description": "일반 사용자 역할로 초대 발송 → 목록 확인 → 취소", - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.access_token": "@isString" - } - } - }, - { - "id": "invite_user_role", - "name": "초대 발송 (role=user)", - "method": "POST", - "endpoint": "/users/invite", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "email": "{{variables.test_email_prefix}}_user_{{$timestamp}}@{{variables.test_domain}}", - "role": "user", - "message": "SAM 시스템에 합류해 주세요!" - }, - "dependsOn": ["login"], - "extract": { - "invitationId": "$.data.id", - "invitationToken": "$.data.token", - "invitedEmail": "$.data.email" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.status": "pending", - "$.data.email": "@isString" - } - } - }, - { - "id": "list_invitations", - "name": "초대 목록 조회", - "method": "GET", - "endpoint": "/users/invitations", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "query": { - "status": "pending", - "per_page": 10 - }, - "dependsOn": ["invite_user_role"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.data": "@isArray" - } - } - }, - { - "id": "cancel_invitation", - "name": "초대 취소", - "method": "DELETE", - "endpoint": "/users/invitations/{{invite_user_role.invitationId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["list_invitations"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - } - ] - }, - { - "id": "flow_2", - "name": "🔴 P1: 관리자 역할 초대 (role=admin)", - "description": "관리자 역할로 초대 발송", - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token" - }, - "expect": { - "status": [200] - } - }, - { - "id": "invite_admin_role", - "name": "초대 발송 (role=admin)", - "method": "POST", - "endpoint": "/users/invite", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "email": "{{variables.test_email_prefix}}_admin_{{$timestamp}}@{{variables.test_domain}}", - "role": "admin", - "message": "관리자로 초대합니다." - }, - "dependsOn": ["login"], - "extract": { - "invitationId": "$.data.id" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.status": "pending" - } - } - }, - { - "id": "cleanup", - "name": "초대 취소 (정리)", - "method": "DELETE", - "endpoint": "/users/invitations/{{invite_admin_role.invitationId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["invite_admin_role"], - "expect": { - "status": [200] - } - } - ] - }, - { - "id": "flow_3", - "name": "🔴 P1: 매니저 역할 초대 (role=manager)", - "description": "매니저 역할로 초대 발송", - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token" - }, - "expect": { - "status": [200] - } - }, - { - "id": "invite_manager_role", - "name": "초대 발송 (role=manager)", - "method": "POST", - "endpoint": "/users/invite", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "email": "{{variables.test_email_prefix}}_manager_{{$timestamp}}@{{variables.test_domain}}", - "role": "manager" - }, - "dependsOn": ["login"], - "extract": { - "invitationId": "$.data.id" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.status": "pending" - } - } - }, - { - "id": "cleanup", - "name": "초대 취소 (정리)", - "method": "DELETE", - "endpoint": "/users/invitations/{{invite_manager_role.invitationId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["invite_manager_role"], - "expect": { - "status": [200] - } - } - ] - }, - { - "id": "flow_4", - "name": "🔴 P1: 초대 재발송 플로우", - "description": "초대 발송 → 재발송 → 취소", - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token" - }, - "expect": { - "status": [200] - } - }, - { - "id": "invite", - "name": "초대 발송", - "method": "POST", - "endpoint": "/users/invite", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "email": "{{variables.test_email_prefix}}_resend_{{$timestamp}}@{{variables.test_domain}}", - "role": "user" - }, - "dependsOn": ["login"], - "extract": { - "invitationId": "$.data.id", - "originalExpiresAt": "$.data.expires_at" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "resend", - "name": "초대 재발송", - "method": "POST", - "endpoint": "/users/invitations/{{invite.invitationId}}/resend", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["invite"], - "extract": { - "newExpiresAt": "$.data.expires_at" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.status": "pending" - } - } - }, - { - "id": "cleanup", - "name": "초대 취소 (정리)", - "method": "DELETE", - "endpoint": "/users/invitations/{{invite.invitationId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["resend"], - "expect": { - "status": [200] - } - } - ] - }, - { - "id": "flow_5", - "name": "🔴 P1: 에러 케이스 - 중복 이메일 초대", - "description": "동일 이메일로 중복 초대 시도 (대기 중 초대 존재)", - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token" - }, - "expect": { - "status": [200] - } - }, - { - "id": "first_invite", - "name": "첫 번째 초대 발송", - "method": "POST", - "endpoint": "/users/invite", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "email": "{{variables.test_email_prefix}}_dup_{{$timestamp}}@{{variables.test_domain}}", - "role": "user" - }, - "dependsOn": ["login"], - "extract": { - "invitationId": "$.data.id", - "testEmail": "$.data.email" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "duplicate_invite", - "name": "중복 초대 발송 (실패 예상)", - "method": "POST", - "endpoint": "/users/invite", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "email": "{{first_invite.testEmail}}", - "role": "user" - }, - "dependsOn": ["first_invite"], - "expect": { - "status": [400, 422], - "jsonPath": { - "$.success": false - } - } - }, - { - "id": "cleanup", - "name": "초대 취소 (정리)", - "method": "DELETE", - "endpoint": "/users/invitations/{{first_invite.invitationId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["duplicate_invite"], - "expect": { - "status": [200] - } - } - ] - }, - { - "id": "flow_6", - "name": "🟡 P2: 목록 필터링 - 상태별", - "description": "초대 목록을 상태별로 필터링", - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token" - }, - "expect": { - "status": [200] - } - }, - { - "id": "list_pending", - "name": "대기 중 목록 조회", - "method": "GET", - "endpoint": "/users/invitations", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "query": { - "status": "pending" - }, - "dependsOn": ["login"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "list_accepted", - "name": "수락된 목록 조회", - "method": "GET", - "endpoint": "/users/invitations", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "query": { - "status": "accepted" - }, - "dependsOn": ["login"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "list_expired", - "name": "만료된 목록 조회", - "method": "GET", - "endpoint": "/users/invitations", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "query": { - "status": "expired" - }, - "dependsOn": ["login"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "list_cancelled", - "name": "취소된 목록 조회", - "method": "GET", - "endpoint": "/users/invitations", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "query": { - "status": "cancelled" - }, - "dependsOn": ["login"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - } - ] - }, - { - "id": "flow_7", - "name": "🟡 P2: 목록 정렬 옵션", - "description": "다양한 정렬 기준으로 목록 조회", - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token" - }, - "expect": { - "status": [200] - } - }, - { - "id": "sort_created_at_desc", - "name": "생성일 내림차순", - "method": "GET", - "endpoint": "/users/invitations", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "query": { - "sort_by": "created_at", - "sort_dir": "desc" - }, - "dependsOn": ["login"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "sort_expires_at_asc", - "name": "만료일 오름차순", - "method": "GET", - "endpoint": "/users/invitations", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "query": { - "sort_by": "expires_at", - "sort_dir": "asc" - }, - "dependsOn": ["login"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - }, - { - "id": "sort_email_asc", - "name": "이메일 오름차순", - "method": "GET", - "endpoint": "/users/invitations", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "query": { - "sort_by": "email", - "sort_dir": "asc" - }, - "dependsOn": ["login"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true - } - } - } - ] - }, - { - "id": "flow_8", - "name": "🟡 P2: 이메일 검색", - "description": "이메일 키워드로 초대 검색", - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token" - }, - "expect": { - "status": [200] - } - }, - { - "id": "search_by_email", - "name": "이메일 검색", - "method": "GET", - "endpoint": "/users/invitations", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "query": { - "search": "flowtest" - }, - "dependsOn": ["login"], - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.data": "@isArray" - } - } - } - ] - }, - { - "id": "flow_9", - "name": "🟢 P3: 만료 기간 지정 옵션", - "description": "만료 기간을 명시적으로 지정하여 초대", - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token" - }, - "expect": { - "status": [200] - } - }, - { - "id": "invite_with_expires", - "name": "14일 만료 기간으로 초대", - "method": "POST", - "endpoint": "/users/invite", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "email": "{{variables.test_email_prefix}}_expires_{{$timestamp}}@{{variables.test_domain}}", - "role": "user", - "expires_days": 14 - }, - "dependsOn": ["login"], - "extract": { - "invitationId": "$.data.id" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.expires_at": "@isString" - } - } - }, - { - "id": "cleanup", - "name": "초대 취소 (정리)", - "method": "DELETE", - "endpoint": "/users/invitations/{{invite_with_expires.invitationId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["invite_with_expires"], - "expect": { - "status": [200] - } - } - ] - }, - { - "id": "flow_10", - "name": "🟢 P3: role_id로 초대 (숫자 ID 사용)", - "description": "role 문자열 대신 role_id 숫자로 초대", - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token" - }, - "expect": { - "status": [200] - } - }, - { - "id": "invite_with_role_id", - "name": "role_id=2로 초대", - "method": "POST", - "endpoint": "/users/invite", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "email": "{{variables.test_email_prefix}}_roleid_{{$timestamp}}@{{variables.test_domain}}", - "role_id": 2 - }, - "dependsOn": ["login"], - "extract": { - "invitationId": "$.data.id" - }, - "expect": { - "status": [200], - "jsonPath": { - "$.success": true, - "$.data.role_id": 2 - } - } - }, - { - "id": "cleanup", - "name": "초대 취소 (정리)", - "method": "DELETE", - "endpoint": "/users/invitations/{{invite_with_role_id.invitationId}}", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["invite_with_role_id"], - "expect": { - "status": [200] - } - } - ] - }, - { - "id": "flow_11", - "name": "🔴 P1: 에러 케이스 - 잘못된 역할", - "description": "존재하지 않는 role 값으로 초대 시도", - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token" - }, - "expect": { - "status": [200] - } - }, - { - "id": "invite_invalid_role", - "name": "잘못된 role로 초대 (실패 예상)", - "method": "POST", - "endpoint": "/users/invite", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "email": "{{variables.test_email_prefix}}_invalid_{{$timestamp}}@{{variables.test_domain}}", - "role": "superadmin" - }, - "dependsOn": ["login"], - "expect": { - "status": [400, 422], - "jsonPath": { - "$.success": false - } - } - } - ] - }, - { - "id": "flow_12", - "name": "🔴 P1: 에러 케이스 - 이메일 형식 오류", - "description": "잘못된 이메일 형식으로 초대 시도", - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token" - }, - "expect": { - "status": [200] - } - }, - { - "id": "invite_invalid_email", - "name": "잘못된 이메일로 초대 (실패 예상)", - "method": "POST", - "endpoint": "/users/invite", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "body": { - "email": "invalid-email-format", - "role": "user" - }, - "dependsOn": ["login"], - "expect": { - "status": [422], - "jsonPath": { - "$.success": false - } - } - } - ] - }, - { - "id": "flow_13", - "name": "🔴 P1: 에러 케이스 - 권한 없이 접근", - "description": "인증 없이 초대 API 접근 시도", - "steps": [ - { - "id": "invite_no_auth", - "name": "인증 없이 초대 시도 (실패 예상)", - "method": "POST", - "endpoint": "/users/invite", - "body": { - "email": "noauth@example.com", - "role": "user" - }, - "expect": { - "status": [401], - "jsonPath": { - "$.success": false - } - } - }, - { - "id": "list_no_auth", - "name": "인증 없이 목록 조회 (실패 예상)", - "method": "GET", - "endpoint": "/users/invitations", - "expect": { - "status": [401], - "jsonPath": { - "$.success": false - } - } - } - ] - }, - { - "id": "flow_14", - "name": "🔴 P1: 에러 케이스 - 존재하지 않는 초대 취소", - "description": "존재하지 않는 초대 ID로 취소 시도", - "steps": [ - { - "id": "login", - "name": "로그인", - "method": "POST", - "endpoint": "/login", - "body": { - "user_id": "{{variables.user_id}}", - "user_pwd": "{{variables.user_pwd}}" - }, - "extract": { - "accessToken": "$.access_token" - }, - "expect": { - "status": [200] - } - }, - { - "id": "cancel_not_found", - "name": "존재하지 않는 초대 취소 (실패 예상)", - "method": "DELETE", - "endpoint": "/users/invitations/999999", - "headers": { - "Authorization": "Bearer {{login.accessToken}}" - }, - "dependsOn": ["login"], - "expect": { - "status": [404], - "jsonPath": { - "$.success": false - } - } - } - ] - } - ], - "summary": { - "total_flows": 14, - "priority_breakdown": { - "P1_critical": 8, - "P2_important": 3, - "P3_recommended": 3 - }, - "coverage": { - "endpoints": [ - "POST /users/invite", - "GET /users/invitations", - "DELETE /users/invitations/{id}", - "POST /users/invitations/{id}/resend" - ], - "not_covered": [ - "POST /users/invitations/{token}/accept (별도 Flow 필요: 실제 이메일 수신 필요)" - ] - }, - "branches_tested": { - "role_types": ["admin", "manager", "user"], - "role_methods": ["role (string)", "role_id (integer)"], - "status_filters": ["pending", "accepted", "expired", "cancelled"], - "sort_options": ["created_at", "expires_at", "email"], - "error_cases": ["duplicate_email", "invalid_role", "invalid_email", "no_auth", "not_found"] - } - } -} diff --git a/lang/ko/message.php b/lang/ko/message.php index b7fdcec..fdac75a 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -375,4 +375,11 @@ 'request_approved' => '회사 추가 신청이 승인되었습니다.', 'request_rejected' => '회사 추가 신청이 반려되었습니다.', ], + + // FCM 푸시 알림 관리 + 'fcm' => [ + 'sent' => 'FCM 발송이 완료되었습니다.', + 'token_toggled' => '토큰 상태가 변경되었습니다.', + 'token_deleted' => '토큰이 삭제되었습니다.', + ], ];