diff --git a/docs/extension/layout-extensions.md b/docs/extension/layout-extensions.md index 892f4f9d..85a1353c 100644 --- a/docs/extension/layout-extensions.md +++ b/docs/extension/layout-extensions.md @@ -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` | + --- ## 템플릿 오버라이드 diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 6306a1a9..134cca0b 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -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 diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/cash_receipt.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/cash_receipt.php index 716f69aa..8f7e0616 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/cash_receipt.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/cash_receipt.php @@ -58,4 +58,9 @@ return [ 'reissued' => '現金領収書が再発行されました。', 'status_retrieved' => '現金領収書情報を照会しました。', ], + 'result_status' => [ + 'in_progress' => '処理中', + 'completed' => '完了', + 'failed' => '失敗', + ], ]; diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php index bc43b2ab..f855125a 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php @@ -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' => '変更する注文を選択してください。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/ja.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/ja.json index 51332e91..e1cfb0bb 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/ja.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/ja.json @@ -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": "発行後は用途を変更することはできません。" + } } } diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/order.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/order.json index 8f8acd1a..0d8b31b2 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/order.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/order.json @@ -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": { diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json index 16f3a899..d756c545 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json @@ -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": { diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/order.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/order.json index e1646e17..e03f4d91 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/order.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/order.json @@ -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": "返金に伴う現金領収証の再発行に失敗しました。カスタマーサポートにお問い合わせください。" } } diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md index a4a499f9..8861490a 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md @@ -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 diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/shop.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/shop.json index 97ce15d7..35b48d2a 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/shop.json +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/shop.json @@ -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": "注文完了", diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json index 66c77bb3..450ac498 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json @@ -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", diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index d7d519ed..c64d29c1 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -29,6 +29,18 @@ - 현금영수증 식별번호(휴대폰번호·사업자등록번호 등)는 재발급에 쓸 수 있도록 암호화해 보관하고 구매확정 시점에 폐기합니다. 이력에는 뒤 4자리를 제외한 마스킹 값만 남으며, 발급 업체가 보내온 응답에 섞여 오는 개인정보도 가려서 저장합니다. 주민등록번호는 수집하지 않습니다. - 결제 영수증(카드 매출전표 등)과 현금영수증을 각각 따로 조회할 수 있습니다. 두 영수증은 서로 다른 값으로 보관되어 한쪽이 다른 쪽을 덮어쓰지 않습니다. +#### 신청·발급 화면 + +- 주문서의 무통장입금 결제 영역에서 현금영수증을 신청할 수 있습니다. 용도(소득공제/지출증빙)에 따라 고를 수 있는 발급 수단이 바뀌며, 그 용도에 쓸 수 없는 수단이 선택되어 있으면 자동으로 되돌립니다. 발급 업체를 설정하지 않은 상점에서는 신청란이 나타나지 않습니다. +- 구매자 주문 상세에 현금영수증 카드가 표시됩니다. 입금 전에는 안내만, 입금 후 미발급이면 발급 버튼, 발급 후에는 발급일시·용도·식별번호와 영수증 보기 링크가 나타납니다. 환불에 따른 재발급이 실패한 경우에는 안내 문구가 표시됩니다. +- 관리자 주문 상세에 현금영수증 카드가 표시됩니다. 발급·발급취소·수동 재발급을 할 수 있고 발급·취소 이력을 함께 볼 수 있습니다. 재발급이 실패한 주문에는 경고와 함께 다시 발급하는 버튼이 나타납니다. +- 이커머스 환경설정 주문설정 탭에서 현금영수증 발급 업체, 배송비 과세 방식, 자진발급 사용 여부를 설정할 수 있습니다. + +#### 환불 계좌 + +- 주문서의 무통장입금·가상계좌 결제 영역에서 환불받을 계좌(은행·계좌번호·예금주)를 미리 입력할 수 있습니다. 입력하지 않아도 주문할 수 있지만, 세 칸 중 하나라도 입력하면 나머지도 입력해야 합니다. +- 관리자 주문 취소 화면에서 환불 계좌를 입력하거나 수정할 수 있습니다. 주문할 때 입력한 계좌가 있으면 미리 채워집니다. 입금이 완료된 가상계좌 주문은 환불에 계좌가 반드시 필요하므로 입력하지 않으면 취소할 수 없습니다. 무통장입금은 관리자가 직접 이체할 때 참고하도록 선택 입력이며, 카드·간편결제는 원래 결제수단으로 환불되므로 계좌란이 나타나지 않습니다. + #### 금액·과세 - 주문에 현금성 금액이 기록됩니다. 무통장입금 주문에서 마일리지를 사용한 경우, 실제로 계좌에 입금된 금액만 현금영수증 발급 대상이 됩니다. 주문을 일부 취소하면 남은 실입금액 기준으로 다시 계산됩니다. @@ -157,6 +169,7 @@ - 관리자 목록에서 기록이 아주 많아 총 건수를 끝까지 세지 못하면 "1페이지뿐" 으로 표시되어 뒤쪽 페이지를 볼 수 없던 문제를 수정했습니다. 주문·상품·마일리지·쿠폰·쿠폰 발급 이력·상품 리뷰·배송 정책·활동 이력 목록이 해당하며, 마지막 페이지로 바로 뛰는 버튼만 감춰지고 이전·다음 이동은 그대로 동작합니다. - 쿠폰 사용조건의 상품 검색에서 결과가 많을 때 목록 끝까지 스크롤해도 다음 묶음이 더 불러와지지 않던 문제를 수정했습니다. - 관리자가 주문상품을 일괄 구매확정할 때와 구매자가 직접 구매확정할 때, 구매확정 시점이 최초 확정 시각으로 동일하게 보존됩니다 — 이미 확정된 주문을 다시 확정 처리해도 시점이 덮어써지지 않습니다. +- 구매자 주문 상세의 결제 정보 영역에 결제 플러그인이 제공하는 공급가액·부가세·영수증 링크·에스크로 구매확정 버튼이 표시되지 않던 문제를 수정했습니다. ## [1.0.4] - 2026-07-16 diff --git a/modules/_bundled/sirsoft-ecommerce/components.json b/modules/_bundled/sirsoft-ecommerce/components.json index 8988446a..d0e9b462 100644 --- a/modules/_bundled/sirsoft-ecommerce/components.json +++ b/modules/_bundled/sirsoft-ecommerce/components.json @@ -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": [], diff --git a/modules/_bundled/sirsoft-ecommerce/dist/js/module.iife.js b/modules/_bundled/sirsoft-ecommerce/dist/js/module.iife.js index 82e47720..efa3a1ce 100644 --- a/modules/_bundled/sirsoft-ecommerce/dist/js/module.iife.js +++ b/modules/_bundled/sirsoft-ecommerce/dist/js/module.iife.js @@ -1,2 +1,2 @@ -var SirsoftEcommerce=function(Pe){"use strict";const St={KRW:0,JPY:0},vt={KRW:"₩",USD:"$",JPY:"¥",CNY:"元",EUR:"€",GBP:"£"},Lt={KRW:1e3,JPY:100};function xt(r){if(!r)return 1;const c=r.base_unit??Lt[r.code]??1;return Math.max(1,c)}function Pt(r,c,e){const t=parseFloat(c)||1;if(t<=0)return r;const o=r/t;let a;switch(e){case"ceil":a=Math.ceil(o);break;case"floor":a=Math.floor(o);break;default:a=Math.round(o)}return a*t}function ye(r,c){const e=typeof c=="string"?{code:c}:c,t=e.code,o=e.symbol&&e.symbol.length>0?e.symbol:vt[t]??"",a=e.decimal_places??St[t]??2,n=r.toLocaleString(void 0,{minimumFractionDigits:a,maximumFractionDigits:a});return o?t==="KRW"||o==="₩"||o==="원"?n+"원":o+n:n+" "+t}function se(r,c){const{basePrice:e,currencies:t}=r,o=parseFloat(String(e))||0;if(!t||!Array.isArray(t)||t.length===0)return{};const a=t.find(i=>i.is_default),n=xt(a),s={};for(const i of t){const l=i.code;if(i.is_default??!1)s[l]={price:o,formatted:ye(o,i),is_default:!0,editable:!0};else if(i.exchange_rate&&i.exchange_rate>0){const d=o/n*i.exchange_rate,m=Pt(d,i.rounding_unit||"0.01",i.rounding_method||"round");s[l]={price:m,formatted:ye(m,i),is_default:!1,editable:!1,exchange_rate:i.exchange_rate}}}return s}const z=window.G7Core?.createLogger?.("Ecom:UpdateProduct")??{log:(...r)=>console.log("[Ecom:UpdateProduct]",...r),warn:(...r)=>console.warn("[Ecom:UpdateProduct]",...r),error:(...r)=>console.error("[Ecom:UpdateProduct]",...r)};function At(r,c){const e=r.params||{},{productId:t,field:o,dataSourceId:a="products"}=e;let{value:n}=e;if(n&&typeof n=="object"&&n.target!==void 0&&(n=n.target?.value,z.log("[updateProductField] Extracted value from event:",n)),!t||!o){z.warn("[updateProductField] Missing required params:",{productId:t,field:o});return}if(n===void 0){z.warn("[updateProductField] Value is undefined, skipping update");return}const s=window.G7Core;if(!s?.dataSource?.get||!s?.dataSource?.set){z.warn("[updateProductField] G7Core.dataSource API is not available");return}const i=s.dataSource.get(a);if(!i){console.warn(`[updateProductField] DataSource '${a}' not found`);return}const l=i.data?.data||[];if(!Array.isArray(l)||l.length===0){z.warn("[updateProductField] Products data is empty or invalid");return}const u=s.state.get()||{},d=u.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies;let f=[...u._local?.productFieldErrors||[]];const g=l.map(C=>{if(String(C.id)===String(t)){const A=C[o];if(typeof n=="object"&&n!==null?JSON.stringify(A)===JSON.stringify(n):String(A)===String(n))return z.log(`[updateProductField] No change for product ${t}.${o}, skipping`),C;let I=n;const N=parseFloat(n)||0;if(o==="selling_price"){const O=parseFloat(C.list_price)||0;O>0&&N>O?(f.some(Q=>Q.productId===String(t)&&Q.field===o)||f.push({productId:String(t),field:"selling_price",message:"판매가는 정가를 초과할 수 없습니다."}),I=O):f=f.filter($=>!($.productId===String(t)&&$.field===o))}else f=f.filter(O=>!(O.productId===String(t)&&O.field===o));const L={...C,[o]:I,_modified:!0};return o==="selling_price"&&d&&Array.isArray(d)&&(L.multi_currency_selling_price=se({basePrice:I,currencies:d}),z.log(`[updateProductField] Recalculated multi_currency_selling_price for product ${t}`)),L}return C});s.dataSource.set(a,{...i,data:{...i.data,data:g}});const w=s.state.getLocal()||{},y=new Set(w.modifiedProductIds||[]);y.add(t);const _={...w.modifiedProductFields||{}},p=String(t),v=new Set(_[p]||[]);v.add(o),_[p]=Array.from(v),s.state.setLocal({modifiedProductIds:Array.from(y),modifiedProductFields:_,productFieldErrors:f}),z.log(`[updateProductField] Updated product ${t}.${o} =`,n)}const B=window.G7Core?.createLogger?.("Ecom:UpdateOption")??{log:(...r)=>console.log("[Ecom:UpdateOption]",...r),warn:(...r)=>console.warn("[Ecom:UpdateOption]",...r),error:(...r)=>console.error("[Ecom:UpdateOption]",...r)};function kt(r,c){const e=r.params||{},{productId:t,optionId:o,field:a,value:n,dataSourceId:s="products"}=e;if(!t||!o||!a){B.warn("[updateOptionField] Missing required params:",{productId:t,optionId:o,field:a});return}const i=window.G7Core;if(!i?.dataSource?.get||!i?.dataSource?.set){B.warn("[updateOptionField] G7Core.dataSource API is not available");return}const l=i.dataSource.get(s);if(!l){console.warn(`[updateOptionField] DataSource '${s}' not found`);return}const u=l.data?.data||[];if(!Array.isArray(u)||u.length===0){B.warn("[updateOptionField] Products data is empty or invalid");return}const d=i.state.get()||{},m=d.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies;let g=[...d._local?.optionFieldErrors||[]],w=!1,y=!1;const _=u.map(N=>{if(String(N.id)===String(t)){const L=N.options||[],O=L.findIndex(D=>String(D.id)===String(o));if(O===-1)return B.warn("[updateOptionField] Option not found:",o),N;y=!0;const $=L[O],Q=$[a];if(typeof n=="object"&&n!==null?JSON.stringify(Q)===JSON.stringify(n):String(Q)===String(n))return B.log(`[updateOptionField] No change for option ${o}.${a}, skipping`),N;let J=n;const xe=parseFloat(n)||0;if(a==="selling_price"){const D=parseFloat($.list_price)||0;D>0&&xe>D?(g.some(we=>we.productId===String(t)&&we.optionId===String(o)&&we.field===a)||g.push({productId:String(t),optionId:String(o),field:"selling_price",message:"판매가는 정가를 초과할 수 없습니다."}),J=D):g=g.filter(H=>!(H.productId===String(t)&&H.optionId===String(o)&&H.field===a))}else g=g.filter(D=>!(D.productId===String(t)&&D.optionId===String(o)&&D.field===a));const re=[...L],ne={...re[O],[a]:J,_modified:!0};let ae=null;if(a==="selling_price"){const D=parseFloat(J)||0;if($.is_default===!0)ae=D,ne.price_adjustment=0;else{const H=parseFloat(N.selling_price)||0;ne.price_adjustment=D-H}ne.price_adjustment_formatted=(ne.price_adjustment>=0?"+":"")+ne.price_adjustment.toLocaleString()+"원"}if(a==="selling_price"&&m&&Array.isArray(m)&&(ne.multi_currency_selling_price=se({basePrice:J,currencies:m}),B.log(`[updateOptionField] Recalculated multi_currency_selling_price for option ${o}`)),re[O]=ne,ae!==null)for(let D=0;DH.is_active===!1?D:D+(parseInt(H.stock_quantity,10)||0),0),Be=Ke,B.log(`[updateOptionField] Updated product stock to option sum: ${Be}`));const Qe={...N,options:re,stock_quantity:Be,option_stock_sum:Ke,_modified:!0};return ae!==null&&(Qe.selling_price=ae,m&&Array.isArray(m)&&(Qe.multi_currency_selling_price=se({basePrice:ae,currencies:m})),w=!0,B.log(`[updateOptionField] Reverse-synced product ${t} selling_price to ${ae} from default option`)),Qe}return N});if(!y)return;i.dataSource.set(s,{...l,data:{...l.data,data:_}});const p=i.state.getLocal()||{},v=new Set(p.modifiedOptionIds||[]),C=`${t}-${o}`;v.add(C);const A={...p.modifiedOptionFields||{}},x=new Set(A[C]||[]);x.add(a),A[C]=Array.from(x);const I={modifiedOptionIds:Array.from(v),modifiedOptionFields:A,optionFieldErrors:g};if(w){const N=new Set(p.modifiedProductIds||[]);N.add(t);const L={...p.modifiedProductFields||{}},O=String(t),$=new Set(L[O]||[]);$.add("selling_price"),L[O]=Array.from($),I.modifiedProductIds=Array.from(N),I.modifiedProductFields=L}i.state.setLocal(I),B.log(`[updateOptionField] Updated option ${t}/${o}.${a} =`,n)}const Ot="g7_preferred_currency",Gt="g7_locale",ze=window.G7Core?.createLogger?.("Handler:InitPreferredCurrency")??{log:(...r)=>console.log("[Handler:InitPreferredCurrency]",...r),warn:(...r)=>console.warn("[Handler:InitPreferredCurrency]",...r),error:(...r)=>console.error("[Handler:InitPreferredCurrency]",...r)};function Et(r){const c=window.G7Core;c?.state?.set?c.state.set(r):ze.warn("G7Core.state.set not available")}function Ft(){try{return localStorage.getItem(Gt)}catch{return null}}function Dt(){try{const e=(window.G7Core?.state?.get?.()||{})?.modules?.["sirsoft-ecommerce"]?.language_currency;return Array.isArray(e?.currencies)?e.currencies:[]}catch{return[]}}function $t(r){if(!r)return null;const c=r.toLowerCase().split("-")[0].split("_")[0];if(!c)return null;const t=Dt().filter(o=>(Array.isArray(o?.locales)?o.locales:[]).some(n=>String(n).toLowerCase()===c));return t.length===1?t[0]?.code??null:t.length>1?t.find(a=>a?.is_default===!0)?.code??null:null}function Nt(){try{const r=window.G7Config;return r?.user?r.user:(window.G7Core?.state?.get?.()||{}).currentUser||null}catch{return null}}function Tt(r,c){const e=r?.params?.defaultCurrency||"KRW",t=l=>typeof l=="string"&&/^[A-Z]{3}$/.test(l),o=Nt(),a=r?.params?.accountCurrency??o?.ecommerce_preferred_currency;let n=null;try{n=localStorage.getItem(Ot)}catch{}const s=a?null:$t(Ft()),i=t(a)?a:t(n)?n:t(s)?s:t(e)?e:"KRW";Et({preferredCurrency:i}),ze.log("Preferred currency initialized:",i)}const Rt="g7_preferred_shipping_country",We=window.G7Core?.createLogger?.("Handler:InitPreferredShippingCountry")??{log:(...r)=>console.log("[Handler:InitPreferredShippingCountry]",...r),warn:(...r)=>console.warn("[Handler:InitPreferredShippingCountry]",...r),error:(...r)=>console.error("[Handler:InitPreferredShippingCountry]",...r)};function Mt(r){const c=window.G7Core;c?.state?.set?c.state.set(r):We.warn("G7Core.state.set not available")}function Ht(){try{const r=window.G7Config;return r?.user?r.user:(window.G7Core?.state?.get?.()||{}).currentUser||null}catch{return null}}function qt(r,c){const e=r?.params?.defaultCountry||"KR",t=i=>typeof i=="string"&&/^[A-Z]{2}$/.test(i),o=Ht(),a=r?.params?.accountCountry??o?.ecommerce_preferred_shipping_country;let n=null;try{n=localStorage.getItem(Rt)}catch{}const s=t(a)?a:t(n)?n:t(e)?e:"KR";Mt({preferredShippingCountry:s}),We.log("Preferred shipping country initialized:",s)}function X(r){const c=r.getFullYear(),e=String(r.getMonth()+1).padStart(2,"0"),t=String(r.getDate()).padStart(2,"0");return`${c}-${e}-${t}`}function Ut(r){const c=new Date,e=X(c);let t;switch(r){case"today":t=e;break;case"3days":{const o=new Date(c);o.setDate(o.getDate()-2),t=X(o);break}case"week":{const o=new Date(c);o.setDate(o.getDate()-6),t=X(o);break}case"month":{const o=new Date(c);o.setMonth(o.getMonth()-1),t=X(o);break}case"3months":{const o=new Date(c);o.setMonth(o.getMonth()-3),t=X(o);break}case"6months":{const o=new Date(c);o.setMonth(o.getMonth()-6),t=X(o);break}case"1year":{const o=new Date(c);o.setFullYear(o.getFullYear()-1),t=X(o);break}case"all":default:return t="",{startDate:"",endDate:""}}return{startDate:t,endDate:e}}function Vt(r){const{preset:c}=r.params||{preset:"today"},{startDate:e,endDate:t}=Ut(c);return{startDate:e,endDate:t,preset:c}}const ie=window.G7Core?.createLogger?.("Ecom:DefaultOption")??{log:(...r)=>console.log("[Ecom:DefaultOption]",...r),warn:(...r)=>console.warn("[Ecom:DefaultOption]",...r),error:(...r)=>console.error("[Ecom:DefaultOption]",...r)};function jt(r,c){const e=r.params||{},{productId:t,optionId:o,dataSourceId:a="products"}=e;if(!t||!o){ie.warn("[setDefaultOption] Missing required params:",{productId:t,optionId:o});return}const n=window.G7Core;if(!n?.dataSource?.get||!n?.dataSource?.set){ie.warn("[setDefaultOption] G7Core.dataSource API is not available");return}const s=n.dataSource.get(a);if(!s){console.warn(`[setDefaultOption] DataSource '${a}' not found`);return}const i=s.data?.data||[];if(!Array.isArray(i)||i.length===0){ie.warn("[setDefaultOption] Products data is empty or invalid");return}const u=(n.state.get()||{}).modules?.["sirsoft-ecommerce"]?.language_currency?.currencies,d=i.map(w=>{if(String(w.id)!==String(t))return w;const y=w.options||[];if(y.length===0)return w;const _=y.find(x=>String(x.id)===String(o));if(!_)return ie.warn("[setDefaultOption] Option not found:",o),w;const p=y.map(x=>{const I=String(x.id)===String(o);return{...x,is_default:I,_modified:I?!0:x._modified}}),v=_.list_price,C=_.selling_price;let A=w.multi_currency_selling_price;return u&&Array.isArray(u)&&(A=se({basePrice:C,currencies:u})),ie.log(`[setDefaultOption] Set option ${o} as default for product ${t}. Syncing prices: list_price=${v}, selling_price=${C}`),{...w,options:p,list_price:v,selling_price:C,multi_currency_selling_price:A,_modified:!0}});n.dataSource.set(a,{...s,data:{...s.data,data:d}});const m=n.state.getLocal()||{},f=new Set(m.modifiedProductIds||[]);f.add(String(t));const g=new Set(m.modifiedOptionIds||[]);g.add(`${t}-${o}`),n.state.setLocal({modifiedProductIds:Array.from(f),modifiedOptionIds:Array.from(g)}),ie.log(`[setDefaultOption] Option ${o} set as default for product ${t}`)}const Ae=window.G7Core?.createLogger?.("Ecom:OptionSelect")??{log:(...r)=>console.log("[Ecom:OptionSelect]",...r),warn:(...r)=>console.warn("[Ecom:OptionSelect]",...r),error:(...r)=>console.error("[Ecom:OptionSelect]",...r)},de=(r,c)=>`${r}-${c}`,ke=r=>r.split("-")[0];function Bt(r,c){const e=r.params||{},{productId:t,optionId:o,checked:a}=e,n=window.G7Core;if(!n?.state){Ae.warn("[toggleOption] G7Core.state를 사용할 수 없습니다.");return}const s=n.state.getLocal(),i=s.selectedOptionIds||[],l=s.selectedItems||[],u=de(t,o);let d;a?i.includes(u)?d=i:d=[...i,u]:d=i.filter(p=>p!==u);const w=(n.dataSource?.get("products")?.data?.data||[]).find(p=>String(p.id)===String(t))?.options||[];let y=[...l];const _=Number(t);w.length>0&&(w.map(C=>de(t,C.id)).filter(C=>d.includes(C)).length===w.length?y.includes(_)||y.push(_):y=y.filter(C=>C!==_)),n.state.setLocal({selectedOptionIds:d,selectedItems:y})}function Ye(r,c){const e=r.params||{},{productId:t,options:o,checked:a}=e,n=window.G7Core;if(!n?.state){Ae.warn("[toggleProductOptions] G7Core.state를 사용할 수 없습니다.");return}const s=n.state.getLocal(),i=s.selectedOptionIds||[],l=s.selectedItems||[],u=(o||[]).map(g=>de(t,g.id));let d,m=[...l];const f=Number(t);a?(d=[...i.filter(w=>ke(w)!==String(t)),...u],m.includes(f)||m.push(f)):(d=i.filter(g=>ke(g)!==String(t)),m=m.filter(g=>g!==f)),n.state.setLocal({selectedOptionIds:d,selectedItems:m})}function Kt(r,c){return Ye(r)}function Qt(r,c){const e=r.params||{products:[],selectedOptionIds:[]},{products:t,selectedOptionIds:o}=e,a=o||[];if(!t||!Array.isArray(t))return{};const n={};for(const s of t){const i=String(s.id),l=s.options||[];if(l.length===0){n[i]="none";continue}const d=l.map(m=>de(i,m.id)).filter(m=>a.includes(m)).length;d===0?n[i]="none":d===l.length?n[i]="all":n[i]="some"}return n}function zt(r,c){const e=r.params||{},{selectedIds:t}=e,o=window.G7Core;if(!o?.state){Ae.warn("[syncProductSelection] G7Core.state를 사용할 수 없습니다.");return}const a=o.state.getLocal(),n=a.selectedItems||[],s=a.selectedOptionIds||[],l=o.dataSource?.get("products")?.data?.data||[],u=t.filter(f=>!n.includes(f)),d=n.filter(f=>!t.includes(f));let m=[...s];for(const f of u){const g=l.find(w=>Number(w.id)===f);if(g?.options)for(const w of g.options){const y=de(f,w.id);m.includes(y)||m.push(y)}}for(const f of d)m=m.filter(g=>ke(g)!==String(f));o.state.setLocal({selectedItems:t,selectedOptionIds:m})}const he=window.G7Core?.createLogger?.("Ecom:CopyCode")??{log:(...r)=>console.log("[Ecom:CopyCode]",...r),warn:(...r)=>console.warn("[Ecom:CopyCode]",...r),error:(...r)=>console.error("[Ecom:CopyCode]",...r)};async function Wt(r,c){const e=window.G7Core;if(!e?.state||!e?.api){he.warn("[generateCopyProductCode] G7Core API is not available");return}try{const t=await e.api.post("/api/modules/sirsoft-ecommerce/admin/products/generate-code");if(!t?.data?.product_code)throw new Error("Invalid API response");const o=t.data.product_code;if((window.__g7LayoutContextStack||[]).length>0&&e.state.setParentLocal)e.state.setParentLocal({"ui.copyOptions.newProductCode":o}),he.log(`[generateCopyProductCode] Generated (parent): ${o}`);else{const s=e.state.getLocal()||{};e.state.setLocal({ui:{...s.ui,copyOptions:{...s.ui?.copyOptions||{},newProductCode:o}}}),he.log(`[generateCopyProductCode] Generated (local): ${o}`)}}catch(t){he.error("[generateCopyProductCode] Failed:",t),e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.messages.code_generate_error")??"Failed to generate product code.")}}const Je=window.G7Core?.createLogger?.("Ecom:CopyProduct")??{log:(...r)=>console.log("[Ecom:CopyProduct]",...r),warn:(...r)=>console.warn("[Ecom:CopyProduct]",...r),error:(...r)=>console.error("[Ecom:CopyProduct]",...r)};async function Yt(r,c){const e=window.G7Core;if(!e?.state){Je.warn("[copyProduct] G7Core API is not available");return}const t=e.state.getLocal()||{},a=(e.state.getParent?.()||{})._local||{},n=a.ui?.copyOptions||t.ui?.copyOptions||{},s=t.form?.product_code||a.form?.product_code||a.productList?.copyTargetProduct?.product_code||t.productList?.copyTargetProduct?.product_code;if(!s){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.messages.copy_source_not_found")??"Could not find source product information.");return}e.modal?.close?.();const i={copy_id:s,copy_images:n.images??!0?"1":"0",copy_options:n.options??!0?"1":"0",copy_categories:n.categories??!0?"1":"0",copy_sales_info:n.sales_info??!0?"1":"0",copy_description:n.description??!0?"1":"0",copy_notice:n.notice??!0?"1":"0",copy_common_info:n.common_info??!0?"1":"0",copy_other_info:n.other_info??!0?"1":"0",copy_shipping:n.shipping??!0?"1":"0",copy_seo:n.seo??!1?"1":"0",copy_identification:n.identification??!0?"1":"0"},u=`/admin/ecommerce/products/create?${new URLSearchParams(i).toString()}`;Je.log("[copyProduct] Navigating to:",u),e.navigate?e.navigate(u):c.navigate?c.navigate(u):window.location.href=u}const Ce=window.G7Core?.createLogger?.("Ecom:ExpandedOptions")??{log:(...r)=>console.log("[Ecom:ExpandedOptions]",...r),warn:(...r)=>console.warn("[Ecom:ExpandedOptions]",...r),error:(...r)=>console.error("[Ecom:ExpandedOptions]",...r)},Jt="/api/modules/sirsoft-ecommerce/admin/products/options",Oe=100,Ge=new Set;function Xt(r,c){const t=r?.dataSource?.get?.(c)?.data?.data;return Array.isArray(t)?t:[]}function Xe(r,c,e){const t=r?.dataSource?.get?.(c),o=t?.data?.data;if(!t||!Array.isArray(o)){Ce.warn("[loadExpandedOptions] DataSource not available for merge:",c);return}const a=o.map(n=>{const s=String(n?.id);return Object.prototype.hasOwnProperty.call(e,s)?{...n,options:e[s]}:n});r.dataSource.set(c,{...t,data:{...t.data,data:a}})}function Zt(r,c){const e=r?.state?.getLocal?.()||{},t=e.selectedItems||[];if(t.length===0)return;const o=new Set(t.map(s=>String(s))),a=new Set(e.selectedOptionIds||[]);let n=!1;Object.entries(c).forEach(([s,i])=>{o.has(s)&&i.forEach(l=>{const u=`${s}-${l?.id}`;a.has(u)||(a.add(u),n=!0)})}),n&&r.state.setLocal({selectedOptionIds:Array.from(a)})}function Ee(r,c){const e=r?.state?.getLocal?.()||{},t=c(e.optionsLoadingIds||[],e.optionsErrorIds||[]);r.state.setLocal({optionsLoadingIds:t.loading,optionsErrorIds:t.errored})}function Fe(r,c){const e=r.params||{},t=e.dataSourceId||"products",o=e.force===!0,a=window.G7Core;if(!a?.dataSource?.get||!a?.dataSource?.set||!a?.state?.setLocal){Ce.warn("[loadExpandedOptions] G7Core dataSource/state API is not available");return}Array.isArray(e.expandedRows)&&a.state.setLocal({expandedRows:e.expandedRows});const n=Array.isArray(e.productIds)?e.productIds:e.productId!==void 0&&e.productId!==null?[e.productId]:Array.isArray(e.expandedRows)?e.expandedRows:a.state.getLocal?.()?.expandedRows||[];if(!Array.isArray(n)||n.length===0)return;const s=Xt(a,t),i=new Map(s.map(d=>[String(d?.id),d])),l=[],u={};n.forEach(d=>{const m=String(d),f=i.get(m);if(f&&!(!o&&Array.isArray(f.options))){if(Number(f.options_total_count)===0){u[m]=[];return}Ge.has(m)||l.push(m)}}),Object.keys(u).length>0&&Xe(a,t,u),l.length!==0&&(l.length>Oe&&(Ce.warn(`[loadExpandedOptions] Requested ${l.length} products, truncating to ${Oe}`),l.length=Oe),l.forEach(d=>Ge.add(d)),Ee(a,(d,m)=>({loading:Array.from(new Set([...d,...l])),errored:m.filter(f=>!l.includes(f))})),a.api.get(Jt,{params:{product_ids:l.map(d=>Number(d))}}).then(d=>{const f=(d?.data||{}).options||{},g={};l.forEach(w=>{const y=f[w];g[w]=Array.isArray(y)?y:[]}),Xe(a,t,g),Zt(a,g),Ee(a,(w,y)=>({loading:w.filter(_=>!l.includes(_)),errored:y.filter(_=>!l.includes(_))}))}).catch(d=>{Ce.error("[loadExpandedOptions] Failed to load options:",d),Ee(a,(f,g)=>({loading:f.filter(w=>!l.includes(w)),errored:Array.from(new Set([...g,...l]))}));const m=d?.response?.data?.message||a.t?.("sirsoft-ecommerce.admin.product.table.options.load_failed")||"Failed to load product options.";a.toast?.error?.(m)}).finally(()=>{l.forEach(d=>Ge.delete(d))}))}function eo(r,c={}){const e=r?.state?.getLocal?.()?.expandedRows||[];!Array.isArray(e)||e.length===0||Fe({params:{productIds:e,force:c.force===!0,dataSourceId:c.dataSourceId||"products"}})}function to(r,c){const e=r.params||{};Fe({params:{productId:e.productId,productIds:e.productIds,force:!0,dataSourceId:e.dataSourceId}})}const P=window.G7Core?.createLogger?.("Ecom:Category")??{log:(...r)=>console.log("[Ecom:Category]",...r),warn:(...r)=>console.warn("[Ecom:Category]",...r),error:(...r)=>console.error("[Ecom:Category]",...r)};function Ze(r,c,e,t=[]){for(const o of r){const a=o.name?.[e]||o.name?.ko||"";if(o.id===c)return[...t,a].join(" > ");if(o.children&&o.children.length>0){const n=Ze(o.children,c,e,[...t,a]);if(n)return n}}return null}function oo(r,c,e){return r.map(t=>{if(t.breadcrumb)return{id:t.id,breadcrumb:t.breadcrumb};const o=Ze(c,t.id,e);if(o)return{id:t.id,breadcrumb:o};const a=t.name?.[e]||t.name?.ko||t.name_localized||"";return{id:t.id,breadcrumb:a}})}function ro(r,c){const e=r.params||{},t=e.depth,o=e.category;if(t===void 0||!o){P.warn("[selectCategory] Missing depth or category param");return}const a=c.isolatedContext;if(a){const u=a.state.selectedCategories||[null,null,null,null];if(u[t]?.id===o.id){P.log(`[selectCategory] Same category already selected at depth ${t}, skipping`);return}const d=u.map((m,f)=>fdp0?t+2:f;a.mergeState({selectedCategories:g,categoryStep:y}),P.log(`[selectCategoryMobile] Selected category at depth ${t} (isolated):`,o.id,"nextStep:",y);return}const n=window.G7Core;if(!n?.state){P.warn("[selectCategoryMobile] G7Core.state API is not available");return}const s=n.state.getLocal()||{},l=(s.ui?.selectedCategories||[null,null,null,null]).map((m,f)=>f0?t+2:s.ui?.categoryStep;n.state.setLocal({ui:{...s.ui,selectedCategories:l,categoryStep:d}}),P.log(`[selectCategoryMobile] Selected category at depth ${t}:`,o.id,"nextStep:",d)}function ao(r,c){P.log("[addCategoryToSelection] START");const e=window.G7Core;if(!e?.state){P.warn("[addCategoryToSelection] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=e.locale?.current?.()||"ko",a=c.isolatedContext,n=a?a.state.selectedCategories||[]:t.ui?.selectedCategories||[];P.log("[addCategoryToSelection] Current state:",{selectedCats:n.map(p=>p?.id??null),currentCategoryIds:t.form?.category_ids,currentSelectedCategoryInfos:t.selectedCategoryInfos,usingIsolatedContext:!!a});const s=n.filter(Boolean).pop();if(!s){P.warn("[addCategoryToSelection] No category selected");return}P.log("[addCategoryToSelection] Last selected category:",s.id,s.name);const i=t.form?.category_ids||[];if(i.length>=5){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.category.messages.max_5")??"You can select up to 5 categories.");return}if(i.includes(s.id)){P.log("[addCategoryToSelection] Duplicate detected:",s.id,"in",i),e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.category.messages.already_selected")??"This category is already selected.");return}const l=n.filter(Boolean).map(p=>p.name?.[o]||p.name?.ko||"").join(" > "),u=[...i,s.id],d=t.form?.primary_category_id||s.id,f=(e.state.getDataSources?.()||{}).categories?.data?.data||[];let g=t.selectedCategoryInfos||[];const w=t.form?.categories||[];g.length===0&&w.length>0&&(P.log("[addCategoryToSelection] Initializing from form.categories:",w.length,"items"),g=oo(w,f,o));const y=[...g,{id:s.id,breadcrumb:l}];P.log("[addCategoryToSelection] Before setLocal:",{newCategoryIds:u,primaryCategoryId:d,newInfos:y});try{e.state.setLocal({form:{...t.form,category_ids:u,primary_category_id:d},selectedCategoryInfos:y,hasChanges:!0}),P.log("[addCategoryToSelection] setLocal SUCCESS")}catch(p){P.error("[addCategoryToSelection] setLocal FAILED:",p)}const _=e.state.getLocal()||{};P.log("[addCategoryToSelection] After setLocal:",{category_ids:_.form?.category_ids,selectedCategoryInfos:_.selectedCategoryInfos}),P.log(`[addCategoryToSelection] Added category ${s.id} with breadcrumb: ${l}. Total: ${u.length}`)}function so(r,c){const t=(r.params||{}).categoryId;if(!t){P.warn("[removeCategoryFromSelection] Missing categoryId param");return}const o=window.G7Core;if(!o?.state){P.warn("[removeCategoryFromSelection] G7Core.state API is not available");return}const a=o.state.getLocal()||{},s=(a.form?.category_ids||[]).filter(d=>d!==t);let i=a.form?.primary_category_id;i===t&&(i=s[0]||null);const u=(a.selectedCategoryInfos||[]).filter(d=>d.id!==t);o.state.setLocal({form:{...a.form,category_ids:s,primary_category_id:i},selectedCategoryInfos:u,hasChanges:!0}),P.log(`[removeCategoryFromSelection] Removed category ${t}. Remaining: ${s.length}`)}function io(r,c){const e=r.params||{},t=e.categoryId,o=e.categories;if(!t||!o||!Array.isArray(o))return"";const n=window.G7Core?.locale?.current?.()||"ko",s=(l,u,d=[])=>{for(const m of l){const f=m.name?.[n]||m.name?.ko||"";if(m.id===u)return[...d,f];if(m.children&&m.children.length>0){const g=s(m.children,u,[...d,f]);if(g)return g}}return null},i=s(o,t);return i?i.join(" > "):""}function co(r,c){P.log("[initCategoryInfosFromProduct] START");const e=window.G7Core;if(!e?.state){P.warn("[initCategoryInfosFromProduct] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=e.locale?.current?.()||"ko",n=(e.state.getDataSources?.()||{}).categories?.data?.data||[],s=t.form?.categories||[],i=t.form?.category_ids||[];if(P.log("[initCategoryInfosFromProduct] Product categories:",s),P.log("[initCategoryInfosFromProduct] Category IDs:",i),P.log("[initCategoryInfosFromProduct] Categories tree available:",n.length>0),i.length===0){P.log("[initCategoryInfosFromProduct] No categories to initialize");return}const l=(d,m,f=[])=>{for(const g of d){const w=g.name?.[o]||g.name?.ko||"";if(g.id===m)return[...f,w];if(g.children&&g.children.length>0){const y=l(g.children,m,[...f,w]);if(y)return y}}return null},u=[];for(const d of i){const m=l(n,d);if(m)u.push({id:d,breadcrumb:m.join(" > ")});else{const f=s.find(g=>g.id===d);if(f){const g=f.name?.[o]||f.name?.ko||f.name_localized||"";u.push({id:d,breadcrumb:g})}}}P.log("[initCategoryInfosFromProduct] Generated selectedCategoryInfos:",u),e.state.setLocal({selectedCategoryInfos:u}),P.log("[initCategoryInfosFromProduct] DONE")}function lo(r,c){const e={category1:"",category2:"",category3:"",category4:""},t=r.params||{},o=t.categoryId1?String(t.categoryId1):"",a=t.categoryId2?String(t.categoryId2):"",n=t.categoryId3?String(t.categoryId3):"",s=t.categoryId4?String(t.categoryId4):"",i=t.categories;if(!i||!Array.isArray(i)||i.length===0)return P.warn("[validateCategoryPath] No categories data provided"),e;if(o){const l=i.find(u=>String(u.id)===o);if(l&&l.depth===0&&(e.category1=o,a&&l.children)){const u=l.children.find(d=>String(d.id)===a);if(u&&u.depth===1&&(e.category2=a,n&&u.children)){const d=u.children.find(m=>String(m.id)===n);if(d&&d.depth===2&&(e.category3=n,s&&d.children)){const m=d.children.find(f=>String(f.id)===s);m&&m.depth===3&&(e.category4=s)}}}}return P.log("[validateCategoryPath] Result:",e),e}function uo(r,c){const e=r.params||{},t=e.brandId,o=e.brands;if(!t||!o||!Array.isArray(o))return"";const n=window.G7Core?.locale?.current?.()||"ko",s=o.find(i=>i.id===t);return s?s.name?.[n]??s.name?.ko??"":""}function mo(r,c){const e=r.params||{},t=e.brandId,o=e.brands;if(!t||!o||!Array.isArray(o))return"";const n=window.G7Core?.locale?.current?.()||"ko",s=o.find(i=>i.id===t);return s?.description?.[n]??s?.description?.ko??""}const Z=window.G7Core?.createLogger?.("Ecom:Price")??{log:(...r)=>console.log("[Ecom:Price]",...r),warn:(...r)=>console.warn("[Ecom:Price]",...r),error:(...r)=>console.error("[Ecom:Price]",...r)},po={KRW:1e3,JPY:100};function fo(r,c){const t=r.find(o=>o.code===c)?.base_unit??po[c]??1;return Math.max(1,t)}function go(r,c,e,t){const o=r/t*c;switch(e){case"ceil":return Math.ceil(o);case"floor":return Math.floor(o);case"round":default:return Math.round(o)}}function _o(r,c){const e=r.params||{},t=e.field,o=e.currency,a=e.value,n=e.autoFill;if(!t||!o||a===void 0){Z.warn("[updatePrice] Missing required params: field, currency, or value");return}const s=window.G7Core;if(!s?.state){Z.warn("[updatePrice] G7Core.state API is not available");return}const i=s.state.getLocal()||{},l=c.datasources?.currencies?.data||{},u=l?.list||[],d=l?.default_currency||"KRW",m=l?.rounding_method||"round",f={...i.form?.multi_currency_prices||{}};if(f[o]={...f[o]||{},[t]:a},n&&o===d){const y=fo(u,d);u.forEach(_=>{if(_.code!==d){const p=_.exchange_rate??1,v=go(a,p,m,y);f[_.code]={...f[_.code]||{},[t]:v}}})}const g={list:"list_price",selling:"selling_price"},w=o===d?a:i.form?.[g[t]];s.state.setLocal({form:{...i.form,multi_currency_prices:f,[g[t]]:w},hasChanges:!0}),Z.log(`[updatePrice] Updated ${t} price for ${o}: ${a}`)}function wo(r,c){const t=(r.params||{}).options;if(!t||!Array.isArray(t)||t.length===0)return 0;const o=t.reduce((a,n)=>n.is_active===!1?a:a+(n.stock_quantity||0),0);return Z.log(`[calculateTotalOptionStock] Total stock: ${o}`),o}function yo(r,c){const e=window.G7Core;if(!e?.state){Z.warn("[validatePriceRelation] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=parseFloat(String(t.form?.list_price))||0,a=parseFloat(String(t.form?.selling_price))||0,n=a>o&&o>0,s=e.t?.("sirsoft-ecommerce.admin.product.validation.selling_price_exceeds_list_price")??"판매가는 정가보다 클 수 없습니다.",i={...t.errors||{}};n?i.selling_price=[s]:delete i.selling_price;const l=Object.keys(i).length>0;e.state.setLocal({validation:{...t.validation,price_error:n},errors:l?i:null}),n?(e.toast?.warning?.(s),Z.warn(`[validatePriceRelation] Selling price (${a}) exceeds list price (${o})`)):Z.log("[validatePriceRelation] Price validation passed")}const T=window.G7Core?.createLogger?.("Ecom:ProductOption")??{log:(...r)=>console.log("[Ecom:ProductOption]",...r),warn:(...r)=>console.warn("[Ecom:ProductOption]",...r),error:(...r)=>console.error("[Ecom:ProductOption]",...r)};function ho(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function et(){return ho().reduce((c,e)=>({...c,[e]:""}),{})}function be(r){return(r??[]).filter(c=>c.is_active!==!1).reduce((c,e)=>c+(parseInt(String(e.stock_quantity),10)||0),0)}const Co={KRW:1e3,JPY:100};function De(){const e=((window.G7Core?.state?.get?.()||{}).modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).find(o=>o.is_default);if(!e)return 1;const t=e.base_unit??Co[e.code]??1;return Math.max(1,t)}function bo(r,c,e){const t=parseFloat(c)||1;if(t<=0)return r;const o=r/t;let a;switch(e){case"ceil":a=Math.ceil(o);break;case"floor":a=Math.floor(o);break;default:a=Math.round(o)}return a*t}function $e(r,c,e){const t=c.exchange_rate||0;if(t<=0)return{price:0,formatted:ye(0,c)};const o=e??De(),a=r/o*t,n=bo(a,c.rounding_unit||"0.01",c.rounding_method||"round"),s=c.decimal_places??2,i=parseFloat(n.toFixed(s));return{price:i,formatted:ye(i,c)}}function Ne(r,c,e){return r.map(t=>{const o=parseFloat(String(t.price_adjustment))||0,a=c+o,n={...t,selling_price:a};if(e.length>0){const s=De(),i={};e.forEach(l=>{i[l.code]=$e(a,l,s)}),n.multi_currency_selling_price=i}return n})}function Te(){return((window.G7Core?.state?.get?.()||{}).modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).filter(c=>!c.is_default&&c.exchange_rate)}function Io(r,c){const e=r.params||{},t=window.G7Core;if(!t?.state){T.warn("[setDefaultOption] G7Core.state API is not available");return}const o=t.state.getLocal()||{},a=[...o.form?.options??[]],n=e.index,s=n!=null?typeof n=="string"?parseInt(n,10):n:-1,i=e.optionCode??a[s]?.option_code;if(!i){T.warn("[setDefaultOption] Missing optionCode/index param",{index:n});return}let l=a.map(m=>({...m,is_default:m.option_code===i}));const u=l.find(m=>m.is_default===!0),d=u?parseFloat(String(u.selling_price))||parseFloat(String(o.form?.selling_price))||0:parseFloat(String(o.form?.selling_price))||0;if(u){u.price_adjustment=0;const m=Te();l=Ne(l,d,m)}t.state.setLocal({form:{...o.form,options:l,selling_price:d},hasChanges:!0}),T.log(`[setDefaultOption] Set default option to ${i}, synced product selling_price to ${d}`)}function So(r,c){const e=r.params||{},t=typeof e.index=="string"?parseInt(e.index,10):e.index,o=e.field,a=e.value;if(t==null||!o){T.warn("[updateFormOptionField] Missing required params:",{index:t,field:o});return}const n=window.G7Core;if(!n?.state){T.warn("[updateFormOptionField] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.options??[]];if(t<0||t>=i.length){T.warn("[updateFormOptionField] Index out of bounds:",{index:t,length:i.length});return}const l=["regular_price","sale_price","list_price","selling_price","stock_quantity","safe_stock_quantity","weight","volume","mileage_value"];let u=a;if(l.includes(o)&&(u=parseFloat(a)||0),i[t]={...i[t],[o]:u},o==="selling_price"){const d=parseFloat(String(u))||0,m=Te();if(i[t].is_default===!0){i[t].price_adjustment=0;const g=Ne(i,d,m);n.state.setLocal({form:{...s.form,options:g,selling_price:d},hasChanges:!0}),T.log(`[updateFormOptionField] Reverse-synced product selling_price to ${d} from default option`);return}const f=parseFloat(String(s.form?.selling_price))||0;if(i[t].price_adjustment=d-f,m.length>0){const g=De(),w={};m.forEach(y=>{w[y.code]=$e(d,y,g)}),i[t].multi_currency_selling_price=w,T.log(`[updateFormOptionField] Recalculated multi_currency_selling_price for option at index ${t}:`,m.map(y=>y.code))}}if((o==="stock_quantity"||o==="is_active")&&i.length>0){const d=be(i);n.state.setLocal({form:{...s.form,options:i,stock_quantity:d},hasChanges:!0}),T.log(`[updateFormOptionField] Updated product stock_quantity to ${d} (sum of active options)`);return}n.state.setLocal({form:{...s.form,options:i},hasChanges:!0}),T.log(`[updateFormOptionField] Updated options[${t}].${o} =`,u)}function vo(r,c){const e=window.G7Core;if(!e?.state){T.warn("[addOptionRow] G7Core.state API is not available");return}const o=r.params?.isCreate===!0||r.params?.isCreate==="true"?1:0,a=e.state.getLocal()||{},n=[...a.form?.options??[]],s=a.form?.option_groups??[],i=`OPT-${Date.now()}`,l=s.map(f=>({key:f.name,value:et()})),u={id:null,option_code:i,option_name:et(),option_values:l,is_default:n.length===0,regular_price:0,sale_price:0,list_price:0,selling_price:0,price_adjustment:0,multi_currency_selling_price:{},sku:"",stock_quantity:o,safe_stock_quantity:0,weight:0,volume:0,mileage_value:0,mileage_type:"percent",is_active:!0},d=[...n,u],m=be(d);e.state.setLocal({form:{...a.form,options:d,stock_quantity:m,has_options:d.length>0},hasChanges:!0}),T.log(`[addOptionRow] Added new option row: ${i}, product stock_quantity = ${m}`)}function Lo(r,c){const e=window.G7Core;if(!e?.state){T.warn("[recalculateOptionPriceAdjustments] G7Core.state API is not available");return}const t=r.params||{},o=e.state.getLocal()||{},a=t.newSellingPrice!==void 0?parseFloat(String(t.newSellingPrice))||0:parseFloat(String(o.form?.selling_price))||0,n=[...o.form?.options??[]];if(n.length===0){T.log("[recalculateOptionPriceAdjustments] No options to recalculate");return}const s=Te(),i=Ne(n,a,s);e.state.setLocal({form:{...o.form,options:i}}),T.log(`[recalculateOptionPriceAdjustments] Recalculated selling_price for ${n.length} options based on product selling_price: ${a}`)}const h=window.G7Core?.createLogger?.("Ecom:Option")??{log:(...r)=>console.log("[Ecom:Option]",...r),warn:(...r)=>console.warn("[Ecom:Option]",...r),error:(...r)=>console.error("[Ecom:Option]",...r)};function Re(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function ce(){return window.G7Core?.config?.("app.locale")??Re()[0]??"ko"}function q(){return Re().reduce((c,e)=>({...c,[e]:""}),{})}function tt(r){return{id:null,name:q(),price_adjustment:0,is_default:r===0,is_active:!0,allow_custom_text:!1,sort_order:r}}function xo(r,c){return typeof r=="string"?r:r[c]??r.ko??Object.values(r)[0]??""}function ot(r){return r&&typeof r=="object"&&!Array.isArray(r)}function Po(r){return Array.isArray(r)?r:r&&typeof r=="object"?Object.values(r):[]}function Ao(r){return r.reduce((c,e)=>{const t=[];return c.forEach(o=>{e.forEach(a=>{t.push([...o,a])})}),t},[[]])}function ko(r){const c=Re(),e={};for(const t of c){const o=r.map(a=>xo(a,t));e[t]=o.join("/")}return e}function Oo(r,c){return c.map((e,t)=>({key:typeof e.name=="string"?{...q(),[ce()]:e.name}:e.name,value:ot(r[t])?r[t]:{...q(),[ce()]:r[t]}}))}function Go(r,c){const e=window.G7Core;if(!e?.state){h.warn("[addOptionInput] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.ui?.optionInputs??[];if(o.length>=3){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.input_max_3")??"You can add up to 3 option inputs.");return}e.state.setLocal({ui:{...t.ui,optionInputs:[...o,{name:q(),values:[]}]}}),h.log(`[addOptionInput] Added option input. Total: ${o.length+1}`)}function Eo(r,c){const t=(r.params||{}).index;if(t===void 0){h.warn("[removeOptionInput] Missing index param");return}const o=window.G7Core;if(!o?.state){h.warn("[removeOptionInput] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.ui?.optionInputs??[]];n.splice(t,1),o.state.setLocal({ui:{...a.ui,optionInputs:n}}),h.log(`[removeOptionInput] Removed option input at index ${t}`)}function Fo(r,c){const e=r.params||{},t=e.index,o=e.field,a=e.value;if(t===void 0||!o){h.warn("[updateOptionInput] Missing required params");return}const n=window.G7Core;if(!n?.state){h.warn("[updateOptionInput] G7Core.state API is not available");return}h.log(`[updateOptionInput] Called with index=${t}, field="${o}" (type: ${typeof o}), value=`,a);const s=n.state.getLocal()||{},i=[...s.ui?.optionInputs??[]];if(h.log(`[updateOptionInput] Current inputs[${t}]:`,i[t]),i[t]||(h.log(`[updateOptionInput] inputs[${t}] not found, initializing with empty structure`),i[t]={name:q(),values:[]}),o==="name"){const l=ce(),u=i[t].name;h.log(`[updateOptionInput] Processing 'name' field - defaultLocale: ${l}, existingName:`,u,`type: ${typeof u}`);let d;a&&typeof a=="object"&&!Array.isArray(a)?(d=a,h.log("[updateOptionInput] Using multilingual value directly:",d)):(d={...u&&typeof u=="object"&&!Array.isArray(u)?u:q(),[l]:a},h.log("[updateOptionInput] Setting single locale value:",d)),i[t]={...i[t],name:d}}else h.log(`[updateOptionInput] Processing non-name field '${o}'`),i[t]={...i[t],[o]:a};h.log(`[updateOptionInput] Final inputs[${t}]:`,i[t]),n.state.setLocal({ui:{...s.ui,optionInputs:i}})}function Do(r,c){const e=window.G7Core;if(!e?.state){h.warn("[generateOptions] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=e.state.get()||{},a=t.ui?.optionInputs??[],n=ce(),s=p=>p.name?typeof p.name=="string"?p.name:p.name[n]??"":"",i=a.map(p=>({...p,values:Po(p.values)})),l=i.filter(p=>s(p)&&p.values&&p.values.length>0);if(h.log(`[generateOptions] Validating inputs - defaultLocale: ${n}, normalizedInputs:`,i.map(p=>({name:p.name,nameType:typeof p.name,extractedName:s(p),values:p.values,valuesIsArray:Array.isArray(p.values)}))),h.log(`[generateOptions] Valid inputs count: ${l.length}`),l.length===0){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.name_value_required")??"Please enter option name and values.");return}const u=t.form?.options??[],d=r.params?.skipConfirm===!0;if(u.length>0&&!d){e.modal?.open?.("modal_confirm_regenerate");return}const f=r.params?.isCreate===!0||r.params?.isCreate==="true"?1:0,g=parseFloat(String(t.form?.list_price))||0,w=parseFloat(String(t.form?.selling_price))||0,y=t.form?.multi_currency_selling_price,_=(o.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).filter(p=>!p.is_default&&p.exchange_rate);try{h.log("[generateOptions] Step 1: Creating optionGroups...");const p=l.map((L,O)=>(h.log(`[generateOptions] Processing input[${O}]:`,{name:L.name,values:L.values,valuesType:typeof L.values,valuesIsArray:Array.isArray(L.values)}),{name:typeof L.name=="string"?{...q(),[n]:L.name}:L.name,values:L.values.map($=>ot($)?$:{...q(),[n]:$})})),C=Ao(l.map(L=>L.values)).map((L,O)=>{const $=Oo(L,l),Q=ko(L),Ct=`OPT-${String(O+1).padStart(3,"0")}`;let J={};return y&&typeof y=="object"&&Object.keys(y).length>0?J=JSON.parse(JSON.stringify(y)):w>0&&_.length>0&&_.forEach(xe=>{J[xe.code]=$e(w,xe)}),{id:null,option_code:Ct,option_values:$,option_name:Q,list_price:g,selling_price:w,price_adjustment:0,stock_quantity:f,safe_stock_quantity:0,is_default:O===0,is_active:!0,sku:"",multi_currency_selling_price:J}}),A=t.form?.options??[],x=L=>L.option_name?typeof L.option_name=="string"?L.option_name:L.option_name[n]??L.option_name.ko??"":"",I=C.map(L=>{const O=x(L),$=A.find(Q=>x(Q)===O);return $?{...L,...$,id:$.id}:L}),N=be(I);e.state.setLocal({form:{...t.form,option_groups:p,options:I,has_options:I.length>0,stock_quantity:N},hasChanges:!0}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.generated",{count:I.length})??`${I.length} options have been generated.`),h.log(`[generateOptions] Generated ${I.length} options`)}catch(p){throw h.error("[generateOptions] Error occurred:",p),h.error("[generateOptions] Error message:",p.message),h.error("[generateOptions] Error stack:",p.stack),p}}function $o(r,c){const t=(r.params||{}).rowIndex;if(t===void 0){h.warn("[deleteOption] Missing rowIndex param");return}const o=window.G7Core;if(!o?.state){h.warn("[deleteOption] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.options??[]];n.splice(t,1);const s=be(n),i=n.length>0;o.state.setLocal({form:{...a.form,options:n,has_options:i,...i?{stock_quantity:s}:{}},hasChanges:!0}),h.log(`[deleteOption] Deleted option at index ${t}, product stock_quantity = ${s}`)}function No(r,c){const e=r.params||{},t=e.optionName,o=e.applyToAll,a=e.inheritPrice,n=window.G7Core;if(!n?.state){h.warn("[applyOptionAddTool] G7Core.state API is not available");return}if(!t?.trim()){n.toast?.warning?.(n.t?.("sirsoft-ecommerce.admin.product.options.messages.name_required")??"Please enter option name.");return}const s=n.state.getLocal()||{},l=[...s.form?.options??[]].map(u=>{const d={...u};return o&&(d.option_values={...u.option_values,[t]:""}),a&&(d.price_adjustment=u.price_adjustment??0),d});n.state.setLocal({form:{...s.form,options:l},ui:{...s.ui,optionAddToolName:"",optionAddToolApplyToAll:!1,optionAddToolInheritPrice:!1},hasChanges:!0}),n.toast?.success?.(n.t?.("sirsoft-ecommerce.admin.product.options.messages.add_tool_applied")??"Option add tool has been applied.")}function To(r,c){const e=window.G7Core;if(!e?.state){h.warn("[addRequiredItem] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.form?.required_items??[],a={id:null,sort_order:o.length,option_name:{ko:"",en:""},description:{ko:"",en:""}};e.state.setLocal({form:{...t.form,required_items:[...o,a]},hasChanges:!0}),h.log(`[addRequiredItem] Added required item. Total: ${o.length+1}`)}function Ro(r,c){const e=r.params||{},t=e.index,o=e.field,a=e.locale,n=e.value;if(t===void 0||!o||!a){h.warn("[updateRequiredItem] Missing required params");return}const s=window.G7Core;if(!s?.state){h.warn("[updateRequiredItem] G7Core.state API is not available");return}const i=s.state.getLocal()||{},l=[...i.form?.required_items??[]];l[t]={...l[t],[o]:{...l[t][o],[a]:n}},s.state.setLocal({form:{...i.form,required_items:l},hasChanges:!0})}function Mo(r,c){const t=(r.params||{}).index;if(t===void 0){h.warn("[removeRequiredItem] Missing index param");return}const o=window.G7Core;if(!o?.state){h.warn("[removeRequiredItem] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.required_items??[]];n.splice(t,1),n.forEach((s,i)=>{s.sort_order=i}),o.state.setLocal({form:{...a.form,required_items:n},hasChanges:!0}),h.log(`[removeRequiredItem] Removed required item at index ${t}`)}function Ho(r,c){const e=r.params||{},t=e.oldIndex,o=e.newIndex;if(t===void 0||o===void 0){h.warn("[reorderRequiredItems] Missing oldIndex or newIndex param");return}const a=window.G7Core;if(!a?.state){h.warn("[reorderRequiredItems] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.required_items??[]],[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,required_items:s},hasChanges:!0}),h.log(`[reorderRequiredItems] Moved item from ${t} to ${o}`)}function qo(r,c){const e=window.G7Core;if(!e?.state){h.warn("[addAdditionalOption] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.form?.additional_options??[];if(o.length>=5){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.additional_max_5")??"You can add up to 5 additional options.");return}const a=`temp_${Date.now()}_${Math.random().toString(36).substring(2,9)}`;e.state.setLocal({form:{...t.form,additional_options:[...o,{id:a,name:q(),is_required:!1,sort_order:o.length,values:[tt(0)]}]},hasChanges:!0}),h.log(`[addAdditionalOption] Added additional option. Total: ${o.length+1}`)}function Uo(r,c){const e=r.params||{},t=e.index,o=e.field,a=e.value;if(t===void 0||!o){h.warn("[updateAdditionalOption] Missing required params");return}const n=window.G7Core;if(!n?.state){h.warn("[updateAdditionalOption] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.additional_options??[]];if(!i[t]){h.warn("[updateAdditionalOption] Option not found at index",t);return}if(o==="name")if(a&&typeof a=="object"&&!Array.isArray(a))i[t]={...i[t],name:a};else{const l=ce(),u=i[t].name,d=u&&typeof u=="object"&&!Array.isArray(u)?u:q();i[t]={...i[t],name:{...d,[l]:a}}}else i[t]={...i[t],[o]:a};n.state.setLocal({form:{...s.form,additional_options:i},hasChanges:!0}),h.log(`[updateAdditionalOption] Updated options[${t}].${o}`)}function Vo(r,c){const t=(r.params||{}).index;if(t===void 0){h.warn("[removeAdditionalOption] Missing index param");return}const o=window.G7Core;if(!o?.state){h.warn("[removeAdditionalOption] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.additional_options??[]];n.splice(t,1),n.forEach((s,i)=>{s.sort_order=i}),o.state.setLocal({form:{...a.form,additional_options:n},hasChanges:!0}),h.log(`[removeAdditionalOption] Removed additional option at index ${t}`)}function jo(r,c){const e=r.params||{},t=parseInt(String(e.oldIndex),10),o=parseInt(String(e.newIndex),10);if(isNaN(t)||isNaN(o)||t===o)return;const a=window.G7Core;if(!a?.state){h.warn("[reorderAdditionalOptions] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.additional_options??[]];if(t<0||t>=s.length||o<0||o>=s.length)return;const[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,additional_options:s},ui:{...n.ui,dragIndex:null},hasChanges:!0}),h.log(`[reorderAdditionalOptions] Moved item from ${t} to ${o}`)}function Bo(r,c){const e=window.G7Core;if(!e?.state){h.warn("[clearAdditionalOptions] G7Core.state API is not available");return}const t=e.state.getLocal()||{};e.state.setLocal({form:{...t.form,additional_options:[]},hasChanges:!0}),h.log("[clearAdditionalOptions] Cleared all additional options.")}function Me(r){if(!r)return[];const c=r.values;return Array.isArray(c)?c:c&&typeof c=="object"?Object.values(c):[]}function Ko(r,c){const e=r.params||{},t=typeof e.groupIndex=="string"?parseInt(e.groupIndex,10):e.groupIndex;if(t==null||isNaN(t)){h.warn("[addAdditionalOptionValue] Missing groupIndex param");return}const o=window.G7Core;if(!o?.state){h.warn("[addAdditionalOptionValue] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.additional_options??[]];if(!n[t]){h.warn("[addAdditionalOptionValue] Group not found at index",t);return}const s=[...Me(n[t])];if(s.length>=20){o.toast?.warning?.(o.t?.("sirsoft-ecommerce.admin.product.options.messages.additional_value_max_20")??"You can add up to 20 choices per group.");return}s.push(tt(s.length)),n[t]={...n[t],values:s},o.state.setLocal({form:{...a.form,additional_options:n},hasChanges:!0}),h.log(`[addAdditionalOptionValue] Added value to group ${t}. Total: ${s.length}`)}function Qo(r,c){const e=r.params||{},t=typeof e.groupIndex=="string"?parseInt(e.groupIndex,10):e.groupIndex,o=typeof e.valueIndex=="string"?parseInt(e.valueIndex,10):e.valueIndex,a=e.field,n=e.value;if(t===void 0||o===void 0||!a){h.warn("[updateAdditionalOptionValue] Missing required params");return}const s=window.G7Core;if(!s?.state){h.warn("[updateAdditionalOptionValue] G7Core.state API is not available");return}const i=s.state.getLocal()||{},l=[...i.form?.additional_options??[]];if(!l[t]){h.warn("[updateAdditionalOptionValue] Group not found at index",t);return}const u=[...Me(l[t])];if(!u[o]){h.warn("[updateAdditionalOptionValue] Value not found at index",o);return}if(a==="name")if(n&&typeof n=="object"&&!Array.isArray(n))u[o]={...u[o],name:n};else{const d=ce(),m=u[o].name,f=m&&typeof m=="object"&&!Array.isArray(m)?m:q();u[o]={...u[o],name:{...f,[d]:n}}}else if(a==="price_adjustment"){let d=parseFloat(String(n));(isNaN(d)||d<0)&&(d=0),u[o]={...u[o],price_adjustment:d}}else if(a==="is_default")n===!0||n==="true"?u.forEach((m,f)=>{m.is_default=f===o}):u[o].is_default=!1;else if(a==="is_active"||a==="allow_custom_text"){const d=n===!0||n==="true";u[o]={...u[o],[a]:d}}else u[o]={...u[o],[a]:n};l[t]={...l[t],values:u},s.state.setLocal({form:{...i.form,additional_options:l},hasChanges:!0}),h.log(`[updateAdditionalOptionValue] Updated group ${t} value ${o} field ${a}`)}function zo(r,c){const e=r.params||{},t=typeof e.groupIndex=="string"?parseInt(e.groupIndex,10):e.groupIndex,o=typeof e.valueIndex=="string"?parseInt(e.valueIndex,10):e.valueIndex;if(t===void 0||o===void 0){h.warn("[removeAdditionalOptionValue] Missing required params");return}const a=window.G7Core;if(!a?.state){h.warn("[removeAdditionalOptionValue] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.additional_options??[]];if(!s[t]){h.warn("[removeAdditionalOptionValue] Group not found at index",t);return}const i=[...Me(s[t])];if(i.length<=1){a.toast?.warning?.(a.t?.("sirsoft-ecommerce.admin.product.options.messages.additional_value_min_1")??"At least one choice is required per group.");return}i.splice(o,1),i.forEach((l,u)=>{l.sort_order=u}),i.some(l=>l.is_default)||(i[0].is_default=!0),s[t]={...s[t],values:i},a.state.setLocal({form:{...n.form,additional_options:s},hasChanges:!0}),h.log(`[removeAdditionalOptionValue] Removed value ${o} from group ${t}`)}const j=window.G7Core?.createLogger?.("Ecom:Image")??{log:(...r)=>console.log("[Ecom:Image]",...r),warn:(...r)=>console.warn("[Ecom:Image]",...r),error:(...r)=>console.error("[Ecom:Image]",...r)},Wo=["image/jpeg","image/png","image/gif","image/webp"],Yo=10*1024*1024,Jo=20;async function Xo(r,c){const t=(r.params||{}).files;if(!t||t.length===0){j.warn("[uploadImages] No files provided");return}const o=window.G7Core;if(!o?.state||!o?.api){j.warn("[uploadImages] G7Core.state or G7Core.api is not available");return}const n=(o.state.getLocal()||{}).form?.images??[],s=Jo-n.length;t.length>s&&o.toast?.warning?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.max_count",{max:s})??`You can add up to ${s} images.`);const i=Array.from(t).slice(0,s);for(const l of i){if(!Wo.includes(l.type)){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.unsupported_format",{name:l.name})??`${l.name}: Unsupported format.`);continue}if(l.size>Yo){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.size_exceeded",{name:l.name})??`${l.name}: Exceeds 10MB.`);continue}const u=o.state.getLocal()||{};o.state.setLocal({ui:{...u.ui,uploadProgress:{...u.ui?.uploadProgress||{},[l.name]:0}}});try{const d=new FormData;d.append("file",l),d.append("collection","products");const m=await o.api.upload("/api/admin/media/upload",d,{onProgress:v=>{const C=o.state.getLocal()||{};o.state.setLocal({ui:{...C.ui,uploadProgress:{...C.ui?.uploadProgress||{},[l.name]:v}}})}}),f=o.state.getLocal()||{},g=f.form?.images??[],w={...m.data,is_thumbnail:g.length===0,sort_order:g.length};o.state.setLocal({form:{...f.form,images:[...g,w]},hasChanges:!0});const y=o.state.getLocal()||{},{[l.name]:_,...p}=y.ui?.uploadProgress||{};o.state.setLocal({ui:{...y.ui,uploadProgress:p}}),j.log(`[uploadImages] Uploaded ${l.name}`)}catch(d){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.upload_failed",{name:l.name})??`${l.name}: Upload failed`);const m=o.state.getLocal()||{},{[l.name]:f,...g}=m.ui?.uploadProgress||{};o.state.setLocal({ui:{...m.ui,uploadProgress:g}}),j.error(`[uploadImages] Failed to upload ${l.name}:`,d)}}}function Zo(r,c){const t=(r.params||{}).index;if(t===void 0){j.warn("[setThumbnail] Missing index param");return}const o=window.G7Core;if(!o?.state){j.warn("[setThumbnail] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=(a.form?.images??[]).map((s,i)=>({...s,is_thumbnail:i===t}));o.state.setLocal({form:{...a.form,images:n},hasChanges:!0}),j.log(`[setThumbnail] Set thumbnail to image at index ${t}`)}function er(r,c){const e=r.params||{},t=e.oldIndex,o=e.newIndex;if(t===void 0||o===void 0){j.warn("[reorderImages] Missing oldIndex or newIndex param");return}const a=window.G7Core;if(!a?.state){j.warn("[reorderImages] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.images??[]],[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,images:s},hasChanges:!0}),j.log(`[reorderImages] Moved image from ${t} to ${o}`)}const rt=window.G7Core?.createLogger?.("Ecom:Description")??{log:(...r)=>console.log("[Ecom:Description]",...r),warn:(...r)=>console.warn("[Ecom:Description]",...r),error:(...r)=>console.error("[Ecom:Description]",...r)};function tr(r,c){const e=r.params||{},t=e.locale,o=e.value;if(!t){rt.warn("[updateDescription] Missing locale param");return}const a=window.G7Core;if(!a?.state){rt.warn("[updateDescription] G7Core.state API is not available");return}const n=a.state.getLocal()||{};a.state.setLocal({form:{...n.form,description:{...n.form?.description,[t]:o}},hasChanges:!0})}const k=window.G7Core?.createLogger?.("Ecom:Notice")??{log:(...r)=>console.log("[Ecom:Notice]",...r),warn:(...r)=>console.warn("[Ecom:Notice]",...r),error:(...r)=>console.error("[Ecom:Notice]",...r)};function ue(r=""){const e=window.G7Core?.locale?.supported?.()??["ko","en"],t={};for(const o of e)t[o]=r;return t}function or(r,c){const t=(r.params||{}).templateId,o=window.G7Core;if(!o?.state){k.warn("[confirmSelectNoticeTemplate] G7Core.state API is not available");return}const n=(o.state.getLocal()||{}).form?.notice_items??[],s=n.length>0&&n.some(i=>Object.values(i.content||{}).some(u=>u&&u.trim()!==""));if(!t||!s){nt(r);return}o.state.setGlobal({pendingNoticeTemplateId:t}),o.modal?.open?.("notice_template_confirm_modal"),k.log(`[confirmSelectNoticeTemplate] Showing confirm modal for template: ${t}`)}function nt(r,c){const t=(r.params||{}).templateId,o=window.G7Core;if(!o?.state){k.warn("[selectNoticeTemplate] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=o.dataSource?.get?.("notice_templates"),s=n?.data?.data??n?.data??[];if(!t){o.state.setLocal({form:{...a.form,notice_items:[]},ui:{...a.ui,selectedNoticeTemplateId:null},hasChanges:!0});return}const i=s.find(d=>d.id===t);if(!i){k.warn(`[selectNoticeTemplate] Template not found: ${t}`);return}const l=a.form?.notice_items??[],u=(i.fields??[]).map((d,m)=>{const f=JSON.stringify(d.name),g=l.find(w=>JSON.stringify(w.name)===f);return{key:`field_${m}`,name:d.name,content:g?.content??d.content??ue(),sort_order:m,is_custom:!1}});o.state.setLocal({form:{...a.form,notice_items:u},ui:{...a.ui,selectedNoticeTemplateId:t},hasChanges:!0}),k.log(`[selectNoticeTemplate] Selected template: ${t}`)}function rr(r,c){const e=r.params||{},t=e.index,o=e.locale,a=e.value;if(t===void 0||!o){k.warn("[updateNoticeItem] Missing required params");return}const n=window.G7Core;if(!n?.state){k.warn("[updateNoticeItem] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.notice_items??[]];i[t]={...i[t],content:{...i[t].content,[o]:a}},n.state.setLocal({form:{...s.form,notice_items:i},hasChanges:!0})}function nr(r,c){const t=(r.params||{}).index;if(t===void 0){k.warn("[removeNoticeItem] Missing index param");return}const o=window.G7Core;if(!o?.state){k.warn("[removeNoticeItem] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.notice_items??[]];n.splice(t,1),n.forEach((s,i)=>{s.sort_order=i}),o.state.setLocal({form:{...a.form,notice_items:n},hasChanges:!0}),k.log(`[removeNoticeItem] Removed notice item at index ${t}`)}function ar(r,c){const e=r.params||{},t=e.oldIndex,o=e.newIndex;if(t===void 0||o===void 0){k.warn("[reorderNoticeItems] Missing oldIndex or newIndex param");return}const a=window.G7Core;if(!a?.state){k.warn("[reorderNoticeItems] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.notice_items??[]],[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,notice_items:s},hasChanges:!0}),k.log(`[reorderNoticeItems] Moved item from ${t} to ${o}`)}function sr(r,c){const t=(r.params||{}).value,o=window.G7Core;if(!o?.state){k.warn("[fillNoticeWithValue] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=a.ui?.noticeLocale??"ko",s=(a.form?.notice_items??[]).map(i=>({...i,content:{...i.content,[n]:i.content?.[n]||t}}));o.state.setLocal({form:{...a.form,notice_items:s},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.notice.messages.filled")??"Empty fields have been filled.")}function ir(r,c){const t=(r.params||{}).mode;if(!t){k.warn("[switchNoticeMode] Missing mode param");return}const o=window.G7Core;if(!o?.state){k.warn("[switchNoticeMode] G7Core.state API is not available");return}const a=o.state.getLocal()||{};o.state.setLocal({ui:{...a.ui,noticeMode:t}}),k.log(`[switchNoticeMode] Switched to ${t} mode`)}function cr(r,c){const e=r.params||{},t=e.locale,o=e.value;if(!t){k.warn("[updateNewTemplateName] Missing locale param");return}const a=window.G7Core;if(!a?.state){k.warn("[updateNewTemplateName] G7Core.state API is not available");return}const n=a.state.getLocal()||{};a.state.setLocal({form:{...n.form,new_template_name:{...n.form?.new_template_name,[t]:o}}})}function lr(r,c){const e=window.G7Core;if(!e?.state){k.warn("[addNoticeItem] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.form?.notice_items??[],a={key:`custom_${Date.now()}`,name:ue(),content:ue(),sort_order:o.length,is_custom:!0};e.state.setLocal({form:{...t.form,notice_items:[...o,a]},hasChanges:!0}),k.log(`[addNoticeItem] Added new notice item. Total: ${o.length+1}`)}function dr(r,c){const e=r.params||{},t=e.index,o=e.locale,a=e.value;if(t===void 0||!o){k.warn("[updateNoticeItemName] Missing required params");return}const n=window.G7Core;if(!n?.state){k.warn("[updateNoticeItemName] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.notice_items??[]];i[t]={...i[t],name:{...i[t].name,[o]:a}},n.state.setLocal({form:{...s.form,notice_items:i},hasChanges:!0})}function ur(r,c){const e=window.G7Core;if(!e?.state){k.warn("[saveAsNoticeTemplate] G7Core.state API is not available");return}if(((e.state.getLocal()||{}).form?.notice_items??[]).length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.no_items")??"No items to save.");return}e.state.set?.({"ui.saveTemplateData":{name:ue(),category_id:null,is_default:!1}}),e.dispatch?.({handler:"openModal",target:"modal_save_template"})}const Ie={ko:"상세설명참조",en:"See detail description"};function mr(r,c){const e=window.G7Core;if(!e?.state){k.warn("[fillTemplateFieldsWithDetailReference] G7Core.state API is not available");return}const a=((r.params||{}).form||{}).fields??[];if(a.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product_notice_template.messages.no_fields_to_change")??"변경할 항목이 없습니다.");return}const n=e.locale?.supported?.()??["ko","en"],s={};for(const l of n)s[l]=Ie[l]??Ie.en??"See detail description";const i=a.map(l=>({...l,content:{...s}}));e.state.setLocal({"form.fields":i}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product_notice_template.messages.all_fields_changed_to_detail_ref")??"모든 항목이 '상세설명참조'로 변경되었습니다."),k.log("[fillTemplateFieldsWithDetailReference] All fields updated to detail reference")}function pr(r,c){const e=window.G7Core;if(!e?.state){k.warn("[fillNoticeItemsWithDetailReference] G7Core.state API is not available");return}const a=((r.params||{}).form||{}).notice_items??[];if(a.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.no_items_to_change")??"변경할 항목이 없습니다.");return}const n=e.locale?.supported?.()??["ko","en"],s={};for(const l of n)s[l]=Ie[l]??Ie.en??"See detail description";const i=a.map(l=>({...l,content:{...s}}));e.state.setLocal({"form.notice_items":i}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.all_items_changed_to_detail_ref")??"모든 항목이 '상세설명참조'로 변경되었습니다."),k.log("[fillNoticeItemsWithDetailReference] All notice items updated to detail reference")}async function fr(r,c){const e=window.G7Core;if(!e?.state||!e?.api){k.warn("[confirmSaveNoticeTemplate] G7Core.state or G7Core.api is not available");return}const t=e.state.getLocal()||{},o=e.state.get?.()?.ui?.saveTemplateData??{},a=o.name;if(!a?.ko?.trim()){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.template_name_required")??"Please enter template name.");return}try{const n=t.form?.notice_items??[],s=await e.api.post("/api/modules/sirsoft-ecommerce/admin/products/notice-templates",{name:a,is_active:o.is_default??!1,fields:n.map(i=>({name:i.name,content:i.content}))});e.state.set?.({"ui.saveTemplateData":{name:ue(),category_id:null,is_default:!1}}),e.state.setLocal({ui:{...t.ui,noticeMode:"existing"},hasChanges:!0}),e.dispatch?.({handler:"closeModal"}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.template_saved")??"Template has been saved."),e.datasources?.refresh?.("notice_templates"),k.log(`[confirmSaveNoticeTemplate] Template saved: ${s.data.id}`)}catch(n){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.template_save_error")??"Failed to save template."),k.error("[confirmSaveNoticeTemplate] Failed to save template:",n)}}const S=window.G7Core?.createLogger?.("Ecom:Misc")??{log:(...r)=>console.log("[Ecom:Misc]",...r),warn:(...r)=>console.warn("[Ecom:Misc]",...r),error:(...r)=>console.error("[Ecom:Misc]",...r)};function gr(r,c){const t=(r.params||{}).labelId;if(!t){S.warn("[toggleLabel] Missing labelId param");return}const o=window.G7Core;if(!o?.state){S.warn("[toggleLabel] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=a.form?.labels??[],s=n.includes(t)?n.filter(i=>i!==t):[...n,t];o.state.setLocal({form:{...a.form,labels:s},hasChanges:!0}),S.log(`[toggleLabel] Toggled label ${t}. Current labels: ${s.length}`)}async function _r(r,c){const e=window.G7Core;if(!e?.state||!e?.api){S.warn("[generateProductCode] G7Core.state or G7Core.api is not available");return}try{const t=await e.api.post("/api/modules/sirsoft-ecommerce/admin/products/generate-code"),o=e.state.getLocal()||{};e.state.setLocal({form:{...o.form,product_code:t.data.product_code}}),S.log(`[generateProductCode] Generated: ${t.data.product_code}`)}catch(t){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.messages.code_generate_error")??"Failed to generate product code."),S.error("[generateProductCode] Failed:",t)}}function wr(r,c){const e=r.params||{},t=e.policyId,o=e.field,a=e.policies;if(!t||!o||!a)return"-";const n=a.find(s=>s.id===t);return n?n[o]??"-":"-"}function yr(r,c){const e=r.params||{},t=e.templateId,o=e.templates;if(!t||!o)return"";const n=window.G7Core?.locale?.current?.()||"ko",s=o.find(i=>i.id===t);return s?.content?.[n]??s?.content?.ko??""}function hr(r,c){const e=r.params||{},t=e.platform,o=e.field,a=e.value;if(!t||!o){S.warn("[updateShoppingIntegration] Missing required params");return}const n=window.G7Core;if(!n?.state){S.warn("[updateShoppingIntegration] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=s.form?.shopping_integrations??{naver:{enabled:!1,sync_interval:"manual"},daum:{enabled:!1,sync_interval:"manual"},facebook:{enabled:!1,sync_interval:"manual"},google:{enabled:!1,sync_interval:"manual"}};n.state.setLocal({form:{...s.form,shopping_integrations:{...i,[t]:{...i[t],[o]:a}}},hasChanges:!0}),S.log(`[updateShoppingIntegration] Updated ${t}.${o} to ${a}`)}function Cr(r,c){const t=(r.params||{}).type;if(!t){S.warn("[updateShippingType] Missing type param");return}const o=window.G7Core;if(!o?.state){S.warn("[updateShippingType] G7Core.state API is not available");return}const a=o.state.getLocal()||{};let n=0,s=0;t==="fixed_paid"?n=3e3:t==="threshold_free"&&(s=3e4),o.state.setLocal({form:{...a.form,shipping_type:t,shipping_fee:t==="fixed_free"?0:a.form?.shipping_fee??n,free_shipping_threshold:t==="threshold_free"?a.form?.free_shipping_threshold??s:0},hasChanges:!0}),S.log(`[updateShippingType] Updated shipping type to ${t}`)}function br(r,c){const e=r.params||{},t=e.field,o=e.value;if(!t){S.warn("[updateIdentificationCode] Missing field param");return}const a=window.G7Core;if(!a?.state){S.warn("[updateIdentificationCode] G7Core.state API is not available");return}const n=a.state.getLocal()||{};a.state.setLocal({form:{...n.form,[t]:o},hasChanges:!0})}function Ir(r,c){const e=r.params||{},t=e.labelId,o=e.labelName;if(!t){S.warn("[openLabelPeriodModal] Missing labelId param");return}const a=window.G7Core;if(!a?.state){S.warn("[openLabelPeriodModal] G7Core.state API is not available");return}const n=a.state.getLocal()||{},i=(n.form?.labels??[]).find(l=>l.id===t);a.state.setLocal({ui:{...n.ui,showLabelPeriodModal:!0,labelPeriodModalData:{labelId:t,labelName:o,startDate:i?.start_date??null,endDate:i?.end_date??null}}})}function Sr(r,c){const e=r.params||{},t=e.labelId,o=e.startDate,a=e.endDate;if(!t){S.warn("[saveLabelPeriod] Missing labelId param");return}const n=window.G7Core;if(!n?.state){S.warn("[saveLabelPeriod] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.labels??[]],l=i.findIndex(u=>u.id===t);l>=0?i[l]={...i[l],start_date:o,end_date:a}:i.push({id:t,start_date:o,end_date:a}),n.state.setLocal({form:{...s.form,labels:i},ui:{...s.ui,showLabelPeriodModal:!1,labelPeriodModalData:null},hasChanges:!0}),n.toast?.success?.(n.t?.("sirsoft-ecommerce.admin.product.messages.label_period_saved")??"Label period has been saved."),S.log(`[saveLabelPeriod] Saved period for label ${t}`)}function vr(r,c){const t=(r.params||{}).labelId;if(!t){S.warn("[removeLabelPeriod] Missing labelId param");return}const o=window.G7Core;if(!o?.state){S.warn("[removeLabelPeriod] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.labels??[]],s=n.findIndex(i=>i.id===t);s>=0&&(n[s]={...n[s],start_date:null,end_date:null}),o.state.setLocal({form:{...a.form,labels:n},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.messages.label_period_removed")??"Label period has been removed."),S.log(`[removeLabelPeriod] Removed period for label ${t}`)}function Lr(r,c){const t=(r.params||{}).sort;if(!t){S.warn("[updateActivityLogSort] Missing sort param");return}const o=window.G7Core;if(!o?.state){S.warn("[updateActivityLogSort] G7Core.state API is not available");return}const a=o.state.getLocal()||{};o.state.setLocal({ui:{...a.ui,activityLogSort:t}}),o.datasources?.refresh?.("activity_logs",{params:{sort:t==="latest"?"desc":"asc",per_page:a.ui?.activityLogPerPage??10}}),S.log(`[updateActivityLogSort] Updated sort to ${t}`)}function xr(r,c){const t=(r.params||{}).perPage;if(!t){S.warn("[updateActivityLogPerPage] Missing perPage param");return}const o=window.G7Core;if(!o?.state){S.warn("[updateActivityLogPerPage] G7Core.state API is not available");return}const a=o.state.getLocal()||{};o.state.setLocal({ui:{...a.ui,activityLogPerPage:t}}),o.datasources?.refresh?.("activity_logs",{params:{sort:a.ui?.activityLogSort==="oldest"?"asc":"desc",per_page:t}}),S.log(`[updateActivityLogPerPage] Updated perPage to ${t}`)}function Pr(r,c){const e=window.G7Core;if(!e?.state){S.warn("[setDefaultShippingPolicy] G7Core.state API is not available");return}const t=e.state.getLocal()||{};if(t.form?.shipping_policy_id){S.log("[setDefaultShippingPolicy] Shipping policy already set, skipping");return}if(t.ui?.useDefaultShippingPolicy===!1){S.log("[setDefaultShippingPolicy] useDefaultShippingPolicy is OFF, skipping");return}const o=e.dataSource?.get?.("shipping_policies"),a=o?.data?.data??o?.data??[];if(a.length===0){S.log("[setDefaultShippingPolicy] No shipping policies found");return}const n=a.find(s=>s.is_default===!0);n?(e.state.setLocal({form:{...t.form,shipping_policy_id:n.id}}),S.log(`[setDefaultShippingPolicy] Set default shipping policy: ${n.id} (${n.name})`)):S.log("[setDefaultShippingPolicy] No default shipping policy found")}function Ar(r,c){const e=window.G7Core;if(!e?.state){S.warn("[toggleDefaultShippingPolicy] G7Core.state API is not available");return}const t=r.params?.checked??!1,o=e.state.getLocal()||{},a={...o.ui,useDefaultShippingPolicy:t};if(t){const n=e.dataSource?.get?.("shipping_policies"),i=(n?.data?.data??n?.data??[]).find(l=>l.is_default===!0);e.state.setLocal({ui:a,form:{...o.form,shipping_policy_id:i?.id??null}}),i?S.log(`[toggleDefaultShippingPolicy] ON - Selected default policy: ${i.id}`):S.log("[toggleDefaultShippingPolicy] ON - No default policy found")}else e.state.setLocal({ui:a}),S.log("[toggleDefaultShippingPolicy] OFF - Keeping current shipping policy")}function kr(r,c){const e=window.G7Core;if(!e?.state){S.warn("[setLabelDatePreset] G7Core.state API is not available");return}const t=r.params?.preset;if(!t){S.warn("[setLabelDatePreset] Missing preset param");return}const o=new Date,a=i=>{const l=i.getFullYear(),u=String(i.getMonth()+1).padStart(2,"0"),d=String(i.getDate()).padStart(2,"0");return`${l}-${u}-${d}`},n=a(o);let s=null;switch(t){case"7d":{const i=new Date(o);i.setDate(i.getDate()+7),s=a(i);break}case"14d":{const i=new Date(o);i.setDate(i.getDate()+14),s=a(i);break}case"30d":{const i=new Date(o);i.setDate(i.getDate()+30),s=a(i);break}case"permanent":s=null;break;default:S.warn(`[setLabelDatePreset] Unknown preset: ${t}`);return}e.state.setGlobal({"labelFormData.start_date":n,"labelFormData.end_date":s}),S.log(`[setLabelDatePreset] Applied preset '${t}': ${n} ~ ${s??"permanent"}`)}const Or=(r,c)=>{const e=window.G7Core,o=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],a=Number(r.params?.labelId);if(!a)return;const n=[...o];if(n.find(l=>l.label_id===a)){e?.state?.setLocal?.({"ui.lastClickedLabelId":a});return}const i=[...n,{label_id:a,started_at:null,ended_at:null}];e?.state?.setLocal?.({"form.label_assignments":i,"ui.lastClickedLabelId":a,hasChanges:!0})},Gr=(r,c)=>{const e=window.G7Core,o=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],a=Number(r.params?.labelId),n=r.params?.field,s=r.params?.value;if(!a||!n)return;const i=[...o],l=i.findIndex(u=>u.label_id===a);l<0||(i[l]={...i[l],[n]:s||null},e?.state?.setLocal?.({"form.label_assignments":i,hasChanges:!0}))},Er=(r,c)=>{const e=window.G7Core,t=e?.state?.getLocal?.()||{},o=t.form?.label_assignments??[],a=t.ui?.lastClickedLabelId,n=r.params?.preset;if(!a||!n)return;const s=[...o],i=s.findIndex(f=>f.label_id===a);if(i<0)return;const l=new Date,u=f=>f.toISOString().split("T")[0],d=u(l);let m=null;switch(n){case"7d":m=u(new Date(l.getTime()+7*24*60*60*1e3));break;case"14d":m=u(new Date(l.getTime()+14*24*60*60*1e3));break;case"30d":m=u(new Date(l.getTime()+30*24*60*60*1e3));break;case"permanent":m=null;break}s[i]={...s[i],started_at:d,ended_at:m},e?.state?.setLocal?.({"form.label_assignments":s,hasChanges:!0})},Fr=(r,c)=>{const e=window.G7Core,o=(e?.state?.get()||{}).labelToUncheckId;if(!o)return;const i=[...(e?.state?.getParent?.()?._local||{}).form?.label_assignments??[]].filter(l=>l.label_id!==o);e?.state?.setParentLocal?.({"form.label_assignments":i,hasChanges:!0}),e?.state?.setGlobal?.({labelToUncheckId:null}),e?.modal?.close?.()},Dr=async(r,c)=>{const e=window.G7Core,t=e?.state?.get()||{},a=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],n=t.labelToDeleteId;if(n){e?.state?.setGlobal?.({isDeletingLabel:!0});try{const s=await e.api.delete(`/api/modules/sirsoft-ecommerce/admin/product-labels/${n}`);if(!s?.success)throw new Error(s?.message||"Failed to delete label");const l=[...a].filter(u=>u.label_id!==n);e?.state?.setLocal?.({"form.label_assignments":l,"ui.lastClickedLabelId":null}),await e?.dataSource?.refetch?.("product_labels"),e?.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.labels.delete_success")??"Label deleted."),e?.modal?.close?.()}catch(s){e?.toast?.error?.(s?.response?.data?.message||s?.message||e.t?.("sirsoft-ecommerce.admin.product.labels.delete_error")||"Failed to delete label.")}finally{e?.state?.setGlobal?.({isDeletingLabel:!1,labelToDeleteId:null})}}},$r=async(r,c)=>{const e=window.G7Core,t=e?.state?.get()||{},a=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],n=t.editingLabelId,s=t.labelFormData;if(s?.name?.ko){e?.state?.setGlobal?.({isSavingLabel:!0});try{if(n){const i=await e.api.put(`/api/modules/sirsoft-ecommerce/admin/product-labels/${n}`,{name:s.name,color:s.color});if(!i?.success)throw new Error(i?.message||"Failed to update label");const u=[...a].map(d=>d.label_id===n?{...d,started_at:s.started_at||null,ended_at:s.ended_at||null}:d);e?.state?.setLocal?.({"form.label_assignments":u,hasChanges:!0})}else{const i=await e.api.post("/api/modules/sirsoft-ecommerce/admin/product-labels",{name:s.name,color:s.color||"#6B7280"});if(!i?.success)throw new Error(i?.message||"Failed to create label");const l=Number(i?.data?.id);if(l){const d=[...[...a],{label_id:l,started_at:null,ended_at:null}];e?.state?.setLocal?.({"form.label_assignments":d,"ui.lastClickedLabelId":l,hasChanges:!0})}}await e?.dataSource?.refetch?.("product_labels"),e?.toast?.success?.(n?e.t?.("sirsoft-ecommerce.admin.product.labels.save_success")??"Label saved.":e.t?.("sirsoft-ecommerce.admin.product.labels.create_success")??"Label created."),e?.modal?.close?.()}catch(i){e?.toast?.error?.(i?.response?.data?.message||i?.message||e.t?.("sirsoft-ecommerce.admin.product.labels.save_error")||"Failed to save label.")}finally{e?.state?.setGlobal?.({isSavingLabel:!1})}}},ee=window.G7Core?.createLogger?.("Ecom:DescriptionLocale")??{log:(...r)=>console.log("[Ecom:DescriptionLocale]",...r),warn:(...r)=>console.warn("[Ecom:DescriptionLocale]",...r),error:(...r)=>console.error("[Ecom:DescriptionLocale]",...r)};function at(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function Nr(r,c){const t=(r.params||{}).locale;if(!t){ee.warn("[removeDescriptionLocale] Missing locale param");return}const o=window.G7Core;if(!o?.state){ee.warn("[removeDescriptionLocale] G7Core.state API is not available");return}const a=o?.state?.get?.("_global.settings.general.language")??o?.config?.("app.fallback_locale")??"ko";if(t===a){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.default_locale_cannot_delete")??"Default language cannot be deleted.");return}const n=o.state.getLocal()||{},s=at(),i={...n.form?.description};delete i[t];const l={...n.form?.description_mobile};delete l[t];const u=(n.ui?.descriptionActiveLocales??s).filter(d=>d!==t);o.state.setLocal({form:{...n.form,description:i,description_mobile:l},ui:{...n.ui,descriptionActiveLocales:u,descriptionLocale:u[0]??"ko"},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_removed",{locale:t.toUpperCase()})??`${t.toUpperCase()} language has been removed.`),ee.log(`[removeDescriptionLocale] Removed locale: ${t}`)}function Tr(r,c){const e=window.G7Core;if(!e?.state){ee.warn("[showAddLocaleModal] G7Core.state API is not available");return}const t=e.state.getLocal()||{};e.state.setLocal({ui:{...t.ui,showAddLocaleModal:!0}})}function Rr(r,c){const t=(r.params||{}).locale;if(!t){ee.warn("[addDescriptionLocale] Missing locale param");return}const o=window.G7Core;if(!o?.state){ee.warn("[addDescriptionLocale] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=at(),s=a.ui?.descriptionActiveLocales??["ko"];if(s.includes(t)){o.toast?.warning?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_already_added")??"This language is already added.");return}if(!n.includes(t)){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_unsupported")??"Unsupported language.");return}o.state.setLocal({form:{...a.form,description:{...a.form?.description,[t]:""},description_mobile:{...a.form?.description_mobile,[t]:""}},ui:{...a.ui,descriptionActiveLocales:[...s,t],descriptionLocale:t,showAddLocaleModal:!1},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_added",{locale:t.toUpperCase()})??`${t.toUpperCase()} language has been added.`),ee.log(`[addDescriptionLocale] Added locale: ${t}`)}const W=window.G7Core?.createLogger?.("Ecom:BulkUpdate")??{log:(...r)=>console.log("[Ecom:BulkUpdate]",...r),warn:(...r)=>console.warn("[Ecom:BulkUpdate]",...r),error:(...r)=>console.error("[Ecom:BulkUpdate]",...r)};function st(r,c,e){const t=c?.response?.data??{},o=t?.message||c?.message||"",a=t?.errors,n=a&&typeof a=="object"?Object.values(a).map(i=>Array.isArray(i)?i[0]:String(i)):[],s=o||r.t(e);r.toast.error(s),r.state.set({bulkUpdateErrors:n.length>0?n:s?[s]:[]})}function Y(r){return typeof r=="string"?r:r&&typeof r=="object"?r.ko||r.en||JSON.stringify(r):String(r??"")}function Se(r,c,e){return e?r.t(`sirsoft-ecommerce.enums.${c}.${e}`)||e:String(e??"")}function it(){const r=window.G7Core;return r||(W.error("[bulkUpdate] G7Core is not available"),null)}function Mr(r,c){const e=it();if(!e)return;const t=e.state.getLocal()||{},o=e.state.get()||{},a=o.bulkSelectedItems||t.selectedItems||[],n=o.bulkSelectedOptionIds||t.selectedOptionIds||[];W.log("[bulkUpdate] globalState.bulkSelectedItems:",o.bulkSelectedItems),W.log("[bulkUpdate] localState.selectedItems:",t.selectedItems),W.log("[bulkUpdate] Final selectedItems:",a),a.length>0?Hr(e,o,t,a):n.length>0?qr(e,o,t,n):e.toast.warning(e.t("sirsoft-ecommerce.admin.product.bulk.no_selection"))}function Hr(r,c,e,t){const a=r.dataSource.get("products")?.data?.data||[],n={};c.bulkSalesStatus&&(n.sales_status=c.bulkSalesStatus),c.bulkDisplayStatus&&(n.display_status=c.bulkDisplayStatus);const s={};if(c.bulkPriceMethod&&c.bulkPriceValue!==void 0&&c.bulkPriceValue!==null&&c.bulkPriceValue!==""){const p={fixed:"set",increase:"add",decrease:"subtract"};c.bulkPriceUnit==="percent"?s.price_adjustment={method:"percent",value:c.bulkPriceMethod==="decrease"?-(Number(c.bulkPriceValue)||0):Number(c.bulkPriceValue)||0}:s.price_adjustment={method:p[c.bulkPriceMethod]||"add",value:Number(c.bulkPriceValue)||0}}if(c.bulkStockMethod&&c.bulkStockValue!==void 0&&c.bulkStockValue!==null&&c.bulkStockValue!==""){const p={set:"set",increase:"add",decrease:"subtract"};s.stock_quantity={method:p[c.bulkStockMethod]||"add",value:Number(c.bulkStockValue)||0}}const i=e.modifiedProductIds||[],l=e.modifiedOptionIds||[],u=e.modifiedProductFields||{},d=e.modifiedOptionFields||{},m=c.bulkSelectedOptionIds||e.selectedOptionIds||[],f=new Set(t),g=[],w=[];function y(p,v,C){if(v._modified||l.includes(C)){const x={product_id:p.id,option_id:v.id},I=d[C]||[];!s.price_adjustment&&I.includes("price_adjustment")&&(x.price_adjustment=v.price_adjustment),!s.price_adjustment&&I.includes("selling_price")&&(x.price_adjustment=v.price_adjustment),!s.stock_quantity&&I.includes("stock_quantity")&&(x.stock_quantity=v.stock_quantity),I.includes("option_name")&&(x.option_name=v.option_name),I.includes("sku")&&(x.sku=v.sku),I.includes("list_price")&&(x.list_price=v.list_price),I.includes("safe_stock_quantity")&&(x.safe_stock_quantity=v.safe_stock_quantity),I.includes("is_default")&&(x.is_default=v.is_default),I.includes("is_active")&&(x.is_active=v.is_active),Object.keys(x).length>2&&w.push(x)}}a.filter(p=>t.includes(p.id)).forEach(p=>{if(p._modified||i.includes(p.id)){const C={id:p.id},A=String(p.id),x=u[A]||[];!n.sales_status&&x.includes("sales_status")&&(C.sales_status=p.sales_status),!n.display_status&&x.includes("display_status")&&(C.display_status=p.display_status),x.includes("name")&&(C.name=p.name),x.includes("list_price")&&(C.list_price=p.list_price),x.includes("selling_price")&&(C.selling_price=p.selling_price),Object.keys(C).length>1&&g.push(C)}(p.options||[]).forEach(C=>{const A=`${p.id}-${C.id}`;y(p,C,A)})}),m.length>0&&a.forEach(p=>{f.has(p.id)||(p.options||[]).forEach(v=>{const C=`${p.id}-${v.id}`;m.includes(C)&&y(p,v,C)})});const _={ids:t};Object.keys(n).length>0&&(_.bulk_changes=n),g.length>0&&(_.items=g),Object.keys(s).length>0&&(_.option_bulk_changes=s),w.length>0&&(_.option_items=w),r.state.set({isProcessing:!0,bulkUpdateErrors:[]}),r.api.patch("/api/modules/sirsoft-ecommerce/admin/products/bulk-update",_).then(p=>{r.toast.success(r.t("sirsoft-ecommerce.admin.product.bulk.update_success",{products:p?.data?.products_updated||0,options:p?.data?.options_updated||0})),lt(r),ct(r),r.modal.close()}).catch(p=>{W.error("[bulkUpdateProducts] Error:",p),st(r,p,"sirsoft-ecommerce.admin.product.bulk.update_error")}).finally(()=>{r.state.set({isProcessing:!1})})}function qr(r,c,e,t){const a=r.dataSource.get("products")?.data?.data||[],n={};if(c.bulkPriceMethod&&c.bulkPriceValue!==void 0&&c.bulkPriceValue!==null&&c.bulkPriceValue!==""){const d={fixed:"set",increase:"add",decrease:"subtract"};c.bulkPriceUnit==="percent"?n.price_adjustment={method:"percent",value:c.bulkPriceMethod==="decrease"?-(Number(c.bulkPriceValue)||0):Number(c.bulkPriceValue)||0}:n.price_adjustment={method:d[c.bulkPriceMethod]||"add",value:Number(c.bulkPriceValue)||0}}if(c.bulkStockMethod&&c.bulkStockValue!==void 0&&c.bulkStockValue!==null&&c.bulkStockValue!==""){const d={set:"set",increase:"add",decrease:"subtract"};n.stock_quantity={method:d[c.bulkStockMethod]||"add",value:Number(c.bulkStockValue)||0}}const s=e.modifiedOptionFields||{},i=e.modifiedOptionIds||[],l=[];a.forEach(d=>{(d.options||[]).forEach(m=>{const f=`${d.id}-${m.id}`,g=m._modified||i.includes(f);if(t.includes(f)&&g){const w={product_id:d.id,option_id:m.id},y=s[f]||[];!n.price_adjustment&&y.includes("price_adjustment")&&(w.price_adjustment=m.price_adjustment),!n.price_adjustment&&y.includes("selling_price")&&(w.price_adjustment=m.price_adjustment),!n.stock_quantity&&y.includes("stock_quantity")&&(w.stock_quantity=m.stock_quantity),y.includes("option_name")&&(w.option_name=m.option_name),y.includes("sku")&&(w.sku=m.sku),y.includes("list_price")&&(w.list_price=m.list_price),y.includes("safe_stock_quantity")&&(w.safe_stock_quantity=m.safe_stock_quantity),y.includes("is_default")&&(w.is_default=m.is_default),y.includes("is_active")&&(w.is_active=m.is_active),Object.keys(w).length>2&&l.push(w)}})});const u={ids:t};Object.keys(n).length>0&&(u.bulk_changes=n),l.length>0&&(u.items=l),r.state.set({isProcessing:!0,bulkUpdateErrors:[]}),r.api.patch("/api/modules/sirsoft-ecommerce/admin/options/bulk-update",u).then(d=>{r.toast.success(r.t("sirsoft-ecommerce.admin.product.bulk.option_update_success",{count:d?.data?.options_updated||0})),lt(r),ct(r),r.modal.close()}).catch(d=>{W.error("[bulkUpdateOptions] Error:",d),st(r,d,"sirsoft-ecommerce.admin.product.bulk.option_update_error")}).finally(()=>{r.state.set({isProcessing:!1})})}function ct(r){Promise.resolve(r.dataSource.refetch("products")).then(()=>{eo(r,{force:!0})}).catch(c=>{W.error("[bulkUpdate] Failed to refetch products:",c)})}function lt(r){r.state.setLocal({modifiedProductIds:[],modifiedOptionIds:[],modifiedProductFields:{},modifiedOptionFields:{},selectedItems:[],selectedOptionIds:[]}),r.state.set({bulkSalesStatus:null,bulkDisplayStatus:null,bulkPriceCondition:null,bulkStockCondition:null,bulkConfirmData:null,bulkUpdateErrors:[]})}function Ur(r,c){const e=it();if(!e)return;const t=e.state.getLocal()||{},o=e.state.get()||{},n=e.dataSource.get("products")?.data?.data||[],s=o.bulkSelectedItems||t.selectedItems||[],i=o.bulkSelectedOptionIds||t.selectedOptionIds||[],l={products:[],options:[],summary:{productCount:0,optionCount:0,hasBulkChanges:!1,hasInlineChanges:!1}},u=!!(o.bulkSalesStatus||o.bulkDisplayStatus||o.bulkPriceCondition||o.bulkStockCondition);l.summary.hasBulkChanges=u;const d=t.modifiedProductIds||[],m=t.modifiedOptionIds||[],f=t.modifiedProductFields||{},g=t.modifiedOptionFields||{},w=new Set(s);function y(_,p,v){const C=[],A=g[p]||[];if(o.bulkPriceCondition&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_price_adjustment_inline",{value:o.bulkPriceCondition})||`Price adjustment: ${o.bulkPriceCondition}`),o.bulkStockCondition&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_stock_inline",{value:o.bulkStockCondition})||`Stock: ${o.bulkStockCondition}`),v){if(!o.bulkPriceCondition&&A.includes("price_adjustment")){const x=_.price_adjustment?.toLocaleString?.()??_.price_adjustment;C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_price_adjustment_inline",{value:x})||`Price adjustment: ${x}`)}!o.bulkPriceCondition&&A.includes("selling_price")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_selling_price",{price:_.selling_price?.toLocaleString?.()??_.selling_price})||`Selling price: ${_.selling_price}`),!o.bulkStockCondition&&A.includes("stock_quantity")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_stock_inline",{value:_.stock_quantity})||`Stock: ${_.stock_quantity}`),A.includes("option_name")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_option_name",{name:Y(_.option_name)})||`Option name: ${Y(_.option_name)}`),A.includes("sku")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sku",{sku:_.sku})||`SKU: ${_.sku}`),A.includes("list_price")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price",{price:_.list_price?.toLocaleString?.()??_.list_price})||`List price: ${_.list_price}`),A.includes("safe_stock_quantity")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_safe_stock",{value:_.safe_stock_quantity})||`Safe stock: ${_.safe_stock_quantity}`),A.includes("is_active")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_is_active",{value:_.is_active?"ON":"OFF"})||`Active: ${_.is_active?"ON":"OFF"}`),l.summary.hasInlineChanges=!0}return C}s.length>0&&n.filter(_=>s.includes(_.id)).forEach(_=>{const p=[],v=_._modified||d.includes(_.id),C=String(_.id),A=f[C]||[];if(o.bulkSalesStatus){const I=Se(e,"sales_status",o.bulkSalesStatus);p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sales_status",{status:I})||`Sales status: ${I}`)}if(o.bulkDisplayStatus){const I=Se(e,"display_status",o.bulkDisplayStatus);p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_display_status",{status:I})||`Display status: ${I}`)}if(v){if(!o.bulkSalesStatus&&A.includes("sales_status")){const I=Se(e,"sales_status",_.sales_status);p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sales_status",{status:I})||`Sales status: ${I}`)}if(!o.bulkDisplayStatus&&A.includes("display_status")){const I=Se(e,"display_status",_.display_status);p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_display_status",{status:I})||`Display status: ${I}`)}A.includes("name")&&p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_name_changed")||"Name changed"),A.includes("list_price")&&p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price",{price:_.list_price?.toLocaleString?.()||_.list_price})||`List price: ${_.list_price?.toLocaleString?.()||_.list_price}`),A.includes("selling_price")&&p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_selling_price",{price:_.selling_price?.toLocaleString?.()||_.selling_price})||`Selling price: ${_.selling_price?.toLocaleString?.()||_.selling_price}`),l.summary.hasInlineChanges=!0}(p.length>0||u||v)&&l.products.push({id:_.id,name:Y(_.name),changes:p.length>0?p.join(", "):v?e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_inline_modified")||"Inline modified":e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_bulk_applied")||"Bulk changes applied"});const x=u&&!!(o.bulkPriceCondition||o.bulkStockCondition);if(Array.isArray(_.options))_.options.forEach(I=>{const N=`${_.id}-${I.id}`,L=I._modified||m.includes(N),O=y(I,N,L);(O.length>0||x||L)&&l.options.push({productId:_.id,optionId:I.id,productName:Y(_.name),optionName:I.option_name_localized||Y(I.option_name)||I.name||"",changes:O.length>0?O.join(", "):L?e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_inline_modified")||"Inline modified":e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_bulk_applied")||"Bulk changes applied"})});else if(x){const I=Number(_.options_total_count)||0;I>0&&l.options.push({aggregate:!0,productId:_.id,optionId:0,optionCount:I,productName:Y(_.name),optionName:"",changes:y({},`${_.id}-*`,!1).join(", ")||e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_bulk_applied")||"Bulk changes applied"})}}),i.length>0&&n.forEach(_=>{w.has(_.id)||(_.options||[]).forEach(p=>{const v=`${_.id}-${p.id}`;if(i.includes(v)){const C=p._modified||m.includes(v),A=y(p,v,C);l.options.push({productId:_.id,optionId:p.id,productName:Y(_.name),optionName:p.option_name_localized||Y(p.option_name)||p.name||"",changes:A.length>0?A.join(", "):C?e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_inline_modified")||"Inline modified":e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_selected")||"Selected"})}})}),l.summary.productCount=l.products.length,l.summary.optionCount=l.options.reduce((_,p)=>_+(p.aggregate?p.optionCount||0:1),0),e.state.set({bulkConfirmData:l}),W.log("[buildConfirmData] Built confirm data:",l)}const M=window.G7Core?.createLogger?.("Ecom:Order")??{log:(...r)=>console.log("[Ecom:Order]",...r),warn:(...r)=>console.warn("[Ecom:Order]",...r),error:(...r)=>console.error("[Ecom:Order]",...r)};function Vr(r){const{currencies:c=[]}=r.params||{},e=[{field:"no",header:"NO",width:"60px",sortable:!1,required:!0},{field:"ordered_at",header:"$t:sirsoft-ecommerce.admin.order.column.order_date",width:"140px",sortable:!0,required:!0},{field:"order_number",header:"$t:sirsoft-ecommerce.admin.order.column.order_number",width:"140px",sortable:!0,required:!0},{field:"product",header:"$t:sirsoft-ecommerce.admin.order.column.product",width:"auto",sortable:!1,required:!0},{field:"order_amount",header:"$t:sirsoft-ecommerce.admin.order.column.order_amount",width:"120px",sortable:!0,required:!1},{field:"order_status",header:"$t:sirsoft-ecommerce.admin.order.column.order_status",width:"100px",sortable:!1,required:!1},{field:"orderer",header:"$t:sirsoft-ecommerce.admin.order.column.orderer",width:"120px",sortable:!1,required:!1},{field:"payment_method",header:"$t:sirsoft-ecommerce.admin.order.column.payment_method",width:"100px",sortable:!1,required:!1},{field:"delivery_method",header:"$t:sirsoft-ecommerce.admin.order.column.delivery_method",width:"100px",sortable:!1,required:!1},{field:"country",header:"$t:sirsoft-ecommerce.admin.order.column.country",width:"80px",sortable:!1,required:!1}],t=c.filter(o=>!o.is_default).map(o=>({field:`order_amount_${o.code.toLowerCase()}`,header:`${o.name} (${o.symbol})`,width:"100px",sortable:!0,required:!1}));return[...e,...t]}function jr(r,c){const e=r.params||{},{target:t="_local",path:o,value:a}=e,n=window.G7Core;if(!n?.state)return M.warn("[toggleArrayValue] G7Core.state를 사용할 수 없습니다."),{success:!1,newValue:[]};const s=t==="_global"?n.state.getGlobal():n.state.getLocal(),i=o.split(".");let l=s;for(const d of i)l=l?.[d];Array.isArray(l)||(l=[]);let u;return l.includes(a)?u=l.filter(d=>d!==a):u=[...l,a],t==="_global"?n.state.setGlobal({[o]:u}):n.state.setLocal({[o]:u}),{success:!0,newValue:u}}function Br(r,c){const e=r.params||{},{filterKey:t,checked:o}=e,a=window.G7Core;if(!a?.state)return M.warn("[toggleVisibleFilter] G7Core.state를 사용할 수 없습니다."),{success:!1,newValue:[]};const s=a.state.getLocal().visibleFilters||[];let i;o?i=s.includes(t)?s:[...s,t]:i=s.filter(l=>l!==t),a.state.setLocal({visibleFilters:i});try{localStorage.setItem("g7_order_filter_visible_filters",JSON.stringify(i))}catch(l){M.warn("[toggleVisibleFilter] LocalStorage 저장 실패:",l)}return{success:!0,newValue:i}}function Kr(r,c){const e=r.params||{},{selectedIds:t=[]}=e,o=window.G7Core;return o?.state?(o.state.setLocal({selectedItems:t,selectAll:!1}),{success:!0}):(M.warn("[syncOrderSelection] G7Core.state를 사용할 수 없습니다."),{success:!1})}function Qr(r,c){const e=r.params||{},{actionId:t,row:o}=e,a=window.G7Core;if(!t||!o?.order_number)return M.warn("[handleOrderRowAction] actionId 또는 row.order_number가 없습니다."),{success:!1};switch(t){case"view":a?.navigate?.(`/admin/ecommerce/orders/${o.order_number}`);break;case"edit":a?.navigate?.(`/admin/ecommerce/orders/${o.order_number}`);break;case"status":case"shipping":case"memo":case"delete":a?.state?.setGlobal({orderList:{currentActionOrderId:o.id,currentActionType:t}});break;case"print":window.open(`/admin/ecommerce/orders/${o.order_number}/shipping-label`,"_blank");break;default:return console.warn(`[handleOrderRowAction] 알 수 없는 액션: ${t}`),{success:!1}}return{success:!0,action:t,targetId:o.id}}async function zr(r,c){const e=r.params||{},{actionType:t,orderIds:o,status:a,carrierId:n,trackingNumber:s}=e,i=window.G7Core;if(!o||o.length===0)throw new Error("선택된 주문이 없습니다.");let l="";const u={ids:o};switch(t){case"status":if(!a)throw new Error("변경할 상태를 선택해주세요.");l="/api/modules/sirsoft-ecommerce/admin/orders/bulk",u.order_status=a;break;case"shipping":if(!s&&!n)throw new Error("택배사 또는 운송장번호를 입력해주세요.");l="/api/modules/sirsoft-ecommerce/admin/orders/bulk",n&&(u.carrier_id=n),s&&(u.tracking_number=s);break;default:throw new Error(`알 수 없는 액션 타입: ${t}`)}const d=await i?.api?.call(l,{method:"PATCH",body:u});if(!d?.success)throw new Error(d?.message||"일괄 처리에 실패했습니다.");return{success:!0,successCount:d.data?.updated_count??o.length,failCount:d.data?.fail_count??0,errors:d.data?.errors??[]}}async function Wr(r,c){const e=r.params||{},{downloadType:t,orderIds:o,filters:a}=e,n=window.G7Core,s="/api/modules/sirsoft-ecommerce/admin/orders/export",i={};if(t==="selected"){if(!o||o.length===0)throw new Error("선택된 주문이 없습니다.");i.ids=o}else i.filters=a||{};const l=n?.auth?.getToken(),u=await fetch(s,{method:"POST",headers:{"Content-Type":"application/json",...l?{Authorization:`Bearer ${l}`}:{}},body:JSON.stringify(i)});if(!u.ok){const y=await u.json().catch(()=>null);throw new Error(y?.message||"엑셀 다운로드에 실패했습니다.")}const d=await u.blob(),m=window.URL.createObjectURL(d),f=document.createElement("a");f.href=m;const g=new Date,w=`${g.getFullYear()}-${String(g.getMonth()+1).padStart(2,"0")}-${String(g.getDate()).padStart(2,"0")}`;return f.download=`orders_${w}.xlsx`,document.body.appendChild(f),f.click(),document.body.removeChild(f),window.URL.revokeObjectURL(m),{success:!0}}function Yr(r,c){const e=r.params||{},{columns:t=[]}=e;try{localStorage.setItem("g7_order_visible_columns",JSON.stringify(t))}catch(o){return M.warn("[saveVisibleColumns] LocalStorage 저장 실패:",o),{success:!1}}return{success:!0}}function Jr(r,c){const t=(r.params||{}).defaultColumns||["order_date","order_number","order_status","order_amount","product","orderer","payment_method","delivery_method","country"];try{const o=localStorage.getItem("g7_order_visible_columns");if(o){const a=JSON.parse(o);if(Array.isArray(a))return a}}catch(o){M.warn("[loadVisibleColumns] LocalStorage 로드 실패:",o)}return t}function Xr(r,c){const e=r.params||{},{id:t,defaultFilters:o=[]}=e,a=window.G7Core;if(!t)return M.warn("[loadVisibleFilters] id 파라미터가 필요합니다."),o;let n;try{const i=a?.AuthManager?.getInstance();i&&(n=i.getUser()?.uuid)}catch{}const s=`g7_filters_${t}${n!==void 0?`_${n}`:""}`;try{const i=localStorage.getItem(s);if(i){const l=JSON.parse(i);if(Array.isArray(l)&&l.length>0)return M.log(`[loadVisibleFilters] localStorage에서 로드: ${s}`,l),l}}catch(i){M.warn("[loadVisibleFilters] LocalStorage 로드 실패:",i)}return M.log(`[loadVisibleFilters] 기본값 사용: ${s}`,o),o}function Zr(r,c){const e=window.G7Core;if(!e)return;const t=e.state.get()||{},o=t.bulkSelectedItems||[],a=t.bulkOrderStatus||"",n=t.bulkCourier||"",s=t.bulkTrackingNumber||"";if(!a){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_status_selected")||"변경할 주문상태를 선택해주세요.");return}if(["shipping_ready","shipping"].includes(a)&&(!n||!s)){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.carrier_required")||"해당 상태로 변경하려면 택배사와 송장번호를 모두 입력해주세요.");return}e.state.set({bulkConfirmData:{selectedItems:o,orderStatus:a,courierId:n,trackingNumber:s}}),e.modal?.open?.("modal_bulk_confirm")}async function en(r,c){const e=window.G7Core;if(!e)return;const o=(e.state.get()||{}).bulkConfirmData;if(!o||!o.selectedItems?.length){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.error")||"일괄 처리에 실패했습니다.");return}const a={ids:o.selectedItems,order_status:o.orderStatus};o.courierId&&(a.carrier_id=o.courierId),o.trackingNumber&&(a.tracking_number=o.trackingNumber);try{await e.api.patch("/api/modules/sirsoft-ecommerce/admin/orders/bulk",a),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.success")||"일괄 변경이 완료되었습니다."),e.state.setLocal({selectedItems:[],selectAll:!1,bulkOrderStatus:"",bulkCourier:"",bulkTrackingNumber:""}),e.state.set({bulkConfirmData:null}),e.dataSource?.refetch?.("orders"),e.modal?.close?.()}catch(n){M.error("[executeOrderBulkAction] Error:",n);const i=(n?.response?.data||n?.data||{})?.message||n?.message;e.toast?.error?.(i||e.t?.("sirsoft-ecommerce.admin.order.bulk.error")||"일괄 처리에 실패했습니다.")}}const He=window.G7Core?.createLogger?.("Ecom:ProductList")??{log:(...r)=>console.log("[Ecom:ProductList]",...r),warn:(...r)=>console.warn("[Ecom:ProductList]",...r),error:(...r)=>console.error("[Ecom:ProductList]",...r)};async function tn(r,c){const e=r.params||{},{actionId:t,row:o}=e,a=window.G7Core;if(!t||!o?.id)return He.warn("[handleProductRowAction] actionId 또는 row.id가 없습니다."),{success:!1};switch(t){case"edit":a?.navigate?.(`/admin/ecommerce/products/${o.product_code}/edit`);break;case"copy":a?.state?.setLocal({productList:{copyTargetProduct:o},ui:{showCopyModal:!0}});break;case"delete":try{const s=(await a?.api?.get(`/api/modules/sirsoft-ecommerce/admin/products/${o.id}/can-delete`))?.data;a?.state?.setLocal({productList:{deleteTargetProduct:o,canDelete:s?.canDelete??!0,deleteBlockReason:s?.reason??null,relatedData:s?.relatedData??null},ui:{showDeleteModal:!0}})}catch(n){He.error("[handleProductRowAction] 삭제 가능 여부 확인 실패:",n),a?.state?.setLocal({productList:{deleteTargetProduct:o,canDelete:!1,deleteBlockReason:"삭제 가능 여부를 확인할 수 없습니다.",relatedData:null},ui:{showDeleteModal:!0}})}break;default:return He.warn(`[handleProductRowAction] 알 수 없는 액션: ${t}`),{success:!1}}return{success:!0,action:t,targetId:o.id}}const U=window.G7Core?.createLogger?.("Ecom:OrderDetail")??{log:(...r)=>console.log("[Ecom:OrderDetail]",...r),warn:(...r)=>console.warn("[Ecom:OrderDetail]",...r),error:(...r)=>console.error("[Ecom:OrderDetail]",...r)};function dt(){return typeof document>"u"?"":document.querySelector('input[name="batchTrackingNumber"]')?.value?.trim()??""}function on(r,c){const e=window.G7Core;if(!e?.state){U.warn("[initOrderDetailForm] G7Core.state를 사용할 수 없습니다.");return}e.state.getLocal();const o=c?.data?.data??e.dataSource?.get?.("order")?.data;if(!o){U.warn("[initOrderDetailForm] order 데이터가 없습니다.");return}e.state.setLocal({form:{recipient_name:o.recipient_name||"",recipient_phone:o.recipient_phone||"",recipient_tel:o.recipient_tel||"",recipient_zipcode:o.recipient_zipcode||"",recipient_address:o.recipient_address||"",recipient_detail_address:o.recipient_detail_address||"",delivery_memo:o.delivery_memo||"",admin_memo:o.admin_memo||""}}),U.log("[initOrderDetailForm] 폼 초기화 완료")}function rn(r,c){const e=window.G7Core,{optionId:t}=r.params||{};if(!e?.state||!t)return;const a=e.state.getLocal().selectedProducts||[];let n;a.includes(t)?n=a.filter(u=>u!==t):n=[...a,t];const i=(e.dataSource?.get?.("order")?.data?.options||[]).length,l=n.length===i&&i>0;e.state.setLocal({selectedProducts:n,selectAll:l})}function nn(r,c){const e=window.G7Core;if(!e?.state)return;const o=e.state.getLocal().selectAll,n=e.dataSource?.get?.("order")?.data?.options||[];if(o)e.state.setLocal({selectedProducts:[],selectAll:!1});else{const s=n.map(i=>i.id);e.state.setLocal({selectedProducts:s,selectAll:!0})}}function an(r,c){const e=window.G7Core;if(!e)return;const t=r.params||{},o=t.selectedProducts||[],a=t.batchOrderStatus||"",n=t.batchCarrierId||"",s=t.batchTrackingNumber||dt();if(!a){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_status_selected")||"변경할 주문상태를 선택해주세요.");return}if(o.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_items_selected")||"변경할 상품을 선택해주세요.");return}if(a==="cancelled"){const f=t.cancelPg??!0,g=e.dataSource?.get?.("order")?.data,w=g?.options||[],y=o.map(_=>{const p=w.find(v=>v.id===_);return!p||p.option_status==="cancelled"?null:{id:p.id,product_name:p.product_name,product_option_name:p.product_option_name,thumbnail_url:p.product_snapshot?.thumbnail_url||"",unit_price:p.unit_price,quantity:p.quantity,cancel_quantity:p.quantity,option_status:p.option_status,option_status_label:p.option_status_label||p.option_status}}).filter(Boolean);if(y.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_cancellable_items")||"취소할 수 있는 상품이 없습니다. 이미 취소된 상품은 제외됩니다.");return}if(f){e.state.setLocal({cancelItems:y,cancelReason:"",cancelReasonDetail:"",cancelPg:!0,refundPriority:"pg_first",refundEstimate:null,refundLoading:!1,isCancelling:!1,cancelError:null,cancelValidationErrors:null}),e.modal?.open?.("modal_cancel_order");const _=g?.order_number||"";_&&e.dispatch?.({handler:"sirsoft-ecommerce.estimateRefundAmount",params:{orderId:_,cancelItems:y}})}else{const _={};for(const p of y)_[p.id]=p.quantity;e.state.setLocal({bulkConfirmItems:y,changeQuantities:_,batchOrderStatus:a}),e.modal?.open?.("modal_batch_change_confirm")}return}if(["shipping_ready","shipping","delivered"].includes(a)&&(!n||!s)){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.carrier_required")||"해당 상태로 변경하려면 택배사와 송장번호를 모두 입력해주세요.");return}const u=e.dataSource?.get?.("order")?.data?.options||[],d=[],m={};for(const f of o){const g=u.find(w=>w.id===f);g&&(d.push({id:g.id,product_name:g.product_name,product_option_name:g.product_option_name,sku:g.sku,thumbnail_url:g.product_snapshot?.thumbnail_url||"",unit_price:g.unit_price,original_price:g.product_snapshot?.original_price||g.unit_price,quantity:g.quantity,option_status:g.option_status}),m[g.id]=g.quantity)}e.state.setLocal({bulkConfirmItems:d,changeQuantities:m,batchOrderStatus:a,batchCarrierId:n,batchTrackingNumber:s}),e.modal?.open?.("modal_batch_change_confirm")}async function sn(r,c){const e=window.G7Core,{orderId:t}=r.params||{};if(!e?.state||!t){U.warn("[processOrderDetailBulkChange] orderId가 없습니다.");return}const o=e.state.getLocal?.()??{},a=r.params?.selectedProducts??o.selectedProducts,n=r.params?.batchOrderStatus||o.batchOrderStatus,s=r.params?.batchCarrierId||o.batchCarrierId,i=r.params?.batchTrackingNumber||o.batchTrackingNumber||dt(),l=r.params?.changeQuantities??o.changeQuantities;if(!a||a.length===0){U.warn("[processOrderDetailBulkChange] 선택된 상품이 없습니다.");return}if(!n){U.warn("[processOrderDetailBulkChange] 변경할 상태가 없습니다.");return}const d=e.dataSource?.get?.("order")?.data?.options||[],f={items:a.map(g=>{const w=d.find(y=>y.id===g);return{option_id:g,quantity:l?.[g]??w?.quantity??1}}),status:n};s&&(f.carrier_id=s),i&&(f.tracking_number=i);try{const g=await e.api.patch(`/api/modules/sirsoft-ecommerce/admin/orders/${t}/options/bulk-status`,f);if(g?.success){const w=e.t,y=g.data?.changed_count??a.length;e.toast?.success?.(w?.("sirsoft-ecommerce.admin.order.detail.handler.bulk_change_success",{count:y})??`${y}개 옵션의 상태가 변경되었습니다.`),e.modal?.close?.("modal_batch_change_confirm"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order_logs"}}),e.state.setLocal({selectedProducts:[],selectAll:!1,batchOrderStatus:"",batchCarrierId:"",batchTrackingNumber:"",changeQuantities:{}})}else throw new Error(g?.message||"상태 변경에 실패했습니다.")}catch(g){U.error("[processOrderDetailBulkChange] API 호출 실패:",g);const y=(g?.response?.data||g?.data||{})?.message||g?.message;e.toast?.error?.(y??e.t?.("sirsoft-ecommerce.admin.order.detail.handler.bulk_change_failed")??"상태 변경에 실패했습니다.")}}async function cn(r,c){const e=window.G7Core,{orderId:t}=r.params||{};if(!e?.state||!t)return;const a=e.state.getLocal().form?.admin_memo??"";try{const n=await e.api.patch(`/api/modules/sirsoft-ecommerce/admin/orders/${t}`,{admin_memo:a});if(n?.success)e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.detail.handler.memo_save_success")??"관리자 메모가 저장되었습니다.");else throw new Error(n?.message)}catch(n){U.error("[saveAdminMemo] 저장 실패:",n),e.toast?.error?.(n?.message??"메모 저장에 실패했습니다.")}}function ln(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,maxQuantity:o}=r.params||{};if(!t||!o)return;const a=r.params?.value??r.$event?.target?.value,n=parseInt(String(a),10),s=Math.max(1,Math.min(isNaN(n)?o:n,o)),l={...e.state.getLocal().changeQuantities||{}};l[t]=s,e.state.setLocal({changeQuantities:l})}function dn(r,c){const e=window.G7Core;if(!e?.state){U.warn("[openConfirmDepositModal] G7Core.state 를 사용할 수 없습니다.");return}const t=e.dataSource?.get?.("order")?.data,o=t?.depositor_name??"",a=t?.total_due_amount??0;e.state.setLocal({depositorName:o,depositAmount:a,depositConfirmErrors:null,isConfirmingDeposit:!1,markOrderComplete:!1}),e.modal?.open?.("modal_confirm_deposit")}async function un(r,c){const e=window.G7Core,{orderId:t}=r.params||{};if(!e?.state||!t){U.warn("[confirmDeposit] orderId 가 없습니다.");return}const o=e.state.getLocal(),a=r.params?.amount??o.depositAmount,n=r.params?.depositorName??o.depositorName??"",s=r.params?.markOrderComplete??o.markOrderComplete??!1,i=s===!0||s==="true";e.state.setLocal({isConfirmingDeposit:!0,depositConfirmErrors:null});try{const l=await e.api.patch(`/api/modules/sirsoft-ecommerce/admin/orders/${t}/confirm-deposit`,{amount:a,depositor_name:n,mark_order_complete:i});if(l?.success)e.state.setLocal({isConfirmingDeposit:!1,depositConfirmErrors:null}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.detail.modal.confirm_deposit.confirm_success")??"입금이 확인되어 결제완료 처리되었습니다."),e.modal?.close?.("modal_confirm_deposit"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order_logs"}});else throw new Error(l?.message||"Deposit confirm failed")}catch(l){U.error("[confirmDeposit] 실패:",l);const u=l?.response?.data||l?.data||{},d=u?.errors?.detail||u?.message||l?.message||"",m=e.t?.("sirsoft-ecommerce.admin.order.detail.modal.confirm_deposit.confirm_failed")??"입금 확인에 실패했습니다.";e.state.setLocal({isConfirmingDeposit:!1,depositConfirmErrors:{amount:[d||m]}}),e.toast?.error?.(d||m)}}const qe=window.G7Core?.createLogger?.("Ecom:CancelOrder")??{log:(...r)=>console.log("[Ecom:CancelOrder]",...r),warn:(...r)=>console.warn("[Ecom:CancelOrder]",...r),error:(...r)=>console.error("[Ecom:CancelOrder]",...r)};let K=null;function mn(){K&&(clearTimeout(K),K=null,qe.log("[clearCancelOrderTimers] debounce 타이머 정리 완료"))}function pn(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,maxQuantity:o,orderId:a}=r.params||{};if(!t||!o||!a)return;const n=r.params?.value??r.$event?.target?.value,s=parseInt(String(n),10),i=Math.max(1,Math.min(isNaN(s)?o:s,o)),u=(e.state.getLocal().cancelItems||[]).map(m=>m.id===t?{...m,cancel_quantity:i}:m);e.state.setLocal({cancelItems:u});const d=window.__g7ActionContext;K&&clearTimeout(K),K=setTimeout(()=>{const m=window.__g7ActionContext;window.__g7ActionContext=d;try{Ue(e,a)}finally{window.__g7ActionContext=m}},500)}async function fn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,cancelItems:o}=r.params||{};t&&await Ue(e,t,o)}async function Ue(r,c,e){const t=r.state.getLocal(),o=e||t.cancelItems||[],a=t.refundPriority||"pg_first";if(o.length===0){r.state.setLocal({refundEstimate:null});return}const n=o.map(s=>({order_option_id:s.id,cancel_quantity:s.cancel_quantity}));r.state.setLocal({refundLoading:!0});try{const s=await r.api.post(`/api/modules/sirsoft-ecommerce/admin/orders/${c}/estimate-refund`,{items:n,refund_priority:a});if(s?.success)r.state.setLocal({refundEstimate:s.data,refundLoading:!1,cancelError:null});else throw new Error(s?.message||"Estimate failed")}catch(s){qe.error("[estimateRefundAmount] 실패:",s),r.state.setLocal({refundLoading:!1,refundEstimate:null}),r.toast?.error?.(r.t?.("sirsoft-ecommerce.admin.order.detail.modal.cancel.estimate_failed")??"환불 예상금액 계산에 실패했습니다.")}}function gn(r,c){const e=window.G7Core;if(!e?.state)return;const{priority:t,orderId:o}=r.params||{};if(!t||!o)return;e.state.setLocal({refundPriority:t});const a=window.__g7ActionContext;K&&clearTimeout(K),K=setTimeout(()=>{const n=window.__g7ActionContext;window.__g7ActionContext=a;try{Ue(e,o)}finally{window.__g7ActionContext=n}},300)}async function _n(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};if(!t)return;const o=e.state.getLocal(),a=r.params?.cancelItems||o.cancelItems||[],n=r.params?.cancelReason||o.cancelReason||"",s=r.params?.cancelReasonDetail||o.cancelReasonDetail||"",i=r.params?.cancelPg??o.cancelPg??!0,l=r.params?.refundPriority||o.refundPriority||"pg_first",u={type:"partial",cancel_pg:i,refund_priority:l,items:a.map(d=>({order_option_id:d.id,cancel_quantity:d.cancel_quantity}))};n&&(u.reason=n),s&&(u.reason_detail=s),e.state.setLocal({isCancelling:!0,cancelError:null,cancelValidationErrors:null});try{const d=await e.api.post(`/api/modules/sirsoft-ecommerce/admin/orders/${t}/cancel`,u);if(d?.success)e.state.setLocal({isCancelling:!1}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.detail.modal.cancel.cancel_success")??"주문이 취소되었습니다."),e.modal?.close?.("modal_cancel_order"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order_logs"}}),e.state.setLocal({selectedProducts:[],selectAll:!1,batchOrderStatus:""});else throw new Error(d?.message||"Cancel failed")}catch(d){qe.error("[executeCancelOrder] 실패:",d);const m=d?.response?.data||d?.data,g=(d?.response?.status||d?.status)===422,w=m?.errors?.detail,y=m?.errors??null,_=w||m?.message||d?.message||"",p=e.t?.("sirsoft-ecommerce.admin.order.detail.modal.cancel.cancel_failed")??"주문 취소에 실패했습니다.";g?(e.state.setLocal({isCancelling:!1,cancelError:null,cancelValidationErrors:y}),e.toast?.error?.(_||p)):(e.state.setLocal({isCancelling:!1,cancelError:_||p,cancelValidationErrors:null}),e.toast?.error?.(_||p))}}const ut=window.G7Core?.createLogger?.("Ecom:UserCancelOrder")??{log:(...r)=>console.log("[Ecom:UserCancelOrder]",...r),warn:(...r)=>console.warn("[Ecom:UserCancelOrder]",...r),error:(...r)=>console.error("[Ecom:UserCancelOrder]",...r)};let R=null;function ve(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}function wn(){R&&(clearTimeout(R),R=null)}function yn(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t}=r.params||{};if(!t)return;const a=[...e.state.getLocal().selectedItemIds||[]],n=a.indexOf(t);n>=0?a.splice(n,1):a.push(t);const i=(e.dataSource?.get?.("order")?.data?.options||[]).filter(u=>u.option_status!=="cancelled").map(u=>u.id),l=i.length>0&&i.every(u=>a.includes(u));e.state.setLocal({selectedItemIds:a,selectAllItems:l})}function hn(r,c){const e=window.G7Core;if(!e?.state)return;const t=e.state.getLocal(),a=(e.dataSource?.get?.("order")?.data?.options||[]).filter(s=>s.option_status!=="cancelled").map(s=>s.id),n=!t.selectAllItems;e.state.setLocal({selectedItemIds:n?a:[],selectAllItems:n})}function Cn(r,c){const e=window.G7Core;if(!e?.state)return;const t=e.dataSource?.get?.("order")?.data;if(!t)return;const a=e.state.getLocal().selectedItemIds||[];if(a.length===0){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.no_selected_items")??"취소할 상품을 선택해주세요.");return}const s=(t.options||[]).filter(l=>a.includes(l.id)&&l.option_status!=="cancelled").map(l=>({id:l.id,product_name:l.product_name??"",product_option_name:l.product_option_name??"",thumbnail_url:l.product_snapshot?.thumbnail_url||"",unit_price:l.unit_price??0,quantity:l.quantity??1,cancel_quantity:l.quantity??1,option_status:l.option_status??"",selected:!0}));if(s.length===0){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.no_cancellable_items")??"취소 가능한 상품이 없습니다.");return}e.state.setLocal({cancelItems:s,cancelSelectAll:!0,cancelReason:"",refundEstimate:null,refundLoading:!1,isCancelling:!1,cancelError:null}),e.modal?.open?.("modal_cancel_order");const i=r.params?.orderId||t.id;(i||t.order_number)&&le(e,i)}function bn(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,orderId:o}=r.params||{};if(!t)return;const n=(e.state.getLocal().cancelItems||[]).map(i=>i.id===t?{...i,selected:!i.selected}:i),s=n.every(i=>i.selected);e.state.setLocal({cancelItems:n,cancelSelectAll:s}),R&&clearTimeout(R),R=setTimeout(()=>{le(e,o)},500)}function In(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{},o=e.state.getLocal(),a=!o.cancelSelectAll,n=(o.cancelItems||[]).map(s=>({...s,selected:a}));e.state.setLocal({cancelItems:n,cancelSelectAll:a}),R&&clearTimeout(R),R=setTimeout(()=>{le(e,t)},500)}function Sn(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,maxQuantity:o,orderId:a}=r.params||{};if(!t||!o||!a&&!ve(e))return;const n=r.params?.value??r.$event?.target?.value,s=parseInt(String(n),10),i=Math.max(1,Math.min(isNaN(s)?o:s,o)),u=(e.state.getLocal().cancelItems||[]).map(m=>m.id===t?{...m,cancel_quantity:i}:m);e.state.setLocal({cancelItems:u});const d=window.__g7ActionContext;R&&clearTimeout(R),R=setTimeout(()=>{const m=window.__g7ActionContext;window.__g7ActionContext=d;try{le(e,a)}finally{window.__g7ActionContext=m}},500)}async function vn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};!t&&!ve(e)||await le(e,t)}async function le(r,c){const e=r.state.getLocal(),t=e.cancelItems||[],o=e.refundPriority||"pg_first",a=t.filter(s=>s.selected);if(a.length===0){r.state.setLocal({refundEstimate:null,refundLoading:!1});return}const n=a.map(s=>({order_option_id:s.id,cancel_quantity:s.cancel_quantity}));r.state.setLocal({refundLoading:!0});try{const s=r.state.get?.("_global")?.guestOrderToken,i=r.dataSource?.get?.("order")?.data?.order_number,l=!!(s&&i),u=l?`/api/modules/sirsoft-ecommerce/guest/orders/${i}/estimate-refund`:`/api/modules/sirsoft-ecommerce/user/orders/${c}/estimate-refund`,d=l?{headers:{"X-Guest-Order-Token":s}}:void 0,m=await r.api.post(u,{items:n,refund_priority:o},d);if(m?.success)r.state.setLocal({refundEstimate:m.data,refundLoading:!1,cancelError:null});else throw new Error(m?.message||"Estimate failed")}catch(s){ut.error("[estimateUserRefund] 실패:",s),r.state.setLocal({refundLoading:!1,refundEstimate:null}),r.toast?.error?.(r.t?.("mypage.order_detail.cancel_modal.estimate_failed")??"환불 예상금액 계산에 실패했습니다.")}}function Ln(r,c){const e=window.G7Core;if(!e?.state)return;const{priority:t,orderId:o}=r.params||{};if(!t||!o&&!ve(e))return;e.state.setLocal({refundPriority:t});const a=window.__g7ActionContext;R&&clearTimeout(R),R=setTimeout(()=>{const n=window.__g7ActionContext;window.__g7ActionContext=a;try{le(e,o)}finally{window.__g7ActionContext=n}},300)}async function xn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};if(!t&&!ve(e))return;const o=e.state.getLocal(),n=(o.cancelItems||[]).filter(d=>d.selected),s=r.params?.cancelReason||o.cancelReason||"",i=r.params?.cancelReasonDetail??o.cancelReasonDetail??"",l=r.params?.refundPriority||o.refundPriority||"pg_first";if(n.length===0){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.no_selected_items")??"취소할 상품을 선택해주세요.");return}if(!s){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.reason_required")??"취소 사유를 선택해주세요.");return}const u={reason:s,refund_priority:l,items:n.map(d=>({order_option_id:d.id,cancel_quantity:d.cancel_quantity}))};i&&(u.reason_detail=i),e.state.setLocal({isCancelling:!0,cancelError:null,cancelValidationErrors:null});try{const d=e.state.get?.("_global")?.guestOrderToken,m=e.dataSource?.get?.("order")?.data?.order_number,f=!!(d&&m),g=f?`/api/modules/sirsoft-ecommerce/guest/orders/${m}/cancel`:`/api/modules/sirsoft-ecommerce/user/orders/${t}/cancel`,w=f?{headers:{"X-Guest-Order-Token":d}}:void 0,y=await e.api.post(g,u,w);if(y?.success)e.state.setLocal({isCancelling:!1}),e.toast?.success?.(e.t?.("mypage.order_detail.cancel_success")??"주문이 취소되었습니다."),e.modal?.close?.("modal_cancel_order"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}});else throw new Error(y?.message||"Cancel failed")}catch(d){ut.error("[executeUserCancelOrder] 실패:",d);const m=d?.response?.data||d?.data,f=m?.message||d?.message||"",g=m?.errors??null,w=e.t?.("mypage.order_detail.cancel_modal.cancel_failed")??"주문 취소에 실패했습니다.";e.state.setLocal({isCancelling:!1,cancelError:f||w,cancelValidationErrors:g}),e.toast?.error?.(f||w)}}const mt=window.G7Core?.createLogger?.("Ecom:ConfirmOrder")??{log:(...r)=>console.log("[Ecom:ConfirmOrder]",...r),warn:(...r)=>console.warn("[Ecom:ConfirmOrder]",...r),error:(...r)=>console.error("[Ecom:ConfirmOrder]",...r)};function Pn(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}async function An(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,optionId:o}=r.params||{};if(!o||!t&&!Pn(e)){mt.error("[confirmOrderOption] optionId 또는 식별자 누락");return}e.state.setLocal({isConfirming:!0});try{const a=e.state.get?.("_global")?.guestOrderToken,n=e.dataSource?.get?.("order")?.data?.order_number,s=!!(a&&n),i=s?`/api/modules/sirsoft-ecommerce/guest/orders/${n}/options/${o}/confirm`:`/api/modules/sirsoft-ecommerce/user/orders/${t}/options/${o}/confirm`,l=s?{headers:{"X-Guest-Order-Token":a}}:void 0,u=await e.api.post(i,void 0,l);if(u?.success)e.state.setLocal({isConfirming:!1,confirmTarget:null}),e.modal?.close?.("modal_confirm_purchase"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.toast?.success?.(u.message??e.t?.("sirsoft-ecommerce::messages.order.confirmed")??"구매확정이 완료되었습니다.");else throw new Error(u?.message||"Confirm failed")}catch(a){mt.error("[confirmOrderOption] 실패:",a);const n=a?.response?.data?.message||a?.data?.message||a?.message||e.t?.("sirsoft-ecommerce::messages.order.cannot_confirm")||"구매확정에 실패했습니다.";e.state.setLocal({isConfirming:!1}),e.toast?.error?.(n)}}const pt=window.G7Core?.createLogger?.("Ecom:ChangeShippingAddress")??{log:(...r)=>console.log("[Ecom:ChangeShippingAddress]",...r),warn:(...r)=>console.warn("[Ecom:ChangeShippingAddress]",...r),error:(...r)=>console.error("[Ecom:ChangeShippingAddress]",...r)};function kn(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}async function On(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{},o=kn(e);if(!t&&!o){pt.error("[changeShippingAddress] orderId 또는 비회원 식별자 누락");return}const a=e.state.get?.("_global")??{},s=(o?"manual":a.changeAddressMode??"saved")==="saved"?{address_id:a.selectedAddressId}:a.editingShippingAddress;e.state.setLocal({isSubmittingAddress:!0});try{const i=a.guestOrderToken,l=e.dataSource?.get?.("order")?.data?.order_number,u=o?`/api/modules/sirsoft-ecommerce/guest/orders/${l}/shipping-address`:`/api/modules/sirsoft-ecommerce/user/orders/${t}/shipping-address`,d=o?{headers:{"X-Guest-Order-Token":i}}:void 0,m=await e.api.put(u,s,d);if(m?.success)e.state.setLocal({isSubmittingAddress:!1}),e.modal?.close?.("changeAddressModal"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.toast?.success?.(m.message??e.t?.("mypage.order_detail.shipping_address_changed")??"배송지가 변경되었습니다.");else throw new Error(m?.message||"Change shipping address failed")}catch(i){pt.error("[changeShippingAddress] 실패:",i),e.state.setLocal({isSubmittingAddress:!1});const l=i?.response?.data?.message||i?.data?.message||i?.message||"배송지 변경에 실패했습니다.";e.toast?.error?.(l)}}const Ve=window.G7Core?.createLogger?.("Ecom:Review")??{log:(...r)=>console.log("[Ecom:Review]",...r),warn:(...r)=>console.warn("[Ecom:Review]",...r),error:(...r)=>console.error("[Ecom:Review]",...r)};async function Gn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,optionId:o,productId:a}=r.params||{};if(!t||!o||!a){Ve.error("[submitReview] orderId, optionId 또는 productId 누락");return}const n=e.state.getLocal(),s=r.params?.rating??n.reviewRating??5,i=(r.params?.content??n.reviewContent??"").trim(),u=(r.params?.images??n.reviewImages??[]).map(d=>d instanceof File?d:d?.file instanceof File||d?.file instanceof Blob||d?.file&&typeof d.file.name=="string"&&d.file.size>0?d.file:d instanceof Blob?d:null).filter(d=>d!==null);if(!s||s<1||s>5){e.toast?.error?.(e.t?.("sirsoft-ecommerce.user.review.validation.rating_required")??"별점을 선택해 주세요.");return}if(i.length<10){e.state.setLocal({reviewError:e.t?.("sirsoft-ecommerce.user.review.validation.content_min")??"리뷰 내용을 최소 10자 이상 입력해 주세요."});return}e.state.setLocal({isSubmittingReview:!0,reviewError:null});try{const d=await e.api.post("/api/modules/sirsoft-ecommerce/user/reviews",{order_option_id:o,product_id:a,rating:s,content:i});if(!d?.success)throw new Error(d?.message||"Review creation failed");const m=d.data?.id;if(m&&u.length>0)for(let f=0;fconsole.log("[Ecom:ShippingPolicyForm]",...r),warn:(...r)=>console.warn("[Ecom:ShippingPolicyForm]",...r),error:(...r)=>console.error("[Ecom:ShippingPolicyForm]",...r)},En=["fixed","conditional_free","per_quantity","per_weight","per_volume","per_volume_weight","per_amount"],Fn=["conditional_free"],Dn=["range_amount","range_quantity","range_weight","range_volume","range_volume_weight"],$n=["api"],Nn=["per_quantity","per_weight","per_volume","per_volume_weight","per_amount"],Tn={shipping_method:"parcel",custom_shipping_name:null,carrier:null,currency_code:"KRW",charge_policy:"fixed",base_fee:0,free_threshold:null,ranges:null,api_endpoint:null,api_request_fields:null,api_response_fee_field:null,api_config:null,extra_fee_enabled:!1,extra_fee_settings:[],extra_fee_multiply:!1,is_active:!0};function te(r){return{showBaseFee:En.includes(r),showFreeThreshold:Fn.includes(r),showRanges:Dn.includes(r),showApiSettings:$n.includes(r),showUnitValue:Nn.includes(r)}}function G(){const r=window.G7Core;return r?.state?r:(b.warn("G7Core.state not available"),null)}function E(r,c){r.setLocalState?r.setLocalState(c):G()?.state?.setLocal(c)}function F(r){return[...(r.state.getLocal?.()??{}).form?.country_settings??[]]}function Rn(r,c){const e=r.params||{},t=e.policy,o=!!e.isEdit,a=!!e.isCopy;if(e.availableCountries,!G())return;const s={activeCountryTab:0,rangeErrors:{}};if((o||a)&&t){const i=t.country_settings??[],u=i[0]?.charge_policy??"fixed",d=te(u);Object.assign(s,d),b.log("[initShippingPolicyForm]",o?"Edit":"Copy","mode, countries:",i.length,"first charge_policy:",u)}else{const i=te("fixed");Object.assign(s,i),b.log("[initShippingPolicyForm] Create mode, default flags")}E(c,s)}function Mn(r,c){const t=(r.params||{}).country_code??"";if(!t){b.warn("[addCountrySetting] country_code is empty");return}const o=G();if(!o)return;const a=F(o);if(a.some(u=>u.country_code===t)){b.warn("[addCountrySetting] Duplicate country_code:",t);return}const n={country_code:t,...Tn};a.push(n);const s=a.length-1,i=te(n.charge_policy),l={"form.country_settings":a,activeCountryTab:s,...i};E(c,l),b.log("[addCountrySetting] Added country:",t,"index:",s)}function Hn(r,c){const e=r.params||{},t=Number(e.index);if(isNaN(t)||t<0){b.warn("[removeCountrySetting] Invalid index:",e.index);return}const o=G();if(!o)return;const a=F(o),n=o.state.getLocal?.()??{};if(t>=a.length){b.warn("[removeCountrySetting] Index out of bounds:",t);return}const s=a[t].country_code;a.splice(t,1);const i=n.activeCountryTab??0;let l=i;a.length===0?l=0:i>=a.length?l=a.length-1:i>t&&(l=i-1);const u={...n.rangeErrors??{}};u[s]=[];const d=a[l],m=te(d?d.charge_policy:"fixed"),f={"form.country_settings":a,activeCountryTab:l,rangeErrors:u,...m};E(c,f),b.log("[removeCountrySetting] Removed index:",t,"country:",s,"newTab:",l)}function qn(r,c){const e=r.params||{},t=Number(e.index);if(isNaN(t)||t<0){b.warn("[switchCountryTab] Invalid index:",e.index);return}const o=G();if(!o)return;const a=F(o);if(t>=a.length){b.warn("[switchCountryTab] Index out of bounds:",t);return}const n=a[t],s=te(n.charge_policy),i={activeCountryTab:t,...s};E(c,i),b.log("[switchCountryTab] Switched to tab:",t,"country:",n.country_code,"charge_policy:",n.charge_policy)}function Un(r,c){const e=r.params||{},t=e.field??"",o=e.value;if(!t){b.warn("[updateCountryField] field is empty");return}const a=G();if(!a)return;const n=a.state.getLocal?.()??{},s=Number(n.activeCountryTab??0),i=F(a);if(s>=i.length){b.warn("[updateCountryField] Index out of bounds:",s);return}i[s]={...i[s],[t]:o},E(c,{"form.country_settings":i}),b.log("[updateCountryField] Updated",t,"=",o,"at index:",s)}function Vn(r,c){const e=r.params||{},t=e.value??"",o=Number(e.index??0);if(!t){b.warn("[onChargePolicyChange] charge_policy value is empty");return}const a=G();if(!a)return;const n=te(t);b.log("[onChargePolicyChange]",t,n,"countryIndex:",o);const s=F(a),i=s[o];if(!i){b.warn("[onChargePolicyChange] No country setting at index:",o);return}const l={...i,charge_policy:t};if(n.showBaseFee||(l.base_fee=0),n.showFreeThreshold||(l.free_threshold=null),!n.showRanges&&!n.showUnitValue&&(l.ranges=null),n.showApiSettings||(l.api_endpoint=null,l.api_request_fields=null,l.api_response_fee_field=null,l.api_config=null),n.showRanges&&(!l.ranges||!l.ranges.tiers)){const d=t.replace("range_","");l.ranges={type:d,tiers:[{min:0,max:null,fee:0}]}}n.showUnitValue&&(!l.ranges||!l.ranges.unit_value)&&(l.ranges={type:t,unit_value:1}),s[o]=l;const u={"form.country_settings":s,...n};E(c,u)}function jn(r,c){const e=r.params||{},t=Number(e.index??0),o=G();if(!o)return;const a=F(o),n=a[t];if(!n){b.warn("[addRangeTier] No country setting at index:",t);return}const s=[...n.ranges?.tiers??[]],i=s[s.length-1],u={min:i?.max!=null?i.max+1:0,max:null,fee:0};s.push(u),a[t]={...n,ranges:{...n.ranges??{},tiers:s}},E(c,{"form.country_settings":a}),Le(o,c,t,s,n.country_code),b.log("[addRangeTier] Added tier, total:",s.length)}function Bn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=Number(e.tierIndex);if(isNaN(o)||o<0){b.warn("[removeRangeTier] Invalid tierIndex:",e.tierIndex);return}const a=G();if(!a)return;const n=F(a),s=n[t];if(!s){b.warn("[removeRangeTier] No country setting at index:",t);return}const i=[...s.ranges?.tiers??[]];if(o>=i.length){b.warn("[removeRangeTier] tierIndex out of bounds:",o);return}i.splice(o,1),n[t]={...s,ranges:{...s.ranges??{},tiers:i}},E(c,{"form.country_settings":n}),Le(a,c,t,i,s.country_code),b.log("[removeRangeTier] Removed tier:",o,"remaining:",i.length)}function Kn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=Number(e.tierIndex),a=e.field,n=e.value;if(isNaN(o)||o<0){b.warn("[updateRangeTierField] Invalid tierIndex:",e.tierIndex);return}if(!["min","max","fee"].includes(a)){b.warn("[updateRangeTierField] Invalid field:",a);return}const s=G();if(!s)return;const i=F(s),l=i[t];if(!l){b.warn("[updateRangeTierField] No country setting at index:",t);return}const u=[...l.ranges?.tiers??[]];if(o>=u.length){b.warn("[updateRangeTierField] tierIndex out of bounds:",o);return}let d;a==="max"?d=n===""||n===null||n===void 0?null:Number(n):d=Number(n),u[o]={...u[o],[a]:d},i[t]={...l,ranges:{...l.ranges??{},tiers:u}},E(c,{"form.country_settings":i}),Le(s,c,t,u,l.country_code)}function Qn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=G();if(!o)return;const n=F(o)[t];if(!n)return;const s=n.ranges?.tiers??[];Le(o,c,t,s,n.country_code)}function Le(r,c,e,t,o){const n={...(r.state.getLocal?.()??{}).rangeErrors??{}};if(t.length===0){n[o]=[],E(c,{rangeErrors:n});return}const s=new Array(t.length).fill(null).map(()=>({}));let i=!1;const l=r.t;for(let u=0;u=d.max&&(s[u].min=l?.("sirsoft-ecommerce.validation.shipping_policy.ranges.min_less_than_max")??"시작값이 종료값보다 작아야 합니다.",i=!0),u=i.length){b.warn("[removeExtraFeeRow] feeIndex out of bounds:",o);return}i.splice(o,1),n[t]={...s,extra_fee_settings:i},E(c,{"form.country_settings":n}),b.log("[removeExtraFeeRow] Removed index:",o,"remaining:",i.length)}function Yn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=e.settings;if(!o||!Array.isArray(o)){b.warn("[applyExtraFeeTemplate] Invalid settings:",o);return}const a=G();if(!a)return;const n=F(a),s=n[t];if(!s){b.warn("[applyExtraFeeTemplate] No country setting at index:",t);return}n[t]={...s,extra_fee_settings:o},E(c,{"form.country_settings":n});const i=a.t,l=i?.("sirsoft-ecommerce.admin.shipping_policy.form.template_applied")??"템플릿이 적용되었습니다.";a.toast?.success?.(l),b.log("[applyExtraFeeTemplate] Applied template with",o.length,"rows")}function Jn(r,c){const e=r.params||{},t=parseFloat(e.value)||1,o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];i&&(s[n]={...i,ranges:{...i.ranges??{},type:i.charge_policy,unit_value:t}},E(c,{"form.country_settings":s}),b.log("[updateUnitValue] Updated unit_value:",t))}function Xn(r,c){const e=G();if(!e)return;const t=e.state.getLocal?.()??{},o=Number(t.activeCountryTab??0),a=F(e),n=a[o];if(!n)return;const s=[...n.api_request_fields??[]];s.push(""),a[o]={...n,api_request_fields:s},E(c,{"form.country_settings":a}),b.log("[addApiRequestField] Added field, total:",s.length)}function Zn(r,c){const e=r.params||{},t=Number(e.fieldIndex),o=e.value??"";if(isNaN(t)||t<0){b.warn("[updateApiRequestField] Invalid fieldIndex:",e.fieldIndex);return}const a=G();if(!a)return;const n=a.state.getLocal?.()??{},s=Number(n.activeCountryTab??0),i=F(a),l=i[s];if(!l)return;const u=[...l.api_request_fields??[]];t>=u.length||(u[t]=o,i[s]={...l,api_request_fields:u},E(c,{"form.country_settings":i}),b.log("[updateApiRequestField] Updated index:",t,"value:",o))}function ea(r,c){const e=r.params||{},t=Number(e.fieldIndex);if(isNaN(t)||t<0){b.warn("[removeApiRequestField] Invalid fieldIndex:",e.fieldIndex);return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];if(!i)return;const l=[...i.api_request_fields??[]];t>=l.length||(l.splice(t,1),s[n]={...i,api_request_fields:l},E(c,{"form.country_settings":s}),b.log("[removeApiRequestField] Removed index:",t,"remaining:",l.length))}function ta(r,c){const t=(r.params||{}).field??"";if(!t){b.warn("[toggleApiRequestField] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];if(!i)return;const l=[...i.api_request_fields??[]],u=l.indexOf(t);u>=0?l.splice(u,1):l.push(t);const d=l.length>0?l:null;s[n]={...i,api_request_fields:d},E(c,{"form.country_settings":s}),b.log("[toggleApiRequestField] Toggled:",t,"selected:",d)}function oa(r,c){const e=r.params||{},t=e.field??"";if(!t){b.warn("[updateApiConfigField] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];if(!i)return;const l={...i.api_config??{},[t]:e.value};s[n]={...i,api_config:l},E(c,{"form.country_settings":s}),b.log("[updateApiConfigField] Updated",t,"at index:",n)}function ra(r,c){const e=r.params||{},t=e.field??"";if(!t){b.warn("[updateApiFieldMap] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];if(!i)return;const l=e.value??"",u={...i.api_config?.field_map??{}};l.trim()===""?delete u[t]:u[t]=l;const d={...i.api_config??{},field_map:Object.keys(u).length>0?u:null};s[n]={...i,api_config:d},E(c,{"form.country_settings":s}),b.log("[updateApiFieldMap] Mapped",t,"→",l||"(removed)")}function na(r,c){const e=G();if(!e)return;const t=e.state.getLocal?.()??{},o=Number(t.activeCountryTab??0),n=F(e)[o];if(n){if(!n.api_endpoint){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.shipping_policy.form.api_test_endpoint_required")??"Endpoint required");return}e.state.set?.({apiTestLoading:!0,apiTestResult:null}),e.dispatch({handler:"apiCall",target:"/api/modules/sirsoft-ecommerce/admin/shipping-policies/test-api-call",auth_required:!0,params:{method:"POST",body:{endpoint:n.api_endpoint,request_fields:n.api_request_fields,config:n.api_config??{},sample:{country_code:n.country_code}}},onSuccess:{handler:"setState",params:{target:"global",apiTestLoading:!1,apiTestResult:"{{response.data}}"}},onError:{handler:"setState",params:{target:"global",apiTestLoading:!1,apiTestResult:"{{error.errors ?? error}}"}}})}}function aa(r,c){const e=r.params||{},t=Number(e.feeIndex),o=e.field,a=e.value;if(isNaN(t)||t<0){b.warn("[updateExtraFeeField] Invalid feeIndex:",e.feeIndex);return}if(!["zipcode","fee","region"].includes(o)){b.warn("[updateExtraFeeField] Invalid field:",o);return}const n=G();if(!n)return;const s=n.state.getLocal?.()??{},i=Number(s.activeCountryTab??0),l=F(n),u=l[i];if(!u)return;const d=[...u.extra_fee_settings??[]];if(t>=d.length)return;const m=o==="fee"?parseFloat(a)||0:a;d[t]={...d[t],[o]:m},l[i]={...u,extra_fee_settings:d},E(c,{"form.country_settings":l}),b.log("[updateExtraFeeField] Updated index:",t,o,"=",m)}const me=window.G7Core?.createLogger?.("Handler:InitCategory")??{log:(...r)=>console.log("[Handler:InitCategory]",...r),warn:(...r)=>console.warn("[Handler:InitCategory]",...r),error:(...r)=>console.error("[Handler:InitCategory]",...r)};function ft(r,c){for(const e of r){if(e.id===c)return e;if(e.children&&e.children.length>0){const t=ft(e.children,c);if(t)return t}}return null}function gt(r){return new URLSearchParams(window.location.search).get(r)}function sa(r){return window.G7Core?.state?.getDataSource?.(r)}async function ia(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function ca(r,c){const e=window.G7Core,t=gt("id"),o=gt("mode");if(!t){me.log("[initCategoryFromUrl] No id parameter in URL");return}if(!e?.state?.set){me.warn("[initCategoryFromUrl] G7Core.state.set not available");return}const a=await ia("categories",l=>l?.data?.data);if(!a){me.warn("[initCategoryFromUrl] categories data not available after waiting");return}const n=parseInt(t,10),s=ft(a,n);if(!s){me.warn("[initCategoryFromUrl] Category not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedCategoryId:s.id,selectedCategory:s,panelMode:i}),me.log("[initCategoryFromUrl] Category initialized from URL:",{id:n,mode:i})}const pe=window.G7Core?.createLogger?.("Handler:InitBrand")??{log:(...r)=>console.log("[Handler:InitBrand]",...r),warn:(...r)=>console.warn("[Handler:InitBrand]",...r),error:(...r)=>console.error("[Handler:InitBrand]",...r)};function _t(r){return new URLSearchParams(window.location.search).get(r)}function la(r){return window.G7Core?.state?.getDataSource?.(r)}async function da(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function ua(r,c){const e=window.G7Core,t=_t("id"),o=_t("mode");if(!t){pe.log("[initBrandFromUrl] No id parameter in URL");return}if(!e?.state?.set){pe.warn("[initBrandFromUrl] G7Core.state.set not available");return}const a=await da("brands",l=>l?.data?.data);if(!a){pe.warn("[initBrandFromUrl] brands data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){pe.warn("[initBrandFromUrl] Brand not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedBrandId:s.id,selectedBrand:s,panelMode:i}),pe.log("[initBrandFromUrl] Brand initialized from URL:",{id:n,mode:i})}const fe=window.G7Core?.createLogger?.("Handler:InitCommonInfo")??{log:(...r)=>console.log("[Handler:InitCommonInfo]",...r),warn:(...r)=>console.warn("[Handler:InitCommonInfo]",...r),error:(...r)=>console.error("[Handler:InitCommonInfo]",...r)};function wt(r){return new URLSearchParams(window.location.search).get(r)}function ma(r){return window.G7Core?.state?.getDataSource?.(r)}async function pa(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function fa(r,c){const e=window.G7Core,t=wt("id"),o=wt("mode");if(!t){fe.log("[initCommonInfoFromUrl] No id parameter in URL");return}if(!e?.state?.set){fe.warn("[initCommonInfoFromUrl] G7Core.state.set not available");return}const a=await pa("commonInfos",l=>l?.data);if(!a){fe.warn("[initCommonInfoFromUrl] commonInfos data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){fe.warn("[initCommonInfoFromUrl] Common info not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedId:s.id,selectedItem:s,panelMode:i}),fe.log("[initCommonInfoFromUrl] Common info initialized from URL:",{id:n,mode:i})}const ge=window.G7Core?.createLogger?.("Handler:InitNotice")??{log:(...r)=>console.log("[Handler:InitNotice]",...r),warn:(...r)=>console.warn("[Handler:InitNotice]",...r),error:(...r)=>console.error("[Handler:InitNotice]",...r)};function yt(r){return new URLSearchParams(window.location.search).get(r)}function ga(r){return window.G7Core?.state?.getDataSource?.(r)}async function _a(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function wa(r,c){const e=window.G7Core,t=yt("id"),o=yt("mode");if(!t){ge.log("[initNoticeFromUrl] No id parameter in URL");return}if(!e?.state?.set){ge.warn("[initNoticeFromUrl] G7Core.state.set not available");return}const a=await _a("templates",l=>l?.data);if(!a){ge.warn("[initNoticeFromUrl] templates data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){ge.warn("[initNoticeFromUrl] Template not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedTemplateId:s.id,selectedTemplate:s,panelMode:i}),ge.log("[initNoticeFromUrl] Template initialized from URL:",{id:n,mode:i})}const oe={updateProductField:At,updateOptionField:kt,calculateCurrencyPrices:se,initPreferredCurrency:Tt,initPreferredShippingCountry:qt,setDateRange:Vt,setDefaultOption:jt,toggleOption:Bt,toggleProductOptions:Ye,toggleAllOptionsInRow:Kt,getProductOptionStates:Qt,syncProductSelection:zt,loadExpandedOptions:Fe,retryExpandedOptions:to,generateCopyProductCode:Wt,copyProduct:Yt,selectCategory:ro,selectCategoryMobile:no,addCategoryToSelection:ao,removeCategoryFromSelection:so,getCategoryBreadcrumb:io,validateCategoryPath:lo,initCategoryInfosFromProduct:co,getBrandName:uo,getBrandDescription:mo,updatePrice:_o,calculateTotalOptionStock:wo,validatePriceRelation:yo,addOptionInput:Go,removeOptionInput:Eo,updateOptionInput:Fo,generateOptions:Do,deleteOption:$o,applyOptionAddTool:No,addRequiredItem:To,updateRequiredItem:Ro,removeRequiredItem:Mo,reorderRequiredItems:Ho,addAdditionalOption:qo,updateAdditionalOption:Uo,removeAdditionalOption:Vo,reorderAdditionalOptions:jo,clearAdditionalOptions:Bo,addAdditionalOptionValue:Ko,updateAdditionalOptionValue:Qo,removeAdditionalOptionValue:zo,uploadImages:Xo,setThumbnail:Zo,reorderImages:er,updateDescription:tr,confirmSelectNoticeTemplate:or,selectNoticeTemplate:nt,updateNoticeItem:rr,removeNoticeItem:nr,reorderNoticeItems:ar,fillNoticeWithValue:sr,switchNoticeMode:ir,updateNewTemplateName:cr,addNoticeItem:lr,updateNoticeItemName:dr,saveAsNoticeTemplate:ur,confirmSaveNoticeTemplate:fr,fillTemplateFieldsWithDetailReference:mr,fillNoticeItemsWithDetailReference:pr,toggleLabel:gr,generateProductCode:_r,getShippingPolicyInfo:wr,getCommonInfoContent:yr,updateShoppingIntegration:hr,updateShippingType:Cr,updateIdentificationCode:br,openLabelPeriodModal:Ir,saveLabelPeriod:Sr,removeLabelPeriod:vr,updateActivityLogSort:Lr,updateActivityLogPerPage:xr,setDefaultShippingPolicy:Pr,setLabelDatePreset:kr,toggleDefaultShippingPolicy:Ar,toggleLabelAssignment:Or,saveLabelSettings:$r,deleteLabel:Dr,updateLabelPeriodInline:Gr,setLabelDatePresetInline:Er,confirmUncheckLabel:Fr,removeDescriptionLocale:Nr,showAddLocaleModal:Tr,addDescriptionLocale:Rr,setDefaultOptionFromGrid:Io,addOptionRow:vo,updateFormOptionField:So,recalculateOptionPriceAdjustments:Lo,bulkUpdate:Mr,buildConfirmData:Ur,buildOrderColumns:Vr,toggleArrayValue:jr,toggleVisibleFilter:Br,syncOrderSelection:Kr,handleOrderRowAction:Qr,processOrderBulkAction:zr,buildOrderBulkConfirmData:Zr,executeOrderBulkAction:en,downloadOrderExcel:Wr,saveVisibleColumns:Yr,loadVisibleColumns:Jr,loadVisibleFilters:Xr,handleProductRowAction:tn,initOrderDetailForm:on,toggleProductSelection:rn,toggleAllProducts:nn,buildOrderDetailBulkConfirmData:an,processOrderDetailBulkChange:sn,saveAdminMemo:cn,updateChangeQuantity:ln,openConfirmDepositModal:dn,confirmDeposit:un,initShippingPolicyForm:Rn,addCountrySetting:Mn,removeCountrySetting:Hn,switchCountryTab:qn,updateCountryField:Un,onChargePolicyChange:Vn,addRangeTier:jn,removeRangeTier:Bn,updateRangeTierField:Kn,validateRangeTiers:Qn,addExtraFeeRow:zn,removeExtraFeeRow:Wn,applyExtraFeeTemplate:Yn,updateUnitValue:Jn,addApiRequestField:Xn,updateApiRequestField:Zn,removeApiRequestField:ea,toggleApiRequestField:ta,updateApiConfigField:oa,updateApiFieldMap:ra,testShippingApi:na,updateExtraFeeField:aa,updateCancelQuantity:pn,estimateRefundAmount:fn,changeRefundPriority:gn,executeCancelOrder:_n,clearCancelOrderTimers:mn,toggleItemSelection:yn,toggleSelectAllItems:hn,initUserCancelItems:Cn,toggleUserCancelItem:bn,toggleUserCancelSelectAll:In,updateUserCancelQuantity:Sn,estimateUserRefund:vn,changeUserRefundPriority:Ln,executeUserCancelOrder:xn,clearUserCancelOrderTimers:wn,confirmOrderOption:An,changeShippingAddress:On,submitReview:Gn,initCategoryFromUrl:ca,initBrandFromUrl:ua,initCommonInfoFromUrl:fa,initNoticeFromUrl:wa},V="sirsoft-ecommerce",_e=window.G7Core?.createLogger?.(`Module:${V}`)??{log:(...r)=>console.log(`[Module:${V}]`,...r),warn:(...r)=>console.warn(`[Module:${V}]`,...r),error:(...r)=>console.error(`[Module:${V}]`,...r)};function ht(r=!1){const c=window.G7Core?.getActionDispatcher?.();if(c)Object.entries(oe).forEach(([e,t])=>{const o=`${V}.${e}`;c.registerHandler(o,t,{category:"module",source:V})}),_e.log(`${Object.keys(oe).length} handler(s) registered:`,Object.keys(oe).map(e=>`${V}.${e}`));else if(r){let e=0;const t=50,o=()=>{const a=window.G7Core?.getActionDispatcher?.();a?(Object.entries(oe).forEach(([n,s])=>{const i=`${V}.${n}`;a.registerHandler(i,s,{category:"module",source:V})}),_e.log(`${Object.keys(oe).length} handler(s) registered:`,Object.keys(oe).map(n=>`${V}.${n}`))):(e++,e<=t?(_e.warn(`ActionDispatcher not found, retrying... (${e}/${t})`),setTimeout(o,100)):_e.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};o()}else _e.warn("ActionDispatcher not found, handlers not registered")}function je(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>ht(!0));else{const r=!!window.G7Core?.getActionDispatcher?.();ht(!r)}}return je(),typeof window<"u"&&(window.__SirsoftEcommerce={identifier:V,handlers:Object.keys(oe),initModule:je}),Pe.initModule=je,Object.defineProperty(Pe,Symbol.toStringTag,{value:"Module"}),Pe}({}); +var SirsoftEcommerce=function(Pe){"use strict";const St={KRW:0,JPY:0},vt={KRW:"₩",USD:"$",JPY:"¥",CNY:"元",EUR:"€",GBP:"£"},Lt={KRW:1e3,JPY:100};function xt(r){if(!r)return 1;const c=r.base_unit??Lt[r.code]??1;return Math.max(1,c)}function Pt(r,c,e){const t=parseFloat(c)||1;if(t<=0)return r;const o=r/t;let a;switch(e){case"ceil":a=Math.ceil(o);break;case"floor":a=Math.floor(o);break;default:a=Math.round(o)}return a*t}function ye(r,c){const e=typeof c=="string"?{code:c}:c,t=e.code,o=e.symbol&&e.symbol.length>0?e.symbol:vt[t]??"",a=e.decimal_places??St[t]??2,n=r.toLocaleString(void 0,{minimumFractionDigits:a,maximumFractionDigits:a});return o?t==="KRW"||o==="₩"||o==="원"?n+"원":o+n:n+" "+t}function se(r,c){const{basePrice:e,currencies:t}=r,o=parseFloat(String(e))||0;if(!t||!Array.isArray(t)||t.length===0)return{};const a=t.find(i=>i.is_default),n=xt(a),s={};for(const i of t){const l=i.code;if(i.is_default??!1)s[l]={price:o,formatted:ye(o,i),is_default:!0,editable:!0};else if(i.exchange_rate&&i.exchange_rate>0){const d=o/n*i.exchange_rate,m=Pt(d,i.rounding_unit||"0.01",i.rounding_method||"round");s[l]={price:m,formatted:ye(m,i),is_default:!1,editable:!1,exchange_rate:i.exchange_rate}}}return s}const z=window.G7Core?.createLogger?.("Ecom:UpdateProduct")??{log:(...r)=>console.log("[Ecom:UpdateProduct]",...r),warn:(...r)=>console.warn("[Ecom:UpdateProduct]",...r),error:(...r)=>console.error("[Ecom:UpdateProduct]",...r)};function At(r,c){const e=r.params||{},{productId:t,field:o,dataSourceId:a="products"}=e;let{value:n}=e;if(n&&typeof n=="object"&&n.target!==void 0&&(n=n.target?.value,z.log("[updateProductField] Extracted value from event:",n)),!t||!o){z.warn("[updateProductField] Missing required params:",{productId:t,field:o});return}if(n===void 0){z.warn("[updateProductField] Value is undefined, skipping update");return}const s=window.G7Core;if(!s?.dataSource?.get||!s?.dataSource?.set){z.warn("[updateProductField] G7Core.dataSource API is not available");return}const i=s.dataSource.get(a);if(!i){console.warn(`[updateProductField] DataSource '${a}' not found`);return}const l=i.data?.data||[];if(!Array.isArray(l)||l.length===0){z.warn("[updateProductField] Products data is empty or invalid");return}const u=s.state.get()||{},d=u.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies;let f=[...u._local?.productFieldErrors||[]];const g=l.map(C=>{if(String(C.id)===String(t)){const A=C[o];if(typeof n=="object"&&n!==null?JSON.stringify(A)===JSON.stringify(n):String(A)===String(n))return z.log(`[updateProductField] No change for product ${t}.${o}, skipping`),C;let I=n;const N=parseFloat(n)||0;if(o==="selling_price"){const O=parseFloat(C.list_price)||0;O>0&&N>O?(f.some(Q=>Q.productId===String(t)&&Q.field===o)||f.push({productId:String(t),field:"selling_price",message:"판매가는 정가를 초과할 수 없습니다."}),I=O):f=f.filter($=>!($.productId===String(t)&&$.field===o))}else f=f.filter(O=>!(O.productId===String(t)&&O.field===o));const L={...C,[o]:I,_modified:!0};return o==="selling_price"&&d&&Array.isArray(d)&&(L.multi_currency_selling_price=se({basePrice:I,currencies:d}),z.log(`[updateProductField] Recalculated multi_currency_selling_price for product ${t}`)),L}return C});s.dataSource.set(a,{...i,data:{...i.data,data:g}});const w=s.state.getLocal()||{},y=new Set(w.modifiedProductIds||[]);y.add(t);const _={...w.modifiedProductFields||{}},p=String(t),S=new Set(_[p]||[]);S.add(o),_[p]=Array.from(S),s.state.setLocal({modifiedProductIds:Array.from(y),modifiedProductFields:_,productFieldErrors:f}),z.log(`[updateProductField] Updated product ${t}.${o} =`,n)}const B=window.G7Core?.createLogger?.("Ecom:UpdateOption")??{log:(...r)=>console.log("[Ecom:UpdateOption]",...r),warn:(...r)=>console.warn("[Ecom:UpdateOption]",...r),error:(...r)=>console.error("[Ecom:UpdateOption]",...r)};function kt(r,c){const e=r.params||{},{productId:t,optionId:o,field:a,value:n,dataSourceId:s="products"}=e;if(!t||!o||!a){B.warn("[updateOptionField] Missing required params:",{productId:t,optionId:o,field:a});return}const i=window.G7Core;if(!i?.dataSource?.get||!i?.dataSource?.set){B.warn("[updateOptionField] G7Core.dataSource API is not available");return}const l=i.dataSource.get(s);if(!l){console.warn(`[updateOptionField] DataSource '${s}' not found`);return}const u=l.data?.data||[];if(!Array.isArray(u)||u.length===0){B.warn("[updateOptionField] Products data is empty or invalid");return}const d=i.state.get()||{},m=d.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies;let g=[...d._local?.optionFieldErrors||[]],w=!1,y=!1;const _=u.map(N=>{if(String(N.id)===String(t)){const L=N.options||[],O=L.findIndex(D=>String(D.id)===String(o));if(O===-1)return B.warn("[updateOptionField] Option not found:",o),N;y=!0;const $=L[O],Q=$[a];if(typeof n=="object"&&n!==null?JSON.stringify(Q)===JSON.stringify(n):String(Q)===String(n))return B.log(`[updateOptionField] No change for option ${o}.${a}, skipping`),N;let J=n;const xe=parseFloat(n)||0;if(a==="selling_price"){const D=parseFloat($.list_price)||0;D>0&&xe>D?(g.some(we=>we.productId===String(t)&&we.optionId===String(o)&&we.field===a)||g.push({productId:String(t),optionId:String(o),field:"selling_price",message:"판매가는 정가를 초과할 수 없습니다."}),J=D):g=g.filter(H=>!(H.productId===String(t)&&H.optionId===String(o)&&H.field===a))}else g=g.filter(D=>!(D.productId===String(t)&&D.optionId===String(o)&&D.field===a));const re=[...L],ne={...re[O],[a]:J,_modified:!0};let ae=null;if(a==="selling_price"){const D=parseFloat(J)||0;if($.is_default===!0)ae=D,ne.price_adjustment=0;else{const H=parseFloat(N.selling_price)||0;ne.price_adjustment=D-H}ne.price_adjustment_formatted=(ne.price_adjustment>=0?"+":"")+ne.price_adjustment.toLocaleString()+"원"}if(a==="selling_price"&&m&&Array.isArray(m)&&(ne.multi_currency_selling_price=se({basePrice:J,currencies:m}),B.log(`[updateOptionField] Recalculated multi_currency_selling_price for option ${o}`)),re[O]=ne,ae!==null)for(let D=0;DH.is_active===!1?D:D+(parseInt(H.stock_quantity,10)||0),0),Be=Ke,B.log(`[updateOptionField] Updated product stock to option sum: ${Be}`));const Qe={...N,options:re,stock_quantity:Be,option_stock_sum:Ke,_modified:!0};return ae!==null&&(Qe.selling_price=ae,m&&Array.isArray(m)&&(Qe.multi_currency_selling_price=se({basePrice:ae,currencies:m})),w=!0,B.log(`[updateOptionField] Reverse-synced product ${t} selling_price to ${ae} from default option`)),Qe}return N});if(!y)return;i.dataSource.set(s,{...l,data:{...l.data,data:_}});const p=i.state.getLocal()||{},S=new Set(p.modifiedOptionIds||[]),C=`${t}-${o}`;S.add(C);const A={...p.modifiedOptionFields||{}},x=new Set(A[C]||[]);x.add(a),A[C]=Array.from(x);const I={modifiedOptionIds:Array.from(S),modifiedOptionFields:A,optionFieldErrors:g};if(w){const N=new Set(p.modifiedProductIds||[]);N.add(t);const L={...p.modifiedProductFields||{}},O=String(t),$=new Set(L[O]||[]);$.add("selling_price"),L[O]=Array.from($),I.modifiedProductIds=Array.from(N),I.modifiedProductFields=L}i.state.setLocal(I),B.log(`[updateOptionField] Updated option ${t}/${o}.${a} =`,n)}const Ot="g7_preferred_currency",Gt="g7_locale",ze=window.G7Core?.createLogger?.("Handler:InitPreferredCurrency")??{log:(...r)=>console.log("[Handler:InitPreferredCurrency]",...r),warn:(...r)=>console.warn("[Handler:InitPreferredCurrency]",...r),error:(...r)=>console.error("[Handler:InitPreferredCurrency]",...r)};function Et(r){const c=window.G7Core;c?.state?.set?c.state.set(r):ze.warn("G7Core.state.set not available")}function Ft(){try{return localStorage.getItem(Gt)}catch{return null}}function Dt(){try{const e=(window.G7Core?.state?.get?.()||{})?.modules?.["sirsoft-ecommerce"]?.language_currency;return Array.isArray(e?.currencies)?e.currencies:[]}catch{return[]}}function $t(r){if(!r)return null;const c=r.toLowerCase().split("-")[0].split("_")[0];if(!c)return null;const t=Dt().filter(o=>(Array.isArray(o?.locales)?o.locales:[]).some(n=>String(n).toLowerCase()===c));return t.length===1?t[0]?.code??null:t.length>1?t.find(a=>a?.is_default===!0)?.code??null:null}function Nt(){try{const r=window.G7Config;return r?.user?r.user:(window.G7Core?.state?.get?.()||{}).currentUser||null}catch{return null}}function Tt(r,c){const e=r?.params?.defaultCurrency||"KRW",t=l=>typeof l=="string"&&/^[A-Z]{3}$/.test(l),o=Nt(),a=r?.params?.accountCurrency??o?.ecommerce_preferred_currency;let n=null;try{n=localStorage.getItem(Ot)}catch{}const s=a?null:$t(Ft()),i=t(a)?a:t(n)?n:t(s)?s:t(e)?e:"KRW";Et({preferredCurrency:i}),ze.log("Preferred currency initialized:",i)}const Rt="g7_preferred_shipping_country",We=window.G7Core?.createLogger?.("Handler:InitPreferredShippingCountry")??{log:(...r)=>console.log("[Handler:InitPreferredShippingCountry]",...r),warn:(...r)=>console.warn("[Handler:InitPreferredShippingCountry]",...r),error:(...r)=>console.error("[Handler:InitPreferredShippingCountry]",...r)};function Mt(r){const c=window.G7Core;c?.state?.set?c.state.set(r):We.warn("G7Core.state.set not available")}function Ht(){try{const r=window.G7Config;return r?.user?r.user:(window.G7Core?.state?.get?.()||{}).currentUser||null}catch{return null}}function qt(r,c){const e=r?.params?.defaultCountry||"KR",t=i=>typeof i=="string"&&/^[A-Z]{2}$/.test(i),o=Ht(),a=r?.params?.accountCountry??o?.ecommerce_preferred_shipping_country;let n=null;try{n=localStorage.getItem(Rt)}catch{}const s=t(a)?a:t(n)?n:t(e)?e:"KR";Mt({preferredShippingCountry:s}),We.log("Preferred shipping country initialized:",s)}function X(r){const c=r.getFullYear(),e=String(r.getMonth()+1).padStart(2,"0"),t=String(r.getDate()).padStart(2,"0");return`${c}-${e}-${t}`}function Ut(r){const c=new Date,e=X(c);let t;switch(r){case"today":t=e;break;case"3days":{const o=new Date(c);o.setDate(o.getDate()-2),t=X(o);break}case"week":{const o=new Date(c);o.setDate(o.getDate()-6),t=X(o);break}case"month":{const o=new Date(c);o.setMonth(o.getMonth()-1),t=X(o);break}case"3months":{const o=new Date(c);o.setMonth(o.getMonth()-3),t=X(o);break}case"6months":{const o=new Date(c);o.setMonth(o.getMonth()-6),t=X(o);break}case"1year":{const o=new Date(c);o.setFullYear(o.getFullYear()-1),t=X(o);break}case"all":default:return t="",{startDate:"",endDate:""}}return{startDate:t,endDate:e}}function Vt(r){const{preset:c}=r.params||{preset:"today"},{startDate:e,endDate:t}=Ut(c);return{startDate:e,endDate:t,preset:c}}const ie=window.G7Core?.createLogger?.("Ecom:DefaultOption")??{log:(...r)=>console.log("[Ecom:DefaultOption]",...r),warn:(...r)=>console.warn("[Ecom:DefaultOption]",...r),error:(...r)=>console.error("[Ecom:DefaultOption]",...r)};function jt(r,c){const e=r.params||{},{productId:t,optionId:o,dataSourceId:a="products"}=e;if(!t||!o){ie.warn("[setDefaultOption] Missing required params:",{productId:t,optionId:o});return}const n=window.G7Core;if(!n?.dataSource?.get||!n?.dataSource?.set){ie.warn("[setDefaultOption] G7Core.dataSource API is not available");return}const s=n.dataSource.get(a);if(!s){console.warn(`[setDefaultOption] DataSource '${a}' not found`);return}const i=s.data?.data||[];if(!Array.isArray(i)||i.length===0){ie.warn("[setDefaultOption] Products data is empty or invalid");return}const u=(n.state.get()||{}).modules?.["sirsoft-ecommerce"]?.language_currency?.currencies,d=i.map(w=>{if(String(w.id)!==String(t))return w;const y=w.options||[];if(y.length===0)return w;const _=y.find(x=>String(x.id)===String(o));if(!_)return ie.warn("[setDefaultOption] Option not found:",o),w;const p=y.map(x=>{const I=String(x.id)===String(o);return{...x,is_default:I,_modified:I?!0:x._modified}}),S=_.list_price,C=_.selling_price;let A=w.multi_currency_selling_price;return u&&Array.isArray(u)&&(A=se({basePrice:C,currencies:u})),ie.log(`[setDefaultOption] Set option ${o} as default for product ${t}. Syncing prices: list_price=${S}, selling_price=${C}`),{...w,options:p,list_price:S,selling_price:C,multi_currency_selling_price:A,_modified:!0}});n.dataSource.set(a,{...s,data:{...s.data,data:d}});const m=n.state.getLocal()||{},f=new Set(m.modifiedProductIds||[]);f.add(String(t));const g=new Set(m.modifiedOptionIds||[]);g.add(`${t}-${o}`),n.state.setLocal({modifiedProductIds:Array.from(f),modifiedOptionIds:Array.from(g)}),ie.log(`[setDefaultOption] Option ${o} set as default for product ${t}`)}const Ae=window.G7Core?.createLogger?.("Ecom:OptionSelect")??{log:(...r)=>console.log("[Ecom:OptionSelect]",...r),warn:(...r)=>console.warn("[Ecom:OptionSelect]",...r),error:(...r)=>console.error("[Ecom:OptionSelect]",...r)},de=(r,c)=>`${r}-${c}`,ke=r=>r.split("-")[0];function Bt(r,c){const e=r.params||{},{productId:t,optionId:o,checked:a}=e,n=window.G7Core;if(!n?.state){Ae.warn("[toggleOption] G7Core.state를 사용할 수 없습니다.");return}const s=n.state.getLocal(),i=s.selectedOptionIds||[],l=s.selectedItems||[],u=de(t,o);let d;a?i.includes(u)?d=i:d=[...i,u]:d=i.filter(p=>p!==u);const w=(n.dataSource?.get("products")?.data?.data||[]).find(p=>String(p.id)===String(t))?.options||[];let y=[...l];const _=Number(t);w.length>0&&(w.map(C=>de(t,C.id)).filter(C=>d.includes(C)).length===w.length?y.includes(_)||y.push(_):y=y.filter(C=>C!==_)),n.state.setLocal({selectedOptionIds:d,selectedItems:y})}function Ye(r,c){const e=r.params||{},{productId:t,options:o,checked:a}=e,n=window.G7Core;if(!n?.state){Ae.warn("[toggleProductOptions] G7Core.state를 사용할 수 없습니다.");return}const s=n.state.getLocal(),i=s.selectedOptionIds||[],l=s.selectedItems||[],u=(o||[]).map(g=>de(t,g.id));let d,m=[...l];const f=Number(t);a?(d=[...i.filter(w=>ke(w)!==String(t)),...u],m.includes(f)||m.push(f)):(d=i.filter(g=>ke(g)!==String(t)),m=m.filter(g=>g!==f)),n.state.setLocal({selectedOptionIds:d,selectedItems:m})}function Kt(r,c){return Ye(r)}function Qt(r,c){const e=r.params||{products:[],selectedOptionIds:[]},{products:t,selectedOptionIds:o}=e,a=o||[];if(!t||!Array.isArray(t))return{};const n={};for(const s of t){const i=String(s.id),l=s.options||[];if(l.length===0){n[i]="none";continue}const d=l.map(m=>de(i,m.id)).filter(m=>a.includes(m)).length;d===0?n[i]="none":d===l.length?n[i]="all":n[i]="some"}return n}function zt(r,c){const e=r.params||{},{selectedIds:t}=e,o=window.G7Core;if(!o?.state){Ae.warn("[syncProductSelection] G7Core.state를 사용할 수 없습니다.");return}const a=o.state.getLocal(),n=a.selectedItems||[],s=a.selectedOptionIds||[],l=o.dataSource?.get("products")?.data?.data||[],u=t.filter(f=>!n.includes(f)),d=n.filter(f=>!t.includes(f));let m=[...s];for(const f of u){const g=l.find(w=>Number(w.id)===f);if(g?.options)for(const w of g.options){const y=de(f,w.id);m.includes(y)||m.push(y)}}for(const f of d)m=m.filter(g=>ke(g)!==String(f));o.state.setLocal({selectedItems:t,selectedOptionIds:m})}const he=window.G7Core?.createLogger?.("Ecom:CopyCode")??{log:(...r)=>console.log("[Ecom:CopyCode]",...r),warn:(...r)=>console.warn("[Ecom:CopyCode]",...r),error:(...r)=>console.error("[Ecom:CopyCode]",...r)};async function Wt(r,c){const e=window.G7Core;if(!e?.state||!e?.api){he.warn("[generateCopyProductCode] G7Core API is not available");return}try{const t=await e.api.post("/api/modules/sirsoft-ecommerce/admin/products/generate-code");if(!t?.data?.product_code)throw new Error("Invalid API response");const o=t.data.product_code;if((window.__g7LayoutContextStack||[]).length>0&&e.state.setParentLocal)e.state.setParentLocal({"ui.copyOptions.newProductCode":o}),he.log(`[generateCopyProductCode] Generated (parent): ${o}`);else{const s=e.state.getLocal()||{};e.state.setLocal({ui:{...s.ui,copyOptions:{...s.ui?.copyOptions||{},newProductCode:o}}}),he.log(`[generateCopyProductCode] Generated (local): ${o}`)}}catch(t){he.error("[generateCopyProductCode] Failed:",t),e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.messages.code_generate_error")??"Failed to generate product code.")}}const Je=window.G7Core?.createLogger?.("Ecom:CopyProduct")??{log:(...r)=>console.log("[Ecom:CopyProduct]",...r),warn:(...r)=>console.warn("[Ecom:CopyProduct]",...r),error:(...r)=>console.error("[Ecom:CopyProduct]",...r)};async function Yt(r,c){const e=window.G7Core;if(!e?.state){Je.warn("[copyProduct] G7Core API is not available");return}const t=e.state.getLocal()||{},a=(e.state.getParent?.()||{})._local||{},n=a.ui?.copyOptions||t.ui?.copyOptions||{},s=t.form?.product_code||a.form?.product_code||a.productList?.copyTargetProduct?.product_code||t.productList?.copyTargetProduct?.product_code;if(!s){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.messages.copy_source_not_found")??"Could not find source product information.");return}e.modal?.close?.();const i={copy_id:s,copy_images:n.images??!0?"1":"0",copy_options:n.options??!0?"1":"0",copy_categories:n.categories??!0?"1":"0",copy_sales_info:n.sales_info??!0?"1":"0",copy_description:n.description??!0?"1":"0",copy_notice:n.notice??!0?"1":"0",copy_common_info:n.common_info??!0?"1":"0",copy_other_info:n.other_info??!0?"1":"0",copy_shipping:n.shipping??!0?"1":"0",copy_seo:n.seo??!1?"1":"0",copy_identification:n.identification??!0?"1":"0"},u=`/admin/ecommerce/products/create?${new URLSearchParams(i).toString()}`;Je.log("[copyProduct] Navigating to:",u),e.navigate?e.navigate(u):c.navigate?c.navigate(u):window.location.href=u}const Ce=window.G7Core?.createLogger?.("Ecom:ExpandedOptions")??{log:(...r)=>console.log("[Ecom:ExpandedOptions]",...r),warn:(...r)=>console.warn("[Ecom:ExpandedOptions]",...r),error:(...r)=>console.error("[Ecom:ExpandedOptions]",...r)},Jt="/api/modules/sirsoft-ecommerce/admin/products/options",Oe=100,Ge=new Set;function Xt(r,c){const t=r?.dataSource?.get?.(c)?.data?.data;return Array.isArray(t)?t:[]}function Xe(r,c,e){const t=r?.dataSource?.get?.(c),o=t?.data?.data;if(!t||!Array.isArray(o)){Ce.warn("[loadExpandedOptions] DataSource not available for merge:",c);return}const a=o.map(n=>{const s=String(n?.id);return Object.prototype.hasOwnProperty.call(e,s)?{...n,options:e[s]}:n});r.dataSource.set(c,{...t,data:{...t.data,data:a}})}function Zt(r,c){const e=r?.state?.getLocal?.()||{},t=e.selectedItems||[];if(t.length===0)return;const o=new Set(t.map(s=>String(s))),a=new Set(e.selectedOptionIds||[]);let n=!1;Object.entries(c).forEach(([s,i])=>{o.has(s)&&i.forEach(l=>{const u=`${s}-${l?.id}`;a.has(u)||(a.add(u),n=!0)})}),n&&r.state.setLocal({selectedOptionIds:Array.from(a)})}function Ee(r,c){const e=r?.state?.getLocal?.()||{},t=c(e.optionsLoadingIds||[],e.optionsErrorIds||[]);r.state.setLocal({optionsLoadingIds:t.loading,optionsErrorIds:t.errored})}function Fe(r,c){const e=r.params||{},t=e.dataSourceId||"products",o=e.force===!0,a=window.G7Core;if(!a?.dataSource?.get||!a?.dataSource?.set||!a?.state?.setLocal){Ce.warn("[loadExpandedOptions] G7Core dataSource/state API is not available");return}Array.isArray(e.expandedRows)&&a.state.setLocal({expandedRows:e.expandedRows});const n=Array.isArray(e.productIds)?e.productIds:e.productId!==void 0&&e.productId!==null?[e.productId]:Array.isArray(e.expandedRows)?e.expandedRows:a.state.getLocal?.()?.expandedRows||[];if(!Array.isArray(n)||n.length===0)return;const s=Xt(a,t),i=new Map(s.map(d=>[String(d?.id),d])),l=[],u={};n.forEach(d=>{const m=String(d),f=i.get(m);if(f&&!(!o&&Array.isArray(f.options))){if(Number(f.options_total_count)===0){u[m]=[];return}Ge.has(m)||l.push(m)}}),Object.keys(u).length>0&&Xe(a,t,u),l.length!==0&&(l.length>Oe&&(Ce.warn(`[loadExpandedOptions] Requested ${l.length} products, truncating to ${Oe}`),l.length=Oe),l.forEach(d=>Ge.add(d)),Ee(a,(d,m)=>({loading:Array.from(new Set([...d,...l])),errored:m.filter(f=>!l.includes(f))})),a.api.get(Jt,{params:{product_ids:l.map(d=>Number(d))}}).then(d=>{const f=(d?.data||{}).options||{},g={};l.forEach(w=>{const y=f[w];g[w]=Array.isArray(y)?y:[]}),Xe(a,t,g),Zt(a,g),Ee(a,(w,y)=>({loading:w.filter(_=>!l.includes(_)),errored:y.filter(_=>!l.includes(_))}))}).catch(d=>{Ce.error("[loadExpandedOptions] Failed to load options:",d),Ee(a,(f,g)=>({loading:f.filter(w=>!l.includes(w)),errored:Array.from(new Set([...g,...l]))}));const m=d?.response?.data?.message||a.t?.("sirsoft-ecommerce.admin.product.table.options.load_failed")||"Failed to load product options.";a.toast?.error?.(m)}).finally(()=>{l.forEach(d=>Ge.delete(d))}))}function eo(r,c={}){const e=r?.state?.getLocal?.()?.expandedRows||[];!Array.isArray(e)||e.length===0||Fe({params:{productIds:e,force:c.force===!0,dataSourceId:c.dataSourceId||"products"}})}function to(r,c){const e=r.params||{};Fe({params:{productId:e.productId,productIds:e.productIds,force:!0,dataSourceId:e.dataSourceId}})}const P=window.G7Core?.createLogger?.("Ecom:Category")??{log:(...r)=>console.log("[Ecom:Category]",...r),warn:(...r)=>console.warn("[Ecom:Category]",...r),error:(...r)=>console.error("[Ecom:Category]",...r)};function Ze(r,c,e,t=[]){for(const o of r){const a=o.name?.[e]||o.name?.ko||"";if(o.id===c)return[...t,a].join(" > ");if(o.children&&o.children.length>0){const n=Ze(o.children,c,e,[...t,a]);if(n)return n}}return null}function oo(r,c,e){return r.map(t=>{if(t.breadcrumb)return{id:t.id,breadcrumb:t.breadcrumb};const o=Ze(c,t.id,e);if(o)return{id:t.id,breadcrumb:o};const a=t.name?.[e]||t.name?.ko||t.name_localized||"";return{id:t.id,breadcrumb:a}})}function ro(r,c){const e=r.params||{},t=e.depth,o=e.category;if(t===void 0||!o){P.warn("[selectCategory] Missing depth or category param");return}const a=c.isolatedContext;if(a){const u=a.state.selectedCategories||[null,null,null,null];if(u[t]?.id===o.id){P.log(`[selectCategory] Same category already selected at depth ${t}, skipping`);return}const d=u.map((m,f)=>fdp0?t+2:f;a.mergeState({selectedCategories:g,categoryStep:y}),P.log(`[selectCategoryMobile] Selected category at depth ${t} (isolated):`,o.id,"nextStep:",y);return}const n=window.G7Core;if(!n?.state){P.warn("[selectCategoryMobile] G7Core.state API is not available");return}const s=n.state.getLocal()||{},l=(s.ui?.selectedCategories||[null,null,null,null]).map((m,f)=>f0?t+2:s.ui?.categoryStep;n.state.setLocal({ui:{...s.ui,selectedCategories:l,categoryStep:d}}),P.log(`[selectCategoryMobile] Selected category at depth ${t}:`,o.id,"nextStep:",d)}function ao(r,c){P.log("[addCategoryToSelection] START");const e=window.G7Core;if(!e?.state){P.warn("[addCategoryToSelection] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=e.locale?.current?.()||"ko",a=c.isolatedContext,n=a?a.state.selectedCategories||[]:t.ui?.selectedCategories||[];P.log("[addCategoryToSelection] Current state:",{selectedCats:n.map(p=>p?.id??null),currentCategoryIds:t.form?.category_ids,currentSelectedCategoryInfos:t.selectedCategoryInfos,usingIsolatedContext:!!a});const s=n.filter(Boolean).pop();if(!s){P.warn("[addCategoryToSelection] No category selected");return}P.log("[addCategoryToSelection] Last selected category:",s.id,s.name);const i=t.form?.category_ids||[];if(i.length>=5){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.category.messages.max_5")??"You can select up to 5 categories.");return}if(i.includes(s.id)){P.log("[addCategoryToSelection] Duplicate detected:",s.id,"in",i),e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.category.messages.already_selected")??"This category is already selected.");return}const l=n.filter(Boolean).map(p=>p.name?.[o]||p.name?.ko||"").join(" > "),u=[...i,s.id],d=t.form?.primary_category_id||s.id,f=(e.state.getDataSources?.()||{}).categories?.data?.data||[];let g=t.selectedCategoryInfos||[];const w=t.form?.categories||[];g.length===0&&w.length>0&&(P.log("[addCategoryToSelection] Initializing from form.categories:",w.length,"items"),g=oo(w,f,o));const y=[...g,{id:s.id,breadcrumb:l}];P.log("[addCategoryToSelection] Before setLocal:",{newCategoryIds:u,primaryCategoryId:d,newInfos:y});try{e.state.setLocal({form:{...t.form,category_ids:u,primary_category_id:d},selectedCategoryInfos:y,hasChanges:!0}),P.log("[addCategoryToSelection] setLocal SUCCESS")}catch(p){P.error("[addCategoryToSelection] setLocal FAILED:",p)}const _=e.state.getLocal()||{};P.log("[addCategoryToSelection] After setLocal:",{category_ids:_.form?.category_ids,selectedCategoryInfos:_.selectedCategoryInfos}),P.log(`[addCategoryToSelection] Added category ${s.id} with breadcrumb: ${l}. Total: ${u.length}`)}function so(r,c){const t=(r.params||{}).categoryId;if(!t){P.warn("[removeCategoryFromSelection] Missing categoryId param");return}const o=window.G7Core;if(!o?.state){P.warn("[removeCategoryFromSelection] G7Core.state API is not available");return}const a=o.state.getLocal()||{},s=(a.form?.category_ids||[]).filter(d=>d!==t);let i=a.form?.primary_category_id;i===t&&(i=s[0]||null);const u=(a.selectedCategoryInfos||[]).filter(d=>d.id!==t);o.state.setLocal({form:{...a.form,category_ids:s,primary_category_id:i},selectedCategoryInfos:u,hasChanges:!0}),P.log(`[removeCategoryFromSelection] Removed category ${t}. Remaining: ${s.length}`)}function io(r,c){const e=r.params||{},t=e.categoryId,o=e.categories;if(!t||!o||!Array.isArray(o))return"";const n=window.G7Core?.locale?.current?.()||"ko",s=(l,u,d=[])=>{for(const m of l){const f=m.name?.[n]||m.name?.ko||"";if(m.id===u)return[...d,f];if(m.children&&m.children.length>0){const g=s(m.children,u,[...d,f]);if(g)return g}}return null},i=s(o,t);return i?i.join(" > "):""}function co(r,c){P.log("[initCategoryInfosFromProduct] START");const e=window.G7Core;if(!e?.state){P.warn("[initCategoryInfosFromProduct] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=e.locale?.current?.()||"ko",n=(e.state.getDataSources?.()||{}).categories?.data?.data||[],s=t.form?.categories||[],i=t.form?.category_ids||[];if(P.log("[initCategoryInfosFromProduct] Product categories:",s),P.log("[initCategoryInfosFromProduct] Category IDs:",i),P.log("[initCategoryInfosFromProduct] Categories tree available:",n.length>0),i.length===0){P.log("[initCategoryInfosFromProduct] No categories to initialize");return}const l=(d,m,f=[])=>{for(const g of d){const w=g.name?.[o]||g.name?.ko||"";if(g.id===m)return[...f,w];if(g.children&&g.children.length>0){const y=l(g.children,m,[...f,w]);if(y)return y}}return null},u=[];for(const d of i){const m=l(n,d);if(m)u.push({id:d,breadcrumb:m.join(" > ")});else{const f=s.find(g=>g.id===d);if(f){const g=f.name?.[o]||f.name?.ko||f.name_localized||"";u.push({id:d,breadcrumb:g})}}}P.log("[initCategoryInfosFromProduct] Generated selectedCategoryInfos:",u),e.state.setLocal({selectedCategoryInfos:u}),P.log("[initCategoryInfosFromProduct] DONE")}function lo(r,c){const e={category1:"",category2:"",category3:"",category4:""},t=r.params||{},o=t.categoryId1?String(t.categoryId1):"",a=t.categoryId2?String(t.categoryId2):"",n=t.categoryId3?String(t.categoryId3):"",s=t.categoryId4?String(t.categoryId4):"",i=t.categories;if(!i||!Array.isArray(i)||i.length===0)return P.warn("[validateCategoryPath] No categories data provided"),e;if(o){const l=i.find(u=>String(u.id)===o);if(l&&l.depth===0&&(e.category1=o,a&&l.children)){const u=l.children.find(d=>String(d.id)===a);if(u&&u.depth===1&&(e.category2=a,n&&u.children)){const d=u.children.find(m=>String(m.id)===n);if(d&&d.depth===2&&(e.category3=n,s&&d.children)){const m=d.children.find(f=>String(f.id)===s);m&&m.depth===3&&(e.category4=s)}}}}return P.log("[validateCategoryPath] Result:",e),e}function uo(r,c){const e=r.params||{},t=e.brandId,o=e.brands;if(!t||!o||!Array.isArray(o))return"";const n=window.G7Core?.locale?.current?.()||"ko",s=o.find(i=>i.id===t);return s?s.name?.[n]??s.name?.ko??"":""}function mo(r,c){const e=r.params||{},t=e.brandId,o=e.brands;if(!t||!o||!Array.isArray(o))return"";const n=window.G7Core?.locale?.current?.()||"ko",s=o.find(i=>i.id===t);return s?.description?.[n]??s?.description?.ko??""}const Z=window.G7Core?.createLogger?.("Ecom:Price")??{log:(...r)=>console.log("[Ecom:Price]",...r),warn:(...r)=>console.warn("[Ecom:Price]",...r),error:(...r)=>console.error("[Ecom:Price]",...r)},po={KRW:1e3,JPY:100};function fo(r,c){const t=r.find(o=>o.code===c)?.base_unit??po[c]??1;return Math.max(1,t)}function go(r,c,e,t){const o=r/t*c;switch(e){case"ceil":return Math.ceil(o);case"floor":return Math.floor(o);case"round":default:return Math.round(o)}}function _o(r,c){const e=r.params||{},t=e.field,o=e.currency,a=e.value,n=e.autoFill;if(!t||!o||a===void 0){Z.warn("[updatePrice] Missing required params: field, currency, or value");return}const s=window.G7Core;if(!s?.state){Z.warn("[updatePrice] G7Core.state API is not available");return}const i=s.state.getLocal()||{},l=c.datasources?.currencies?.data||{},u=l?.list||[],d=l?.default_currency||"KRW",m=l?.rounding_method||"round",f={...i.form?.multi_currency_prices||{}};if(f[o]={...f[o]||{},[t]:a},n&&o===d){const y=fo(u,d);u.forEach(_=>{if(_.code!==d){const p=_.exchange_rate??1,S=go(a,p,m,y);f[_.code]={...f[_.code]||{},[t]:S}}})}const g={list:"list_price",selling:"selling_price"},w=o===d?a:i.form?.[g[t]];s.state.setLocal({form:{...i.form,multi_currency_prices:f,[g[t]]:w},hasChanges:!0}),Z.log(`[updatePrice] Updated ${t} price for ${o}: ${a}`)}function wo(r,c){const t=(r.params||{}).options;if(!t||!Array.isArray(t)||t.length===0)return 0;const o=t.reduce((a,n)=>n.is_active===!1?a:a+(n.stock_quantity||0),0);return Z.log(`[calculateTotalOptionStock] Total stock: ${o}`),o}function yo(r,c){const e=window.G7Core;if(!e?.state){Z.warn("[validatePriceRelation] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=parseFloat(String(t.form?.list_price))||0,a=parseFloat(String(t.form?.selling_price))||0,n=a>o&&o>0,s=e.t?.("sirsoft-ecommerce.admin.product.validation.selling_price_exceeds_list_price")??"판매가는 정가보다 클 수 없습니다.",i={...t.errors||{}};n?i.selling_price=[s]:delete i.selling_price;const l=Object.keys(i).length>0;e.state.setLocal({validation:{...t.validation,price_error:n},errors:l?i:null}),n?(e.toast?.warning?.(s),Z.warn(`[validatePriceRelation] Selling price (${a}) exceeds list price (${o})`)):Z.log("[validatePriceRelation] Price validation passed")}const T=window.G7Core?.createLogger?.("Ecom:ProductOption")??{log:(...r)=>console.log("[Ecom:ProductOption]",...r),warn:(...r)=>console.warn("[Ecom:ProductOption]",...r),error:(...r)=>console.error("[Ecom:ProductOption]",...r)};function ho(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function et(){return ho().reduce((c,e)=>({...c,[e]:""}),{})}function be(r){return(r??[]).filter(c=>c.is_active!==!1).reduce((c,e)=>c+(parseInt(String(e.stock_quantity),10)||0),0)}const Co={KRW:1e3,JPY:100};function De(){const e=((window.G7Core?.state?.get?.()||{}).modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).find(o=>o.is_default);if(!e)return 1;const t=e.base_unit??Co[e.code]??1;return Math.max(1,t)}function bo(r,c,e){const t=parseFloat(c)||1;if(t<=0)return r;const o=r/t;let a;switch(e){case"ceil":a=Math.ceil(o);break;case"floor":a=Math.floor(o);break;default:a=Math.round(o)}return a*t}function $e(r,c,e){const t=c.exchange_rate||0;if(t<=0)return{price:0,formatted:ye(0,c)};const o=e??De(),a=r/o*t,n=bo(a,c.rounding_unit||"0.01",c.rounding_method||"round"),s=c.decimal_places??2,i=parseFloat(n.toFixed(s));return{price:i,formatted:ye(i,c)}}function Ne(r,c,e){return r.map(t=>{const o=parseFloat(String(t.price_adjustment))||0,a=c+o,n={...t,selling_price:a};if(e.length>0){const s=De(),i={};e.forEach(l=>{i[l.code]=$e(a,l,s)}),n.multi_currency_selling_price=i}return n})}function Te(){return((window.G7Core?.state?.get?.()||{}).modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).filter(c=>!c.is_default&&c.exchange_rate)}function Io(r,c){const e=r.params||{},t=window.G7Core;if(!t?.state){T.warn("[setDefaultOption] G7Core.state API is not available");return}const o=t.state.getLocal()||{},a=[...o.form?.options??[]],n=e.index,s=n!=null?typeof n=="string"?parseInt(n,10):n:-1,i=e.optionCode??a[s]?.option_code;if(!i){T.warn("[setDefaultOption] Missing optionCode/index param",{index:n});return}let l=a.map(m=>({...m,is_default:m.option_code===i}));const u=l.find(m=>m.is_default===!0),d=u?parseFloat(String(u.selling_price))||parseFloat(String(o.form?.selling_price))||0:parseFloat(String(o.form?.selling_price))||0;if(u){u.price_adjustment=0;const m=Te();l=Ne(l,d,m)}t.state.setLocal({form:{...o.form,options:l,selling_price:d},hasChanges:!0}),T.log(`[setDefaultOption] Set default option to ${i}, synced product selling_price to ${d}`)}function So(r,c){const e=r.params||{},t=typeof e.index=="string"?parseInt(e.index,10):e.index,o=e.field,a=e.value;if(t==null||!o){T.warn("[updateFormOptionField] Missing required params:",{index:t,field:o});return}const n=window.G7Core;if(!n?.state){T.warn("[updateFormOptionField] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.options??[]];if(t<0||t>=i.length){T.warn("[updateFormOptionField] Index out of bounds:",{index:t,length:i.length});return}const l=["regular_price","sale_price","list_price","selling_price","stock_quantity","safe_stock_quantity","weight","volume","mileage_value"];let u=a;if(l.includes(o)&&(u=parseFloat(a)||0),i[t]={...i[t],[o]:u},o==="selling_price"){const d=parseFloat(String(u))||0,m=Te();if(i[t].is_default===!0){i[t].price_adjustment=0;const g=Ne(i,d,m);n.state.setLocal({form:{...s.form,options:g,selling_price:d},hasChanges:!0}),T.log(`[updateFormOptionField] Reverse-synced product selling_price to ${d} from default option`);return}const f=parseFloat(String(s.form?.selling_price))||0;if(i[t].price_adjustment=d-f,m.length>0){const g=De(),w={};m.forEach(y=>{w[y.code]=$e(d,y,g)}),i[t].multi_currency_selling_price=w,T.log(`[updateFormOptionField] Recalculated multi_currency_selling_price for option at index ${t}:`,m.map(y=>y.code))}}if((o==="stock_quantity"||o==="is_active")&&i.length>0){const d=be(i);n.state.setLocal({form:{...s.form,options:i,stock_quantity:d},hasChanges:!0}),T.log(`[updateFormOptionField] Updated product stock_quantity to ${d} (sum of active options)`);return}n.state.setLocal({form:{...s.form,options:i},hasChanges:!0}),T.log(`[updateFormOptionField] Updated options[${t}].${o} =`,u)}function vo(r,c){const e=window.G7Core;if(!e?.state){T.warn("[addOptionRow] G7Core.state API is not available");return}const o=r.params?.isCreate===!0||r.params?.isCreate==="true"?1:0,a=e.state.getLocal()||{},n=[...a.form?.options??[]],s=a.form?.option_groups??[],i=`OPT-${Date.now()}`,l=s.map(f=>({key:f.name,value:et()})),u={id:null,option_code:i,option_name:et(),option_values:l,is_default:n.length===0,regular_price:0,sale_price:0,list_price:0,selling_price:0,price_adjustment:0,multi_currency_selling_price:{},sku:"",stock_quantity:o,safe_stock_quantity:0,weight:0,volume:0,mileage_value:0,mileage_type:"percent",is_active:!0},d=[...n,u],m=be(d);e.state.setLocal({form:{...a.form,options:d,stock_quantity:m,has_options:d.length>0},hasChanges:!0}),T.log(`[addOptionRow] Added new option row: ${i}, product stock_quantity = ${m}`)}function Lo(r,c){const e=window.G7Core;if(!e?.state){T.warn("[recalculateOptionPriceAdjustments] G7Core.state API is not available");return}const t=r.params||{},o=e.state.getLocal()||{},a=t.newSellingPrice!==void 0?parseFloat(String(t.newSellingPrice))||0:parseFloat(String(o.form?.selling_price))||0,n=[...o.form?.options??[]];if(n.length===0){T.log("[recalculateOptionPriceAdjustments] No options to recalculate");return}const s=Te(),i=Ne(n,a,s);e.state.setLocal({form:{...o.form,options:i}}),T.log(`[recalculateOptionPriceAdjustments] Recalculated selling_price for ${n.length} options based on product selling_price: ${a}`)}const h=window.G7Core?.createLogger?.("Ecom:Option")??{log:(...r)=>console.log("[Ecom:Option]",...r),warn:(...r)=>console.warn("[Ecom:Option]",...r),error:(...r)=>console.error("[Ecom:Option]",...r)};function Re(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function ce(){return window.G7Core?.config?.("app.locale")??Re()[0]??"ko"}function q(){return Re().reduce((c,e)=>({...c,[e]:""}),{})}function tt(r){return{id:null,name:q(),price_adjustment:0,is_default:r===0,is_active:!0,allow_custom_text:!1,sort_order:r}}function xo(r,c){return typeof r=="string"?r:r[c]??r.ko??Object.values(r)[0]??""}function ot(r){return r&&typeof r=="object"&&!Array.isArray(r)}function Po(r){return Array.isArray(r)?r:r&&typeof r=="object"?Object.values(r):[]}function Ao(r){return r.reduce((c,e)=>{const t=[];return c.forEach(o=>{e.forEach(a=>{t.push([...o,a])})}),t},[[]])}function ko(r){const c=Re(),e={};for(const t of c){const o=r.map(a=>xo(a,t));e[t]=o.join("/")}return e}function Oo(r,c){return c.map((e,t)=>({key:typeof e.name=="string"?{...q(),[ce()]:e.name}:e.name,value:ot(r[t])?r[t]:{...q(),[ce()]:r[t]}}))}function Go(r,c){const e=window.G7Core;if(!e?.state){h.warn("[addOptionInput] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.ui?.optionInputs??[];if(o.length>=3){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.input_max_3")??"You can add up to 3 option inputs.");return}e.state.setLocal({ui:{...t.ui,optionInputs:[...o,{name:q(),values:[]}]}}),h.log(`[addOptionInput] Added option input. Total: ${o.length+1}`)}function Eo(r,c){const t=(r.params||{}).index;if(t===void 0){h.warn("[removeOptionInput] Missing index param");return}const o=window.G7Core;if(!o?.state){h.warn("[removeOptionInput] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.ui?.optionInputs??[]];n.splice(t,1),o.state.setLocal({ui:{...a.ui,optionInputs:n}}),h.log(`[removeOptionInput] Removed option input at index ${t}`)}function Fo(r,c){const e=r.params||{},t=e.index,o=e.field,a=e.value;if(t===void 0||!o){h.warn("[updateOptionInput] Missing required params");return}const n=window.G7Core;if(!n?.state){h.warn("[updateOptionInput] G7Core.state API is not available");return}h.log(`[updateOptionInput] Called with index=${t}, field="${o}" (type: ${typeof o}), value=`,a);const s=n.state.getLocal()||{},i=[...s.ui?.optionInputs??[]];if(h.log(`[updateOptionInput] Current inputs[${t}]:`,i[t]),i[t]||(h.log(`[updateOptionInput] inputs[${t}] not found, initializing with empty structure`),i[t]={name:q(),values:[]}),o==="name"){const l=ce(),u=i[t].name;h.log(`[updateOptionInput] Processing 'name' field - defaultLocale: ${l}, existingName:`,u,`type: ${typeof u}`);let d;a&&typeof a=="object"&&!Array.isArray(a)?(d=a,h.log("[updateOptionInput] Using multilingual value directly:",d)):(d={...u&&typeof u=="object"&&!Array.isArray(u)?u:q(),[l]:a},h.log("[updateOptionInput] Setting single locale value:",d)),i[t]={...i[t],name:d}}else h.log(`[updateOptionInput] Processing non-name field '${o}'`),i[t]={...i[t],[o]:a};h.log(`[updateOptionInput] Final inputs[${t}]:`,i[t]),n.state.setLocal({ui:{...s.ui,optionInputs:i}})}function Do(r,c){const e=window.G7Core;if(!e?.state){h.warn("[generateOptions] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=e.state.get()||{},a=t.ui?.optionInputs??[],n=ce(),s=p=>p.name?typeof p.name=="string"?p.name:p.name[n]??"":"",i=a.map(p=>({...p,values:Po(p.values)})),l=i.filter(p=>s(p)&&p.values&&p.values.length>0);if(h.log(`[generateOptions] Validating inputs - defaultLocale: ${n}, normalizedInputs:`,i.map(p=>({name:p.name,nameType:typeof p.name,extractedName:s(p),values:p.values,valuesIsArray:Array.isArray(p.values)}))),h.log(`[generateOptions] Valid inputs count: ${l.length}`),l.length===0){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.name_value_required")??"Please enter option name and values.");return}const u=t.form?.options??[],d=r.params?.skipConfirm===!0;if(u.length>0&&!d){e.modal?.open?.("modal_confirm_regenerate");return}const f=r.params?.isCreate===!0||r.params?.isCreate==="true"?1:0,g=parseFloat(String(t.form?.list_price))||0,w=parseFloat(String(t.form?.selling_price))||0,y=t.form?.multi_currency_selling_price,_=(o.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).filter(p=>!p.is_default&&p.exchange_rate);try{h.log("[generateOptions] Step 1: Creating optionGroups...");const p=l.map((L,O)=>(h.log(`[generateOptions] Processing input[${O}]:`,{name:L.name,values:L.values,valuesType:typeof L.values,valuesIsArray:Array.isArray(L.values)}),{name:typeof L.name=="string"?{...q(),[n]:L.name}:L.name,values:L.values.map($=>ot($)?$:{...q(),[n]:$})})),C=Ao(l.map(L=>L.values)).map((L,O)=>{const $=Oo(L,l),Q=ko(L),Ct=`OPT-${String(O+1).padStart(3,"0")}`;let J={};return y&&typeof y=="object"&&Object.keys(y).length>0?J=JSON.parse(JSON.stringify(y)):w>0&&_.length>0&&_.forEach(xe=>{J[xe.code]=$e(w,xe)}),{id:null,option_code:Ct,option_values:$,option_name:Q,list_price:g,selling_price:w,price_adjustment:0,stock_quantity:f,safe_stock_quantity:0,is_default:O===0,is_active:!0,sku:"",multi_currency_selling_price:J}}),A=t.form?.options??[],x=L=>L.option_name?typeof L.option_name=="string"?L.option_name:L.option_name[n]??L.option_name.ko??"":"",I=C.map(L=>{const O=x(L),$=A.find(Q=>x(Q)===O);return $?{...L,...$,id:$.id}:L}),N=be(I);e.state.setLocal({form:{...t.form,option_groups:p,options:I,has_options:I.length>0,stock_quantity:N},hasChanges:!0}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.generated",{count:I.length})??`${I.length} options have been generated.`),h.log(`[generateOptions] Generated ${I.length} options`)}catch(p){throw h.error("[generateOptions] Error occurred:",p),h.error("[generateOptions] Error message:",p.message),h.error("[generateOptions] Error stack:",p.stack),p}}function $o(r,c){const t=(r.params||{}).rowIndex;if(t===void 0){h.warn("[deleteOption] Missing rowIndex param");return}const o=window.G7Core;if(!o?.state){h.warn("[deleteOption] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.options??[]];n.splice(t,1);const s=be(n),i=n.length>0;o.state.setLocal({form:{...a.form,options:n,has_options:i,...i?{stock_quantity:s}:{}},hasChanges:!0}),h.log(`[deleteOption] Deleted option at index ${t}, product stock_quantity = ${s}`)}function No(r,c){const e=r.params||{},t=e.optionName,o=e.applyToAll,a=e.inheritPrice,n=window.G7Core;if(!n?.state){h.warn("[applyOptionAddTool] G7Core.state API is not available");return}if(!t?.trim()){n.toast?.warning?.(n.t?.("sirsoft-ecommerce.admin.product.options.messages.name_required")??"Please enter option name.");return}const s=n.state.getLocal()||{},l=[...s.form?.options??[]].map(u=>{const d={...u};return o&&(d.option_values={...u.option_values,[t]:""}),a&&(d.price_adjustment=u.price_adjustment??0),d});n.state.setLocal({form:{...s.form,options:l},ui:{...s.ui,optionAddToolName:"",optionAddToolApplyToAll:!1,optionAddToolInheritPrice:!1},hasChanges:!0}),n.toast?.success?.(n.t?.("sirsoft-ecommerce.admin.product.options.messages.add_tool_applied")??"Option add tool has been applied.")}function To(r,c){const e=window.G7Core;if(!e?.state){h.warn("[addRequiredItem] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.form?.required_items??[],a={id:null,sort_order:o.length,option_name:{ko:"",en:""},description:{ko:"",en:""}};e.state.setLocal({form:{...t.form,required_items:[...o,a]},hasChanges:!0}),h.log(`[addRequiredItem] Added required item. Total: ${o.length+1}`)}function Ro(r,c){const e=r.params||{},t=e.index,o=e.field,a=e.locale,n=e.value;if(t===void 0||!o||!a){h.warn("[updateRequiredItem] Missing required params");return}const s=window.G7Core;if(!s?.state){h.warn("[updateRequiredItem] G7Core.state API is not available");return}const i=s.state.getLocal()||{},l=[...i.form?.required_items??[]];l[t]={...l[t],[o]:{...l[t][o],[a]:n}},s.state.setLocal({form:{...i.form,required_items:l},hasChanges:!0})}function Mo(r,c){const t=(r.params||{}).index;if(t===void 0){h.warn("[removeRequiredItem] Missing index param");return}const o=window.G7Core;if(!o?.state){h.warn("[removeRequiredItem] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.required_items??[]];n.splice(t,1),n.forEach((s,i)=>{s.sort_order=i}),o.state.setLocal({form:{...a.form,required_items:n},hasChanges:!0}),h.log(`[removeRequiredItem] Removed required item at index ${t}`)}function Ho(r,c){const e=r.params||{},t=e.oldIndex,o=e.newIndex;if(t===void 0||o===void 0){h.warn("[reorderRequiredItems] Missing oldIndex or newIndex param");return}const a=window.G7Core;if(!a?.state){h.warn("[reorderRequiredItems] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.required_items??[]],[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,required_items:s},hasChanges:!0}),h.log(`[reorderRequiredItems] Moved item from ${t} to ${o}`)}function qo(r,c){const e=window.G7Core;if(!e?.state){h.warn("[addAdditionalOption] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.form?.additional_options??[];if(o.length>=5){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.additional_max_5")??"You can add up to 5 additional options.");return}const a=`temp_${Date.now()}_${Math.random().toString(36).substring(2,9)}`;e.state.setLocal({form:{...t.form,additional_options:[...o,{id:a,name:q(),is_required:!1,sort_order:o.length,values:[tt(0)]}]},hasChanges:!0}),h.log(`[addAdditionalOption] Added additional option. Total: ${o.length+1}`)}function Uo(r,c){const e=r.params||{},t=e.index,o=e.field,a=e.value;if(t===void 0||!o){h.warn("[updateAdditionalOption] Missing required params");return}const n=window.G7Core;if(!n?.state){h.warn("[updateAdditionalOption] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.additional_options??[]];if(!i[t]){h.warn("[updateAdditionalOption] Option not found at index",t);return}if(o==="name")if(a&&typeof a=="object"&&!Array.isArray(a))i[t]={...i[t],name:a};else{const l=ce(),u=i[t].name,d=u&&typeof u=="object"&&!Array.isArray(u)?u:q();i[t]={...i[t],name:{...d,[l]:a}}}else i[t]={...i[t],[o]:a};n.state.setLocal({form:{...s.form,additional_options:i},hasChanges:!0}),h.log(`[updateAdditionalOption] Updated options[${t}].${o}`)}function Vo(r,c){const t=(r.params||{}).index;if(t===void 0){h.warn("[removeAdditionalOption] Missing index param");return}const o=window.G7Core;if(!o?.state){h.warn("[removeAdditionalOption] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.additional_options??[]];n.splice(t,1),n.forEach((s,i)=>{s.sort_order=i}),o.state.setLocal({form:{...a.form,additional_options:n},hasChanges:!0}),h.log(`[removeAdditionalOption] Removed additional option at index ${t}`)}function jo(r,c){const e=r.params||{},t=parseInt(String(e.oldIndex),10),o=parseInt(String(e.newIndex),10);if(isNaN(t)||isNaN(o)||t===o)return;const a=window.G7Core;if(!a?.state){h.warn("[reorderAdditionalOptions] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.additional_options??[]];if(t<0||t>=s.length||o<0||o>=s.length)return;const[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,additional_options:s},ui:{...n.ui,dragIndex:null},hasChanges:!0}),h.log(`[reorderAdditionalOptions] Moved item from ${t} to ${o}`)}function Bo(r,c){const e=window.G7Core;if(!e?.state){h.warn("[clearAdditionalOptions] G7Core.state API is not available");return}const t=e.state.getLocal()||{};e.state.setLocal({form:{...t.form,additional_options:[]},hasChanges:!0}),h.log("[clearAdditionalOptions] Cleared all additional options.")}function Me(r){if(!r)return[];const c=r.values;return Array.isArray(c)?c:c&&typeof c=="object"?Object.values(c):[]}function Ko(r,c){const e=r.params||{},t=typeof e.groupIndex=="string"?parseInt(e.groupIndex,10):e.groupIndex;if(t==null||isNaN(t)){h.warn("[addAdditionalOptionValue] Missing groupIndex param");return}const o=window.G7Core;if(!o?.state){h.warn("[addAdditionalOptionValue] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.additional_options??[]];if(!n[t]){h.warn("[addAdditionalOptionValue] Group not found at index",t);return}const s=[...Me(n[t])];if(s.length>=20){o.toast?.warning?.(o.t?.("sirsoft-ecommerce.admin.product.options.messages.additional_value_max_20")??"You can add up to 20 choices per group.");return}s.push(tt(s.length)),n[t]={...n[t],values:s},o.state.setLocal({form:{...a.form,additional_options:n},hasChanges:!0}),h.log(`[addAdditionalOptionValue] Added value to group ${t}. Total: ${s.length}`)}function Qo(r,c){const e=r.params||{},t=typeof e.groupIndex=="string"?parseInt(e.groupIndex,10):e.groupIndex,o=typeof e.valueIndex=="string"?parseInt(e.valueIndex,10):e.valueIndex,a=e.field,n=e.value;if(t===void 0||o===void 0||!a){h.warn("[updateAdditionalOptionValue] Missing required params");return}const s=window.G7Core;if(!s?.state){h.warn("[updateAdditionalOptionValue] G7Core.state API is not available");return}const i=s.state.getLocal()||{},l=[...i.form?.additional_options??[]];if(!l[t]){h.warn("[updateAdditionalOptionValue] Group not found at index",t);return}const u=[...Me(l[t])];if(!u[o]){h.warn("[updateAdditionalOptionValue] Value not found at index",o);return}if(a==="name")if(n&&typeof n=="object"&&!Array.isArray(n))u[o]={...u[o],name:n};else{const d=ce(),m=u[o].name,f=m&&typeof m=="object"&&!Array.isArray(m)?m:q();u[o]={...u[o],name:{...f,[d]:n}}}else if(a==="price_adjustment"){let d=parseFloat(String(n));(isNaN(d)||d<0)&&(d=0),u[o]={...u[o],price_adjustment:d}}else if(a==="is_default")n===!0||n==="true"?u.forEach((m,f)=>{m.is_default=f===o}):u[o].is_default=!1;else if(a==="is_active"||a==="allow_custom_text"){const d=n===!0||n==="true";u[o]={...u[o],[a]:d}}else u[o]={...u[o],[a]:n};l[t]={...l[t],values:u},s.state.setLocal({form:{...i.form,additional_options:l},hasChanges:!0}),h.log(`[updateAdditionalOptionValue] Updated group ${t} value ${o} field ${a}`)}function zo(r,c){const e=r.params||{},t=typeof e.groupIndex=="string"?parseInt(e.groupIndex,10):e.groupIndex,o=typeof e.valueIndex=="string"?parseInt(e.valueIndex,10):e.valueIndex;if(t===void 0||o===void 0){h.warn("[removeAdditionalOptionValue] Missing required params");return}const a=window.G7Core;if(!a?.state){h.warn("[removeAdditionalOptionValue] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.additional_options??[]];if(!s[t]){h.warn("[removeAdditionalOptionValue] Group not found at index",t);return}const i=[...Me(s[t])];if(i.length<=1){a.toast?.warning?.(a.t?.("sirsoft-ecommerce.admin.product.options.messages.additional_value_min_1")??"At least one choice is required per group.");return}i.splice(o,1),i.forEach((l,u)=>{l.sort_order=u}),i.some(l=>l.is_default)||(i[0].is_default=!0),s[t]={...s[t],values:i},a.state.setLocal({form:{...n.form,additional_options:s},hasChanges:!0}),h.log(`[removeAdditionalOptionValue] Removed value ${o} from group ${t}`)}const j=window.G7Core?.createLogger?.("Ecom:Image")??{log:(...r)=>console.log("[Ecom:Image]",...r),warn:(...r)=>console.warn("[Ecom:Image]",...r),error:(...r)=>console.error("[Ecom:Image]",...r)},Wo=["image/jpeg","image/png","image/gif","image/webp"],Yo=10*1024*1024,Jo=20;async function Xo(r,c){const t=(r.params||{}).files;if(!t||t.length===0){j.warn("[uploadImages] No files provided");return}const o=window.G7Core;if(!o?.state||!o?.api){j.warn("[uploadImages] G7Core.state or G7Core.api is not available");return}const n=(o.state.getLocal()||{}).form?.images??[],s=Jo-n.length;t.length>s&&o.toast?.warning?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.max_count",{max:s})??`You can add up to ${s} images.`);const i=Array.from(t).slice(0,s);for(const l of i){if(!Wo.includes(l.type)){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.unsupported_format",{name:l.name})??`${l.name}: Unsupported format.`);continue}if(l.size>Yo){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.size_exceeded",{name:l.name})??`${l.name}: Exceeds 10MB.`);continue}const u=o.state.getLocal()||{};o.state.setLocal({ui:{...u.ui,uploadProgress:{...u.ui?.uploadProgress||{},[l.name]:0}}});try{const d=new FormData;d.append("file",l),d.append("collection","products");const m=await o.api.upload("/api/admin/media/upload",d,{onProgress:S=>{const C=o.state.getLocal()||{};o.state.setLocal({ui:{...C.ui,uploadProgress:{...C.ui?.uploadProgress||{},[l.name]:S}}})}}),f=o.state.getLocal()||{},g=f.form?.images??[],w={...m.data,is_thumbnail:g.length===0,sort_order:g.length};o.state.setLocal({form:{...f.form,images:[...g,w]},hasChanges:!0});const y=o.state.getLocal()||{},{[l.name]:_,...p}=y.ui?.uploadProgress||{};o.state.setLocal({ui:{...y.ui,uploadProgress:p}}),j.log(`[uploadImages] Uploaded ${l.name}`)}catch(d){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.upload_failed",{name:l.name})??`${l.name}: Upload failed`);const m=o.state.getLocal()||{},{[l.name]:f,...g}=m.ui?.uploadProgress||{};o.state.setLocal({ui:{...m.ui,uploadProgress:g}}),j.error(`[uploadImages] Failed to upload ${l.name}:`,d)}}}function Zo(r,c){const t=(r.params||{}).index;if(t===void 0){j.warn("[setThumbnail] Missing index param");return}const o=window.G7Core;if(!o?.state){j.warn("[setThumbnail] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=(a.form?.images??[]).map((s,i)=>({...s,is_thumbnail:i===t}));o.state.setLocal({form:{...a.form,images:n},hasChanges:!0}),j.log(`[setThumbnail] Set thumbnail to image at index ${t}`)}function er(r,c){const e=r.params||{},t=e.oldIndex,o=e.newIndex;if(t===void 0||o===void 0){j.warn("[reorderImages] Missing oldIndex or newIndex param");return}const a=window.G7Core;if(!a?.state){j.warn("[reorderImages] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.images??[]],[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,images:s},hasChanges:!0}),j.log(`[reorderImages] Moved image from ${t} to ${o}`)}const rt=window.G7Core?.createLogger?.("Ecom:Description")??{log:(...r)=>console.log("[Ecom:Description]",...r),warn:(...r)=>console.warn("[Ecom:Description]",...r),error:(...r)=>console.error("[Ecom:Description]",...r)};function tr(r,c){const e=r.params||{},t=e.locale,o=e.value;if(!t){rt.warn("[updateDescription] Missing locale param");return}const a=window.G7Core;if(!a?.state){rt.warn("[updateDescription] G7Core.state API is not available");return}const n=a.state.getLocal()||{};a.state.setLocal({form:{...n.form,description:{...n.form?.description,[t]:o}},hasChanges:!0})}const k=window.G7Core?.createLogger?.("Ecom:Notice")??{log:(...r)=>console.log("[Ecom:Notice]",...r),warn:(...r)=>console.warn("[Ecom:Notice]",...r),error:(...r)=>console.error("[Ecom:Notice]",...r)};function ue(r=""){const e=window.G7Core?.locale?.supported?.()??["ko","en"],t={};for(const o of e)t[o]=r;return t}function or(r,c){const t=(r.params||{}).templateId,o=window.G7Core;if(!o?.state){k.warn("[confirmSelectNoticeTemplate] G7Core.state API is not available");return}const n=(o.state.getLocal()||{}).form?.notice_items??[],s=n.length>0&&n.some(i=>Object.values(i.content||{}).some(u=>u&&u.trim()!==""));if(!t||!s){nt(r);return}o.state.setGlobal({pendingNoticeTemplateId:t}),o.modal?.open?.("notice_template_confirm_modal"),k.log(`[confirmSelectNoticeTemplate] Showing confirm modal for template: ${t}`)}function nt(r,c){const t=(r.params||{}).templateId,o=window.G7Core;if(!o?.state){k.warn("[selectNoticeTemplate] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=o.dataSource?.get?.("notice_templates"),s=n?.data?.data??n?.data??[];if(!t){o.state.setLocal({form:{...a.form,notice_items:[]},ui:{...a.ui,selectedNoticeTemplateId:null},hasChanges:!0});return}const i=s.find(d=>d.id===t);if(!i){k.warn(`[selectNoticeTemplate] Template not found: ${t}`);return}const l=a.form?.notice_items??[],u=(i.fields??[]).map((d,m)=>{const f=JSON.stringify(d.name),g=l.find(w=>JSON.stringify(w.name)===f);return{key:`field_${m}`,name:d.name,content:g?.content??d.content??ue(),sort_order:m,is_custom:!1}});o.state.setLocal({form:{...a.form,notice_items:u},ui:{...a.ui,selectedNoticeTemplateId:t},hasChanges:!0}),k.log(`[selectNoticeTemplate] Selected template: ${t}`)}function rr(r,c){const e=r.params||{},t=e.index,o=e.locale,a=e.value;if(t===void 0||!o){k.warn("[updateNoticeItem] Missing required params");return}const n=window.G7Core;if(!n?.state){k.warn("[updateNoticeItem] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.notice_items??[]];i[t]={...i[t],content:{...i[t].content,[o]:a}},n.state.setLocal({form:{...s.form,notice_items:i},hasChanges:!0})}function nr(r,c){const t=(r.params||{}).index;if(t===void 0){k.warn("[removeNoticeItem] Missing index param");return}const o=window.G7Core;if(!o?.state){k.warn("[removeNoticeItem] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.notice_items??[]];n.splice(t,1),n.forEach((s,i)=>{s.sort_order=i}),o.state.setLocal({form:{...a.form,notice_items:n},hasChanges:!0}),k.log(`[removeNoticeItem] Removed notice item at index ${t}`)}function ar(r,c){const e=r.params||{},t=e.oldIndex,o=e.newIndex;if(t===void 0||o===void 0){k.warn("[reorderNoticeItems] Missing oldIndex or newIndex param");return}const a=window.G7Core;if(!a?.state){k.warn("[reorderNoticeItems] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.notice_items??[]],[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,notice_items:s},hasChanges:!0}),k.log(`[reorderNoticeItems] Moved item from ${t} to ${o}`)}function sr(r,c){const t=(r.params||{}).value,o=window.G7Core;if(!o?.state){k.warn("[fillNoticeWithValue] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=a.ui?.noticeLocale??"ko",s=(a.form?.notice_items??[]).map(i=>({...i,content:{...i.content,[n]:i.content?.[n]||t}}));o.state.setLocal({form:{...a.form,notice_items:s},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.notice.messages.filled")??"Empty fields have been filled.")}function ir(r,c){const t=(r.params||{}).mode;if(!t){k.warn("[switchNoticeMode] Missing mode param");return}const o=window.G7Core;if(!o?.state){k.warn("[switchNoticeMode] G7Core.state API is not available");return}const a=o.state.getLocal()||{};o.state.setLocal({ui:{...a.ui,noticeMode:t}}),k.log(`[switchNoticeMode] Switched to ${t} mode`)}function cr(r,c){const e=r.params||{},t=e.locale,o=e.value;if(!t){k.warn("[updateNewTemplateName] Missing locale param");return}const a=window.G7Core;if(!a?.state){k.warn("[updateNewTemplateName] G7Core.state API is not available");return}const n=a.state.getLocal()||{};a.state.setLocal({form:{...n.form,new_template_name:{...n.form?.new_template_name,[t]:o}}})}function lr(r,c){const e=window.G7Core;if(!e?.state){k.warn("[addNoticeItem] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.form?.notice_items??[],a={key:`custom_${Date.now()}`,name:ue(),content:ue(),sort_order:o.length,is_custom:!0};e.state.setLocal({form:{...t.form,notice_items:[...o,a]},hasChanges:!0}),k.log(`[addNoticeItem] Added new notice item. Total: ${o.length+1}`)}function dr(r,c){const e=r.params||{},t=e.index,o=e.locale,a=e.value;if(t===void 0||!o){k.warn("[updateNoticeItemName] Missing required params");return}const n=window.G7Core;if(!n?.state){k.warn("[updateNoticeItemName] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.notice_items??[]];i[t]={...i[t],name:{...i[t].name,[o]:a}},n.state.setLocal({form:{...s.form,notice_items:i},hasChanges:!0})}function ur(r,c){const e=window.G7Core;if(!e?.state){k.warn("[saveAsNoticeTemplate] G7Core.state API is not available");return}if(((e.state.getLocal()||{}).form?.notice_items??[]).length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.no_items")??"No items to save.");return}e.state.set?.({"ui.saveTemplateData":{name:ue(),category_id:null,is_default:!1}}),e.dispatch?.({handler:"openModal",target:"modal_save_template"})}const Ie={ko:"상세설명참조",en:"See detail description"};function mr(r,c){const e=window.G7Core;if(!e?.state){k.warn("[fillTemplateFieldsWithDetailReference] G7Core.state API is not available");return}const a=((r.params||{}).form||{}).fields??[];if(a.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product_notice_template.messages.no_fields_to_change")??"변경할 항목이 없습니다.");return}const n=e.locale?.supported?.()??["ko","en"],s={};for(const l of n)s[l]=Ie[l]??Ie.en??"See detail description";const i=a.map(l=>({...l,content:{...s}}));e.state.setLocal({"form.fields":i}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product_notice_template.messages.all_fields_changed_to_detail_ref")??"모든 항목이 '상세설명참조'로 변경되었습니다."),k.log("[fillTemplateFieldsWithDetailReference] All fields updated to detail reference")}function pr(r,c){const e=window.G7Core;if(!e?.state){k.warn("[fillNoticeItemsWithDetailReference] G7Core.state API is not available");return}const a=((r.params||{}).form||{}).notice_items??[];if(a.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.no_items_to_change")??"변경할 항목이 없습니다.");return}const n=e.locale?.supported?.()??["ko","en"],s={};for(const l of n)s[l]=Ie[l]??Ie.en??"See detail description";const i=a.map(l=>({...l,content:{...s}}));e.state.setLocal({"form.notice_items":i}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.all_items_changed_to_detail_ref")??"모든 항목이 '상세설명참조'로 변경되었습니다."),k.log("[fillNoticeItemsWithDetailReference] All notice items updated to detail reference")}async function fr(r,c){const e=window.G7Core;if(!e?.state||!e?.api){k.warn("[confirmSaveNoticeTemplate] G7Core.state or G7Core.api is not available");return}const t=e.state.getLocal()||{},o=e.state.get?.()?.ui?.saveTemplateData??{},a=o.name;if(!a?.ko?.trim()){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.template_name_required")??"Please enter template name.");return}try{const n=t.form?.notice_items??[],s=await e.api.post("/api/modules/sirsoft-ecommerce/admin/products/notice-templates",{name:a,is_active:o.is_default??!1,fields:n.map(i=>({name:i.name,content:i.content}))});e.state.set?.({"ui.saveTemplateData":{name:ue(),category_id:null,is_default:!1}}),e.state.setLocal({ui:{...t.ui,noticeMode:"existing"},hasChanges:!0}),e.dispatch?.({handler:"closeModal"}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.template_saved")??"Template has been saved."),e.datasources?.refresh?.("notice_templates"),k.log(`[confirmSaveNoticeTemplate] Template saved: ${s.data.id}`)}catch(n){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.template_save_error")??"Failed to save template."),k.error("[confirmSaveNoticeTemplate] Failed to save template:",n)}}const v=window.G7Core?.createLogger?.("Ecom:Misc")??{log:(...r)=>console.log("[Ecom:Misc]",...r),warn:(...r)=>console.warn("[Ecom:Misc]",...r),error:(...r)=>console.error("[Ecom:Misc]",...r)};function gr(r,c){const t=(r.params||{}).labelId;if(!t){v.warn("[toggleLabel] Missing labelId param");return}const o=window.G7Core;if(!o?.state){v.warn("[toggleLabel] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=a.form?.labels??[],s=n.includes(t)?n.filter(i=>i!==t):[...n,t];o.state.setLocal({form:{...a.form,labels:s},hasChanges:!0}),v.log(`[toggleLabel] Toggled label ${t}. Current labels: ${s.length}`)}async function _r(r,c){const e=window.G7Core;if(!e?.state||!e?.api){v.warn("[generateProductCode] G7Core.state or G7Core.api is not available");return}try{const t=await e.api.post("/api/modules/sirsoft-ecommerce/admin/products/generate-code"),o=e.state.getLocal()||{};e.state.setLocal({form:{...o.form,product_code:t.data.product_code}}),v.log(`[generateProductCode] Generated: ${t.data.product_code}`)}catch(t){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.messages.code_generate_error")??"Failed to generate product code."),v.error("[generateProductCode] Failed:",t)}}function wr(r,c){const e=r.params||{},t=e.policyId,o=e.field,a=e.policies;if(!t||!o||!a)return"-";const n=a.find(s=>s.id===t);return n?n[o]??"-":"-"}function yr(r,c){const e=r.params||{},t=e.templateId,o=e.templates;if(!t||!o)return"";const n=window.G7Core?.locale?.current?.()||"ko",s=o.find(i=>i.id===t);return s?.content?.[n]??s?.content?.ko??""}function hr(r,c){const e=r.params||{},t=e.platform,o=e.field,a=e.value;if(!t||!o){v.warn("[updateShoppingIntegration] Missing required params");return}const n=window.G7Core;if(!n?.state){v.warn("[updateShoppingIntegration] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=s.form?.shopping_integrations??{naver:{enabled:!1,sync_interval:"manual"},daum:{enabled:!1,sync_interval:"manual"},facebook:{enabled:!1,sync_interval:"manual"},google:{enabled:!1,sync_interval:"manual"}};n.state.setLocal({form:{...s.form,shopping_integrations:{...i,[t]:{...i[t],[o]:a}}},hasChanges:!0}),v.log(`[updateShoppingIntegration] Updated ${t}.${o} to ${a}`)}function Cr(r,c){const t=(r.params||{}).type;if(!t){v.warn("[updateShippingType] Missing type param");return}const o=window.G7Core;if(!o?.state){v.warn("[updateShippingType] G7Core.state API is not available");return}const a=o.state.getLocal()||{};let n=0,s=0;t==="fixed_paid"?n=3e3:t==="threshold_free"&&(s=3e4),o.state.setLocal({form:{...a.form,shipping_type:t,shipping_fee:t==="fixed_free"?0:a.form?.shipping_fee??n,free_shipping_threshold:t==="threshold_free"?a.form?.free_shipping_threshold??s:0},hasChanges:!0}),v.log(`[updateShippingType] Updated shipping type to ${t}`)}function br(r,c){const e=r.params||{},t=e.field,o=e.value;if(!t){v.warn("[updateIdentificationCode] Missing field param");return}const a=window.G7Core;if(!a?.state){v.warn("[updateIdentificationCode] G7Core.state API is not available");return}const n=a.state.getLocal()||{};a.state.setLocal({form:{...n.form,[t]:o},hasChanges:!0})}function Ir(r,c){const e=r.params||{},t=e.labelId,o=e.labelName;if(!t){v.warn("[openLabelPeriodModal] Missing labelId param");return}const a=window.G7Core;if(!a?.state){v.warn("[openLabelPeriodModal] G7Core.state API is not available");return}const n=a.state.getLocal()||{},i=(n.form?.labels??[]).find(l=>l.id===t);a.state.setLocal({ui:{...n.ui,showLabelPeriodModal:!0,labelPeriodModalData:{labelId:t,labelName:o,startDate:i?.start_date??null,endDate:i?.end_date??null}}})}function Sr(r,c){const e=r.params||{},t=e.labelId,o=e.startDate,a=e.endDate;if(!t){v.warn("[saveLabelPeriod] Missing labelId param");return}const n=window.G7Core;if(!n?.state){v.warn("[saveLabelPeriod] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.labels??[]],l=i.findIndex(u=>u.id===t);l>=0?i[l]={...i[l],start_date:o,end_date:a}:i.push({id:t,start_date:o,end_date:a}),n.state.setLocal({form:{...s.form,labels:i},ui:{...s.ui,showLabelPeriodModal:!1,labelPeriodModalData:null},hasChanges:!0}),n.toast?.success?.(n.t?.("sirsoft-ecommerce.admin.product.messages.label_period_saved")??"Label period has been saved."),v.log(`[saveLabelPeriod] Saved period for label ${t}`)}function vr(r,c){const t=(r.params||{}).labelId;if(!t){v.warn("[removeLabelPeriod] Missing labelId param");return}const o=window.G7Core;if(!o?.state){v.warn("[removeLabelPeriod] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.labels??[]],s=n.findIndex(i=>i.id===t);s>=0&&(n[s]={...n[s],start_date:null,end_date:null}),o.state.setLocal({form:{...a.form,labels:n},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.messages.label_period_removed")??"Label period has been removed."),v.log(`[removeLabelPeriod] Removed period for label ${t}`)}function Lr(r,c){const t=(r.params||{}).sort;if(!t){v.warn("[updateActivityLogSort] Missing sort param");return}const o=window.G7Core;if(!o?.state){v.warn("[updateActivityLogSort] G7Core.state API is not available");return}const a=o.state.getLocal()||{};o.state.setLocal({ui:{...a.ui,activityLogSort:t}}),o.datasources?.refresh?.("activity_logs",{params:{sort:t==="latest"?"desc":"asc",per_page:a.ui?.activityLogPerPage??10}}),v.log(`[updateActivityLogSort] Updated sort to ${t}`)}function xr(r,c){const t=(r.params||{}).perPage;if(!t){v.warn("[updateActivityLogPerPage] Missing perPage param");return}const o=window.G7Core;if(!o?.state){v.warn("[updateActivityLogPerPage] G7Core.state API is not available");return}const a=o.state.getLocal()||{};o.state.setLocal({ui:{...a.ui,activityLogPerPage:t}}),o.datasources?.refresh?.("activity_logs",{params:{sort:a.ui?.activityLogSort==="oldest"?"asc":"desc",per_page:t}}),v.log(`[updateActivityLogPerPage] Updated perPage to ${t}`)}function Pr(r,c){const e=window.G7Core;if(!e?.state){v.warn("[setDefaultShippingPolicy] G7Core.state API is not available");return}const t=e.state.getLocal()||{};if(t.form?.shipping_policy_id){v.log("[setDefaultShippingPolicy] Shipping policy already set, skipping");return}if(t.ui?.useDefaultShippingPolicy===!1){v.log("[setDefaultShippingPolicy] useDefaultShippingPolicy is OFF, skipping");return}const o=e.dataSource?.get?.("shipping_policies"),a=o?.data?.data??o?.data??[];if(a.length===0){v.log("[setDefaultShippingPolicy] No shipping policies found");return}const n=a.find(s=>s.is_default===!0);n?(e.state.setLocal({form:{...t.form,shipping_policy_id:n.id}}),v.log(`[setDefaultShippingPolicy] Set default shipping policy: ${n.id} (${n.name})`)):v.log("[setDefaultShippingPolicy] No default shipping policy found")}function Ar(r,c){const e=window.G7Core;if(!e?.state){v.warn("[toggleDefaultShippingPolicy] G7Core.state API is not available");return}const t=r.params?.checked??!1,o=e.state.getLocal()||{},a={...o.ui,useDefaultShippingPolicy:t};if(t){const n=e.dataSource?.get?.("shipping_policies"),i=(n?.data?.data??n?.data??[]).find(l=>l.is_default===!0);e.state.setLocal({ui:a,form:{...o.form,shipping_policy_id:i?.id??null}}),i?v.log(`[toggleDefaultShippingPolicy] ON - Selected default policy: ${i.id}`):v.log("[toggleDefaultShippingPolicy] ON - No default policy found")}else e.state.setLocal({ui:a}),v.log("[toggleDefaultShippingPolicy] OFF - Keeping current shipping policy")}function kr(r,c){const e=window.G7Core;if(!e?.state){v.warn("[setLabelDatePreset] G7Core.state API is not available");return}const t=r.params?.preset;if(!t){v.warn("[setLabelDatePreset] Missing preset param");return}const o=new Date,a=i=>{const l=i.getFullYear(),u=String(i.getMonth()+1).padStart(2,"0"),d=String(i.getDate()).padStart(2,"0");return`${l}-${u}-${d}`},n=a(o);let s=null;switch(t){case"7d":{const i=new Date(o);i.setDate(i.getDate()+7),s=a(i);break}case"14d":{const i=new Date(o);i.setDate(i.getDate()+14),s=a(i);break}case"30d":{const i=new Date(o);i.setDate(i.getDate()+30),s=a(i);break}case"permanent":s=null;break;default:v.warn(`[setLabelDatePreset] Unknown preset: ${t}`);return}e.state.setGlobal({"labelFormData.start_date":n,"labelFormData.end_date":s}),v.log(`[setLabelDatePreset] Applied preset '${t}': ${n} ~ ${s??"permanent"}`)}const Or=(r,c)=>{const e=window.G7Core,o=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],a=Number(r.params?.labelId);if(!a)return;const n=[...o];if(n.find(l=>l.label_id===a)){e?.state?.setLocal?.({"ui.lastClickedLabelId":a});return}const i=[...n,{label_id:a,started_at:null,ended_at:null}];e?.state?.setLocal?.({"form.label_assignments":i,"ui.lastClickedLabelId":a,hasChanges:!0})},Gr=(r,c)=>{const e=window.G7Core,o=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],a=Number(r.params?.labelId),n=r.params?.field,s=r.params?.value;if(!a||!n)return;const i=[...o],l=i.findIndex(u=>u.label_id===a);l<0||(i[l]={...i[l],[n]:s||null},e?.state?.setLocal?.({"form.label_assignments":i,hasChanges:!0}))},Er=(r,c)=>{const e=window.G7Core,t=e?.state?.getLocal?.()||{},o=t.form?.label_assignments??[],a=t.ui?.lastClickedLabelId,n=r.params?.preset;if(!a||!n)return;const s=[...o],i=s.findIndex(f=>f.label_id===a);if(i<0)return;const l=new Date,u=f=>f.toISOString().split("T")[0],d=u(l);let m=null;switch(n){case"7d":m=u(new Date(l.getTime()+7*24*60*60*1e3));break;case"14d":m=u(new Date(l.getTime()+14*24*60*60*1e3));break;case"30d":m=u(new Date(l.getTime()+30*24*60*60*1e3));break;case"permanent":m=null;break}s[i]={...s[i],started_at:d,ended_at:m},e?.state?.setLocal?.({"form.label_assignments":s,hasChanges:!0})},Fr=(r,c)=>{const e=window.G7Core,o=(e?.state?.get()||{}).labelToUncheckId;if(!o)return;const i=[...(e?.state?.getParent?.()?._local||{}).form?.label_assignments??[]].filter(l=>l.label_id!==o);e?.state?.setParentLocal?.({"form.label_assignments":i,hasChanges:!0}),e?.state?.setGlobal?.({labelToUncheckId:null}),e?.modal?.close?.()},Dr=async(r,c)=>{const e=window.G7Core,t=e?.state?.get()||{},a=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],n=t.labelToDeleteId;if(n){e?.state?.setGlobal?.({isDeletingLabel:!0});try{const s=await e.api.delete(`/api/modules/sirsoft-ecommerce/admin/product-labels/${n}`);if(!s?.success)throw new Error(s?.message||"Failed to delete label");const l=[...a].filter(u=>u.label_id!==n);e?.state?.setLocal?.({"form.label_assignments":l,"ui.lastClickedLabelId":null}),await e?.dataSource?.refetch?.("product_labels"),e?.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.labels.delete_success")??"Label deleted."),e?.modal?.close?.()}catch(s){e?.toast?.error?.(s?.response?.data?.message||s?.message||e.t?.("sirsoft-ecommerce.admin.product.labels.delete_error")||"Failed to delete label.")}finally{e?.state?.setGlobal?.({isDeletingLabel:!1,labelToDeleteId:null})}}},$r=async(r,c)=>{const e=window.G7Core,t=e?.state?.get()||{},a=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],n=t.editingLabelId,s=t.labelFormData;if(s?.name?.ko){e?.state?.setGlobal?.({isSavingLabel:!0});try{if(n){const i=await e.api.put(`/api/modules/sirsoft-ecommerce/admin/product-labels/${n}`,{name:s.name,color:s.color});if(!i?.success)throw new Error(i?.message||"Failed to update label");const u=[...a].map(d=>d.label_id===n?{...d,started_at:s.started_at||null,ended_at:s.ended_at||null}:d);e?.state?.setLocal?.({"form.label_assignments":u,hasChanges:!0})}else{const i=await e.api.post("/api/modules/sirsoft-ecommerce/admin/product-labels",{name:s.name,color:s.color||"#6B7280"});if(!i?.success)throw new Error(i?.message||"Failed to create label");const l=Number(i?.data?.id);if(l){const d=[...[...a],{label_id:l,started_at:null,ended_at:null}];e?.state?.setLocal?.({"form.label_assignments":d,"ui.lastClickedLabelId":l,hasChanges:!0})}}await e?.dataSource?.refetch?.("product_labels"),e?.toast?.success?.(n?e.t?.("sirsoft-ecommerce.admin.product.labels.save_success")??"Label saved.":e.t?.("sirsoft-ecommerce.admin.product.labels.create_success")??"Label created."),e?.modal?.close?.()}catch(i){e?.toast?.error?.(i?.response?.data?.message||i?.message||e.t?.("sirsoft-ecommerce.admin.product.labels.save_error")||"Failed to save label.")}finally{e?.state?.setGlobal?.({isSavingLabel:!1})}}},ee=window.G7Core?.createLogger?.("Ecom:DescriptionLocale")??{log:(...r)=>console.log("[Ecom:DescriptionLocale]",...r),warn:(...r)=>console.warn("[Ecom:DescriptionLocale]",...r),error:(...r)=>console.error("[Ecom:DescriptionLocale]",...r)};function at(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function Nr(r,c){const t=(r.params||{}).locale;if(!t){ee.warn("[removeDescriptionLocale] Missing locale param");return}const o=window.G7Core;if(!o?.state){ee.warn("[removeDescriptionLocale] G7Core.state API is not available");return}const a=o?.state?.get?.("_global.settings.general.language")??o?.config?.("app.fallback_locale")??"ko";if(t===a){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.default_locale_cannot_delete")??"Default language cannot be deleted.");return}const n=o.state.getLocal()||{},s=at(),i={...n.form?.description};delete i[t];const l={...n.form?.description_mobile};delete l[t];const u=(n.ui?.descriptionActiveLocales??s).filter(d=>d!==t);o.state.setLocal({form:{...n.form,description:i,description_mobile:l},ui:{...n.ui,descriptionActiveLocales:u,descriptionLocale:u[0]??"ko"},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_removed",{locale:t.toUpperCase()})??`${t.toUpperCase()} language has been removed.`),ee.log(`[removeDescriptionLocale] Removed locale: ${t}`)}function Tr(r,c){const e=window.G7Core;if(!e?.state){ee.warn("[showAddLocaleModal] G7Core.state API is not available");return}const t=e.state.getLocal()||{};e.state.setLocal({ui:{...t.ui,showAddLocaleModal:!0}})}function Rr(r,c){const t=(r.params||{}).locale;if(!t){ee.warn("[addDescriptionLocale] Missing locale param");return}const o=window.G7Core;if(!o?.state){ee.warn("[addDescriptionLocale] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=at(),s=a.ui?.descriptionActiveLocales??["ko"];if(s.includes(t)){o.toast?.warning?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_already_added")??"This language is already added.");return}if(!n.includes(t)){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_unsupported")??"Unsupported language.");return}o.state.setLocal({form:{...a.form,description:{...a.form?.description,[t]:""},description_mobile:{...a.form?.description_mobile,[t]:""}},ui:{...a.ui,descriptionActiveLocales:[...s,t],descriptionLocale:t,showAddLocaleModal:!1},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_added",{locale:t.toUpperCase()})??`${t.toUpperCase()} language has been added.`),ee.log(`[addDescriptionLocale] Added locale: ${t}`)}const W=window.G7Core?.createLogger?.("Ecom:BulkUpdate")??{log:(...r)=>console.log("[Ecom:BulkUpdate]",...r),warn:(...r)=>console.warn("[Ecom:BulkUpdate]",...r),error:(...r)=>console.error("[Ecom:BulkUpdate]",...r)};function st(r,c,e){const t=c?.response?.data??{},o=t?.message||c?.message||"",a=t?.errors,n=a&&typeof a=="object"?Object.values(a).map(i=>Array.isArray(i)?i[0]:String(i)):[],s=o||r.t(e);r.toast.error(s),r.state.set({bulkUpdateErrors:n.length>0?n:s?[s]:[]})}function Y(r){return typeof r=="string"?r:r&&typeof r=="object"?r.ko||r.en||JSON.stringify(r):String(r??"")}function Se(r,c,e){return e?r.t(`sirsoft-ecommerce.enums.${c}.${e}`)||e:String(e??"")}function it(){const r=window.G7Core;return r||(W.error("[bulkUpdate] G7Core is not available"),null)}function Mr(r,c){const e=it();if(!e)return;const t=e.state.getLocal()||{},o=e.state.get()||{},a=o.bulkSelectedItems||t.selectedItems||[],n=o.bulkSelectedOptionIds||t.selectedOptionIds||[];W.log("[bulkUpdate] globalState.bulkSelectedItems:",o.bulkSelectedItems),W.log("[bulkUpdate] localState.selectedItems:",t.selectedItems),W.log("[bulkUpdate] Final selectedItems:",a),a.length>0?Hr(e,o,t,a):n.length>0?qr(e,o,t,n):e.toast.warning(e.t("sirsoft-ecommerce.admin.product.bulk.no_selection"))}function Hr(r,c,e,t){const a=r.dataSource.get("products")?.data?.data||[],n={};c.bulkSalesStatus&&(n.sales_status=c.bulkSalesStatus),c.bulkDisplayStatus&&(n.display_status=c.bulkDisplayStatus);const s={};if(c.bulkPriceMethod&&c.bulkPriceValue!==void 0&&c.bulkPriceValue!==null&&c.bulkPriceValue!==""){const p={fixed:"set",increase:"add",decrease:"subtract"};c.bulkPriceUnit==="percent"?s.price_adjustment={method:"percent",value:c.bulkPriceMethod==="decrease"?-(Number(c.bulkPriceValue)||0):Number(c.bulkPriceValue)||0}:s.price_adjustment={method:p[c.bulkPriceMethod]||"add",value:Number(c.bulkPriceValue)||0}}if(c.bulkStockMethod&&c.bulkStockValue!==void 0&&c.bulkStockValue!==null&&c.bulkStockValue!==""){const p={set:"set",increase:"add",decrease:"subtract"};s.stock_quantity={method:p[c.bulkStockMethod]||"add",value:Number(c.bulkStockValue)||0}}const i=e.modifiedProductIds||[],l=e.modifiedOptionIds||[],u=e.modifiedProductFields||{},d=e.modifiedOptionFields||{},m=c.bulkSelectedOptionIds||e.selectedOptionIds||[],f=new Set(t),g=[],w=[];function y(p,S,C){if(S._modified||l.includes(C)){const x={product_id:p.id,option_id:S.id},I=d[C]||[];!s.price_adjustment&&I.includes("price_adjustment")&&(x.price_adjustment=S.price_adjustment),!s.price_adjustment&&I.includes("selling_price")&&(x.price_adjustment=S.price_adjustment),!s.stock_quantity&&I.includes("stock_quantity")&&(x.stock_quantity=S.stock_quantity),I.includes("option_name")&&(x.option_name=S.option_name),I.includes("sku")&&(x.sku=S.sku),I.includes("list_price")&&(x.list_price=S.list_price),I.includes("safe_stock_quantity")&&(x.safe_stock_quantity=S.safe_stock_quantity),I.includes("is_default")&&(x.is_default=S.is_default),I.includes("is_active")&&(x.is_active=S.is_active),Object.keys(x).length>2&&w.push(x)}}a.filter(p=>t.includes(p.id)).forEach(p=>{if(p._modified||i.includes(p.id)){const C={id:p.id},A=String(p.id),x=u[A]||[];!n.sales_status&&x.includes("sales_status")&&(C.sales_status=p.sales_status),!n.display_status&&x.includes("display_status")&&(C.display_status=p.display_status),x.includes("name")&&(C.name=p.name),x.includes("list_price")&&(C.list_price=p.list_price),x.includes("selling_price")&&(C.selling_price=p.selling_price),Object.keys(C).length>1&&g.push(C)}(p.options||[]).forEach(C=>{const A=`${p.id}-${C.id}`;y(p,C,A)})}),m.length>0&&a.forEach(p=>{f.has(p.id)||(p.options||[]).forEach(S=>{const C=`${p.id}-${S.id}`;m.includes(C)&&y(p,S,C)})});const _={ids:t};Object.keys(n).length>0&&(_.bulk_changes=n),g.length>0&&(_.items=g),Object.keys(s).length>0&&(_.option_bulk_changes=s),w.length>0&&(_.option_items=w),r.state.set({isProcessing:!0,bulkUpdateErrors:[]}),r.api.patch("/api/modules/sirsoft-ecommerce/admin/products/bulk-update",_).then(p=>{r.toast.success(r.t("sirsoft-ecommerce.admin.product.bulk.update_success",{products:p?.data?.products_updated||0,options:p?.data?.options_updated||0})),lt(r),ct(r),r.modal.close()}).catch(p=>{W.error("[bulkUpdateProducts] Error:",p),st(r,p,"sirsoft-ecommerce.admin.product.bulk.update_error")}).finally(()=>{r.state.set({isProcessing:!1})})}function qr(r,c,e,t){const a=r.dataSource.get("products")?.data?.data||[],n={};if(c.bulkPriceMethod&&c.bulkPriceValue!==void 0&&c.bulkPriceValue!==null&&c.bulkPriceValue!==""){const d={fixed:"set",increase:"add",decrease:"subtract"};c.bulkPriceUnit==="percent"?n.price_adjustment={method:"percent",value:c.bulkPriceMethod==="decrease"?-(Number(c.bulkPriceValue)||0):Number(c.bulkPriceValue)||0}:n.price_adjustment={method:d[c.bulkPriceMethod]||"add",value:Number(c.bulkPriceValue)||0}}if(c.bulkStockMethod&&c.bulkStockValue!==void 0&&c.bulkStockValue!==null&&c.bulkStockValue!==""){const d={set:"set",increase:"add",decrease:"subtract"};n.stock_quantity={method:d[c.bulkStockMethod]||"add",value:Number(c.bulkStockValue)||0}}const s=e.modifiedOptionFields||{},i=e.modifiedOptionIds||[],l=[];a.forEach(d=>{(d.options||[]).forEach(m=>{const f=`${d.id}-${m.id}`,g=m._modified||i.includes(f);if(t.includes(f)&&g){const w={product_id:d.id,option_id:m.id},y=s[f]||[];!n.price_adjustment&&y.includes("price_adjustment")&&(w.price_adjustment=m.price_adjustment),!n.price_adjustment&&y.includes("selling_price")&&(w.price_adjustment=m.price_adjustment),!n.stock_quantity&&y.includes("stock_quantity")&&(w.stock_quantity=m.stock_quantity),y.includes("option_name")&&(w.option_name=m.option_name),y.includes("sku")&&(w.sku=m.sku),y.includes("list_price")&&(w.list_price=m.list_price),y.includes("safe_stock_quantity")&&(w.safe_stock_quantity=m.safe_stock_quantity),y.includes("is_default")&&(w.is_default=m.is_default),y.includes("is_active")&&(w.is_active=m.is_active),Object.keys(w).length>2&&l.push(w)}})});const u={ids:t};Object.keys(n).length>0&&(u.bulk_changes=n),l.length>0&&(u.items=l),r.state.set({isProcessing:!0,bulkUpdateErrors:[]}),r.api.patch("/api/modules/sirsoft-ecommerce/admin/options/bulk-update",u).then(d=>{r.toast.success(r.t("sirsoft-ecommerce.admin.product.bulk.option_update_success",{count:d?.data?.options_updated||0})),lt(r),ct(r),r.modal.close()}).catch(d=>{W.error("[bulkUpdateOptions] Error:",d),st(r,d,"sirsoft-ecommerce.admin.product.bulk.option_update_error")}).finally(()=>{r.state.set({isProcessing:!1})})}function ct(r){Promise.resolve(r.dataSource.refetch("products")).then(()=>{eo(r,{force:!0})}).catch(c=>{W.error("[bulkUpdate] Failed to refetch products:",c)})}function lt(r){r.state.setLocal({modifiedProductIds:[],modifiedOptionIds:[],modifiedProductFields:{},modifiedOptionFields:{},selectedItems:[],selectedOptionIds:[]}),r.state.set({bulkSalesStatus:null,bulkDisplayStatus:null,bulkPriceCondition:null,bulkStockCondition:null,bulkConfirmData:null,bulkUpdateErrors:[]})}function Ur(r,c){const e=it();if(!e)return;const t=e.state.getLocal()||{},o=e.state.get()||{},n=e.dataSource.get("products")?.data?.data||[],s=o.bulkSelectedItems||t.selectedItems||[],i=o.bulkSelectedOptionIds||t.selectedOptionIds||[],l={products:[],options:[],summary:{productCount:0,optionCount:0,hasBulkChanges:!1,hasInlineChanges:!1}},u=!!(o.bulkSalesStatus||o.bulkDisplayStatus||o.bulkPriceCondition||o.bulkStockCondition);l.summary.hasBulkChanges=u;const d=t.modifiedProductIds||[],m=t.modifiedOptionIds||[],f=t.modifiedProductFields||{},g=t.modifiedOptionFields||{},w=new Set(s);function y(_,p,S){const C=[],A=g[p]||[];if(o.bulkPriceCondition&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_price_adjustment_inline",{value:o.bulkPriceCondition})||`Price adjustment: ${o.bulkPriceCondition}`),o.bulkStockCondition&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_stock_inline",{value:o.bulkStockCondition})||`Stock: ${o.bulkStockCondition}`),S){if(!o.bulkPriceCondition&&A.includes("price_adjustment")){const x=_.price_adjustment?.toLocaleString?.()??_.price_adjustment;C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_price_adjustment_inline",{value:x})||`Price adjustment: ${x}`)}!o.bulkPriceCondition&&A.includes("selling_price")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_selling_price",{price:_.selling_price?.toLocaleString?.()??_.selling_price})||`Selling price: ${_.selling_price}`),!o.bulkStockCondition&&A.includes("stock_quantity")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_stock_inline",{value:_.stock_quantity})||`Stock: ${_.stock_quantity}`),A.includes("option_name")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_option_name",{name:Y(_.option_name)})||`Option name: ${Y(_.option_name)}`),A.includes("sku")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sku",{sku:_.sku})||`SKU: ${_.sku}`),A.includes("list_price")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price",{price:_.list_price?.toLocaleString?.()??_.list_price})||`List price: ${_.list_price}`),A.includes("safe_stock_quantity")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_safe_stock",{value:_.safe_stock_quantity})||`Safe stock: ${_.safe_stock_quantity}`),A.includes("is_active")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_is_active",{value:_.is_active?"ON":"OFF"})||`Active: ${_.is_active?"ON":"OFF"}`),l.summary.hasInlineChanges=!0}return C}s.length>0&&n.filter(_=>s.includes(_.id)).forEach(_=>{const p=[],S=_._modified||d.includes(_.id),C=String(_.id),A=f[C]||[];if(o.bulkSalesStatus){const I=Se(e,"sales_status",o.bulkSalesStatus);p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sales_status",{status:I})||`Sales status: ${I}`)}if(o.bulkDisplayStatus){const I=Se(e,"display_status",o.bulkDisplayStatus);p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_display_status",{status:I})||`Display status: ${I}`)}if(S){if(!o.bulkSalesStatus&&A.includes("sales_status")){const I=Se(e,"sales_status",_.sales_status);p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sales_status",{status:I})||`Sales status: ${I}`)}if(!o.bulkDisplayStatus&&A.includes("display_status")){const I=Se(e,"display_status",_.display_status);p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_display_status",{status:I})||`Display status: ${I}`)}A.includes("name")&&p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_name_changed")||"Name changed"),A.includes("list_price")&&p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price",{price:_.list_price?.toLocaleString?.()||_.list_price})||`List price: ${_.list_price?.toLocaleString?.()||_.list_price}`),A.includes("selling_price")&&p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_selling_price",{price:_.selling_price?.toLocaleString?.()||_.selling_price})||`Selling price: ${_.selling_price?.toLocaleString?.()||_.selling_price}`),l.summary.hasInlineChanges=!0}(p.length>0||u||S)&&l.products.push({id:_.id,name:Y(_.name),changes:p.length>0?p.join(", "):S?e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_inline_modified")||"Inline modified":e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_bulk_applied")||"Bulk changes applied"});const x=u&&!!(o.bulkPriceCondition||o.bulkStockCondition);if(Array.isArray(_.options))_.options.forEach(I=>{const N=`${_.id}-${I.id}`,L=I._modified||m.includes(N),O=y(I,N,L);(O.length>0||x||L)&&l.options.push({productId:_.id,optionId:I.id,productName:Y(_.name),optionName:I.option_name_localized||Y(I.option_name)||I.name||"",changes:O.length>0?O.join(", "):L?e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_inline_modified")||"Inline modified":e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_bulk_applied")||"Bulk changes applied"})});else if(x){const I=Number(_.options_total_count)||0;I>0&&l.options.push({aggregate:!0,productId:_.id,optionId:0,optionCount:I,productName:Y(_.name),optionName:"",changes:y({},`${_.id}-*`,!1).join(", ")||e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_bulk_applied")||"Bulk changes applied"})}}),i.length>0&&n.forEach(_=>{w.has(_.id)||(_.options||[]).forEach(p=>{const S=`${_.id}-${p.id}`;if(i.includes(S)){const C=p._modified||m.includes(S),A=y(p,S,C);l.options.push({productId:_.id,optionId:p.id,productName:Y(_.name),optionName:p.option_name_localized||Y(p.option_name)||p.name||"",changes:A.length>0?A.join(", "):C?e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_inline_modified")||"Inline modified":e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_selected")||"Selected"})}})}),l.summary.productCount=l.products.length,l.summary.optionCount=l.options.reduce((_,p)=>_+(p.aggregate?p.optionCount||0:1),0),e.state.set({bulkConfirmData:l}),W.log("[buildConfirmData] Built confirm data:",l)}const M=window.G7Core?.createLogger?.("Ecom:Order")??{log:(...r)=>console.log("[Ecom:Order]",...r),warn:(...r)=>console.warn("[Ecom:Order]",...r),error:(...r)=>console.error("[Ecom:Order]",...r)};function Vr(r){const{currencies:c=[]}=r.params||{},e=[{field:"no",header:"NO",width:"60px",sortable:!1,required:!0},{field:"ordered_at",header:"$t:sirsoft-ecommerce.admin.order.column.order_date",width:"140px",sortable:!0,required:!0},{field:"order_number",header:"$t:sirsoft-ecommerce.admin.order.column.order_number",width:"140px",sortable:!0,required:!0},{field:"product",header:"$t:sirsoft-ecommerce.admin.order.column.product",width:"auto",sortable:!1,required:!0},{field:"order_amount",header:"$t:sirsoft-ecommerce.admin.order.column.order_amount",width:"120px",sortable:!0,required:!1},{field:"order_status",header:"$t:sirsoft-ecommerce.admin.order.column.order_status",width:"100px",sortable:!1,required:!1},{field:"orderer",header:"$t:sirsoft-ecommerce.admin.order.column.orderer",width:"120px",sortable:!1,required:!1},{field:"payment_method",header:"$t:sirsoft-ecommerce.admin.order.column.payment_method",width:"100px",sortable:!1,required:!1},{field:"delivery_method",header:"$t:sirsoft-ecommerce.admin.order.column.delivery_method",width:"100px",sortable:!1,required:!1},{field:"country",header:"$t:sirsoft-ecommerce.admin.order.column.country",width:"80px",sortable:!1,required:!1}],t=c.filter(o=>!o.is_default).map(o=>({field:`order_amount_${o.code.toLowerCase()}`,header:`${o.name} (${o.symbol})`,width:"100px",sortable:!0,required:!1}));return[...e,...t]}function jr(r,c){const e=r.params||{},{target:t="_local",path:o,value:a}=e,n=window.G7Core;if(!n?.state)return M.warn("[toggleArrayValue] G7Core.state를 사용할 수 없습니다."),{success:!1,newValue:[]};const s=t==="_global"?n.state.getGlobal():n.state.getLocal(),i=o.split(".");let l=s;for(const d of i)l=l?.[d];Array.isArray(l)||(l=[]);let u;return l.includes(a)?u=l.filter(d=>d!==a):u=[...l,a],t==="_global"?n.state.setGlobal({[o]:u}):n.state.setLocal({[o]:u}),{success:!0,newValue:u}}function Br(r,c){const e=r.params||{},{filterKey:t,checked:o}=e,a=window.G7Core;if(!a?.state)return M.warn("[toggleVisibleFilter] G7Core.state를 사용할 수 없습니다."),{success:!1,newValue:[]};const s=a.state.getLocal().visibleFilters||[];let i;o?i=s.includes(t)?s:[...s,t]:i=s.filter(l=>l!==t),a.state.setLocal({visibleFilters:i});try{localStorage.setItem("g7_order_filter_visible_filters",JSON.stringify(i))}catch(l){M.warn("[toggleVisibleFilter] LocalStorage 저장 실패:",l)}return{success:!0,newValue:i}}function Kr(r,c){const e=r.params||{},{selectedIds:t=[]}=e,o=window.G7Core;return o?.state?(o.state.setLocal({selectedItems:t,selectAll:!1}),{success:!0}):(M.warn("[syncOrderSelection] G7Core.state를 사용할 수 없습니다."),{success:!1})}function Qr(r,c){const e=r.params||{},{actionId:t,row:o}=e,a=window.G7Core;if(!t||!o?.order_number)return M.warn("[handleOrderRowAction] actionId 또는 row.order_number가 없습니다."),{success:!1};switch(t){case"view":a?.navigate?.(`/admin/ecommerce/orders/${o.order_number}`);break;case"edit":a?.navigate?.(`/admin/ecommerce/orders/${o.order_number}`);break;case"status":case"shipping":case"memo":case"delete":a?.state?.setGlobal({orderList:{currentActionOrderId:o.id,currentActionType:t}});break;case"print":window.open(`/admin/ecommerce/orders/${o.order_number}/shipping-label`,"_blank");break;default:return console.warn(`[handleOrderRowAction] 알 수 없는 액션: ${t}`),{success:!1}}return{success:!0,action:t,targetId:o.id}}async function zr(r,c){const e=r.params||{},{actionType:t,orderIds:o,status:a,carrierId:n,trackingNumber:s}=e,i=window.G7Core;if(!o||o.length===0)throw new Error("선택된 주문이 없습니다.");let l="";const u={ids:o};switch(t){case"status":if(!a)throw new Error("변경할 상태를 선택해주세요.");l="/api/modules/sirsoft-ecommerce/admin/orders/bulk",u.order_status=a;break;case"shipping":if(!s&&!n)throw new Error("택배사 또는 운송장번호를 입력해주세요.");l="/api/modules/sirsoft-ecommerce/admin/orders/bulk",n&&(u.carrier_id=n),s&&(u.tracking_number=s);break;default:throw new Error(`알 수 없는 액션 타입: ${t}`)}const d=await i?.api?.call(l,{method:"PATCH",body:u});if(!d?.success)throw new Error(d?.message||"일괄 처리에 실패했습니다.");return{success:!0,successCount:d.data?.updated_count??o.length,failCount:d.data?.fail_count??0,errors:d.data?.errors??[]}}async function Wr(r,c){const e=r.params||{},{downloadType:t,orderIds:o,filters:a}=e,n=window.G7Core,s="/api/modules/sirsoft-ecommerce/admin/orders/export",i={};if(t==="selected"){if(!o||o.length===0)throw new Error("선택된 주문이 없습니다.");i.ids=o}else i.filters=a||{};const l=n?.auth?.getToken(),u=await fetch(s,{method:"POST",headers:{"Content-Type":"application/json",...l?{Authorization:`Bearer ${l}`}:{}},body:JSON.stringify(i)});if(!u.ok){const y=await u.json().catch(()=>null);throw new Error(y?.message||"엑셀 다운로드에 실패했습니다.")}const d=await u.blob(),m=window.URL.createObjectURL(d),f=document.createElement("a");f.href=m;const g=new Date,w=`${g.getFullYear()}-${String(g.getMonth()+1).padStart(2,"0")}-${String(g.getDate()).padStart(2,"0")}`;return f.download=`orders_${w}.xlsx`,document.body.appendChild(f),f.click(),document.body.removeChild(f),window.URL.revokeObjectURL(m),{success:!0}}function Yr(r,c){const e=r.params||{},{columns:t=[]}=e;try{localStorage.setItem("g7_order_visible_columns",JSON.stringify(t))}catch(o){return M.warn("[saveVisibleColumns] LocalStorage 저장 실패:",o),{success:!1}}return{success:!0}}function Jr(r,c){const t=(r.params||{}).defaultColumns||["order_date","order_number","order_status","order_amount","product","orderer","payment_method","delivery_method","country"];try{const o=localStorage.getItem("g7_order_visible_columns");if(o){const a=JSON.parse(o);if(Array.isArray(a))return a}}catch(o){M.warn("[loadVisibleColumns] LocalStorage 로드 실패:",o)}return t}function Xr(r,c){const e=r.params||{},{id:t,defaultFilters:o=[]}=e,a=window.G7Core;if(!t)return M.warn("[loadVisibleFilters] id 파라미터가 필요합니다."),o;let n;try{const i=a?.AuthManager?.getInstance();i&&(n=i.getUser()?.uuid)}catch{}const s=`g7_filters_${t}${n!==void 0?`_${n}`:""}`;try{const i=localStorage.getItem(s);if(i){const l=JSON.parse(i);if(Array.isArray(l)&&l.length>0)return M.log(`[loadVisibleFilters] localStorage에서 로드: ${s}`,l),l}}catch(i){M.warn("[loadVisibleFilters] LocalStorage 로드 실패:",i)}return M.log(`[loadVisibleFilters] 기본값 사용: ${s}`,o),o}function Zr(r,c){const e=window.G7Core;if(!e)return;const t=e.state.get()||{},o=t.bulkSelectedItems||[],a=t.bulkOrderStatus||"",n=t.bulkCourier||"",s=t.bulkTrackingNumber||"";if(!a){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_status_selected")||"변경할 주문상태를 선택해주세요.");return}if(["shipping_ready","shipping"].includes(a)&&(!n||!s)){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.carrier_required")||"해당 상태로 변경하려면 택배사와 송장번호를 모두 입력해주세요.");return}e.state.set({bulkConfirmData:{selectedItems:o,orderStatus:a,courierId:n,trackingNumber:s}}),e.modal?.open?.("modal_bulk_confirm")}async function en(r,c){const e=window.G7Core;if(!e)return;const o=(e.state.get()||{}).bulkConfirmData;if(!o||!o.selectedItems?.length){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.error")||"일괄 처리에 실패했습니다.");return}const a={ids:o.selectedItems,order_status:o.orderStatus};o.courierId&&(a.carrier_id=o.courierId),o.trackingNumber&&(a.tracking_number=o.trackingNumber);try{await e.api.patch("/api/modules/sirsoft-ecommerce/admin/orders/bulk",a),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.success")||"일괄 변경이 완료되었습니다."),e.state.setLocal({selectedItems:[],selectAll:!1,bulkOrderStatus:"",bulkCourier:"",bulkTrackingNumber:""}),e.state.set({bulkConfirmData:null}),e.dataSource?.refetch?.("orders"),e.modal?.close?.()}catch(n){M.error("[executeOrderBulkAction] Error:",n);const i=(n?.response?.data||n?.data||{})?.message||n?.message;e.toast?.error?.(i||e.t?.("sirsoft-ecommerce.admin.order.bulk.error")||"일괄 처리에 실패했습니다.")}}const He=window.G7Core?.createLogger?.("Ecom:ProductList")??{log:(...r)=>console.log("[Ecom:ProductList]",...r),warn:(...r)=>console.warn("[Ecom:ProductList]",...r),error:(...r)=>console.error("[Ecom:ProductList]",...r)};async function tn(r,c){const e=r.params||{},{actionId:t,row:o}=e,a=window.G7Core;if(!t||!o?.id)return He.warn("[handleProductRowAction] actionId 또는 row.id가 없습니다."),{success:!1};switch(t){case"edit":a?.navigate?.(`/admin/ecommerce/products/${o.product_code}/edit`);break;case"copy":a?.state?.setLocal({productList:{copyTargetProduct:o},ui:{showCopyModal:!0}});break;case"delete":try{const s=(await a?.api?.get(`/api/modules/sirsoft-ecommerce/admin/products/${o.id}/can-delete`))?.data;a?.state?.setLocal({productList:{deleteTargetProduct:o,canDelete:s?.canDelete??!0,deleteBlockReason:s?.reason??null,relatedData:s?.relatedData??null},ui:{showDeleteModal:!0}})}catch(n){He.error("[handleProductRowAction] 삭제 가능 여부 확인 실패:",n),a?.state?.setLocal({productList:{deleteTargetProduct:o,canDelete:!1,deleteBlockReason:"삭제 가능 여부를 확인할 수 없습니다.",relatedData:null},ui:{showDeleteModal:!0}})}break;default:return He.warn(`[handleProductRowAction] 알 수 없는 액션: ${t}`),{success:!1}}return{success:!0,action:t,targetId:o.id}}const U=window.G7Core?.createLogger?.("Ecom:OrderDetail")??{log:(...r)=>console.log("[Ecom:OrderDetail]",...r),warn:(...r)=>console.warn("[Ecom:OrderDetail]",...r),error:(...r)=>console.error("[Ecom:OrderDetail]",...r)};function dt(){return typeof document>"u"?"":document.querySelector('input[name="batchTrackingNumber"]')?.value?.trim()??""}function on(r,c){const e=window.G7Core;if(!e?.state){U.warn("[initOrderDetailForm] G7Core.state를 사용할 수 없습니다.");return}e.state.getLocal();const o=c?.data?.data??e.dataSource?.get?.("order")?.data;if(!o){U.warn("[initOrderDetailForm] order 데이터가 없습니다.");return}e.state.setLocal({form:{recipient_name:o.recipient_name||"",recipient_phone:o.recipient_phone||"",recipient_tel:o.recipient_tel||"",recipient_zipcode:o.recipient_zipcode||"",recipient_address:o.recipient_address||"",recipient_detail_address:o.recipient_detail_address||"",delivery_memo:o.delivery_memo||"",admin_memo:o.admin_memo||""}}),U.log("[initOrderDetailForm] 폼 초기화 완료")}function rn(r,c){const e=window.G7Core,{optionId:t}=r.params||{};if(!e?.state||!t)return;const a=e.state.getLocal().selectedProducts||[];let n;a.includes(t)?n=a.filter(u=>u!==t):n=[...a,t];const i=(e.dataSource?.get?.("order")?.data?.options||[]).length,l=n.length===i&&i>0;e.state.setLocal({selectedProducts:n,selectAll:l})}function nn(r,c){const e=window.G7Core;if(!e?.state)return;const o=e.state.getLocal().selectAll,n=e.dataSource?.get?.("order")?.data?.options||[];if(o)e.state.setLocal({selectedProducts:[],selectAll:!1});else{const s=n.map(i=>i.id);e.state.setLocal({selectedProducts:s,selectAll:!0})}}function an(r,c){const e=window.G7Core;if(!e)return;const t=r.params||{},o=t.selectedProducts||[],a=t.batchOrderStatus||"",n=t.batchCarrierId||"",s=t.batchTrackingNumber||dt();if(!a){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_status_selected")||"변경할 주문상태를 선택해주세요.");return}if(o.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_items_selected")||"변경할 상품을 선택해주세요.");return}if(a==="cancelled"){const f=t.cancelPg??!0,g=e.dataSource?.get?.("order")?.data,w=g?.options||[],y=o.map(_=>{const p=w.find(S=>S.id===_);return!p||p.option_status==="cancelled"?null:{id:p.id,product_name:p.product_name,product_option_name:p.product_option_name,thumbnail_url:p.product_snapshot?.thumbnail_url||"",unit_price:p.unit_price,quantity:p.quantity,cancel_quantity:p.quantity,option_status:p.option_status,option_status_label:p.option_status_label||p.option_status}}).filter(Boolean);if(y.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_cancellable_items")||"취소할 수 있는 상품이 없습니다. 이미 취소된 상품은 제외됩니다.");return}if(f){e.state.setLocal({cancelItems:y,cancelReason:"",cancelReasonDetail:"",cancelPg:!0,refundPriority:"pg_first",refundEstimate:null,refundLoading:!1,isCancelling:!1,cancelError:null,cancelValidationErrors:null}),e.modal?.open?.("modal_cancel_order");const _=g?.order_number||"";_&&e.dispatch?.({handler:"sirsoft-ecommerce.estimateRefundAmount",params:{orderId:_,cancelItems:y}})}else{const _={};for(const p of y)_[p.id]=p.quantity;e.state.setLocal({bulkConfirmItems:y,changeQuantities:_,batchOrderStatus:a}),e.modal?.open?.("modal_batch_change_confirm")}return}if(["shipping_ready","shipping","delivered"].includes(a)&&(!n||!s)){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.carrier_required")||"해당 상태로 변경하려면 택배사와 송장번호를 모두 입력해주세요.");return}const u=e.dataSource?.get?.("order")?.data?.options||[],d=[],m={};for(const f of o){const g=u.find(w=>w.id===f);g&&(d.push({id:g.id,product_name:g.product_name,product_option_name:g.product_option_name,sku:g.sku,thumbnail_url:g.product_snapshot?.thumbnail_url||"",unit_price:g.unit_price,original_price:g.product_snapshot?.original_price||g.unit_price,quantity:g.quantity,option_status:g.option_status}),m[g.id]=g.quantity)}e.state.setLocal({bulkConfirmItems:d,changeQuantities:m,batchOrderStatus:a,batchCarrierId:n,batchTrackingNumber:s}),e.modal?.open?.("modal_batch_change_confirm")}async function sn(r,c){const e=window.G7Core,{orderId:t}=r.params||{};if(!e?.state||!t){U.warn("[processOrderDetailBulkChange] orderId가 없습니다.");return}const o=e.state.getLocal?.()??{},a=r.params?.selectedProducts??o.selectedProducts,n=r.params?.batchOrderStatus||o.batchOrderStatus,s=r.params?.batchCarrierId||o.batchCarrierId,i=r.params?.batchTrackingNumber||o.batchTrackingNumber||dt(),l=r.params?.changeQuantities??o.changeQuantities;if(!a||a.length===0){U.warn("[processOrderDetailBulkChange] 선택된 상품이 없습니다.");return}if(!n){U.warn("[processOrderDetailBulkChange] 변경할 상태가 없습니다.");return}const d=e.dataSource?.get?.("order")?.data?.options||[],f={items:a.map(g=>{const w=d.find(y=>y.id===g);return{option_id:g,quantity:l?.[g]??w?.quantity??1}}),status:n};s&&(f.carrier_id=s),i&&(f.tracking_number=i);try{const g=await e.api.patch(`/api/modules/sirsoft-ecommerce/admin/orders/${t}/options/bulk-status`,f);if(g?.success){const w=e.t,y=g.data?.changed_count??a.length;e.toast?.success?.(w?.("sirsoft-ecommerce.admin.order.detail.handler.bulk_change_success",{count:y})??`${y}개 옵션의 상태가 변경되었습니다.`),e.modal?.close?.("modal_batch_change_confirm"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order_logs"}}),e.state.setLocal({selectedProducts:[],selectAll:!1,batchOrderStatus:"",batchCarrierId:"",batchTrackingNumber:"",changeQuantities:{}})}else throw new Error(g?.message||"상태 변경에 실패했습니다.")}catch(g){U.error("[processOrderDetailBulkChange] API 호출 실패:",g);const y=(g?.response?.data||g?.data||{})?.message||g?.message;e.toast?.error?.(y??e.t?.("sirsoft-ecommerce.admin.order.detail.handler.bulk_change_failed")??"상태 변경에 실패했습니다.")}}async function cn(r,c){const e=window.G7Core,{orderId:t}=r.params||{};if(!e?.state||!t)return;const a=e.state.getLocal().form?.admin_memo??"";try{const n=await e.api.patch(`/api/modules/sirsoft-ecommerce/admin/orders/${t}`,{admin_memo:a});if(n?.success)e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.detail.handler.memo_save_success")??"관리자 메모가 저장되었습니다.");else throw new Error(n?.message)}catch(n){U.error("[saveAdminMemo] 저장 실패:",n),e.toast?.error?.(n?.message??"메모 저장에 실패했습니다.")}}function ln(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,maxQuantity:o}=r.params||{};if(!t||!o)return;const a=r.params?.value??r.$event?.target?.value,n=parseInt(String(a),10),s=Math.max(1,Math.min(isNaN(n)?o:n,o)),l={...e.state.getLocal().changeQuantities||{}};l[t]=s,e.state.setLocal({changeQuantities:l})}function dn(r,c){const e=window.G7Core;if(!e?.state){U.warn("[openConfirmDepositModal] G7Core.state 를 사용할 수 없습니다.");return}const t=e.dataSource?.get?.("order")?.data,o=t?.depositor_name??"",a=t?.total_due_amount??0;e.state.setLocal({depositorName:o,depositAmount:a,depositConfirmErrors:null,isConfirmingDeposit:!1,markOrderComplete:!1}),e.modal?.open?.("modal_confirm_deposit")}async function un(r,c){const e=window.G7Core,{orderId:t}=r.params||{};if(!e?.state||!t){U.warn("[confirmDeposit] orderId 가 없습니다.");return}const o=e.state.getLocal(),a=r.params?.amount??o.depositAmount,n=r.params?.depositorName??o.depositorName??"",s=r.params?.markOrderComplete??o.markOrderComplete??!1,i=s===!0||s==="true";e.state.setLocal({isConfirmingDeposit:!0,depositConfirmErrors:null});try{const l=await e.api.patch(`/api/modules/sirsoft-ecommerce/admin/orders/${t}/confirm-deposit`,{amount:a,depositor_name:n,mark_order_complete:i});if(l?.success)e.state.setLocal({isConfirmingDeposit:!1,depositConfirmErrors:null}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.detail.modal.confirm_deposit.confirm_success")??"입금이 확인되어 결제완료 처리되었습니다."),e.modal?.close?.("modal_confirm_deposit"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order_logs"}});else throw new Error(l?.message||"Deposit confirm failed")}catch(l){U.error("[confirmDeposit] 실패:",l);const u=l?.response?.data||l?.data||{},d=u?.errors?.detail||u?.message||l?.message||"",m=e.t?.("sirsoft-ecommerce.admin.order.detail.modal.confirm_deposit.confirm_failed")??"입금 확인에 실패했습니다.";e.state.setLocal({isConfirmingDeposit:!1,depositConfirmErrors:{amount:[d||m]}}),e.toast?.error?.(d||m)}}const qe=window.G7Core?.createLogger?.("Ecom:CancelOrder")??{log:(...r)=>console.log("[Ecom:CancelOrder]",...r),warn:(...r)=>console.warn("[Ecom:CancelOrder]",...r),error:(...r)=>console.error("[Ecom:CancelOrder]",...r)};let K=null;function mn(){K&&(clearTimeout(K),K=null,qe.log("[clearCancelOrderTimers] debounce 타이머 정리 완료"))}function pn(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,maxQuantity:o,orderId:a}=r.params||{};if(!t||!o||!a)return;const n=r.params?.value??r.$event?.target?.value,s=parseInt(String(n),10),i=Math.max(1,Math.min(isNaN(s)?o:s,o)),u=(e.state.getLocal().cancelItems||[]).map(m=>m.id===t?{...m,cancel_quantity:i}:m);e.state.setLocal({cancelItems:u});const d=window.__g7ActionContext;K&&clearTimeout(K),K=setTimeout(()=>{const m=window.__g7ActionContext;window.__g7ActionContext=d;try{Ue(e,a)}finally{window.__g7ActionContext=m}},500)}async function fn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,cancelItems:o}=r.params||{};t&&await Ue(e,t,o)}async function Ue(r,c,e){const t=r.state.getLocal(),o=e||t.cancelItems||[],a=t.refundPriority||"pg_first";if(o.length===0){r.state.setLocal({refundEstimate:null});return}const n=o.map(s=>({order_option_id:s.id,cancel_quantity:s.cancel_quantity}));r.state.setLocal({refundLoading:!0});try{const s=await r.api.post(`/api/modules/sirsoft-ecommerce/admin/orders/${c}/estimate-refund`,{items:n,refund_priority:a});if(s?.success)r.state.setLocal({refundEstimate:s.data,refundLoading:!1,cancelError:null});else throw new Error(s?.message||"Estimate failed")}catch(s){qe.error("[estimateRefundAmount] 실패:",s),r.state.setLocal({refundLoading:!1,refundEstimate:null}),r.toast?.error?.(r.t?.("sirsoft-ecommerce.admin.order.detail.modal.cancel.estimate_failed")??"환불 예상금액 계산에 실패했습니다.")}}function gn(r,c){const e=window.G7Core;if(!e?.state)return;const{priority:t,orderId:o}=r.params||{};if(!t||!o)return;e.state.setLocal({refundPriority:t});const a=window.__g7ActionContext;K&&clearTimeout(K),K=setTimeout(()=>{const n=window.__g7ActionContext;window.__g7ActionContext=a;try{Ue(e,o)}finally{window.__g7ActionContext=n}},300)}async function _n(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};if(!t)return;const o=e.state.getLocal(),a=r.params?.cancelItems||o.cancelItems||[],n=r.params?.cancelReason||o.cancelReason||"",s=r.params?.cancelReasonDetail||o.cancelReasonDetail||"",i=r.params?.cancelPg??o.cancelPg??!0,l=r.params?.refundPriority||o.refundPriority||"pg_first",u={type:"partial",cancel_pg:i,refund_priority:l,items:a.map(m=>({order_option_id:m.id,cancel_quantity:m.cancel_quantity}))};n&&(u.reason=n),s&&(u.reason_detail=s);const d={bank_code:o.refundBankCode||"",account_number:o.refundBankAccount||"",holder:o.refundBankHolder||""};(d.bank_code||d.account_number||d.holder)&&(u.refund_bank=d),e.state.setLocal({isCancelling:!0,cancelError:null,cancelValidationErrors:null});try{const m=await e.api.post(`/api/modules/sirsoft-ecommerce/admin/orders/${t}/cancel`,u);if(m?.success)e.state.setLocal({isCancelling:!1}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.detail.modal.cancel.cancel_success")??"주문이 취소되었습니다."),e.modal?.close?.("modal_cancel_order"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order_logs"}}),e.state.setLocal({selectedProducts:[],selectAll:!1,batchOrderStatus:""});else throw new Error(m?.message||"Cancel failed")}catch(m){qe.error("[executeCancelOrder] 실패:",m);const f=m?.response?.data||m?.data,w=(m?.response?.status||m?.status)===422,y=f?.errors?.detail,_=f?.errors??null,p=y||f?.message||m?.message||"",S=e.t?.("sirsoft-ecommerce.admin.order.detail.modal.cancel.cancel_failed")??"주문 취소에 실패했습니다.";w?(e.state.setLocal({isCancelling:!1,cancelError:null,cancelValidationErrors:_}),e.toast?.error?.(p||S)):(e.state.setLocal({isCancelling:!1,cancelError:p||S,cancelValidationErrors:null}),e.toast?.error?.(p||S))}}const ut=window.G7Core?.createLogger?.("Ecom:UserCancelOrder")??{log:(...r)=>console.log("[Ecom:UserCancelOrder]",...r),warn:(...r)=>console.warn("[Ecom:UserCancelOrder]",...r),error:(...r)=>console.error("[Ecom:UserCancelOrder]",...r)};let R=null;function ve(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}function wn(){R&&(clearTimeout(R),R=null)}function yn(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t}=r.params||{};if(!t)return;const a=[...e.state.getLocal().selectedItemIds||[]],n=a.indexOf(t);n>=0?a.splice(n,1):a.push(t);const i=(e.dataSource?.get?.("order")?.data?.options||[]).filter(u=>u.option_status!=="cancelled").map(u=>u.id),l=i.length>0&&i.every(u=>a.includes(u));e.state.setLocal({selectedItemIds:a,selectAllItems:l})}function hn(r,c){const e=window.G7Core;if(!e?.state)return;const t=e.state.getLocal(),a=(e.dataSource?.get?.("order")?.data?.options||[]).filter(s=>s.option_status!=="cancelled").map(s=>s.id),n=!t.selectAllItems;e.state.setLocal({selectedItemIds:n?a:[],selectAllItems:n})}function Cn(r,c){const e=window.G7Core;if(!e?.state)return;const t=e.dataSource?.get?.("order")?.data;if(!t)return;const a=e.state.getLocal().selectedItemIds||[];if(a.length===0){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.no_selected_items")??"취소할 상품을 선택해주세요.");return}const s=(t.options||[]).filter(l=>a.includes(l.id)&&l.option_status!=="cancelled").map(l=>({id:l.id,product_name:l.product_name??"",product_option_name:l.product_option_name??"",thumbnail_url:l.product_snapshot?.thumbnail_url||"",unit_price:l.unit_price??0,quantity:l.quantity??1,cancel_quantity:l.quantity??1,option_status:l.option_status??"",selected:!0}));if(s.length===0){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.no_cancellable_items")??"취소 가능한 상품이 없습니다.");return}e.state.setLocal({cancelItems:s,cancelSelectAll:!0,cancelReason:"",refundEstimate:null,refundLoading:!1,isCancelling:!1,cancelError:null}),e.modal?.open?.("modal_cancel_order");const i=r.params?.orderId||t.id;(i||t.order_number)&&le(e,i)}function bn(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,orderId:o}=r.params||{};if(!t)return;const n=(e.state.getLocal().cancelItems||[]).map(i=>i.id===t?{...i,selected:!i.selected}:i),s=n.every(i=>i.selected);e.state.setLocal({cancelItems:n,cancelSelectAll:s}),R&&clearTimeout(R),R=setTimeout(()=>{le(e,o)},500)}function In(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{},o=e.state.getLocal(),a=!o.cancelSelectAll,n=(o.cancelItems||[]).map(s=>({...s,selected:a}));e.state.setLocal({cancelItems:n,cancelSelectAll:a}),R&&clearTimeout(R),R=setTimeout(()=>{le(e,t)},500)}function Sn(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,maxQuantity:o,orderId:a}=r.params||{};if(!t||!o||!a&&!ve(e))return;const n=r.params?.value??r.$event?.target?.value,s=parseInt(String(n),10),i=Math.max(1,Math.min(isNaN(s)?o:s,o)),u=(e.state.getLocal().cancelItems||[]).map(m=>m.id===t?{...m,cancel_quantity:i}:m);e.state.setLocal({cancelItems:u});const d=window.__g7ActionContext;R&&clearTimeout(R),R=setTimeout(()=>{const m=window.__g7ActionContext;window.__g7ActionContext=d;try{le(e,a)}finally{window.__g7ActionContext=m}},500)}async function vn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};!t&&!ve(e)||await le(e,t)}async function le(r,c){const e=r.state.getLocal(),t=e.cancelItems||[],o=e.refundPriority||"pg_first",a=t.filter(s=>s.selected);if(a.length===0){r.state.setLocal({refundEstimate:null,refundLoading:!1});return}const n=a.map(s=>({order_option_id:s.id,cancel_quantity:s.cancel_quantity}));r.state.setLocal({refundLoading:!0});try{const s=r.state.get?.("_global")?.guestOrderToken,i=r.dataSource?.get?.("order")?.data?.order_number,l=!!(s&&i),u=l?`/api/modules/sirsoft-ecommerce/guest/orders/${i}/estimate-refund`:`/api/modules/sirsoft-ecommerce/user/orders/${c}/estimate-refund`,d=l?{headers:{"X-Guest-Order-Token":s}}:void 0,m=await r.api.post(u,{items:n,refund_priority:o},d);if(m?.success)r.state.setLocal({refundEstimate:m.data,refundLoading:!1,cancelError:null});else throw new Error(m?.message||"Estimate failed")}catch(s){ut.error("[estimateUserRefund] 실패:",s),r.state.setLocal({refundLoading:!1,refundEstimate:null}),r.toast?.error?.(r.t?.("mypage.order_detail.cancel_modal.estimate_failed")??"환불 예상금액 계산에 실패했습니다.")}}function Ln(r,c){const e=window.G7Core;if(!e?.state)return;const{priority:t,orderId:o}=r.params||{};if(!t||!o&&!ve(e))return;e.state.setLocal({refundPriority:t});const a=window.__g7ActionContext;R&&clearTimeout(R),R=setTimeout(()=>{const n=window.__g7ActionContext;window.__g7ActionContext=a;try{le(e,o)}finally{window.__g7ActionContext=n}},300)}async function xn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};if(!t&&!ve(e))return;const o=e.state.getLocal(),n=(o.cancelItems||[]).filter(d=>d.selected),s=r.params?.cancelReason||o.cancelReason||"",i=r.params?.cancelReasonDetail??o.cancelReasonDetail??"",l=r.params?.refundPriority||o.refundPriority||"pg_first";if(n.length===0){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.no_selected_items")??"취소할 상품을 선택해주세요.");return}if(!s){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.reason_required")??"취소 사유를 선택해주세요.");return}const u={reason:s,refund_priority:l,items:n.map(d=>({order_option_id:d.id,cancel_quantity:d.cancel_quantity}))};i&&(u.reason_detail=i),e.state.setLocal({isCancelling:!0,cancelError:null,cancelValidationErrors:null});try{const d=e.state.get?.("_global")?.guestOrderToken,m=e.dataSource?.get?.("order")?.data?.order_number,f=!!(d&&m),g=f?`/api/modules/sirsoft-ecommerce/guest/orders/${m}/cancel`:`/api/modules/sirsoft-ecommerce/user/orders/${t}/cancel`,w=f?{headers:{"X-Guest-Order-Token":d}}:void 0,y=await e.api.post(g,u,w);if(y?.success)e.state.setLocal({isCancelling:!1}),e.toast?.success?.(e.t?.("mypage.order_detail.cancel_success")??"주문이 취소되었습니다."),e.modal?.close?.("modal_cancel_order"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}});else throw new Error(y?.message||"Cancel failed")}catch(d){ut.error("[executeUserCancelOrder] 실패:",d);const m=d?.response?.data||d?.data,f=m?.message||d?.message||"",g=m?.errors??null,w=e.t?.("mypage.order_detail.cancel_modal.cancel_failed")??"주문 취소에 실패했습니다.";e.state.setLocal({isCancelling:!1,cancelError:f||w,cancelValidationErrors:g}),e.toast?.error?.(f||w)}}const mt=window.G7Core?.createLogger?.("Ecom:ConfirmOrder")??{log:(...r)=>console.log("[Ecom:ConfirmOrder]",...r),warn:(...r)=>console.warn("[Ecom:ConfirmOrder]",...r),error:(...r)=>console.error("[Ecom:ConfirmOrder]",...r)};function Pn(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}async function An(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,optionId:o}=r.params||{};if(!o||!t&&!Pn(e)){mt.error("[confirmOrderOption] optionId 또는 식별자 누락");return}e.state.setLocal({isConfirming:!0});try{const a=e.state.get?.("_global")?.guestOrderToken,n=e.dataSource?.get?.("order")?.data?.order_number,s=!!(a&&n),i=s?`/api/modules/sirsoft-ecommerce/guest/orders/${n}/options/${o}/confirm`:`/api/modules/sirsoft-ecommerce/user/orders/${t}/options/${o}/confirm`,l=s?{headers:{"X-Guest-Order-Token":a}}:void 0,u=await e.api.post(i,void 0,l);if(u?.success)e.state.setLocal({isConfirming:!1,confirmTarget:null}),e.modal?.close?.("modal_confirm_purchase"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.toast?.success?.(u.message??e.t?.("sirsoft-ecommerce::messages.order.confirmed")??"구매확정이 완료되었습니다.");else throw new Error(u?.message||"Confirm failed")}catch(a){mt.error("[confirmOrderOption] 실패:",a);const n=a?.response?.data?.message||a?.data?.message||a?.message||e.t?.("sirsoft-ecommerce::messages.order.cannot_confirm")||"구매확정에 실패했습니다.";e.state.setLocal({isConfirming:!1}),e.toast?.error?.(n)}}const pt=window.G7Core?.createLogger?.("Ecom:ChangeShippingAddress")??{log:(...r)=>console.log("[Ecom:ChangeShippingAddress]",...r),warn:(...r)=>console.warn("[Ecom:ChangeShippingAddress]",...r),error:(...r)=>console.error("[Ecom:ChangeShippingAddress]",...r)};function kn(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}async function On(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{},o=kn(e);if(!t&&!o){pt.error("[changeShippingAddress] orderId 또는 비회원 식별자 누락");return}const a=e.state.get?.("_global")??{},s=(o?"manual":a.changeAddressMode??"saved")==="saved"?{address_id:a.selectedAddressId}:a.editingShippingAddress;e.state.setLocal({isSubmittingAddress:!0});try{const i=a.guestOrderToken,l=e.dataSource?.get?.("order")?.data?.order_number,u=o?`/api/modules/sirsoft-ecommerce/guest/orders/${l}/shipping-address`:`/api/modules/sirsoft-ecommerce/user/orders/${t}/shipping-address`,d=o?{headers:{"X-Guest-Order-Token":i}}:void 0,m=await e.api.put(u,s,d);if(m?.success)e.state.setLocal({isSubmittingAddress:!1}),e.modal?.close?.("changeAddressModal"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.toast?.success?.(m.message??e.t?.("mypage.order_detail.shipping_address_changed")??"배송지가 변경되었습니다.");else throw new Error(m?.message||"Change shipping address failed")}catch(i){pt.error("[changeShippingAddress] 실패:",i),e.state.setLocal({isSubmittingAddress:!1});const l=i?.response?.data?.message||i?.data?.message||i?.message||"배송지 변경에 실패했습니다.";e.toast?.error?.(l)}}const Ve=window.G7Core?.createLogger?.("Ecom:Review")??{log:(...r)=>console.log("[Ecom:Review]",...r),warn:(...r)=>console.warn("[Ecom:Review]",...r),error:(...r)=>console.error("[Ecom:Review]",...r)};async function Gn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,optionId:o,productId:a}=r.params||{};if(!t||!o||!a){Ve.error("[submitReview] orderId, optionId 또는 productId 누락");return}const n=e.state.getLocal(),s=r.params?.rating??n.reviewRating??5,i=(r.params?.content??n.reviewContent??"").trim(),u=(r.params?.images??n.reviewImages??[]).map(d=>d instanceof File?d:d?.file instanceof File||d?.file instanceof Blob||d?.file&&typeof d.file.name=="string"&&d.file.size>0?d.file:d instanceof Blob?d:null).filter(d=>d!==null);if(!s||s<1||s>5){e.toast?.error?.(e.t?.("sirsoft-ecommerce.user.review.validation.rating_required")??"별점을 선택해 주세요.");return}if(i.length<10){e.state.setLocal({reviewError:e.t?.("sirsoft-ecommerce.user.review.validation.content_min")??"리뷰 내용을 최소 10자 이상 입력해 주세요."});return}e.state.setLocal({isSubmittingReview:!0,reviewError:null});try{const d=await e.api.post("/api/modules/sirsoft-ecommerce/user/reviews",{order_option_id:o,product_id:a,rating:s,content:i});if(!d?.success)throw new Error(d?.message||"Review creation failed");const m=d.data?.id;if(m&&u.length>0)for(let f=0;fconsole.log("[Ecom:ShippingPolicyForm]",...r),warn:(...r)=>console.warn("[Ecom:ShippingPolicyForm]",...r),error:(...r)=>console.error("[Ecom:ShippingPolicyForm]",...r)},En=["fixed","conditional_free","per_quantity","per_weight","per_volume","per_volume_weight","per_amount"],Fn=["conditional_free"],Dn=["range_amount","range_quantity","range_weight","range_volume","range_volume_weight"],$n=["api"],Nn=["per_quantity","per_weight","per_volume","per_volume_weight","per_amount"],Tn={shipping_method:"parcel",custom_shipping_name:null,carrier:null,currency_code:"KRW",charge_policy:"fixed",base_fee:0,free_threshold:null,ranges:null,api_endpoint:null,api_request_fields:null,api_response_fee_field:null,api_config:null,extra_fee_enabled:!1,extra_fee_settings:[],extra_fee_multiply:!1,is_active:!0};function te(r){return{showBaseFee:En.includes(r),showFreeThreshold:Fn.includes(r),showRanges:Dn.includes(r),showApiSettings:$n.includes(r),showUnitValue:Nn.includes(r)}}function G(){const r=window.G7Core;return r?.state?r:(b.warn("G7Core.state not available"),null)}function E(r,c){r.setLocalState?r.setLocalState(c):G()?.state?.setLocal(c)}function F(r){return[...(r.state.getLocal?.()??{}).form?.country_settings??[]]}function Rn(r,c){const e=r.params||{},t=e.policy,o=!!e.isEdit,a=!!e.isCopy;if(e.availableCountries,!G())return;const s={activeCountryTab:0,rangeErrors:{}};if((o||a)&&t){const i=t.country_settings??[],u=i[0]?.charge_policy??"fixed",d=te(u);Object.assign(s,d),b.log("[initShippingPolicyForm]",o?"Edit":"Copy","mode, countries:",i.length,"first charge_policy:",u)}else{const i=te("fixed");Object.assign(s,i),b.log("[initShippingPolicyForm] Create mode, default flags")}E(c,s)}function Mn(r,c){const t=(r.params||{}).country_code??"";if(!t){b.warn("[addCountrySetting] country_code is empty");return}const o=G();if(!o)return;const a=F(o);if(a.some(u=>u.country_code===t)){b.warn("[addCountrySetting] Duplicate country_code:",t);return}const n={country_code:t,...Tn};a.push(n);const s=a.length-1,i=te(n.charge_policy),l={"form.country_settings":a,activeCountryTab:s,...i};E(c,l),b.log("[addCountrySetting] Added country:",t,"index:",s)}function Hn(r,c){const e=r.params||{},t=Number(e.index);if(isNaN(t)||t<0){b.warn("[removeCountrySetting] Invalid index:",e.index);return}const o=G();if(!o)return;const a=F(o),n=o.state.getLocal?.()??{};if(t>=a.length){b.warn("[removeCountrySetting] Index out of bounds:",t);return}const s=a[t].country_code;a.splice(t,1);const i=n.activeCountryTab??0;let l=i;a.length===0?l=0:i>=a.length?l=a.length-1:i>t&&(l=i-1);const u={...n.rangeErrors??{}};u[s]=[];const d=a[l],m=te(d?d.charge_policy:"fixed"),f={"form.country_settings":a,activeCountryTab:l,rangeErrors:u,...m};E(c,f),b.log("[removeCountrySetting] Removed index:",t,"country:",s,"newTab:",l)}function qn(r,c){const e=r.params||{},t=Number(e.index);if(isNaN(t)||t<0){b.warn("[switchCountryTab] Invalid index:",e.index);return}const o=G();if(!o)return;const a=F(o);if(t>=a.length){b.warn("[switchCountryTab] Index out of bounds:",t);return}const n=a[t],s=te(n.charge_policy),i={activeCountryTab:t,...s};E(c,i),b.log("[switchCountryTab] Switched to tab:",t,"country:",n.country_code,"charge_policy:",n.charge_policy)}function Un(r,c){const e=r.params||{},t=e.field??"",o=e.value;if(!t){b.warn("[updateCountryField] field is empty");return}const a=G();if(!a)return;const n=a.state.getLocal?.()??{},s=Number(n.activeCountryTab??0),i=F(a);if(s>=i.length){b.warn("[updateCountryField] Index out of bounds:",s);return}i[s]={...i[s],[t]:o},E(c,{"form.country_settings":i}),b.log("[updateCountryField] Updated",t,"=",o,"at index:",s)}function Vn(r,c){const e=r.params||{},t=e.value??"",o=Number(e.index??0);if(!t){b.warn("[onChargePolicyChange] charge_policy value is empty");return}const a=G();if(!a)return;const n=te(t);b.log("[onChargePolicyChange]",t,n,"countryIndex:",o);const s=F(a),i=s[o];if(!i){b.warn("[onChargePolicyChange] No country setting at index:",o);return}const l={...i,charge_policy:t};if(n.showBaseFee||(l.base_fee=0),n.showFreeThreshold||(l.free_threshold=null),!n.showRanges&&!n.showUnitValue&&(l.ranges=null),n.showApiSettings||(l.api_endpoint=null,l.api_request_fields=null,l.api_response_fee_field=null,l.api_config=null),n.showRanges&&(!l.ranges||!l.ranges.tiers)){const d=t.replace("range_","");l.ranges={type:d,tiers:[{min:0,max:null,fee:0}]}}n.showUnitValue&&(!l.ranges||!l.ranges.unit_value)&&(l.ranges={type:t,unit_value:1}),s[o]=l;const u={"form.country_settings":s,...n};E(c,u)}function jn(r,c){const e=r.params||{},t=Number(e.index??0),o=G();if(!o)return;const a=F(o),n=a[t];if(!n){b.warn("[addRangeTier] No country setting at index:",t);return}const s=[...n.ranges?.tiers??[]],i=s[s.length-1],u={min:i?.max!=null?i.max+1:0,max:null,fee:0};s.push(u),a[t]={...n,ranges:{...n.ranges??{},tiers:s}},E(c,{"form.country_settings":a}),Le(o,c,t,s,n.country_code),b.log("[addRangeTier] Added tier, total:",s.length)}function Bn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=Number(e.tierIndex);if(isNaN(o)||o<0){b.warn("[removeRangeTier] Invalid tierIndex:",e.tierIndex);return}const a=G();if(!a)return;const n=F(a),s=n[t];if(!s){b.warn("[removeRangeTier] No country setting at index:",t);return}const i=[...s.ranges?.tiers??[]];if(o>=i.length){b.warn("[removeRangeTier] tierIndex out of bounds:",o);return}i.splice(o,1),n[t]={...s,ranges:{...s.ranges??{},tiers:i}},E(c,{"form.country_settings":n}),Le(a,c,t,i,s.country_code),b.log("[removeRangeTier] Removed tier:",o,"remaining:",i.length)}function Kn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=Number(e.tierIndex),a=e.field,n=e.value;if(isNaN(o)||o<0){b.warn("[updateRangeTierField] Invalid tierIndex:",e.tierIndex);return}if(!["min","max","fee"].includes(a)){b.warn("[updateRangeTierField] Invalid field:",a);return}const s=G();if(!s)return;const i=F(s),l=i[t];if(!l){b.warn("[updateRangeTierField] No country setting at index:",t);return}const u=[...l.ranges?.tiers??[]];if(o>=u.length){b.warn("[updateRangeTierField] tierIndex out of bounds:",o);return}let d;a==="max"?d=n===""||n===null||n===void 0?null:Number(n):d=Number(n),u[o]={...u[o],[a]:d},i[t]={...l,ranges:{...l.ranges??{},tiers:u}},E(c,{"form.country_settings":i}),Le(s,c,t,u,l.country_code)}function Qn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=G();if(!o)return;const n=F(o)[t];if(!n)return;const s=n.ranges?.tiers??[];Le(o,c,t,s,n.country_code)}function Le(r,c,e,t,o){const n={...(r.state.getLocal?.()??{}).rangeErrors??{}};if(t.length===0){n[o]=[],E(c,{rangeErrors:n});return}const s=new Array(t.length).fill(null).map(()=>({}));let i=!1;const l=r.t;for(let u=0;u=d.max&&(s[u].min=l?.("sirsoft-ecommerce.validation.shipping_policy.ranges.min_less_than_max")??"시작값이 종료값보다 작아야 합니다.",i=!0),u=i.length){b.warn("[removeExtraFeeRow] feeIndex out of bounds:",o);return}i.splice(o,1),n[t]={...s,extra_fee_settings:i},E(c,{"form.country_settings":n}),b.log("[removeExtraFeeRow] Removed index:",o,"remaining:",i.length)}function Yn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=e.settings;if(!o||!Array.isArray(o)){b.warn("[applyExtraFeeTemplate] Invalid settings:",o);return}const a=G();if(!a)return;const n=F(a),s=n[t];if(!s){b.warn("[applyExtraFeeTemplate] No country setting at index:",t);return}n[t]={...s,extra_fee_settings:o},E(c,{"form.country_settings":n});const i=a.t,l=i?.("sirsoft-ecommerce.admin.shipping_policy.form.template_applied")??"템플릿이 적용되었습니다.";a.toast?.success?.(l),b.log("[applyExtraFeeTemplate] Applied template with",o.length,"rows")}function Jn(r,c){const e=r.params||{},t=parseFloat(e.value)||1,o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];i&&(s[n]={...i,ranges:{...i.ranges??{},type:i.charge_policy,unit_value:t}},E(c,{"form.country_settings":s}),b.log("[updateUnitValue] Updated unit_value:",t))}function Xn(r,c){const e=G();if(!e)return;const t=e.state.getLocal?.()??{},o=Number(t.activeCountryTab??0),a=F(e),n=a[o];if(!n)return;const s=[...n.api_request_fields??[]];s.push(""),a[o]={...n,api_request_fields:s},E(c,{"form.country_settings":a}),b.log("[addApiRequestField] Added field, total:",s.length)}function Zn(r,c){const e=r.params||{},t=Number(e.fieldIndex),o=e.value??"";if(isNaN(t)||t<0){b.warn("[updateApiRequestField] Invalid fieldIndex:",e.fieldIndex);return}const a=G();if(!a)return;const n=a.state.getLocal?.()??{},s=Number(n.activeCountryTab??0),i=F(a),l=i[s];if(!l)return;const u=[...l.api_request_fields??[]];t>=u.length||(u[t]=o,i[s]={...l,api_request_fields:u},E(c,{"form.country_settings":i}),b.log("[updateApiRequestField] Updated index:",t,"value:",o))}function ea(r,c){const e=r.params||{},t=Number(e.fieldIndex);if(isNaN(t)||t<0){b.warn("[removeApiRequestField] Invalid fieldIndex:",e.fieldIndex);return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];if(!i)return;const l=[...i.api_request_fields??[]];t>=l.length||(l.splice(t,1),s[n]={...i,api_request_fields:l},E(c,{"form.country_settings":s}),b.log("[removeApiRequestField] Removed index:",t,"remaining:",l.length))}function ta(r,c){const t=(r.params||{}).field??"";if(!t){b.warn("[toggleApiRequestField] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];if(!i)return;const l=[...i.api_request_fields??[]],u=l.indexOf(t);u>=0?l.splice(u,1):l.push(t);const d=l.length>0?l:null;s[n]={...i,api_request_fields:d},E(c,{"form.country_settings":s}),b.log("[toggleApiRequestField] Toggled:",t,"selected:",d)}function oa(r,c){const e=r.params||{},t=e.field??"";if(!t){b.warn("[updateApiConfigField] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];if(!i)return;const l={...i.api_config??{},[t]:e.value};s[n]={...i,api_config:l},E(c,{"form.country_settings":s}),b.log("[updateApiConfigField] Updated",t,"at index:",n)}function ra(r,c){const e=r.params||{},t=e.field??"";if(!t){b.warn("[updateApiFieldMap] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];if(!i)return;const l=e.value??"",u={...i.api_config?.field_map??{}};l.trim()===""?delete u[t]:u[t]=l;const d={...i.api_config??{},field_map:Object.keys(u).length>0?u:null};s[n]={...i,api_config:d},E(c,{"form.country_settings":s}),b.log("[updateApiFieldMap] Mapped",t,"→",l||"(removed)")}function na(r,c){const e=G();if(!e)return;const t=e.state.getLocal?.()??{},o=Number(t.activeCountryTab??0),n=F(e)[o];if(n){if(!n.api_endpoint){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.shipping_policy.form.api_test_endpoint_required")??"Endpoint required");return}e.state.set?.({apiTestLoading:!0,apiTestResult:null}),e.dispatch({handler:"apiCall",target:"/api/modules/sirsoft-ecommerce/admin/shipping-policies/test-api-call",auth_required:!0,params:{method:"POST",body:{endpoint:n.api_endpoint,request_fields:n.api_request_fields,config:n.api_config??{},sample:{country_code:n.country_code}}},onSuccess:{handler:"setState",params:{target:"global",apiTestLoading:!1,apiTestResult:"{{response.data}}"}},onError:{handler:"setState",params:{target:"global",apiTestLoading:!1,apiTestResult:"{{error.errors ?? error}}"}}})}}function aa(r,c){const e=r.params||{},t=Number(e.feeIndex),o=e.field,a=e.value;if(isNaN(t)||t<0){b.warn("[updateExtraFeeField] Invalid feeIndex:",e.feeIndex);return}if(!["zipcode","fee","region"].includes(o)){b.warn("[updateExtraFeeField] Invalid field:",o);return}const n=G();if(!n)return;const s=n.state.getLocal?.()??{},i=Number(s.activeCountryTab??0),l=F(n),u=l[i];if(!u)return;const d=[...u.extra_fee_settings??[]];if(t>=d.length)return;const m=o==="fee"?parseFloat(a)||0:a;d[t]={...d[t],[o]:m},l[i]={...u,extra_fee_settings:d},E(c,{"form.country_settings":l}),b.log("[updateExtraFeeField] Updated index:",t,o,"=",m)}const me=window.G7Core?.createLogger?.("Handler:InitCategory")??{log:(...r)=>console.log("[Handler:InitCategory]",...r),warn:(...r)=>console.warn("[Handler:InitCategory]",...r),error:(...r)=>console.error("[Handler:InitCategory]",...r)};function ft(r,c){for(const e of r){if(e.id===c)return e;if(e.children&&e.children.length>0){const t=ft(e.children,c);if(t)return t}}return null}function gt(r){return new URLSearchParams(window.location.search).get(r)}function sa(r){return window.G7Core?.state?.getDataSource?.(r)}async function ia(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function ca(r,c){const e=window.G7Core,t=gt("id"),o=gt("mode");if(!t){me.log("[initCategoryFromUrl] No id parameter in URL");return}if(!e?.state?.set){me.warn("[initCategoryFromUrl] G7Core.state.set not available");return}const a=await ia("categories",l=>l?.data?.data);if(!a){me.warn("[initCategoryFromUrl] categories data not available after waiting");return}const n=parseInt(t,10),s=ft(a,n);if(!s){me.warn("[initCategoryFromUrl] Category not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedCategoryId:s.id,selectedCategory:s,panelMode:i}),me.log("[initCategoryFromUrl] Category initialized from URL:",{id:n,mode:i})}const pe=window.G7Core?.createLogger?.("Handler:InitBrand")??{log:(...r)=>console.log("[Handler:InitBrand]",...r),warn:(...r)=>console.warn("[Handler:InitBrand]",...r),error:(...r)=>console.error("[Handler:InitBrand]",...r)};function _t(r){return new URLSearchParams(window.location.search).get(r)}function la(r){return window.G7Core?.state?.getDataSource?.(r)}async function da(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function ua(r,c){const e=window.G7Core,t=_t("id"),o=_t("mode");if(!t){pe.log("[initBrandFromUrl] No id parameter in URL");return}if(!e?.state?.set){pe.warn("[initBrandFromUrl] G7Core.state.set not available");return}const a=await da("brands",l=>l?.data?.data);if(!a){pe.warn("[initBrandFromUrl] brands data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){pe.warn("[initBrandFromUrl] Brand not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedBrandId:s.id,selectedBrand:s,panelMode:i}),pe.log("[initBrandFromUrl] Brand initialized from URL:",{id:n,mode:i})}const fe=window.G7Core?.createLogger?.("Handler:InitCommonInfo")??{log:(...r)=>console.log("[Handler:InitCommonInfo]",...r),warn:(...r)=>console.warn("[Handler:InitCommonInfo]",...r),error:(...r)=>console.error("[Handler:InitCommonInfo]",...r)};function wt(r){return new URLSearchParams(window.location.search).get(r)}function ma(r){return window.G7Core?.state?.getDataSource?.(r)}async function pa(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function fa(r,c){const e=window.G7Core,t=wt("id"),o=wt("mode");if(!t){fe.log("[initCommonInfoFromUrl] No id parameter in URL");return}if(!e?.state?.set){fe.warn("[initCommonInfoFromUrl] G7Core.state.set not available");return}const a=await pa("commonInfos",l=>l?.data);if(!a){fe.warn("[initCommonInfoFromUrl] commonInfos data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){fe.warn("[initCommonInfoFromUrl] Common info not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedId:s.id,selectedItem:s,panelMode:i}),fe.log("[initCommonInfoFromUrl] Common info initialized from URL:",{id:n,mode:i})}const ge=window.G7Core?.createLogger?.("Handler:InitNotice")??{log:(...r)=>console.log("[Handler:InitNotice]",...r),warn:(...r)=>console.warn("[Handler:InitNotice]",...r),error:(...r)=>console.error("[Handler:InitNotice]",...r)};function yt(r){return new URLSearchParams(window.location.search).get(r)}function ga(r){return window.G7Core?.state?.getDataSource?.(r)}async function _a(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function wa(r,c){const e=window.G7Core,t=yt("id"),o=yt("mode");if(!t){ge.log("[initNoticeFromUrl] No id parameter in URL");return}if(!e?.state?.set){ge.warn("[initNoticeFromUrl] G7Core.state.set not available");return}const a=await _a("templates",l=>l?.data);if(!a){ge.warn("[initNoticeFromUrl] templates data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){ge.warn("[initNoticeFromUrl] Template not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedTemplateId:s.id,selectedTemplate:s,panelMode:i}),ge.log("[initNoticeFromUrl] Template initialized from URL:",{id:n,mode:i})}const oe={updateProductField:At,updateOptionField:kt,calculateCurrencyPrices:se,initPreferredCurrency:Tt,initPreferredShippingCountry:qt,setDateRange:Vt,setDefaultOption:jt,toggleOption:Bt,toggleProductOptions:Ye,toggleAllOptionsInRow:Kt,getProductOptionStates:Qt,syncProductSelection:zt,loadExpandedOptions:Fe,retryExpandedOptions:to,generateCopyProductCode:Wt,copyProduct:Yt,selectCategory:ro,selectCategoryMobile:no,addCategoryToSelection:ao,removeCategoryFromSelection:so,getCategoryBreadcrumb:io,validateCategoryPath:lo,initCategoryInfosFromProduct:co,getBrandName:uo,getBrandDescription:mo,updatePrice:_o,calculateTotalOptionStock:wo,validatePriceRelation:yo,addOptionInput:Go,removeOptionInput:Eo,updateOptionInput:Fo,generateOptions:Do,deleteOption:$o,applyOptionAddTool:No,addRequiredItem:To,updateRequiredItem:Ro,removeRequiredItem:Mo,reorderRequiredItems:Ho,addAdditionalOption:qo,updateAdditionalOption:Uo,removeAdditionalOption:Vo,reorderAdditionalOptions:jo,clearAdditionalOptions:Bo,addAdditionalOptionValue:Ko,updateAdditionalOptionValue:Qo,removeAdditionalOptionValue:zo,uploadImages:Xo,setThumbnail:Zo,reorderImages:er,updateDescription:tr,confirmSelectNoticeTemplate:or,selectNoticeTemplate:nt,updateNoticeItem:rr,removeNoticeItem:nr,reorderNoticeItems:ar,fillNoticeWithValue:sr,switchNoticeMode:ir,updateNewTemplateName:cr,addNoticeItem:lr,updateNoticeItemName:dr,saveAsNoticeTemplate:ur,confirmSaveNoticeTemplate:fr,fillTemplateFieldsWithDetailReference:mr,fillNoticeItemsWithDetailReference:pr,toggleLabel:gr,generateProductCode:_r,getShippingPolicyInfo:wr,getCommonInfoContent:yr,updateShoppingIntegration:hr,updateShippingType:Cr,updateIdentificationCode:br,openLabelPeriodModal:Ir,saveLabelPeriod:Sr,removeLabelPeriod:vr,updateActivityLogSort:Lr,updateActivityLogPerPage:xr,setDefaultShippingPolicy:Pr,setLabelDatePreset:kr,toggleDefaultShippingPolicy:Ar,toggleLabelAssignment:Or,saveLabelSettings:$r,deleteLabel:Dr,updateLabelPeriodInline:Gr,setLabelDatePresetInline:Er,confirmUncheckLabel:Fr,removeDescriptionLocale:Nr,showAddLocaleModal:Tr,addDescriptionLocale:Rr,setDefaultOptionFromGrid:Io,addOptionRow:vo,updateFormOptionField:So,recalculateOptionPriceAdjustments:Lo,bulkUpdate:Mr,buildConfirmData:Ur,buildOrderColumns:Vr,toggleArrayValue:jr,toggleVisibleFilter:Br,syncOrderSelection:Kr,handleOrderRowAction:Qr,processOrderBulkAction:zr,buildOrderBulkConfirmData:Zr,executeOrderBulkAction:en,downloadOrderExcel:Wr,saveVisibleColumns:Yr,loadVisibleColumns:Jr,loadVisibleFilters:Xr,handleProductRowAction:tn,initOrderDetailForm:on,toggleProductSelection:rn,toggleAllProducts:nn,buildOrderDetailBulkConfirmData:an,processOrderDetailBulkChange:sn,saveAdminMemo:cn,updateChangeQuantity:ln,openConfirmDepositModal:dn,confirmDeposit:un,initShippingPolicyForm:Rn,addCountrySetting:Mn,removeCountrySetting:Hn,switchCountryTab:qn,updateCountryField:Un,onChargePolicyChange:Vn,addRangeTier:jn,removeRangeTier:Bn,updateRangeTierField:Kn,validateRangeTiers:Qn,addExtraFeeRow:zn,removeExtraFeeRow:Wn,applyExtraFeeTemplate:Yn,updateUnitValue:Jn,addApiRequestField:Xn,updateApiRequestField:Zn,removeApiRequestField:ea,toggleApiRequestField:ta,updateApiConfigField:oa,updateApiFieldMap:ra,testShippingApi:na,updateExtraFeeField:aa,updateCancelQuantity:pn,estimateRefundAmount:fn,changeRefundPriority:gn,executeCancelOrder:_n,clearCancelOrderTimers:mn,toggleItemSelection:yn,toggleSelectAllItems:hn,initUserCancelItems:Cn,toggleUserCancelItem:bn,toggleUserCancelSelectAll:In,updateUserCancelQuantity:Sn,estimateUserRefund:vn,changeUserRefundPriority:Ln,executeUserCancelOrder:xn,clearUserCancelOrderTimers:wn,confirmOrderOption:An,changeShippingAddress:On,submitReview:Gn,initCategoryFromUrl:ca,initBrandFromUrl:ua,initCommonInfoFromUrl:fa,initNoticeFromUrl:wa},V="sirsoft-ecommerce",_e=window.G7Core?.createLogger?.(`Module:${V}`)??{log:(...r)=>console.log(`[Module:${V}]`,...r),warn:(...r)=>console.warn(`[Module:${V}]`,...r),error:(...r)=>console.error(`[Module:${V}]`,...r)};function ht(r=!1){const c=window.G7Core?.getActionDispatcher?.();if(c)Object.entries(oe).forEach(([e,t])=>{const o=`${V}.${e}`;c.registerHandler(o,t,{category:"module",source:V})}),_e.log(`${Object.keys(oe).length} handler(s) registered:`,Object.keys(oe).map(e=>`${V}.${e}`));else if(r){let e=0;const t=50,o=()=>{const a=window.G7Core?.getActionDispatcher?.();a?(Object.entries(oe).forEach(([n,s])=>{const i=`${V}.${n}`;a.registerHandler(i,s,{category:"module",source:V})}),_e.log(`${Object.keys(oe).length} handler(s) registered:`,Object.keys(oe).map(n=>`${V}.${n}`))):(e++,e<=t?(_e.warn(`ActionDispatcher not found, retrying... (${e}/${t})`),setTimeout(o,100)):_e.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};o()}else _e.warn("ActionDispatcher not found, handlers not registered")}function je(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>ht(!0));else{const r=!!window.G7Core?.getActionDispatcher?.();ht(!r)}}return je(),typeof window<"u"&&(window.__SirsoftEcommerce={identifier:V,handlers:Object.keys(oe),initModule:je}),Pe.initModule=je,Object.defineProperty(Pe,Symbol.toStringTag,{value:"Module"}),Pe}({}); //# sourceMappingURL=module.iife.js.map diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md index e9646d16..b82ff0f9 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md @@ -42,7 +42,19 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 예시** - + + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "Checkout has been deleted.", + "data": null +} +``` **에러 응답** @@ -208,7 +220,7 @@ Content-Type: application/json **응답 예시** - + **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md index 2ba6c784..65124ade 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md @@ -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!" } diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md index cc91cde7..83dd37b3 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md @@ -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…` | | @@ -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…` | 공개 가능한 결제 설정 (활성 결제수단·무통장 은행명 매핑 포함, 민감 정보 제외) | **응답 예시** diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/checkout_cash_receipt.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/checkout_cash_receipt.json new file mode 100644 index 00000000..6e5fc09c --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/checkout_cash_receipt.json @@ -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" + } + ] + } + ] + } + ] +} diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/mypage_order_cash_receipt.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/mypage_order_cash_receipt.json new file mode 100644 index 00000000..d932977b --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/mypage_order_cash_receipt.json @@ -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" + } + ] + } + ] + } + ] +} diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/cancelOrderHandlers.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/cancelOrderHandlers.test.ts index 526e888a..62431478 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/cancelOrderHandlers.test.ts +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/cancelOrderHandlers.test.ts @@ -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: '', + }); + }); + }); }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx index 93bc1346..102ce34c 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx @@ -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', + ]); }); }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptResponsiveRender.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptResponsiveRender.test.tsx new file mode 100644 index 00000000..36e9d84a --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptResponsiveRender.test.tsx @@ -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> = ({ children, className, id, text }) => ( +
+ {children ?? text} +
+); + +/** + * 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(); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptUi.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptUi.test.tsx new file mode 100644 index 00000000..bd2f5bbe --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptUi.test.tsx @@ -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) => 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 = {}) => ({ + 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/); + } + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/checkoutPaymentMethodRegression.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/checkoutPaymentMethodRegression.test.tsx index 7d856304..c24f4ae1 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/checkoutPaymentMethodRegression.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/checkoutPaymentMethodRegression.test.tsx @@ -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) => + 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(); }); }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypageOrders.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypageOrders.test.tsx index e9f761ee..f55c00ec 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypageOrders.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypageOrders.test.tsx @@ -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를 사용해야 함', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/orderDetailLayouts.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/orderDetailLayouts.test.tsx index db90bf55..a1b783a4 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/orderDetailLayouts.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/orderDetailLayouts.test.tsx @@ -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', + ]) + ); }); }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/handlers/cancelOrderHandlers.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/handlers/cancelOrderHandlers.ts index 3fa36db2..388a68f1 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/handlers/cancelOrderHandlers.ts +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/handlers/cancelOrderHandlers.ts @@ -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 { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/en.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/en.json index 3d3837c2..7f6acbc2 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/en.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/en.json @@ -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." + } } } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/ko.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/ko.json index 03d7462d..c649e6dc 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/ko.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/ko.json @@ -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": "발급 후에는 용도를 변경할 수 없습니다." + } } } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/order.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/order.json index 62226976..e590ee04 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/order.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/order.json @@ -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": { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json index a4213fd2..d7d44b99 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json @@ -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": { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/order.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/order.json index 0d894e42..611544d5 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/order.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/order.json @@ -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." } } \ No newline at end of file diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/order.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/order.json index c8c92434..d6507aed 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/order.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/order.json @@ -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": { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json index 2d79ba73..e792c594 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json @@ -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": { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/order.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/order.json index 04bbd8bb..76317abf 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/order.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/order.json @@ -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": "환불에 따른 현금영수증 재발급이 실패했습니다. 고객센터로 문의해 주세요." } } \ No newline at end of file diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_order_detail.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_order_detail.json index 502515b3..ecdaf172 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_order_detail.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_order_detail.json @@ -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" }, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json index dab3bbe9..477a1d00 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json @@ -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", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_issue_cash_receipt.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_issue_cash_receipt.json new file mode 100644 index 00000000..e517e2c2 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_issue_cash_receipt.json @@ -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')}}" + } + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_payment_info.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_payment_info.json index adfee8b3..f676cfdf 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_payment_info.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_partial_payment_info.json @@ -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 ?? ''}}" + } + ] + } + ] + } + ] + } + ] } ] } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json index 6ddfa2a0..93fac16e 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json @@ -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", diff --git a/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptIdentifierType.php b/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptIdentifierType.php index e13646a0..fd9fdaeb 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptIdentifierType.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Enums/CashReceiptIdentifierType.php @@ -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); + } + /** * 유효한 값인지 확인합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/EcommerceSettingsController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/EcommerceSettingsController.php index 918686bb..c3ca608d 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/EcommerceSettingsController.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/EcommerceSettingsController.php @@ -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', diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/OrderController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/OrderController.php index 93e3823e..d57f8371 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/OrderController.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/OrderController.php @@ -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(), ); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php index b7c0fd9c..e1217fb5 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php @@ -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']); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/CancelOrderRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/CancelOrderRequest.php index 44393649..6b8ed1ea 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/CancelOrderRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/CancelOrderRequest.php @@ -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 { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php index dac453c1..2342321c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php @@ -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'], diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CreateOrderRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CreateOrderRequest.php index 69274a16..af02c240 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CreateOrderRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CreateOrderRequest.php @@ -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; + } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/CashReceiptResource.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/CashReceiptResource.php index b5564a0d..109fea43 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/CashReceiptResource.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/CashReceiptResource.php @@ -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), ]; } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderPaymentResource.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderPaymentResource.php index af0c3ca4..7e241259 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderPaymentResource.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderPaymentResource.php @@ -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), diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php index 5b315841..a70f771c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php @@ -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; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php index b9a3a139..4e307915 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php @@ -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; + } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php index 9abf1c48..82bac990 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderRepository.php @@ -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(); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CashReceiptService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CashReceiptService.php index c20f3470..e9b5e0c4 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/CashReceiptService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CashReceiptService.php @@ -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); } /** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php index e2af87b4..56330611 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php @@ -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; + } + /** * 특정 결제수단의 설정을 조회합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php index 65f47204..c97a56e9 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php @@ -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 한다. diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php index ad54ba64..8c068bf1 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php @@ -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); + } + /** * 결제명(상품명 요약)을 생성합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/cash_receipt.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/cash_receipt.php index 0d180509..d7faf3a1 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/cash_receipt.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/cash_receipt.php @@ -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', + ], + /* * 배송비 과세 정책 */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php index d0442268..85c581b2 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php @@ -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 diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/cash_receipt.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/cash_receipt.php index dc297505..09392b98 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/cash_receipt.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/cash_receipt.php @@ -35,6 +35,16 @@ return [ 'failed' => '발급 실패', ], + /* + * 이력 표의 결과 열 — 발급 행과 취소 행이 같은 열을 공유하므로 거래유형과 무관한 중립 표현을 쓴다. + * (취소 행에 "발급 완료" 라고 쓰면 관리자가 오해한다) + */ + 'result_status' => [ + 'in_progress' => '처리 중', + 'completed' => '완료', + 'failed' => '실패', + ], + /* * 배송비 과세 정책 */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php index 7a2a0e41..f15a1c7e 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php @@ -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' => '입금이 완료된 가상계좌 주문은 환불 계좌가 필요합니다.', ], // 비회원 주문 조회 인증 검증 메시지 diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CancelOrderRefundBankTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CancelOrderRefundBankTest.php new file mode 100644 index 00000000..8459bedb --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CancelOrderRefundBankTest.php @@ -0,0 +1,273 @@ +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 $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 $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']); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsCashReceiptTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsCashReceiptTest.php new file mode 100644 index 00000000..43680e96 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsCashReceiptTest.php @@ -0,0 +1,127 @@ +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']]); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CheckoutCashReceiptAndRefundBankTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CheckoutCashReceiptAndRefundBankTest.php new file mode 100644 index 00000000..3d4177a1 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CheckoutCashReceiptAndRefundBankTest.php @@ -0,0 +1,407 @@ +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 $overrides + * @return array + */ + 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 $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 + */ + 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, array}> + */ + 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 $refundBank + * @param array $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); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/checkout-cash-receipt.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/checkout-cash-receipt.spec.ts new file mode 100644 index 00000000..8120e20c --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/checkout-cash-receipt.spec.ts @@ -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 { + 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 { + 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 { + 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 { + await paymentMethod(page, 'dbank').click(); + await expect(page.locator(SLOT)).toBeVisible(); +} + +/** + * 발급수단 드롭다운을 열고 보이는 항목 라벨을 반환한다. + * + * 템플릿의 Select 는 네이티브