From bd7b31feb845d5e025052511eacd0174eb9e6240 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Thu, 6 Aug 2026 11:09:47 +0900 Subject: [PATCH] =?UTF-8?q?fix(ecommerce):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=B3=84=EC=A0=90=EC=9D=B4=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=9C=A0=EB=AC=B4=EC=99=80=20=EB=AC=B4=EA=B4=80?= =?UTF-8?q?=ED=95=98=EA=B2=8C=200=20=EC=9C=BC=EB=A1=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 최근 본 상품과 찜 목록만 리뷰 집계를 붙이지 않아, 리뷰가 달린 상품도 카드에 별 0 개로 표시됐다. 두 조회에 다른 목록과 같은 집계를 붙였다. 값이 비었는지로 판정하던 소비 측도 함께 고쳤다. 집계를 안 한 것과 세어보니 0건인 것이 같은 0 으로 뭉개져 있었다 — 이제 집계 컬럼이 붙었는지로 판정해, 세지 않은 조회에서는 항목을 생략하고 리뷰가 0건이면 종전대로 0 으로 표기한다. 관리자 상품 목록이 그 대상이라 API 문서에 어느 조회가 통계를 싣는지 명시했다. 소비처를 필드명으로만 훑어 처음에는 "화면에 안 보인다" 고 판정했는데 틀렸다. 레이아웃은 상품 객체를 통째로 카드에 넘기고 카드가 내부에서 별점을 그린다. 그 컴포넌트를 쓰는 화면의 공급 경로를 전수로 세어 찜 목록을 추가로 찾았다. develop 리베이스 후속 검증 결과와 충돌 해소 근거도 함께 담는다. --- AGENTS.md | 4 +- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 2 + .../sirsoft-ecommerce/docs/api/products.md | 14 ++ .../Http/Resources/ProductListResource.php | 39 ++++- .../src/Repositories/ProductRepository.php | 9 +- .../ProductWishlistRepository.php | 5 + .../ProductListReviewAggregateTest.php | 158 ++++++++++++++++++ tests/scenarios/bounded-total-pagination.yaml | 8 + 8 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Resources/ProductListReviewAggregateTest.php diff --git a/AGENTS.md b/AGENTS.md index d5e66856..97891c15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,7 +152,7 @@ | 대상 | 진입점 | 문서/엔드포인트 | |------|--------|----------------| -| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 35 / 291 | +| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 | ### 확장 API 레퍼런스 (13개 확장, 자동 스캔) @@ -163,7 +163,7 @@ |------|------|--------------|----------------| | `gnuboard7-hello_module` | 모듈 | [docs/api/](modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 7 | | `sirsoft-board` | 모듈 | [docs/api/](modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 | -| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 231 | +| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 232 | | `sirsoft-page` | 모듈 | [docs/api/](modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 | | `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | | `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 48506da2..f8f105b8 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -60,6 +60,8 @@ ### Fixed +- "최근 본 상품" 과 찜 목록의 상품 카드가 리뷰가 달린 상품도 별점 0 개 · 리뷰 0건으로 표시하던 문제를 수정했습니다. 두 조회만 리뷰를 세지 않아 값이 비어 있었고, 빈 값이 0 으로 표기되어 실제 평점과 다르게 보였습니다. 이제 다른 상품 목록과 동일하게 실제 평점·리뷰 수가 표시됩니다. +- 리뷰를 세지 않는 조회 경로(관리자 상품 목록 등)에서는 평점·리뷰 수 항목을 아예 싣지 않도록 바꿨습니다. 종전에는 세지 않았을 뿐인데 0 이 실려 "리뷰 없음" 과 구분되지 않았습니다. 리뷰가 실제로 0건이면 종전대로 0 으로 표시됩니다. - 모바일·태블릿 화면에서 쿠폰 목록의 카드 보기가 내용 없이 나오던 문제를 수정했습니다. 목록의 각 쿠폰 정보가 카드에 전달되지 않아, 쿠폰이 있어도 빈 카드만 표시됐습니다. - 주문 검색 프리셋 관리 창에서 "프리셋 추가" 버튼을 눌러도 저장 창이 열리지 않던 문제를 수정했습니다. - 상품 리뷰 목록의 작성자 이름과 쿠폰 목록의 쿠폰 이름을 키보드로 선택해 이동할 수 있게 했습니다. 이전에는 마우스 클릭으로만 이동할 수 있었습니다. diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md index 93b40ec0..928833cd 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md @@ -28,6 +28,20 @@ 같은 규약이 이 모듈의 다른 목록 응답(주문·쿠폰·마일리지·문의·리뷰 등)에도 적용됩니다. 상세 규약은 [pagination.md](../../../../../docs/backend/pagination.md) 를 참고하세요. +## 목록 응답의 리뷰 통계 + +`review_count` 와 `rating_avg` 는 상품 표에 저장된 값이 아니라 조회 시 함께 계산되는 집계입니다. +따라서 **그 계산을 수행하는 조회에서만 응답에 실립니다.** + +| 조회 | 리뷰 통계 | +| --- | --- | +| 공개 상품 목록 · 인기 · 신상품 · 최근 본 상품 · 상품 검색 | 실림 | +| 관리자 상품 목록 | 실리지 않음 (화면이 사용하지 않습니다) | + +리뷰가 한 건도 없는 상품은 계산이 수행된 조회에서 `review_count: 0` · `rating_avg: 0.0` 으로 +실립니다. 계산하지 않은 조회에서 두 항목이 **아예 빠지는 것**과는 다릅니다 — 값이 0 인 것과 +값이 없는 것을 구분해야 하므로, 항목이 없을 때를 0 으로 간주하지 마세요. + --- diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductListResource.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductListResource.php index b09edbdc..c7afc695 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductListResource.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/ProductListResource.php @@ -6,6 +6,7 @@ use App\Http\Resources\BaseApiResource; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Http\Resources\MissingValue; use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices; /** @@ -117,9 +118,13 @@ class ProductListResource extends BaseApiResource ])->values() ), - // 리뷰 통계 (visibleReviews withCount/withAvg eager loading 필요) - 'review_count' => (int) ($this->review_count ?? 0), - 'rating_avg' => $this->rating_avg !== null ? round((float) $this->rating_avg, 1) : 0.0, + // 리뷰 통계 — 조회 시 조인으로 붙는 집계다. + // 값이 null 인지가 아니라 **집계 컬럼이 붙었는지**로 판정한다. 리뷰 0건이면 COUNT/AVG + // 별칭은 존재하고 값만 null 이므로 종전대로 0 으로 표기되고, 집계를 아예 붙이지 않은 + // 경로에서는 필드를 생략한다 — 0 으로 채우면 "세어보니 0" 과 구분되지 않아 + // 리뷰가 달린 상품도 평점 0.0 으로 나간다. + 'review_count' => $this->resolveReviewCount(), + 'rating_avg' => $this->resolveRatingAvg(), // 날짜 'created_at' => $this->formatDateTimeStringForUser($this->created_at), @@ -175,6 +180,34 @@ class ProductListResource extends BaseApiResource return $this->whenLoaded('activeOptions', fn () => $this->activeOptions->count()); } + /** + * 노출 리뷰 수를 반환합니다. + * + * 집계를 붙이지 않은 조회 경로에서는 0 을 지어내지 않고 필드를 생략합니다. + * + * @return mixed 리뷰 수(int) 또는 집계 부재 시 MissingValue + */ + protected function resolveReviewCount(): mixed + { + return $this->hasAggregateAttribute('review_count') + ? (int) $this->review_count + : new MissingValue; + } + + /** + * 노출 리뷰의 평균 평점을 반환합니다. + * + * 리뷰가 0건이면 AVG 는 null 이고 별칭은 존재하므로 0.0 으로 표기합니다. + * + * @return mixed 평균 평점(float) 또는 집계 부재 시 MissingValue + */ + protected function resolveRatingAvg(): mixed + { + return $this->hasAggregateAttribute('rating_avg') + ? round((float) $this->rating_avg, 1) + : new MissingValue; + } + /** * 비활성을 포함한 전체 옵션 개수를 반환합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php index 7939163f..c2842590 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php @@ -894,11 +894,14 @@ class ProductRepository implements ProductRepositoryInterface return $this->model->newCollection(); } - $products = $this->model->newQuery() + $query = $this->model->newQuery() ->with(['images', 'categories', 'activeLabelAssignments.label']) ->where('display_status', 'visible') - ->whereIn('id', $ids) - ->get(); + ->whereIn($this->model->getTable().'.id', $ids); + + // 다른 목록 경로와 같은 집계를 붙인다. 붙이지 않으면 평점·리뷰 수 별칭이 아예 없어 + // 목록 표현에서 그 필드가 빠지고, 화면은 리뷰가 달린 상품도 통계 없이 그리게 된다. + $products = $this->joinReviewAggregate($query)->get(); // 클라이언트 요청 순서 유지 (DB 독립적 정렬) $idOrder = array_flip($ids); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductWishlistRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductWishlistRepository.php index 7ca5cd66..aa173a4b 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductWishlistRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductWishlistRepository.php @@ -68,6 +68,11 @@ class ProductWishlistRepository implements ProductWishlistRepositoryInterface 'product.categories', 'product.activeLabelAssignments.label', 'product.images:id,product_id,hash,is_thumbnail,sort_order', + // 찜 카드도 상품 카드와 같은 컴포넌트라 별점을 그린다. 집계를 붙이지 않으면 + // 목록 표현이 평점·리뷰 수를 싣지 못해 리뷰가 달린 상품도 별 0 개로 보인다. + 'product' => fn ($q) => $q + ->withCount(['visibleReviews as review_count']) + ->withAvg('visibleReviews as rating_avg', 'rating'), ]) ->orderByDesc('created_at') // 전순서 보장 — 한 번에 여러 건을 담았을 때의 created_at 동률 대비 diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Resources/ProductListReviewAggregateTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Resources/ProductListReviewAggregateTest.php new file mode 100644 index 00000000..eed33922 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Resources/ProductListReviewAggregateTest.php @@ -0,0 +1,158 @@ +rating_avg !== null` 이었다. 값 검사는 "집계를 안 했다" 와 + * "집계했더니 0건이다" 를 구분하지 못하므로, 리뷰가 달린 상품도 집계를 붙이지 않은 경로에서는 + * `rating_avg: 0.0` · `review_count: 0` 으로 나갔다 — 틀린 값이 사실처럼 나가고 예외도 + * 경고도 남지 않는다. + * + * 두 방향을 함께 고정한다. + * 1. 최근 본 상품 조회는 집계를 붙여 **실제 값**을 돌려준다. + * 2. 집계를 붙이지 않은 경로에서는 0 을 지어내지 않고 **필드를 생략**한다. + * (집계를 붙였는데 리뷰가 0건이면 별칭은 존재하고 값만 null 이므로 종전대로 0 으로 표기) + * + * @scenario case=product_list_review_aggregate + * + * @effects recently_viewed_products_carry_real_review_stats + * @effects list_resource_omits_review_stats_when_not_aggregated + */ +class ProductListReviewAggregateTest extends ModuleTestCase +{ + /** + * 최근 본 상품 조회가 실제 리뷰 통계를 싣는지 확인합니다. + * + * @effects recently_viewed_products_carry_real_review_stats + */ + public function test_recently_viewed_products_carry_real_review_stats(): void + { + $product = Product::factory()->create(['display_status' => 'visible']); + $this->makeVisibleReview($product, 5); + $this->makeVisibleReview($product, 4); + + $products = app(ProductService::class)->getProductsByIds([$product->id]); + + $payload = ProductListResource::collection($products) + ->resolve(request()); + + $this->assertCount(1, $payload); + $this->assertSame( + 2, + $payload[0]['review_count'] ?? null, + '최근 본 상품 응답의 리뷰 수가 실제 건수와 다릅니다 — 집계를 붙이지 않으면 0 이 나갑니다.' + ); + $this->assertSame( + 4.5, + $payload[0]['rating_avg'] ?? null, + '최근 본 상품 응답의 평점이 실제 평균과 다릅니다.' + ); + } + + /** + * 리뷰가 0건이면 집계가 붙어도 0 으로 표기되는지 확인합니다. + */ + public function test_product_without_reviews_reports_zero_stats(): void + { + $product = Product::factory()->create(['display_status' => 'visible']); + + $products = app(ProductService::class)->getProductsByIds([$product->id]); + + $payload = ProductListResource::collection($products)->resolve(request()); + + $this->assertSame(0, $payload[0]['review_count'] ?? null); + $this->assertSame(0.0, $payload[0]['rating_avg'] ?? null); + } + + /** + * 집계를 붙이지 않은 모델에서는 리뷰 통계 필드가 생략되는지 확인합니다. + * + * @effects list_resource_omits_review_stats_when_not_aggregated + */ + public function test_list_resource_omits_review_stats_when_not_aggregated(): void + { + $product = Product::factory()->create(['display_status' => 'visible']); + $this->makeVisibleReview($product, 5); + + // 집계 없이 그대로 읽은 모델 — 별칭 자체가 붙지 않는다. + $raw = Product::query()->findOrFail($product->id); + + $payload = (new ProductListResource($raw))->resolve(request()); + + $this->assertArrayNotHasKey( + 'review_count', + $payload, + '집계하지 않은 경로인데 리뷰 수가 실렸습니다 — 0 이 정확한 값처럼 나갑니다.' + ); + $this->assertArrayNotHasKey( + 'rating_avg', + $payload, + '집계하지 않은 경로인데 평점이 실렸습니다.' + ); + } + + /** + * 찜 목록 카드도 실제 리뷰 통계를 싣는지 확인합니다. + * + * 찜 화면은 상품 목록과 같은 카드 컴포넌트를 쓰므로 별점을 그린다. 집계를 붙이지 않으면 + * 리뷰가 달린 상품도 별 0 개로 보인다. + * + * @effects wishlist_cards_carry_real_review_stats + */ + public function test_wishlist_cards_carry_real_review_stats(): void + { + $owner = User::factory()->create(); + $product = Product::factory()->create(['display_status' => 'visible']); + $this->makeVisibleReview($product, 5); + $this->makeVisibleReview($product, 4); + + ProductWishlist::create([ + 'user_id' => $owner->id, + 'product_id' => $product->id, + ]); + + $wishlists = app(ProductWishlistRepositoryInterface::class)->getByUser($owner->id, 20); + + $payload = WishlistResource::collection($wishlists->getCollection())->resolve(request()); + + $this->assertCount(1, $payload); + $this->assertSame( + 2, + $payload[0]['product']['review_count'] ?? null, + '찜 카드의 리뷰 수가 실제 건수와 다릅니다 — 집계를 붙이지 않으면 별 0 개로 보입니다.' + ); + $this->assertSame(4.5, $payload[0]['product']['rating_avg'] ?? null); + } + + /** + * 노출 상태 리뷰를 1건 만듭니다. + * + * @param Product $product 대상 상품 + * @param int $rating 평점 + */ + private function makeVisibleReview(Product $product, int $rating): void + { + ProductReview::factory()->create([ + 'product_id' => $product->id, + 'user_id' => User::factory()->create()->id, + 'rating' => $rating, + 'status' => ReviewStatus::VISIBLE->value, + ]); + } +} diff --git a/tests/scenarios/bounded-total-pagination.yaml b/tests/scenarios/bounded-total-pagination.yaml index 9d2e0bba..7e07c6c3 100644 --- a/tests/scenarios/bounded-total-pagination.yaml +++ b/tests/scenarios/bounded-total-pagination.yaml @@ -103,6 +103,12 @@ effects: # 검증도 통과하므로, 운영자에게는 "바꿨는데 안 바뀐다" 로만 나타난다. - pagination_limits_persist_from_admin_settings - advanced_tab_persists_every_merged_category + # ── 목록 집계값의 정확도 (P1) ── + # 평점·리뷰 수는 조회 시 조인으로 붙는 집계다. 값이 비었는지로 판정하면 "집계를 안 했다" 와 + # "집계했더니 0건이다" 가 같은 0 으로 뭉개져, 리뷰가 달린 상품도 평점 0.0 으로 나간다. + - recently_viewed_products_carry_real_review_stats + - list_resource_omits_review_stats_when_not_aggregated + - wishlist_cards_carry_real_review_stats # ── 확장 다국어 키 해석 (P1) ── # 네임스페이스 없는 키는 번역이 실패해도 예외가 나지 않고 원시 키가 화면에 그대로 나간다. - extension_non_namespaced_key_resolves_to_core_group @@ -183,6 +189,8 @@ test_files: - tests/Playwright/specs/pagination/bounded-total-and-product-grid.spec.ts # 코어 — 고급 탭 저장이 병합 대상 카테고리(상한값 포함)를 빠짐없이 분류하는지 - tests/Feature/Settings/AdvancedTabCategoryPersistenceTest.php + # 이커머스 — 목록 리뷰 통계가 실제 값인지 / 집계 없는 경로에서 0 을 지어내지 않는지 + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Resources/ProductListReviewAggregateTest.php # 코어 — 확장의 네임스페이스 없는 다국어 키가 해석되는지 (확장 소스 전수 스캔) - tests/Unit/ExtensionTranslationKeyResolutionTest.php # 화면 — 검색 상태의 SSoT 가 URL 쿼리인지 (레이아웃 전수 스캔)