From eeee451f366801a841647da7039bf8ed2c5d0206 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Wed, 20 May 2026 09:55:17 +0800 Subject: [PATCH 01/81] docs: add sirsoft-inquiry module design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 별도 모듈 sirsoft-inquiry v1 설계 — 1:N 운영자 수주형 제작의뢰의 접수·견적·진행·완료 전체 라이프사이클을 한 도메인 모듈에 담는다. 표준 4단계 상태머신, 구조화된 견적(version immutable), sirsoft-ecommerce 결제 선택적 의존, 채팅형 메시지 스레드, 인앱+이메일 알림 포함. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...236\221\354\235\230\353\242\260-design.md" | 488 ++++++++++++++++++ 1 file changed, 488 insertions(+) create mode 100644 "docs/superpowers/specs/2026-05-20-\354\240\234\354\236\221\354\235\230\353\242\260-design.md" diff --git "a/docs/superpowers/specs/2026-05-20-\354\240\234\354\236\221\354\235\230\353\242\260-design.md" "b/docs/superpowers/specs/2026-05-20-\354\240\234\354\236\221\354\235\230\353\242\260-design.md" new file mode 100644 index 00000000..2b4fa99a --- /dev/null +++ "b/docs/superpowers/specs/2026-05-20-\354\240\234\354\236\221\354\235\230\353\242\260-design.md" @@ -0,0 +1,488 @@ +# 제작의뢰 모듈 (sirsoft-inquiry) — 설계 문서 + +- **작성일**: 2026-05-20 +- **작성자**: Ryan Heo (with Claude) +- **상태**: Draft → 사용자 리뷰 대기 +- **대상 버전**: v1.0 (게스트 비지원, ecommerce 선택적 의존) + +--- + +## 1. 목적 + +사이트 운영자가 외부 고객으로부터 제작 의뢰를 받아 **접수 → 견적 → 진행 → 완료**의 전 과정을 한 곳에서 처리한다. 단순 문의 폼이 아니라 의뢰 한 건의 라이프사이클 전체(상태 변화·견적·결제·소통·첨부·알림)를 도메인 객체로 다룬다. + +운영 형태는 **1:N 운영자 수주형** — 다수의 외부 고객이 단일 운영자(=관리자 그룹)에게 의뢰. 마켓플레이스(다대다)는 범위 밖. + +--- + +## 2. 범위 결정 (Scope) + +### v1 포함 +- 회원 의뢰자 ↔ 운영자(관리자 그룹) 간 의뢰 라이프사이클 +- 표준 4단계 상태머신 + canceled +- 구조화된 견적(항목·합계·유효기간) + 재견적(version) 지원 +- 채팅형 메시지 스레드 + 첨부 +- 견적 수락 시 sirsoft-ecommerce 결제 페이지 연결 (`OrderPaid` 이벤트 구독) +- 인앱 + 이메일 알림 (Laravel Notification) +- 의뢰자용 프론트 화면 + 운영자용 어드민 화면 + +### v1 제외 (의도적) +- 게스트(비회원) 의뢰 — 회원 가입 강제로 간소화 +- 다대다 마켓플레이스, 다중 작업자 견적 경쟁 +- 부분 결제·분할 결제 +- 자동 환불·정산 +- 실시간 푸시 메시지 (WebSocket·SSE) +- 완료 의뢰의 포트폴리오 공개 (필요해지면 v2: `is_showcase` boolean 추가) +- 마크다운·HTML 메시지 본문 (plain text + URL 자동 링크화만) +- SLA/계약서 PDF 생성 +- 카카오톡·SMS 알림 채널 + +--- + +## 3. 아키텍처 + +### 3.1 모듈 경계 + +별도 모듈 **`modules/_bundled/sirsoft-inquiry/`** 로 구축. board / ecommerce 와 도메인이 본질적으로 다르므로 통합하지 않고 **컴포지션으로 참조**한다. + +- `sirsoft-board` 의존: **없음**. 첨부는 별도 테이블에 저장하고 동일 storage disk만 공유. +- `sirsoft-ecommerce` 의존: **선택적**. ServiceProvider에서 ecommerce 클래스 존재 시에만 이벤트 구독자를 등록. 없는 환경에서는 운영자 수동(`mark_paid_offline`) 경로로만 동작. + +### 3.2 디렉터리 구조 + +``` +modules/_bundled/sirsoft-inquiry/ + src/ + Models/ Inquiry, InquiryQuote, InquiryQuoteItem, InquiryMessage, InquiryAttachment + Repositories/ InquiryRepository, InquiryQuoteRepository, InquiryMessageRepository, InquiryAttachmentRepository + Http/ + Controllers/ Api/{Public/InquiryController, Public/InquiryMessageController, Public/InquiryAttachmentController, Admin/InquiryController, Admin/InquiryQuoteController} + Requests/ StoreInquiryRequest, UpdateInquiryRequest, StoreQuoteRequest, StoreMessageRequest, UploadAttachmentRequest, ... + Resources/ InquiryResource, InquiryQuoteResource, InquiryQuoteItemResource, InquiryMessageResource, InquiryAttachmentResource + Enums/ InquiryStatus, QuoteStatus, SenderRole + Policies/ InquiryPolicy + Services/ + InquiryStateMachine 상태 전이 규칙 + 부수 효과 트리거 + InquiryPaymentBridge ecommerce Order 생성/수신 (선택적 의존) + InquiryAttachmentStorage 업로드·정책 검증 + Notifications/ InquiryReceivedToOperators, QuoteIssued, QuoteRevoked, PaymentConfirmed, InquiryCompleted, InquiryCanceled, NewMessage + Events/ InquiryStatusTransitioned, InquiryMessagePosted + Listeners/ DispatchInquiryNotifications, AppendSystemMessageOnTransition + Providers/ InquiryServiceProvider + database/migrations/ + *_create_inquiries.php + *_create_inquiry_quotes.php + *_create_inquiry_quote_items.php + *_create_inquiry_messages.php + *_create_inquiry_attachments.php + resources/ + layouts/admin/ admin_inquiry_index.json, admin_inquiry_detail.json, admin_inquiry_quote_form.json + layouts/admin/partials/ ... + lang/{ko,en}/ common.php, validation.php, notifications.php + +templates/_bundled/sirsoft-basic/ + layouts/inquiry/ index.json, new.json, show.json + layouts/inquiry/partials/ _quote_card.json, _message_thread.json, ... + src/components/composite/ InquiryStatusBar.tsx, InquiryMessageThread.tsx, QuoteCard.tsx, QuotePayButton.tsx, InquiryCard.tsx +``` + +### 3.3 핵심 설계 원칙 + +1. **상태 전이는 단일 진입점**. 컨트롤러·리포지토리가 `$inquiry->status = ...` 직접 수정 금지. 반드시 `InquiryStateMachine::transition($inquiry, $event, $actor)`. +2. **견적은 불변(immutable)**. 수정 대신 새 `version` 발급. 회계·감사 추적성 확보. +3. **이벤트 경계로 모듈 간 연결**. inquiry는 ecommerce 결제 디테일을 모르고, ecommerce는 inquiry 존재를 모름. `OrderPaid` 이벤트의 `meta.inquiry_id` 만 매개. +4. **알림은 상태 전이의 부수 효과로만**. 컨트롤러에서 직접 Notification 발송 금지. 모든 알림은 `InquiryStatusTransitioned`/`InquiryMessagePosted` 이벤트의 Listener에서. +5. **UI 액션은 layout JSON의 공식 모달 패턴**. 컴포넌트 내부에 `window.confirm` + 직접 fetch 금지. 견적 철회·취소·완료 처리 등 위험 액션은 모두 `_modal_*` 파셜 + `_global.*Modal` 글로벌 상태 + `apiCall` 시퀀스. + +--- + +## 4. 도메인 모델 + +### 4.1 `inquiries` + +| 컬럼 | 타입 | 제약 | 설명 | +|---|---|---|---| +| `id` | bigint | pk | | +| `uuid` | uuid | unique, index | 외부 노출용 식별자 | +| `user_id` | bigint | fk users, NOT NULL | 의뢰자 (v1은 회원만) | +| `title` | string(200) | NOT NULL | | +| `content` | text | NOT NULL | plain text + 줄바꿈 보존 | +| `category` | string(50) | nullable | 분류(웹/디자인/유지보수 등) — config의 화이트리스트 | +| `budget_range` | string(100) | nullable | 희망 예산 메모(자유텍스트) | +| `desired_due_at` | date | nullable | 희망 완료일 | +| `status` | string(20) | NOT NULL, index, default `'received'` | `InquiryStatus` enum | +| `accepted_quote_id` | bigint | fk inquiry_quotes nullable | 수락된 견적 | +| `payment_id` | string(64) | nullable | ecommerce Order uuid | +| `extra_data` | json | nullable | 향후 확장 슬롯 | +| `received_at` | timestamp | NOT NULL default now | | +| `quoted_at`, `started_at`, `completed_at`, `canceled_at` | timestamp | nullable | 단계별 타임스탬프 | +| `created_at`, `updated_at` | timestamp | | | + +인덱스: `(user_id, status)`, `(status, received_at desc)`. + +### 4.2 `inquiry_quotes` + +| 컬럼 | 타입 | 제약 | 설명 | +|---|---|---|---| +| `id` | bigint | pk | | +| `inquiry_id` | bigint | fk, NOT NULL | | +| `version` | int | NOT NULL | 회차(1,2,...) | +| `total_amount` | decimal(12,0) | NOT NULL | 합계 (원 단위) | +| `tax_amount` | decimal(12,0) | NOT NULL default 0 | | +| `currency` | string(3) | NOT NULL default `'KRW'` | | +| `valid_until` | date | nullable | 유효기간 | +| `note` | text | nullable | 운영자 메모 | +| `status` | string(20) | NOT NULL default `'draft'` | `QuoteStatus` enum: `draft / issued / accepted / rejected / expired` | +| `issued_at`, `accepted_at`, `rejected_at` | timestamp | nullable | | +| `created_at`, `updated_at` | timestamp | | | + +제약: `(inquiry_id, version)` unique. + +### 4.3 `inquiry_quote_items` + +| 컬럼 | 타입 | 설명 | +|---|---|---| +| `id`, `quote_id` (fk) | | | +| `position` | int | 표시 순서 | +| `name` | string(200) | | +| `description` | text nullable | | +| `qty` | decimal(10,2) | | +| `unit_price` | decimal(12,0) | 원 단위 | +| `amount` | decimal(12,0) | `qty * unit_price` 저장본(반올림 정책 명시) | + +### 4.4 `inquiry_messages` + +| 컬럼 | 타입 | 설명 | +|---|---|---| +| `id`, `inquiry_id` (fk) | | | +| `sender_user_id` | fk users nullable | 시스템 메시지일 때 null | +| `sender_role` | enum `client / operator / system` | | +| `body` | text | 사용자 메시지: plain text + 줄바꿈 보존. 시스템 메시지: 비어 있고 `meta` 사용. | +| `meta` | json nullable | 시스템 메시지 전용 — `{key: 'inquiry.system.quote_issued', params: {version: 2, total: 890000}}` 형식. 사용자 메시지에서는 null. | +| `read_at` | timestamp nullable | 상대방이 읽은 시각 | +| `created_at`, `updated_at` | | | + +인덱스: `(inquiry_id, created_at asc)`. + +### 4.5 `inquiry_attachments` + +| 컬럼 | 타입 | 설명 | +|---|---|---| +| `id`, `inquiry_id` (fk) | | | +| `message_id` | fk inquiry_messages nullable | 메시지 첨부일 때 set, 의뢰 본문 첨부면 null | +| `uploader_user_id` | fk users | | +| `disk` | string(20) | Laravel Storage disk 이름 | +| `path` | string | 디스크 내 경로 | +| `original_name` | string(255) | | +| `mime` | string(100) | | +| `size` | bigint | bytes | +| `created_at`, `updated_at` | | | + +--- + +## 5. 상태머신 (`InquiryStateMachine`) + +### 5.1 상태 + +``` +received → quoted ⇄ received (재견적) + ↓ + in_progress + ↓ + completed + + (어느 단계든) → canceled +``` + +### 5.2 전이 표 + +| from | event | to | 허용 주체 | 조건 | +|---|---|---|---|---| +| `received` | `issue_quote` | `quoted` | operator | 견적 생성 + items ≥ 1, total > 0 | +| `quoted` | `revoke_quote` | `received` | operator | `accepted_quote_id is null` | +| `quoted` | `reject_quote` | `received` | client | 의뢰자가 견적 거절. 활성 quote는 `status='rejected'`로 마킹. | +| `quoted` | `accept_and_pay` | `in_progress` | client (결제 콜백 발화) | ecommerce `OrderPaid` 수신, `meta.inquiry_id` 일치 | +| `quoted` | `mark_paid_offline` | `in_progress` | operator | 외부 결제 수동 확인 | +| `in_progress` | `mark_completed` | `completed` | operator | 추가 조건 없음 (운영자 판단). 단, `payment_id is not null` (정상 결제 경로) 또는 `mark_paid_offline` 경유여야 함. | +| `received` / `quoted` / `in_progress` | `cancel` | `canceled` | client OR operator | 단계별 환불 정책은 v1에서 수동 | + +잘못된 전이 시 `InvalidStateTransitionException` 발생 → API 422. + +### 5.3 부수 효과 (트랜잭션 내 원자적) + +`StateMachine::transition()` 성공 시 한 트랜잭션 안에서: + +1. `inquiries.status` + 해당 단계 timestamp 갱신 +2. `inquiry_messages` 에 `sender_role='system'` 시스템 메시지 자동 삽입 (i18n 키 사용) +3. `InquiryStatusTransitioned` 이벤트 dispatch (after-commit) +4. Listener `DispatchInquiryNotifications` 가 인앱/이메일 발송 + +`accept_and_pay`만 예외 — 의뢰자가 결제 버튼을 클릭한 시점이 아니라 ecommerce 결제 콜백 수신 시점에서만 전이 발화한다. 결제 실패/중단 시 inquiry는 `quoted` 유지. + +### 5.4 견적 유효기간 만료 + +`inquiry_quotes.valid_until` 이 지난 활성 견적은 **lazy + scheduled** 두 경로로 expire: + +- **Lazy**: API에서 의뢰/견적 조회 시 `status='issued'` AND `valid_until < now` 이면 응답 직전 `status='expired'`로 마킹. +- **Scheduled**: 일 1회 cron 작업 `inquiry:expire-quotes` 가 같은 조건의 견적을 일괄 expire. + +견적이 expired되어도 inquiry status는 `quoted` 유지(운영자가 재견적 발행 또는 의뢰자가 cancel 가능). expired 시 자동 알림 없음(v2 검토). + +--- + +## 6. 권한·가시성 (`InquiryPolicy`) + +- 의뢰는 기본 **비공개**. 본인 + 운영자만 접근. +- 운영자 = 권한 `inquiry.manage` 보유자(보통 관리자 그룹). + +| 액션 | 본인 client | operator | 그 외 | +|---|---|---|---| +| `view` | ✓ | ✓ | ✗ | +| `update` (본문 수정) | `received` 까지 | 모든 단계 | ✗ | +| `cancel` | `received` / `quoted` | 모든 단계 | ✗ | +| `issueQuote` | ✗ | ✓ | ✗ | +| `revokeQuote` | ✗ | ✓ (해당 견적 미수락) | ✗ | +| `acceptQuote` (= 결제 요청 보내기) | ✓ (`quoted` 단계만) | ✗ | ✗ | +| `rejectQuote` | ✓ (`quoted` 단계만) | ✗ | ✗ | +| `markPaidOffline` | ✗ | ✓ | ✗ | +| `markCompleted` | ✗ | ✓ | ✗ | +| `postMessage` | ✓ | ✓ | ✗ | +| `viewAttachment` | ✓ (본인 의뢰) | ✓ | ✗ | +| `uploadAttachment` | ✓ | ✓ | ✗ | + +운영자가 견적을 발행한 후 새 견적을 추가할 때(재견적)는 기존 견적의 `status='rejected'` 또는 `'expired'`로 마킹 후 새 version 생성. + +--- + +## 7. 견적·결제 연동 (`InquiryPaymentBridge`) + +### 7.1 견적 발행 (운영자) + +1. 어드민 의뢰 상세 → "견적 발행" 액션 +2. `admin_inquiry_quote_form.json` 폼에서 항목 입력 (이름/수량/단가) → 합계·세금 자동 계산 +3. 저장 시 `InquiryQuoteRepository::issue($inquiry, $payload)` 호출 + - 같은 의뢰에 활성(`issued`) 견적 있으면 `expired`로 마킹 + - 새 `inquiry_quotes` row + items 생성 (`status='issued'`, `issued_at=now`) + - `StateMachine::transition($inquiry, 'issue_quote')` 호출 → status `quoted` + +### 7.2 수락·결제 (의뢰자) + +1. `/inquiry/{uuid}` 상세에서 `status='quoted'` + 본인일 때만 `QuoteCard` 의 결제 버튼 노출 +2. 클릭 → `POST /api/modules/sirsoft-inquiry/inquiries/{uuid}/quotes/{quoteId}/accept` +3. 컨트롤러가 `InquiryPaymentBridge::createOrder($quote)` 호출: + - ecommerce 클래스 부재 시 422 + "결제 모듈이 설치되지 않았습니다. 운영자에게 문의해 주세요." + - 설치 시 ecommerce `Order` 1건 생성 (line item 1개 = "의뢰 #UUID 견적 #v2", 금액 = `total_amount + tax_amount`), `meta.inquiry_id` / `meta.quote_id` 저장 + - 응답: `{ redirect_url: ecommerce 결제 페이지 URL }` +4. 프론트는 redirect_url로 이동 → ecommerce가 결제 완료/실패 후 자체 페이지로 돌아옴 +5. ecommerce가 `OrderPaid` 이벤트 발행 → inquiry의 Listener `InquiryPaymentBridge::handleOrderPaid()` 가 `meta.inquiry_id` 확인 + - 일치 시: `inquiry_quotes.status='accepted'`, `accepted_at=now`, `inquiries.accepted_quote_id=$quote->id`, `payment_id=$order->uuid` 저장 + - `StateMachine::transition($inquiry, 'accept_and_pay')` 호출 → status `in_progress` + +### 7.3 ecommerce 미설치 환경 + +- 운영자가 어드민에서 `mark_paid_offline` 액션으로 직접 진행 처리. 사유 메모를 운영자 메시지로 함께 기록. +- v1에서는 별도 영수증 발행 없음. + +--- + +## 8. 메시지·첨부 + +### 8.1 메시지 스레드 + +- 의뢰 상세의 우측(데스크톱) / 하단(모바일) `InquiryMessageThread` composite. +- 시간순(asc) 정렬, 페이지네이션(또는 무한스크롤 — v1은 단순 페이지네이션 50건/페이지). +- 본문은 plain text. 줄바꿈 보존. URL 자동 링크화(서버에서 안전 escape 후 `` 변환). 마크다운/HTML 금지. +- 전송: `POST /api/modules/sirsoft-inquiry/inquiries/{uuid}/messages` `{body, attachment_ids?}`. +- 첨부는 메시지 전송 전에 별도 업로드 → id 발급 → 메시지에 묶음 (메시지 본문은 비어 있어도 첨부만 있는 메시지 허용). + +### 8.2 읽음 표시 + +- `inquiry_messages.read_at`: 상대편 역할이 메시지를 화면에 표시한 시점 갱신. + - 의뢰자가 상세 페이지를 열면 운영자 발 메시지 `read_at = now`. + - 운영자가 어드민 상세를 열면 의뢰자 발 메시지 `read_at = now`. +- 의뢰 목록 카드에 "안 읽은 메시지 수" 뱃지 표시. +- 실시간 푸시는 범위 밖 — 페이지 진입/리프레시 시점에만 갱신. + +### 8.3 시스템 메시지 + +상태 전이 시 자동 삽입되는 `sender_role='system'` 메시지. 본문은 i18n 키로 저장하여 표시 시 보간: + +``` +inquiry.system.quote_issued | v2, total=890,000원 +inquiry.system.quote_revoked | v2 +inquiry.system.payment_confirmed | order=XXXX +inquiry.system.completed +inquiry.system.canceled | actor=client|operator +``` + +저장: `inquiry_messages.meta` 컬럼에 `{key, params}` JSON. `body`는 비워둠. 표시 시 i18n 보간. + +### 8.4 첨부 정책 + +- mime 화이트리스트: `image/jpeg`, `image/png`, `image/webp`, `image/gif`, `application/pdf`, `application/zip`, `application/x-zip-compressed`, `application/octet-stream`(확장자 검증 병행). +- 사이즈 상한 (config 키): + - `inquiry.attachment.max_size_inquiry` (기본 50MB) — 의뢰 본문 첨부 + - `inquiry.attachment.max_size_message` (기본 20MB) — 메시지 첨부 +- 저장 disk: `config('inquiry.attachment.disk', 'local')`. +- 다운로드: `GET /api/modules/sirsoft-inquiry/attachments/{id}` — Policy `viewAttachment` 통과 후 streamed response. 직접 public URL 노출 금지. +- Orphan 정리: + - 업로드 직후 `inquiry_id` 는 set되지만 `message_id` 는 null이고, 의뢰 본문 첨부도 아닌(=의뢰 작성 폼 단계에서 미완) 경우 orphan으로 간주. + - 의뢰 본문/메시지 어느 쪽에도 연결 안 된 첨부가 업로드 후 30분 경과 시 cron `inquiry:cleanup-orphan-attachments` 가 파일+DB 삭제. + +--- + +## 9. 알림 + +### 9.1 채널 + +- `database` (인앱): Laravel `notifications` 테이블. 프론트는 기존 `NotificationCenter` composite로 표시. +- `mail`: Laravel `mail::notification` 기본 템플릿. 추후 디자인 보강. +- v1에서 SMS/카카오톡/푸시 채널 없음. + +### 9.2 Notification 클래스 및 트리거 + +| 클래스 | 트리거 | 수신자 | +|---|---|---| +| `InquiryReceivedToOperators` | 의뢰 신규 생성 | 운영자 그룹 | +| `QuoteIssued` | `issue_quote` 전이 | 의뢰자 | +| `QuoteRevoked` | `revoke_quote` 전이 | 의뢰자 | +| `PaymentConfirmed` | `accept_and_pay` / `mark_paid_offline` 전이 | 의뢰자 + 운영자 그룹 | +| `InquiryCompleted` | `mark_completed` 전이 | 의뢰자 | +| `InquiryCanceled` | `cancel` 전이 | (취소 주체의 상대편) | +| `NewMessage` | `InquiryMessagePosted` 이벤트 | (메시지 작성자의 상대편) | + +### 9.3 수신자 결정 + +- "운영자 그룹" = 권한 `inquiry.notify` 보유자 전원. 어드민 설정에서 토글 가능. +- 의뢰자의 알림 수신 여부도 사용자 설정(`UserNotificationSetting` 패턴 차용)으로 제어 가능 — v1은 글로벌 on/off 한 개만. + +--- + +## 10. UI 화면 구조 + +### 10.1 프론트 (`/inquiry`) + +| 경로 | 레이아웃 | 핵심 컴포넌트 / 슬롯 | +|---|---|---| +| `/inquiry` | `layouts/inquiry/index.json` | 내 의뢰 카드 리스트 (`InquiryCard` × N), 상태 필터, "새 의뢰" CTA | +| `/inquiry/new` | `layouts/inquiry/new.json` | 작성 폼 (제목·분류·본문·예산범위·희망일·첨부) | +| `/inquiry/{uuid}` | `layouts/inquiry/show.json` | 상단 `InquiryStatusBar`(4단계 stepper) / 좌측 의뢰 요약·`QuoteCard`·`QuotePayButton` / 우측 `InquiryMessageThread` | + +### 10.2 어드민 (`/admin/inquiry`) + +| 경로 | 레이아웃 | 비고 | +|---|---|---| +| `/admin/inquiry` | `admin_inquiry_index.json` | 필터(상태·기간·검색), 데이터그리드, 행 클릭 → 상세 | +| `/admin/inquiry/{uuid}` | `admin_inquiry_detail.json` | 요약 + 상태 전이 액션(견적 발행/철회/완료/취소) + 견적 이력 + 메시지 스레드 | +| `/admin/inquiry/{uuid}/quote/new` | `admin_inquiry_quote_form.json` | 견적 작성(항목 동적 추가·합계 자동) | + +### 10.3 신규 composite 컴포넌트 + +- `InquiryStatusBar` — 4단계 stepper, canceled 시 별도 표기 +- `InquiryMessageThread` — 메시지 리스트 + 입력창 + 첨부 업로더 +- `QuoteCard` — 견적 항목 표 + 합계 + 수락/거부 버튼 + 유효기간 표기 +- `QuotePayButton` — `accept_and_pay` 트리거 → ecommerce 결제 redirect +- `InquiryCard` — 목록 카드(상태 뱃지·최근 메시지 미리보기·안 읽은 수) + +### 10.4 모달 패턴 (공식 방식 준수) + +위험 액션은 모두 `_modal_*` 파셜 + `_global.*Modal` 글로벌 상태 + `apiCall` 시퀀스로 처리: + +- `_modal_inquiry_cancel.json` — 의뢰 취소 +- `_modal_quote_revoke.json` — 견적 철회 +- `_modal_quote_reject.json` — 견적 거절(의뢰자) +- `_modal_inquiry_complete.json` — 완료 처리(운영자) +- `_modal_mark_paid_offline.json` — 외부 결제 수동 확인(운영자) + +컴포넌트 내부에 `window.confirm` + 직접 fetch 금지. + +--- + +## 11. API 라우트 요약 + +### 11.1 공개(인증 필요, 본인 의뢰) + +``` +GET /api/modules/sirsoft-inquiry/inquiries 내 의뢰 목록 +POST /api/modules/sirsoft-inquiry/inquiries 의뢰 생성 +GET /api/modules/sirsoft-inquiry/inquiries/{uuid} 의뢰 상세 +PATCH /api/modules/sirsoft-inquiry/inquiries/{uuid} 의뢰 본문 수정 (received 단계만) +POST /api/modules/sirsoft-inquiry/inquiries/{uuid}/cancel 의뢰 취소 + +GET /api/modules/sirsoft-inquiry/inquiries/{uuid}/messages 메시지 목록 +POST /api/modules/sirsoft-inquiry/inquiries/{uuid}/messages 메시지 작성 + +POST /api/modules/sirsoft-inquiry/inquiries/{uuid}/attachments 의뢰 본문 첨부 업로드 +POST /api/modules/sirsoft-inquiry/inquiries/{uuid}/messages/{id}/attachments 메시지 첨부 +GET /api/modules/sirsoft-inquiry/attachments/{id} 첨부 다운로드(스트림) + +POST /api/modules/sirsoft-inquiry/inquiries/{uuid}/quotes/{quoteId}/accept 견적 수락 → 결제 페이지 URL 반환 +POST /api/modules/sirsoft-inquiry/inquiries/{uuid}/quotes/{quoteId}/reject 견적 거절 +``` + +### 11.2 어드민 + +``` +GET /api/modules/sirsoft-inquiry/admin/inquiries 전체 의뢰 목록(필터) +GET /api/modules/sirsoft-inquiry/admin/inquiries/{uuid} 상세 +POST /api/modules/sirsoft-inquiry/admin/inquiries/{uuid}/quotes 견적 발행 +POST /api/modules/sirsoft-inquiry/admin/inquiries/{uuid}/quotes/{qid}/revoke 견적 철회 +POST /api/modules/sirsoft-inquiry/admin/inquiries/{uuid}/mark-paid-offline 외부 결제 수동 확인 +POST /api/modules/sirsoft-inquiry/admin/inquiries/{uuid}/complete 완료 처리 +POST /api/modules/sirsoft-inquiry/admin/inquiries/{uuid}/cancel 운영자 취소 +``` + +--- + +## 12. 테스트 전략 + +### 12.1 Feature 테스트 (Pest/PHPUnit) + +- `tests/Feature/Modules/Inquiry/StateMachineTest.php` — 합법/불법 전이 + 부수 효과 검증 +- `tests/Feature/Modules/Inquiry/InquiryFlowTest.php` — 의뢰 생성 → 견적 발행 → 수락 → 결제 콜백 → 완료의 전체 골든패스 +- `tests/Feature/Modules/Inquiry/PermissionTest.php` — 본인/타인/운영자별 접근 매트릭스 +- `tests/Feature/Modules/Inquiry/AttachmentTest.php` — mime/사이즈/policy +- `tests/Feature/Modules/Inquiry/PaymentBridgeTest.php` — ecommerce mock으로 OrderPaid 콜백 검증 + +### 12.2 UI 수동 검증 + +- 의뢰 생성 → 목록 노출 확인 +- 운영자 견적 발행 → 의뢰자 알림 도착 + UI 갱신 +- 견적 결제 redirect → 결제 완료 후 in_progress 진입 +- 메시지 첨부 업로드 + 다운로드 + 권한 외 다운로드 차단 + +--- + +## 13. 마이그레이션·배포 고려 + +- 신규 모듈이므로 기존 데이터 없음. fresh 마이그레이션. +- 모듈 enable/disable 토글이 module 시스템에 의해 가능해야 함 (다른 모듈과 동일 패턴). +- `inquiry.manage`, `inquiry.notify` 권한은 모듈 설치 시 시드. +- 운영자 그룹에 기본으로 `inquiry.manage` + `inquiry.notify` 부여(어드민 패널에서 조정). + +--- + +## 14. v2 후속 고려 사항 (구현 안 함, 기록만) + +- 완료 의뢰의 포트폴리오 공개 (`is_showcase` boolean + 공개 인덱스 페이지) +- 부분/분할 결제, 자동 환불 정책 +- SLA 추적, 계약서 PDF 자동 생성 +- 카카오톡·SMS 알림 채널 +- 실시간 푸시 (Reverb/Pusher) +- 다중 작업자(=내부 스태프)에게 의뢰 배정·이관 + +--- + +## 15. 결정 로그 + +| # | 결정 | 사유 | +|---|---|---| +| 1 | 별도 모듈 sirsoft-inquiry | 도메인이 board와 본질적으로 다름 (상태머신·견적·결제 복합) | +| 2 | 표준 4단계 상태머신 | 운영 시나리오에 충분, 6단계는 과함 | +| 3 | 구조화된 견적 + ecommerce 결제 | 금액 다툼 예방, 결제 인프라 재사용 | +| 4 | 채팅형 메시지 스레드(전용 테이블) | 1:1 의뢰 소통에 board 댓글 트리는 과함 | +| 5 | 회원만 의뢰 허용 (v1) | 권한·알림·결제 흐름 간소화, 운영 리스크 감소 | +| 6 | ecommerce 선택적 의존 | 패키지 조합 자유도 확보, 운영자 수동 경로 보장 | +| 7 | 견적 immutable (version 누적) | 회계·감사 추적성 | +| 8 | 알림은 이벤트 부수 효과로만 | 컨트롤러 단순화 + 중복 알림 방지 | +| 9 | UI 위험 액션은 공식 모달 패턴 | dark-mode 작업에서 합의된 표준 따름 | From f43ed6592c9a1858163f5a36139fb9e16d565fbc Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Thu, 21 May 2026 14:43:56 +0800 Subject: [PATCH 02/81] docs: add sirsoft-inquiry backend foundation implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 1/4 of the sirsoft-inquiry rollout — 26 TDD tasks covering module scaffold, migrations, models, enums, repositories, state machine, attachment storage, and policy. API controllers, frontend, quote/payment bridge, and notifications are scheduled for Plans 2-4. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...235\230\353\242\260-backend-foundation.md" | 2910 +++++++++++++++++ 1 file changed, 2910 insertions(+) create mode 100644 "docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-backend-foundation.md" diff --git "a/docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-backend-foundation.md" "b/docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-backend-foundation.md" new file mode 100644 index 00000000..6f9bc0d0 --- /dev/null +++ "b/docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-backend-foundation.md" @@ -0,0 +1,2910 @@ +# 제작의뢰 모듈 — Backend Foundation 구현 계획 (Plan 1/4) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** sirsoft-inquiry 모듈의 백엔드 도메인 기반(마이그레이션·모델·상태머신·정책·저장 서비스)을 만들어, API/UI 없이도 PHP 단위·기능 테스트로 의뢰 라이프사이클을 검증할 수 있는 상태에 도달한다. + +**Architecture:** Laravel 모듈 패턴(`modules/_bundled/sirsoft-inquiry`, PSR-4 + `BaseModuleServiceProvider`)을 board 모듈 패턴 그대로 채택. 도메인 객체 5종(Inquiry/Quote/QuoteItem/Message/Attachment) + 상태머신 1개(`InquiryStateMachine`) + 정책 1개(`InquiryPolicy`) + 저장 서비스 1개(`InquiryAttachmentStorage`). 모든 상태 전이는 단일 진입점을 강제하며, 트랜잭션 안에서 시스템 메시지 삽입 + 이벤트 dispatch(after-commit)까지 수행. + +**Tech Stack:** PHP 8.2+, Laravel 11, Pest/PHPUnit, MySQL/MariaDB, Laravel Storage. + +**Spec:** `docs/superpowers/specs/2026-05-20-제작의뢰-design.md` + +--- + +## File Structure + +``` +modules/_bundled/sirsoft-inquiry/ + composer.json + module.json + config/ + inquiry.php + src/ + Providers/ + InquiryServiceProvider.php + Enums/ + InquiryStatus.php + QuoteStatus.php + SenderRole.php + TransitionEvent.php + Models/ + Inquiry.php + InquiryQuote.php + InquiryQuoteItem.php + InquiryMessage.php + InquiryAttachment.php + Repositories/ + Contracts/ + InquiryRepositoryInterface.php + InquiryQuoteRepositoryInterface.php + InquiryMessageRepositoryInterface.php + InquiryAttachmentRepositoryInterface.php + InquiryRepository.php + InquiryQuoteRepository.php + InquiryMessageRepository.php + InquiryAttachmentRepository.php + Policies/ + InquiryPolicy.php + Services/ + InquiryStateMachine.php + InquiryAttachmentStorage.php + Events/ + InquiryStatusTransitioned.php + InquiryMessagePosted.php + Exceptions/ + InvalidStateTransitionException.php + InquiryNotFoundException.php + QuoteNotFoundException.php + lang/ + ko/system.php + en/system.php + database/migrations/ + 2026_05_21_100001_create_inquiries_table.php + 2026_05_21_100002_create_inquiry_quotes_table.php + 2026_05_21_100003_create_inquiry_quote_items_table.php + 2026_05_21_100004_create_inquiry_messages_table.php + 2026_05_21_100005_create_inquiry_attachments_table.php +tests/Feature/Modules/Inquiry/ + ModuleBootstrapTest.php + ModelRelationshipTest.php + StateMachineTest.php + AttachmentStorageTest.php + PolicyTest.php +``` + +**파일 책임 요약** +- `Enums/*`: 상태·역할·이벤트 식별자. 라벨/색상은 lang 파일과 합쳐서 다국어 처리. +- `Models/*`: Eloquent — 관계와 캐스팅, 쿼리 scope. 비즈니스 로직 없음. +- `Repositories/*`: 쿼리·persistence. 컨트롤러가 직접 Eloquent 안 만지게 격리. +- `Services/InquiryStateMachine`: 상태 전이 단일 진입점. 트랜잭션 + 시스템 메시지 + 이벤트. +- `Services/InquiryAttachmentStorage`: 업로드 정책 검증 + Storage 저장. +- `Policies/InquiryPolicy`: 권한 매트릭스(스펙 §6). + +--- + +## 사전 확인 (작업 전 1회) + +- [ ] **Pre-1: 모듈 마이그레이션 등록 방식 확인** + +board 모듈의 `BaseModuleServiceProvider` 가 `boot()` 안에서 어떻게 마이그레이션과 config·정책을 로드하는지 확인하여 동일 패턴을 따른다. 확인 명령: + +```bash +grep -n "loadMigrations\|mergeConfig\|Gate::policy" \ + app/Extension/BaseModuleServiceProvider.php \ + modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php +``` + +발견된 패턴(특히 `loadMigrationsFrom` 호출 시점, repository binding 배열 키)을 본 plan의 ServiceProvider 코드에 그대로 반영한다. 본 plan은 board 모듈 패턴이 동일하다고 가정하고 작성됨 — 다르면 해당 task에서 보정. + +- [ ] **Pre-2: 테스트 실행 명령 확인** + +```bash +php artisan test --filter="Modules\\\\Inquiry" 2>&1 | head -20 +# 또는 ./vendor/bin/pest tests/Feature/Modules/Inquiry +``` + +성공 또는 "No tests found" 출력이 정상. 환경에 따라 어느 쪽을 쓸지 결정하여 이후 모든 task의 테스트 실행 명령에 적용. + +--- + +## Task 1: 모듈 스캐폴드 (디렉터리 + manifest) + +**Files:** +- Create: `modules/_bundled/sirsoft-inquiry/module.json` +- Create: `modules/_bundled/sirsoft-inquiry/composer.json` +- Create: 위에 명시된 디렉터리 트리(빈 폴더는 `.gitkeep`) + +- [ ] **Step 1: 디렉터리 생성** + +```bash +mkdir -p modules/_bundled/sirsoft-inquiry/{config,src/{Providers,Enums,Models,Repositories/Contracts,Policies,Services,Events,Exceptions,lang/ko,lang/en},database/migrations,tests} +``` + +- [ ] **Step 2: module.json 작성** + +```json +{ + "identifier": "sirsoft-inquiry", + "vendor": "sirsoft", + "name": { + "ko": "제작의뢰", + "en": "Inquiry" + }, + "version": "1.0.0-alpha.1", + "license": "MIT", + "description": { + "ko": "제작의뢰 라이프사이클(접수·견적·진행·완료) 관리 모듈", + "en": "Inquiry lifecycle module (received / quoted / in_progress / completed)" + }, + "g7_version": ">=7.0.0-beta.5", + "dependencies": { + "modules": {}, + "plugins": {} + } +} +``` + +- [ ] **Step 3: composer.json 작성** + +```json +{ + "name": "modules/sirsoft-inquiry", + "description": "Inquiry module for Gnuboard7", + "type": "library", + "version": "1.0.0-alpha.1", + "license": "MIT", + "autoload": { + "psr-4": { + "Modules\\Sirsoft\\Inquiry\\": "src/", + "Modules\\Sirsoft\\Inquiry\\Database\\Seeders\\": "database/seeders/", + "Modules\\Sirsoft\\Inquiry\\Database\\Factories\\": "database/factories/" + } + }, + "require": { + "php": "^8.2" + } +} +``` + +- [ ] **Step 4: 루트 composer autoload 갱신** + +```bash +composer dump-autoload 2>&1 | tail -5 +``` + +기대: "Generated autoload files" 메시지. 에러 없음. + +- [ ] **Step 5: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/module.json modules/_bundled/sirsoft-inquiry/composer.json modules/_bundled/sirsoft-inquiry/ +git commit -m "feat(inquiry): scaffold module skeleton" +``` + +--- + +## Task 2: ServiceProvider 골격 + Bootstrap 테스트 + +**Files:** +- Create: `modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php` +- Create: `tests/Feature/Modules/Inquiry/ModuleBootstrapTest.php` + +- [ ] **Step 1: 실패 테스트 작성** + +`tests/Feature/Modules/Inquiry/ModuleBootstrapTest.php`: + +```php +app->make(InquiryServiceProvider::class, ['app' => $this->app]); + $this->assertInstanceOf(InquiryServiceProvider::class, $provider); + } + + public function test_module_identifier_matches_manifest(): void + { + $provider = $this->app->make(InquiryServiceProvider::class, ['app' => $this->app]); + $reflection = new \ReflectionClass($provider); + $prop = $reflection->getProperty('moduleIdentifier'); + $prop->setAccessible(true); + $this->assertSame('sirsoft-inquiry', $prop->getValue($provider)); + } +} +``` + +- [ ] **Step 2: 테스트 실행 (실패 확인)** + +```bash +php artisan test --filter=ModuleBootstrapTest 2>&1 | tail -10 +``` + +기대: "Class Modules\Sirsoft\Inquiry\Providers\InquiryServiceProvider not found" + +- [ ] **Step 3: ServiceProvider 작성** + +```php +loadMigrationsFrom(__DIR__ . '/../../database/migrations'); + $this->mergeConfigFrom(__DIR__ . '/../../config/inquiry.php', 'inquiry'); + $this->loadTranslationsFrom(__DIR__ . '/../lang', 'inquiry'); + } +} +``` + +- [ ] **Step 4: 모듈 시스템에 ServiceProvider 등록** + +board 모듈이 `module.json` 또는 별도 manifest로 ServiceProvider를 노출하는 방식을 확인하여 동일하게 등록. 일반적으로 `module.json`의 `service_provider` 필드 또는 `config/app.php` 의 `providers` 배열. **확인 명령**: + +```bash +grep -rn "BoardServiceProvider" config/ modules/_bundled/sirsoft-board/module.json 2>/dev/null +``` + +발견된 위치에 `Modules\Sirsoft\Inquiry\Providers\InquiryServiceProvider` 추가. + +- [ ] **Step 5: 테스트 실행 (성공 확인)** + +```bash +php artisan test --filter=ModuleBootstrapTest 2>&1 | tail -10 +``` + +기대: "OK (2 tests, 2 assertions)" 또는 동등. + +- [ ] **Step 6: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php \ + tests/Feature/Modules/Inquiry/ModuleBootstrapTest.php \ + config/app.php # 또는 ServiceProvider 등록 위치 +git commit -m "feat(inquiry): add ServiceProvider skeleton + bootstrap test" +``` + +--- + +## Task 3: config/inquiry.php + +**Files:** +- Create: `modules/_bundled/sirsoft-inquiry/config/inquiry.php` + +- [ ] **Step 1: config 파일 작성** + +```php + [ + 'disk' => env('INQUIRY_ATTACHMENT_DISK', 'local'), + 'max_size_inquiry' => env('INQUIRY_ATTACHMENT_MAX_INQUIRY', 50 * 1024 * 1024), // 50MB + 'max_size_message' => env('INQUIRY_ATTACHMENT_MAX_MESSAGE', 20 * 1024 * 1024), // 20MB + 'allowed_mimes' => [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'application/pdf', + 'application/zip', + 'application/x-zip-compressed', + ], + 'orphan_cleanup_after_minutes' => 30, + ], + + 'categories' => [ + 'web', + 'design', + 'maintenance', + 'consulting', + 'etc', + ], + + 'quote' => [ + 'currency' => 'KRW', + 'default_valid_days' => 14, + ], + + 'permissions' => [ + 'manage' => 'inquiry.manage', + 'notify' => 'inquiry.notify', + ], +]; +``` + +- [ ] **Step 2: config 로드 검증 테스트 추가** + +`ModuleBootstrapTest::test_config_is_merged()`: + +```php +public function test_config_is_merged(): void +{ + $this->assertSame('KRW', config('inquiry.quote.currency')); + $this->assertContains('image/jpeg', config('inquiry.attachment.allowed_mimes')); +} +``` + +- [ ] **Step 3: 테스트 실행** + +```bash +php artisan test --filter=ModuleBootstrapTest 2>&1 | tail -10 +``` + +기대: 모든 테스트 PASS. + +- [ ] **Step 4: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/config/inquiry.php tests/Feature/Modules/Inquiry/ModuleBootstrapTest.php +git commit -m "feat(inquiry): add module config (attachment/quote/permissions)" +``` + +--- + +## Task 4: Enums 4종 + +**Files:** +- Create: `src/Enums/InquiryStatus.php` +- Create: `src/Enums/QuoteStatus.php` +- Create: `src/Enums/SenderRole.php` +- Create: `src/Enums/TransitionEvent.php` + +- [ ] **Step 1: 실패 테스트 작성** + +`tests/Feature/Modules/Inquiry/EnumsTest.php`: + +```php +assertSame('received', InquiryStatus::Received->value); + $this->assertSame('quoted', InquiryStatus::Quoted->value); + $this->assertSame('in_progress', InquiryStatus::InProgress->value); + $this->assertSame('completed', InquiryStatus::Completed->value); + $this->assertSame('canceled', InquiryStatus::Canceled->value); + $this->assertCount(5, InquiryStatus::cases()); + } + + public function test_quote_status_values(): void + { + $this->assertSame('draft', QuoteStatus::Draft->value); + $this->assertSame('issued', QuoteStatus::Issued->value); + $this->assertSame('accepted', QuoteStatus::Accepted->value); + $this->assertSame('rejected', QuoteStatus::Rejected->value); + $this->assertSame('expired', QuoteStatus::Expired->value); + } + + public function test_sender_role_values(): void + { + $this->assertSame('client', SenderRole::Client->value); + $this->assertSame('operator', SenderRole::Operator->value); + $this->assertSame('system', SenderRole::System->value); + } + + public function test_transition_event_values(): void + { + $events = array_map(fn ($e) => $e->value, TransitionEvent::cases()); + $this->assertEqualsCanonicalizing([ + 'issue_quote', + 'revoke_quote', + 'reject_quote', + 'accept_and_pay', + 'mark_paid_offline', + 'mark_completed', + 'cancel', + ], $events); + } +} +``` + +- [ ] **Step 2: 테스트 실행 (실패 확인)** + +```bash +php artisan test --filter=EnumsTest 2>&1 | tail -10 +``` + +기대: "Class ... not found" 4건. + +- [ ] **Step 3: Enums 작성** + +`src/Enums/InquiryStatus.php`: + +```php +value); + } + + public function isTerminal(): bool + { + return in_array($this, [self::Completed, self::Canceled], true); + } +} +``` + +`src/Enums/QuoteStatus.php`: + +```php +&1 | tail -10 +``` + +기대: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Enums/ tests/Feature/Modules/Inquiry/EnumsTest.php +git commit -m "feat(inquiry): add Status/Role/Event enums" +``` + +--- + +## Task 5: Migration — inquiries + +**Files:** +- Create: `database/migrations/2026_05_21_100001_create_inquiries_table.php` + +- [ ] **Step 1: 마이그레이션 작성** + +```php +bigIncrements('id'); + $table->uuid('uuid')->unique(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('title', 200); + $table->text('content'); + $table->string('category', 50)->nullable(); + $table->string('budget_range', 100)->nullable(); + $table->date('desired_due_at')->nullable(); + $table->string('status', 20)->default('received')->index(); + $table->unsignedBigInteger('accepted_quote_id')->nullable(); + $table->string('payment_id', 64)->nullable(); + $table->json('extra_data')->nullable(); + $table->timestamp('received_at')->useCurrent(); + $table->timestamp('quoted_at')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('canceled_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'status']); + $table->index(['status', 'received_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('inquiries'); + } +}; +``` + +- [ ] **Step 2: 마이그레이션 실행 검증** + +```bash +php artisan migrate --path=modules/_bundled/sirsoft-inquiry/database/migrations 2>&1 | tail -5 +php artisan tinker --execute="dump(\Schema::hasTable('inquiries'));" +``` + +기대: `true` 출력. + +- [ ] **Step 3: rollback도 동작하는지 확인** + +```bash +php artisan migrate:rollback --path=modules/_bundled/sirsoft-inquiry/database/migrations --step=1 2>&1 | tail -5 +php artisan tinker --execute="dump(\Schema::hasTable('inquiries'));" +php artisan migrate --path=modules/_bundled/sirsoft-inquiry/database/migrations +``` + +기대: rollback 후 `false`, 재실행 후 정상. + +- [ ] **Step 4: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100001_create_inquiries_table.php +git commit -m "feat(inquiry): add inquiries migration" +``` + +--- + +## Task 6: Migration — inquiry_quotes + +**Files:** +- Create: `database/migrations/2026_05_21_100002_create_inquiry_quotes_table.php` + +- [ ] **Step 1: 마이그레이션 작성** + +```php +bigIncrements('id'); + $table->foreignId('inquiry_id')->constrained('inquiries')->cascadeOnDelete(); + $table->unsignedInteger('version'); + $table->decimal('total_amount', 12, 0); + $table->decimal('tax_amount', 12, 0)->default(0); + $table->string('currency', 3)->default('KRW'); + $table->date('valid_until')->nullable(); + $table->text('note')->nullable(); + $table->string('status', 20)->default('draft')->index(); + $table->timestamp('issued_at')->nullable(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamp('rejected_at')->nullable(); + $table->timestamps(); + + $table->unique(['inquiry_id', 'version']); + }); + } + + public function down(): void + { + Schema::dropIfExists('inquiry_quotes'); + } +}; +``` + +- [ ] **Step 2: 마이그레이션 실행 + 테이블 존재 확인** + +```bash +php artisan migrate --path=modules/_bundled/sirsoft-inquiry/database/migrations 2>&1 | tail -5 +php artisan tinker --execute="dump(\Schema::hasTable('inquiry_quotes'));" +``` + +기대: `true`. + +- [ ] **Step 3: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100002_create_inquiry_quotes_table.php +git commit -m "feat(inquiry): add inquiry_quotes migration" +``` + +--- + +## Task 7: Migration — inquiry_quote_items + +**Files:** +- Create: `database/migrations/2026_05_21_100003_create_inquiry_quote_items_table.php` + +- [ ] **Step 1: 마이그레이션 작성** + +```php +bigIncrements('id'); + $table->foreignId('quote_id')->constrained('inquiry_quotes')->cascadeOnDelete(); + $table->unsignedInteger('position')->default(0); + $table->string('name', 200); + $table->text('description')->nullable(); + $table->decimal('qty', 10, 2); + $table->decimal('unit_price', 12, 0); + $table->decimal('amount', 12, 0); + $table->timestamps(); + + $table->index(['quote_id', 'position']); + }); + } + + public function down(): void + { + Schema::dropIfExists('inquiry_quote_items'); + } +}; +``` + +- [ ] **Step 2: 마이그레이션 실행** + +```bash +php artisan migrate --path=modules/_bundled/sirsoft-inquiry/database/migrations 2>&1 | tail -5 +php artisan tinker --execute="dump(\Schema::hasTable('inquiry_quote_items'));" +``` + +- [ ] **Step 3: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100003_create_inquiry_quote_items_table.php +git commit -m "feat(inquiry): add inquiry_quote_items migration" +``` + +--- + +## Task 8: Migration — inquiry_messages + +**Files:** +- Create: `database/migrations/2026_05_21_100004_create_inquiry_messages_table.php` + +- [ ] **Step 1: 마이그레이션 작성** + +```php +bigIncrements('id'); + $table->foreignId('inquiry_id')->constrained('inquiries')->cascadeOnDelete(); + $table->foreignId('sender_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('sender_role', 20); + $table->text('body')->nullable(); + $table->json('meta')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + + $table->index(['inquiry_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('inquiry_messages'); + } +}; +``` + +- [ ] **Step 2: 마이그레이션 실행 + 검증** + +```bash +php artisan migrate --path=modules/_bundled/sirsoft-inquiry/database/migrations 2>&1 | tail -5 +php artisan tinker --execute="dump(\Schema::hasTable('inquiry_messages'));" +``` + +- [ ] **Step 3: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100004_create_inquiry_messages_table.php +git commit -m "feat(inquiry): add inquiry_messages migration (with meta column for system msgs)" +``` + +--- + +## Task 9: Migration — inquiry_attachments + +**Files:** +- Create: `database/migrations/2026_05_21_100005_create_inquiry_attachments_table.php` + +- [ ] **Step 1: 마이그레이션 작성** + +```php +bigIncrements('id'); + $table->foreignId('inquiry_id')->constrained('inquiries')->cascadeOnDelete(); + $table->foreignId('message_id')->nullable()->constrained('inquiry_messages')->cascadeOnDelete(); + $table->foreignId('uploader_user_id')->constrained('users')->cascadeOnDelete(); + $table->string('disk', 20); + $table->string('path'); + $table->string('original_name', 255); + $table->string('mime', 100); + $table->unsignedBigInteger('size'); + $table->timestamps(); + + $table->index(['inquiry_id', 'message_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('inquiry_attachments'); + } +}; +``` + +- [ ] **Step 2: 마이그레이션 실행 + 검증** + +```bash +php artisan migrate --path=modules/_bundled/sirsoft-inquiry/database/migrations 2>&1 | tail -5 +php artisan tinker --execute="dump(\Schema::hasTable('inquiry_attachments'));" +``` + +- [ ] **Step 3: 전체 마이그레이션 rollback + redo 검증** + +```bash +php artisan migrate:rollback --path=modules/_bundled/sirsoft-inquiry/database/migrations --step=5 +php artisan migrate --path=modules/_bundled/sirsoft-inquiry/database/migrations +php artisan tinker --execute="dump(['inquiries'=>\Schema::hasTable('inquiries'),'inquiry_quotes'=>\Schema::hasTable('inquiry_quotes'),'inquiry_quote_items'=>\Schema::hasTable('inquiry_quote_items'),'inquiry_messages'=>\Schema::hasTable('inquiry_messages'),'inquiry_attachments'=>\Schema::hasTable('inquiry_attachments')]);" +``` + +기대: 5개 모두 `true`. + +- [ ] **Step 4: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100005_create_inquiry_attachments_table.php +git commit -m "feat(inquiry): add inquiry_attachments migration" +``` + +--- + +## Task 10: Model — Inquiry + +**Files:** +- Create: `src/Models/Inquiry.php` + +- [ ] **Step 1: 실패 테스트 작성** + +`tests/Feature/Modules/Inquiry/ModelRelationshipTest.php`: + +```php +create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => '홈페이지 리뉴얼 의뢰', + 'content' => '기존 사이트 개편 부탁드립니다.', + 'status' => InquiryStatus::Received->value, + ]); + + $this->assertInstanceOf(User::class, $inquiry->user); + $this->assertSame($user->id, $inquiry->user->id); + $this->assertInstanceOf(InquiryStatus::class, $inquiry->status); + $this->assertSame(InquiryStatus::Received, $inquiry->status); + } +} +``` + +- [ ] **Step 2: 테스트 실행 (실패)** + +```bash +php artisan test --filter=test_inquiry_belongs_to_user_and_casts_status 2>&1 | tail -10 +``` + +기대: "Class Modules\Sirsoft\Inquiry\Models\Inquiry not found". + +- [ ] **Step 3: Inquiry Model 작성** + +```php + InquiryStatus::class, + 'extra_data' => 'array', + 'desired_due_at' => 'date', + 'received_at' => 'datetime', + 'quoted_at' => 'datetime', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'canceled_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function quotes(): HasMany + { + return $this->hasMany(InquiryQuote::class)->orderBy('version'); + } + + public function acceptedQuote(): BelongsTo + { + return $this->belongsTo(InquiryQuote::class, 'accepted_quote_id'); + } + + public function messages(): HasMany + { + return $this->hasMany(InquiryMessage::class)->orderBy('created_at'); + } + + public function attachments(): HasMany + { + return $this->hasMany(InquiryAttachment::class); + } +} +``` + +- [ ] **Step 4: 테스트 실행 (성공)** + +```bash +php artisan test --filter=test_inquiry_belongs_to_user_and_casts_status 2>&1 | tail -10 +``` + +기대: PASS. (의존 모델은 빈 클래스로 임시 만들거나 다음 task에서 채워질 때까지 관계 호출은 테스트하지 않음.) + +- [ ] **Step 5: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Models/Inquiry.php tests/Feature/Modules/Inquiry/ModelRelationshipTest.php +git commit -m "feat(inquiry): add Inquiry model with relationships and casts" +``` + +--- + +## Task 11: Models — InquiryQuote + InquiryQuoteItem + +**Files:** +- Create: `src/Models/InquiryQuote.php` +- Create: `src/Models/InquiryQuoteItem.php` + +- [ ] **Step 1: 실패 테스트 추가** + +`ModelRelationshipTest::test_quote_has_items_and_inquiry()`: + +```php +public function test_quote_has_items_and_inquiry(): void +{ + $user = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', + 'status' => 'received', + ]); + $quote = $inquiry->quotes()->create([ + 'version' => 1, + 'total_amount' => 1000000, + 'status' => 'issued', + ]); + $quote->items()->create([ + 'position' => 1, + 'name' => '메인 페이지 디자인', + 'qty' => 1, + 'unit_price' => 1000000, + 'amount' => 1000000, + ]); + + $this->assertSame(1, $quote->items()->count()); + $this->assertSame($inquiry->id, $quote->inquiry->id); +} +``` + +- [ ] **Step 2: 테스트 실패 확인** + +```bash +php artisan test --filter=test_quote_has_items_and_inquiry 2>&1 | tail -10 +``` + +- [ ] **Step 3: 모델 작성** + +`src/Models/InquiryQuote.php`: + +```php + QuoteStatus::class, + 'valid_until' => 'date', + 'issued_at' => 'datetime', + 'accepted_at' => 'datetime', + 'rejected_at' => 'datetime', + 'total_amount' => 'decimal:0', + 'tax_amount' => 'decimal:0', + ]; + + public function inquiry(): BelongsTo + { + return $this->belongsTo(Inquiry::class); + } + + public function items(): HasMany + { + return $this->hasMany(InquiryQuoteItem::class, 'quote_id')->orderBy('position'); + } +} +``` + +`src/Models/InquiryQuoteItem.php`: + +```php + 'decimal:2', + 'unit_price' => 'decimal:0', + 'amount' => 'decimal:0', + ]; + + public function quote(): BelongsTo + { + return $this->belongsTo(InquiryQuote::class, 'quote_id'); + } +} +``` + +- [ ] **Step 4: 테스트 실행 (성공)** + +```bash +php artisan test --filter=test_quote_has_items_and_inquiry 2>&1 | tail -10 +``` + +- [ ] **Step 5: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Models/InquiryQuote.php modules/_bundled/sirsoft-inquiry/src/Models/InquiryQuoteItem.php tests/Feature/Modules/Inquiry/ModelRelationshipTest.php +git commit -m "feat(inquiry): add InquiryQuote + InquiryQuoteItem models" +``` + +--- + +## Task 12: Models — InquiryMessage + InquiryAttachment + +**Files:** +- Create: `src/Models/InquiryMessage.php` +- Create: `src/Models/InquiryAttachment.php` + +- [ ] **Step 1: 실패 테스트 추가** + +`ModelRelationshipTest::test_message_and_attachment()`: + +```php +public function test_message_and_attachment(): void +{ + $user = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', + 'status' => 'received', + ]); + $msg = $inquiry->messages()->create([ + 'sender_user_id' => $user->id, + 'sender_role' => 'client', + 'body' => '안녕하세요', + ]); + $att = $inquiry->attachments()->create([ + 'message_id' => $msg->id, + 'uploader_user_id' => $user->id, + 'disk' => 'local', + 'path' => 'inquiries/test.pdf', + 'original_name' => 'test.pdf', + 'mime' => 'application/pdf', + 'size' => 1234, + ]); + + $this->assertSame('client', $msg->sender_role->value); + $this->assertSame($msg->id, $att->message->id); + $this->assertSame(1, $msg->attachments()->count()); +} +``` + +- [ ] **Step 2: 실패 확인** + +```bash +php artisan test --filter=test_message_and_attachment 2>&1 | tail -10 +``` + +- [ ] **Step 3: 모델 작성** + +`src/Models/InquiryMessage.php`: + +```php + SenderRole::class, + 'meta' => 'array', + 'read_at' => 'datetime', + ]; + + public function inquiry(): BelongsTo + { + return $this->belongsTo(Inquiry::class); + } + + public function sender(): BelongsTo + { + return $this->belongsTo(User::class, 'sender_user_id'); + } + + public function attachments(): HasMany + { + return $this->hasMany(InquiryAttachment::class, 'message_id'); + } + + public function isSystem(): bool + { + return $this->sender_role === SenderRole::System; + } +} +``` + +`src/Models/InquiryAttachment.php`: + +```php + 'integer', + ]; + + public function inquiry(): BelongsTo + { + return $this->belongsTo(Inquiry::class); + } + + public function message(): BelongsTo + { + return $this->belongsTo(InquiryMessage::class, 'message_id'); + } + + public function uploader(): BelongsTo + { + return $this->belongsTo(User::class, 'uploader_user_id'); + } +} +``` + +- [ ] **Step 4: 테스트 실행 (성공)** + +```bash +php artisan test --filter=test_message_and_attachment 2>&1 | tail -10 +``` + +- [ ] **Step 5: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Models/InquiryMessage.php modules/_bundled/sirsoft-inquiry/src/Models/InquiryAttachment.php tests/Feature/Modules/Inquiry/ModelRelationshipTest.php +git commit -m "feat(inquiry): add InquiryMessage + InquiryAttachment models" +``` + +--- + +## Task 13: Exceptions + +**Files:** +- Create: `src/Exceptions/InvalidStateTransitionException.php` +- Create: `src/Exceptions/InquiryNotFoundException.php` +- Create: `src/Exceptions/QuoteNotFoundException.php` + +- [ ] **Step 1: 작성** + +`src/Exceptions/InvalidStateTransitionException.php`: + +```php +value}' to inquiry in status '{$from->value}'.", + 422 + ); + } +} +``` + +`src/Exceptions/InquiryNotFoundException.php`: + +```php +assertStringContainsString("received", $ex->getMessage()); + $this->assertStringContainsString("accept_and_pay", $ex->getMessage()); + $this->assertSame(422, $ex->getCode()); +} +``` + +```bash +php artisan test --filter=test_invalid_transition_exception_carries_info 2>&1 | tail -10 +``` + +기대: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Exceptions/ tests/Feature/Modules/Inquiry/EnumsTest.php +git commit -m "feat(inquiry): add domain exceptions" +``` + +--- + +## Task 14: Events + +**Files:** +- Create: `src/Events/InquiryStatusTransitioned.php` +- Create: `src/Events/InquiryMessagePosted.php` + +- [ ] **Step 1: 작성** + +`src/Events/InquiryStatusTransitioned.php`: + +```php + [ + 'received' => '접수', + 'quoted' => '견적', + 'in_progress' => '진행', + 'completed' => '완료', + 'canceled' => '취소', + ], + 'message' => [ + 'quote_issued' => '운영자가 견적을 발행했습니다 (회차 #:version, 합계 :total원).', + 'quote_revoked' => '운영자가 견적을 철회했습니다 (회차 #:version).', + 'quote_rejected' => '의뢰자가 견적을 거절했습니다 (회차 #:version).', + 'payment_confirmed' => '결제가 확인되었습니다.', + 'payment_confirmed_offline' => '운영자가 결제를 수동 확인했습니다.', + 'completed' => '의뢰가 완료되었습니다.', + 'canceled_by_client' => '의뢰자가 의뢰를 취소했습니다.', + 'canceled_by_operator' => '운영자가 의뢰를 취소했습니다.', + ], +]; +``` + +- [ ] **Step 2: en/system.php 작성** + +```php + [ + 'received' => 'Received', + 'quoted' => 'Quoted', + 'in_progress' => 'In Progress', + 'completed' => 'Completed', + 'canceled' => 'Canceled', + ], + 'message' => [ + 'quote_issued' => 'Operator issued a quote (version #:version, total :total KRW).', + 'quote_revoked' => 'Operator revoked the quote (version #:version).', + 'quote_rejected' => 'Client rejected the quote (version #:version).', + 'payment_confirmed' => 'Payment has been confirmed.', + 'payment_confirmed_offline' => 'Operator manually confirmed the payment.', + 'completed' => 'Inquiry has been completed.', + 'canceled_by_client' => 'Client canceled the inquiry.', + 'canceled_by_operator' => 'Operator canceled the inquiry.', + ], +]; +``` + +- [ ] **Step 3: 로드 검증** + +```bash +php artisan tinker --execute="dump(__('inquiry::system.status.received', [], 'ko'));" +``` + +기대: `"접수"`. + +- [ ] **Step 4: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/lang/ +git commit -m "feat(inquiry): add system message lang files (ko/en)" +``` + +--- + +## Task 16: Repository — Inquiry + +**Files:** +- Create: `src/Repositories/Contracts/InquiryRepositoryInterface.php` +- Create: `src/Repositories/InquiryRepository.php` + +- [ ] **Step 1: 인터페이스 작성** + +```php +create(); + $inquiry = $repo->create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => '리뉴얼', + 'content' => '본문', + 'status' => 'received', + ]); + $found = $repo->findByUuidOrFail($inquiry->uuid); + $this->assertTrue($found->is($inquiry)); +} +``` + +```bash +php artisan test --filter=test_repository_find_and_create 2>&1 | tail -10 +``` + +기대: 인터페이스 미바인딩 실패. + +- [ ] **Step 3: 구현체 작성** + +```php +firstOr(fn () => throw new InquiryNotFoundException($uuid)); + } + + public function create(array $data): Inquiry + { + return Inquiry::create($data); + } + + public function update(Inquiry $inquiry, array $data): Inquiry + { + $inquiry->fill($data)->save(); + return $inquiry; + } + + public function listByUser(int $userId, ?string $status = null, int $perPage = 20): LengthAwarePaginator + { + return Inquiry::query() + ->where('user_id', $userId) + ->when($status, fn ($q) => $q->where('status', $status)) + ->orderByDesc('received_at') + ->paginate($perPage); + } + + public function listForAdmin(?string $status = null, ?string $search = null, int $perPage = 20): LengthAwarePaginator + { + return Inquiry::query() + ->when($status, fn ($q) => $q->where('status', $status)) + ->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%")) + ->orderByDesc('received_at') + ->paginate($perPage); + } +} +``` + +- [ ] **Step 4: ServiceProvider $repositories 배열에 추가** + +`InquiryServiceProvider::$repositories`: + +```php +protected array $repositories = [ + \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryRepositoryInterface::class + => \Modules\Sirsoft\Inquiry\Repositories\InquiryRepository::class, +]; +``` + +- [ ] **Step 5: 테스트 실행 (성공)** + +```bash +php artisan test --filter=test_repository_find_and_create 2>&1 | tail -10 +``` + +- [ ] **Step 6: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Repositories/InquiryRepository.php modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryRepositoryInterface.php modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php tests/Feature/Modules/Inquiry/ModelRelationshipTest.php +git commit -m "feat(inquiry): add InquiryRepository" +``` + +--- + +## Task 17: Repository — InquiryQuote + +**Files:** +- Create: `src/Repositories/Contracts/InquiryQuoteRepositoryInterface.php` +- Create: `src/Repositories/InquiryQuoteRepository.php` + +- [ ] **Step 1: 인터페이스 작성** + +```php +create(); + $inquiry = $inquiryRepo->create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + + $q1 = $repo->issue($inquiry, ['total_amount' => 1000000, 'tax_amount' => 0], [ + ['name' => 'A', 'qty' => 1, 'unit_price' => 1000000, 'amount' => 1000000], + ]); + $this->assertSame(1, $q1->version); + $this->assertSame('issued', $q1->status->value); + $this->assertSame(1, $q1->items()->count()); + + $expired = $repo->expireActiveQuotes($inquiry); + $this->assertSame(1, $expired); + $this->assertSame('expired', $q1->fresh()->status->value); + + $q2 = $repo->issue($inquiry, ['total_amount' => 1200000], [ + ['name' => 'A', 'qty' => 1, 'unit_price' => 1200000, 'amount' => 1200000], + ]); + $this->assertSame(2, $q2->version); +} +``` + +```bash +php artisan test --filter=test_quote_repository_issue_creates_versioned_quote 2>&1 | tail -10 +``` + +- [ ] **Step 3: 구현체 작성** + +```php +expireActiveQuotes($inquiry); + + $nextVersion = ($inquiry->quotes()->max('version') ?? 0) + 1; + $quote = $inquiry->quotes()->create(array_merge($payload, [ + 'version' => $nextVersion, + 'status' => QuoteStatus::Issued->value, + 'issued_at' => now(), + 'currency' => $payload['currency'] ?? config('inquiry.quote.currency', 'KRW'), + ])); + + foreach ($items as $i => $item) { + $quote->items()->create(array_merge($item, [ + 'position' => $item['position'] ?? $i + 1, + ])); + } + + return $quote; + }); + } + + public function expireActiveQuotes(Inquiry $inquiry): int + { + return $inquiry->quotes() + ->where('status', QuoteStatus::Issued->value) + ->update(['status' => QuoteStatus::Expired->value]); + } + + public function markAccepted(InquiryQuote $quote): void + { + $quote->update([ + 'status' => QuoteStatus::Accepted->value, + 'accepted_at' => now(), + ]); + } + + public function markRejected(InquiryQuote $quote): void + { + $quote->update([ + 'status' => QuoteStatus::Rejected->value, + 'rejected_at' => now(), + ]); + } + + public function findActiveForInquiry(Inquiry $inquiry): ?InquiryQuote + { + return $inquiry->quotes() + ->where('status', QuoteStatus::Issued->value) + ->latest('version') + ->first(); + } + + public function findOrFail(int $id): InquiryQuote + { + return InquiryQuote::find($id) ?? throw new QuoteNotFoundException($id); + } +} +``` + +- [ ] **Step 4: ServiceProvider $repositories 배열 갱신** + +```php +\Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryQuoteRepositoryInterface::class + => \Modules\Sirsoft\Inquiry\Repositories\InquiryQuoteRepository::class, +``` + +- [ ] **Step 5: 테스트 실행** + +```bash +php artisan test --filter=test_quote_repository_issue_creates_versioned_quote 2>&1 | tail -10 +``` + +- [ ] **Step 6: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Repositories/InquiryQuoteRepository.php modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryQuoteRepositoryInterface.php modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php tests/Feature/Modules/Inquiry/ModelRelationshipTest.php +git commit -m "feat(inquiry): add InquiryQuoteRepository (issue/expire/accept)" +``` + +--- + +## Task 18: Repository — InquiryMessage + +**Files:** +- Create: `src/Repositories/Contracts/InquiryMessageRepositoryInterface.php` +- Create: `src/Repositories/InquiryMessageRepository.php` + +- [ ] **Step 1: 인터페이스 작성** + +```php +create(); + $inquiry = $inquiryRepo->create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + + $msg = $repo->append($inquiry, $user->id, \Modules\Sirsoft\Inquiry\Enums\SenderRole::Client, '안녕하세요'); + $this->assertSame('client', $msg->sender_role->value); + + $sys = $repo->appendSystem($inquiry, 'inquiry::system.message.quote_issued', ['version' => 1, 'total' => '1,000,000']); + $this->assertSame('system', $sys->sender_role->value); + $this->assertNull($sys->body); + $this->assertSame('inquiry::system.message.quote_issued', $sys->meta['key']); + $this->assertSame(1, $sys->meta['params']['version']); +} +``` + +```bash +php artisan test --filter=test_message_repository_append_and_system 2>&1 | tail -10 +``` + +- [ ] **Step 3: 구현체 작성** + +```php +messages()->create([ + 'sender_user_id' => $senderUserId, + 'sender_role' => $role->value, + 'body' => $body, + ]); + } + + public function appendSystem(Inquiry $inquiry, string $key, array $params = []): InquiryMessage + { + return $inquiry->messages()->create([ + 'sender_user_id' => null, + 'sender_role' => SenderRole::System->value, + 'body' => null, + 'meta' => ['key' => $key, 'params' => $params], + ]); + } + + public function listForInquiry(Inquiry $inquiry, int $perPage = 50): LengthAwarePaginator + { + return $inquiry->messages()->orderBy('created_at')->paginate($perPage); + } + + public function markReadFor(Inquiry $inquiry, SenderRole $oppositeRole): int + { + return $inquiry->messages() + ->where('sender_role', $oppositeRole->value) + ->whereNull('read_at') + ->update(['read_at' => now()]); + } +} +``` + +- [ ] **Step 4: ServiceProvider 갱신** + +`InquiryMessageRepositoryInterface => InquiryMessageRepository`. + +- [ ] **Step 5: 테스트 + Commit** + +```bash +php artisan test --filter=test_message_repository_append_and_system 2>&1 | tail -10 +git add modules/_bundled/sirsoft-inquiry/src/Repositories/InquiryMessageRepository.php modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryMessageRepositoryInterface.php modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php tests/Feature/Modules/Inquiry/ModelRelationshipTest.php +git commit -m "feat(inquiry): add InquiryMessageRepository (with appendSystem)" +``` + +--- + +## Task 19: Repository — InquiryAttachment + +**Files:** +- Create: `src/Repositories/Contracts/InquiryAttachmentRepositoryInterface.php` +- Create: `src/Repositories/InquiryAttachmentRepository.php` + +- [ ] **Step 1: 인터페이스 + 구현 작성 (단순 CRUD)** + +`Contracts/InquiryAttachmentRepositoryInterface.php`: + +```php +update(['message_id' => $message->id]); + } + + public function findOrFail(int $id): InquiryAttachment + { + return InquiryAttachment::findOrFail($id); + } + + public function listOrphansOlderThanMinutes(int $minutes): Collection + { + return InquiryAttachment::query() + ->whereNull('message_id') + ->where('created_at', '<', now()->subMinutes($minutes)) + ->get(); + } + + public function delete(InquiryAttachment $attachment): void + { + $attachment->delete(); + } +} +``` + +- [ ] **Step 2: ServiceProvider 갱신 + 간단 검증** + +```bash +php artisan tinker --execute="dump(get_class(app(\Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryAttachmentRepositoryInterface::class)));" +``` + +기대: `"Modules\Sirsoft\Inquiry\Repositories\InquiryAttachmentRepository"`. + +- [ ] **Step 3: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Repositories/InquiryAttachmentRepository.php modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryAttachmentRepositoryInterface.php modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php +git commit -m "feat(inquiry): add InquiryAttachmentRepository" +``` + +--- + +## Task 20: StateMachine — happy path (`issue_quote`) + +**Files:** +- Create: `src/Services/InquiryStateMachine.php` +- Create: `tests/Feature/Modules/Inquiry/StateMachineTest.php` + +- [ ] **Step 1: 실패 테스트 작성** + +```php +create(); + return Inquiry::create(array_merge([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', + 'status' => 'received', + ], $overrides)); + } + + public function test_issue_quote_transitions_received_to_quoted_and_emits_system_message(): void + { + Event::fake([InquiryStatusTransitioned::class]); + $sm = app(InquiryStateMachine::class); + $inquiry = $this->makeInquiry(); + + $sm->transition($inquiry, TransitionEvent::IssueQuote, actorUserId: 1, payload: ['quote_version' => 1, 'quote_total' => 1000000]); + + $inquiry->refresh(); + $this->assertSame(InquiryStatus::Quoted, $inquiry->status); + $this->assertNotNull($inquiry->quoted_at); + + $sys = $inquiry->messages()->where('sender_role', 'system')->first(); + $this->assertNotNull($sys); + $this->assertSame('inquiry::system.message.quote_issued', $sys->meta['key']); + + Event::assertDispatched(InquiryStatusTransitioned::class, fn ($e) => + $e->from === InquiryStatus::Received && $e->to === InquiryStatus::Quoted + ); + } +} +``` + +```bash +php artisan test --filter=test_issue_quote_transitions_received_to_quoted_and_emits_system_message 2>&1 | tail -10 +``` + +기대: 실패 (Service 미존재). + +- [ ] **Step 2: StateMachine 작성 (issue_quote 만)** + +```php +, to: InquiryStatus, systemKey: string, timestampColumn: ?string}> */ + private array $rules; + + public function __construct( + private readonly InquiryMessageRepositoryInterface $messages, + ) { + $this->rules = [ + TransitionEvent::IssueQuote->value => [ + 'from' => [InquiryStatus::Received], + 'to' => InquiryStatus::Quoted, + 'systemKey' => 'inquiry::system.message.quote_issued', + 'timestampColumn' => 'quoted_at', + ], + ]; + } + + public function transition(Inquiry $inquiry, TransitionEvent $event, ?int $actorUserId = null, array $payload = []): Inquiry + { + $rule = $this->rules[$event->value] + ?? throw new InvalidStateTransitionException($inquiry->status, $event); + + if (! in_array($inquiry->status, $rule['from'], true)) { + throw new InvalidStateTransitionException($inquiry->status, $event); + } + + $from = $inquiry->status; + $to = $rule['to']; + + DB::transaction(function () use ($inquiry, $rule, $to, $payload) { + $inquiry->status = $to->value; + if ($rule['timestampColumn']) { + $inquiry->{$rule['timestampColumn']} = now(); + } + $inquiry->save(); + + $params = $this->systemMessageParams($payload); + $this->messages->appendSystem($inquiry, $rule['systemKey'], $params); + }); + + InquiryStatusTransitioned::dispatch($inquiry, $from, $to, $event, $actorUserId); + + return $inquiry; + } + + private function systemMessageParams(array $payload): array + { + return array_filter([ + 'version' => $payload['quote_version'] ?? null, + 'total' => isset($payload['quote_total']) ? number_format($payload['quote_total']) : null, + 'order' => $payload['order_uuid'] ?? null, + 'actor' => $payload['actor'] ?? null, + ], fn ($v) => $v !== null); + } +} +``` + +- [ ] **Step 3: 테스트 실행 (성공)** + +```bash +php artisan test --filter=test_issue_quote_transitions_received_to_quoted_and_emits_system_message 2>&1 | tail -10 +``` + +- [ ] **Step 4: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Services/InquiryStateMachine.php tests/Feature/Modules/Inquiry/StateMachineTest.php +git commit -m "feat(inquiry): add StateMachine with issue_quote transition" +``` + +--- + +## Task 21: StateMachine — 나머지 전이 추가 + +**Files:** +- Modify: `src/Services/InquiryStateMachine.php` +- Modify: `tests/Feature/Modules/Inquiry/StateMachineTest.php` + +- [ ] **Step 1: 실패 테스트 추가 (6개 전이)** + +```php +public function test_revoke_quote_back_to_received(): void +{ + $sm = app(InquiryStateMachine::class); + $inquiry = $this->makeInquiry(['status' => 'quoted', 'quoted_at' => now()]); + $sm->transition($inquiry, TransitionEvent::RevokeQuote, 1, ['quote_version' => 1]); + $this->assertSame(InquiryStatus::Received, $inquiry->refresh()->status); +} + +public function test_reject_quote_back_to_received(): void +{ + $sm = app(InquiryStateMachine::class); + $inquiry = $this->makeInquiry(['status' => 'quoted', 'quoted_at' => now()]); + $sm->transition($inquiry, TransitionEvent::RejectQuote, 1, ['quote_version' => 1]); + $this->assertSame(InquiryStatus::Received, $inquiry->refresh()->status); +} + +public function test_accept_and_pay_to_in_progress(): void +{ + $sm = app(InquiryStateMachine::class); + $inquiry = $this->makeInquiry(['status' => 'quoted', 'quoted_at' => now()]); + $sm->transition($inquiry, TransitionEvent::AcceptAndPay, null, ['order_uuid' => 'order-xyz']); + $this->assertSame(InquiryStatus::InProgress, $inquiry->refresh()->status); + $this->assertNotNull($inquiry->started_at); +} + +public function test_mark_paid_offline_to_in_progress(): void +{ + $sm = app(InquiryStateMachine::class); + $inquiry = $this->makeInquiry(['status' => 'quoted', 'quoted_at' => now()]); + $sm->transition($inquiry, TransitionEvent::MarkPaidOffline, 1); + $this->assertSame(InquiryStatus::InProgress, $inquiry->refresh()->status); +} + +public function test_mark_completed_from_in_progress(): void +{ + $sm = app(InquiryStateMachine::class); + $inquiry = $this->makeInquiry(['status' => 'in_progress', 'started_at' => now()]); + $sm->transition($inquiry, TransitionEvent::MarkCompleted, 1); + $this->assertSame(InquiryStatus::Completed, $inquiry->refresh()->status); + $this->assertNotNull($inquiry->completed_at); +} + +public function test_cancel_from_any_active_state(): void +{ + $sm = app(InquiryStateMachine::class); + foreach (['received', 'quoted', 'in_progress'] as $from) { + $inquiry = $this->makeInquiry(['status' => $from]); + $sm->transition($inquiry, TransitionEvent::Cancel, 1, ['actor' => 'client']); + $this->assertSame(InquiryStatus::Canceled, $inquiry->refresh()->status, "from {$from}"); + $this->assertNotNull($inquiry->canceled_at); + } +} +``` + +```bash +php artisan test --filter=StateMachineTest 2>&1 | tail -20 +``` + +기대: 새 6개 테스트 실패 (`Invalid transition` 또는 nullpointer). + +- [ ] **Step 2: rules 배열 확장** + +`InquiryStateMachine::__construct()` 의 `$this->rules` 를 다음으로 교체: + +```php +$this->rules = [ + TransitionEvent::IssueQuote->value => [ + 'from' => [InquiryStatus::Received], + 'to' => InquiryStatus::Quoted, + 'systemKey' => 'inquiry::system.message.quote_issued', + 'timestampColumn' => 'quoted_at', + ], + TransitionEvent::RevokeQuote->value => [ + 'from' => [InquiryStatus::Quoted], + 'to' => InquiryStatus::Received, + 'systemKey' => 'inquiry::system.message.quote_revoked', + 'timestampColumn' => null, + ], + TransitionEvent::RejectQuote->value => [ + 'from' => [InquiryStatus::Quoted], + 'to' => InquiryStatus::Received, + 'systemKey' => 'inquiry::system.message.quote_rejected', + 'timestampColumn' => null, + ], + TransitionEvent::AcceptAndPay->value => [ + 'from' => [InquiryStatus::Quoted], + 'to' => InquiryStatus::InProgress, + 'systemKey' => 'inquiry::system.message.payment_confirmed', + 'timestampColumn' => 'started_at', + ], + TransitionEvent::MarkPaidOffline->value => [ + 'from' => [InquiryStatus::Quoted], + 'to' => InquiryStatus::InProgress, + 'systemKey' => 'inquiry::system.message.payment_confirmed_offline', + 'timestampColumn' => 'started_at', + ], + TransitionEvent::MarkCompleted->value => [ + 'from' => [InquiryStatus::InProgress], + 'to' => InquiryStatus::Completed, + 'systemKey' => 'inquiry::system.message.completed', + 'timestampColumn' => 'completed_at', + ], + TransitionEvent::Cancel->value => [ + 'from' => [InquiryStatus::Received, InquiryStatus::Quoted, InquiryStatus::InProgress], + 'to' => InquiryStatus::Canceled, + 'systemKey' => 'inquiry::system.message.canceled_by_client', // payload['actor']로 분기는 systemMessageParams에서 + 'timestampColumn' => 'canceled_at', + ], +]; +``` + +`transition()` 안에서 `Cancel` 이벤트일 때 `actor` payload에 따라 systemKey 분기: + +```php +$systemKey = $rule['systemKey']; +if ($event === TransitionEvent::Cancel && ($payload['actor'] ?? null) === 'operator') { + $systemKey = 'inquiry::system.message.canceled_by_operator'; +} +``` + +`appendSystem` 호출을 `$systemKey` 변수 사용으로 변경. + +- [ ] **Step 3: 테스트 실행 (성공)** + +```bash +php artisan test --filter=StateMachineTest 2>&1 | tail -20 +``` + +기대: 모든 전이 테스트 PASS. + +- [ ] **Step 4: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Services/InquiryStateMachine.php tests/Feature/Modules/Inquiry/StateMachineTest.php +git commit -m "feat(inquiry): complete StateMachine transitions (revoke/reject/accept/offline/complete/cancel)" +``` + +--- + +## Task 22: StateMachine — 불법 전이 거부 + +**Files:** +- Modify: `tests/Feature/Modules/Inquiry/StateMachineTest.php` + +- [ ] **Step 1: 불법 전이 테스트 추가** + +```php +public function test_invalid_transition_throws(): void +{ + $sm = app(InquiryStateMachine::class); + $inquiry = $this->makeInquiry(['status' => 'received']); + + $this->expectException(\Modules\Sirsoft\Inquiry\Exceptions\InvalidStateTransitionException::class); + $sm->transition($inquiry, TransitionEvent::AcceptAndPay); +} + +public function test_cannot_transition_from_terminal_states(): void +{ + $sm = app(InquiryStateMachine::class); + foreach (['completed', 'canceled'] as $terminal) { + $inquiry = $this->makeInquiry(['status' => $terminal]); + try { + $sm->transition($inquiry, TransitionEvent::IssueQuote); + $this->fail("Expected exception from terminal state {$terminal}"); + } catch (\Modules\Sirsoft\Inquiry\Exceptions\InvalidStateTransitionException $e) { + $this->assertSame(422, $e->getCode()); + } + } +} +``` + +- [ ] **Step 2: 실행 (이미 통과해야 함 — Task 20 의 from 검증으로)** + +```bash +php artisan test --filter=test_invalid_transition_throws 2>&1 | tail -10 +php artisan test --filter=test_cannot_transition_from_terminal_states 2>&1 | tail -10 +``` + +기대: PASS. 실패 시 `transition()` 의 `from` 검증 로직을 점검. + +- [ ] **Step 3: Commit** + +```bash +git add tests/Feature/Modules/Inquiry/StateMachineTest.php +git commit -m "test(inquiry): cover illegal state transitions" +``` + +--- + +## Task 23: AttachmentStorage 서비스 + +**Files:** +- Create: `src/Services/InquiryAttachmentStorage.php` +- Create: `tests/Feature/Modules/Inquiry/AttachmentStorageTest.php` + +- [ ] **Step 1: 실패 테스트 작성** + +```php +create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + $file = UploadedFile::fake()->create('plan.pdf', 100, 'application/pdf'); + + $att = $svc->store($inquiry, $user->id, $file, context: 'message'); + + $this->assertSame('application/pdf', $att->mime); + $this->assertSame('plan.pdf', $att->original_name); + Storage::disk('local')->assertExists($att->path); + } + + public function test_store_rejects_disallowed_mime(): void + { + Storage::fake('local'); + $svc = app(InquiryAttachmentStorage::class); + $user = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + $file = UploadedFile::fake()->create('bad.exe', 10, 'application/x-msdownload'); + + $this->expectException(\InvalidArgumentException::class); + $svc->store($inquiry, $user->id, $file, context: 'message'); + } + + public function test_store_rejects_oversize_file(): void + { + config(['inquiry.attachment.max_size_message' => 1024]); // 1KB + Storage::fake('local'); + $svc = app(InquiryAttachmentStorage::class); + $user = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + $file = UploadedFile::fake()->create('big.pdf', 10, 'application/pdf'); // 10KB + + $this->expectException(\InvalidArgumentException::class); + $svc->store($inquiry, $user->id, $file, context: 'message'); + } +} +``` + +```bash +php artisan test --filter=AttachmentStorageTest 2>&1 | tail -20 +``` + +기대: 3개 실패. + +- [ ] **Step 2: 서비스 작성** + +```php +getMimeType() ?? $file->getClientMimeType(); + $allowed = config('inquiry.attachment.allowed_mimes', []); + if (! in_array($mime, $allowed, true)) { + throw new InvalidArgumentException("Disallowed mime: {$mime}"); + } + + $maxKey = $context === 'inquiry' ? 'max_size_inquiry' : 'max_size_message'; + $maxBytes = (int) config("inquiry.attachment.{$maxKey}"); + if ($file->getSize() > $maxBytes) { + throw new InvalidArgumentException("File too large: {$file->getSize()} > {$maxBytes}"); + } + + $disk = config('inquiry.attachment.disk', 'local'); + $path = $file->store("inquiries/{$inquiry->uuid}", $disk); + + return $this->attachments->create([ + 'inquiry_id' => $inquiry->id, + 'message_id' => null, + 'uploader_user_id' => $uploaderUserId, + 'disk' => $disk, + 'path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'mime' => $mime, + 'size' => $file->getSize(), + ]); + } +} +``` + +- [ ] **Step 3: 테스트 실행 (성공)** + +```bash +php artisan test --filter=AttachmentStorageTest 2>&1 | tail -20 +``` + +- [ ] **Step 4: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Services/InquiryAttachmentStorage.php tests/Feature/Modules/Inquiry/AttachmentStorageTest.php +git commit -m "feat(inquiry): add InquiryAttachmentStorage service (mime/size validation)" +``` + +--- + +## Task 24: InquiryPolicy + +**Files:** +- Create: `src/Policies/InquiryPolicy.php` +- Create: `tests/Feature/Modules/Inquiry/PolicyTest.php` + +- [ ] **Step 1: 실패 테스트 작성** + +```php +create(); + $other = User::factory()->create(); + $operator = User::factory()->create(); + // 운영자 권한 부여 — 프로젝트의 권한 부여 방식에 맞춰 구현. + // ex) Spatie/Permission: $operator->givePermissionTo('inquiry.manage'); + $operator->givePermissionTo('inquiry.manage'); + + $inquiry = Inquiry::create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $owner->id, + 'title' => 'X', 'content' => 'Y', + 'status' => $status, + ]); + + return compact('owner', 'other', 'operator', 'inquiry'); + } + + public function test_owner_can_view_others_cannot(): void + { + ['owner' => $owner, 'other' => $other, 'inquiry' => $inquiry] = $this->makeUserAndInquiry(); + $this->assertTrue($owner->can('view', $inquiry)); + $this->assertFalse($other->can('view', $inquiry)); + } + + public function test_operator_can_view(): void + { + ['operator' => $op, 'inquiry' => $inquiry] = $this->makeUserAndInquiry(); + $this->assertTrue($op->can('view', $inquiry)); + } + + public function test_owner_can_update_only_in_received(): void + { + ['owner' => $owner, 'inquiry' => $inquiry] = $this->makeUserAndInquiry('received'); + $this->assertTrue($owner->can('update', $inquiry)); + + $inquiry->update(['status' => 'quoted']); + $this->assertFalse($owner->can('update', $inquiry->fresh())); + } + + public function test_only_operator_can_issue_quote(): void + { + ['owner' => $owner, 'operator' => $op, 'inquiry' => $inquiry] = $this->makeUserAndInquiry('received'); + $this->assertFalse($owner->can('issueQuote', $inquiry)); + $this->assertTrue($op->can('issueQuote', $inquiry)); + } + + public function test_owner_can_accept_quote_only_in_quoted(): void + { + ['owner' => $owner, 'inquiry' => $inquiry] = $this->makeUserAndInquiry('received'); + $this->assertFalse($owner->can('acceptQuote', $inquiry)); + $inquiry->update(['status' => 'quoted']); + $this->assertTrue($owner->can('acceptQuote', $inquiry->fresh())); + } +} +``` + +> 권한 부여 메서드(`givePermissionTo`)는 프로젝트의 권한 시스템에 따라 다를 수 있다. 작업 전 `grep -rn "givePermissionTo\|assignRole" app/` 로 실제 메서드 확인. 다르면 본 테스트와 Policy의 `userIsOperator()` 헬퍼 모두 그 패턴에 맞추어 보정. + +```bash +php artisan test --filter=PolicyTest 2>&1 | tail -20 +``` + +기대: 실패 (Policy 미존재 또는 권한 시스템 미연동). + +- [ ] **Step 2: Policy 작성** + +```php +isOwner($user, $inquiry) || $this->isOperator($user); + } + + public function update(User $user, Inquiry $inquiry): bool + { + if ($this->isOperator($user)) { + return true; + } + return $this->isOwner($user, $inquiry) && $inquiry->status === InquiryStatus::Received; + } + + public function cancel(User $user, Inquiry $inquiry): bool + { + if ($this->isOperator($user)) { + return ! in_array($inquiry->status, [InquiryStatus::Completed, InquiryStatus::Canceled], true); + } + return $this->isOwner($user, $inquiry) + && in_array($inquiry->status, [InquiryStatus::Received, InquiryStatus::Quoted], true); + } + + public function issueQuote(User $user, Inquiry $inquiry): bool + { + return $this->isOperator($user); + } + + public function revokeQuote(User $user, Inquiry $inquiry): bool + { + return $this->isOperator($user) && $inquiry->accepted_quote_id === null; + } + + public function acceptQuote(User $user, Inquiry $inquiry): bool + { + return $this->isOwner($user, $inquiry) && $inquiry->status === InquiryStatus::Quoted; + } + + public function rejectQuote(User $user, Inquiry $inquiry): bool + { + return $this->acceptQuote($user, $inquiry); + } + + public function markPaidOffline(User $user, Inquiry $inquiry): bool + { + return $this->isOperator($user); + } + + public function markCompleted(User $user, Inquiry $inquiry): bool + { + return $this->isOperator($user); + } + + public function postMessage(User $user, Inquiry $inquiry): bool + { + return $this->view($user, $inquiry); + } + + public function viewAttachment(User $user, Inquiry $inquiry): bool + { + return $this->view($user, $inquiry); + } + + public function uploadAttachment(User $user, Inquiry $inquiry): bool + { + return $this->view($user, $inquiry); + } + + private function isOwner(User $user, Inquiry $inquiry): bool + { + return $user->id === $inquiry->user_id; + } + + private function isOperator(User $user): bool + { + return $user->can(config('inquiry.permissions.manage', 'inquiry.manage')); + } +} +``` + +- [ ] **Step 3: ServiceProvider 에 Policy 등록** + +`InquiryServiceProvider::boot()` 에 추가: + +```php +\Illuminate\Support\Facades\Gate::policy( + \Modules\Sirsoft\Inquiry\Models\Inquiry::class, + \Modules\Sirsoft\Inquiry\Policies\InquiryPolicy::class, +); +``` + +- [ ] **Step 4: 권한 시드(테스트용)** + +기존 권한 등록 패턴에 맞춰 `inquiry.manage`, `inquiry.notify` 권한을 시드/마이그레이션 또는 `database/seeders/InquiryPermissionsSeeder.php`로 생성. 본 plan은 다음 plan(Phase B)으로 미루지 않고 테스트 setUp 안에서 수동 생성: + +`tests/Feature/Modules/Inquiry/PolicyTest::setUp()`: + +```php +protected function setUp(): void +{ + parent::setUp(); + // 프로젝트가 Spatie/Permission을 쓴다면: + \Spatie\Permission\Models\Permission::firstOrCreate(['name' => 'inquiry.manage']); + \Spatie\Permission\Models\Permission::firstOrCreate(['name' => 'inquiry.notify']); +} +``` + +- [ ] **Step 5: 테스트 실행 (성공)** + +```bash +php artisan test --filter=PolicyTest 2>&1 | tail -20 +``` + +기대: 5개 모두 PASS. + +- [ ] **Step 6: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Policies/ modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php tests/Feature/Modules/Inquiry/PolicyTest.php +git commit -m "feat(inquiry): add InquiryPolicy with permission matrix" +``` + +--- + +## Task 25: ServiceProvider 최종 정리 + 통합 검증 + +**Files:** +- Modify: `src/Providers/InquiryServiceProvider.php` + +- [ ] **Step 1: 모든 바인딩이 등록되었는지 최종 확인** + +```php +protected array $repositories = [ + \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryRepositoryInterface::class + => \Modules\Sirsoft\Inquiry\Repositories\InquiryRepository::class, + \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryQuoteRepositoryInterface::class + => \Modules\Sirsoft\Inquiry\Repositories\InquiryQuoteRepository::class, + \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryMessageRepositoryInterface::class + => \Modules\Sirsoft\Inquiry\Repositories\InquiryMessageRepository::class, + \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryAttachmentRepositoryInterface::class + => \Modules\Sirsoft\Inquiry\Repositories\InquiryAttachmentRepository::class, +]; +``` + +- [ ] **Step 2: 통합 검증 — 전체 fresh + 전체 테스트** + +```bash +php artisan migrate:fresh --path=modules/_bundled/sirsoft-inquiry/database/migrations +php artisan test --filter="Modules\\\\Inquiry" 2>&1 | tail -30 +``` + +기대: 모든 테스트 PASS (ModuleBootstrap, Enums, ModelRelationship, StateMachine, AttachmentStorage, Policy). + +- [ ] **Step 3: ServiceProvider 와 컨테이너 lookup 빠른 sanity check** + +```bash +php artisan tinker --execute=' +$bindings = [ + \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryRepositoryInterface::class, + \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryQuoteRepositoryInterface::class, + \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryMessageRepositoryInterface::class, + \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryAttachmentRepositoryInterface::class, + \Modules\Sirsoft\Inquiry\Services\InquiryStateMachine::class, + \Modules\Sirsoft\Inquiry\Services\InquiryAttachmentStorage::class, +]; +foreach ($bindings as $b) { dump([$b, get_class(app($b))]); } +' +``` + +기대: 각 인터페이스가 구체 클래스로 해소됨. + +- [ ] **Step 4: 최종 Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php +git commit -m "feat(inquiry): finalize ServiceProvider bindings — Phase 1 (foundation) done" +``` + +--- + +## Task 26: Phase 1 회고 + Phase 2 진입 준비 + +- [ ] **Step 1: 전체 commit 로그 확인** + +```bash +git log --oneline | head -30 +``` + +기대: 25-30개 commit (Phase 1 단계). + +- [ ] **Step 2: 변경 통계** + +```bash +git diff --stat $(git log --oneline | grep "feat(inquiry): scaffold module skeleton" | awk '{print $1}')^..HEAD +``` + +- [ ] **Step 3: 다음 Plan으로 이동 신호** + +이 Plan의 모든 task가 통과하면 Plan 2 (Backend API + 사용자 프론트)로 진입할 수 있다. Plan 2의 첫 task는 `Resources` (InquiryResource, InquiryMessageResource, InquiryAttachmentResource) + 의뢰 CRUD 컨트롤러부터 시작한다. + +--- + +## 부록 A — 자주 발생할 수 있는 문제 + +**모듈 마이그레이션이 자동으로 잡히지 않을 때** +- `php artisan migrate:fresh` 만으로 모듈 마이그레이션이 실행 안 되면 `loadMigrationsFrom()` 호출 누락 또는 ServiceProvider 미등록. +- `php artisan migrate --path=...` 로 명시 경로 지정해서 우회 가능. ServiceProvider 등록 후 path 없이도 자동 발견되는지 재확인. + +**Spatie/Permission 미사용 환경** +- 권한 부여 메서드가 다를 수 있음. `$user->permissions()->attach($id)` 같은 패턴. `InquiryPolicy::isOperator()` 안의 `$user->can(...)` 가 동작하는지가 핵심 — 작동하면 권한 시스템에 상관없이 통과. + +**`uuid` 컬럼 인덱스 충돌** +- 일부 MySQL 버전에서 `uuid` + `unique` 가 길이 문제로 실패. 그럴 경우 `$table->char('uuid', 36)->unique()` 로 변경. + +**`Illuminate\Support\Str::uuid()` import** +- 테스트에서 `\Str::uuid()` 사용 시 helper 미등록. `use Illuminate\Support\Str;` 후 `Str::uuid()` 또는 `(string) Str::uuid()` 사용. From ad5d757b9771a3320430b76e95378d3ef03db8c4 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Thu, 21 May 2026 14:58:02 +0800 Subject: [PATCH 03/81] docs(plan): correct module patterns after BaseModuleServiceProvider review Pre-execution sanity check revealed that this project's module system differs from the assumptions baked into Plan 1: - BaseModuleServiceProvider::register/boot already handle migrations, translations, and repository binding. Subclasses only declare arrays. - Module migrations run via ModuleManager (php artisan module:install / module:activate), NOT via `php artisan migrate`. - Translations auto-load from src/lang. Updated Pre-check, Task 2 (ServiceProvider skeleton), Task 3 (config merge in register), and added Appendix A/B so implementer and reviewers share a single source of truth on these patterns. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...235\230\353\242\260-backend-foundation.md" | 144 +++++++++++++----- 1 file changed, 110 insertions(+), 34 deletions(-) diff --git "a/docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-backend-foundation.md" "b/docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-backend-foundation.md" index 6f9bc0d0..5a539e13 100644 --- "a/docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-backend-foundation.md" +++ "b/docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-backend-foundation.md" @@ -83,28 +83,46 @@ tests/Feature/Modules/Inquiry/ --- -## 사전 확인 (작업 전 1회) +## 사전 확인 (작업 전 1회) — 2026-05-21 보정 -- [ ] **Pre-1: 모듈 마이그레이션 등록 방식 확인** +> **모듈 시스템 패턴 (확정)** — 이 plan 작성 후 실제 코드를 다시 확인하여 다음 사실들을 확인했다. 본 plan의 모든 task는 이 패턴을 전제로 한다. +> +> - **`BaseModuleServiceProvider::register()` 가 자동 처리**: `$repositories` 배열의 모든 interface→impl 바인딩, `$storageServices` / `$cacheServices` 의 컨텍스트 바인딩. 자식은 배열만 정의. +> - **`BaseModuleServiceProvider::boot()` 가 자동 처리**: `loadModuleMigrations()` (실제로는 빈 메서드 — `php artisan migrate` 와 분리) + `loadModuleTranslations()` (`{module}/src/lang` 에서 자동 로드). +> - **모듈 마이그레이션은 `ModuleManager::runMigrations()` 가 실행** — `php artisan module:install ` / `module:activate ` 명령으로 트리거. `php artisan migrate` 로는 모듈 마이그레이션이 실행되지 않음. +> - **모듈 인식**: `_bundled` 디렉터리에 원본을 두고, install 명령이 `modules//` 로 활성화시키며 `bootstrap/cache/autoload-extensions.php` 가 갱신됨. PSR-4 autoload는 install 명령이 처리. +> - **다국어 자동 로드 경로**: `modules/_bundled//src/lang/{locale}/.php` → 키 prefix `::file.key` (예: `inquiry::system.status.received`). +> +> 따라서 본 plan의 Task 2(`InquiryServiceProvider`)에서 `boot()` 메서드를 재정의하지 않으며, `loadMigrationsFrom` / `loadTranslationsFrom` 직접 호출도 제외한다. config 머지가 필요하면 `register()` 안에서 한다. -board 모듈의 `BaseModuleServiceProvider` 가 `boot()` 안에서 어떻게 마이그레이션과 config·정책을 로드하는지 확인하여 동일 패턴을 따른다. 확인 명령: +- [ ] **Pre-1: BaseModuleServiceProvider / ModuleManager 패턴 재확인 (참고 자료 읽기)** ```bash -grep -n "loadMigrations\|mergeConfig\|Gate::policy" \ - app/Extension/BaseModuleServiceProvider.php \ - modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php +sed -n '50,90p' app/Extension/BaseModuleServiceProvider.php +sed -n '155,170p' app/Extension/BaseModuleServiceProvider.php +grep -n "runMigrations" app/Extension/ModuleManager.php 2>/dev/null | head -5 ``` -발견된 패턴(특히 `loadMigrationsFrom` 호출 시점, repository binding 배열 키)을 본 plan의 ServiceProvider 코드에 그대로 반영한다. 본 plan은 board 모듈 패턴이 동일하다고 가정하고 작성됨 — 다르면 해당 task에서 보정. +기대: parent `register()` 와 `boot()` 가 모든 공통 처리를 한다는 사실 확인. -- [ ] **Pre-2: 테스트 실행 명령 확인** +- [ ] **Pre-2: 테스트 환경(.env.testing) 준비** ```bash -php artisan test --filter="Modules\\\\Inquiry" 2>&1 | head -20 -# 또는 ./vendor/bin/pest tests/Feature/Modules/Inquiry +test -f .env.testing || cp .env.testing.example .env.testing +grep "^APP_KEY=" .env.testing | grep -v "APP_KEY=$" >/dev/null || php artisan key:generate --env=testing +php artisan test --filter=NothingMatchesThisTest 2>&1 | tail -5 ``` -성공 또는 "No tests found" 출력이 정상. 환경에 따라 어느 쪽을 쓸지 결정하여 이후 모든 task의 테스트 실행 명령에 적용. +기대: 마지막 명령은 "No tests executed" 같은 깨끗한 결과(에러 아님). `.env.testing` 키 정상. + +- [ ] **Pre-3: `module:install` / `module:activate` 흐름 확인** + +```bash +php artisan module:list 2>&1 | head -10 +php artisan list 2>&1 | grep -E "module:(install|activate|uninstall|composer)" | head -10 +``` + +기대: board/page/ecommerce 등 기존 모듈이 보임. install/activate 명령 존재 확인. --- @@ -231,6 +249,8 @@ php artisan test --filter=ModuleBootstrapTest 2>&1 | tail -10 - [ ] **Step 3: ServiceProvider 작성** +`BaseModuleServiceProvider` 가 마이그레이션·번역 로드, repository binding을 모두 자동 처리하므로 `boot()` / `register()` 재정의 불필요. 자식은 배열만 정의. **config 머지가 필요하면 `register()` 안에서 처리** (Task 3에서 추가): + ```php loadMigrationsFrom(__DIR__ . '/../../database/migrations'); - $this->mergeConfigFrom(__DIR__ . '/../../config/inquiry.php', 'inquiry'); - $this->loadTranslationsFrom(__DIR__ . '/../lang', 'inquiry'); - } } ``` -- [ ] **Step 4: 모듈 시스템에 ServiceProvider 등록** +- [ ] **Step 4: 모듈 등록 (ModuleManager 통한 install 흐름)** -board 모듈이 `module.json` 또는 별도 manifest로 ServiceProvider를 노출하는 방식을 확인하여 동일하게 등록. 일반적으로 `module.json`의 `service_provider` 필드 또는 `config/app.php` 의 `providers` 배열. **확인 명령**: +ServiceProvider를 `config/app.php` 의 `providers` 에 직접 추가하지 않는다. 대신 ModuleManager가 활성화 시점에 자동 등록한다. 이번 step에서는 모듈이 _bundled 디렉터리에서 인식되도록 install 흐름을 실행: ```bash -grep -rn "BoardServiceProvider" config/ modules/_bundled/sirsoft-board/module.json 2>/dev/null +php artisan module:list 2>&1 | grep sirsoft-inquiry +# 없으면 install: +php artisan module:install sirsoft-inquiry 2>&1 | tail -10 +php artisan module:activate sirsoft-inquiry 2>&1 | tail -5 +php artisan module:list 2>&1 | grep sirsoft-inquiry ``` -발견된 위치에 `Modules\Sirsoft\Inquiry\Providers\InquiryServiceProvider` 추가. +기대: 마지막 명령에 sirsoft-inquiry 가 activated 상태로 출력. + +`module:install` 실패 시 board 모듈의 활성화 패턴을 비교해 누락된 파일(예: `module.php`)을 보강: + +```bash +ls modules/_bundled/sirsoft-board/module.php modules/sirsoft-board/module.php 2>&1 +# 필요 시 동일 구조로 modules/_bundled/sirsoft-inquiry/module.php 생성 +``` - [ ] **Step 5: 테스트 실행 (성공 확인)** @@ -336,7 +360,23 @@ return [ ]; ``` -- [ ] **Step 2: config 로드 검증 테스트 추가** +- [ ] **Step 2: ServiceProvider의 register() 안에서 config 머지** + +`InquiryServiceProvider` 에 `register()` 메서드를 추가해 `parent::register()` 호출 + config 머지: + +```php +public function register(): void +{ + parent::register(); + + $this->mergeConfigFrom( + $this->getProviderPath() . '/../../config/inquiry.php', + 'inquiry' + ); +} +``` + +- [ ] **Step 3: config 로드 검증 테스트 추가** `ModuleBootstrapTest::test_config_is_merged()`: @@ -348,7 +388,7 @@ public function test_config_is_merged(): void } ``` -- [ ] **Step 3: 테스트 실행** +- [ ] **Step 4: 테스트 실행** ```bash php artisan test --filter=ModuleBootstrapTest 2>&1 | tail -10 @@ -356,10 +396,12 @@ php artisan test --filter=ModuleBootstrapTest 2>&1 | tail -10 기대: 모든 테스트 PASS. -- [ ] **Step 4: Commit** +- [ ] **Step 5: Commit** ```bash -git add modules/_bundled/sirsoft-inquiry/config/inquiry.php tests/Feature/Modules/Inquiry/ModuleBootstrapTest.php +git add modules/_bundled/sirsoft-inquiry/config/inquiry.php \ + modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php \ + tests/Feature/Modules/Inquiry/ModuleBootstrapTest.php git commit -m "feat(inquiry): add module config (attachment/quote/permissions)" ``` @@ -2896,9 +2938,33 @@ git diff --stat $(git log --oneline | grep "feat(inquiry): scaffold module skele ## 부록 A — 자주 발생할 수 있는 문제 -**모듈 마이그레이션이 자동으로 잡히지 않을 때** -- `php artisan migrate:fresh` 만으로 모듈 마이그레이션이 실행 안 되면 `loadMigrationsFrom()` 호출 누락 또는 ServiceProvider 미등록. -- `php artisan migrate --path=...` 로 명시 경로 지정해서 우회 가능. ServiceProvider 등록 후 path 없이도 자동 발견되는지 재확인. +**모듈 마이그레이션 실행 절차 (Task 5-9, 25 공통)** + +이 프로젝트의 모듈 마이그레이션은 `php artisan migrate` 가 자동으로 잡지 않는다. `ModuleManager::runMigrations()` 가 `module:install` / `module:activate` / `module:update` 시점에 실행한다. 따라서 본 plan Task 5-9 의 "마이그레이션 실행 검증" 단계는 다음 패턴 중 환경에 맞는 것을 사용한다: + +```bash +# 패턴 A — 매 마이그레이션 추가마다 재설치 (idempotent하다면) +php artisan module:uninstall sirsoft-inquiry 2>&1 | tail -3 +php artisan module:install sirsoft-inquiry 2>&1 | tail -3 +php artisan module:activate sirsoft-inquiry 2>&1 | tail -3 + +# 패턴 B — 마이그레이션 작성 시점엔 syntax 만 검증, 실제 실행은 Task 9에서 한 번에 +php -l modules/_bundled/sirsoft-inquiry/database/migrations/.php +# Task 9 끝에서 한 번에 install/activate + +# 패턴 C — module:update 가 새 마이그레이션을 감지한다면 +php artisan module:update sirsoft-inquiry 2>&1 | tail -5 + +# 테이블 존재 검증 +php artisan tinker --execute="dump(\Schema::hasTable('inquiries'));" +``` + +implementer는 Pre-3에서 발견한 실제 명령 패턴(`module:list` 출력 / `module:update` 존재 여부)에 따라 위 중 하나를 선택한다. Task 5의 첫 마이그레이션 검증에서 선택한 패턴을 Task 6-9, 25 에서 일관 적용. + +**모듈 인식 자체가 안 될 때** +- `bootstrap/cache/autoload-extensions.php` 갱신 누락 가능. `php artisan module:composer-install sirsoft-inquiry` 또는 `composer dump-autoload` 실행. +- `modules/_bundled/sirsoft-inquiry/module.json` 의 `identifier` 와 디렉터리명이 일치해야 함. +- board 모듈에 `module.php` 같은 부가 manifest가 있는지 비교: `ls modules/_bundled/sirsoft-board/module.php`. 있으면 동일 구조로 inquiry에도 추가. **Spatie/Permission 미사용 환경** - 권한 부여 메서드가 다를 수 있음. `$user->permissions()->attach($id)` 같은 패턴. `InquiryPolicy::isOperator()` 안의 `$user->can(...)` 가 동작하는지가 핵심 — 작동하면 권한 시스템에 상관없이 통과. @@ -2908,3 +2974,13 @@ git diff --stat $(git log --oneline | grep "feat(inquiry): scaffold module skele **`Illuminate\Support\Str::uuid()` import** - 테스트에서 `\Str::uuid()` 사용 시 helper 미등록. `use Illuminate\Support\Str;` 후 `Str::uuid()` 또는 `(string) Str::uuid()` 사용. + +## 부록 B — Plan vs 코드 불일치 처리 가이드 (implementer/reviewer 공통) + +본 plan 작성 후 `BaseModuleServiceProvider` / `ModuleManager` 패턴을 재검증하여 다음 사실을 plan 본문에 반영했다: + +1. ServiceProvider는 `boot()`/`register()` 재정의 불필요 (Task 2). config 머지가 필요할 때만 `register()` 안에서 처리 (Task 3). +2. 마이그레이션 실행은 `module:install`/`activate` 흐름. `php artisan migrate --path=...` 사용 금지. +3. 다국어는 자동 로드(`{module}/src/lang`). `loadTranslationsFrom` 직접 호출 금지. + +spec reviewer / code reviewer 는 위 3가지를 plan 의 정답으로 간주하고, plan 본문의 옛 코드 블록 중 위와 충돌하는 부분이 발견되면 본 부록 B 를 따른다. From d0a64381533e315e162bfa6f2ecadc9ea93bf521 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Thu, 21 May 2026 14:59:29 +0800 Subject: [PATCH 04/81] feat(inquiry): scaffold module skeleton --- .../_bundled/sirsoft-inquiry/composer.json | 17 +++++++++++++++++ modules/_bundled/sirsoft-inquiry/module.json | 19 +++++++++++++++++++ modules/_bundled/sirsoft-inquiry/module.php | 11 +++++++++++ 3 files changed, 47 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/composer.json create mode 100644 modules/_bundled/sirsoft-inquiry/module.json create mode 100644 modules/_bundled/sirsoft-inquiry/module.php diff --git a/modules/_bundled/sirsoft-inquiry/composer.json b/modules/_bundled/sirsoft-inquiry/composer.json new file mode 100644 index 00000000..446cc98d --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/composer.json @@ -0,0 +1,17 @@ +{ + "name": "modules/sirsoft-inquiry", + "description": "Inquiry module for Gnuboard7", + "type": "library", + "version": "1.0.0-alpha.1", + "license": "MIT", + "autoload": { + "psr-4": { + "Modules\\Sirsoft\\Inquiry\\": "src/", + "Modules\\Sirsoft\\Inquiry\\Database\\Seeders\\": "database/seeders/", + "Modules\\Sirsoft\\Inquiry\\Database\\Factories\\": "database/factories/" + } + }, + "require": { + "php": "^8.2" + } +} diff --git a/modules/_bundled/sirsoft-inquiry/module.json b/modules/_bundled/sirsoft-inquiry/module.json new file mode 100644 index 00000000..b7b70565 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/module.json @@ -0,0 +1,19 @@ +{ + "identifier": "sirsoft-inquiry", + "vendor": "sirsoft", + "name": { + "ko": "제작의뢰", + "en": "Inquiry" + }, + "version": "1.0.0-alpha.1", + "license": "MIT", + "description": { + "ko": "제작의뢰 라이프사이클(접수·견적·진행·완료) 관리 모듈", + "en": "Inquiry lifecycle module (received / quoted / in_progress / completed)" + }, + "g7_version": ">=7.0.0-beta.5", + "dependencies": { + "modules": {}, + "plugins": {} + } +} diff --git a/modules/_bundled/sirsoft-inquiry/module.php b/modules/_bundled/sirsoft-inquiry/module.php new file mode 100644 index 00000000..8e955e61 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/module.php @@ -0,0 +1,11 @@ + Date: Thu, 21 May 2026 23:49:32 +0900 Subject: [PATCH 05/81] feat(inquiry): add ServiceProvider skeleton + bootstrap test Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/Providers/InquiryServiceProvider.php | 20 ++++++++++++++++ .../Modules/Inquiry/ModuleBootstrapTest.php | 24 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php create mode 100644 tests/Feature/Modules/Inquiry/ModuleBootstrapTest.php diff --git a/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php b/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php new file mode 100644 index 00000000..9ca7d4b5 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php @@ -0,0 +1,20 @@ +app->make(InquiryServiceProvider::class, ['app' => $this->app]); + $this->assertInstanceOf(InquiryServiceProvider::class, $provider); + } + + public function test_module_identifier_matches_manifest(): void + { + $provider = $this->app->make(InquiryServiceProvider::class, ['app' => $this->app]); + $reflection = new \ReflectionClass($provider); + $prop = $reflection->getProperty('moduleIdentifier'); + $prop->setAccessible(true); + $this->assertSame('sirsoft-inquiry', $prop->getValue($provider)); + } +} From f23bb7b6bfcfa66ebc9357af1ea4869a71ba818f Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Thu, 21 May 2026 23:50:57 +0900 Subject: [PATCH 06/81] feat(inquiry): add module config (attachment/quote/permissions) --- .../sirsoft-inquiry/config/inquiry.php | 37 +++++++++++++++++++ .../src/Providers/InquiryServiceProvider.php | 10 +++++ .../Modules/Inquiry/ModuleBootstrapTest.php | 13 +++++++ 3 files changed, 60 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/config/inquiry.php diff --git a/modules/_bundled/sirsoft-inquiry/config/inquiry.php b/modules/_bundled/sirsoft-inquiry/config/inquiry.php new file mode 100644 index 00000000..3d70e4f5 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/config/inquiry.php @@ -0,0 +1,37 @@ + [ + 'disk' => env('INQUIRY_ATTACHMENT_DISK', 'local'), + 'max_size_inquiry' => env('INQUIRY_ATTACHMENT_MAX_INQUIRY', 50 * 1024 * 1024), // 50MB + 'max_size_message' => env('INQUIRY_ATTACHMENT_MAX_MESSAGE', 20 * 1024 * 1024), // 20MB + 'allowed_mimes' => [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'application/pdf', + 'application/zip', + 'application/x-zip-compressed', + ], + 'orphan_cleanup_after_minutes' => 30, + ], + + 'categories' => [ + 'web', + 'design', + 'maintenance', + 'consulting', + 'etc', + ], + + 'quote' => [ + 'currency' => 'KRW', + 'default_valid_days' => 14, + ], + + 'permissions' => [ + 'manage' => 'inquiry.manage', + 'notify' => 'inquiry.notify', + ], +]; diff --git a/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php b/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php index 9ca7d4b5..afb35df1 100644 --- a/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php +++ b/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php @@ -17,4 +17,14 @@ class InquiryServiceProvider extends BaseModuleServiceProvider protected array $cacheServices = []; protected array $storageServices = []; + + public function register(): void + { + parent::register(); + + $this->mergeConfigFrom( + $this->getProviderPath() . '/../../config/inquiry.php', + 'inquiry' + ); + } } diff --git a/tests/Feature/Modules/Inquiry/ModuleBootstrapTest.php b/tests/Feature/Modules/Inquiry/ModuleBootstrapTest.php index 4f15bbf0..979b82d5 100644 --- a/tests/Feature/Modules/Inquiry/ModuleBootstrapTest.php +++ b/tests/Feature/Modules/Inquiry/ModuleBootstrapTest.php @@ -7,6 +7,13 @@ class ModuleBootstrapTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + $this->app->register(InquiryServiceProvider::class); + } + public function test_service_provider_is_resolvable(): void { $provider = $this->app->make(InquiryServiceProvider::class, ['app' => $this->app]); @@ -21,4 +28,10 @@ public function test_module_identifier_matches_manifest(): void $prop->setAccessible(true); $this->assertSame('sirsoft-inquiry', $prop->getValue($provider)); } + + public function test_config_is_merged(): void + { + $this->assertSame('KRW', config('inquiry.quote.currency')); + $this->assertContains('image/jpeg', config('inquiry.attachment.allowed_mimes')); + } } From 75551dd95a7c2e2ea071e405bcc798fa5005e4fb Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Thu, 21 May 2026 23:52:26 +0900 Subject: [PATCH 07/81] feat(inquiry): add Status/Role/Event enums --- .../src/Enums/InquiryStatus.php | 22 ++++++++ .../sirsoft-inquiry/src/Enums/QuoteStatus.php | 17 ++++++ .../sirsoft-inquiry/src/Enums/SenderRole.php | 10 ++++ .../src/Enums/TransitionEvent.php | 14 +++++ tests/Feature/Modules/Inquiry/EnumsTest.php | 52 +++++++++++++++++++ 5 files changed, 115 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/src/Enums/InquiryStatus.php create mode 100644 modules/_bundled/sirsoft-inquiry/src/Enums/QuoteStatus.php create mode 100644 modules/_bundled/sirsoft-inquiry/src/Enums/SenderRole.php create mode 100644 modules/_bundled/sirsoft-inquiry/src/Enums/TransitionEvent.php create mode 100644 tests/Feature/Modules/Inquiry/EnumsTest.php diff --git a/modules/_bundled/sirsoft-inquiry/src/Enums/InquiryStatus.php b/modules/_bundled/sirsoft-inquiry/src/Enums/InquiryStatus.php new file mode 100644 index 00000000..2133ac77 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Enums/InquiryStatus.php @@ -0,0 +1,22 @@ +value); + } + + public function isTerminal(): bool + { + return in_array($this, [self::Completed, self::Canceled], true); + } +} diff --git a/modules/_bundled/sirsoft-inquiry/src/Enums/QuoteStatus.php b/modules/_bundled/sirsoft-inquiry/src/Enums/QuoteStatus.php new file mode 100644 index 00000000..0253d1e6 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Enums/QuoteStatus.php @@ -0,0 +1,17 @@ +assertSame('received', InquiryStatus::Received->value); + $this->assertSame('quoted', InquiryStatus::Quoted->value); + $this->assertSame('in_progress', InquiryStatus::InProgress->value); + $this->assertSame('completed', InquiryStatus::Completed->value); + $this->assertSame('canceled', InquiryStatus::Canceled->value); + $this->assertCount(5, InquiryStatus::cases()); + } + + public function test_quote_status_values(): void + { + $this->assertSame('draft', QuoteStatus::Draft->value); + $this->assertSame('issued', QuoteStatus::Issued->value); + $this->assertSame('accepted', QuoteStatus::Accepted->value); + $this->assertSame('rejected', QuoteStatus::Rejected->value); + $this->assertSame('expired', QuoteStatus::Expired->value); + } + + public function test_sender_role_values(): void + { + $this->assertSame('client', SenderRole::Client->value); + $this->assertSame('operator', SenderRole::Operator->value); + $this->assertSame('system', SenderRole::System->value); + } + + public function test_transition_event_values(): void + { + $events = array_map(fn ($e) => $e->value, TransitionEvent::cases()); + $this->assertEqualsCanonicalizing([ + 'issue_quote', + 'revoke_quote', + 'reject_quote', + 'accept_and_pay', + 'mark_paid_offline', + 'mark_completed', + 'cancel', + ], $events); + } +} From f7cc0786af524347b4b4f8e202828ddf7dfd960e Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Thu, 21 May 2026 23:53:41 +0900 Subject: [PATCH 08/81] feat(inquiry): add inquiries migration --- ...26_05_21_100001_create_inquiries_table.php | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100001_create_inquiries_table.php diff --git a/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100001_create_inquiries_table.php b/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100001_create_inquiries_table.php new file mode 100644 index 00000000..626110bc --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100001_create_inquiries_table.php @@ -0,0 +1,40 @@ +bigIncrements('id'); + $table->uuid('uuid')->unique(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('title', 200); + $table->text('content'); + $table->string('category', 50)->nullable(); + $table->string('budget_range', 100)->nullable(); + $table->date('desired_due_at')->nullable(); + $table->string('status', 20)->default('received')->index(); + $table->unsignedBigInteger('accepted_quote_id')->nullable(); + $table->string('payment_id', 64)->nullable(); + $table->json('extra_data')->nullable(); + $table->timestamp('received_at')->useCurrent(); + $table->timestamp('quoted_at')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('canceled_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'status']); + $table->index(['status', 'received_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('inquiries'); + } +}; From 1dc1bde6c4f62f917a6ec4afb1ceda694ae7a3bd Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Thu, 21 May 2026 23:53:54 +0900 Subject: [PATCH 09/81] feat(inquiry): add inquiry_quotes migration --- ..._21_100002_create_inquiry_quotes_table.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100002_create_inquiry_quotes_table.php diff --git a/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100002_create_inquiry_quotes_table.php b/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100002_create_inquiry_quotes_table.php new file mode 100644 index 00000000..73aba59a --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100002_create_inquiry_quotes_table.php @@ -0,0 +1,34 @@ +bigIncrements('id'); + $table->foreignId('inquiry_id')->constrained('inquiries')->cascadeOnDelete(); + $table->unsignedInteger('version'); + $table->decimal('total_amount', 12, 0); + $table->decimal('tax_amount', 12, 0)->default(0); + $table->string('currency', 3)->default('KRW'); + $table->date('valid_until')->nullable(); + $table->text('note')->nullable(); + $table->string('status', 20)->default('draft')->index(); + $table->timestamp('issued_at')->nullable(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamp('rejected_at')->nullable(); + $table->timestamps(); + + $table->unique(['inquiry_id', 'version']); + }); + } + + public function down(): void + { + Schema::dropIfExists('inquiry_quotes'); + } +}; From 4719c263b962510169eb32a26572f3b995b24798 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Thu, 21 May 2026 23:54:05 +0900 Subject: [PATCH 10/81] feat(inquiry): add inquiry_quote_items migration --- ...00003_create_inquiry_quote_items_table.php | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100003_create_inquiry_quote_items_table.php diff --git a/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100003_create_inquiry_quote_items_table.php b/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100003_create_inquiry_quote_items_table.php new file mode 100644 index 00000000..0da3411f --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100003_create_inquiry_quote_items_table.php @@ -0,0 +1,30 @@ +bigIncrements('id'); + $table->foreignId('quote_id')->constrained('inquiry_quotes')->cascadeOnDelete(); + $table->unsignedInteger('position')->default(0); + $table->string('name', 200); + $table->text('description')->nullable(); + $table->decimal('qty', 10, 2); + $table->decimal('unit_price', 12, 0); + $table->decimal('amount', 12, 0); + $table->timestamps(); + + $table->index(['quote_id', 'position']); + }); + } + + public function down(): void + { + Schema::dropIfExists('inquiry_quote_items'); + } +}; From cdd76667c886d440846e4bb816126200ce6e9e12 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Thu, 21 May 2026 23:54:16 +0900 Subject: [PATCH 11/81] feat(inquiry): add inquiry_messages migration (with meta column for system msgs) --- ...1_100004_create_inquiry_messages_table.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100004_create_inquiry_messages_table.php diff --git a/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100004_create_inquiry_messages_table.php b/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100004_create_inquiry_messages_table.php new file mode 100644 index 00000000..82a2a015 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100004_create_inquiry_messages_table.php @@ -0,0 +1,29 @@ +bigIncrements('id'); + $table->foreignId('inquiry_id')->constrained('inquiries')->cascadeOnDelete(); + $table->foreignId('sender_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('sender_role', 20); + $table->text('body')->nullable(); + $table->json('meta')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + + $table->index(['inquiry_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('inquiry_messages'); + } +}; From fdd55daa7ae89d3f88f0dfac0882418431074d27 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Thu, 21 May 2026 23:54:26 +0900 Subject: [PATCH 12/81] feat(inquiry): add inquiry_attachments migration --- ...00005_create_inquiry_attachments_table.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100005_create_inquiry_attachments_table.php diff --git a/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100005_create_inquiry_attachments_table.php b/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100005_create_inquiry_attachments_table.php new file mode 100644 index 00000000..e20d0376 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/database/migrations/2026_05_21_100005_create_inquiry_attachments_table.php @@ -0,0 +1,31 @@ +bigIncrements('id'); + $table->foreignId('inquiry_id')->constrained('inquiries')->cascadeOnDelete(); + $table->foreignId('message_id')->nullable()->constrained('inquiry_messages')->cascadeOnDelete(); + $table->foreignId('uploader_user_id')->constrained('users')->cascadeOnDelete(); + $table->string('disk', 20); + $table->string('path'); + $table->string('original_name', 255); + $table->string('mime', 100); + $table->unsignedBigInteger('size'); + $table->timestamps(); + + $table->index(['inquiry_id', 'message_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('inquiry_attachments'); + } +}; From 2890e490890519baa1fc472650995af6bb186ce5 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Thu, 21 May 2026 23:57:27 +0900 Subject: [PATCH 13/81] feat(inquiry): add Inquiry model with relationships and casts Co-Authored-By: Claude Sonnet 4.6 --- .../sirsoft-inquiry/src/Models/Inquiry.php | 69 +++++++++++++++++++ .../Modules/Inquiry/ModelRelationshipTest.php | 36 ++++++++++ 2 files changed, 105 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/src/Models/Inquiry.php create mode 100644 tests/Feature/Modules/Inquiry/ModelRelationshipTest.php diff --git a/modules/_bundled/sirsoft-inquiry/src/Models/Inquiry.php b/modules/_bundled/sirsoft-inquiry/src/Models/Inquiry.php new file mode 100644 index 00000000..3f1c35dd --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Models/Inquiry.php @@ -0,0 +1,69 @@ + InquiryStatus::class, + 'extra_data' => 'array', + 'desired_due_at' => 'date', + 'received_at' => 'datetime', + 'quoted_at' => 'datetime', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'canceled_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function quotes(): HasMany + { + return $this->hasMany(InquiryQuote::class)->orderBy('version'); + } + + public function acceptedQuote(): BelongsTo + { + return $this->belongsTo(InquiryQuote::class, 'accepted_quote_id'); + } + + public function messages(): HasMany + { + return $this->hasMany(InquiryMessage::class)->orderBy('created_at'); + } + + public function attachments(): HasMany + { + return $this->hasMany(InquiryAttachment::class); + } +} diff --git a/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php b/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php new file mode 100644 index 00000000..fe536281 --- /dev/null +++ b/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php @@ -0,0 +1,36 @@ +create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), + 'user_id' => $user->id, + 'title' => '홈페이지 리뉴얼 의뢰', + 'content' => '기존 사이트 개편 부탁드립니다.', + 'status' => InquiryStatus::Received->value, + ]); + + $this->assertInstanceOf(User::class, $inquiry->user); + $this->assertSame($user->id, $inquiry->user->id); + $this->assertInstanceOf(InquiryStatus::class, $inquiry->status); + $this->assertSame(InquiryStatus::Received, $inquiry->status); + } +} From cd1692e64bd590f52e75333d7fc059293815f774 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Thu, 21 May 2026 23:58:06 +0900 Subject: [PATCH 14/81] feat(inquiry): add InquiryQuote + InquiryQuoteItem models Co-Authored-By: Claude Sonnet 4.6 --- .../src/Models/InquiryQuote.php | 47 +++++++++++++++++++ .../src/Models/InquiryQuoteItem.php | 32 +++++++++++++ .../Modules/Inquiry/ModelRelationshipTest.php | 28 +++++++++++ 3 files changed, 107 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/src/Models/InquiryQuote.php create mode 100644 modules/_bundled/sirsoft-inquiry/src/Models/InquiryQuoteItem.php diff --git a/modules/_bundled/sirsoft-inquiry/src/Models/InquiryQuote.php b/modules/_bundled/sirsoft-inquiry/src/Models/InquiryQuote.php new file mode 100644 index 00000000..8f1fb6e5 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Models/InquiryQuote.php @@ -0,0 +1,47 @@ + QuoteStatus::class, + 'valid_until' => 'date', + 'issued_at' => 'datetime', + 'accepted_at' => 'datetime', + 'rejected_at' => 'datetime', + 'total_amount' => 'decimal:0', + 'tax_amount' => 'decimal:0', + ]; + + public function inquiry(): BelongsTo + { + return $this->belongsTo(Inquiry::class); + } + + public function items(): HasMany + { + return $this->hasMany(InquiryQuoteItem::class, 'quote_id')->orderBy('position'); + } +} diff --git a/modules/_bundled/sirsoft-inquiry/src/Models/InquiryQuoteItem.php b/modules/_bundled/sirsoft-inquiry/src/Models/InquiryQuoteItem.php new file mode 100644 index 00000000..8cb97553 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Models/InquiryQuoteItem.php @@ -0,0 +1,32 @@ + 'decimal:2', + 'unit_price' => 'decimal:0', + 'amount' => 'decimal:0', + ]; + + public function quote(): BelongsTo + { + return $this->belongsTo(InquiryQuote::class, 'quote_id'); + } +} diff --git a/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php b/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php index fe536281..d362a28b 100644 --- a/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php +++ b/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php @@ -7,6 +7,8 @@ use Illuminate\Support\Str; use Modules\Sirsoft\Inquiry\Enums\InquiryStatus; use Modules\Sirsoft\Inquiry\Models\Inquiry; +use Modules\Sirsoft\Inquiry\Models\InquiryQuote; +use Modules\Sirsoft\Inquiry\Models\InquiryQuoteItem; use Tests\TestCase; class ModelRelationshipTest extends TestCase @@ -33,4 +35,30 @@ public function test_inquiry_belongs_to_user_and_casts_status(): void $this->assertInstanceOf(InquiryStatus::class, $inquiry->status); $this->assertSame(InquiryStatus::Received, $inquiry->status); } + + public function test_quote_has_items_and_inquiry(): void + { + $user = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', + 'status' => 'received', + ]); + $quote = $inquiry->quotes()->create([ + 'version' => 1, + 'total_amount' => 1000000, + 'status' => 'issued', + ]); + $quote->items()->create([ + 'position' => 1, + 'name' => '메인 페이지 디자인', + 'qty' => 1, + 'unit_price' => 1000000, + 'amount' => 1000000, + ]); + + $this->assertSame(1, $quote->items()->count()); + $this->assertSame($inquiry->id, $quote->inquiry->id); + } } From 1eaaa7295d4fdbbefc5b664388c9aa96d3020495 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Thu, 21 May 2026 23:58:46 +0900 Subject: [PATCH 15/81] feat(inquiry): add InquiryMessage + InquiryAttachment models Co-Authored-By: Claude Sonnet 4.6 --- .../src/Models/InquiryAttachment.php | 42 ++++++++++++++++ .../src/Models/InquiryMessage.php | 49 +++++++++++++++++++ .../Modules/Inquiry/ModelRelationshipTest.php | 31 ++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/src/Models/InquiryAttachment.php create mode 100644 modules/_bundled/sirsoft-inquiry/src/Models/InquiryMessage.php diff --git a/modules/_bundled/sirsoft-inquiry/src/Models/InquiryAttachment.php b/modules/_bundled/sirsoft-inquiry/src/Models/InquiryAttachment.php new file mode 100644 index 00000000..f9c7386d --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Models/InquiryAttachment.php @@ -0,0 +1,42 @@ + 'integer', + ]; + + public function inquiry(): BelongsTo + { + return $this->belongsTo(Inquiry::class); + } + + public function message(): BelongsTo + { + return $this->belongsTo(InquiryMessage::class, 'message_id'); + } + + public function uploader(): BelongsTo + { + return $this->belongsTo(User::class, 'uploader_user_id'); + } +} diff --git a/modules/_bundled/sirsoft-inquiry/src/Models/InquiryMessage.php b/modules/_bundled/sirsoft-inquiry/src/Models/InquiryMessage.php new file mode 100644 index 00000000..414028eb --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Models/InquiryMessage.php @@ -0,0 +1,49 @@ + SenderRole::class, + 'meta' => 'array', + 'read_at' => 'datetime', + ]; + + public function inquiry(): BelongsTo + { + return $this->belongsTo(Inquiry::class); + } + + public function sender(): BelongsTo + { + return $this->belongsTo(User::class, 'sender_user_id'); + } + + public function attachments(): HasMany + { + return $this->hasMany(InquiryAttachment::class, 'message_id'); + } + + public function isSystem(): bool + { + return $this->sender_role === SenderRole::System; + } +} diff --git a/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php b/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php index d362a28b..98724f64 100644 --- a/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php +++ b/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php @@ -7,6 +7,8 @@ use Illuminate\Support\Str; use Modules\Sirsoft\Inquiry\Enums\InquiryStatus; use Modules\Sirsoft\Inquiry\Models\Inquiry; +use Modules\Sirsoft\Inquiry\Models\InquiryAttachment; +use Modules\Sirsoft\Inquiry\Models\InquiryMessage; use Modules\Sirsoft\Inquiry\Models\InquiryQuote; use Modules\Sirsoft\Inquiry\Models\InquiryQuoteItem; use Tests\TestCase; @@ -61,4 +63,33 @@ public function test_quote_has_items_and_inquiry(): void $this->assertSame(1, $quote->items()->count()); $this->assertSame($inquiry->id, $quote->inquiry->id); } + + public function test_message_and_attachment(): void + { + $user = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', + 'status' => 'received', + ]); + $msg = $inquiry->messages()->create([ + 'sender_user_id' => $user->id, + 'sender_role' => 'client', + 'body' => '안녕하세요', + ]); + $att = $inquiry->attachments()->create([ + 'message_id' => $msg->id, + 'uploader_user_id' => $user->id, + 'disk' => 'local', + 'path' => 'inquiries/test.pdf', + 'original_name' => 'test.pdf', + 'mime' => 'application/pdf', + 'size' => 1234, + ]); + + $this->assertSame('client', $msg->sender_role->value); + $this->assertSame($msg->id, $att->message->id); + $this->assertSame(1, $msg->attachments()->count()); + } } From 135dbf22046394d7582b011b0d46abbf0e621447 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Fri, 22 May 2026 00:00:18 +0900 Subject: [PATCH 16/81] feat(inquiry): add domain exceptions --- .../Exceptions/InquiryNotFoundException.php | 13 +++++++++++++ .../InvalidStateTransitionException.php | 18 ++++++++++++++++++ .../src/Exceptions/QuoteNotFoundException.php | 13 +++++++++++++ tests/Feature/Modules/Inquiry/EnumsTest.php | 11 +++++++++++ 4 files changed, 55 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/src/Exceptions/InquiryNotFoundException.php create mode 100644 modules/_bundled/sirsoft-inquiry/src/Exceptions/InvalidStateTransitionException.php create mode 100644 modules/_bundled/sirsoft-inquiry/src/Exceptions/QuoteNotFoundException.php diff --git a/modules/_bundled/sirsoft-inquiry/src/Exceptions/InquiryNotFoundException.php b/modules/_bundled/sirsoft-inquiry/src/Exceptions/InquiryNotFoundException.php new file mode 100644 index 00000000..253083d0 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Exceptions/InquiryNotFoundException.php @@ -0,0 +1,13 @@ +value}' to inquiry in status '{$from->value}'.", + 422 + ); + } +} diff --git a/modules/_bundled/sirsoft-inquiry/src/Exceptions/QuoteNotFoundException.php b/modules/_bundled/sirsoft-inquiry/src/Exceptions/QuoteNotFoundException.php new file mode 100644 index 00000000..9b94b9ea --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Exceptions/QuoteNotFoundException.php @@ -0,0 +1,13 @@ +assertStringContainsString("received", $ex->getMessage()); + $this->assertStringContainsString("accept_and_pay", $ex->getMessage()); + $this->assertSame(422, $ex->getCode()); + } } From 7bca962fefdc4a1e2acffbc1a3312031d9aeadbb Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Fri, 22 May 2026 00:00:41 +0900 Subject: [PATCH 17/81] feat(inquiry): add domain events --- .../src/Events/InquiryMessagePosted.php | 16 ++++++++++++++ .../src/Events/InquiryStatusTransitioned.php | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/src/Events/InquiryMessagePosted.php create mode 100644 modules/_bundled/sirsoft-inquiry/src/Events/InquiryStatusTransitioned.php diff --git a/modules/_bundled/sirsoft-inquiry/src/Events/InquiryMessagePosted.php b/modules/_bundled/sirsoft-inquiry/src/Events/InquiryMessagePosted.php new file mode 100644 index 00000000..5adde8bc --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Events/InquiryMessagePosted.php @@ -0,0 +1,16 @@ + Date: Fri, 22 May 2026 00:01:14 +0900 Subject: [PATCH 18/81] feat(inquiry): add system message lang files (ko/en) --- .../sirsoft-inquiry/src/lang/en/system.php | 21 +++++++++++++++++++ .../sirsoft-inquiry/src/lang/ko/system.php | 21 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/src/lang/en/system.php create mode 100644 modules/_bundled/sirsoft-inquiry/src/lang/ko/system.php diff --git a/modules/_bundled/sirsoft-inquiry/src/lang/en/system.php b/modules/_bundled/sirsoft-inquiry/src/lang/en/system.php new file mode 100644 index 00000000..c804466e --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/lang/en/system.php @@ -0,0 +1,21 @@ + [ + 'received' => 'Received', + 'quoted' => 'Quoted', + 'in_progress' => 'In Progress', + 'completed' => 'Completed', + 'canceled' => 'Canceled', + ], + 'message' => [ + 'quote_issued' => 'Operator issued a quote (version #:version, total :total KRW).', + 'quote_revoked' => 'Operator revoked the quote (version #:version).', + 'quote_rejected' => 'Client rejected the quote (version #:version).', + 'payment_confirmed' => 'Payment has been confirmed.', + 'payment_confirmed_offline' => 'Operator manually confirmed the payment.', + 'completed' => 'Inquiry has been completed.', + 'canceled_by_client' => 'Client canceled the inquiry.', + 'canceled_by_operator' => 'Operator canceled the inquiry.', + ], +]; diff --git a/modules/_bundled/sirsoft-inquiry/src/lang/ko/system.php b/modules/_bundled/sirsoft-inquiry/src/lang/ko/system.php new file mode 100644 index 00000000..c78b8de6 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/lang/ko/system.php @@ -0,0 +1,21 @@ + [ + 'received' => '접수', + 'quoted' => '견적', + 'in_progress' => '진행', + 'completed' => '완료', + 'canceled' => '취소', + ], + 'message' => [ + 'quote_issued' => '운영자가 견적을 발행했습니다 (회차 #:version, 합계 :total원).', + 'quote_revoked' => '운영자가 견적을 철회했습니다 (회차 #:version).', + 'quote_rejected' => '의뢰자가 견적을 거절했습니다 (회차 #:version).', + 'payment_confirmed' => '결제가 확인되었습니다.', + 'payment_confirmed_offline' => '운영자가 결제를 수동 확인했습니다.', + 'completed' => '의뢰가 완료되었습니다.', + 'canceled_by_client' => '의뢰자가 의뢰를 취소했습니다.', + 'canceled_by_operator' => '운영자가 의뢰를 취소했습니다.', + ], +]; From 4f5581d8fe1357ddf44fa64373a3bc3fcf2e0083 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Fri, 22 May 2026 00:03:06 +0900 Subject: [PATCH 19/81] feat(inquiry): add InquiryRepository --- .../src/Providers/InquiryServiceProvider.php | 5 ++- .../Contracts/InquiryRepositoryInterface.php | 19 ++++++++ .../src/Repositories/InquiryRepository.php | 45 +++++++++++++++++++ .../Modules/Inquiry/ModelRelationshipTest.php | 15 +++++++ 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryRepositoryInterface.php create mode 100644 modules/_bundled/sirsoft-inquiry/src/Repositories/InquiryRepository.php diff --git a/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php b/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php index afb35df1..e4943919 100644 --- a/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php +++ b/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php @@ -12,7 +12,10 @@ class InquiryServiceProvider extends BaseModuleServiceProvider * Repository 인터페이스 → 구현체 매핑. * Task 16-19 에서 채워짐. */ - protected array $repositories = []; + protected array $repositories = [ + \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryRepositoryInterface::class + => \Modules\Sirsoft\Inquiry\Repositories\InquiryRepository::class, + ]; protected array $cacheServices = []; diff --git a/modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryRepositoryInterface.php b/modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryRepositoryInterface.php new file mode 100644 index 00000000..3c124822 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryRepositoryInterface.php @@ -0,0 +1,19 @@ +firstOr(fn () => throw new InquiryNotFoundException($uuid)); + } + + public function create(array $data): Inquiry + { + return Inquiry::create($data); + } + + public function update(Inquiry $inquiry, array $data): Inquiry + { + $inquiry->fill($data)->save(); + return $inquiry; + } + + public function listByUser(int $userId, ?string $status = null, int $perPage = 20): LengthAwarePaginator + { + return Inquiry::query() + ->where('user_id', $userId) + ->when($status, fn ($q) => $q->where('status', $status)) + ->orderByDesc('received_at') + ->paginate($perPage); + } + + public function listForAdmin(?string $status = null, ?string $search = null, int $perPage = 20): LengthAwarePaginator + { + return Inquiry::query() + ->when($status, fn ($q) => $q->where('status', $status)) + ->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%")) + ->orderByDesc('received_at') + ->paginate($perPage); + } +} diff --git a/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php b/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php index 98724f64..2722361a 100644 --- a/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php +++ b/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php @@ -92,4 +92,19 @@ public function test_message_and_attachment(): void $this->assertSame($msg->id, $att->message->id); $this->assertSame(1, $msg->attachments()->count()); } + + public function test_repository_find_and_create(): void + { + $repo = app(\Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryRepositoryInterface::class); + $user = User::factory()->create(); + $inquiry = $repo->create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => '리뉴얼', + 'content' => '본문', + 'status' => 'received', + ]); + $found = $repo->findByUuidOrFail($inquiry->uuid); + $this->assertTrue($found->is($inquiry)); + } } From 339f1e972c9de409fa217543edfac3aa7b2cc0c6 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Fri, 22 May 2026 00:03:53 +0900 Subject: [PATCH 20/81] feat(inquiry): add InquiryQuoteRepository (issue/expire/accept) --- .../src/Providers/InquiryServiceProvider.php | 2 + .../InquiryQuoteRepositoryInterface.php | 21 ++++++ .../Repositories/InquiryQuoteRepository.php | 72 +++++++++++++++++++ .../Modules/Inquiry/ModelRelationshipTest.php | 28 ++++++++ 4 files changed, 123 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryQuoteRepositoryInterface.php create mode 100644 modules/_bundled/sirsoft-inquiry/src/Repositories/InquiryQuoteRepository.php diff --git a/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php b/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php index e4943919..d236e925 100644 --- a/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php +++ b/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php @@ -15,6 +15,8 @@ class InquiryServiceProvider extends BaseModuleServiceProvider protected array $repositories = [ \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryRepositoryInterface::class => \Modules\Sirsoft\Inquiry\Repositories\InquiryRepository::class, + \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryQuoteRepositoryInterface::class + => \Modules\Sirsoft\Inquiry\Repositories\InquiryQuoteRepository::class, ]; protected array $cacheServices = []; diff --git a/modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryQuoteRepositoryInterface.php b/modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryQuoteRepositoryInterface.php new file mode 100644 index 00000000..9ebdd266 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryQuoteRepositoryInterface.php @@ -0,0 +1,21 @@ +expireActiveQuotes($inquiry); + + $nextVersion = ($inquiry->quotes()->max('version') ?? 0) + 1; + $quote = $inquiry->quotes()->create(array_merge($payload, [ + 'version' => $nextVersion, + 'status' => QuoteStatus::Issued->value, + 'issued_at' => now(), + 'currency' => $payload['currency'] ?? config('inquiry.quote.currency', 'KRW'), + ])); + + foreach ($items as $i => $item) { + $quote->items()->create(array_merge($item, [ + 'position' => $item['position'] ?? $i + 1, + ])); + } + + return $quote; + }); + } + + public function expireActiveQuotes(Inquiry $inquiry): int + { + return $inquiry->quotes() + ->where('status', QuoteStatus::Issued->value) + ->update(['status' => QuoteStatus::Expired->value]); + } + + public function markAccepted(InquiryQuote $quote): void + { + $quote->update([ + 'status' => QuoteStatus::Accepted->value, + 'accepted_at' => now(), + ]); + } + + public function markRejected(InquiryQuote $quote): void + { + $quote->update([ + 'status' => QuoteStatus::Rejected->value, + 'rejected_at' => now(), + ]); + } + + public function findActiveForInquiry(Inquiry $inquiry): ?InquiryQuote + { + return $inquiry->quotes() + ->where('status', QuoteStatus::Issued->value) + ->latest('version') + ->first(); + } + + public function findOrFail(int $id): InquiryQuote + { + return InquiryQuote::find($id) ?? throw new QuoteNotFoundException($id); + } +} diff --git a/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php b/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php index 2722361a..223f2cc9 100644 --- a/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php +++ b/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php @@ -107,4 +107,32 @@ public function test_repository_find_and_create(): void $found = $repo->findByUuidOrFail($inquiry->uuid); $this->assertTrue($found->is($inquiry)); } + + public function test_quote_repository_issue_creates_versioned_quote(): void + { + $repo = app(\Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryQuoteRepositoryInterface::class); + $inquiryRepo = app(\Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryRepositoryInterface::class); + $user = User::factory()->create(); + $inquiry = $inquiryRepo->create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + + $q1 = $repo->issue($inquiry, ['total_amount' => 1000000, 'tax_amount' => 0], [ + ['name' => 'A', 'qty' => 1, 'unit_price' => 1000000, 'amount' => 1000000], + ]); + $this->assertSame(1, $q1->version); + $this->assertSame('issued', $q1->status->value); + $this->assertSame(1, $q1->items()->count()); + + $expired = $repo->expireActiveQuotes($inquiry); + $this->assertSame(1, $expired); + $this->assertSame('expired', $q1->fresh()->status->value); + + $q2 = $repo->issue($inquiry, ['total_amount' => 1200000], [ + ['name' => 'A', 'qty' => 1, 'unit_price' => 1200000, 'amount' => 1200000], + ]); + $this->assertSame(2, $q2->version); + } } From b90e4ee7ca506fb071f49974dc70697e17c59f60 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Fri, 22 May 2026 00:04:40 +0900 Subject: [PATCH 21/81] feat(inquiry): add InquiryMessageRepository (with appendSystem) --- .../src/Providers/InquiryServiceProvider.php | 2 + .../InquiryMessageRepositoryInterface.php | 19 ++++++++ .../Repositories/InquiryMessageRepository.php | 44 +++++++++++++++++++ .../Modules/Inquiry/ModelRelationshipTest.php | 21 +++++++++ 4 files changed, 86 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryMessageRepositoryInterface.php create mode 100644 modules/_bundled/sirsoft-inquiry/src/Repositories/InquiryMessageRepository.php diff --git a/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php b/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php index d236e925..e746bc22 100644 --- a/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php +++ b/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php @@ -17,6 +17,8 @@ class InquiryServiceProvider extends BaseModuleServiceProvider => \Modules\Sirsoft\Inquiry\Repositories\InquiryRepository::class, \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryQuoteRepositoryInterface::class => \Modules\Sirsoft\Inquiry\Repositories\InquiryQuoteRepository::class, + \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryMessageRepositoryInterface::class + => \Modules\Sirsoft\Inquiry\Repositories\InquiryMessageRepository::class, ]; protected array $cacheServices = []; diff --git a/modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryMessageRepositoryInterface.php b/modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryMessageRepositoryInterface.php new file mode 100644 index 00000000..dab09ba3 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryMessageRepositoryInterface.php @@ -0,0 +1,19 @@ +messages()->create([ + 'sender_user_id' => $senderUserId, + 'sender_role' => $role->value, + 'body' => $body, + ]); + } + + public function appendSystem(Inquiry $inquiry, string $key, array $params = []): InquiryMessage + { + return $inquiry->messages()->create([ + 'sender_user_id' => null, + 'sender_role' => SenderRole::System->value, + 'body' => null, + 'meta' => ['key' => $key, 'params' => $params], + ]); + } + + public function listForInquiry(Inquiry $inquiry, int $perPage = 50): LengthAwarePaginator + { + return $inquiry->messages()->orderBy('created_at')->paginate($perPage); + } + + public function markReadFor(Inquiry $inquiry, SenderRole $oppositeRole): int + { + return $inquiry->messages() + ->where('sender_role', $oppositeRole->value) + ->whereNull('read_at') + ->update(['read_at' => now()]); + } +} diff --git a/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php b/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php index 223f2cc9..69ad2947 100644 --- a/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php +++ b/tests/Feature/Modules/Inquiry/ModelRelationshipTest.php @@ -135,4 +135,25 @@ public function test_quote_repository_issue_creates_versioned_quote(): void ]); $this->assertSame(2, $q2->version); } + + public function test_message_repository_append_and_system(): void + { + $repo = app(\Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryMessageRepositoryInterface::class); + $inquiryRepo = app(\Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryRepositoryInterface::class); + $user = User::factory()->create(); + $inquiry = $inquiryRepo->create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + + $msg = $repo->append($inquiry, $user->id, \Modules\Sirsoft\Inquiry\Enums\SenderRole::Client, '안녕하세요'); + $this->assertSame('client', $msg->sender_role->value); + + $sys = $repo->appendSystem($inquiry, 'inquiry::system.message.quote_issued', ['version' => 1, 'total' => '1,000,000']); + $this->assertSame('system', $sys->sender_role->value); + $this->assertNull($sys->body); + $this->assertSame('inquiry::system.message.quote_issued', $sys->meta['key']); + $this->assertSame(1, $sys->meta['params']['version']); + } } From 1d90da76f90e99574e62148378e861bfb35fe767 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Fri, 22 May 2026 00:06:59 +0900 Subject: [PATCH 22/81] feat(inquiry): add InquiryAttachmentRepository --- .../src/Providers/InquiryServiceProvider.php | 2 + .../InquiryAttachmentRepositoryInterface.php | 20 ++++++++++ .../InquiryAttachmentRepository.php | 39 +++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryAttachmentRepositoryInterface.php create mode 100644 modules/_bundled/sirsoft-inquiry/src/Repositories/InquiryAttachmentRepository.php diff --git a/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php b/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php index e746bc22..7ce55b10 100644 --- a/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php +++ b/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php @@ -19,6 +19,8 @@ class InquiryServiceProvider extends BaseModuleServiceProvider => \Modules\Sirsoft\Inquiry\Repositories\InquiryQuoteRepository::class, \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryMessageRepositoryInterface::class => \Modules\Sirsoft\Inquiry\Repositories\InquiryMessageRepository::class, + \Modules\Sirsoft\Inquiry\Repositories\Contracts\InquiryAttachmentRepositoryInterface::class + => \Modules\Sirsoft\Inquiry\Repositories\InquiryAttachmentRepository::class, ]; protected array $cacheServices = []; diff --git a/modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryAttachmentRepositoryInterface.php b/modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryAttachmentRepositoryInterface.php new file mode 100644 index 00000000..a54d57e9 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Repositories/Contracts/InquiryAttachmentRepositoryInterface.php @@ -0,0 +1,20 @@ +update(['message_id' => $message->id]); + } + + public function findOrFail(int $id): InquiryAttachment + { + return InquiryAttachment::findOrFail($id); + } + + public function listOrphansOlderThanMinutes(int $minutes): Collection + { + return InquiryAttachment::query() + ->whereNull('message_id') + ->where('created_at', '<', now()->subMinutes($minutes)) + ->get(); + } + + public function delete(InquiryAttachment $attachment): void + { + $attachment->delete(); + } +} From 54b15d8d97f3a9cdf92417200affb40172817a80 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Fri, 22 May 2026 00:11:00 +0900 Subject: [PATCH 23/81] feat(inquiry): add StateMachine with issue_quote transition Co-Authored-By: Claude Sonnet 4.6 --- .../src/Services/InquiryStateMachine.php | 68 +++++++++++++++++++ .../Modules/Inquiry/StateMachineTest.php | 52 ++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/src/Services/InquiryStateMachine.php create mode 100644 tests/Feature/Modules/Inquiry/StateMachineTest.php diff --git a/modules/_bundled/sirsoft-inquiry/src/Services/InquiryStateMachine.php b/modules/_bundled/sirsoft-inquiry/src/Services/InquiryStateMachine.php new file mode 100644 index 00000000..a4379e97 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Services/InquiryStateMachine.php @@ -0,0 +1,68 @@ +, to: InquiryStatus, systemKey: string, timestampColumn: ?string}> */ + private array $rules; + + public function __construct( + private readonly InquiryMessageRepositoryInterface $messages, + ) { + $this->rules = [ + TransitionEvent::IssueQuote->value => [ + 'from' => [InquiryStatus::Received], + 'to' => InquiryStatus::Quoted, + 'systemKey' => 'inquiry::system.message.quote_issued', + 'timestampColumn' => 'quoted_at', + ], + ]; + } + + public function transition(Inquiry $inquiry, TransitionEvent $event, ?int $actorUserId = null, array $payload = []): Inquiry + { + $rule = $this->rules[$event->value] + ?? throw new InvalidStateTransitionException($inquiry->status, $event); + + if (! in_array($inquiry->status, $rule['from'], true)) { + throw new InvalidStateTransitionException($inquiry->status, $event); + } + + $from = $inquiry->status; + $to = $rule['to']; + + DB::transaction(function () use ($inquiry, $rule, $to, $payload) { + $inquiry->status = $to->value; + if ($rule['timestampColumn']) { + $inquiry->{$rule['timestampColumn']} = now(); + } + $inquiry->save(); + + $params = $this->systemMessageParams($payload); + $this->messages->appendSystem($inquiry, $rule['systemKey'], $params); + }); + + InquiryStatusTransitioned::dispatch($inquiry, $from, $to, $event, $actorUserId); + + return $inquiry; + } + + private function systemMessageParams(array $payload): array + { + return array_filter([ + 'version' => $payload['quote_version'] ?? null, + 'total' => isset($payload['quote_total']) ? number_format($payload['quote_total']) : null, + 'order' => $payload['order_uuid'] ?? null, + 'actor' => $payload['actor'] ?? null, + ], fn ($v) => $v !== null); + } +} diff --git a/tests/Feature/Modules/Inquiry/StateMachineTest.php b/tests/Feature/Modules/Inquiry/StateMachineTest.php new file mode 100644 index 00000000..760610c4 --- /dev/null +++ b/tests/Feature/Modules/Inquiry/StateMachineTest.php @@ -0,0 +1,52 @@ +create(); + return Inquiry::create(array_merge([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', + 'status' => 'received', + ], $overrides)); + } + + public function test_issue_quote_transitions_received_to_quoted_and_emits_system_message(): void + { + Event::fake([InquiryStatusTransitioned::class]); + $sm = app(InquiryStateMachine::class); + $inquiry = $this->makeInquiry(); + + $sm->transition($inquiry, TransitionEvent::IssueQuote, actorUserId: 1, payload: ['quote_version' => 1, 'quote_total' => 1000000]); + + $inquiry->refresh(); + $this->assertSame(InquiryStatus::Quoted, $inquiry->status); + $this->assertNotNull($inquiry->quoted_at); + + $sys = $inquiry->messages()->where('sender_role', 'system')->first(); + $this->assertNotNull($sys); + $this->assertSame('inquiry::system.message.quote_issued', $sys->meta['key']); + + Event::assertDispatched(InquiryStatusTransitioned::class, fn ($e) => + $e->from === InquiryStatus::Received && $e->to === InquiryStatus::Quoted + ); + } +} From 28330f99fb7a0318e2d6e9825eaf5ec6437992f0 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Fri, 22 May 2026 00:11:49 +0900 Subject: [PATCH 24/81] feat(inquiry): complete StateMachine transitions (revoke/reject/accept/offline/complete/cancel) Co-Authored-By: Claude Sonnet 4.6 --- .../src/Services/InquiryStateMachine.php | 45 +++++++++++++++- .../Modules/Inquiry/StateMachineTest.php | 53 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/modules/_bundled/sirsoft-inquiry/src/Services/InquiryStateMachine.php b/modules/_bundled/sirsoft-inquiry/src/Services/InquiryStateMachine.php index a4379e97..5bd6b915 100644 --- a/modules/_bundled/sirsoft-inquiry/src/Services/InquiryStateMachine.php +++ b/modules/_bundled/sirsoft-inquiry/src/Services/InquiryStateMachine.php @@ -25,6 +25,42 @@ public function __construct( 'systemKey' => 'inquiry::system.message.quote_issued', 'timestampColumn' => 'quoted_at', ], + TransitionEvent::RevokeQuote->value => [ + 'from' => [InquiryStatus::Quoted], + 'to' => InquiryStatus::Received, + 'systemKey' => 'inquiry::system.message.quote_revoked', + 'timestampColumn' => null, + ], + TransitionEvent::RejectQuote->value => [ + 'from' => [InquiryStatus::Quoted], + 'to' => InquiryStatus::Received, + 'systemKey' => 'inquiry::system.message.quote_rejected', + 'timestampColumn' => null, + ], + TransitionEvent::AcceptAndPay->value => [ + 'from' => [InquiryStatus::Quoted], + 'to' => InquiryStatus::InProgress, + 'systemKey' => 'inquiry::system.message.payment_confirmed', + 'timestampColumn' => 'started_at', + ], + TransitionEvent::MarkPaidOffline->value => [ + 'from' => [InquiryStatus::Quoted], + 'to' => InquiryStatus::InProgress, + 'systemKey' => 'inquiry::system.message.payment_confirmed_offline', + 'timestampColumn' => 'started_at', + ], + TransitionEvent::MarkCompleted->value => [ + 'from' => [InquiryStatus::InProgress], + 'to' => InquiryStatus::Completed, + 'systemKey' => 'inquiry::system.message.completed', + 'timestampColumn' => 'completed_at', + ], + TransitionEvent::Cancel->value => [ + 'from' => [InquiryStatus::Received, InquiryStatus::Quoted, InquiryStatus::InProgress], + 'to' => InquiryStatus::Canceled, + 'systemKey' => 'inquiry::system.message.canceled_by_client', // payload['actor']로 분기는 systemMessageParams에서 + 'timestampColumn' => 'canceled_at', + ], ]; } @@ -40,7 +76,12 @@ public function transition(Inquiry $inquiry, TransitionEvent $event, ?int $actor $from = $inquiry->status; $to = $rule['to']; - DB::transaction(function () use ($inquiry, $rule, $to, $payload) { + $systemKey = $rule['systemKey']; + if ($event === TransitionEvent::Cancel && ($payload['actor'] ?? null) === 'operator') { + $systemKey = 'inquiry::system.message.canceled_by_operator'; + } + + DB::transaction(function () use ($inquiry, $rule, $to, $payload, $systemKey) { $inquiry->status = $to->value; if ($rule['timestampColumn']) { $inquiry->{$rule['timestampColumn']} = now(); @@ -48,7 +89,7 @@ public function transition(Inquiry $inquiry, TransitionEvent $event, ?int $actor $inquiry->save(); $params = $this->systemMessageParams($payload); - $this->messages->appendSystem($inquiry, $rule['systemKey'], $params); + $this->messages->appendSystem($inquiry, $systemKey, $params); }); InquiryStatusTransitioned::dispatch($inquiry, $from, $to, $event, $actorUserId); diff --git a/tests/Feature/Modules/Inquiry/StateMachineTest.php b/tests/Feature/Modules/Inquiry/StateMachineTest.php index 760610c4..60f3fad2 100644 --- a/tests/Feature/Modules/Inquiry/StateMachineTest.php +++ b/tests/Feature/Modules/Inquiry/StateMachineTest.php @@ -49,4 +49,57 @@ public function test_issue_quote_transitions_received_to_quoted_and_emits_system $e->from === InquiryStatus::Received && $e->to === InquiryStatus::Quoted ); } + + public function test_revoke_quote_back_to_received(): void + { + $sm = app(InquiryStateMachine::class); + $inquiry = $this->makeInquiry(['status' => 'quoted', 'quoted_at' => now()]); + $sm->transition($inquiry, TransitionEvent::RevokeQuote, 1, ['quote_version' => 1]); + $this->assertSame(InquiryStatus::Received, $inquiry->refresh()->status); + } + + public function test_reject_quote_back_to_received(): void + { + $sm = app(InquiryStateMachine::class); + $inquiry = $this->makeInquiry(['status' => 'quoted', 'quoted_at' => now()]); + $sm->transition($inquiry, TransitionEvent::RejectQuote, 1, ['quote_version' => 1]); + $this->assertSame(InquiryStatus::Received, $inquiry->refresh()->status); + } + + public function test_accept_and_pay_to_in_progress(): void + { + $sm = app(InquiryStateMachine::class); + $inquiry = $this->makeInquiry(['status' => 'quoted', 'quoted_at' => now()]); + $sm->transition($inquiry, TransitionEvent::AcceptAndPay, null, ['order_uuid' => 'order-xyz']); + $this->assertSame(InquiryStatus::InProgress, $inquiry->refresh()->status); + $this->assertNotNull($inquiry->started_at); + } + + public function test_mark_paid_offline_to_in_progress(): void + { + $sm = app(InquiryStateMachine::class); + $inquiry = $this->makeInquiry(['status' => 'quoted', 'quoted_at' => now()]); + $sm->transition($inquiry, TransitionEvent::MarkPaidOffline, 1); + $this->assertSame(InquiryStatus::InProgress, $inquiry->refresh()->status); + } + + public function test_mark_completed_from_in_progress(): void + { + $sm = app(InquiryStateMachine::class); + $inquiry = $this->makeInquiry(['status' => 'in_progress', 'started_at' => now()]); + $sm->transition($inquiry, TransitionEvent::MarkCompleted, 1); + $this->assertSame(InquiryStatus::Completed, $inquiry->refresh()->status); + $this->assertNotNull($inquiry->completed_at); + } + + public function test_cancel_from_any_active_state(): void + { + $sm = app(InquiryStateMachine::class); + foreach (['received', 'quoted', 'in_progress'] as $from) { + $inquiry = $this->makeInquiry(['status' => $from]); + $sm->transition($inquiry, TransitionEvent::Cancel, 1, ['actor' => 'client']); + $this->assertSame(InquiryStatus::Canceled, $inquiry->refresh()->status, "from {$from}"); + $this->assertNotNull($inquiry->canceled_at); + } + } } From 644af5850d34b1f67596f84e6fe0577c6d7e7b6d Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Fri, 22 May 2026 00:12:09 +0900 Subject: [PATCH 25/81] test(inquiry): cover illegal state transitions Co-Authored-By: Claude Sonnet 4.6 --- .../Modules/Inquiry/StateMachineTest.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Feature/Modules/Inquiry/StateMachineTest.php b/tests/Feature/Modules/Inquiry/StateMachineTest.php index 60f3fad2..f5fe1eb5 100644 --- a/tests/Feature/Modules/Inquiry/StateMachineTest.php +++ b/tests/Feature/Modules/Inquiry/StateMachineTest.php @@ -102,4 +102,27 @@ public function test_cancel_from_any_active_state(): void $this->assertNotNull($inquiry->canceled_at); } } + + public function test_invalid_transition_throws(): void + { + $sm = app(InquiryStateMachine::class); + $inquiry = $this->makeInquiry(['status' => 'received']); + + $this->expectException(\Modules\Sirsoft\Inquiry\Exceptions\InvalidStateTransitionException::class); + $sm->transition($inquiry, TransitionEvent::AcceptAndPay); + } + + public function test_cannot_transition_from_terminal_states(): void + { + $sm = app(InquiryStateMachine::class); + foreach (['completed', 'canceled'] as $terminal) { + $inquiry = $this->makeInquiry(['status' => $terminal]); + try { + $sm->transition($inquiry, TransitionEvent::IssueQuote); + $this->fail("Expected exception from terminal state {$terminal}"); + } catch (\Modules\Sirsoft\Inquiry\Exceptions\InvalidStateTransitionException $e) { + $this->assertSame(422, $e->getCode()); + } + } + } } From 3112d5508d96f4c005d3c2e7fd475d85c519e78e Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Fri, 22 May 2026 00:15:07 +0900 Subject: [PATCH 26/81] feat(inquiry): add InquiryAttachmentStorage service (mime/size validation) Co-Authored-By: Claude Sonnet 4.6 --- .../src/Services/InquiryAttachmentStorage.php | 49 +++++++++++++ .../Modules/Inquiry/AttachmentStorageTest.php | 72 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/src/Services/InquiryAttachmentStorage.php create mode 100644 tests/Feature/Modules/Inquiry/AttachmentStorageTest.php diff --git a/modules/_bundled/sirsoft-inquiry/src/Services/InquiryAttachmentStorage.php b/modules/_bundled/sirsoft-inquiry/src/Services/InquiryAttachmentStorage.php new file mode 100644 index 00000000..7fd5ac2b --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Services/InquiryAttachmentStorage.php @@ -0,0 +1,49 @@ +getMimeType() ?? $file->getClientMimeType(); + $allowed = config('inquiry.attachment.allowed_mimes', []); + if (! in_array($mime, $allowed, true)) { + throw new InvalidArgumentException("Disallowed mime: {$mime}"); + } + + $maxKey = $context === 'inquiry' ? 'max_size_inquiry' : 'max_size_message'; + $maxBytes = (int) config("inquiry.attachment.{$maxKey}"); + if ($file->getSize() > $maxBytes) { + throw new InvalidArgumentException("File too large: {$file->getSize()} > {$maxBytes}"); + } + + $disk = config('inquiry.attachment.disk', 'local'); + $path = $file->store("inquiries/{$inquiry->uuid}", $disk); + + return $this->attachments->create([ + 'inquiry_id' => $inquiry->id, + 'message_id' => null, + 'uploader_user_id' => $uploaderUserId, + 'disk' => $disk, + 'path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'mime' => $mime, + 'size' => $file->getSize(), + ]); + } +} diff --git a/tests/Feature/Modules/Inquiry/AttachmentStorageTest.php b/tests/Feature/Modules/Inquiry/AttachmentStorageTest.php new file mode 100644 index 00000000..37e1e6d9 --- /dev/null +++ b/tests/Feature/Modules/Inquiry/AttachmentStorageTest.php @@ -0,0 +1,72 @@ +create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + $file = UploadedFile::fake()->create('plan.pdf', 100, 'application/pdf'); + + $att = $svc->store($inquiry, $user->id, $file, context: 'message'); + + $this->assertSame('application/pdf', $att->mime); + $this->assertSame('plan.pdf', $att->original_name); + Storage::disk('local')->assertExists($att->path); + } + + public function test_store_rejects_disallowed_mime(): void + { + Storage::fake('local'); + $svc = app(InquiryAttachmentStorage::class); + $user = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + $file = UploadedFile::fake()->create('bad.exe', 10, 'application/x-msdownload'); + + $this->expectException(\InvalidArgumentException::class); + $svc->store($inquiry, $user->id, $file, context: 'message'); + } + + public function test_store_rejects_oversize_file(): void + { + config(['inquiry.attachment.max_size_message' => 1024]); // 1KB + Storage::fake('local'); + $svc = app(InquiryAttachmentStorage::class); + $user = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) \Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + $file = UploadedFile::fake()->create('big.pdf', 10, 'application/pdf'); // 10KB + + $this->expectException(\InvalidArgumentException::class); + $svc->store($inquiry, $user->id, $file, context: 'message'); + } +} From 28fca3b6d78e83d559220318a8872c3105fa8d5f Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Fri, 22 May 2026 00:17:21 +0900 Subject: [PATCH 27/81] feat(inquiry): add InquiryPolicy with permission matrix Co-Authored-By: Claude Sonnet 4.6 --- .../src/Policies/InquiryPolicy.php | 87 +++++++++++++++ .../src/Providers/InquiryServiceProvider.php | 10 ++ tests/Feature/Modules/Inquiry/PolicyTest.php | 104 ++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 modules/_bundled/sirsoft-inquiry/src/Policies/InquiryPolicy.php create mode 100644 tests/Feature/Modules/Inquiry/PolicyTest.php diff --git a/modules/_bundled/sirsoft-inquiry/src/Policies/InquiryPolicy.php b/modules/_bundled/sirsoft-inquiry/src/Policies/InquiryPolicy.php new file mode 100644 index 00000000..7c7f8e74 --- /dev/null +++ b/modules/_bundled/sirsoft-inquiry/src/Policies/InquiryPolicy.php @@ -0,0 +1,87 @@ +isOwner($user, $inquiry) || $this->isOperator($user); + } + + public function update(User $user, Inquiry $inquiry): bool + { + if ($this->isOperator($user)) { + return true; + } + return $this->isOwner($user, $inquiry) && $inquiry->status === InquiryStatus::Received; + } + + public function cancel(User $user, Inquiry $inquiry): bool + { + if ($this->isOperator($user)) { + return ! in_array($inquiry->status, [InquiryStatus::Completed, InquiryStatus::Canceled], true); + } + return $this->isOwner($user, $inquiry) + && in_array($inquiry->status, [InquiryStatus::Received, InquiryStatus::Quoted], true); + } + + public function issueQuote(User $user, Inquiry $inquiry): bool + { + return $this->isOperator($user); + } + + public function revokeQuote(User $user, Inquiry $inquiry): bool + { + return $this->isOperator($user) && $inquiry->accepted_quote_id === null; + } + + public function acceptQuote(User $user, Inquiry $inquiry): bool + { + return $this->isOwner($user, $inquiry) && $inquiry->status === InquiryStatus::Quoted; + } + + public function rejectQuote(User $user, Inquiry $inquiry): bool + { + return $this->acceptQuote($user, $inquiry); + } + + public function markPaidOffline(User $user, Inquiry $inquiry): bool + { + return $this->isOperator($user); + } + + public function markCompleted(User $user, Inquiry $inquiry): bool + { + return $this->isOperator($user); + } + + public function postMessage(User $user, Inquiry $inquiry): bool + { + return $this->view($user, $inquiry); + } + + public function viewAttachment(User $user, Inquiry $inquiry): bool + { + return $this->view($user, $inquiry); + } + + public function uploadAttachment(User $user, Inquiry $inquiry): bool + { + return $this->view($user, $inquiry); + } + + private function isOwner(User $user, Inquiry $inquiry): bool + { + return $user->id === $inquiry->user_id; + } + + private function isOperator(User $user): bool + { + return $user->can(config('inquiry.permissions.manage', 'inquiry.manage')); + } +} diff --git a/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php b/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php index 7ce55b10..c97eee3d 100644 --- a/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php +++ b/modules/_bundled/sirsoft-inquiry/src/Providers/InquiryServiceProvider.php @@ -36,4 +36,14 @@ public function register(): void 'inquiry' ); } + + public function boot(): void + { + parent::boot(); + + \Illuminate\Support\Facades\Gate::policy( + \Modules\Sirsoft\Inquiry\Models\Inquiry::class, + \Modules\Sirsoft\Inquiry\Policies\InquiryPolicy::class, + ); + } } diff --git a/tests/Feature/Modules/Inquiry/PolicyTest.php b/tests/Feature/Modules/Inquiry/PolicyTest.php new file mode 100644 index 00000000..03abca01 --- /dev/null +++ b/tests/Feature/Modules/Inquiry/PolicyTest.php @@ -0,0 +1,104 @@ + 'inquiry.manage'], + ['name' => ['ko' => '제작의뢰 관리'], 'type' => 'admin'] + ); + Permission::firstOrCreate( + ['identifier' => 'inquiry.notify'], + ['name' => ['ko' => '제작의뢰 알림'], 'type' => 'admin'] + ); + } + + private function makeUserAndInquiry(string $status = 'received'): array + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $operator = User::factory()->create(); + + // 운영자 역할 생성 및 inquiry.manage 권한 부여 + $role = Role::create([ + 'identifier' => 'inquiry-operator-' . Str::random(6), + 'name' => ['ko' => '의뢰 운영자'], + 'is_active' => true, + ]); + + $permission = Permission::where('identifier', 'inquiry.manage')->first(); + $role->permissions()->attach($permission->id, [ + 'granted_at' => now(), + ]); + + $operator->roles()->attach($role->id, [ + 'assigned_at' => now(), + ]); + + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), + 'user_id' => $owner->id, + 'title' => 'X', 'content' => 'Y', + 'status' => $status, + ]); + + return compact('owner', 'other', 'operator', 'inquiry'); + } + + public function test_owner_can_view_others_cannot(): void + { + ['owner' => $owner, 'other' => $other, 'inquiry' => $inquiry] = $this->makeUserAndInquiry(); + $this->assertTrue($owner->can('view', $inquiry)); + $this->assertFalse($other->can('view', $inquiry)); + } + + public function test_operator_can_view(): void + { + ['operator' => $op, 'inquiry' => $inquiry] = $this->makeUserAndInquiry(); + $this->assertTrue($op->can('view', $inquiry)); + } + + public function test_owner_can_update_only_in_received(): void + { + ['owner' => $owner, 'inquiry' => $inquiry] = $this->makeUserAndInquiry('received'); + $this->assertTrue($owner->can('update', $inquiry)); + + $inquiry->update(['status' => 'quoted']); + $this->assertFalse($owner->can('update', $inquiry->fresh())); + } + + public function test_only_operator_can_issue_quote(): void + { + ['owner' => $owner, 'operator' => $op, 'inquiry' => $inquiry] = $this->makeUserAndInquiry('received'); + $this->assertFalse($owner->can('issueQuote', $inquiry)); + $this->assertTrue($op->can('issueQuote', $inquiry)); + } + + public function test_owner_can_accept_quote_only_in_quoted(): void + { + ['owner' => $owner, 'inquiry' => $inquiry] = $this->makeUserAndInquiry('received'); + $this->assertFalse($owner->can('acceptQuote', $inquiry)); + $inquiry->update(['status' => 'quoted']); + $this->assertTrue($owner->can('acceptQuote', $inquiry->fresh())); + } +} From 131e3060e7d37befc9150845ba470fa52060b457 Mon Sep 17 00:00:00 2001 From: Ryan Heo Date: Fri, 22 May 2026 00:34:47 +0900 Subject: [PATCH 28/81] docs(plan): add sirsoft-inquiry API + frontend implementation plan (Plan 2/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 2/4 — 21 TDD tasks covering Public REST API (9 routes), 3 frontend composites (StatusBar / Card / MessageThread), and 3 user-facing layouts (index / new / show + cancel modal partial). Quote accept/pay, admin screens, and notifications are scheduled for Plans 3-4. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...1\354\235\230\353\242\260-api-frontend.md" | 2633 +++++++++++++++++ 1 file changed, 2633 insertions(+) create mode 100644 "docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-api-frontend.md" diff --git "a/docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-api-frontend.md" "b/docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-api-frontend.md" new file mode 100644 index 00000000..7edef8ea --- /dev/null +++ "b/docs/superpowers/plans/2026-05-21-\354\240\234\354\236\221\354\235\230\353\242\260-api-frontend.md" @@ -0,0 +1,2633 @@ +# 제작의뢰 모듈 — Backend API + 사용자 프론트 (Plan 2/4) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Plan 1 백엔드 기반 위에 의뢰자(클라이언트)가 사용하는 Public REST API와 sirsoft-basic 템플릿의 사용자 화면(목록·작성·상세+채팅) 세 페이지를 추가하여, 의뢰 접수와 메시지·첨부 소통이 실제로 동작하는 상태에 도달. + +**Architecture:** Spec §11.1 의 Public 라우트 11개 중 견적 accept/reject 2개(Plan 3)를 제외한 9개를 구현. Controller 3개(Inquiry/Message/Attachment) + Form Request 4개 + Resource 5개 + Frontend composite 3개 + Layout JSON 3개. 모든 상태 변경은 Plan 1 의 `InquiryStateMachine` / `InquiryPolicy` / Repositories 를 통과. 견적 카드는 read-only placeholder(Plan 3에서 활성). + +**Tech Stack:** Laravel 11 / Sanctum / Eloquent API Resource / React 18 + sirsoft-basic composite components / layout JSON DSL. + +**Spec:** `docs/superpowers/specs/2026-05-20-제작의뢰-design.md` +**Plan 1:** `docs/superpowers/plans/2026-05-21-제작의뢰-backend-foundation.md` (완료, PR #42) + +--- + +## Module Routing Pattern (확정 사실) + +`app/Providers/ModuleRouteServiceProvider.php:110` 가 모든 모듈 routes/api.php 를 `api/modules/{module}` prefix 안에서 group 등록한다. 따라서 inquiry 모듈의 `src/routes/api.php` 안에서는 `Route::prefix('inquiries')->...` 만 선언하면 실제 URL은 `/api/modules/sirsoft-inquiry/inquiries/...` 가 된다. + +`module.php` 에서 `getRoutes()` 메서드로 routes 파일 경로를 노출해야 ModuleRouteServiceProvider 가 발견한다. board 모듈 패턴: + +```php +public function getRoutes(): array +{ + return [ + 'api' => $this->getModulePath() . '/src/routes/api.php', + ]; +} +``` + +Plan 1에서 만든 `modules/_bundled/sirsoft-inquiry/module.php` 는 빈 골격(getRoutes 미정의). Task 9에서 이 메서드를 추가한다. + +--- + +## File Structure + +``` +modules/_bundled/sirsoft-inquiry/ + module.php # MODIFY: add getRoutes() + src/ + Http/ + Controllers/ + User/ + InquiryController.php # index/show/store/update/cancel + InquiryMessageController.php # index/store + InquiryAttachmentController.php # upload (inquiry body & message), download + Requests/ + StoreInquiryRequest.php + UpdateInquiryRequest.php + StoreInquiryMessageRequest.php + UploadInquiryAttachmentRequest.php + Resources/ + InquiryResource.php + InquiryQuoteResource.php + InquiryQuoteItemResource.php + InquiryMessageResource.php + InquiryAttachmentResource.php + routes/ + api.php # Public route group + +tests/Feature/Modules/Inquiry/Api/ + InquiryCrudTest.php + InquiryMessageTest.php + InquiryAttachmentTest.php + InquiryFlowTest.php + +templates/_bundled/sirsoft-basic/ + src/components/composite/ + InquiryStatusBar.tsx + InquiryCard.tsx + InquiryMessageThread.tsx + index.ts # MODIFY: export new composites + layouts/inquiry/ + index.json + new.json + show.json + partials/ + _modal_inquiry_cancel.json + routes.json # MODIFY: register 3 inquiry routes +``` + +**파일 책임 요약** +- `Controllers/User/Inquiry*`: HTTP 진입점. Form Request에서 검증 후 Repository/StateMachine 위임. 직접 Eloquent 호출 금지. +- `Requests/*`: 입력 검증 + Policy 호출. +- `Resources/*`: 직렬화. 사용자 권한에 따라 표시 필드 결정(`is_owner`, `abilities.*`). +- Composites: stateless presentational + 최소 local state. 데이터·액션은 layout JSON DSL이 props로 주입. +- Layouts: 데이터 소스 + 슬롯 구성 + 액션 시퀀스. 직접 fetch 금지(서버 통신은 `dataSource` + `apiCall`). + +--- + +## Pre-check (작업 전 1회) + +- [ ] **Pre-1: Plan 1 commit 위에서 시작** + +```bash +git branch --show-current +git log --oneline | head -5 +``` + +기대: 현재 branch가 `feature/sirsoft-inquiry-foundation` 의 head(또는 그 위) 거나, 별도 branch 생성된 경우 그 base 가 같음. + +- [ ] **Pre-2: 테스트 환경 확인** + +```bash +php artisan test --filter="Modules\\\\Inquiry" 2>&1 | tail -3 +``` + +기대: 31 tests pass (Plan 1 회귀). + +- [ ] **Pre-3: board User controller·Form Request·Resource·routes 예시 미리 훑기** + +작업 중 참고할 파일들: +- `modules/_bundled/sirsoft-board/src/Http/Controllers/User/BoardController.php` +- `modules/_bundled/sirsoft-board/src/Http/Requests/StorePostRequest.php` +- `modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php` +- `modules/_bundled/sirsoft-board/src/routes/api.php` + +특히 인증 미들웨어(`auth:sanctum` vs `optional.sanctum`), throttle, Policy 호출 시점 패턴을 동일하게 따른다. + +--- + +## Task 1: Resources — Message + Attachment + +**Files:** +- Create: `src/Http/Resources/InquiryMessageResource.php` +- Create: `src/Http/Resources/InquiryAttachmentResource.php` + +- [ ] **Step 1: 실패 테스트 작성** + +`tests/Feature/Modules/Inquiry/Api/ResourceShapeTest.php`: + +```php +create(); + return Inquiry::create([ + 'uuid' => (string) Str::uuid(), + 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', + 'status' => 'received', + ]); + } + + public function test_message_resource_shape(): void + { + $inquiry = $this->makeInquiry(); + $msg = $inquiry->messages()->create([ + 'sender_user_id' => $inquiry->user_id, + 'sender_role' => 'client', + 'body' => '안녕하세요', + ]); + + $array = (new InquiryMessageResource($msg))->resolve(); + + $this->assertSame($msg->id, $array['id']); + $this->assertSame('client', $array['sender_role']); + $this->assertSame('안녕하세요', $array['body']); + $this->assertArrayHasKey('created_at', $array); + $this->assertNull($array['meta']); + } + + public function test_attachment_resource_shape(): void + { + $inquiry = $this->makeInquiry(); + $att = $inquiry->attachments()->create([ + 'uploader_user_id' => $inquiry->user_id, + 'disk' => 'local', + 'path' => 'inquiries/x/plan.pdf', + 'original_name' => 'plan.pdf', + 'mime' => 'application/pdf', + 'size' => 1234, + ]); + + $array = (new InquiryAttachmentResource($att))->resolve(); + + $this->assertSame($att->id, $array['id']); + $this->assertSame('plan.pdf', $array['original_name']); + $this->assertSame('application/pdf', $array['mime']); + $this->assertSame(1234, $array['size']); + $this->assertStringContainsString("/api/modules/sirsoft-inquiry/attachments/{$att->id}", $array['download_url']); + } +} +``` + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +```bash +php artisan test --filter=ResourceShapeTest 2>&1 | tail -10 +``` + +기대: "Class ... not found" (2건). + +- [ ] **Step 3: Resource 작성** + +`src/Http/Resources/InquiryMessageResource.php`: + +```php + $this->id, + 'inquiry_id' => $this->inquiry_id, + 'sender_user_id' => $this->sender_user_id, + 'sender_role' => $this->sender_role?->value, + 'body' => $this->body, + 'meta' => $this->meta, + 'read_at' => $this->read_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'attachments' => InquiryAttachmentResource::collection($this->whenLoaded('attachments')), + ]; + } +} +``` + +`src/Http/Resources/InquiryAttachmentResource.php`: + +```php + $this->id, + 'inquiry_id' => $this->inquiry_id, + 'message_id' => $this->message_id, + 'original_name' => $this->original_name, + 'mime' => $this->mime, + 'size' => $this->size, + 'download_url' => url("/api/modules/sirsoft-inquiry/attachments/{$this->id}"), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} +``` + +- [ ] **Step 4: 테스트 실행 — 성공** + +```bash +php artisan test --filter=ResourceShapeTest 2>&1 | tail -10 +``` + +기대: 2 passes. + +- [ ] **Step 5: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Http/Resources/InquiryMessageResource.php \ + modules/_bundled/sirsoft-inquiry/src/Http/Resources/InquiryAttachmentResource.php \ + tests/Feature/Modules/Inquiry/Api/ResourceShapeTest.php +git commit -m "feat(inquiry): add Message + Attachment API resources" +``` + +--- + +## Task 2: Resources — Quote + QuoteItem (read-only for Plan 2) + +**Files:** +- Create: `src/Http/Resources/InquiryQuoteItemResource.php` +- Create: `src/Http/Resources/InquiryQuoteResource.php` + +견적 발행/수락 액션은 Plan 3 범위이지만, 의뢰 상세 응답에 견적 이력을 read-only 로 노출해 두면 Plan 3에서 액션만 추가하면 된다. + +- [ ] **Step 1: 테스트 추가** (`ResourceShapeTest::test_quote_resource_shape`): + +```php +public function test_quote_resource_shape(): void +{ + $inquiry = $this->makeInquiry(); + $quote = $inquiry->quotes()->create([ + 'version' => 1, + 'total_amount' => 1000000, + 'tax_amount' => 0, + 'currency' => 'KRW', + 'status' => 'issued', + 'issued_at' => now(), + ]); + $quote->items()->create([ + 'position' => 1, + 'name' => '메인 페이지 디자인', + 'qty' => 1, + 'unit_price' => 1000000, + 'amount' => 1000000, + ]); + + $array = (new \Modules\Sirsoft\Inquiry\Http\Resources\InquiryQuoteResource($quote->load('items')))->resolve(); + + $this->assertSame(1, $array['version']); + $this->assertSame('issued', $array['status']); + $this->assertSame('1000000', (string) $array['total_amount']); + $this->assertCount(1, $array['items']); + $this->assertSame('메인 페이지 디자인', $array['items'][0]['name']); +} +``` + +```bash +php artisan test --filter=test_quote_resource_shape 2>&1 | tail -10 +``` + +기대: 실패. + +- [ ] **Step 2: Resource 작성** + +`src/Http/Resources/InquiryQuoteItemResource.php`: + +```php + $this->id, + 'position' => $this->position, + 'name' => $this->name, + 'description' => $this->description, + 'qty' => (string) $this->qty, + 'unit_price' => (string) $this->unit_price, + 'amount' => (string) $this->amount, + ]; + } +} +``` + +`src/Http/Resources/InquiryQuoteResource.php`: + +```php + $this->id, + 'inquiry_id' => $this->inquiry_id, + 'version' => $this->version, + 'total_amount' => (string) $this->total_amount, + 'tax_amount' => (string) $this->tax_amount, + 'currency' => $this->currency, + 'valid_until' => $this->valid_until?->toDateString(), + 'note' => $this->note, + 'status' => $this->status?->value, + 'issued_at' => $this->issued_at?->toIso8601String(), + 'accepted_at' => $this->accepted_at?->toIso8601String(), + 'rejected_at' => $this->rejected_at?->toIso8601String(), + 'items' => InquiryQuoteItemResource::collection($this->whenLoaded('items')), + ]; + } +} +``` + +- [ ] **Step 3: 테스트 PASS + Commit** + +```bash +php artisan test --filter=ResourceShapeTest 2>&1 | tail -10 +git add modules/_bundled/sirsoft-inquiry/src/Http/Resources/InquiryQuoteResource.php \ + modules/_bundled/sirsoft-inquiry/src/Http/Resources/InquiryQuoteItemResource.php \ + tests/Feature/Modules/Inquiry/Api/ResourceShapeTest.php +git commit -m "feat(inquiry): add Quote + QuoteItem API resources (read-only)" +``` + +--- + +## Task 3: Resource — Inquiry + +**Files:** +- Create: `src/Http/Resources/InquiryResource.php` + +Inquiry 응답에는 다음을 포함: 본문 + 견적 이력(read-only) + 본문 첨부 + abilities/is_owner 메타. + +- [ ] **Step 1: 테스트 추가** (`ResourceShapeTest`): + +```php +public function test_inquiry_resource_shape_for_owner(): void +{ + $inquiry = $this->makeInquiry(); // makeInquiry() always uses $user + $inquiry->load(['quotes.items', 'attachments']); + + $request = \Illuminate\Http\Request::create('/'); + $request->setUserResolver(fn () => $inquiry->user); // owner + + $array = (new \Modules\Sirsoft\Inquiry\Http\Resources\InquiryResource($inquiry))->toArray($request); + + $this->assertSame($inquiry->uuid, $array['uuid']); + $this->assertSame('received', $array['status']); + $this->assertTrue($array['is_owner']); + $this->assertIsArray($array['abilities']); + $this->assertTrue($array['abilities']['update']); + $this->assertTrue($array['abilities']['cancel']); + $this->assertArrayHasKey('quotes', $array); + $this->assertArrayHasKey('attachments', $array); +} +``` + +```bash +php artisan test --filter=test_inquiry_resource_shape_for_owner 2>&1 | tail -10 +``` + +기대: 실패. + +- [ ] **Step 2: InquiryResource 작성** + +`src/Http/Resources/InquiryResource.php`: + +```php +user(); + $isOwner = $user && $user->id === $this->user_id; + + return [ + 'uuid' => $this->uuid, + 'id' => $this->id, + 'user_id' => $this->user_id, + 'title' => $this->title, + 'content' => $this->content, + 'category' => $this->category, + 'budget_range' => $this->budget_range, + 'desired_due_at' => $this->desired_due_at?->toDateString(), + 'status' => $this->status?->value, + 'accepted_quote_id' => $this->accepted_quote_id, + 'payment_id' => $this->payment_id, + 'received_at' => $this->received_at?->toIso8601String(), + 'quoted_at' => $this->quoted_at?->toIso8601String(), + 'started_at' => $this->started_at?->toIso8601String(), + 'completed_at' => $this->completed_at?->toIso8601String(), + 'canceled_at' => $this->canceled_at?->toIso8601String(), + 'is_owner' => $isOwner, + 'abilities' => [ + 'update' => $user ? $user->can('update', $this->resource) : false, + 'cancel' => $user ? $user->can('cancel', $this->resource) : false, + 'postMessage' => $user ? $user->can('postMessage', $this->resource) : false, + 'acceptQuote' => $user ? $user->can('acceptQuote', $this->resource) : false, + 'rejectQuote' => $user ? $user->can('rejectQuote', $this->resource) : false, + ], + 'quotes' => InquiryQuoteResource::collection($this->whenLoaded('quotes')), + 'attachments' => InquiryAttachmentResource::collection( + $this->whenLoaded('attachments', fn () => $this->attachments->whereNull('message_id')) + ), + ]; + } +} +``` + +- [ ] **Step 3: 테스트 PASS + Commit** + +```bash +php artisan test --filter=ResourceShapeTest 2>&1 | tail -10 +git add modules/_bundled/sirsoft-inquiry/src/Http/Resources/InquiryResource.php \ + tests/Feature/Modules/Inquiry/Api/ResourceShapeTest.php +git commit -m "feat(inquiry): add Inquiry API resource with abilities meta" +``` + +--- + +## Task 4: Form Requests — Store/Update Inquiry + +**Files:** +- Create: `src/Http/Requests/StoreInquiryRequest.php` +- Create: `src/Http/Requests/UpdateInquiryRequest.php` + +- [ ] **Step 1: Request 작성** + +`src/Http/Requests/StoreInquiryRequest.php`: + +```php +user() !== null; // 회원만 (v1 spec) + } + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:200'], + 'content' => ['required', 'string'], + 'category' => ['nullable', 'string', 'in:' . implode(',', config('inquiry.categories', []))], + 'budget_range' => ['nullable', 'string', 'max:100'], + 'desired_due_at' => ['nullable', 'date', 'after_or_equal:today'], + ]; + } +} +``` + +`src/Http/Requests/UpdateInquiryRequest.php`: + +```php +route('inquiry'); + if (! $inquiry instanceof Inquiry) { + return false; + } + return $this->user()?->can('update', $inquiry) ?? false; + } + + public function rules(): array + { + return [ + 'title' => ['sometimes', 'required', 'string', 'max:200'], + 'content' => ['sometimes', 'required', 'string'], + 'category' => ['nullable', 'string', 'in:' . implode(',', config('inquiry.categories', []))], + 'budget_range' => ['nullable', 'string', 'max:100'], + 'desired_due_at' => ['nullable', 'date', 'after_or_equal:today'], + ]; + } +} +``` + +- [ ] **Step 2: Syntax check + Commit** + +```bash +php -l modules/_bundled/sirsoft-inquiry/src/Http/Requests/StoreInquiryRequest.php +php -l modules/_bundled/sirsoft-inquiry/src/Http/Requests/UpdateInquiryRequest.php +git add modules/_bundled/sirsoft-inquiry/src/Http/Requests/Store* \ + modules/_bundled/sirsoft-inquiry/src/Http/Requests/Update* +git commit -m "feat(inquiry): add Inquiry Store/Update form requests" +``` + +--- + +## Task 5: Form Requests — Message + Attachment + +**Files:** +- Create: `src/Http/Requests/StoreInquiryMessageRequest.php` +- Create: `src/Http/Requests/UploadInquiryAttachmentRequest.php` + +- [ ] **Step 1: 작성** + +`src/Http/Requests/StoreInquiryMessageRequest.php`: + +```php +route('inquiry'); + if (! $inquiry instanceof Inquiry) { + return false; + } + return $this->user()?->can('postMessage', $inquiry) ?? false; + } + + public function rules(): array + { + return [ + 'body' => ['required_without:attachment_ids', 'nullable', 'string', 'max:10000'], + 'attachment_ids' => ['nullable', 'array', 'max:10'], + 'attachment_ids.*' => ['integer', 'exists:inquiry_attachments,id'], + ]; + } +} +``` + +`src/Http/Requests/UploadInquiryAttachmentRequest.php`: + +```php +route('inquiry'); + if (! $inquiry instanceof Inquiry) { + return false; + } + return $this->user()?->can('uploadAttachment', $inquiry) ?? false; + } + + public function rules(): array + { + $context = $this->route('inquiryMessage') ? 'message' : 'inquiry'; + $maxBytes = (int) config( + $context === 'message' + ? 'inquiry.attachment.max_size_message' + : 'inquiry.attachment.max_size_inquiry' + ); + + return [ + 'file' => [ + 'required', + 'file', + 'max:' . (int) ($maxBytes / 1024), // Laravel expects KB + ], + ]; + } +} +``` + +- [ ] **Step 2: Syntax check + Commit** + +```bash +php -l modules/_bundled/sirsoft-inquiry/src/Http/Requests/StoreInquiryMessageRequest.php +php -l modules/_bundled/sirsoft-inquiry/src/Http/Requests/UploadInquiryAttachmentRequest.php +git add modules/_bundled/sirsoft-inquiry/src/Http/Requests/StoreInquiryMessageRequest.php \ + modules/_bundled/sirsoft-inquiry/src/Http/Requests/UploadInquiryAttachmentRequest.php +git commit -m "feat(inquiry): add Message/Attachment form requests" +``` + +--- + +## Task 6: Controller — InquiryController (index + show) + +**Files:** +- Create: `src/Http/Controllers/User/InquiryController.php` + +- [ ] **Step 1: 실패 테스트 작성** + +`tests/Feature/Modules/Inquiry/Api/InquiryCrudTest.php`: + +```php +create(); + $other = User::factory()->create(); + Inquiry::create(['uuid' => (string) Str::uuid(), 'user_id' => $me->id, 'title' => 'Mine', 'content' => 'x', 'status' => 'received']); + Inquiry::create(['uuid' => (string) Str::uuid(), 'user_id' => $other->id, 'title' => 'Theirs', 'content' => 'x', 'status' => 'received']); + + Sanctum::actingAs($me); + $res = $this->getJson('/api/modules/sirsoft-inquiry/inquiries'); + $res->assertOk(); + $titles = array_column($res->json('data'), 'title'); + $this->assertContains('Mine', $titles); + $this->assertNotContains('Theirs', $titles); + } + + public function test_show_returns_inquiry_for_owner(): void + { + $me = User::factory()->create(); + $inquiry = Inquiry::create(['uuid' => (string) Str::uuid(), 'user_id' => $me->id, 'title' => 'X', 'content' => 'Y', 'status' => 'received']); + + Sanctum::actingAs($me); + $res = $this->getJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}"); + $res->assertOk(); + $res->assertJsonPath('data.uuid', $inquiry->uuid); + $res->assertJsonPath('data.is_owner', true); + } + + public function test_show_returns_403_for_others(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $inquiry = Inquiry::create(['uuid' => (string) Str::uuid(), 'user_id' => $owner->id, 'title' => 'X', 'content' => 'Y', 'status' => 'received']); + + Sanctum::actingAs($other); + $this->getJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}") + ->assertForbidden(); + } + + public function test_index_requires_auth(): void + { + $this->getJson('/api/modules/sirsoft-inquiry/inquiries') + ->assertUnauthorized(); + } +} +``` + +```bash +php artisan test --filter=InquiryCrudTest 2>&1 | tail -15 +``` + +기대: 4 fails (route not found / class not found). + +- [ ] **Step 2: Controller 작성 (index + show 만)** + +`src/Http/Controllers/User/InquiryController.php`: + +```php +query('status'); + $perPage = (int) $request->query('per_page', 20); + + $paginator = $this->inquiries->listByUser($request->user()->id, $status ?: null, $perPage); + + return InquiryResource::collection($paginator); + } + + public function show(Request $request, Inquiry $inquiry) + { + $this->authorize('view', $inquiry); + + $inquiry->load(['quotes.items', 'attachments']); + + return new InquiryResource($inquiry); + } +} +``` + +(`Inquiry` 모델은 implicit binding으로 `{inquiry:uuid}` URL 파라미터에서 자동 해석 — route 정의에서 `->whereUuid()` 또는 explicit binding 사용. Task 8에서 명시.) + +- [ ] **Step 3: routes/api.php 임시 등록 (Task 8에서 정식 정리)** + +이번 Task 6 단계에서는 라우트가 아직 없어 테스트가 404로 실패한다. Task 8에서 정식 등록할 때까지 일단 commit 후 다음 task에서 라우트 등록 + 테스트 PASS 확인. 즉 Step 4는 commit만: + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Http/Controllers/User/InquiryController.php \ + tests/Feature/Modules/Inquiry/Api/InquiryCrudTest.php +git commit -m "feat(inquiry): add InquiryController (index + show)" +``` + +테스트는 Task 8 완료 후 일괄 PASS 확인. + +--- + +## Task 7: Controller — store + update + cancel + +**Files:** +- Modify: `src/Http/Controllers/User/InquiryController.php` + +- [ ] **Step 1: 테스트 추가** (`InquiryCrudTest`) + +```php +public function test_store_creates_inquiry(): void +{ + $me = User::factory()->create(); + Sanctum::actingAs($me); + + $res = $this->postJson('/api/modules/sirsoft-inquiry/inquiries', [ + 'title' => '홈페이지 리뉴얼', + 'content' => '기존 사이트를 모던하게 개편 부탁드립니다.', + 'category' => 'web', + 'budget_range' => '300-500만원', + 'desired_due_at' => now()->addMonth()->toDateString(), + ]); + + $res->assertCreated(); + $res->assertJsonPath('data.title', '홈페이지 리뉴얼'); + $res->assertJsonPath('data.status', 'received'); + $res->assertJsonPath('data.is_owner', true); + + $this->assertDatabaseHas('inquiries', [ + 'user_id' => $me->id, + 'title' => '홈페이지 리뉴얼', + 'status' => 'received', + ]); +} + +public function test_update_only_in_received_state(): void +{ + $me = User::factory()->create(); + $inquiry = Inquiry::create(['uuid' => (string) Str::uuid(), 'user_id' => $me->id, 'title' => 'old', 'content' => 'old', 'status' => 'received']); + + Sanctum::actingAs($me); + $this->patchJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}", ['title' => 'new']) + ->assertOk() + ->assertJsonPath('data.title', 'new'); + + $inquiry->update(['status' => 'quoted', 'quoted_at' => now()]); + $this->patchJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}", ['title' => 'newer']) + ->assertForbidden(); +} + +public function test_cancel_transitions_status(): void +{ + $me = User::factory()->create(); + $inquiry = Inquiry::create(['uuid' => (string) Str::uuid(), 'user_id' => $me->id, 'title' => 'X', 'content' => 'Y', 'status' => 'received']); + + Sanctum::actingAs($me); + $this->postJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/cancel") + ->assertOk() + ->assertJsonPath('data.status', 'canceled'); + + $this->assertNotNull($inquiry->fresh()->canceled_at); +} +``` + +- [ ] **Step 2: Controller 확장** + +`src/Http/Controllers/User/InquiryController.php` 의 use 절·생성자·메서드 추가: + +```php +use Illuminate\Support\Str; +use Modules\Sirsoft\Inquiry\Enums\TransitionEvent; +use Modules\Sirsoft\Inquiry\Http\Requests\StoreInquiryRequest; +use Modules\Sirsoft\Inquiry\Http\Requests\UpdateInquiryRequest; +use Modules\Sirsoft\Inquiry\Services\InquiryStateMachine; +``` + +생성자 시그니처: +```php +public function __construct( + private readonly InquiryRepositoryInterface $inquiries, + private readonly InquiryStateMachine $stateMachine, +) {} +``` + +메서드 추가: + +```php +public function store(StoreInquiryRequest $request) +{ + $inquiry = $this->inquiries->create([ + 'uuid' => (string) Str::uuid(), + 'user_id' => $request->user()->id, + 'title' => $request->string('title'), + 'content' => $request->string('content'), + 'category' => $request->input('category'), + 'budget_range' => $request->input('budget_range'), + 'desired_due_at' => $request->input('desired_due_at'), + 'status' => 'received', + ]); + + return (new InquiryResource($inquiry->load(['quotes.items', 'attachments']))) + ->response() + ->setStatusCode(201); +} + +public function update(UpdateInquiryRequest $request, Inquiry $inquiry) +{ + $this->inquiries->update($inquiry, $request->validated()); + return new InquiryResource($inquiry->fresh()->load(['quotes.items', 'attachments'])); +} + +public function cancel(Request $request, Inquiry $inquiry) +{ + $this->authorize('cancel', $inquiry); + + $this->stateMachine->transition( + $inquiry, + TransitionEvent::Cancel, + actorUserId: $request->user()->id, + payload: ['actor' => 'client'], + ); + + return new InquiryResource($inquiry->fresh()->load(['quotes.items', 'attachments'])); +} +``` + +- [ ] **Step 3: Commit (테스트는 Task 8 후 일괄)** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/Http/Controllers/User/InquiryController.php \ + tests/Feature/Modules/Inquiry/Api/InquiryCrudTest.php +git commit -m "feat(inquiry): add Inquiry store/update/cancel endpoints" +``` + +--- + +## Task 8: routes/api.php + module.php::getRoutes() + +**Files:** +- Create: `src/routes/api.php` +- Modify: `module.php` + +- [ ] **Step 1: routes/api.php 작성** + +`src/routes/api.php`: + +```php + Inquiry::where('uuid', $value)->firstOrFail()); + +Route::prefix('inquiries') + ->middleware(['auth:sanctum', 'throttle:600,1']) + ->name('inquiries.') + ->group(function () { + Route::get('/', [InquiryController::class, 'index'])->name('index'); + Route::post('/', [InquiryController::class, 'store'])->name('store'); + Route::get('/{inquiry}', [InquiryController::class, 'show'])->name('show'); + Route::patch('/{inquiry}', [InquiryController::class, 'update'])->name('update'); + Route::post('/{inquiry}/cancel', [InquiryController::class, 'cancel'])->name('cancel'); + }); +``` + +- [ ] **Step 2: module.php에 getRoutes() 추가** + +`modules/_bundled/sirsoft-inquiry/module.php` 의 클래스 안에 추가: + +```php +public function getRoutes(): array +{ + return [ + 'api' => $this->getModulePath() . '/src/routes/api.php', + ]; +} +``` + +(`getModulePath()` 는 `AbstractModule` 의 메서드. board 모듈도 동일.) + +- [ ] **Step 3: 라우트 캐시 + 테스트 실행** + +```bash +php artisan route:clear +php artisan test --filter=InquiryCrudTest 2>&1 | tail -15 +``` + +기대: 7 passes (Task 6의 4개 + Task 7의 3개). + +- [ ] **Step 4: Commit** + +```bash +git add modules/_bundled/sirsoft-inquiry/src/routes/api.php \ + modules/_bundled/sirsoft-inquiry/module.php +git commit -m "feat(inquiry): register Inquiry API routes via module.php" +``` + +--- + +## Task 9: InquiryMessageController + 라우트 + +**Files:** +- Create: `src/Http/Controllers/User/InquiryMessageController.php` +- Modify: `src/routes/api.php` + +- [ ] **Step 1: 실패 테스트** + +`tests/Feature/Modules/Inquiry/Api/InquiryMessageTest.php`: + +```php +create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + return [$user, $inquiry]; + } + + public function test_post_message_creates_record(): void + { + [$user, $inquiry] = $this->setupInquiry(); + Sanctum::actingAs($user); + + $res = $this->postJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/messages", [ + 'body' => '추가 자료 첨부합니다', + ]); + + $res->assertCreated(); + $res->assertJsonPath('data.body', '추가 자료 첨부합니다'); + $res->assertJsonPath('data.sender_role', 'client'); + + $this->assertDatabaseHas('inquiry_messages', [ + 'inquiry_id' => $inquiry->id, + 'body' => '추가 자료 첨부합니다', + 'sender_role' => 'client', + ]); + } + + public function test_index_returns_messages_ordered(): void + { + [$user, $inquiry] = $this->setupInquiry(); + $inquiry->messages()->create(['sender_user_id' => $user->id, 'sender_role' => 'client', 'body' => 'first', 'created_at' => now()->subHour()]); + $inquiry->messages()->create(['sender_user_id' => $user->id, 'sender_role' => 'client', 'body' => 'second', 'created_at' => now()]); + + Sanctum::actingAs($user); + $res = $this->getJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/messages"); + + $res->assertOk(); + $bodies = array_column($res->json('data'), 'body'); + $this->assertSame(['first', 'second'], $bodies); + } + + public function test_post_message_marks_opposite_role_messages_as_read(): void + { + [$user, $inquiry] = $this->setupInquiry(); + $op = User::factory()->create(); + $inquiry->messages()->create(['sender_user_id' => $op->id, 'sender_role' => 'operator', 'body' => 'hi', 'read_at' => null]); + + Sanctum::actingAs($user); + $this->postJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/messages", ['body' => 'reply']); + + $this->assertNotNull($inquiry->fresh()->messages()->where('sender_role', 'operator')->first()->read_at); + } + + public function test_index_requires_owner(): void + { + [, $inquiry] = $this->setupInquiry(); + $stranger = User::factory()->create(); + Sanctum::actingAs($stranger); + $this->getJson("/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/messages") + ->assertForbidden(); + } +} +``` + +```bash +php artisan test --filter=InquiryMessageTest 2>&1 | tail -15 +``` + +기대: 4 fails. + +- [ ] **Step 2: Controller 작성** + +`src/Http/Controllers/User/InquiryMessageController.php`: + +```php +authorize('view', $inquiry); + + // 상대 역할 메시지 읽음 처리 + $myRole = $request->user()->id === $inquiry->user_id + ? SenderRole::Client + : SenderRole::Operator; + $opposite = $myRole === SenderRole::Client ? SenderRole::Operator : SenderRole::Client; + $this->messages->markReadFor($inquiry, $opposite); + + $perPage = (int) $request->query('per_page', 50); + $paginator = $this->messages->listForInquiry($inquiry, $perPage); + + return InquiryMessageResource::collection($paginator); + } + + public function store(StoreInquiryMessageRequest $request, Inquiry $inquiry) + { + $role = $request->user()->id === $inquiry->user_id + ? SenderRole::Client + : SenderRole::Operator; + + $msg = $this->messages->append( + $inquiry, + $request->user()->id, + $role, + $request->string('body', '') + ); + + $attachmentIds = $request->input('attachment_ids', []); + foreach ($attachmentIds as $attId) { + $att = $this->attachments->findOrFail((int) $attId); + if ($att->inquiry_id !== $inquiry->id) { + abort(422, 'Attachment does not belong to this inquiry'); + } + $this->attachments->attachToMessage($att, $msg); + } + + // 상대 역할 이전 메시지 읽음 처리 + $opposite = $role === SenderRole::Client ? SenderRole::Operator : SenderRole::Client; + $this->messages->markReadFor($inquiry, $opposite); + + InquiryMessagePosted::dispatch($msg); + + return (new InquiryMessageResource($msg->load('attachments'))) + ->response() + ->setStatusCode(201); + } +} +``` + +- [ ] **Step 3: 라우트 추가** + +`src/routes/api.php` 의 inquiries 그룹 안에 추가: + +```php +Route::get('/{inquiry}/messages', [\Modules\Sirsoft\Inquiry\Http\Controllers\User\InquiryMessageController::class, 'index'])->name('messages.index'); +Route::post('/{inquiry}/messages', [\Modules\Sirsoft\Inquiry\Http\Controllers\User\InquiryMessageController::class, 'store'])->name('messages.store'); +``` + +- [ ] **Step 4: 테스트 PASS + Commit** + +```bash +php artisan route:clear +php artisan test --filter=InquiryMessageTest 2>&1 | tail -15 +git add modules/_bundled/sirsoft-inquiry/src/Http/Controllers/User/InquiryMessageController.php \ + modules/_bundled/sirsoft-inquiry/src/routes/api.php \ + tests/Feature/Modules/Inquiry/Api/InquiryMessageTest.php +git commit -m "feat(inquiry): add InquiryMessageController (index/store) + routes" +``` + +--- + +## Task 10: InquiryAttachmentController — upload + +**Files:** +- Create: `src/Http/Controllers/User/InquiryAttachmentController.php` +- Modify: `src/routes/api.php` + +- [ ] **Step 1: 실패 테스트** + +`tests/Feature/Modules/Inquiry/Api/InquiryAttachmentTest.php`: + +```php +create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + + Sanctum::actingAs($user); + $file = UploadedFile::fake()->create('plan.pdf', 100, 'application/pdf'); + + $res = $this->postJson( + "/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/attachments", + ['file' => $file] + ); + + $res->assertCreated(); + $res->assertJsonPath('data.original_name', 'plan.pdf'); + $res->assertJsonPath('data.mime', 'application/pdf'); + $this->assertDatabaseHas('inquiry_attachments', [ + 'inquiry_id' => $inquiry->id, + 'message_id' => null, + 'original_name' => 'plan.pdf', + ]); + } + + public function test_upload_rejects_disallowed_mime(): void + { + Storage::fake('local'); + $user = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + + Sanctum::actingAs($user); + $file = UploadedFile::fake()->create('bad.exe', 10, 'application/x-msdownload'); + + $this->postJson( + "/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/attachments", + ['file' => $file] + )->assertStatus(422); + } + + public function test_upload_requires_owner(): void + { + Storage::fake('local'); + $owner = User::factory()->create(); + $other = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), 'user_id' => $owner->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + + Sanctum::actingAs($other); + $this->postJson( + "/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/attachments", + ['file' => UploadedFile::fake()->create('a.pdf', 1, 'application/pdf')] + )->assertForbidden(); + } +} +``` + +- [ ] **Step 2: Controller 작성** + +`src/Http/Controllers/User/InquiryAttachmentController.php`: + +```php +storage->store( + $inquiry, + $request->user()->id, + $request->file('file'), + context: 'inquiry', + ); + } catch (InvalidArgumentException $e) { + abort(422, $e->getMessage()); + } + + return (new InquiryAttachmentResource($att)) + ->response() + ->setStatusCode(201); + } + + public function uploadMessage(UploadInquiryAttachmentRequest $request, Inquiry $inquiry) + { + try { + $att = $this->storage->store( + $inquiry, + $request->user()->id, + $request->file('file'), + context: 'message', + ); + } catch (InvalidArgumentException $e) { + abort(422, $e->getMessage()); + } + + return (new InquiryAttachmentResource($att)) + ->response() + ->setStatusCode(201); + } + + public function download(Request $request, InquiryAttachment $attachment): StreamedResponse + { + $inquiry = $attachment->inquiry; + $this->authorize('viewAttachment', $inquiry); + + return \Illuminate\Support\Facades\Storage::disk($attachment->disk)->download( + $attachment->path, + $attachment->original_name, + ['Content-Type' => $attachment->mime] + ); + } +} +``` + +- [ ] **Step 3: 라우트 추가** + +`src/routes/api.php` 에 추가: + +```php +Route::post('/{inquiry}/attachments', [\Modules\Sirsoft\Inquiry\Http\Controllers\User\InquiryAttachmentController::class, 'uploadInquiryBody']) + ->name('attachments.inquiry-body'); +Route::post('/{inquiry}/messages/attachments', [\Modules\Sirsoft\Inquiry\Http\Controllers\User\InquiryAttachmentController::class, 'uploadMessage']) + ->name('attachments.message'); +``` + +그리고 inquiries 그룹 **밖에** (별도 그룹) 다운로드 라우트 추가: + +```php +Route::middleware(['auth:sanctum', 'throttle:600,1']) + ->name('inquiry-attachments.') + ->group(function () { + Route::get('/attachments/{attachment}', [\Modules\Sirsoft\Inquiry\Http\Controllers\User\InquiryAttachmentController::class, 'download']) + ->name('download'); + }); +``` + +`attachment` 바인딩(implicit binding on id) 은 기본 동작 사용. + +- [ ] **Step 4: 테스트 PASS + Commit** + +```bash +php artisan route:clear +php artisan test --filter=InquiryAttachmentTest 2>&1 | tail -15 +git add modules/_bundled/sirsoft-inquiry/src/Http/Controllers/User/InquiryAttachmentController.php \ + modules/_bundled/sirsoft-inquiry/src/routes/api.php \ + tests/Feature/Modules/Inquiry/Api/InquiryAttachmentTest.php +git commit -m "feat(inquiry): add InquiryAttachmentController upload endpoints" +``` + +--- + +## Task 11: Attachment download test + 권한 검증 + +**Files:** +- Modify: `tests/Feature/Modules/Inquiry/Api/InquiryAttachmentTest.php` + +- [ ] **Step 1: 다운로드 테스트 추가** + +```php +public function test_download_returns_file_for_owner(): void +{ + Storage::fake('local'); + $user = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), 'user_id' => $user->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + Sanctum::actingAs($user); + $upload = $this->postJson( + "/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/attachments", + ['file' => UploadedFile::fake()->create('plan.pdf', 100, 'application/pdf')] + ); + $attId = $upload->json('data.id'); + + $res = $this->get("/api/modules/sirsoft-inquiry/attachments/{$attId}"); + $res->assertOk(); + $res->assertHeader('content-type', 'application/pdf'); +} + +public function test_download_forbidden_for_strangers(): void +{ + Storage::fake('local'); + $owner = User::factory()->create(); + $other = User::factory()->create(); + $inquiry = Inquiry::create([ + 'uuid' => (string) Str::uuid(), 'user_id' => $owner->id, + 'title' => 'X', 'content' => 'Y', 'status' => 'received', + ]); + Sanctum::actingAs($owner); + $upload = $this->postJson( + "/api/modules/sirsoft-inquiry/inquiries/{$inquiry->uuid}/attachments", + ['file' => UploadedFile::fake()->create('plan.pdf', 100, 'application/pdf')] + ); + $attId = $upload->json('data.id'); + + Sanctum::actingAs($other); + $this->get("/api/modules/sirsoft-inquiry/attachments/{$attId}") + ->assertForbidden(); +} +``` + +- [ ] **Step 2: 테스트 실행 + Commit** + +```bash +php artisan test --filter=InquiryAttachmentTest 2>&1 | tail -15 +git add tests/Feature/Modules/Inquiry/Api/InquiryAttachmentTest.php +git commit -m "test(inquiry): cover attachment download permission" +``` + +--- + +## Task 12: E2E flow test + +**Files:** +- Create: `tests/Feature/Modules/Inquiry/Api/InquiryFlowTest.php` + +사용자 시나리오 골든패스를 한 테스트로 묶는다. + +- [ ] **Step 1: 테스트 작성** + +```php +create(); + Sanctum::actingAs($user); + + // 1) Create inquiry + $created = $this->postJson('/api/modules/sirsoft-inquiry/inquiries', [ + 'title' => '쇼핑몰 리뉴얼', + 'content' => '디자인 + 결제 연동', + 'category' => 'web', + ])->assertCreated()->json('data'); + $uuid = $created['uuid']; + + // 2) Upload inquiry body attachment + $upload = $this->postJson( + "/api/modules/sirsoft-inquiry/inquiries/{$uuid}/attachments", + ['file' => UploadedFile::fake()->create('brief.pdf', 50, 'application/pdf')] + )->assertCreated()->json('data'); + + // 3) Post message + $this->postJson("/api/modules/sirsoft-inquiry/inquiries/{$uuid}/messages", [ + 'body' => '추가 안내 드립니다', + ])->assertCreated(); + + // 4) List inquiries — should appear + $list = $this->getJson('/api/modules/sirsoft-inquiry/inquiries')->assertOk()->json('data'); + $this->assertTrue(collect($list)->contains('uuid', $uuid)); + + // 5) Show with attachments + messages embedded + $show = $this->getJson("/api/modules/sirsoft-inquiry/inquiries/{$uuid}") + ->assertOk() + ->assertJsonPath('data.status', 'received') + ->json('data'); + $this->assertCount(1, $show['attachments']); + + $msgs = $this->getJson("/api/modules/sirsoft-inquiry/inquiries/{$uuid}/messages") + ->assertOk() + ->json('data'); + $this->assertCount(1, $msgs); + + // 6) Cancel + $this->postJson("/api/modules/sirsoft-inquiry/inquiries/{$uuid}/cancel") + ->assertOk() + ->assertJsonPath('data.status', 'canceled'); + + // 7) After cancel, system message appended + $msgs = $this->getJson("/api/modules/sirsoft-inquiry/inquiries/{$uuid}/messages")->json('data'); + $systemMsg = collect($msgs)->firstWhere('sender_role', 'system'); + $this->assertNotNull($systemMsg); + $this->assertSame('inquiry::system.message.canceled_by_client', $systemMsg['meta']['key']); + } +} +``` + +- [ ] **Step 2: 실행 + Commit** + +```bash +php artisan test --filter=InquiryFlowTest 2>&1 | tail -15 +git add tests/Feature/Modules/Inquiry/Api/InquiryFlowTest.php +git commit -m "test(inquiry): cover end-to-end client flow (create→msg→cancel)" +``` + +기대: 1 test PASS, 12+ assertions. + +--- + +## Task 13: Composite — InquiryStatusBar + +**Files:** +- Create: `templates/_bundled/sirsoft-basic/src/components/composite/InquiryStatusBar.tsx` +- Modify: `templates/_bundled/sirsoft-basic/src/components/composite/index.ts` + +- [ ] **Step 1: 컴포넌트 작성** + +`InquiryStatusBar.tsx`: + +```tsx +import React from 'react'; +import { Div, Span } from '../basic'; + +export interface InquiryStatusBarProps { + /** 현재 의뢰 상태 */ + status: 'received' | 'quoted' | 'in_progress' | 'completed' | 'canceled'; + className?: string; +} + +const STEPS: Array<{ key: string; label: string }> = [ + { key: 'received', label: '접수' }, + { key: 'quoted', label: '견적' }, + { key: 'in_progress', label: '진행' }, + { key: 'completed', label: '완료' }, +]; + +const stepIndex = (status: string): number => { + if (status === 'canceled') return -1; + return STEPS.findIndex((s) => s.key === status); +}; + +const InquiryStatusBar: React.FC = ({ status, className = '' }) => { + const current = stepIndex(status); + const canceled = status === 'canceled'; + + return ( +
+ {canceled ? ( +
+ 취소된 의뢰 +
+ ) : ( +
+ {STEPS.map((step, i) => { + const active = i <= current; + const isCurrent = i === current; + return ( + +
+ {i + 1} + {step.label} +
+ {i < STEPS.length - 1 && ( +
+ )} + + ); + })} +
+ )} +
+ ); +}; + +export default InquiryStatusBar; +``` + +- [ ] **Step 2: index.ts 에 export 추가** + +`templates/_bundled/sirsoft-basic/src/components/composite/index.ts` 에 라인 추가: + +```ts +export { default as InquiryStatusBar } from './InquiryStatusBar'; +``` + +- [ ] **Step 3: 빌드 검증 + Commit** + +```bash +cd templates/_bundled/sirsoft-basic && npm run build 2>&1 | tail -10 +cd ../../.. +git add templates/_bundled/sirsoft-basic/src/components/composite/InquiryStatusBar.tsx \ + templates/_bundled/sirsoft-basic/src/components/composite/index.ts +git commit -m "feat(inquiry): add InquiryStatusBar composite" +``` + +기대: 빌드 에러 없음. + +--- + +## Task 14: Composite — InquiryCard + +**Files:** +- Create: `templates/_bundled/sirsoft-basic/src/components/composite/InquiryCard.tsx` +- Modify: `templates/_bundled/sirsoft-basic/src/components/composite/index.ts` + +목록 화면에서 사용. 상태 뱃지 + 제목 + 안 읽은 메시지 수 + 받은 날짜 + 클릭 시 상세 이동. + +- [ ] **Step 1: 작성** + +```tsx +import React from 'react'; +import { A, Div, H3, Span } from '../basic'; + +export interface InquiryCardProps { + uuid: string; + title: string; + status: 'received' | 'quoted' | 'in_progress' | 'completed' | 'canceled'; + category?: string; + receivedAt?: string; + unreadCount?: number; + className?: string; +} + +const STATUS_STYLES: Record = { + received: 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300', + quoted: 'bg-yellow-50 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300', + in_progress: 'bg-purple-50 text-purple-700 dark:bg-purple-900/20 dark:text-purple-300', + completed: 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-300', + canceled: 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-300', +}; + +const STATUS_LABEL: Record = { + received: '접수', + quoted: '견적', + in_progress: '진행', + completed: '완료', + canceled: '취소', +}; + +const InquiryCard: React.FC = ({ + uuid, + title, + status, + category, + receivedAt, + unreadCount = 0, + className = '', +}) => ( +
+
+

{title}

+ + {STATUS_LABEL[status] || status} + +
+
+ {category && {category}} + {receivedAt && {new Date(receivedAt).toLocaleDateString('ko-KR')}} + {unreadCount > 0 && ( + + 새 메시지 {unreadCount} + + )} +
+
+); + +export default InquiryCard; +``` + +- [ ] **Step 2: index.ts 갱신 + 빌드 + Commit** + +```ts +// composite/index.ts 에 추가 +export { default as InquiryCard } from './InquiryCard'; +``` + +```bash +cd templates/_bundled/sirsoft-basic && npm run build 2>&1 | tail -10 +cd ../../.. +git add templates/_bundled/sirsoft-basic/src/components/composite/InquiryCard.tsx \ + templates/_bundled/sirsoft-basic/src/components/composite/index.ts +git commit -m "feat(inquiry): add InquiryCard composite for list view" +``` + +--- + +## Task 15: Composite — InquiryMessageThread + +**Files:** +- Create: `templates/_bundled/sirsoft-basic/src/components/composite/InquiryMessageThread.tsx` +- Modify: `templates/_bundled/sirsoft-basic/src/components/composite/index.ts` + +채팅형 메시지 스레드 + 입력창. 데이터·전송 액션은 layout JSON DSL 에서 props 로 주입. + +- [ ] **Step 1: 작성** + +```tsx +import React, { useState } from 'react'; +import { Button, Div, P, Span, Textarea } from '../basic'; + +export interface InquiryMessage { + id: number; + sender_role: 'client' | 'operator' | 'system'; + body: string | null; + meta?: { key?: string; params?: Record } | null; + created_at?: string; +} + +export interface InquiryMessageThreadProps { + messages: InquiryMessage[]; + /** 현재 사용자 역할 (대개 'client') */ + myRole?: 'client' | 'operator'; + /** 메시지 전송 콜백 — layout JSON 에서 onSend 액션으로 바인딩 */ + onSend?: (body: string) => void; + /** 전송 중 비활성화 */ + submitting?: boolean; + /** placeholder 텍스트 */ + placeholder?: string; + className?: string; +} + +const renderSystemBody = (meta?: InquiryMessage['meta']): string => { + if (!meta?.key) return '시스템 메시지'; + // 간단한 키 표시 — i18n 보간은 향후 강화 + const keySuffix = meta.key.split('.').pop() || ''; + const params = meta.params || {}; + switch (keySuffix) { + case 'quote_issued': + return `운영자가 견적을 발행했습니다 (회차 #${params.version ?? '?'}, 합계 ${params.total ?? '-'}원)`; + case 'quote_revoked': + return `운영자가 견적을 철회했습니다 (회차 #${params.version ?? '?'})`; + case 'quote_rejected': + return `의뢰자가 견적을 거절했습니다 (회차 #${params.version ?? '?'})`; + case 'payment_confirmed': + return '결제가 확인되었습니다'; + case 'payment_confirmed_offline': + return '운영자가 결제를 수동 확인했습니다'; + case 'completed': + return '의뢰가 완료되었습니다'; + case 'canceled_by_client': + return '의뢰자가 의뢰를 취소했습니다'; + case 'canceled_by_operator': + return '운영자가 의뢰를 취소했습니다'; + default: + return meta.key; + } +}; + +const InquiryMessageThread: React.FC = ({ + messages, + myRole = 'client', + onSend, + submitting = false, + placeholder = '메시지를 입력하세요', + className = '', +}) => { + const [draft, setDraft] = useState(''); + + const handleSend = () => { + const trimmed = draft.trim(); + if (!trimmed) return; + onSend?.(trimmed); + setDraft(''); + }; + + return ( +
+
+ {messages.map((msg) => { + if (msg.sender_role === 'system') { + return ( +
+ + {renderSystemBody(msg.meta)} + +
+ ); + } + const mine = msg.sender_role === myRole; + return ( +
+
+ {msg.body &&

{msg.body}

} +
+
+ ); + })} + {messages.length === 0 && ( +
+ 아직 메시지가 없습니다 +
+ )} +
+ +
+