광고연구소

OnSureLab 기술 레퍼런스

개발자

어드민 API, OpenAPI 스펙, DB Viewer를 통합한 기술 레퍼런스입니다.

최근 변경사항

2026-03-12
  • 추가
    결제 요청 API (POST /executions/:id/request-payment)
  • 추가
    견적서 PDF API (GET /executions/:id/quote/pdf)
  • 추가
    결제 계좌 정보 조회 API (GET /executions/:id/payment-info)
  • 추가
    이메일 인증 재발송 API (POST /auth/resend-verification)
  • 추가
    회원 탈퇴 API (DELETE /auth/me)
  • 추가
    환불 처리 API (POST /admin/executions/:id/refund)
  • 추가
    부분결제 처리 API (POST /admin/executions/:id/partial-payment)
  • 수정
    GET /executions, PATCH /executions/:id — status 필터/검증 10단계 전체 허용
  • 수정
    MEMBER 역할에 PAYMENT_WAIT 전이 권한 추가
2026-03-11
  • 추가
    게첨보고서(execution_report) API — PDF 업로드/조회/삭제
  • 추가
    집행 사진(execution_photo) API — 시점별 증빙 사진 업로드/조회/삭제
  • 추가
    세금계산서 발행정보 CRUD API 5개 (고객용)
  • 추가
    PATCH /auth/me 이메일 변경 기능 (중복 체크 포함)
  • 삭제
    PLAN_REVIEW, PLAN_APPROVED, PLAN_REJECTED 상태 제거 (13 → 10단계)
  • 삭제
    submit-plan, approve-plan, reject-plan API 제거 (고객 + 어드민)
