fix(ecommerce): 비회원 주문 상세의 상품별 배송정책 표시 누락 해소

비회원 주문 조회 상세는 회원 상세와 같은 화면 구성(partial)을 쓰는데
비회원 응답에만 배송정책 스냅샷이 없어 그 줄만 조용히 비어 있었다.
오류도 빈 값 표기도 없이 항목 자체가 사라지므로 응답을 열어 보기 전에는
드러나지 않는다.

스냅샷 전체를 그대로 내보내면 원가·계산 근거까지 비회원에게 노출되므로
ShippingPolicySnapshot::forDisplay 로 화면이 쓰는 정책명·개별 배송비만
추려 내보낸다. 내부 계산 정보는 종전대로 제공하지 않는다.
This commit is contained in:
HeuJung
2026-08-04 10:34:00 +09:00
parent e426498fb3
commit 1ce434e2da
5 changed files with 167 additions and 1 deletions
@@ -41,6 +41,7 @@
- 주문 검색 프리셋 관리 창의 수정·삭제 아이콘이 의도한 크기보다 작게 보이던 문제를 수정했습니다.
- 할인코드 목록에서 선택 개수·전체 건수·페이지당 표시 개수 문구에 숫자가 채워지지 않고 자리표시자가 그대로 보이던 문제를 수정했습니다.
- 마이페이지와 비회원 주문 조회의 주문 상세에서 상품별 배송정책명과 개별 배송비가 아무 안내 없이 표시되지 않던 문제를 수정했습니다. 주문에 보관된 배송정책 기록의 저장 형태가 화면이 기대하는 형태와 달라 해당 줄이 통째로 빠져 있었습니다. 이미 저장된 주문도 업그레이드 시 함께 정리됩니다.
- 비회원 주문 조회의 주문 상세에서 상품별 배송정책명과 개별 배송비가 표시되지 않던 문제를 추가로 수정했습니다. 회원 주문 상세와 같은 화면 구성을 쓰는데 비회원 응답에만 배송정책 정보가 빠져 있어, 오류 없이 그 줄만 비어 있었습니다. 표시에 필요한 정책명과 배송비만 내려주며 내부 계산 정보는 종전대로 제공하지 않습니다.
- 미입금 자동취소 기한을 비운 채 저장하면 "저장되었습니다"라고 안내되면서도 값은 사라지던 문제를 수정했습니다. 숫자 항목에 숫자가 아닌 값을 입력하면 입력칸이 비워지는데, 그대로 저장하면 설정이 없는 상태로 남아 화면에 보이는 값과 실제 동작이 어긋났습니다. 이제 비워 둔 채로는 저장되지 않고 값을 입력하라고 안내합니다.
- 미입금 자동취소 기한 입력 안내가 실제 입력 하한과 다른 "0일 이상"으로 표시되던 문구를 "1일 이상"으로 바로잡았습니다.
- 쿠폰 수정 화면에서 유효기간을 "발급일로부터"로 두고 일수를 비운 채 저장할 수 있던 문제를 수정했습니다. 그렇게 저장된 쿠폰은 발급되는 즉시 만료 상태가 되어, 받은 회원이 사용할 수 없으면서도 아무 오류가 나지 않았습니다. 이제 저장 단계에서 일수를 입력하도록 안내하고, 이미 그렇게 저장돼 있던 쿠폰은 발급 시 유효기간이 설정되지 않았다고 알려 줍니다.
@@ -498,6 +498,7 @@ _단건 응답: `data` 객체의 필드._
| cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) |
| promotions_applied_snapshot | object | `{"coupon_issue_ids":[7330],"item_coupons":[],"discount_co…` | 적용된 프로모션 스냅샷 (재계산용) |
| shipping_policy_applied_snapshot | object | `{"items": [], "address": {}}` | 적용된 배송정책 스냅샷 (재계산용). `items` 는 옵션별 적용 정책 목록(각 항목: `product_option_id`, `policy`), `address` 는 주문 시점 배송지 메타(`country_code`, `zipcode`). 항목이 없어도 `items` 는 빈 배열이다 |
| shipping_policy_applied_snapshot (비회원 응답) | object | `{"items": [{"product_option_id": 481, "policy": {"policy_name": "국내 무료배송", "standalone_shipping_amount": 0, "standalone_shipping_amount_formatted": "무료배송"}}]}` | 비로그인(비회원 조회 토큰) 응답은 **표시용 필드만** 내보낸다 — `policy_name` · `standalone_shipping_amount(_formatted)`. 정책 id·계산 근거와 배송지 메타(`address`)는 제외된다. 비회원 주문 상세 화면이 회원과 같은 partial 로 상품별 정책명·개별 배송비를 그리므로 필드 자체를 빼면 그 줄만 오류 없이 사라진다 |
| admin_memo | null | `null` | 관리자 메모 (내부 관리용) |
| customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) |
| created_at | string | `2026-06-17T02:07:25+00:00` | 생성 일시 |
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Http\Resources;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
/**
* 비회원 주문 상세 리소스
@@ -13,9 +14,14 @@ use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
* 회원용 OrderResource 와 달리 다음을 노출하지 않는다.
* - admin_memo / customer_memo (관리자·내부 메모)
* - user / user_id / user_login_id (회원 정보)
* - promotions_applied_snapshot / shipping_policy_applied_snapshot (내부 계산 스냅샷)
* - promotions_applied_snapshot (내부 계산 스냅샷)
* - 회원 권한 메타(resourceMeta/abilityMap)
*
* `shipping_policy_applied_snapshot` 은 **표시용 필드만** 추려 내보낸다. 비회원 주문 상세
* 화면이 회원 마이페이지와 같은 partial 로 상품별 정책명·개별 배송비를 그리므로, 통째로
* 빼면 오류 없이 그 줄만 사라진다. 정책 id·계산 근거는 `ShippingPolicySnapshot::forDisplay()`
* 가 걸러낸다.
*
* 허용 액션(abilities)은 회원 권한이 아니라 주문 상태로만 판정한다.
*/
class GuestOrderResource extends BaseApiResource
@@ -120,6 +126,14 @@ class GuestOrderResource extends BaseApiResource
// 취소 이력 (취소 사유·상세 사유·취소일시 표시용) — 최근 취소가 먼저 오도록 정렬
'cancels' => OrderCancelResource::collection($this->whenLoaded('cancels')),
// 배송정책 — **표시용 필드만**. 비회원 주문 상세 화면은 회원 마이페이지와 같은
// partial 을 써서 상품별 정책명·개별 배송비를 그리는데, 이 필드를 통째로 빼면
// 오류 없이 그 줄만 사라진다(옵셔널 체이닝이 빈 배열로 떨어져 조건이 false).
// 정책 id·계산 근거 같은 내부 값은 forDisplay() 가 걸러낸다.
'shipping_policy_applied_snapshot' => ShippingPolicySnapshot::forDisplay(
$this->shipping_policy_applied_snapshot
),
// 상태 기반 허용 액션 (회원 권한이 아닌 주문 상태로만 판정)
'abilities' => $this->guestAbilities(),
];
@@ -145,6 +145,48 @@ class ShippingPolicySnapshot
return $map;
}
/**
* 화면 표시에 필요한 필드만 남긴 스냅샷을 만듭니다.
*
* 주문 상세의 배송정책 블록(`partials/mypage/orders/_items.json`)이 읽는 것은
* `product_option_id` 와 `policy.policy_name` · `policy.standalone_shipping_amount`
* (+ 포맷) 뿐이다. 비회원 응답은 정책 id·계산 근거 같은 내부 값을 내보내지 않으면서도
* 이 블록이 그려지도록 표시용 필드만 추린다.
*
* 배송지 메타(`address`)는 표시 대상이 아니므로 제외한다 — 화면은 별도 배송지 블록에서
* 주문 주소를 쓴다.
*
* `items` 는 프론트가 `.find(...)` 로 순회하므로 **항상 리스트**여야 한다.
*
* @param array|null $snapshot 원본 스냅샷 (신형 · 구형 모두 허용)
* @return array{items: array<int, array>} 표시용 스냅샷
*
* @since 1.0.5
*/
public static function forDisplay(?array $snapshot): array
{
$items = [];
foreach (self::items($snapshot) as $entry) {
if (! isset($entry['product_option_id'], $entry['policy']) || ! is_array($entry['policy'])) {
continue;
}
$policy = $entry['policy'];
$items[] = [
'product_option_id' => $entry['product_option_id'],
'policy' => [
'policy_name' => $policy['policy_name'] ?? '',
'standalone_shipping_amount' => $policy['standalone_shipping_amount'] ?? 0,
'standalone_shipping_amount_formatted' => $policy['standalone_shipping_amount_formatted'] ?? '',
],
];
}
return [self::ITEMS => $items];
}
/**
* 지정한 옵션 ID 집합에 해당하는 항목만 추립니다.
*
@@ -0,0 +1,108 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Resources;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Http\Resources\GuestOrderResource;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 비회원 주문 상세의 배송정책 표시 필드 정합성 테스트
*
* 비회원 주문 상세 화면(`shop/guest_order_show.json`)은 회원 마이페이지와 **같은
* partial**(`partials/mypage/orders/_items.json`)을 써서 상품별 배송정책명과 개별
* 배송비를 그린다. 그 partial 은 `shipping_policy_applied_snapshot.items[]` 에서
* `policy.policy_name` · `policy.standalone_shipping_amount(_formatted)` 를 읽는다.
*
* 그런데 `GuestOrderResource` 는 그 필드를 "내부 계산 스냅샷" 으로 분류해 통째로
* 제외했다. 결과는 오류가 아니라 **조용한 누락** — 옵셔널 체이닝이 빈 배열로 떨어져
* `if` 가 false 가 되고, 비회원 주문 상세에서만 배송정책 줄이 항상 사라진다.
* (브라우저 실측으로 발견: 회원 경로는 표시, 비회원 경로는 미표시)
*
* 내부 계산 근거(정책 id·계산 파라미터)를 비회원에게 노출하지 않는다는 기존 원칙은
* 유지하고, 화면이 실제로 읽는 **표시용 필드만** 노출하는 것으로 해소한다.
*/
class GuestOrderShippingPolicyDisplayTest extends ModuleTestCase
{
/**
* 표시용 스냅샷을 담은 주문을 만듭니다.
*
* @return Order 스냅샷이 채워진 주문
*/
private function makeOrderWithSnapshot(): Order
{
return OrderFactory::new()->make([
'shipping_policy_applied_snapshot' => ShippingPolicySnapshot::make(
[[
'product_option_id' => 481,
'policy' => [
'policy_id' => 7,
'policy_name' => '국내 무료배송',
'charge_policy' => 'free',
'standalone_shipping_amount' => 0,
'calculation_basis' => ['internal' => 'do-not-expose'],
],
]],
['country_code' => 'KR', 'zipcode' => '06236'],
),
]);
}
/**
* 비회원 응답이 화면이 읽는 배송정책 표시 필드를 노출하는지 확인
*/
public function test_guest_resource_exposes_shipping_policy_display_fields(): void
{
// Given: 배송정책 스냅샷이 있는 비회원 주문
$order = $this->makeOrderWithSnapshot();
// When: 비회원 리소스로 직렬화
$result = (new GuestOrderResource($order))->toArray(request());
// Then: 화면이 읽는 경로가 살아 있다
$this->assertArrayHasKey('shipping_policy_applied_snapshot', $result);
$items = $result['shipping_policy_applied_snapshot']['items'] ?? null;
$this->assertIsArray($items, 'items 는 프론트 .find() 대상이므로 배열이어야 한다');
$this->assertCount(1, $items);
$this->assertSame(481, $items[0]['product_option_id']);
$this->assertSame('국내 무료배송', $items[0]['policy']['policy_name']);
$this->assertArrayHasKey('standalone_shipping_amount', $items[0]['policy']);
}
/**
* 표시에 쓰이지 않는 내부 계산 값은 비회원에게 노출되지 않는지 확인
*/
public function test_guest_resource_hides_internal_calculation_fields(): void
{
// Given: 내부 계산 근거가 섞인 스냅샷
$order = $this->makeOrderWithSnapshot();
// When: 비회원 리소스로 직렬화
$result = (new GuestOrderResource($order))->toArray(request());
// Then: 표시용 외 내부 키는 빠진다
$policy = $result['shipping_policy_applied_snapshot']['items'][0]['policy'];
$this->assertArrayNotHasKey('calculation_basis', $policy);
$this->assertArrayNotHasKey('policy_id', $policy);
// 배송지 메타도 비회원 표시 대상이 아니다
$this->assertArrayNotHasKey('address', $result['shipping_policy_applied_snapshot']);
}
/**
* 스냅샷이 없어도 화면이 안전하게 빈 목록을 받는지 확인
*/
public function test_guest_resource_returns_empty_items_when_snapshot_missing(): void
{
// Given: 스냅샷이 비어 있는 주문
$order = OrderFactory::new()->make(['shipping_policy_applied_snapshot' => null]);
// When: 비회원 리소스로 직렬화
$result = (new GuestOrderResource($order))->toArray(request());
// Then: `.find()` 가 죽지 않도록 배열이 유지된다
$this->assertSame([], $result['shipping_policy_applied_snapshot']['items']);
}
}