diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md index f8015f4b..4c241eda 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-board-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.0.3] - 2026-08-12 + +### Changed + +- 신고 반려 누적 제한 안내 문구의 일본어 번역을 실제 동작(설정 건수 도달 시 차단)에 맞게 정정했습니다. + ## [1.0.2] - 2026-08-10 ### Added diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin.json index 0610e730..d7650519 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin.json @@ -926,7 +926,7 @@ "daily_report_limit_hint": "1日(午前0時基準)全体掲示板合わせて通報できる最大回数。0の場合は制限なし。 (0~100)", "rejection_limit_count": "却下累積制限 (件数)", "rejection_limit_days": "却下累積制限期間 (日)", - "rejection_limit_hint": "この期間内に却下件数を超えると通報機能がブロックされます。件数が0の場合は制限なし。 (件数: 0~50、期間: 1~365日)", + "rejection_limit_hint": "この期間内の却下件数が設定値に達すると通報機能がブロックされます。件数が0の場合は制限なし。 (件数: 0~50、期間: 1~365日)", "report_cooldown_seconds": "連続通報クールタイム (秒)", "report_cooldown_seconds_hint": "連続通報の最小間隔。0の場合は制限なし。 (0~3600秒)", "notify_author_on_report_action": "通報処理通知", diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json index 69a00fc2..1d376654 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json @@ -103,7 +103,7 @@ "daily_report_limit_hint": "1日(午前0時基準)全掲示板で通報できる最大回数です。0の場合は制限なし。(0~100)", "rejection_limit_count": "却下累積制限 (件数)", "rejection_limit_days": "却下累積制限期間 (日)", - "rejection_limit_hint": "この期間内に却下件数を超えると通報機能がブロックされます。件数が0の場合は制限なし。(件数: 0~50、期間: 1~365日)", + "rejection_limit_hint": "この期間内の却下件数が設定値に達すると通報機能がブロックされます。件数が0の場合は制限なし。(件数: 0~50、期間: 1~365日)", "report_cooldown_seconds": "連続通報クールタイム (秒)", "report_cooldown_seconds_hint": "連続通報の最小間隔です。0の場合は制限なし。(0~3600秒)", "notify_author_on_report_action": "通報処理通知", diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json index 0b311854..ff730d88 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 module (sirsoft-board) Japanese language pack (bundled)", "ja": "G7 モジュール (sirsoft-board) 日本語 言語パック(バンドル)" }, - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "scope": "module", "target_identifier": "sirsoft-board", 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 7f766eba..a3cfeee3 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -4,6 +4,22 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.1.1] - 2026-08-12 + +### Added + +- 배송정책 구간 설정의 새 검증 안내(구간 미등록, 중간 구간 종료값 누락, 수량 구간의 정수 제한, 단위값·무료배송 기준금액 누락, 도서산간 우편번호 형식) 일본어 번역을 추가했습니다. +- 배송정책 편집 화면에서 구간 입력 오류를 안내하는 문구(중간 구간 종료값 누락, 배송비 미입력, 수량 구간 정수 제한, 단위값·무료배송 기준금액 미입력) 일본어 번역을 추가했습니다. +- 배송비 요약에 표시되는 구간 단위(개/kg/L)와 구간 입력란의 단위 라벨 일본어 번역을 추가했습니다. + +### Changed + +- 도서산간 추가배송비의 "중복 부과" 안내를 배송 단위 기준으로 정정했습니다. 이전 문구는 건수 기준으로 읽혀 실제 부과 방식과 달랐습니다. + +### Removed + +- 화면에서 더 이상 쓰이지 않는 배송비 요약 접두 단위 문구와 배송정책 통화 선택 안내 문구를 제거했습니다. + ## [1.1.0] - 2026-08-10 ### Added diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php index f6fe2cf2..5816b863 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php @@ -440,6 +440,11 @@ return [ 'per_volume' => ':unitL当たり:fee', 'per_volume_weight' => ':unitkg当たり:fee', 'per_amount' => ':unit当たり:fee', + 'range_unit' => [ + 'quantity' => '個', + 'weight' => 'kg', + 'volume' => 'L', + ], ], 'fee_details' => [ 'base_fee' => '送料', 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 f855125a..7a321ba6 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 @@ -935,6 +935,14 @@ return [ 'tier_min_non_negative' => '区間開始値は0以上である必要があります。', 'tier_max_non_negative' => '区間終了値は0以上である必要があります。', 'unit_value_min' => '区間単位値は0より大きくする必要があります。', + 'tiers_required' => '区間別送料ポリシーは区間を1つ以上登録する必要があります。', + 'middle_max_required' => '最後の区間を除く区間には終了値を入力する必要があります。', + 'tier_value_integer' => '数量区間の開始値と終了値は整数である必要があります。', + 'unit_value_required' => '単位当たり送料ポリシーは単位値を入力する必要があります。', + ], + 'free_threshold_required' => '条件付き送料無料ポリシーは送料無料基準金額を入力する必要があります。', + 'extra_fee' => [ + 'zipcode_format' => '郵便番号は「63000」「63000-63999」「63*」のいずれかの形式である必要があります。', ], 'country_settings' => [ 'country_code' => [ diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json index 77480c97..74d49a26 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json @@ -114,12 +114,6 @@ "free": "送料無料", "fixed": "配送料金: {{fee}}", "conditional": "{{threshold}}未満 {{fee}} / {{threshold}}以上 送料無料", - "range_prefix": { - "amount": "", - "quantity": "個", - "weight": "kg", - "volume": "cm³" - }, "api": "外部API連携(リアルタイム計算)", "per_quantity": "{{unit}}個当たり {{fee}}", "per_weight": "{{unit}}kg当たり {{fee}}", @@ -196,14 +190,17 @@ "carrier": "配送業者", "carrier_placeholder": "配送業者を選択してください", "currency_code": "基準通貨", - "currency_code_placeholder": "通貨を選択してください", "charge_policy": "配送料金課金ポリシー", "base_fee": "配送料金", "free_threshold": "送料無料基準金額", "free_threshold_hint": "この金額以上のご購入で配送料金が無料", "range_tiers": "区間別配送料金", "range_min": "開始値", + "range_min_auto": "自動", "range_max": "終了値", + "unit_quantity": "個", + "unit_weight": "kg", + "unit_volume": "L", "range_fee": "配送料金", "range_unlimited": "無制限", "range_add": "区間を追加", @@ -213,6 +210,11 @@ "range_error_min_max": "開始値は終了値より小さい必要があります。", "range_error_fee": "配送料は0以上である必要があります。", "range_error_min_one": "最小1つの区間が必要です。", + "range_error_middle_max": "最後の区間を除く区間には終了値を入力する必要があります。", + "range_error_fee_required": "区間の送料を入力してください。", + "range_error_integer": "数量区間の開始値と終了値は整数である必要があります。", + "range_error_unit_value_required": "単位あたり送料ポリシーは単位値を入力する必要があります。", + "range_error_free_threshold_required": "条件付き送料無料ポリシーは送料無料基準金額を入力する必要があります。", "per_unit_value": "単位基準値", "per_unit_hint": "設定した単位ごとに上記の配送料が課金されます", "api_endpoint": "APIエンドポイント", @@ -225,8 +227,8 @@ "api_response_fee_field_placeholder": "shipping_fee", "api_response_fee_field_hint": "API応答から配送料の値が含まれるフィールド名", "extra_fee_enabled": "離島追加配送料の使用", - "extra_fee_multiply": "建当たり重複課金", - "extra_fee_multiply_hint": "単位ごとの配送料適用時に離島追加配送料を建当たり重複課金します", + "extra_fee_multiply": "配送単位当たり重複課金", + "extra_fee_multiply_hint": "単位ごとの配送料適用時に、離島追加配送料を基本送料と同じ配送単位数だけ重複課金します", "extra_fee_zipcode": "郵便番号", "extra_fee_zipcode_placeholder": "63000または63000-63644", "extra_fee_amount": "追加配送料", diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json index 2aa61bdb..686c0794 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)", "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)" }, - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "scope": "module", "target_identifier": "sirsoft-ecommerce", diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 4c3a2461..39cc22c1 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -10,6 +10,10 @@ - 글 저장·수정 시 비밀글 여부 값을 문자열(`"true"`/`"false"`)로 보내는 클라이언트도 수용하도록 해석을 관대화했습니다. 해석할 수 없는 값은 종전과 동일하게 거부됩니다. +### Fixed + +- 신고 반려 누적 제한 안내 문구를 실제 동작에 맞게 정정했습니다. "설정 건수를 초과하면 차단"으로 적혀 있었지만 실제로는 설정 건수에 도달하는 순간부터 차단됩니다 — 5건으로 설정하면 5번째 반려부터 신고가 막힙니다. + ## [1.0.3] - 2026-08-10 ### Security diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin.json b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin.json index 1fa0071b..9938bb4f 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin.json +++ b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin.json @@ -926,7 +926,7 @@ "daily_report_limit_hint": "Max reports per user per day across all boards. 0 = no limit. (0–100)", "rejection_limit_count": "Rejection Limit (count)", "rejection_limit_days": "Rejection Limit Period (days)", - "rejection_limit_hint": "Reporting will be blocked when rejections exceed the limit within this period. 0 count = no limit. (Count: 0–50, Period: 1–365 days)", + "rejection_limit_hint": "Reporting is blocked once rejections within this period reach the limit. 0 count = no limit. (Count: 0–50, Period: 1–365 days)", "report_cooldown_seconds": "Report Cooldown (sec)", "report_cooldown_seconds_hint": "Minimum interval between consecutive reports. 0 = no limit. (0–3600 sec)", "notify_author_on_report_action": "Report Action Notification", diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json index c8e3f84c..2c7a5ec7 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json +++ b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json @@ -103,7 +103,7 @@ "daily_report_limit_hint": "Max reports per user per day across all boards. 0 = no limit. (0\u2013100)", "rejection_limit_count": "Rejection Limit (count)", "rejection_limit_days": "Rejection Limit Period (days)", - "rejection_limit_hint": "Reporting will be blocked when rejections exceed the limit within this period. 0 count = no limit. (Count: 0\u201350, Period: 1\u2013365 days)", + "rejection_limit_hint": "Reporting is blocked once rejections within this period reach the limit. 0 count = no limit. (Count: 0\u201350, Period: 1\u2013365 days)", "report_cooldown_seconds": "Report Cooldown (sec)", "report_cooldown_seconds_hint": "Minimum interval between consecutive reports. 0 = no limit. (0\u20133600 sec)", "notify_author_on_report_action": "Report Action Notification", diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin.json b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin.json index 7ae527d5..577fa4ab 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin.json +++ b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin.json @@ -926,7 +926,7 @@ "daily_report_limit_hint": "하루(자정 기준) 전체 게시판 통틀어 신고할 수 있는 최대 횟수. 0이면 제한 없음. (0~100)", "rejection_limit_count": "반려 누적 제한 (건수)", "rejection_limit_days": "반려 누적 제한 기간 (일)", - "rejection_limit_hint": "이 기간 내 반려 건수 초과 시 신고 기능이 차단됩니다. 건수 0이면 제한 없음. (건수: 0~50, 기간: 1~365일)", + "rejection_limit_hint": "이 기간 내 반려 건수가 설정값에 도달하면 신고 기능이 차단됩니다. 건수 0이면 제한 없음. (건수: 0~50, 기간: 1~365일)", "report_cooldown_seconds": "연속 신고 쿨타임 (초)", "report_cooldown_seconds_hint": "연속 신고 최소 간격. 0이면 제한 없음. (0~3600초)", "notify_author_on_report_action": "신고 처리 알림", diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json index b1d38d1e..02207152 100644 --- a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json +++ b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json @@ -103,7 +103,7 @@ "daily_report_limit_hint": "하루(자정 기준) 전체 게시판 통틀어 신고할 수 있는 최대 횟수. 0이면 제한 없음. (0~100)", "rejection_limit_count": "반려 누적 제한 (건수)", "rejection_limit_days": "반려 누적 제한 기간 (일)", - "rejection_limit_hint": "이 기간 내 반려 건수 초과 시 신고 기능이 차단됩니다. 건수 0이면 제한 없음. (건수: 0~50, 기간: 1~365일)", + "rejection_limit_hint": "이 기간 내 반려 건수가 설정값에 도달하면 신고 기능이 차단됩니다. 건수 0이면 제한 없음. (건수: 0~50, 기간: 1~365일)", "report_cooldown_seconds": "연속 신고 쿨타임 (초)", "report_cooldown_seconds_hint": "연속 신고 최소 간격. 0이면 제한 없음. (0~3600초)", "notify_author_on_report_action": "신고 처리 알림", diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 1a0210b0..4591aff7 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.1.1] - 2026-08-11 +## [1.1.1] - 2026-08-12 ### Added @@ -20,6 +20,31 @@ - 같은 모달의 "처리중"·"실패"·"만료" 상태 배지에서 아이콘이 표시되지 않던 문제를 수정했습니다. - 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다. +#### 배송비 구간 + +- 구간별 배송비의 종료값이 그 구간에 포함되도록 수정했습니다. "1~5개 3,000원 / 6개~ 5,000원"으로 설정했을 때 수량이 정확히 5개면 어느 구간에도 해당하지 않아 배송비가 0원(무료배송)으로 계산되던 문제입니다. 이미 저장해 둔 정책은 그대로 두어도 정상 계산됩니다. (#94 @koojunho 님께서 제보해주셨습니다.) +- 무게·부피·금액 구간은 이제 "5kg 다음 구간은 5kg 초과"처럼 이어서 설정합니다. 이전에는 수량과 동일하게 "종료값 + 1"을 요구해 2.5kg 같은 소수 구간을 저장할 수 없었습니다. 수량 구간은 종전대로 "5개 다음은 6개"입니다. (#94 @koojunho 님께서 제보해주셨습니다.) +- 구간 시작값은 직전 구간의 종료값에서 자동으로 채워지며 읽기전용으로 표시됩니다. 구간 입력란에는 정책에 맞는 단위(개/kg/L)가 함께 표시됩니다. 이전 형식으로 저장된 정책은 수정 화면에 들어가면 자동으로 맞춰지므로, 배송비만 바꿔 저장할 때 시작값 때문에 저장이 막히지 않습니다. +- 구간 입력 오류 안내가 문장으로 표시됩니다. 이전에는 "sirsoft-ecommerce.validation..." 같은 내부 식별자가 그대로 보였습니다. +- 배송비 요약 문구에 단위가 다시 표시됩니다. 관리자 화면에서 저장한 정책은 단위가 빠진 채 "~5: 3,000원"으로만 보였습니다. 단위가 0.5처럼 소수인 경우 1로 반올림되어 표시되던 문제도 함께 수정했습니다. +- 금액당 배송비 요약에 통화가 표시됩니다. 이전에는 "10,000당 500원"처럼 기준 금액에 통화 표기가 빠져 있었습니다. + +#### 무게·부피 단위 + +- 무게·부피 기준 배송비가 실제 상품 무게로 계산됩니다. 상품 옵션에 500g을 입력하고 "1kg당 1,000원" 정책을 걸면 500,000원이 청구되던 문제입니다. 부피 기준 정책도 마찬가지로 1,000cm³ 상품이 200,000원으로 계산됐습니다. (#94 @koojunho 님께서 제보해주셨습니다.) +- 부피무게 기준 정책이 실제 무게와 부피무게를 같은 단위로 비교합니다. 이전에는 서로 다른 단위로 비교해 항상 한쪽만 채택됐습니다. (#94 @koojunho 님께서 제보해주셨습니다.) +- 상품 옵션의 무게·부피가 관리자 수정 화면에 표시됩니다. 이전에는 저장된 값이 있어도 0으로 보였고, 그 상태로 저장하면 실제 값이 0으로 덮어써졌습니다. +- 주문에 총 무게·부피가 실제 값으로 기록됩니다. 이전에는 항상 0으로 저장되어 배송사 연동·운임 정산에서 무게 없는 주문으로 취급됐습니다. 기존 주문도 업데이트 시 주문 항목의 값으로 채워집니다. + +#### 배송정책 설정 검증 + +- 배송비가 조용히 0원이 되는 설정을 저장 단계에서 막습니다 — 구간별 정책의 구간 미등록, 단위당 정책의 단위값 누락, 조건부 무료배송의 기준금액 누락, 중간 구간의 종료값 공란이 해당합니다. +- 단위당 배송비의 단위값과 조건부 무료배송의 기준금액을 비우면 저장을 누르기 전에 편집 화면에서 바로 알려 줍니다. 부과정책을 바꾸면 직전 정책에서 남은 안내는 사라집니다. +- 계산 API 정책의 기본 배송비를 0원으로 저장할 수 없습니다. 외부 API 호출이 실패했을 때 쓰는 값이라 0원이면 장애가 그대로 무료배송이 됩니다. 외부 API가 음수 배송비를 반환하는 경우에도 기본 배송비로 대체합니다. +- 도서산간 우편번호는 "63000", "63000-63999", "63*" 세 형식만 저장됩니다. 다른 형식은 어떤 우편번호에도 맞지 않아 추가배송비가 빠진 채로 주문이 완료됐습니다. 자릿수가 다른 우편번호가 범위 조건에 잘못 걸리던 문제도 함께 수정했습니다. +- 도서산간 추가배송비의 "중복 부과"가 기본 배송비와 같은 배송 단위 수를 기준으로 계산됩니다. 이전에는 주문 수량을 기준으로 삼아 무게당·부피당 정책에서 기본 배송비와 어긋난 금액이 부과됐습니다. +- 배송비에 소수점이 있는 경우 버리지 않고 반올림합니다. + ## [1.1.0] - 2026-08-10 ### Security diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000017_create_ecommerce_orders_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000017_create_ecommerce_orders_table.php index 6e974a45..7aaddd9c 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000017_create_ecommerce_orders_table.php +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000017_create_ecommerce_orders_table.php @@ -14,7 +14,7 @@ return new class extends Migration { Schema::create('ecommerce_orders', function (Blueprint $table) { $table->id()->comment('주문 ID'); - $table->unsignedBigInteger('user_id')->nullable(); + $table->unsignedBigInteger('user_id')->nullable()->comment('주문자 회원 ID (비회원 주문은 null)'); $table->string('order_number', 50)->unique()->comment('주문번호'); $table->string('order_status', 30)->comment('주문상태 (OrderStatusEnum)'); $table->string('order_device', 20)->nullable()->comment('주문 디바이스 (pc/mobile/app)'); @@ -44,7 +44,7 @@ return new class extends Migration $table->decimal('total_refunded_points_amount', 12, 2)->default(0)->comment('총 환불 포인트'); $table->decimal('total_earned_points_amount', 12, 2)->default(0)->comment('총 적립 예정 포인트'); $table->integer('item_count')->comment('총 주문수량 (상품 수량 합계)'); - $table->decimal('total_weight', 10, 3)->nullable()->comment('총 무게 (kg)'); + $table->decimal('total_weight', 10, 3)->nullable()->comment('총 무게 (g)'); $table->decimal('total_volume', 10, 3)->nullable()->comment('총 부피 (cm³)'); $table->timestamp('ordered_at')->comment('주문일시'); $table->timestamp('paid_at')->nullable()->comment('결제완료일시'); @@ -83,7 +83,7 @@ return new class extends Migration }); if (DB::getDriverName() === 'mysql') { - DB::statement("ALTER TABLE `".DB::getTablePrefix()."ecommerce_orders` COMMENT '주문 정보'"); + DB::statement('ALTER TABLE `'.DB::getTablePrefix()."ecommerce_orders` COMMENT '주문 정보'"); } } diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000018_create_ecommerce_order_options_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000018_create_ecommerce_order_options_table.php index 70493a21..2e5cb317 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000018_create_ecommerce_order_options_table.php +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000018_create_ecommerce_order_options_table.php @@ -14,10 +14,10 @@ return new class extends Migration { Schema::create('ecommerce_order_options', function (Blueprint $table) { $table->id()->comment('주문 옵션 ID'); - $table->unsignedBigInteger('order_id'); + $table->unsignedBigInteger('order_id')->comment('소속 주문 ID'); $table->unsignedBigInteger('parent_option_id')->nullable()->comment('추가 옵션/구성품의 부모 옵션 ID'); - $table->unsignedBigInteger('product_id'); - $table->unsignedBigInteger('product_option_id'); + $table->unsignedBigInteger('product_id')->comment('주문 시점 상품 ID'); + $table->unsignedBigInteger('product_option_id')->comment('주문 시점 상품 옵션 ID'); $table->string('option_status', 30)->comment('옵션 상태 (OrderStatusEnum)'); $table->boolean('is_stock_deducted')->default(false)->comment('재고 차감 여부'); $table->string('source_type', 20)->default('order')->comment('생성 원인 (order/exchange)'); @@ -28,10 +28,10 @@ return new class extends Migration $table->string('option_name', 255)->nullable()->comment('옵션명 (예: "색상", "사이즈")'); $table->string('option_value', 255)->nullable()->comment('옵션값 (예: "빨강", "XL")'); $table->integer('quantity')->comment('주문수량'); - $table->decimal('unit_weight', 10, 3)->nullable()->comment('단위 무게 (kg, 주문 시점)'); + $table->decimal('unit_weight', 10, 3)->nullable()->comment('단위 무게 (g, 주문 시점)'); $table->decimal('unit_volume', 10, 3)->nullable()->comment('단위 부피 (cm³, 주문 시점)'); - $table->decimal('subtotal_weight', 10, 3)->nullable()->comment('무게 소계 (unit_weight × quantity)'); - $table->decimal('subtotal_volume', 10, 3)->nullable()->comment('부피 소계 (unit_volume × quantity)'); + $table->decimal('subtotal_weight', 10, 3)->nullable()->comment('무게 소계 (g, unit_weight × quantity)'); + $table->decimal('subtotal_volume', 10, 3)->nullable()->comment('부피 소계 (cm³, unit_volume × quantity)'); $table->decimal('unit_price', 12, 2)->comment('단가 (주문 시점 가격)'); $table->decimal('subtotal_price', 12, 2)->comment('소계 (unit_price × quantity)'); $table->decimal('subtotal_discount_amount', 12, 2)->default(0)->comment('할인 소계 (아래 할인 합계)'); @@ -75,7 +75,7 @@ return new class extends Migration }); if (DB::getDriverName() === 'mysql') { - DB::statement("ALTER TABLE `".DB::getTablePrefix()."ecommerce_order_options` COMMENT '주문 옵션 정보'"); + DB::statement('ALTER TABLE `'.DB::getTablePrefix()."ecommerce_order_options` COMMENT '주문 옵션 정보'"); } } diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_11_000001_fix_weight_volume_unit_comments_in_ecommerce_order_tables.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_11_000001_fix_weight_volume_unit_comments_in_ecommerce_order_tables.php new file mode 100644 index 00000000..5244b6a5 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_11_000001_fix_weight_volume_unit_comments_in_ecommerce_order_tables.php @@ -0,0 +1,83 @@ +applyComments( + orderWeightComment: '총 무게 (g)', + orderVolumeComment: '총 부피 (cm³)', + unitWeightComment: '단위 무게 (g, 주문 시점)', + unitVolumeComment: '단위 부피 (cm³, 주문 시점)', + subtotalWeightComment: '무게 소계 (g, unit_weight × quantity)', + subtotalVolumeComment: '부피 소계 (cm³, unit_volume × quantity)', + ); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $this->applyComments( + orderWeightComment: '총 무게 (kg)', + orderVolumeComment: '총 부피 (cm³)', + unitWeightComment: '단위 무게 (kg, 주문 시점)', + unitVolumeComment: '단위 부피 (cm³, 주문 시점)', + subtotalWeightComment: '무게 소계 (unit_weight × quantity)', + subtotalVolumeComment: '부피 소계 (unit_volume × quantity)', + ); + } + + /** + * 컬럼 주석을 일괄 적용합니다 (컬럼 정의는 그대로, 주석만 변경). + * + * @param string $orderWeightComment orders.total_weight 주석 + * @param string $orderVolumeComment orders.total_volume 주석 + * @param string $unitWeightComment order_options.unit_weight 주석 + * @param string $unitVolumeComment order_options.unit_volume 주석 + * @param string $subtotalWeightComment order_options.subtotal_weight 주석 + * @param string $subtotalVolumeComment order_options.subtotal_volume 주석 + */ + private function applyComments( + string $orderWeightComment, + string $orderVolumeComment, + string $unitWeightComment, + string $unitVolumeComment, + string $subtotalWeightComment, + string $subtotalVolumeComment, + ): void { + if (Schema::hasTable('ecommerce_orders')) { + Schema::table('ecommerce_orders', function (Blueprint $table) use ($orderWeightComment, $orderVolumeComment) { + $table->decimal('total_weight', 10, 3)->nullable()->comment($orderWeightComment)->change(); + $table->decimal('total_volume', 10, 3)->nullable()->comment($orderVolumeComment)->change(); + }); + } + + if (Schema::hasTable('ecommerce_order_options')) { + Schema::table('ecommerce_order_options', function (Blueprint $table) use ( + $unitWeightComment, + $unitVolumeComment, + $subtotalWeightComment, + $subtotalVolumeComment + ) { + $table->decimal('unit_weight', 10, 3)->nullable()->comment($unitWeightComment)->change(); + $table->decimal('unit_volume', 10, 3)->nullable()->comment($unitVolumeComment)->change(); + $table->decimal('subtotal_weight', 10, 3)->nullable()->comment($subtotalWeightComment)->change(); + $table->decimal('subtotal_volume', 10, 3)->nullable()->comment($subtotalVolumeComment)->change(); + }); + } + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/OrderSeeder.php b/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/OrderSeeder.php index ed40a31f..b2fa2d3c 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/OrderSeeder.php +++ b/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/OrderSeeder.php @@ -932,15 +932,18 @@ class OrderSeeder extends Seeder { $subtotal = (int) $orderOption->subtotal_price; $quantity = (int) $orderOption->quantity; - $weight = (float) ($orderOption->subtotal_weight ?? $quantity * 0.5); - $volume = (float) ($orderOption->subtotal_volume ?? $quantity * 1000); - $baseFee = (int) $countrySetting->base_fee; + // 주문 옵션의 무게/부피는 g / cm³ 로 저장된다 (미입력 시 1개당 500g / 1,000cm³ 로 가정) + $weightGram = (float) ($orderOption->subtotal_weight ?? $quantity * 500); + $volumeCubicCm = (float) ($orderOption->subtotal_volume ?? $quantity * 1000); + $baseFee = (int) round((float) $countrySetting->base_fee); $ranges = $countrySetting->ranges; - // 부피무게 계산 + // 배송정책의 구간·단위값은 kg / L 이므로 여기서 환산한다 (엔진과 동일 규칙) $volumeWeightDivisor = $ranges['volume_weight_divisor'] ?? 6000; - $volumeWeight = $volumeWeightDivisor > 0 ? $volume / $volumeWeightDivisor : 0.0; - $chargeableWeight = max($weight, $volumeWeight); + $weightKg = $weightGram / 1000; + $volumeLiter = $volumeCubicCm / 1000; + $volumetricWeightKg = $volumeWeightDivisor > 0 ? $volumeCubicCm / $volumeWeightDivisor : 0.0; + $chargeableWeightKg = max($weightKg, $volumetricWeightKg); return match ($countrySetting->charge_policy) { ChargePolicyEnum::FREE => 0, @@ -948,38 +951,55 @@ class OrderSeeder extends Seeder ChargePolicyEnum::CONDITIONAL_FREE => $subtotal >= (int) $countrySetting->free_threshold ? 0 : $baseFee, ChargePolicyEnum::RANGE_AMOUNT => $this->calculateSeederRangeFee($ranges, $subtotal), ChargePolicyEnum::RANGE_QUANTITY => $this->calculateSeederRangeFee($ranges, $quantity), - ChargePolicyEnum::RANGE_WEIGHT => $this->calculateSeederRangeFee($ranges, (int) ($weight * 1000)), - ChargePolicyEnum::RANGE_VOLUME => $this->calculateSeederRangeFee($ranges, (int) $volume), - ChargePolicyEnum::RANGE_VOLUME_WEIGHT => $this->calculateSeederRangeFee($ranges, (int) ($chargeableWeight * 1000)), - ChargePolicyEnum::PER_QUANTITY => (int) (ceil($quantity / max($ranges['unit_value'] ?? 1, 1)) * $baseFee), - ChargePolicyEnum::PER_WEIGHT => (int) (ceil($weight / max($ranges['unit_value'] ?? 0.5, 0.01)) * $baseFee), - ChargePolicyEnum::PER_VOLUME => (int) (ceil($volume / max($ranges['unit_value'] ?? 1000, 1)) * $baseFee), - ChargePolicyEnum::PER_VOLUME_WEIGHT => (int) (ceil($chargeableWeight / max($ranges['unit_value'] ?? 0.5, 0.01)) * $baseFee), - ChargePolicyEnum::PER_AMOUNT => (int) (ceil($subtotal / max($ranges['unit_value'] ?? 10000, 1)) * $baseFee), + ChargePolicyEnum::RANGE_WEIGHT => $this->calculateSeederRangeFee($ranges, $weightKg), + ChargePolicyEnum::RANGE_VOLUME => $this->calculateSeederRangeFee($ranges, $volumeLiter), + ChargePolicyEnum::RANGE_VOLUME_WEIGHT => $this->calculateSeederRangeFee($ranges, $chargeableWeightKg), + ChargePolicyEnum::PER_QUANTITY => (int) round(ceil($quantity / max($ranges['unit_value'] ?? 1, 1)) * $baseFee), + ChargePolicyEnum::PER_WEIGHT => (int) round(ceil($weightKg / max($ranges['unit_value'] ?? 0.5, 0.01)) * $baseFee), + ChargePolicyEnum::PER_VOLUME => (int) round(ceil($volumeLiter / max($ranges['unit_value'] ?? 1, 0.01)) * $baseFee), + ChargePolicyEnum::PER_VOLUME_WEIGHT => (int) round(ceil($chargeableWeightKg / max($ranges['unit_value'] ?? 0.5, 0.01)) * $baseFee), + ChargePolicyEnum::PER_AMOUNT => (int) round(ceil($subtotal / max($ranges['unit_value'] ?? 10000, 1)) * $baseFee), ChargePolicyEnum::API => $baseFee, // API는 시더에서 base_fee 사용 default => $baseFee, }; } /** - * 구간별 배송비 계산 (시더용) + * 구간별 배송비 계산 (시더용 — 상한 포함 사다리 매칭) + * + * 구간을 상한 오름차순(무제한이 마지막)으로 정렬한 뒤, 값이 상한 이하인 첫 구간의 + * 배송비를 반환합니다. 시작값은 표시용이라 매칭에 쓰지 않습니다 + * (OrderCalculationService::calculateRangeFee 와 동일 규칙). * * @param array|null $ranges 구간 설정 - * @param int $value 비교 값 + * @param int|float $value 비교 값 (금액·수량·kg·L) * @return int 배송비 */ - private function calculateSeederRangeFee(?array $ranges, int $value): int + private function calculateSeederRangeFee(?array $ranges, int|float $value): int { - if (empty($ranges) || empty($ranges['tiers'])) { + $tiers = is_array($ranges) ? ($ranges['tiers'] ?? []) : []; + + if (! is_array($tiers) || count($tiers) === 0) { return 0; } - foreach ($ranges['tiers'] as $tier) { - $min = $tier['min'] ?? 0; - $max = $tier['max'] ?? PHP_INT_MAX; + $sortedTiers = array_values($tiers); + usort($sortedTiers, function ($a, $b) { + $aMax = ($a['max'] ?? null) === null || ($a['max'] ?? null) === '' ? null : (float) $a['max']; + $bMax = ($b['max'] ?? null) === null || ($b['max'] ?? null) === '' ? null : (float) $b['max']; - if ($value >= $min && ($max === null || $value < $max)) { - return (int) ($tier['fee'] ?? 0); + if ($aMax === null && $bMax === null) { + return 0; + } + + return $aMax === null ? 1 : ($bMax === null ? -1 : $aMax <=> $bMax); + }); + + foreach ($sortedTiers as $tier) { + $max = ($tier['max'] ?? null) === null || ($tier['max'] ?? null) === '' ? null : (float) $tier['max']; + + if ($max === null || (float) $value <= $max) { + return (int) round((float) ($tier['fee'] ?? 0)); } } diff --git a/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/ShippingPolicySeeder.php b/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/ShippingPolicySeeder.php index 1d5d5f26..4ae4e4e7 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/ShippingPolicySeeder.php +++ b/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/ShippingPolicySeeder.php @@ -162,12 +162,13 @@ class ShippingPolicySeeder extends Seeder 'base_fee' => 0, 'ranges' => [ 'type' => 'amount', + // 연속형(금액) 구간: 다음 시작값 = 직전 종료값, 종료값은 포함 'tiers' => [ - ['min' => 0, 'max' => 10000, 'unit' => '원', 'fee' => 5000], - ['min' => 10000, 'max' => 30000, 'unit' => '원', 'fee' => 3000], - ['min' => 30000, 'max' => 50000, 'unit' => '원', 'fee' => 2000], - ['min' => 50000, 'max' => 100000, 'unit' => '원', 'fee' => 1000], - ['min' => 100000, 'max' => null, 'unit' => '원', 'fee' => 0], + ['min' => 0, 'max' => 10000, 'fee' => 5000], + ['min' => 10000, 'max' => 30000, 'fee' => 3000], + ['min' => 30000, 'max' => 50000, 'fee' => 2000], + ['min' => 50000, 'max' => 100000, 'fee' => 1000], + ['min' => 100000, 'max' => null, 'fee' => 0], ], ], 'extra_fee_enabled' => false, @@ -190,9 +191,10 @@ class ShippingPolicySeeder extends Seeder 'base_fee' => 0, 'ranges' => [ 'type' => 'quantity', + // 이산형(수량) 구간: 다음 시작값 = 직전 종료값 + 1, 종료값은 포함 'tiers' => [ - ['min' => 1, 'max' => 5, 'unit' => '개', 'fee' => 3000], - ['min' => 6, 'max' => null, 'unit' => '개', 'fee' => 5000], + ['min' => 0, 'max' => 5, 'fee' => 3000], + ['min' => 6, 'max' => null, 'fee' => 5000], ], ], 'extra_fee_enabled' => false, @@ -215,11 +217,12 @@ class ShippingPolicySeeder extends Seeder 'base_fee' => 0, 'ranges' => [ 'type' => 'weight', + // 무게 구간은 kg 단위 (상품 옵션의 g 값은 배송비 계산 시점에 환산된다) 'tiers' => [ - ['min' => 0, 'max' => 2, 'unit' => 'kg', 'fee' => 3000], - ['min' => 2, 'max' => 5, 'unit' => 'kg', 'fee' => 4000], - ['min' => 5, 'max' => 10, 'unit' => 'kg', 'fee' => 6000], - ['min' => 10, 'max' => null, 'unit' => 'kg', 'fee' => 8000], + ['min' => 0, 'max' => 2, 'fee' => 3000], + ['min' => 2, 'max' => 5, 'fee' => 4000], + ['min' => 5, 'max' => 10, 'fee' => 6000], + ['min' => 10, 'max' => null, 'fee' => 8000], ], ], 'extra_fee_enabled' => false, @@ -242,10 +245,11 @@ class ShippingPolicySeeder extends Seeder 'base_fee' => 0, 'ranges' => [ 'type' => 'volume', + // 부피 구간은 L 단위 (상품 옵션의 cm³ 값은 배송비 계산 시점에 환산된다) 'tiers' => [ - ['min' => 0, 'max' => 50, 'unit' => 'L', 'fee' => 5000], - ['min' => 50, 'max' => 100, 'unit' => 'L', 'fee' => 10000], - ['min' => 100, 'max' => null, 'unit' => 'L', 'fee' => 20000], + ['min' => 0, 'max' => 50, 'fee' => 5000], + ['min' => 50, 'max' => 100, 'fee' => 10000], + ['min' => 100, 'max' => null, 'fee' => 20000], ], ], 'extra_fee_enabled' => false, @@ -268,11 +272,12 @@ class ShippingPolicySeeder extends Seeder 'base_fee' => 0, 'ranges' => [ 'type' => 'volume_weight', + // 부피무게 구간은 kg 단위 (실무게 kg 과 부피무게 kg 중 큰 값으로 매칭) 'tiers' => [ - ['min' => 0, 'max' => 5, 'unit' => 'kg', 'fee' => 3500], - ['min' => 5, 'max' => 10, 'unit' => 'kg', 'fee' => 5000], - ['min' => 10, 'max' => 20, 'unit' => 'kg', 'fee' => 8000], - ['min' => 20, 'max' => null, 'unit' => 'kg', 'fee' => 12000], + ['min' => 0, 'max' => 5, 'fee' => 3500], + ['min' => 5, 'max' => 10, 'fee' => 5000], + ['min' => 10, 'max' => 20, 'fee' => 8000], + ['min' => 20, 'max' => null, 'fee' => 12000], ], ], 'extra_fee_enabled' => false, @@ -292,9 +297,10 @@ class ShippingPolicySeeder extends Seeder 'shipping_method' => 'parcel', 'currency_code' => 'KRW', 'charge_policy' => 'api', - 'base_fee' => 0, + // 외부 API 장애 시 폴백 배송비 (0 이면 장애가 곧 무음 무료배송이 된다) + 'base_fee' => 30000, 'api_endpoint' => 'https://api.example.com/shipping/calculate', - 'api_request_fields' => ['order_amount', 'weight', 'zipcode'], + 'api_request_fields' => ['group_total', 'total_quantity', 'country_code'], 'api_response_fee_field' => 'shipping_fee', // 계산 API 연동 상세 설정 (MP12 — A13) — api_config JSON 'api_config' => [ @@ -318,9 +324,10 @@ class ShippingPolicySeeder extends Seeder 'shipping_method' => 'parcel', 'currency_code' => 'KRW', 'charge_policy' => 'api', - 'base_fee' => 0, + // 외부 API 장애 시 폴백 배송비 (0 이면 장애가 곧 무음 무료배송이 된다) + 'base_fee' => 30000, 'api_endpoint' => 'https://api.example.com/shipping/calculate', - 'api_request_fields' => ['order_amount', 'weight', 'zipcode'], + 'api_request_fields' => ['group_total', 'total_quantity', 'country_code'], 'api_response_fee_field' => 'shipping_fee', // 계산 API 연동 상세 설정 (MP12 — A13) — api_config JSON 'api_config' => [ @@ -344,9 +351,10 @@ class ShippingPolicySeeder extends Seeder 'shipping_method' => 'parcel', 'currency_code' => 'KRW', 'charge_policy' => 'api', - 'base_fee' => 0, + // 외부 API 장애 시 폴백 배송비 (0 이면 장애가 곧 무음 무료배송이 된다) + 'base_fee' => 30000, 'api_endpoint' => 'https://api.example.com/shipping/calculate', - 'api_request_fields' => ['order_amount', 'weight', 'zipcode'], + 'api_request_fields' => ['group_total', 'total_quantity', 'country_code'], 'api_response_fee_field' => 'shipping_fee', // 계산 API 연동 상세 설정 (MP12 — A13) — api_config JSON 'api_config' => [ @@ -398,7 +406,7 @@ class ShippingPolicySeeder extends Seeder 'currency_code' => 'KRW', 'charge_policy' => 'per_quantity', 'base_fee' => 3000, - 'ranges' => ['unit_value' => 3], + 'ranges' => ['type' => 'per_quantity', 'unit_value' => 3], 'extra_fee_enabled' => false, 'is_active' => true, ], @@ -417,7 +425,7 @@ class ShippingPolicySeeder extends Seeder 'currency_code' => 'KRW', 'charge_policy' => 'per_weight', 'base_fee' => 1000, - 'ranges' => ['unit_value' => 1], + 'ranges' => ['type' => 'per_weight', 'unit_value' => 1], 'extra_fee_enabled' => false, 'is_active' => true, ], @@ -436,7 +444,7 @@ class ShippingPolicySeeder extends Seeder 'currency_code' => 'KRW', 'charge_policy' => 'per_volume', 'base_fee' => 2000, - 'ranges' => ['unit_value' => 10], + 'ranges' => ['type' => 'per_volume', 'unit_value' => 10], 'extra_fee_enabled' => false, 'is_active' => true, ], @@ -455,7 +463,7 @@ class ShippingPolicySeeder extends Seeder 'currency_code' => 'KRW', 'charge_policy' => 'per_volume_weight', 'base_fee' => 3000, - 'ranges' => ['unit_value' => 5], + 'ranges' => ['type' => 'per_volume_weight', 'unit_value' => 5], 'extra_fee_enabled' => false, 'is_active' => true, ], @@ -467,10 +475,11 @@ class ShippingPolicySeeder extends Seeder 'base_fee' => 0, 'ranges' => [ 'type' => 'weight', + // 배송비는 기본 통화(KRW) 기준 금액이다 — 국가만 US 일 뿐 통화가 바뀌지 않는다 'tiers' => [ - ['min' => 0, 'max' => 2, 'unit' => 'kg', 'fee' => 25], - ['min' => 2, 'max' => 5, 'unit' => 'kg', 'fee' => 40], - ['min' => 5, 'max' => null, 'unit' => 'kg', 'fee' => 60], + ['min' => 0, 'max' => 2, 'fee' => 25000], + ['min' => 2, 'max' => 5, 'fee' => 40000], + ['min' => 5, 'max' => null, 'fee' => 60000], ], ], 'extra_fee_enabled' => false, @@ -491,7 +500,7 @@ class ShippingPolicySeeder extends Seeder 'currency_code' => 'KRW', 'charge_policy' => 'per_amount', 'base_fee' => 500, - 'ranges' => ['unit_value' => 10000], + 'ranges' => ['type' => 'per_amount', 'unit_value' => 10000], 'extra_fee_enabled' => false, 'is_active' => true, ], diff --git a/modules/_bundled/sirsoft-ecommerce/dist/js/module.iife.js b/modules/_bundled/sirsoft-ecommerce/dist/js/module.iife.js index 0990db9e..33f83930 100644 --- a/modules/_bundled/sirsoft-ecommerce/dist/js/module.iife.js +++ b/modules/_bundled/sirsoft-ecommerce/dist/js/module.iife.js @@ -1 +1 @@ -var SirsoftEcommerce=function(Ae){"use strict";const Lt={KRW:0,JPY:0},xt={KRW:"₩",USD:"$",JPY:"¥",CNY:"元",EUR:"€",GBP:"£"},Pt={KRW:1e3,JPY:100};function At(r){if(!r)return 1;const c=r.base_unit??Pt[r.code]??1;return Math.max(1,c)}function kt(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 de(r,c){const e=typeof c=="string"?{code:c}:c,t=e.code,o=e.symbol&&e.symbol.length>0?e.symbol:xt[t]??"",a=e.decimal_places??Lt[t]??2,n=r.toLocaleString(void 0,{minimumFractionDigits:a,maximumFractionDigits:a});return o?t==="KRW"||o==="₩"||o==="원"?n+"원":o+n:n+" "+t}function Ot(){try{const e=(window.G7Core?.state?.get?.()||{})?.modules?.["sirsoft-ecommerce"]?.language_currency;return Array.isArray(e?.currencies)?e.currencies:[]}catch{return[]}}function ue(r,c){const e=Ot(),t=c||e.find(o=>o.is_default)?.code;return t?de(r,e.find(o=>o.code===t)??t):r.toLocaleString()}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=At(a),s={};for(const i of t){const l=i.code;if(i.is_default??!1)s[l]={price:o,formatted:de(o,i),is_default:!0,editable:!0};else if(i.exchange_rate&&i.exchange_rate>0){const d=o/n*i.exchange_rate,m=kt(d,i.rounding_unit||"0.01",i.rounding_method||"round");s[l]={price:m,formatted:de(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 Gt(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 p=[...u._local?.productFieldErrors||[]];const g=l.map(C=>{if(String(C.id)===String(t)){const x=C[o];if(typeof n=="object"&&n!==null?JSON.stringify(x)===JSON.stringify(n):String(x)===String(n))return z.log(`[updateProductField] No change for product ${t}.${o}, skipping`),C;let S=n;const N=parseFloat(n)||0;if(o==="selling_price"){const O=parseFloat(C.list_price)||0;O>0&&N>O?(p.some(Q=>Q.productId===String(t)&&Q.field===o)||p.push({productId:String(t),field:"selling_price",message:"판매가는 정가를 초과할 수 없습니다."}),S=O):p=p.filter($=>!($.productId===String(t)&&$.field===o))}else p=p.filter(O=>!(O.productId===String(t)&&O.field===o));const L={...C,[o]:S,_modified:!0};return o==="selling_price"&&d&&Array.isArray(d)&&(L.multi_currency_selling_price=se({basePrice:S,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||{}},f=String(t),b=new Set(_[f]||[]);b.add(o),_[f]=Array.from(b),s.state.setLocal({modifiedProductIds:Array.from(y),modifiedProductFields:_,productFieldErrors:p}),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 Et(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 Pe=parseFloat(n)||0;if(a==="selling_price"){const D=parseFloat($.list_price)||0;D>0&&Pe>D?(g.some(he=>he.productId===String(t)&&he.optionId===String(o)&&he.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),Ke=Qe,B.log(`[updateOptionField] Updated product stock to option sum: ${Ke}`));const ze={...N,options:re,stock_quantity:Ke,option_stock_sum:Qe,_modified:!0};return ae!==null&&(ze.selling_price=ae,m&&Array.isArray(m)&&(ze.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`)),ze}return N});if(!y)return;i.dataSource.set(s,{...l,data:{...l.data,data:_}});const f=i.state.getLocal()||{},b=new Set(f.modifiedOptionIds||[]),C=`${t}-${o}`;b.add(C);const x={...f.modifiedOptionFields||{}},P=new Set(x[C]||[]);P.add(a),x[C]=Array.from(P);const S={modifiedOptionIds:Array.from(b),modifiedOptionFields:x,optionFieldErrors:g};if(w){const N=new Set(f.modifiedProductIds||[]);N.add(t);const L={...f.modifiedProductFields||{}},O=String(t),$=new Set(L[O]||[]);$.add("selling_price"),L[O]=Array.from($),S.modifiedProductIds=Array.from(N),S.modifiedProductFields=L}i.state.setLocal(S),B.log(`[updateOptionField] Updated option ${t}/${o}.${a} =`,n)}const Ft="g7_preferred_currency",Dt="g7_locale",Ye=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 $t(r){const c=window.G7Core;c?.state?.set?c.state.set(r):Ye.warn("G7Core.state.set not available")}function Nt(){try{return localStorage.getItem(Dt)}catch{return null}}function We(){try{const e=(window.G7Core?.state?.get?.()||{})?.modules?.["sirsoft-ecommerce"]?.language_currency;return Array.isArray(e?.currencies)?e.currencies:[]}catch{return[]}}function Tt(r){if(!r)return null;const c=r.toLowerCase().split("-")[0].split("_")[0];if(!c)return null;const t=We().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 Rt(){try{const r=window.G7Config;return r?.user?r.user:(window.G7Core?.state?.get?.()||{}).currentUser||null}catch{return null}}function Mt(r,c){const e=r?.params?.defaultCurrency||We().find(l=>l?.is_default)?.code||"",t=l=>typeof l=="string"&&/^[A-Z]{3}$/.test(l),o=Rt(),a=r?.params?.accountCurrency??o?.ecommerce_preferred_currency;let n=null;try{n=localStorage.getItem(Ft)}catch{}const s=a?null:Tt(Nt()),i=t(a)?a:t(n)?n:t(s)?s:t(e)?e:"KRW";$t({preferredCurrency:i}),Ye.log("Preferred currency initialized:",i)}const Ht="g7_preferred_shipping_country",Je=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 qt(r){const c=window.G7Core;c?.state?.set?c.state.set(r):Je.warn("G7Core.state.set not available")}function Ut(){try{const r=window.G7Config;return r?.user?r.user:(window.G7Core?.state?.get?.()||{}).currentUser||null}catch{return null}}function Vt(r,c){const e=r?.params?.defaultCountry||"KR",t=i=>typeof i=="string"&&/^[A-Z]{2}$/.test(i),o=Ut(),a=r?.params?.accountCountry??o?.ecommerce_preferred_shipping_country;let n=null;try{n=localStorage.getItem(Ht)}catch{}const s=t(a)?a:t(n)?n:t(e)?e:"KR";qt({preferredShippingCountry:s}),Je.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 jt(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 Bt(r){const{preset:c}=r.params||{preset:"today"},{startDate:e,endDate:t}=jt(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 Kt(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(P=>String(P.id)===String(o));if(!_)return ie.warn("[setDefaultOption] Option not found:",o),w;const f=y.map(P=>{const S=String(P.id)===String(o);return{...P,is_default:S,_modified:S?!0:P._modified}}),b=_.list_price,C=_.selling_price;let x=w.multi_currency_selling_price;return u&&Array.isArray(u)&&(x=se({basePrice:C,currencies:u})),ie.log(`[setDefaultOption] Set option ${o} as default for product ${t}. Syncing prices: list_price=${b}, selling_price=${C}`),{...w,options:f,list_price:b,selling_price:C,multi_currency_selling_price:x,_modified:!0}});n.dataSource.set(a,{...s,data:{...s.data,data:d}});const m=n.state.getLocal()||{},p=new Set(m.modifiedProductIds||[]);p.add(String(t));const g=new Set(m.modifiedOptionIds||[]);g.add(`${t}-${o}`),n.state.setLocal({modifiedProductIds:Array.from(p),modifiedOptionIds:Array.from(g)}),ie.log(`[setDefaultOption] Option ${o} set as default for product ${t}`)}const ke=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)},me=(r,c)=>`${r}-${c}`,Oe=r=>r.split("-")[0];function Qt(r,c){const e=r.params||{},{productId:t,optionId:o,checked:a}=e,n=window.G7Core;if(!n?.state){ke.warn("[toggleOption] G7Core.state를 사용할 수 없습니다.");return}const s=n.state.getLocal(),i=s.selectedOptionIds||[],l=s.selectedItems||[],u=me(t,o);let d;a?i.includes(u)?d=i:d=[...i,u]:d=i.filter(f=>f!==u);const w=(n.dataSource?.get("products")?.data?.data||[]).find(f=>String(f.id)===String(t))?.options||[];let y=[...l];const _=Number(t);w.length>0&&(w.map(C=>me(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 Xe(r,c){const e=r.params||{},{productId:t,options:o,checked:a}=e,n=window.G7Core;if(!n?.state){ke.warn("[toggleProductOptions] G7Core.state를 사용할 수 없습니다.");return}const s=n.state.getLocal(),i=s.selectedOptionIds||[],l=s.selectedItems||[],u=(o||[]).map(g=>me(t,g.id));let d,m=[...l];const p=Number(t);a?(d=[...i.filter(w=>Oe(w)!==String(t)),...u],m.includes(p)||m.push(p)):(d=i.filter(g=>Oe(g)!==String(t)),m=m.filter(g=>g!==p)),n.state.setLocal({selectedOptionIds:d,selectedItems:m})}function zt(r,c){return Xe(r)}function Yt(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=>me(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 Wt(r,c){const e=r.params||{},{selectedIds:t}=e,o=window.G7Core;if(!o?.state){ke.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(p=>!n.includes(p)),d=n.filter(p=>!t.includes(p));let m=[...s];for(const p of u){const g=l.find(w=>Number(w.id)===p);if(g?.options)for(const w of g.options){const y=me(p,w.id);m.includes(y)||m.push(y)}}for(const p of d)m=m.filter(g=>Oe(g)!==String(p));o.state.setLocal({selectedItems:t,selectedOptionIds:m})}const Ce=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 Jt(r,c){const e=window.G7Core;if(!e?.state||!e?.api){Ce.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}),Ce.log(`[generateCopyProductCode] Generated (parent): ${o}`);else{const s=e.state.getLocal()||{};e.state.setLocal({ui:{...s.ui,copyOptions:{...s.ui?.copyOptions||{},newProductCode:o}}}),Ce.log(`[generateCopyProductCode] Generated (local): ${o}`)}}catch(t){Ce.error("[generateCopyProductCode] Failed:",t),e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.messages.code_generate_error")??"Failed to generate product code.")}}const Ze=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 Xt(r,c){const e=window.G7Core;if(!e?.state){Ze.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()}`;Ze.log("[copyProduct] Navigating to:",u),e.navigate?e.navigate(u):c.navigate?c.navigate(u):window.location.href=u}const be=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)},Zt="/api/modules/sirsoft-ecommerce/admin/products/options",Ge=100,Ee=new Set;function eo(r,c){const t=r?.dataSource?.get?.(c)?.data?.data;return Array.isArray(t)?t:[]}function et(r,c,e){const t=r?.dataSource?.get?.(c),o=t?.data?.data;if(!t||!Array.isArray(o)){be.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 to(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 Fe(r,c){const e=r?.state?.getLocal?.()||{},t=c(e.optionsLoadingIds||[],e.optionsErrorIds||[]);r.state.setLocal({optionsLoadingIds:t.loading,optionsErrorIds:t.errored})}function De(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){be.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=eo(a,t),i=new Map(s.map(d=>[String(d?.id),d])),l=[],u={};n.forEach(d=>{const m=String(d),p=i.get(m);if(p&&!(!o&&Array.isArray(p.options))){if(Number(p.options_total_count)===0){u[m]=[];return}Ee.has(m)||l.push(m)}}),Object.keys(u).length>0&&et(a,t,u),l.length!==0&&(l.length>Ge&&(be.warn(`[loadExpandedOptions] Requested ${l.length} products, truncating to ${Ge}`),l.length=Ge),l.forEach(d=>Ee.add(d)),Fe(a,(d,m)=>({loading:Array.from(new Set([...d,...l])),errored:m.filter(p=>!l.includes(p))})),a.api.get(Zt,{params:{product_ids:l.map(d=>Number(d))}}).then(d=>{const p=(d?.data||{}).options||{},g={};l.forEach(w=>{const y=p[w];g[w]=Array.isArray(y)?y:[]}),et(a,t,g),to(a,g),Fe(a,(w,y)=>({loading:w.filter(_=>!l.includes(_)),errored:y.filter(_=>!l.includes(_))}))}).catch(d=>{be.error("[loadExpandedOptions] Failed to load options:",d),Fe(a,(p,g)=>({loading:p.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=>Ee.delete(d))}))}function oo(r,c={}){const e=r?.state?.getLocal?.()?.expandedRows||[];!Array.isArray(e)||e.length===0||De({params:{productIds:e,force:c.force===!0,dataSourceId:c.dataSourceId||"products"}})}function ro(r,c){const e=r.params||{};De({params:{productId:e.productId,productIds:e.productIds,force:!0,dataSourceId:e.dataSourceId}})}const A=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 tt(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=tt(o.children,c,e,[...t,a]);if(n)return n}}return null}function no(r,c,e){return r.map(t=>{if(t.breadcrumb)return{id:t.id,breadcrumb:t.breadcrumb};const o=tt(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 ao(r,c){const e=r.params||{},t=e.depth,o=e.category;if(t===void 0||!o){A.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){A.log(`[selectCategory] Same category already selected at depth ${t}, skipping`);return}const d=u.map((m,p)=>pdf0?t+2:p;a.mergeState({selectedCategories:g,categoryStep:y}),A.log(`[selectCategoryMobile] Selected category at depth ${t} (isolated):`,o.id,"nextStep:",y);return}const n=window.G7Core;if(!n?.state){A.warn("[selectCategoryMobile] G7Core.state API is not available");return}const s=n.state.getLocal()||{},l=(s.ui?.selectedCategories||[null,null,null,null]).map((m,p)=>p0?t+2:s.ui?.categoryStep;n.state.setLocal({ui:{...s.ui,selectedCategories:l,categoryStep:d}}),A.log(`[selectCategoryMobile] Selected category at depth ${t}:`,o.id,"nextStep:",d)}function io(r,c){A.log("[addCategoryToSelection] START");const e=window.G7Core;if(!e?.state){A.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||[];A.log("[addCategoryToSelection] Current state:",{selectedCats:n.map(f=>f?.id??null),currentCategoryIds:t.form?.category_ids,currentSelectedCategoryInfos:t.selectedCategoryInfos,usingIsolatedContext:!!a});const s=n.filter(Boolean).pop();if(!s){A.warn("[addCategoryToSelection] No category selected");return}A.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)){A.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(f=>f.name?.[o]||f.name?.ko||"").join(" > "),u=[...i,s.id],d=t.form?.primary_category_id||s.id,p=(e.state.getDataSources?.()||{}).categories?.data?.data||[];let g=t.selectedCategoryInfos||[];const w=t.form?.categories||[];g.length===0&&w.length>0&&(A.log("[addCategoryToSelection] Initializing from form.categories:",w.length,"items"),g=no(w,p,o));const y=[...g,{id:s.id,breadcrumb:l}];A.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}),A.log("[addCategoryToSelection] setLocal SUCCESS")}catch(f){A.error("[addCategoryToSelection] setLocal FAILED:",f)}const _=e.state.getLocal()||{};A.log("[addCategoryToSelection] After setLocal:",{category_ids:_.form?.category_ids,selectedCategoryInfos:_.selectedCategoryInfos}),A.log(`[addCategoryToSelection] Added category ${s.id} with breadcrumb: ${l}. Total: ${u.length}`)}function co(r,c){const t=(r.params||{}).categoryId;if(!t){A.warn("[removeCategoryFromSelection] Missing categoryId param");return}const o=window.G7Core;if(!o?.state){A.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}),A.log(`[removeCategoryFromSelection] Removed category ${t}. Remaining: ${s.length}`)}function lo(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 p=m.name?.[n]||m.name?.ko||"";if(m.id===u)return[...d,p];if(m.children&&m.children.length>0){const g=s(m.children,u,[...d,p]);if(g)return g}}return null},i=s(o,t);return i?i.join(" > "):""}function uo(r,c){A.log("[initCategoryInfosFromProduct] START");const e=window.G7Core;if(!e?.state){A.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(A.log("[initCategoryInfosFromProduct] Product categories:",s),A.log("[initCategoryInfosFromProduct] Category IDs:",i),A.log("[initCategoryInfosFromProduct] Categories tree available:",n.length>0),i.length===0){A.log("[initCategoryInfosFromProduct] No categories to initialize");return}const l=(d,m,p=[])=>{for(const g of d){const w=g.name?.[o]||g.name?.ko||"";if(g.id===m)return[...p,w];if(g.children&&g.children.length>0){const y=l(g.children,m,[...p,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 p=s.find(g=>g.id===d);if(p){const g=p.name?.[o]||p.name?.ko||p.name_localized||"";u.push({id:d,breadcrumb:g})}}}A.log("[initCategoryInfosFromProduct] Generated selectedCategoryInfos:",u),e.state.setLocal({selectedCategoryInfos:u}),A.log("[initCategoryInfosFromProduct] DONE")}function mo(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 A.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(p=>String(p.id)===s);m&&m.depth===3&&(e.category4=s)}}}}return A.log("[validateCategoryPath] Result:",e),e}function po(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 fo(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)},go={KRW:1e3,JPY:100};function _o(r,c){const t=r.find(o=>o.code===c)?.base_unit??go[c]??1;return Math.max(1,t)}function wo(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 yo(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||u.find(y=>y?.is_default)?.code||"",m=l?.rounding_method||"round",p={...i.form?.multi_currency_prices||{}};if(p[o]={...p[o]||{},[t]:a},n&&o===d){const y=_o(u,d);u.forEach(_=>{if(_.code!==d){const f=_.exchange_rate??1,b=wo(a,f,m,y);p[_.code]={...p[_.code]||{},[t]:b}}})}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:p,[g[t]]:w},hasChanges:!0}),Z.log(`[updatePrice] Updated ${t} price for ${o}: ${a}`)}function ho(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 Co(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 bo(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function ot(){return bo().reduce((c,e)=>({...c,[e]:""}),{})}function Ie(r){return(r??[]).filter(c=>c.is_active!==!1).reduce((c,e)=>c+(parseInt(String(e.stock_quantity),10)||0),0)}const Io={KRW:1e3,JPY:100};function $e(){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??Io[e.code]??1;return Math.max(1,t)}function So(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 Ne(r,c,e){const t=c.exchange_rate||0;if(t<=0)return{price:0,formatted:de(0,c)};const o=e??$e(),a=r/o*t,n=So(a,c.rounding_unit||"0.01",c.rounding_method||"round"),s=c.decimal_places??2,i=parseFloat(n.toFixed(s));return{price:i,formatted:de(i,c)}}function Te(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=$e(),i={};e.forEach(l=>{i[l.code]=Ne(a,l,s)}),n.multi_currency_selling_price=i}return n})}function Re(){return((window.G7Core?.state?.get?.()||{}).modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).filter(c=>!c.is_default&&c.exchange_rate)}function vo(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=Re();l=Te(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 Lo(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=Re();if(i[t].is_default===!0){i[t].price_adjustment=0;const g=Te(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 p=parseFloat(String(s.form?.selling_price))||0;if(i[t].price_adjustment=d-p,m.length>0){const g=$e(),w={};m.forEach(y=>{w[y.code]=Ne(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=Ie(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 xo(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(p=>({key:p.name,value:ot()})),u={id:null,option_code:i,option_name:ot(),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=Ie(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 Po(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=Re(),i=Te(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 Me(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function ce(){return window.G7Core?.config?.("app.locale")??Me()[0]??"ko"}function q(){return Me().reduce((c,e)=>({...c,[e]:""}),{})}function rt(r){return{id:null,name:q(),price_adjustment:0,is_default:r===0,is_active:!0,allow_custom_text:!1,sort_order:r}}function Ao(r,c){return typeof r=="string"?r:r[c]??r.ko??Object.values(r)[0]??""}function nt(r){return r&&typeof r=="object"&&!Array.isArray(r)}function ko(r){return Array.isArray(r)?r:r&&typeof r=="object"?Object.values(r):[]}function Oo(r){return r.reduce((c,e)=>{const t=[];return c.forEach(o=>{e.forEach(a=>{t.push([...o,a])})}),t},[[]])}function Go(r){const c=Me(),e={};for(const t of c){const o=r.map(a=>Ao(a,t));e[t]=o.join("/")}return e}function Eo(r,c){return c.map((e,t)=>({key:typeof e.name=="string"?{...q(),[ce()]:e.name}:e.name,value:nt(r[t])?r[t]:{...q(),[ce()]:r[t]}}))}function Fo(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 Do(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 $o(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 No(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=f=>f.name?typeof f.name=="string"?f.name:f.name[n]??"":"",i=a.map(f=>({...f,values:ko(f.values)})),l=i.filter(f=>s(f)&&f.values&&f.values.length>0);if(h.log(`[generateOptions] Validating inputs - defaultLocale: ${n}, normalizedInputs:`,i.map(f=>({name:f.name,nameType:typeof f.name,extractedName:s(f),values:f.values,valuesIsArray:Array.isArray(f.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 p=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(f=>!f.is_default&&f.exchange_rate);try{h.log("[generateOptions] Step 1: Creating optionGroups...");const f=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($=>nt($)?$:{...q(),[n]:$})})),C=Oo(l.map(L=>L.values)).map((L,O)=>{const $=Eo(L,l),Q=Go(L),It=`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(Pe=>{J[Pe.code]=Ne(w,Pe)}),{id:null,option_code:It,option_values:$,option_name:Q,list_price:g,selling_price:w,price_adjustment:0,stock_quantity:p,safe_stock_quantity:0,is_default:O===0,is_active:!0,sku:"",multi_currency_selling_price:J}}),x=t.form?.options??[],P=L=>L.option_name?typeof L.option_name=="string"?L.option_name:L.option_name[n]??L.option_name.ko??"":"",S=C.map(L=>{const O=P(L),$=x.find(Q=>P(Q)===O);return $?{...L,...$,id:$.id}:L}),N=Ie(S);e.state.setLocal({form:{...t.form,option_groups:f,options:S,has_options:S.length>0,stock_quantity:N},hasChanges:!0}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.generated",{count:S.length})??`${S.length} options have been generated.`),h.log(`[generateOptions] Generated ${S.length} options`)}catch(f){throw h.error("[generateOptions] Error occurred:",f),h.error("[generateOptions] Error message:",f.message),h.error("[generateOptions] Error stack:",f.stack),f}}function To(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=Ie(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 Ro(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 Mo(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 Ho(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 qo(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 Uo(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 Vo(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:[rt(0)]}]},hasChanges:!0}),h.log(`[addAdditionalOption] Added additional option. Total: ${o.length+1}`)}function jo(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 Bo(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 Ko(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 Qo(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 He(r){if(!r)return[];const c=r.values;return Array.isArray(c)?c:c&&typeof c=="object"?Object.values(c):[]}function zo(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=[...He(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(rt(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 Yo(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=[...He(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,p=m&&typeof m=="object"&&!Array.isArray(m)?m:q();u[o]={...u[o],name:{...p,[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,p)=>{m.is_default=p===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 Wo(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=[...He(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)},Jo=["image/jpeg","image/png","image/gif","image/webp"],Xo=10*1024*1024,Zo=20;async function er(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=Zo-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(!Jo.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>Xo){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:b=>{const C=o.state.getLocal()||{};o.state.setLocal({ui:{...C.ui,uploadProgress:{...C.ui?.uploadProgress||{},[l.name]:b}}})}}),p=o.state.getLocal()||{},g=p.form?.images??[],w={...m.data,is_thumbnail:g.length===0,sort_order:g.length};o.state.setLocal({form:{...p.form,images:[...g,w]},hasChanges:!0});const y=o.state.getLocal()||{},{[l.name]:_,...f}=y.ui?.uploadProgress||{};o.state.setLocal({ui:{...y.ui,uploadProgress:f}}),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]:p,...g}=m.ui?.uploadProgress||{};o.state.setLocal({ui:{...m.ui,uploadProgress:g}}),j.error(`[uploadImages] Failed to upload ${l.name}:`,d)}}}function tr(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 or(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 at=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 rr(r,c){const e=r.params||{},t=e.locale,o=e.value;if(!t){at.warn("[updateDescription] Missing locale param");return}const a=window.G7Core;if(!a?.state){at.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 pe(r=""){const e=window.G7Core?.locale?.supported?.()??["ko","en"],t={};for(const o of e)t[o]=r;return t}function nr(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){st(r);return}o.state.setGlobal({pendingNoticeTemplateId:t}),o.modal?.open?.("notice_template_confirm_modal"),k.log(`[confirmSelectNoticeTemplate] Showing confirm modal for template: ${t}`)}function st(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 p=JSON.stringify(d.name),g=l.find(w=>JSON.stringify(w.name)===p);return{key:`field_${m}`,name:d.name,content:g?.content??d.content??pe(),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 ar(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 sr(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 ir(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 cr(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 lr(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 dr(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 ur(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:pe(),content:pe(),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 mr(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 pr(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:pe(),category_id:null,is_default:!1}}),e.dispatch?.({handler:"openModal",target:"modal_save_template"})}const Se={ko:"상세설명참조",en:"See detail description"};function fr(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]=Se[l]??Se.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 gr(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]=Se[l]??Se.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 _r(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:pe(),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 wr(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 yr(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 hr(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 Cr(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 br(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 Ir(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 Sr(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 vr(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 Lr(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 xr(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 Pr(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 Ar(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 kr(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 Or(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 Gr(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 Er=(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})},Fr=(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}))},Dr=(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(p=>p.label_id===a);if(i<0)return;const l=new Date,u=p=>p.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})},$r=(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?.()},Nr=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})}}},Tr=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 it(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function Rr(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=it(),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 Mr(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 Hr(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=it(),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 Y=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 ct(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 W(r){return typeof r=="string"?r:r&&typeof r=="object"?r.ko||r.en||JSON.stringify(r):String(r??"")}function ve(r,c,e){return e?r.t(`sirsoft-ecommerce.enums.${c}.${e}`)||e:String(e??"")}function lt(){const r=window.G7Core;return r||(Y.error("[bulkUpdate] G7Core is not available"),null)}function qr(r,c){const e=lt();if(!e)return;const t=e.state.getLocal()||{},o=e.state.get()||{},a=o.bulkSelectedItems||t.selectedItems||[],n=o.bulkSelectedOptionIds||t.selectedOptionIds||[];Y.log("[bulkUpdate] globalState.bulkSelectedItems:",o.bulkSelectedItems),Y.log("[bulkUpdate] localState.selectedItems:",t.selectedItems),Y.log("[bulkUpdate] Final selectedItems:",a),a.length>0?Ur(e,o,t,a):n.length>0?Vr(e,o,t,n):e.toast.warning(e.t("sirsoft-ecommerce.admin.product.bulk.no_selection"))}function Ur(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 f={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:f[c.bulkPriceMethod]||"add",value:Number(c.bulkPriceValue)||0}}if(c.bulkStockMethod&&c.bulkStockValue!==void 0&&c.bulkStockValue!==null&&c.bulkStockValue!==""){const f={set:"set",increase:"add",decrease:"subtract"};s.stock_quantity={method:f[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||[],p=new Set(t),g=[],w=[];function y(f,b,C){if(b._modified||l.includes(C)){const P={product_id:f.id,option_id:b.id},S=d[C]||[];!s.price_adjustment&&S.includes("price_adjustment")&&(P.price_adjustment=b.price_adjustment),!s.price_adjustment&&S.includes("selling_price")&&(P.price_adjustment=b.price_adjustment),!s.stock_quantity&&S.includes("stock_quantity")&&(P.stock_quantity=b.stock_quantity),S.includes("option_name")&&(P.option_name=b.option_name),S.includes("sku")&&(P.sku=b.sku),S.includes("list_price")&&(P.list_price=b.list_price),S.includes("safe_stock_quantity")&&(P.safe_stock_quantity=b.safe_stock_quantity),S.includes("is_default")&&(P.is_default=b.is_default),S.includes("is_active")&&(P.is_active=b.is_active),Object.keys(P).length>2&&w.push(P)}}a.filter(f=>t.includes(f.id)).forEach(f=>{if(f._modified||i.includes(f.id)){const C={id:f.id},x=String(f.id),P=u[x]||[];!n.sales_status&&P.includes("sales_status")&&(C.sales_status=f.sales_status),!n.display_status&&P.includes("display_status")&&(C.display_status=f.display_status),P.includes("name")&&(C.name=f.name),P.includes("list_price")&&(C.list_price=f.list_price),P.includes("selling_price")&&(C.selling_price=f.selling_price),Object.keys(C).length>1&&g.push(C)}(f.options||[]).forEach(C=>{const x=`${f.id}-${C.id}`;y(f,C,x)})}),m.length>0&&a.forEach(f=>{p.has(f.id)||(f.options||[]).forEach(b=>{const C=`${f.id}-${b.id}`;m.includes(C)&&y(f,b,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(f=>{r.toast.success(r.t("sirsoft-ecommerce.admin.product.bulk.update_success",{products:f?.data?.products_updated||0,options:f?.data?.options_updated||0})),ut(r),dt(r),r.modal.close()}).catch(f=>{Y.error("[bulkUpdateProducts] Error:",f),ct(r,f,"sirsoft-ecommerce.admin.product.bulk.update_error")}).finally(()=>{r.state.set({isProcessing:!1})})}function Vr(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 p=`${d.id}-${m.id}`,g=m._modified||i.includes(p);if(t.includes(p)&&g){const w={product_id:d.id,option_id:m.id},y=s[p]||[];!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})),ut(r),dt(r),r.modal.close()}).catch(d=>{Y.error("[bulkUpdateOptions] Error:",d),ct(r,d,"sirsoft-ecommerce.admin.product.bulk.option_update_error")}).finally(()=>{r.state.set({isProcessing:!1})})}function dt(r){Promise.resolve(r.dataSource.refetch("products")).then(()=>{oo(r,{force:!0})}).catch(c=>{Y.error("[bulkUpdate] Failed to refetch products:",c)})}function ut(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 jr(r,c){const e=lt();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||[],p=t.modifiedProductFields||{},g=t.modifiedOptionFields||{},w=new Set(s);function y(_,f,b){const C=[],x=g[f]||[];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}`),b){if(!o.bulkPriceCondition&&x.includes("price_adjustment")){const P=_.price_adjustment?.toLocaleString?.()??_.price_adjustment;C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_price_adjustment_inline",{value:P})||`Price adjustment: ${P}`)}!o.bulkPriceCondition&&x.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&&x.includes("stock_quantity")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_stock_inline",{value:_.stock_quantity})||`Stock: ${_.stock_quantity}`),x.includes("option_name")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_option_name",{name:W(_.option_name)})||`Option name: ${W(_.option_name)}`),x.includes("sku")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sku",{sku:_.sku})||`SKU: ${_.sku}`),x.includes("list_price")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price",{price:ue(Number(_.list_price??0))})||`List price: ${_.list_price}`),x.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}`),x.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 f=[],b=_._modified||d.includes(_.id),C=String(_.id),x=p[C]||[];if(o.bulkSalesStatus){const S=ve(e,"sales_status",o.bulkSalesStatus);f.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sales_status",{status:S})||`Sales status: ${S}`)}if(o.bulkDisplayStatus){const S=ve(e,"display_status",o.bulkDisplayStatus);f.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_display_status",{status:S})||`Display status: ${S}`)}if(b){if(!o.bulkSalesStatus&&x.includes("sales_status")){const S=ve(e,"sales_status",_.sales_status);f.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sales_status",{status:S})||`Sales status: ${S}`)}if(!o.bulkDisplayStatus&&x.includes("display_status")){const S=ve(e,"display_status",_.display_status);f.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_display_status",{status:S})||`Display status: ${S}`)}x.includes("name")&&f.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_name_changed")||"Name changed"),x.includes("list_price")&&f.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price",{price:ue(Number(_.list_price??0))})||`List price: ${_.list_price?.toLocaleString?.()||_.list_price}`),x.includes("selling_price")&&f.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_selling_price",{price:ue(Number(_.selling_price??0))})||`Selling price: ${_.selling_price?.toLocaleString?.()||_.selling_price}`),l.summary.hasInlineChanges=!0}(f.length>0||u||b)&&l.products.push({id:_.id,name:W(_.name),changes:f.length>0?f.join(", "):b?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 P=u&&!!(o.bulkPriceCondition||o.bulkStockCondition);if(Array.isArray(_.options))_.options.forEach(S=>{const N=`${_.id}-${S.id}`,L=S._modified||m.includes(N),O=y(S,N,L);(O.length>0||P||L)&&l.options.push({productId:_.id,optionId:S.id,productName:W(_.name),optionName:S.option_name_localized||W(S.option_name)||S.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(P){const S=Number(_.options_total_count)||0;S>0&&l.options.push({aggregate:!0,productId:_.id,optionId:0,optionCount:S,productName:W(_.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(f=>{const b=`${_.id}-${f.id}`;if(i.includes(b)){const C=f._modified||m.includes(b),x=y(f,b,C);l.options.push({productId:_.id,optionId:f.id,productName:W(_.name),optionName:f.option_name_localized||W(f.option_name)||f.name||"",changes:x.length>0?x.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((_,f)=>_+(f.aggregate?f.optionCount||0:1),0),e.state.set({bulkConfirmData:l}),Y.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 Br(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 Kr(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 Qr(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 zr(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 Yr(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 Wr(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 Jr(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),p=document.createElement("a");p.href=m;const g=new Date,w=`${g.getFullYear()}-${String(g.getMonth()+1).padStart(2,"0")}-${String(g.getDate()).padStart(2,"0")}`;return p.download=`orders_${w}.xlsx`,document.body.appendChild(p),p.click(),document.body.removeChild(p),window.URL.revokeObjectURL(m),{success:!0}}function Xr(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 Zr(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 en(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 tn(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 on(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 qe=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 rn(r,c){const e=r.params||{},{actionId:t,row:o}=e,a=window.G7Core;if(!t||!o?.id)return qe.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){qe.error("[handleProductRowAction] 삭제 가능 여부 확인 실패:",n),a?.state?.setLocal({productList:{deleteTargetProduct:o,canDelete:!1,deleteBlockReason:"삭제 가능 여부를 확인할 수 없습니다.",relatedData:null},ui:{showDeleteModal:!0}})}break;default:return qe.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 mt(){return typeof document>"u"?"":document.querySelector('input[name="batchTrackingNumber"]')?.value?.trim()??""}function nn(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 an(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 sn(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 cn(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||mt();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 p=t.cancelPg??!0,g=e.dataSource?.get?.("order")?.data,w=g?.options||[],y=g?.currency_snapshot?.base_currency??null,_=o.map(f=>{const b=w.find(x=>x.id===f);if(!b||b.option_status==="cancelled")return null;const C=b.quantity??1;return{id:b.id,product_name:b.product_name,product_option_name:b.product_option_name,thumbnail_url:b.product_snapshot?.thumbnail_url||"",unit_price:b.unit_price,unit_price_formatted:b.unit_price_formatted??ue(Number(b.unit_price??0),y),quantity:C,subtotal_formatted:ue(Number(b.unit_price??0)*C,y),cancel_quantity:C,option_status:b.option_status,option_status_label:b.option_status_label||b.option_status}}).filter(Boolean);if(_.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_cancellable_items")||"취소할 수 있는 상품이 없습니다. 이미 취소된 상품은 제외됩니다.");return}if(p){e.state.setLocal({cancelItems:_,cancelReason:"",cancelReasonDetail:"",cancelPg:!0,refundPriority:"pg_first",refundEstimate:null,refundLoading:!1,isCancelling:!1,cancelError:null,cancelValidationErrors:null}),e.modal?.open?.("modal_cancel_order");const f=g?.order_number||"";f&&e.dispatch?.({handler:"sirsoft-ecommerce.estimateRefundAmount",params:{orderId:f,cancelItems:_}})}else{const f={};for(const b of _)f[b.id]=b.quantity;e.state.setLocal({bulkConfirmItems:_,changeQuantities:f,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 p of o){const g=u.find(w=>w.id===p);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 ln(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||mt(),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||[],p={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&&(p.carrier_id=s),i&&(p.tracking_number=i);try{const g=await e.api.patch(`/api/modules/sirsoft-ecommerce/admin/orders/${t}/options/bulk-status`,p);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 dn(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 un(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 mn(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_charge_amount??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 pn(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 Ue=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 fn(){K&&(clearTimeout(K),K=null,Ue.log("[clearCancelOrderTimers] debounce 타이머 정리 완료"))}function gn(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{Ve(e,a)}finally{window.__g7ActionContext=m}},500)}async function _n(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,cancelItems:o}=r.params||{};t&&await Ve(e,t,o)}async function Ve(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){Ue.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 wn(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{Ve(e,o)}finally{window.__g7ActionContext=n}},300)}async function yn(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){Ue.error("[executeCancelOrder] 실패:",m);const p=m?.response?.data||m?.data,w=(m?.response?.status||m?.status)===422,y=p?.errors?.detail,_=p?.errors??null,f=y||p?.message||m?.message||"",b=e.t?.("sirsoft-ecommerce.admin.order.detail.modal.cancel.cancel_failed")??"주문 취소에 실패했습니다.";w?(e.state.setLocal({isCancelling:!1,cancelError:null,cancelValidationErrors:_}),e.toast?.error?.(f||b)):(e.state.setLocal({isCancelling:!1,cancelError:f||b,cancelValidationErrors:null}),e.toast?.error?.(f||b))}}const pt=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 Le(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}function hn(){R&&(clearTimeout(R),R=null)}function Cn(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 bn(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 In(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 Sn(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 vn(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 Ln(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,maxQuantity:o,orderId:a}=r.params||{};if(!t||!o||!a&&!Le(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 xn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};!t&&!Le(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){pt.error("[estimateUserRefund] 실패:",s),r.state.setLocal({refundLoading:!1,refundEstimate:null}),r.toast?.error?.(r.t?.("mypage.order_detail.cancel_modal.estimate_failed")??"환불 예상금액 계산에 실패했습니다.")}}function Pn(r,c){const e=window.G7Core;if(!e?.state)return;const{priority:t,orderId:o}=r.params||{};if(!t||!o&&!Le(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 An(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};if(!t&&!Le(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,p=!!(d&&m),g=p?`/api/modules/sirsoft-ecommerce/guest/orders/${m}/cancel`:`/api/modules/sirsoft-ecommerce/user/orders/${t}/cancel`,w=p?{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){pt.error("[executeUserCancelOrder] 실패:",d);const m=d?.response?.data||d?.data,p=m?.message||d?.message||"",g=m?.errors??null,w=e.t?.("mypage.order_detail.cancel_modal.cancel_failed")??"주문 취소에 실패했습니다.";e.state.setLocal({isCancelling:!1,cancelError:p||w,cancelValidationErrors:g}),e.toast?.error?.(p||w)}}const ft=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 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,optionId:o}=r.params||{};if(!o||!t&&!kn(e)){ft.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){ft.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 gt=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 Gn(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}async function En(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{},o=Gn(e);if(!t&&!o){gt.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){gt.error("[changeShippingAddress] 실패:",i),e.state.setLocal({isSubmittingAddress:!1});const l=i?.response?.data?.message||i?.data?.message||i?.message||"배송지 변경에 실패했습니다.";e.toast?.error?.(l)}}const je=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 Fn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,optionId:o,productId:a}=r.params||{};if(!t||!o||!a){je.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 p=0;pconsole.log("[Ecom:ShippingPolicyForm]",...r),warn:(...r)=>console.warn("[Ecom:ShippingPolicyForm]",...r),error:(...r)=>console.error("[Ecom:ShippingPolicyForm]",...r)},Dn=["fixed","conditional_free","per_quantity","per_weight","per_volume","per_volume_weight","per_amount"],$n=["conditional_free"],Nn=["range_amount","range_quantity","range_weight","range_volume","range_volume_weight"],Tn=["api"],Rn=["per_quantity","per_weight","per_volume","per_volume_weight","per_amount"],Mn={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:Dn.includes(r),showFreeThreshold:$n.includes(r),showRanges:Nn.includes(r),showApiSettings:Tn.includes(r),showUnitValue:Rn.includes(r)}}function G(){const r=window.G7Core;return r?.state?r:(I.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 Hn(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),I.log("[initShippingPolicyForm]",o?"Edit":"Copy","mode, countries:",i.length,"first charge_policy:",u)}else{const i=te("fixed");Object.assign(s,i),I.log("[initShippingPolicyForm] Create mode, default flags")}E(c,s)}function qn(r,c){const t=(r.params||{}).country_code??"";if(!t){I.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)){I.warn("[addCountrySetting] Duplicate country_code:",t);return}const n={country_code:t,...Mn};a.push(n);const s=a.length-1,i=te(n.charge_policy),l={"form.country_settings":a,activeCountryTab:s,...i};E(c,l),I.log("[addCountrySetting] Added country:",t,"index:",s)}function Un(r,c){const e=r.params||{},t=Number(e.index);if(isNaN(t)||t<0){I.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){I.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"),p={"form.country_settings":a,activeCountryTab:l,rangeErrors:u,...m};E(c,p),I.log("[removeCountrySetting] Removed index:",t,"country:",s,"newTab:",l)}function Vn(r,c){const e=r.params||{},t=Number(e.index);if(isNaN(t)||t<0){I.warn("[switchCountryTab] Invalid index:",e.index);return}const o=G();if(!o)return;const a=F(o);if(t>=a.length){I.warn("[switchCountryTab] Index out of bounds:",t);return}const n=a[t],s=te(n.charge_policy),i={activeCountryTab:t,...s};E(c,i),I.log("[switchCountryTab] Switched to tab:",t,"country:",n.country_code,"charge_policy:",n.charge_policy)}function jn(r,c){const e=r.params||{},t=e.field??"",o=e.value;if(!t){I.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){I.warn("[updateCountryField] Index out of bounds:",s);return}i[s]={...i[s],[t]:o},E(c,{"form.country_settings":i}),I.log("[updateCountryField] Updated",t,"=",o,"at index:",s)}function Bn(r,c){const e=r.params||{},t=e.value??"",o=Number(e.index??0);if(!t){I.warn("[onChargePolicyChange] charge_policy value is empty");return}const a=G();if(!a)return;const n=te(t);I.log("[onChargePolicyChange]",t,n,"countryIndex:",o);const s=F(a),i=s[o];if(!i){I.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 Kn(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){I.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}),xe(o,c,t,s,n.country_code),I.log("[addRangeTier] Added tier, total:",s.length)}function Qn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=Number(e.tierIndex);if(isNaN(o)||o<0){I.warn("[removeRangeTier] Invalid tierIndex:",e.tierIndex);return}const a=G();if(!a)return;const n=F(a),s=n[t];if(!s){I.warn("[removeRangeTier] No country setting at index:",t);return}const i=[...s.ranges?.tiers??[]];if(o>=i.length){I.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}),xe(a,c,t,i,s.country_code),I.log("[removeRangeTier] Removed tier:",o,"remaining:",i.length)}function zn(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){I.warn("[updateRangeTierField] Invalid tierIndex:",e.tierIndex);return}if(!["min","max","fee"].includes(a)){I.warn("[updateRangeTierField] Invalid field:",a);return}const s=G();if(!s)return;const i=F(s),l=i[t];if(!l){I.warn("[updateRangeTierField] No country setting at index:",t);return}const u=[...l.ranges?.tiers??[]];if(o>=u.length){I.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}),xe(s,c,t,u,l.country_code)}function Yn(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??[];xe(o,c,t,s,n.country_code)}function xe(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){I.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}),I.log("[removeExtraFeeRow] Removed index:",o,"remaining:",i.length)}function Xn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=e.settings;if(!o||!Array.isArray(o)){I.warn("[applyExtraFeeTemplate] Invalid settings:",o);return}const a=G();if(!a)return;const n=F(a),s=n[t];if(!s){I.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),I.log("[applyExtraFeeTemplate] Applied template with",o.length,"rows")}function Zn(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}),I.log("[updateUnitValue] Updated unit_value:",t))}function ea(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}),I.log("[addApiRequestField] Added field, total:",s.length)}function ta(r,c){const e=r.params||{},t=Number(e.fieldIndex),o=e.value??"";if(isNaN(t)||t<0){I.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}),I.log("[updateApiRequestField] Updated index:",t,"value:",o))}function oa(r,c){const e=r.params||{},t=Number(e.fieldIndex);if(isNaN(t)||t<0){I.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}),I.log("[removeApiRequestField] Removed index:",t,"remaining:",l.length))}function ra(r,c){const t=(r.params||{}).field??"";if(!t){I.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}),I.log("[toggleApiRequestField] Toggled:",t,"selected:",d)}function na(r,c){const e=r.params||{},t=e.field??"";if(!t){I.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}),I.log("[updateApiConfigField] Updated",t,"at index:",n)}function aa(r,c){const e=r.params||{},t=e.field??"";if(!t){I.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}),I.log("[updateApiFieldMap] Mapped",t,"→",l||"(removed)")}function sa(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 ia(r,c){const e=r.params||{},t=Number(e.feeIndex),o=e.field,a=e.value;if(isNaN(t)||t<0){I.warn("[updateExtraFeeField] Invalid feeIndex:",e.feeIndex);return}if(!["zipcode","fee","region"].includes(o)){I.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}),I.log("[updateExtraFeeField] Updated index:",t,o,"=",m)}const fe=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 _t(r,c){for(const e of r){if(e.id===c)return e;if(e.children&&e.children.length>0){const t=_t(e.children,c);if(t)return t}}return null}function wt(r){return new URLSearchParams(window.location.search).get(r)}function ca(r){return window.G7Core?.state?.getDataSource?.(r)}async function la(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function da(r,c){const e=window.G7Core,t=wt("id"),o=wt("mode");if(!t){fe.log("[initCategoryFromUrl] No id parameter in URL");return}if(!e?.state?.set){fe.warn("[initCategoryFromUrl] G7Core.state.set not available");return}const a=await la("categories",l=>l?.data?.data);if(!a){fe.warn("[initCategoryFromUrl] categories data not available after waiting");return}const n=parseInt(t,10),s=_t(a,n);if(!s){fe.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}),fe.log("[initCategoryFromUrl] Category initialized from URL:",{id:n,mode:i})}const ge=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 yt(r){return new URLSearchParams(window.location.search).get(r)}function ua(r){return window.G7Core?.state?.getDataSource?.(r)}async function ma(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function pa(r,c){const e=window.G7Core,t=yt("id"),o=yt("mode");if(!t){ge.log("[initBrandFromUrl] No id parameter in URL");return}if(!e?.state?.set){ge.warn("[initBrandFromUrl] G7Core.state.set not available");return}const a=await ma("brands",l=>l?.data?.data);if(!a){ge.warn("[initBrandFromUrl] brands data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){ge.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}),ge.log("[initBrandFromUrl] Brand initialized from URL:",{id:n,mode:i})}const _e=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 ht(r){return new URLSearchParams(window.location.search).get(r)}function fa(r){return window.G7Core?.state?.getDataSource?.(r)}async function ga(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function _a(r,c){const e=window.G7Core,t=ht("id"),o=ht("mode");if(!t){_e.log("[initCommonInfoFromUrl] No id parameter in URL");return}if(!e?.state?.set){_e.warn("[initCommonInfoFromUrl] G7Core.state.set not available");return}const a=await ga("commonInfos",l=>l?.data);if(!a){_e.warn("[initCommonInfoFromUrl] commonInfos data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){_e.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}),_e.log("[initCommonInfoFromUrl] Common info initialized from URL:",{id:n,mode:i})}const we=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 Ct(r){return new URLSearchParams(window.location.search).get(r)}function wa(r){return window.G7Core?.state?.getDataSource?.(r)}async function ya(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function ha(r,c){const e=window.G7Core,t=Ct("id"),o=Ct("mode");if(!t){we.log("[initNoticeFromUrl] No id parameter in URL");return}if(!e?.state?.set){we.warn("[initNoticeFromUrl] G7Core.state.set not available");return}const a=await ya("templates",l=>l?.data);if(!a){we.warn("[initNoticeFromUrl] templates data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){we.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}),we.log("[initNoticeFromUrl] Template initialized from URL:",{id:n,mode:i})}const oe={updateProductField:Gt,updateOptionField:Et,calculateCurrencyPrices:se,initPreferredCurrency:Mt,initPreferredShippingCountry:Vt,setDateRange:Bt,setDefaultOption:Kt,toggleOption:Qt,toggleProductOptions:Xe,toggleAllOptionsInRow:zt,getProductOptionStates:Yt,syncProductSelection:Wt,loadExpandedOptions:De,retryExpandedOptions:ro,generateCopyProductCode:Jt,copyProduct:Xt,selectCategory:ao,selectCategoryMobile:so,addCategoryToSelection:io,removeCategoryFromSelection:co,getCategoryBreadcrumb:lo,validateCategoryPath:mo,initCategoryInfosFromProduct:uo,getBrandName:po,getBrandDescription:fo,updatePrice:yo,calculateTotalOptionStock:ho,validatePriceRelation:Co,addOptionInput:Fo,removeOptionInput:Do,updateOptionInput:$o,generateOptions:No,deleteOption:To,applyOptionAddTool:Ro,addRequiredItem:Mo,updateRequiredItem:Ho,removeRequiredItem:qo,reorderRequiredItems:Uo,addAdditionalOption:Vo,updateAdditionalOption:jo,removeAdditionalOption:Bo,reorderAdditionalOptions:Ko,clearAdditionalOptions:Qo,addAdditionalOptionValue:zo,updateAdditionalOptionValue:Yo,removeAdditionalOptionValue:Wo,uploadImages:er,setThumbnail:tr,reorderImages:or,updateDescription:rr,confirmSelectNoticeTemplate:nr,selectNoticeTemplate:st,updateNoticeItem:ar,removeNoticeItem:sr,reorderNoticeItems:ir,fillNoticeWithValue:cr,switchNoticeMode:lr,updateNewTemplateName:dr,addNoticeItem:ur,updateNoticeItemName:mr,saveAsNoticeTemplate:pr,confirmSaveNoticeTemplate:_r,fillTemplateFieldsWithDetailReference:fr,fillNoticeItemsWithDetailReference:gr,toggleLabel:wr,generateProductCode:yr,getShippingPolicyInfo:hr,getCommonInfoContent:Cr,updateShoppingIntegration:br,updateShippingType:Ir,updateIdentificationCode:Sr,openLabelPeriodModal:vr,saveLabelPeriod:Lr,removeLabelPeriod:xr,updateActivityLogSort:Pr,updateActivityLogPerPage:Ar,setDefaultShippingPolicy:kr,setLabelDatePreset:Gr,toggleDefaultShippingPolicy:Or,toggleLabelAssignment:Er,saveLabelSettings:Tr,deleteLabel:Nr,updateLabelPeriodInline:Fr,setLabelDatePresetInline:Dr,confirmUncheckLabel:$r,removeDescriptionLocale:Rr,showAddLocaleModal:Mr,addDescriptionLocale:Hr,setDefaultOptionFromGrid:vo,addOptionRow:xo,updateFormOptionField:Lo,recalculateOptionPriceAdjustments:Po,bulkUpdate:qr,buildConfirmData:jr,buildOrderColumns:Br,toggleArrayValue:Kr,toggleVisibleFilter:Qr,syncOrderSelection:zr,handleOrderRowAction:Yr,processOrderBulkAction:Wr,buildOrderBulkConfirmData:tn,executeOrderBulkAction:on,downloadOrderExcel:Jr,saveVisibleColumns:Xr,loadVisibleColumns:Zr,loadVisibleFilters:en,handleProductRowAction:rn,initOrderDetailForm:nn,toggleProductSelection:an,toggleAllProducts:sn,buildOrderDetailBulkConfirmData:cn,processOrderDetailBulkChange:ln,saveAdminMemo:dn,updateChangeQuantity:un,openConfirmDepositModal:mn,confirmDeposit:pn,initShippingPolicyForm:Hn,addCountrySetting:qn,removeCountrySetting:Un,switchCountryTab:Vn,updateCountryField:jn,onChargePolicyChange:Bn,addRangeTier:Kn,removeRangeTier:Qn,updateRangeTierField:zn,validateRangeTiers:Yn,addExtraFeeRow:Wn,removeExtraFeeRow:Jn,applyExtraFeeTemplate:Xn,updateUnitValue:Zn,addApiRequestField:ea,updateApiRequestField:ta,removeApiRequestField:oa,toggleApiRequestField:ra,updateApiConfigField:na,updateApiFieldMap:aa,testShippingApi:sa,updateExtraFeeField:ia,updateCancelQuantity:gn,estimateRefundAmount:_n,changeRefundPriority:wn,executeCancelOrder:yn,clearCancelOrderTimers:fn,toggleItemSelection:Cn,toggleSelectAllItems:bn,initUserCancelItems:In,toggleUserCancelItem:Sn,toggleUserCancelSelectAll:vn,updateUserCancelQuantity:Ln,estimateUserRefund:xn,changeUserRefundPriority:Pn,executeUserCancelOrder:An,clearUserCancelOrderTimers:hn,confirmOrderOption:On,changeShippingAddress:En,submitReview:Fn,initCategoryFromUrl:da,initBrandFromUrl:pa,initCommonInfoFromUrl:_a,initNoticeFromUrl:ha},V="sirsoft-ecommerce",ye=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 bt(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})}),ye.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})}),ye.log(`${Object.keys(oe).length} handler(s) registered:`,Object.keys(oe).map(n=>`${V}.${n}`))):(e++,e<=t?(ye.warn(`ActionDispatcher not found, retrying... (${e}/${t})`),setTimeout(o,100)):ye.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};o()}else ye.warn("ActionDispatcher not found, handlers not registered")}function Be(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>bt(!0));else{const r=!!window.G7Core?.getActionDispatcher?.();bt(!r)}}return Be(),typeof window<"u"&&(window.__SirsoftEcommerce={identifier:V,handlers:Object.keys(oe),initModule:Be}),Ae.initModule=Be,Object.defineProperty(Ae,Symbol.toStringTag,{value:"Module"}),Ae}({}); +var SirsoftEcommerce=function(ke){"use strict";const Et={KRW:0,JPY:0},Dt={KRW:"₩",USD:"$",JPY:"¥",CNY:"元",EUR:"€",GBP:"£"},Ft={KRW:1e3,JPY:100};function $t(r){if(!r)return 1;const c=r.base_unit??Ft[r.code]??1;return Math.max(1,c)}function Nt(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 de(r,c){const e=typeof c=="string"?{code:c}:c,t=e.code,o=e.symbol&&e.symbol.length>0?e.symbol:Dt[t]??"",a=e.decimal_places??Et[t]??2,n=r.toLocaleString(void 0,{minimumFractionDigits:a,maximumFractionDigits:a});return o?t==="KRW"||o==="₩"||o==="원"?n+"원":o+n:n+" "+t}function Tt(){try{const e=(window.G7Core?.state?.get?.()||{})?.modules?.["sirsoft-ecommerce"]?.language_currency;return Array.isArray(e?.currencies)?e.currencies:[]}catch{return[]}}function ue(r,c){const e=Tt(),t=c||e.find(o=>o.is_default)?.code;return t?de(r,e.find(o=>o.code===t)??t):r.toLocaleString()}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=$t(a),s={};for(const i of t){const l=i.code;if(i.is_default??!1)s[l]={price:o,formatted:de(o,i),is_default:!0,editable:!0};else if(i.exchange_rate&&i.exchange_rate>0){const d=o/n*i.exchange_rate,m=Nt(d,i.rounding_unit||"0.01",i.rounding_method||"round");s[l]={price:m,formatted:de(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 Rt(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 p=[...u._local?.productFieldErrors||[]];const f=l.map(C=>{if(String(C.id)===String(t)){const x=C[o];if(typeof n=="object"&&n!==null?JSON.stringify(x)===JSON.stringify(n):String(x)===String(n))return z.log(`[updateProductField] No change for product ${t}.${o}, skipping`),C;let S=n;const N=parseFloat(n)||0;if(o==="selling_price"){const O=parseFloat(C.list_price)||0;O>0&&N>O?(p.some(Q=>Q.productId===String(t)&&Q.field===o)||p.push({productId:String(t),field:"selling_price",message:"판매가는 정가를 초과할 수 없습니다."}),S=O):p=p.filter($=>!($.productId===String(t)&&$.field===o))}else p=p.filter(O=>!(O.productId===String(t)&&O.field===o));const L={...C,[o]:S,_modified:!0};return o==="selling_price"&&d&&Array.isArray(d)&&(L.multi_currency_selling_price=se({basePrice:S,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:f}});const y=s.state.getLocal()||{},w=new Set(y.modifiedProductIds||[]);w.add(t);const _={...y.modifiedProductFields||{}},g=String(t),b=new Set(_[g]||[]);b.add(o),_[g]=Array.from(b),s.state.setLocal({modifiedProductIds:Array.from(w),modifiedProductFields:_,productFieldErrors:p}),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 Mt(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 f=[...d._local?.optionFieldErrors||[]],y=!1,w=!1;const _=u.map(N=>{if(String(N.id)===String(t)){const L=N.options||[],O=L.findIndex(F=>String(F.id)===String(o));if(O===-1)return B.warn("[updateOptionField] Option not found:",o),N;w=!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 Ae=parseFloat(n)||0;if(a==="selling_price"){const F=parseFloat($.list_price)||0;F>0&&Ae>F?(f.some(he=>he.productId===String(t)&&he.optionId===String(o)&&he.field===a)||f.push({productId:String(t),optionId:String(o),field:"selling_price",message:"판매가는 정가를 초과할 수 없습니다."}),J=F):f=f.filter(H=>!(H.productId===String(t)&&H.optionId===String(o)&&H.field===a))}else f=f.filter(F=>!(F.productId===String(t)&&F.optionId===String(o)&&F.field===a));const re=[...L],ne={...re[O],[a]:J,_modified:!0};let ae=null;if(a==="selling_price"){const F=parseFloat(J)||0;if($.is_default===!0)ae=F,ne.price_adjustment=0;else{const H=parseFloat(N.selling_price)||0;ne.price_adjustment=F-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 F=0;FH.is_active===!1?F:F+(parseInt(H.stock_quantity,10)||0),0),ze=Ye,B.log(`[updateOptionField] Updated product stock to option sum: ${ze}`));const We={...N,options:re,stock_quantity:ze,option_stock_sum:Ye,_modified:!0};return ae!==null&&(We.selling_price=ae,m&&Array.isArray(m)&&(We.multi_currency_selling_price=se({basePrice:ae,currencies:m})),y=!0,B.log(`[updateOptionField] Reverse-synced product ${t} selling_price to ${ae} from default option`)),We}return N});if(!w)return;i.dataSource.set(s,{...l,data:{...l.data,data:_}});const g=i.state.getLocal()||{},b=new Set(g.modifiedOptionIds||[]),C=`${t}-${o}`;b.add(C);const x={...g.modifiedOptionFields||{}},P=new Set(x[C]||[]);P.add(a),x[C]=Array.from(P);const S={modifiedOptionIds:Array.from(b),modifiedOptionFields:x,optionFieldErrors:f};if(y){const N=new Set(g.modifiedProductIds||[]);N.add(t);const L={...g.modifiedProductFields||{}},O=String(t),$=new Set(L[O]||[]);$.add("selling_price"),L[O]=Array.from($),S.modifiedProductIds=Array.from(N),S.modifiedProductFields=L}i.state.setLocal(S),B.log(`[updateOptionField] Updated option ${t}/${o}.${a} =`,n)}const Ht="g7_preferred_currency",qt="g7_locale",Je=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 Ut(r){const c=window.G7Core;c?.state?.set?c.state.set(r):Je.warn("G7Core.state.set not available")}function Vt(){try{return localStorage.getItem(qt)}catch{return null}}function Xe(){try{const e=(window.G7Core?.state?.get?.()||{})?.modules?.["sirsoft-ecommerce"]?.language_currency;return Array.isArray(e?.currencies)?e.currencies:[]}catch{return[]}}function jt(r){if(!r)return null;const c=r.toLowerCase().split("-")[0].split("_")[0];if(!c)return null;const t=Xe().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 Bt(){try{const r=window.G7Config;return r?.user?r.user:(window.G7Core?.state?.get?.()||{}).currentUser||null}catch{return null}}function Kt(r,c){const e=r?.params?.defaultCurrency||Xe().find(l=>l?.is_default)?.code||"",t=l=>typeof l=="string"&&/^[A-Z]{3}$/.test(l),o=Bt(),a=r?.params?.accountCurrency??o?.ecommerce_preferred_currency;let n=null;try{n=localStorage.getItem(Ht)}catch{}const s=a?null:jt(Vt()),i=t(a)?a:t(n)?n:t(s)?s:t(e)?e:"KRW";Ut({preferredCurrency:i}),Je.log("Preferred currency initialized:",i)}const Qt="g7_preferred_shipping_country",Ze=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 zt(r){const c=window.G7Core;c?.state?.set?c.state.set(r):Ze.warn("G7Core.state.set not available")}function Yt(){try{const r=window.G7Config;return r?.user?r.user:(window.G7Core?.state?.get?.()||{}).currentUser||null}catch{return null}}function Wt(r,c){const e=r?.params?.defaultCountry||"KR",t=i=>typeof i=="string"&&/^[A-Z]{2}$/.test(i),o=Yt(),a=r?.params?.accountCountry??o?.ecommerce_preferred_shipping_country;let n=null;try{n=localStorage.getItem(Qt)}catch{}const s=t(a)?a:t(n)?n:t(e)?e:"KR";zt({preferredShippingCountry:s}),Ze.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 Jt(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 Xt(r){const{preset:c}=r.params||{preset:"today"},{startDate:e,endDate:t}=Jt(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 Zt(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(y=>{if(String(y.id)!==String(t))return y;const w=y.options||[];if(w.length===0)return y;const _=w.find(P=>String(P.id)===String(o));if(!_)return ie.warn("[setDefaultOption] Option not found:",o),y;const g=w.map(P=>{const S=String(P.id)===String(o);return{...P,is_default:S,_modified:S?!0:P._modified}}),b=_.list_price,C=_.selling_price;let x=y.multi_currency_selling_price;return u&&Array.isArray(u)&&(x=se({basePrice:C,currencies:u})),ie.log(`[setDefaultOption] Set option ${o} as default for product ${t}. Syncing prices: list_price=${b}, selling_price=${C}`),{...y,options:g,list_price:b,selling_price:C,multi_currency_selling_price:x,_modified:!0}});n.dataSource.set(a,{...s,data:{...s.data,data:d}});const m=n.state.getLocal()||{},p=new Set(m.modifiedProductIds||[]);p.add(String(t));const f=new Set(m.modifiedOptionIds||[]);f.add(`${t}-${o}`),n.state.setLocal({modifiedProductIds:Array.from(p),modifiedOptionIds:Array.from(f)}),ie.log(`[setDefaultOption] Option ${o} set as default for product ${t}`)}const Oe=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)},me=(r,c)=>`${r}-${c}`,Ge=r=>r.split("-")[0];function eo(r,c){const e=r.params||{},{productId:t,optionId:o,checked:a}=e,n=window.G7Core;if(!n?.state){Oe.warn("[toggleOption] G7Core.state를 사용할 수 없습니다.");return}const s=n.state.getLocal(),i=s.selectedOptionIds||[],l=s.selectedItems||[],u=me(t,o);let d;a?i.includes(u)?d=i:d=[...i,u]:d=i.filter(g=>g!==u);const y=(n.dataSource?.get("products")?.data?.data||[]).find(g=>String(g.id)===String(t))?.options||[];let w=[...l];const _=Number(t);y.length>0&&(y.map(C=>me(t,C.id)).filter(C=>d.includes(C)).length===y.length?w.includes(_)||w.push(_):w=w.filter(C=>C!==_)),n.state.setLocal({selectedOptionIds:d,selectedItems:w})}function et(r,c){const e=r.params||{},{productId:t,options:o,checked:a}=e,n=window.G7Core;if(!n?.state){Oe.warn("[toggleProductOptions] G7Core.state를 사용할 수 없습니다.");return}const s=n.state.getLocal(),i=s.selectedOptionIds||[],l=s.selectedItems||[],u=(o||[]).map(f=>me(t,f.id));let d,m=[...l];const p=Number(t);a?(d=[...i.filter(y=>Ge(y)!==String(t)),...u],m.includes(p)||m.push(p)):(d=i.filter(f=>Ge(f)!==String(t)),m=m.filter(f=>f!==p)),n.state.setLocal({selectedOptionIds:d,selectedItems:m})}function to(r,c){return et(r)}function oo(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=>me(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 ro(r,c){const e=r.params||{},{selectedIds:t}=e,o=window.G7Core;if(!o?.state){Oe.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(p=>!n.includes(p)),d=n.filter(p=>!t.includes(p));let m=[...s];for(const p of u){const f=l.find(y=>Number(y.id)===p);if(f?.options)for(const y of f.options){const w=me(p,y.id);m.includes(w)||m.push(w)}}for(const p of d)m=m.filter(f=>Ge(f)!==String(p));o.state.setLocal({selectedItems:t,selectedOptionIds:m})}const Ce=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 no(r,c){const e=window.G7Core;if(!e?.state||!e?.api){Ce.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}),Ce.log(`[generateCopyProductCode] Generated (parent): ${o}`);else{const s=e.state.getLocal()||{};e.state.setLocal({ui:{...s.ui,copyOptions:{...s.ui?.copyOptions||{},newProductCode:o}}}),Ce.log(`[generateCopyProductCode] Generated (local): ${o}`)}}catch(t){Ce.error("[generateCopyProductCode] Failed:",t),e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.messages.code_generate_error")??"Failed to generate product code.")}}const tt=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 ao(r,c){const e=window.G7Core;if(!e?.state){tt.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()}`;tt.log("[copyProduct] Navigating to:",u),e.navigate?e.navigate(u):c.navigate?c.navigate(u):window.location.href=u}const be=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)},so="/api/modules/sirsoft-ecommerce/admin/products/options",Ee=100,De=new Set;function io(r,c){const t=r?.dataSource?.get?.(c)?.data?.data;return Array.isArray(t)?t:[]}function ot(r,c,e){const t=r?.dataSource?.get?.(c),o=t?.data?.data;if(!t||!Array.isArray(o)){be.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 co(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 Fe(r,c){const e=r?.state?.getLocal?.()||{},t=c(e.optionsLoadingIds||[],e.optionsErrorIds||[]);r.state.setLocal({optionsLoadingIds:t.loading,optionsErrorIds:t.errored})}function $e(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){be.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=io(a,t),i=new Map(s.map(d=>[String(d?.id),d])),l=[],u={};n.forEach(d=>{const m=String(d),p=i.get(m);if(p&&!(!o&&Array.isArray(p.options))){if(Number(p.options_total_count)===0){u[m]=[];return}De.has(m)||l.push(m)}}),Object.keys(u).length>0&&ot(a,t,u),l.length!==0&&(l.length>Ee&&(be.warn(`[loadExpandedOptions] Requested ${l.length} products, truncating to ${Ee}`),l.length=Ee),l.forEach(d=>De.add(d)),Fe(a,(d,m)=>({loading:Array.from(new Set([...d,...l])),errored:m.filter(p=>!l.includes(p))})),a.api.get(so,{params:{product_ids:l.map(d=>Number(d))}}).then(d=>{const p=(d?.data||{}).options||{},f={};l.forEach(y=>{const w=p[y];f[y]=Array.isArray(w)?w:[]}),ot(a,t,f),co(a,f),Fe(a,(y,w)=>({loading:y.filter(_=>!l.includes(_)),errored:w.filter(_=>!l.includes(_))}))}).catch(d=>{be.error("[loadExpandedOptions] Failed to load options:",d),Fe(a,(p,f)=>({loading:p.filter(y=>!l.includes(y)),errored:Array.from(new Set([...f,...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=>De.delete(d))}))}function lo(r,c={}){const e=r?.state?.getLocal?.()?.expandedRows||[];!Array.isArray(e)||e.length===0||$e({params:{productIds:e,force:c.force===!0,dataSourceId:c.dataSourceId||"products"}})}function uo(r,c){const e=r.params||{};$e({params:{productId:e.productId,productIds:e.productIds,force:!0,dataSourceId:e.dataSourceId}})}const A=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 rt(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=rt(o.children,c,e,[...t,a]);if(n)return n}}return null}function mo(r,c,e){return r.map(t=>{if(t.breadcrumb)return{id:t.id,breadcrumb:t.breadcrumb};const o=rt(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 po(r,c){const e=r.params||{},t=e.depth,o=e.category;if(t===void 0||!o){A.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){A.log(`[selectCategory] Same category already selected at depth ${t}, skipping`);return}const d=u.map((m,p)=>pdg0?t+2:p;a.mergeState({selectedCategories:f,categoryStep:w}),A.log(`[selectCategoryMobile] Selected category at depth ${t} (isolated):`,o.id,"nextStep:",w);return}const n=window.G7Core;if(!n?.state){A.warn("[selectCategoryMobile] G7Core.state API is not available");return}const s=n.state.getLocal()||{},l=(s.ui?.selectedCategories||[null,null,null,null]).map((m,p)=>p0?t+2:s.ui?.categoryStep;n.state.setLocal({ui:{...s.ui,selectedCategories:l,categoryStep:d}}),A.log(`[selectCategoryMobile] Selected category at depth ${t}:`,o.id,"nextStep:",d)}function go(r,c){A.log("[addCategoryToSelection] START");const e=window.G7Core;if(!e?.state){A.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||[];A.log("[addCategoryToSelection] Current state:",{selectedCats:n.map(g=>g?.id??null),currentCategoryIds:t.form?.category_ids,currentSelectedCategoryInfos:t.selectedCategoryInfos,usingIsolatedContext:!!a});const s=n.filter(Boolean).pop();if(!s){A.warn("[addCategoryToSelection] No category selected");return}A.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)){A.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(g=>g.name?.[o]||g.name?.ko||"").join(" > "),u=[...i,s.id],d=t.form?.primary_category_id||s.id,p=(e.state.getDataSources?.()||{}).categories?.data?.data||[];let f=t.selectedCategoryInfos||[];const y=t.form?.categories||[];f.length===0&&y.length>0&&(A.log("[addCategoryToSelection] Initializing from form.categories:",y.length,"items"),f=mo(y,p,o));const w=[...f,{id:s.id,breadcrumb:l}];A.log("[addCategoryToSelection] Before setLocal:",{newCategoryIds:u,primaryCategoryId:d,newInfos:w});try{e.state.setLocal({form:{...t.form,category_ids:u,primary_category_id:d},selectedCategoryInfos:w,hasChanges:!0}),A.log("[addCategoryToSelection] setLocal SUCCESS")}catch(g){A.error("[addCategoryToSelection] setLocal FAILED:",g)}const _=e.state.getLocal()||{};A.log("[addCategoryToSelection] After setLocal:",{category_ids:_.form?.category_ids,selectedCategoryInfos:_.selectedCategoryInfos}),A.log(`[addCategoryToSelection] Added category ${s.id} with breadcrumb: ${l}. Total: ${u.length}`)}function _o(r,c){const t=(r.params||{}).categoryId;if(!t){A.warn("[removeCategoryFromSelection] Missing categoryId param");return}const o=window.G7Core;if(!o?.state){A.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}),A.log(`[removeCategoryFromSelection] Removed category ${t}. Remaining: ${s.length}`)}function yo(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 p=m.name?.[n]||m.name?.ko||"";if(m.id===u)return[...d,p];if(m.children&&m.children.length>0){const f=s(m.children,u,[...d,p]);if(f)return f}}return null},i=s(o,t);return i?i.join(" > "):""}function wo(r,c){A.log("[initCategoryInfosFromProduct] START");const e=window.G7Core;if(!e?.state){A.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(A.log("[initCategoryInfosFromProduct] Product categories:",s),A.log("[initCategoryInfosFromProduct] Category IDs:",i),A.log("[initCategoryInfosFromProduct] Categories tree available:",n.length>0),i.length===0){A.log("[initCategoryInfosFromProduct] No categories to initialize");return}const l=(d,m,p=[])=>{for(const f of d){const y=f.name?.[o]||f.name?.ko||"";if(f.id===m)return[...p,y];if(f.children&&f.children.length>0){const w=l(f.children,m,[...p,y]);if(w)return w}}return null},u=[];for(const d of i){const m=l(n,d);if(m)u.push({id:d,breadcrumb:m.join(" > ")});else{const p=s.find(f=>f.id===d);if(p){const f=p.name?.[o]||p.name?.ko||p.name_localized||"";u.push({id:d,breadcrumb:f})}}}A.log("[initCategoryInfosFromProduct] Generated selectedCategoryInfos:",u),e.state.setLocal({selectedCategoryInfos:u}),A.log("[initCategoryInfosFromProduct] DONE")}function ho(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 A.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(p=>String(p.id)===s);m&&m.depth===3&&(e.category4=s)}}}}return A.log("[validateCategoryPath] Result:",e),e}function Co(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 bo(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)},Io={KRW:1e3,JPY:100};function So(r,c){const t=r.find(o=>o.code===c)?.base_unit??Io[c]??1;return Math.max(1,t)}function vo(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 Lo(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||u.find(w=>w?.is_default)?.code||"",m=l?.rounding_method||"round",p={...i.form?.multi_currency_prices||{}};if(p[o]={...p[o]||{},[t]:a},n&&o===d){const w=So(u,d);u.forEach(_=>{if(_.code!==d){const g=_.exchange_rate??1,b=vo(a,g,m,w);p[_.code]={...p[_.code]||{},[t]:b}}})}const f={list:"list_price",selling:"selling_price"},y=o===d?a:i.form?.[f[t]];s.state.setLocal({form:{...i.form,multi_currency_prices:p,[f[t]]:y},hasChanges:!0}),Z.log(`[updatePrice] Updated ${t} price for ${o}: ${a}`)}function xo(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 Po(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 Ao(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function nt(){return Ao().reduce((c,e)=>({...c,[e]:""}),{})}function Ie(r){return(r??[]).filter(c=>c.is_active!==!1).reduce((c,e)=>c+(parseInt(String(e.stock_quantity),10)||0),0)}const ko={KRW:1e3,JPY:100};function Ne(){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??ko[e.code]??1;return Math.max(1,t)}function Oo(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 Te(r,c,e){const t=c.exchange_rate||0;if(t<=0)return{price:0,formatted:de(0,c)};const o=e??Ne(),a=r/o*t,n=Oo(a,c.rounding_unit||"0.01",c.rounding_method||"round"),s=c.decimal_places??2,i=parseFloat(n.toFixed(s));return{price:i,formatted:de(i,c)}}function Re(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=Ne(),i={};e.forEach(l=>{i[l.code]=Te(a,l,s)}),n.multi_currency_selling_price=i}return n})}function Me(){return((window.G7Core?.state?.get?.()||{}).modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).filter(c=>!c.is_default&&c.exchange_rate)}function Go(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=Me();l=Re(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 Eo(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=Me();if(i[t].is_default===!0){i[t].price_adjustment=0;const f=Re(i,d,m);n.state.setLocal({form:{...s.form,options:f,selling_price:d},hasChanges:!0}),T.log(`[updateFormOptionField] Reverse-synced product selling_price to ${d} from default option`);return}const p=parseFloat(String(s.form?.selling_price))||0;if(i[t].price_adjustment=d-p,m.length>0){const f=Ne(),y={};m.forEach(w=>{y[w.code]=Te(d,w,f)}),i[t].multi_currency_selling_price=y,T.log(`[updateFormOptionField] Recalculated multi_currency_selling_price for option at index ${t}:`,m.map(w=>w.code))}}if((o==="stock_quantity"||o==="is_active")&&i.length>0){const d=Ie(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 Do(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(p=>({key:p.name,value:nt()})),u={id:null,option_code:i,option_name:nt(),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=Ie(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 Fo(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=Me(),i=Re(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 He(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function ce(){return window.G7Core?.config?.("app.locale")??He()[0]??"ko"}function q(){return He().reduce((c,e)=>({...c,[e]:""}),{})}function at(r){return{id:null,name:q(),price_adjustment:0,is_default:r===0,is_active:!0,allow_custom_text:!1,sort_order:r}}function $o(r,c){return typeof r=="string"?r:r[c]??r.ko??Object.values(r)[0]??""}function st(r){return r&&typeof r=="object"&&!Array.isArray(r)}function No(r){return Array.isArray(r)?r:r&&typeof r=="object"?Object.values(r):[]}function To(r){return r.reduce((c,e)=>{const t=[];return c.forEach(o=>{e.forEach(a=>{t.push([...o,a])})}),t},[[]])}function Ro(r){const c=He(),e={};for(const t of c){const o=r.map(a=>$o(a,t));e[t]=o.join("/")}return e}function Mo(r,c){return c.map((e,t)=>({key:typeof e.name=="string"?{...q(),[ce()]:e.name}:e.name,value:st(r[t])?r[t]:{...q(),[ce()]:r[t]}}))}function Ho(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 qo(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 Uo(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 Vo(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=g=>g.name?typeof g.name=="string"?g.name:g.name[n]??"":"",i=a.map(g=>({...g,values:No(g.values)})),l=i.filter(g=>s(g)&&g.values&&g.values.length>0);if(h.log(`[generateOptions] Validating inputs - defaultLocale: ${n}, normalizedInputs:`,i.map(g=>({name:g.name,nameType:typeof g.name,extractedName:s(g),values:g.values,valuesIsArray:Array.isArray(g.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 p=r.params?.isCreate===!0||r.params?.isCreate==="true"?1:0,f=parseFloat(String(t.form?.list_price))||0,y=parseFloat(String(t.form?.selling_price))||0,w=t.form?.multi_currency_selling_price,_=(o.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).filter(g=>!g.is_default&&g.exchange_rate);try{h.log("[generateOptions] Step 1: Creating optionGroups...");const g=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($=>st($)?$:{...q(),[n]:$})})),C=To(l.map(L=>L.values)).map((L,O)=>{const $=Mo(L,l),Q=Ro(L),kt=`OPT-${String(O+1).padStart(3,"0")}`;let J={};return w&&typeof w=="object"&&Object.keys(w).length>0?J=JSON.parse(JSON.stringify(w)):y>0&&_.length>0&&_.forEach(Ae=>{J[Ae.code]=Te(y,Ae)}),{id:null,option_code:kt,option_values:$,option_name:Q,list_price:f,selling_price:y,price_adjustment:0,stock_quantity:p,safe_stock_quantity:0,is_default:O===0,is_active:!0,sku:"",multi_currency_selling_price:J}}),x=t.form?.options??[],P=L=>L.option_name?typeof L.option_name=="string"?L.option_name:L.option_name[n]??L.option_name.ko??"":"",S=C.map(L=>{const O=P(L),$=x.find(Q=>P(Q)===O);return $?{...L,...$,id:$.id}:L}),N=Ie(S);e.state.setLocal({form:{...t.form,option_groups:g,options:S,has_options:S.length>0,stock_quantity:N},hasChanges:!0}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.generated",{count:S.length})??`${S.length} options have been generated.`),h.log(`[generateOptions] Generated ${S.length} options`)}catch(g){throw h.error("[generateOptions] Error occurred:",g),h.error("[generateOptions] Error message:",g.message),h.error("[generateOptions] Error stack:",g.stack),g}}function jo(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=Ie(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 Bo(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 Ko(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 Qo(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 zo(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 Yo(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 Wo(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:[at(0)]}]},hasChanges:!0}),h.log(`[addAdditionalOption] Added additional option. Total: ${o.length+1}`)}function Jo(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 Xo(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 Zo(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 er(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 qe(r){if(!r)return[];const c=r.values;return Array.isArray(c)?c:c&&typeof c=="object"?Object.values(c):[]}function tr(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=[...qe(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(at(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 or(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=[...qe(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,p=m&&typeof m=="object"&&!Array.isArray(m)?m:q();u[o]={...u[o],name:{...p,[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,p)=>{m.is_default=p===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 rr(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=[...qe(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)},nr=["image/jpeg","image/png","image/gif","image/webp"],ar=10*1024*1024,sr=20;async function ir(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=sr-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(!nr.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>ar){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:b=>{const C=o.state.getLocal()||{};o.state.setLocal({ui:{...C.ui,uploadProgress:{...C.ui?.uploadProgress||{},[l.name]:b}}})}}),p=o.state.getLocal()||{},f=p.form?.images??[],y={...m.data,is_thumbnail:f.length===0,sort_order:f.length};o.state.setLocal({form:{...p.form,images:[...f,y]},hasChanges:!0});const w=o.state.getLocal()||{},{[l.name]:_,...g}=w.ui?.uploadProgress||{};o.state.setLocal({ui:{...w.ui,uploadProgress:g}}),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]:p,...f}=m.ui?.uploadProgress||{};o.state.setLocal({ui:{...m.ui,uploadProgress:f}}),j.error(`[uploadImages] Failed to upload ${l.name}:`,d)}}}function cr(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 lr(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 it=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 dr(r,c){const e=r.params||{},t=e.locale,o=e.value;if(!t){it.warn("[updateDescription] Missing locale param");return}const a=window.G7Core;if(!a?.state){it.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 pe(r=""){const e=window.G7Core?.locale?.supported?.()??["ko","en"],t={};for(const o of e)t[o]=r;return t}function ur(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){ct(r);return}o.state.setGlobal({pendingNoticeTemplateId:t}),o.modal?.open?.("notice_template_confirm_modal"),k.log(`[confirmSelectNoticeTemplate] Showing confirm modal for template: ${t}`)}function ct(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 p=JSON.stringify(d.name),f=l.find(y=>JSON.stringify(y.name)===p);return{key:`field_${m}`,name:d.name,content:f?.content??d.content??pe(),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 mr(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 pr(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 fr(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 gr(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 _r(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 yr(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 wr(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:pe(),content:pe(),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 hr(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 Cr(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:pe(),category_id:null,is_default:!1}}),e.dispatch?.({handler:"openModal",target:"modal_save_template"})}const Se={ko:"상세설명참조",en:"See detail description"};function br(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]=Se[l]??Se.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 Ir(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]=Se[l]??Se.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 Sr(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:pe(),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 vr(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 Lr(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 xr(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 Pr(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 Ar(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 kr(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 Or(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 Gr(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 Er(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 Dr(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 Fr(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 $r(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 Nr(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 Tr(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 Rr(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 Mr=(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})},Hr=(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}))},qr=(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(p=>p.label_id===a);if(i<0)return;const l=new Date,u=p=>p.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})},Ur=(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?.()},Vr=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})}}},jr=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 lt(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function Br(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=lt(),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 Kr(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 Qr(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=lt(),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 Y=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 dt(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 W(r){return typeof r=="string"?r:r&&typeof r=="object"?r.ko||r.en||JSON.stringify(r):String(r??"")}function ve(r,c,e){return e?r.t(`sirsoft-ecommerce.enums.${c}.${e}`)||e:String(e??"")}function ut(){const r=window.G7Core;return r||(Y.error("[bulkUpdate] G7Core is not available"),null)}function zr(r,c){const e=ut();if(!e)return;const t=e.state.getLocal()||{},o=e.state.get()||{},a=o.bulkSelectedItems||t.selectedItems||[],n=o.bulkSelectedOptionIds||t.selectedOptionIds||[];Y.log("[bulkUpdate] globalState.bulkSelectedItems:",o.bulkSelectedItems),Y.log("[bulkUpdate] localState.selectedItems:",t.selectedItems),Y.log("[bulkUpdate] Final selectedItems:",a),a.length>0?Yr(e,o,t,a):n.length>0?Wr(e,o,t,n):e.toast.warning(e.t("sirsoft-ecommerce.admin.product.bulk.no_selection"))}function Yr(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 g={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:g[c.bulkPriceMethod]||"add",value:Number(c.bulkPriceValue)||0}}if(c.bulkStockMethod&&c.bulkStockValue!==void 0&&c.bulkStockValue!==null&&c.bulkStockValue!==""){const g={set:"set",increase:"add",decrease:"subtract"};s.stock_quantity={method:g[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||[],p=new Set(t),f=[],y=[];function w(g,b,C){if(b._modified||l.includes(C)){const P={product_id:g.id,option_id:b.id},S=d[C]||[];!s.price_adjustment&&S.includes("price_adjustment")&&(P.price_adjustment=b.price_adjustment),!s.price_adjustment&&S.includes("selling_price")&&(P.price_adjustment=b.price_adjustment),!s.stock_quantity&&S.includes("stock_quantity")&&(P.stock_quantity=b.stock_quantity),S.includes("option_name")&&(P.option_name=b.option_name),S.includes("sku")&&(P.sku=b.sku),S.includes("list_price")&&(P.list_price=b.list_price),S.includes("safe_stock_quantity")&&(P.safe_stock_quantity=b.safe_stock_quantity),S.includes("is_default")&&(P.is_default=b.is_default),S.includes("is_active")&&(P.is_active=b.is_active),Object.keys(P).length>2&&y.push(P)}}a.filter(g=>t.includes(g.id)).forEach(g=>{if(g._modified||i.includes(g.id)){const C={id:g.id},x=String(g.id),P=u[x]||[];!n.sales_status&&P.includes("sales_status")&&(C.sales_status=g.sales_status),!n.display_status&&P.includes("display_status")&&(C.display_status=g.display_status),P.includes("name")&&(C.name=g.name),P.includes("list_price")&&(C.list_price=g.list_price),P.includes("selling_price")&&(C.selling_price=g.selling_price),Object.keys(C).length>1&&f.push(C)}(g.options||[]).forEach(C=>{const x=`${g.id}-${C.id}`;w(g,C,x)})}),m.length>0&&a.forEach(g=>{p.has(g.id)||(g.options||[]).forEach(b=>{const C=`${g.id}-${b.id}`;m.includes(C)&&w(g,b,C)})});const _={ids:t};Object.keys(n).length>0&&(_.bulk_changes=n),f.length>0&&(_.items=f),Object.keys(s).length>0&&(_.option_bulk_changes=s),y.length>0&&(_.option_items=y),r.state.set({isProcessing:!0,bulkUpdateErrors:[]}),r.api.patch("/api/modules/sirsoft-ecommerce/admin/products/bulk-update",_).then(g=>{r.toast.success(r.t("sirsoft-ecommerce.admin.product.bulk.update_success",{products:g?.data?.products_updated||0,options:g?.data?.options_updated||0})),pt(r),mt(r),r.modal.close()}).catch(g=>{Y.error("[bulkUpdateProducts] Error:",g),dt(r,g,"sirsoft-ecommerce.admin.product.bulk.update_error")}).finally(()=>{r.state.set({isProcessing:!1})})}function Wr(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 p=`${d.id}-${m.id}`,f=m._modified||i.includes(p);if(t.includes(p)&&f){const y={product_id:d.id,option_id:m.id},w=s[p]||[];!n.price_adjustment&&w.includes("price_adjustment")&&(y.price_adjustment=m.price_adjustment),!n.price_adjustment&&w.includes("selling_price")&&(y.price_adjustment=m.price_adjustment),!n.stock_quantity&&w.includes("stock_quantity")&&(y.stock_quantity=m.stock_quantity),w.includes("option_name")&&(y.option_name=m.option_name),w.includes("sku")&&(y.sku=m.sku),w.includes("list_price")&&(y.list_price=m.list_price),w.includes("safe_stock_quantity")&&(y.safe_stock_quantity=m.safe_stock_quantity),w.includes("is_default")&&(y.is_default=m.is_default),w.includes("is_active")&&(y.is_active=m.is_active),Object.keys(y).length>2&&l.push(y)}})});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})),pt(r),mt(r),r.modal.close()}).catch(d=>{Y.error("[bulkUpdateOptions] Error:",d),dt(r,d,"sirsoft-ecommerce.admin.product.bulk.option_update_error")}).finally(()=>{r.state.set({isProcessing:!1})})}function mt(r){Promise.resolve(r.dataSource.refetch("products")).then(()=>{lo(r,{force:!0})}).catch(c=>{Y.error("[bulkUpdate] Failed to refetch products:",c)})}function pt(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 Jr(r,c){const e=ut();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||[],p=t.modifiedProductFields||{},f=t.modifiedOptionFields||{},y=new Set(s);function w(_,g,b){const C=[],x=f[g]||[];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}`),b){if(!o.bulkPriceCondition&&x.includes("price_adjustment")){const P=_.price_adjustment?.toLocaleString?.()??_.price_adjustment;C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_price_adjustment_inline",{value:P})||`Price adjustment: ${P}`)}!o.bulkPriceCondition&&x.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&&x.includes("stock_quantity")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_stock_inline",{value:_.stock_quantity})||`Stock: ${_.stock_quantity}`),x.includes("option_name")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_option_name",{name:W(_.option_name)})||`Option name: ${W(_.option_name)}`),x.includes("sku")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sku",{sku:_.sku})||`SKU: ${_.sku}`),x.includes("list_price")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price",{price:ue(Number(_.list_price??0))})||`List price: ${_.list_price}`),x.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}`),x.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 g=[],b=_._modified||d.includes(_.id),C=String(_.id),x=p[C]||[];if(o.bulkSalesStatus){const S=ve(e,"sales_status",o.bulkSalesStatus);g.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sales_status",{status:S})||`Sales status: ${S}`)}if(o.bulkDisplayStatus){const S=ve(e,"display_status",o.bulkDisplayStatus);g.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_display_status",{status:S})||`Display status: ${S}`)}if(b){if(!o.bulkSalesStatus&&x.includes("sales_status")){const S=ve(e,"sales_status",_.sales_status);g.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sales_status",{status:S})||`Sales status: ${S}`)}if(!o.bulkDisplayStatus&&x.includes("display_status")){const S=ve(e,"display_status",_.display_status);g.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_display_status",{status:S})||`Display status: ${S}`)}x.includes("name")&&g.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_name_changed")||"Name changed"),x.includes("list_price")&&g.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price",{price:ue(Number(_.list_price??0))})||`List price: ${_.list_price?.toLocaleString?.()||_.list_price}`),x.includes("selling_price")&&g.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_selling_price",{price:ue(Number(_.selling_price??0))})||`Selling price: ${_.selling_price?.toLocaleString?.()||_.selling_price}`),l.summary.hasInlineChanges=!0}(g.length>0||u||b)&&l.products.push({id:_.id,name:W(_.name),changes:g.length>0?g.join(", "):b?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 P=u&&!!(o.bulkPriceCondition||o.bulkStockCondition);if(Array.isArray(_.options))_.options.forEach(S=>{const N=`${_.id}-${S.id}`,L=S._modified||m.includes(N),O=w(S,N,L);(O.length>0||P||L)&&l.options.push({productId:_.id,optionId:S.id,productName:W(_.name),optionName:S.option_name_localized||W(S.option_name)||S.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(P){const S=Number(_.options_total_count)||0;S>0&&l.options.push({aggregate:!0,productId:_.id,optionId:0,optionCount:S,productName:W(_.name),optionName:"",changes:w({},`${_.id}-*`,!1).join(", ")||e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_bulk_applied")||"Bulk changes applied"})}}),i.length>0&&n.forEach(_=>{y.has(_.id)||(_.options||[]).forEach(g=>{const b=`${_.id}-${g.id}`;if(i.includes(b)){const C=g._modified||m.includes(b),x=w(g,b,C);l.options.push({productId:_.id,optionId:g.id,productName:W(_.name),optionName:g.option_name_localized||W(g.option_name)||g.name||"",changes:x.length>0?x.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((_,g)=>_+(g.aggregate?g.optionCount||0:1),0),e.state.set({bulkConfirmData:l}),Y.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 Xr(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 Zr(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 en(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 tn(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 on(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 rn(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 nn(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 w=await u.json().catch(()=>null);throw new Error(w?.message||"엑셀 다운로드에 실패했습니다.")}const d=await u.blob(),m=window.URL.createObjectURL(d),p=document.createElement("a");p.href=m;const f=new Date,y=`${f.getFullYear()}-${String(f.getMonth()+1).padStart(2,"0")}-${String(f.getDate()).padStart(2,"0")}`;return p.download=`orders_${y}.xlsx`,document.body.appendChild(p),p.click(),document.body.removeChild(p),window.URL.revokeObjectURL(m),{success:!0}}function an(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 sn(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 cn(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 ln(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 dn(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 Ue=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 un(r,c){const e=r.params||{},{actionId:t,row:o}=e,a=window.G7Core;if(!t||!o?.id)return Ue.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){Ue.error("[handleProductRowAction] 삭제 가능 여부 확인 실패:",n),a?.state?.setLocal({productList:{deleteTargetProduct:o,canDelete:!1,deleteBlockReason:"삭제 가능 여부를 확인할 수 없습니다.",relatedData:null},ui:{showDeleteModal:!0}})}break;default:return Ue.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 ft(){return typeof document>"u"?"":document.querySelector('input[name="batchTrackingNumber"]')?.value?.trim()??""}function mn(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 pn(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 fn(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 gn(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||ft();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 p=t.cancelPg??!0,f=e.dataSource?.get?.("order")?.data,y=f?.options||[],w=f?.currency_snapshot?.base_currency??null,_=o.map(g=>{const b=y.find(x=>x.id===g);if(!b||b.option_status==="cancelled")return null;const C=b.quantity??1;return{id:b.id,product_name:b.product_name,product_option_name:b.product_option_name,thumbnail_url:b.product_snapshot?.thumbnail_url||"",unit_price:b.unit_price,unit_price_formatted:b.unit_price_formatted??ue(Number(b.unit_price??0),w),quantity:C,subtotal_formatted:ue(Number(b.unit_price??0)*C,w),cancel_quantity:C,option_status:b.option_status,option_status_label:b.option_status_label||b.option_status}}).filter(Boolean);if(_.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_cancellable_items")||"취소할 수 있는 상품이 없습니다. 이미 취소된 상품은 제외됩니다.");return}if(p){e.state.setLocal({cancelItems:_,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=f?.order_number||"";g&&e.dispatch?.({handler:"sirsoft-ecommerce.estimateRefundAmount",params:{orderId:g,cancelItems:_}})}else{const g={};for(const b of _)g[b.id]=b.quantity;e.state.setLocal({bulkConfirmItems:_,changeQuantities:g,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 p of o){const f=u.find(y=>y.id===p);f&&(d.push({id:f.id,product_name:f.product_name,product_option_name:f.product_option_name,sku:f.sku,thumbnail_url:f.product_snapshot?.thumbnail_url||"",unit_price:f.unit_price,original_price:f.product_snapshot?.original_price||f.unit_price,quantity:f.quantity,option_status:f.option_status}),m[f.id]=f.quantity)}e.state.setLocal({bulkConfirmItems:d,changeQuantities:m,batchOrderStatus:a,batchCarrierId:n,batchTrackingNumber:s}),e.modal?.open?.("modal_batch_change_confirm")}async function _n(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||ft(),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||[],p={items:a.map(f=>{const y=d.find(w=>w.id===f);return{option_id:f,quantity:l?.[f]??y?.quantity??1}}),status:n};s&&(p.carrier_id=s),i&&(p.tracking_number=i);try{const f=await e.api.patch(`/api/modules/sirsoft-ecommerce/admin/orders/${t}/options/bulk-status`,p);if(f?.success){const y=e.t,w=f.data?.changed_count??a.length;e.toast?.success?.(y?.("sirsoft-ecommerce.admin.order.detail.handler.bulk_change_success",{count:w})??`${w}개 옵션의 상태가 변경되었습니다.`),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(f?.message||"상태 변경에 실패했습니다.")}catch(f){U.error("[processOrderDetailBulkChange] API 호출 실패:",f);const w=(f?.response?.data||f?.data||{})?.message||f?.message;e.toast?.error?.(w??e.t?.("sirsoft-ecommerce.admin.order.detail.handler.bulk_change_failed")??"상태 변경에 실패했습니다.")}}async function yn(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 wn(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 hn(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_charge_amount??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 Cn(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 Ve=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 bn(){K&&(clearTimeout(K),K=null,Ve.log("[clearCancelOrderTimers] debounce 타이머 정리 완료"))}function In(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{je(e,a)}finally{window.__g7ActionContext=m}},500)}async function Sn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,cancelItems:o}=r.params||{};t&&await je(e,t,o)}async function je(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){Ve.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 vn(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{je(e,o)}finally{window.__g7ActionContext=n}},300)}async function Ln(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){Ve.error("[executeCancelOrder] 실패:",m);const p=m?.response?.data||m?.data,y=(m?.response?.status||m?.status)===422,w=p?.errors?.detail,_=p?.errors??null,g=w||p?.message||m?.message||"",b=e.t?.("sirsoft-ecommerce.admin.order.detail.modal.cancel.cancel_failed")??"주문 취소에 실패했습니다.";y?(e.state.setLocal({isCancelling:!1,cancelError:null,cancelValidationErrors:_}),e.toast?.error?.(g||b)):(e.state.setLocal({isCancelling:!1,cancelError:g||b,cancelValidationErrors:null}),e.toast?.error?.(g||b))}}const gt=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 Le(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}function xn(){R&&(clearTimeout(R),R=null)}function Pn(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 An(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 kn(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 On(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 Gn(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 En(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,maxQuantity:o,orderId:a}=r.params||{};if(!t||!o||!a&&!Le(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 Dn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};!t&&!Le(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){gt.error("[estimateUserRefund] 실패:",s),r.state.setLocal({refundLoading:!1,refundEstimate:null}),r.toast?.error?.(r.t?.("mypage.order_detail.cancel_modal.estimate_failed")??"환불 예상금액 계산에 실패했습니다.")}}function Fn(r,c){const e=window.G7Core;if(!e?.state)return;const{priority:t,orderId:o}=r.params||{};if(!t||!o&&!Le(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 $n(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};if(!t&&!Le(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,p=!!(d&&m),f=p?`/api/modules/sirsoft-ecommerce/guest/orders/${m}/cancel`:`/api/modules/sirsoft-ecommerce/user/orders/${t}/cancel`,y=p?{headers:{"X-Guest-Order-Token":d}}:void 0,w=await e.api.post(f,u,y);if(w?.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(w?.message||"Cancel failed")}catch(d){gt.error("[executeUserCancelOrder] 실패:",d);const m=d?.response?.data||d?.data,p=m?.message||d?.message||"",f=m?.errors??null,y=e.t?.("mypage.order_detail.cancel_modal.cancel_failed")??"주문 취소에 실패했습니다.";e.state.setLocal({isCancelling:!1,cancelError:p||y,cancelValidationErrors:f}),e.toast?.error?.(p||y)}}const _t=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 Nn(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}async function Tn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,optionId:o}=r.params||{};if(!o||!t&&!Nn(e)){_t.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){_t.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 yt=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 Rn(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}async function Mn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{},o=Rn(e);if(!t&&!o){yt.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){yt.error("[changeShippingAddress] 실패:",i),e.state.setLocal({isSubmittingAddress:!1});const l=i?.response?.data?.message||i?.data?.message||i?.message||"배송지 변경에 실패했습니다.";e.toast?.error?.(l)}}const Be=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 Hn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,optionId:o,productId:a}=r.params||{};if(!t||!o||!a){Be.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 p=0;pconsole.log("[Ecom:ShippingPolicyForm]",...r),warn:(...r)=>console.warn("[Ecom:ShippingPolicyForm]",...r),error:(...r)=>console.error("[Ecom:ShippingPolicyForm]",...r)},qn=["fixed","conditional_free","per_quantity","per_weight","per_volume","per_volume_weight","per_amount"],wt=["conditional_free"],ht=["range_amount","range_quantity","range_weight","range_volume","range_volume_weight"],Un=["api"],Ct=["per_quantity","per_weight","per_volume","per_volume_weight","per_amount"],Vn=["range_quantity"],jn=.001;function bt(r){return Vn.includes(r)}function Bn(r,c){return r==null||Number.isNaN(r)?null:bt(c)?r+1:r}function xe(r,c){return r.map((e,t)=>({...e,min:t===0?0:Bn(r[t-1]?.max,c)}))}const Kn={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:qn.includes(r),showFreeThreshold:wt.includes(r),showRanges:ht.includes(r),showApiSettings:Un.includes(r),showUnitValue:Ct.includes(r)}}function G(){const r=window.G7Core;return r?.state?r:(I.warn("G7Core.state not available"),null)}function E(r,c){r.setLocalState?r.setLocalState(c):G()?.state?.setLocal(c)}function D(r){return[...(r.state.getLocal?.()??{}).form?.country_settings??[]]}function Qn(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);const m=i.map(p=>{const f=p?.ranges?.tiers;return!Array.isArray(f)||f.length===0?p:{...p,ranges:{...p.ranges,tiers:xe(f,p.charge_policy)}}});s["form.country_settings"]=m,I.log("[initShippingPolicyForm]",o?"Edit":"Copy","mode, countries:",i.length,"first charge_policy:",u)}else{const i=te("fixed");Object.assign(s,i),I.log("[initShippingPolicyForm] Create mode, default flags")}E(c,s)}function zn(r,c){const t=(r.params||{}).country_code??"";if(!t){I.warn("[addCountrySetting] country_code is empty");return}const o=G();if(!o)return;const a=D(o);if(a.some(u=>u.country_code===t)){I.warn("[addCountrySetting] Duplicate country_code:",t);return}const n={country_code:t,...Kn};a.push(n);const s=a.length-1,i=te(n.charge_policy),l={"form.country_settings":a,activeCountryTab:s,...i};E(c,l),I.log("[addCountrySetting] Added country:",t,"index:",s)}function Yn(r,c){const e=r.params||{},t=Number(e.index);if(isNaN(t)||t<0){I.warn("[removeCountrySetting] Invalid index:",e.index);return}const o=G();if(!o)return;const a=D(o),n=o.state.getLocal?.()??{};if(t>=a.length){I.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"),p={"form.country_settings":a,activeCountryTab:l,rangeErrors:u,...m};E(c,p),I.log("[removeCountrySetting] Removed index:",t,"country:",s,"newTab:",l)}function Wn(r,c){const e=r.params||{},t=Number(e.index);if(isNaN(t)||t<0){I.warn("[switchCountryTab] Invalid index:",e.index);return}const o=G();if(!o)return;const a=D(o);if(t>=a.length){I.warn("[switchCountryTab] Index out of bounds:",t);return}const n=a[t],s=te(n.charge_policy),i={activeCountryTab:t,...s};E(c,i),I.log("[switchCountryTab] Switched to tab:",t,"country:",n.country_code,"charge_policy:",n.charge_policy)}function Jn(r,c){const e=r.params||{},t=e.field??"",o=e.value;if(!t){I.warn("[updateCountryField] field is empty");return}const a=G();if(!a)return;const n=a.state.getLocal?.()??{},s=Number(n.activeCountryTab??0),i=D(a);if(s>=i.length){I.warn("[updateCountryField] Index out of bounds:",s);return}i[s]={...i[s],[t]:o},E(c,{"form.country_settings":i}),t==="free_threshold"&&Ke(a,c,s,i[s]),I.log("[updateCountryField] Updated",t,"=",o,"at index:",s)}function Xn(r,c){const e=r.params||{},t=e.value??"",o=Number(e.index??0);if(!t){I.warn("[onChargePolicyChange] charge_policy value is empty");return}const a=G();if(!a)return;const n=te(t);I.log("[onChargePolicyChange]",t,n,"countryIndex:",o);const s=D(a),i=s[o];if(!i){I.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:null}]}}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),Ke(a,c,o,l)}function Zn(r,c){const e=r.params||{},t=Number(e.index??0),o=G();if(!o)return;const a=D(o),n=a[t];if(!n){I.warn("[addRangeTier] No country setting at index:",t);return}const s=[...n.ranges?.tiers??[],{min:null,max:null,fee:null}],i=xe(s,n.charge_policy);a[t]={...n,ranges:{...n.ranges??{},tiers:i}},E(c,{"form.country_settings":a}),Pe(o,c,t,i,n.country_code,n.charge_policy),I.log("[addRangeTier] Added tier, total:",i.length)}function ea(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=Number(e.tierIndex);if(isNaN(o)||o<0){I.warn("[removeRangeTier] Invalid tierIndex:",e.tierIndex);return}const a=G();if(!a)return;const n=D(a),s=n[t];if(!s){I.warn("[removeRangeTier] No country setting at index:",t);return}const i=[...s.ranges?.tiers??[]];if(o>=i.length){I.warn("[removeRangeTier] tierIndex out of bounds:",o);return}i.splice(o,1);const l=xe(i,s.charge_policy);n[t]={...s,ranges:{...s.ranges??{},tiers:l}},E(c,{"form.country_settings":n}),Pe(a,c,t,l,s.country_code,s.charge_policy),I.log("[removeRangeTier] Removed tier:",o,"remaining:",l.length)}function ta(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){I.warn("[updateRangeTierField] Invalid tierIndex:",e.tierIndex);return}if(!["min","max","fee"].includes(a)){I.warn("[updateRangeTierField] Invalid field:",a);return}const s=G();if(!s)return;const i=D(s),l=i[t];if(!l){I.warn("[updateRangeTierField] No country setting at index:",t);return}const u=[...l.ranges?.tiers??[]];if(o>=u.length){I.warn("[updateRangeTierField] tierIndex out of bounds:",o);return}const m=n===""||n===null||n===void 0?null:Number(n);u[o]={...u[o],[a]:m};const p=a==="max"?xe(u,l.charge_policy):u;i[t]={...l,ranges:{...l.ranges??{},tiers:p}},E(c,{"form.country_settings":i}),Pe(s,c,t,p,l.country_code,l.charge_policy)}function oa(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=G();if(!o)return;const n=D(o)[t];if(!n)return;const s=n.ranges?.tiers??[];Pe(o,c,t,s,n.country_code,n.charge_policy)}function Pe(r,c,e,t,o,a){const s={...(r.state.getLocal?.()??{}).rangeErrors??{}},i=r.t;if(t.length===0){s[o]=ht.includes(a)?[{max:i?.("sirsoft-ecommerce.admin.shipping_policy.form.range_error_min_one")??"구간별 배송비 정책은 구간을 1개 이상 등록해야 합니다."}]:[],E(c,{rangeErrors:s});return}const l=new Array(t.length).fill(null).map(()=>({}));let u=!1;const d=bt(a),m=p=>p==null;for(let p=0;p=w&&(l[p].min=i?.("sirsoft-ecommerce.admin.shipping_policy.form.range_error_min_max")??"시작값이 종료값보다 작아야 합니다.",u=!0);const _=d?w+1:w,g=Number(t[p+1]?.min??0);Math.abs(_-g)>=jn&&(l[p].max=i?.("sirsoft-ecommerce.admin.shipping_policy.form.range_error_continuity")??"구간이 연속적이지 않습니다.",u=!0)}f.fee===null||f.fee===void 0?(l[p].fee=i?.("sirsoft-ecommerce.admin.shipping_policy.form.range_error_fee_required")??"구간 배송비를 입력해주세요.",u=!0):f.fee<0&&(l[p].fee=i?.("sirsoft-ecommerce.admin.shipping_policy.form.range_error_fee")??"배송비는 0 이상이어야 합니다.",u=!0)}s[o]=u?l:[],E(c,{rangeErrors:s})}function It(r,c){return`country_settings.${r}.${c}`}function Ke(r,c,e,t){const a={...(r.state.getLocal?.()??{}).errors??{}},n=r.t,s=t.ranges?.unit_value,i=Ct.includes(t.charge_policy)&&(s==null||Number(s)<=0);a[It(e,"ranges.unit_value")]=i?[n?.("sirsoft-ecommerce.admin.shipping_policy.form.range_error_unit_value_required")??"단위당 배송비 정책은 단위값을 입력해야 합니다."]:null;const l=t.free_threshold,u=wt.includes(t.charge_policy)&&(l==null||String(l)==="");a[It(e,"free_threshold")]=u?[n?.("sirsoft-ecommerce.admin.shipping_policy.form.range_error_free_threshold_required")??"조건부 무료배송 정책은 무료배송 기준금액을 입력해야 합니다."]:null,E(c,{errors:a})}function ra(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=G();if(!o)return;const a=D(o),n=a[t];if(!n){I.warn("[addExtraFeeRow] No country setting at index:",t);return}if(n.country_code!=="KR"){I.warn("[addExtraFeeRow] Extra fee only available for KR, current:",n.country_code);return}const s=[...n.extra_fee_settings??[]];s.push({zipcode:"",fee:0,region:""}),a[t]={...n,extra_fee_settings:s},E(c,{"form.country_settings":a}),I.log("[addExtraFeeRow] Added row, total:",s.length)}function na(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=Number(e.feeIndex);if(isNaN(o)||o<0){I.warn("[removeExtraFeeRow] Invalid feeIndex:",e.feeIndex);return}const a=G();if(!a)return;const n=D(a),s=n[t];if(!s){I.warn("[removeExtraFeeRow] No country setting at index:",t);return}const i=[...s.extra_fee_settings??[]];if(o>=i.length){I.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}),I.log("[removeExtraFeeRow] Removed index:",o,"remaining:",i.length)}function aa(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=e.settings;if(!o||!Array.isArray(o)){I.warn("[applyExtraFeeTemplate] Invalid settings:",o);return}const a=G();if(!a)return;const n=D(a),s=n[t];if(!s){I.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),I.log("[applyExtraFeeTemplate] Applied template with",o.length,"rows")}function sa(r,c){const t=(r.params||{}).value,o=t===""||t===null||t===void 0,a=parseFloat(t),n=o||Number.isNaN(a)?null:a,s=G();if(!s)return;const i=s.state.getLocal?.()??{},l=Number(i.activeCountryTab??0),u=D(s),d=u[l];d&&(u[l]={...d,ranges:{...d.ranges??{},type:d.charge_policy,unit_value:n}},E(c,{"form.country_settings":u}),Ke(s,c,l,u[l]),I.log("[updateUnitValue] Updated unit_value:",n))}function ia(r,c){const e=G();if(!e)return;const t=e.state.getLocal?.()??{},o=Number(t.activeCountryTab??0),a=D(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}),I.log("[addApiRequestField] Added field, total:",s.length)}function ca(r,c){const e=r.params||{},t=Number(e.fieldIndex),o=e.value??"";if(isNaN(t)||t<0){I.warn("[updateApiRequestField] Invalid fieldIndex:",e.fieldIndex);return}const a=G();if(!a)return;const n=a.state.getLocal?.()??{},s=Number(n.activeCountryTab??0),i=D(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}),I.log("[updateApiRequestField] Updated index:",t,"value:",o))}function la(r,c){const e=r.params||{},t=Number(e.fieldIndex);if(isNaN(t)||t<0){I.warn("[removeApiRequestField] Invalid fieldIndex:",e.fieldIndex);return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=D(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}),I.log("[removeApiRequestField] Removed index:",t,"remaining:",l.length))}function da(r,c){const t=(r.params||{}).field??"";if(!t){I.warn("[toggleApiRequestField] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=D(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}),I.log("[toggleApiRequestField] Toggled:",t,"selected:",d)}function ua(r,c){const e=r.params||{},t=e.field??"";if(!t){I.warn("[updateApiConfigField] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=D(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}),I.log("[updateApiConfigField] Updated",t,"at index:",n)}function ma(r,c){const e=r.params||{},t=e.field??"";if(!t){I.warn("[updateApiFieldMap] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=D(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}),I.log("[updateApiFieldMap] Mapped",t,"→",l||"(removed)")}function pa(r,c){const e=G();if(!e)return;const t=e.state.getLocal?.()??{},o=Number(t.activeCountryTab??0),n=D(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 fa(r,c){const e=r.params||{},t=Number(e.feeIndex),o=e.field,a=e.value;if(isNaN(t)||t<0){I.warn("[updateExtraFeeField] Invalid feeIndex:",e.feeIndex);return}if(!["zipcode","fee","region"].includes(o)){I.warn("[updateExtraFeeField] Invalid field:",o);return}const n=G();if(!n)return;const s=n.state.getLocal?.()??{},i=Number(s.activeCountryTab??0),l=D(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}),I.log("[updateExtraFeeField] Updated index:",t,o,"=",m)}const fe=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 St(r,c){for(const e of r){if(e.id===c)return e;if(e.children&&e.children.length>0){const t=St(e.children,c);if(t)return t}}return null}function vt(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 ya(r,c){const e=window.G7Core,t=vt("id"),o=vt("mode");if(!t){fe.log("[initCategoryFromUrl] No id parameter in URL");return}if(!e?.state?.set){fe.warn("[initCategoryFromUrl] G7Core.state.set not available");return}const a=await _a("categories",l=>l?.data?.data);if(!a){fe.warn("[initCategoryFromUrl] categories data not available after waiting");return}const n=parseInt(t,10),s=St(a,n);if(!s){fe.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}),fe.log("[initCategoryFromUrl] Category initialized from URL:",{id:n,mode:i})}const ge=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 Lt(r){return new URLSearchParams(window.location.search).get(r)}function wa(r){return window.G7Core?.state?.getDataSource?.(r)}async function ha(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=Lt("id"),o=Lt("mode");if(!t){ge.log("[initBrandFromUrl] No id parameter in URL");return}if(!e?.state?.set){ge.warn("[initBrandFromUrl] G7Core.state.set not available");return}const a=await ha("brands",l=>l?.data?.data);if(!a){ge.warn("[initBrandFromUrl] brands data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){ge.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}),ge.log("[initBrandFromUrl] Brand initialized from URL:",{id:n,mode:i})}const _e=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 xt(r){return new URLSearchParams(window.location.search).get(r)}function ba(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 Sa(r,c){const e=window.G7Core,t=xt("id"),o=xt("mode");if(!t){_e.log("[initCommonInfoFromUrl] No id parameter in URL");return}if(!e?.state?.set){_e.warn("[initCommonInfoFromUrl] G7Core.state.set not available");return}const a=await Ia("commonInfos",l=>l?.data);if(!a){_e.warn("[initCommonInfoFromUrl] commonInfos data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){_e.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}),_e.log("[initCommonInfoFromUrl] Common info initialized from URL:",{id:n,mode:i})}const ye=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 Pt(r){return new URLSearchParams(window.location.search).get(r)}function va(r){return window.G7Core?.state?.getDataSource?.(r)}async function La(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function xa(r,c){const e=window.G7Core,t=Pt("id"),o=Pt("mode");if(!t){ye.log("[initNoticeFromUrl] No id parameter in URL");return}if(!e?.state?.set){ye.warn("[initNoticeFromUrl] G7Core.state.set not available");return}const a=await La("templates",l=>l?.data);if(!a){ye.warn("[initNoticeFromUrl] templates data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){ye.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}),ye.log("[initNoticeFromUrl] Template initialized from URL:",{id:n,mode:i})}const oe={updateProductField:Rt,updateOptionField:Mt,calculateCurrencyPrices:se,initPreferredCurrency:Kt,initPreferredShippingCountry:Wt,setDateRange:Xt,setDefaultOption:Zt,toggleOption:eo,toggleProductOptions:et,toggleAllOptionsInRow:to,getProductOptionStates:oo,syncProductSelection:ro,loadExpandedOptions:$e,retryExpandedOptions:uo,generateCopyProductCode:no,copyProduct:ao,selectCategory:po,selectCategoryMobile:fo,addCategoryToSelection:go,removeCategoryFromSelection:_o,getCategoryBreadcrumb:yo,validateCategoryPath:ho,initCategoryInfosFromProduct:wo,getBrandName:Co,getBrandDescription:bo,updatePrice:Lo,calculateTotalOptionStock:xo,validatePriceRelation:Po,addOptionInput:Ho,removeOptionInput:qo,updateOptionInput:Uo,generateOptions:Vo,deleteOption:jo,applyOptionAddTool:Bo,addRequiredItem:Ko,updateRequiredItem:Qo,removeRequiredItem:zo,reorderRequiredItems:Yo,addAdditionalOption:Wo,updateAdditionalOption:Jo,removeAdditionalOption:Xo,reorderAdditionalOptions:Zo,clearAdditionalOptions:er,addAdditionalOptionValue:tr,updateAdditionalOptionValue:or,removeAdditionalOptionValue:rr,uploadImages:ir,setThumbnail:cr,reorderImages:lr,updateDescription:dr,confirmSelectNoticeTemplate:ur,selectNoticeTemplate:ct,updateNoticeItem:mr,removeNoticeItem:pr,reorderNoticeItems:fr,fillNoticeWithValue:gr,switchNoticeMode:_r,updateNewTemplateName:yr,addNoticeItem:wr,updateNoticeItemName:hr,saveAsNoticeTemplate:Cr,confirmSaveNoticeTemplate:Sr,fillTemplateFieldsWithDetailReference:br,fillNoticeItemsWithDetailReference:Ir,toggleLabel:vr,generateProductCode:Lr,getShippingPolicyInfo:xr,getCommonInfoContent:Pr,updateShoppingIntegration:Ar,updateShippingType:kr,updateIdentificationCode:Or,openLabelPeriodModal:Gr,saveLabelPeriod:Er,removeLabelPeriod:Dr,updateActivityLogSort:Fr,updateActivityLogPerPage:$r,setDefaultShippingPolicy:Nr,setLabelDatePreset:Rr,toggleDefaultShippingPolicy:Tr,toggleLabelAssignment:Mr,saveLabelSettings:jr,deleteLabel:Vr,updateLabelPeriodInline:Hr,setLabelDatePresetInline:qr,confirmUncheckLabel:Ur,removeDescriptionLocale:Br,showAddLocaleModal:Kr,addDescriptionLocale:Qr,setDefaultOptionFromGrid:Go,addOptionRow:Do,updateFormOptionField:Eo,recalculateOptionPriceAdjustments:Fo,bulkUpdate:zr,buildConfirmData:Jr,buildOrderColumns:Xr,toggleArrayValue:Zr,toggleVisibleFilter:en,syncOrderSelection:tn,handleOrderRowAction:on,processOrderBulkAction:rn,buildOrderBulkConfirmData:ln,executeOrderBulkAction:dn,downloadOrderExcel:nn,saveVisibleColumns:an,loadVisibleColumns:sn,loadVisibleFilters:cn,handleProductRowAction:un,initOrderDetailForm:mn,toggleProductSelection:pn,toggleAllProducts:fn,buildOrderDetailBulkConfirmData:gn,processOrderDetailBulkChange:_n,saveAdminMemo:yn,updateChangeQuantity:wn,openConfirmDepositModal:hn,confirmDeposit:Cn,initShippingPolicyForm:Qn,addCountrySetting:zn,removeCountrySetting:Yn,switchCountryTab:Wn,updateCountryField:Jn,onChargePolicyChange:Xn,addRangeTier:Zn,removeRangeTier:ea,updateRangeTierField:ta,validateRangeTiers:oa,addExtraFeeRow:ra,removeExtraFeeRow:na,applyExtraFeeTemplate:aa,updateUnitValue:sa,addApiRequestField:ia,updateApiRequestField:ca,removeApiRequestField:la,toggleApiRequestField:da,updateApiConfigField:ua,updateApiFieldMap:ma,testShippingApi:pa,updateExtraFeeField:fa,updateCancelQuantity:In,estimateRefundAmount:Sn,changeRefundPriority:vn,executeCancelOrder:Ln,clearCancelOrderTimers:bn,toggleItemSelection:Pn,toggleSelectAllItems:An,initUserCancelItems:kn,toggleUserCancelItem:On,toggleUserCancelSelectAll:Gn,updateUserCancelQuantity:En,estimateUserRefund:Dn,changeUserRefundPriority:Fn,executeUserCancelOrder:$n,clearUserCancelOrderTimers:xn,confirmOrderOption:Tn,changeShippingAddress:Mn,submitReview:Hn,initCategoryFromUrl:ya,initBrandFromUrl:Ca,initCommonInfoFromUrl:Sa,initNoticeFromUrl:xa},V="sirsoft-ecommerce",we=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 At(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})}),we.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})}),we.log(`${Object.keys(oe).length} handler(s) registered:`,Object.keys(oe).map(n=>`${V}.${n}`))):(e++,e<=t?(we.warn(`ActionDispatcher not found, retrying... (${e}/${t})`),setTimeout(o,100)):we.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};o()}else we.warn("ActionDispatcher not found, handlers not registered")}function Qe(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>At(!0));else{const r=!!window.G7Core?.getActionDispatcher?.();At(!r)}}return Qe(),typeof window<"u"&&(window.__SirsoftEcommerce={identifier:V,handlers:Object.keys(oe),initModule:Qe}),ke.initModule=Qe,Object.defineProperty(ke,Symbol.toStringTag,{value:"Module"}),ke}({}); diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md index a8f591cb..7b32ffbe 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md @@ -16,6 +16,27 @@ --- +## 옵션의 무게·부피 (`options[].weight` / `options[].volume`) + +옵션 리소스(`ProductOptionResource`)는 배송비 계산에 쓰이는 물성 두 개를 함께 내려보냅니다. + +| 필드 | 타입 | 단위 | 설명 | +| --- | --- | --- | --- | +| weight | number\|null | g (그램) | 옵션 1개의 무게. 미입력이면 `null` | +| volume | number\|null | cm³ (세제곱센티미터) | 옵션 1개의 부피. 미입력이면 `null` | + +단위는 저장 단위 그대로입니다. 배송정책의 구간·단위값은 kg / L 로 입력받으며, 환산은 +배송비를 계산하는 시점에 한 번만 수행됩니다 (`docs/api/shipping-policies.md` 참고). + +`null` 과 `0` 은 구분됩니다 — `null` 은 미입력, `0` 은 "무게 없음"입니다. 수정 요청은 +전달된 필드만 반영하므로, 편집 화면이 이 값을 받지 못한 채 저장하면 기존 값이 그대로 +유지되지만 화면에 `0` 으로 그려 놓고 저장하면 실측값이 0 으로 덮어써집니다. + +주문 생성 시 이 값은 주문 옵션(`unit_weight`/`unit_volume`)에 g / cm³ 그대로 복사되고, +주문의 `total_weight`/`total_volume` 은 옵션 소계의 합입니다. + +--- + ## 목록 응답의 총 건수와 페이지 이동 상품 목록(관리자·공개 양쪽)은 총 건수를 상한까지만 셉니다. 상한을 넘으면 응답의 diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md index 849ff4d1..b0bf0322 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md @@ -16,6 +16,91 @@ --- +## 국가별 설정(`country_settings[]`)의 검증 계약 + +생성(`POST`)·수정(`PUT`) 요청은 같은 규칙을 공유합니다. 이 규칙들은 저장 시점에 422 로 차단되며, +목적은 "설정은 저장됐는데 계산 결과가 조용히 0원(무료배송)이 되는" 상태를 만들지 않는 것입니다. + +**부과정책별 필수값** + +| 부과정책 | 필수 항목 | 누락 시 | +| --- | --- | --- | +| `range_*` (금액/수량/무게/부피/부피무게) | `ranges.tiers` 최소 1개 | 422 | +| `per_*` (수량당/무게당/부피당/부피무게당/금액당) | `ranges.unit_value` | 422 | +| `conditional_free` | `free_threshold` | 422 | +| `fixed`, `conditional_free`, `per_*`, `api` | `base_fee` > 0 | 422 | +| `api` | `api_endpoint` | 422 | + +`api` 정책의 `base_fee` 는 외부 API 호출이 실패했을 때 사용할 폴백 배송비입니다. 0 을 허용하면 +외부 API 장애가 그대로 무료배송이 되므로 0 을 거부합니다. + +**구간(`ranges.tiers[]`) 규칙** + +구간의 종료값(`max`)은 **포함**입니다 — `max: 5` 인 구간은 값 5까지 그 구간입니다. +마지막 구간의 `max` 는 `null`(무제한)이어야 하고, 그 외 구간은 `max` 가 있어야 합니다. + +구간을 잇는 방식은 값의 성질에 따라 다릅니다. + +| 부과정책 | 다음 구간의 `min` | 예시 | +| --- | --- | --- | +| `range_quantity` (이산값) | 직전 `max` + 1 | `0~5` → `6~` | +| `range_amount` · `range_weight` · `range_volume` · `range_volume_weight` (연속값) | 직전 `max` | `0~2` → `2~` | + +연속형은 소수 경계(`2.5`)를 허용하며, 비교에는 0.001 의 허용 오차를 둡니다. +`range_quantity` 의 `min`/`max` 는 정수여야 합니다. +첫 구간의 `min` 은 0 이어야 합니다. + +`min` 은 화면 표시용이며 배송비 계산에는 사용되지 않습니다. 계산은 구간을 종료값 오름차순으로 +정렬한 뒤 값이 종료값 이하인 첫 구간의 배송비를 채택합니다. + +**구간·단위값의 단위** + +상품 옵션의 무게·부피는 **g / cm³** 로 저장되고, 배송정책의 구간 경계와 `unit_value` 는 +**kg / L** 로 입력합니다. 환산은 배송비 계산 시점에 한 번만 수행됩니다. + +| 부과정책 | 구간/단위값 단위 | +| --- | --- | +| `range_quantity` · `per_quantity` | 개 | +| `range_weight` · `range_volume_weight` · `per_weight` · `per_volume_weight` | kg | +| `range_volume` · `per_volume` | L | +| `range_amount` · `per_amount` | 기본 통화 금액 | + +부피무게는 `부피(cm³) ÷ ranges.volume_weight_divisor`(기본 6000)로 kg 을 얻고, 실무게(kg)와 +비교해 큰 값을 사용합니다. + +**계산 API 요청 페이로드의 단위** + +`api` 정책이 외부 서버로 보내는 요청은 **저장 단위(g / cm³)를 그대로** 싣습니다. 위 kg/L 환산은 +쇼핑몰이 자체 계산할 때만 적용되며, 외부 API 로는 환산 전 값이 나갑니다. 값에 단위를 나타내는 +접미사나 별도 필드는 붙지 않으므로, 받는 쪽에서 g/cm³ 로 해석해야 합니다. + +| 필드 | 단위 | 비고 | +| --- | --- | --- | +| `items[].weight` | g | 상품 옵션에 저장된 무게 | +| `items[].volume` | cm³ | 상품 옵션에 저장된 부피 | +| `items[].quantity` | 개 | | +| `items[].subtotal` · `group_total` | 기본 통화 금액 | | +| `total_quantity` | 개 | 배송 그룹 합계 수량 | + +`api_config.field_map` 은 키 이름만 바꿉니다 — 값의 단위는 변환하지 않습니다. 외부 API 가 kg 을 +요구한다면 그 변환은 받는 쪽에서 수행해야 합니다. + +**도서산간 우편번호(`extra_fee_settings[].zipcode`) 형식** + +다음 3가지 형식만 허용합니다. 그 외 형식은 어떤 우편번호에도 매칭되지 않아 추가배송비가 +조용히 누락되므로 422 로 거부합니다. + +| 형식 | 예 | 의미 | +| --- | --- | --- | +| `\d+` | `63000` | 정확일치 | +| `\d+-\d+` | `63000-63644` | 범위(양끝 포함) | +| `\d+*` | `63*` | 접두 일치 | + +`extra_fee_multiply` 를 켜면 `per_*` 정책에서 추가배송비가 **기본 배송비와 같은 배송 단위 수**만큼 +곱해집니다(주문 수량이 아닙니다). + +--- + ### GET /api/modules/sirsoft-ecommerce/admin/shipping-policies @@ -728,7 +813,18 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지 **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송정책이 삭제되었습니다.", + "data": null +} +``` + **에러 응답** @@ -767,11 +863,55 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +| 필드 | 타입 | 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"기본 배송정책","en":"Default Shipping Policy"}` | 정책명 (로케일별 값 객체) | +| name_localized | string | `기본 배송정책` | `name` 의 현재 로케일 해석 값 | +| country_settings | array | `[]` | 국가별 배송 설정 목록 (`countrySettings` 관계가 로드된 경우에만 포함) | +| fee_summary | string | `KR: 배송비: 3,000원` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (활성 설정 없으면 빈 문자열) | +| countries_display | string | `🇰🇷` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개, 초과분은 `+N` 축약) | +| is_active | boolean | `true` | 활성 여부 | +| is_default | boolean | `false` | 기본 배송정책 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송정책을 조회했습니다.", + "data": { + "id": 1, + "name": { + "ko": "기본 배송정책", + "en": "Default Shipping Policy" + }, + "name_localized": "기본 배송정책", + "fee_summary": "KR: 배송비: 3,000원", + "countries_display": "🇰🇷", + "is_active": true, + "is_default": false, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + **에러 응답** @@ -917,11 +1057,55 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +| 필드 | 타입 | 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"기본 배송정책","en":"Default Shipping Policy"}` | 정책명 (로케일별 값 객체) | +| name_localized | string | `기본 배송정책` | `name` 의 현재 로케일 해석 값 | +| country_settings | array | `[]` | 국가별 배송 설정 목록 (`countrySettings` 관계가 로드된 경우에만 포함) | +| fee_summary | string | `KR: 배송비: 3,000원` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (활성 설정 없으면 빈 문자열) | +| countries_display | string | `🇰🇷` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개, 초과분은 `+N` 축약) | +| is_active | boolean | `true` | 활성 여부 | +| is_default | boolean | `false` | 기본 배송정책 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "기본 배송정책으로 설정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "기본 배송정책", + "en": "Default Shipping Policy" + }, + "name_localized": "기본 배송정책", + "fee_summary": "KR: 배송비: 3,000원", + "countries_display": "🇰🇷", + "is_active": true, + "is_default": false, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + **에러 응답** @@ -959,11 +1143,55 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +| 필드 | 타입 | 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"기본 배송정책","en":"Default Shipping Policy"}` | 정책명 (로케일별 값 객체) | +| name_localized | string | `기본 배송정책` | `name` 의 현재 로케일 해석 값 | +| country_settings | array | `[]` | 국가별 배송 설정 목록 (`countrySettings` 관계가 로드된 경우에만 포함) | +| fee_summary | string | `KR: 배송비: 3,000원` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (활성 설정 없으면 빈 문자열) | +| countries_display | string | `🇰🇷` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개, 초과분은 `+N` 축약) | +| is_active | boolean | `true` | 활성 여부 | +| is_default | boolean | `false` | 기본 배송정책 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송정책 상태가 변경되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "기본 배송정책", + "en": "Default Shipping Policy" + }, + "name_localized": "기본 배송정책", + "fee_summary": "KR: 배송비: 3,000원", + "countries_display": "🇰🇷", + "is_active": true, + "is_default": false, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/shippingPolicyFormHandlers.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/shippingPolicyFormHandlers.test.ts index 71490a66..5aeed037 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/shippingPolicyFormHandlers.test.ts +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/shippingPolicyFormHandlers.test.ts @@ -25,6 +25,7 @@ import { addCountrySettingHandler, removeCountrySettingHandler, switchCountryTabHandler, + updateCountryFieldHandler, onChargePolicyChangeHandler, addRangeTierHandler, removeRangeTierHandler, @@ -36,6 +37,7 @@ import { toggleApiRequestFieldHandler, updateApiConfigFieldHandler, updateApiFieldMapHandler, + updateUnitValueHandler, } from '../../handlers/shippingPolicyFormHandlers'; // ===== 헬퍼: 중첩 경로에 값 설정 ===== @@ -498,7 +500,7 @@ describe('shippingPolicyFormHandlers', () => { expect(updates.showBaseFee).toBe(false); expect(updates['form.country_settings'][0].ranges).toEqual({ type: 'weight', - tiers: [{ min: 0, max: null, fee: 0 }], + tiers: [{ min: 0, max: null, fee: null }], }); }); @@ -606,12 +608,13 @@ describe('shippingPolicyFormHandlers', () => { // ===== addRangeTierHandler ===== describe('addRangeTierHandler', () => { - it('country_settings[index].ranges.tiers에 추가', () => { + it('country_settings[index].ranges.tiers에 추가 (연속형: 시작값 = 직전 종료값)', () => { mockLocalState = { form: { country_settings: [ { ...createDefaultCountrySetting('KR'), + charge_policy: 'range_amount', ranges: { type: 'amount', tiers: [{ min: 0, max: 10000, fee: 3000 }], @@ -630,17 +633,19 @@ describe('shippingPolicyFormHandlers', () => { const updates = mockContext.setLocalState.mock.calls[0][0]; const ranges = updates['form.country_settings'][0].ranges; expect(ranges.tiers).toHaveLength(2); - expect(ranges.tiers[1]).toEqual({ min: 10001, max: null, fee: 0 }); + // 배송비는 빈 값으로 시작해 미입력이 required 검증에 걸리게 한다 + expect(ranges.tiers[1]).toEqual({ min: 10000, max: null, fee: null }); }); - it('새 tier의 min = 이전 tier의 max + 1 (포함 범위)', () => { + it('이산형(수량) 구간의 새 시작값 = 직전 종료값 + 1', () => { mockLocalState = { form: { country_settings: [ { ...createDefaultCountrySetting('KR'), + charge_policy: 'range_quantity', ranges: { - type: 'weight', + type: 'quantity', tiers: [ { min: 0, max: 5, fee: 3000 }, { min: 6, max: 20, fee: 5000 }, @@ -663,6 +668,37 @@ describe('shippingPolicyFormHandlers', () => { expect(ranges.tiers[2].min).toBe(21); }); + it('연속형(무게) 구간의 새 시작값 = 직전 종료값', () => { + mockLocalState = { + form: { + country_settings: [ + { + ...createDefaultCountrySetting('KR'), + charge_policy: 'range_weight', + ranges: { + type: 'weight', + tiers: [ + { min: 0, max: 5, fee: 3000 }, + { min: 5, max: 20, fee: 5000 }, + ], + }, + }, + ], + }, + rangeErrors: {}, + }; + + addRangeTierHandler( + createAction({ index: 0 }), + mockContext + ); + + const updates = mockContext.setLocalState.mock.calls[0][0]; + const ranges = updates['form.country_settings'][0].ranges; + expect(ranges.tiers).toHaveLength(3); + expect(ranges.tiers[2].min).toBe(20); + }); + it('빈 tiers에 첫 추가', () => { mockLocalState = { form: { @@ -684,7 +720,42 @@ describe('shippingPolicyFormHandlers', () => { const updates = mockContext.setLocalState.mock.calls[0][0]; const ranges = updates['form.country_settings'][0].ranges; expect(ranges.tiers).toHaveLength(1); - expect(ranges.tiers[0]).toEqual({ min: 0, max: null, fee: 0 }); + expect(ranges.tiers[0]).toEqual({ min: 0, max: null, fee: null }); + }); + + it('직전 구간 종료값이 비어 있으면 새 시작값은 비고 종료값 누락 오류가 뜬다', () => { + // 마지막 구간(무제한)은 종료값이 비어 있다 — 그 뒤에 구간을 추가하면 직전 구간이 + // 중간 구간이 되므로 종료값을 입력해야 하고, 시작값은 파생할 근거가 없어 빈 값이 된다 + mockLocalState = { + form: { + country_settings: [ + { + ...createDefaultCountrySetting('KR'), + charge_policy: 'range_weight', + ranges: { + type: 'weight', + tiers: [{ min: 0, max: null, fee: 3000 }], + }, + }, + ], + }, + rangeErrors: {}, + }; + + addRangeTierHandler( + createAction({ index: 0 }), + mockContext + ); + + const updates = mockContext.setLocalState.mock.calls[0][0]; + const ranges = updates['form.country_settings'][0].ranges; + expect(ranges.tiers).toHaveLength(2); + expect(ranges.tiers[1].min).toBeNull(); + + const rangeErrors = mockLocalState.rangeErrors; + expect(rangeErrors.KR[0].max).toBe( + 'sirsoft-ecommerce.admin.shipping_policy.form.range_error_middle_max' + ); }); }); @@ -933,11 +1004,12 @@ describe('shippingPolicyFormHandlers', () => { country_settings: [ { ...createDefaultCountrySetting('KR'), + charge_policy: 'range_amount', ranges: { type: 'amount', tiers: [ - { min: 0, max: 9999, fee: 3000 }, - { min: 10000, max: 29999, fee: 2000 }, + { min: 0, max: 10000, fee: 3000 }, + { min: 10000, max: 30000, fee: 2000 }, { min: 30000, max: null, fee: 0 }, ], }, @@ -1443,4 +1515,419 @@ describe('shippingPolicyFormHandlers', () => { expect(updates['form.country_settings'][0].api_config.field_map).toBeNull(); }); }); + + // ===== 구간 경계·필수값 계약 (공개 #94) ===== + + describe('구간 경계·필수값 계약 (#94)', () => { + /** + * 구간 정책의 국가별 설정을 만듭니다. + * + * @param chargePolicy 부과정책 + * @param tiers 구간 배열 + */ + function stateWithTiers(chargePolicy: string, tiers: any[]) { + return { + form: { + country_settings: [ + { + ...createDefaultCountrySetting('KR'), + charge_policy: chargePolicy, + ranges: { type: chargePolicy.replace('range_', ''), tiers }, + }, + ], + }, + activeCountryTab: 0, + rangeErrors: {}, + }; + } + + /** + * 마지막 setLocalState 호출의 rangeErrors 를 반환합니다. + */ + function lastRangeErrors(): any[] { + const calls = mockContext.setLocalState.mock.calls; + const last = calls[calls.length - 1][0]; + + return last.rangeErrors.KR; + } + + it('종료값 입력 시 다음 구간의 시작값이 자동 파생된다 (연속형)', () => { + mockLocalState = stateWithTiers('range_weight', [ + { min: 0, max: null, fee: 3000 }, + { min: null, max: null, fee: 5000 }, + ]); + + updateRangeTierFieldHandler( + createAction({ countryIndex: 0, tierIndex: 0, field: 'max', value: '2.5' }), + mockContext + ); + + const updates = mockContext.setLocalState.mock.calls[0][0]; + const tiers = updates['form.country_settings'][0].ranges.tiers; + expect(tiers[0].max).toBe(2.5); + expect(tiers[1].min).toBe(2.5); + }); + + it('종료값 입력 시 다음 구간의 시작값이 자동 파생된다 (이산형)', () => { + mockLocalState = stateWithTiers('range_quantity', [ + { min: 0, max: null, fee: 3000 }, + { min: null, max: null, fee: 5000 }, + ]); + + updateRangeTierFieldHandler( + createAction({ countryIndex: 0, tierIndex: 0, field: 'max', value: '5' }), + mockContext + ); + + const updates = mockContext.setLocalState.mock.calls[0][0]; + const tiers = updates['form.country_settings'][0].ranges.tiers; + expect(tiers[1].min).toBe(6); + }); + + it('종료값을 비우면 다음 구간의 시작값도 비워진다', () => { + mockLocalState = stateWithTiers('range_weight', [ + { min: 0, max: 2, fee: 3000 }, + { min: 2, max: null, fee: 5000 }, + ]); + + updateRangeTierFieldHandler( + createAction({ countryIndex: 0, tierIndex: 0, field: 'max', value: '' }), + mockContext + ); + + const updates = mockContext.setLocalState.mock.calls[0][0]; + const tiers = updates['form.country_settings'][0].ranges.tiers; + expect(tiers[0].max).toBeNull(); + expect(tiers[1].min).toBeNull(); + }); + + it('구간이 하나도 없으면 오류를 표시한다', () => { + mockLocalState = stateWithTiers('range_quantity', []); + + validateRangeTiersHandler(createAction({ countryIndex: 0 }), mockContext); + + const errors = lastRangeErrors(); + expect(errors).toHaveLength(1); + expect(errors[0].max).toContain('range_error_min_one'); + }); + + it('배송비 미입력은 오류다 (0 으로 강제하지 않는다)', () => { + mockLocalState = stateWithTiers('range_weight', [ + { min: 0, max: 2, fee: null }, + { min: 2, max: null, fee: 5000 }, + ]); + + validateRangeTiersHandler(createAction({ countryIndex: 0 }), mockContext); + + const errors = lastRangeErrors(); + expect(errors[0].fee).toContain('range_error_fee_required'); + }); + + it('중간 구간의 종료값 누락은 오류다', () => { + mockLocalState = stateWithTiers('range_weight', [ + { min: 0, max: null, fee: 3000 }, + { min: 2, max: null, fee: 5000 }, + ]); + + validateRangeTiersHandler(createAction({ countryIndex: 0 }), mockContext); + + const errors = lastRangeErrors(); + expect(errors[0].max).toContain('range_error_middle_max'); + }); + + it('연속형은 다음 시작값이 직전 종료값이어야 한다', () => { + mockLocalState = stateWithTiers('range_weight', [ + { min: 0, max: 2, fee: 3000 }, + { min: 3, max: null, fee: 5000 }, + ]); + + validateRangeTiersHandler(createAction({ countryIndex: 0 }), mockContext); + + expect(lastRangeErrors()[0].max).toContain('range_error_continuity'); + }); + + it('연속형은 소수 경계를 허용한다', () => { + mockLocalState = stateWithTiers('range_weight', [ + { min: 0, max: 2.5, fee: 3000 }, + { min: 2.5, max: null, fee: 5000 }, + ]); + + validateRangeTiersHandler(createAction({ countryIndex: 0 }), mockContext); + + expect(lastRangeErrors()).toEqual([]); + }); + + it('이산형은 다음 시작값이 직전 종료값 + 1 이어야 한다', () => { + mockLocalState = stateWithTiers('range_quantity', [ + { min: 0, max: 5, fee: 3000 }, + { min: 6, max: null, fee: 5000 }, + ]); + + validateRangeTiersHandler(createAction({ countryIndex: 0 }), mockContext); + + expect(lastRangeErrors()).toEqual([]); + }); + + it('이산형의 소수 경계는 오류다', () => { + mockLocalState = stateWithTiers('range_quantity', [ + { min: 0, max: 5.5, fee: 3000 }, + { min: 6.5, max: null, fee: 5000 }, + ]); + + validateRangeTiersHandler(createAction({ countryIndex: 0 }), mockContext); + + expect(lastRangeErrors()[0].max).toContain('range_error_integer'); + }); + + it('수정 모드 진입 시 저장된 구간의 시작값을 재파생한다', () => { + // 기존 데이터는 "1~5개 / 6개~" 처럼 첫 시작값이 1 인 형태로 저장돼 있다. + // 시작값이 읽기전용이 된 이상 화면이 정규화하지 않으면 운영자가 고칠 방법이 없고, + // 그대로 저장하면 "첫 구간의 시작값은 0이어야 합니다" 로 422 가 된다. + mockLocalState = { form: { country_settings: [] } }; + + initShippingPolicyFormHandler( + createAction({ + isEdit: true, + policy: { + country_settings: [ + { + ...createDefaultCountrySetting('KR'), + charge_policy: 'range_quantity', + ranges: { + type: 'quantity', + tiers: [ + { min: 1, max: 5, fee: 3000 }, + { min: 6, max: null, fee: 5000 }, + ], + }, + }, + ], + }, + }), + mockContext + ); + + const updates = mockContext.setLocalState.mock.calls[0][0]; + const tiers = updates['form.country_settings'][0].ranges.tiers; + expect(tiers[0].min).toBe(0); + expect(tiers[1].min).toBe(6); + // 종료값·배송비는 손대지 않는다 + expect(tiers[0].max).toBe(5); + expect(tiers[1].fee).toBe(5000); + }); + + it('수정 모드 진입 시 연속형 구간의 시작값도 재파생한다', () => { + mockLocalState = { form: { country_settings: [] } }; + + initShippingPolicyFormHandler( + createAction({ + isEdit: true, + policy: { + country_settings: [ + { + ...createDefaultCountrySetting('KR'), + charge_policy: 'range_weight', + ranges: { + type: 'weight', + tiers: [ + { min: 0, max: 2, fee: 3000 }, + { min: 3, max: null, fee: 5000 }, + ], + }, + }, + ], + }, + }), + mockContext + ); + + const updates = mockContext.setLocalState.mock.calls[0][0]; + const tiers = updates['form.country_settings'][0].ranges.tiers; + expect(tiers[1].min).toBe(2); + }); + + it('단위값을 비우면 null 로 보존한다 (1 로 강제하지 않는다)', () => { + mockLocalState = { + form: { + country_settings: [ + { + ...createDefaultCountrySetting('KR'), + charge_policy: 'per_weight', + ranges: { type: 'per_weight', unit_value: 2 }, + }, + ], + }, + activeCountryTab: 0, + }; + + updateUnitValueHandler(createAction({ value: '' }), mockContext); + + const updates = mockContext.setLocalState.mock.calls[0][0]; + expect(updates['form.country_settings'][0].ranges.unit_value).toBeNull(); + }); + }); + + // ===== 폼 레벨 필수값 선제 검증 (단위값 / 무료배송 기준금액) ===== + + describe('폼 레벨 필수값 선제 검증', () => { + const UNIT_VALUE_KEY = 'country_settings.0.ranges.unit_value'; + const FREE_THRESHOLD_KEY = 'country_settings.0.free_threshold'; + const UNIT_VALUE_MESSAGE = + 'sirsoft-ecommerce.admin.shipping_policy.form.range_error_unit_value_required'; + const FREE_THRESHOLD_MESSAGE = + 'sirsoft-ecommerce.admin.shipping_policy.form.range_error_free_threshold_required'; + + function seedPerUnitPolicy(unitValue: number | null) { + mockLocalState = { + form: { + country_settings: [ + { + ...createDefaultCountrySetting('KR'), + charge_policy: 'per_weight', + ranges: { type: 'per_weight', unit_value: unitValue }, + }, + ], + }, + activeCountryTab: 0, + errors: null, + }; + } + + it('단위값을 비우면 저장 전에 오류가 표시된다', () => { + seedPerUnitPolicy(2); + + updateUnitValueHandler(createAction({ value: '' }), mockContext); + + expect(mockLocalState.errors[UNIT_VALUE_KEY]).toEqual([UNIT_VALUE_MESSAGE]); + }); + + it('단위값이 0 이하이면 오류가 표시된다', () => { + seedPerUnitPolicy(2); + + updateUnitValueHandler(createAction({ value: '0' }), mockContext); + + expect(mockLocalState.errors[UNIT_VALUE_KEY]).toEqual([UNIT_VALUE_MESSAGE]); + }); + + it('단위값을 다시 채우면 오류가 해제된다', () => { + seedPerUnitPolicy(null); + mockLocalState.errors = { [UNIT_VALUE_KEY]: [UNIT_VALUE_MESSAGE] }; + + updateUnitValueHandler(createAction({ value: '0.5' }), mockContext); + + expect(mockLocalState.errors[UNIT_VALUE_KEY]).toBeNull(); + }); + + it('단위값을 요구하지 않는 정책에서는 오류가 없다', () => { + mockLocalState = { + form: { + country_settings: [ + { + ...createDefaultCountrySetting('KR'), + charge_policy: 'fixed', + ranges: { type: 'fixed', unit_value: null }, + }, + ], + }, + activeCountryTab: 0, + errors: null, + }; + + updateUnitValueHandler(createAction({ value: '' }), mockContext); + + expect(mockLocalState.errors[UNIT_VALUE_KEY]).toBeNull(); + }); + + it('조건부 무료배송에서 기준금액을 비우면 저장 전에 오류가 표시된다', () => { + mockLocalState = { + form: { + country_settings: [ + { + ...createDefaultCountrySetting('KR'), + charge_policy: 'conditional_free', + free_threshold: 50000, + }, + ], + }, + activeCountryTab: 0, + errors: null, + }; + + updateCountryFieldHandler( + createAction({ field: 'free_threshold', value: null }), + mockContext + ); + + expect(mockLocalState.errors[FREE_THRESHOLD_KEY]).toEqual([FREE_THRESHOLD_MESSAGE]); + }); + + it('기준금액을 다시 채우면 오류가 해제된다', () => { + mockLocalState = { + form: { + country_settings: [ + { + ...createDefaultCountrySetting('KR'), + charge_policy: 'conditional_free', + free_threshold: null, + }, + ], + }, + activeCountryTab: 0, + errors: { [FREE_THRESHOLD_KEY]: [FREE_THRESHOLD_MESSAGE] }, + }; + + updateCountryFieldHandler( + createAction({ field: 'free_threshold', value: 30000 }), + mockContext + ); + + expect(mockLocalState.errors[FREE_THRESHOLD_KEY]).toBeNull(); + }); + + it('정책을 바꾸면 직전 정책에서 남은 필수값 오류가 사라진다', () => { + mockLocalState = { + form: { + country_settings: [ + { + ...createDefaultCountrySetting('KR'), + charge_policy: 'per_weight', + ranges: { type: 'per_weight', unit_value: null }, + }, + ], + }, + activeCountryTab: 0, + errors: { [UNIT_VALUE_KEY]: [UNIT_VALUE_MESSAGE] }, + }; + + onChargePolicyChangeHandler( + createAction({ value: 'fixed', index: 0 }), + mockContext + ); + + expect(mockLocalState.errors[UNIT_VALUE_KEY]).toBeNull(); + }); + + it('조건부 무료배송으로 바꾸면 비어 있는 기준금액이 즉시 오류로 드러난다', () => { + mockLocalState = { + form: { + country_settings: [ + { + ...createDefaultCountrySetting('KR'), + charge_policy: 'fixed', + free_threshold: null, + }, + ], + }, + activeCountryTab: 0, + errors: null, + }; + + onChargePolicyChangeHandler( + createAction({ value: 'conditional_free', index: 0 }), + mockContext + ); + + expect(mockLocalState.errors[FREE_THRESHOLD_KEY]).toEqual([FREE_THRESHOLD_MESSAGE]); + }); + }); }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/shippingPolicyFormI18nKeys.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/shippingPolicyFormI18nKeys.test.ts new file mode 100644 index 00000000..2cae3be0 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/shippingPolicyFormI18nKeys.test.ts @@ -0,0 +1,74 @@ +/** + * 배송정책 폼 핸들러가 요청하는 다국어 키가 실제로 존재하는지 검사한다. + * + * `G7Core.t()` 는 키를 찾지 못하면 키 문자열을 그대로 돌려준다. 핸들러의 + * `t?.(key) ?? '기본 문구'` 는 이 경우 방어가 되지 않는다 — null 이 아니라 키가 + * 반환되므로 `??` 가 발동하지 않고, 화면에는 + * "sirsoft-ecommerce.validation.shipping_policy.ranges.continuity" 같은 원문 키가 뜬다. + * + * 오류도 경고도 나지 않고 문구만 깨지므로, 키 존재 여부는 이렇게 구조적으로 잠근다. + * + * @vitest-environment node + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const MODULE_ROOT = resolve(__dirname, '../../../..'); +const HANDLER_PATH = resolve(MODULE_ROOT, 'resources/js/handlers/shippingPolicyFormHandlers.ts'); + +/** 핸들러 소스에서 요청하는 다국어 키를 전수 추출한다. */ +function extractRequestedKeys(): string[] { + const source = readFileSync(HANDLER_PATH, 'utf-8'); + const keys = new Set(); + const re = /t\?\.\(\s*'([^']+)'\s*\)/g; + let match; + + while ((match = re.exec(source)) !== null) { + keys.add(match[1]); + } + + return [...keys]; +} + +/** + * `sirsoft-ecommerce.admin.shipping_policy.form.x` 형태의 키를 파일/경로로 해석한다. + * + * 프론트 다국어는 `resources/lang/partial/{locale}/admin/shipping_policy.json` 이 + * `admin/shipping_policy` 네임스페이스를 담당한다. + */ +function resolveKey(locale: string, key: string): string | undefined { + const prefix = 'sirsoft-ecommerce.admin.shipping_policy.'; + + if (!key.startsWith(prefix)) { + return undefined; + } + + const json = JSON.parse( + readFileSync(resolve(MODULE_ROOT, `resources/lang/partial/${locale}/admin/shipping_policy.json`), 'utf-8') + ); + + return key + .slice(prefix.length) + .split('.') + .reduce((acc, segment) => (acc == null ? undefined : acc[segment]), json); +} + +describe('shippingPolicyFormHandlers 다국어 키', () => { + const requested = extractRequestedKeys(); + + it('핸들러가 다국어 키를 실제로 요청한다 (추출이 비면 이 테스트는 아무것도 검증하지 못한다)', () => { + expect(requested.length).toBeGreaterThan(5); + }); + + it('모든 요청 키가 프론트엔드 네임스페이스를 사용한다', () => { + // 백엔드 lang 경로(sirsoft-ecommerce.validation.*)는 프론트 번들에 실리지 않는다 + const backendKeys = requested.filter(k => !k.startsWith('sirsoft-ecommerce.admin.shipping_policy.')); + expect(backendKeys).toEqual([]); + }); + + it.each(['ko', 'en'])('%s 로케일에 모든 요청 키가 정의되어 있다', (locale) => { + const missing = requested.filter(key => typeof resolveKey(locale, key) !== 'string'); + expect(missing).toEqual([]); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingPolicyFormLayouts.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingPolicyFormLayouts.test.tsx index caa7edab..58d2b203 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingPolicyFormLayouts.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingPolicyFormLayouts.test.tsx @@ -563,8 +563,10 @@ describe('shippingPolicyFormLayouts', () => { expect(rangesField.if).toBe('{{_local.showRanges}}'); // tier 행 iteration - const tierRow = findById(chargeSettingsPartial, 'range_tier_row'); - expect(tierRow).toBeDefined(); + // id 는 반복 인덱스를 포함한 템플릿 형태다 — 접두어만으로 찾으면 null 이 돌아오고, + // toBeDefined() 는 null 도 통과시키므로 존재 확인은 not.toBeNull() 로 한다. + const tierRow = findById(chargeSettingsPartial, 'range_tier_row_{{tierIdx}}'); + expect(tierRow).not.toBeNull(); expect(tierRow.iteration).toBeDefined(); expect(tierRow.iteration.source).toBe('{{_computed.activeCountrySetting?.ranges?.tiers ?? []}}'); expect(tierRow.iteration.item_var).toBe('tier'); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/handlers/shippingPolicyFormHandlers.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/handlers/shippingPolicyFormHandlers.ts index e2ada8ab..63f9073c 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/handlers/shippingPolicyFormHandlers.ts +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/handlers/shippingPolicyFormHandlers.ts @@ -29,7 +29,7 @@ interface CountrySetting { charge_policy: string; base_fee: number; free_threshold: number | null; - ranges: { type?: string; tiers?: RangeTier[]; unit_value?: number } | null; + ranges: { type?: string; tiers?: RangeTier[]; unit_value?: number | null } | null; api_endpoint: string | null; api_request_fields: string[] | null; api_response_fee_field: string | null; @@ -41,9 +41,10 @@ interface CountrySetting { } interface RangeTier { - min: number; + /** 시작값 — 직전 구간의 종료값에서 자동 파생되며 화면에서는 읽기전용 표시 */ + min: number | null; max: number | null; - fee: number; + fee: number | null; } interface ApiConfig { @@ -87,6 +88,59 @@ const REQUIRES_UNIT_VALUE = [ 'per_quantity', 'per_weight', 'per_volume', 'per_volume_weight', 'per_amount', ]; +/** + * 구간 경계값이 이산형(정수)인 정책. + * + * 수량은 "5개 다음이 6개"(max + 1)로 이어지고, 금액·무게·부피는 연속값이라 + * "5kg 다음은 5kg 초과"(다음 min = max)로 이어진다. 서버 검증(ChargePolicyEnum:: + * hasDiscreteRangeValues)과 같은 판정을 사용해야 화면과 저장 결과가 어긋나지 않는다. + */ +const DISCRETE_RANGE_POLICIES = ['range_quantity']; + +/** 연속형 구간 경계 비교 허용 오차 (서버 검증과 동일) */ +const CONTINUITY_EPSILON = 0.001; + +/** + * 부과정책이 이산형 구간을 쓰는지 판정합니다. + * + * @param chargePolicy 부과정책 값 + * @returns 이산형이면 true + */ +function isDiscreteRangePolicy(chargePolicy: string): boolean { + return DISCRETE_RANGE_POLICIES.includes(chargePolicy); +} + +/** + * 직전 구간의 종료값으로부터 다음 구간의 시작값을 파생합니다. + * + * @param previousMax 직전 구간 종료값 + * @param chargePolicy 부과정책 값 + * @returns 파생된 시작값 (종료값 미입력 시 null) + */ +function deriveNextMin(previousMax: number | null | undefined, chargePolicy: string): number | null { + if (previousMax === null || previousMax === undefined || Number.isNaN(previousMax)) { + return null; + } + + return isDiscreteRangePolicy(chargePolicy) ? previousMax + 1 : previousMax; +} + +/** + * 구간 배열의 시작값을 직전 구간 종료값 기준으로 전부 재파생합니다. + * + * 첫 구간은 항상 0 이며, 이후 구간은 직전 종료값에서 파생됩니다. + * + * @param tiers 구간 배열 + * @param chargePolicy 부과정책 값 + * @returns 시작값이 재파생된 새 구간 배열 + */ +function rederiveTierMins(tiers: RangeTier[], chargePolicy: string): RangeTier[] { + return tiers.map((tier, index) => ({ + ...tier, + min: index === 0 ? 0 : deriveNextMin(tiers[index - 1]?.max, chargePolicy), + })); +} + // ===== 국가별 기본 설정값 ===== const DEFAULT_COUNTRY_SETTING: Omit = { @@ -197,6 +251,26 @@ export function initShippingPolicyFormHandler( const chargePolicy = firstSetting?.charge_policy ?? 'fixed'; const flags = getVisibilityFlags(chargePolicy); Object.assign(stateUpdates, flags); + + // 저장된 구간의 시작값을 현재 계약으로 재파생한다. + // 기존 데이터는 "1~5개 / 6개~" 처럼 첫 시작값이 1 인 형태가 있는데, 시작값이 + // 읽기전용이 된 이상 화면이 정규화하지 않으면 운영자가 고칠 수단이 없고 + // 그대로 저장하면 "첫 구간의 시작값은 0이어야 합니다" 로 거부된다. + const normalized = countrySettings.map((cs: any) => { + const tiers = cs?.ranges?.tiers; + + if (!Array.isArray(tiers) || tiers.length === 0) { + return cs; + } + + return { + ...cs, + ranges: { ...cs.ranges, tiers: rederiveTierMins(tiers, cs.charge_policy) }, + }; + }); + + stateUpdates['form.country_settings'] = normalized; + logger.log('[initShippingPolicyForm]', isEdit ? 'Edit' : 'Copy', 'mode, countries:', countrySettings.length, 'first charge_policy:', chargePolicy); } else { // 등록 모드: 기본 가시성 (fixed 기준) @@ -399,6 +473,12 @@ export function updateCountryFieldHandler( updateLocalState(context, { 'form.country_settings': countrySettings, }); + + // 무료배송 기준금액은 비면 배송비가 계산되지 않으므로 저장 전에 화면에서 먼저 알린다 + if (field === 'free_threshold') { + validateChargeSettingRequirements(G7Core, context, index, countrySettings[index]); + } + logger.log('[updateCountryField] Updated', field, '=', value, 'at index:', index); } @@ -457,12 +537,14 @@ export function onChargePolicyChangeHandler( } // ranges 초기화 (구간 정책 선택 시 기본 구조 제공) + // 배송비는 빈 값으로 두어 미입력 상태가 required 검증에 걸리도록 한다 + // (0 으로 채우면 운영자가 입력하지 않은 구간이 무료배송으로 저장된다). if (flags.showRanges) { if (!updatedCS.ranges || !updatedCS.ranges.tiers) { const rangeType = chargePolicy.replace('range_', ''); updatedCS.ranges = { type: rangeType, - tiers: [{ min: 0, max: null, fee: 0 }], + tiers: [{ min: 0, max: null, fee: null }], }; } } @@ -485,6 +567,9 @@ export function onChargePolicyChangeHandler( }; updateLocalState(context, stateUpdates); + + // 정책이 바뀌면 요구되는 필수값도 바뀐다 — 직전 정책에서 남은 오류를 지우고 새 요구사항을 반영 + validateChargeSettingRequirements(G7Core, context, countryIndex, updatedCS); } /** @@ -510,19 +595,18 @@ export function addRangeTierHandler( return; } - const currentTiers = [...(cs.ranges?.tiers ?? [])]; - - // 마지막 tier의 max + 1 값을 새 tier의 min으로 사용 (포함 범위 기준) - const lastTier = currentTiers[currentTiers.length - 1]; - const newMin = lastTier?.max != null ? lastTier.max + 1 : 0; - - const newTier: RangeTier = { min: newMin, max: null, fee: 0 }; - currentTiers.push(newTier); + // 직전까지 마지막이던 구간은 이제 종료값이 필요하다 (무제한은 새 구간이 이어받는다). + // 종료값은 비운 상태로 두어 운영자 입력을 기다리고, 새 구간의 시작값은 그 입력 시점에 파생된다. + const currentTiers = [ + ...(cs.ranges?.tiers ?? []), + { min: null, max: null, fee: null } as RangeTier, + ]; + const nextTiers = rederiveTierMins(currentTiers, cs.charge_policy); // 새 객체 생성 (원본 mutation 방지 — deepMerge 변경 감지를 위해 필수) countrySettings[countryIndex] = { ...cs, - ranges: { ...(cs.ranges ?? {}), tiers: currentTiers }, + ranges: { ...(cs.ranges ?? {}), tiers: nextTiers }, }; updateLocalState(context, { @@ -530,8 +614,8 @@ export function addRangeTierHandler( }); // 구간 검증 실행 - validateRangeTiersInternal(G7Core, context, countryIndex, currentTiers, cs.country_code); - logger.log('[addRangeTier] Added tier, total:', currentTiers.length); + validateRangeTiersInternal(G7Core, context, countryIndex, nextTiers, cs.country_code, cs.charge_policy); + logger.log('[addRangeTier] Added tier, total:', nextTiers.length); } /** @@ -572,10 +656,13 @@ export function removeRangeTierHandler( currentTiers.splice(tierIndex, 1); + // 삭제로 앞뒤 구간이 이어졌으므로 시작값을 다시 파생한다 + const nextTiers = rederiveTierMins(currentTiers, cs.charge_policy); + // 새 객체 생성 (원본 mutation 방지 — deepMerge 변경 감지를 위해 필수) countrySettings[countryIndex] = { ...cs, - ranges: { ...(cs.ranges ?? {}), tiers: currentTiers }, + ranges: { ...(cs.ranges ?? {}), tiers: nextTiers }, }; updateLocalState(context, { @@ -583,8 +670,8 @@ export function removeRangeTierHandler( }); // 구간 검증 실행 - validateRangeTiersInternal(G7Core, context, countryIndex, currentTiers, cs.country_code); - logger.log('[removeRangeTier] Removed tier:', tierIndex, 'remaining:', currentTiers.length); + validateRangeTiersInternal(G7Core, context, countryIndex, nextTiers, cs.country_code, cs.charge_policy); + logger.log('[removeRangeTier] Removed tier:', tierIndex, 'remaining:', nextTiers.length); } /** @@ -630,20 +717,21 @@ export function updateRangeTierFieldHandler( } // min/max/fee는 숫자로 변환 (DOM input의 $event.target.value는 string) - // max는 빈 값일 경우 null (무제한) - let parsedValue: number | null; - if (field === 'max') { - parsedValue = value === '' || value === null || value === undefined ? null : Number(value); - } else { - parsedValue = Number(value); - } + // 빈 값은 null 로 보존한다 — 0 으로 강제하면 미입력 구간이 무료배송으로 저장된다. + const isEmpty = value === '' || value === null || value === undefined; + const parsedValue: number | null = isEmpty ? null : Number(value); currentTiers[tierIndex] = { ...currentTiers[tierIndex], [field]: parsedValue }; + // 종료값이 바뀌면 뒤따르는 구간의 시작값을 자동 재파생한다 (시작값은 입력 대상이 아니다) + const nextTiers = field === 'max' + ? rederiveTierMins(currentTiers, cs.charge_policy) + : currentTiers; + // 새 객체 생성 (원본 mutation 방지 — deepMerge 변경 감지를 위해 필수) countrySettings[countryIndex] = { ...cs, - ranges: { ...(cs.ranges ?? {}), tiers: currentTiers }, + ranges: { ...(cs.ranges ?? {}), tiers: nextTiers }, }; updateLocalState(context, { @@ -651,7 +739,7 @@ export function updateRangeTierFieldHandler( }); // 구간 검증 실행 - validateRangeTiersInternal(G7Core, context, countryIndex, currentTiers, cs.country_code); + validateRangeTiersInternal(G7Core, context, countryIndex, nextTiers, cs.country_code, cs.charge_policy); } /** @@ -675,73 +763,116 @@ export function validateRangeTiersHandler( if (!cs) return; const tiers = cs.ranges?.tiers ?? []; - validateRangeTiersInternal(G7Core, context, countryIndex, tiers, cs.country_code); + validateRangeTiersInternal(G7Core, context, countryIndex, tiers, cs.country_code, cs.charge_policy); } /** * 구간 검증 내부 로직 + * + * 서버(StoreShippingPolicyRequest::validateRangeTiersContinuity)와 동일한 규칙을 적용합니다. + * 한쪽만 바뀌면 화면에서 통과한 설정이 저장 시 422 로 거부되거나, 그 반대가 됩니다. */ function validateRangeTiersInternal( G7Core: any, context: ActionContext, _countryIndex: number, tiers: RangeTier[], - countryCode: string + countryCode: string, + chargePolicy: string ): void { const localState = G7Core.state.getLocal?.() ?? {}; const rangeErrors: Record = { ...(localState.rangeErrors ?? {}) }; + const t = G7Core.t; if (tiers.length === 0) { - // deepMerge에서 delete로 키를 제거할 수 없으므로 빈 배열로 대체 - rangeErrors[countryCode] = []; + // 구간별 정책인데 구간이 하나도 없으면 모든 주문이 0원(무료배송)이 된다 + rangeErrors[countryCode] = REQUIRES_RANGES.includes(chargePolicy) + ? [{ + max: t?.('sirsoft-ecommerce.admin.shipping_policy.form.range_error_min_one') + ?? '구간별 배송비 정책은 구간을 1개 이상 등록해야 합니다.', + }] + : []; updateLocalState(context, { rangeErrors }); return; } const tierErrors: RangeTierError[] = new Array(tiers.length).fill(null).map(() => ({})); let hasError = false; - - const t = G7Core.t; + const isDiscrete = isDiscreteRangePolicy(chargePolicy); + const isUnlimited = (max: number | null | undefined) => max === null || max === undefined; for (let i = 0; i < tiers.length; i++) { const tier = tiers[i]; + const isLast = i === tiers.length - 1; // 첫 구간 min은 0이어야 함 - if (i === 0 && tier.min !== 0) { - tierErrors[i].min = t?.('sirsoft-ecommerce.validation.shipping_policy.ranges.first_min_zero') + if (i === 0 && Number(tier.min ?? 0) !== 0) { + tierErrors[i].min = t?.('sirsoft-ecommerce.admin.shipping_policy.form.range_error_first_min') ?? '첫 구간의 시작값은 0이어야 합니다.'; hasError = true; } - // 마지막 구간 max는 null이어야 함 - if (i === tiers.length - 1 && tier.max !== null && tier.max !== undefined) { - tierErrors[i].max = t?.('sirsoft-ecommerce.validation.shipping_policy.ranges.last_max_unlimited') + // 마지막 구간 max는 비어 있어야 함 (무제한) + if (isLast && !isUnlimited(tier.max)) { + tierErrors[i].max = t?.('sirsoft-ecommerce.admin.shipping_policy.form.range_error_last_max') ?? '마지막 구간의 종료값은 비워야 합니다.'; hasError = true; + continue; } - // min < max (마지막 구간 제외) - if (i < tiers.length - 1 && tier.max !== null && tier.max !== undefined) { - if (tier.min >= tier.max) { - tierErrors[i].min = t?.('sirsoft-ecommerce.validation.shipping_policy.ranges.min_less_than_max') + if (isLast) { + // 마지막 구간은 아래 종료값 기반 검사 대상이 아니다 + if (tier.fee === null || tier.fee === undefined) { + tierErrors[i].fee = t?.('sirsoft-ecommerce.admin.shipping_policy.form.range_error_fee_required') + ?? '구간 배송비를 입력해주세요.'; + hasError = true; + } else if (tier.fee < 0) { + tierErrors[i].fee = t?.('sirsoft-ecommerce.admin.shipping_policy.form.range_error_fee') + ?? '배송비는 0 이상이어야 합니다.'; + hasError = true; + } + continue; + } + + // 중간 구간의 종료값 누락 금지 (뒤 구간이 영구히 도달 불가해진다) + if (isUnlimited(tier.max)) { + tierErrors[i].max = t?.('sirsoft-ecommerce.admin.shipping_policy.form.range_error_middle_max') + ?? '마지막 구간을 제외한 구간에는 종료값을 입력해야 합니다.'; + hasError = true; + } else { + const max = Number(tier.max); + + // 수량 구간의 경계값은 정수 + if (isDiscrete && !Number.isInteger(max)) { + tierErrors[i].max = t?.('sirsoft-ecommerce.admin.shipping_policy.form.range_error_integer') + ?? '수량 구간의 시작값과 종료값은 정수여야 합니다.'; + hasError = true; + } + + // min < max + if (Number(tier.min ?? 0) >= max) { + tierErrors[i].min = t?.('sirsoft-ecommerce.admin.shipping_policy.form.range_error_min_max') ?? '시작값이 종료값보다 작아야 합니다.'; hasError = true; } - } - // 구간 연속성: 현재 max + 1 === 다음 min (포함 범위 기준) - if (i < tiers.length - 1) { - const nextTier = tiers[i + 1]; - if (tier.max !== null && tier.max !== undefined && (tier.max + 1) !== nextTier.min) { - tierErrors[i].max = t?.('sirsoft-ecommerce.validation.shipping_policy.ranges.continuity') + // 구간 연속성 (이산형은 max + 1, 연속형은 max 가 다음 min) + const expectedNextMin = isDiscrete ? max + 1 : max; + const actualNextMin = Number(tiers[i + 1]?.min ?? 0); + if (Math.abs(expectedNextMin - actualNextMin) >= CONTINUITY_EPSILON) { + tierErrors[i].max = t?.('sirsoft-ecommerce.admin.shipping_policy.form.range_error_continuity') ?? '구간이 연속적이지 않습니다.'; hasError = true; } } - // fee >= 0 - if (tier.fee < 0) { - tierErrors[i].fee = t?.('sirsoft-ecommerce.validation.shipping_policy.ranges.fee_non_negative') + // 배송비 필수 + 0 이상 + if (tier.fee === null || tier.fee === undefined) { + tierErrors[i].fee = t?.('sirsoft-ecommerce.admin.shipping_policy.form.range_error_fee_required') + ?? '구간 배송비를 입력해주세요.'; + hasError = true; + } else if (tier.fee < 0) { + tierErrors[i].fee = t?.('sirsoft-ecommerce.admin.shipping_policy.form.range_error_fee') ?? '배송비는 0 이상이어야 합니다.'; hasError = true; } @@ -753,6 +884,66 @@ function validateRangeTiersInternal( updateLocalState(context, { rangeErrors }); } +/** + * 폼 레벨 오류 키를 만듭니다. + * + * 서버 422 응답의 키 형태(`country_settings.{index}.{field}`)와 동일해야 레이아웃의 기존 + * 오류 표시가 클라이언트 검증 결과에도 그대로 쓰인다. + * + * @param countryIndex 국가 탭 인덱스 + * @param field 필드 경로 + * @returns 오류 맵 키 + */ +function chargeSettingErrorKey(countryIndex: number, field: string): string { + return `country_settings.${countryIndex}.${field}`; +} + +/** + * 부과정책이 요구하는 폼 레벨 필수값(단위값 / 무료배송 기준금액)을 선제 검증합니다. + * + * 서버(StoreShippingPolicyRequest::validatePolicyRequiredSettings)와 같은 규칙을 화면에서 + * 먼저 적용해, 저장을 눌러 422 를 받기 전에 빈 값이 드러나게 한다. 이 두 값이 비면 배송비가 + * 계산되지 않아 조용히 0원이 되므로 서버도 저장을 거부한다. + * + * 오류 없음은 `null` 로 기록한다 — deepMerge 로는 키를 제거할 수 없고, 빈 배열은 레이아웃의 + * `if` 조건에서 truthy 라 빈 오류 문구가 남는다. + * + * @param G7Core G7Core 전역 객체 + * @param context 액션 컨텍스트 + * @param countryIndex 국가 탭 인덱스 + * @param cs 검증 대상 국가별 설정 + */ +function validateChargeSettingRequirements( + G7Core: any, + context: ActionContext, + countryIndex: number, + cs: CountrySetting +): void { + const localState = G7Core.state.getLocal?.() ?? {}; + const errors: Record = { ...(localState.errors ?? {}) }; + const t = G7Core.t; + + const unitValue = cs.ranges?.unit_value; + const unitValueMissing = REQUIRES_UNIT_VALUE.includes(cs.charge_policy) + && (unitValue === null || unitValue === undefined || Number(unitValue) <= 0); + + errors[chargeSettingErrorKey(countryIndex, 'ranges.unit_value')] = unitValueMissing + ? [t?.('sirsoft-ecommerce.admin.shipping_policy.form.range_error_unit_value_required') + ?? '단위당 배송비 정책은 단위값을 입력해야 합니다.'] + : null; + + const freeThreshold = cs.free_threshold; + const freeThresholdMissing = REQUIRES_FREE_THRESHOLD.includes(cs.charge_policy) + && (freeThreshold === null || freeThreshold === undefined || String(freeThreshold) === ''); + + errors[chargeSettingErrorKey(countryIndex, 'free_threshold')] = freeThresholdMissing + ? [t?.('sirsoft-ecommerce.admin.shipping_policy.form.range_error_free_threshold_required') + ?? '조건부 무료배송 정책은 무료배송 기준금액을 입력해야 합니다.'] + : null; + + updateLocalState(context, { errors }); +} + /** * 도서산간 추가배송비 행을 추가합니다. * @@ -896,7 +1087,11 @@ export function updateUnitValueHandler( context: ActionContext ): void { const params = action.params || {}; - const value = parseFloat(params.value as string) || 1; + const raw = params.value; + // 빈 값은 null 로 보존한다 — 1 로 강제하면 미입력이 조용히 유효값이 되어 서버 필수 검증을 우회한다. + const isEmpty = raw === '' || raw === null || raw === undefined; + const parsed = parseFloat(raw as string); + const value: number | null = isEmpty || Number.isNaN(parsed) ? null : parsed; const G7Core = getG7Core(); if (!G7Core) return; @@ -916,6 +1111,10 @@ export function updateUnitValueHandler( updateLocalState(context, { 'form.country_settings': countrySettings, }); + + // 단위값이 비거나 0 이하이면 배송비가 계산되지 않으므로 저장 전에 화면에서 먼저 알린다 + validateChargeSettingRequirements(G7Core, context, countryIndex, countrySettings[countryIndex]); + logger.log('[updateUnitValue] Updated unit_value:', value); } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/shipping_policy.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/shipping_policy.json index f8a49885..cd1d29b7 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/shipping_policy.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/shipping_policy.json @@ -121,12 +121,6 @@ "free": "Free Shipping", "fixed": "Shipping: {{fee}}", "conditional": "Under {{threshold}}: {{fee}} / {{threshold}}+: Free", - "range_prefix": { - "amount": "", - "quantity": "pcs", - "weight": "kg", - "volume": "cm³" - }, "api": "External API (Real-time calculation)", "per_quantity": "{{fee}} per {{unit}} items", "per_weight": "{{fee}} per {{unit}}kg", @@ -213,7 +207,11 @@ "free_threshold_hint": "Free shipping for orders at or above this amount", "range_tiers": "Range-based Fees", "range_min": "Min", + "range_min_auto": "Auto", "range_max": "Max", + "unit_quantity": "items", + "unit_weight": "kg", + "unit_volume": "L", "range_fee": "Fee", "range_unlimited": "Unlimited", "range_add": "Add Range", @@ -223,6 +221,11 @@ "range_error_min_max": "Min value must be less than max value.", "range_error_fee": "Fee must be 0 or greater.", "range_error_min_one": "At least one range is required.", + "range_error_middle_max": "Every range except the last one must have an end value.", + "range_error_fee_required": "Please enter the shipping fee for this range.", + "range_error_integer": "Quantity range start and end values must be integers.", + "range_error_unit_value_required": "A per-unit charge policy requires a unit value.", + "range_error_free_threshold_required": "A conditional free shipping policy requires a free shipping threshold.", "per_unit_value": "Unit Value", "per_unit_hint": "The shipping fee above will be charged per unit", "api_endpoint": "API Endpoint", @@ -252,8 +255,8 @@ "api_test_response": "Response (status :status):", "api_test_endpoint_required": "Enter the API endpoint first to run a test.", "extra_fee_enabled": "Enable Remote Area Fee", - "extra_fee_multiply": "Multiply Per Unit", - "extra_fee_multiply_hint": "Apply remote area fee per unit when using per-unit shipping", + "extra_fee_multiply": "Multiply Per Shipping Unit", + "extra_fee_multiply_hint": "Charge the remote area fee by the same number of shipping units used for the base fee", "extra_fee_zipcode": "Zipcode", "extra_fee_zipcode_placeholder": "63000 or 63000-63644", "extra_fee_amount": "Extra Fee", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/shipping_policy.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/shipping_policy.json index 5c340003..f0958b71 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/shipping_policy.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/shipping_policy.json @@ -121,12 +121,6 @@ "free": "무료배송", "fixed": "배송비: {{fee}}", "conditional": "{{threshold}} 미만 {{fee}} / {{threshold}} 이상 무료", - "range_prefix": { - "amount": "", - "quantity": "개", - "weight": "kg", - "volume": "cm³" - }, "api": "외부 API 연동 (실시간 계산)", "per_quantity": "{{unit}}개당 {{fee}}", "per_weight": "{{unit}}kg당 {{fee}}", @@ -213,7 +207,11 @@ "free_threshold_hint": "이 금액 이상 구매 시 배송비 무료", "range_tiers": "구간별 배송비", "range_min": "시작값", + "range_min_auto": "자동", "range_max": "종료값", + "unit_quantity": "개", + "unit_weight": "kg", + "unit_volume": "L", "range_fee": "배송비", "range_unlimited": "무제한", "range_add": "구간 추가", @@ -223,6 +221,11 @@ "range_error_min_max": "시작값은 종료값보다 작아야 합니다.", "range_error_fee": "배송비는 0 이상이어야 합니다.", "range_error_min_one": "최소 1개 구간이 필요합니다.", + "range_error_middle_max": "마지막 구간을 제외한 구간에는 종료값을 입력해야 합니다.", + "range_error_fee_required": "구간 배송비를 입력해주세요.", + "range_error_integer": "수량 구간의 시작값과 종료값은 정수여야 합니다.", + "range_error_unit_value_required": "단위당 배송비 정책은 단위값을 입력해야 합니다.", + "range_error_free_threshold_required": "조건부 무료배송 정책은 무료배송 기준금액을 입력해야 합니다.", "per_unit_value": "단위 기준값", "per_unit_hint": "설정한 단위마다 위 배송비가 부과됩니다", "api_endpoint": "API 엔드포인트", @@ -252,8 +255,8 @@ "api_test_response": "응답 (상태 :status):", "api_test_endpoint_required": "테스트하려면 먼저 API 엔드포인트를 입력하세요.", "extra_fee_enabled": "도서산간 추가배송비 사용", - "extra_fee_multiply": "건당 중복 부과", - "extra_fee_multiply_hint": "단위당 배송비 적용 시 도서산간 추가배송비를 건당 중복 부과합니다", + "extra_fee_multiply": "배송 단위당 중복 부과", + "extra_fee_multiply_hint": "단위당 배송비 적용 시 도서산간 추가배송비를 기본 배송비와 같은 배송 단위 수만큼 중복 부과합니다", "extra_fee_zipcode": "우편번호", "extra_fee_zipcode_placeholder": "63000 또는 63000-63644", "extra_fee_amount": "추가배송비", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_shipping_policy_form.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_shipping_policy_form.json index 9a30286f..5869804d 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_shipping_policy_form.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_shipping_policy_form.json @@ -22,6 +22,8 @@ "apiAuthTypeOptions": "{{ecommerce_settings?.data?.shipping?.api_auth_types ?? []}}", "apiResponseTypeOptions": "{{ecommerce_settings?.data?.shipping?.api_response_types ?? []}}", "activeCountrySetting": "{{(_local.form?.country_settings ?? [])[_local.activeCountryTab ?? 0]}}", + "rangeUnitLabel": "{{(() => { const p = (_local.form?.country_settings ?? [])[_local.activeCountryTab ?? 0]?.charge_policy; if (p === 'range_quantity') return $t('sirsoft-ecommerce.admin.shipping_policy.form.unit_quantity'); if (p === 'range_weight' || p === 'range_volume_weight') return $t('sirsoft-ecommerce.admin.shipping_policy.form.unit_weight'); if (p === 'range_volume') return $t('sirsoft-ecommerce.admin.shipping_policy.form.unit_volume'); if (p === 'range_amount') return ecommerce_settings?.data?.language_currency?.default_currency ?? ''; return ''; })()}}", + "perUnitLabel": "{{(() => { const p = (_local.form?.country_settings ?? [])[_local.activeCountryTab ?? 0]?.charge_policy; if (p === 'per_quantity') return $t('sirsoft-ecommerce.admin.shipping_policy.form.unit_quantity'); if (p === 'per_weight' || p === 'per_volume_weight') return $t('sirsoft-ecommerce.admin.shipping_policy.form.unit_weight'); if (p === 'per_volume') return $t('sirsoft-ecommerce.admin.shipping_policy.form.unit_volume'); if (p === 'per_amount') return ecommerce_settings?.data?.language_currency?.default_currency ?? ''; return ''; })()}}", "activeCountryCode": "{{(_local.form?.country_settings ?? [])[_local.activeCountryTab ?? 0]?.country_code ?? ''}}", "isReadOnly": "{{!!route.id && policy?.data?.abilities?.can_update !== true}}" }, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_partial_charge_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_partial_charge_settings.json index 19355439..1686ed7b 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_partial_charge_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_form/_partial_charge_settings.json @@ -259,26 +259,53 @@ }, { "type": "basic", - "name": "Input", + "name": "Div", "props": { - "type": "number", - "className": "input", - "value": "{{_computed.activeCountrySetting?.ranges?.unit_value ?? 1}}", - "min": 0.01, - "step": 0.01, - "disabled": "{{_computed.isReadOnly}}" + "className": "flex items-center gap-2" }, - "actions": [ + "children": [ { - "type": "change", - "handler": "sirsoft-ecommerce.updateUnitValue", - "debounce": 300, - "params": { - "value": "{{$event.target.value}}" - } + "type": "basic", + "name": "Input", + "props": { + "type": "number", + "className": "{{_local.errors?.['country_settings.' + (_local.activeCountryTab ?? 0) + '.ranges.unit_value'] ? 'input input-error' : 'input'}}", + "value": "{{_computed.activeCountrySetting?.ranges?.unit_value ?? ''}}", + "min": 0.01, + "step": 0.01, + "disabled": "{{_computed.isReadOnly}}" + }, + "actions": [ + { + "type": "change", + "handler": "sirsoft-ecommerce.updateUnitValue", + "debounce": 300, + "params": { + "value": "{{$event.target.value}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "if": "{{!!_computed.perUnitLabel}}", + "props": { + "className": "text-tertiary whitespace-nowrap" + }, + "text": "{{_computed.perUnitLabel ?? ''}}" } ] }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.errors?.['country_settings.' + (_local.activeCountryTab ?? 0) + '.ranges.unit_value']}}", + "props": { + "className": "form-error-xs" + }, + "text": "{{(_local.errors?.['country_settings.' + (_local.activeCountryTab ?? 0) + '.ranges.unit_value'] ?? [])[0] ?? ''}}" + }, { "type": "basic", "name": "Span", @@ -374,12 +401,12 @@ { "type": "basic", "name": "Th", - "text": "$t:sirsoft-ecommerce.admin.shipping_policy.form.range_min" + "text": "{{$t('sirsoft-ecommerce.admin.shipping_policy.form.range_min') + (_computed.rangeUnitLabel ? ' (' + _computed.rangeUnitLabel + ')' : '')}}" }, { "type": "basic", "name": "Th", - "text": "$t:sirsoft-ecommerce.admin.shipping_policy.form.range_max" + "text": "{{$t('sirsoft-ecommerce.admin.shipping_policy.form.range_max') + (_computed.rangeUnitLabel ? ' (' + _computed.rangeUnitLabel + ')' : '')}}" }, { "type": "basic", @@ -420,25 +447,13 @@ "name": "Input", "props": { "type": "number", - "className": "{{_local.rangeErrors?.[_computed.activeCountryCode]?.[tierIdx]?.min ? 'input input-error' : 'input'}}", - "value": "{{tier.min ?? 0}}", - "min": 0, - "placeholder": "0", + "className": "{{_local.rangeErrors?.[_computed.activeCountryCode]?.[tierIdx]?.min ? 'input input-error bg-gray-100 dark:bg-gray-700' : 'input bg-gray-100 dark:bg-gray-700'}}", + "value": "{{tier.min ?? ''}}", + "readOnly": true, + "tabIndex": -1, + "placeholder": "$t:sirsoft-ecommerce.admin.shipping_policy.form.range_min_auto", "disabled": "{{_computed.isReadOnly}}" - }, - "actions": [ - { - "type": "change", - "handler": "sirsoft-ecommerce.updateRangeTierField", - "debounce": 300, - "params": { - "countryIndex": "{{_local.activeCountryTab ?? 0}}", - "tierIndex": "{{tierIdx}}", - "field": "min", - "value": "{{parseFloat($event.target.value) || 0}}" - } - } - ] + } }, { "type": "basic", @@ -475,7 +490,7 @@ "countryIndex": "{{_local.activeCountryTab ?? 0}}", "tierIndex": "{{tierIdx}}", "field": "max", - "value": "{{$event.target.value ? parseFloat($event.target.value) : null}}" + "value": "{{$event.target.value}}" } } ] @@ -501,7 +516,7 @@ "props": { "type": "number", "className": "{{_local.rangeErrors?.[_computed.activeCountryCode]?.[tierIdx]?.fee ? 'input input-error' : 'input'}}", - "value": "{{tier.fee ?? 0}}", + "value": "{{tier.fee ?? ''}}", "min": 0, "step": 100, "disabled": "{{_computed.isReadOnly}}" @@ -515,7 +530,7 @@ "countryIndex": "{{_local.activeCountryTab ?? 0}}", "tierIndex": "{{tierIdx}}", "field": "fee", - "value": "{{parseFloat($event.target.value) || 0}}" + "value": "{{$event.target.value}}" } } ] @@ -586,6 +601,15 @@ "type": "basic", "name": "Span", "text": "$t:sirsoft-ecommerce.admin.shipping_policy.form.no_ranges" + }, + { + "type": "basic", + "name": "Span", + "if": "{{!!_local.rangeErrors?.[_computed.activeCountryCode]?.[0]?.max}}", + "props": { + "className": "form-error-xs block" + }, + "text": "{{_local.rangeErrors?.[_computed.activeCountryCode]?.[0]?.max ?? ''}}" } ] } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Enums/ChargePolicyEnum.php b/modules/_bundled/sirsoft-ecommerce/src/Enums/ChargePolicyEnum.php index bd5359d0..eae84538 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Enums/ChargePolicyEnum.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Enums/ChargePolicyEnum.php @@ -75,6 +75,9 @@ enum ChargePolicyEnum: string return in_array($this, [ self::FIXED, self::CONDITIONAL_FREE, + // API 정책의 base_fee 는 외부 API 장애 시의 폴백 배송비다. + // 0 으로 강제되면 장애가 곧 무음 무료배송이 된다. + self::API, self::PER_QUANTITY, self::PER_WEIGHT, self::PER_VOLUME, @@ -83,6 +86,19 @@ enum ChargePolicyEnum: string ]); } + /** + * 구간 경계값이 이산형(정수)인 정책인지 확인합니다. + * + * 수량은 정수 단위라 "5개 다음은 6개"(max + 1)로 이어지고, 금액·무게·부피는 + * 연속값이라 "5kg 다음은 5kg 초과"(다음 min = max)로 이어집니다. + * + * @return bool 이산형 여부 + */ + public function hasDiscreteRangeValues(): bool + { + return $this === self::RANGE_QUANTITY; + } + /** * 무료 기준금액 필드가 필요한 정책인지 확인합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreShippingPolicyRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreShippingPolicyRequest.php index 2d95e452..e059bd1e 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreShippingPolicyRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreShippingPolicyRequest.php @@ -13,12 +13,11 @@ use Modules\Sirsoft\Ecommerce\Enums\ShippingApiAuthType; use Modules\Sirsoft\Ecommerce\Enums\ShippingApiHttpMethod; use Modules\Sirsoft\Ecommerce\Enums\ShippingApiRequestField; use Modules\Sirsoft\Ecommerce\Enums\ShippingApiResponseType; -use Modules\Sirsoft\Ecommerce\Models\ShippingType; +use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingTypeRepositoryInterface; /** * 배송정책 생성 요청 */ -// audit:allow api-doc-coverage reason: 이번 변경은 messages() 의 다국어 키 경로 정정뿐이다. 요청 파라미터·응답 구조·검증 규칙이 그대로라 docs/api/shipping-policies.md 에 갱신할 내용이 없다 (422 에러표는 개별 메시지 문구를 기재하지 않음). class StoreShippingPolicyRequest extends FormRequest { /** @@ -48,7 +47,11 @@ class StoreShippingPolicyRequest extends FormRequest // 국가별 설정 (최소 1개 필수) 'country_settings' => ['required', 'array', 'min:1'], 'country_settings.*.country_code' => ['required', 'string', 'max:10', 'distinct'], - 'country_settings.*.shipping_method' => ['required', 'string', Rule::in(ShippingType::pluck('code')->toArray())], + 'country_settings.*.shipping_method' => [ + 'required', + 'string', + Rule::in(app(ShippingTypeRepositoryInterface::class)->getAll()->pluck('code')->toArray()), + ], 'country_settings.*.custom_shipping_name' => ['nullable', 'array'], 'country_settings.*.custom_shipping_name.*' => ['nullable', 'string', 'max:100'], // 통화는 서버가 상점 기본 통화로 강제하므로 클라이언트 입력에 의존하지 않는다 (읽기전용 표시). @@ -141,6 +144,9 @@ class StoreShippingPolicyRequest extends FormRequest 'country_settings.*.ranges.tiers.*.min.min' => __('sirsoft-ecommerce::validation.shipping_policy.ranges.tier_min_non_negative'), 'country_settings.*.ranges.tiers.*.max.min' => __('sirsoft-ecommerce::validation.shipping_policy.ranges.tier_max_non_negative'), 'country_settings.*.ranges.unit_value.min' => __('sirsoft-ecommerce::validation.shipping_policy.ranges.unit_value_min'), + 'country_settings.*.ranges.tiers.min' => __('sirsoft-ecommerce::validation.shipping_policy.ranges.tiers_required'), + 'country_settings.*.ranges.tiers.array' => __('sirsoft-ecommerce::validation.shipping_policy.ranges.tiers_required'), + 'country_settings.*.extra_fee_settings.*.zipcode.required' => __('sirsoft-ecommerce::validation.shipping_policy.extra_fee.zipcode_format'), 'country_settings.*.extra_fee_settings.*.fee.min' => __('sirsoft-ecommerce::validation.extra_fee_template.fee_min'), 'is_active.required' => __('sirsoft-ecommerce::validation.shipping_policy.is_active_required'), ]; @@ -158,14 +164,125 @@ class StoreShippingPolicyRequest extends FormRequest public function withValidator(Validator $validator): void { $validator->after(function (Validator $validator) { + $this->validatePolicyRequiredSettings($validator); $this->validateRangeTiersContinuity($validator); $this->validateNonFreeBaseFee($validator); $this->validateCustomShippingName($validator); $this->validateApiEndpointRequired($validator); $this->validateApiConfig($validator); + $this->validateExtraFeeZipcodeFormat($validator); }); } + /** + * 부과정책이 요구하는 설정값의 존재를 검증합니다 (cross-field). + * + * 이 값들이 비어 있으면 계산 결과가 조용히 0원(무료배송)이 되므로 저장 시점에 차단합니다. + * + * - 구간별 정책: ranges.tiers 최소 1개 + * - 단위당 정책: ranges.unit_value + * - 조건부 무료: free_threshold + * + * @param Validator $validator Laravel validator 인스턴스 + */ + private function validatePolicyRequiredSettings(Validator $validator): void + { + $countrySettings = $this->input('country_settings', []); + + if (! is_array($countrySettings)) { + return; + } + + foreach ($countrySettings as $i => $cs) { + $policy = ChargePolicyEnum::tryFrom($cs['charge_policy'] ?? ''); + + if (! $policy) { + continue; + } + + if ($policy->requiresRanges()) { + $tiers = $cs['ranges']['tiers'] ?? null; + + if (! is_array($tiers) || count($tiers) === 0) { + $validator->errors()->add( + "country_settings.{$i}.ranges.tiers", + __('sirsoft-ecommerce::validation.shipping_policy.ranges.tiers_required') + ); + } + } + + if ($policy->requiresUnitValue()) { + $unitValue = $cs['ranges']['unit_value'] ?? null; + + if ($unitValue === null || $unitValue === '' || ! is_numeric($unitValue)) { + $validator->errors()->add( + "country_settings.{$i}.ranges.unit_value", + __('sirsoft-ecommerce::validation.shipping_policy.ranges.unit_value_required') + ); + } + } + + if ($policy->requiresFreeThreshold()) { + $freeThreshold = $cs['free_threshold'] ?? null; + + if ($freeThreshold === null || $freeThreshold === '' || ! is_numeric($freeThreshold)) { + $validator->errors()->add( + "country_settings.{$i}.free_threshold", + __('sirsoft-ecommerce::validation.shipping_policy.free_threshold_required') + ); + } + } + } + } + + /** + * 도서산간 우편번호 패턴의 형식을 검증합니다. + * + * 지원 형식은 정확일치("63000"), 범위("63000-63999"), 접두 와일드카드("63*") 3종입니다. + * 그 외 형식은 어떤 우편번호에도 매칭되지 않아 추가배송비가 조용히 누락됩니다. + * + * @param Validator $validator Laravel validator 인스턴스 + */ + private function validateExtraFeeZipcodeFormat(Validator $validator): void + { + $countrySettings = $this->input('country_settings', []); + + if (! is_array($countrySettings)) { + return; + } + + foreach ($countrySettings as $i => $cs) { + if (empty($cs['extra_fee_enabled'])) { + continue; + } + + $settings = $cs['extra_fee_settings'] ?? []; + + if (! is_array($settings)) { + continue; + } + + foreach ($settings as $j => $setting) { + $zipcode = trim((string) ($setting['zipcode'] ?? '')); + + if ($zipcode === '') { + continue; + } + + $isValid = preg_match('/^\d+-\d+$/', $zipcode) + || preg_match('/^\d+\*$/', $zipcode) + || preg_match('/^\d+$/', $zipcode); + + if (! $isValid) { + $validator->errors()->add( + "country_settings.{$i}.extra_fee_settings.{$j}.zipcode", + __('sirsoft-ecommerce::validation.shipping_policy.extra_fee.zipcode_format') + ); + } + } + } + } + /** * 계산 API 정책(api) 선택 시 api_endpoint 필수 검증 (cross-field). * @@ -234,8 +351,11 @@ class StoreShippingPolicyRequest extends FormRequest * * - 첫 구간의 시작값은 0이어야 합니다. * - 마지막 구간의 종료값은 null(무제한)이어야 합니다. + * - 마지막을 제외한 구간에는 종료값이 있어야 합니다 (중간 무제한 구간 금지). * - 시작값이 종료값보다 작아야 합니다. - * - 구간이 연속적이어야 합니다 (현재 max + 1 === 다음 min, 포함 범위 기준). + * - 구간이 연속적이어야 합니다. 연결 규칙은 값의 성질에 따라 다릅니다. + * - 이산형(수량): 다음 min === 현재 max + 1 + * - 연속형(금액/무게/부피): 다음 min === 현재 max (부동소수 오차 허용) * - 배송비는 0 이상이어야 합니다. */ private function validateRangeTiersContinuity(Validator $validator): void @@ -246,6 +366,9 @@ class StoreShippingPolicyRequest extends FormRequest return; } + // 연속형 구간의 소수 입력(2.5kg)에서 부동소수 오차로 정상 설정이 거부되지 않도록 허용 오차를 둔다. + $epsilon = 0.001; + foreach ($countrySettings as $i => $cs) { $tiers = $cs['ranges']['tiers'] ?? null; @@ -253,8 +376,11 @@ class StoreShippingPolicyRequest extends FormRequest continue; } - // 첫 구간 min은 0이어야 함 - if (($tiers[0]['min'] ?? null) != 0) { + $policy = ChargePolicyEnum::tryFrom($cs['charge_policy'] ?? ''); + $isDiscrete = $policy?->hasDiscreteRangeValues() ?? false; + + // 첫 구간 min은 0이어야 함 (문자열 "0" 도 허용) + if ((float) ($tiers[0]['min'] ?? null) != 0.0) { $validator->errors()->add( "country_settings.{$i}.ranges.tiers.0.min", __('sirsoft-ecommerce::validation.shipping_policy.ranges.first_min_zero') @@ -263,7 +389,7 @@ class StoreShippingPolicyRequest extends FormRequest // 마지막 구간 max는 null이어야 함 $lastIdx = count($tiers) - 1; - if (isset($tiers[$lastIdx]['max']) && $tiers[$lastIdx]['max'] !== null) { + if (! $this->isUnlimitedMax($tiers[$lastIdx]['max'] ?? null)) { $validator->errors()->add( "country_settings.{$i}.ranges.tiers.{$lastIdx}.max", __('sirsoft-ecommerce::validation.shipping_policy.ranges.last_max_unlimited') @@ -272,9 +398,35 @@ class StoreShippingPolicyRequest extends FormRequest for ($j = 0; $j < count($tiers); $j++) { $tier = $tiers[$j]; + $hasMax = ! $this->isUnlimitedMax($tier['max'] ?? null); + + // 중간 구간의 종료값 누락 금지 (뒤 구간이 영구히 도달 불가해진다) + if ($j < $lastIdx && ! $hasMax) { + $validator->errors()->add( + "country_settings.{$i}.ranges.tiers.{$j}.max", + __('sirsoft-ecommerce::validation.shipping_policy.ranges.middle_max_required') + ); + + continue; + } + + // 수량 구간의 경계값은 정수 + if ($isDiscrete) { + $minValue = $tier['min'] ?? 0; + $maxValue = $tier['max'] ?? null; + $hasFractionalBoundary = (is_numeric($minValue) && floor((float) $minValue) != (float) $minValue) + || ($hasMax && is_numeric($maxValue) && floor((float) $maxValue) != (float) $maxValue); + + if ($hasFractionalBoundary) { + $validator->errors()->add( + "country_settings.{$i}.ranges.tiers.{$j}.max", + __('sirsoft-ecommerce::validation.shipping_policy.ranges.tier_value_integer') + ); + } + } // min < max (마지막 구간 제외) - if ($j < $lastIdx && isset($tier['max']) && $tier['max'] !== null) { + if ($j < $lastIdx && $hasMax) { if ((float) ($tier['min'] ?? 0) >= (float) $tier['max']) { $validator->errors()->add( "country_settings.{$i}.ranges.tiers.{$j}.min", @@ -283,11 +435,12 @@ class StoreShippingPolicyRequest extends FormRequest } } - // 구간 연속성: 현재 max + 1 === 다음 min (포함 범위 기준) - if ($j < $lastIdx) { - $nextTier = $tiers[$j + 1]; - if (isset($tier['max']) && $tier['max'] !== null - && (float) $tier['max'] + 1 !== (float) ($nextTier['min'] ?? 0)) { + // 구간 연속성 (이산형은 max + 1, 연속형은 max 가 다음 min) + if ($j < $lastIdx && $hasMax) { + $expectedNextMin = (float) $tier['max'] + ($isDiscrete ? 1 : 0); + $actualNextMin = (float) ($tiers[$j + 1]['min'] ?? 0); + + if (abs($expectedNextMin - $actualNextMin) >= $epsilon) { $validator->errors()->add( "country_settings.{$i}.ranges.tiers.{$j}.max", __('sirsoft-ecommerce::validation.shipping_policy.ranges.continuity') @@ -306,6 +459,17 @@ class StoreShippingPolicyRequest extends FormRequest } } + /** + * 구간의 종료값이 무제한(비어 있음)인지 판정합니다. + * + * @param mixed $max 종료값 + * @return bool 무제한이면 true + */ + private function isUnlimitedMax(mixed $max): bool + { + return $max === null || $max === ''; + } + /** * 무료배송이 아닌 정책에서 배송비 0원을 금지합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductOptionResource.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductOptionResource.php index 24fb8ac3..cef38f86 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductOptionResource.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductOptionResource.php @@ -61,6 +61,11 @@ class ProductOptionResource extends BaseApiResource // 품절 여부 — 재고 0 이하이거나 비활성 옵션 (프론트 드롭다운 비활성/라벨용, MP07 §2-b) 'is_sold_out' => ($this->stock_quantity ?? 0) <= 0 || ! $this->is_active, + // 배송 계산용 물성 (g / cm³ — 배송정책의 kg/L 환산은 배송비 계산 시점에 수행) + // 직렬화하지 않으면 관리자 수정 화면이 0 으로 표시하고, 그대로 저장하면 실측값이 덮어써진다. + 'weight' => $this->weight !== null ? (float) $this->weight : null, + 'volume' => $this->volume !== null ? (float) $this->volume : null, + // 상태 'is_default' => $this->is_default, 'is_active' => $this->is_active, diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/ShippingPolicyCountrySetting.php b/modules/_bundled/sirsoft-ecommerce/src/Models/ShippingPolicyCountrySetting.php index 9688539b..95470a7c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/ShippingPolicyCountrySetting.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/ShippingPolicyCountrySetting.php @@ -124,29 +124,88 @@ class ShippingPolicyCountrySetting extends Model return ''; } - $tiers = $this->ranges['tiers']; - $unit = $tiers[0]['unit'] ?? ''; $parts = []; - foreach ($tiers as $tier) { - $min = $tier['min'] ?? 0; - $max = $tier['max'] ?? null; + foreach ($this->ranges['tiers'] as $tier) { $fee = ecommerce_format_price($tier['fee'] ?? 0); - - if ($min === 0) { - $range = "~{$max}{$unit}"; - } elseif ($max === null) { - $range = "{$min}{$unit}~"; - } else { - $range = "{$min}~{$max}{$unit}"; - } - - $parts[] = "{$range}: {$fee}"; + $parts[] = $this->formatTierRangeLabel($tier).": {$fee}"; } return implode(' / ', $parts); } + /** + * 구간의 범위 라벨을 포맷합니다 (예: "~5개", "6개~", "2~5kg"). + * + * 단위는 부과정책에서 파생합니다. tier 의 `unit` 키는 시더만 채우던 값이라 + * 관리자 화면에서 저장한 정책에는 존재하지 않아 단위가 사라졌습니다. + * + * @param array $tier 구간 정의 + * @return string 범위 라벨 + */ + protected function formatTierRangeLabel(array $tier): string + { + $min = $tier['min'] ?? 0; + $max = $tier['max'] ?? null; + $isAmount = $this->charge_policy === ChargePolicyEnum::RANGE_AMOUNT; + + $format = fn ($value) => $isAmount + ? ecommerce_format_price($value ?? 0) + : $this->formatRangeNumber($value).$this->resolveRangeUnitLabel(); + + // 첫 구간(시작 0)은 상한만, 마지막 구간(상한 없음)은 시작값만 표기 + if ((float) $min == 0.0) { + return $max === null || $max === '' ? '~' : '~'.$format($max); + } + + if ($max === null || $max === '') { + return $format($min).'~'; + } + + return $isAmount + ? $format($min).'~'.$format($max) + : $this->formatRangeNumber($min).'~'.$format($max); + } + + /** + * 부과정책에서 구간 단위 라벨을 파생합니다. + * + * @return string 단위 라벨 (해당 없으면 빈 문자열) + */ + protected function resolveRangeUnitLabel(): string + { + $key = match ($this->charge_policy) { + ChargePolicyEnum::RANGE_QUANTITY => 'quantity', + ChargePolicyEnum::RANGE_WEIGHT, ChargePolicyEnum::RANGE_VOLUME_WEIGHT => 'weight', + ChargePolicyEnum::RANGE_VOLUME => 'volume', + default => null, + }; + + return $key === null + ? '' + : __('sirsoft-ecommerce::messages.shipping_policy.fee_summary.range_unit.'.$key); + } + + /** + * 구간 경계값을 소수 손실 없이 포맷합니다. + * + * number_format 은 기본 소수 0자리라 0.5kg 이 "1" 로 표시됩니다. + * + * @param int|float|string|null $value 경계값 + * @return string 포맷된 숫자 + */ + protected function formatRangeNumber(int|float|string|null $value): string + { + $number = (float) ($value ?? 0); + + if (floor($number) == $number) { + return number_format($number); + } + + // 불필요한 뒤쪽 0 제거 (2.50 → 2.5) + return rtrim(rtrim(number_format($number, 3), '0'), '.'); + } + /** * 단위당 배송비 요약 포맷 * @@ -156,8 +215,13 @@ class ShippingPolicyCountrySetting extends Model { $unitValue = $this->ranges['unit_value'] ?? 1; + // 금액당 정책의 단위값은 금액이므로 통화 표기를 붙인다 (그 외는 개/kg/L 로 문구가 단위를 갖는다) + $unit = $this->charge_policy === ChargePolicyEnum::PER_AMOUNT + ? ecommerce_format_price($unitValue) + : $this->formatRangeNumber($unitValue); + return __('sirsoft-ecommerce::messages.shipping_policy.fee_summary.'.$this->charge_policy->value, [ - 'unit' => number_format($unitValue), + 'unit' => $unit, 'fee' => ecommerce_format_price($this->base_fee ?? 0), ]); } @@ -190,26 +254,12 @@ class ShippingPolicyCountrySetting extends Model return null; } - $tiers = $this->ranges['tiers']; - $unit = $tiers[0]['unit'] ?? ''; $result = []; - foreach ($tiers as $tier) { - $min = $tier['min'] ?? 0; - $max = $tier['max'] ?? null; - $fee = $tier['fee'] ?? 0; - - if ($min === 0) { - $range = '~'.number_format($max).$unit; - } elseif ($max === null) { - $range = number_format($min).$unit.'~'; - } else { - $range = number_format($min).'~'.number_format($max).$unit; - } - + foreach ($this->ranges['tiers'] as $tier) { $result[] = [ - 'range' => $range, - 'fee' => ecommerce_format_price($fee), + 'range' => $this->formatTierRangeLabel($tier), + 'fee' => ecommerce_format_price($tier['fee'] ?? 0), ]; } @@ -219,7 +269,7 @@ class ShippingPolicyCountrySetting extends Model /** * 우편번호가 도서산간 지역인지 확인하고 추가배송비를 반환합니다. * - * @param string|null $zipcode 우편번호 + * @param string|null $zipcode 우편번호 * @return int 추가배송비 (도서산간 아닌 경우 0) */ public function getExtraFeeForZipcode(?string $zipcode): int @@ -245,10 +295,12 @@ class ShippingPolicyCountrySetting extends Model } // 범위 지원: "63000-63999" + // 자릿수가 다른 우편번호에서 문자열 비교가 오판정하므로 숫자로 비교한다. if (preg_match('/^(\d+)-(\d+)$/', $pattern, $matches)) { - $start = $matches[1]; - $end = $matches[2]; - if ($normalizedZipcode >= $start && $normalizedZipcode <= $end) { + $start = (int) $matches[1]; + $end = (int) $matches[2]; + $numericZipcode = (int) $normalizedZipcode; + if ($numericZipcode >= $start && $numericZipcode <= $end) { return $fee; } @@ -277,7 +329,7 @@ class ShippingPolicyCountrySetting extends Model /** * 우편번호가 도서산간 지역인지 확인합니다. * - * @param string|null $zipcode 우편번호 + * @param string|null $zipcode 우편번호 * @return bool 도서산간 지역 여부 */ public function isRemoteArea(?string $zipcode): bool diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php index 6bfa65b2..7f6a6385 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php @@ -1105,6 +1105,11 @@ class OrderCalculationService // 배송정책이 삭제되었거나 조회 불가한 경우 배송비 0으로 처리 if (! $policy) { + Log::warning('배송정책을 조회할 수 없어 배송비를 0원으로 처리했습니다', [ + 'policy_id' => $policyId, + 'country_code' => $countryCode, + ]); + foreach ($groupItems as $item) { $shippingResults[$item['product_option_id']] = new AppliedShippingPolicy( policyId: $policyId, @@ -1124,6 +1129,11 @@ class OrderCalculationService if (! $countrySetting) { // 해당 국가 설정이 없으면 배송비 0으로 처리 + Log::warning('국가별 배송 설정이 없어 배송비를 0원으로 처리했습니다', [ + 'policy_id' => $policyId, + 'country_code' => $countryCode, + ]); + foreach ($groupItems as $item) { $shippingResults[$item['product_option_id']] = new AppliedShippingPolicy( policyId: $policyId, @@ -1142,7 +1152,7 @@ class OrderCalculationService $baseShippingTotal += $shippingFee; // 추가 배송비(도서산간) 계산 - KR 전용 - $extraShippingFee = $this->calculateExtraShippingFee($countrySetting, $countryCode, $zipcode, $group['total_quantity']); + $extraShippingFee = $this->calculateExtraShippingFee($countrySetting, $countryCode, $zipcode, $group); $extraShippingTotal += $extraShippingFee; // 배송비를 그룹 내 아이템들에게 안분 @@ -1170,7 +1180,7 @@ class OrderCalculationService 'custom_shipping_name' => $countrySetting->custom_shipping_name, 'currency_code' => $countrySetting->currency_code, 'charge_policy' => $countrySetting->charge_policy->value, - 'base_fee' => (int) $countrySetting->base_fee, + 'base_fee' => (int) round((float) $countrySetting->base_fee), 'free_threshold' => $countrySetting->free_threshold ? (int) $countrySetting->free_threshold : null, 'ranges' => $countrySetting->ranges, 'extra_fee_enabled' => $countrySetting->extra_fee_enabled, @@ -1198,10 +1208,10 @@ class OrderCalculationService * @param ShippingPolicyCountrySetting $countrySetting 국가별 설정 * @param string $countryCode 수신자 국가코드 * @param string|null $zipcode 우편번호 - * @param int $quantity 그룹 합계 수량 + * @param array $group 그룹 정보 (total_amount, total_quantity, total_weight, total_volume) * @return int 추가 배송비 */ - protected function calculateExtraShippingFee(ShippingPolicyCountrySetting $countrySetting, string $countryCode, ?string $zipcode, int $quantity): int + protected function calculateExtraShippingFee(ShippingPolicyCountrySetting $countrySetting, string $countryCode, ?string $zipcode, array $group): int { // 도서산간 추가배송비는 KR 전용 if ($countryCode !== 'KR') { @@ -1219,15 +1229,10 @@ class OrderCalculationService return 0; } - // per_* 정책에서 extra_fee_multiply가 true면 건수만큼 중복 부과 + // per_* 정책에서 extra_fee_multiply가 true면 배송 단위 수만큼 중복 부과 + // (기본 배송비와 동일한 단위 수를 사용해야 "단위당" 의미가 어긋나지 않는다) if ($countrySetting->extra_fee_multiply && $this->isPerUnitPolicy($countrySetting)) { - $unitValue = $countrySetting->ranges['unit_value'] ?? 1; - if ($unitValue <= 0) { - $unitValue = 1; - } - $units = (int) ceil($quantity / $unitValue); - - return $extraFee * $units; + return $extraFee * $this->resolveChargeUnits($countrySetting, $group); } // 기본: 1회만 부과 @@ -1242,13 +1247,7 @@ class OrderCalculationService */ protected function isPerUnitPolicy(ShippingPolicyCountrySetting $countrySetting): bool { - return in_array($countrySetting->charge_policy, [ - ChargePolicyEnum::PER_QUANTITY, - ChargePolicyEnum::PER_WEIGHT, - ChargePolicyEnum::PER_VOLUME, - ChargePolicyEnum::PER_VOLUME_WEIGHT, - ChargePolicyEnum::PER_AMOUNT, - ]); + return in_array($countrySetting->charge_policy, ChargePolicyEnum::perUnitPolicies(), true); } /** @@ -1291,7 +1290,7 @@ class OrderCalculationService $baseShippingFee = $this->calculateCountryShippingFee($countrySetting, $standaloneGroup); // 추가 배송비 계산 (도서산간 - KR 전용) - $extraShippingFee = $this->calculateExtraShippingFee($countrySetting, $countryCode, $zipcode, $targetItem['quantity']); + $extraShippingFee = $this->calculateExtraShippingFee($countrySetting, $countryCode, $zipcode, $standaloneGroup); return $baseShippingFee + $extraShippingFee; } @@ -2143,70 +2142,182 @@ class OrderCalculationService $groupWeight = $group['total_weight'] ?? 0.0; $groupVolume = $group['total_volume'] ?? 0.0; - // 부피무게 계산 (부피 / 부피무게 계수, 기본값 6000) + // 단위 환산은 이 지점 한 곳에서만 수행한다. + // 상품 옵션은 g / cm³ 로 저장되고, 배송정책 구간·단위값은 kg / L 로 입력받는다. $volumeWeightDivisor = $countrySetting->ranges['volume_weight_divisor'] ?? 6000; - $volumeWeight = $volumeWeightDivisor > 0 ? $groupVolume / $volumeWeightDivisor : 0.0; - $chargeableWeight = max($groupWeight, $volumeWeight); + $weightKg = $groupWeight / 1000; + $volumeLiter = $groupVolume / 1000; + // 부피무게(kg) = 부피(cm³) / 부피무게 계수 (기본 6000) + $volumetricWeightKg = $volumeWeightDivisor > 0 ? $groupVolume / $volumeWeightDivisor : 0.0; + $chargeableWeightKg = max($weightKg, $volumetricWeightKg); return match ($countrySetting->charge_policy) { ChargePolicyEnum::FREE => 0, - ChargePolicyEnum::FIXED => (int) $countrySetting->base_fee, - ChargePolicyEnum::CONDITIONAL_FREE => $groupTotal >= $countrySetting->free_threshold ? 0 : (int) $countrySetting->base_fee, - ChargePolicyEnum::RANGE_AMOUNT => $this->calculateRangeFee($countrySetting->ranges, $groupTotal), - ChargePolicyEnum::RANGE_QUANTITY => $this->calculateRangeFee($countrySetting->ranges, $groupQuantity), - ChargePolicyEnum::RANGE_WEIGHT => $this->calculateRangeFee($countrySetting->ranges, (int) ($groupWeight * 1000)), // kg → g 변환 - ChargePolicyEnum::RANGE_VOLUME => $this->calculateRangeFee($countrySetting->ranges, (int) $groupVolume), - ChargePolicyEnum::RANGE_VOLUME_WEIGHT => $this->calculateRangeFee($countrySetting->ranges, (int) ($chargeableWeight * 1000)), + ChargePolicyEnum::FIXED => (int) round((float) $countrySetting->base_fee), + ChargePolicyEnum::CONDITIONAL_FREE => $groupTotal >= $countrySetting->free_threshold ? 0 : (int) round((float) $countrySetting->base_fee), + ChargePolicyEnum::RANGE_AMOUNT => $this->calculateRangeFee($countrySetting->ranges, $groupTotal, $countrySetting), + ChargePolicyEnum::RANGE_QUANTITY => $this->calculateRangeFee($countrySetting->ranges, $groupQuantity, $countrySetting), + ChargePolicyEnum::RANGE_WEIGHT => $this->calculateRangeFee($countrySetting->ranges, $weightKg, $countrySetting), + ChargePolicyEnum::RANGE_VOLUME => $this->calculateRangeFee($countrySetting->ranges, $volumeLiter, $countrySetting), + ChargePolicyEnum::RANGE_VOLUME_WEIGHT => $this->calculateRangeFee($countrySetting->ranges, $chargeableWeightKg, $countrySetting), ChargePolicyEnum::PER_QUANTITY => $this->calculatePerUnitFee($countrySetting->base_fee, $groupQuantity, $countrySetting->ranges['unit_value'] ?? 1), - ChargePolicyEnum::PER_WEIGHT => $this->calculatePerUnitFee($countrySetting->base_fee, $groupWeight, $countrySetting->ranges['unit_value'] ?? 0.5), - ChargePolicyEnum::PER_VOLUME => $this->calculatePerUnitFee($countrySetting->base_fee, $groupVolume, $countrySetting->ranges['unit_value'] ?? 1000), - ChargePolicyEnum::PER_VOLUME_WEIGHT => $this->calculatePerUnitFee($countrySetting->base_fee, $chargeableWeight, $countrySetting->ranges['unit_value'] ?? 0.5), + ChargePolicyEnum::PER_WEIGHT => $this->calculatePerUnitFee($countrySetting->base_fee, $weightKg, $countrySetting->ranges['unit_value'] ?? 0.5), + ChargePolicyEnum::PER_VOLUME => $this->calculatePerUnitFee($countrySetting->base_fee, $volumeLiter, $countrySetting->ranges['unit_value'] ?? 1), + ChargePolicyEnum::PER_VOLUME_WEIGHT => $this->calculatePerUnitFee($countrySetting->base_fee, $chargeableWeightKg, $countrySetting->ranges['unit_value'] ?? 0.5), ChargePolicyEnum::PER_AMOUNT => $this->calculatePerUnitFee($countrySetting->base_fee, $groupTotal, $countrySetting->ranges['unit_value'] ?? 10000), ChargePolicyEnum::API => $this->calculateApiShippingFee($countrySetting, $group), - default => (int) $countrySetting->base_fee, + default => (int) round((float) $countrySetting->base_fee), }; } /** - * 구간별 배송비를 계산합니다. + * 구간별 배송비를 계산합니다 (상한 포함 사다리 매칭). + * + * 구간을 상한 오름차순(무제한 구간을 마지막)으로 정렬한 뒤, 값이 상한 이하인 첫 구간의 + * 배송비를 반환합니다. 시작값(min)은 화면 표시용이며 매칭에 사용하지 않습니다. 이 방식은 + * 상한 포함형(max+1=다음 min)과 반개형(max=다음 min) 어느 형태로 저장된 데이터에서도 + * 간극 없이 동작합니다. * * @param array|null $ranges 구간 설정 - * @param int $value 비교 값 (금액 또는 수량) + * @param int|float $value 비교 값 (금액·수량·kg·L) + * @param ShippingPolicyCountrySetting|null $countrySetting 로그 맥락용 국가별 설정 * @return int 배송비 */ - protected function calculateRangeFee(?array $ranges, int $value): int + protected function calculateRangeFee(?array $ranges, int|float $value, ?ShippingPolicyCountrySetting $countrySetting = null): int { - if (empty($ranges) || empty($ranges['tiers'])) { + $tiers = is_array($ranges) ? ($ranges['tiers'] ?? []) : []; + + if (! is_array($tiers) || count($tiers) === 0) { + $this->logZeroShippingFallback( + '구간별 배송비 구간(tiers)이 비어 있어 배송비를 0원으로 처리했습니다', + $countrySetting, + ['value' => $value] + ); + return 0; } - foreach ($ranges['tiers'] as $tier) { - $min = $tier['min'] ?? 0; - $max = $tier['max'] ?? PHP_INT_MAX; + // 상한 오름차순 정렬 (무제한 구간은 항상 마지막) + $sortedTiers = array_values($tiers); + usort($sortedTiers, function ($a, $b) { + $aMax = $this->resolveTierMax($a); + $bMax = $this->resolveTierMax($b); - if ($value >= $min && ($max === null || $value < $max)) { - return (int) ($tier['fee'] ?? 0); + if ($aMax === null && $bMax === null) { + return 0; + } + if ($aMax === null) { + return 1; + } + if ($bMax === null) { + return -1; + } + + return $aMax <=> $bMax; + }); + + foreach ($sortedTiers as $tier) { + $max = $this->resolveTierMax($tier); + + if ($max === null || (float) $value <= $max) { + return (int) round((float) ($tier['fee'] ?? 0)); } } + // 마지막 구간의 max 가 null 이면 도달하지 않는 경로 (저장 시점 검증이 1차 방어) + $this->logZeroShippingFallback( + '구간별 배송비에서 매칭되는 구간을 찾지 못해 0원으로 처리했습니다', + $countrySetting, + ['value' => $value] + ); + return 0; } + /** + * 구간의 상한값을 정규화합니다 (무제한이면 null). + * + * @param array $tier 구간 정의 + * @return float|null 상한값 (무제한이면 null) + */ + protected function resolveTierMax(array $tier): ?float + { + $max = $tier['max'] ?? null; + + if ($max === null || $max === '') { + return null; + } + + return (float) $max; + } + + /** + * 배송비 0원 폴백을 경고 로그로 남깁니다. + * + * 설정 오류로 인한 무음 0원(무료배송)은 화면상 정상 주문과 구분되지 않으므로 + * 운영자가 사후에 원인을 추적할 수 있도록 맥락을 남깁니다. + * + * @param string $message 로그 메시지 + * @param ShippingPolicyCountrySetting|null $countrySetting 국가별 설정 + * @param array $context 추가 맥락 + */ + protected function logZeroShippingFallback(string $message, ?ShippingPolicyCountrySetting $countrySetting, array $context = []): void + { + Log::warning($message, array_merge([ + 'policy_id' => $countrySetting?->shipping_policy_id, + 'country_code' => $countrySetting?->country_code, + 'charge_policy' => $countrySetting?->charge_policy?->value, + ], $context)); + } + /** * 단위당 배송비를 계산합니다. * - * @param int|float $baseFee 기본 배송비 - * @param int|float $value 계산 기준 값 (수량, 무게, 부피, 금액 등) + * @param int|float|string $baseFee 기본 배송비 (decimal 캐스트로 문자열이 올 수 있음) + * @param int|float $value 계산 기준 값 (수량, kg, L, 금액 등) * @param int|float $unitValue 단위 값 * @return int 배송비 */ - protected function calculatePerUnitFee(int|float $baseFee, int|float $value, int|float $unitValue): int + protected function calculatePerUnitFee(int|float|string $baseFee, int|float $value, int|float $unitValue): int { if ($unitValue <= 0) { $unitValue = 1; } - return (int) ceil($value / $unitValue) * (int) $baseFee; + return (int) round(ceil($value / $unitValue) * (float) $baseFee); + } + + /** + * per_* 정책에서 부과 단위 수를 계산합니다 (기본 배송비와 도서산간 곱의 공통 기준). + * + * @param ShippingPolicyCountrySetting $countrySetting 국가별 설정 + * @param array $group 그룹 정보 (total_amount, total_quantity, total_weight, total_volume) + * @return int 부과 단위 수 + */ + protected function resolveChargeUnits(ShippingPolicyCountrySetting $countrySetting, array $group): int + { + $groupWeight = (float) ($group['total_weight'] ?? 0.0); + $groupVolume = (float) ($group['total_volume'] ?? 0.0); + $volumeWeightDivisor = $countrySetting->ranges['volume_weight_divisor'] ?? 6000; + + $weightKg = $groupWeight / 1000; + $volumeLiter = $groupVolume / 1000; + $volumetricWeightKg = $volumeWeightDivisor > 0 ? $groupVolume / $volumeWeightDivisor : 0.0; + + [$value, $defaultUnitValue] = match ($countrySetting->charge_policy) { + ChargePolicyEnum::PER_WEIGHT => [$weightKg, 0.5], + ChargePolicyEnum::PER_VOLUME => [$volumeLiter, 1], + ChargePolicyEnum::PER_VOLUME_WEIGHT => [max($weightKg, $volumetricWeightKg), 0.5], + ChargePolicyEnum::PER_AMOUNT => [(float) ($group['total_amount'] ?? 0), 10000], + default => [(float) ($group['total_quantity'] ?? 0), 1], + }; + + $unitValue = $countrySetting->ranges['unit_value'] ?? $defaultUnitValue; + if ($unitValue <= 0) { + $unitValue = 1; + } + + return max(0, (int) ceil($value / $unitValue)); } /** @@ -2222,7 +2333,7 @@ class OrderCalculationService // 엔드포인트 미설정 시 기본 배송비 폴백 if (empty($apiEndpoint)) { - return (int) $countrySetting->base_fee; + return (int) round((float) $countrySetting->base_fee); } try { @@ -2232,12 +2343,12 @@ class OrderCalculationService $response = $this->dispatchApiRequest($apiEndpoint, $requestData, $config); if ($response === null || ! $response->successful()) { - return (int) $countrySetting->base_fee; + return (int) round((float) $countrySetting->base_fee); } $fee = $this->extractFeeFromApiResponse($response, $countrySetting, $config); - return $fee ?? (int) $countrySetting->base_fee; + return $fee ?? (int) round((float) $countrySetting->base_fee); } catch (\Throwable $e) { // 예외 발생 시 기본 배송비 반환 (토큰 등 민감값은 로그에 남기지 않음) Log::warning('API 배송비 계산 실패', [ @@ -2248,7 +2359,7 @@ class OrderCalculationService 'error' => $e->getMessage(), ]); - return (int) $countrySetting->base_fee; + return (int) round((float) $countrySetting->base_fee); } } @@ -2385,7 +2496,7 @@ class OrderCalculationService // 텍스트 응답: 숫자/소수점/부호만 남겨 추출 ("₩3,000" → 3000) $cleaned = preg_replace('/[^0-9.\-]/', '', $response->body()); - return is_numeric($cleaned) ? (int) $cleaned : null; + return is_numeric($cleaned) ? $this->normalizeApiFee((int) $cleaned, $countrySetting) : null; } // JSON 응답: response_path 점표기 중첩 경로 추출 (없으면 기존 api_response_fee_field 폴백) @@ -2395,7 +2506,32 @@ class OrderCalculationService $value = data_get($response->json(), $path); - return is_numeric($value) ? (int) $value : null; + return is_numeric($value) ? $this->normalizeApiFee((int) $value, $countrySetting) : null; + } + + /** + * 외부 API 가 돌려준 배송비를 검증합니다. + * + * 음수 배송비는 주문 총액을 깎아 금전 손실로 이어지므로 추출 실패로 처리해 + * 기본 배송비 폴백 경로에 태웁니다. + * + * @param int $fee 추출된 배송비 + * @param ShippingPolicyCountrySetting $countrySetting 국가별 설정 + * @return int|null 검증을 통과한 배송비 (음수면 null) + */ + protected function normalizeApiFee(int $fee, ShippingPolicyCountrySetting $countrySetting): ?int + { + if ($fee < 0) { + Log::warning('배송비 계산 API 가 음수 배송비를 반환해 기본 배송비로 폴백합니다', [ + 'policy_id' => $countrySetting->shipping_policy_id, + 'country_code' => $countrySetting->country_code, + 'fee' => $fee, + ]); + + return null; + } + + return $fee; } /** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php index 880fa0ab..44633f9c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php @@ -803,6 +803,20 @@ class OrderProcessingService $createdOptions[$item->productOptionId] = $orderOption; } + // 주문 합계 무게/부피는 옵션 소계의 합이다. + // 주문 생성 시점에는 옵션이 없어 0 으로 두었으므로 여기서 실값으로 채운다 + // (배송사 연동·운임 정산이 이 값을 읽는다). + $order->update([ + 'total_weight' => array_sum(array_map( + fn (OrderOption $option) => (float) ($option->subtotal_weight ?? 0), + $createdOptions + )), + 'total_volume' => array_sum(array_map( + fn (OrderOption $option) => (float) ($option->subtotal_volume ?? 0), + $createdOptions + )), + ]); + return $createdOptions; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php index f418aeea..ede924d3 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/messages.php @@ -488,6 +488,12 @@ return [ 'per_volume' => ':fee per :unit L', 'per_volume_weight' => ':fee per :unit kg', 'per_amount' => ':fee per :unit', + // Tier boundary units for range policies (derived from the charge policy, not stored data) + 'range_unit' => [ + 'quantity' => ' items', + 'weight' => ' kg', + 'volume' => ' L', + ], ], 'fee_details' => [ 'base_fee' => 'Shipping Fee', diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php index 85c581b2..85340336 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php @@ -1033,6 +1033,14 @@ return [ 'tier_min_non_negative' => 'The tier start value must be 0 or greater.', 'tier_max_non_negative' => 'The tier end value must be 0 or greater.', 'unit_value_min' => 'The tier unit value must be greater than 0.', + 'tiers_required' => 'A range-based charge policy requires at least one tier.', + 'middle_max_required' => 'Every tier except the last one must have an end value.', + 'tier_value_integer' => 'Quantity tier start and end values must be integers.', + 'unit_value_required' => 'A per-unit charge policy requires a unit value.', + ], + 'free_threshold_required' => 'A conditional free shipping policy requires a free shipping threshold.', + 'extra_fee' => [ + 'zipcode_format' => 'The postal code must be in one of these formats: "63000", "63000-63999", "63*".', ], 'country_settings' => [ 'required' => 'Please add at least one country shipping setting.', diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php index 27730908..7a7612bd 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/messages.php @@ -492,6 +492,12 @@ return [ 'per_volume' => ':unitL당 :fee', 'per_volume_weight' => ':unitkg당 :fee', 'per_amount' => ':unit당 :fee', + // 구간별 정책의 경계값 단위 (부과정책에서 파생 — 저장 데이터에 의존하지 않음) + 'range_unit' => [ + 'quantity' => '개', + 'weight' => 'kg', + 'volume' => 'L', + ], ], 'fee_details' => [ 'base_fee' => '배송비', diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php index f15a1c7e..5052798b 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php @@ -1033,6 +1033,14 @@ return [ 'tier_min_non_negative' => '구간 시작값은 0 이상이어야 합니다.', 'tier_max_non_negative' => '구간 종료값은 0 이상이어야 합니다.', 'unit_value_min' => '구간 단위값은 0보다 커야 합니다.', + 'tiers_required' => '구간별 배송비 정책은 구간을 1개 이상 등록해야 합니다.', + 'middle_max_required' => '마지막 구간을 제외한 구간에는 종료값을 입력해야 합니다.', + 'tier_value_integer' => '수량 구간의 시작값과 종료값은 정수여야 합니다.', + 'unit_value_required' => '단위당 배송비 정책은 단위값을 입력해야 합니다.', + ], + 'free_threshold_required' => '조건부 무료배송 정책은 무료배송 기준금액을 입력해야 합니다.', + 'extra_fee' => [ + 'zipcode_format' => '우편번호는 "63000", "63000-63999", "63*" 형식 중 하나여야 합니다.', ], 'country_settings' => [ 'required' => '국가별 배송 설정을 1개 이상 추가해주세요.', diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/ShippingPolicyValidationMessageTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/ShippingPolicyValidationMessageTest.php index c0d15d17..fc6acb3b 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/ShippingPolicyValidationMessageTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/ShippingPolicyValidationMessageTest.php @@ -334,4 +334,228 @@ class ShippingPolicyValidationMessageTest extends ModuleTestCase $response->assertStatus(422); } + + // ───────── #94: 구간 필수값·경계 규칙 ───────── + + #[Test] + public function test_range_policy_without_tiers_is_rejected(): void + { + // 구간이 없으면 모든 주문이 조용히 0원(무료배송)이 된다 + $cs = $this->krSetting([ + 'charge_policy' => 'range_quantity', + 'base_fee' => 0, + 'ranges' => ['type' => 'quantity', 'tiers' => []], + ]); + + $response = $this->store($this->payload([$cs])); + + $response->assertStatus(422); + $this->assertNoRawKeys($response); + $this->assertStringContainsString('구간을 1개 이상', implode(' ', $this->allMessages($response))); + } + + #[Test] + public function test_per_unit_policy_without_unit_value_is_rejected(): void + { + $cs = $this->krSetting([ + 'charge_policy' => 'per_weight', + 'base_fee' => 1000, + 'ranges' => ['type' => 'per_weight'], + ]); + + $response = $this->store($this->payload([$cs])); + + $response->assertStatus(422); + $this->assertNoRawKeys($response); + $this->assertStringContainsString('단위값', implode(' ', $this->allMessages($response))); + } + + #[Test] + public function test_conditional_free_without_threshold_is_rejected(): void + { + $cs = $this->krSetting([ + 'charge_policy' => 'conditional_free', + 'base_fee' => 3000, + 'free_threshold' => null, + ]); + + $response = $this->store($this->payload([$cs])); + + $response->assertStatus(422); + $this->assertNoRawKeys($response); + $this->assertStringContainsString('무료배송 기준금액', implode(' ', $this->allMessages($response))); + } + + #[Test] + public function test_api_policy_with_zero_base_fee_is_rejected(): void + { + // API 폴백 배송비가 0 이면 외부 API 장애가 곧 무음 무료배송이 된다 + $cs = $this->krSetting([ + 'charge_policy' => 'api', + 'base_fee' => 0, + 'api_endpoint' => 'https://example.com/calc', + ]); + + $response = $this->store($this->payload([$cs])); + + $response->assertStatus(422); + $this->assertNoRawKeys($response); + } + + #[Test] + public function test_middle_tier_without_max_is_rejected(): void + { + // 중간 구간의 종료값이 비면 뒤 구간이 영구히 도달 불가해진다 + $cs = $this->krSetting([ + 'charge_policy' => 'range_weight', + 'base_fee' => 0, + 'ranges' => [ + 'type' => 'weight', + 'tiers' => [ + ['min' => 0, 'max' => null, 'fee' => 3000], + ['min' => 2, 'max' => null, 'fee' => 5000], + ], + ], + ]); + + $response = $this->store($this->payload([$cs])); + + $response->assertStatus(422); + $this->assertNoRawKeys($response); + $this->assertStringContainsString('종료값을 입력', implode(' ', $this->allMessages($response))); + } + + #[Test] + public function test_continuous_range_accepts_next_min_equal_to_previous_max(): void + { + $cs = $this->krSetting([ + 'charge_policy' => 'range_weight', + 'base_fee' => 0, + 'ranges' => [ + 'type' => 'weight', + 'tiers' => [ + ['min' => 0, 'max' => 2, 'fee' => 3000], + ['min' => 2, 'max' => null, 'fee' => 5000], + ], + ], + ]); + + $this->store($this->payload([$cs]))->assertStatus(201); + } + + #[Test] + public function test_continuous_range_rejects_legacy_max_plus_one_gap(): void + { + // 구 규칙(max + 1)은 2 초과 3 미만 구간에 간극을 만든다 + $cs = $this->krSetting([ + 'charge_policy' => 'range_weight', + 'base_fee' => 0, + 'ranges' => [ + 'type' => 'weight', + 'tiers' => [ + ['min' => 0, 'max' => 2, 'fee' => 3000], + ['min' => 3, 'max' => null, 'fee' => 5000], + ], + ], + ]); + + $response = $this->store($this->payload([$cs])); + + $response->assertStatus(422); + $this->assertStringContainsString('연속', implode(' ', $this->allMessages($response))); + } + + #[Test] + public function test_continuous_range_accepts_decimal_boundary(): void + { + // 소수 구간(2.5kg)은 정상 설정이다 — 구 max+1 규칙에서는 저장 자체가 불가했다 + $cs = $this->krSetting([ + 'charge_policy' => 'range_weight', + 'base_fee' => 0, + 'ranges' => [ + 'type' => 'weight', + 'tiers' => [ + ['min' => 0, 'max' => 2.5, 'fee' => 3000], + ['min' => 2.5, 'max' => null, 'fee' => 5000], + ], + ], + ]); + + $this->store($this->payload([$cs]))->assertStatus(201); + } + + #[Test] + public function test_discrete_quantity_range_accepts_max_plus_one(): void + { + // 수량은 이산형이라 "5개 다음은 6개"가 맞다 (공개 #94 본문 설정) + $cs = $this->krSetting([ + 'charge_policy' => 'range_quantity', + 'base_fee' => 0, + 'ranges' => [ + 'type' => 'quantity', + 'tiers' => [ + ['min' => 0, 'max' => 5, 'fee' => 3000], + ['min' => 6, 'max' => null, 'fee' => 5000], + ], + ], + ]); + + $this->store($this->payload([$cs]))->assertStatus(201); + } + + #[Test] + public function test_discrete_quantity_range_rejects_fractional_boundary(): void + { + $cs = $this->krSetting([ + 'charge_policy' => 'range_quantity', + 'base_fee' => 0, + 'ranges' => [ + 'type' => 'quantity', + 'tiers' => [ + ['min' => 0, 'max' => 5.5, 'fee' => 3000], + ['min' => 6.5, 'max' => null, 'fee' => 5000], + ], + ], + ]); + + $response = $this->store($this->payload([$cs])); + + $response->assertStatus(422); + $this->assertStringContainsString('정수', implode(' ', $this->allMessages($response))); + } + + // ───────── #94: 도서산간 우편번호 형식 ───────── + + #[Test] + public function test_extra_fee_accepts_three_supported_zipcode_formats(): void + { + $cs = $this->krSetting([ + 'extra_fee_enabled' => true, + 'extra_fee_settings' => [ + ['zipcode' => '63000', 'fee' => 3000, 'region' => '정확일치'], + ['zipcode' => '63000-63644', 'fee' => 3000, 'region' => '범위'], + ['zipcode' => '63*', 'fee' => 3000, 'region' => '접두 와일드카드'], + ], + ]); + + $this->store($this->payload([$cs]))->assertStatus(201); + } + + #[Test] + public function test_extra_fee_rejects_unsupported_zipcode_format(): void + { + // 어떤 우편번호에도 매칭되지 않아 추가배송비가 조용히 누락된다 + $cs = $this->krSetting([ + 'extra_fee_enabled' => true, + 'extra_fee_settings' => [ + ['zipcode' => '제주 63*', 'fee' => 3000, 'region' => '오형식'], + ], + ]); + + $response = $this->store($this->payload([$cs])); + + $response->assertStatus(422); + $this->assertNoRawKeys($response); + $this->assertStringContainsString('우편번호', implode(' ', $this->allMessages($response))); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ShippingPolicyControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ShippingPolicyControllerTest.php index 7a76fa6d..9bad627a 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ShippingPolicyControllerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ShippingPolicyControllerTest.php @@ -552,9 +552,9 @@ class ShippingPolicyControllerTest extends ModuleTestCase $ranges = [ 'type' => 'amount', 'tiers' => [ - ['min' => 0, 'max' => 29999, 'fee' => 3000, 'unit' => '원'], - ['min' => 30000, 'max' => 49999, 'fee' => 2000, 'unit' => '원'], - ['min' => 50000, 'max' => null, 'fee' => 0, 'unit' => '원'], + ['min' => 0, 'max' => 30000, 'fee' => 3000], + ['min' => 30000, 'max' => 50000, 'fee' => 2000], + ['min' => 50000, 'max' => null, 'fee' => 0], ], ]; @@ -586,8 +586,8 @@ class ShippingPolicyControllerTest extends ModuleTestCase $ranges = [ 'type' => 'quantity', 'tiers' => [ - ['min' => 0, 'max' => 4, 'fee' => 3000, 'unit' => '개'], - ['min' => 5, 'max' => null, 'fee' => 5000, 'unit' => '개'], + ['min' => 0, 'max' => 4, 'fee' => 3000], + ['min' => 5, 'max' => null, 'fee' => 5000], ], ]; @@ -614,8 +614,8 @@ class ShippingPolicyControllerTest extends ModuleTestCase $ranges = [ 'type' => 'weight', 'tiers' => [ - ['min' => 0, 'max' => 2, 'fee' => 3000, 'unit' => 'kg'], - ['min' => 3, 'max' => null, 'fee' => 5000, 'unit' => 'kg'], + ['min' => 0, 'max' => 2, 'fee' => 3000], + ['min' => 2, 'max' => null, 'fee' => 5000], ], ]; @@ -642,8 +642,8 @@ class ShippingPolicyControllerTest extends ModuleTestCase $ranges = [ 'type' => 'volume', 'tiers' => [ - ['min' => 0, 'max' => 99, 'fee' => 5000, 'unit' => 'cm³'], - ['min' => 100, 'max' => null, 'fee' => 10000, 'unit' => 'cm³'], + ['min' => 0, 'max' => 100, 'fee' => 5000], + ['min' => 100, 'max' => null, 'fee' => 10000], ], ]; @@ -670,8 +670,8 @@ class ShippingPolicyControllerTest extends ModuleTestCase $ranges = [ 'type' => 'volume_weight', 'tiers' => [ - ['min' => 0, 'max' => 4, 'fee' => 5000, 'unit' => 'kg'], - ['min' => 5, 'max' => null, 'fee' => 10000, 'unit' => 'kg'], + ['min' => 0, 'max' => 5, 'fee' => 5000], + ['min' => 5, 'max' => null, 'fee' => 10000], ], ]; @@ -698,7 +698,8 @@ class ShippingPolicyControllerTest extends ModuleTestCase $payload = $this->makeStorePayload([], [ $this->makeKrCountrySetting([ 'charge_policy' => 'api', - 'base_fee' => 0, + // API 정책의 base_fee 는 외부 API 장애 시 폴백 배송비 (0 금지) + 'base_fee' => 30000, 'api_endpoint' => 'https://api.example.com/shipping/calculate', // 후보 5종(ShippingApiRequestField) SSoT 내 값만 허용 (W3 Rule::in) 'api_request_fields' => ['items', 'group_total', 'total_quantity'], @@ -1034,7 +1035,7 @@ class ShippingPolicyControllerTest extends ModuleTestCase 'type' => 'amount', 'tiers' => [ ['min' => 0, 'max' => 50000, 'fee' => 3000], - ['min' => 50001, 'max' => null, 'fee' => 0], + ['min' => 50000, 'max' => null, 'fee' => 0], ], ], ]), @@ -1378,6 +1379,7 @@ class ShippingPolicyControllerTest extends ModuleTestCase * 배송정책 상세 조회 - country_settings 포함 * * @scenario endpoint=detail,opt_in=default + * * @effects detail_still_provides_full_country_settings */ #[Test] diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ShippingTierAndOrderTotalsBackfillTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ShippingTierAndOrderTotalsBackfillTest.php new file mode 100644 index 00000000..3ad422e5 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ShippingTierAndOrderTotalsBackfillTest.php @@ -0,0 +1,210 @@ +run(new UpgradeContext('1.1.0', '1.1.1', '1.1.1', 'extension-upgrade')); + } + + /** + * 구간 설정을 가진 배송정책 국가별 설정을 생성합니다. + * + * @param ChargePolicyEnum $chargePolicy 부과정책 + * @param array $tiers 구간 배열 + * @return int 생성된 국가별 설정 ID + */ + private function makeCountrySetting(ChargePolicyEnum $chargePolicy, array $tiers): int + { + $policy = ShippingPolicy::create([ + 'name' => ['ko' => '업그레이드 테스트 정책', 'en' => 'Upgrade Test Policy'], + 'is_default' => false, + 'is_active' => true, + ]); + + $setting = $policy->countrySettings()->create([ + 'country_code' => 'KR', + 'shipping_method' => 'parcel', + 'currency_code' => 'KRW', + 'charge_policy' => $chargePolicy, + 'base_fee' => 0, + 'ranges' => ['tiers' => $tiers], + 'extra_fee_enabled' => false, + 'is_active' => true, + ]); + + return $setting->id; + } + + /** + * 국가별 설정의 구간 배열을 반환합니다. + * + * @param int $settingId 국가별 설정 ID + * @return array 구간 배열 + */ + private function tiersOf(int $settingId): array + { + $row = DB::table('ecommerce_shipping_policy_country_settings')->where('id', $settingId)->first(); + + return json_decode((string) $row->ranges, true)['tiers'] ?? []; + } + + public function test_it_normalizes_continuous_tier_min_to_previous_max(): void + { + // 구 규칙(max + 1)으로 저장된 무게 구간 + $settingId = $this->makeCountrySetting(ChargePolicyEnum::RANGE_WEIGHT, [ + ['min' => 0, 'max' => 2, 'fee' => 3000], + ['min' => 3, 'max' => 5, 'fee' => 5000], + ['min' => 6, 'max' => null, 'fee' => 8000], + ]); + + $this->runUpgrade(); + + $tiers = $this->tiersOf($settingId); + $this->assertEquals(0, $tiers[0]['min']); + $this->assertEquals(2, $tiers[1]['min']); + $this->assertEquals(5, $tiers[2]['min']); + // 종료값과 배송비는 손대지 않는다 + $this->assertEquals(2, $tiers[0]['max']); + $this->assertEquals(5, $tiers[1]['max']); + $this->assertNull($tiers[2]['max']); + $this->assertEquals(8000, $tiers[2]['fee']); + } + + public function test_it_keeps_max_plus_one_for_quantity_tiers(): void + { + // 수량은 이산형이라 max + 1 이 정상 — 그 형태는 그대로 유지된다 + $settingId = $this->makeCountrySetting(ChargePolicyEnum::RANGE_QUANTITY, [ + ['min' => 0, 'max' => 5, 'fee' => 3000], + ['min' => 6, 'max' => null, 'fee' => 5000], + ]); + + $this->runUpgrade(); + + $tiers = $this->tiersOf($settingId); + $this->assertEquals(0, $tiers[0]['min']); + $this->assertEquals(6, $tiers[1]['min']); + } + + public function test_it_normalizes_first_quantity_tier_min_to_zero(): void + { + // 샘플 데이터는 "1~5개 / 6개~" 로 저장돼 있다. 시작값이 읽기전용이 된 이상 + // 이 형태가 남아 있으면 운영자가 그 정책을 다시 저장할 수 없다. + $settingId = $this->makeCountrySetting(ChargePolicyEnum::RANGE_QUANTITY, [ + ['min' => 1, 'max' => 5, 'fee' => 3000], + ['min' => 6, 'max' => null, 'fee' => 5000], + ]); + + $this->runUpgrade(); + + $tiers = $this->tiersOf($settingId); + $this->assertEquals(0, $tiers[0]['min']); + $this->assertEquals(6, $tiers[1]['min']); + // 종료값·배송비는 불변 (계산 결과가 바뀌면 안 된다) + $this->assertEquals(5, $tiers[0]['max']); + $this->assertEquals(3000, $tiers[0]['fee']); + } + + public function test_it_backfills_order_totals_from_option_subtotals(): void + { + $order = OrderFactory::new()->create(); + DB::table('ecommerce_orders')->where('id', $order->id)->update([ + 'total_weight' => 0, + 'total_volume' => 0, + ]); + + foreach ([[500.0, 1000.0], [250.0, 400.0]] as [$weight, $volume]) { + $orderOption = OrderOptionFactory::new()->forOrder($order)->create(['parent_option_id' => null]); + DB::table('ecommerce_order_options')->where('id', $orderOption->id)->update([ + 'subtotal_weight' => $weight, + 'subtotal_volume' => $volume, + ]); + } + + $this->runUpgrade(); + + $row = DB::table('ecommerce_orders')->where('id', $order->id)->first(); + $this->assertEquals(750.0, (float) $row->total_weight); + $this->assertEquals(1400.0, (float) $row->total_volume); + } + + public function test_it_preserves_orders_that_already_have_totals(): void + { + $order = OrderFactory::new()->create(); + DB::table('ecommerce_orders')->where('id', $order->id)->update([ + 'total_weight' => 999.0, + 'total_volume' => 888.0, + ]); + + $orderOption = OrderOptionFactory::new()->forOrder($order)->create(['parent_option_id' => null]); + DB::table('ecommerce_order_options')->where('id', $orderOption->id)->update([ + 'subtotal_weight' => 1.0, + 'subtotal_volume' => 2.0, + ]); + + $this->runUpgrade(); + + $row = DB::table('ecommerce_orders')->where('id', $order->id)->first(); + $this->assertEquals(999.0, (float) $row->total_weight); + $this->assertEquals(888.0, (float) $row->total_volume); + } + + public function test_it_is_idempotent(): void + { + $settingId = $this->makeCountrySetting(ChargePolicyEnum::RANGE_VOLUME, [ + ['min' => 0, 'max' => 50, 'fee' => 5000], + ['min' => 51, 'max' => null, 'fee' => 9000], + ]); + + $order = OrderFactory::new()->create(); + DB::table('ecommerce_orders')->where('id', $order->id)->update([ + 'total_weight' => 0, + 'total_volume' => 0, + ]); + $orderOption = OrderOptionFactory::new()->forOrder($order)->create(['parent_option_id' => null]); + DB::table('ecommerce_order_options')->where('id', $orderOption->id)->update([ + 'subtotal_weight' => 500.0, + 'subtotal_volume' => 1000.0, + ]); + + $this->runUpgrade(); + $firstTiers = $this->tiersOf($settingId); + $firstOrder = DB::table('ecommerce_orders')->where('id', $order->id)->first(); + + $this->runUpgrade(); + $secondTiers = $this->tiersOf($settingId); + $secondOrder = DB::table('ecommerce_orders')->where('id', $order->id)->first(); + + $this->assertSame($firstTiers, $secondTiers); + $this->assertEquals((float) $firstOrder->total_weight, (float) $secondOrder->total_weight); + $this->assertEquals((float) $firstOrder->total_volume, (float) $secondOrder->total_volume); + $this->assertEquals(50, $secondTiers[1]['min']); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts index 63cae0cc..ee654ad9 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts @@ -45,6 +45,8 @@ type EcommerceAuthFixtures = { shippingPolicyToken: string; /** 프로모션 쿠폰 조회+등록+수정+삭제 권한 보유 토큰 (쿠폰 폼 검증용) */ couponManageToken: string; + /** 배송정책 + 상품 + 카테고리 권한 토큰 (구간 경계 E2E 픽스처 구성용) */ + shippingBoundaryToken: string; /** 일반 쇼핑 사용자 토큰 (관리자 권한 없음 — 유저 화면 검증용) */ userToken: string; /** 구매 고객 토큰 (장바구니/추가옵션 등 쇼핑 플로우 검증용) */ @@ -115,6 +117,22 @@ export const test = base.extend({ ), ); }, + shippingBoundaryToken: async ({}, use) => { + // 구간 경계 검증은 정책·상품·카테고리를 한 세션에서 만들고 지우므로 권한을 함께 발급한다. + await use( + issueToken( + 'sirsoft-ecommerce.shipping-policies.read', + 'sirsoft-ecommerce.shipping-policies.create', + 'sirsoft-ecommerce.shipping-policies.update', + 'sirsoft-ecommerce.shipping-policies.delete', + 'sirsoft-ecommerce.products.read', + 'sirsoft-ecommerce.products.create', + 'sirsoft-ecommerce.products.update', + 'sirsoft-ecommerce.products.delete', + 'sirsoft-ecommerce.categories.read', + ), + ); + }, // userToken / customerToken 은 현재 둘 다 "관리자 권한 없는 인증 사용자" 로 동일하다. // 유저 화면(마이페이지/헤더)과 구매 플로우(장바구니/주문서)는 요구 권한이 갈릴 수 있어 // 호출부의 의도를 이름으로 남긴다 — 한쪽에 권한이 붙어도 다른 쪽이 영향받지 않는다. diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/shipping-policy-tier-boundary.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/shipping-policy-tier-boundary.spec.ts new file mode 100644 index 00000000..52334d0c --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/shipping-policy-tier-boundary.spec.ts @@ -0,0 +1,450 @@ +/** + * 배송정책 구간 경계·단위 환산 (공개 이슈 #94). + * + * 관리자 화면이 "1~5개: 3,000원 / 6개~: 5,000원" 이라고 약속한 정책에서 수량 5가 어느 구간에도 + * 매칭되지 않아 배송비가 0원(무료배송)이 됐다. 상품 옵션은 g/cm³ 로 저장되는데 배송정책은 + * kg/L 로 설정되므로, 500g 상품에 "1kg당 1,000원" 을 걸면 500,000원이 청구됐다. + * + * 두 결함 모두 저장된 값은 정상이고 화면도 오류를 내지 않는다 — 금액만 틀린다. 그래서 이 + * spec 은 관리자 저장(검증 계약)과 상점 장바구니(계산 결과)를 한 흐름에서 확인한다. + * + * @scenario charge_policy=range_quantity, charge_policy=range_weight, charge_policy=per_weight, + * boundary=at_max, boundary=decimal, unit_source=g, tier_shape=inclusive + * @effects fee_matches_admin_promise, + * boundary_value_charged_not_free, + * unit_converted_kg_l, + * invalid_config_rejected_422, + * tier_min_derived_from_previous_max, + * range_unit_label_displayed + */ +import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth'; + +const POLICY_API = '/api/modules/sirsoft-ecommerce/admin/shipping-policies'; +const PRODUCT_API = '/api/modules/sirsoft-ecommerce/admin/products'; +const CATEGORY_API = '/api/modules/sirsoft-ecommerce/admin/categories'; +const CART_API = '/api/modules/sirsoft-ecommerce/cart'; +const POLICY_FORM_URL = '/admin/ecommerce/shipping-policies/create'; +const POLICY_EDIT_URL = (id: number) => `/admin/ecommerce/shipping-policies/${id}/edit`; + +type ApiResult = { status: number; body: any }; + +/** + * 로그인된 페이지 컨텍스트에서 API 를 호출합니다. + * + * @param page Playwright 페이지 + * @param method HTTP 메서드 + * @param url 요청 URL + * @param body 요청 본문 (없으면 생략) + * @returns 상태코드와 파싱된 응답 + */ +async function api(page: any, method: string, url: string, body?: any): Promise { + return page.evaluate( + async ({ method, url, body }: { method: string; url: string; body?: any }) => { + const res = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${localStorage.getItem('auth_token')}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + let parsed: any = null; + try { + parsed = await res.json(); + } catch { + parsed = null; + } + + return { status: res.status, body: parsed }; + }, + { method, url, body }, + ); +} + +/** + * 국가별 설정 1건을 가진 배송정책 페이로드를 만듭니다. + * + * @param name 정책명 + * @param countrySetting 국가별 설정 오버라이드 + */ +function policyPayload(name: string, countrySetting: Record) { + return { + name: { ko: name }, + is_active: true, + is_default: false, + sort_order: 0, + country_settings: [ + { + country_code: 'KR', + shipping_method: 'parcel', + charge_policy: 'fixed', + base_fee: 3000, + extra_fee_enabled: false, + is_active: true, + ...countrySetting, + }, + ], + }; +} + +test.describe('배송정책 구간 경계·단위 환산 (#94)', () => { + test('저장 검증: 무음 0원을 만드는 설정은 422 로 차단된다', async ({ page, shippingBoundaryToken }) => { + await authenticatePage(page, shippingBoundaryToken); + await page.goto(POLICY_FORM_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + const suffix = `${Date.now()}`.slice(-9); + const createdIds: number[] = []; + + /** + * 저장 요청 결과를 확인하고, 만들어진 정책이 있으면 정리 대상에 등록합니다. + * + * 422 를 기대한 요청이 통과해 버리면 그 정책이 사이트에 그대로 남는다 — 실패한 + * 단언보다 잔여 데이터가 더 오래 남으므로 생성 여부와 무관하게 수집한다. + */ + const track = (result: ApiResult): ApiResult => { + if (result.body?.data?.id) { + createdIds.push(result.body.data.id); + } + + return result; + }; + + try { + // ① 구간별 정책인데 구간이 없음 → 모든 주문이 0원이 된다 + const noTiers = track(await api(page, 'POST', POLICY_API, policyPayload(`E2E 구간없음 ${suffix}`, { + charge_policy: 'range_quantity', + base_fee: 0, + ranges: { type: 'quantity', tiers: [] }, + }))); + expect(noTiers.status).toBe(422); + + // ② 중간 구간의 종료값 공란 → 뒤 구간이 영구히 도달 불가 + const middleMax = track(await api(page, 'POST', POLICY_API, policyPayload(`E2E 중간무제한 ${suffix}`, { + charge_policy: 'range_weight', + base_fee: 0, + ranges: { + type: 'weight', + tiers: [ + { min: 0, max: null, fee: 3000 }, + { min: 2, max: null, fee: 5000 }, + ], + }, + }))); + expect(middleMax.status).toBe(422); + + // ③ 조건부 무료인데 기준금액 공란 + const noThreshold = track(await api(page, 'POST', POLICY_API, policyPayload(`E2E 기준없음 ${suffix}`, { + charge_policy: 'conditional_free', + base_fee: 3000, + free_threshold: null, + }))); + expect(noThreshold.status).toBe(422); + + // ④ 단위당 정책인데 단위값 공란 + const noUnit = track(await api(page, 'POST', POLICY_API, policyPayload(`E2E 단위없음 ${suffix}`, { + charge_policy: 'per_weight', + base_fee: 1000, + ranges: { type: 'per_weight' }, + }))); + expect(noUnit.status).toBe(422); + + // ⑤ 연속형 구간의 소수 경계는 정상 설정이다 (구 규칙에서는 저장조차 불가했다) + const decimal = track(await api(page, 'POST', POLICY_API, policyPayload(`E2E 소수구간 ${suffix}`, { + charge_policy: 'range_weight', + base_fee: 0, + ranges: { + type: 'weight', + tiers: [ + { min: 0, max: 2.5, fee: 3000 }, + { min: 2.5, max: null, fee: 5000 }, + ], + }, + }))); + expect(decimal.status).toBe(201); + } finally { + for (const id of createdIds) { + await api(page, 'DELETE', `${POLICY_API}/${id}`); + } + } + }); + + test('구간 폼 UI: 시작값 자동 파생·읽기전용, 단위 라벨, 단위값 선제 검증', async ({ + page, + shippingBoundaryToken, + }) => { + await authenticatePage(page, shippingBoundaryToken); + await page.goto(POLICY_FORM_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + const suffix = `${Date.now()}`.slice(-9); + let policyId: number | null = null; + + try { + // 소수 경계를 가진 무게 구간 — 시작값이 직전 종료값과 같은 연속형이다 + const policy = await api(page, 'POST', POLICY_API, policyPayload(`E2E 폼UI ${suffix}`, { + charge_policy: 'range_weight', + base_fee: 0, + ranges: { + type: 'weight', + tiers: [ + { min: 0, max: 2.5, fee: 3000 }, + { min: 2.5, max: null, fee: 5000 }, + ], + }, + })); + expect(policy.status, JSON.stringify(policy.body?.errors ?? {})).toBe(201); + policyId = policy.body.data.id; + + await page.goto(POLICY_EDIT_URL(policyId as number)); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + const firstRow = page.locator('#range_tier_row_0'); + await expect(firstRow).toBeVisible({ timeout: 30_000 }); + + // 시작값 칸은 파생값이라 운영자가 직접 고칠 수 없다 + await expect(firstRow.locator('input').first()).toHaveJSProperty('readOnly', true); + + // 구간 표 머리글의 단위는 부과정책(무게)에서 파생된다 + const headers = await page.locator('#range_tiers_table thead th').allInnerTexts(); + expect(headers[0], '시작값 머리글 단위').toContain('kg'); + expect(headers[1], '종료값 머리글 단위').toContain('kg'); + + // 종료값을 고치면 다음 구간 시작값이 따라온다 (연속형 = 직전 종료값) + const secondRowMin = page.locator('#range_tier_row_1').locator('input').first(); + await expect(secondRowMin).toHaveValue('2.5'); + + await firstRow.locator('input').nth(1).fill('3.5'); + await expect(secondRowMin).toHaveValue('3.5', { timeout: 10_000 }); + + // 부과정책을 단위당으로 바꾸면 단위 표기가 그 정책을 따라간다 + await page.locator('#field_charge_policy').getByRole('button').first().click(); + const listbox = page.getByRole('listbox'); + await expect(listbox).toBeVisible({ timeout: 10_000 }); + await listbox.getByRole('option', { name: '무게당 배송비', exact: true }).click(); + + const unitField = page.locator('#field_unit_value'); + await expect(unitField).toBeVisible({ timeout: 10_000 }); + await expect(unitField).toContainText('kg'); + + // 단위값을 비우면 저장 요청 없이 화면에서 먼저 오류가 뜬다 + const requestsBefore = page.waitForRequest( + (req) => req.url().includes('/admin/shipping-policies') && req.method() !== 'GET', + { timeout: 3_000 }, + ).catch(() => null); + + await unitField.locator('input').first().fill(''); + await expect(unitField).toContainText('단위값', { timeout: 10_000 }); + expect(await requestsBefore, '선제 검증은 저장 요청 없이 이뤄진다').toBeNull(); + } finally { + if (policyId) { + await page.goto(POLICY_FORM_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await api(page, 'DELETE', `${POLICY_API}/${policyId}`); + } + } + }); + + test('수량 구간의 상한은 포함이다 — 장바구니 4/5/6개 배송비', async ({ + page, + shippingBoundaryToken, + customerToken, + }) => { + await authenticatePage(page, shippingBoundaryToken); + await page.goto(POLICY_FORM_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + const suffix = `${Date.now()}`.slice(-9); + let policyId: number | null = null; + let productId: number | null = null; + let optionId: number | null = null; + + try { + // 공개 이슈 #94 본문과 동일한 설정: 1~5개 3,000원 / 6개~ 5,000원 + const policy = await api(page, 'POST', POLICY_API, policyPayload(`E2E 수량구간 ${suffix}`, { + charge_policy: 'range_quantity', + base_fee: 0, + ranges: { + type: 'quantity', + tiers: [ + { min: 0, max: 5, fee: 3000 }, + { min: 6, max: null, fee: 5000 }, + ], + }, + })); + expect(policy.status).toBe(201); + policyId = policy.body.data.id; + + // 목록 요약이 관리자에게 약속하는 문장 — 단위(개)가 붙어야 한다 + expect(String(policy.body.data.fee_summary)).toContain('개'); + + const categories = await api(page, 'GET', `${CATEGORY_API}?per_page=1`); + const categoryId = categories.body?.data?.data?.[0]?.id ?? categories.body?.data?.[0]?.id; + expect(categoryId, '카테고리가 1건 이상 있어야 상품을 만들 수 있다').toBeTruthy(); + + const product = await api(page, 'POST', PRODUCT_API, { + name: { ko: `E2E 구간경계 상품 ${suffix}` }, + product_code: `E2E-BOUND-${suffix}`, + category_ids: [categoryId], + list_price: 10000, + selling_price: 10000, + stock_quantity: 999, + sales_status: 'on_sale', + display_status: 'visible', + tax_status: 'taxable', + shipping_policy_id: policyId, + options: [ + { + option_code: `E2E-BOUND-${suffix}-001`, + option_name: { ko: '기본' }, + option_values: [{ key: { ko: '구성' }, value: { ko: '기본' } }], + list_price: 10000, + selling_price: 10000, + stock_quantity: 999, + is_default: true, + is_active: true, + }, + ], + }); + expect(product.status, JSON.stringify(product.body?.errors ?? {})).toBe(201); + productId = product.body.data.id; + optionId = product.body.data.options?.[0]?.id; + expect(optionId).toBeTruthy(); + + // 구매자로 전환해 장바구니에서 실제 계산 결과를 본다 + await authenticatePage(page, customerToken); + await page.goto('/shop/cart'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + // product_id 는 최상위, 옵션·수량은 items[] (BulkAddToCartRequest 계약) + const added = await api(page, 'POST', CART_API, { + product_id: productId, + items: [{ product_option_id: optionId, quantity: 4 }], + }); + expect(added.status, JSON.stringify(added.body ?? {})).toBe(201); + const cartItemId = added.body?.data?.items?.[0]?.id; + expect(cartItemId).toBeTruthy(); + + const expectations: Array<[number, number]> = [ + [4, 3000], + [5, 3000], // 경계값 — 기존에는 어느 구간에도 걸리지 않아 0원이었다 + [6, 5000], + ]; + + for (const [quantity, expectedFee] of expectations) { + await api(page, 'PATCH', `${CART_API}/${cartItemId}/quantity`, { quantity }); + + const cart = await api(page, 'POST', `${CART_API}/query`, { selected_ids: [cartItemId] }); + expect(cart.status).toBe(200); + + const totalShipping = cart.body?.data?.calculation?.summary?.total_shipping; + expect(totalShipping, `수량 ${quantity} 의 배송비`).toBe(expectedFee); + } + + await api(page, 'DELETE', `${CART_API}/${cartItemId}`); + } finally { + await authenticatePage(page, shippingBoundaryToken); + await page.goto(POLICY_FORM_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + if (productId) { + await api(page, 'DELETE', `${PRODUCT_API}/${productId}`); + } + if (policyId) { + await api(page, 'DELETE', `${POLICY_API}/${policyId}`); + } + } + }); + + test('무게당 정책은 g 저장값을 kg 으로 환산한다 — 500g 상품 1개', async ({ + page, + shippingBoundaryToken, + customerToken, + }) => { + await authenticatePage(page, shippingBoundaryToken); + await page.goto(POLICY_FORM_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + const suffix = `${Date.now()}`.slice(-9); + let policyId: number | null = null; + let productId: number | null = null; + let optionId: number | null = null; + + try { + const policy = await api(page, 'POST', POLICY_API, policyPayload(`E2E 무게당 ${suffix}`, { + charge_policy: 'per_weight', + base_fee: 1000, + ranges: { type: 'per_weight', unit_value: 1 }, + })); + expect(policy.status).toBe(201); + policyId = policy.body.data.id; + + const categories = await api(page, 'GET', `${CATEGORY_API}?per_page=1`); + const categoryId = categories.body?.data?.data?.[0]?.id ?? categories.body?.data?.[0]?.id; + expect(categoryId).toBeTruthy(); + + const product = await api(page, 'POST', PRODUCT_API, { + name: { ko: `E2E 무게환산 상품 ${suffix}` }, + product_code: `E2E-WEIGHT-${suffix}`, + category_ids: [categoryId], + list_price: 10000, + selling_price: 10000, + stock_quantity: 999, + sales_status: 'on_sale', + display_status: 'visible', + tax_status: 'taxable', + shipping_policy_id: policyId, + options: [ + { + option_code: `E2E-WEIGHT-${suffix}-001`, + option_name: { ko: '기본' }, + option_values: [{ key: { ko: '구성' }, value: { ko: '기본' } }], + list_price: 10000, + selling_price: 10000, + stock_quantity: 999, + // 상품 옵션의 저장 단위는 g — 정책의 1kg 과 단위가 다르다 + weight: 500, + is_default: true, + is_active: true, + }, + ], + }); + expect(product.status, JSON.stringify(product.body?.errors ?? {})).toBe(201); + productId = product.body.data.id; + optionId = product.body.data.options?.[0]?.id; + + // 저장한 무게가 응답에 실려야 관리자 수정 화면이 0 으로 덮어쓰지 않는다 + expect(Number(product.body.data.options?.[0]?.weight)).toBe(500); + + await authenticatePage(page, customerToken); + await page.goto('/shop/cart'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + const added = await api(page, 'POST', CART_API, { + product_id: productId, + items: [{ product_option_id: optionId, quantity: 1 }], + }); + expect(added.status, JSON.stringify(added.body ?? {})).toBe(201); + const cartItemId = added.body?.data?.items?.[0]?.id; + + const cart = await api(page, 'POST', `${CART_API}/query`, { selected_ids: [cartItemId] }); + // 500g = 0.5kg → ceil(0.5 / 1) = 1단위 × 1,000원. 기존 버그값은 500,000원이었다. + expect(cart.body?.data?.calculation?.summary?.total_shipping).toBe(1000); + + await api(page, 'DELETE', `${CART_API}/${cartItemId}`); + } finally { + await authenticatePage(page, shippingBoundaryToken); + await page.goto(POLICY_FORM_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + if (productId) { + await api(page, 'DELETE', `${PRODUCT_API}/${productId}`); + } + if (policyId) { + await api(page, 'DELETE', `${POLICY_API}/${policyId}`); + } + } + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Database/Seeders/ShippingPolicySeederTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Database/Seeders/ShippingPolicySeederTest.php index 384f9fa8..2402408f 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Database/Seeders/ShippingPolicySeederTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Database/Seeders/ShippingPolicySeederTest.php @@ -2,8 +2,12 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Database\Seeders; +use Illuminate\Routing\Redirector; +use Illuminate\Validation\ValidationException; use Modules\Sirsoft\Ecommerce\Database\Seeders\Sample\ShippingPolicySeeder; +use Modules\Sirsoft\Ecommerce\Database\Seeders\ShippingTypeSeeder; use Modules\Sirsoft\Ecommerce\Enums\ChargePolicyEnum; +use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreShippingPolicyRequest; use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy; use Modules\Sirsoft\Ecommerce\Models\ShippingPolicyCountrySetting; use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase; @@ -142,10 +146,83 @@ class ShippingPolicySeederTest extends ModuleTestCase $this->assertArrayHasKey('min', $tier); $this->assertArrayHasKey('max', $tier); - $this->assertArrayHasKey('unit', $tier); $this->assertArrayHasKey('fee', $tier); } + /** + * 시더가 만든 모든 정책이 관리자 저장 검증(StoreShippingPolicyRequest)을 통과해야 한다. + * + * 시더는 Model::create 로 검증을 우회하므로, 키 존재만 확인하면 화면에서는 저장조차 + * 불가한 설정이 샘플 데이터로 배포된다 (구간 연속성·필수값 규칙 위반). + */ + public function test_seeded_policies_pass_admin_store_validation(): void + { + $this->seed(ShippingTypeSeeder::class); + $this->runSeeder(); + + $policies = ShippingPolicy::with('countrySettings')->get(); + $this->assertTrue($policies->isNotEmpty()); + + foreach ($policies as $policy) { + $payload = [ + 'name' => $policy->name, + 'is_active' => $policy->is_active, + 'is_default' => $policy->is_default, + 'sort_order' => $policy->sort_order, + 'country_settings' => $policy->countrySettings + ->map(fn (ShippingPolicyCountrySetting $cs) => [ + 'country_code' => $cs->country_code, + 'shipping_method' => $cs->shipping_method, + 'custom_shipping_name' => $cs->custom_shipping_name, + 'currency_code' => $cs->currency_code, + 'charge_policy' => $cs->charge_policy->value, + 'base_fee' => (float) $cs->base_fee, + 'free_threshold' => $cs->free_threshold !== null ? (float) $cs->free_threshold : null, + 'ranges' => $cs->ranges, + 'api_endpoint' => $cs->api_endpoint, + 'api_request_fields' => $cs->api_request_fields, + 'api_response_fee_field' => $cs->api_response_fee_field, + 'api_config' => $cs->api_config, + 'extra_fee_enabled' => (bool) $cs->extra_fee_enabled, + 'extra_fee_settings' => $cs->extra_fee_settings, + 'extra_fee_multiply' => (bool) $cs->extra_fee_multiply, + 'is_active' => (bool) $cs->is_active, + ]) + ->values() + ->all(), + ]; + + $errors = $this->collectStoreValidationErrors($payload); + + $this->assertSame( + [], + $errors, + "정책 '{$policy->getLocalizedName()}' 이 관리자 저장 검증을 통과하지 못했습니다: " + .json_encode($errors, JSON_UNESCAPED_UNICODE) + ); + } + } + + /** + * 배송정책 저장 요청 검증을 수행하고 에러 메시지 배열을 반환합니다. + * + * @param array $payload 저장 요청 페이로드 + * @return array> 필드별 에러 메시지 (통과 시 빈 배열) + */ + private function collectStoreValidationErrors(array $payload): array + { + $request = StoreShippingPolicyRequest::create('/', 'POST', $payload); + $request->setContainer(app())->setRedirector(app(Redirector::class)); + + try { + $request->validateResolved(); + } catch (ValidationException $e) { + return $e->errors(); + } + + return []; + } + // ======================================== // 단위당(per_*) 정책 테스트 (countrySettings 기반) // ======================================== diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Enums/ShippingPolicyEnumsTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Enums/ShippingPolicyEnumsTest.php index 7604ad81..2356f565 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Enums/ShippingPolicyEnumsTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Enums/ShippingPolicyEnumsTest.php @@ -73,9 +73,23 @@ class ShippingPolicyEnumsTest extends ModuleTestCase $this->assertTrue(ChargePolicyEnum::PER_VOLUME->requiresBaseFee()); $this->assertTrue(ChargePolicyEnum::PER_VOLUME_WEIGHT->requiresBaseFee()); $this->assertTrue(ChargePolicyEnum::PER_AMOUNT->requiresBaseFee()); + // API 정책의 base_fee 는 외부 API 장애 시 폴백 배송비 — 0 이면 장애가 곧 무음 무료배송이 된다 + $this->assertTrue(ChargePolicyEnum::API->requiresBaseFee()); $this->assertFalse(ChargePolicyEnum::FREE->requiresBaseFee()); $this->assertFalse(ChargePolicyEnum::RANGE_AMOUNT->requiresBaseFee()); - $this->assertFalse(ChargePolicyEnum::API->requiresBaseFee()); + } + + public function test_charge_policy_has_discrete_range_values(): void + { + // 수량만 이산형 — "5개 다음은 6개"(max + 1)로 이어진다 + $this->assertTrue(ChargePolicyEnum::RANGE_QUANTITY->hasDiscreteRangeValues()); + + // 금액·무게·부피는 연속값 — "5kg 다음은 5kg 초과"(다음 min = max) + $this->assertFalse(ChargePolicyEnum::RANGE_AMOUNT->hasDiscreteRangeValues()); + $this->assertFalse(ChargePolicyEnum::RANGE_WEIGHT->hasDiscreteRangeValues()); + $this->assertFalse(ChargePolicyEnum::RANGE_VOLUME->hasDiscreteRangeValues()); + $this->assertFalse(ChargePolicyEnum::RANGE_VOLUME_WEIGHT->hasDiscreteRangeValues()); + $this->assertFalse(ChargePolicyEnum::PER_QUANTITY->hasDiscreteRangeValues()); } public function test_charge_policy_requires_free_threshold(): void diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Resources/ProductOptionResourceDimensionsTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Resources/ProductOptionResourceDimensionsTest.php new file mode 100644 index 00000000..bc9a0f38 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Resources/ProductOptionResourceDimensionsTest.php @@ -0,0 +1,67 @@ +create(); + $option = ProductOption::factory()->create(array_merge([ + 'product_id' => $product->id, + ], $attributes)); + + return (new ProductOptionResource($option))->toArray(Request::create('/')); + } + + public function test_it_serializes_weight_and_volume_in_storage_units(): void + { + // 상품 옵션의 저장 단위는 g / cm³ (배송정책의 kg/L 환산은 배송비 계산 시점에만) + $data = $this->toResourceArray(['weight' => 500, 'volume' => 1200]); + + $this->assertArrayHasKey('weight', $data); + $this->assertArrayHasKey('volume', $data); + $this->assertEquals(500.0, $data['weight']); + $this->assertEquals(1200.0, $data['volume']); + } + + public function test_it_preserves_null_instead_of_coercing_to_zero(): void + { + // null 을 0 으로 내리면 "미입력"과 "무게 0" 이 구분되지 않는다 + $data = $this->toResourceArray(['weight' => null, 'volume' => null]); + + $this->assertNull($data['weight']); + $this->assertNull($data['volume']); + } + + public function test_it_preserves_decimal_precision(): void + { + $data = $this->toResourceArray(['weight' => 12.5, 'volume' => 0.75]); + + $this->assertEquals(12.5, $data['weight']); + $this->assertEquals(0.75, $data['volume']); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php index a68cf0fc..66548763 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php @@ -1873,12 +1873,12 @@ class OrderAdjustmentServiceTest extends ModuleTestCase chargePolicy: ChargePolicyEnum::RANGE_WEIGHT, ranges: [ 'tiers' => [ - ['min' => 0, 'max' => 500, 'fee' => 2500], - ['min' => 500, 'max' => null, 'fee' => 4000], + ['min' => 0, 'max' => 0.5, 'fee' => 2500], + ['min' => 0.5, 'max' => null, 'fee' => 4000], ], ], ); - [$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 0.3); + [$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 300); $input = new CalculationInput(items: [ new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1), @@ -1903,8 +1903,8 @@ class OrderAdjustmentServiceTest extends ModuleTestCase chargePolicy: ChargePolicyEnum::RANGE_VOLUME, ranges: [ 'tiers' => [ - ['min' => 0, 'max' => 1000, 'fee' => 3000], - ['min' => 1000, 'max' => null, 'fee' => 5000], + ['min' => 0, 'max' => 1, 'fee' => 3000], + ['min' => 1, 'max' => null, 'fee' => 5000], ], ], ); @@ -1934,13 +1934,13 @@ class OrderAdjustmentServiceTest extends ModuleTestCase chargePolicy: ChargePolicyEnum::RANGE_VOLUME_WEIGHT, ranges: [ 'tiers' => [ - ['min' => 0, 'max' => 500, 'fee' => 3000], - ['min' => 500, 'max' => null, 'fee' => 5000], + ['min' => 0, 'max' => 0.5, 'fee' => 3000], + ['min' => 0.5, 'max' => null, 'fee' => 5000], ], 'volume_weight_divisor' => 6000, ], ); - [$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 0.5, volume: 4200); + [$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 500, volume: 4200); $input = new CalculationInput(items: [ new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1), @@ -1965,7 +1965,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase baseFee: 2000, ranges: ['unit_value' => 0.5], ); - [$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 1.2); + [$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 1200); $input = new CalculationInput(items: [ new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1), @@ -1988,7 +1988,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase $sp = $this->createShippingPolicy( chargePolicy: ChargePolicyEnum::PER_VOLUME, baseFee: 1500, - ranges: ['unit_value' => 1000], + ranges: ['unit_value' => 1], ); [$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, volume: 2500); @@ -2017,7 +2017,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase baseFee: 2000, ranges: ['unit_value' => 0.5, 'volume_weight_divisor' => 6000], ); - [$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 0.3, volume: 4200); + [$p, $o] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 300, volume: 4200); $input = new CalculationInput(items: [ new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 1), @@ -2360,13 +2360,13 @@ class OrderAdjustmentServiceTest extends ModuleTestCase chargePolicy: ChargePolicyEnum::RANGE_WEIGHT, ranges: [ 'tiers' => [ - ['min' => 0, 'max' => 500, 'fee' => 2500], - ['min' => 500, 'max' => null, 'fee' => 4000], + ['min' => 0, 'max' => 0.5, 'fee' => 2500], + ['min' => 0.5, 'max' => null, 'fee' => 4000], ], ], ); // 0.35kg × 2개 = 0.7kg = 700g → 4000원 구간 - [$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 0.35); + [$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 350); $input = new CalculationInput(items: [ new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 2), @@ -2396,8 +2396,8 @@ class OrderAdjustmentServiceTest extends ModuleTestCase chargePolicy: ChargePolicyEnum::RANGE_VOLUME, ranges: [ 'tiers' => [ - ['min' => 0, 'max' => 1000, 'fee' => 3000], - ['min' => 1000, 'max' => null, 'fee' => 5000], + ['min' => 0, 'max' => 1, 'fee' => 3000], + ['min' => 1, 'max' => null, 'fee' => 5000], ], ], ); @@ -2437,8 +2437,8 @@ class OrderAdjustmentServiceTest extends ModuleTestCase chargePolicy: ChargePolicyEnum::RANGE_VOLUME_WEIGHT, ranges: [ 'tiers' => [ - ['min' => 0, 'max' => 500, 'fee' => 3000], - ['min' => 500, 'max' => null, 'fee' => 5000], + ['min' => 0, 'max' => 0.5, 'fee' => 3000], + ['min' => 0.5, 'max' => null, 'fee' => 5000], ], 'volume_weight_divisor' => 6000, ], @@ -2446,7 +2446,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase // 0.2kg, 1500cm³ per item // 3개: 무게0.6kg, 부피4500→부피무게0.75, max(0.6,0.75)=750g → 5000원 // 1개: 무게0.2kg, 부피1500→부피무게0.25, max(0.2,0.25)=250g → 3000원 - [$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 0.2, volume: 1500); + [$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 200, volume: 1500); $input = new CalculationInput(items: [ new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 3), @@ -2642,7 +2642,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase ranges: ['unit_value' => 0.5], ); // 0.4kg × 3개 = 1.2kg - [$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 0.4); + [$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 400); $input = new CalculationInput(items: [ new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 3), @@ -2675,7 +2675,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase ranges: ['unit_value' => 0.5], ); // 0.4kg × 3개 = 1.2kg - [$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 0.4); + [$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 400); $input = new CalculationInput(items: [ new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 3), @@ -2704,7 +2704,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase $sp = $this->createShippingPolicy( chargePolicy: ChargePolicyEnum::PER_VOLUME, baseFee: 1500, - ranges: ['unit_value' => 1000], + ranges: ['unit_value' => 1], ); // 500cm³ × 5개 = 2500cm³ [$p, $o] = $this->createProductWithOption(price: 5000, shippingPolicy: $sp, volume: 500); @@ -2809,7 +2809,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase baseFee: 2000, ranges: ['unit_value' => 0.5, 'volume_weight_divisor' => 6000], ); - [$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 0.2, volume: 1500); + [$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 200, volume: 1500); $input = new CalculationInput(items: [ new CalculationItem(productId: $p->id, productOptionId: $o->id, quantity: 4), @@ -2987,9 +2987,9 @@ class OrderAdjustmentServiceTest extends ModuleTestCase * A-7-4: PER_WEIGHT + multiply extra fee → 수량 감소 시 도서산간도 비례 감소 * * unit=0.5kg, fee=2000, extra=1500×multiply. - * 도서산간 multiply는 quantity/unit_value 기반 (PER_WEIGHT에서도 수량 사용) - * 3개: 배송 ceil(1.2kg/0.5)×2K=6K, 도서산간 ceil(3/0.5)×1.5K=9K - * 1개: 배송 ceil(0.4kg/0.5)×2K=2K, 도서산간 ceil(1/0.5)×1.5K=3K + * 도서산간 multiply 는 기본 배송비와 같은 배송 단위 수 기준 (수량이 아니다) + * 3개(1.2kg): 배송 ceil(1.2/0.5)=3단위 × 2K=6K, 도서산간 3단위 × 1.5K=4.5K + * 1개(0.4kg): 배송 ceil(0.4/0.5)=1단위 × 2K=2K, 도서산간 1단위 × 1.5K=1.5K */ public function test_extra_fee_multiply_per_weight(): void { @@ -3004,7 +3004,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase extraFeeMultiply: true, ); // 0.4kg × 3개 = 1.2kg - [$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 0.4); + [$p, $o] = $this->createProductWithOption(price: 10000, shippingPolicy: $sp, weight: 400); $address = new ShippingAddress(countryCode: 'KR', zipcode: '63400'); $input = new CalculationInput( @@ -3020,10 +3020,10 @@ class OrderAdjustmentServiceTest extends ModuleTestCase ['order_option_id' => $option->id, 'cancel_quantity' => 2], ])); - // 원래: 30K + 6K(배송) + 9K(도서산간) = 45K - // 잔여: 10K + 2K(배송) + 3K(도서산간) = 15K - // refund = 45K - 15K = 30,000 - $this->assertEquals(30000, $result->refundAmount); + // 원래: 30K + 6K(배송) + 4.5K(도서산간) = 40.5K + // 잔여: 10K + 2K(배송) + 1.5K(도서산간) = 13.5K + // refund = 40.5K - 13.5K = 27,000 + $this->assertEquals(27000, $result->refundAmount); } /** @@ -4228,15 +4228,15 @@ class OrderAdjustmentServiceTest extends ModuleTestCase chargePolicy: ChargePolicyEnum::RANGE_WEIGHT, ranges: [ 'tiers' => [ - ['min' => 0, 'max' => 500, 'fee' => 2500], - ['min' => 500, 'max' => null, 'fee' => 4000], + ['min' => 0, 'max' => 0.5, 'fee' => 2500], + ['min' => 0.5, 'max' => null, 'fee' => 4000], ], ], ); // 0.3kg × 2개 - [$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 0.3); - [$pB, $oB] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 0.3); + [$pA, $oA] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 300); + [$pB, $oB] = $this->createProductWithOption(price: 20000, shippingPolicy: $sp, weight: 300); $input = new CalculationInput( items: [ diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCalculationServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCalculationServiceTest.php index 062f4958..1c9c593b 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCalculationServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCalculationServiceTest.php @@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services; use App\Models\User; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory; use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory; use Modules\Sirsoft\Ecommerce\DTO\CalculationInput; @@ -744,8 +745,8 @@ class OrderCalculationServiceTest extends ModuleTestCase * * @param int $price 상품 가격 * @param ShippingPolicy $shippingPolicy 배송정책 - * @param float $weight 무게 (kg) - * @param float $volume 부피 (cm³) + * @param float $weight 무게 (g — 상품 옵션의 저장 단위) + * @param float $volume 부피 (cm³ — 상품 옵션의 저장 단위) * @return array [Product, ProductOption] */ protected function createProductWithDimensions( @@ -1018,19 +1019,19 @@ class OrderCalculationServiceTest extends ModuleTestCase */ public function test_it_calculates_range_weight_shipping_policy(): void { - // Given: RANGE_WEIGHT 정책 (g 단위로 설정) + // Given: RANGE_WEIGHT 정책 (배송정책 구간은 kg 단위) $shippingPolicy = $this->createShippingPolicy( chargePolicy: ChargePolicyEnum::RANGE_WEIGHT, ranges: [ 'tiers' => [ - ['min' => 0, 'max' => 1000, 'fee' => 3000], // ~1kg - ['min' => 1000, 'max' => 3000, 'fee' => 5000], // ~3kg - ['min' => 3000, 'max' => null, 'fee' => 8000], // 3kg~ + ['min' => 0, 'max' => 1, 'fee' => 3000], // ~1kg + ['min' => 1, 'max' => 3, 'fee' => 5000], // ~3kg + ['min' => 3, 'max' => null, 'fee' => 8000], // 3kg~ ], ] ); - // 2.5kg = 2500g → ~3000g 구간 → 5000원 - [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 2.5, 1000); + // 상품 옵션 무게는 g 저장 → 2,500g = 2.5kg → ~3kg 구간 → 5,000원 + [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 2500, 1000); $input = new CalculationInput( items: [ @@ -1052,23 +1053,23 @@ class OrderCalculationServiceTest extends ModuleTestCase /** * 테스트 17: RANGE_VOLUME 정책 - 부피 구간별 배송비 * - * 입력: RANGE_VOLUME, tiers: [~5000cm³:3천, ~10000cm³:5천, ~∞:8천], 부피 7000cm³ + * 입력: RANGE_VOLUME, tiers: [~5L:3천, ~10L:5천, ~∞:8천], 부피 7000cm³(=7L) * 기대: 배송비 5,000원 */ public function test_it_calculates_range_volume_shipping_policy(): void { - // Given: RANGE_VOLUME 정책 + // Given: RANGE_VOLUME 정책 (배송정책 구간은 L 단위) $shippingPolicy = $this->createShippingPolicy( chargePolicy: ChargePolicyEnum::RANGE_VOLUME, ranges: [ 'tiers' => [ - ['min' => 0, 'max' => 5000, 'fee' => 3000], - ['min' => 5000, 'max' => 10000, 'fee' => 5000], - ['min' => 10000, 'max' => null, 'fee' => 8000], + ['min' => 0, 'max' => 5, 'fee' => 3000], + ['min' => 5, 'max' => 10, 'fee' => 5000], + ['min' => 10, 'max' => null, 'fee' => 8000], ], ] ); - [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 1.0, 7000); + [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 1000, 7000); $input = new CalculationInput( items: [ @@ -1083,7 +1084,7 @@ class OrderCalculationServiceTest extends ModuleTestCase // When $result = $this->service->calculate($input); - // Then: 7000cm³ → ~10000cm³ 구간 → 5,000원 + // Then: 7000cm³ = 7L → ~10L 구간 → 5,000원 $this->assertEquals(5000, $result->summary->totalShipping); } @@ -1096,20 +1097,20 @@ class OrderCalculationServiceTest extends ModuleTestCase */ public function test_it_calculates_range_volume_weight_shipping_policy(): void { - // Given: RANGE_VOLUME_WEIGHT 정책 (g 단위로 설정) + // Given: RANGE_VOLUME_WEIGHT 정책 (배송정책 구간은 kg 단위) $shippingPolicy = $this->createShippingPolicy( chargePolicy: ChargePolicyEnum::RANGE_VOLUME_WEIGHT, ranges: [ 'volume_weight_divisor' => 6000, 'tiers' => [ - ['min' => 0, 'max' => 2000, 'fee' => 3000], // ~2kg - ['min' => 2000, 'max' => 5000, 'fee' => 5000], // ~5kg - ['min' => 5000, 'max' => null, 'fee' => 8000], // 5kg~ + ['min' => 0, 'max' => 2, 'fee' => 3000], // ~2kg + ['min' => 2, 'max' => 5, 'fee' => 5000], // ~5kg + ['min' => 5, 'max' => null, 'fee' => 8000], // 5kg~ ], ] ); - // 부피 18000cm³ / 6000 = 부피무게 3kg = 3000g → ~5000g 구간 → 5000원 - [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 1.0, 18000); + // 실무게 1,000g(=1kg) vs 부피무게 18000cm³ / 6000 = 3kg → 3kg 채택 → ~5kg 구간 → 5,000원 + [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 1000, 18000); $input = new CalculationInput( items: [ @@ -1142,7 +1143,8 @@ class OrderCalculationServiceTest extends ModuleTestCase baseFee: 1000, ranges: ['unit_value' => 0.5] ); - [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 2.0, 1000); + // 2,000g = 2kg + [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 2000, 1000); $input = new CalculationInput( items: [ @@ -1164,18 +1166,18 @@ class OrderCalculationServiceTest extends ModuleTestCase /** * 테스트 21: PER_VOLUME 정책 - 부피당 배송비 * - * 입력: PER_VOLUME, base_fee: 500원, unit_value: 1000cm³, 부피 3500cm³ - * 기대: ceil(3500/1000) × 500 = 4 × 500 = 2,000원 + * 입력: PER_VOLUME, base_fee: 500원, unit_value: 1L, 부피 3500cm³(=3.5L) + * 기대: ceil(3.5/1) × 500 = 4 × 500 = 2,000원 */ public function test_it_calculates_per_volume_shipping_policy(): void { - // Given: PER_VOLUME (1000cm³당 500원) + // Given: PER_VOLUME (1L당 500원 — 배송정책 단위값은 L) $shippingPolicy = $this->createShippingPolicy( chargePolicy: ChargePolicyEnum::PER_VOLUME, baseFee: 500, - ranges: ['unit_value' => 1000] + ranges: ['unit_value' => 1] ); - [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 1.0, 3500); + [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 1000, 3500); $input = new CalculationInput( items: [ @@ -1190,7 +1192,7 @@ class OrderCalculationServiceTest extends ModuleTestCase // When $result = $this->service->calculate($input); - // Then: ceil(3500/1000) × 500 = 4 × 500 = 2,000원 + // Then: ceil(3.5/1) × 500 = 4 × 500 = 2,000원 $this->assertEquals(2000, $result->summary->totalShipping); } @@ -1209,8 +1211,8 @@ class OrderCalculationServiceTest extends ModuleTestCase baseFee: 2000, ranges: ['volume_weight_divisor' => 5000, 'unit_value' => 1] ); - // 부피 12000cm³ / 5000 = 부피무게 2.4kg - [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 1.0, 12000); + // 실무게 1,000g(=1kg) vs 부피 12000cm³ / 5000 = 부피무게 2.4kg → 2.4kg 채택 + [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 1000, 12000); $input = new CalculationInput( items: [ @@ -2681,7 +2683,7 @@ class OrderCalculationServiceTest extends ModuleTestCase /** * 테스트 36-11e: RANGE_WEIGHT 정책 + 도서산간 * - * 무게 2.5kg(2500g) → 구간 매칭 → 기본 5,000원 + 추가 3,000원 + * 무게 2,500g(=2.5kg) → 구간 매칭 → 기본 5,000원 + 추가 3,000원 */ public function test_it_applies_extra_fee_with_range_weight(): void { @@ -2690,9 +2692,9 @@ class OrderCalculationServiceTest extends ModuleTestCase ranges: [ 'type' => 'weight', 'tiers' => [ - ['min' => 0, 'max' => 1000, 'fee' => 3000], - ['min' => 1001, 'max' => 3000, 'fee' => 5000], - ['min' => 3001, 'max' => null, 'fee' => 8000], + ['min' => 0, 'max' => 1, 'fee' => 3000], + ['min' => 1, 'max' => 3, 'fee' => 5000], + ['min' => 3, 'max' => null, 'fee' => 8000], ], ], extraFeeEnabled: true, @@ -2700,7 +2702,7 @@ class OrderCalculationServiceTest extends ModuleTestCase ['zipcode' => '63000-63644', 'fee' => 3000], ] ); - [$product, $option] = $this->createProductWithDimensions(10000, $policy, weight: 2.5, volume: 0); + [$product, $option] = $this->createProductWithDimensions(10000, $policy, weight: 2500, volume: 0); $shippingAddress = new ShippingAddress(zipcode: '63100'); @@ -2717,7 +2719,7 @@ class OrderCalculationServiceTest extends ModuleTestCase $result = $this->service->calculate($input); - // Then: 기본 5,000원 (1001~3000g 구간) + 추가 3,000원 = 8,000원 + // Then: 기본 5,000원 (1~3kg 구간) + 추가 3,000원 = 8,000원 $this->assertEquals(8000, $result->summary->totalShipping); $this->assertEquals(5000, $result->items[0]->appliedShippingPolicy->shippingAmount); $this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount); @@ -2726,7 +2728,7 @@ class OrderCalculationServiceTest extends ModuleTestCase /** * 테스트 36-11f: RANGE_VOLUME 정책 + 도서산간 (이번 버그 시나리오) * - * 부피 30cm³ → 구간 매칭 → 기본 5,000원 + 추가 3,000원 + * 부피 30,000cm³ × 2개 = 60L → 구간 매칭 → 기본 10,000원 + 추가 3,000원 */ public function test_it_applies_extra_fee_with_range_volume(): void { @@ -2736,8 +2738,8 @@ class OrderCalculationServiceTest extends ModuleTestCase 'type' => 'volume', 'tiers' => [ ['min' => 0, 'max' => 50, 'fee' => 5000], - ['min' => 51, 'max' => 100, 'fee' => 10000], - ['min' => 101, 'max' => null, 'fee' => 20000], + ['min' => 50, 'max' => 100, 'fee' => 10000], + ['min' => 100, 'max' => null, 'fee' => 20000], ], ], extraFeeEnabled: true, @@ -2745,7 +2747,7 @@ class OrderCalculationServiceTest extends ModuleTestCase ['zipcode' => '63000-63644', 'fee' => 3000], ] ); - [$product, $option] = $this->createProductWithDimensions(27000, $policy, weight: 0.5, volume: 30); + [$product, $option] = $this->createProductWithDimensions(27000, $policy, weight: 500, volume: 30000); $shippingAddress = new ShippingAddress(zipcode: '63100'); @@ -2762,7 +2764,7 @@ class OrderCalculationServiceTest extends ModuleTestCase $result = $this->service->calculate($input); - // Then: 부피 60cm³(30×2) → 51~100 구간 → 기본 10,000원 + 추가 3,000원 = 13,000원 + // Then: 부피 60,000cm³(30,000×2) = 60L → ~100L 구간 → 기본 10,000원 + 추가 3,000원 = 13,000원 $this->assertEquals(13000, $result->summary->totalShipping); $this->assertEquals(10000, $result->items[0]->appliedShippingPolicy->shippingAmount); $this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount); @@ -2771,9 +2773,9 @@ class OrderCalculationServiceTest extends ModuleTestCase /** * 테스트 36-11g: PER_WEIGHT 정책 + 도서산간 + multiply=true * - * 무게 1.5kg, 수량 4개, 단위 2개당 2,000원 - * 기본: ceil(4/2)×2,000=4,000원 - * 추가: multiply는 total_quantity/unit_value 기준 → ceil(4/2)×3,000=6,000원 + * 무게 1,500g(=1.5kg) × 4개 = 6kg, 단위 2kg당 2,000원 + * 기본: ceil(6/2)×2,000=6,000원 + * 추가: multiply 는 기본 배송비와 같은 배송 단위 수(3) 기준 → 3×3,000=9,000원 */ public function test_it_applies_extra_fee_with_per_weight_multiply(): void { @@ -2787,7 +2789,7 @@ class OrderCalculationServiceTest extends ModuleTestCase ], extraFeeMultiply: true ); - [$product, $option] = $this->createProductWithDimensions(10000, $policy, weight: 1.5, volume: 0); + [$product, $option] = $this->createProductWithDimensions(10000, $policy, weight: 1500, volume: 0); $shippingAddress = new ShippingAddress(zipcode: '63200'); @@ -2804,16 +2806,16 @@ class OrderCalculationServiceTest extends ModuleTestCase $result = $this->service->calculate($input); - // Then: 기본 ceil(6kg/2)×2,000=6,000원 + 추가 ceil(4/2)×3,000=6,000원 = 12,000원 - $this->assertEquals(12000, $result->summary->totalShipping); + // Then: 기본 ceil(6kg/2kg)=3단위 × 2,000 = 6,000원 + 추가 3단위 × 3,000 = 9,000원 = 15,000원 + $this->assertEquals(15000, $result->summary->totalShipping); $this->assertEquals(6000, $result->items[0]->appliedShippingPolicy->shippingAmount); - $this->assertEquals(6000, $result->items[0]->appliedShippingPolicy->extraShippingAmount); + $this->assertEquals(9000, $result->items[0]->appliedShippingPolicy->extraShippingAmount); } /** * 테스트 36-11h: PER_VOLUME 정책 + 도서산간 * - * 부피 50cm³, 단위 20cm³당 1,500원, 추가배송비 3,000원(1회) + * 부피 50,000cm³(=50L), 단위 20L당 1,500원, 추가배송비 3,000원(1회) */ public function test_it_applies_extra_fee_with_per_volume(): void { @@ -2826,7 +2828,7 @@ class OrderCalculationServiceTest extends ModuleTestCase ['zipcode' => '63000-63644', 'fee' => 3000], ] ); - [$product, $option] = $this->createProductWithDimensions(10000, $policy, weight: 0, volume: 50); + [$product, $option] = $this->createProductWithDimensions(10000, $policy, weight: 0, volume: 50000); $shippingAddress = new ShippingAddress(zipcode: '63500'); @@ -2843,7 +2845,7 @@ class OrderCalculationServiceTest extends ModuleTestCase $result = $this->service->calculate($input); - // Then: 기본 ceil(50/20)×1,500=4,500원 + 추가 3,000원(1회) = 7,500원 + // Then: 기본 ceil(50L/20L)×1,500=4,500원 + 추가 3,000원(1회) = 7,500원 $this->assertEquals(7500, $result->summary->totalShipping); $this->assertEquals(4500, $result->items[0]->appliedShippingPolicy->shippingAmount); $this->assertEquals(3000, $result->items[0]->appliedShippingPolicy->extraShippingAmount); @@ -5112,10 +5114,10 @@ class OrderCalculationServiceTest extends ModuleTestCase // Then $this->assertEquals(35000, $result->summary->subtotal); $this->assertEquals(5000, $result->summary->productCouponDiscount); - // 배송비 구간: 35000원 → 30001-50000 구간 → 3000원 - $this->assertEquals(3000, $result->summary->totalShipping); - // finalAmount = 35000 - 5000 + 3000 = 33000 - $this->assertEquals(33000, $result->summary->finalAmount); + // 배송비는 쿠폰 할인 후 금액 기준: 35,000 - 5,000 = 30,000원 → "~30,000원" 구간(상한 포함) → 5,000원 + $this->assertEquals(5000, $result->summary->totalShipping); + // finalAmount = 35000 - 5000 + 5000 = 35000 + $this->assertEquals(35000, $result->summary->finalAmount); } /** @@ -7897,4 +7899,486 @@ class OrderCalculationServiceTest extends ModuleTestCase $this->assertEquals(0, $result->summary->totalShipping); $this->assertEquals(30000, $result->summary->subtotal); } + + // ======================================== + // Section 7.15: 구간 경계 · 단위 환산 회귀 (공개 #94) + // + // 계약 ① 구간 매칭 = 상한 포함 + 사다리(ladder). min 은 표시 전용이며 매칭에 쓰지 않는다. + // 계약 ② 단위 = 상품 옵션은 g/cm³ 저장, 배송정책 입력은 kg/L, 환산은 엔진 한 곳에서. + // 계약 ③ 무음 0원 금지 = 잔여 미매칭 경로는 0원을 유지하되 경고 로그를 남긴다. + // ======================================== + + /** + * 수량 구간의 상한은 포함이다 (공개 #94 본문 재현). + * + * 관리자 화면·요약 문자열이 "1~5개: 3,000 / 6개~: 5,000" 으로 약속한 대로 + * 경계값 5 가 첫 구간에 매칭되어야 한다. 기존 반개(半開) 매칭에서는 5 가 + * 어느 구간에도 걸리지 않아 무음 0원(무료배송)이 되었다. + */ + public function test_it_matches_quantity_range_with_inclusive_upper_bound(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::RANGE_QUANTITY, + ranges: [ + 'tiers' => [ + ['min' => 1, 'max' => 5, 'fee' => 3000], + ['min' => 6, 'max' => null, 'fee' => 5000], + ], + ] + ); + [$product, $option] = $this->createProductWithShippingPolicy(10000, $shippingPolicy); + + $expectations = [1 => 3000, 4 => 3000, 5 => 3000, 6 => 5000, 100 => 5000]; + + foreach ($expectations as $quantity => $expectedFee) { + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem( + productId: $product->id, + productOptionId: $option->id, + quantity: $quantity + ), + ] + )); + + $this->assertEquals( + $expectedFee, + $result->summary->totalShipping, + "수량 {$quantity} 의 배송비가 기대와 다릅니다." + ); + } + } + + /** + * 반개(半開) 형태로 저장된 기존 데이터에서도 사다리 매칭이 정확해야 한다. + * + * 다음 구간의 min 이 현재 구간의 max 와 같은 형태(0~2 / 2~5 / 5~)에서 + * 경계값 2.0kg 은 "~2kg" 구간에 속한다 (상한 포함). + */ + public function test_it_matches_half_open_legacy_tiers_by_upper_bound(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::RANGE_WEIGHT, + ranges: [ + 'tiers' => [ + ['min' => 0, 'max' => 2, 'fee' => 3000], + ['min' => 2, 'max' => 5, 'fee' => 5000], + ['min' => 5, 'max' => null, 'fee' => 8000], + ], + ] + ); + // 2,000g = 2.0kg (경계값) + [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 2000, 0); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ] + )); + + $this->assertEquals(3000, $result->summary->totalShipping); + } + + /** + * 소수 경계(2.5kg)를 가진 연속형 구간도 정확히 매칭되어야 한다. + */ + public function test_it_matches_decimal_tier_boundary(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::RANGE_WEIGHT, + ranges: [ + 'tiers' => [ + ['min' => 0, 'max' => 2.5, 'fee' => 3000], + ['min' => 2.5, 'max' => null, 'fee' => 5000], + ], + ] + ); + // 2,500g = 2.5kg (소수 경계값) + [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 2500, 0); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ] + )); + + $this->assertEquals(3000, $result->summary->totalShipping); + } + + /** + * tiers 가 비어 있으면 0원을 유지하되 경고 로그를 남긴다 (계약 ③). + */ + public function test_it_logs_warning_when_range_tiers_are_empty(): void + { + Log::spy(); + + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::RANGE_QUANTITY, + ranges: ['tiers' => []] + ); + [$product, $option] = $this->createProductWithShippingPolicy(10000, $shippingPolicy); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ] + )); + + $this->assertEquals(0, $result->summary->totalShipping); + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains((string) $message, '구간별 배송비')) + ->atLeast()->once(); + } + + /** + * 배송정책이 삭제된 경우 0원을 유지하되 경고 로그를 남긴다 (계약 ③). + */ + public function test_it_logs_warning_when_shipping_policy_is_missing(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::FIXED, + baseFee: 3000 + ); + [$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy); + + $shippingPolicy->countrySettings()->delete(); + $shippingPolicy->delete(); + + Log::spy(); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ] + )); + + $this->assertEquals(0, $result->summary->totalShipping); + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains((string) $message, '배송정책')) + ->atLeast()->once(); + } + + /** + * 수신자 국가의 설정이 없으면 0원을 유지하되 경고 로그를 남긴다 (계약 ③). + */ + public function test_it_logs_warning_when_country_setting_is_missing(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::FIXED, + baseFee: 3000, + countryCode: 'KR' + ); + [$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy); + + Log::spy(); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ], + shippingAddress: new ShippingAddress(countryCode: 'US') + )); + + $this->assertEquals(0, $result->summary->totalShipping); + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains((string) $message, '국가별 배송 설정')) + ->atLeast()->once(); + } + + /** + * 무게 구간 정책은 상품의 g 값을 kg 으로 환산해 비교한다 (계약 ②). + * + * 500g 상품 → 0.5kg → "~2kg" 구간. 기존 코드는 ×1000 이중 변환으로 + * 500,000 이 되어 최상위 구간에 오적중했다. + */ + public function test_it_converts_grams_to_kilograms_for_range_weight(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::RANGE_WEIGHT, + ranges: [ + 'tiers' => [ + ['min' => 0, 'max' => 2, 'fee' => 3000], + ['min' => 2, 'max' => null, 'fee' => 7000], + ], + ] + ); + [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 500, 0); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ] + )); + + $this->assertEquals(3000, $result->summary->totalShipping); + } + + /** + * 부피 구간 정책은 상품의 cm³ 값을 L 로 환산해 비교한다 (계약 ②). + * + * 1,000cm³ = 1L → "~50L" 구간. + */ + public function test_it_converts_cubic_centimeters_to_liters_for_range_volume(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::RANGE_VOLUME, + ranges: [ + 'tiers' => [ + ['min' => 0, 'max' => 50, 'fee' => 5000], + ['min' => 50, 'max' => null, 'fee' => 9000], + ], + ] + ); + [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 0, 1000); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ] + )); + + $this->assertEquals(5000, $result->summary->totalShipping); + } + + /** + * 무게당 정책은 kg 단위로 계산한다 (계약 ②). + * + * 500g = 0.5kg, 1kg당 1,000원 → ceil(0.5/1) × 1,000 = 1,000원. + * 기존 코드는 500 ÷ 1 = 500단위 → 500,000원이었다 (공개 #94 재현값). + */ + public function test_it_charges_per_kilogram_for_per_weight_policy(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::PER_WEIGHT, + baseFee: 1000, + ranges: ['unit_value' => 1] + ); + [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 500, 0); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ] + )); + + $this->assertEquals(1000, $result->summary->totalShipping); + } + + /** + * 부피당 정책은 L 단위로 계산한다 (계약 ②). + * + * 1,000cm³ = 1L, 10L당 2,000원 → ceil(1/10) × 2,000 = 2,000원. + */ + public function test_it_charges_per_liter_for_per_volume_policy(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::PER_VOLUME, + baseFee: 2000, + ranges: ['unit_value' => 10] + ); + [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 0, 1000); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ] + )); + + $this->assertEquals(2000, $result->summary->totalShipping); + } + + /** + * 부피무게 구간은 실무게(kg)와 부피무게(kg)를 같은 단위로 비교한다 (계약 ②). + * + * 500g(0.5kg) + 6,000cm³ ÷ 6,000 = 1kg → 부피무게 1kg 채택 → "~5kg" 구간. + */ + public function test_it_compares_actual_and_volumetric_weight_in_kilograms(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::RANGE_VOLUME_WEIGHT, + ranges: [ + 'volume_weight_divisor' => 6000, + 'tiers' => [ + ['min' => 0, 'max' => 5, 'fee' => 3500], + ['min' => 5, 'max' => null, 'fee' => 9000], + ], + ] + ); + [$product, $option] = $this->createProductWithDimensions(20000, $shippingPolicy, 500, 6000); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ] + )); + + $this->assertEquals(3500, $result->summary->totalShipping); + } + + /** + * 소수 기본배송비는 절삭이 아니라 반올림한다 (검토 메모 12). + * + * base_fee 999.50 × 1단위 → 1,000원 (기존 (int) 절삭은 999원). + */ + public function test_it_rounds_decimal_base_fee_instead_of_truncating(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::PER_QUANTITY, + baseFee: 1000, + ranges: ['unit_value' => 1] + ); + $shippingPolicy->countrySettings()->first()->update(['base_fee' => 999.5]); + [$product, $option] = $this->createProductWithShippingPolicy(10000, $shippingPolicy); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ] + )); + + $this->assertEquals(1000, $result->summary->totalShipping); + } + + /** + * 도서산간 중복 부과는 수량이 아니라 배송 단위 수를 기준으로 한다 (검토 메모 14). + * + * PER_WEIGHT 1kg당 2,000원, 500g 상품 3개 → 1.5kg → 단위 수 2 (수량 3이 아님). + * 기본 2 × 2,000 = 4,000원, 도서산간 2 × 3,000 = 6,000원. + */ + public function test_it_multiplies_extra_fee_by_charge_units_not_quantity(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::PER_WEIGHT, + baseFee: 2000, + ranges: ['unit_value' => 1], + extraFeeEnabled: true, + extraFeeSettings: [ + ['zipcode' => '63*', 'fee' => 3000], + ], + extraFeeMultiply: true + ); + [$product, $option] = $this->createProductWithDimensions(10000, $shippingPolicy, 500, 0); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 3), + ], + shippingAddress: new ShippingAddress(zipcode: '63123') + )); + + $this->assertEquals(4000, $result->items[0]->appliedShippingPolicy->shippingAmount); + $this->assertEquals(6000, $result->items[0]->appliedShippingPolicy->extraShippingAmount); + $this->assertEquals(10000, $result->summary->totalShipping); + } + + /** + * 저장된 단위값이 0 이어도 0 으로 나누지 않고 1 단위로 계산한다. + * + * 신규 저장은 FormRequest 의 min:0.01 이 막지만, 그 규칙이 생기기 전에 저장된 정책과 + * 시더·업그레이드처럼 FormRequest 를 거치지 않는 경로는 0 을 그대로 들고 있을 수 있다. + * 이 값이 나눗셈에 닿으면 계산 전체가 DivisionByZeroError 로 죽는다. + * + * PER_WEIGHT base 2,000원 · unit_value 0 → 1 로 클램프 → 1.5kg → ceil(1.5/1)=2 → 4,000원. + */ + public function test_it_clamps_zero_unit_value_instead_of_dividing_by_zero(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::PER_WEIGHT, + baseFee: 2000, + ranges: ['unit_value' => 0] + ); + [$product, $option] = $this->createProductWithDimensions(10000, $shippingPolicy, 1500, 0); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ] + )); + + $this->assertEquals(4000, $result->summary->totalShipping); + } + + /** + * 저장된 단위값이 음수여도 1 단위로 클램프한다. + * + * 음수는 나눗셈이 성립해 예외가 나지 않는 대신 단위 수가 음수가 되어, 배송비가 음수이거나 + * 0 원으로 뒤집힌다 — 예외 없이 금액만 틀리는 무음 결함이라 0 과 함께 고정한다. + */ + public function test_it_clamps_negative_unit_value_instead_of_inverting_the_fee(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::PER_WEIGHT, + baseFee: 2000, + ranges: ['unit_value' => -1] + ); + [$product, $option] = $this->createProductWithDimensions(10000, $shippingPolicy, 1500, 0); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ] + )); + + $this->assertEquals(4000, $result->summary->totalShipping); + } + + /** + * 도서산간 곱의 단위 수 계산도 같은 클램프를 적용한다. + * + * 단위 수는 기본 배송비와 도서산간 곱 두 곳에서 각각 나눗셈을 수행하므로, 한쪽만 막으면 + * 도서산간을 켠 정책에서만 계산이 죽는다. + */ + public function test_it_clamps_zero_unit_value_in_extra_fee_charge_units(): void + { + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::PER_WEIGHT, + baseFee: 2000, + ranges: ['unit_value' => 0], + extraFeeEnabled: true, + extraFeeSettings: [ + ['zipcode' => '63*', 'fee' => 3000], + ], + extraFeeMultiply: true + ); + [$product, $option] = $this->createProductWithDimensions(10000, $shippingPolicy, 1500, 0); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ], + shippingAddress: new ShippingAddress(zipcode: '63123') + )); + + $this->assertEquals(4000, $result->items[0]->appliedShippingPolicy->shippingAmount); + $this->assertEquals(6000, $result->items[0]->appliedShippingPolicy->extraShippingAmount); + } + + /** + * 외부 API 가 음수 배송비를 반환하면 기본 배송비로 폴백하고 경고를 남긴다 (검토 메모 11). + */ + public function test_it_falls_back_to_base_fee_when_api_returns_negative_fee(): void + { + Http::fake([ + 'https://shipping-api.example.com/calculate' => Http::response(['shipping_fee' => -500], 200), + ]); + Log::spy(); + + $shippingPolicy = $this->createShippingPolicy( + chargePolicy: ChargePolicyEnum::API, + baseFee: 3000, + apiEndpoint: 'https://shipping-api.example.com/calculate' + ); + [$product, $option] = $this->createProductWithShippingPolicy(30000, $shippingPolicy); + + $result = $this->service->calculate(new CalculationInput( + items: [ + new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1), + ] + )); + + $this->assertEquals(3000, $result->summary->totalShipping); + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains((string) $message, '음수')) + ->atLeast()->once(); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php index c2d337b8..fb392257 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php @@ -644,6 +644,79 @@ class OrderProcessingServiceTest extends ModuleTestCase $this->assertStringContainsString('"items":[', json_encode($snapshot)); } + /** + * 주문의 총 무게/부피는 주문 옵션 소계의 합으로 기록된다 (공개 #94 / N7). + * + * 기존에는 0 으로 고정 기록되어, 배송사 연동·운임 정산이 이 값을 읽으면 + * 무게 없는 주문으로 취급됐다. 값의 단위는 상품 옵션과 같은 g / cm³ 다. + */ + public function test_create_from_temp_order_records_total_weight_and_volume(): void + { + $user = User::factory()->create(); + + $product = Product::factory()->create(); + $productOption = ProductOption::factory()->create([ + 'product_id' => $product->id, + 'weight' => 500, + 'volume' => 1200, + ]); + + $tempOrder = TempOrderFactory::new() + ->forUser($user) + ->withItems([ + [ + 'cart_id' => 1, + 'product_id' => $product->id, + 'product_option_id' => $productOption->id, + 'quantity' => 3, + ], + ]) + ->withCalculationResult([ + 'summary' => ['final_amount' => 103000], + 'items' => [], + 'promotions' => [ + 'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []], + 'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []], + ], + 'validation_errors' => [], + ]) + ->create(); + + $item = new ItemCalculation( + productId: $product->id, + productOptionId: $productOption->id, + quantity: 3, + unitPrice: 100000, + subtotal: 300000, + finalAmount: 300000, + ); + + $this->mockCalculationService($this->makeCalculationResult(103000, [ + 'items' => [$item], + ])); + + $order = $this->service->createFromTempOrder( + $tempOrder, + ['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'], + ['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'], + 'card', + 103000 + ); + + $order->refresh(); + + // 단위 무게는 상품 옵션의 g 값을 그대로 복사한다 (kg 환산은 배송비 계산 시점에만) + $orderOption = $order->options()->first(); + $this->assertEquals(500.0, (float) $orderOption->unit_weight); + $this->assertEquals(1200.0, (float) $orderOption->unit_volume); + $this->assertEquals(1500.0, (float) $orderOption->subtotal_weight); + $this->assertEquals(3600.0, (float) $orderOption->subtotal_volume); + + // 주문 합계는 옵션 소계의 합 + $this->assertEquals(1500.0, (float) $order->total_weight); + $this->assertEquals(3600.0, (float) $order->total_volume); + } + public function test_create_from_temp_order_saves_order_meta_with_calculation_input(): void { $this->allowAnyMileageUsage(); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/issue94-shipping-tier-boundary-units.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/issue94-shipping-tier-boundary-units.yaml new file mode 100644 index 00000000..6eaced31 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/issue94-shipping-tier-boundary-units.yaml @@ -0,0 +1,84 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 nested axes 를 읽지 못하는 +# 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도 audit:allow). 본 변경은 +# OrderCalculationServiceTest 가 charge_policy × boundary × unit_source 전 조합을, +# ShippingPolicyValidationMessageTest 가 저장 검증 매트릭스를 커버하며 green 이고, +# Playwright spec 이 관리자 저장 → 상점 장바구니 계산 경로를 종단으로 고정한다. +feature: 배송비 구간 경계 포함 + 무게/부피 단위 환산 (공개 #94) + +description: | + 구간별 배송비의 종료값은 포함(inclusive)이며, 매칭은 종료값 오름차순 사다리로 수행한다 + (시작값은 표시 전용). 상품 옵션은 g/cm³ 로 저장되고 배송정책은 kg/L 로 설정되므로, + 환산은 배송비 계산 지점 한 곳에서만 수행한다. 설정 오류로 계산이 조용히 0원이 되는 + 경우는 저장 시점에 422 로 차단하고, 런타임 잔여 경로는 경고 로그를 남긴다. + +axes: + charge_policy: + - range_quantity + - range_amount + - range_weight + - range_volume + - range_volume_weight + - per_weight + - per_volume + - per_volume_weight + - api + - conditional_free + boundary: + - below # 구간 상한 미만 + - at_max # 구간 상한 정확히 (공개 #94 재현 지점) + - above # 구간 상한 초과 + - decimal # 소수 경계 (2.5kg) + - empty_tiers # 구간 없음 + unit_source: + - g # 상품 옵션 무게 저장 단위 + - cm3 # 상품 옵션 부피 저장 단위 + - quantity # 수량 (환산 없음) + - amount # 금액 (환산 없음) + tier_shape: + - inclusive # 다음 min = max + 1 (관리자 화면 저장 형태) + - half_open # 다음 min = max (기존 저장 데이터 형태) + config_validity: + - valid + - missing_tiers + - missing_unit_value + - missing_free_threshold + - middle_max_unlimited + - legacy_max_plus_one_on_continuous + - fractional_quantity_boundary + - zero_base_fee_on_api + - invalid_zipcode_format + - zero_unit_value # 저장 규칙 신설 이전 데이터 / 시더 경로 + - negative_unit_value + +effects: + - fee_matches_admin_promise # 요약 문자열이 약속한 금액과 계산값 일치 + - boundary_value_charged_not_free # 상한 경계값이 무료배송으로 새지 않음 + - unit_converted_kg_l # g→kg, cm³→L 환산이 1회만 수행됨 + - volumetric_weight_compared_in_kg # 실무게와 부피무게를 같은 단위로 비교 + - decimal_base_fee_rounded # 소수 기본배송비 절삭 대신 반올림 + - extra_fee_multiplied_by_charge_units # 도서산간 곱이 수량이 아닌 배송 단위 수 기준 + - api_negative_fee_falls_back # 외부 API 음수 배송비 → 기본 배송비 폴백 + - invalid_config_rejected_422 # 무음 0원을 만드는 설정은 저장 차단 + - silent_zero_logged # 잔여 0원 경로는 경고 로그 + - tier_min_derived_from_previous_max # 화면에서 시작값 자동 파생 (읽기전용) + - range_unit_label_displayed # 구간 입력에 정책별 단위(개/kg/L) 표기 + - continuous_tier_min_normalized # 업그레이드 스텝의 표시 정합 보정 + - order_totals_backfilled # 주문 총 무게/부피 백필 + - unit_value_clamped_not_divided_by_zero # 0/음수 단위값이 계산을 죽이거나 금액을 뒤집지 않음 + - upgrade_is_idempotent + - product_option_dimensions_round_trip # 옵션 무게/부피 응답 왕복 + +test_files: + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCalculationServiceTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Enums/ShippingPolicyEnumsTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Resources/ProductOptionResourceDimensionsTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Database/Seeders/ShippingPolicySeederTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/ShippingPolicyValidationMessageTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ShippingPolicyControllerTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ShippingTierAndOrderTotalsBackfillTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php + - modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/shippingPolicyFormHandlers.test.ts + - modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingPolicyFormLayouts.test.tsx + - modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/shipping-policy-tier-boundary.spec.ts diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-shipping-policy-validation.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-shipping-policy-validation.yaml index 5861c39e..53382875 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-shipping-policy-validation.yaml +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp11-shipping-policy-validation.yaml @@ -2,9 +2,10 @@ # cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 nested axes 를 읽지 못하는 # 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도 audit:allow). 본 변경은 # StoreShippingPolicyRequest 의 attributes()/messages() 보강(A12) + api_endpoint 필수화(W1) -# + i18n 키 보강(W2) + api_request_fields Rule::in 후보 검증(W3)을 PHPUnit Feature 가 -# 대상필드 × 입력 × 로케일 × Store/Update cross product 로 커버하며 green. -feature: 배송정책 구간/계산 API 검증 메시지 친화화 (A12 + A13) +# + i18n 키 보강(W2) + api_request_fields Rule::in 후보 검증(W3) + 구간 필수/연속성 규칙 +# 신설(공개 #94)을 PHPUnit Feature 가 대상필드 × 입력 × 로케일 × Store/Update cross product +# 로 커버하며 green. +feature: 배송정책 구간/계산 API 검증 메시지 친화화 (A12 + A13) + 구간 설정 필수 규칙 (#94) description: | 구간별 배송비(tier fee/min/max/unit_value)·추가배송비 음수/비숫자/required 누락 시 사용자 @@ -12,13 +13,22 @@ description: | 계산 API 정책 선택 시 api_endpoint 필수화(cross-field), 잘못된 URL 시 i18n 키 보강, api_request_fields 는 후보 5종 SSoT enum 의 Rule::in 으로 검증(후보 외 거부). + 공개 #94 로 저장 시점 차단 규칙이 확장된다 — 계산이 조용히 0원이 되는 설정(구간 없음 / + 단위값 없음 / 무료 기준금액 없음 / 중간 구간 상한 무제한 / 계산 API 폴백 배송비 0)은 422 로 + 거부하고, 연속성 규칙은 정책 타입별로 갈린다(이산형 수량 = 다음 시작값이 직전 종료값 + 1, + 연속형 금액·무게·부피 = 다음 시작값이 직전 종료값과 동일, 소수 경계 허용). 추가배송비 + 우편번호는 지원 3형식만 통과한다. + axes: target_field: - tier_fee - tier_min - tier_max + - tiers # 구간 배열 자체 (RANGE_* 필수) - unit_value + - free_threshold # conditional_free 필수 - extra_fee + - extra_fee_zipcode - base_fee - api_endpoint - api_request_fields @@ -30,6 +40,16 @@ axes: - api_empty_endpoint # api 정책 + 빈 endpoint → W1 422 - field_in_candidates # api_request_fields 후보 내 → 통과 - field_out_candidates # api_request_fields 후보 외 → Rule::in 422 + - empty_tiers # RANGE_* 인데 구간 0개 → 422 (#94) + - middle_max_unlimited # 중간 구간 상한 무제한 → 422 (#94) + - continuous_next_min_equals_prev_max # 연속형 정상 → 통과 (#94) + - continuous_legacy_max_plus_one # 연속형 구 규칙 → 422 간극 (#94) + - continuous_decimal_boundary # 연속형 소수 경계 → 통과 (#94) + - discrete_next_min_equals_prev_max_plus_one # 이산형 정상 → 통과 (#94) + - discrete_fractional_boundary # 이산형 소수 경계 → 422 (#94) + - zero_base_fee_on_api # 계산 API 폴백 0원 → 422 (#94) + - zipcode_supported_format # 3형식 → 통과 (#94) + - zipcode_unsupported_format # 그 외 → 422 (#94) locale: - ko - en @@ -46,6 +66,14 @@ effects: - request_field_out_of_candidates_rejected # W3 Rule::in - request_field_in_candidates_accepted # W3 통과 - update_inherits_messages_attributes # Update 상속 + - range_policy_requires_tiers # #94 구간 없는 구간별 정책 차단 + - per_unit_policy_requires_unit_value # #94 단위값 없는 단위당 정책 차단 + - conditional_free_requires_threshold # #94 무료 기준금액 없는 조건부 무료 차단 + - middle_tier_unlimited_max_rejected # #94 중간 구간 상한 무제한 차단 + - continuity_rule_matches_policy_type # #94 이산형 max+1 / 연속형 max + - decimal_tier_boundary_accepted # #94 소수 구간(2.5kg) 저장 허용 + - api_policy_requires_positive_base_fee # #94 폴백 0원 차단 + - zipcode_format_validated # #94 지원 3형식 외 거부 test_files: - modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/ShippingPolicyValidationMessageTest.php diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_1_1.php b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_1_1.php new file mode 100644 index 00000000..04874c40 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_1_1.php @@ -0,0 +1,21 @@ + + */ + private const DISCRETE_POLICIES = ['range_quantity']; + + /** + * 연속형(다음 시작값 = 직전 종료값) 부과정책. + * + * @var array + */ + private const CONTINUOUS_POLICIES = [ + 'range_amount', + 'range_weight', + 'range_volume', + 'range_volume_weight', + ]; + + public function name(): string + { + return 'NormalizeRangeTierMin'; + } + + public function run(UpgradeContext $context): void + { + if (! Schema::hasTable(self::TABLE) || ! Schema::hasColumn(self::TABLE, 'ranges')) { + $context->logger->warning('[ecommerce:1.1.1] '.self::TABLE.'.ranges 미존재 — 스킵'); + + return; + } + + $normalized = 0; + + DB::table(self::TABLE) + ->whereIn('charge_policy', array_merge(self::DISCRETE_POLICIES, self::CONTINUOUS_POLICIES)) + ->whereNotNull('ranges') + ->orderBy('id') + ->chunkById(100, function ($rows) use (&$normalized, $context) { + foreach ($rows as $row) { + $ranges = json_decode((string) $row->ranges, true); + + if (! is_array($ranges) || ! isset($ranges['tiers']) || ! is_array($ranges['tiers'])) { + continue; + } + + $isDiscrete = in_array($row->charge_policy, self::DISCRETE_POLICIES, true); + $tiers = array_values($ranges['tiers']); + $changed = false; + + foreach ($tiers as $index => $tier) { + if (! is_array($tier)) { + continue; + } + + if ($index === 0) { + $expectedMin = 0; + } else { + $previousMax = $this->resolveMax($tiers[$index - 1] ?? []); + + if ($previousMax === null) { + // 직전 구간의 종료값이 없으면 파생 근거가 없다 — 손대지 않는다 + continue; + } + + $expectedMin = $previousMax + ($isDiscrete ? 1 : 0); + } + + if ((float) ($tier['min'] ?? 0) !== (float) $expectedMin) { + $tiers[$index]['min'] = $expectedMin; + $changed = true; + } + } + + if (! $changed) { + continue; + } + + $ranges['tiers'] = $tiers; + + DB::table(self::TABLE) + ->where('id', $row->id) + ->update(['ranges' => json_encode($ranges, JSON_UNESCAPED_UNICODE)]); + + $normalized++; + $context->logger->info("[ecommerce:1.1.1] 국가별 설정 #{$row->id} 구간 시작값 정규화"); + } + }); + + $context->logger->info("[ecommerce:1.1.1] 구간 시작값 정규화 완료: {$normalized} 건"); + } + + /** + * 구간의 종료값을 반환합니다 (무제한이면 null). + * + * @param array $tier 구간 정의 + * @return float|null 종료값 + */ + private function resolveMax(array $tier): ?float + { + $max = $tier['max'] ?? null; + + if ($max === null || $max === '') { + return null; + } + + return (float) $max; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/02_BackfillOrderTotalWeightVolume.php b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/02_BackfillOrderTotalWeightVolume.php new file mode 100644 index 00000000..a84566e5 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/02_BackfillOrderTotalWeightVolume.php @@ -0,0 +1,88 @@ +logger->warning('[ecommerce:1.1.1] 주문/주문옵션 테이블 미존재 — 스킵'); + + return; + } + + foreach (['total_weight', 'total_volume'] as $column) { + if (! Schema::hasColumn(self::ORDERS_TABLE, $column)) { + $context->logger->warning('[ecommerce:1.1.1] '.self::ORDERS_TABLE.".{$column} 미존재 — 스킵"); + + return; + } + } + + $backfilled = 0; + + DB::table(self::ORDERS_TABLE) + ->where(function ($query) { + $query->where('total_weight', 0)->orWhereNull('total_weight'); + }) + ->where(function ($query) { + $query->where('total_volume', 0)->orWhereNull('total_volume'); + }) + ->orderBy('id') + ->select('id') + ->chunkById(200, function ($orders) use (&$backfilled) { + foreach ($orders as $order) { + $totals = DB::table(self::OPTIONS_TABLE) + ->where('order_id', $order->id) + ->selectRaw('COALESCE(SUM(subtotal_weight), 0) as weight_sum, COALESCE(SUM(subtotal_volume), 0) as volume_sum') + ->first(); + + $weight = (float) ($totals->weight_sum ?? 0); + $volume = (float) ($totals->volume_sum ?? 0); + + if ($weight <= 0 && $volume <= 0) { + continue; + } + + DB::table(self::ORDERS_TABLE) + ->where('id', $order->id) + ->update([ + 'total_weight' => $weight, + 'total_volume' => $volume, + ]); + + $backfilled++; + } + }); + + $context->logger->info("[ecommerce:1.1.1] 주문 총 무게/부피 백필 완료: {$backfilled} 건"); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/03_BackfillOrderForeignKeyColumnComments.php b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/03_BackfillOrderForeignKeyColumnComments.php new file mode 100644 index 00000000..01a149f1 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/data/1.1.1/migrations/03_BackfillOrderForeignKeyColumnComments.php @@ -0,0 +1,178 @@ + [컬럼 => 설명]. + */ + private const TARGETS = [ + 'ecommerce_orders' => [ + 'user_id' => '주문자 회원 ID (비회원 주문은 null)', + ], + 'ecommerce_order_options' => [ + 'order_id' => '소속 주문 ID', + 'product_id' => '주문 시점 상품 ID', + 'product_option_id' => '주문 시점 상품 옵션 ID', + ], + ]; + + /** + * 마이그레이션 식별자 (로그용). + * + * @return string 식별자 + */ + public function name(): string + { + return 'BackfillOrderForeignKeyColumnComments'; + } + + /** + * 비어 있는 외래키 컬럼 설명을 채웁니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) { + $context->logger->info('[ecommerce:1.1.1] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다'); + + return; + } + + $filled = 0; + $skipped = 0; + $failed = 0; + + foreach (self::TARGETS as $table => $columns) { + $prefixed = $context->table($table); + + foreach ($columns as $column => $comment) { + try { + $meta = $this->columnMeta($prefixed, $column); + + if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') { + $skipped++; + + continue; + } + + DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment)); + $filled++; + } catch (\Throwable $e) { + $failed++; + $context->logger->warning(sprintf( + '[ecommerce:1.1.1] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s', + $prefixed, + $column, + $e->getMessage(), + )); + } + } + } + + $context->logger->info(sprintf( + '[ecommerce:1.1.1] 주문 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건', + $filled, + $skipped, + $failed, + )); + } + + /** + * 컬럼의 현재 스키마 메타데이터를 조회합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null) + */ + private function columnMeta(string $table, string $column): ?object + { + $rows = DB::select( + 'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + [$table, $column], + ); + + return $rows[0] ?? null; + } + + /** + * 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @param object $meta columnMeta() 결과 + * @param string $comment 넣을 설명 + * @return string 실행할 SQL + */ + private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string + { + $sql = sprintf( + 'ALTER TABLE %s MODIFY COLUMN %s %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($column), + $meta->COLUMN_TYPE, + ); + + $sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL'; + + if ($meta->COLUMN_DEFAULT !== null) { + $sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT); + } + + if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) { + $sql .= ' AUTO_INCREMENT'; + } + + return $sql.' COMMENT '.$this->quoteValue($comment); + } + + /** + * 식별자를 백틱으로 감쌉니다. + * + * @param string $identifier 테이블/컬럼명 + * @return string 이스케이프된 식별자 + */ + private function quoteIdentifier(string $identifier): string + { + return '`'.str_replace('`', '``', $identifier).'`'; + } + + /** + * 문자열 값을 SQL 리터럴로 인용합니다. + * + * @param string $value 값 + * @return string 인용된 리터럴 + */ + private function quoteValue(string $value): string + { + return DB::connection()->getPdo()->quote($value); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json index a0e34d8d..96fd99b2 100644 --- a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json +++ b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json @@ -1,11 +1,11 @@ { "schema_version": "1.0", - "generated_at": "2026-08-11T07:27:43+00:00", + "generated_at": "2026-08-12T14:41:52+00:00", "generator": "g7 vendor-bundle:build", "target": "module:sirsoft-ecommerce", "composer_json_sha256": "c1a7e6cb8fcf68b62af5b7817509e2a2df6cb19348b9951ec8ea30634f3030d3", "composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d", - "zip_sha256": "9c9bbaa646fdc7500234699362177f825bd421a2981afac5168a4fee554f5d5e", + "zip_sha256": "afda2275ddb2a403be87062539f2ecc4b2d9136ffa5f85533128cc3eaa2724d9", "zip_size": 435548, "package_count": 1, "php_requirement": "^8.2", diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip index a031ae43..b280f36c 100644 Binary files a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip and b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip differ