959 lines
1openapi: 3.0.3
2info:
3  title: OnSureLab Admin API
4  description: |
5    OnSureLab 관리자 전용 API 스펙.
6    - 대시보드 (1개): KPI 조회
7    - 집행 관리 (12개): 목록, 상세, 상태 변경, 환불, 부분결제, 액션 API
8    - 집행 사진 (3개): 사진 업로드, 목록 조회, 삭제
9    - 게첨보고서 (3개): PDF 업로드, 목록 조회, 삭제
10    - 유저 관리 (3개): 목록, 상세, 수정 (ADMIN 전용)
11    - 결제 관리 (1개): 결제 목록
12    - DB 관리 (1개): 스키마 조회
13
14    ## 인증
15    모든 API는 X-API-Key 헤더 + onsure_session 쿠키(JWT)가 필요합니다.
16
17    ## 권한
18    - **DEVELOPER**: 모든 어드민 API 접근 가능
19    - **ADMIN**: 모든 어드민 API 접근 가능
20    - **OPERATOR**: 유저 관리 제외 접근 가능
21    - **MEMBER**: 접근 불가
22  version: 2.0.0
23
24servers:
25  - url: https://ad.dukplace.com
26    description: Production
27  - url: http://localhost:4000
28    description: Development
29
30tags:
31  - name: Dashboard
32    description: 대시보드 KPI
33  - name: Execution
34    description: 집행 관리 (ADMIN, OPERATOR)
35  - name: User
36    description: 유저 관리 (ADMIN 전용)
37  - name: Payment
38    description: 결제 관리 (ADMIN, OPERATOR)
39  - name: Database
40    description: DB 스키마 조회
41
42security:
43  - apiKey: []
44  - session: []
45
46# ──────────────────────────────────────────────
47# PATHS
48# ──────────────────────────────────────────────
49paths:
50
51  # ════════════════════════════════════════════
52  # 대시보드
53  # ════════════════════════════════════════════
54
55  /api/onsurelab/admin/dashboard:
56    get:
57      tags: [Dashboard]
58      summary: 대시보드 KPI
59      description: "상태별 집행 건수, 긴급 처리 항목, 최근 히스토리 조회"
60      operationId: adminDashboard
61      responses:
62        '200':
63          description: 대시보드 데이터
64          content:
65            application/json:
66              schema:
67                $ref: '#/components/schemas/SuccessData'
68              example:
69                success: true
70                data:
71                  statusCounts:
72                    - { status: RUNNING, count: 12 }
73                    - { status: PAYMENT_WAIT, count: 3 }
74                  urgentItems:
75                    - id: exec_xxx
76                      title: "지하철 포스터"
77                      status: PAYMENT_WAIT
78                      updated_at: "2026-03-07T08:00:00.000Z"
79                      user_name: "김OO"
80                  recentHistory: []
81
82  # ════════════════════════════════════════════
83  # 집행 관리
84  # ════════════════════════════════════════════
85
86  /api/onsurelab/admin/executions:
87    get:
88      tags: [Execution]
89      summary: 집행 목록 (필터/검색/페이징)
90      operationId: adminListExecutions
91      parameters:
92        - name: status
93          in: query
94          schema:
95            $ref: '#/components/schemas/ExecutionStatus'
96        - name: search
97          in: query
98          schema:
99            type: string
100          description: 고객명/제목/ID 검색
101        - name: sort
102          in: query
103          schema:
104            type: string
105            enum: [latest, amount]
106            default: latest
107        - name: page
108          in: query
109          schema:
110            type: integer
111            default: 1
112        - name: size
113          in: query
114          schema:
115            type: integer
116            default: 20
117            maximum: 100
118      responses:
119        '200':
120          description: 집행 목록
121          content:
122            application/json:
123              schema:
124                $ref: '#/components/schemas/PaginatedResponse'
125
126  /api/onsurelab/admin/executions/{id}:
127    parameters:
128      - $ref: '#/components/parameters/ExecutionId'
129    get:
130      tags: [Execution]
131      summary: 집행 상세 (모든 탭 데이터 통합)
132      description: "execution, user, plan, files, messages, history, photos, reports 포함"
133      operationId: adminGetExecution
134      responses:
135        '200':
136          description: 집행 상세 통합 데이터
137          content:
138            application/json:
139              schema:
140                $ref: '#/components/schemas/SuccessData'
141              example:
142                success: true
143                data:
144                  execution: { id: "exec_abc123", title: "마케팅 실증 사업", status: "RUNNING" }
145                  user: { id: 1, name: "홍길동", email: "[email protected]" }
146                  plan: { id: "plan_xxx", executionId: "exec_abc123" }
147                  files: []
148                  messages: []
149                  history: []
150    patch:
151      tags: [Execution]
152      summary: 범용 상태 변경 (HOLD, CANCELLED 등)
153      description: "상태 머신 검증 후 히스토리 자동 기록"
154      operationId: adminUpdateExecution
155      requestBody:
156        required: true
157        content:
158          application/json:
159            schema:
160              type: object
161              required: [status]
162              properties:
163                status:
164                  $ref: '#/components/schemas/ExecutionStatus'
165                reason:
166                  type: string
167                  description: HOLD, CANCELLED 시 사유
168            example:
169              status: HOLD
170              reason: "고객 요청으로 일시 중지"
171      responses:
172        '200':
173          description: 상태 변경 완료
174          content:
175            application/json:
176              schema:
177                $ref: '#/components/schemas/SuccessMessage'
178
179  /api/onsurelab/admin/executions/{id}/confirm-payment:
180    parameters:
181      - $ref: '#/components/parameters/ExecutionId'
182    post:
183      tags: [Execution]
184      summary: 입금 확인
185      description: "전이: PAYMENT_WAIT → PAID"
186      operationId: adminConfirmPayment
187      requestBody:
188        required: true
189        content:
190          application/json:
191            schema:
192              type: object
193              properties:
194                amount:
195                  type: integer
196                  example: 3850000
197                date:
198                  type: string
199                  format: date
200                  example: "2026-03-07"
201                memo:
202                  type: string
203                  example: "기업은행 입금 확인"
204      responses:
205        '200':
206          description: 입금 확인 완료
207          content:
208            application/json:
209              schema:
210                $ref: '#/components/schemas/SuccessMessage'
211
212  /api/onsurelab/admin/executions/{id}/approve-material:
213    parameters:
214      - $ref: '#/components/parameters/ExecutionId'
215    post:
216      tags: [Execution]
217      summary: 소재 승인
218      description: "전이: MATERIAL_SENT → MATERIAL_APPROVED"
219      operationId: adminApproveMaterial
220      responses:
221        '200':
222          description: 소재 승인 완료
223          content:
224            application/json:
225              schema:
226                $ref: '#/components/schemas/SuccessMessage'
227
228  /api/onsurelab/admin/executions/{id}/request-revision:
229    parameters:
230      - $ref: '#/components/parameters/ExecutionId'
231    post:
232      tags: [Execution]
233      summary: 소재 수정 요청
234      description: "전이: MATERIAL_SENT → MATERIAL_REVISION"
235      operationId: adminRequestRevision
236      requestBody:
237        required: true
238        content:
239          application/json:
240            schema:
241              type: object
242              required: [reason]
243              properties:
244                reason:
245                  type: string
246                  example: "이미지 해상도가 낮습니다. 300dpi 이상으로 재업로드해주세요."
247      responses:
248        '200':
249          description: 수정 요청 완료
250          content:
251            application/json:
252              schema:
253                $ref: '#/components/schemas/SuccessMessage'
254
255  /api/onsurelab/admin/executions/{id}/start:
256    parameters:
257      - $ref: '#/components/parameters/ExecutionId'
258    post:
259      tags: [Execution]
260      summary: 집행 시작
261      description: "전이: MATERIAL_APPROVED → RUNNING"
262      operationId: adminStartExecution
263      requestBody:
264        required: true
265        content:
266          application/json:
267            schema:
268              type: object
269              required: [startDate]
270              properties:
271                startDate:
272                  type: string
273                  format: date
274                  example: "2026-03-10"
275      responses:
276        '200':
277          description: 집행 시작 완료
278          content:
279            application/json:
280              schema:
281                $ref: '#/components/schemas/SuccessMessage'
282
283  /api/onsurelab/admin/executions/{id}/complete:
284    parameters:
285      - $ref: '#/components/parameters/ExecutionId'
286    post:
287      tags: [Execution]
288      summary: 집행 완료
289      description: "전이: RUNNING → DONE. endDate 미입력 시 현재 날짜 사용"
290      operationId: adminCompleteExecution
291      requestBody:
292        content:
293          application/json:
294            schema:
295              type: object
296              properties:
297                endDate:
298                  type: string
299                  format: date
300                  example: "2026-04-10"
301      responses:
302        '200':
303          description: 집행 완료 처리
304          content:
305            application/json:
306              schema:
307                $ref: '#/components/schemas/SuccessMessage'
308
309  /api/onsurelab/admin/executions/{id}/refund:
310    parameters:
311      - $ref: '#/components/parameters/ExecutionId'
312    post:
313      tags: [Execution]
314      summary: 환불 처리
315      description: "payment_status가 PAID/PARTIAL → REFUNDED + status CANCELLED"
316      operationId: adminRefund
317      requestBody:
318        required: true
319        content:
320          application/json:
321            schema:
322              type: object
323              required: [reason]
324              properties:
325                reason:
326                  type: string
327                  example: "고객 요청에 의한 전액 환불"
328      responses:
329        '200':
330          description: 환불 완료
331          content:
332            application/json:
333              schema:
334                $ref: '#/components/schemas/SuccessData'
335
336  /api/onsurelab/admin/executions/{id}/partial-payment:
337    parameters:
338      - $ref: '#/components/parameters/ExecutionId'
339    post:
340      tags: [Execution]
341      summary: 부분결제 처리
342      description: "payment_status가 WAITING/PARTIAL → PARTIAL. status 변경 없음"
343      operationId: adminPartialPayment
344      requestBody:
345        required: true
346        content:
347          application/json:
348            schema:
349              type: object
350              required: [amount]
351              properties:
352                amount:
353                  type: integer
354                  example: 1000000
355                  description: 결제 금액 (0보다 커야 함)
356                memo:
357                  type: string
358                  example: "1차 분할 입금"
359      responses:
360        '200':
361          description: 부분결제 완료
362          content:
363            application/json:
364              schema:
365                $ref: '#/components/schemas/SuccessData'
366
367  /api/onsurelab/admin/executions/{id}/files:
368    parameters:
369      - $ref: '#/components/parameters/ExecutionId'
370    post:
371      tags: [Execution]
372      summary: 파일 업로드
373      operationId: adminUploadFile
374      requestBody:
375        required: true
376        content:
377          multipart/form-data:
378            schema:
379              type: object
380              required: [file, category]
381              properties:
382                file:
383                  type: string
384                  format: binary
385                  description: 업로드할 파일
386                category:
387                  type: string
388                  enum: [reference, logo, brand_guide, evidence, deliverable]
389                  description: 파일 카테고리
390                step:
391                  type: integer
392                  description: 스텝 번호
393      responses:
394        '201':
395          description: 업로드 완료
396          content:
397            application/json:
398              schema:
399                $ref: '#/components/schemas/SuccessData'
400
401  # ════════════════════════════════════════════
402  # 집행 사진 관리
403  # ════════════════════════════════════════════
404
405  /api/onsurelab/admin/executions/{id}/photos:
406    parameters:
407      - $ref: '#/components/parameters/ExecutionId'
408    get:
409      tags: [Execution]
410      summary: 집행 사진 목록 조회
411      operationId: adminListPhotos
412      parameters:
413        - name: type
414          in: query
415          schema:
416            type: string
417            enum: [PROGRESS, FINAL]
418          description: 사진 유형 필터
419        - name: milestone
420          in: query
421          schema:
422            type: string
423          description: 시점 필터
424      responses:
425        '200':
426          description: 사진 목록
427          content:
428            application/json:
429              schema:
430                type: object
431                properties:
432                  success:
433                    type: boolean
434                  data:
435                    type: object
436                    properties:
437                      photos:
438                        type: array
439                        items:
440                          $ref: '#/components/schemas/ExecutionPhoto'
441    post:
442      tags: [Execution]
443      summary: 집행 사진 업로드
444      operationId: adminUploadPhoto
445      requestBody:
446        required: true
447        content:
448          multipart/form-data:
449            schema:
450              type: object
451              required: [file, milestone]
452              properties:
453                file:
454                  type: string
455                  format: binary
456                  description: 업로드할 사진 파일
457                type:
458                  type: string
459                  enum: [PROGRESS, FINAL]
460                  default: PROGRESS
461                  description: "사진 유형 (PROGRESS: 진행 중, FINAL: 최종)"
462                milestone:
463                  type: string
464                  description: "시점 (자유 입력: '1개월차', '3개월차', '최종' 등)"
465                  example: 1개월차
466                shot_at:
467                  type: string
468                  format: date
469                  description: 촬영/해당 시점 날짜 (선택)
470                  example: "2026-03-15"
471                note:
472                  type: string
473                  description: 메모 (선택)
474      responses:
475        '201':
476          description: 업로드 완료
477          content:
478            application/json:
479              schema:
480                $ref: '#/components/schemas/SuccessData'
481
482  /api/onsurelab/admin/executions/{id}/photos/{photoId}:
483    parameters:
484      - $ref: '#/components/parameters/ExecutionId'
485      - name: photoId
486        in: path
487        required: true
488        schema:
489          type: string
490        description: 사진 ID (photo_xxxx)
491    delete:
492      tags: [Execution]
493      summary: 집행 사진 삭제
494      operationId: adminDeletePhoto
495      responses:
496        '200':
497          description: 삭제 완료
498          content:
499            application/json:
500              schema:
501                $ref: '#/components/schemas/SuccessResponse'
502
503  # ════════════════════════════════════════════
504  # 게첨보고서 관리
505  # ════════════════════════════════════════════
506
507  /api/onsurelab/admin/executions/{id}/reports:
508    parameters:
509      - $ref: '#/components/parameters/ExecutionId'
510    get:
511      tags: [Execution]
512      summary: 게첨보고서 목록 조회
513      operationId: adminListReports
514      responses:
515        '200':
516          description: 보고서 목록
517          content:
518            application/json:
519              schema:
520                type: object
521                properties:
522                  success:
523                    type: boolean
524                  data:
525                    type: object
526                    properties:
527                      reports:
528                        type: array
529                        items:
530                          $ref: '#/components/schemas/ExecutionReport'
531    post:
532      tags: [Execution]
533      summary: 게첨보고서 업로드 (PDF)
534      operationId: adminUploadReport
535      requestBody:
536        required: true
537        content:
538          multipart/form-data:
539            schema:
540              type: object
541              required: [file, title]
542              properties:
543                file:
544                  type: string
545                  format: binary
546                  description: 업로드할 PDF 파일
547                title:
548                  type: string
549                  description: 보고서 제목
550                  example: 1개월차 게첨보고서
551                milestone:
552                  type: string
553                  description: "시점 (선택, 자유 입력)"
554                  example: 1개월차
555                note:
556                  type: string
557                  description: 메모 (선택)
558      responses:
559        '201':
560          description: 업로드 완료
561          content:
562            application/json:
563              schema:
564                $ref: '#/components/schemas/SuccessData'
565
566  /api/onsurelab/admin/executions/{id}/reports/{reportId}:
567    parameters:
568      - $ref: '#/components/parameters/ExecutionId'
569      - name: reportId
570        in: path
571        required: true
572        schema:
573          type: string
574        description: 보고서 ID (report_xxxx)
575    delete:
576      tags: [Execution]
577      summary: 게첨보고서 삭제
578      operationId: adminDeleteReport
579      responses:
580        '200':
581          description: 삭제 완료
582          content:
583            application/json:
584              schema:
585                $ref: '#/components/schemas/SuccessResponse'
586
587  # ════════════════════════════════════════════
588  # 유저 관리 (ADMIN 전용)
589  # ════════════════════════════════════════════
590
591  /api/onsurelab/admin/users:
592    get:
593      tags: [User]
594      summary: 유저 목록
595      description: "권한: ADMIN 전용"
596      operationId: adminListUsers
597      parameters:
598        - name: search
599          in: query
600          schema:
601            type: string
602          description: 이메일/이름/회사명 검색
603        - name: role
604          in: query
605          schema:
606            $ref: '#/components/schemas/UserRole'
607        - name: isActive
608          in: query
609          schema:
610            type: boolean
611        - name: page
612          in: query
613          schema:
614            type: integer
615            default: 1
616        - name: size
617          in: query
618          schema:
619            type: integer
620            default: 20
621      responses:
622        '200':
623          description: 유저 목록
624          content:
625            application/json:
626              schema:
627                $ref: '#/components/schemas/PaginatedResponse'
628
629  /api/onsurelab/admin/users/{userId}:
630    parameters:
631      - name: userId
632        in: path
633        required: true
634        schema:
635          type: integer
636    get:
637      tags: [User]
638      summary: 유저 상세
639      description: "유저 정보 + 해당 유저의 집행 목록. 권한: ADMIN 전용"
640      operationId: adminGetUser
641      responses:
642        '200':
643          description: 유저 상세
644          content:
645            application/json:
646              schema:
647                $ref: '#/components/schemas/SuccessData'
648              example:
649                success: true
650                data:
651                  user: { id: 1, email: "[email protected]", name: "홍길동", role: "MEMBER" }
652                  executions: []
653    patch:
654      tags: [User]
655      summary: 유저 정보 수정 (역할, 활성화 등)
656      description: "권한: ADMIN 전용. 부분 업데이트"
657      operationId: adminUpdateUser
658      requestBody:
659        content:
660          application/json:
661            schema:
662              type: object
663              properties:
664                role:
665                  $ref: '#/components/schemas/UserRole'
666                isActive:
667                  type: boolean
668                name:
669                  type: string
670                company:
671                  type: string
672            example:
673              role: OPERATOR
674              isActive: true
675      responses:
676        '200':
677          description: 수정 완료
678          content:
679            application/json:
680              schema:
681                $ref: '#/components/schemas/SuccessData'
682
683  # ════════════════════════════════════════════
684  # 결제 관리
685  # ════════════════════════════════════════════
686
687  /api/onsurelab/admin/payments:
688    get:
689      tags: [Payment]
690      summary: 결제 목록
691      operationId: adminListPayments
692      parameters:
693        - name: paymentStatus
694          in: query
695          schema:
696            type: string
697            enum: [ALL, WAITING, PAID, REFUNDED]
698          description: "ALL이면 전체 조회"
699        - name: page
700          in: query
701          schema:
702            type: integer
703            default: 1
704        - name: size
705          in: query
706          schema:
707            type: integer
708            default: 20
709      responses:
710        '200':
711          description: 결제 목록
712          content:
713            application/json:
714              schema:
715                $ref: '#/components/schemas/PaginatedResponse'
716              example:
717                success: true
718                data:
719                  items:
720                    - id: exec_abc123
721                      title: "마케팅 실증 사업"
722                      payment_status: PAID
723                      payment_method: BANK_TRANSFER
724                      final_quote: 5000000
725                      user_name: "홍길동"
726                      user_company: "(주)온슈어"
727                  total: 10
728                  page: 1
729                  totalPages: 1
730
731  # ════════════════════════════════════════════
732  # DB 관리
733  # ════════════════════════════════════════════
734
735  /api/onsurelab/admin/database:
736    get:
737      tags: [Database]
738      summary: DB 스키마 조회
739      description: "전체 테이블 목록 + 각 테이블 컬럼 구조 (DESCRIBE)"
740      operationId: adminGetDatabaseSchema
741      responses:
742        '200':
743          description: DB 스키마
744          content:
745            application/json:
746              schema:
747                $ref: '#/components/schemas/SuccessData'
748              example:
749                success: true
750                env: dev
751                tableCount: 6
752                tables:
753                  - name: user
754                    columns:
755                      - { Field: id, Type: bigint, "Null": "NO", Key: PRI, Default: null, Extra: auto_increment }
756                      - { Field: email, Type: "varchar(255)", "Null": "NO", Key: UNI, Default: null, Extra: "" }
757                      - { Field: name, Type: "varchar(100)", "Null": "NO", Key: "", Default: null, Extra: "" }
758                  - name: execution
759                    columns:
760                      - { Field: id, Type: "varchar(20)", "Null": "NO", Key: PRI, Default: null, Extra: "" }
761
762# ──────────────────────────────────────────────
763# COMPONENTS
764# ──────────────────────────────────────────────
765components:
766
767  securitySchemes:
768    apiKey:
769      type: apiKey
770      in: header
771      name: X-API-Key
772      description: |
773        Dev: `duk-med-partner-dev`
774        Prod: `duk-med-partner`
775    session:
776      type: apiKey
777      in: cookie
778      name: onsure_session
779      description: 로그인 시 발급되는 httpOnly JWT 쿠키 (24시간 만료)
780
781  parameters:
782    ExecutionId:
783      name: id
784      in: path
785      required: true
786      schema:
787        type: string
788      description: 집행 ID (예: exec_a1b2c3d4e5f6)
789
790  schemas:
791    SuccessMessage:
792      type: object
793      properties:
794        success:
795          type: boolean
796          example: true
797        data:
798          type: object
799        message:
800          type: string
801
802    SuccessData:
803      type: object
804      properties:
805        success:
806          type: boolean
807          example: true
808        data:
809          type: object
810
811    SuccessResponse:
812      type: object
813      properties:
814        success:
815          type: boolean
816          example: true
817        message:
818          type: string
819
820    ErrorResponse:
821      type: object
822      properties:
823        success:
824          type: boolean
825          example: false
826        error:
827          type: string
828
829    PaginatedResponse:
830      type: object
831      properties:
832        success:
833          type: boolean
834          example: true
835        data:
836          type: object
837          properties:
838            items:
839              type: array
840              items:
841                type: object
842            total:
843              type: integer
844            page:
845              type: integer
846            totalPages:
847              type: integer
848
849    ExecutionReport:
850      type: object
851      properties:
852        reportId:
853          type: string
854          example: report_abc123def456
855        executionId:
856          type: string
857          example: exec_abc123
858        url:
859          type: string
860          example: https://kr.object.ncloudstorage.com/bucket/executions/exec_abc123/reports/report.pdf
861        originalName:
862          type: string
863          example: 1개월차_게첨보고서.pdf
864        size:
865          type: integer
866          example: 2048000
867        mimeType:
868          type: string
869          example: application/pdf
870        title:
871          type: string
872          example: 1개월차 게첨보고서
873        milestone:
874          type: string
875          nullable: true
876          example: 1개월차
877        note:
878          type: string
879          nullable: true
880        uploadedAt:
881          type: string
882          format: date-time
883        uploadedBy:
884          type: integer
885          nullable: true
886          description: 업로드한 어드민 user_id
887
888    ExecutionPhoto:
889      type: object
890      properties:
891        photoId:
892          type: string
893          example: photo_abc123def456
894        executionId:
895          type: string
896          example: exec_abc123
897        url:
898          type: string
899          example: https://kr.object.ncloudstorage.com/bucket/executions/exec_abc123/photos/photo.webp
900        originalName:
901          type: string
902          example: 1개월차_정류장사진.jpg
903        size:
904          type: integer
905          example: 1024000
906        mimeType:
907          type: string
908          example: image/jpeg
909        type:
910          type: string
911          enum: [PROGRESS, FINAL]
912          description: "PROGRESS: 진행 중 시점, FINAL: 최종"
913        milestone:
914          type: string
915          description: "시점 (자유 입력)"
916          example: 1개월차
917        shotAt:
918          type: string
919          format: date
920          nullable: true
921          example: "2026-03-15"
922        note:
923          type: string
924          nullable: true
925        uploadedAt:
926          type: string
927          format: date-time
928        uploadedBy:
929          type: integer
930          nullable: true
931          description: 업로드한 어드민 user_id
932
933    ExecutionStatus:
934      type: string
935      enum:
936        - DRAFT
937        - PAYMENT_WAIT
938        - PAID
939        - MATERIAL_SENT
940        - MATERIAL_APPROVED
941        - MATERIAL_REVISION
942        - RUNNING
943        - DONE
944        - HOLD
945        - CANCELLED
946      description: |
947        10개 집행 상태:
948        DRAFT → PAYMENT_WAIT → PAID → MATERIAL_SENT → MATERIAL_APPROVED → RUNNING → DONE
949        분기: MATERIAL_REVISION, HOLD, CANCELLED
950
951    UserRole:
952      type: string
953      enum: [DEVELOPER, ADMIN, OPERATOR, MEMBER]
954      description: |
955        - DEVELOPER: 개발자 (전체 접근)
956        - ADMIN: 전체 관리 (유저관리 포함)
957        - OPERATOR: 집행/결제/소재 관리
958        - MEMBER: 고객 (기본값)
959