Merge pull request from gnuboard:HeuJung/issue551

fix(ecommerce): 배송비 구간 경계 포함 + 무게/부피 단위 환산
This commit is contained in:
정정홍
2026-08-12 23:49:06 +09:00
committed by GitHub
60 changed files with 4210 additions and 420 deletions
@@ -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
@@ -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": "通報処理通知",
@@ -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": "通報処理通知",
@@ -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",
@@ -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
@@ -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' => '送料',
@@ -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' => [
@@ -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": "追加配送料",
@@ -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",
@@ -10,6 +10,10 @@
- 글 저장·수정 시 비밀글 여부 값을 문자열(`"true"`/`"false"`)로 보내는 클라이언트도 수용하도록 해석을 관대화했습니다. 해석할 수 없는 값은 종전과 동일하게 거부됩니다.
### Fixed
- 신고 반려 누적 제한 안내 문구를 실제 동작에 맞게 정정했습니다. "설정 건수를 초과하면 차단"으로 적혀 있었지만 실제로는 설정 건수에 도달하는 순간부터 차단됩니다 — 5건으로 설정하면 5번째 반려부터 신고가 막힙니다.
## [1.0.3] - 2026-08-10
### Security
@@ -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",
@@ -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",
@@ -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": "신고 처리 알림",
@@ -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": "신고 처리 알림",
@@ -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
@@ -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 '주문 정보'");
}
}
@@ -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 '주문 옵션 정보'");
}
}
@@ -0,0 +1,83 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* 주문 무게/부피 컬럼의 단위 주석을 실제 저장 단위(g / cm³)로 정정합니다 (공개 #94).
* 상품 옵션은 g / cm³ 로 저장되고 주문 생성 시 그 값이 그대로 복사되는데,
* 주문 테이블 주석만 kg 로 적혀 있어 배송비 계산·외부 연동에서 단위를 잘못 읽는
* 근거가 되었습니다. 저장된 값은 정확하므로 값 변환 없이 주석만 정정합니다.
*/
public function up(): void
{
$this->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();
});
}
}
};
@@ -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));
}
}
@@ -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,
],
File diff suppressed because one or more lines are too long
@@ -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` 은 옵션 소계의 합입니다.
---
## 목록 응답의 총 건수와 페이지 이동
상품 목록(관리자·공개 양쪽)은 총 건수를 상한까지만 셉니다. 상한을 넘으면 응답의
@@ -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
<!-- @generated:start:api.modules.sirsoft-ecommerce.admin.shipping-policies.index -->
@@ -728,7 +813,18 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "배송정책이 삭제되었습니다.",
"data": null
}
```
**에러 응답**
@@ -767,11 +863,55 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
| 필드 | 타입 | 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| 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}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
| 필드 | 타입 | 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| 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}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
| 필드 | 타입 | 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| 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}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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
}
}
}
```
**에러 응답**
@@ -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]);
});
});
});
@@ -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<string>();
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<any>((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([]);
});
});
@@ -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');
@@ -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<CountrySetting, 'country_code'> = {
@@ -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<string, RangeTierError[]> = { ...(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<string, any> = { ...(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);
}
@@ -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",
@@ -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": "추가배송비",
@@ -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}}"
},
@@ -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 ?? ''}}"
}
]
}
@@ -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;
}
/**
* 무료 기준금액 필드가 필요한 정책인지 확인합니다.
*
@@ -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원을 금지합니다.
*
@@ -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,
@@ -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
@@ -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;
}
/**
@@ -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;
}
@@ -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',
@@ -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.',
@@ -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' => '배송비',
@@ -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개 이상 추가해주세요.',
@@ -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)));
}
}
@@ -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]
@@ -0,0 +1,210 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Upgrade;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
use Modules\Sirsoft\Ecommerce\Enums\ChargePolicyEnum;
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use Modules\Sirsoft\Ecommerce\Upgrades\Upgrade_1_1_1;
/**
* 1.1.1 업그레이드 스텝 테스트 (공개 #94)
*
* - 연속형 구간 정책의 시작값을 직전 종료값으로 정규화 (표시 정합)
* - 총 무게/부피가 0 으로 고정 기록된 주문을 옵션 소계 합으로 백필
*
* 두 스텝 모두 멱등이어야 합니다 — 업그레이드는 재실행될 수 있고, 두 번째 실행이
* 값을 다시 움직이면 운영자가 보정한 값이 조용히 덮어써집니다.
*
* @scenario charge_policy=range_weight, boundary=at_max, unit_source=g
*
* @effects continuous_tier_min_normalized, order_totals_backfilled, upgrade_is_idempotent
*/
class ShippingTierAndOrderTotalsBackfillTest extends ModuleTestCase
{
/**
* 1.1.1 업그레이드 스텝을 실행합니다.
*/
private function runUpgrade(): void
{
(new Upgrade_1_1_1)->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']);
}
}
@@ -45,6 +45,8 @@ type EcommerceAuthFixtures = {
shippingPolicyToken: string;
/** 프로모션 쿠폰 조회+등록+수정+삭제 권한 보유 토큰 (쿠폰 폼 검증용) */
couponManageToken: string;
/** 배송정책 + 상품 + 카테고리 권한 토큰 (구간 경계 E2E 픽스처 구성용) */
shippingBoundaryToken: string;
/** 일반 쇼핑 사용자 토큰 (관리자 권한 없음 — 유저 화면 검증용) */
userToken: string;
/** 구매 고객 토큰 (장바구니/추가옵션 등 쇼핑 플로우 검증용) */
@@ -115,6 +117,22 @@ export const test = base.extend<EcommerceAuthFixtures>({
),
);
},
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 은 현재 둘 다 "관리자 권한 없는 인증 사용자" 로 동일하다.
// 유저 화면(마이페이지/헤더)과 구매 플로우(장바구니/주문서)는 요구 권한이 갈릴 수 있어
// 호출부의 의도를 이름으로 남긴다 — 한쪽에 권한이 붙어도 다른 쪽이 영향받지 않는다.
@@ -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<ApiResult> {
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<string, any>) {
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}`);
}
}
});
});
@@ -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<string, array<int, string>> 필드별 에러 메시지 (통과 시 빈 배열)
*/
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 기반)
// ========================================
@@ -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
@@ -0,0 +1,67 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Http\Resources;
use Illuminate\Http\Request;
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductOptionResource;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 상품 옵션 응답의 무게/부피 직렬화 테스트 (공개 #94 / N8)
*
* 저장된 무게·부피가 응답에 실리지 않으면 관리자 수정 화면이 0 을 표시하고,
* 그 상태로 저장하면 실측값이 덮어써진다. 배송비 계산이 이 값을 읽으므로
* 왕복(read → edit → save)에서 값이 살아남는지가 이 필드의 계약이다.
*
* @scenario charge_policy=range_weight, unit_source=g
*
* @effects product_option_dimensions_round_trip
*/
class ProductOptionResourceDimensionsTest extends ModuleTestCase
{
/**
* 옵션을 리소스 배열로 변환합니다.
*
* @param array $attributes 옵션 속성 오버라이드
* @return array 리소스 배열
*/
private function toResourceArray(array $attributes): array
{
$product = Product::factory()->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']);
}
}
@@ -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: [
@@ -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();
}
}
@@ -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();
@@ -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
@@ -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
@@ -0,0 +1,21 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* Ecommerce 모듈 1.1.1 업그레이드 스텝
*
* 공개 #94 (배송비 구간 경계·단위) 후속 데이터 정리.
*
* 1. 연속형 구간(금액/무게/부피/부피무게)의 시작값을 직전 구간 종료값으로 정규화한다.
* 구(舊) 규칙(다음 min = max + 1)으로 저장된 정책은 계산에는 영향이 없지만
* (사다리 매칭은 시작값을 쓰지 않는다) 관리자 화면 요약이 "10001~30000" 처럼 표시된다.
* 2. 총 무게/부피가 0 으로 고정 기록된 기존 주문을 주문 옵션 소계 합으로 백필한다.
*
* 모든 비즈니스 로직은 data/1.1.1/migrations/ 로 격리(AbstractUpgradeStep 규약).
*
* @upgrade-path B
*/
class Upgrade_1_1_1 extends AbstractUpgradeStep {}
@@ -0,0 +1,136 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_1_1\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 구간별 배송비의 구간 시작값을 현재 계약으로 정규화합니다.
*
* 시작값은 표시 전용이며 직전 구간의 종료값에서 파생됩니다 — 첫 구간은 0, 이후 구간은
* 수량이면 `직전 max + 1`, 금액·무게·부피면 `직전 max` 입니다. 배송비 계산은 종료값
* 기준 사다리 매칭이라 시작값과 무관하지만, 화면에서 시작값이 읽기전용이 된 이상
* 저장 데이터가 규칙과 어긋나 있으면 운영자가 그 정책을 다시 저장할 수 없습니다
* (첫 구간 시작값이 1 인 기존 데이터가 그대로 거부됨).
*
* 종료값과 배송비는 손대지 않습니다 — 계산 결과가 바뀌지 않습니다.
*
* idempotent: 이미 정규화된 정책은 변경 대상이 없어 no-op.
* V-1 안전: Illuminate\Support\Facades\* 만 사용 — 모듈 Enum/Model 을 참조하지 않는다.
*/
class NormalizeRangeTierMin implements DataMigration
{
private const TABLE = 'ecommerce_shipping_policy_country_settings';
/**
* 이산형(다음 시작값 = 직전 종료값 + 1) 부과정책.
*
* @var array<int, string>
*/
private const DISCRETE_POLICIES = ['range_quantity'];
/**
* 연속형(다음 시작값 = 직전 종료값) 부과정책.
*
* @var array<int, string>
*/
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;
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_1_1\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 주문의 총 무게/부피를 주문 옵션 소계 합으로 백필합니다.
*
* 주문 생성 시 `total_weight`/`total_volume` 이 0 으로 고정 기록되어,
* 배송사 연동·운임 정산이 이 값을 읽으면 무게 없는 주문으로 취급됐다.
* 옵션에는 주문 시점 소계(subtotal_weight/subtotal_volume)가 남아 있으므로
* 그 합으로 복원한다.
*
* 0 인 행만 대상으로 하며(운영자가 이후 보정한 값은 보존), 두 값이 모두 0 인
* 주문(무게 미입력 상품만 담긴 주문)은 갱신 결과도 0 이라 재실행해도 동일하다.
*
* V-1 안전: Illuminate\Support\Facades\* 만 사용 — 모듈 Model 을 참조하지 않는다.
*/
class BackfillOrderTotalWeightVolume implements DataMigration
{
private const ORDERS_TABLE = 'ecommerce_orders';
private const OPTIONS_TABLE = 'ecommerce_order_options';
public function name(): string
{
return 'BackfillOrderTotalWeightVolume';
}
public function run(UpgradeContext $context): void
{
if (! Schema::hasTable(self::ORDERS_TABLE) || ! Schema::hasTable(self::OPTIONS_TABLE)) {
$context->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} 건");
}
}
@@ -0,0 +1,178 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_1_1\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
/**
* 주문/주문옵션 외래키 컬럼의 누락된 한국어 comment 를 채웁니다.
*
* 1.0.5 의 같은 보정에서 빠져 있던 컬럼들입니다. 마이그레이션 소스는 교정했지만,
* 이미 설치를 마친 사이트는 create 마이그레이션을 다시 실행하지 않으므로
* 설명이 비어 있는 채로 남습니다.
*
* 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다.
*
* 안전:
* - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다.
* - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는
* 아무 것도 바뀌지 않습니다.
* - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다).
*
* 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다.
*
* V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을
* 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조).
*/
class BackfillOrderForeignKeyColumnComments implements DataMigration
{
/**
* 보정 대상 (1.1.1 시점 동결) — 테이블 => [컬럼 => 설명].
*/
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);
}
}
@@ -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",
Binary file not shown.