fix(ecommerce,core): 현금영수증 UI 보완 + 하네스 결함 6건 + 확장점 룰 사각 제거
현금영수증/환불계좌 기능(S3)의 감사 보완과, 그 과정에서 발견한 하네스 결함을 같은 세션에서 일괄 처리한다. 지시에 따라 무관 산출물(namuwiki 문서 초안 포함)도 단일 커밋으로 묶는다. - 현금영수증 발급 이력 접기/펼치기(W-3) + 결제행별 독립 토글 + aria 상태 - vendor-bundle manifest 해시 재생성 — composer.json version bump 미반영분 정합 - 시나리오 매니페스트 거짓 경로 정정(A/B) + 미작성 부채 :test-files 정직 표기(C) - layout-* 룰의 resources/extensions 검사 사각 제거 + 확장점 전용 root 스키마 분기 - validate-test-scenario.cjs: 맵형 test_files 크래시 + fallback 파서 covers 오검출 수정 - tosspayments 확장점 setState target 규약 위반 수정
This commit is contained in:
@@ -419,6 +419,53 @@ v1.17.0 이전: modals 내부의 extension_point는 처리되지 않았음
|
||||
| `prepend` | default **앞에** 추가 | `[NEW] [default...]` |
|
||||
| `replace` | default **완전 교체** | `[NEW]` (default 제거) |
|
||||
|
||||
### 확장 병합 칸 (주입 폼의 값을 템플릿 API 요청에 실어 보내기)
|
||||
|
||||
슬롯에 주입한 폼이 입력값을 서버로 보내려면, 그 값이 **템플릿이 소유한 apiCall 의 `params.body`** 에 도달해야 한다. 그런데 확장에는 body 에 키를 추가할 수단이 없다.
|
||||
|
||||
- 결제 버튼처럼 `id` 가 없는 노드는 overlay `target_id` 로 잡을 수 없다
|
||||
- `inject_props` 는 컴포넌트 props 병합 전용이라 `actions[].params.body` 에 닿지 않는다 (`_merge` 는 shallow)
|
||||
|
||||
그래서 템플릿이 **확장 병합 칸**을 하나 열어 둔다. 규약은 이렇다.
|
||||
|
||||
**템플릿 쪽** — body 를 통짜 표현식으로 만들고 말미에 확장 칸을 spread 한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"target": "/api/modules/sirsoft-ecommerce/user/orders",
|
||||
"params": {
|
||||
"body": "{{ ({ temp_order_id: _local.tempOrderId, payment_method: _computed.selectedPaymentMethod, ...(_local.checkoutExtraPayload ?? {}) }) }}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**확장 쪽** — 자기 필드를 그 칸에 setState 한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"checkoutExtraPayload.cash_receipt_requested": "{{$event.target.checked}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
지켜야 할 것:
|
||||
|
||||
- 확장은 **자기 도메인 키만** 쓴다. spread 가 뒤에 오므로 템플릿의 기존 키를 덮어쓸 수 있다
|
||||
- 폼을 접거나 신청을 해제하면 칸을 `{}` 로 비운다. 남겨 두면 서버가 신청으로 오인한다
|
||||
- 템플릿이 소유한 필드(예: 환불계좌)는 칸을 경유하지 않고 body 에 직접 기재한다
|
||||
- body 를 통짜 표현식으로 전환할 때는 **전환 전후 산출값이 동일한지** 회귀 테스트로 고정한다 (`DataBindingEngine.evaluateExpression` 으로 실제 평가)
|
||||
|
||||
현재 열려 있는 칸:
|
||||
|
||||
| 칸 이름 | 위치 | 소비처 |
|
||||
|---------|------|--------|
|
||||
| `_local.checkoutExtraPayload` | `sirsoft-basic` 주문서 (`_checkout_summary.json`) | 주문 생성 `POST /user/orders` |
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 오버라이드
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
|
||||
- 현금영수증 관련 문구(`cash_receipt.*` — 발급 용도, 식별번호 종류, 발급/취소 이력 상태, 배송비 과세 방식, 오류 안내) 일본어 번역 추가 — 현금영수증 발급 화면과 이커머스 환경설정이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 현금영수증 발급 요청의 입력 항목명·검증 안내·처리 결과 문구(`cash_receipt.attributes.*`, `cash_receipt.validation.*`, `cash_receipt.messages.*`)와 발급 불가 사유 안내(`cash_receipt.errors.*`) 일본어 번역 추가 — 잘못된 식별번호를 입력했을 때의 안내와 발급·취소 결과 메시지가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 주문서의 현금영수증 신청 폼 문구(`checkout.cash_receipt.*` — 신청 여부, 발급 용도, 발급 수단, 입력 안내) 일본어 번역 추가 — 무통장입금으로 주문할 때 현금영수증을 신청하는 화면이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 주문 상세의 현금영수증 카드 문구(발급 상태 안내, 발급·발급취소·재발급 버튼, 발급 이력 표) 일본어 번역 추가 — 구매자와 관리자가 보는 현금영수증 영역이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 환불 계좌 입력 안내와 검증 문구(`validation.order.refund_bank_*`) 일본어 번역 추가 — 주문서와 관리자 취소 화면의 환불 계좌 입력란이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 현금영수증 자진발급 설정의 국세청 의무발행 업종 안내 링크 문구(`order_settings.cash_receipt.self_issue_mandatory_link`) 일본어 번역 추가 — 자진발급을 켤지 판단할 때 참고하는 국세청 안내 링크가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.0.8] - 2026-08-01
|
||||
|
||||
|
||||
@@ -58,4 +58,9 @@ return [
|
||||
'reissued' => '現金領収書が再発行されました。',
|
||||
'status_retrieved' => '現金領収書情報を照会しました。',
|
||||
],
|
||||
'result_status' => [
|
||||
'in_progress' => '処理中',
|
||||
'completed' => '完了',
|
||||
'failed' => '失敗',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -836,6 +836,13 @@ return [
|
||||
'guest_lookup_password_min' => '注文照会パスワードは8文字以上である必要があります。',
|
||||
'guest_lookup_password_confirmed' => '注文照会パスワードが一致しません。',
|
||||
'guest_lookup_password_confirmation_required' => '注文照会パスワード確認を入力してください。',
|
||||
'cash_receipt_type_required' => '現金領収証の発行目的を選択してください。',
|
||||
'cash_receipt_type_invalid' => '現金領収証の発行目的が正しくありません。',
|
||||
'cash_receipt_identifier_type_required' => '現金領収証の発行方法を選択してください。',
|
||||
'cash_receipt_identifier_type_invalid' => '現金領収証の発行方法が正しくありません。',
|
||||
'cash_receipt_identifier_required' => '現金領収証の発行に使用する番号を入力してください。',
|
||||
'refund_bank_required_with' => '返金口座は銀行·口座番号·預金者をすべて入力する必要があります。',
|
||||
'refund_bank_required_for_vbank' => '入金が完了した仮想口座注文は返金口座が必要です。',
|
||||
],
|
||||
'order_bulk' => [
|
||||
'ids_required' => '変更する注文を選択してください。',
|
||||
|
||||
@@ -165,7 +165,8 @@
|
||||
"seoStats": "SEO統計",
|
||||
"settings": "設定",
|
||||
"shipping_policies": "配送ポリシー一覧",
|
||||
"templates": "テンプレート一覧"
|
||||
"templates": "テンプレート一覧",
|
||||
"paymentSettings": "決済環境設定"
|
||||
},
|
||||
"action": {
|
||||
"request_pg_payment": {
|
||||
@@ -195,5 +196,19 @@
|
||||
"label": "配送国",
|
||||
"hint": "基本配送国です。登録後、マイ情報で変更できます。"
|
||||
}
|
||||
},
|
||||
"checkout": {
|
||||
"cash_receipt": {
|
||||
"request": "現金領収証申請",
|
||||
"purpose": "用途",
|
||||
"purpose_income": "所得控除用",
|
||||
"purpose_expense": "支出証明用",
|
||||
"identifier_phone": "携帯電話番号",
|
||||
"identifier_card": "現金領収証カード番号",
|
||||
"identifier_business": "事業者登録番号",
|
||||
"identifier_placeholder": "- なしで数字のみ入力",
|
||||
"purpose_help": "所得控除用は年末調整に、支出証明用は事業者の仕入税額控除に使用されます。",
|
||||
"immutable_help": "発行後は用途を変更することはできません。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-1
@@ -419,7 +419,13 @@
|
||||
"close_btn": "閉じる",
|
||||
"estimate_failed": "返金予想金額の計算に失敗しました。",
|
||||
"cancel_success": "注文がキャンセルされました。",
|
||||
"cancel_failed": "注文のキャンセルに失敗しました。"
|
||||
"cancel_failed": "注文のキャンセルに失敗しました。",
|
||||
"refund_bank_label": "返金口座",
|
||||
"refund_bank_select": "銀行を選択",
|
||||
"refund_bank_account_placeholder": "口座番号 (ハイフンなしで入力)",
|
||||
"refund_bank_holder_placeholder": "口座名義人",
|
||||
"refund_bank_required_hint": "入金が完了した仮想口座注文は返金口座が必要です。",
|
||||
"refund_bank_partial_notice": "3つのうちいずれかを入力した場合は、残りも入力する必要があります。"
|
||||
},
|
||||
"reset_guest_password": {
|
||||
"title": "非会員閲覧パスワード再設定",
|
||||
@@ -450,6 +456,38 @@
|
||||
"mark_order_complete": "注文を決済完了として処理",
|
||||
"mark_order_complete_hint": "チェックすると入金記録とともに注文ステータスを決済完了に変更します(ポイント積立·在庫減少·通知送信含む)。チェックしないと入金内訳のみ記録します。"
|
||||
}
|
||||
},
|
||||
"cash_receipt": {
|
||||
"title": "現金領収書",
|
||||
"issue_title": "現金領収書発行",
|
||||
"status_issued": "発行完了",
|
||||
"awaiting_deposit": "入金が確認されたら発行できます。",
|
||||
"purpose": "用途",
|
||||
"purpose_income": "所得控除用",
|
||||
"purpose_expense": "支出証拠用",
|
||||
"identifier_type": "発行手段",
|
||||
"identifier": "識別番号",
|
||||
"identifier_phone": "携帯電話番号",
|
||||
"identifier_card": "現金領収書カード番号",
|
||||
"identifier_business": "事業者登録番号",
|
||||
"identifier_placeholder": "ハイフンなしで数字のみ入力",
|
||||
"immutable_help": "発行後は用途を変更することはできません。",
|
||||
"amount": "金額",
|
||||
"tax_free_amount": "非課税金額",
|
||||
"issue_number": "発行番号",
|
||||
"issued_at": "発行日時",
|
||||
"issue": "現金領収書発行",
|
||||
"cancel": "キャンセル",
|
||||
"cancel_issue": "発行取消",
|
||||
"reissue": "手動再発行",
|
||||
"view_receipt": "領収書を表示",
|
||||
"history": "発行履歴 ({{count}}件)",
|
||||
"issue_success": "現金領収書が発行されました。",
|
||||
"issue_failed": "現金領収書の発行に失敗しました。",
|
||||
"cancel_success": "現金領収書の発行がキャンセルされました。",
|
||||
"cancel_failed": "現金領収書の発行キャンセルに失敗しました。",
|
||||
"reissue_failed": "再発行失敗",
|
||||
"reissue_success": "現金領収書が再発行されました。"
|
||||
}
|
||||
},
|
||||
"preset": {
|
||||
|
||||
+17
@@ -224,6 +224,23 @@
|
||||
"restore_on_cancel": "注文キャンセル時の在庫復旧",
|
||||
"restore_on_cancel_description": "注文がキャンセルされると、差し引かれた在庫を自動的に復旧します。",
|
||||
"restore_on_cancel_hint": "返品·交換処理時にも適用されます。"
|
||||
},
|
||||
"cash_receipt": {
|
||||
"title": "現金領収証",
|
||||
"description": "現金領収証の発行プロバイダーと配送料の課税方式を設定します。",
|
||||
"provider_label": "現金領収証プロバイダー",
|
||||
"provider_none": "(未使用)",
|
||||
"provider_not_installed": "決済プラグインを有効化すると、発行プロバイダーを選択できます。",
|
||||
"provider_help": "決済に使用するPGとは別に選択できます。",
|
||||
"shipping_fee_tax_label": "配送料の課税方式",
|
||||
"shipping_fee_tax_proportional": "按分 (デフォルト)",
|
||||
"shipping_fee_tax_taxable": "全額課税",
|
||||
"shipping_fee_tax_follow_main_item": "主要な財貨基準",
|
||||
"shipping_fee_tax_help": "按分は、課税·非課税商品の金額比率で配送料を分割します。",
|
||||
"self_issue_label": "自進発行の使用",
|
||||
"self_issue_help": "購入者が申請していない無通帳入金建も自動で発行します (所得控除用専用)。",
|
||||
"self_issue_mandatory_help": "現金領収証の義務発行業種は、購入者が申請しなくても発行義務があります。該当業種であればオンにしてください。",
|
||||
"self_issue_mandatory_link": "国税庁の義務発行業種案内"
|
||||
}
|
||||
},
|
||||
"claim": {
|
||||
|
||||
@@ -12,5 +12,29 @@
|
||||
"delivered": "配送完了",
|
||||
"cancelled": "キャンセル済み",
|
||||
"refunded": "返金済み"
|
||||
},
|
||||
"cash_receipt": {
|
||||
"title": "現金領収証",
|
||||
"issue_title": "現金領収証の発行",
|
||||
"purpose": "用途",
|
||||
"purpose_income": "所得控除用",
|
||||
"purpose_expense": "支出証明用",
|
||||
"identifier_type": "発行手段",
|
||||
"identifier": "識別番号",
|
||||
"identifier_phone": "電話番号",
|
||||
"identifier_card": "現金領収証カード番号",
|
||||
"identifier_business": "事業者登録番号",
|
||||
"identifier_placeholder": "ハイフンなしで数字のみを入力",
|
||||
"immutable_help": "発行後は用途を変更することはできません。",
|
||||
"awaiting_deposit": "入金が確認されると発行されます。",
|
||||
"none": "発行された現金領収証がありません。",
|
||||
"issue": "現金領収証の発行",
|
||||
"cancel": "キャンセル",
|
||||
"issued_at": "発行日時",
|
||||
"view_receipt": "領収証を表示",
|
||||
"issue_success": "現金領収証が発行されました。",
|
||||
"issue_failed": "現金領収証の発行に失敗しました。",
|
||||
"reissue_failed": "再発行失敗",
|
||||
"reissue_failed_help": "返金に伴う現金領収証の再発行に失敗しました。カスタマーサポートにお問い合わせください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.0] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- 주문서의 환불 계좌 입력란 문구(`shop.checkout.refund_bank_*` — 안내 제목, 은행 선택, 계좌번호·예금주 입력 안내, 부분 입력 주의) 일본어 번역 추가 — 무통장입금·가상계좌로 주문할 때 환불받을 계좌를 남기는 영역이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.0.2] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -465,7 +465,12 @@
|
||||
"guest_token_issue_failed": "非会員照会認証準備に失敗しました。注文照会時に携帯電話とパスワードで再度ご確認ください。",
|
||||
"coupon_already_used": "他の商品に既に適用されています",
|
||||
"coupon_not_applied": "一部のクーポンを適用できないため除外されました。最小注文金額·使用回数·重複使用の条件を確認してください。",
|
||||
"shipping_country": "配送国家"
|
||||
"shipping_country": "配送国家",
|
||||
"refund_bank_title": "返金口座 (注文キャンセル時に使用、選択)",
|
||||
"refund_bank_select": "銀行を選択",
|
||||
"refund_bank_account_placeholder": "口座番号 (ハイフンなしで入力)",
|
||||
"refund_bank_holder_placeholder": "口座名義人",
|
||||
"refund_bank_partial_notice": "3つのいずれかを入力した場合、残りも入力する必要があります。"
|
||||
},
|
||||
"order_complete": {
|
||||
"page_title": "注文完了",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
|
||||
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"scope": "template",
|
||||
"target_identifier": "sirsoft-basic",
|
||||
|
||||
@@ -29,6 +29,18 @@
|
||||
- 현금영수증 식별번호(휴대폰번호·사업자등록번호 등)는 재발급에 쓸 수 있도록 암호화해 보관하고 구매확정 시점에 폐기합니다. 이력에는 뒤 4자리를 제외한 마스킹 값만 남으며, 발급 업체가 보내온 응답에 섞여 오는 개인정보도 가려서 저장합니다. 주민등록번호는 수집하지 않습니다.
|
||||
- 결제 영수증(카드 매출전표 등)과 현금영수증을 각각 따로 조회할 수 있습니다. 두 영수증은 서로 다른 값으로 보관되어 한쪽이 다른 쪽을 덮어쓰지 않습니다.
|
||||
|
||||
#### 신청·발급 화면
|
||||
|
||||
- 주문서의 무통장입금 결제 영역에서 현금영수증을 신청할 수 있습니다. 용도(소득공제/지출증빙)에 따라 고를 수 있는 발급 수단이 바뀌며, 그 용도에 쓸 수 없는 수단이 선택되어 있으면 자동으로 되돌립니다. 발급 업체를 설정하지 않은 상점에서는 신청란이 나타나지 않습니다.
|
||||
- 구매자 주문 상세에 현금영수증 카드가 표시됩니다. 입금 전에는 안내만, 입금 후 미발급이면 발급 버튼, 발급 후에는 발급일시·용도·식별번호와 영수증 보기 링크가 나타납니다. 환불에 따른 재발급이 실패한 경우에는 안내 문구가 표시됩니다.
|
||||
- 관리자 주문 상세에 현금영수증 카드가 표시됩니다. 발급·발급취소·수동 재발급을 할 수 있고 발급·취소 이력을 함께 볼 수 있습니다. 재발급이 실패한 주문에는 경고와 함께 다시 발급하는 버튼이 나타납니다.
|
||||
- 이커머스 환경설정 주문설정 탭에서 현금영수증 발급 업체, 배송비 과세 방식, 자진발급 사용 여부를 설정할 수 있습니다.
|
||||
|
||||
#### 환불 계좌
|
||||
|
||||
- 주문서의 무통장입금·가상계좌 결제 영역에서 환불받을 계좌(은행·계좌번호·예금주)를 미리 입력할 수 있습니다. 입력하지 않아도 주문할 수 있지만, 세 칸 중 하나라도 입력하면 나머지도 입력해야 합니다.
|
||||
- 관리자 주문 취소 화면에서 환불 계좌를 입력하거나 수정할 수 있습니다. 주문할 때 입력한 계좌가 있으면 미리 채워집니다. 입금이 완료된 가상계좌 주문은 환불에 계좌가 반드시 필요하므로 입력하지 않으면 취소할 수 없습니다. 무통장입금은 관리자가 직접 이체할 때 참고하도록 선택 입력이며, 카드·간편결제는 원래 결제수단으로 환불되므로 계좌란이 나타나지 않습니다.
|
||||
|
||||
#### 금액·과세
|
||||
|
||||
- 주문에 현금성 금액이 기록됩니다. 무통장입금 주문에서 마일리지를 사용한 경우, 실제로 계좌에 입금된 금액만 현금영수증 발급 대상이 됩니다. 주문을 일부 취소하면 남은 실입금액 기준으로 다시 계산됩니다.
|
||||
@@ -157,6 +169,7 @@
|
||||
- 관리자 목록에서 기록이 아주 많아 총 건수를 끝까지 세지 못하면 "1페이지뿐" 으로 표시되어 뒤쪽 페이지를 볼 수 없던 문제를 수정했습니다. 주문·상품·마일리지·쿠폰·쿠폰 발급 이력·상품 리뷰·배송 정책·활동 이력 목록이 해당하며, 마지막 페이지로 바로 뛰는 버튼만 감춰지고 이전·다음 이동은 그대로 동작합니다.
|
||||
- 쿠폰 사용조건의 상품 검색에서 결과가 많을 때 목록 끝까지 스크롤해도 다음 묶음이 더 불러와지지 않던 문제를 수정했습니다.
|
||||
- 관리자가 주문상품을 일괄 구매확정할 때와 구매자가 직접 구매확정할 때, 구매확정 시점이 최초 확정 시각으로 동일하게 보존됩니다 — 이미 확정된 주문을 다시 확정 처리해도 시점이 덮어써지지 않습니다.
|
||||
- 구매자 주문 상세의 결제 정보 영역에 결제 플러그인이 제공하는 공급가액·부가세·영수증 링크·에스크로 구매확정 버튼이 표시되지 않던 문제를 수정했습니다.
|
||||
|
||||
## [1.0.4] - 2026-07-16
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-ecommerce",
|
||||
"version": "1.0.5",
|
||||
"version": "1.1.0",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -42,7 +42,19 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-404 — 응답 예시는 사람이 작성하세요. -->
|
||||
<!-- @probed -->
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Checkout has been deleted.",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
@@ -208,7 +220,7 @@ Content-Type: application/json
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
<!-- 실측 제외: http-404 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
|
||||
@@ -423,7 +423,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| payment_currency | string | `JPY` | 결제 통화 (유저가 선택·결제한 통화, base_currency 와 다르면 병기 표시) |
|
||||
| is_cross_currency | boolean | `false` | cross currency 여부 |
|
||||
| order_status | string | `payment_complete` | 주문상태 (OrderStatusEnum) |
|
||||
| order_status_label | string | `결제완료` | `order_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) |
|
||||
| order_status_label | string | `Payment Complete` | `order_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) |
|
||||
| order_status_variant | string | `info` | `order_status` 값의 표시 변형 키 (UI 배지 색상/스타일) |
|
||||
| is_partially_cancelled | boolean | `false` | partially cancelled 여부 |
|
||||
| order_device | string | `pc` | 주문 디바이스 (pc/mobile/app) |
|
||||
@@ -511,8 +511,10 @@ _단건 응답: `data` 객체의 필드._
|
||||
| options | array | `[{"id":1263,"option_status":"payment_complete","option_st…` | 주문 옵션(품목) 목록 (OrderOptionResource — 상품·옵션·수량·옵션상태·금액) |
|
||||
| shipping_address | object | `{"id":319,"address_type":"shipping","orderer_name":"박대수",…` | 배송지 상세 (OrderAddressResource — 주문자/수령인/국내·해외 주소) |
|
||||
| billing_address | null | `null` | 청구지 상세 (OrderAddressResource, 미분리 시 null) |
|
||||
| payment | object | `{"id":334,"payment_status":"paid","payment_status_label":…` | 대표 결제 정보 (OrderPaymentResource — 결제수단·결제상태·금액) |
|
||||
| payments | array | `[{"id":334,"payment_status":"paid","payment_status_label"…` | 결제 이력 목록 (OrderPaymentResource 배열 — 다회 결제/부분결제 포함) |
|
||||
| payment | object | `{"id":1,"payment_status":"paid","payment_status_label":"P…` | 대표 결제 정보 (OrderPaymentResource — 결제수단·결제상태·금액) |
|
||||
| payments | array | `[{"id":1,"payment_status":"paid","payment_status_label":"…` | 결제 이력 목록 (OrderPaymentResource 배열 — 다회 결제/부분결제 포함) |
|
||||
| cash_receipt | null | `null` | 현재 유효한 현금영수증 (CashReceiptResource — 취소되지 않은 발급 건, 없으면 null) |
|
||||
| cash_receipts | array | `[]` | 현금영수증 발급·취소 이력 전체 (CashReceiptResource 배열 — 취소된 건 포함) |
|
||||
| shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) |
|
||||
| cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) |
|
||||
| promotions_applied_snapshot | object | `{"coupon_issue_ids":[7330],"item_coupons":[],"discount_co…` | 적용된 프로모션 스냅샷 (재계산용) |
|
||||
@@ -653,6 +655,9 @@ Content-Type: application/json
|
||||
| items | body | array | 아니오 | min 1 | 처리 대상 항목 배열 |
|
||||
| cancel_pg | body | boolean | 아니오 | — | PG 결제 취소 동반 여부 (미지정 시 기본 true — 실제 PG 취소 수행) |
|
||||
| refund_priority | body | string | 아니오 | `pg_first`, `points_first` | 환불 배분 우선순위 (pg_first PG 우선 / points_first 포인트 우선, 기본 pg_first) |
|
||||
| refund_bank.bank_code | body | string | 아니오 | max 10 | 환불 계좌 은행코드. 가상계좌 + 입금완료 건은 필수(주문 시 입력된 계좌가 있으면 생략 가능). 세 필드는 전부 입력하거나 전부 비워야 함 |
|
||||
| refund_bank.account_number | body | string | 아니오 | max 50 | 환불 계좌번호 |
|
||||
| refund_bank.holder | body | string | 아니오 | max 50 | 환불 계좌 예금주 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
@@ -1641,6 +1646,13 @@ HTTP/1.1 200
|
||||
| shipping_memo | body | string | 아니오 | max 500 | 배송 요청사항 메모 |
|
||||
| depositor_name | body | string | 아니오 | max 50 | depositor 이름 (식별자) |
|
||||
| save_shipping_address | body | boolean | 아니오 | — | 회원 주소록에 이번 배송지 저장 여부 (회원 주문 한정) |
|
||||
| cash_receipt_requested | body | boolean | 아니오 | — | 현금영수증 신청 여부 (true 면 아래 3개 필드가 필수) |
|
||||
| cash_receipt_type | body | string | 아니오 | — | 발급 용도 (`income` 소득공제 / `expense` 지출증빙) |
|
||||
| cash_receipt_identifier_type | body | string | 아니오 | — | 발급 수단 (`phone` 휴대폰번호 / `card` 현금영수증카드 / `business` 사업자등록번호 — business 는 지출증빙 전용) |
|
||||
| cash_receipt_identifier | body | string | 아니오 | max 30 | 발급 식별번호 (하이픈 없는 숫자. 사업자등록번호는 체크섬 검증. 마스킹 저장 + 원본은 암호화 보관) |
|
||||
| refund_bank.bank_code | body | string | 아니오 | max 10 | 환불 계좌 은행코드 (세 필드는 전부 입력하거나 전부 비워야 함) |
|
||||
| refund_bank.account_number | body | string | 아니오 | max 50 | 환불 계좌번호 |
|
||||
| refund_bank.holder | body | string | 아니오 | max 50 | 환불 계좌 예금주 |
|
||||
| guest_lookup_password | body | string | 예 | min 8, max 255 | 비회원 주문 조회 비밀번호 (비회원만 필수, 8자 이상 · 해시로 저장) |
|
||||
| guest_lookup_password_confirmation | body | string | 예 | — | 조회 비밀번호 확인 (guest_lookup_password 와 일치해야 함) |
|
||||
|
||||
@@ -1661,6 +1673,10 @@ Content-Type: application/json
|
||||
"shipping_memo": "예시값",
|
||||
"depositor_name": "예시 이름",
|
||||
"save_shipping_address": true,
|
||||
"cash_receipt_requested": true,
|
||||
"cash_receipt_type": "예시값",
|
||||
"cash_receipt_identifier_type": "example-key",
|
||||
"cash_receipt_identifier": "example-key",
|
||||
"guest_lookup_password": "Password123!",
|
||||
"guest_lookup_password_confirmation": "Password123!"
|
||||
}
|
||||
|
||||
@@ -42,9 +42,9 @@ _단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| basic_info | object | `{"shop_name":"111","route_path":"shop","no_route":false,"…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
|
||||
| language_currency | object | `{"default_currency":"JPY","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
|
||||
| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) |
|
||||
| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
|
||||
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
|
||||
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) |
|
||||
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) |
|
||||
| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) |
|
||||
| review_settings | object | `{"write_deadline_days":90,"max_images":5,"max_image_size_…` | 리뷰 정책 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) |
|
||||
@@ -53,6 +53,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| mileage | object | `{"enabled":false,"default_earn_rate":1,"earn_trigger":"co…` | 마일리지 설정 (사용 여부·기본 적립률·적립 트리거·통화별 규칙·소멸/소멸 알림·실제 활성 알림 채널 포함) |
|
||||
| claim | object | `{"refund_reasons":[{"id":1,"type":"refund","code":"order_…` | 클레임 설정 (DB 관리 대상인 환불 사유 목록: 코드·다국어명·귀책 유형·노출/활성 여부) |
|
||||
| available_pg_providers | array | `[{"id":"kginicis","name_key":"sirsoft-pay_kginicis::provi…` | 설치된 PG 플러그인이 훅으로 등록한 PG 제공자 목록 (id·name_key·지원 결제수단) |
|
||||
| available_cash_receipt_providers | array | `[]` | 설치된 플러그인이 훅으로 등록한 현금영수증 발급 제공자 목록 (id·name_key — 미등록 시 빈 배열이며 신청 폼이 노출되지 않음) |
|
||||
| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
| _meta | object | `{"limits":{"auto_cancel_days_min":1,"auto_cancel_days_max…` | <!-- TODO: 설명 --> |
|
||||
|
||||
@@ -481,9 +482,9 @@ _단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| basic_info | object | `{"shop_name":"111","route_path":"shop","no_route":false,"…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
|
||||
| language_currency | object | `{"default_currency":"JPY","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
|
||||
| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) |
|
||||
| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
|
||||
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
|
||||
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) |
|
||||
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) |
|
||||
| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) |
|
||||
| review_settings | object | `{"write_deadline_days":90,"max_images":5,"max_image_size_…` | 리뷰 정책 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) |
|
||||
@@ -492,6 +493,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| mileage | object | `{"enabled":false,"default_earn_rate":1,"earn_trigger":"co…` | 마일리지 설정 (사용 여부·기본 적립률·적립 트리거·통화별 규칙·소멸/소멸 알림·실제 활성 알림 채널 포함) |
|
||||
| claim | object | `{"refund_reasons":[{"id":1,"type":"refund","code":"order_…` | 클레임 설정 (DB 관리 대상인 환불 사유 목록: 코드·다국어명·귀책 유형·노출/활성 여부) |
|
||||
| available_pg_providers | array | `[{"id":"kginicis","name_key":"sirsoft-pay_kginicis::provi…` | 설치된 PG 플러그인이 훅으로 등록한 PG 제공자 목록 (id·name_key·지원 결제수단) |
|
||||
| available_cash_receipt_providers | array | `[]` | 설치된 플러그인이 훅으로 등록한 현금영수증 발급 제공자 목록 (id·name_key — 미등록 시 빈 배열이며 신청 폼이 노출되지 않음) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -896,9 +898,9 @@ _단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| basic_info | object | `{"shop_name":"111","route_path":"shop","no_route":false,"…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
|
||||
| language_currency | object | `{"default_currency":"JPY","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
|
||||
| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) |
|
||||
| basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) |
|
||||
| language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) |
|
||||
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) |
|
||||
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) |
|
||||
| seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) |
|
||||
| review_settings | object | `{"write_deadline_days":90,"max_images":5,"max_image_size_…` | 리뷰 정책 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) |
|
||||
@@ -1301,7 +1303,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 체크아웃용 배송 설정 (기본 국가·배송 가능 국가·무료배송·배송유형 등) |
|
||||
| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 체크아웃용 주문/결제 설정 (기본 PG·활성 결제수단·무통장 계좌 등) |
|
||||
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 체크아웃용 주문/결제 설정 (기본 PG·활성 결제수단·무통장 계좌 등) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -1434,7 +1436,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 공개 가능한 결제 설정 (활성 결제수단·무통장 은행명 매핑 포함, 민감 정보 제외) |
|
||||
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 공개 가능한 결제 설정 (활성 결제수단·무통장 은행명 매핑 포함, 민감 정보 제외) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"_comment": "체크아웃 현금영수증 신청 폼 (W-1 / #454). 템플릿 sirsoft-basic 의 _checkout_payment.json 이 dbank 블록 내부 말단에 연 슬롯에 주입한다. 슬롯이 무통장 블록 안이므로 결제수단 게이트는 불필요하고, 프로바이더 미설정 시에만 전체를 숨긴다. 발급 프로바이더는 PG 플러그인이 담당하지만 신청 폼은 PG 비종속이라 모듈이 소유한다 — 플러그인이 주입하면 두 PG 동시 활성 시 폼이 2개 렌더된다.",
|
||||
"extension_point": "shop_checkout_cash_receipt_slot",
|
||||
"mode": "append",
|
||||
"priority": 100,
|
||||
"components": [
|
||||
{
|
||||
"id": "ext_checkout_cash_receipt",
|
||||
"comment": "프로바이더 미설정이면 슬롯 전체 미렌더 (M2 — DOM 카운트 0)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!paymentSettings?.data?.order_settings?.cash_receipt_provider}}",
|
||||
"props": { "className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-600" },
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": { "className": "mt-3 pt-3 border-t border-gray-200 dark:border-gray-600" }
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "신청 체크박스 — 해제 시 하위 3필드를 if 로 숨기고 확장 칸을 비운다",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex items-center gap-2" },
|
||||
"children": [
|
||||
{
|
||||
"id": "ext_checkout_cash_receipt_toggle",
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "checkbox",
|
||||
"name": "cash_receipt_requested",
|
||||
"checked": "{{_local.cashReceiptRequested ?? false}}",
|
||||
"className": "w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"comment": "체크 시 기본값(소득공제 × 휴대폰번호)으로 확장 칸을 시드하고, 해제 시 확장 칸을 비워 주문 payload 에서 제거한다.",
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"cashReceiptRequested": "{{$event.target.checked}}",
|
||||
"cashReceiptType": "{{$event.target.checked ? (_local.cashReceiptType ?? 'income') : null}}",
|
||||
"cashReceiptIdentifierType": "{{$event.target.checked ? (_local.cashReceiptIdentifierType ?? 'phone') : null}}",
|
||||
"cashReceiptIdentifier": "{{$event.target.checked ? (_local.cashReceiptIdentifier ?? '') : null}}",
|
||||
"checkoutExtraPayload": "{{$event.target.checked ? { cash_receipt_requested: true, cash_receipt_type: (_local.cashReceiptType ?? 'income'), cash_receipt_identifier_type: (_local.cashReceiptIdentifierType ?? 'phone'), cash_receipt_identifier: (_local.cashReceiptIdentifier ?? '') } : {}}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "text-sm font-medium text-gray-700 dark:text-gray-300" },
|
||||
"text": "$t:sirsoft-ecommerce.checkout.cash_receipt.request"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ext_checkout_cash_receipt_fields",
|
||||
"comment": "신청 시에만 렌더 (M4 — 클릭 전후 필드 수 0 → 3)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!_local.cashReceiptRequested}}",
|
||||
"props": { "className": "mt-3 space-y-3" },
|
||||
"children": [
|
||||
{
|
||||
"comment": "용도 — 라디오 2종 (가로 → portable 세로 스택)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
|
||||
"text": "$t:sirsoft-ecommerce.checkout.cash_receipt.purpose"
|
||||
},
|
||||
{
|
||||
"id": "ext_checkout_cash_receipt_purpose",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex flex-row gap-4" },
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": { "className": "flex flex-col gap-2" }
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "소득공제용",
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "radio",
|
||||
"name": "cash_receipt_type",
|
||||
"value": "income",
|
||||
"checked": "{{(_local.cashReceiptType ?? 'income') === 'income'}}",
|
||||
"className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"comment": "소득공제로 전환 — 사업자등록번호는 소득공제에 쓸 수 없으므로 발급수단이 business 면 phone 으로 리셋 (M5)",
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"cashReceiptType": "income",
|
||||
"cashReceiptIdentifierType": "{{_local.cashReceiptIdentifierType === 'business' ? 'phone' : (_local.cashReceiptIdentifierType ?? 'phone')}}",
|
||||
"cashReceiptIdentifier": "{{_local.cashReceiptIdentifierType === 'business' ? '' : (_local.cashReceiptIdentifier ?? '')}}",
|
||||
"checkoutExtraPayload": "{{ { cash_receipt_requested: true, cash_receipt_type: 'income', cash_receipt_identifier_type: (_local.cashReceiptIdentifierType === 'business' ? 'phone' : (_local.cashReceiptIdentifierType ?? 'phone')), cash_receipt_identifier: (_local.cashReceiptIdentifierType === 'business' ? '' : (_local.cashReceiptIdentifier ?? '')) } }}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-ecommerce.checkout.cash_receipt.purpose_income"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "지출증빙용",
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "radio",
|
||||
"name": "cash_receipt_type",
|
||||
"value": "expense",
|
||||
"checked": "{{_local.cashReceiptType === 'expense'}}",
|
||||
"className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"comment": "지출증빙으로 전환 — 발급수단 3종이 모두 유효하므로 현재 선택을 유지한다 (D10: 휴대폰도 지출증빙 가능)",
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"cashReceiptType": "expense",
|
||||
"checkoutExtraPayload": "{{ { cash_receipt_requested: true, cash_receipt_type: 'expense', cash_receipt_identifier_type: (_local.cashReceiptIdentifierType ?? 'phone'), cash_receipt_identifier: (_local.cashReceiptIdentifier ?? '') } }}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-ecommerce.checkout.cash_receipt.purpose_expense"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "발급수단 / 번호 — 2열 → portable 1열",
|
||||
"id": "ext_checkout_cash_receipt_identifier_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "grid grid-cols-2 gap-3" },
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": { "className": "grid grid-cols-1 gap-3" }
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "ext_checkout_cash_receipt_identifier_type",
|
||||
"comment": "발급수단 options — 소득공제는 2종, 지출증빙은 사업자등록번호 포함 3종 (D10). computed 대신 표현식으로 {value,label} 변환 (valueKey/labelKey prop 금지).",
|
||||
"type": "basic",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "cash_receipt_identifier_type",
|
||||
"value": "{{_local.cashReceiptIdentifierType ?? 'phone'}}",
|
||||
"options": "{{(_local.cashReceiptType === 'expense' ? [{ value: 'business', label: $t('sirsoft-ecommerce.checkout.cash_receipt.identifier_business') }] : []).concat([{ value: 'phone', label: $t('sirsoft-ecommerce.checkout.cash_receipt.identifier_phone') }, { value: 'card', label: $t('sirsoft-ecommerce.checkout.cash_receipt.identifier_card') }])}}",
|
||||
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"comment": "발급수단 변경 시 번호를 비운다 — 휴대폰번호를 사업자등록번호 칸에 남겨두면 검증에서 422 가 난다.",
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"cashReceiptIdentifierType": "{{$event.target.value}}",
|
||||
"cashReceiptIdentifier": "",
|
||||
"checkoutExtraPayload": "{{ { cash_receipt_requested: true, cash_receipt_type: (_local.cashReceiptType ?? 'income'), cash_receipt_identifier_type: $event.target.value, cash_receipt_identifier: '' } }}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ext_checkout_cash_receipt_identifier",
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "text",
|
||||
"name": "cash_receipt_identifier",
|
||||
"value": "{{_local.cashReceiptIdentifier ?? ''}}",
|
||||
"placeholder": "$t:sirsoft-ecommerce.checkout.cash_receipt.identifier_placeholder",
|
||||
"className": "{{_local.errors?.cash_receipt_identifier ? 'w-full px-4 py-2 border-2 border-red-500 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500' : 'w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500'}}"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "{{_local.errors?.cash_receipt_identifier ? 'w-full px-3 py-2 border-2 border-red-500 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 text-sm' : 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm'}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"cashReceiptIdentifier": "{{$event.target.value}}",
|
||||
"checkoutExtraPayload": "{{ { cash_receipt_requested: true, cash_receipt_type: (_local.cashReceiptType ?? 'income'), cash_receipt_identifier_type: (_local.cashReceiptIdentifierType ?? 'phone'), cash_receipt_identifier: $event.target.value } }}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "서버 검증 오류 인라인 노출 (M6 — 사업자번호 체크섬 오류 등)",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!_local.errors?.cash_receipt_identifier}}",
|
||||
"props": { "className": "block text-red-500 text-xs" },
|
||||
"text": "{{_local.errors?.cash_receipt_identifier?.[0] ?? ''}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "text-xs text-gray-500 dark:text-gray-400" },
|
||||
"text": "$t:sirsoft-ecommerce.checkout.cash_receipt.purpose_help"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "text-xs text-gray-500 dark:text-gray-400" },
|
||||
"text": "$t:sirsoft-ecommerce.checkout.cash_receipt.immutable_help"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+470
@@ -0,0 +1,470 @@
|
||||
{
|
||||
"_comment": "유저 주문상세 현금영수증 카드 (W-2 / #454). 템플릿 sirsoft-basic 의 partials/mypage/orders/_payment.json 말단 슬롯에 주입한다. 상태머신 5종: ①무통장 아님/프로바이더 미설정→미렌더 ②입금전→안내 ③입금완료+미발급→발급 버튼 ④발급완료→링크 ⑤재발급 실패→경고(유저에겐 수동 재발급 버튼을 주지 않는다 — 관리자 전용). 발급 POST 는 로드된 주문의 실제 id(order.data.id)를 쓴다.",
|
||||
"extension_point": "mypage_order_cash_receipt_slot",
|
||||
"mode": "append",
|
||||
"priority": 100,
|
||||
"modals": [
|
||||
{
|
||||
"id": "ext_cash_receipt_issue_modal",
|
||||
"comment": "발급 모달 — 상태 ③에서 열린다. 모달은 별도 컨텍스트라 부모 _local 을 참조할 수 없으므로 데이터소스(order)와 _global 만 쓴다.",
|
||||
"type": "composite",
|
||||
"name": "Modal",
|
||||
"props": {
|
||||
"title": "$t:sirsoft-ecommerce.order.cash_receipt.issue_title",
|
||||
"size": "small"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "space-y-4" },
|
||||
"children": [
|
||||
{
|
||||
"comment": "용도",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
|
||||
"text": "$t:sirsoft-ecommerce.order.cash_receipt.purpose"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex flex-row gap-4" },
|
||||
"responsive": {
|
||||
"portable": { "props": { "className": "flex flex-col gap-2" } }
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "radio",
|
||||
"name": "receipt_type",
|
||||
"value": "income",
|
||||
"checked": "{{(_global.cashReceiptForm?.receipt_type ?? 'income') === 'income'}}",
|
||||
"className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"comment": "소득공제 전환 시 사업자등록번호 선택을 phone 으로 리셋 (소득공제에 사용 불가)",
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"cashReceiptForm.receipt_type": "income",
|
||||
"cashReceiptForm.identifier_type": "{{_global.cashReceiptForm?.identifier_type === 'business' ? 'phone' : (_global.cashReceiptForm?.identifier_type ?? 'phone')}}",
|
||||
"cashReceiptForm.identifier": "{{_global.cashReceiptForm?.identifier_type === 'business' ? '' : (_global.cashReceiptForm?.identifier ?? '')}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-ecommerce.order.cash_receipt.purpose_income" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "radio",
|
||||
"name": "receipt_type",
|
||||
"value": "expense",
|
||||
"checked": "{{_global.cashReceiptForm?.receipt_type === 'expense'}}",
|
||||
"className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": { "target": "global", "cashReceiptForm.receipt_type": "expense" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-ecommerce.order.cash_receipt.purpose_expense" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "발급수단 — 지출증빙일 때만 사업자등록번호 추가 (D10)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
|
||||
"text": "$t:sirsoft-ecommerce.order.cash_receipt.identifier_type"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "identifier_type",
|
||||
"value": "{{_global.cashReceiptForm?.identifier_type ?? 'phone'}}",
|
||||
"options": "{{(_global.cashReceiptForm?.receipt_type === 'expense' ? [{ value: 'business', label: $t('sirsoft-ecommerce.order.cash_receipt.identifier_business') }] : []).concat([{ value: 'phone', label: $t('sirsoft-ecommerce.order.cash_receipt.identifier_phone') }, { value: 'card', label: $t('sirsoft-ecommerce.order.cash_receipt.identifier_card') }])}}",
|
||||
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"comment": "발급수단 변경 시 번호를 비운다 (형식이 달라 검증 실패를 유발)",
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"cashReceiptForm.identifier_type": "{{$event.target.value}}",
|
||||
"cashReceiptForm.identifier": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "번호",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
|
||||
"text": "$t:sirsoft-ecommerce.order.cash_receipt.identifier"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "text",
|
||||
"name": "identifier",
|
||||
"value": "{{_global.cashReceiptForm?.identifier ?? ''}}",
|
||||
"placeholder": "$t:sirsoft-ecommerce.order.cash_receipt.identifier_placeholder",
|
||||
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": { "target": "global", "cashReceiptForm.identifier": "{{$event.target.value}}" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!_global.cashReceiptForm?.error}}",
|
||||
"props": { "className": "block text-red-500 text-xs mt-1" },
|
||||
"text": "{{_global.cashReceiptForm?.error ?? ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "text-xs text-gray-500 dark:text-gray-400" },
|
||||
"text": "$t:sirsoft-ecommerce.order.cash_receipt.immutable_help"
|
||||
},
|
||||
{
|
||||
"comment": "액션 — 데스크톱 가로 우측정렬, portable 세로 full-width",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex flex-row justify-end gap-2 pt-2" },
|
||||
"responsive": {
|
||||
"portable": { "props": { "className": "flex flex-col gap-2 w-full pt-2" } }
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.order.cash_receipt.cancel",
|
||||
"actions": [{ "type": "click", "handler": "closeModal" }]
|
||||
},
|
||||
{
|
||||
"id": "ext_cash_receipt_issue_submit",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-4 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{!(_global.cashReceiptForm?.identifier ?? '')}}"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.order.cash_receipt.issue",
|
||||
"actions": [
|
||||
{
|
||||
"comment": "회원은 user/orders/{id}, 비회원은 guest/orders/{orderNumber} + X-Guest-Order-Token. 주문 응답의 실제 id 를 쓴다(라우트 파라미터가 주문번호일 수 있으므로).",
|
||||
"type": "click",
|
||||
"handler": "apiCall",
|
||||
"target": "{{_global.currentUser?.uuid ? ('/api/modules/sirsoft-ecommerce/user/orders/' + order.data.id + '/cash-receipt') : ('/api/modules/sirsoft-ecommerce/guest/orders/' + order.data.order_number + '/cash-receipt')}}",
|
||||
"auth_mode": "optional",
|
||||
"params": {
|
||||
"method": "POST",
|
||||
"headers": { "X-Guest-Order-Token": "{{_global.guestOrderToken ?? ''}}" },
|
||||
"body": {
|
||||
"receipt_type": "{{_global.cashReceiptForm?.receipt_type ?? 'income'}}",
|
||||
"identifier_type": "{{_global.cashReceiptForm?.identifier_type ?? 'phone'}}",
|
||||
"identifier": "{{_global.cashReceiptForm?.identifier ?? ''}}"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
{ "handler": "setState", "params": { "target": "global", "cashReceiptForm": null } },
|
||||
{ "handler": "closeModal" },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "order" } },
|
||||
{ "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-ecommerce.order.cash_receipt.issue_success" } }
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"comment": "서버 검증 메시지를 그대로 노출한다 (사업자번호 체크섬·용도×수단 조합 등)",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"cashReceiptForm.error": "{{error.errors?.identifier?.[0] ?? error.message ?? $t('sirsoft-ecommerce.order.cash_receipt.issue_failed')}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"id": "ext_mypage_cash_receipt_card",
|
||||
"comment": "상태 ① — 무통장이 아니거나 프로바이더 미설정이면 카드 자체를 렌더하지 않는다",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{order.data.payment?.payment_method === 'dbank' && !!order.data.payment?.cash_receipt_provider}}",
|
||||
"props": { "className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-700" },
|
||||
"responsive": {
|
||||
"portable": { "props": { "className": "mt-3 pt-3 border-t border-gray-200 dark:border-gray-700" } }
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex items-center gap-2 mb-2" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "receipt", "size": "sm", "className": "text-gray-700 dark:text-gray-300" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H4",
|
||||
"props": { "className": "text-sm font-semibold text-gray-900 dark:text-white" },
|
||||
"text": "$t:sirsoft-ecommerce.order.cash_receipt.title"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ext_mypage_cash_receipt_awaiting",
|
||||
"comment": "상태 ② 입금 전 — 발급 불가 안내 + 신청내역(신청했을 때만). 입금 전 상태는 두 가지다: 무통장 주문은 ready 로 생성되고 가상계좌는 waiting_deposit 이다 (PaymentStatusEnum::isAwaitingDeposit() 이 둘 다 true). waiting_deposit 만 검사하면 무통장 주문 전부에서 카드 본문이 사라진다.",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{['ready', 'waiting_deposit'].includes(order.data.payment?.payment_status)}}",
|
||||
"props": { "className": "space-y-1" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "text-sm text-gray-500 dark:text-gray-400" },
|
||||
"text": "$t:sirsoft-ecommerce.order.cash_receipt.awaiting_deposit"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{!!order.data.payment?.is_cash_receipt_requested}}",
|
||||
"props": { "className": "text-sm text-gray-700 dark:text-gray-300" },
|
||||
"text": "{{order.data.payment?.cash_receipt_type_label ?? ''}} · {{order.data.payment?.cash_receipt_identifier ?? ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ext_mypage_cash_receipt_issuable",
|
||||
"comment": "상태 ③ 입금완료 + 미발급 — 발급 버튼. 재발급 실패 이력이 있으면 상태 ⑤(경고)가 담당하므로 여기서 제외한다(두 상태는 배타적이어야 한다).",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{order.data.payment?.payment_status === 'paid' && !order.data.cash_receipt && String((order.data.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() !== 'failed'}}",
|
||||
"props": { "className": "flex flex-row items-center justify-between gap-2" },
|
||||
"responsive": {
|
||||
"portable": { "props": { "className": "flex flex-col items-start gap-2 w-full" } }
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "text-sm text-gray-500 dark:text-gray-400" },
|
||||
"text": "$t:sirsoft-ecommerce.order.cash_receipt.none"
|
||||
},
|
||||
{
|
||||
"id": "ext_mypage_cash_receipt_issue_button",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "w-full px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.order.cash_receipt.issue",
|
||||
"actions": [
|
||||
{
|
||||
"comment": "모달을 열기 전에 신청내역으로 폼을 시드한다 (미신청이면 기본값)",
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"params": {
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"cashReceiptForm": "{{ { receipt_type: (order.data.payment?.cash_receipt_type ?? 'income'), identifier_type: (order.data.payment?.cash_receipt_identifier_type ?? 'phone'), identifier: '', error: null } }}"
|
||||
}
|
||||
},
|
||||
{ "handler": "openModal", "target": "ext_cash_receipt_issue_modal" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ext_mypage_cash_receipt_issued",
|
||||
"comment": "상태 ④ 발급완료 — 발급일시/용도/식별번호 + 영수증 링크",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!order.data.cash_receipt}}",
|
||||
"props": { "className": "space-y-1" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex items-center justify-between" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-ecommerce.order.cash_receipt.issued_at" },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-900 dark:text-white" }, "text": "{{order.data.cash_receipt?.issued_at_formatted ?? ''}}" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex items-center justify-between" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-ecommerce.order.cash_receipt.purpose" },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-900 dark:text-white" }, "text": "{{order.data.cash_receipt?.receipt_type_label ?? ''}}" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex items-center justify-between" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-ecommerce.order.cash_receipt.identifier" },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-900 dark:text-white" }, "text": "{{order.data.cash_receipt?.identifier_masked ?? ''}}" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "영수증 링크 — 이력 테이블 소유(payment.pg_receipt_url 은 카드 매출전표라 별개)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!order.data.cash_receipt?.receipt_url}}",
|
||||
"props": { "className": "flex flex-row justify-end pt-2" },
|
||||
"responsive": {
|
||||
"portable": { "props": { "className": "flex flex-col w-full pt-2" } }
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "ext_mypage_cash_receipt_link",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "{{order.data.cash_receipt?.receipt_url}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"className": "inline-flex items-center justify-center gap-1 px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"href": "{{order.data.cash_receipt?.receipt_url}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"className": "inline-flex w-full items-center justify-center gap-1 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.order.cash_receipt.view_receipt"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ext_mypage_cash_receipt_failed",
|
||||
"comment": "상태 ⑤ 취소 성공 + 재발급 실패 — 유저에게는 경고만 노출한다(수동 재발급은 관리자 전용). issue_status backing value 는 대문자(FAILED)라 대소문자 무관 비교한다.",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!order.data.cash_receipt && (order.data.cash_receipts ?? []).length > 0 && String((order.data.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() === 'failed'}}",
|
||||
"props": { "className": "rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 space-y-1" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex items-center gap-2" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "triangle-exclamation", "size": "sm", "className": "text-amber-600 dark:text-amber-400" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": { "className": "text-sm font-medium text-amber-800 dark:text-amber-300" },
|
||||
"text": "$t:sirsoft-ecommerce.order.cash_receipt.reissue_failed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "text-xs text-amber-700 dark:text-amber-400" },
|
||||
"text": "$t:sirsoft-ecommerce.order.cash_receipt.reissue_failed_help"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+41
@@ -157,4 +157,45 @@ describe('executeCancelOrderHandler — 항상 items 전송 (백엔드 FULL 승
|
||||
expect(body.cancel_pg).toBe(false);
|
||||
expect(body.refund_priority).toBe('points_first');
|
||||
});
|
||||
|
||||
describe('환불 계좌 (#454 S3)', () => {
|
||||
beforeEach(() => {
|
||||
mockOrderDataSource = {
|
||||
data: { options: [{ id: 100, quantity: 2, option_status: 'payment_complete' }] },
|
||||
};
|
||||
mockLocalState.cancelItems = [{ id: 100, quantity: 2, cancel_quantity: 1 }];
|
||||
});
|
||||
|
||||
it('미입력이면 refund_bank 키 자체를 보내지 않는다 (주문 시 입력된 계좌 보존)', async () => {
|
||||
const body = await runCancel();
|
||||
|
||||
expect(body.refund_bank).toBeUndefined();
|
||||
});
|
||||
|
||||
it('3필드를 모두 입력하면 refund_bank 로 전송된다', async () => {
|
||||
mockLocalState.refundBankCode = '004';
|
||||
mockLocalState.refundBankAccount = '110-123-456789';
|
||||
mockLocalState.refundBankHolder = '홍길동';
|
||||
|
||||
const body = await runCancel();
|
||||
|
||||
expect(body.refund_bank).toEqual({
|
||||
bank_code: '004',
|
||||
account_number: '110-123-456789',
|
||||
holder: '홍길동',
|
||||
});
|
||||
});
|
||||
|
||||
it('부분 입력도 그대로 전송한다 — 거부 판정은 서버(CancelOrderRequest)의 책임이다', async () => {
|
||||
mockLocalState.refundBankAccount = '110-123-456789';
|
||||
|
||||
const body = await runCancel();
|
||||
|
||||
expect(body.refund_bank).toEqual({
|
||||
bank_code: '',
|
||||
account_number: '110-123-456789',
|
||||
holder: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+13
-2
@@ -133,8 +133,19 @@ describe('주문설정 탭 구조 검증 (_tab_order_settings.json)', () => {
|
||||
expect(tab.if).toContain('order_settings');
|
||||
});
|
||||
|
||||
it('8개 카드 섹션을 포함해야 한다 (기본 PG / 결제수단 / 계좌 / 자동취소 / 취소가능상태 / 확정가능상태 / 장바구니 / 재고)', () => {
|
||||
expect(tab.children).toHaveLength(8);
|
||||
it('카드 섹션이 정해진 순서로 배치된다', () => {
|
||||
// 개수가 아니라 id 를 고정한다 — 카드가 추가·제거되면 어느 카드인지 바로 드러난다.
|
||||
expect(tab.children.map((c: any) => c.id)).toEqual([
|
||||
'default_pg_card',
|
||||
'cash_receipt_card',
|
||||
'payment_methods_card',
|
||||
'bank_accounts_card',
|
||||
'auto_cancel_card',
|
||||
'cancellable_statuses_card',
|
||||
'confirmable_statuses_card',
|
||||
'cart_expiry_card',
|
||||
'stock_management_card',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @file cashReceiptResponsiveRender.test.tsx
|
||||
* @description 체크아웃 현금영수증 폼의 반응형 오버라이드 실렌더 검증 (#454 S3, F5)
|
||||
*
|
||||
* cashReceiptUi.test.tsx 는 node 환경(렌더 없음)이라 JSON 구조만 대조한다.
|
||||
* 그래서 "responsive.portable 이 선언되어 있다" 까지만 알 수 있고,
|
||||
* "좁은 화면에서 실제로 세로 스택으로 렌더된다" 는 확인하지 못한다.
|
||||
*
|
||||
* 본 파일은 DynamicRenderer 를 실제로 렌더해 그 간극을 메운다:
|
||||
* - DynamicRenderer 는 useResponsive().width 를 읽어 responsiveManager.getMatchingKey 로
|
||||
* 오버라이드 키를 고른다(DynamicRenderer.tsx:1413, 1445). 그 width 를 mock 으로 주입한다.
|
||||
* - portable 은 0~1023px 단일 오버라이드다(§6-0). 1024px 이상에서는 base 가 그대로 남아야 한다.
|
||||
*
|
||||
* 회귀 방지 대상: 누군가 responsive.portable 을 지우고 Tailwind `md:` 로 되돌리면
|
||||
* 위지윅 편집기의 디바이스 전환(overrideWidth)이 먹지 않는다 — 그때 이 테스트가 깨진다.
|
||||
*
|
||||
* 주의: 이 docblock 에 vitest 환경 지시자 토큰을 문자열로 적지 말 것.
|
||||
* vitest 는 파일 상단 주석을 정규식으로 훑어 환경을 결정하므로, 산문 속 언급도
|
||||
* 그대로 적용되어 jsdom 대신 node 로 실행된다(그러면 window 부재로 수집 단계에서 죽는다).
|
||||
*
|
||||
* @scenario actor=guest, change_mode=manual
|
||||
*
|
||||
* @effects portable_override_applies_below_1024_on_render,
|
||||
* portable_override_absent_at_1024_on_render,
|
||||
* new_ui_has_no_tailwind_breakpoint
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { createLayoutTest } from '@core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
|
||||
|
||||
import extensionJson from '../../../extensions/checkout_cash_receipt.json';
|
||||
|
||||
/** 현재 테스트가 주입할 화면 너비 (아래 mock 이 읽는다) */
|
||||
let mockWidth = 1280;
|
||||
|
||||
vi.mock('@core/template-engine/ResponsiveContext', () => ({
|
||||
ResponsiveContext: React.createContext(null),
|
||||
ResponsiveProvider: ({ children }: { children?: React.ReactNode }) => children,
|
||||
useResponsive: () => ({
|
||||
width: mockWidth,
|
||||
isMobile: mockWidth < 768,
|
||||
isTablet: mockWidth >= 768 && mockWidth < 1024,
|
||||
isDesktop: mockWidth >= 1024,
|
||||
matchedPreset: (mockWidth < 768 ? 'mobile' : mockWidth < 1024 ? 'tablet' : 'desktop') as
|
||||
| 'mobile'
|
||||
| 'tablet'
|
||||
| 'desktop',
|
||||
}),
|
||||
}));
|
||||
|
||||
/** id·className 을 그대로 DOM 에 흘리는 스텁 (검사 대상이 그 두 속성이다) */
|
||||
const Stub: React.FC<Record<string, any>> = ({ children, className, id, text }) => (
|
||||
<div id={id} className={className}>
|
||||
{children ?? text}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* ComponentRegistry 싱글톤을 스텁으로 채운다.
|
||||
*
|
||||
* Fragment 는 createLayoutTest 가 루트 컨테이너로 쓴다 — 빠뜨리면 자식 트리가
|
||||
* 통째로 렌더되지 않고 빈 컨테이너만 남는다.
|
||||
*/
|
||||
function setupTestRegistry(): void {
|
||||
const registry = ComponentRegistry.getInstance();
|
||||
|
||||
(registry as any).registry = {
|
||||
Div: { component: Stub, metadata: { name: 'Div', type: 'basic' } },
|
||||
Span: { component: Stub, metadata: { name: 'Span', type: 'basic' } },
|
||||
P: { component: Stub, metadata: { name: 'P', type: 'basic' } },
|
||||
Label: { component: Stub, metadata: { name: 'Label', type: 'basic' } },
|
||||
Input: { component: Stub, metadata: { name: 'Input', type: 'basic' } },
|
||||
Select: { component: Stub, metadata: { name: 'Select', type: 'basic' } },
|
||||
Fragment: {
|
||||
component: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
metadata: { name: 'Fragment', type: 'layout' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 슬롯 컴포넌트 트리를 독립 레이아웃으로 감싼다 (확장 JSON 은 extension_point 채움용이다) */
|
||||
function layoutWithSlotComponents() {
|
||||
return {
|
||||
version: '1.0',
|
||||
layout_name: 'test_checkout_cash_receipt',
|
||||
data_sources: [],
|
||||
components: JSON.parse(JSON.stringify((extensionJson as any).components)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 루트 gate 가 읽는 paymentSettings 응답.
|
||||
*
|
||||
* 호스트 레이아웃(shop/checkout.json)이 선언하는 데이터소스라 슬롯 JSON 에는 없다.
|
||||
* 반응형 검증이 목적이므로 fetch 를 태우지 않고 initialData 로 직접 주입한다.
|
||||
*/
|
||||
const PAYMENT_SETTINGS = {
|
||||
data: { order_settings: { cash_receipt_provider: 'tosspayments' } },
|
||||
};
|
||||
|
||||
/**
|
||||
* 신청 폼이 펼쳐진 상태로 렌더한다.
|
||||
*
|
||||
* 두 개의 gate 를 모두 열어야 검사 대상 노드가 마운트된다:
|
||||
* - 루트: paymentSettings.data.order_settings.cash_receipt_provider 가 truthy
|
||||
* - 입력 영역: _local.cashReceiptRequested 가 truthy
|
||||
*
|
||||
* @param width 주입할 화면 너비 (px)
|
||||
*/
|
||||
async function renderAtWidth(width: number) {
|
||||
mockWidth = width;
|
||||
setupTestRegistry();
|
||||
|
||||
const testUtils = createLayoutTest(layoutWithSlotComponents() as any, {
|
||||
initialData: { paymentSettings: PAYMENT_SETTINGS },
|
||||
initialState: {
|
||||
_local: {
|
||||
paymentMethod: 'dbank',
|
||||
cashReceiptRequested: true,
|
||||
cashReceiptType: 'income',
|
||||
cashReceiptIdentifierType: 'phone',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await testUtils.render();
|
||||
|
||||
// 두 gate 가 모두 열렸는지 확인한다. 트리가 비면 아래 검사들이 조용히 통과해 버린다.
|
||||
expect(
|
||||
document.getElementById('ext_checkout_cash_receipt_fields'),
|
||||
'신청 폼이 펼쳐지지 않았다 — gate 조건이 바뀌었는지 확인할 것'
|
||||
).not.toBeNull();
|
||||
|
||||
return testUtils;
|
||||
}
|
||||
|
||||
/**
|
||||
* 렌더된 노드의 클래스 목록을 반환한다.
|
||||
*
|
||||
* @param id 레이아웃 JSON 에 선언된 노드 id
|
||||
* @return 클래스 토큰 배열
|
||||
*/
|
||||
const classesOf = (id: string): string[] => {
|
||||
const el = document.getElementById(id);
|
||||
expect(el, `#${id} 가 렌더되지 않았다`).not.toBeNull();
|
||||
|
||||
return (el!.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
|
||||
};
|
||||
|
||||
describe('현금영수증 신청 폼 — 반응형 실렌더 (F5)', () => {
|
||||
beforeEach(() => {
|
||||
mockWidth = 1280;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('데스크톱(1280px)에서는 용도 라디오가 가로 배치된다', async () => {
|
||||
const t = await renderAtWidth(1280);
|
||||
|
||||
const cls = classesOf('ext_checkout_cash_receipt_purpose');
|
||||
expect(cls).toContain('flex-row');
|
||||
expect(cls).not.toContain('flex-col');
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
|
||||
it('모바일(375px)에서는 용도 라디오가 세로 스택으로 바뀐다', async () => {
|
||||
const t = await renderAtWidth(375);
|
||||
|
||||
const cls = classesOf('ext_checkout_cash_receipt_purpose');
|
||||
expect(cls).toContain('flex-col');
|
||||
expect(cls).not.toContain('flex-row');
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
|
||||
it('모바일에서 발급수단·번호 입력행이 1열로 접힌다', async () => {
|
||||
const t = await renderAtWidth(375);
|
||||
|
||||
const cls = classesOf('ext_checkout_cash_receipt_identifier_row');
|
||||
expect(cls).toContain('grid-cols-1');
|
||||
expect(cls).not.toContain('grid-cols-2');
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
|
||||
it('태블릿(1023px)까지 portable 이 적용되고 1024px 부터 base 로 돌아온다', async () => {
|
||||
// portable 은 0~1023px 단일 오버라이드 — 경계값을 양쪽에서 고정한다.
|
||||
const tablet = await renderAtWidth(1023);
|
||||
expect(classesOf('ext_checkout_cash_receipt_purpose')).toContain('flex-col');
|
||||
tablet.cleanup();
|
||||
cleanup();
|
||||
|
||||
const desktop = await renderAtWidth(1024);
|
||||
expect(classesOf('ext_checkout_cash_receipt_purpose')).toContain('flex-row');
|
||||
desktop.cleanup();
|
||||
});
|
||||
|
||||
it('어떤 너비에서도 Tailwind 브레이크포인트 접두사를 쓰지 않는다 (§6-0)', async () => {
|
||||
const t = await renderAtWidth(375);
|
||||
|
||||
const rendered = document.body.innerHTML;
|
||||
expect(rendered).not.toMatch(/\bmd:/);
|
||||
expect(rendered).not.toMatch(/\blg:/);
|
||||
expect(rendered).not.toMatch(/\bsm:/);
|
||||
|
||||
t.cleanup();
|
||||
});
|
||||
});
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* 현금영수증 UI 구조·조건 검증 (#454 S3)
|
||||
*
|
||||
* @description
|
||||
* 다섯 개 표면을 한 곳에서 고정한다:
|
||||
* - W-1 체크아웃 신청 폼 (extension_point 주입) — 확장 병합 칸(_local.checkoutExtraPayload) 기입
|
||||
* - W-2 유저 주문상세 카드 (extension_point 주입) — 상태머신 5종
|
||||
* - W-3 관리자 주문상세 카드 + 발급 모달
|
||||
* - W-4 취소 모달 환불계좌
|
||||
* - W-5 환경설정 (프로바이더 / 배송비 과세 / 자진발급)
|
||||
*
|
||||
* 조건식은 문자열 매칭이 아니라 실제 엔진(evaluateStringCondition)으로 평가한다.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DataBindingEngine } from '@core/template-engine/DataBindingEngine';
|
||||
import { evaluateStringCondition } from '@core/template-engine/helpers/ConditionEvaluator';
|
||||
|
||||
import checkoutExt from '../../../extensions/checkout_cash_receipt.json';
|
||||
import mypageExt from '../../../extensions/mypage_order_cash_receipt.json';
|
||||
import paymentInfo from '../../../layouts/admin/partials/admin_ecommerce_order_detail/_partial_payment_info.json';
|
||||
import issueModal from '../../../layouts/admin/partials/admin_ecommerce_order_detail/_modal_issue_cash_receipt.json';
|
||||
import cancelModal from '../../../layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json';
|
||||
import orderSettingsTab from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json';
|
||||
|
||||
const engine = new DataBindingEngine();
|
||||
const evalIf = (expr: string, ctx: Record<string, any>) => evaluateStringCondition(expr, ctx, engine);
|
||||
|
||||
/** 트리 전체를 평탄화 (children 뿐 아니라 모든 객체 값을 순회) */
|
||||
function flatten(node: any, acc: any[] = []): any[] {
|
||||
if (!node || typeof node !== 'object') return acc;
|
||||
acc.push(node);
|
||||
for (const v of Object.values(node)) {
|
||||
if (Array.isArray(v)) v.forEach((x) => flatten(x, acc));
|
||||
else if (v && typeof v === 'object') flatten(v, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
const byId = (root: any, id: string) => flatten(root).find((n) => n.id === id);
|
||||
|
||||
/**
|
||||
* 결제행 iteration 안의 노드를 id 로 찾는다.
|
||||
*
|
||||
* iteration 안에서는 같은 id 가 행마다 중복되므로 index_var(payIdx)로 행마다 고유해야 한다
|
||||
* (audit `layout-iteration-static-id`). 조회 키에도 접미사를 붙여 그 규약을 함께 고정한다 —
|
||||
* 누군가 id 를 정적으로 되돌리면 이 헬퍼가 찾지 못해 테스트가 깨진다.
|
||||
*/
|
||||
const byRowId = (root: any, id: string) => byId(root, `${id}_{{payIdx}}`);
|
||||
|
||||
// ------------------------------------------------------------------ W-1 체크아웃
|
||||
|
||||
describe('W-1 체크아웃 현금영수증 신청 폼', () => {
|
||||
const root = byId(checkoutExt, 'ext_checkout_cash_receipt');
|
||||
|
||||
it('올바른 확장점에 append 로 주입한다', () => {
|
||||
expect(checkoutExt.extension_point).toBe('shop_checkout_cash_receipt_slot');
|
||||
expect(checkoutExt.mode).toBe('append');
|
||||
});
|
||||
|
||||
it('프로바이더 미설정이면 슬롯 전체가 렌더되지 않는다 (M2)', () => {
|
||||
const ctx = (provider: string) => ({ paymentSettings: { data: { order_settings: { cash_receipt_provider: provider } } } });
|
||||
|
||||
expect(evalIf(root.if, ctx('tosspayments'))).toBe(true);
|
||||
expect(evalIf(root.if, ctx(''))).toBe(false);
|
||||
expect(evalIf(root.if, { paymentSettings: { data: { order_settings: {} } } })).toBe(false);
|
||||
});
|
||||
|
||||
it('신청 전에는 하위 필드가 렌더되지 않는다 (M4: 0 → 3)', () => {
|
||||
const fields = byId(checkoutExt, 'ext_checkout_cash_receipt_fields');
|
||||
|
||||
expect(evalIf(fields.if, { _local: {} })).toBe(false);
|
||||
expect(evalIf(fields.if, { _local: { cashReceiptRequested: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it('신청 체크박스는 확장 병합 칸에 4키를 기입한다 (직접 _local 기입은 서버 미도달)', () => {
|
||||
const toggle = byId(checkoutExt, 'ext_checkout_cash_receipt_toggle');
|
||||
const params = toggle.actions[0].params;
|
||||
|
||||
expect(params.checkoutExtraPayload).toBeDefined();
|
||||
|
||||
// 체크 시: 4키 편입
|
||||
const on = engine.evaluateExpression(
|
||||
String(params.checkoutExtraPayload).slice(2, -2),
|
||||
{ $event: { target: { checked: true } }, _local: {} }
|
||||
);
|
||||
expect(on).toEqual({
|
||||
cash_receipt_requested: true,
|
||||
cash_receipt_type: 'income',
|
||||
cash_receipt_identifier_type: 'phone',
|
||||
cash_receipt_identifier: '',
|
||||
});
|
||||
|
||||
// 해제 시: 빈 객체 → 주문 payload 에서 사라진다
|
||||
const off = engine.evaluateExpression(
|
||||
String(params.checkoutExtraPayload).slice(2, -2),
|
||||
{ $event: { target: { checked: false } }, _local: {} }
|
||||
);
|
||||
expect(off).toEqual({});
|
||||
});
|
||||
|
||||
it('소득공제로 전환하면 사업자등록번호 선택이 휴대폰번호로 리셋된다 (M5)', () => {
|
||||
const purpose = byId(checkoutExt, 'ext_checkout_cash_receipt_purpose');
|
||||
const incomeRadio = flatten(purpose).find((n) => n.props?.value === 'income');
|
||||
const params = incomeRadio.actions[0].params;
|
||||
|
||||
const ctx = { _local: { cashReceiptIdentifierType: 'business', cashReceiptIdentifier: '1234567890' } };
|
||||
const nextType = engine.evaluateExpression(String(params.cashReceiptIdentifierType).slice(2, -2), ctx);
|
||||
const nextId = engine.evaluateExpression(String(params.cashReceiptIdentifier).slice(2, -2), ctx);
|
||||
|
||||
expect(nextType).toBe('phone');
|
||||
expect(nextId).toBe('');
|
||||
});
|
||||
|
||||
it('지출증빙 전환 시에는 휴대폰번호 선택을 유지한다 (D10 — 휴대폰은 양쪽 유효)', () => {
|
||||
const purpose = byId(checkoutExt, 'ext_checkout_cash_receipt_purpose');
|
||||
const expenseRadio = flatten(purpose).find((n) => n.props?.value === 'expense');
|
||||
const payload = engine.evaluateExpression(
|
||||
String(expenseRadio.actions[0].params.checkoutExtraPayload).slice(2, -2),
|
||||
{ _local: { cashReceiptIdentifierType: 'phone', cashReceiptIdentifier: '01012345678' } }
|
||||
);
|
||||
|
||||
expect(payload.cash_receipt_type).toBe('expense');
|
||||
expect(payload.cash_receipt_identifier_type).toBe('phone');
|
||||
expect(payload.cash_receipt_identifier).toBe('01012345678');
|
||||
});
|
||||
|
||||
it('발급수단 options 는 용도에 따라 2종 / 3종이다', () => {
|
||||
const select = byId(checkoutExt, 'ext_checkout_cash_receipt_identifier_type');
|
||||
const expr = String(select.props.options).slice(2, -2);
|
||||
const t = (k: string) => k;
|
||||
|
||||
const income = engine.evaluateExpression(expr, { _local: { cashReceiptType: 'income' }, $t: t });
|
||||
const expense = engine.evaluateExpression(expr, { _local: { cashReceiptType: 'expense' }, $t: t });
|
||||
|
||||
expect(income.map((o: any) => o.value)).toEqual(['phone', 'card']);
|
||||
expect(expense.map((o: any) => o.value)).toEqual(['business', 'phone', 'card']);
|
||||
});
|
||||
|
||||
it('발급수단을 바꾸면 번호를 비운다 (형식 불일치로 인한 422 방지)', () => {
|
||||
const select = byId(checkoutExt, 'ext_checkout_cash_receipt_identifier_type');
|
||||
expect(select.actions[0].params.cashReceiptIdentifier).toBe('');
|
||||
});
|
||||
|
||||
it('Select 는 valueKey/labelKey prop 을 쓰지 않는다', () => {
|
||||
const select = byId(checkoutExt, 'ext_checkout_cash_receipt_identifier_type');
|
||||
expect(select.props.valueKey).toBeUndefined();
|
||||
expect(select.props.labelKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ W-2 유저 주문상세
|
||||
|
||||
describe('W-2 유저 주문상세 현금영수증 카드 — 상태머신 5종', () => {
|
||||
const card = byId(mypageExt, 'ext_mypage_cash_receipt_card');
|
||||
|
||||
const payment = (over: Record<string, any> = {}) => ({
|
||||
payment_method: 'dbank',
|
||||
cash_receipt_provider: 'tosspayments',
|
||||
payment_status: 'paid',
|
||||
...over,
|
||||
});
|
||||
|
||||
it('① 무통장이 아니거나 프로바이더 미설정이면 카드 자체를 렌더하지 않는다', () => {
|
||||
expect(evalIf(card.if, { order: { data: { payment: payment() } } })).toBe(true);
|
||||
expect(evalIf(card.if, { order: { data: { payment: payment({ payment_method: 'card' }) } } })).toBe(false);
|
||||
expect(evalIf(card.if, { order: { data: { payment: payment({ cash_receipt_provider: null }) } } })).toBe(false);
|
||||
});
|
||||
|
||||
it('② 입금 전에는 안내만 노출한다 — 무통장은 ready, 가상계좌는 waiting_deposit', () => {
|
||||
const node = byId(mypageExt, 'ext_mypage_cash_receipt_awaiting');
|
||||
|
||||
// PaymentStatusEnum::isAwaitingDeposit() 이 true 인 두 상태 모두 "입금 전" 이다.
|
||||
// 무통장 주문은 ready 로 생성된다 — waiting_deposit 만 검사하면 무통장 전부에서 안내가 사라진다.
|
||||
expect(evalIf(node.if, { order: { data: { payment: payment({ payment_status: 'ready' }) } } })).toBe(true);
|
||||
expect(evalIf(node.if, { order: { data: { payment: payment({ payment_status: 'waiting_deposit' }) } } })).toBe(true);
|
||||
|
||||
expect(evalIf(node.if, { order: { data: { payment: payment({ payment_status: 'paid' }) } } })).toBe(false);
|
||||
expect(evalIf(node.if, { order: { data: { payment: payment({ payment_status: 'cancelled' }) } } })).toBe(false);
|
||||
});
|
||||
|
||||
it('입금 전(ready) 주문은 5개 상태 중 정확히 하나에 매칭된다 (빈 카드 방지)', () => {
|
||||
const ctx = { order: { data: { payment: payment({ payment_status: 'ready' }), cash_receipt: null, cash_receipts: [] } } };
|
||||
const states = [
|
||||
'ext_mypage_cash_receipt_awaiting',
|
||||
'ext_mypage_cash_receipt_issuable',
|
||||
'ext_mypage_cash_receipt_issued',
|
||||
'ext_mypage_cash_receipt_failed',
|
||||
];
|
||||
const matched = states.filter((id) => evalIf(byId(mypageExt, id).if, ctx));
|
||||
expect(matched).toEqual(['ext_mypage_cash_receipt_awaiting']);
|
||||
});
|
||||
|
||||
it('③ 입금완료 + 미발급 → 발급 버튼', () => {
|
||||
const node = byId(mypageExt, 'ext_mypage_cash_receipt_issuable');
|
||||
expect(evalIf(node.if, { order: { data: { payment: payment(), cash_receipt: null, cash_receipts: [] } } })).toBe(true);
|
||||
expect(evalIf(node.if, { order: { data: { payment: payment(), cash_receipt: { id: 1 }, cash_receipts: [] } } })).toBe(false);
|
||||
});
|
||||
|
||||
it('입금완료 주문의 상태 ③ 과 ⑤ 는 동시에 매칭되지 않는다 (배타)', () => {
|
||||
const failedCtx = { order: { data: { payment: payment(), cash_receipt: null, cash_receipts: [{ issue_status: 'FAILED' }] } } };
|
||||
|
||||
const issuable = byId(mypageExt, 'ext_mypage_cash_receipt_issuable');
|
||||
const failed = byId(mypageExt, 'ext_mypage_cash_receipt_failed');
|
||||
|
||||
expect(evalIf(failed.if, failedCtx)).toBe(true);
|
||||
expect(evalIf(issuable.if, failedCtx)).toBe(false);
|
||||
});
|
||||
|
||||
it('④ 발급완료 → 영수증 링크', () => {
|
||||
const node = byId(mypageExt, 'ext_mypage_cash_receipt_issued');
|
||||
expect(evalIf(node.if, { order: { data: { cash_receipt: { id: 1 } } } })).toBe(true);
|
||||
expect(evalIf(node.if, { order: { data: { cash_receipt: null } } })).toBe(false);
|
||||
});
|
||||
|
||||
it('⑤ 취소 성공 + 재발급 실패 → 경고 (issue_status 는 대문자 FAILED)', () => {
|
||||
const node = byId(mypageExt, 'ext_mypage_cash_receipt_failed');
|
||||
|
||||
const failed = { order: { data: { cash_receipt: null, cash_receipts: [{ issue_status: 'FAILED' }] } } };
|
||||
const ok = { order: { data: { cash_receipt: null, cash_receipts: [{ issue_status: 'COMPLETED' }] } } };
|
||||
const none = { order: { data: { cash_receipt: null, cash_receipts: [] } } };
|
||||
|
||||
expect(evalIf(node.if, failed)).toBe(true);
|
||||
expect(evalIf(node.if, ok)).toBe(false);
|
||||
expect(evalIf(node.if, none)).toBe(false);
|
||||
});
|
||||
|
||||
it('유저에게는 발급취소·수동 재발급 버튼을 주지 않는다 (관리자 전용)', () => {
|
||||
// 'reissue_failed' 안내 문구는 정상 — 금지 대상은 재발급/발급취소를 실행하는 액션이다.
|
||||
const actions = flatten(mypageExt).flatMap((n: any) => n.actions ?? []);
|
||||
const apiCalls = actions.filter((a: any) => a.handler === 'apiCall');
|
||||
|
||||
expect(apiCalls.some((a: any) => String(a.target).includes('/reissue'))).toBe(false);
|
||||
expect(apiCalls.some((a: any) => a.params?.method === 'DELETE')).toBe(false);
|
||||
|
||||
// 남은 apiCall 은 발급(POST) 하나뿐이어야 한다
|
||||
expect(apiCalls.map((a: any) => a.params?.method)).toEqual(['POST']);
|
||||
});
|
||||
|
||||
it('발급 모달은 modals 섹션에 등록한다 (인라인 Modal 금지)', () => {
|
||||
expect(Array.isArray(mypageExt.modals)).toBe(true);
|
||||
expect(mypageExt.modals[0].id).toBe('ext_cash_receipt_issue_modal');
|
||||
});
|
||||
|
||||
it('회원은 user/orders/{id}, 비회원은 guest/orders/{orderNumber} 로 발급한다', () => {
|
||||
const submit = byId(mypageExt, 'ext_cash_receipt_issue_submit');
|
||||
const expr = String(submit.actions[0].target).slice(2, -2);
|
||||
|
||||
const member = engine.evaluateExpression(expr, {
|
||||
_global: { currentUser: { uuid: 'u1' } },
|
||||
order: { data: { id: 42, order_number: 'ORD-1' } },
|
||||
});
|
||||
const guest = engine.evaluateExpression(expr, {
|
||||
_global: { currentUser: null },
|
||||
order: { data: { id: 42, order_number: 'ORD-1' } },
|
||||
});
|
||||
|
||||
expect(member).toBe('/api/modules/sirsoft-ecommerce/user/orders/42/cash-receipt');
|
||||
expect(guest).toBe('/api/modules/sirsoft-ecommerce/guest/orders/ORD-1/cash-receipt');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ W-3 관리자 주문상세
|
||||
|
||||
describe('W-3 관리자 주문상세 현금영수증 카드', () => {
|
||||
const card = byRowId(paymentInfo, 'payment_cash_receipt_card');
|
||||
|
||||
it('결제카드 반복(payment) 컨텍스트에서 무통장 + 프로바이더 설정 시에만 렌더된다', () => {
|
||||
expect(evalIf(card.if, { payment: { payment_method: 'dbank', cash_receipt_provider: 'toss' } })).toBe(true);
|
||||
expect(evalIf(card.if, { payment: { payment_method: 'vbank', cash_receipt_provider: 'toss' } })).toBe(false);
|
||||
expect(evalIf(card.if, { payment: { payment_method: 'dbank', cash_receipt_provider: '' } })).toBe(false);
|
||||
});
|
||||
|
||||
it('입금 전 안내는 무통장(ready)과 가상계좌(waiting_deposit) 모두에서 노출된다', () => {
|
||||
const node = byRowId(paymentInfo, 'payment_cash_receipt_awaiting');
|
||||
|
||||
expect(evalIf(node.if, { payment: { payment_status: 'ready' } })).toBe(true);
|
||||
expect(evalIf(node.if, { payment: { payment_status: 'waiting_deposit' } })).toBe(true);
|
||||
expect(evalIf(node.if, { payment: { payment_status: 'paid' } })).toBe(false);
|
||||
});
|
||||
|
||||
it('관리자 전용 버튼 3종(발급/발급취소/수동 재발급)이 존재한다', () => {
|
||||
expect(byRowId(paymentInfo, 'payment_cash_receipt_issue_button')).toBeDefined();
|
||||
expect(byRowId(paymentInfo, 'payment_cash_receipt_cancel_button')).toBeDefined();
|
||||
expect(byRowId(paymentInfo, 'payment_cash_receipt_reissue_button')).toBeDefined();
|
||||
});
|
||||
|
||||
it('재발급 실패 상태에서 발급 버튼과 재발급 버튼이 동시에 노출되지 않는다', () => {
|
||||
// 실패 이력이 있으면 복구는 "수동 재발급" 경로다. 신규 발급 버튼을 함께 띄우면
|
||||
// 관리자가 어느 쪽을 눌러야 하는지 알 수 없다.
|
||||
const failed = { payment: { payment_status: 'paid' }, order: { data: { cash_receipt: null, cash_receipts: [{ issue_status: 'FAILED' }] } } };
|
||||
|
||||
const issue = byRowId(paymentInfo, 'payment_cash_receipt_issue_button');
|
||||
const reissue = byRowId(paymentInfo, 'payment_cash_receipt_reissue_button');
|
||||
|
||||
expect(evalIf(reissue.if, failed)).toBe(true);
|
||||
expect(evalIf(issue.if, failed)).toBe(false);
|
||||
});
|
||||
|
||||
it('이력이 전혀 없는 입금완료 주문에서는 발급 버튼만 노출된다', () => {
|
||||
const fresh = { payment: { payment_status: 'paid' }, order: { data: { cash_receipt: null, cash_receipts: [] } } };
|
||||
|
||||
expect(evalIf(byRowId(paymentInfo, 'payment_cash_receipt_issue_button').if, fresh)).toBe(true);
|
||||
expect(evalIf(byRowId(paymentInfo, 'payment_cash_receipt_reissue_button').if, fresh)).toBe(false);
|
||||
});
|
||||
|
||||
it('수동 재발급 버튼은 재발급 실패 상태에서만 노출된다', () => {
|
||||
const btn = byRowId(paymentInfo, 'payment_cash_receipt_reissue_button');
|
||||
const failed = { order: { data: { cash_receipt: null, cash_receipts: [{ issue_status: 'FAILED' }] } } };
|
||||
const issued = { order: { data: { cash_receipt: { id: 1 }, cash_receipts: [{ issue_status: 'COMPLETED' }] } } };
|
||||
|
||||
expect(evalIf(btn.if, failed)).toBe(true);
|
||||
expect(evalIf(btn.if, issued)).toBe(false);
|
||||
});
|
||||
|
||||
it('관리자 apiCall 은 모두 auth_required 를 선언한다 (미선언 시 Bearer 토큰 미부착 → 401)', () => {
|
||||
const adminApiCalls = [
|
||||
...flatten(paymentInfo).flatMap((n: any) => n.actions ?? []),
|
||||
...flatten(issueModal).flatMap((n: any) => n.actions ?? []),
|
||||
].filter((a: any) => a.handler === 'apiCall' && String(a.target).includes('/admin/'));
|
||||
|
||||
expect(adminApiCalls.length).toBeGreaterThan(0);
|
||||
for (const a of adminApiCalls) {
|
||||
expect(a.auth_required, `auth_required 누락: ${a.target}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('발급취소는 DELETE, 재발급은 reissue 엔드포인트를 호출한다', () => {
|
||||
const cancel = byRowId(paymentInfo, 'payment_cash_receipt_cancel_button');
|
||||
const reissue = byRowId(paymentInfo, 'payment_cash_receipt_reissue_button');
|
||||
|
||||
expect(cancel.actions[0].params.method).toBe('DELETE');
|
||||
expect(reissue.actions[0].target).toContain('/cash-receipt/reissue');
|
||||
});
|
||||
|
||||
it('발급 이력표는 이력이 있을 때만, 그리고 기본 접힌 상태로 존재한다', () => {
|
||||
const wrap = byRowId(paymentInfo, 'payment_cash_receipt_history');
|
||||
const body = byRowId(paymentInfo, 'payment_cash_receipt_history_body');
|
||||
|
||||
// 껍데기: 이력 0건이면 토글 헤더조차 띄우지 않는다
|
||||
expect(evalIf(wrap.if, { order: { data: { cash_receipts: [] } } })).toBe(false);
|
||||
expect(evalIf(wrap.if, { order: { data: { cash_receipts: [{ id: 1 }] } } })).toBe(true);
|
||||
|
||||
// 본문: 초기 상태(_local.expandedCashReceiptHistory = [])에서 접혀 있어야 한다
|
||||
expect(evalIf(body.if, { payIdx: 0, _local: { expandedCashReceiptHistory: [] } })).toBe(false);
|
||||
expect(evalIf(body.if, { payIdx: 0, _local: { expandedCashReceiptHistory: [0] } })).toBe(true);
|
||||
|
||||
// 상태 키가 아직 초기화되지 않은 순간에도 터지지 않는다 (?? [] fallback)
|
||||
expect(evalIf(body.if, { payIdx: 0, _local: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it('이력 토글은 결제 건마다 독립적으로 접히고 펼쳐진다', () => {
|
||||
// 한 주문에 결제행이 여러 개일 수 있다. payIdx 를 배열에 넣고 빼는 방식이라야
|
||||
// 0번 행을 펼쳐도 1번 행이 함께 펼쳐지지 않는다.
|
||||
const toggle = byRowId(paymentInfo, 'payment_cash_receipt_history_toggle');
|
||||
const body = byRowId(paymentInfo, 'payment_cash_receipt_history_body');
|
||||
const expr = String(toggle.actions[0].params.expandedCashReceiptHistory).slice(2, -2);
|
||||
|
||||
// 0번 행 펼치기
|
||||
const afterOpen0 = engine.evaluateExpression(expr, { payIdx: 0, _local: { expandedCashReceiptHistory: [] } });
|
||||
expect(afterOpen0).toEqual([0]);
|
||||
|
||||
// 그 상태에서 1번 행 펼치기 → 0번은 그대로 열려 있다
|
||||
const afterOpen1 = engine.evaluateExpression(expr, { payIdx: 1, _local: { expandedCashReceiptHistory: afterOpen0 } });
|
||||
expect(afterOpen1).toEqual([0, 1]);
|
||||
expect(evalIf(body.if, { payIdx: 0, _local: { expandedCashReceiptHistory: afterOpen1 } })).toBe(true);
|
||||
|
||||
// 0번 행 다시 접기 → 1번은 열린 채 남는다
|
||||
const afterClose0 = engine.evaluateExpression(expr, { payIdx: 0, _local: { expandedCashReceiptHistory: afterOpen1 } });
|
||||
expect(afterClose0).toEqual([1]);
|
||||
expect(evalIf(body.if, { payIdx: 0, _local: { expandedCashReceiptHistory: afterClose0 } })).toBe(false);
|
||||
expect(evalIf(body.if, { payIdx: 1, _local: { expandedCashReceiptHistory: afterClose0 } })).toBe(true);
|
||||
});
|
||||
|
||||
it('토글 버튼은 접힘 상태를 아이콘과 aria-expanded 로 함께 알린다', () => {
|
||||
const toggle = byRowId(paymentInfo, 'payment_cash_receipt_history_toggle');
|
||||
const body = byRowId(paymentInfo, 'payment_cash_receipt_history_body');
|
||||
|
||||
// Form 밖이지만 submit 오작동 방지 규약을 따른다
|
||||
expect(toggle.props.type).toBe('button');
|
||||
// 스크린리더가 본문 존재를 알 수 있어야 한다
|
||||
expect(toggle.props['aria-controls']).toBe(body.id);
|
||||
|
||||
const icon = flatten(toggle).find((n: any) => n.name === 'Icon');
|
||||
const iconExpr = String(icon.props.name).slice(2, -2);
|
||||
const ariaExpr = String(toggle.props['aria-expanded']).slice(2, -2);
|
||||
|
||||
const closed = { payIdx: 0, _local: { expandedCashReceiptHistory: [] } };
|
||||
const opened = { payIdx: 0, _local: { expandedCashReceiptHistory: [0] } };
|
||||
|
||||
expect(engine.evaluateExpression(iconExpr, closed)).toBe('chevron-right');
|
||||
expect(engine.evaluateExpression(iconExpr, opened)).toBe('chevron-down');
|
||||
expect(engine.evaluateExpression(ariaExpr, closed)).toBe(false);
|
||||
expect(engine.evaluateExpression(ariaExpr, opened)).toBe(true);
|
||||
|
||||
// Icon 은 w-N/h-N 클래스가 아니라 size prop 으로 크기를 지정한다
|
||||
expect(icon.props.size).toBeDefined();
|
||||
expect(icon.props.className ?? '').not.toMatch(/\b[wh]-\d/);
|
||||
});
|
||||
|
||||
it('액션 영역은 데스크톱 가로 → portable 세로 스택 (§6-0)', () => {
|
||||
const actions = byRowId(paymentInfo, 'payment_cash_receipt_actions');
|
||||
expect(actions.props.className).toContain('flex-row');
|
||||
expect(actions.responsive.portable.props.className).toContain('flex-col');
|
||||
expect(Object.keys(actions.responsive)).toEqual(['portable']);
|
||||
});
|
||||
|
||||
it('신설 카드에 Tailwind breakpoint(md:/lg:) 를 쓰지 않는다', () => {
|
||||
for (const n of flatten(card)) {
|
||||
expect(n.props?.className ?? '').not.toMatch(/(^|\s)(md|lg|xl|2xl):/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('W-3 관리자 발급 모달', () => {
|
||||
it('modals 파트너 id 로 등록되고 발급 버튼이 이를 연다', () => {
|
||||
expect(issueModal.id).toBe('modal_issue_cash_receipt');
|
||||
|
||||
const btn = byRowId(paymentInfo, 'payment_cash_receipt_issue_button');
|
||||
const open = flatten(btn.actions).find((a: any) => a.handler === 'openModal');
|
||||
expect(open.target).toBe('modal_issue_cash_receipt');
|
||||
});
|
||||
|
||||
it('발급수단 options 는 용도에 따라 2종 / 3종이다', () => {
|
||||
const select = byId(issueModal, 'modal_cash_receipt_identifier_type');
|
||||
const expr = String(select.props.options).slice(2, -2);
|
||||
const t = (k: string) => k;
|
||||
|
||||
const income = engine.evaluateExpression(expr, { _global: { adminCashReceiptForm: { receipt_type: 'income' } }, $t: t });
|
||||
const expense = engine.evaluateExpression(expr, { _global: { adminCashReceiptForm: { receipt_type: 'expense' } }, $t: t });
|
||||
|
||||
expect(income.map((o: any) => o.value)).toEqual(['phone', 'card']);
|
||||
expect(expense.map((o: any) => o.value)).toEqual(['business', 'phone', 'card']);
|
||||
});
|
||||
|
||||
it('번호가 비어 있으면 발급 버튼이 비활성화된다', () => {
|
||||
const submit = byId(issueModal, 'modal_cash_receipt_submit');
|
||||
const expr = String(submit.props.disabled).slice(2, -2);
|
||||
|
||||
expect(engine.evaluateExpression(expr, { _global: { adminCashReceiptForm: { identifier: '' } } })).toBe(true);
|
||||
expect(engine.evaluateExpression(expr, { _global: { adminCashReceiptForm: { identifier: '01012345678' } } })).toBe(false);
|
||||
});
|
||||
|
||||
it('성공 시 setState 를 closeModal 보다 먼저 실행한다 (순서 역전 시 상태 잔존)', () => {
|
||||
const submit = byId(issueModal, 'modal_cash_receipt_submit');
|
||||
const onSuccess = submit.actions[0].onSuccess.map((a: any) => a.handler);
|
||||
|
||||
expect(onSuccess.indexOf('setState')).toBeLessThan(onSuccess.indexOf('closeModal'));
|
||||
expect(onSuccess).toContain('refetchDataSource');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ W-4 취소 모달
|
||||
|
||||
describe('W-4 취소 모달 환불계좌', () => {
|
||||
const section = byId(cancelModal, 'cancel_refund_bank_section');
|
||||
|
||||
it('가상계좌·무통장에서만 노출한다 (카드·간편결제는 계좌 불필요)', () => {
|
||||
const ctx = (m: string) => ({ order: { data: { payment: { payment_method: m } } } });
|
||||
|
||||
expect(evalIf(section.if, ctx('vbank'))).toBe(true);
|
||||
expect(evalIf(section.if, ctx('dbank'))).toBe(true);
|
||||
expect(evalIf(section.if, ctx('card'))).toBe(false);
|
||||
expect(evalIf(section.if, ctx('point'))).toBe(false);
|
||||
});
|
||||
|
||||
it('3필드가 _local.refundBank* 에 저장된다 (cancelOrderHandlers 가 읽는 키)', () => {
|
||||
const keys = flatten(section)
|
||||
.flatMap((n: any) => n.actions ?? [])
|
||||
.filter((a: any) => a.handler === 'setState')
|
||||
.flatMap((a: any) => Object.keys(a.params).filter((k) => k !== 'target'));
|
||||
|
||||
expect(keys).toEqual(expect.arrayContaining(['refundBankCode', 'refundBankAccount', 'refundBankHolder']));
|
||||
});
|
||||
|
||||
it('가상계좌 입금완료 건에만 필수 안내를 띄운다', () => {
|
||||
const hint = flatten(section).find((n: any) => String(n.text ?? '').includes('refund_bank_required_hint'));
|
||||
const ctx = (m: string, s: string) => ({ order: { data: { payment: { payment_method: m, payment_status: s } } } });
|
||||
|
||||
expect(evalIf(hint.if, ctx('vbank', 'paid'))).toBe(true);
|
||||
expect(evalIf(hint.if, ctx('vbank', 'waiting_deposit'))).toBe(false);
|
||||
expect(evalIf(hint.if, ctx('dbank', 'paid'))).toBe(false);
|
||||
});
|
||||
|
||||
it('3열 → portable 1열로 접힌다', () => {
|
||||
const grid = flatten(section).find((n: any) => (n.props?.className ?? '').includes('grid-cols-3'));
|
||||
expect(grid.responsive.portable.props.className).toContain('grid-cols-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ W-5 환경설정
|
||||
|
||||
describe('W-5 환경설정 — 현금영수증', () => {
|
||||
const card = byId(orderSettingsTab, 'cash_receipt_card');
|
||||
|
||||
it('카드가 주문설정 탭에 존재한다', () => {
|
||||
expect(card).toBeDefined();
|
||||
});
|
||||
|
||||
it('등록된 프로바이더가 없으면 Select 대신 안내를 노출한다', () => {
|
||||
const select = byId(orderSettingsTab, 'cash_receipt_provider_select');
|
||||
|
||||
expect(evalIf(select.if, { _local: { form: { available_cash_receipt_providers: [{ id: 'toss' }] } } })).toBe(true);
|
||||
expect(evalIf(select.if, { _local: { form: { available_cash_receipt_providers: [] } } })).toBe(false);
|
||||
});
|
||||
|
||||
it('배송비 과세 3정책의 value 가 백엔드 Enum 과 일치한다', () => {
|
||||
const select = byId(orderSettingsTab, 'shipping_fee_tax_policy_select');
|
||||
const opts = engine.evaluateExpression(String(select.props.options).slice(2, -2), { $t: (k: string) => k });
|
||||
|
||||
expect(opts.map((o: any) => o.value)).toEqual(['proportional', 'taxable', 'follow_main_item']);
|
||||
});
|
||||
|
||||
it('배송비 과세 기본값은 안분(proportional) 이다', () => {
|
||||
const select = byId(orderSettingsTab, 'shipping_fee_tax_policy_select');
|
||||
const value = engine.evaluateExpression(String(select.props.value).slice(2, -2), { _local: { form: { order_settings: {} } } });
|
||||
|
||||
expect(value).toBe('proportional');
|
||||
});
|
||||
|
||||
it('자진발급 토글의 기본값은 OFF 다 (D14)', () => {
|
||||
const toggle = byId(orderSettingsTab, 'cash_receipt_self_issue_toggle');
|
||||
const checked = engine.evaluateExpression(String(toggle.props.checked).slice(2, -2), { _local: { form: { order_settings: {} } } });
|
||||
|
||||
expect(checked).toBe(false);
|
||||
});
|
||||
|
||||
it('3개 컨트롤이 저장 대상 키에 setState 하고 hasChanges 를 세운다', () => {
|
||||
const targets = flatten(card)
|
||||
.flatMap((n: any) => n.actions ?? [])
|
||||
.filter((a: any) => a.handler === 'setState')
|
||||
.map((a: any) => a.params);
|
||||
|
||||
const keys = targets.flatMap((p: any) => Object.keys(p));
|
||||
expect(keys).toEqual(expect.arrayContaining([
|
||||
'form.order_settings.cash_receipt_provider',
|
||||
'form.order_settings.shipping_fee_tax_policy',
|
||||
'form.order_settings.cash_receipt_self_issue',
|
||||
]));
|
||||
expect(targets.every((p: any) => p.hasChanges === true)).toBe(true);
|
||||
});
|
||||
|
||||
it('자진발급 안내에 국세청 의무발행 업종 링크가 있다', () => {
|
||||
// 의무발행 업종인지 판단하라고 안내만 하고 확인 경로를 주지 않으면 설정을 켤 수 없다.
|
||||
const link = byId(card, 'cash_receipt_mandatory_industry_link');
|
||||
|
||||
expect(link.name).toBe('A');
|
||||
expect(link.props.href).toMatch(/^https:\/\/www\.nts\.go\.kr\//);
|
||||
expect(link.props.target).toBe('_blank');
|
||||
// 외부 링크는 opener 유출을 막는다.
|
||||
expect(link.props.rel).toBe('noopener noreferrer');
|
||||
|
||||
const labels = flatten(link).map((n: any) => n.text).filter(Boolean);
|
||||
expect(labels).toContain(
|
||||
'$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.self_issue_mandatory_link'
|
||||
);
|
||||
});
|
||||
|
||||
it('신설 카드에 Tailwind breakpoint(md:/lg:) 를 쓰지 않는다 (§6-0)', () => {
|
||||
for (const n of flatten(card)) {
|
||||
expect(n.props?.className ?? '').not.toMatch(/(^|\s)(md|lg|xl|2xl):/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ 5개 표면 공통 규약
|
||||
|
||||
describe('현금영수증 5개 표면 공통 규약', () => {
|
||||
const SURFACES: Array<[string, any]> = [
|
||||
['W-1 체크아웃', checkoutExt],
|
||||
['W-2 유저 주문상세', mypageExt],
|
||||
['W-3 관리자 주문상세', paymentInfo],
|
||||
['W-3 발급 모달', issueModal],
|
||||
['W-5 환경설정', orderSettingsTab],
|
||||
];
|
||||
|
||||
it.each(SURFACES)('%s — Icon 크기는 size prop 으로 지정한다 (w-N/h-N 금지)', (_label, root) => {
|
||||
// audit `layout-icon-classname-size` 와 같은 규약. 그 룰의 appliesTo 는
|
||||
// `**/resources/layouts/**` 라서 확장점 파일(resources/extensions/**)을 보지 못한다.
|
||||
// 그 사각을 여기서 덮는다 — 실제로 mypage 확장점의 Icon 2개가 이 규약을 어기고 있었다.
|
||||
for (const icon of flatten(root).filter((n: any) => n.name === 'Icon')) {
|
||||
const cls = String(icon.props?.className ?? '');
|
||||
expect(cls, `Icon(${icon.props?.name}) 에 w-N/h-N 클래스`).not.toMatch(/(^|\s)[wh]-\d/);
|
||||
}
|
||||
});
|
||||
});
|
||||
+51
-20
@@ -12,27 +12,32 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DataBindingEngine } from '@core/template-engine/DataBindingEngine';
|
||||
|
||||
const templatesRoot = path.resolve(__dirname, '../../../../../../../templates/_bundled/sirsoft-basic');
|
||||
const engine = new DataBindingEngine();
|
||||
|
||||
/**
|
||||
* 재귀적으로 JSON 노드에서 apiCall 핸들러의 body를 찾는다
|
||||
* 재귀적으로 JSON 노드에서 주문 생성 apiCall 액션을 찾는다.
|
||||
*
|
||||
* body 는 통짜 표현식 문자열이다(#454 S3 — 확장 병합 칸). 예전처럼 객체가 아니므로
|
||||
* `params.body.payment_method` 로는 찾을 수 없고, endpoint 로 식별한다.
|
||||
*/
|
||||
function findApiCallBody(node: any): any {
|
||||
if (node.handler === 'apiCall' && node.params?.body?.payment_method) {
|
||||
return node.params.body;
|
||||
function findOrderApiCall(node: any): any {
|
||||
if (node.handler === 'apiCall' && typeof node.target === 'string' && node.target.endsWith('/user/orders')) {
|
||||
return node;
|
||||
}
|
||||
for (const key of ['actions', 'children']) {
|
||||
if (Array.isArray(node[key])) {
|
||||
for (const child of node[key]) {
|
||||
const found = findApiCallBody(child);
|
||||
const found = findOrderApiCall(child);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.params?.actions)) {
|
||||
for (const action of node.params.actions) {
|
||||
const found = findApiCallBody(action);
|
||||
const found = findOrderApiCall(action);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +45,7 @@ function findApiCallBody(node: any): any {
|
||||
for (const slotChildren of Object.values(node.slots)) {
|
||||
if (Array.isArray(slotChildren)) {
|
||||
for (const child of slotChildren as any[]) {
|
||||
const found = findApiCallBody(child);
|
||||
const found = findOrderApiCall(child);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
@@ -58,28 +63,54 @@ describe('체크아웃 결제수단 회귀 테스트', () => {
|
||||
);
|
||||
|
||||
describe('_checkout_summary.json apiCall body', () => {
|
||||
const apiCallBody = findApiCallBody(summaryJson);
|
||||
const orderCall = findOrderApiCall(summaryJson);
|
||||
|
||||
it('apiCall body가 존재한다', () => {
|
||||
expect(apiCallBody).not.toBeNull();
|
||||
/**
|
||||
* body 표현식을 실제로 평가해 전송 payload 를 얻는다.
|
||||
*
|
||||
* 문자열 매칭 대신 평가하는 이유: 원래 이 테스트가 고정하려던 것은 "무통장을 고르면
|
||||
* dbank 가 실려 나가는가" 이지 body 가 객체 리터럴인가가 아니다. 표현식으로 바뀐 뒤에도
|
||||
* 그 행위는 그대로 검증되어야 한다.
|
||||
*/
|
||||
const evalBody = (ctx: Record<string, any>) =>
|
||||
engine.evaluateExpression(String(orderCall.params.body).slice(2, -2), ctx);
|
||||
|
||||
const context = (paymentMethod: string, localPaymentMethod?: string) => ({
|
||||
checkoutData: { data: { temp_order_id: 'T-1', calculation: { summary: { final_amount: 1000 } } } },
|
||||
_computed: { selectedPaymentMethod: paymentMethod, ordererDefaults: { name: '홍길동' } },
|
||||
_local: {
|
||||
paymentMethod: localPaymentMethod,
|
||||
shipping: {},
|
||||
selectedDbank: { bank_code: '088', account_number: '110-1', account_holder: '홍길동' },
|
||||
},
|
||||
_global: { currentUser: { uuid: 'u-1' } },
|
||||
});
|
||||
|
||||
it('payment_method가 _computed.selectedPaymentMethod를 사용한다', () => {
|
||||
expect(apiCallBody.payment_method).toBe('{{_computed.selectedPaymentMethod}}');
|
||||
it('주문 생성 apiCall 이 존재한다', () => {
|
||||
expect(orderCall).not.toBeNull();
|
||||
expect(typeof orderCall.params.body).toBe('string');
|
||||
});
|
||||
|
||||
it('payment_method가 _local.paymentMethod를 직접 사용하지 않는다', () => {
|
||||
// _local.paymentMethod는 사용자가 결제수단 버튼을 클릭해야만 설정됨
|
||||
// 클릭 전에는 undefined이므로 직접 참조하면 안 됨
|
||||
expect(apiCallBody.payment_method).not.toContain('_local.paymentMethod');
|
||||
it('payment_method 는 _computed.selectedPaymentMethod 를 그대로 싣는다', () => {
|
||||
expect(evalBody(context('dbank')).payment_method).toBe('dbank');
|
||||
expect(evalBody(context('card')).payment_method).toBe('card');
|
||||
});
|
||||
|
||||
it('dbank 조건이 _computed.selectedPaymentMethod를 사용한다', () => {
|
||||
expect(apiCallBody.dbank).toContain('_computed.selectedPaymentMethod');
|
||||
it('_local.paymentMethod 가 비어 있어도 computed 값이 실린다 (기본값 card 로 새지 않는다)', () => {
|
||||
// 회귀 원인: 사용자가 결제수단 버튼을 누르기 전에는 _local.paymentMethod 가 undefined 다.
|
||||
// body 가 그것을 직접 참조하면 무통장을 골라도 card 가 전송됐다.
|
||||
expect(evalBody(context('dbank', undefined)).payment_method).toBe('dbank');
|
||||
});
|
||||
|
||||
it('dbank 조건이 _local.paymentMethod를 직접 사용하지 않는다', () => {
|
||||
expect(apiCallBody.dbank).not.toContain('_local.paymentMethod');
|
||||
it('dbank 블록은 무통장일 때만 채워지고 그 외에는 null 이다', () => {
|
||||
expect(evalBody(context('dbank')).dbank).toMatchObject({ bank_code: '088' });
|
||||
expect(evalBody(context('card')).dbank).toBeNull();
|
||||
});
|
||||
|
||||
it('_local.paymentMethod 가 computed 와 어긋나도 computed 가 우선한다', () => {
|
||||
const body = evalBody(context('dbank', 'card'));
|
||||
expect(body.payment_method).toBe('dbank');
|
||||
expect(body.dbank).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+10
-5
@@ -208,11 +208,13 @@ describe('주문 목록 partial 구조 검증 (_list.json)', () => {
|
||||
expect(listJsonStr).not.toContain('order.multi_currency_total');
|
||||
});
|
||||
|
||||
it('단가가 item.unit_price_formatted를 사용해야 함', () => {
|
||||
// 마이페이지 주문목록은 옵션 소계(mc_subtotal_price) 로 다통화를 표시하고
|
||||
// 단가는 포맷 문자열(unit_price_formatted) 로만 노출한다. 단가 단위의
|
||||
it('단가는 결제 통화로 굳은 unit_price_formatted 를 그대로 쓴다', () => {
|
||||
// 이미 결제가 끝난 주문의 금액은 결제 시점 통화로 고정이다. 사용자가 지금 보고 있는
|
||||
// 통화(_global.preferredCurrency)로 환산해 보여주면 실제 결제액과 달라진다.
|
||||
// 서버가 formatOrderCurrency 로 확정한 문자열을 표시만 한다. 단가 단위의
|
||||
// 다통화 필드(mc_unit_price)는 이 레이아웃에 존재하지 않는다(주문완료 화면 전용).
|
||||
expect(listJsonStr).toContain('item.unit_price_formatted');
|
||||
expect(listJsonStr).not.toContain('item.mc_unit_price');
|
||||
expect(listJsonStr).not.toContain('item.multi_currency_unit_price');
|
||||
});
|
||||
|
||||
@@ -220,12 +222,15 @@ describe('주문 목록 partial 구조 검증 (_list.json)', () => {
|
||||
expect(listJsonStr).toContain('item.subtotal_price_formatted');
|
||||
});
|
||||
|
||||
it('다통화 소계가 item.mc_subtotal_price를 사용해야 함', () => {
|
||||
it('다통화 소계 목록은 결제 통화를 제외하고 나열한다', () => {
|
||||
// 소계만 다통화 병기를 남긴다 — 참고 표시이므로 결제 통화 행은 중복이라 걸러낸다.
|
||||
expect(listJsonStr).toContain('item.mc_subtotal_price');
|
||||
expect(listJsonStr).toContain('order.base_currency');
|
||||
});
|
||||
|
||||
it('배송비가 order.total_shipping_amount를 사용해야 함', () => {
|
||||
it('배송비는 결제 통화로 굳은 total_shipping_amount_formatted 를 그대로 쓴다', () => {
|
||||
expect(listJsonStr).toContain('order.total_shipping_amount');
|
||||
expect(listJsonStr).toContain('order.total_shipping_amount_formatted');
|
||||
});
|
||||
|
||||
it('다통화 총액이 order.mc_total_amount를 사용해야 함', () => {
|
||||
|
||||
+15
-8
@@ -416,17 +416,24 @@ describe('admin_ecommerce_order_detail.json (메인 레이아웃)', () => {
|
||||
expect(Object.keys(layout.computed)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('modals에 6개의 모달 partial이 정의되어 있다 (reset_guest_password + confirm_deposit 추가)', () => {
|
||||
it('modals에 등록된 모달 partial 집합이 정확히 일치한다', () => {
|
||||
const layout = mainLayout as any;
|
||||
expect(Array.isArray(layout.modals)).toBe(true);
|
||||
expect(layout.modals).toHaveLength(6);
|
||||
|
||||
// 개수와 이름을 따로 단언하면 모달 추가 시 개수만 어긋나 무엇이 늘었는지 드러나지 않는다.
|
||||
// 집합 자체를 고정해 추가·삭제가 진단 가능하도록 한다.
|
||||
const partialPaths = layout.modals.map((m: any) => m.partial);
|
||||
expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json');
|
||||
expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_send_sms.json');
|
||||
expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_send_email.json');
|
||||
expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_cancel_order.json');
|
||||
expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_reset_guest_password.json');
|
||||
expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_confirm_deposit.json');
|
||||
expect(new Set(partialPaths)).toEqual(
|
||||
new Set([
|
||||
'partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json',
|
||||
'partials/admin_ecommerce_order_detail/_modal_send_sms.json',
|
||||
'partials/admin_ecommerce_order_detail/_modal_send_email.json',
|
||||
'partials/admin_ecommerce_order_detail/_modal_cancel_order.json',
|
||||
'partials/admin_ecommerce_order_detail/_modal_issue_cash_receipt.json',
|
||||
'partials/admin_ecommerce_order_detail/_modal_reset_guest_password.json',
|
||||
'partials/admin_ecommerce_order_detail/_modal_confirm_deposit.json',
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -277,6 +277,19 @@ export async function executeCancelOrderHandler(
|
||||
body.reason_detail = cancelReasonDetail;
|
||||
}
|
||||
|
||||
// 환불 계좌 — 관리자가 취소 모달에서 입력/수정한 값.
|
||||
// 셋 중 하나라도 채워졌으면 채워진 그대로 보낸다. 부분 입력 거부와 "가상계좌 입금완료 건은 필수"
|
||||
// 판정은 서버(CancelOrderRequest)가 단독으로 수행한다 — 프론트에서 중복 판정하지 않는다.
|
||||
// 아무것도 입력하지 않았으면 키 자체를 보내지 않아, 주문 시 입력된 계좌가 지워지지 않는다.
|
||||
const refundBank = {
|
||||
bank_code: local.refundBankCode || '',
|
||||
account_number: local.refundBankAccount || '',
|
||||
holder: local.refundBankHolder || '',
|
||||
};
|
||||
if (refundBank.bank_code || refundBank.account_number || refundBank.holder) {
|
||||
body.refund_bank = refundBank;
|
||||
}
|
||||
|
||||
G7Core.state.setLocal({ isCancelling: true, cancelError: null, cancelValidationErrors: null });
|
||||
|
||||
try {
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"order": "Order",
|
||||
"order_logs": "Order Logs",
|
||||
"orders": "Orders",
|
||||
"paymentSettings": "Payment Settings",
|
||||
"policy": "Shipping Policy",
|
||||
"presets": "Presets",
|
||||
"product": "Product",
|
||||
@@ -192,5 +193,19 @@
|
||||
},
|
||||
"shop": {
|
||||
"$partial": "partial/en/shop.json"
|
||||
},
|
||||
"checkout": {
|
||||
"cash_receipt": {
|
||||
"request": "Request a cash receipt",
|
||||
"purpose": "Purpose",
|
||||
"purpose_income": "Income deduction",
|
||||
"purpose_expense": "Proof of expenditure",
|
||||
"identifier_phone": "Mobile number",
|
||||
"identifier_card": "Cash receipt card number",
|
||||
"identifier_business": "Business registration number",
|
||||
"identifier_placeholder": "Digits only, no hyphens",
|
||||
"purpose_help": "Income deduction is used for year-end tax settlement; proof of expenditure is used for business input VAT deduction.",
|
||||
"immutable_help": "The purpose cannot be changed after the receipt is issued."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"order": "주문",
|
||||
"order_logs": "주문 로그",
|
||||
"orders": "주문 목록",
|
||||
"paymentSettings": "결제 환경설정",
|
||||
"policy": "배송 정책",
|
||||
"presets": "프리셋 목록",
|
||||
"product": "상품",
|
||||
@@ -192,5 +193,19 @@
|
||||
},
|
||||
"shop": {
|
||||
"$partial": "partial/ko/shop.json"
|
||||
},
|
||||
"checkout": {
|
||||
"cash_receipt": {
|
||||
"request": "현금영수증 신청",
|
||||
"purpose": "용도",
|
||||
"purpose_income": "소득공제용",
|
||||
"purpose_expense": "지출증빙용",
|
||||
"identifier_phone": "휴대폰번호",
|
||||
"identifier_card": "현금영수증 카드번호",
|
||||
"identifier_business": "사업자등록번호",
|
||||
"identifier_placeholder": "- 없이 숫자만 입력",
|
||||
"purpose_help": "소득공제용은 연말정산에, 지출증빙용은 사업자 매입세액공제에 사용됩니다.",
|
||||
"immutable_help": "발급 후에는 용도를 변경할 수 없습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +286,13 @@
|
||||
"close_btn": "Close",
|
||||
"estimate_failed": "Failed to calculate estimated refund.",
|
||||
"cancel_success": "Order has been cancelled.",
|
||||
"cancel_failed": "Failed to cancel order."
|
||||
"cancel_failed": "Failed to cancel order.",
|
||||
"refund_bank_label": "Refund account",
|
||||
"refund_bank_select": "Select bank",
|
||||
"refund_bank_account_placeholder": "Account number (no hyphens)",
|
||||
"refund_bank_holder_placeholder": "Account holder",
|
||||
"refund_bank_required_hint": "A refund account is required for a paid virtual account order.",
|
||||
"refund_bank_partial_notice": "If you fill in any one of the three fields, the others are required."
|
||||
}
|
||||
},
|
||||
"claim_history": {
|
||||
@@ -450,6 +456,38 @@
|
||||
"after": "After",
|
||||
"bulk_model_id": "ID: {{id}}"
|
||||
}
|
||||
},
|
||||
"cash_receipt": {
|
||||
"title": "Cash Receipt",
|
||||
"issue_title": "Issue a cash receipt",
|
||||
"status_issued": "Issued",
|
||||
"awaiting_deposit": "The receipt can be issued once the deposit is confirmed.",
|
||||
"purpose": "Purpose",
|
||||
"purpose_income": "Income deduction",
|
||||
"purpose_expense": "Proof of expenditure",
|
||||
"identifier_type": "Identifier type",
|
||||
"identifier": "Identifier",
|
||||
"identifier_phone": "Mobile number",
|
||||
"identifier_card": "Cash receipt card number",
|
||||
"identifier_business": "Business registration number",
|
||||
"identifier_placeholder": "Digits only, no hyphens",
|
||||
"immutable_help": "The purpose cannot be changed after the receipt is issued.",
|
||||
"amount": "Amount",
|
||||
"tax_free_amount": "Tax-free amount",
|
||||
"issue_number": "Issue number",
|
||||
"issued_at": "Issued at",
|
||||
"issue": "Issue cash receipt",
|
||||
"cancel": "Cancel",
|
||||
"cancel_issue": "Cancel issuance",
|
||||
"reissue": "Reissue manually",
|
||||
"view_receipt": "View receipt",
|
||||
"history": "Issue history ({{count}})",
|
||||
"issue_success": "The cash receipt has been issued.",
|
||||
"issue_failed": "Failed to issue the cash receipt.",
|
||||
"cancel_success": "The cash receipt issuance has been cancelled.",
|
||||
"cancel_failed": "Failed to cancel the cash receipt issuance.",
|
||||
"reissue_failed": "Reissue failed",
|
||||
"reissue_success": "The cash receipt has been reissued."
|
||||
}
|
||||
},
|
||||
"preset": {
|
||||
|
||||
@@ -219,6 +219,23 @@
|
||||
"restore_on_cancel": "Restore Stock on Cancel",
|
||||
"restore_on_cancel_description": "Automatically restore deducted stock when an order is cancelled.",
|
||||
"restore_on_cancel_hint": "Also applies to returns and exchanges."
|
||||
},
|
||||
"cash_receipt": {
|
||||
"title": "Cash Receipt",
|
||||
"description": "Configure the cash receipt provider and how shipping fees are taxed.",
|
||||
"provider_label": "Cash receipt provider",
|
||||
"provider_none": "(Not used)",
|
||||
"provider_not_installed": "Activate a payment plugin to choose an issuing provider.",
|
||||
"provider_help": "Can be chosen independently of the PG used for payment.",
|
||||
"shipping_fee_tax_label": "Shipping fee tax method",
|
||||
"shipping_fee_tax_proportional": "Proportional (default)",
|
||||
"shipping_fee_tax_taxable": "Fully taxable",
|
||||
"shipping_fee_tax_follow_main_item": "Follow the main item",
|
||||
"shipping_fee_tax_help": "Proportional splits the shipping fee by the taxable and tax-free product amounts.",
|
||||
"self_issue_label": "Enable self-issuance",
|
||||
"self_issue_help": "Automatically issues receipts for bank transfer orders even when the buyer did not request one (income deduction only).",
|
||||
"self_issue_mandatory_help": "Businesses required to issue cash receipts must do so even without a buyer request. Turn this on if that applies to you.",
|
||||
"self_issue_mandatory_link": "NTS guide to mandatory-issuance industries"
|
||||
}
|
||||
},
|
||||
"claim": {
|
||||
|
||||
@@ -12,5 +12,29 @@
|
||||
"delivered": "Delivered",
|
||||
"cancelled": "Cancelled",
|
||||
"refunded": "Refunded"
|
||||
},
|
||||
"cash_receipt": {
|
||||
"title": "Cash Receipt",
|
||||
"issue_title": "Issue a cash receipt",
|
||||
"purpose": "Purpose",
|
||||
"purpose_income": "Income deduction",
|
||||
"purpose_expense": "Proof of expenditure",
|
||||
"identifier_type": "Identifier type",
|
||||
"identifier": "Identifier",
|
||||
"identifier_phone": "Mobile number",
|
||||
"identifier_card": "Cash receipt card number",
|
||||
"identifier_business": "Business registration number",
|
||||
"identifier_placeholder": "Digits only, no hyphens",
|
||||
"immutable_help": "The purpose cannot be changed after the receipt is issued.",
|
||||
"awaiting_deposit": "The receipt will be issued once the deposit is confirmed.",
|
||||
"none": "No cash receipt has been issued.",
|
||||
"issue": "Issue cash receipt",
|
||||
"cancel": "Cancel",
|
||||
"issued_at": "Issued at",
|
||||
"view_receipt": "View receipt",
|
||||
"issue_success": "The cash receipt has been issued.",
|
||||
"issue_failed": "Failed to issue the cash receipt.",
|
||||
"reissue_failed": "Reissue failed",
|
||||
"reissue_failed_help": "Reissuing the cash receipt after the refund failed. Please contact customer support."
|
||||
}
|
||||
}
|
||||
@@ -448,8 +448,46 @@
|
||||
"close_btn": "닫기",
|
||||
"estimate_failed": "환불 예상금액 계산에 실패했습니다.",
|
||||
"cancel_success": "주문이 취소되었습니다.",
|
||||
"cancel_failed": "주문 취소에 실패했습니다."
|
||||
"cancel_failed": "주문 취소에 실패했습니다.",
|
||||
"refund_bank_label": "환불 계좌",
|
||||
"refund_bank_select": "은행 선택",
|
||||
"refund_bank_account_placeholder": "계좌번호 (- 없이 입력)",
|
||||
"refund_bank_holder_placeholder": "예금주",
|
||||
"refund_bank_required_hint": "입금이 완료된 가상계좌 주문은 환불 계좌가 필요합니다.",
|
||||
"refund_bank_partial_notice": "셋 중 하나라도 입력하면 나머지도 입력해야 합니다."
|
||||
}
|
||||
},
|
||||
"cash_receipt": {
|
||||
"title": "현금영수증",
|
||||
"issue_title": "현금영수증 발급",
|
||||
"status_issued": "발급완료",
|
||||
"awaiting_deposit": "입금이 확인되면 발급할 수 있습니다.",
|
||||
"purpose": "용도",
|
||||
"purpose_income": "소득공제용",
|
||||
"purpose_expense": "지출증빙용",
|
||||
"identifier_type": "발급수단",
|
||||
"identifier": "식별번호",
|
||||
"identifier_phone": "휴대폰번호",
|
||||
"identifier_card": "현금영수증 카드번호",
|
||||
"identifier_business": "사업자등록번호",
|
||||
"identifier_placeholder": "- 없이 숫자만 입력",
|
||||
"immutable_help": "발급 후에는 용도를 변경할 수 없습니다.",
|
||||
"amount": "금액",
|
||||
"tax_free_amount": "면세금액",
|
||||
"issue_number": "발급번호",
|
||||
"issued_at": "발급일시",
|
||||
"issue": "현금영수증 발급",
|
||||
"cancel": "취소",
|
||||
"cancel_issue": "발급취소",
|
||||
"reissue": "수동 재발급",
|
||||
"view_receipt": "영수증 보기",
|
||||
"history": "발급 이력 ({{count}}건)",
|
||||
"issue_success": "현금영수증이 발급되었습니다.",
|
||||
"issue_failed": "현금영수증 발급에 실패했습니다.",
|
||||
"cancel_success": "현금영수증 발급이 취소되었습니다.",
|
||||
"cancel_failed": "현금영수증 발급 취소에 실패했습니다.",
|
||||
"reissue_failed": "재발급 실패",
|
||||
"reissue_success": "현금영수증이 재발급되었습니다."
|
||||
}
|
||||
},
|
||||
"preset": {
|
||||
|
||||
@@ -219,6 +219,23 @@
|
||||
"restore_on_cancel": "주문 취소 시 재고 복구",
|
||||
"restore_on_cancel_description": "주문이 취소되면 차감된 재고를 자동으로 복구합니다.",
|
||||
"restore_on_cancel_hint": "반품/교환 처리 시에도 적용됩니다."
|
||||
},
|
||||
"cash_receipt": {
|
||||
"title": "현금영수증",
|
||||
"description": "현금영수증 발급 프로바이더와 배송비 과세 방식을 설정합니다.",
|
||||
"provider_label": "현금영수증 프로바이더",
|
||||
"provider_none": "(미사용)",
|
||||
"provider_not_installed": "결제 플러그인을 활성화하면 발급 프로바이더를 선택할 수 있습니다.",
|
||||
"provider_help": "결제에 사용하는 PG 와 별개로 선택할 수 있습니다.",
|
||||
"shipping_fee_tax_label": "배송비 과세 방식",
|
||||
"shipping_fee_tax_proportional": "안분 (기본)",
|
||||
"shipping_fee_tax_taxable": "전액 과세",
|
||||
"shipping_fee_tax_follow_main_item": "주된 재화 기준",
|
||||
"shipping_fee_tax_help": "안분은 과세·면세 상품 금액 비율로 배송비를 나눕니다.",
|
||||
"self_issue_label": "자진발급 사용",
|
||||
"self_issue_help": "구매자가 신청하지 않은 무통장 입금 건도 자동으로 발급합니다 (소득공제용 전용).",
|
||||
"self_issue_mandatory_help": "현금영수증 의무발행 업종은 구매자가 신청하지 않아도 발급 의무가 있습니다. 해당 업종이면 켜세요.",
|
||||
"self_issue_mandatory_link": "국세청 의무발행 업종 안내"
|
||||
}
|
||||
},
|
||||
"claim": {
|
||||
|
||||
@@ -12,5 +12,29 @@
|
||||
"delivered": "배송완료",
|
||||
"cancelled": "취소됨",
|
||||
"refunded": "환불됨"
|
||||
},
|
||||
"cash_receipt": {
|
||||
"title": "현금영수증",
|
||||
"issue_title": "현금영수증 발급",
|
||||
"purpose": "용도",
|
||||
"purpose_income": "소득공제용",
|
||||
"purpose_expense": "지출증빙용",
|
||||
"identifier_type": "발급수단",
|
||||
"identifier": "식별번호",
|
||||
"identifier_phone": "휴대폰번호",
|
||||
"identifier_card": "현금영수증 카드번호",
|
||||
"identifier_business": "사업자등록번호",
|
||||
"identifier_placeholder": "- 없이 숫자만 입력",
|
||||
"immutable_help": "발급 후에는 용도를 변경할 수 없습니다.",
|
||||
"awaiting_deposit": "입금이 확인되면 발급됩니다.",
|
||||
"none": "발급된 현금영수증이 없습니다.",
|
||||
"issue": "현금영수증 발급",
|
||||
"cancel": "취소",
|
||||
"issued_at": "발급일시",
|
||||
"view_receipt": "영수증 보기",
|
||||
"issue_success": "현금영수증이 발급되었습니다.",
|
||||
"issue_failed": "현금영수증 발급에 실패했습니다.",
|
||||
"reissue_failed": "재발급 실패",
|
||||
"reissue_failed_help": "환불에 따른 현금영수증 재발급이 실패했습니다. 고객센터로 문의해 주세요."
|
||||
}
|
||||
}
|
||||
+20
-2
@@ -16,7 +16,8 @@
|
||||
"batchOrderStatus": "",
|
||||
"batchCarrierId": "",
|
||||
"batchTrackingNumber": "",
|
||||
"activeOrderTab": "order_info"
|
||||
"activeOrderTab": "order_info",
|
||||
"expandedCashReceiptHistory": []
|
||||
},
|
||||
"transition_overlay": {
|
||||
"wait_for": [
|
||||
@@ -64,9 +65,23 @@
|
||||
"form.intl_state": "{{data.shipping_address?.intl_state ?? ''}}",
|
||||
"form.intl_postal_code": "{{data.shipping_address?.intl_postal_code ?? ''}}",
|
||||
"form.delivery_memo": "{{data.delivery_memo ?? ''}}",
|
||||
"form.admin_memo": "{{data.admin_memo ?? ''}}"
|
||||
"form.admin_memo": "{{data.admin_memo ?? ''}}",
|
||||
"_comment_refund_bank": "주문 시 입력된 환불 계좌를 취소 모달에 프리필한다 (관리자가 수정 가능). cancelOrderHandlers 가 이 _local 키를 읽어 refund_bank 로 전송한다.",
|
||||
"refundBankCode": "{{data.payment?.refund_bank_code ?? ''}}",
|
||||
"refundBankAccount": "{{data.payment?.refund_bank_account ?? ''}}",
|
||||
"refundBankHolder": "{{data.payment?.refund_bank_holder ?? ''}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "paymentSettings",
|
||||
"comment": "환불 계좌 은행 목록 (order_settings.banks) — 취소 모달의 은행 Select 가 사용한다. 무통장 입금 계좌와 동일한 목록이다.",
|
||||
"label_key": "$t:sirsoft-ecommerce.editor.data_source.paymentSettings",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/settings/payment",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true
|
||||
},
|
||||
{
|
||||
"id": "active_carriers",
|
||||
"label_key": "$t:sirsoft-ecommerce.editor.data_source.active_carriers",
|
||||
@@ -167,6 +182,9 @@
|
||||
{
|
||||
"partial": "partials/admin_ecommerce_order_detail/_modal_cancel_order.json"
|
||||
},
|
||||
{
|
||||
"partial": "partials/admin_ecommerce_order_detail/_modal_issue_cash_receipt.json"
|
||||
},
|
||||
{
|
||||
"partial": "partials/admin_ecommerce_order_detail/_modal_reset_guest_password.json"
|
||||
},
|
||||
|
||||
+155
@@ -493,6 +493,161 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cancel_refund_bank_section",
|
||||
"comment": "환불 계좌 (W-4 / #454). 가상계좌·무통장에서만 노출한다 — 카드·간편결제는 원 결제수단으로 환불되므로 계좌가 불필요하다. 가상계좌 입금완료 건은 서버(CancelOrderRequest)가 필수로 강제한다. 주문 시 입력값은 레이아웃 initLocal 이 프리필하며, cancelOrderHandlers 가 _local.refundBank* 를 읽어 전송한다. 신설 블록이므로 responsive.portable 로 작성한다(§6-0).",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{['vbank', 'dbank'].includes(order.data?.payment?.payment_method)}}",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-body font-medium block mb-2"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.refund_bank_label"
|
||||
},
|
||||
{
|
||||
"comment": "가상계좌 입금완료 건은 필수임을 미리 안내한다 (서버가 422 로 막기 전에)",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{order.data?.payment?.payment_method === 'vbank' && order.data?.payment?.payment_status === 'paid'}}",
|
||||
"props": {
|
||||
"className": "text-xs text-amber-600 dark:text-amber-400 mb-2"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.refund_bank_required_hint"
|
||||
},
|
||||
{
|
||||
"comment": "은행 / 계좌번호 / 예금주 — 기본 3열, portable 1열 (마크업 구조 변경이므로 responsive 속성 사용)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "grid grid-cols-3 gap-3"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "grid grid-cols-1 gap-3"
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "cancel_refund_bank_code",
|
||||
"type": "basic",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "refund_bank_code",
|
||||
"value": "{{_local.refundBankCode ?? ''}}",
|
||||
"options": "{{[{ value: '', label: $t('sirsoft-ecommerce.admin.order.detail.modal.cancel.refund_bank_select') }].concat((paymentSettings.data?.order_settings?.banks ?? []).map(b => ({ value: b.code, label: $localized(b.name) ?? b.code })))}}",
|
||||
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"refundBankCode": "{{$event.target.value}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cancel_refund_bank_account",
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "text",
|
||||
"name": "refund_bank_account",
|
||||
"value": "{{_local.refundBankAccount ?? ''}}",
|
||||
"placeholder": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.refund_bank_account_placeholder",
|
||||
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"refundBankAccount": "{{$event.target.value}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cancel_refund_bank_holder",
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "text",
|
||||
"name": "refund_bank_holder",
|
||||
"value": "{{_local.refundBankHolder ?? ''}}",
|
||||
"placeholder": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.refund_bank_holder_placeholder",
|
||||
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"refundBankHolder": "{{$event.target.value}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "서버 검증 오류 — 부분 입력 거부 / 가상계좌 입금완료 필수 (필드별 인라인)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!(_local.cancelValidationErrors?.['refund_bank.bank_code'] || _local.cancelValidationErrors?.['refund_bank.account_number'] || _local.cancelValidationErrors?.['refund_bank.holder'])}}",
|
||||
"props": {
|
||||
"className": "mt-1 space-y-0.5"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!_local.cancelValidationErrors?.['refund_bank.bank_code']}}",
|
||||
"props": {
|
||||
"className": "form-error-xs block"
|
||||
},
|
||||
"text": "{{Array.isArray(_local.cancelValidationErrors?.['refund_bank.bank_code']) ? _local.cancelValidationErrors['refund_bank.bank_code'][0] : _local.cancelValidationErrors?.['refund_bank.bank_code']}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!_local.cancelValidationErrors?.['refund_bank.account_number']}}",
|
||||
"props": {
|
||||
"className": "form-error-xs block"
|
||||
},
|
||||
"text": "{{Array.isArray(_local.cancelValidationErrors?.['refund_bank.account_number']) ? _local.cancelValidationErrors['refund_bank.account_number'][0] : _local.cancelValidationErrors?.['refund_bank.account_number']}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!_local.cancelValidationErrors?.['refund_bank.holder']}}",
|
||||
"props": {
|
||||
"className": "form-error-xs block"
|
||||
},
|
||||
"text": "{{Array.isArray(_local.cancelValidationErrors?.['refund_bank.holder']) ? _local.cancelValidationErrors['refund_bank.holder'][0] : _local.cancelValidationErrors?.['refund_bank.holder']}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-xs text-secondary mt-1"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.refund_bank_partial_notice"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "환불 우선순위 라디오 — PG 환불 가능(requires_pg_cancellation)일 때 노출한다. 포인트 사용분이 없으면(total_points_used_amount=0) PG↔포인트 배분 순서가 무의미하므로 노출은 하되 선택 불가(disabled)로 둔다 (A28, 2026-06-22).",
|
||||
"type": "basic",
|
||||
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "관리자 주문상세 - 현금영수증 발급 모달 (사후 발급 포함)"
|
||||
},
|
||||
"id": "modal_issue_cash_receipt",
|
||||
"type": "composite",
|
||||
"name": "Modal",
|
||||
"props": {
|
||||
"title": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.issue_title",
|
||||
"size": "small"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "모달은 부모와 별도 컨텍스트라 iteration 변수(payment)나 부모 _local 을 참조할 수 없다. 발급 버튼이 열기 전에 _global.adminCashReceiptForm 을 시드하며, 주문 식별은 데이터소스(order) 응답을 직접 참조한다.",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "space-y-4" },
|
||||
"children": [
|
||||
{
|
||||
"comment": "용도",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.purpose"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex flex-row gap-4" },
|
||||
"responsive": {
|
||||
"portable": { "props": { "className": "flex flex-col gap-2" } }
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "radio",
|
||||
"name": "receipt_type",
|
||||
"value": "income",
|
||||
"checked": "{{(_global.adminCashReceiptForm?.receipt_type ?? 'income') === 'income'}}",
|
||||
"className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"comment": "소득공제 전환 시 사업자등록번호 선택을 phone 으로 리셋한다 (소득공제에는 사용할 수 없다)",
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"adminCashReceiptForm.receipt_type": "income",
|
||||
"adminCashReceiptForm.identifier_type": "{{_global.adminCashReceiptForm?.identifier_type === 'business' ? 'phone' : (_global.adminCashReceiptForm?.identifier_type ?? 'phone')}}",
|
||||
"adminCashReceiptForm.identifier": "{{_global.adminCashReceiptForm?.identifier_type === 'business' ? '' : (_global.adminCashReceiptForm?.identifier ?? '')}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.purpose_income"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "radio",
|
||||
"name": "receipt_type",
|
||||
"value": "expense",
|
||||
"checked": "{{_global.adminCashReceiptForm?.receipt_type === 'expense'}}",
|
||||
"className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": { "target": "global", "adminCashReceiptForm.receipt_type": "expense" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.purpose_expense"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "발급수단 — 지출증빙일 때만 사업자등록번호를 옵션에 포함한다 (D10: 휴대폰은 양쪽 모두 유효)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.identifier_type"
|
||||
},
|
||||
{
|
||||
"id": "modal_cash_receipt_identifier_type",
|
||||
"type": "basic",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "identifier_type",
|
||||
"value": "{{_global.adminCashReceiptForm?.identifier_type ?? 'phone'}}",
|
||||
"options": "{{(_global.adminCashReceiptForm?.receipt_type === 'expense' ? [{ value: 'business', label: $t('sirsoft-ecommerce.admin.order.detail.cash_receipt.identifier_business') }] : []).concat([{ value: 'phone', label: $t('sirsoft-ecommerce.admin.order.detail.cash_receipt.identifier_phone') }, { value: 'card', label: $t('sirsoft-ecommerce.admin.order.detail.cash_receipt.identifier_card') }])}}",
|
||||
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"comment": "발급수단이 바뀌면 번호를 비운다 — 형식이 달라 그대로 두면 서버 검증에서 422 가 난다.",
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"adminCashReceiptForm.identifier_type": "{{$event.target.value}}",
|
||||
"adminCashReceiptForm.identifier": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "번호",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.identifier"
|
||||
},
|
||||
{
|
||||
"id": "modal_cash_receipt_identifier",
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "text",
|
||||
"name": "identifier",
|
||||
"value": "{{_global.adminCashReceiptForm?.identifier ?? ''}}",
|
||||
"placeholder": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.identifier_placeholder",
|
||||
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": { "target": "global", "adminCashReceiptForm.identifier": "{{$event.target.value}}" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "서버 검증 메시지 인라인 노출 (사업자번호 체크섬 · 용도×수단 조합 위반 등)",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!_global.adminCashReceiptForm?.error}}",
|
||||
"props": { "className": "block text-red-500 text-xs mt-1" },
|
||||
"text": "{{_global.adminCashReceiptForm?.error ?? ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "text-xs text-secondary" },
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.immutable_help"
|
||||
},
|
||||
{
|
||||
"comment": "액션 — 데스크톱 가로 우측정렬 → portable full-width 세로 스택",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "flex flex-row justify-end gap-2 pt-2" },
|
||||
"responsive": {
|
||||
"portable": { "props": { "className": "flex flex-col gap-2 w-full pt-2" } }
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.cancel",
|
||||
"actions": [{ "type": "click", "handler": "closeModal" }]
|
||||
},
|
||||
{
|
||||
"id": "modal_cash_receipt_submit",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-4 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{!(_global.adminCashReceiptForm?.identifier ?? '')}}"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.issue",
|
||||
"actions": [
|
||||
{
|
||||
"comment": "관리자 API 는 auth_required 를 선언해야 Bearer 토큰이 부착된다 — 미선언 시 401.",
|
||||
"type": "click",
|
||||
"handler": "apiCall",
|
||||
"target": "/api/modules/sirsoft-ecommerce/admin/orders/{{order.data?.id}}/cash-receipt",
|
||||
"auth_required": true,
|
||||
"params": {
|
||||
"method": "POST",
|
||||
"body": {
|
||||
"receipt_type": "{{_global.adminCashReceiptForm?.receipt_type ?? 'income'}}",
|
||||
"identifier_type": "{{_global.adminCashReceiptForm?.identifier_type ?? 'phone'}}",
|
||||
"identifier": "{{_global.adminCashReceiptForm?.identifier ?? ''}}"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"comment": "setState 를 먼저 하고 closeModal 을 나중에 한다 (순서 역전 시 상태가 남는다)",
|
||||
"handler": "setState",
|
||||
"params": { "target": "global", "adminCashReceiptForm": null }
|
||||
},
|
||||
{ "handler": "closeModal" },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "order" } },
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "success",
|
||||
"message": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.issue_success"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"adminCashReceiptForm.error": "{{error.errors?.identifier?.[0] ?? error.message ?? $t('sirsoft-ecommerce.admin.order.detail.cash_receipt.issue_failed')}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+584
-1
@@ -27,7 +27,7 @@
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.payment_info.title"
|
||||
},
|
||||
{
|
||||
"id": "payment_cards",
|
||||
"id": "payment_cards_{{payIdx}}",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": {
|
||||
@@ -740,6 +740,589 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "payment_cash_receipt_card_{{payIdx}}",
|
||||
"comment": "현금영수증 카드 (W-3 / #454). 무통장이 아니거나 프로바이더 미설정이면 카드 자체를 렌더하지 않는다. 카드 바깥의 기존 결제정보 본문은 .grid-2col-responsive 등 CSS 유틸을 쓰지만, 신설 카드는 responsive.portable 로 작성한다(액션 버튼의 마크업 구조가 바뀌므로 Tailwind breakpoint 로 처리 불가 — 편집기 디바이스 미리보기가 overrideWidth 를 무시한다).",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{payment?.payment_method === 'dbank' && !!payment?.cash_receipt_provider}}",
|
||||
"props": {
|
||||
"className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "헤더 — 프로바이더 + 발급 상태 배지",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center justify-between mb-3"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "receipt",
|
||||
"size": "sm",
|
||||
"className": "text-gray-700 dark:text-gray-300"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H4",
|
||||
"props": {
|
||||
"className": "text-sm font-semibold text-gray-900 dark:text-white"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.title"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-xs text-secondary"
|
||||
},
|
||||
"text": "{{payment?.cash_receipt_provider ?? ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!order.data?.cash_receipt}}",
|
||||
"props": {
|
||||
"className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.status_issued"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "payment_cash_receipt_awaiting_{{payIdx}}",
|
||||
"comment": "입금 전 — 발급 불가 안내. 무통장 주문은 ready 로 생성되고 가상계좌는 waiting_deposit 이다(PaymentStatusEnum::isAwaitingDeposit() 이 둘 다 true). 위 입금확인 버튼(94행)이 이미 같은 두 상태를 함께 검사한다.",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{['ready', 'waiting_deposit'].includes(payment?.payment_status)}}",
|
||||
"props": {
|
||||
"className": "text-sm text-secondary"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.awaiting_deposit"
|
||||
},
|
||||
{
|
||||
"id": "payment_cash_receipt_issued_{{payIdx}}",
|
||||
"comment": "발급완료 — 정보 2열(기본) → portable 1열",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!order.data?.cash_receipt}}",
|
||||
"props": {
|
||||
"className": "grid grid-cols-2 gap-x-4 gap-y-1.5"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "grid grid-cols-1 gap-y-1.5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex justify-between text-sm"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-secondary"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.issued_at"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-primary"
|
||||
},
|
||||
"text": "{{order.data?.cash_receipt?.issued_at_formatted ?? '-'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex justify-between text-sm"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-secondary"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.purpose"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-primary"
|
||||
},
|
||||
"text": "{{order.data?.cash_receipt?.receipt_type_label ?? '-'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex justify-between text-sm"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-secondary"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.identifier"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-primary"
|
||||
},
|
||||
"text": "{{order.data?.cash_receipt?.identifier_masked ?? '-'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex justify-between text-sm"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-secondary"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.amount"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-primary"
|
||||
},
|
||||
"text": "{{order.data?.cash_receipt?.amount_formatted ?? '-'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex justify-between text-sm"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-secondary"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.tax_free_amount"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-primary"
|
||||
},
|
||||
"text": "{{order.data?.cash_receipt?.tax_free_amount_formatted ?? '-'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex justify-between text-sm"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-secondary"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.issue_number"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-primary"
|
||||
},
|
||||
"text": "{{order.data?.cash_receipt?.issue_number ?? '-'}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "payment_cash_receipt_failed_badge_{{payIdx}}",
|
||||
"comment": "취소 성공 + 재발급 실패 — 경고 배지 + 수동 재발급(관리자 전용). issue_status backing value 는 대문자(FAILED) 라 대소문자 무관 비교한다.",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!order.data?.cash_receipt && (order.data?.cash_receipts ?? []).length > 0 && String((order.data?.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() === 'failed'}}",
|
||||
"props": {
|
||||
"className": "rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 mb-3"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "triangle-exclamation",
|
||||
"size": "sm",
|
||||
"className": "text-amber-600 dark:text-amber-400"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-sm font-medium text-amber-800 dark:text-amber-300"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.reissue_failed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-xs text-amber-700 dark:text-amber-400 mt-1"
|
||||
},
|
||||
"text": "{{(order.data?.cash_receipts ?? [])[0]?.error_message ?? ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "payment_cash_receipt_actions_{{payIdx}}",
|
||||
"comment": "액션 — 데스크톱 가로 우측정렬 → portable full-width 세로 스택 (§6-0)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex flex-row justify-end gap-2 mt-3"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "flex flex-col gap-2 w-full mt-3"
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "payment_cash_receipt_view_link_{{payIdx}}",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"if": "{{!!order.data?.cash_receipt?.receipt_url}}",
|
||||
"props": {
|
||||
"href": "{{order.data?.cash_receipt?.receipt_url}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"className": "inline-flex items-center justify-center gap-1 px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"href": "{{order.data?.cash_receipt?.receipt_url}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"className": "inline-flex w-full items-center justify-center gap-1 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.view_receipt"
|
||||
},
|
||||
{
|
||||
"id": "payment_cash_receipt_issue_button_{{payIdx}}",
|
||||
"comment": "입금완료 + 미발급 + 재발급 실패 이력 없음 → 신규 발급. 실패 이력이 있으면 복구 경로는 수동 재발급이므로 두 버튼을 함께 띄우지 않는다.",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{payment?.payment_status === 'paid' && !order.data?.cash_receipt && String((order.data?.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() !== 'failed'}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "w-full px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.issue",
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"params": {
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"adminCashReceiptForm": "{{ { receipt_type: (payment?.cash_receipt_type ?? 'income'), identifier_type: (payment?.cash_receipt_identifier_type ?? 'phone'), identifier: '', error: null } }}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "openModal",
|
||||
"target": "modal_issue_cash_receipt"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "payment_cash_receipt_reissue_button_{{payIdx}}",
|
||||
"comment": "수동 재발급 — 관리자 전용. 취소 성공 + 재발급 실패 중간 상태 복구 (recoverFailedIssue)",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{!order.data?.cash_receipt && (order.data?.cash_receipts ?? []).length > 0 && String((order.data?.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() === 'failed'}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-3 py-1.5 text-sm rounded-lg bg-amber-600 hover:bg-amber-700 text-white"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "w-full px-3 py-2 text-sm rounded-lg bg-amber-600 hover:bg-amber-700 text-white"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.reissue",
|
||||
"actions": [
|
||||
{
|
||||
"comment": "관리자 API 는 auth_required 를 선언해야 Bearer 토큰이 부착된다 — 미선언 시 401.",
|
||||
"type": "click",
|
||||
"handler": "apiCall",
|
||||
"target": "/api/modules/sirsoft-ecommerce/admin/orders/{{order.data?.id}}/cash-receipt/reissue",
|
||||
"auth_required": true,
|
||||
"params": {
|
||||
"method": "POST"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "order"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "success",
|
||||
"message": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.reissue_success"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message ?? $t('sirsoft-ecommerce.admin.order.detail.cash_receipt.reissue_failed')}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "payment_cash_receipt_cancel_button_{{payIdx}}",
|
||||
"comment": "발급취소 — 관리자 전용 (유저 화면에는 이 버튼이 없다)",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{!!order.data?.cash_receipt}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-3 py-1.5 text-sm rounded-lg border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "w-full px-3 py-2 text-sm rounded-lg border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.cancel_issue",
|
||||
"actions": [
|
||||
{
|
||||
"comment": "관리자 API 는 auth_required 를 선언해야 Bearer 토큰이 부착된다 — 미선언 시 401.",
|
||||
"type": "click",
|
||||
"handler": "apiCall",
|
||||
"target": "/api/modules/sirsoft-ecommerce/admin/orders/{{order.data?.id}}/cash-receipt",
|
||||
"auth_required": true,
|
||||
"params": {
|
||||
"method": "DELETE"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "order"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "success",
|
||||
"message": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.cancel_success"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message ?? $t('sirsoft-ecommerce.admin.order.detail.cash_receipt.cancel_failed')}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "payment_cash_receipt_history_{{payIdx}}",
|
||||
"comment": "발급 이력 — 발급/취소 전 이력을 최신순으로 보여준다 (국세청 신고 대사용)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(order.data?.cash_receipts ?? []).length > 0}}",
|
||||
"props": {
|
||||
"className": "mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "payment_cash_receipt_history_toggle_{{payIdx}}",
|
||||
"comment": "이력은 기본 접힘 — 결제 건별로 독립 토글해야 하므로 _local.expandedCashReceiptHistory 배열에 payIdx 를 넣고 뺀다 (setState params 키는 정적이어야 하므로 값 표현식으로 배열을 조작한다)",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "flex items-center gap-1 w-full text-left text-xs font-semibold text-secondary mb-1.5 hover:text-gray-900 dark:hover:text-gray-100",
|
||||
"aria-expanded": "{{(_local.expandedCashReceiptHistory ?? []).includes(payIdx)}}",
|
||||
"aria-controls": "payment_cash_receipt_history_body_{{payIdx}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"event": "click",
|
||||
"handler": "setState",
|
||||
"target": "local",
|
||||
"params": {
|
||||
"expandedCashReceiptHistory": "{{(_local.expandedCashReceiptHistory ?? []).includes(payIdx) ? (_local.expandedCashReceiptHistory ?? []).filter(i => i !== payIdx) : [...(_local.expandedCashReceiptHistory ?? []), payIdx]}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "{{(_local.expandedCashReceiptHistory ?? []).includes(payIdx) ? 'chevron-down' : 'chevron-right'}}",
|
||||
"size": "xs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.cash_receipt.history|count={{(order.data?.cash_receipts ?? []).length}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "payment_cash_receipt_history_body_{{payIdx}}",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(_local.expandedCashReceiptHistory ?? []).includes(payIdx)}}",
|
||||
"props": {
|
||||
"className": "space-y-1"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "{{order.data?.cash_receipts ?? []}}",
|
||||
"item_var": "receipt",
|
||||
"index_var": "receiptIdx"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center justify-between text-xs text-secondary"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "발급 실패 이력은 issued_at 이 null 이므로 기록 시각(occurred_at_formatted)을 쓴다",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "{{receipt.occurred_at_formatted ?? '-'}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "{{receipt.transaction_type_label ?? ''}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "{{receipt.amount_formatted ?? ''}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "{{String(receipt.issue_status ?? '').toLowerCase() === 'failed' ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}}"
|
||||
},
|
||||
"text": "{{receipt.result_label ?? ''}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+250
@@ -85,6 +85,256 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cash_receipt_card",
|
||||
"comment": "현금영수증 설정 (W-5 / #454). 발급 프로바이더는 결제 PG 와 독립 선택한다(KG 로 결제하면서 영수증만 토스로 발급하는 구성 허용 — D5). 배송비 과세 방식은 3택(D3). 신설 블록이므로 responsive.portable 로 작성한다(§6-0).",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "admin-card"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "card-title"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.title"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "card-description"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.description"
|
||||
},
|
||||
{
|
||||
"comment": "프로바이더 Select + 배송비 과세 Select — 기본 2열, portable 1열",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "grid grid-cols-2 gap-4"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "grid grid-cols-1 gap-4"
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "block text-sm font-medium mb-1"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.provider_label"
|
||||
},
|
||||
{
|
||||
"comment": "등록된 프로바이더가 0개면 안내만 노출한다",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(_local.form?.available_cash_receipt_providers ?? []).length === 0}}",
|
||||
"props": {
|
||||
"className": "flex-center gap-2 text-label-subtle"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "circle-info",
|
||||
"className": "text-sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.provider_not_installed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cash_receipt_provider_select",
|
||||
"type": "basic",
|
||||
"name": "Select",
|
||||
"if": "{{(_local.form?.available_cash_receipt_providers ?? []).length > 0}}",
|
||||
"props": {
|
||||
"value": "{{_local.form?.order_settings?.cash_receipt_provider ?? ''}}",
|
||||
"className": "w-full",
|
||||
"options": "{{[{ value: '', label: $t('sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.provider_none') }].concat((_local.form?.available_cash_receipt_providers ?? []).map(p => ({ value: p.id, label: $localized(p.name) ?? p.id })))}}",
|
||||
"disabled": "{{_computed.isReadOnly}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"form.order_settings.cash_receipt_provider": "{{$event.target.value || ''}}",
|
||||
"hasChanges": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-xs text-secondary mt-1"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.provider_help"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "block text-sm font-medium mb-1"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.shipping_fee_tax_label"
|
||||
},
|
||||
{
|
||||
"id": "shipping_fee_tax_policy_select",
|
||||
"comment": "배송비 과세 3정책 (D3) — 부가가치세법에 '배송비 50% 과세' 같은 일률 규정은 없다. 상점이 선택한다.",
|
||||
"type": "basic",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"value": "{{_local.form?.order_settings?.shipping_fee_tax_policy ?? 'proportional'}}",
|
||||
"className": "w-full",
|
||||
"options": "{{[{ value: 'proportional', label: $t('sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.shipping_fee_tax_proportional') }, { value: 'taxable', label: $t('sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.shipping_fee_tax_taxable') }, { value: 'follow_main_item', label: $t('sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.shipping_fee_tax_follow_main_item') }]}}",
|
||||
"disabled": "{{_computed.isReadOnly}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"form.order_settings.shipping_fee_tax_policy": "{{$event.target.value}}",
|
||||
"hasChanges": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-xs text-secondary mt-1"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.shipping_fee_tax_help"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "자진발급 토글 — 기본 OFF (D14). 의무발행 업종만 켠다.",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "cash_receipt_self_issue_toggle",
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "checkbox",
|
||||
"name": "order_settings.cash_receipt_self_issue",
|
||||
"checked": "{{_local.form?.order_settings?.cash_receipt_self_issue ?? false}}",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": "w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"form.order_settings.cash_receipt_self_issue": "{{$event.target.checked}}",
|
||||
"hasChanges": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-sm font-medium"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.self_issue_label"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-xs text-secondary mt-1 ml-6"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.self_issue_help"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-xs text-secondary mt-1 ml-6"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.self_issue_mandatory_help"
|
||||
},
|
||||
{
|
||||
"id": "cash_receipt_mandatory_industry_link",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "https://www.nts.go.kr/nts/cm/cntnts/cntntsView.do?cntntsId=7796&mi=2471",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"className": "inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-1 ml-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.self_issue_mandatory_link"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "external-link",
|
||||
"size": "sm"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "payment_methods_card",
|
||||
"type": "basic",
|
||||
|
||||
@@ -42,6 +42,26 @@ enum CashReceiptIdentifierType: string
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
|
||||
/**
|
||||
* 식별번호를 마스킹합니다 (뒤 4자리만 노출).
|
||||
*
|
||||
* 식별번호의 형태를 아는 것은 이 Enum 의 책임이므로 마스킹 규칙도 여기에 둔다.
|
||||
* 주문 생성 시점(신청 저장)과 발급 시점(이력 기록)이 동일한 마스킹을 써야 하므로 SSoT 로 유지한다.
|
||||
*
|
||||
* @param string $identifier 식별번호 원본
|
||||
* @return string 마스킹된 식별번호
|
||||
*/
|
||||
public static function mask(string $identifier): string
|
||||
{
|
||||
$length = strlen($identifier);
|
||||
|
||||
if ($length <= 4) {
|
||||
return str_repeat('*', $length);
|
||||
}
|
||||
|
||||
return str_repeat('*', $length - 4).substr($identifier, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효한 값인지 확인합니다.
|
||||
*
|
||||
|
||||
+4
@@ -53,6 +53,9 @@ class EcommerceSettingsController extends AdminBaseController
|
||||
$settings = $this->appendClaimReasonsToSettings($settings);
|
||||
$settings = $this->appendMileageNotificationChannelsToSettings($settings);
|
||||
$settings['available_pg_providers'] = $this->settingsService->getRegisteredPgProviders();
|
||||
// 현금영수증 발급 프로바이더 후보 — 결제 플러그인이 훅으로 자신을 등록한다.
|
||||
// 발급 PG 와 결제 PG 는 독립 선택이므로 목록도 별도로 내린다 (KG 결제 + 토스 발급 등).
|
||||
$settings['available_cash_receipt_providers'] = $this->settingsService->getRegisteredCashReceiptProviders();
|
||||
$settings['abilities'] = [
|
||||
'can_update' => PermissionHelper::check('sirsoft-ecommerce.settings.update', request()->user()),
|
||||
];
|
||||
@@ -167,6 +170,7 @@ class EcommerceSettingsController extends AdminBaseController
|
||||
$updatedSettings = $this->appendClaimReasonsToSettings($updatedSettings);
|
||||
$updatedSettings = $this->appendMileageNotificationChannelsToSettings($updatedSettings);
|
||||
$updatedSettings['available_pg_providers'] = $this->settingsService->getRegisteredPgProviders();
|
||||
$updatedSettings['available_cash_receipt_providers'] = $this->settingsService->getRegisteredCashReceiptProviders();
|
||||
|
||||
return ResponseHelper::moduleSuccess(
|
||||
'sirsoft-ecommerce',
|
||||
|
||||
@@ -338,6 +338,7 @@ class OrderController extends AdminBaseController
|
||||
cancelledBy: $cancelledBy,
|
||||
cancelPg: $request->shouldCancelPg(),
|
||||
refundPriority: $request->getRefundPriority(),
|
||||
refundBankInfo: $request->getRefundBankInfo(),
|
||||
);
|
||||
} else {
|
||||
$result = $this->cancellationService->cancelOrderOptions(
|
||||
@@ -348,6 +349,7 @@ class OrderController extends AdminBaseController
|
||||
cancelledBy: $cancelledBy,
|
||||
cancelPg: $request->shouldCancelPg(),
|
||||
refundPriority: $request->getRefundPriority(),
|
||||
refundBankInfo: $request->getRefundBankInfo(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -91,7 +91,9 @@ trait HandlesOrderCreation
|
||||
shippingMemo: $request->input('shipping_memo'),
|
||||
depositorName: $request->input('depositor_name'),
|
||||
dbankInfo: $request->getDbankInfo(),
|
||||
guestLookupPassword: $request->getGuestLookupPassword()
|
||||
guestLookupPassword: $request->getGuestLookupPassword(),
|
||||
cashReceiptInfo: $request->getCashReceiptInfo(),
|
||||
refundBankInfo: $request->getRefundBankInfo()
|
||||
);
|
||||
|
||||
$order->load(['options', 'payment', 'shippingAddress']);
|
||||
|
||||
+126
-16
@@ -8,9 +8,12 @@ use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ClaimReason;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\RefundPriorityEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ClaimReason;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
|
||||
/**
|
||||
* 주문 취소 요청 (관리자)
|
||||
@@ -22,7 +25,7 @@ class CancelOrderRequest extends FormRequest
|
||||
/**
|
||||
* 사용자가 이 요청을 수행할 권한이 있는지 확인
|
||||
*
|
||||
* @return bool
|
||||
* @return bool 항상 true (권한 검사는 permission 미들웨어가 담당)
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -32,7 +35,7 @@ class CancelOrderRequest extends FormRequest
|
||||
/**
|
||||
* 요청에 적용할 검증 규칙
|
||||
*
|
||||
* @return array
|
||||
* @return array 검증 규칙 배열
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -45,13 +48,40 @@ class CancelOrderRequest extends FormRequest
|
||||
'items.*.cancel_quantity' => ['required_with:items', 'integer', 'min:1'],
|
||||
'cancel_pg' => ['nullable', 'boolean'],
|
||||
'refund_priority' => ['sometimes', 'string', 'in:'.implode(',', RefundPriorityEnum::values())],
|
||||
|
||||
// 환불 계좌 — 표시/필수 조건은 결제수단·입금상태에 따라 다르므로 withValidator 가 판정한다.
|
||||
'refund_bank.bank_code' => ['nullable', 'string', 'max:10'],
|
||||
'refund_bank.account_number' => ['nullable', 'string', 'max:50'],
|
||||
'refund_bank.holder' => ['nullable', 'string', 'max:50'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 환불 계좌 정보를 반환합니다. (미입력이면 null)
|
||||
*
|
||||
* 주문 시 입력된 계좌가 있으면 관리자가 수정할 수 있으며, 여기서 반환된 값이 결제행을 갱신합니다.
|
||||
*
|
||||
* @return array{bank_code: string, account_number: string, holder: string}|null 환불 계좌 (미입력 시 null)
|
||||
*/
|
||||
public function getRefundBankInfo(): ?array
|
||||
{
|
||||
$bankCode = $this->input('refund_bank.bank_code');
|
||||
|
||||
if (blank($bankCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'bank_code' => (string) $bankCode,
|
||||
'account_number' => (string) $this->input('refund_bank.account_number'),
|
||||
'holder' => (string) $this->input('refund_bank.holder'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 필드의 사용자 표시명
|
||||
*
|
||||
* @return array
|
||||
* @return array 필드명 → 표시명 매핑
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
@@ -64,8 +94,7 @@ class CancelOrderRequest extends FormRequest
|
||||
/**
|
||||
* 추가 검증 로직
|
||||
*
|
||||
* @param Validator $validator
|
||||
* @return void
|
||||
* @param Validator $validator 검증기
|
||||
*/
|
||||
protected function withValidator(Validator $validator): void
|
||||
{
|
||||
@@ -99,6 +128,9 @@ class CancelOrderRequest extends FormRequest
|
||||
return;
|
||||
}
|
||||
|
||||
// 환불 계좌 조건부 검증 (결제수단·입금상태 기준)
|
||||
$this->validateRefundBank($validator, $order);
|
||||
|
||||
// 부분취소 시 items 검증
|
||||
if ($this->input('type') === 'partial') {
|
||||
$this->validateCancelItems($validator, $order);
|
||||
@@ -106,12 +138,92 @@ class CancelOrderRequest extends FormRequest
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 환불 계좌를 조건부로 검증합니다.
|
||||
*
|
||||
* 가상계좌 입금완료 건은 PG 환불 API 가 환불받을 계좌를 필수로 요구하므로 미입력을 거부합니다.
|
||||
* 입금 전 가상계좌는 PG 가 계좌를 요구하지 않고, 무통장은 관리자가 수동 이체하므로 선택 입력입니다.
|
||||
* 카드·간편결제는 원 결제수단으로 환불되므로 계좌 자체가 불필요합니다.
|
||||
*
|
||||
* 부분 입력(3필드 중 일부만)은 결제수단과 무관하게 거부합니다 — 그 상태로는 환불이 불가능합니다.
|
||||
*
|
||||
* @param Validator $validator 검증기
|
||||
* @param Order $order 취소 대상 주문
|
||||
*/
|
||||
protected function validateRefundBank(Validator $validator, Order $order): void
|
||||
{
|
||||
$fields = ['bank_code', 'account_number', 'holder'];
|
||||
|
||||
$filled = array_filter(
|
||||
$fields,
|
||||
fn (string $field): bool => filled($this->input("refund_bank.{$field}"))
|
||||
);
|
||||
|
||||
// 부분 입력 거부 (전부 비었거나 전부 채워졌거나)
|
||||
if ($filled !== [] && count($filled) !== count($fields)) {
|
||||
foreach (array_diff($fields, $filled) as $missing) {
|
||||
$validator->errors()->add(
|
||||
"refund_bank.{$missing}",
|
||||
__('sirsoft-ecommerce::validation.order.refund_bank_required_with')
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($filled !== []) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 미입력 — 가상계좌 입금완료 건만 필수. 주문 시 입력된 계좌가 있으면 그것을 쓰므로 통과.
|
||||
$order->loadMissing('payment');
|
||||
$payment = $order->payment;
|
||||
|
||||
if (! $payment || ! $this->requiresRefundBank($payment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filled($payment->refund_bank_code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$validator->errors()->add(
|
||||
"refund_bank.{$field}",
|
||||
__('sirsoft-ecommerce::validation.order.refund_bank_required_for_vbank')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 환불 계좌가 필수인 결제인지 판정합니다.
|
||||
*
|
||||
* @param OrderPayment $payment 결제 정보
|
||||
* @return bool 필수 여부
|
||||
*/
|
||||
protected function requiresRefundBank(OrderPayment $payment): bool
|
||||
{
|
||||
$method = $payment->payment_method instanceof PaymentMethodEnum
|
||||
? $payment->payment_method
|
||||
: PaymentMethodEnum::tryFrom((string) $payment->payment_method);
|
||||
|
||||
if ($method !== PaymentMethodEnum::VBANK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = $payment->payment_status instanceof PaymentStatusEnum
|
||||
? $payment->payment_status
|
||||
: PaymentStatusEnum::tryFrom((string) $payment->payment_status);
|
||||
|
||||
// 입금 전(waiting_deposit)이면 PG 가 환불받을 계좌를 요구하지 않는다.
|
||||
return $status !== null && ! $status->isAwaitingDeposit();
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 아이템 목록을 검증합니다.
|
||||
*
|
||||
* @param Validator $validator
|
||||
* @param Order $order
|
||||
* @return void
|
||||
* @param Validator $validator 검증기
|
||||
* @param Order $order 취소 대상 주문
|
||||
*/
|
||||
protected function validateCancelItems(Validator $validator, Order $order): void
|
||||
{
|
||||
@@ -155,8 +267,6 @@ class CancelOrderRequest extends FormRequest
|
||||
/**
|
||||
* 검증 실패 시 응답 커스터마이징
|
||||
*
|
||||
* @param Validator $validator
|
||||
* @return void
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
@@ -172,7 +282,7 @@ class CancelOrderRequest extends FormRequest
|
||||
/**
|
||||
* 전체취소 여부를 반환합니다.
|
||||
*
|
||||
* @return bool
|
||||
* @return bool 전체취소면 true
|
||||
*/
|
||||
public function isFullCancel(): bool
|
||||
{
|
||||
@@ -182,7 +292,7 @@ class CancelOrderRequest extends FormRequest
|
||||
/**
|
||||
* 취소 사유 코드를 반환합니다.
|
||||
*
|
||||
* @return string|null
|
||||
* @return string|null 취소 사유 코드
|
||||
*/
|
||||
public function getReason(): ?string
|
||||
{
|
||||
@@ -192,7 +302,7 @@ class CancelOrderRequest extends FormRequest
|
||||
/**
|
||||
* 상세 취소 사유를 반환합니다.
|
||||
*
|
||||
* @return string|null
|
||||
* @return string|null 상세 취소 사유 (미입력 시 null)
|
||||
*/
|
||||
public function getReasonDetail(): ?string
|
||||
{
|
||||
@@ -212,7 +322,7 @@ class CancelOrderRequest extends FormRequest
|
||||
/**
|
||||
* PG 결제 취소 여부를 반환합니다.
|
||||
*
|
||||
* @return bool
|
||||
* @return bool PG 결제도 함께 취소하면 true
|
||||
*/
|
||||
public function shouldCancelPg(): bool
|
||||
{
|
||||
@@ -222,7 +332,7 @@ class CancelOrderRequest extends FormRequest
|
||||
/**
|
||||
* 환불 우선순위를 반환합니다.
|
||||
*
|
||||
* @return RefundPriorityEnum
|
||||
* @return RefundPriorityEnum 환불 우선순위
|
||||
*/
|
||||
public function getRefundPriority(): RefundPriorityEnum
|
||||
{
|
||||
|
||||
+7
@@ -5,6 +5,8 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin;
|
||||
use App\Rules\LocaleRequiredTranslatable;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ShippingFeeTaxPolicy;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
@@ -245,6 +247,11 @@ class StoreEcommerceSettingsRequest extends FormRequest
|
||||
// order_settings 섹션
|
||||
'order_settings' => ['sometimes', 'array'],
|
||||
'order_settings.default_pg_provider' => ['nullable', 'string', 'max:50'],
|
||||
// 현금영수증 — 발급 프로바이더는 결제 PG 와 독립 선택한다 (빈 문자열 = 미사용).
|
||||
// 규칙을 명시하지 않으면 validated() 가 키를 떨궈 저장이 조용히 무효화된다.
|
||||
'order_settings.cash_receipt_provider' => ['nullable', 'string', 'max:50'],
|
||||
'order_settings.cash_receipt_self_issue' => ['nullable', 'boolean'],
|
||||
'order_settings.shipping_fee_tax_policy' => ['nullable', 'string', 'in:'.implode(',', ShippingFeeTaxPolicy::values())],
|
||||
'order_settings.payment_methods' => ['nullable', 'array'],
|
||||
'order_settings.payment_methods.*.id' => ['required_with:order_settings.payment_methods', 'string', 'max:50'],
|
||||
'order_settings.payment_methods.*.pg_provider' => ['nullable', 'string', 'max:50'],
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Requests\Public;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Rules\CashReceiptIdentifier;
|
||||
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
|
||||
|
||||
/**
|
||||
@@ -81,6 +85,33 @@ class CreateOrderRequest extends FormRequest
|
||||
|
||||
// 배송지 저장
|
||||
'save_shipping_address' => 'nullable|boolean',
|
||||
|
||||
// 현금영수증 신청 (무통장입금 전용 — 신청 시에만 하위 3키 필수)
|
||||
'cash_receipt_requested' => 'nullable|boolean',
|
||||
'cash_receipt_type' => [
|
||||
'required_if:cash_receipt_requested,true',
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::in(CashReceiptType::values()),
|
||||
],
|
||||
'cash_receipt_identifier_type' => [
|
||||
'required_if:cash_receipt_requested,true',
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::in(CashReceiptIdentifierType::values()),
|
||||
],
|
||||
'cash_receipt_identifier' => [
|
||||
'required_if:cash_receipt_requested,true',
|
||||
'nullable',
|
||||
'string',
|
||||
'max:30',
|
||||
new CashReceiptIdentifier($this->resolveCashReceiptType(), $this->resolveCashReceiptIdentifierType()),
|
||||
],
|
||||
|
||||
// 환불 계좌 (선택) — 부분 입력 금지는 withValidator 의 required_with 로 처리
|
||||
'refund_bank.bank_code' => 'nullable|string|max:10',
|
||||
'refund_bank.account_number' => 'nullable|string|max:50',
|
||||
'refund_bank.holder' => 'nullable|string|max:50',
|
||||
];
|
||||
|
||||
// 주문자 이메일은 회원/비회원 분기 (비회원은 알림 수신 통로가 이메일뿐 → 필수)
|
||||
@@ -92,6 +123,39 @@ class CreateOrderRequest extends FormRequest
|
||||
return HookManager::applyFilters('sirsoft-ecommerce.order.create_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증기 확장 — 환불 계좌의 부분 입력을 거부합니다.
|
||||
*
|
||||
* 3필드는 모두 선택 입력이지만 일부만 채우면 환불 자체가 불가능합니다.
|
||||
* 계좌번호만 있고 예금주가 없으면 PG 환불 API 가 거부하고, 무통장은 관리자가 이체할 수 없습니다.
|
||||
* 따라서 "전부 비었거나 전부 채워졌거나" 둘 중 하나만 허용합니다.
|
||||
*
|
||||
* @param Validator $validator 검증기
|
||||
*/
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$fields = ['bank_code', 'account_number', 'holder'];
|
||||
|
||||
$filled = array_filter(
|
||||
$fields,
|
||||
fn (string $field): bool => filled($this->input("refund_bank.{$field}"))
|
||||
);
|
||||
|
||||
// 전부 비었으면 미입력 (허용), 전부 채워졌으면 완전 입력 (허용)
|
||||
if ($filled === [] || count($filled) === count($fields)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (array_diff($fields, $filled) as $missing) {
|
||||
$validator->errors()->add(
|
||||
"refund_bank.{$missing}",
|
||||
__('sirsoft-ecommerce::validation.order.refund_bank_required_with')
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문자 이메일 검증 규칙을 반환합니다.
|
||||
*
|
||||
@@ -180,6 +244,13 @@ class CreateOrderRequest extends FormRequest
|
||||
'dbank.account_number.required_if' => __('sirsoft-ecommerce::validation.order.dbank_account_number_required'),
|
||||
'dbank.account_holder.required_if' => __('sirsoft-ecommerce::validation.order.dbank_account_holder_required'),
|
||||
|
||||
// 현금영수증 신청
|
||||
'cash_receipt_type.required_if' => __('sirsoft-ecommerce::validation.order.cash_receipt_type_required'),
|
||||
'cash_receipt_type.in' => __('sirsoft-ecommerce::validation.order.cash_receipt_type_invalid'),
|
||||
'cash_receipt_identifier_type.required_if' => __('sirsoft-ecommerce::validation.order.cash_receipt_identifier_type_required'),
|
||||
'cash_receipt_identifier_type.in' => __('sirsoft-ecommerce::validation.order.cash_receipt_identifier_type_invalid'),
|
||||
'cash_receipt_identifier.required_if' => __('sirsoft-ecommerce::validation.order.cash_receipt_identifier_required'),
|
||||
|
||||
// 비회원 조회 비밀번호
|
||||
'guest_lookup_password.required' => __('sirsoft-ecommerce::validation.order.guest_lookup_password_required'),
|
||||
'guest_lookup_password.min' => __('sirsoft-ecommerce::validation.order.guest_lookup_password_min'),
|
||||
@@ -246,4 +317,87 @@ class CreateOrderRequest extends FormRequest
|
||||
|
||||
return is_string($password) && $password !== '' ? $password : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현금영수증 신청 정보를 반환합니다. (미신청이거나 무통장이 아니면 null)
|
||||
*
|
||||
* 식별번호는 하이픈·공백이 제거된 원본입니다. 저장 시점에 마스킹본과 암호문으로 분리되며,
|
||||
* 원본은 응답·로그에 노출하지 않습니다.
|
||||
*
|
||||
* 발급 대상은 무통장(dbank)뿐이므로(CashReceiptService 가 재차 차단) 그 외 결제수단에서는
|
||||
* 신청 정보를 반환하지 않는다. 발급될 수 없는 주문에 식별번호 암호문을 남기지 않기 위함이다.
|
||||
*
|
||||
* @return array{type: CashReceiptType, identifier_type: CashReceiptIdentifierType, identifier: string}|null 신청 정보 (미신청·비무통장 시 null)
|
||||
*/
|
||||
public function getCashReceiptInfo(): ?array
|
||||
{
|
||||
if ($this->input('payment_method') !== PaymentMethodEnum::DBANK->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->boolean('cash_receipt_requested')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = $this->resolveCashReceiptType();
|
||||
$identifierType = $this->resolveCashReceiptIdentifierType();
|
||||
$identifier = $this->input('cash_receipt_identifier');
|
||||
|
||||
// required_if 를 통과했다면 셋 다 존재한다. 방어적으로 한 번 더 확인한다.
|
||||
if ($type === null || $identifierType === null || ! is_string($identifier)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => $type,
|
||||
'identifier_type' => $identifierType,
|
||||
'identifier' => CashReceiptIdentifier::normalize($identifier),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 환불 계좌 정보를 반환합니다. (미입력이면 null)
|
||||
*
|
||||
* withValidator 가 부분 입력을 거부하므로, 값이 있으면 3필드가 모두 채워져 있습니다.
|
||||
*
|
||||
* @return array{bank_code: string, account_number: string, holder: string}|null 환불 계좌 (미입력 시 null)
|
||||
*/
|
||||
public function getRefundBankInfo(): ?array
|
||||
{
|
||||
$bankCode = $this->input('refund_bank.bank_code');
|
||||
|
||||
if (blank($bankCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'bank_code' => (string) $bankCode,
|
||||
'account_number' => (string) $this->input('refund_bank.account_number'),
|
||||
'holder' => (string) $this->input('refund_bank.holder'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 전 입력에서 현금영수증 발급 용도를 해석합니다. (Rule 생성자 주입용)
|
||||
*
|
||||
* @return CashReceiptType|null 해석된 용도 (미해석 시 null)
|
||||
*/
|
||||
private function resolveCashReceiptType(): ?CashReceiptType
|
||||
{
|
||||
$value = $this->input('cash_receipt_type');
|
||||
|
||||
return is_string($value) ? CashReceiptType::tryFrom($value) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 전 입력에서 현금영수증 식별번호 종류를 해석합니다. (Rule 생성자 주입용)
|
||||
*
|
||||
* @return CashReceiptIdentifierType|null 해석된 종류 (미해석 시 null)
|
||||
*/
|
||||
private function resolveCashReceiptIdentifierType(): ?CashReceiptIdentifierType
|
||||
{
|
||||
$value = $this->input('cash_receipt_identifier_type');
|
||||
|
||||
return is_string($value) ? CashReceiptIdentifierType::tryFrom($value) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ class CashReceiptResource extends BaseApiResource
|
||||
'id' => $this->id,
|
||||
'provider' => $this->provider,
|
||||
'transaction_type' => $this->transaction_type,
|
||||
// 관리자 발급 이력 표는 원시값(issue/cancel, FAILED)이 아니라 표시명을 노출한다.
|
||||
'transaction_type_label' => $this->transaction_type?->label(),
|
||||
'receipt_type' => $this->receipt_type,
|
||||
'receipt_type_label' => $this->receipt_type?->label(),
|
||||
'amount' => $this->roundToOrderCurrency($this->amount),
|
||||
@@ -38,11 +40,19 @@ class CashReceiptResource extends BaseApiResource
|
||||
'receipt_url' => $this->receipt_url,
|
||||
'issue_number' => $this->issue_number,
|
||||
'issue_status' => $this->issue_status,
|
||||
'issue_status_label' => $this->issue_status?->label(),
|
||||
// 이력 표의 결과 열 — 발급/취소 행이 같은 열을 공유하므로 중립 표현을 쓴다.
|
||||
// issue_status_label 은 발급 관점 표현("발급 완료")이라 취소 행에 쓰면 오해를 부른다.
|
||||
'result_label' => $this->issue_status
|
||||
? __('sirsoft-ecommerce::cash_receipt.result_status.'.strtolower($this->issue_status->value))
|
||||
: null,
|
||||
'error_code' => $this->error_code,
|
||||
'error_message' => $this->error_message,
|
||||
// 머신 ISO8601 — 표시는 issued_at_formatted 사용
|
||||
'issued_at' => $this->issued_at?->toIso8601String(), // audit:allow datetime-display-user-timezone reason: machine ISO8601, display uses issued_at_formatted sibling
|
||||
'issued_at_formatted' => $this->formatDateTimeStringForUser($this->issued_at),
|
||||
// 이력 표의 시각 열 — 발급 실패 이력은 issued_at 이 null 이므로 기록 시각으로 대체한다.
|
||||
'occurred_at_formatted' => $this->formatDateTimeStringForUser($this->issued_at ?? $this->created_at),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Resources\BaseApiResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
|
||||
/**
|
||||
* 주문 결제 정보 리소스
|
||||
@@ -80,12 +81,25 @@ class OrderPaymentResource extends BaseApiResource
|
||||
'deposit_due_at' => $this->deposit_due_at?->toIso8601String(), // audit:allow datetime-display-user-timezone reason: machine ISO8601, display uses deposit_due_at_formatted sibling
|
||||
'deposit_due_at_formatted' => $this->formatDateTimeStringForUser($this->deposit_due_at),
|
||||
|
||||
// 환불 계좌 — 주문 시 입력된 계좌. 관리자 취소 모달이 이 값을 프리필하고,
|
||||
// PG 환불(refundReceiveAccount 구성)이 같은 컬럼을 읽는다.
|
||||
'refund_bank_code' => $this->refund_bank_code,
|
||||
'refund_bank_name' => $this->refund_bank_name,
|
||||
'refund_bank_account' => $this->refund_bank_account,
|
||||
'refund_bank_holder' => $this->refund_bank_holder,
|
||||
|
||||
// 현금영수증 (cash_receipt_identifier 는 마스킹 값 — 원본/암호문은 노출하지 않는다)
|
||||
'is_cash_receipt_requested' => (bool) $this->is_cash_receipt_requested,
|
||||
'is_cash_receipt_issued' => (bool) $this->is_cash_receipt_issued,
|
||||
'cash_receipt_type' => $this->cash_receipt_type,
|
||||
// 레거시 값(income_deduction 등)도 표시명으로 해석되도록 Enum 의 fromLegacy 를 경유한다.
|
||||
'cash_receipt_type_label' => $this->getCashReceiptType()?->label(),
|
||||
'cash_receipt_identifier_type' => $this->cash_receipt_identifier_type,
|
||||
'cash_receipt_identifier' => $this->cash_receipt_identifier,
|
||||
// 현재 설정된 발급 프로바이더 (미설정이면 null).
|
||||
// 주문상세의 현금영수증 카드가 이 리소스만 보고 렌더 여부를 판정할 수 있도록 함께 내린다
|
||||
// (프로바이더는 상점 전역 설정이지만 카드는 결제 정보 안에 있다).
|
||||
'cash_receipt_provider' => app(EcommerceSettingsService::class)->getCashReceiptProvider(),
|
||||
// 머신 ISO8601 — 표시는 cash_receipt_issued_at_formatted 사용
|
||||
'cash_receipt_issued_at' => $this->cash_receipt_issued_at?->toIso8601String(), // audit:allow datetime-display-user-timezone reason: machine ISO8601, display uses cash_receipt_issued_at_formatted sibling
|
||||
'cash_receipt_issued_at_formatted' => $this->formatDateTimeStringForUser($this->cash_receipt_issued_at),
|
||||
|
||||
+21
@@ -69,4 +69,25 @@ interface OrderPaymentRepositoryInterface
|
||||
* @return bool 암호문 존재 여부
|
||||
*/
|
||||
public function hasCashReceiptIdentifier(OrderPayment $payment): bool;
|
||||
|
||||
/**
|
||||
* 환불 계좌 정보를 갱신합니다.
|
||||
*
|
||||
* 주문 취소 시점에 관리자가 입력·수정한 계좌를 반영한다. PG 환불(executePgRefund)이
|
||||
* 이 컬럼을 읽어 refundReceiveAccount 를 구성하므로 환불 실행 전에 갱신되어야 한다.
|
||||
*
|
||||
* @param OrderPayment $payment 결제 모델
|
||||
* @param string $bankCode 은행코드
|
||||
* @param string $bankName 은행명 (표시용)
|
||||
* @param string $accountNumber 계좌번호
|
||||
* @param string $holder 예금주
|
||||
* @return OrderPayment 갱신된 결제 모델
|
||||
*/
|
||||
public function updateRefundBank(
|
||||
OrderPayment $payment,
|
||||
string $bankCode,
|
||||
string $bankName,
|
||||
string $accountNumber,
|
||||
string $holder,
|
||||
): OrderPayment;
|
||||
}
|
||||
|
||||
@@ -89,4 +89,24 @@ class OrderPaymentRepository implements OrderPaymentRepositoryInterface
|
||||
{
|
||||
return $payment->getRawOriginal('cash_receipt_identifier_encrypted') !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function updateRefundBank(
|
||||
OrderPayment $payment,
|
||||
string $bankCode,
|
||||
string $bankName,
|
||||
string $accountNumber,
|
||||
string $holder,
|
||||
): OrderPayment {
|
||||
$payment->update([
|
||||
'refund_bank_code' => $bankCode,
|
||||
'refund_bank_name' => $bankName,
|
||||
'refund_bank_account' => $accountNumber,
|
||||
'refund_bank_holder' => $holder,
|
||||
]);
|
||||
|
||||
return $payment;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,9 @@ class OrderRepository implements OrderRepositoryInterface
|
||||
'shippings',
|
||||
// 취소 이력 — 주문상세 화면의 취소 사유/일시 표시용 (최근 취소 먼저)
|
||||
'cancels' => fn ($q) => $q->latest('cancelled_at'),
|
||||
// 현금영수증 이력 — 주문상세의 발급 카드가 활성 영수증 1건과 전체 이력을 함께 표시한다.
|
||||
// 미로드 시 OrderResource 의 whenLoaded 가드로 응답에 키 자체가 나타나지 않는다.
|
||||
'cashReceipts',
|
||||
])
|
||||
->find($id);
|
||||
}
|
||||
@@ -182,6 +185,8 @@ class OrderRepository implements OrderRepositoryInterface
|
||||
'options',
|
||||
'shippingAddress',
|
||||
'payment',
|
||||
// 비회원 주문상세도 현금영수증 카드를 렌더하므로 함께 로드한다.
|
||||
'cashReceipts',
|
||||
])
|
||||
->where('order_number', $orderNumber)
|
||||
->first();
|
||||
|
||||
@@ -622,18 +622,15 @@ class CashReceiptService
|
||||
/**
|
||||
* 식별번호를 마스킹합니다 (뒤 4자리만 노출).
|
||||
*
|
||||
* 마스킹 규칙의 SSoT 는 Enum 이다 — 주문 생성 시점(신청 저장)과 발급 시점(이력 기록)이
|
||||
* 동일한 마스킹을 써야 하므로 규칙을 복제하지 않고 위임한다.
|
||||
*
|
||||
* @param string $identifier 식별번호 원본
|
||||
* @return string 마스킹된 식별번호
|
||||
*/
|
||||
private function maskIdentifier(string $identifier): string
|
||||
{
|
||||
$length = strlen($identifier);
|
||||
|
||||
if ($length <= 4) {
|
||||
return str_repeat('*', $length);
|
||||
}
|
||||
|
||||
return str_repeat('*', $length - 4).substr($identifier, -4);
|
||||
return CashReceiptIdentifierType::mask($identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -741,6 +741,27 @@ class EcommerceSettingsService implements ModuleSettingsInterface
|
||||
return ShippingFeeTaxPolicy::fromValueOrDefault(is_string($value) ? $value : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 은행코드로 은행명을 조회합니다.
|
||||
*
|
||||
* 환경설정의 은행 목록(order_settings.banks)에서 현재 로케일의 표시명을 찾습니다.
|
||||
* 무통장 입금 계좌와 환불 계좌가 같은 목록을 공유하므로 여기서 단일 조회 지점을 제공합니다.
|
||||
*
|
||||
* @param string $bankCode 은행코드
|
||||
* @return string 은행명 (목록에 없는 코드는 코드 그대로)
|
||||
*/
|
||||
public function resolveBankName(string $bankCode): string
|
||||
{
|
||||
$banks = $this->getSetting('order_settings.banks');
|
||||
$bank = collect(is_array($banks) ? $banks : [])->firstWhere('code', $bankCode);
|
||||
|
||||
if (! $bank) {
|
||||
return $bankCode;
|
||||
}
|
||||
|
||||
return $bank['name'][app()->getLocale()] ?? $bank['name']['ko'] ?? $bankCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 결제수단의 설정을 조회합니다.
|
||||
*
|
||||
|
||||
@@ -29,6 +29,7 @@ use Modules\Sirsoft\Ecommerce\Models\OrderRefundOption;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderCancelOptionRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderCancelRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderPaymentRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundOptionRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface;
|
||||
@@ -55,6 +56,7 @@ class OrderCancellationService
|
||||
* @param OrderRefundRepositoryInterface $orderRefundRepository 주문 환불 Repository
|
||||
* @param OrderRefundOptionRepositoryInterface $orderRefundOptionRepository 주문 환불 옵션 Repository
|
||||
* @param CashReceiptService $cashReceiptService 현금영수증 발급/취소 서비스
|
||||
* @param OrderPaymentRepositoryInterface $orderPaymentRepository 주문 결제 Repository
|
||||
*/
|
||||
public function __construct(
|
||||
protected OrderAdjustmentService $adjustmentService,
|
||||
@@ -69,8 +71,39 @@ class OrderCancellationService
|
||||
protected OrderRefundRepositoryInterface $orderRefundRepository,
|
||||
protected OrderRefundOptionRepositoryInterface $orderRefundOptionRepository,
|
||||
protected CashReceiptService $cashReceiptService,
|
||||
protected OrderPaymentRepositoryInterface $orderPaymentRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 관리자가 취소 모달에서 입력한 환불 계좌를 결제행에 반영합니다.
|
||||
*
|
||||
* 미입력이면 기존 값(주문 시 입력분)을 그대로 둔다 — 취소 요청이 계좌를 지우지 않는다.
|
||||
* 가상계좌 입금완료 건의 미입력은 CancelOrderRequest 가 이미 422 로 막는다.
|
||||
*
|
||||
* @param Order $order 대상 주문
|
||||
* @param array|null $refundBankInfo 환불 계좌 (bank_code/account_number/holder)
|
||||
*/
|
||||
protected function applyRefundBank(Order $order, ?array $refundBankInfo): void
|
||||
{
|
||||
if ($refundBankInfo === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order->loadMissing('payment');
|
||||
|
||||
if (! $order->payment) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->orderPaymentRepository->updateRefundBank(
|
||||
$order->payment,
|
||||
$refundBankInfo['bank_code'],
|
||||
$this->settingsService->resolveBankName($refundBankInfo['bank_code']),
|
||||
$refundBankInfo['account_number'],
|
||||
$refundBankInfo['holder'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 확정 후 현금영수증을 현재 금액에 맞춰 동기화합니다.
|
||||
*
|
||||
@@ -111,6 +144,7 @@ class OrderCancellationService
|
||||
* @param int|null $cancelledBy 취소 요청자 ID
|
||||
* @param bool $cancelPg PG 결제 취소 여부
|
||||
* @param RefundPriorityEnum $refundPriority 환불 우선순위
|
||||
* @param array|null $refundBankInfo 환불 계좌 (bank_code/account_number/holder, 미입력 시 null)
|
||||
* @return CancellationResult 취소 결과
|
||||
*
|
||||
* @throws \Exception 취소 불가 또는 PG 환불 실패 시
|
||||
@@ -122,6 +156,7 @@ class OrderCancellationService
|
||||
?int $cancelledBy = null,
|
||||
bool $cancelPg = true,
|
||||
RefundPriorityEnum $refundPriority = RefundPriorityEnum::PG_FIRST,
|
||||
?array $refundBankInfo = null,
|
||||
): CancellationResult {
|
||||
$order->loadMissing(['options', 'payment', 'shippings']);
|
||||
|
||||
@@ -146,6 +181,7 @@ class OrderCancellationService
|
||||
cancelledBy: $cancelledBy,
|
||||
cancelPg: $cancelPg,
|
||||
refundPriority: $refundPriority,
|
||||
refundBankInfo: $refundBankInfo,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,6 +195,7 @@ class OrderCancellationService
|
||||
* @param int|null $cancelledBy 취소 요청자 ID
|
||||
* @param bool $cancelPg PG 결제 취소 여부
|
||||
* @param RefundPriorityEnum $refundPriority 환불 우선순위
|
||||
* @param array|null $refundBankInfo 환불 계좌 (bank_code/account_number/holder, 미입력 시 null)
|
||||
* @return CancellationResult 취소 결과
|
||||
*
|
||||
* @throws \Exception 취소 불가 또는 PG 환불 실패 시
|
||||
@@ -171,6 +208,7 @@ class OrderCancellationService
|
||||
?int $cancelledBy = null,
|
||||
bool $cancelPg = true,
|
||||
RefundPriorityEnum $refundPriority = RefundPriorityEnum::PG_FIRST,
|
||||
?array $refundBankInfo = null,
|
||||
): CancellationResult {
|
||||
$order->loadMissing(['options', 'payment', 'shippings']);
|
||||
|
||||
@@ -188,6 +226,7 @@ class OrderCancellationService
|
||||
cancelledBy: $cancelledBy,
|
||||
cancelPg: $cancelPg,
|
||||
refundPriority: $refundPriority,
|
||||
refundBankInfo: $refundBankInfo,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -220,6 +259,7 @@ class OrderCancellationService
|
||||
* @param int|null $cancelledBy 요청자 ID
|
||||
* @param bool $cancelPg PG 취소 여부
|
||||
* @param RefundPriorityEnum $refundPriority 환불 우선순위
|
||||
* @param array|null $refundBankInfo 환불 계좌 (bank_code/account_number/holder, 미입력 시 null)
|
||||
* @return CancellationResult 취소 결과
|
||||
*
|
||||
* @throws \Exception
|
||||
@@ -233,11 +273,16 @@ class OrderCancellationService
|
||||
?int $cancelledBy,
|
||||
bool $cancelPg,
|
||||
RefundPriorityEnum $refundPriority = RefundPriorityEnum::PG_FIRST,
|
||||
?array $refundBankInfo = null,
|
||||
): CancellationResult {
|
||||
// ① 검증
|
||||
$this->validateCancellable($order);
|
||||
$this->validateCancelItems($order, $cancelItems);
|
||||
|
||||
// 환불 계좌 갱신 — PG 환불(executePgRefund)이 이 컬럼을 읽어 refundReceiveAccount 를
|
||||
// 구성하므로 환불 실행 전에 반영해야 한다. 관리자가 취소 모달에서 입력/수정한 값이다.
|
||||
$this->applyRefundBank($order, $refundBankInfo);
|
||||
|
||||
// 결제 취소 전 본인인증(IDV) 정책 가드 지점 (관리자/사용자 — payment.cancel purpose).
|
||||
// EnforceIdentityPolicyListener 가 'sirsoft-ecommerce.payment.cancel' 정책이 활성이고
|
||||
// grace 만료 시 IdentityVerificationRequiredException(428) 을 throw 한다.
|
||||
|
||||
@@ -12,6 +12,7 @@ use Modules\Sirsoft\Ecommerce\DTO\CalculationInput;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\CalculationItem;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\ShippingAddress;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\DeliveryMemoPresetEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\DeviceTypeEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
@@ -86,6 +87,8 @@ class OrderProcessingService
|
||||
* @param string|null $depositorName 입금자명 (무통장입금 시)
|
||||
* @param array|null $dbankInfo 무통장 수동입금 정보 (dbank 결제 시)
|
||||
* @param string|null $guestLookupPassword 비회원 조회 비밀번호 평문 (해시로 저장, 회원 주문은 null)
|
||||
* @param array|null $cashReceiptInfo 현금영수증 신청 정보 (type/identifier_type/identifier, 미신청 시 null)
|
||||
* @param array|null $refundBankInfo 환불 계좌 정보 (bank_code/account_number/holder, 미입력 시 null)
|
||||
* @return Order 생성된 주문
|
||||
*
|
||||
* @throws OrderAmountChangedException 재계산 금액 변동 시
|
||||
@@ -101,7 +104,9 @@ class OrderProcessingService
|
||||
?string $shippingMemo = null,
|
||||
?string $depositorName = null,
|
||||
?array $dbankInfo = null,
|
||||
?string $guestLookupPassword = null
|
||||
?string $guestLookupPassword = null,
|
||||
?array $cashReceiptInfo = null,
|
||||
?array $refundBankInfo = null
|
||||
): Order {
|
||||
// 생성 전 훅
|
||||
HookManager::doAction('sirsoft-ecommerce.order.before_create', $tempOrder, $ordererInfo, $shippingInfo, $paymentMethod);
|
||||
@@ -169,7 +174,9 @@ class OrderProcessingService
|
||||
$initialStatus,
|
||||
$currencySnapshot,
|
||||
$guestLookupPassword,
|
||||
$isZeroPayable
|
||||
$isZeroPayable,
|
||||
$cashReceiptInfo,
|
||||
$refundBankInfo
|
||||
) {
|
||||
// 주문 생성
|
||||
$order = $this->createOrder($tempOrder, $calculationResult, $initialStatus, $currencySnapshot, $guestLookupPassword, $shippingInfo, $paymentMethod);
|
||||
@@ -181,7 +188,7 @@ class OrderProcessingService
|
||||
$this->createOrderAddresses($order, $ordererInfo, $shippingInfo, $shippingMemo);
|
||||
|
||||
// 결제 정보 생성
|
||||
$this->createOrderPayment($order, $paymentMethod, $depositorName, $dbankInfo, $calculationResult, $currencySnapshot, $ordererInfo);
|
||||
$this->createOrderPayment($order, $paymentMethod, $depositorName, $dbankInfo, $calculationResult, $currencySnapshot, $ordererInfo, $cashReceiptInfo, $refundBankInfo);
|
||||
|
||||
// 배송 정보 생성 (주문 옵션과 연결)
|
||||
$this->createOrderShippings($order, $tempOrder, $calculationResult, $currencySnapshot, $createdOptions);
|
||||
@@ -925,6 +932,8 @@ class OrderProcessingService
|
||||
* @param OrderCalculationResult $calculationResult 계산 결과
|
||||
* @param array $currencySnapshot 통화 스냅샷
|
||||
* @param array $ordererInfo 주문자 정보 (name/email/phone — 결제 구매자 정보로 기록)
|
||||
* @param array|null $cashReceiptInfo 현금영수증 신청 정보 (type/identifier_type/identifier, 미신청 시 null)
|
||||
* @param array|null $refundBankInfo 환불 계좌 정보 (bank_code/account_number/holder, 미입력 시 null)
|
||||
*/
|
||||
protected function createOrderPayment(
|
||||
Order $order,
|
||||
@@ -933,7 +942,9 @@ class OrderProcessingService
|
||||
?array $dbankInfo,
|
||||
OrderCalculationResult $calculationResult,
|
||||
array $currencySnapshot,
|
||||
array $ordererInfo = []
|
||||
array $ordererInfo = [],
|
||||
?array $cashReceiptInfo = null,
|
||||
?array $refundBankInfo = null
|
||||
): void {
|
||||
$paymentAmount = $calculationResult->summary->paymentAmount ?? $calculationResult->summary->finalAmount ?? 0;
|
||||
// 결제액 0원(전액 비현금 충당: 마일리지/예치금 등) → 결제 레코드도 즉시 PAID, PG/현금 결제액 0.
|
||||
@@ -994,10 +1005,7 @@ class OrderProcessingService
|
||||
// bank_name이 없으면 설정에서 은행코드 기반으로 조회
|
||||
$bankName = $dbankInfo['bank_name'] ?? null;
|
||||
if (! $bankName && $bankCode) {
|
||||
$orderSettings = module_setting('sirsoft-ecommerce', 'order_settings');
|
||||
$banks = collect($orderSettings['banks'] ?? []);
|
||||
$bank = $banks->firstWhere('code', $bankCode);
|
||||
$bankName = $bank ? ($bank['name'][app()->getLocale()] ?? $bank['name']['ko'] ?? $bankCode) : $bankCode;
|
||||
$bankName = $this->resolveBankName($bankCode);
|
||||
}
|
||||
|
||||
$paymentData['dbank_code'] = $bankCode;
|
||||
@@ -1011,6 +1019,28 @@ class OrderProcessingService
|
||||
$paymentData['deposit_due_at'] = Carbon::now()->addDays($this->resolveAutoCancelDays());
|
||||
}
|
||||
|
||||
// 현금영수증 신청 정보 — 발급은 입금완료 시점에 리스너가 수행하고, 여기서는 신청 내역만 보관한다.
|
||||
// 원본 식별번호는 암호화 컬럼에만 두고 평문 컬럼에는 마스킹본을 저장한다(응답·로그 노출 방지).
|
||||
// 구매확정 시점에 PurgeCashReceiptIdentifierListener 가 암호문을 폐기한다.
|
||||
if ($cashReceiptInfo !== null) {
|
||||
$identifier = $cashReceiptInfo['identifier'];
|
||||
|
||||
$paymentData['is_cash_receipt_requested'] = true;
|
||||
$paymentData['cash_receipt_type'] = $cashReceiptInfo['type']->value;
|
||||
$paymentData['cash_receipt_identifier_type'] = $cashReceiptInfo['identifier_type'];
|
||||
$paymentData['cash_receipt_identifier'] = CashReceiptIdentifierType::mask($identifier);
|
||||
$paymentData['cash_receipt_identifier_encrypted'] = $identifier;
|
||||
}
|
||||
|
||||
// 환불 계좌 — 무통장은 관리자 수동 이체 대상 계좌, 가상계좌는 PG 환불 API 의 refundReceiveAccount.
|
||||
// 미입력 시 관리자 취소 모달에서 다시 받는다.
|
||||
if ($refundBankInfo !== null) {
|
||||
$paymentData['refund_bank_code'] = $refundBankInfo['bank_code'];
|
||||
$paymentData['refund_bank_name'] = $this->resolveBankName($refundBankInfo['bank_code']);
|
||||
$paymentData['refund_bank_account'] = $refundBankInfo['account_number'];
|
||||
$paymentData['refund_bank_holder'] = $refundBankInfo['holder'];
|
||||
}
|
||||
|
||||
$order->payment()->create($paymentData);
|
||||
}
|
||||
|
||||
@@ -1050,6 +1080,20 @@ class OrderProcessingService
|
||||
return max($min, min($max, $days));
|
||||
}
|
||||
|
||||
/**
|
||||
* 은행코드로 은행명을 조회합니다.
|
||||
*
|
||||
* 조회 규칙의 SSoT 는 EcommerceSettingsService 다 — 무통장 입금 계좌와 환불 계좌가
|
||||
* 같은 은행 목록을 공유하므로 규칙을 복제하지 않고 위임한다.
|
||||
*
|
||||
* @param string $bankCode 은행코드
|
||||
* @return string 은행명 (미등록 코드는 코드 그대로)
|
||||
*/
|
||||
protected function resolveBankName(string $bankCode): string
|
||||
{
|
||||
return $this->settingsService->resolveBankName($bankCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제명(상품명 요약)을 생성합니다.
|
||||
*
|
||||
|
||||
@@ -35,6 +35,16 @@ return [
|
||||
'failed' => 'Failed',
|
||||
],
|
||||
|
||||
/*
|
||||
* Ledger result column — issue and cancel rows share this column,
|
||||
* so the wording must be neutral with respect to the transaction type.
|
||||
*/
|
||||
'result_status' => [
|
||||
'in_progress' => 'In Progress',
|
||||
'completed' => 'Completed',
|
||||
'failed' => 'Failed',
|
||||
],
|
||||
|
||||
/*
|
||||
* 배송비 과세 정책
|
||||
*/
|
||||
|
||||
@@ -912,6 +912,13 @@ return [
|
||||
'guest_lookup_password_min' => 'The order lookup password must be at least 8 characters.',
|
||||
'guest_lookup_password_confirmed' => 'The order lookup password confirmation does not match.',
|
||||
'guest_lookup_password_confirmation_required' => 'Please confirm the order lookup password.',
|
||||
'cash_receipt_type_required' => 'Please select the cash receipt purpose.',
|
||||
'cash_receipt_type_invalid' => 'The selected cash receipt purpose is invalid.',
|
||||
'cash_receipt_identifier_type_required' => 'Please select the cash receipt identifier type.',
|
||||
'cash_receipt_identifier_type_invalid' => 'The selected cash receipt identifier type is invalid.',
|
||||
'cash_receipt_identifier_required' => 'Please enter the number to use for the cash receipt.',
|
||||
'refund_bank_required_with' => 'The refund account requires the bank, account number, and account holder.',
|
||||
'refund_bank_required_for_vbank' => 'A refund account is required for a paid virtual account order.',
|
||||
],
|
||||
|
||||
// Guest order lookup verification validation messages
|
||||
|
||||
@@ -35,6 +35,16 @@ return [
|
||||
'failed' => '발급 실패',
|
||||
],
|
||||
|
||||
/*
|
||||
* 이력 표의 결과 열 — 발급 행과 취소 행이 같은 열을 공유하므로 거래유형과 무관한 중립 표현을 쓴다.
|
||||
* (취소 행에 "발급 완료" 라고 쓰면 관리자가 오해한다)
|
||||
*/
|
||||
'result_status' => [
|
||||
'in_progress' => '처리 중',
|
||||
'completed' => '완료',
|
||||
'failed' => '실패',
|
||||
],
|
||||
|
||||
/*
|
||||
* 배송비 과세 정책
|
||||
*/
|
||||
|
||||
@@ -912,6 +912,13 @@ return [
|
||||
'guest_lookup_password_min' => '주문 조회 비밀번호는 8자 이상이어야 합니다.',
|
||||
'guest_lookup_password_confirmed' => '주문 조회 비밀번호가 일치하지 않습니다.',
|
||||
'guest_lookup_password_confirmation_required' => '주문 조회 비밀번호 확인을 입력해주세요.',
|
||||
'cash_receipt_type_required' => '현금영수증 발급 용도를 선택해주세요.',
|
||||
'cash_receipt_type_invalid' => '현금영수증 발급 용도가 올바르지 않습니다.',
|
||||
'cash_receipt_identifier_type_required' => '현금영수증 발급수단을 선택해주세요.',
|
||||
'cash_receipt_identifier_type_invalid' => '현금영수증 발급수단이 올바르지 않습니다.',
|
||||
'cash_receipt_identifier_required' => '현금영수증 발급에 사용할 번호를 입력해주세요.',
|
||||
'refund_bank_required_with' => '환불 계좌는 은행·계좌번호·예금주를 모두 입력해야 합니다.',
|
||||
'refund_bank_required_for_vbank' => '입금이 완료된 가상계좌 주문은 환불 계좌가 필요합니다.',
|
||||
],
|
||||
|
||||
// 비회원 주문 조회 인증 검증 메시지
|
||||
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\SequenceType;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Sequence;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 관리자 주문 취소 시 환불계좌 조건부 검증 (#454 S3 / D13)
|
||||
*
|
||||
* 표시·필수 규칙:
|
||||
* vbank + 입금완료 → 필수 (PG 환불 API 의 refundReceiveAccount)
|
||||
* vbank + 입금전 → 선택 (미입금 취소는 PG 가 계좌를 요구하지 않음)
|
||||
* dbank → 선택 (관리자 수동 이체 참조)
|
||||
* card → 불필요
|
||||
*
|
||||
* 부분 입력(3필드 중 일부)은 결제수단과 무관하게 거부한다 — 그 상태로는 환불이 불가능하다.
|
||||
*
|
||||
* @scenario actor=admin, change_mode=manual
|
||||
*
|
||||
* @effects cancel_refund_bank_required_for_paid_vbank_422,
|
||||
* cancel_refund_bank_optional_before_deposit,
|
||||
* cancel_refund_bank_partial_input_rejected_422,
|
||||
* cancel_refund_bank_persisted_to_payment_row
|
||||
*/
|
||||
class CancelOrderRefundBankTest extends ModuleTestCase
|
||||
{
|
||||
protected User $adminUser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->adminUser = $this->createAdminUser([
|
||||
'sirsoft-ecommerce.orders.read',
|
||||
'sirsoft-ecommerce.orders.update',
|
||||
]);
|
||||
|
||||
$settingsDir = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
|
||||
if (is_dir($settingsDir)) {
|
||||
foreach (glob($settingsDir.'/*.json') as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
app(EcommerceSettingsService::class)->clearCache();
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce', []);
|
||||
|
||||
$this->createCancelSequences();
|
||||
}
|
||||
|
||||
private function createCancelSequences(): void
|
||||
{
|
||||
foreach ([SequenceType::CANCEL, SequenceType::REFUND] as $type) {
|
||||
$config = $type->getDefaultConfig();
|
||||
Sequence::firstOrCreate(
|
||||
['type' => $type->value],
|
||||
[
|
||||
'algorithm' => $config['algorithm']->value,
|
||||
'prefix' => $config['prefix'],
|
||||
'current_value' => 0,
|
||||
'increment' => 1,
|
||||
'min_value' => 1,
|
||||
'max_value' => $config['max_value'],
|
||||
'cycle' => false,
|
||||
'pad_length' => $config['pad_length'],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제완료 주문 + 결제행을 만든다.
|
||||
*
|
||||
* @param array<string, mixed> $paymentOverrides
|
||||
* @return array{order: Order, payment: OrderPayment}
|
||||
*/
|
||||
private function createPaidOrder(array $paymentOverrides = []): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$unitPrice = 20000;
|
||||
|
||||
$order = Order::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'subtotal_amount' => $unitPrice,
|
||||
'total_amount' => $unitPrice,
|
||||
'total_paid_amount' => $unitPrice,
|
||||
'total_due_amount' => 0,
|
||||
'total_cancelled_amount' => 0,
|
||||
'cancellation_count' => 0,
|
||||
'paid_at' => now(),
|
||||
'promotions_applied_snapshot' => [],
|
||||
'shipping_policy_applied_snapshot' => [],
|
||||
]);
|
||||
|
||||
$snapshotOverride = [
|
||||
'product_snapshot' => [
|
||||
'id' => null, 'name' => ['ko' => 't', 'en' => 't'], 'product_code' => null,
|
||||
'sku' => null, 'brand_id' => null, 'list_price' => $unitPrice, 'selling_price' => $unitPrice,
|
||||
'currency_code' => 'KRW', 'stock_quantity' => 100, 'tax_status' => 'taxable',
|
||||
'tax_rate' => 10, 'has_options' => false, 'option_groups' => null, 'thumbnail_url' => null,
|
||||
],
|
||||
'option_snapshot' => [
|
||||
'id' => null, 'option_code' => null, 'option_values' => null, 'option_name' => 't',
|
||||
'price_adjustment' => 0, 'list_price' => $unitPrice, 'selling_price' => $unitPrice,
|
||||
'currency_code' => 'KRW', 'stock_quantity' => 100, 'weight' => 0, 'volume' => 0,
|
||||
],
|
||||
];
|
||||
|
||||
OrderOption::factory()->forOrder($order)->create(array_merge([
|
||||
'quantity' => 1,
|
||||
'unit_price' => $unitPrice,
|
||||
'subtotal_price' => $unitPrice,
|
||||
'subtotal_paid_amount' => $unitPrice,
|
||||
'subtotal_discount_amount' => 0,
|
||||
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
], $snapshotOverride));
|
||||
|
||||
$payment = OrderPayment::factory()->forOrder($order)->create(array_merge([
|
||||
'payment_method' => PaymentMethodEnum::VBANK,
|
||||
'payment_status' => PaymentStatusEnum::PAID,
|
||||
'paid_amount_local' => $unitPrice,
|
||||
'paid_amount_base' => $unitPrice,
|
||||
'paid_at' => now(),
|
||||
'refund_bank_code' => null,
|
||||
'refund_bank_name' => null,
|
||||
'refund_bank_account' => null,
|
||||
'refund_bank_holder' => null,
|
||||
], $paymentOverrides));
|
||||
|
||||
return compact('order', 'payment');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
*/
|
||||
private function cancel(Order $order, array $body = []): TestResponse
|
||||
{
|
||||
return $this->actingAs($this->adminUser)->postJson(
|
||||
"/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/cancel",
|
||||
array_merge(['type' => 'full', 'reason' => 'changed_mind', 'cancel_pg' => false], $body)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_주문상세_응답이_환불계좌를_노출한다(): void
|
||||
{
|
||||
// 취소 모달은 주문 시 입력된 계좌를 프리필한다. 응답에 키가 없으면 프리필이 불가능하다.
|
||||
['order' => $order] = $this->createPaidOrder([
|
||||
'refund_bank_code' => '088',
|
||||
'refund_bank_name' => '신한은행',
|
||||
'refund_bank_account' => '110-123-456789',
|
||||
'refund_bank_holder' => '홍길동',
|
||||
]);
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->getJson("/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.payment.refund_bank_code', '088')
|
||||
->assertJsonPath('data.payment.refund_bank_name', '신한은행')
|
||||
->assertJsonPath('data.payment.refund_bank_account', '110-123-456789')
|
||||
->assertJsonPath('data.payment.refund_bank_holder', '홍길동');
|
||||
}
|
||||
|
||||
// ------------------------------------------------- vbank 입금완료 = 필수
|
||||
|
||||
public function test_가상계좌_입금완료_취소는_환불계좌가_없으면_거부된다(): void
|
||||
{
|
||||
['order' => $order] = $this->createPaidOrder();
|
||||
|
||||
$this->cancel($order)
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors([
|
||||
'refund_bank.bank_code',
|
||||
'refund_bank.account_number',
|
||||
'refund_bank.holder',
|
||||
]);
|
||||
|
||||
$order->refresh();
|
||||
$this->assertNotSame(OrderStatusEnum::CANCELLED, $order->order_status, '검증 실패 시 취소되면 안 된다');
|
||||
}
|
||||
|
||||
public function test_주문시_입력된_환불계좌가_있으면_취소_요청에_다시_넣지_않아도_된다(): void
|
||||
{
|
||||
['order' => $order] = $this->createPaidOrder([
|
||||
'refund_bank_code' => '004',
|
||||
'refund_bank_name' => '국민은행',
|
||||
'refund_bank_account' => '110-123-456789',
|
||||
'refund_bank_holder' => '홍길동',
|
||||
]);
|
||||
|
||||
$this->cancel($order)->assertOk();
|
||||
}
|
||||
|
||||
public function test_가상계좌_입금완료_취소에_환불계좌를_주면_통과하고_결제행에_저장된다(): void
|
||||
{
|
||||
['order' => $order, 'payment' => $payment] = $this->createPaidOrder();
|
||||
|
||||
$this->cancel($order, [
|
||||
'refund_bank' => [
|
||||
'bank_code' => '004',
|
||||
'account_number' => '110-123-456789',
|
||||
'holder' => '홍길동',
|
||||
],
|
||||
])->assertOk();
|
||||
|
||||
$payment->refresh();
|
||||
$this->assertSame('004', $payment->refund_bank_code);
|
||||
$this->assertSame('110-123-456789', $payment->refund_bank_account);
|
||||
$this->assertSame('홍길동', $payment->refund_bank_holder);
|
||||
$this->assertNotNull($payment->refund_bank_name, '은행명이 은행코드로 조회되어 저장되어야 한다');
|
||||
}
|
||||
|
||||
// ------------------------------------------------- vbank 입금전 / dbank = 선택
|
||||
|
||||
public function test_가상계좌_입금전_취소는_환불계좌가_없어도_된다(): void
|
||||
{
|
||||
['order' => $order] = $this->createPaidOrder([
|
||||
'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT,
|
||||
]);
|
||||
|
||||
$this->cancel($order)->assertOk();
|
||||
}
|
||||
|
||||
public function test_무통장_취소는_환불계좌가_없어도_된다(): void
|
||||
{
|
||||
['order' => $order] = $this->createPaidOrder([
|
||||
'payment_method' => PaymentMethodEnum::DBANK,
|
||||
]);
|
||||
|
||||
$this->cancel($order)->assertOk();
|
||||
}
|
||||
|
||||
public function test_카드_취소는_환불계좌가_없어도_된다(): void
|
||||
{
|
||||
['order' => $order] = $this->createPaidOrder([
|
||||
'payment_method' => PaymentMethodEnum::CARD,
|
||||
]);
|
||||
|
||||
$this->cancel($order)->assertOk();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- 부분 입력 거부 (결제수단 무관)
|
||||
|
||||
public function test_무통장이라도_환불계좌_부분_입력은_거부된다(): void
|
||||
{
|
||||
['order' => $order] = $this->createPaidOrder([
|
||||
'payment_method' => PaymentMethodEnum::DBANK,
|
||||
]);
|
||||
|
||||
$this->cancel($order, ['refund_bank' => ['account_number' => '110-123-456789']])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['refund_bank.bank_code', 'refund_bank.holder']);
|
||||
}
|
||||
|
||||
public function test_가상계좌_환불계좌_부분_입력은_거부된다(): void
|
||||
{
|
||||
['order' => $order] = $this->createPaidOrder();
|
||||
|
||||
$this->cancel($order, ['refund_bank' => ['bank_code' => '004', 'account_number' => '110-1']])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['refund_bank.holder']);
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ShippingFeeTaxPolicy;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 현금영수증 환경설정 저장 (W-5 / #454 S3)
|
||||
*
|
||||
* 관리자 환경설정 UI 가 저장하는 3키가 실제로 DB 에 반영되는지 검증한다.
|
||||
* FormRequest 에 rules 를 명시하지 않으면 validated() 가 키를 떨궈 저장이 조용히 무효화된다 —
|
||||
* 이 테스트가 그 회귀를 고정한다.
|
||||
*
|
||||
* @scenario actor=admin, change_mode=manual
|
||||
*
|
||||
* @effects settings_cash_receipt_provider_persisted,
|
||||
* settings_shipping_fee_tax_policy_persisted,
|
||||
* settings_cash_receipt_self_issue_persisted,
|
||||
* settings_invalid_shipping_fee_tax_policy_rejected_422
|
||||
*/
|
||||
class EcommerceSettingsCashReceiptTest extends ModuleTestCase
|
||||
{
|
||||
private string $apiBase = '/api/modules/sirsoft-ecommerce/admin/settings';
|
||||
|
||||
private User $adminUser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->adminUser = $this->createAdminUser([
|
||||
'sirsoft-ecommerce.settings.read',
|
||||
'sirsoft-ecommerce.settings.update',
|
||||
]);
|
||||
}
|
||||
|
||||
private function settings(): EcommerceSettingsService
|
||||
{
|
||||
$settings = app(EcommerceSettingsService::class);
|
||||
$settings->clearCache();
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
public function test_현금영수증_프로바이더가_저장된다(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
|
||||
'_tab' => 'order_settings',
|
||||
'order_settings' => ['cash_receipt_provider' => 'tosspayments'],
|
||||
])->assertOk();
|
||||
|
||||
$this->assertSame('tosspayments', $this->settings()->getSetting('order_settings.cash_receipt_provider'));
|
||||
$this->assertSame('tosspayments', $this->settings()->getCashReceiptProvider());
|
||||
}
|
||||
|
||||
public function test_프로바이더를_빈_문자열로_저장하면_미사용이_된다(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
|
||||
'_tab' => 'order_settings',
|
||||
'order_settings' => ['cash_receipt_provider' => ''],
|
||||
])->assertOk();
|
||||
|
||||
// 빈 문자열은 "미사용" 이며 접근자는 null 로 정규화한다.
|
||||
$this->assertNull($this->settings()->getCashReceiptProvider());
|
||||
}
|
||||
|
||||
public function test_배송비_과세_정책이_저장된다(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
|
||||
'_tab' => 'order_settings',
|
||||
'order_settings' => ['shipping_fee_tax_policy' => ShippingFeeTaxPolicy::TAXABLE->value],
|
||||
])->assertOk();
|
||||
|
||||
$this->assertSame(
|
||||
ShippingFeeTaxPolicy::TAXABLE,
|
||||
$this->settings()->getShippingFeeTaxPolicy()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_배송비_과세_정책_3종이_모두_저장된다(): void
|
||||
{
|
||||
foreach (ShippingFeeTaxPolicy::cases() as $policy) {
|
||||
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
|
||||
'_tab' => 'order_settings',
|
||||
'order_settings' => ['shipping_fee_tax_policy' => $policy->value],
|
||||
])->assertOk();
|
||||
|
||||
$this->assertSame($policy, $this->settings()->getShippingFeeTaxPolicy(), $policy->value);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_알_수_없는_배송비_과세_정책은_거부된다(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
|
||||
'_tab' => 'order_settings',
|
||||
'order_settings' => ['shipping_fee_tax_policy' => 'half_taxable'],
|
||||
])->assertStatus(422)
|
||||
->assertJsonValidationErrors(['order_settings.shipping_fee_tax_policy']);
|
||||
}
|
||||
|
||||
public function test_자진발급_토글이_저장된다(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
|
||||
'_tab' => 'order_settings',
|
||||
'order_settings' => ['cash_receipt_self_issue' => true],
|
||||
])->assertOk();
|
||||
|
||||
$this->assertTrue($this->settings()->isCashReceiptSelfIssueEnabled());
|
||||
|
||||
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
|
||||
'_tab' => 'order_settings',
|
||||
'order_settings' => ['cash_receipt_self_issue' => false],
|
||||
])->assertOk();
|
||||
|
||||
$this->assertFalse($this->settings()->isCashReceiptSelfIssueEnabled());
|
||||
}
|
||||
|
||||
public function test_설정_조회_응답에_발급_프로바이더_후보_목록이_포함된다(): void
|
||||
{
|
||||
// 등록된 프로바이더가 없어도 키 자체는 존재해야 한다 (관리자 UI 가 length 로 분기한다).
|
||||
$this->actingAs($this->adminUser)->getJson($this->apiBase)
|
||||
->assertOk()
|
||||
->assertJsonStructure(['data' => ['available_cash_receipt_providers']]);
|
||||
}
|
||||
}
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Public;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ProductSalesStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
|
||||
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
|
||||
/**
|
||||
* 주문 생성 시 현금영수증 신청 + 환불계좌 수집 검증 (#454 S3)
|
||||
*
|
||||
* 검증 축:
|
||||
* - FormRequest 4단계 사슬: rules 명시 → getter → createFromTempOrder 시그니처 → createOrderPayment 저장
|
||||
* (한 단계라도 빠지면 프론트가 값을 보내도 DB 에 남지 않는다)
|
||||
* - 식별번호는 평문 컬럼에 마스킹본, 암호화 컬럼에 원본
|
||||
* - 용도 × 식별번호 조합 (D10 — 지출증빙+휴대폰 허용 / 지출증빙+자진발급번호 거부)
|
||||
* - 환불계좌 부분 입력 거부 (required_with 상당)
|
||||
*
|
||||
* @scenario actor=guest, change_mode=manual
|
||||
*
|
||||
* @effects checkout_cash_receipt_request_persisted, checkout_refund_bank_persisted,
|
||||
* checkout_cash_receipt_identifier_masked_and_encrypted,
|
||||
* checkout_refund_bank_partial_input_rejected_422,
|
||||
* cash_receipt_request_not_persisted_for_non_dbank
|
||||
*/
|
||||
class CheckoutCashReceiptAndRefundBankTest extends ModuleTestCase
|
||||
{
|
||||
protected string $cartKey;
|
||||
|
||||
protected Product $product;
|
||||
|
||||
protected ProductOption $productOption;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->cartKey = Str::uuid()->toString();
|
||||
|
||||
$this->product = Product::create([
|
||||
'name' => ['ko' => '테스트 상품', 'en' => 'Test Product'],
|
||||
'product_code' => 'TEST-'.Str::random(8),
|
||||
'sku' => 'SKU-'.Str::random(8),
|
||||
'list_price' => 20000,
|
||||
'selling_price' => 15000,
|
||||
'currency_code' => 'KRW',
|
||||
'stock_quantity' => 100,
|
||||
'sales_status' => ProductSalesStatus::ON_SALE,
|
||||
'display_status' => ProductDisplayStatus::VISIBLE,
|
||||
'has_options' => true,
|
||||
]);
|
||||
|
||||
$this->productOption = ProductOption::create([
|
||||
'product_id' => $this->product->id,
|
||||
'option_code' => 'OPT-'.Str::random(8),
|
||||
'option_values' => ['색상' => '검정'],
|
||||
'option_name' => null,
|
||||
'sku' => 'SKU-'.Str::random(8),
|
||||
'price_adjustment' => 0,
|
||||
'stock_quantity' => 50,
|
||||
'safe_stock_quantity' => 5,
|
||||
'is_default' => true,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createTempOrder(): TempOrder
|
||||
{
|
||||
return TempOrder::create([
|
||||
'user_id' => null,
|
||||
'cart_key' => $this->cartKey,
|
||||
'items' => [[
|
||||
'product_id' => $this->product->id,
|
||||
'product_option_id' => $this->productOption->id,
|
||||
'quantity' => 2,
|
||||
]],
|
||||
'calculation_result' => [
|
||||
'items' => [[
|
||||
'product_id' => $this->product->id,
|
||||
'product_option_id' => $this->productOption->id,
|
||||
'quantity' => 2,
|
||||
'unit_price' => 15000,
|
||||
'subtotal' => 30000,
|
||||
'final_amount' => 30000,
|
||||
]],
|
||||
'summary' => [
|
||||
'subtotal' => 30000,
|
||||
'total_discount' => 0,
|
||||
'total_shipping' => 0,
|
||||
'payment_amount' => 30000,
|
||||
'final_amount' => 30000,
|
||||
],
|
||||
],
|
||||
'expires_at' => now()->addMinutes(30),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function payload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'orderer' => ['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'guest@test.com'],
|
||||
'shipping' => [
|
||||
'recipient_name' => '김철수',
|
||||
'recipient_phone' => '010-9876-5432',
|
||||
'country_code' => 'KR',
|
||||
'zipcode' => '12345',
|
||||
'address' => '서울시 강남구 테헤란로 123',
|
||||
'address_detail' => '101동 1001호',
|
||||
],
|
||||
'payment_method' => PaymentMethodEnum::DBANK->value,
|
||||
'expected_total_amount' => 30000,
|
||||
'depositor_name' => '홍길동',
|
||||
'dbank' => [
|
||||
'bank_code' => '004',
|
||||
'bank_name' => '국민은행',
|
||||
'account_number' => '123-456-789012',
|
||||
'account_holder' => '주식회사 테스트',
|
||||
],
|
||||
'guest_lookup_password' => 'guest1234',
|
||||
'guest_lookup_password_confirmation' => 'guest1234',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
protected function postOrder(array $overrides = []): TestResponse
|
||||
{
|
||||
$this->createTempOrder();
|
||||
|
||||
return $this->postJson(
|
||||
'/api/modules/sirsoft-ecommerce/user/orders',
|
||||
$this->payload($overrides),
|
||||
['X-Cart-Key' => $this->cartKey]
|
||||
);
|
||||
}
|
||||
|
||||
protected function latestPayment(): OrderPayment
|
||||
{
|
||||
$order = Order::latest('id')->firstOrFail();
|
||||
|
||||
return $order->payment()->firstOrFail();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 현금영수증
|
||||
|
||||
public function test_현금영수증_미신청_주문은_신청_플래그가_꺼진다(): void
|
||||
{
|
||||
$this->postOrder()->assertStatus(201);
|
||||
|
||||
$payment = $this->latestPayment();
|
||||
|
||||
$this->assertFalse((bool) $payment->is_cash_receipt_requested);
|
||||
$this->assertNull($payment->cash_receipt_type);
|
||||
$this->assertNull($payment->cash_receipt_identifier);
|
||||
$this->assertNull($payment->getRawOriginal('cash_receipt_identifier_encrypted'));
|
||||
}
|
||||
|
||||
public function test_현금영수증_신청이_결제행에_저장된다(): void
|
||||
{
|
||||
$this->postOrder([
|
||||
'cash_receipt_requested' => true,
|
||||
'cash_receipt_type' => CashReceiptType::INCOME->value,
|
||||
'cash_receipt_identifier_type' => CashReceiptIdentifierType::PHONE->value,
|
||||
'cash_receipt_identifier' => '010-1234-5678',
|
||||
])->assertStatus(201);
|
||||
|
||||
$payment = $this->latestPayment();
|
||||
|
||||
$this->assertTrue((bool) $payment->is_cash_receipt_requested);
|
||||
$this->assertSame(CashReceiptType::INCOME->value, $payment->cash_receipt_type);
|
||||
$this->assertSame(CashReceiptIdentifierType::PHONE, $payment->cash_receipt_identifier_type);
|
||||
}
|
||||
|
||||
public function test_식별번호는_평문_컬럼에_마스킹본_암호화_컬럼에_원본이_저장된다(): void
|
||||
{
|
||||
$this->postOrder([
|
||||
'cash_receipt_requested' => true,
|
||||
'cash_receipt_type' => CashReceiptType::INCOME->value,
|
||||
'cash_receipt_identifier_type' => CashReceiptIdentifierType::PHONE->value,
|
||||
'cash_receipt_identifier' => '010-1234-5678',
|
||||
])->assertStatus(201);
|
||||
|
||||
$payment = $this->latestPayment();
|
||||
|
||||
// 하이픈 제거 후 뒤 4자리만 노출
|
||||
$this->assertSame('*******5678', $payment->cash_receipt_identifier);
|
||||
|
||||
// 원본은 복호화로만 얻는다 (raw 컬럼에 평문이 없어야 한다)
|
||||
$this->assertSame('01012345678', $payment->cash_receipt_identifier_encrypted);
|
||||
$this->assertStringNotContainsString(
|
||||
'01012345678',
|
||||
(string) $payment->getRawOriginal('cash_receipt_identifier_encrypted')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_지출증빙에_휴대폰번호를_쓸_수_있다(): void
|
||||
{
|
||||
// D10 — 휴대폰번호는 소득공제/지출증빙 양쪽에서 유효하다
|
||||
$this->postOrder([
|
||||
'cash_receipt_requested' => true,
|
||||
'cash_receipt_type' => CashReceiptType::EXPENSE->value,
|
||||
'cash_receipt_identifier_type' => CashReceiptIdentifierType::PHONE->value,
|
||||
'cash_receipt_identifier' => '010-1234-5678',
|
||||
])->assertStatus(201);
|
||||
|
||||
$this->assertSame(CashReceiptType::EXPENSE->value, $this->latestPayment()->cash_receipt_type);
|
||||
}
|
||||
|
||||
public function test_지출증빙에_자진발급번호는_거부된다(): void
|
||||
{
|
||||
// 자진발급 지정번호는 소득공제 전용 (제도상 지출증빙 자진발급 불가)
|
||||
$this->postOrder([
|
||||
'cash_receipt_requested' => true,
|
||||
'cash_receipt_type' => CashReceiptType::EXPENSE->value,
|
||||
'cash_receipt_identifier_type' => CashReceiptIdentifierType::PHONE->value,
|
||||
'cash_receipt_identifier' => CashReceiptIdentifierType::SELF_ISSUE_NUMBER,
|
||||
])->assertStatus(422)
|
||||
->assertJsonPath('errors.cash_receipt_identifier.0', __('sirsoft-ecommerce::cash_receipt.validation.self_issue_income_only'));
|
||||
}
|
||||
|
||||
public function test_사업자등록번호_체크섬이_틀리면_거부된다(): void
|
||||
{
|
||||
$this->postOrder([
|
||||
'cash_receipt_requested' => true,
|
||||
'cash_receipt_type' => CashReceiptType::EXPENSE->value,
|
||||
'cash_receipt_identifier_type' => CashReceiptIdentifierType::BUSINESS->value,
|
||||
'cash_receipt_identifier' => '1234567890',
|
||||
])->assertStatus(422);
|
||||
|
||||
$this->assertSame(0, Order::count(), '검증 실패 시 주문 부산물이 남아서는 안 된다');
|
||||
}
|
||||
|
||||
public function test_신청했으나_하위키가_없으면_거부된다(): void
|
||||
{
|
||||
$this->postOrder(['cash_receipt_requested' => true])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors([
|
||||
'cash_receipt_type',
|
||||
'cash_receipt_identifier_type',
|
||||
'cash_receipt_identifier',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 무통장이 아닌 결제수단으로 신청정보가 넘어와도 결제행에 저장하지 않는다.
|
||||
*
|
||||
* 발급 자체는 CashReceiptService 가 dbank 로 차단하므로 기능 홀은 아니지만,
|
||||
* 발급될 수 없는 주문에 식별번호 암호문을 남기는 것은 불필요한 개인정보 보관이다.
|
||||
* getDbankInfo() 와 동일하게 getCashReceiptInfo() 도 결제수단으로 게이팅한다.
|
||||
*
|
||||
* @param string $paymentMethod 무통장이 아닌 결제수단
|
||||
*/
|
||||
#[DataProvider('nonDbankPaymentMethodProvider')]
|
||||
public function test_무통장이_아니면_현금영수증_신청정보를_저장하지_않는다(string $paymentMethod): void
|
||||
{
|
||||
$this->postOrder([
|
||||
'payment_method' => $paymentMethod,
|
||||
// vbank 는 입금자명이 필수다(required_if). dbank 전용 계좌정보만 비운다.
|
||||
'depositor_name' => $paymentMethod === PaymentMethodEnum::VBANK->value ? '홍길동' : null,
|
||||
'dbank' => null,
|
||||
'cash_receipt_requested' => true,
|
||||
'cash_receipt_type' => CashReceiptType::INCOME->value,
|
||||
'cash_receipt_identifier_type' => CashReceiptIdentifierType::PHONE->value,
|
||||
'cash_receipt_identifier' => '010-1234-5678',
|
||||
])->assertStatus(201);
|
||||
|
||||
$payment = $this->latestPayment();
|
||||
|
||||
$this->assertFalse(
|
||||
(bool) $payment->is_cash_receipt_requested,
|
||||
"{$paymentMethod} 주문에 현금영수증 신청 플래그가 켜져서는 안 된다"
|
||||
);
|
||||
$this->assertNull($payment->cash_receipt_type);
|
||||
$this->assertNull($payment->cash_receipt_identifier_type);
|
||||
$this->assertNull($payment->cash_receipt_identifier, '마스킹본도 남기지 않는다');
|
||||
$this->assertNull(
|
||||
$payment->getRawOriginal('cash_receipt_identifier_encrypted'),
|
||||
'발급될 수 없는 주문에 식별번호 암호문을 보관해서는 안 된다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 무통장(dbank)을 제외한 전 결제수단.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function nonDbankPaymentMethodProvider(): array
|
||||
{
|
||||
$cases = [];
|
||||
|
||||
foreach (PaymentMethodEnum::cases() as $method) {
|
||||
if ($method === PaymentMethodEnum::DBANK) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cases[$method->value] = [$method->value];
|
||||
}
|
||||
|
||||
return $cases;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 환불계좌
|
||||
|
||||
public function test_환불계좌_미입력이_허용된다(): void
|
||||
{
|
||||
$this->postOrder()->assertStatus(201);
|
||||
|
||||
$payment = $this->latestPayment();
|
||||
|
||||
$this->assertNull($payment->refund_bank_code);
|
||||
$this->assertNull($payment->refund_bank_account);
|
||||
$this->assertNull($payment->refund_bank_holder);
|
||||
}
|
||||
|
||||
public function test_환불계좌_전체_입력이_결제행에_저장된다(): void
|
||||
{
|
||||
$this->postOrder([
|
||||
'refund_bank' => [
|
||||
'bank_code' => '004',
|
||||
'account_number' => '110-123-456789',
|
||||
'holder' => '홍길동',
|
||||
],
|
||||
])->assertStatus(201);
|
||||
|
||||
$payment = $this->latestPayment();
|
||||
|
||||
$this->assertSame('004', $payment->refund_bank_code);
|
||||
$this->assertSame('110-123-456789', $payment->refund_bank_account);
|
||||
$this->assertSame('홍길동', $payment->refund_bank_holder);
|
||||
// 은행명은 은행코드로 조회해 함께 저장한다 (관리자 화면 표시용)
|
||||
$this->assertNotNull($payment->refund_bank_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{array<string, mixed>, array<int, string>}>
|
||||
*/
|
||||
public static function partialRefundBankProvider(): array
|
||||
{
|
||||
return [
|
||||
'계좌번호만' => [
|
||||
['account_number' => '110-123-456789'],
|
||||
['refund_bank.bank_code', 'refund_bank.holder'],
|
||||
],
|
||||
'은행만' => [
|
||||
['bank_code' => '004'],
|
||||
['refund_bank.account_number', 'refund_bank.holder'],
|
||||
],
|
||||
'예금주 누락' => [
|
||||
['bank_code' => '004', 'account_number' => '110-123-456789'],
|
||||
['refund_bank.holder'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $refundBank
|
||||
* @param array<int, string> $expectedErrors
|
||||
*/
|
||||
#[DataProvider('partialRefundBankProvider')]
|
||||
public function test_환불계좌_부분_입력은_거부된다(array $refundBank, array $expectedErrors): void
|
||||
{
|
||||
$this->postOrder(['refund_bank' => $refundBank])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors($expectedErrors);
|
||||
|
||||
$this->assertSame(0, Order::count(), '검증 실패 시 주문 부산물이 남아서는 안 된다');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 사슬 무결성
|
||||
|
||||
public function test_현금영수증과_환불계좌를_함께_보내면_둘_다_저장된다(): void
|
||||
{
|
||||
$this->postOrder([
|
||||
'cash_receipt_requested' => true,
|
||||
'cash_receipt_type' => CashReceiptType::INCOME->value,
|
||||
'cash_receipt_identifier_type' => CashReceiptIdentifierType::PHONE->value,
|
||||
'cash_receipt_identifier' => '010-1234-5678',
|
||||
'refund_bank' => [
|
||||
'bank_code' => '004',
|
||||
'account_number' => '110-123-456789',
|
||||
'holder' => '홍길동',
|
||||
],
|
||||
])->assertStatus(201);
|
||||
|
||||
$payment = $this->latestPayment();
|
||||
|
||||
$this->assertTrue((bool) $payment->is_cash_receipt_requested);
|
||||
$this->assertSame('004', $payment->refund_bank_code);
|
||||
}
|
||||
}
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* 체크아웃 현금영수증 신청 폼 E2E (#454 S3 / W-1).
|
||||
*
|
||||
* 이 spec 이 검증하는 것은 "슬롯에 주입된 모듈 폼이 브라우저에서 실제로 동작하는가" 다.
|
||||
* 레이아웃 JSON 단위 테스트(cashReceiptUi.test.tsx)는 JSON 구조만 읽으므로,
|
||||
* 확장 주입 → 렌더 → 상호작용 → 상태 반영의 실경로는 브라우저에서만 확인된다.
|
||||
*
|
||||
* 실측으로만 드러난 결함이 실제로 있었다(계획서 §12-3): 무통장 결제 상태가 waiting_deposit 이
|
||||
* 아니라 ready 였고, 유닛 픽스처는 내가 넣어준 값이라 green 이었다. 그래서 본 spec 은
|
||||
* 픽스처 대신 라이브 API/DOM 만 신뢰한다.
|
||||
*
|
||||
* 시드는 도메인 seeder(playwright:seed-ecommerce, 아직 stub)를 쓰지 않는다.
|
||||
* 대신 사용자가 실제로 밟는 경로 — 인증 토큰 발급 → 카트 담기 API → 체크아웃 진입 — 을 그대로 밟는다.
|
||||
* 검증 대상(현금영수증 폼)을 우회하지 않으므로 "강제 API 로 통과 선언" 에 해당하지 않는다.
|
||||
*
|
||||
* @scenario cash-receipt-ui-and-refund-bank
|
||||
* @effects checkout_slot_renders_only_inside_dbank_block,
|
||||
* checkout_slot_hidden_when_provider_unset,
|
||||
* checkout_fields_mount_on_request_toggle,
|
||||
* checkout_purpose_switch_resets_invalid_identifier,
|
||||
* checkout_identifier_type_options_by_purpose,
|
||||
* checkout_identifier_cleared_on_type_change,
|
||||
* new_ui_uses_portable_only,
|
||||
* new_ui_has_no_tailwind_breakpoint
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* 카트 담기에 쓰는 상품 — 전 상품이 옵션 보유(has_options=true)라 option_values 가 필수다.
|
||||
*
|
||||
* CartService::bulkAddToCart 는 저장된 다국어 JSON 이 아니라 현재 로케일로 평탄화된 맵
|
||||
* (`{"색상":"화이트"}`) 과 비교한다(getLocalizedOptionValues). 그래서 요청 로케일을 ko 로 고정한다.
|
||||
*/
|
||||
const CART_LOCALE = 'ko';
|
||||
const CART_ITEM = {
|
||||
product_id: 1,
|
||||
items: [{ option_values: { 색상: '화이트' }, quantity: 1 }],
|
||||
};
|
||||
|
||||
/** 발급수단 드롭다운의 항목 라벨 (ko 고정 — beforeEach 가 g7_locale 을 ko 로 pin 한다) */
|
||||
const IDENTIFIER_LABEL = {
|
||||
phone: '휴대폰번호',
|
||||
card: '현금영수증 카드번호',
|
||||
business: '사업자등록번호',
|
||||
} as const;
|
||||
|
||||
const SLOT = '#ext_checkout_cash_receipt';
|
||||
const FIELDS = '#ext_checkout_cash_receipt_fields';
|
||||
const TOGGLE = '#ext_checkout_cash_receipt_toggle';
|
||||
const PURPOSE = '#ext_checkout_cash_receipt_purpose';
|
||||
const ID_TYPE = '#ext_checkout_cash_receipt_identifier_type';
|
||||
const IDENTIFIER = '#ext_checkout_cash_receipt_identifier';
|
||||
|
||||
/**
|
||||
* 카트를 비우고 상품 1건을 담은 뒤 임시주문을 생성한다.
|
||||
*
|
||||
* 체크아웃 화면은 카트에 상품이 있는 것만으로는 열리지 않는다 — 카트의 "주문하기" 가
|
||||
* POST /checkout 으로 만든 임시주문이 있어야 하고, 없으면 "주문 정보를 찾을 수 없습니다"
|
||||
* 모달이 뜨고 카트로 되돌아간다. 그래서 사용자가 밟는 두 단계를 그대로 밟는다.
|
||||
*/
|
||||
async function seedCheckout(page: Page): Promise<void> {
|
||||
const result = await page.evaluate(
|
||||
async ({ payload, locale }) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'Accept-Language': locale,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
|
||||
await fetch('/api/modules/sirsoft-ecommerce/cart/all', { method: 'DELETE', headers });
|
||||
|
||||
const added = await fetch('/api/modules/sirsoft-ecommerce/cart', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!added.ok) return { step: 'cart', status: added.status, body: await added.text() };
|
||||
|
||||
const cart = await fetch('/api/modules/sirsoft-ecommerce/cart', { headers }).then((r) => r.json());
|
||||
const itemIds = (cart?.data?.items ?? cart?.data ?? []).map((i: { id: number }) => i.id);
|
||||
if (itemIds.length === 0) {
|
||||
return { step: 'cart-read', status: 0, body: JSON.stringify(cart).slice(0, 300) };
|
||||
}
|
||||
|
||||
// 카트의 "주문하기" 버튼과 동일한 호출 (_cart_summary.json)
|
||||
const checkout = await fetch('/api/modules/sirsoft-ecommerce/checkout', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ item_ids: itemIds }),
|
||||
});
|
||||
return { step: 'checkout', status: checkout.status, body: await checkout.text() };
|
||||
},
|
||||
{ payload: CART_ITEM, locale: CART_LOCALE }
|
||||
);
|
||||
|
||||
expect(result.status, `${result.step} 단계 실패: ${result.body}`).toBeLessThan(300);
|
||||
}
|
||||
|
||||
/** 결제수단 버튼 — iteration 으로 렌더되며 method.id 로 식별된다. */
|
||||
const paymentMethod = (page: Page, id: string) =>
|
||||
page.getByTestId(`checkout-payment-method-${id}`);
|
||||
|
||||
/**
|
||||
* GDPR 쿠키 동의 배너를 닫는다.
|
||||
*
|
||||
* 신규 브라우저 컨텍스트에는 동의 기록이 없어 전체 화면 오버레이가 클릭을 가로챈다.
|
||||
* 동의 상태는 서버(/consent/cookie/status)가 SSoT 이므로 localStorage 를 조작하지 않고
|
||||
* 실제 사용자와 같이 버튼을 누른다.
|
||||
*/
|
||||
async function dismissCookieNotice(page: Page): Promise<void> {
|
||||
const necessaryOnly = page.getByRole('button', { name: /Necessary Only|필수만/i });
|
||||
const overlay = page.locator('.fixed.inset-0');
|
||||
|
||||
// 배너는 /consent/cookie/status 응답 후 비동기로 마운트되고, 클릭 직후 자기 자신을 떼어낸다.
|
||||
// 한 번만 확인하면 (a) 아직 없는 상태를 "없음" 으로 오판하거나 (b) 클릭 재시도가
|
||||
// detach 와 겹쳐 실패한다. 오버레이가 사라질 때까지 클릭을 재시도한다.
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
if ((await overlay.count()) === 0) return;
|
||||
if (!(await necessaryOnly.isVisible().catch(() => false))) {
|
||||
await necessaryOnly.waitFor({ state: 'visible', timeout: 5_000 }).catch(() => undefined);
|
||||
}
|
||||
await necessaryOnly.click({ timeout: 5_000 }).catch(() => undefined);
|
||||
const cleared = await expect
|
||||
.poll(() => overlay.count(), { timeout: 5_000 })
|
||||
.toBe(0)
|
||||
.then(() => true, () => false);
|
||||
if (cleared) return;
|
||||
}
|
||||
|
||||
await expect(overlay, '쿠키 동의 오버레이가 닫히지 않았다').toHaveCount(0);
|
||||
}
|
||||
|
||||
/** 체크아웃으로 이동하고 결제수단 블록이 그려질 때까지 기다린다. */
|
||||
async function gotoCheckout(page: Page): Promise<void> {
|
||||
await page.goto('/shop/checkout');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await dismissCookieNotice(page);
|
||||
// 무통장 버튼이 뜨면 결제수단 섹션 렌더 완료
|
||||
await paymentMethod(page, 'dbank').waitFor({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** 무통장입금을 선택한다 — 현금영수증 슬롯은 dbank 블록 내부에만 있다. */
|
||||
async function selectDbank(page: Page): Promise<void> {
|
||||
await paymentMethod(page, 'dbank').click();
|
||||
await expect(page.locator(SLOT)).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* 발급수단 드롭다운을 열고 보이는 항목 라벨을 반환한다.
|
||||
*
|
||||
* 템플릿의 Select 는 네이티브 <select> 가 아니라 portal 로 띄우는 커스텀 드롭다운이다
|
||||
* (role=listbox / role=option, value 속성 없음). 그래서 selectOption() 이 동작하지 않고
|
||||
* 항목은 라벨 텍스트로만 식별된다.
|
||||
*/
|
||||
async function openIdentifierOptions(page: Page): Promise<string[]> {
|
||||
await page.locator(ID_TYPE).click();
|
||||
const listbox = page.getByRole('listbox');
|
||||
await listbox.waitFor({ state: 'visible' });
|
||||
return listbox.getByRole('option').allInnerTexts();
|
||||
}
|
||||
|
||||
/** 발급수단 드롭다운에서 항목을 고른다. */
|
||||
async function chooseIdentifierType(page: Page, key: keyof typeof IDENTIFIER_LABEL): Promise<void> {
|
||||
await page.locator(ID_TYPE).click();
|
||||
const listbox = page.getByRole('listbox');
|
||||
await listbox.waitFor({ state: 'visible' });
|
||||
await listbox.getByRole('option', { name: IDENTIFIER_LABEL[key], exact: true }).click();
|
||||
await expect(listbox).toBeHidden();
|
||||
}
|
||||
|
||||
test.describe('체크아웃 현금영수증 신청 폼 (무통장 슬롯 주입)', () => {
|
||||
test.beforeEach(async ({ page, noPermissionToken }) => {
|
||||
// 구매자 역할 — 관리자 권한 없이 상점 화면만 사용한다.
|
||||
await authenticatePage(page, noPermissionToken);
|
||||
// 드롭다운 항목은 라벨 텍스트로만 식별되므로 앱 로케일을 ko 로 고정한다
|
||||
// (기본 브라우저 로케일이 en 이면 라벨이 달라져 spec 이 로케일에 종속된다).
|
||||
await page.addInitScript((locale) => localStorage.setItem('g7_locale', locale), CART_LOCALE);
|
||||
await page.goto('/shop');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await dismissCookieNotice(page);
|
||||
await seedCheckout(page);
|
||||
});
|
||||
|
||||
test('무통장 선택 시에만 현금영수증 슬롯이 렌더된다 (다른 결제수단에서는 0개)', async ({ page }) => {
|
||||
await gotoCheckout(page);
|
||||
|
||||
// 무통장 → 슬롯 1개.
|
||||
// 활성 결제수단이 무통장 하나뿐이면 진입 시점에 이미 선택되어 있다(슬롯도 이미 렌더).
|
||||
// 그래서 "선택 전 0개" 를 단정하지 않는다 — 그건 결제수단 구성에 따라 달라지는 값이다.
|
||||
await selectDbank(page);
|
||||
await expect(page.locator(SLOT)).toHaveCount(1);
|
||||
|
||||
// 다른 결제수단으로 전환하면 슬롯이 사라진다.
|
||||
// 활성 결제수단은 환경설정 가변이므로 dbank 아닌 활성 버튼이 있을 때만 전환을 검증한다.
|
||||
// 전수(8종) 확인은 환경설정을 바꿔가며 수행하는 매트릭스 실측의 몫이다 — 여기서 활성화를
|
||||
// 조작하면 병렬 워커가 공유하는 상점 설정을 오염시킨다.
|
||||
const others = page.locator('[data-testid^="checkout-payment-method-"]:not([data-testid$="-dbank"])');
|
||||
if ((await others.count()) === 0) {
|
||||
test.info().annotations.push({
|
||||
type: 'coverage-gap',
|
||||
description: '무통장 외 활성 결제수단이 없어 전환 후 슬롯 소멸은 미검증 (환경설정 의존)',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await others.first().click();
|
||||
await expect(page.locator(SLOT)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('신청 체크 전에는 입력 필드가 없고, 체크하면 마운트된다', async ({ page }) => {
|
||||
await gotoCheckout(page);
|
||||
await selectDbank(page);
|
||||
|
||||
await expect(page.locator(FIELDS)).toHaveCount(0);
|
||||
await expect(page.locator('[name^="cash_receipt_"]')).toHaveCount(1); // 토글 체크박스만
|
||||
|
||||
await page.locator(TOGGLE).check();
|
||||
|
||||
await expect(page.locator(FIELDS)).toHaveCount(1);
|
||||
await expect(page.locator(PURPOSE)).toBeVisible();
|
||||
await expect(page.locator(ID_TYPE)).toBeVisible();
|
||||
await expect(page.locator(IDENTIFIER)).toBeVisible();
|
||||
|
||||
// 해제하면 다시 사라진다
|
||||
await page.locator(TOGGLE).uncheck();
|
||||
await expect(page.locator(FIELDS)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('용도에 따라 발급수단 선택지가 달라진다 — 지출증빙에만 사업자등록번호', async ({ page }) => {
|
||||
await gotoCheckout(page);
|
||||
await selectDbank(page);
|
||||
await page.locator(TOGGLE).check();
|
||||
|
||||
// 기본값 = 소득공제 → 2종 (사업자등록번호 없음)
|
||||
await expect(page.locator('input[name="cash_receipt_type"][value="income"]')).toBeChecked();
|
||||
expect(await openIdentifierOptions(page)).toEqual([
|
||||
IDENTIFIER_LABEL.phone,
|
||||
IDENTIFIER_LABEL.card,
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// 지출증빙 → 3종 (사업자등록번호 포함)
|
||||
await page.locator('input[name="cash_receipt_type"][value="expense"]').check();
|
||||
expect(await openIdentifierOptions(page)).toEqual([
|
||||
IDENTIFIER_LABEL.business,
|
||||
IDENTIFIER_LABEL.phone,
|
||||
IDENTIFIER_LABEL.card,
|
||||
]);
|
||||
});
|
||||
|
||||
test('지출증빙+사업자등록번호에서 소득공제로 전환하면 발급수단과 번호가 리셋된다', async ({ page }) => {
|
||||
await gotoCheckout(page);
|
||||
await selectDbank(page);
|
||||
await page.locator(TOGGLE).check();
|
||||
|
||||
// 지출증빙 → 사업자등록번호 선택 → 번호 입력
|
||||
await page.locator('input[name="cash_receipt_type"][value="expense"]').check();
|
||||
await chooseIdentifierType(page, 'business');
|
||||
await page.locator(IDENTIFIER).fill('1234567890');
|
||||
await expect(page.locator(IDENTIFIER)).toHaveValue('1234567890');
|
||||
|
||||
// 소득공제로 전환 — business 는 소득공제에 쓸 수 없으므로 phone 으로 리셋되고 번호는 비워진다
|
||||
await page.locator('input[name="cash_receipt_type"][value="income"]').check();
|
||||
|
||||
await expect(page.locator(ID_TYPE)).toContainText(IDENTIFIER_LABEL.phone);
|
||||
await expect(page.locator(IDENTIFIER)).toHaveValue('');
|
||||
});
|
||||
|
||||
test('발급수단을 바꾸면 이전에 입력한 번호가 비워진다', async ({ page }) => {
|
||||
await gotoCheckout(page);
|
||||
await selectDbank(page);
|
||||
await page.locator(TOGGLE).check();
|
||||
|
||||
await page.locator(IDENTIFIER).fill('01012345678');
|
||||
await expect(page.locator(IDENTIFIER)).toHaveValue('01012345678');
|
||||
|
||||
await chooseIdentifierType(page, 'card');
|
||||
await expect(page.locator(IDENTIFIER)).toHaveValue('');
|
||||
});
|
||||
|
||||
test('반응형은 portable 단일 오버라이드로만 분기한다 (md:/lg: 브레이크포인트 없음)', async ({ page }) => {
|
||||
await gotoCheckout(page);
|
||||
await selectDbank(page);
|
||||
await page.locator(TOGGLE).check();
|
||||
|
||||
// 신설 UI 안에 Tailwind 브레이크포인트 클래스가 없어야 한다 (§6-0)
|
||||
const breakpointClasses = await page.locator(SLOT).evaluate((root) =>
|
||||
Array.from(root.querySelectorAll('*'))
|
||||
.flatMap((el) => Array.from(el.classList))
|
||||
.filter((c) => /^(sm|md|lg|xl|2xl):/.test(c))
|
||||
);
|
||||
expect(breakpointClasses).toEqual([]);
|
||||
|
||||
const direction = () => page.locator(PURPOSE).evaluate((el) => getComputedStyle(el).flexDirection);
|
||||
const columns = () => page.locator('#ext_checkout_cash_receipt_identifier_row')
|
||||
.evaluate((el) => getComputedStyle(el).gridTemplateColumns.split(' ').length);
|
||||
|
||||
// 뷰포트 변경 후 responsive 오버라이드가 리렌더될 때까지 재시도한다 (즉시 읽으면 이전 값).
|
||||
const expectLayout = async (width: number, dir: string, cols: number) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await expect.poll(direction, { message: `${width}px flexDirection` }).toBe(dir);
|
||||
await expect.poll(columns, { message: `${width}px grid columns` }).toBe(cols);
|
||||
};
|
||||
|
||||
// 데스크톱(1091px) — 가로 배치 / 2열
|
||||
await expectLayout(1091, 'row', 2);
|
||||
|
||||
// 태블릿(900px)·모바일(390px) 은 동일 구간(portable 0~1023) — 세로 배치 / 1열
|
||||
await expectLayout(900, 'column', 1);
|
||||
await expectLayout(390, 'column', 1);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ use App\Extension\HookManager;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIssueStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptTransactionType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
@@ -213,6 +214,76 @@ class CashReceiptResourceTest extends ModuleTestCase
|
||||
$this->assertSame('PROVIDER_ERROR', $ledger[0]['error_code']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 리소스는_거래유형과_발급상태의_표시명을_함께_노출한다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$order = $this->makeOrder();
|
||||
app(CashReceiptService::class)->issue(
|
||||
$order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE,
|
||||
);
|
||||
|
||||
$receipt = $this->resourceArray($order)['cash_receipt'];
|
||||
|
||||
// 관리자 발급 이력 표는 원시값(issue / COMPLETED)이 아니라 표시명을 보여줘야 한다.
|
||||
$this->assertSame(CashReceiptTransactionType::ISSUE->label(), $receipt['transaction_type_label']);
|
||||
$this->assertSame(CashReceiptIssueStatus::COMPLETED->label(), $receipt['issue_status_label']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 취소_이력의_상태는_발급_완료로_표시되지_않는다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.cash_receipt.cancel',
|
||||
fn (array $result, Order $order, string $provider, string $receiptKey) => [
|
||||
'success' => true, 'error_code' => null, 'error_message' => null,
|
||||
'receipt_key' => $receiptKey, 'raw_response' => null,
|
||||
],
|
||||
);
|
||||
|
||||
$order = $this->makeOrder();
|
||||
$service = app(CashReceiptService::class);
|
||||
|
||||
$service->issue($order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE);
|
||||
$service->cancelAll($order->fresh(), '테스트 취소');
|
||||
|
||||
$ledger = $this->resourceArray($order)['cash_receipts'];
|
||||
$cancelRow = collect($ledger)->firstWhere('transaction_type', CashReceiptTransactionType::CANCEL);
|
||||
|
||||
$this->assertNotNull($cancelRow, '취소 이력이 있어야 한다');
|
||||
|
||||
// issue_status 는 "그 거래가 성공했는가" 이므로 COMPLETED 다. 그러나 취소 행에 "발급 완료" 라고
|
||||
// 쓰면 관리자가 오해한다 — 이력 표에는 거래유형과 무관한 중립 표현을 쓴다.
|
||||
$this->assertSame(CashReceiptIssueStatus::COMPLETED->value, $cancelRow['issue_status']);
|
||||
$this->assertStringNotContainsString('발급', $cancelRow['result_label']);
|
||||
$this->assertNotEmpty($cancelRow['result_label']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 발급에_실패한_이력도_표시용_일시를_가진다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
$this->issueShouldFail = true;
|
||||
|
||||
$order = $this->makeOrder();
|
||||
|
||||
try {
|
||||
app(CashReceiptService::class)->issue(
|
||||
$order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// 발급 실패는 이력만 남기고 삼켜지거나 예외로 전파된다 — 어느 쪽이든 이력은 남는다.
|
||||
}
|
||||
|
||||
$failed = $this->resourceArray($order)['cash_receipts'][0];
|
||||
|
||||
// 실패 이력은 issued_at 이 null 이다. 그대로 두면 관리자 이력 표의 일시가 '-' 로 비어 버린다.
|
||||
$this->assertSame(CashReceiptIssueStatus::FAILED->value, $failed['issue_status']);
|
||||
$this->assertNull($failed['issued_at']);
|
||||
$this->assertNotEmpty($failed['occurred_at_formatted'], '실패 이력도 발생 시각을 표시할 수 있어야 한다');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 리소스는_프로바이더_원응답을_노출하지_않는다(): void
|
||||
{
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
# audit:allow test-scenario-coverage reason: 14축 full cross product 691만 조합 — 전개 상한 초과.
|
||||
# test_files 실재 검사는 이 면제와 무관하게 항상 수행된다(끄려면 `:test-files` 접미사).
|
||||
# 실제 커버는 아래 effects 69건과 test_files 12개가 담당한다. S2 가 이월한 E2E axis 를 본 매니페스트에서 채운다.
|
||||
feature: 현금영수증 신청/발급 UI · 환불계좌 수집
|
||||
|
||||
description: |
|
||||
S1(인프라)·S2(API·자동발급) 위에 사람이 조작하는 표면을 얹는다. 그 전까지는 구매자가 신청할 곳도,
|
||||
관리자가 발급을 볼 곳도 없었다.
|
||||
|
||||
발견한 구조적 구멍(S3 착수 시 실측): 주문 생성 POST body 는 템플릿 소유 파일(_checkout_summary.json)의
|
||||
리터럴 객체다. 슬롯에 주입된 모듈 폼이 _local 에 값을 써도 그 body 에 실릴 방법이 없다 —
|
||||
결제 버튼에 id 가 없어 overlay 로 타겟할 수 없고, inject_props 는 컴포넌트 props 전용이라
|
||||
actions[].params.body 에 도달하지 못한다. 그대로 두면 구매자가 신청해도 주문에 아무 기록이 남지 않는다.
|
||||
→ PO 결정: body 를 통짜 표현식으로 바꾸고 확장 병합 칸(...(_local.checkoutExtraPayload ?? {})) 한 개를 연다.
|
||||
템플릿은 현금영수증을 알지 않고, 모듈은 그 칸에만 쓴다. 확장 미주입 시 payload 는 전환 전과 동일하다.
|
||||
|
||||
발견한 사전 결함(본 세션에서 수정):
|
||||
· 환경설정 저장이 조용히 무효였다 — StoreEcommerceSettingsRequest 에 3키 규칙이 없어
|
||||
validated() 가 떨궜다. 관리자가 저장해도 아무 일도 일어나지 않았다.
|
||||
· payment 응답에 cash_receipt_provider 가 없어, 주문상세 현금영수증 카드가 영원히 렌더될 수 없었다.
|
||||
· payment 응답에 refund_bank_* 4키가 없어, 취소 모달이 주문 시 입력된 계좌를 프리필할 수 없었다.
|
||||
· _payment.json 루트에 id 가 없어 KG 플러그인의 overlay 주입(세금내역·영수증 링크·에스크로 버튼)이
|
||||
조용히 no-op 이었다. 본 이슈가 부수 복구한다(R1).
|
||||
|
||||
실서버(Playwright MCP) 실측에서만 드러난 결함 — 유닛 테스트는 전부 green 이었다:
|
||||
· 무통장 주문의 결제 상태는 waiting_deposit 이 아니라 ready 다. 카드 상태머신이 waiting_deposit 만
|
||||
매칭해 모든 무통장 주문에서 카드 본문이 비었다. 픽스처가 내가 넣어준 값이라 유닛으로는 잡히지 않았다.
|
||||
· 관리자 apiCall 3종(발급·발급취소·재발급)에 auth_required 가 없어 Bearer 미첨부 → 전부 401.
|
||||
· 발급 버튼과 재발급 버튼의 노출 조건이 배타적이지 않아 FAILED 상태에서 둘 다 떴다.
|
||||
· 이력 표가 원시값을 노출했다 — 취소 행에 "발급 완료", 실패 행 일시 "-"(issued_at null), issue/FAILED.
|
||||
|
||||
환불계좌는 PG·현금영수증 프로바이더와 무관한 순수 주문 정보이므로 템플릿이 직접 소유한다(PO 결정).
|
||||
취소 시점 갱신은 레이아웃이 아니라 cancelOrderHandlers.ts 가 수행한다 — 취소 POST body 를
|
||||
그 핸들러가 조립하기 때문이다.
|
||||
|
||||
axis_notes:
|
||||
payment_status_of_dbank: |
|
||||
무통장 주문 생성 직후 payment_status 는 ready 다(PaymentStatusEnum::isAwaitingDeposit() 는
|
||||
ready·waiting_deposit 양쪽에 true). 입금 대기 상태를 판정하는 모든 표면은 두 값을 함께 매칭해야 한다.
|
||||
|
||||
axes:
|
||||
surface: # 사람이 조작하는 표면
|
||||
- checkout # W-1 체크아웃 신청 폼 (모듈 주입)
|
||||
- mypage_order # W-2 유저 주문상세 카드 (모듈 주입)
|
||||
- admin_order # W-3 관리자 주문상세 카드 + 발급 모달
|
||||
- admin_cancel_modal # W-4 취소 모달 환불계좌
|
||||
- admin_settings # W-5 환경설정 (프로바이더 · 배송비 과세 · 자진발급)
|
||||
|
||||
payment_method: # 8종 전수 — 현금영수증 대상은 dbank 뿐(D4). 나머지 7종은 "숨김" 이 검증 대상
|
||||
- dbank # 무통장 → 슬롯/카드 렌더
|
||||
- vbank # 가상계좌 → 슬롯 미렌더, 환불계좌는 렌더
|
||||
- card
|
||||
- bank
|
||||
- phone
|
||||
- point
|
||||
- deposit
|
||||
- free
|
||||
|
||||
provider_state: # 발급 프로바이더 설정 상태
|
||||
- configured # 슬롯/카드 렌더
|
||||
- not_configured # 슬롯/카드 전체 미렌더 (DOM 카운트 0)
|
||||
|
||||
receipt_type:
|
||||
- income # 소득공제용 → 발급수단 2종
|
||||
- expense # 지출증빙용 → 발급수단 3종 (사업자등록번호 포함)
|
||||
|
||||
identifier_type: # 용도 × 식별번호 5조합 (D10)
|
||||
- phone # 양쪽 용도 유효
|
||||
- card # 양쪽 용도 유효
|
||||
- business # 지출증빙 전용 + 체크섬
|
||||
- self_issue # 소득공제 전용 (지출증빙 조합은 거부)
|
||||
|
||||
payment_status: # 입금 대기를 표현하는 값이 둘이다 — 실서버 결함 ①의 축
|
||||
- ready # 무통장 주문 생성 직후의 실제 값
|
||||
- waiting_deposit # 가상계좌 발급 후의 값
|
||||
- paid
|
||||
|
||||
card_state: # 주문상세 카드 상태머신 5종
|
||||
- hidden # ① 무통장 아님 / 프로바이더 미설정
|
||||
- awaiting_deposit # ② 입금 전 → 안내만
|
||||
- issuable # ③ 입금완료 + 미발급 → 발급 버튼
|
||||
- issued # ④ 발급완료 → 영수증 링크
|
||||
- reissue_failed # ⑤ 취소 성공 + 재발급 실패 → 경고 배지
|
||||
|
||||
actor: # 권한 축 — 관리자 전용 액션 분리
|
||||
- admin # 발급 · 발급취소 · 수동 재발급
|
||||
- member # 발급만 (취소·재발급 버튼 부재)
|
||||
- guest # 발급만 (X-Guest-Order-Token)
|
||||
|
||||
refund_bank_input: # 환불계좌 입력 상태 (부분 입력 거부)
|
||||
- empty # 미입력
|
||||
- complete # 3필드 전부
|
||||
- partial_account_only # 계좌번호만
|
||||
- partial_bank_only # 은행만
|
||||
- partial_missing_holder # 예금주 누락
|
||||
|
||||
refund_bank_requirement: # 필수 여부 판정 (D13)
|
||||
- vbank_paid_required # 가상계좌 + 입금완료 → 필수 (미입력 422)
|
||||
- vbank_waiting_optional # 가상계좌 + 입금 전 → 선택
|
||||
- dbank_optional # 무통장 → 선택 (관리자 수동 이체 참조)
|
||||
- card_hidden # 카드·간편결제 → 필드 자체 숨김
|
||||
|
||||
shipping_fee_tax_policy: # 배송비 과세 3정책 (D3) — 백엔드 Enum 과 값 일치
|
||||
- proportional # 안분 (기본)
|
||||
- taxable # 전액 과세
|
||||
- follow_main_item # 주된 재화 기준
|
||||
|
||||
responsive: # §6-0 반응형 규약 — portable 단독
|
||||
- desktop
|
||||
- portable # 0~1023px (mobile + tablet)
|
||||
|
||||
locale:
|
||||
- ko
|
||||
- en
|
||||
|
||||
e2e_browser: # S2 가 이월한 E2E axis
|
||||
- chromium
|
||||
|
||||
cross_product:
|
||||
- [payment_method, provider_state] # dbank 외 7종에서 슬롯 미렌더 (M10 — 샘플 금지)
|
||||
- [receipt_type, identifier_type] # 용도 × 식별번호 5조합 (M14)
|
||||
- [refund_bank_input, refund_bank_requirement] # 부분 입력 × 필수 판정
|
||||
- [card_state, actor] # 상태머신 × 권한 (유저에겐 취소·재발급 없음)
|
||||
- [surface, responsive] # 신설 UI 전부 portable 오버라이드
|
||||
- [payment_status, card_state] # ready·waiting_deposit 양쪽에서 ② 안내가 떠야 한다
|
||||
|
||||
effects:
|
||||
# 확장 병합 칸 (구조적 계약)
|
||||
- checkout_body_is_single_expression # body 통짜 표현식 전환
|
||||
- checkout_body_legacy_keys_unchanged # 전환 전후 기존 11키 산출값 동일
|
||||
- checkout_body_extra_payload_merged # 확장 칸 기입 시 payload 편입
|
||||
- checkout_body_empty_extra_payload_is_noop # 확장 미주입 시 키 집합·값 동일
|
||||
- checkout_extra_payload_can_override_keys # spread 가 뒤 → 확장은 자기 도메인 키만 쓴다(계약)
|
||||
|
||||
# 체크아웃 신청 폼 (W-1)
|
||||
- checkout_slot_renders_only_inside_dbank_block # 슬롯이 dbank 블록 내부 → 별도 if 불필요
|
||||
- checkout_slot_hidden_when_provider_unset # 프로바이더 미설정 시 DOM 카운트 0
|
||||
- checkout_fields_mount_on_request_toggle # 신청 체크 시 0 → 3 필드
|
||||
- checkout_purpose_switch_resets_invalid_identifier # 소득공제 전환 시 business → phone 리셋
|
||||
- checkout_identifier_type_options_by_purpose # 소득공제 2종 / 지출증빙 3종
|
||||
- checkout_identifier_cleared_on_type_change # 발급수단 변경 시 번호 초기화
|
||||
|
||||
# 주문 생성 저장 (FormRequest 4단계 사슬)
|
||||
- checkout_cash_receipt_request_persisted # is_cash_receipt_requested + 용도 + 수단
|
||||
- checkout_cash_receipt_identifier_masked_and_encrypted # 평문=마스킹, 암호화 컬럼=원본
|
||||
- checkout_expense_with_phone_allowed # D10 — 지출증빙 + 휴대폰 허용
|
||||
- checkout_expense_with_self_issue_rejected_422 # 자진발급번호는 소득공제 전용
|
||||
- checkout_business_number_checksum_rejected_422
|
||||
- checkout_requested_without_subkeys_rejected_422
|
||||
- checkout_no_order_artifact_on_validation_failure # 검증 실패 시 주문 부산물 0
|
||||
|
||||
# 환불계좌 (템플릿 소유 — 확장 칸 미경유)
|
||||
- checkout_refund_bank_persisted
|
||||
- checkout_refund_bank_partial_input_rejected_422
|
||||
- checkout_refund_bank_empty_allowed
|
||||
- checkout_refund_bank_name_resolved_from_code # 은행코드 → 은행명 조회 저장
|
||||
|
||||
# 유저 주문상세 카드 (W-2)
|
||||
- mypage_card_hidden_when_not_dbank_or_no_provider
|
||||
- mypage_card_state_machine_five_states
|
||||
- mypage_issue_uses_member_or_guest_endpoint # user/orders/{id} vs guest/orders/{orderNumber}
|
||||
- mypage_no_cancel_or_reissue_action_for_user # 관리자 전용 액션 부재
|
||||
|
||||
# 관리자 주문상세 (W-3)
|
||||
- admin_card_renders_in_payment_iteration_context
|
||||
- admin_issue_cancel_reissue_actions_present
|
||||
- admin_reissue_visible_only_on_failed_history
|
||||
- admin_issue_modal_setstate_before_closemodal # 순서 역전 시 상태 잔존
|
||||
- admin_cash_receipt_history_listed # 발급/취소 이력 최신순
|
||||
- admin_history_collapsed_by_default # 이력이 있어도 기본 접힘
|
||||
- admin_history_toggle_is_per_payment_row # 결제행마다 독립 토글 (payIdx)
|
||||
- admin_history_toggle_announces_state # 아이콘 방향 + aria-expanded
|
||||
|
||||
# 취소 모달 환불계좌 (W-4)
|
||||
- cancel_refund_bank_visible_only_for_vbank_dbank
|
||||
- cancel_refund_bank_required_for_paid_vbank_422
|
||||
- cancel_refund_bank_optional_before_deposit
|
||||
- cancel_refund_bank_optional_for_dbank
|
||||
- cancel_refund_bank_partial_input_rejected_422
|
||||
- cancel_refund_bank_persisted_to_payment_row # 환불 실행 전에 컬럼 갱신
|
||||
- cancel_refund_bank_omitted_when_untouched # 미입력 시 키 미전송 → 주문 시 계좌 보존
|
||||
- cancel_refund_bank_prefilled_from_order # 주문 시 입력값 프리필
|
||||
|
||||
# 환경설정 (W-5)
|
||||
- settings_cash_receipt_provider_persisted # rules 누락 시 validated() 가 떨궈 저장 무효
|
||||
- settings_provider_empty_means_disabled
|
||||
- settings_shipping_fee_tax_policy_persisted
|
||||
- settings_shipping_fee_tax_all_three_policies
|
||||
- settings_invalid_shipping_fee_tax_policy_rejected_422
|
||||
- settings_cash_receipt_self_issue_persisted
|
||||
- settings_self_issue_default_off # D14
|
||||
- settings_available_providers_exposed # 후보 목록 응답 포함
|
||||
|
||||
# 응답 표면
|
||||
- payment_resource_exposes_cash_receipt_provider # 없으면 카드가 영원히 미렌더
|
||||
- payment_resource_exposes_type_label
|
||||
- payment_resource_exposes_refund_bank_fields # 없으면 취소 모달 프리필 불가 (실서버 결함 ③)
|
||||
- order_detail_eager_loads_cash_receipts # whenLoaded 가드 → 미로드 시 키 자체 부재
|
||||
|
||||
# 실서버 실측에서만 드러난 결함 — 유닛은 전부 green 이었다. 회귀 pin.
|
||||
- card_awaiting_deposit_matches_ready_status # 결함 ① dbank 실제 값은 ready
|
||||
- card_awaiting_deposit_matches_waiting_deposit_status
|
||||
- admin_cash_receipt_apicalls_require_auth # 결함 ② auth_required 누락 → 401
|
||||
- admin_issue_button_hidden_on_failed_state # 결함 ④ 발급·재발급 버튼 동시 노출
|
||||
- history_transaction_type_label_localized # 결함 ⑤ 원시값 issue 노출
|
||||
- history_issue_status_label_localized # 결함 ⑤ 원시값 FAILED 노출
|
||||
- history_cancel_row_does_not_say_issued # 결함 ⑤ 취소 행에 "발급 완료"
|
||||
- history_occurred_at_falls_back_to_created_at # 결함 ⑥ 실패 행 issued_at null → "-"
|
||||
|
||||
# 반응형 규약 (§6-0)
|
||||
- new_ui_uses_portable_only # mobile/tablet 혼용 금지
|
||||
- new_ui_has_no_tailwind_breakpoint # md:/lg: 로 마크업 구조 분기 금지
|
||||
- portable_override_applies_below_1024_on_render # 실렌더: 세로 스택 전환 (F5)
|
||||
- portable_override_absent_at_1024_on_render # 실렌더: 1024px 부터 base 복귀
|
||||
|
||||
# 데이터 위생 (감사 지적 C8, PO 결정 2026-07-10)
|
||||
- cash_receipt_request_not_persisted_for_non_dbank # 발급 불가 주문에 암호문 미보관
|
||||
|
||||
# 자진발급 설정 안내 (감사 지적 W-5)
|
||||
- self_issue_links_to_nts_mandatory_industry_guide # 의무발행 업종 판단 경로 제공
|
||||
|
||||
# 부수 복구
|
||||
- kg_user_order_show_injection_restored # order_payment_info_panel id 부여 (R1)
|
||||
|
||||
test_files:
|
||||
# 백엔드
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CheckoutCashReceiptAndRefundBankTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CancelOrderRefundBankTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsCashReceiptTest.php
|
||||
|
||||
# 모듈 프론트엔드
|
||||
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptUi.test.tsx
|
||||
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptResponsiveRender.test.tsx
|
||||
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/cancelOrderHandlers.test.ts
|
||||
|
||||
# 템플릿 (sirsoft-basic)
|
||||
- templates/_bundled/sirsoft-basic/__tests__/layouts/checkoutSummaryBodyPayload.test.tsx
|
||||
- templates/_bundled/sirsoft-basic/__tests__/layouts/checkoutCashReceiptSlot.test.tsx
|
||||
- templates/_bundled/sirsoft-basic/__tests__/layouts/mypageOrderPaymentPanel.test.tsx
|
||||
- templates/_bundled/sirsoft-basic/__tests__/layouts/shop-guest-checkout-entry.test.tsx
|
||||
|
||||
# 응답 표면 회귀 (실서버 결함 ③)
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Resources/CashReceiptResourceTest.php
|
||||
|
||||
# E2E (S2 이월분)
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/checkout-cash-receipt.spec.ts
|
||||
@@ -1,7 +1,8 @@
|
||||
# audit:allow test-scenario-coverage reason: |
|
||||
# audit:allow test-scenario-coverage:test-files reason: |
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
|
||||
# base 통화 정책 axis 는 PHPUnit(OrderProcessingService/buildCurrencySnapshot/OrderAdjustmentService/
|
||||
# StoreEcommerceSettingsRequest)·Playwright 가 커버하며 green 이다.
|
||||
# test-files 면제 사유: OrderAdjustmentRefundSnapshotTest / mp08-base-currency-guard.spec.ts 미작성 (#421 이월)
|
||||
feature: base 통화 정책 + 변경 가드 + 결제/통지/부분환불 base 기준 (A2 — 신규)
|
||||
|
||||
description: |
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# audit:allow test-scenario-coverage reason: |
|
||||
# audit:allow test-scenario-coverage:test-files reason: |
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
|
||||
# 정가 공통 베이스 axis 는 PHPUnit(BaseOrderItemResource 경유 Cart/Checkout)·Vitest 레이아웃·
|
||||
# Playwright 가 커버하며 green 이다.
|
||||
# test-files 면제 사유: mp08-list-price.spec.ts 미작성 (#421 이월)
|
||||
feature: 정가/할인율 공통 베이스 — 장바구니·주문서 (U5·U4②)
|
||||
|
||||
description: |
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# audit:allow test-scenario-coverage reason: |
|
||||
# audit:allow test-scenario-coverage:test-files reason: |
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 이 없어 fallback YAML 파서가
|
||||
# nested axes 배열을 읽지 못하는 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도
|
||||
# audit:allow 처리됨). 통화 표시 환산의 백엔드 axis 조합은 PHPUnit(HasMultiCurrencyPrices/
|
||||
# ProductOptionResource), 셀렉터·가격 바인딩은 Vitest 레이아웃/핸들러, 드롭다운 전환은
|
||||
# Playwright E2E 가 커버하며 모두 green 이다.
|
||||
# test-files 면제 사유: mp08-currency-switch.spec.ts 미작성 (#421 이월)
|
||||
feature: 다통화 표시 환산 + 셀렉터 노출 + JPY 포맷 (A1·U11·U4③)
|
||||
|
||||
description: |
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# audit:allow test-scenario-coverage reason: |
|
||||
# audit:allow test-scenario-coverage:test-files reason: |
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
|
||||
# 유저별 영속 통화 axis 는 PHPUnit(EcommerceUserProfile/Repository/통화 저장 컨트롤러)·
|
||||
# Vitest(설정 UI)·Playwright(설치 게이트)가 커버하며 green 이다.
|
||||
# test-files 면제 사유: mp08-install-gate.spec.ts 미작성 (#421 이월)
|
||||
feature: 유저별 영속 통화 — user-profile 확장 + 4곳 설정 (A3·A5 — 신규)
|
||||
|
||||
description: |
|
||||
|
||||
+1
-1
@@ -39,4 +39,4 @@ effects:
|
||||
- valid_input_preserved # 정상 입력 비파괴
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/CouponBasicValidationTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponBasicValidationTest.php
|
||||
|
||||
+1
-1
@@ -30,4 +30,4 @@ effects:
|
||||
- exclude_intersect_returns_false # exclude 교집합 → 미적용
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/User/CouponCategoryApplicabilityTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Services/CouponCategoryApplicabilityTest.php
|
||||
|
||||
@@ -31,4 +31,4 @@ effects:
|
||||
- download_coupon_regression_DL # downloadCoupon 추출 후 DL- 코드 동일
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/CouponDirectIssueTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponDirectIssueTest.php
|
||||
|
||||
@@ -29,4 +29,4 @@ effects:
|
||||
- created_by_field_matches_creator # created_by → creator 매칭
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/CouponSearchFieldTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponSearchFieldTest.php
|
||||
|
||||
@@ -34,4 +34,4 @@ effects:
|
||||
- include_entry_passes # include 1건 이상 → 통과
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/CouponTargetScopeValidationTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponTargetScopeValidationTest.php
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# audit:allow test-scenario-coverage reason: |
|
||||
# audit:allow test-scenario-coverage:test-files reason: |
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 nested axes 를 읽지 못하는
|
||||
# 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도 audit:allow). 본 변경은
|
||||
# 고시 무한스크롤 response.data.data 바인딩 정정 + toggle-active 라우트/컨트롤러/Service
|
||||
# (전용 활동로그) + 드롭다운 active_only 필터를 PHPUnit Feature + Vitest 레이아웃 렌더링이
|
||||
# 커버하며 green.
|
||||
# test-files 면제 사유: product-notice-list-toggle.test.tsx 미작성 (#421 이월)
|
||||
feature: 상품정보제공고시 무한스크롤/활성토글/드롭다운 필터 (A9)
|
||||
|
||||
description: |
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# audit:allow test-scenario-coverage reason: |
|
||||
# audit:allow test-scenario-coverage:test-files reason: |
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 nested axes 를 읽지 못하는
|
||||
# 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도 audit:allow). 본 변경은
|
||||
# 배송정책 목록 empty_state if 경계 정비 + 상품폼 /active endpoint + activeList 응답 필드 보강
|
||||
# + 비활성 부여 정책 union 표시/안내를 PHPUnit Feature + Vitest 레이아웃 렌더링이 커버하며 green.
|
||||
# test-files 면제 사유: shipping-policy-list-empty / product-form-shipping-active.test.tsx 미작성 (#421 이월)
|
||||
feature: 배송정책 목록 빈 화면 단일화 + 상품폼 활성 정책 노출 (A11)
|
||||
|
||||
description: |
|
||||
|
||||
+1
-1
@@ -40,5 +40,5 @@ effects:
|
||||
- backend_no_options_keeps_price # 옵션 미보유 미변경
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/resources/js/handlers/__tests__/productOptionHandlers.test.ts
|
||||
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/productOptionHandlers.test.ts
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductServiceOptionsTest.php
|
||||
|
||||
@@ -72,4 +72,4 @@ test_files:
|
||||
- templates/_bundled/sirsoft-basic/src/handlers/__tests__/productOptionsAdditional.test.ts
|
||||
- templates/_bundled/sirsoft-basic/src/__tests__/layouts/shopAdditionalOptions.test.tsx
|
||||
# 세션 C E2E
|
||||
- templates/_bundled/sirsoft-basic/tests/Playwright/specs/shop/additional-options.spec.ts
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/additional-options.spec.ts
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"generated_at": "2026-08-06T02:43:43+00:00",
|
||||
"generated_at": "2026-08-06T03:32:02+00:00",
|
||||
"generator": "g7 vendor-bundle:build",
|
||||
"target": "module:sirsoft-ecommerce",
|
||||
"composer_json_sha256": "cf5460aed4b90364c33889e3bd479eeb87c05b8f4b1ee94886575e14dcf441d1",
|
||||
"composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
|
||||
"zip_sha256": "3c84d3216050b9ecdb49ebb8b457275d221b2b0c8369a608a20377b611fecafb",
|
||||
"zip_sha256": "a475756e4c92ad685d324cacb83beab2c8f94e5d29d8b33bf64dd63b12180f67",
|
||||
"zip_size": 435548,
|
||||
"package_count": 1,
|
||||
"php_requirement": "^8.2",
|
||||
|
||||
Binary file not shown.
@@ -99,9 +99,9 @@ sub_flows:
|
||||
- script_without_preblocker_attr_blocked_by_backup_line
|
||||
|
||||
test_files:
|
||||
- resources/js/__tests__/blocker-domains.test.ts
|
||||
- resources/js/__tests__/blocker.test.ts
|
||||
- resources/js/__tests__/preblocker.test.ts
|
||||
- plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/blocker-domains.test.ts
|
||||
- plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/blocker.test.ts
|
||||
- plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/preblocker.test.ts
|
||||
|
||||
notes: |
|
||||
본 매니페스트는 SSoT 로 작성되었으며, audit 룰 test-scenario-coverage 의 docblock 마킹
|
||||
|
||||
@@ -72,13 +72,13 @@ effects:
|
||||
- migration_up_after_down_is_idempotent
|
||||
|
||||
test_files:
|
||||
- tests/Unit/Services/GdprPolicyVersionServiceTest.php
|
||||
- tests/Feature/Api/Admin/GdprAdminSettingsControllerTest.php
|
||||
- tests/Feature/Api/Admin/GdprAdminPolicyVersionControllerTest.php
|
||||
- tests/Feature/Installation/GdprPolicyVersionMigrationSmokeTest.php
|
||||
- tests/Unit/Services/GdprConsentServiceTest.php
|
||||
- tests/Feature/Api/Public/GdprCookieConsentControllerTest.php
|
||||
- tests/Feature/Api/User/GdprConsentControllerTest.php
|
||||
- plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/GdprPolicyVersionServiceTest.php
|
||||
- plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Admin/GdprAdminSettingsControllerTest.php
|
||||
- plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Admin/GdprAdminPolicyVersionControllerTest.php
|
||||
- plugins/_bundled/sirsoft-gdpr/tests/Feature/Installation/GdprPolicyVersionMigrationSmokeTest.php
|
||||
- plugins/_bundled/sirsoft-gdpr/tests/Unit/Services/GdprConsentServiceTest.php
|
||||
- plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php
|
||||
- plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/User/GdprConsentControllerTest.php
|
||||
|
||||
related_docs:
|
||||
- docs/backend/service-repository.md
|
||||
|
||||
+2
-2
@@ -54,9 +54,9 @@
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setState",
|
||||
"target": "local",
|
||||
"params": {
|
||||
"target": "_local.paymentErrorMessage",
|
||||
"value": null
|
||||
"paymentErrorMessage": null
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -129,5 +129,5 @@ scope:
|
||||
- 코어 password_reset 훅에 verification_token 전달 추가 (별도 결함, 본 작업 무관)
|
||||
|
||||
test_files:
|
||||
- tests/Feature/Listeners/AssertNoDuplicateInicisIdentityTest.php
|
||||
- tests/Feature/Identity/InicisIdentityProviderBindingTest.php
|
||||
- plugins/_bundled/sirsoft-verification_kginicis/tests/Feature/Listeners/AssertNoDuplicateInicisIdentityTest.php
|
||||
- plugins/_bundled/sirsoft-verification_kginicis/tests/Feature/Identity/InicisIdentityProviderBindingTest.php
|
||||
@@ -4,10 +4,13 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.4] - 2026-07-19
|
||||
## [1.1.0] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- 주문서의 무통장입금·가상계좌 결제 영역에 환불 계좌(은행·계좌번호·예금주) 입력란을 추가했습니다. 주문을 취소할 때 환불받을 계좌를 미리 남겨둘 수 있으며, 입력하지 않아도 주문할 수 있습니다. 다만 세 칸 중 하나라도 입력하면 나머지도 입력해야 합니다.
|
||||
- 주문서의 무통장입금 영역에 현금영수증 신청 폼이 표시될 수 있도록 확장 영역을 마련했습니다. 신청 폼 자체는 쇼핑몰 기능이 제공합니다.
|
||||
- 주문 상세의 결제 정보 영역에 현금영수증 발급 내역이 표시될 수 있도록 확장 영역을 마련했습니다.
|
||||
- 회원가입 화면에 휴대폰번호·전화번호 입력란을 추가했습니다. 두 항목 모두 선택 사항입니다.
|
||||
|
||||
### Changed
|
||||
@@ -16,6 +19,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 주문 상세의 결제 정보 영역에 결제 플러그인이 제공하는 공급가액·부가세·영수증 링크·에스크로 구매확정 버튼이 표시되지 않던 문제를 수정했습니다.
|
||||
- 총 건수를 끝까지 세지 못한 목록에서 페이지 이동 막대가 사라지거나 "1페이지뿐" 으로 잘못 표시되던 문제를 수정했습니다. 마이페이지의 주문·마일리지·위시리스트·알림함, 게시판 목록, 상품 리뷰·문의, 내가 쓴 글 목록이 해당합니다.
|
||||
- 검색 결과의 탭 배지가 끝까지 세지 못한 건수를 정확한 숫자처럼 보여주던 문제를 수정했습니다. 이제 그 경우 숫자 뒤에 "이상" 표시가 붙습니다.
|
||||
|
||||
@@ -57,7 +61,7 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- 옵션 담기 방식 개선에 따라 이 템플릿은 이커머스 모듈 1.0.5 이상이 필요합니다.
|
||||
- 이커머스 모듈 최소 요구 버전을 1.1.0 으로 상향했습니다. 옵션 담기 방식 개선과 주문서 환불 계좌·현금영수증 확장 영역 표시에 해당 버전 이상이 필요합니다.
|
||||
- 검색 결과가 아주 많아 총 건수를 정확히 셀 수 없는 경우, "N건 이상" 으로 표시하고 검색어를 좁히면 정확한 건수를 볼 수 있다는 안내를 함께 보여 줍니다. 이때 마지막 페이지로 바로 뛰는 버튼은 감춰지고 다음 페이지 이동은 그대로 됩니다.
|
||||
- 페이지네이션에서 처음/마지막 버튼을 따로 켜고 끌 수 있게 했습니다.
|
||||
- 목록의 총 건수 정확도 표시를 사용하기 위해 이 템플릿은 이제 코어 7.0.6 이상이 필요합니다.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @file checkoutCashReceiptSlot.test.tsx
|
||||
* @description 체크아웃 현금영수증 확장 슬롯 + 환불계좌 입력 필드 구조 검증 (#454 S3).
|
||||
*
|
||||
* 설계 계약:
|
||||
* - 현금영수증 신청 폼은 템플릿이 슬롯(extension_point)만 열고 이커머스 모듈이 주입한다.
|
||||
* 슬롯이 dbank 블록 **내부**에 있으므로 결제수단이 무통장일 때만 렌더된다 → 별도 if 불필요.
|
||||
* - 환불계좌 3필드는 템플릿이 직접 소유한다(PG·현금영수증 프로바이더 비종속, 은행 목록은
|
||||
* 이미 템플릿이 가진 paymentSettings 를 재사용). dbank·vbank 양쪽 블록에 존재한다.
|
||||
* - 반응형은 responsive.portable 단독. 신설 노드의 className 에 Tailwind breakpoint(md:/lg:)를
|
||||
* 쓰면 레이아웃 편집기의 디바이스 미리보기(overrideWidth)가 깨지므로 금지한다.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import checkoutPaymentJson from '../../layouts/partials/shop/_checkout_payment.json';
|
||||
|
||||
/** if 표현식에 해당 결제수단이 걸린 최상위 블록 */
|
||||
const blockFor = (method: string) =>
|
||||
(checkoutPaymentJson.children as any[]).find(
|
||||
(c) => typeof c.if === 'string' && c.if.includes(`'${method}'`)
|
||||
);
|
||||
|
||||
const dbankBlock = blockFor('dbank');
|
||||
const vbankBlock = blockFor('vbank');
|
||||
|
||||
/** 노드 트리 전체를 평탄화 */
|
||||
function flatten(node: any, out: any[] = []): any[] {
|
||||
if (node == null || typeof node !== 'object') return out;
|
||||
out.push(node);
|
||||
for (const value of Object.values(node)) {
|
||||
if (Array.isArray(value)) value.forEach((v) => flatten(v, out));
|
||||
else if (value && typeof value === 'object') flatten(value, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const findById = (root: any, id: string) => flatten(root).find((n) => n.id === id);
|
||||
|
||||
describe('체크아웃 현금영수증 확장 슬롯', () => {
|
||||
it('dbank 블록과 vbank 블록이 존재해야 한다', () => {
|
||||
expect(dbankBlock, '무통장 블록').toBeDefined();
|
||||
expect(vbankBlock, '가상계좌 블록').toBeDefined();
|
||||
});
|
||||
|
||||
it('슬롯은 dbank 블록의 직계 자식이어야 한다 (결제수단 게이트를 블록이 대신함)', () => {
|
||||
const slot = (dbankBlock.children as any[]).find(
|
||||
(c) => c.type === 'extension_point' && c.name === 'shop_checkout_cash_receipt_slot'
|
||||
);
|
||||
expect(slot, 'dbank 블록 직계의 현금영수증 슬롯').toBeDefined();
|
||||
expect(slot.default).toEqual([]);
|
||||
});
|
||||
|
||||
it('슬롯에 별도 if 게이트가 없어야 한다 (dbank 블록 내부이므로 중복)', () => {
|
||||
const slot = (dbankBlock.children as any[]).find(
|
||||
(c) => c.type === 'extension_point' && c.name === 'shop_checkout_cash_receipt_slot'
|
||||
);
|
||||
expect(slot.if).toBeUndefined();
|
||||
});
|
||||
|
||||
it('vbank 블록에는 현금영수증 슬롯이 없어야 한다 (D4 — 가상계좌는 PG 가 자동 발급)', () => {
|
||||
const slots = flatten(vbankBlock).filter((n) => n.type === 'extension_point');
|
||||
expect(slots.map((s) => s.name)).not.toContain('shop_checkout_cash_receipt_slot');
|
||||
});
|
||||
|
||||
it('템플릿은 현금영수증 도메인 필드를 직접 그리지 않는다 (모듈 소유)', () => {
|
||||
const raw = JSON.stringify(checkoutPaymentJson);
|
||||
expect(raw).not.toContain('cash_receipt_type');
|
||||
expect(raw).not.toContain('cashReceiptIdentifier');
|
||||
});
|
||||
});
|
||||
|
||||
describe('환불계좌 입력 필드 (템플릿 직접 소유)', () => {
|
||||
const cases: Array<[string, string, any]> = [
|
||||
['dbank', 'checkout_refund_bank_dbank', dbankBlock],
|
||||
['vbank', 'checkout_refund_bank_vbank', vbankBlock],
|
||||
];
|
||||
|
||||
for (const [method, containerId, block] of cases) {
|
||||
describe(`${method} 블록`, () => {
|
||||
const container = findById(block, containerId);
|
||||
|
||||
it('환불계좌 컨테이너가 존재해야 한다', () => {
|
||||
expect(container, `${containerId}`).toBeDefined();
|
||||
});
|
||||
|
||||
it('은행 Select + 계좌번호 + 예금주 3필드를 가져야 한다', () => {
|
||||
const names = flatten(container)
|
||||
.map((n) => n.props?.name)
|
||||
.filter(Boolean);
|
||||
expect(names).toContain('refund_bank_code');
|
||||
expect(names).toContain('refund_bank_account');
|
||||
expect(names).toContain('refund_bank_holder');
|
||||
});
|
||||
|
||||
it('3필드가 각각 _local.refundBank* 에 setState 해야 한다', () => {
|
||||
const targets = flatten(container)
|
||||
.flatMap((n) => n.actions ?? [])
|
||||
.filter((a: any) => a.handler === 'setState')
|
||||
.map((a: any) => Object.keys(a.params).filter((k) => k !== 'target'))
|
||||
.flat();
|
||||
expect(targets).toEqual(
|
||||
expect.arrayContaining(['refundBankCode', 'refundBankAccount', 'refundBankHolder'])
|
||||
);
|
||||
});
|
||||
|
||||
it('은행 Select 는 paymentSettings 의 banks 를 {value,label} 로 변환해야 한다', () => {
|
||||
const select = flatten(container).find((n) => n.name === 'Select');
|
||||
expect(select, '은행 Select').toBeDefined();
|
||||
// valueKey/labelKey prop 금지 — computed/표현식으로 변환해야 한다
|
||||
expect(select.props.valueKey).toBeUndefined();
|
||||
expect(select.props.labelKey).toBeUndefined();
|
||||
expect(select.props.options).toContain('order_settings?.banks');
|
||||
expect(select.props.options).toContain('value:');
|
||||
expect(select.props.options).toContain('label:');
|
||||
});
|
||||
|
||||
it('신설 노드는 responsive.portable 만 쓰고 md:/lg: breakpoint 를 쓰지 않는다 (§6-0)', () => {
|
||||
const nodes = flatten(container);
|
||||
|
||||
const breakpoints = nodes.filter((n) => /(^|\s)(md|lg|xl|2xl):/.test(n.props?.className ?? ''));
|
||||
expect(breakpoints, 'Tailwind breakpoint 사용 노드').toEqual([]);
|
||||
|
||||
const responsiveKeys = nodes.flatMap((n) => Object.keys(n.responsive ?? {}));
|
||||
expect(responsiveKeys.length, 'responsive 오버라이드가 하나 이상').toBeGreaterThan(0);
|
||||
expect([...new Set(responsiveKeys)], 'portable 단독 (mobile/tablet 혼용 금지)').toEqual([
|
||||
'portable',
|
||||
]);
|
||||
});
|
||||
|
||||
it('2열 그리드가 portable 에서 1열로 접힌다', () => {
|
||||
const grid = flatten(container).find((n) => (n.props?.className ?? '').includes('grid-cols-2'));
|
||||
expect(grid, '2열 그리드').toBeDefined();
|
||||
expect(grid.responsive.portable.props.className).toContain('grid-cols-1');
|
||||
});
|
||||
|
||||
it('다크모드 variant 가 함께 지정되어야 한다', () => {
|
||||
const withBg = flatten(container).filter((n) =>
|
||||
/(^|\s)(bg-|text-|border-)/.test(n.props?.className ?? '')
|
||||
);
|
||||
expect(withBg.length).toBeGreaterThan(0);
|
||||
for (const n of withBg) {
|
||||
expect(n.props.className, `dark: variant 누락 → ${n.props.className}`).toContain('dark:');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* @file checkoutSummaryBodyPayload.test.tsx
|
||||
* @description 주문 생성 POST body 의 확장 병합 칸(checkoutExtraPayload) 계약 검증.
|
||||
*
|
||||
* 배경 (#454 S3):
|
||||
* 슬롯에 주입된 모듈 폼이 _local 에 값을 써도, body 가 템플릿 소유 리터럴 객체이면
|
||||
* 그 값은 서버에 도달하지 않는다. 모듈은 body 줄을 추가할 수단이 없다
|
||||
* (결제 버튼 id 부재 → overlay 불가 / inject_props 는 actions[].params.body 미도달).
|
||||
* → body 를 통짜 표현식으로 두고 확장 병합 칸 1개를 연다.
|
||||
*
|
||||
* 평가 경로 변화:
|
||||
* 전환 전 = ActionDispatcher.resolveParams 가 body 를 재귀하며 키마다 evaluateExpression
|
||||
* 전환 후 = body 가 단일 문자열 → evaluateExpression 1회
|
||||
* → 두 경로의 산출값 동등성을 고정한다 (특히 dbank 삼항 · guest_lookup_password null 분기 ·
|
||||
* expected_total_amount 숫자형 · shipping_memo custom 분기).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DataBindingEngine } from '@core/template-engine/DataBindingEngine';
|
||||
import checkoutSummaryJson from '../../layouts/partials/shop/_checkout_summary.json';
|
||||
|
||||
/** 객체 트리에서 조건을 만족하는 첫 노드를 깊이우선 탐색 */
|
||||
function findNode(node: any, predicate: (n: any) => boolean): any {
|
||||
if (node == null || typeof node !== 'object') return undefined;
|
||||
if (predicate(node)) return node;
|
||||
for (const value of Object.values(node)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = findNode(item, predicate);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
const found = findNode(value, predicate);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 주문 생성 POST apiCall 노드 */
|
||||
const orderCall = findNode(
|
||||
checkoutSummaryJson,
|
||||
(n) =>
|
||||
n.handler === 'apiCall' &&
|
||||
typeof n.target === 'string' &&
|
||||
n.target.includes('/user/orders') &&
|
||||
n.params?.method === 'POST'
|
||||
);
|
||||
|
||||
const engine = new DataBindingEngine();
|
||||
|
||||
/** body 가 통짜 표현식일 때 실제 런타임 평가 (resolveParams 의 문자열 분기와 동일) */
|
||||
function evalBody(ctx: Record<string, any>): Record<string, any> {
|
||||
const raw: string = orderCall.params.body;
|
||||
return engine.evaluateExpression(raw.slice(2, -2), ctx);
|
||||
}
|
||||
|
||||
/** 전환 이전 동작을 재현하는 기준 payload — 이 테스트가 진실의 기준(SSoT)이다. */
|
||||
function expectedLegacy(ctx: any): Record<string, any> {
|
||||
const { checkoutData, _computed, _local, _global } = ctx;
|
||||
return {
|
||||
temp_order_id: checkoutData?.data?.temp_order_id,
|
||||
orderer: _computed?.ordererDefaults,
|
||||
shipping: _local?.shipping,
|
||||
payment_method: _computed?.selectedPaymentMethod,
|
||||
shipping_memo:
|
||||
_local?.shippingMemo === 'custom' ? _local?.shippingMemoCustom : _local?.shippingMemo,
|
||||
depositor_name: _local?.depositorName ?? _computed?.ordererDefaults?.name ?? '',
|
||||
dbank:
|
||||
_computed?.selectedPaymentMethod === 'dbank'
|
||||
? {
|
||||
bank_code: _local?.selectedDbank?.bank_code,
|
||||
account_number: _local?.selectedDbank?.account_number,
|
||||
account_holder: _local?.selectedDbank?.account_holder,
|
||||
}
|
||||
: null,
|
||||
expected_total_amount: checkoutData?.data?.calculation?.summary?.final_amount ?? 0,
|
||||
save_shipping_address: _local?.saveShippingAddress ?? false,
|
||||
guest_lookup_password: _global?.currentUser?.uuid ? null : (_local?.guestLookupPassword ?? ''),
|
||||
guest_lookup_password_confirmation: _global?.currentUser?.uuid
|
||||
? null
|
||||
: (_local?.guestLookupPasswordConfirmation ?? ''),
|
||||
refund_bank: _local?.refundBankCode
|
||||
? {
|
||||
bank_code: _local?.refundBankCode,
|
||||
account_number: _local?.refundBankAccount ?? null,
|
||||
holder: _local?.refundBankHolder ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const LEGACY_KEYS = Object.keys(expectedLegacy({}));
|
||||
|
||||
const baseCtx = (over: Record<string, any> = {}) => ({
|
||||
checkoutData: {
|
||||
data: {
|
||||
temp_order_id: 'TMP-1',
|
||||
calculation: { summary: { final_amount: 53000 } },
|
||||
},
|
||||
},
|
||||
_computed: {
|
||||
ordererDefaults: { name: '홍길동', phone: '01012345678', email: 'a@b.c' },
|
||||
selectedPaymentMethod: 'dbank',
|
||||
},
|
||||
_local: {
|
||||
shipping: { recipient_name: '홍길동' },
|
||||
shippingMemo: 'custom',
|
||||
shippingMemoCustom: '문 앞에 두세요',
|
||||
selectedDbank: { bank_code: '004', account_number: '110-123', account_holder: '시르소프트' },
|
||||
saveShippingAddress: true,
|
||||
guestLookupPassword: 'pw12345678',
|
||||
guestLookupPasswordConfirmation: 'pw12345678',
|
||||
},
|
||||
_global: { currentUser: null },
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('주문 생성 body — 확장 병합 칸 계약', () => {
|
||||
it('주문 생성 POST apiCall 노드가 존재해야 한다', () => {
|
||||
expect(orderCall, '주문 생성 POST apiCall 노드').toBeDefined();
|
||||
});
|
||||
|
||||
it('body 는 통짜 표현식이어야 한다 (확장 칸 spread 를 담기 위함)', () => {
|
||||
expect(typeof orderCall.params.body).toBe('string');
|
||||
expect(orderCall.params.body).toContain('checkoutExtraPayload');
|
||||
});
|
||||
|
||||
describe('전환 동등성 — 기존 키 산출값 보존', () => {
|
||||
const cases: Array<[string, Record<string, any>]> = [
|
||||
['dbank + 비회원 (기본)', baseCtx()],
|
||||
[
|
||||
'card + 회원 — dbank=null · guest 비밀번호 null 분기',
|
||||
baseCtx({
|
||||
_computed: {
|
||||
ordererDefaults: { name: '김철수', phone: '', email: '' },
|
||||
selectedPaymentMethod: 'card',
|
||||
},
|
||||
_global: { currentUser: { uuid: 'user-uuid-1' } },
|
||||
}),
|
||||
],
|
||||
[
|
||||
'shipping_memo 비-custom 분기',
|
||||
baseCtx({ _local: { ...baseCtx()._local, shippingMemo: 'door', shippingMemoCustom: '무시됨' } }),
|
||||
],
|
||||
[
|
||||
'depositor_name 미입력 → ordererDefaults.name 폴백',
|
||||
baseCtx({ _local: { ...baseCtx()._local, depositorName: undefined } }),
|
||||
],
|
||||
[
|
||||
'초기 진입 (빈 _local · calculation 없음)',
|
||||
{
|
||||
checkoutData: { data: { temp_order_id: null, calculation: null } },
|
||||
_computed: { ordererDefaults: undefined, selectedPaymentMethod: 'vbank' },
|
||||
_local: {},
|
||||
_global: {},
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
for (const [name, ctx] of cases) {
|
||||
it(`[${name}] 통짜 평가 결과가 전환 전 산출값과 같다`, () => {
|
||||
expect(evalBody(ctx)).toEqual(expectedLegacy(ctx));
|
||||
});
|
||||
}
|
||||
|
||||
it('expected_total_amount 는 숫자형을 보존한다 (문자열 보간 금지)', () => {
|
||||
expect(typeof evalBody(baseCtx()).expected_total_amount).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('확장 병합 칸 (checkoutExtraPayload)', () => {
|
||||
it('모듈 비활성(칸 미주입) 시 payload 가 전환 전과 키·값 모두 동일하다', () => {
|
||||
const ctx = baseCtx();
|
||||
const body = evalBody(ctx);
|
||||
expect(Object.keys(body).sort()).toEqual(LEGACY_KEYS.sort());
|
||||
expect(body).toEqual(expectedLegacy(ctx));
|
||||
});
|
||||
|
||||
it('칸에 기입한 키가 payload 에 편입되고 기존 키는 불변이다', () => {
|
||||
const ctx = baseCtx();
|
||||
(ctx._local as Record<string, any>).checkoutExtraPayload = {
|
||||
cash_receipt_requested: true,
|
||||
cash_receipt_type: 'income',
|
||||
cash_receipt_identifier_type: 'phone',
|
||||
cash_receipt_identifier: '01012345678',
|
||||
};
|
||||
|
||||
const body = evalBody(ctx);
|
||||
expect(body.cash_receipt_requested).toBe(true);
|
||||
expect(body.cash_receipt_type).toBe('income');
|
||||
expect(body.cash_receipt_identifier_type).toBe('phone');
|
||||
expect(body.cash_receipt_identifier).toBe('01012345678');
|
||||
|
||||
for (const key of LEGACY_KEYS) {
|
||||
expect(body[key]).toEqual(expectedLegacy(ctx)[key]);
|
||||
}
|
||||
});
|
||||
|
||||
it('확장 칸은 기존 키를 덮어쓸 수 없어야 한다 — spread 가 뒤에 오므로 덮어쓰기가 가능함을 명시 고정', () => {
|
||||
const ctx = baseCtx();
|
||||
(ctx._local as Record<string, any>).checkoutExtraPayload = { payment_method: 'HIJACKED' };
|
||||
// 현재 계약: 확장 칸이 뒤에 spread 되므로 덮어쓴다.
|
||||
// 이 동작을 '알고 있는 것'으로 고정한다 — 모듈은 자기 네임스페이스 키만 써야 한다.
|
||||
expect(evalBody(ctx).payment_method).toBe('HIJACKED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('환불계좌 (템플릿 직접 소유 — 확장 칸 미경유)', () => {
|
||||
it('미입력 시 refund_bank 는 null 이다', () => {
|
||||
expect(evalBody(baseCtx()).refund_bank).toBeNull();
|
||||
});
|
||||
|
||||
it('입력 시 3필드가 그대로 실린다', () => {
|
||||
const ctx = baseCtx({
|
||||
_local: {
|
||||
...baseCtx()._local,
|
||||
refundBankCode: '004',
|
||||
refundBankAccount: '110-123-456789',
|
||||
refundBankHolder: '홍길동',
|
||||
},
|
||||
});
|
||||
expect(evalBody(ctx).refund_bank).toEqual({
|
||||
bank_code: '004',
|
||||
account_number: '110-123-456789',
|
||||
holder: '홍길동',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file mypageOrderPaymentPanel.test.tsx
|
||||
* @description 유저 주문상세 결제 패널의 확장 앵커 검증 (#454 S3).
|
||||
*
|
||||
* 두 가지를 고정한다:
|
||||
* 1. 루트 Div 의 id = "order_payment_info_panel"
|
||||
* KG 플러그인(resources/extensions/user_order_show.json)이 이 id 를 target_id 로 지정하고
|
||||
* mypageOrderShowInjector.ts 가 getElementById 로 찾는다. 그동안 이 노드에 id 가 없어
|
||||
* 주입이 조용히 no-op 이었다 — 본 이슈가 부수 복구한다(R1). id 를 지우면 KG 세금내역·
|
||||
* 영수증 링크·에스크로 확인 버튼이 한꺼번에 사라진다.
|
||||
* 2. 말단 extension_point "mypage_order_cash_receipt_slot"
|
||||
* 이커머스 모듈이 현금영수증 카드를 주입하는 자리.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import paymentPartialJson from '../../layouts/partials/mypage/orders/_payment.json';
|
||||
import kgUserOrderShowJson from '../../../../../plugins/_bundled/sirsoft-pay_kginicis/resources/extensions/user_order_show.json';
|
||||
|
||||
function flatten(node: any, out: any[] = []): any[] {
|
||||
if (node == null || typeof node !== 'object') return out;
|
||||
out.push(node);
|
||||
for (const value of Object.values(node)) {
|
||||
if (Array.isArray(value)) value.forEach((v) => flatten(v, out));
|
||||
else if (value && typeof value === 'object') flatten(value, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('유저 주문상세 결제 패널 — 확장 앵커', () => {
|
||||
it('루트 Div 에 KG 가 기대하는 id 가 부여되어야 한다 (R1 no-op 주입 복구)', () => {
|
||||
expect(paymentPartialJson.id).toBe('order_payment_info_panel');
|
||||
});
|
||||
|
||||
it('KG overlay 의 target_id 와 실제 앵커 id 가 일치해야 한다', () => {
|
||||
const injection = (kgUserOrderShowJson as any).injections[0];
|
||||
expect(injection.target_id).toBe(paymentPartialJson.id);
|
||||
// append_child = 패널 내부 말단에 붙는다 → 앵커가 컨테이너여야 한다
|
||||
expect(injection.position).toBe('append_child');
|
||||
expect(Array.isArray(paymentPartialJson.children)).toBe(true);
|
||||
});
|
||||
|
||||
it('현금영수증 확장 슬롯이 패널 말단에 존재해야 한다', () => {
|
||||
const slots = flatten(paymentPartialJson).filter((n) => n.type === 'extension_point');
|
||||
expect(slots.map((s) => s.name)).toContain('mypage_order_cash_receipt_slot');
|
||||
|
||||
const slot = slots.find((s) => s.name === 'mypage_order_cash_receipt_slot');
|
||||
expect(slot.default).toEqual([]);
|
||||
});
|
||||
|
||||
it('슬롯은 결제 정보 목록의 마지막 자식이어야 한다 (결제수단 행 아래)', () => {
|
||||
const infoList = (paymentPartialJson.children as any[]).find(
|
||||
(c) => (c.props?.className ?? '').includes('space-y-3')
|
||||
);
|
||||
const last = infoList.children[infoList.children.length - 1];
|
||||
expect(last.type).toBe('extension_point');
|
||||
expect(last.name).toBe('mypage_order_cash_receipt_slot');
|
||||
});
|
||||
|
||||
it('템플릿은 현금영수증 도메인 필드를 직접 그리지 않는다 (모듈 소유)', () => {
|
||||
// 슬롯 이름/주석에는 'cash_receipt' 가 들어간다(정상). 금지 대상은 도메인 값 바인딩이다.
|
||||
const bindings = flatten(paymentPartialJson)
|
||||
.filter((n) => n.type !== 'extension_point')
|
||||
.flatMap((n) => [n.text, ...Object.values(n.props ?? {})])
|
||||
.filter((v): v is string => typeof v === 'string');
|
||||
|
||||
for (const b of bindings) {
|
||||
expect(b, `템플릿이 현금영수증 필드를 직접 바인딩 → ${b}`).not.toContain('cash_receipt');
|
||||
}
|
||||
});
|
||||
|
||||
it('id 부여가 기존 responsive.portable 규약을 깨지 않는다', () => {
|
||||
expect(Object.keys(paymentPartialJson.responsive)).toEqual(['portable']);
|
||||
});
|
||||
});
|
||||
+22
-5
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DataBindingEngine } from '@core/template-engine/DataBindingEngine';
|
||||
import purchaseCardJson from '../../layouts/partials/shop/detail/_purchase_card.json';
|
||||
import cartSummaryJson from '../../layouts/partials/shop/_cart_summary.json';
|
||||
import loginJson from '../../layouts/auth/login.json';
|
||||
@@ -174,11 +175,27 @@ describe('결제하기 엔드포인트 분기 - _checkout_summary (Issue #55)',
|
||||
});
|
||||
|
||||
it('body 에 비회원 조회 비밀번호 + 확인이 비로그인일 때만 값으로 전송되어야 한다', () => {
|
||||
const body = ordersApiCall.params?.body ?? {};
|
||||
// 회원이면 null, 비회원이면 _local.guestLookupPassword 값
|
||||
expect(body.guest_lookup_password).toContain('_global.currentUser?.uuid');
|
||||
expect(body.guest_lookup_password).toContain('_local.guestLookupPassword');
|
||||
expect(body.guest_lookup_password_confirmation).toContain('_local.guestLookupPasswordConfirmation');
|
||||
// body 는 통짜 표현식이다(#454 확장 병합 칸). 표현식 문자열을 substring 으로 훑는 대신
|
||||
// 실제로 평가해 "회원=null / 비회원=입력값" 동작 자체를 고정한다.
|
||||
const engine = new DataBindingEngine();
|
||||
const evalBody = (currentUser: unknown) =>
|
||||
engine.evaluateExpression(String(ordersApiCall.params.body).slice(2, -2), {
|
||||
checkoutData: { data: { temp_order_id: 'TMP-1', calculation: null } },
|
||||
_computed: { ordererDefaults: {}, selectedPaymentMethod: 'card' },
|
||||
_local: {
|
||||
guestLookupPassword: 'pw12345678',
|
||||
guestLookupPasswordConfirmation: 'pw12345678',
|
||||
},
|
||||
_global: { currentUser },
|
||||
});
|
||||
|
||||
const guest = evalBody(null);
|
||||
expect(guest.guest_lookup_password).toBe('pw12345678');
|
||||
expect(guest.guest_lookup_password_confirmation).toBe('pw12345678');
|
||||
|
||||
const member = evalBody({ uuid: 'user-uuid-1' });
|
||||
expect(member.guest_lookup_password).toBeNull();
|
||||
expect(member.guest_lookup_password_confirmation).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -489,7 +489,12 @@
|
||||
"payment_order_not_found": "Order not found.",
|
||||
"payment_generic_error": "An error occurred during payment. Please try again.",
|
||||
"coupon_download_btn": "Download Coupons",
|
||||
"guest_token_issue_failed": "Could not prepare guest order lookup. Please re-verify with your phone and password when viewing your order."
|
||||
"guest_token_issue_failed": "Could not prepare guest order lookup. Please re-verify with your phone and password when viewing your order.",
|
||||
"refund_bank_title": "Refund Account (used on cancellation, optional)",
|
||||
"refund_bank_select": "Select bank",
|
||||
"refund_bank_account_placeholder": "Account number (no hyphens)",
|
||||
"refund_bank_holder_placeholder": "Account holder",
|
||||
"refund_bank_partial_notice": "If you fill in any one of the three fields, the others are required."
|
||||
},
|
||||
"order_complete": {
|
||||
"page_title": "Order Complete",
|
||||
|
||||
@@ -489,7 +489,12 @@
|
||||
"payment_order_not_found": "주문을 찾을 수 없습니다.",
|
||||
"payment_generic_error": "결제 처리 중 오류가 발생했습니다. 다시 시도해 주세요.",
|
||||
"coupon_download_btn": "쿠폰 다운로드",
|
||||
"guest_token_issue_failed": "비회원 조회 인증 준비에 실패했습니다. 주문 조회 시 휴대폰과 비밀번호로 다시 확인해 주세요."
|
||||
"guest_token_issue_failed": "비회원 조회 인증 준비에 실패했습니다. 주문 조회 시 휴대폰과 비밀번호로 다시 확인해 주세요.",
|
||||
"refund_bank_title": "환불 계좌 (주문 취소 시 사용, 선택)",
|
||||
"refund_bank_select": "은행 선택",
|
||||
"refund_bank_account_placeholder": "계좌번호 (- 없이 입력)",
|
||||
"refund_bank_holder_placeholder": "예금주",
|
||||
"refund_bank_partial_notice": "셋 중 하나라도 입력하면 나머지도 입력해야 합니다."
|
||||
},
|
||||
"order_complete": {
|
||||
"page_title": "주문 완료",
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
"is_partial": true,
|
||||
"description": "주문 상세 - 결제 정보"
|
||||
},
|
||||
"_comment_id": "id 는 확장의 overlay 앵커다. KG 플러그인의 resources/extensions/user_order_show.json 이 target_id 로 이 이름을 지정하고 mypageOrderShowInjector.ts 가 getElementById 로 찾는데, 그동안 이 노드에 id 가 없어 주입이 조용히 실패하고 있었다(#454 에서 부수 복구).",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"id": "order_payment_info_panel",
|
||||
"props": { "className": "bg-white dark:bg-gray-800 rounded-lg p-6" },
|
||||
"responsive": {
|
||||
"portable": {
|
||||
@@ -26,7 +28,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "credit-card", "className": "w-5 h-5 text-gray-700 dark:text-gray-300" }
|
||||
"props": { "name": "credit-card", "className": "text-xl text-gray-700 dark:text-gray-300" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -135,7 +137,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "ticket", "className": "w-3.5 h-3.5 text-red-400 dark:text-red-500" }
|
||||
"props": { "name": "ticket", "className": "text-sm text-red-400 dark:text-red-500" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -174,7 +176,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "ticket", "className": "w-3 h-3 text-red-400 dark:text-red-500" }
|
||||
"props": { "name": "ticket", "className": "text-xs text-red-400 dark:text-red-500" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -209,7 +211,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "ticket", "className": "w-3.5 h-3.5 text-red-400 dark:text-red-500" }
|
||||
"props": { "name": "ticket", "className": "text-sm text-red-400 dark:text-red-500" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -248,7 +250,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "ticket", "className": "w-3 h-3 text-red-400 dark:text-red-500" }
|
||||
"props": { "name": "ticket", "className": "text-xs text-red-400 dark:text-red-500" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -283,7 +285,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "tag", "className": "w-3.5 h-3.5 text-orange-400 dark:text-orange-500" }
|
||||
"props": { "name": "tag", "className": "text-sm text-orange-400 dark:text-orange-500" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -322,7 +324,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "tag", "className": "w-3 h-3 text-orange-400 dark:text-orange-500" }
|
||||
"props": { "name": "tag", "className": "text-xs text-orange-400 dark:text-orange-500" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -412,7 +414,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "ticket", "className": "w-3.5 h-3.5 text-red-400 dark:text-red-500" }
|
||||
"props": { "name": "ticket", "className": "text-sm text-red-400 dark:text-red-500" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -619,6 +621,12 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "현금영수증 카드 확장 슬롯 — 이커머스 모듈이 resources/extensions/mypage_order_cash_receipt.json 으로 주입한다. 결제수단/프로바이더/발급상태에 따른 표시 분기는 주입 측이 if 로 처리한다(무통장이 아니거나 프로바이더 미설정이면 카드 자체 미렌더).",
|
||||
"type": "extension_point",
|
||||
"name": "mypage_order_cash_receipt_slot",
|
||||
"default": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
},
|
||||
"props": {
|
||||
"type": "button",
|
||||
"data-testid": "checkout-payment-method-{{method.id}}",
|
||||
"className": "{{_computed.selectedPaymentMethod === method.id ? 'p-4 border-2 border-gray-900 dark:border-white rounded-lg text-left' : 'p-4 border border-gray-300 dark:border-gray-600 rounded-lg text-left hover:border-gray-400 dark:hover:border-gray-500'}}"
|
||||
},
|
||||
"responsive": {
|
||||
@@ -246,6 +247,119 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "환불 계좌 (선택 입력) — 가상계좌 입금완료 건의 환불은 PG(토스 등)가 refundReceiveAccount 를 필수로 요구한다. 여기서 미입력이면 관리자 취소 모달에서 다시 받는다. 3필드 중 하나라도 채우면 나머지도 필수(CreateOrderRequest::withValidator 의 required_with).",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"id": "checkout_refund_bank_vbank",
|
||||
"props": { "className": "mt-4 pt-4 border-t border-blue-200 dark:border-blue-800" },
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": { "className": "mt-3 pt-3 border-t border-blue-200 dark:border-blue-800" }
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" },
|
||||
"text": "$t:shop.checkout.refund_bank_title"
|
||||
},
|
||||
{
|
||||
"comment": "은행 / 계좌번호 2열 → portable 1열 (마크업 구조 변경이므로 responsive 속성 사용, Tailwind breakpoint 금지)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "grid grid-cols-2 gap-3" },
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": { "className": "grid grid-cols-1 gap-3" }
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "refund_bank_code",
|
||||
"value": "{{_local.refundBankCode ?? ''}}",
|
||||
"options": "{{[{ value: '', label: $t('shop.checkout.refund_bank_select') }, ...((paymentSettings.data?.order_settings?.banks ?? []).map(b => ({ value: b.code, label: $localized(b.name) ?? b.code })))]}}",
|
||||
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": { "target": "local", "refundBankCode": "{{$event.target.value}}" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "text",
|
||||
"name": "refund_bank_account",
|
||||
"value": "{{_local.refundBankAccount ?? ''}}",
|
||||
"placeholder": "$t:shop.checkout.refund_bank_account_placeholder",
|
||||
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": { "target": "local", "refundBankAccount": "{{$event.target.value}}" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "text",
|
||||
"name": "refund_bank_holder",
|
||||
"value": "{{_local.refundBankHolder ?? ''}}",
|
||||
"placeholder": "$t:shop.checkout.refund_bank_holder_placeholder",
|
||||
"className": "w-full mt-3 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "w-full mt-3 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": { "target": "local", "refundBankHolder": "{{$event.target.value}}" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-2" },
|
||||
"text": "$t:shop.checkout.refund_bank_partial_notice"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -422,6 +536,125 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "환불 계좌 (선택 입력) — 무통장은 PG 를 경유하지 않아 관리자가 수동 이체한다. 여기 입력된 계좌가 그 대상이다. 3필드 중 하나라도 채우면 나머지도 필수(CreateOrderRequest::withValidator 의 required_with).",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"id": "checkout_refund_bank_dbank",
|
||||
"props": { "className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-600" },
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": { "className": "mt-3 pt-3 border-t border-gray-200 dark:border-gray-600" }
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" },
|
||||
"text": "$t:shop.checkout.refund_bank_title"
|
||||
},
|
||||
{
|
||||
"comment": "은행 / 계좌번호 2열 → portable 1열 (마크업 구조 변경이므로 responsive 속성 사용, Tailwind breakpoint 금지)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "grid grid-cols-2 gap-3" },
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": { "className": "grid grid-cols-1 gap-3" }
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "refund_bank_code",
|
||||
"value": "{{_local.refundBankCode ?? ''}}",
|
||||
"options": "{{[{ value: '', label: $t('shop.checkout.refund_bank_select') }, ...((paymentSettings.data?.order_settings?.banks ?? []).map(b => ({ value: b.code, label: $localized(b.name) ?? b.code })))]}}",
|
||||
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": { "target": "local", "refundBankCode": "{{$event.target.value}}" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "text",
|
||||
"name": "refund_bank_account",
|
||||
"value": "{{_local.refundBankAccount ?? ''}}",
|
||||
"placeholder": "$t:shop.checkout.refund_bank_account_placeholder",
|
||||
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": { "target": "local", "refundBankAccount": "{{$event.target.value}}" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "text",
|
||||
"name": "refund_bank_holder",
|
||||
"value": "{{_local.refundBankHolder ?? ''}}",
|
||||
"placeholder": "$t:shop.checkout.refund_bank_holder_placeholder",
|
||||
"className": "w-full mt-3 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "w-full mt-3 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": { "target": "local", "refundBankHolder": "{{$event.target.value}}" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-2" },
|
||||
"text": "$t:shop.checkout.refund_bank_partial_notice"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "현금영수증 신청 폼 확장 슬롯 — 이커머스 모듈이 resources/extensions/checkout_cash_receipt.json 으로 주입한다. 슬롯이 dbank 블록 내부에 있으므로 결제수단이 무통장일 때만 렌더되며 별도 if 가 필요 없다. 발급 프로바이더는 PG 플러그인이 담당하지만 신청 폼 자체는 PG 비종속이라 모듈이 소유한다(플러그인이 주입하면 두 PG 동시 활성 시 폼이 2개 렌더된다). 주입된 폼은 _local.checkoutExtraPayload 에 자기 필드를 써야 주문 생성 payload 에 실린다 — _checkout_summary.json 의 body 확장 병합 칸 참조.",
|
||||
"type": "extension_point",
|
||||
"name": "shop_checkout_cash_receipt_slot",
|
||||
"default": []
|
||||
},
|
||||
{
|
||||
"comment": "입금 기한 안내 — 가상계좌 안내와 동일 규칙(서버 resolveAutoCancelDays 일치). 빈 문자열/0/음수/비숫자는 기본치 3 으로 되돌린다.",
|
||||
"type": "basic",
|
||||
|
||||
@@ -453,6 +453,7 @@
|
||||
},
|
||||
{
|
||||
"comment": "결제 엔드포인트 — 회원/비회원 공유 단일 endpoint (optional.sanctum). PG 플러그인 fetch 인터셉터가 /user/orders 한 경로만 매칭하므로 비회원도 같은 endpoint 로 호출해 PG 결제창이 정상 노출되도록 한다. 회원/비회원 분기는 백엔드 컨트롤러가 처리.",
|
||||
"comment_body": "params.body 는 통짜 표현식이다. 말미의 ...(_local.checkoutExtraPayload ?? {}) 는 슬롯에 주입된 모듈/플러그인 폼이 자기 필드를 주문 생성 payload 에 실어 보내기 위한 확장 병합 칸이다. 주입분은 이 body 리터럴을 수정할 수단이 없으므로(결제 버튼에 id 부재 → overlay target 불가, inject_props 는 컴포넌트 props 전용이라 actions[].params.body 미도달) 이 칸이 유일한 경로다. 확장은 자기 도메인 키만 쓴다 — spread 가 뒤에 오므로 기존 키를 덮어쓸 수 있다. 확장 미주입 시 {} 이므로 payload 는 전환 전과 키·값 모두 동일하다. refund_bank 는 템플릿 소유 필드라 확장 칸을 경유하지 않고 직접 기재한다. 회귀 pin: __tests__/layouts/checkoutSummaryBodyPayload.test.tsx",
|
||||
"handler": "apiCall",
|
||||
"target": "/api/modules/sirsoft-ecommerce/user/orders",
|
||||
"auth_mode": "optional",
|
||||
@@ -463,19 +464,7 @@
|
||||
"params": {
|
||||
"method": "POST",
|
||||
"headers": { "X-Cart-Key": "{{_global.cartKey}}" },
|
||||
"body": {
|
||||
"temp_order_id": "{{checkoutData.data.temp_order_id}}",
|
||||
"orderer": "{{_computed.ordererDefaults}}",
|
||||
"shipping": "{{_local.shipping}}",
|
||||
"payment_method": "{{_computed.selectedPaymentMethod}}",
|
||||
"shipping_memo": "{{_local.shippingMemo === 'custom' ? _local.shippingMemoCustom : _local.shippingMemo}}",
|
||||
"depositor_name": "{{_local.depositorName ?? _computed.ordererDefaults?.name ?? ''}}",
|
||||
"dbank": "{{_computed.selectedPaymentMethod === 'dbank' ? { bank_code: _local.selectedDbank?.bank_code, account_number: _local.selectedDbank?.account_number, account_holder: _local.selectedDbank?.account_holder } : null}}",
|
||||
"expected_total_amount": "{{checkoutData.data.calculation?.summary?.final_amount ?? 0}}",
|
||||
"save_shipping_address": "{{_local.saveShippingAddress ?? false}}",
|
||||
"guest_lookup_password": "{{_global.currentUser?.uuid ? null : (_local.guestLookupPassword ?? '')}}",
|
||||
"guest_lookup_password_confirmation": "{{_global.currentUser?.uuid ? null : (_local.guestLookupPasswordConfirmation ?? '')}}"
|
||||
}
|
||||
"body": "{{ ({ temp_order_id: checkoutData.data.temp_order_id, orderer: _computed.ordererDefaults, shipping: _local.shipping, payment_method: _computed.selectedPaymentMethod, shipping_memo: _local.shippingMemo === 'custom' ? _local.shippingMemoCustom : _local.shippingMemo, depositor_name: _local.depositorName ?? _computed.ordererDefaults?.name ?? '', dbank: _computed.selectedPaymentMethod === 'dbank' ? { bank_code: _local.selectedDbank?.bank_code, account_number: _local.selectedDbank?.account_number, account_holder: _local.selectedDbank?.account_holder } : null, expected_total_amount: checkoutData.data.calculation?.summary?.final_amount ?? 0, save_shipping_address: _local.saveShippingAddress ?? false, guest_lookup_password: _global.currentUser?.uuid ? null : (_local.guestLookupPassword ?? ''), guest_lookup_password_confirmation: _global.currentUser?.uuid ? null : (_local.guestLookupPasswordConfirmation ?? ''), refund_bank: _local.refundBankCode ? { bank_code: _local.refundBankCode, account_number: _local.refundBankAccount ?? null, holder: _local.refundBankHolder ?? null } : null, ...(_local.checkoutExtraPayload ?? {}) }) }}"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sirsoft-basic",
|
||||
"version": "1.0.4",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sirsoft-basic",
|
||||
"version": "1.0.4",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sirsoft-basic",
|
||||
"version": "1.0.4",
|
||||
"version": "1.1.0",
|
||||
"description": "Gnuboard7 Basic User Template Components - Nexibase Style",
|
||||
"type": "module",
|
||||
"main": "dist/components.js",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "Basic",
|
||||
"en": "Basic"
|
||||
},
|
||||
"version": "1.0.4",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "그누보드7 기본 사용자 템플릿",
|
||||
@@ -26,7 +26,7 @@
|
||||
"dependencies": {
|
||||
"modules": {
|
||||
"sirsoft-board": ">=1.0.0",
|
||||
"sirsoft-ecommerce": ">=1.0.5",
|
||||
"sirsoft-ecommerce": ">=1.1.0",
|
||||
"sirsoft-page": ">=1.0.0"
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
+20
-14
@@ -278,35 +278,41 @@ abstract class TestCase extends BaseTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* g7_testing DB의 좀비 커넥션을 정리합니다.
|
||||
* 테스트 DB 의 좀비 커넥션을 정리합니다.
|
||||
*
|
||||
* 현재 프로세스의 커넥션은 제외하고,
|
||||
* g7_testing DB에 연결된 다른 모든 커넥션을 KILL합니다.
|
||||
* 테스트 DB 에 연결된 다른 모든 커넥션을 KILL 합니다.
|
||||
*/
|
||||
private function killStaleTestingConnections(): void
|
||||
{
|
||||
// phpunit.xml에서 DB_DATABASE=g7_testing으로 설정됨
|
||||
$testingDb = config('database.connections.mysql.database');
|
||||
|
||||
// config('database.connections.mysql.database') 를 읽지 않는다 — mysql 커넥션은
|
||||
// read/write 분리 구조라 최상위 'database' 키가 존재하지 않아 항상 null 이 된다.
|
||||
// null 이면 아래 비교(`($process->db ?? '') === $testingDb`)가 어떤 커넥션과도
|
||||
// 매칭되지 않아, 좀비 정리가 조용히 전면 무력화된다(예외도 나지 않는다).
|
||||
// 실제 접속 DB 이름은 커넥션에 직접 묻는다(write 설정이 반영된 값).
|
||||
try {
|
||||
$currentId = DB::selectOne('SELECT CONNECTION_ID() as id')->id;
|
||||
$processes = DB::select('SHOW PROCESSLIST');
|
||||
$killed = 0;
|
||||
$testingDb = DB::connection()->getDatabaseName();
|
||||
|
||||
foreach ($processes as $process) {
|
||||
if ($testingDb === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentId = DB::selectOne('SELECT CONNECTION_ID() as id')->id;
|
||||
|
||||
// 정리 결과를 출력하지 않는다 — PHPUnit 은 테스트 실행 중의 예기치 않은 STDOUT/STDERR
|
||||
// 출력을 오류(`PHPUnit\Framework\Exception`)로 처리한다. 과거에는 대상 DB 판정이
|
||||
// null 이라 아무것도 KILL 하지 못해 출력이 없었고, 판정을 고치자 그 출력 때문에
|
||||
// 무관한 테스트들이 무더기로 깨졌다.
|
||||
// 실제 정리 여부는 tests/Unit/TestCaseKillStaleConnectionsTest 가 행동으로 검증한다.
|
||||
foreach (DB::select('SHOW PROCESSLIST') as $process) {
|
||||
if (($process->db ?? '') === $testingDb && $process->Id !== $currentId) {
|
||||
try {
|
||||
DB::statement('KILL '.$process->Id);
|
||||
$killed++;
|
||||
} catch (\Throwable) {
|
||||
// 이미 종료된 커넥션은 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($killed > 0) {
|
||||
fwrite(STDERR, "\n[TestCase] Killed {$killed} stale g7_testing connection(s)\n");
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// DB 연결 실패 시 무시 (첫 마이그레이션에서 처리됨)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PDO;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* TestCase::killStaleTestingConnections() 회귀 테스트.
|
||||
*
|
||||
* 배경: mysql 커넥션은 read/write 분리 구조(config/database.php)라 최상위 'database'
|
||||
* 키가 존재하지 않는다. 과거 구현은 `config('database.connections.mysql.database')` 를
|
||||
* 읽어 항상 null 을 얻었고, 그 결과
|
||||
*
|
||||
* if (($process->db ?? '') === $testingDb && ...) // $testingDb === null
|
||||
*
|
||||
* 조건이 어떤 커넥션과도 매칭되지 않았다(`'' === null` 도, `'g7_testing' === null` 도 false).
|
||||
* 즉 좀비 커넥션 정리가 전면 무력화된 채로 조용히 통과하고 있었다. 정리 실패는 예외를
|
||||
* 던지지 않으므로 아무도 눈치채지 못한다.
|
||||
*
|
||||
* 본 테스트는 테스트 DB 에 붙은 별도 커넥션(좀비 대역)을 만들어 두고 메서드를 실행해,
|
||||
* 그 커넥션이 실제로 KILL 되는지를 확인한다. 판정값이 null 로 회귀하면 좀비가 살아남아 red.
|
||||
*/
|
||||
class TestCaseKillStaleConnectionsTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 테스트 DB 에 붙은 별도 PDO 커넥션을 만듭니다 (좀비 대역).
|
||||
*
|
||||
* @return array{0: PDO, 1: int} PDO 와 그 커넥션 ID
|
||||
*/
|
||||
private function openZombie(): array
|
||||
{
|
||||
$c = config('database.connections.mysql.write');
|
||||
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s', $c['host'][0] ?? $c['host'], $c['port'], $c['database']),
|
||||
$c['username'],
|
||||
$c['password'],
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
$id = (int) $pdo->query('SELECT CONNECTION_ID()')->fetchColumn();
|
||||
|
||||
return [$pdo, $id];
|
||||
}
|
||||
|
||||
/**
|
||||
* PROCESSLIST 에 해당 커넥션 ID 가 남아 있는지 확인합니다.
|
||||
*/
|
||||
private function connectionAlive(int $id): bool
|
||||
{
|
||||
foreach (DB::select('SHOW PROCESSLIST') as $p) {
|
||||
if ((int) $p->Id === $id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function invokeKiller(): void
|
||||
{
|
||||
$m = new ReflectionMethod(TestCase::class, 'killStaleTestingConnections');
|
||||
$m->setAccessible(true);
|
||||
$m->invoke($this);
|
||||
}
|
||||
|
||||
public function test_좀비는_정리하고_자기_커넥션은_살려둔다(): void
|
||||
{
|
||||
$testingDb = DB::connection()->getDatabaseName();
|
||||
$selfId = (int) DB::selectOne('SELECT CONNECTION_ID() AS id')->id;
|
||||
[$pdo, $zombieId] = $this->openZombie();
|
||||
|
||||
$this->assertTrue($this->connectionAlive($zombieId), '좀비 대역이 만들어져야 한다');
|
||||
$this->assertNotSame($selfId, $zombieId, '좀비는 자기 커넥션과 달라야 한다');
|
||||
|
||||
$this->invokeKiller();
|
||||
|
||||
$this->assertFalse(
|
||||
$this->connectionAlive($zombieId),
|
||||
'테스트 DB 에 붙은 좀비 커넥션이 정리되어야 한다 '
|
||||
.'(대상 DB 판정이 null 이면 어떤 행과도 매칭되지 않아 살아남는다)'
|
||||
);
|
||||
|
||||
// 자기 커넥션은 KILL 대상에서 제외되므로 정리 직후에도 쿼리가 가능해야 한다.
|
||||
// (연결 ID 동일성으로 단정하지 않는다 — 다른 커넥션을 KILL 하면 Laravel 이
|
||||
// 재연결하며 ID 가 바뀔 수 있고, 그것은 결함이 아니다.)
|
||||
$this->assertSame(
|
||||
$testingDb,
|
||||
DB::selectOne('SELECT DATABASE() AS d')->d,
|
||||
'정리 직후에도 자기 커넥션으로 후속 쿼리가 가능해야 한다'
|
||||
);
|
||||
|
||||
unset($pdo);
|
||||
}
|
||||
|
||||
public function test_대상_db_판정이_실제_접속_db와_일치한다(): void
|
||||
{
|
||||
$target = DB::connection()->getDatabaseName();
|
||||
$actual = DB::selectOne('SELECT DATABASE() AS d')->d;
|
||||
|
||||
$this->assertNotSame('', $target, '대상 DB 가 비면 좀비 정리가 무력화된다');
|
||||
$this->assertSame($actual, $target, '판정값이 실제 접속 DB 와 달라서는 안 된다');
|
||||
|
||||
// 과거 구현이 읽던 최상위 키는 존재하지 않는다(구조적 사실).
|
||||
$this->assertNull(
|
||||
config('database.connections.mysql.database'),
|
||||
'read/write 분리 커넥션에는 최상위 database 키가 없다'
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user