From 4a90046d5423819ed5b870da986863c1d15a986c Mon Sep 17 00:00:00 2001 From: HeuJung Date: Fri, 3 Jul 2026 21:05:43 +0900 Subject: [PATCH] =?UTF-8?q?fix(ecommerce):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=C2=B7=EC=9C=A0=EC=A0=80=20=EA=B5=AC=EB=A7=A4=ED=99=95=EC=A0=95?= =?UTF-8?q?=20=EC=A0=81=EB=A6=BD=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A7=88=EC=9D=BC=EB=A6=AC=EC=A7=80=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 주문옵션 구매확정 경로가 유저 셀프 확정과 부수효과가 어긋나던 문제를 바로잡고, 검수에서 드러난 적립 금액·기능 토글 결함을 함께 수정한다. - confirmed_at 대칭화: 관리자 CONFIRMED 전이 시 옵션·주문 헤더 confirmed_at 기록 + order-option.after_confirm 훅 발화(즉시적립·활동로그·리뷰기한 정상화) - 나눠 확정 델타 적립: 멱등 기준을 "적립 건수"에서 "금액 델타"로 전환. 분할·병합으로 목표 적립액이 늘어도 차액만큼 정확히 지급(기존 lot 증액, 적립 내역 한 줄 유지). 병합 시 적립 거래를 생존 옵션으로 이전 - 마일리지 미사용 시 즉시적립도 미지급하도록 통일(지연적립 게이트와 일관) 관리자·회원·비회원 셀프확정 전 표면 및 지연적립 스케줄러 연계를 Chrome MCP로 실측 검증(전 시나리오 통과). 회귀 테스트 12건 추가. --- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 8 + .../_bundled/sirsoft-ecommerce/composer.json | 2 +- .../_bundled/sirsoft-ecommerce/module.json | 2 +- .../sirsoft-ecommerce/package-lock.json | 4 +- .../_bundled/sirsoft-ecommerce/package.json | 2 +- .../Listeners/MileageTransactionListener.php | 23 +- .../MileageTransactionRepositoryInterface.php | 34 +++ .../MileageTransactionRepository.php | 45 ++- .../src/Services/OrderOptionService.php | 33 ++- .../src/Services/UserMileageService.php | 30 +- .../Services/MileageEarnDeltaAndGateTest.php | 228 +++++++++++++++ .../OrderOptionServiceConfirmTest.php | 270 ++++++++++++++++++ .../order-option-status-transition.yaml | 15 + 13 files changed, 676 insertions(+), 20 deletions(-) create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnDeltaAndGateTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceConfirmTest.php diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 73dc1a5c..e8192cdf 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -4,6 +4,14 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.1] - 2026-07-02 + +### Fixed + +- 관리자 주문 상세에서 주문상품을 "구매확정"으로 변경할 때 구매확정 처리가 고객이 직접 구매확정한 경우와 동일하게 이뤄지지 않던 문제를 수정했습니다 — 이제 구매확정 시점이 정확히 기록되어(부분 수량만 구매확정한 경우 포함), 구매확정 기준으로 지급되는 마일리지 적립이 정상적으로 지급되고, 리뷰 작성 기한도 올바르게 계산됩니다. 기존에는 관리자가 구매확정 처리한 주문상품이 마일리지 적립 대상에서 누락되고 리뷰 작성 기한이 계산되지 않을 수 있었습니다. (#61 @miles44229 님께서 제보해주셨습니다.) +- 주문상품을 여러 번에 나누어 구매확정할 때(예: 2개 중 1개씩 따로 확정) 적립 포인트가 일부만 지급되던 문제를 수정했습니다 — 이제 나누어 확정해도 예정 적립 포인트가 정확한 총액으로 지급됩니다. +- 마일리지 기능을 사용 안 함으로 설정한 뒤에는 지연 적립뿐 아니라 즉시 적립도 지급되지 않도록 통일했습니다 — 기존에는 지급 시점 설정에 따라 어떤 적립은 지급되고 어떤 적립은 지급되지 않을 수 있었습니다. + ## [1.0.0] - 2026-07-01 ### Fixed diff --git a/modules/_bundled/sirsoft-ecommerce/composer.json b/modules/_bundled/sirsoft-ecommerce/composer.json index 6bd372d3..3ccab90e 100644 --- a/modules/_bundled/sirsoft-ecommerce/composer.json +++ b/modules/_bundled/sirsoft-ecommerce/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-ecommerce", "description": "Ecommerce module for Gnuboard7", "type": "library", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 2e91f1f5..323944a8 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -5,7 +5,7 @@ "ko": "이커머스", "en": "Ecommerce" }, - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "description": { "ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", diff --git a/modules/_bundled/sirsoft-ecommerce/package-lock.json b/modules/_bundled/sirsoft-ecommerce/package-lock.json index 4ecff5ca..608be326 100644 --- a/modules/_bundled/sirsoft-ecommerce/package-lock.json +++ b/modules/_bundled/sirsoft-ecommerce/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.0", + "version": "1.0.1", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-ecommerce/package.json b/modules/_bundled/sirsoft-ecommerce/package.json index 5576622f..3066e2a1 100644 --- a/modules/_bundled/sirsoft-ecommerce/package.json +++ b/modules/_bundled/sirsoft-ecommerce/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.0", + "version": "1.0.1", "description": "그누보드7 이커머스 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php index 0dd22248..e127365c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php @@ -184,15 +184,20 @@ class MileageTransactionListener implements HookListenerInterface /** * 적립 처리. * - * mileage.enabled 토글은 주문 당시 적립액 "계산"(OrderCalculationService) 단계에서만 작용한다. - * 리스너는 이미 계산·저장된 subtotal_earned_points_amount 를 실행할 뿐이므로 enabled 를 재검사하지 않는다. - * (저장값이 0 이면 Service 가 자연 no-op — 정책 확정: 계산 완료된 적립은 토글로 막지 않음) + * mileage.enabled 가 꺼져 있으면 즉시 적립도 발생시키지 않는다 — 지연 적립(스케줄러 + * EarnMileageCommand)이 enabled 게이트로 막히는 것과 미지급 기준으로 통일한다. + * (PO 확정 2026-07-02: 기능 비활성 시 계산 완료건도 지급하지 않음. 이전 "토글로 막지 않음" + * 정책에서 변경 — 즉시/지연 경로 일관성 확보.) 저장 적립액이 0 이면 Service 가 자연 no-op. * * @param Order $order 주문 * @param OrderOption $option 주문옵션 */ private function earn(Order $order, OrderOption $option): void { + if (! $this->earnEnabled()) { + return; + } + try { $this->mileageService->earnForOrderOption($order, $option, MileageTransactionTypeEnum::PURCHASE_EARN); } catch (\Throwable $e) { @@ -205,6 +210,18 @@ class MileageTransactionListener implements HookListenerInterface } } + /** + * 마일리지 기능 활성화 여부를 반환합니다. + * + * 스케줄러(EarnMileageCommand)의 enabled 게이트와 동일 기준 — 즉시/지연 적립 미지급 통일. + * + * @return bool 활성화 여부 + */ + private function earnEnabled(): bool + { + return (bool) $this->settings->getSetting('mileage.enabled', false); + } + /** * 설정된 적립 시점을 반환합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/MileageTransactionRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/MileageTransactionRepositoryInterface.php index 4ef2044d..7c00ab92 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/MileageTransactionRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/MileageTransactionRepositoryInterface.php @@ -76,6 +76,40 @@ interface MileageTransactionRepositoryInterface */ public function existsEarnForOption(int $orderOptionId): bool; + /** + * 해당 주문옵션의 구매 적립(purchase_earn) 총액을 반환합니다 (금액 델타 멱등 기준). + * + * 관리자 수동 지급(admin_earn)·복원(refund_restore/order_cancel_restore)은 제외하고 + * 구매확정/배송 적립(purchase_earn)만 합산한다. 나눠 확정·병합으로 옵션의 적립 예정액이 + * 늘었을 때, 목표액과 이 합계의 차액(델타)만 추가 적립하기 위한 기준값이다. + * + * @param int $orderOptionId 주문옵션 ID + * @return float 기적립 purchase_earn 총액 + */ + public function sumPurchaseEarnedForOption(int $orderOptionId): float; + + /** + * 피흡수 옵션의 구매 적립(purchase_earn) 거래를 생존 옵션으로 이전합니다 (병합 정합). + * + * 옵션 병합 시 shipping·review 처럼 마일리지 적립 거래의 order_option_id 도 생존 레코드로 + * 옮겨, 병합 후 생존 옵션 기준의 적립 합계·델타 계산이 정합하도록 한다. + * + * @param int $fromOrderOptionId 피흡수 옵션 ID + * @param int $toOrderOptionId 생존 옵션 ID + * @return int 이전된 거래 수 + */ + public function transferPurchaseEarnByOrderOptionId(int $fromOrderOptionId, int $toOrderOptionId): int; + + /** + * 기존 구매 적립건(lot)에 델타 금액을 증액합니다 (방식 A — 적립 내역 한 줄 유지). + * + * amount·remaining_amount 를 델타만큼 증가시킨다. expires_at 은 최초 적립 시점을 유지한다. + * + * @param MileageTransaction $lot 대상 적립건 + * @param float $delta 증액할 금액 (> 0) + */ + public function incrementEarnLotAmount(MileageTransaction $lot, float $delta): void; + /** * 해당 주문취소에 대한 복원 거래가 이미 존재하는지 확인합니다 (멱등 가드). * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php index 39cfc40d..f847dc1c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/MileageTransactionRepository.php @@ -94,6 +94,38 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf ->exists(); } + /** + * {@inheritdoc} + */ + public function sumPurchaseEarnedForOption(int $orderOptionId): float + { + return (float) MileageTransaction::query() + ->where('order_option_id', $orderOptionId) + ->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value) + ->sum('amount'); + } + + /** + * {@inheritdoc} + */ + public function transferPurchaseEarnByOrderOptionId(int $fromOrderOptionId, int $toOrderOptionId): int + { + return MileageTransaction::query() + ->where('order_option_id', $fromOrderOptionId) + ->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value) + ->update(['order_option_id' => $toOrderOptionId]); + } + + /** + * {@inheritdoc} + */ + public function incrementEarnLotAmount(MileageTransaction $lot, float $delta): void + { + $lot->amount = (float) $lot->amount + $delta; + $lot->remaining_amount = (float) $lot->remaining_amount + $delta; + $lot->save(); + } + /** * {@inheritdoc} */ @@ -335,11 +367,14 @@ class MileageTransactionRepository implements MileageTransactionRepositoryInterf ->whereNotNull("opt.{$triggerColumn}") ->where("opt.{$triggerColumn}", '<=', $threshold) ->where('opt.subtotal_earned_points_amount', '>', 0) - ->whereNotExists(function ($q) { - $q->select(DB::raw(1)) - ->from('ecommerce_mileage_transactions as tx') - ->whereColumn('tx.order_option_id', 'opt.id') - ->whereIn('tx.type', self::EARN_TYPES); + // 금액 델타 멱등: 목표 적립액보다 기적립 purchase_earn 합계가 적은 옵션을 대상으로 삼는다. + // (나눠 확정·병합으로 목표액이 늘어난 옵션의 잔여분까지 스케줄러가 포착) + // 빌더 서브쿼리로 표현 — raw SQL/테이블 별칭은 prefix 자동 적용을 받지 못하므로 별칭 없이 컬럼만 참조. + ->where('opt.subtotal_earned_points_amount', '>', function ($sub) { + $sub->from('ecommerce_mileage_transactions') + ->selectRaw('COALESCE(SUM(amount), 0)') + ->whereColumn('order_option_id', 'opt.id') + ->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value); }) ->orderBy('opt.id', 'asc') ->select([ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php index e4338d52..124485d4 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php @@ -10,6 +10,7 @@ use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderOption; +use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageTransactionRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface; @@ -27,6 +28,7 @@ class OrderOptionService * @param OrderShippingRepositoryInterface $orderShippingRepository 주문 배송 Repository * @param OrderRepositoryInterface $orderRepository 주문 Repository * @param ProductReviewRepositoryInterface $productReviewRepository 상품 리뷰 Repository + * @param MileageTransactionRepositoryInterface $mileageTransactionRepository 마일리지 거래 Repository (병합 시 적립 거래 이전) */ public function __construct( protected OrderOptionRepositoryInterface $orderOptionRepository, @@ -35,6 +37,7 @@ class OrderOptionService protected ProductReviewRepositoryInterface $productReviewRepository, protected StockService $stockService, protected EcommerceSettingsService $settingsService, + protected MileageTransactionRepositoryInterface $mileageTransactionRepository, ) {} /** @@ -141,10 +144,15 @@ class OrderOptionService if ($quantity === $option->quantity) { // 전체 수량 변경 → UPDATE만 $updateData = ['option_status' => $newStatus]; - // 배송완료 진입 시점 기록 (confirmed_at 과 대칭 — 지연 적립 기준) + // 배송완료 진입 시점 기록 (지연 적립 기준) if ($newStatus === OrderStatusEnum::DELIVERED && $option->delivered_at === null) { $updateData['delivered_at'] = now(); } + // 구매확정 진입 시점 기록 (delivered_at 과 대칭 — 지연 적립·리뷰 기한 기준). + // 멱등: 이미 값이 있으면 유지(상태 재진입 시 최초 확정 시점 보존). + if ($newStatus === OrderStatusEnum::CONFIRMED && $option->confirmed_at === null) { + $updateData['confirmed_at'] = now(); + } $this->orderOptionRepository->update($option, $updateData); // 배송 정보(택배사·송장번호) 기록 — 병합 전 상태 변경 대상 옵션에 반영. @@ -187,6 +195,11 @@ class OrderOptionService if ($newStatus === OrderStatusEnum::DELIVERED && $splitOption->delivered_at === null) { $splitOption->delivered_at = now(); } + // 구매확정으로 분할 전이 시 분할 레코드에 구매확정 시점 기록 (delivered_at 과 대칭). + // 원본에서 replicate 로 계승한 delivered_at 은 유지한다(배송완료·구매확정은 둘 다 실제 발생한 사건). + if ($newStatus === OrderStatusEnum::CONFIRMED && $splitOption->confirmed_at === null) { + $splitOption->confirmed_at = now(); + } // 분할 레코드 금액 = 원본 × ratio $this->applySplitAmounts($splitOption, $origAmounts, $ratio, $quantity); @@ -226,6 +239,14 @@ class OrderOptionService // 변경 후 훅 HookManager::doAction('sirsoft-ecommerce.order_option.after_status_change', $result['original'], $newStatus, $result['split']); + // 구매확정 전이는 유저 셀프 확정(OrderService::confirmOption)과 동일하게 order-option.after_confirm 을 발화한다. + // (즉시 적립·구매확정 활동로그 등 확정 전용 부수효과가 관리자 경로에서도 대칭 동작하도록) + // 즉시 적립·로그 대상은 CONFIRMED 를 보유한 (병합 후) 생존 레코드 → merged_into > split > original 순으로 선택. + if ($newStatus === OrderStatusEnum::CONFIRMED) { + $confirmTarget = $result['merged_into'] ?? $result['split'] ?? $result['original']; + HookManager::doAction('sirsoft-ecommerce.order-option.after_confirm', $confirmTarget->order, $confirmTarget); + } + return $result; } @@ -474,6 +495,8 @@ class OrderOptionService // 1. 의존 레코드 이전 (cascade 삭제 방지) $this->orderShippingRepository->transferByOrderOptionId($consumed->id, $survivor->id); $this->productReviewRepository->transferByOrderOptionId($consumed->id, $survivor->id); + // 피흡수 옵션의 구매 적립 거래도 생존 옵션으로 이전 — 병합 후 생존 옵션 기준 적립 델타 계산 정합. + $this->mileageTransactionRepository->transferPurchaseEarnByOrderOptionId($consumed->id, $survivor->id); // 2. 수량/금액 합산 $amountFields = [ @@ -670,7 +693,13 @@ class OrderOptionService } $previousStatus = $order->order_status?->value; - $order->update(['order_status' => $newStatus->value]); + $orderUpdate = ['order_status' => $newStatus->value]; + // 주문 전체가 구매확정으로 전이되면 헤더에도 확정 시점 기록 (유저 셀프 확정 OrderService::confirmOption 과 대칭). + // 멱등: 이미 값이 있으면 유지(최초 확정 시점 보존). + if ($newStatus === OrderStatusEnum::CONFIRMED && $order->confirmed_at === null) { + $orderUpdate['confirmed_at'] = now(); + } + $order->update($orderUpdate); // 옵션별 일괄 상태변경(운송장 등)으로 부모 주문 상태가 전이되면 알림 훅 발화 (A35/A36/C3). // OrderStatusNotificationListener 가 결제완료/배송중/배송완료/구매확정 알림으로 매핑한다. diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php index 2460eecb..55ece9d9 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php @@ -284,16 +284,36 @@ class UserMileageService return null; } - if ($this->ledger->existsEarnForOption($option->id)) { - return null; - } - - $amount = (float) $option->subtotal_earned_points_amount; + // 금액 델타 멱등: 목표 적립액(옵션 subtotal_earned) − 기적립 purchase_earn 합계 = 델타만 적립. + // 나눠 확정·병합으로 목표액이 늘면 차액을 추가 지급하고, 단순 재확정·스케줄러 재실행은 델타 0 으로 no-op. + $target = (float) $option->subtotal_earned_points_amount; + $alreadyEarned = $this->ledger->sumPurchaseEarnedForOption($option->id); + $amount = $target - $alreadyEarned; if ($amount <= 0) { return null; } $currency = $this->baseCurrencyForOrder($order); + + // 방식 A: 기존 purchase_earn lot 이 있으면 그 lot 에 델타를 증액(적립 내역 한 줄 유지). + // 취소 회수(findEarnLotForOption 단일 lot 가정)·유효기간 정합을 위해 신규 lot 을 늘리지 않는다. + if ($type === MileageTransactionTypeEnum::PURCHASE_EARN + && ($existingLot = $this->ledger->findEarnLotForOption($option->id)) !== null) { + $this->ledger->incrementEarnLotAmount($existingLot, $amount); + + $this->cache->recalculateForUser($order->user_id, $currency); + $this->cache->recalculatePending($order->user_id, $currency); + + $this->logActivity('mileage.earn', [ + 'loggable' => $existingLot, + 'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn', + 'description_params' => ['amount' => (int) $amount], + 'properties' => ['order_id' => $order->id, 'order_option_id' => $option->id, 'currency' => $currency, 'delta' => (int) $amount], + ]); + + return $existingLot; + } + $expiresAt = $this->resolveEarnExpiry(); $tx = $this->ledger->createTransaction([ diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnDeltaAndGateTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnDeltaAndGateTest.php new file mode 100644 index 00000000..48716329 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnDeltaAndGateTest.php @@ -0,0 +1,228 @@ +service = app(OrderOptionService::class); + $this->writeMileageSettings(); + } + + protected function tearDown(): void + { + $file = storage_path('framework/testing/modules/sirsoft-ecommerce/settings/mileage.json'); + if (file_exists($file)) { + unlink($file); + } + parent::tearDown(); + } + + /** + * 마일리지 설정 파일을 작성합니다. (trigger=confirmed, delay 기본 0) + * + * @param array $overrides 덮어쓸 값 + */ + private function writeMileageSettings(array $overrides = []): void + { + $path = storage_path('framework/testing/modules/sirsoft-ecommerce/settings'); + if (! is_dir($path)) { + mkdir($path, 0755, true); + } + + $settings = array_merge([ + 'enabled' => true, + 'default_earn_rate' => 1, + 'earn_trigger' => 'confirmed', + 'earn_delay_days' => 0, + 'currency_rules' => [['currency_code' => 'KRW', 'point_value' => 1, 'min_use_amount' => 0, 'use_unit' => 1, 'max_use_type' => 'percent', 'max_use_percent' => 100, 'max_use_value' => 0]], + 'expiry_enabled' => true, + 'expiry_days' => 365, + 'expiry_notification_enabled' => true, + 'expiry_notification_days_before' => 7, + ], $overrides); + + file_put_contents($path.'/mileage.json', json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + } + + /** + * 배송완료 상태 옵션(적립예정 있음)을 가진 회원 주문을 생성합니다. + * + * @param int $quantity 옵션 수량 + * @param float $earnPoints 적립예정 포인트 소계 + * @return Order + */ + private function makeDeliveredOrder(int $quantity, float $earnPoints): Order + { + $user = User::factory()->create(); + $order = OrderFactory::new()->create([ + 'order_status' => OrderStatusEnum::DELIVERED, + 'user_id' => $user->id, + 'currency' => 'KRW', + ]); + OrderOptionFactory::new()->forOrder($order)->create([ + 'option_status' => OrderStatusEnum::DELIVERED, + 'delivered_at' => now()->subDay(), + 'quantity' => $quantity, + 'subtotal_earned_points_amount' => $earnPoints, + ]); + + return $order->fresh(); + } + + /** + * 해당 주문의 구매적립(purchase_earn) 총액을 반환합니다. + * + * @param int $orderId 주문 ID + * @return float + */ + private function purchaseEarnTotal(int $orderId): float + { + return (float) MileageTransaction::where('order_id', $orderId) + ->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value) + ->sum('amount'); + } + + // ---- 후속-2: 델타 멱등 ---- + + /** + * 수량 2(적립예정 200P) 를 1개씩 두 번 구매확정하면 총 200P 가 적립된다. + * (두 번째 확정이 첫 분할 레코드로 병합되어도 델타 100P 가 추가 적립되어야 함) + * + * @effects admin_split_then_merge_confirm_earns_full_delta + */ + public function test_split_then_merge_confirm_earns_full_amount(): void + { + $order = $this->makeDeliveredOrder(2, 200); + $option = $order->options->first(); + + // 1차: 1개만 구매확정 → 분할 + 100P 적립 + $this->service->bulkChangeStatusWithQuantity( + [['option_id' => $option->id, 'quantity' => 1]], + OrderStatusEnum::CONFIRMED + ); + $this->assertEqualsWithDelta(100.0, $this->purchaseEarnTotal($order->id), 0.5, '1차 확정 후 100P 적립되어야 함'); + + // 2차: 나머지 1개 구매확정 → 병합 + 델타 100P 추가 적립 + $remaining = $order->options() + ->whereIn('option_status', [OrderStatusEnum::DELIVERED->value]) + ->first(); + $this->assertNotNull($remaining, '잔여(배송완료) 레코드가 남아 있어야 함'); + + $this->service->bulkChangeStatusWithQuantity( + [['option_id' => $remaining->id, 'quantity' => 1]], + OrderStatusEnum::CONFIRMED + ); + + $this->assertEqualsWithDelta(200.0, $this->purchaseEarnTotal($order->id), 0.5, '나눠 확정 총합은 200P 여야 함'); + } + + /** + * 3분할(수량 3, 300P)을 1개씩 세 번 확정하면 누적 300P. + * + * @effects admin_three_way_split_confirm_accumulates + */ + public function test_three_way_split_confirm_accumulates_to_full(): void + { + $order = $this->makeDeliveredOrder(3, 300); + + for ($i = 0; $i < 3; $i++) { + $target = $order->fresh()->options() + ->where('option_status', OrderStatusEnum::DELIVERED->value) + ->first(); + $this->assertNotNull($target, "{$i}회차 확정 대상(배송완료)이 있어야 함"); + $this->service->bulkChangeStatusWithQuantity( + [['option_id' => $target->id, 'quantity' => 1]], + OrderStatusEnum::CONFIRMED + ); + } + + $this->assertEqualsWithDelta(300.0, $this->purchaseEarnTotal($order->id), 0.5, '3분할 누적 300P'); + } + + /** + * 전량 확정(분할 없음) 후 스케줄러/재확정 재실행해도 델타=0 이라 중복 적립 없음. + * + * @effects admin_full_confirm_delta_idempotent + */ + public function test_full_confirm_is_delta_idempotent(): void + { + $order = $this->makeDeliveredOrder(1, 150); + $option = $order->options->first(); + + $this->service->bulkChangeStatusWithQuantity( + [['option_id' => $option->id, 'quantity' => 1]], + OrderStatusEnum::CONFIRMED + ); + $this->assertEqualsWithDelta(150.0, $this->purchaseEarnTotal($order->id), 0.5); + + // 스케줄러 재실행 → 델타 0 → 추가 없음 + Artisan::call('sirsoft-ecommerce:earn-mileage'); + $this->assertEqualsWithDelta(150.0, $this->purchaseEarnTotal($order->id), 0.5, '재실행 시 중복 적립 없어야 함'); + } + + // ---- 후속-3: enabled 게이트 미지급 통일 ---- + + /** + * enabled=OFF + delay=0: 관리자 CONFIRMED 전이 시 즉시적립이 발생하지 않는다. + * + * @effects admin_confirm_no_earn_when_disabled_immediate + */ + public function test_no_immediate_earn_when_mileage_disabled(): void + { + $this->writeMileageSettings(['enabled' => false, 'earn_delay_days' => 0]); + $order = $this->makeDeliveredOrder(1, 120); + $option = $order->options->first(); + + $this->service->bulkChangeStatusWithQuantity( + [['option_id' => $option->id, 'quantity' => 1]], + OrderStatusEnum::CONFIRMED + ); + + $this->assertSame(0.0, $this->purchaseEarnTotal($order->id), '기능 OFF 면 즉시적립도 발생하지 않아야 함'); + // 전이 자체는 성공 (confirmed_at 기록) + $this->assertNotNull($option->fresh()->confirmed_at, '기능 OFF 여도 구매확정 전이·시점 기록은 정상'); + } + + /** + * enabled=ON 대조군: delay=0 즉시적립 정상 발생 (회귀 대조). + * + * @effects admin_confirm_earns_when_enabled_immediate + */ + public function test_immediate_earn_when_mileage_enabled(): void + { + $order = $this->makeDeliveredOrder(1, 120); + $option = $order->options->first(); + + $this->service->bulkChangeStatusWithQuantity( + [['option_id' => $option->id, 'quantity' => 1]], + OrderStatusEnum::CONFIRMED + ); + + $this->assertEqualsWithDelta(120.0, $this->purchaseEarnTotal($order->id), 0.5, '기능 ON 이면 즉시적립 정상'); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceConfirmTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceConfirmTest.php new file mode 100644 index 00000000..790c9bab --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceConfirmTest.php @@ -0,0 +1,270 @@ +service = app(OrderOptionService::class); + $this->writeMileageSettings(); + } + + protected function tearDown(): void + { + $file = storage_path('framework/testing/modules/sirsoft-ecommerce/settings/mileage.json'); + if (file_exists($file)) { + unlink($file); + } + parent::tearDown(); + } + + /** + * 마일리지 설정 파일을 작성합니다. (trigger=confirmed, delay 기본 0) + * + * @param array $overrides 덮어쓸 값 + */ + private function writeMileageSettings(array $overrides = []): void + { + $path = storage_path('framework/testing/modules/sirsoft-ecommerce/settings'); + if (! is_dir($path)) { + mkdir($path, 0755, true); + } + + $settings = array_merge([ + 'enabled' => true, + 'default_earn_rate' => 1, + 'earn_trigger' => 'confirmed', + 'earn_delay_days' => 0, + 'currency_rules' => [['currency_code' => 'KRW', 'point_value' => 1, 'min_use_amount' => 0, 'use_unit' => 1, 'max_use_type' => 'percent', 'max_use_percent' => 100, 'max_use_value' => 0]], + 'expiry_enabled' => true, + 'expiry_days' => 365, + 'expiry_notification_enabled' => true, + 'expiry_notification_days_before' => 7, + ], $overrides); + + file_put_contents($path.'/mileage.json', json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + } + + /** + * 배송완료 상태 옵션을 가진 주문을 생성합니다. (CONFIRMED 전이 전제 상태) + * + * @param int $quantity 옵션 수량 + * @param int|null $userId 주문자 (마일리지 적립 대상, null 이면 게스트) + * @return Order + */ + private function makeDeliveredOrder(int $quantity = 1, ?int $userId = null): Order + { + $order = OrderFactory::new()->create([ + 'order_status' => OrderStatusEnum::DELIVERED, + 'user_id' => $userId, + 'currency' => 'KRW', + ]); + OrderOptionFactory::new()->forOrder($order)->create([ + 'option_status' => OrderStatusEnum::DELIVERED, + 'delivered_at' => now()->subDay(), + 'quantity' => $quantity, + 'subtotal_earned_points_amount' => 500, + ]); + + return $order->fresh(); + } + + /** + * 전체수량 CONFIRMED 전이 시 옵션 confirmed_at 이 기록된다. + * + * @scenario transition_path=change_with_quantity, target_status=confirmed, quantity=full + * + * @effects admin_full_quantity_confirm_stamps_confirmed_at + */ + public function test_full_quantity_confirmed_stamps_confirmed_at(): void + { + $order = $this->makeDeliveredOrder(2); + $option = $order->options->first(); + + $this->service->changeStatusWithQuantity($option, OrderStatusEnum::CONFIRMED, $option->quantity); + + $fresh = $option->fresh(); + $this->assertEquals(OrderStatusEnum::CONFIRMED, $fresh->option_status); + $this->assertNotNull($fresh->confirmed_at, '전체수량 구매확정 시 confirmed_at 이 기록되어야 함'); + $this->assertEqualsWithDelta(now()->timestamp, $fresh->confirmed_at->timestamp, 5); + } + + /** + * 부분수량 CONFIRMED 분할 전이 시 분할 레코드에 confirmed_at 이 기록된다. + * (원본이 DELIVERED 였으면 분할 레코드는 delivered_at 계승 + confirmed_at 신규를 함께 보유) + * + * @scenario transition_path=change_with_quantity, target_status=confirmed, quantity=partial + * + * @effects admin_split_confirm_stamps_confirmed_at + */ + public function test_partial_quantity_confirmed_stamps_confirmed_at_on_split(): void + { + $order = $this->makeDeliveredOrder(5); + $option = $order->options->first(); + + $result = $this->service->changeStatusWithQuantity($option, OrderStatusEnum::CONFIRMED, 2); + + $split = $result['split']; + $this->assertNotNull($split, '부분수량 전이는 분할 레코드를 생성해야 함'); + $this->assertEquals(OrderStatusEnum::CONFIRMED, $split->option_status); + $this->assertNotNull($split->confirmed_at, '분할 CONFIRMED 레코드에 confirmed_at 이 기록되어야 함'); + $this->assertEqualsWithDelta(now()->timestamp, $split->confirmed_at->timestamp, 5); + // 원본에서 계승한 delivered_at 은 정상(배송완료·구매확정 둘 다 실제 발생한 사건) + $this->assertNotNull($split->delivered_at, '분할 레코드는 원본의 delivered_at 을 계승한다'); + } + + /** + * CONFIRMED 전이 시 order-option.after_confirm 훅이 발화되고, + * 인자로 CONFIRMED 를 보유한 (병합 후) 생존 레코드가 전달된다. + * + * @scenario transition_path=change_with_quantity, target_status=confirmed, quantity=full + * + * @effects admin_confirm_fires_after_confirm_hook + */ + public function test_confirmed_transition_fires_after_confirm_hook(): void + { + $captured = []; + $cb = function ($order, $option) use (&$captured) { + $captured[] = $option; + }; + HookManager::addAction('sirsoft-ecommerce.order-option.after_confirm', $cb, 10); + + $order = $this->makeDeliveredOrder(1); + $option = $order->options->first(); + + $this->service->changeStatusWithQuantity($option, OrderStatusEnum::CONFIRMED, $option->quantity); + + HookManager::removeAction('sirsoft-ecommerce.order-option.after_confirm', $cb); + + $this->assertCount(1, $captured, 'CONFIRMED 전이 시 after_confirm 훅이 1회 발화되어야 함'); + $this->assertInstanceOf(OrderOption::class, $captured[0]); + $this->assertEquals(OrderStatusEnum::CONFIRMED, $captured[0]->option_status); + } + + /** + * 분할 CONFIRMED 전이 시 after_confirm 훅 인자는 CONFIRMED 를 보유한 분할 레코드다. + * + * @scenario transition_path=change_with_quantity, target_status=confirmed, quantity=partial + * + * @effects admin_split_confirm_fires_after_confirm_hook_with_split + */ + public function test_partial_confirmed_transition_fires_after_confirm_hook_with_split(): void + { + $captured = []; + $cb = function ($order, $option) use (&$captured) { + $captured[] = $option->id; + }; + HookManager::addAction('sirsoft-ecommerce.order-option.after_confirm', $cb, 10); + + $order = $this->makeDeliveredOrder(5); + $option = $order->options->first(); + + $result = $this->service->changeStatusWithQuantity($option, OrderStatusEnum::CONFIRMED, 2); + + HookManager::removeAction('sirsoft-ecommerce.order-option.after_confirm', $cb); + + $this->assertCount(1, $captured); + $this->assertEquals($result['split']->id, $captured[0], 'after_confirm 은 CONFIRMED 를 보유한 분할 레코드를 넘겨야 함'); + } + + /** + * trigger=confirmed & delay=0 설정에서 관리자 CONFIRMED 전이 시 마일리지가 즉시 적립되고 멱등하다. + * + * @scenario transition_path=bulk_change_with_quantity, target_status=confirmed, earn_delay_days=0 + * + * @effects admin_confirm_earns_mileage_immediately_when_delay_zero + */ + public function test_confirmed_transition_earns_mileage_immediately_when_delay_zero(): void + { + $user = User::factory()->create(); + $order = $this->makeDeliveredOrder(1, $user->id); + $option = $order->options->first(); + + $this->service->bulkChangeStatusWithQuantity( + [['option_id' => $option->id, 'quantity' => $option->quantity]], + OrderStatusEnum::CONFIRMED + ); + + $this->assertSame(1, MileageTransaction::where('order_option_id', $option->id) + ->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value)->count(), + '즉시 적립(delay=0) 이 1건 발생해야 함'); + + // 스케줄러가 뒤이어 돌아도 whereNotExists(EARN_TYPES) 멱등 가드로 중복 적립 없음 + Artisan::call('sirsoft-ecommerce:earn-mileage'); + $this->assertSame(1, MileageTransaction::where('order_option_id', $option->id) + ->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value)->count(), + '즉시적립 후 스케줄러가 이중 적립하지 않아야 함'); + } + + /** + * 전체 옵션이 CONFIRMED 로 확정되면 부모 주문 헤더에도 confirmed_at 이 기록된다. + * + * @scenario transition_path=bulk_change_with_quantity, target_status=confirmed, order_fully_confirmed=true + * + * @effects admin_full_confirm_stamps_order_header_confirmed_at + */ + public function test_full_confirm_stamps_order_header_confirmed_at(): void + { + $order = $this->makeDeliveredOrder(1); + $option = $order->options->first(); + + $this->service->bulkChangeStatusWithQuantity( + [['option_id' => $option->id, 'quantity' => $option->quantity]], + OrderStatusEnum::CONFIRMED + ); + + $freshOrder = $order->fresh(); + $this->assertEquals(OrderStatusEnum::CONFIRMED, $freshOrder->order_status); + $this->assertNotNull($freshOrder->confirmed_at, '주문 전체 확정 시 주문 헤더 confirmed_at 이 기록되어야 함'); + } + + /** + * delivered_at 회귀 가드: 전체수량 DELIVERED 전이 시 delivered_at 이 기록된다. + * + * @scenario transition_path=change_with_quantity, target_status=delivered, quantity=full + * + * @effects admin_full_quantity_delivered_stamps_delivered_at + */ + public function test_full_quantity_delivered_stamps_delivered_at(): void + { + $order = OrderFactory::new()->create(['order_status' => OrderStatusEnum::SHIPPING]); + $option = OrderOptionFactory::new()->forOrder($order)->create([ + 'option_status' => OrderStatusEnum::SHIPPING, + 'quantity' => 1, + ]); + + $this->service->changeStatusWithQuantity($option->fresh(), OrderStatusEnum::DELIVERED, 1); + + $fresh = $option->fresh(); + $this->assertEquals(OrderStatusEnum::DELIVERED, $fresh->option_status); + $this->assertNotNull($fresh->delivered_at, '배송완료 전이 시 delivered_at 이 기록되어야 함'); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/order-option-status-transition.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/order-option-status-transition.yaml index 80399ad4..b8580ad3 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/order-option-status-transition.yaml +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/order-option-status-transition.yaml @@ -69,6 +69,19 @@ effects: - can_confirm_true_only_for_shipping_or_delivered_status - can_write_review_true_only_after_confirmed - confirm_option_earns_mileage_via_order_option_after_confirm + # 관리자 옵션 상태변경 → 구매확정 대칭 (이슈 #61: changeStatusWithQuantity 경로가 유저 셀프 확정과 대칭) + - admin_full_quantity_confirm_stamps_confirmed_at # 전체수량 CONFIRMED → 옵션 confirmed_at 기록 + - admin_split_confirm_stamps_confirmed_at # 부분수량 CONFIRMED 분할 → 분할 레코드 confirmed_at 기록 + - admin_confirm_fires_after_confirm_hook # CONFIRMED 전이 → order-option.after_confirm 발화(생존 레코드 인자) + - admin_confirm_earns_mileage_immediately_when_delay_zero # delay=0 즉시 적립 + 스케줄러 멱등 + - admin_full_confirm_stamps_order_header_confirmed_at # 주문 전체 확정 시 주문 헤더 confirmed_at 기록 + - admin_full_quantity_delivered_stamps_delivered_at # delivered_at 회귀 가드 + # 나눠 확정 델타 적립 + enabled 게이트 통일 (검수 후속 2·3) + - admin_split_then_merge_confirm_earns_full_delta # 나눠 확정→병합 시 목표액 델타까지 적립(총액 정확) + - admin_three_way_split_confirm_accumulates # 3분할 확정 누적 총액 + - admin_full_confirm_delta_idempotent # 전량 확정 후 재실행 델타=0 멱등 + - admin_confirm_no_earn_when_disabled_immediate # 기능 OFF 시 즉시적립도 미발생(지연과 미지급 통일) + - admin_confirm_earns_when_enabled_immediate # 기능 ON 즉시적립 대조군 # 프론트 (Vitest 레이아웃 렌더링 커버) - review_button_opens_inline_modal_not_dead_route # F1 - review_button_visible_only_when_can_write_review # F1 @@ -78,6 +91,8 @@ effects: test_files: - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceConfirmTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnDeltaAndGateTest.php - modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Resources/OrderPaymentResourceTest.php - modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/ConfirmDepositControllerTest.php - modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminOrderConfirmDeposit.test.tsx