diff --git a/CHANGELOG.md b/CHANGELOG.md index a75030fa..43ebc106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,10 @@ ### Fixed +#### 레이아웃 코드 편집 + +- 레이아웃 코드 편집 화면이 파일 목록을 불러올 때 편집하지 않는 레이아웃의 본문까지 전부 내려받던 문제를 수정했습니다. 레이아웃이 많은 템플릿에서는 목록을 여는 것만으로 수십 MB가 브라우저에 쌓여 화면이 크게 느려지거나, 디버그 모드에서는 탭이 메모리 부족으로 종료됐습니다. 이제 목록에는 이름·설명·크기·수정일만 실리고 본문은 선택한 파일만 불러옵니다. + #### 설정 화면 입력 - 설정 화면에서 여러 입력칸을 고친 뒤 다른 탭에 갔다 돌아오면, 먼저 고친 칸에 입력했던 값이 그대로 남던 문제를 수정했습니다. 저장된 값은 원래 값이라 화면에 보이는 값과 실제 값이 어긋났고, 새로고침해야 드러났습니다. 이제 탭을 오갈 때 모든 입력칸이 저장된 값으로 함께 돌아옵니다. @@ -107,6 +111,15 @@ #### 화면 표시·목록 상태 - 목록 표의 각 칸에 넣은 날짜·숫자 서식이 적용되지 않아 값이 비어 보이던 문제를 수정했습니다. 같은 서식을 일반 화면에서 쓰면 정상이었지만 목록 표의 칸 안에서는 값이 사라지거나(날짜 서식) 서식이 빠진 원래 값이 그대로 나왔습니다(숫자·대문자 서식). 목록 표와 카드 목록, 펼침 영역 등 반복해서 그려지는 모든 자리에서 서식이 정상 적용됩니다. (#87 @glitter-gim 님께서 제보해주셨습니다.) +- 위 문제와 같은 뿌리에서 비롯된 화면 표시 오류를 함께 정리했습니다. 같은 방식으로 작성한 값이 어디에 놓이느냐(목록 칸·본문·표시 조건·버튼 동작)에 따라 다르게 해석되던 것을 한 가지 기준으로 통일했으며, 아래 항목이 그 결과입니다. 대부분 오류 메시지 없이 값이 비거나 잘못 보이던 증상이라 눈치채기 어려웠습니다. +- 행마다 달라지는 조건(예: "활성 상태인 항목만 표시")을 목록에 걸면 해당하는 행만 걸러지지 않고 **목록 전체가 사라지던** 문제를 수정했습니다. 이제 행마다 조건을 따져 표시하며, 검색엔진 봇이 보는 화면에도 같은 규칙이 적용됩니다. +- 관리자 목록의 여러 개 고를 수 있는 상태·분류 필터처럼 항목 이름에 대괄호가 들어가는 값이 화면에 따라 비어 보이던 문제를 수정했습니다. 선택한 조건이 화면에 표시되지 않거나 조건 표시가 사라지던 증상이 해소됩니다. +- 값 하나를 계산하다 실패하면 그 값이 속한 영역 전체가 그려지지 않던 문제를 수정했습니다. 이제 문제가 된 자리만 비고 나머지는 정상 표시됩니다. +- 계산식에 날짜·숫자 서식을 함께 걸면 원본 값이 바뀌어도 처음 계산된 값이 계속 표시되던 문제를 수정했습니다. +- 목록 칸이나 합계 행 안에서 번역 문구가 문장 중간에 들어간 경우 번역되지 않고 원본 키가 그대로 보이던 문제, 그리고 일부 표시 값에 `{{ }}` 기호가 섞여 나오던 문제를 수정했습니다. +- 목록 칸이나 합계 행에서 '번역하지 않고 원문 그대로 표시'로 지정한 값에 보이지 않는 특수문자가 함께 출력되던 문제를 수정했습니다. 화면에서는 빈칸이나 깨진 글자로 보였고, 값을 복사해 붙여넣을 때 따라붙었습니다. +- 목록 표·카드의 셀에서 `true`/`false` 같은 값을 직접 쓴 자리가 비어 보이던 문제를 수정했습니다. 같은 값을 표시 조건에 쓰면 정상 동작해서, 같은 작성이 놓인 자리에 따라 갈렸습니다. +- 레이아웃 코드 편집 화면의 파일 목록과 선택한 파일 설명에 번역되지 않은 내부 표기(`$t:…` 나 `{{ … }}`)가 그대로 보이던 문제를 수정했습니다. 다른 템플릿의 레이아웃을 편집할 때 그 템플릿의 번역을 찾지 못해 생긴 문제로, 이제 해당 템플릿의 번역으로 표시하며 번역을 찾을 수 없으면 파일 이름을 대신 보여줍니다. - 목록 화면에서 글이나 항목을 열어 보고 돌아올 때 보고 있던 페이지·검색어·필터가 사라지던 문제를 화면 엔진 차원에서 함께 수정했습니다. '현재 주소의 조건을 유지한다'고 지정해도 덧붙일 값이 없으면 유지가 아예 동작하지 않아 조건이 통째로 사라지던 동작을 바로잡았습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) #### 확장·데이터베이스 안정성 diff --git a/app/Contracts/Repositories/LayoutRepositoryInterface.php b/app/Contracts/Repositories/LayoutRepositoryInterface.php index 153ca2a8..20ece537 100644 --- a/app/Contracts/Repositories/LayoutRepositoryInterface.php +++ b/app/Contracts/Repositories/LayoutRepositoryInterface.php @@ -6,6 +6,7 @@ use App\Enums\LayoutSourceType; use App\Models\TemplateLayout; use App\Models\TemplateLayoutVersion; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Collection as SupportCollection; interface LayoutRepositoryInterface { @@ -17,6 +18,17 @@ interface LayoutRepositoryInterface */ public function getByTemplateId(int $templateId): Collection; + /** + * 목록 표시용 경량 레이아웃 행 조회 + * + * 본문(`content`)을 반환에 포함하지 않는다 — 설명·크기 등 본문 파생값만 계산해 담는다. + * 편집 대상 본문은 상세 조회가 제공한다. + * + * @param int $templateId 템플릿 ID + * @return Collection 목록 행 배열 컬렉션 + */ + public function getListByTemplateId(int $templateId): SupportCollection; + /** * 특정 레이아웃 조회 (템플릿 ID와 이름으로) * diff --git a/app/Http/Controllers/Api/Admin/LayoutController.php b/app/Http/Controllers/Api/Admin/LayoutController.php index 79b456a0..cd732266 100644 --- a/app/Http/Controllers/Api/Admin/LayoutController.php +++ b/app/Http/Controllers/Api/Admin/LayoutController.php @@ -6,6 +6,7 @@ use App\Exceptions\ConcurrentModificationException; use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Requests\Layout\StoreLayoutPreviewRequest; use App\Http\Requests\Layout\UpdateLayoutContentRequest; +use App\Http\Resources\LayoutListResource; use App\Http\Resources\LayoutResource; use App\Http\Resources\LayoutVersionResource; use App\Services\LayoutPreviewService; @@ -38,15 +39,26 @@ class LayoutController extends AdminBaseController return $this->notFound('common.not_found'); } - $layouts = $this->layoutService->getLayoutsByTemplateId($template->id); + // 목록은 본문(`content`)을 담지 않는 경량 조회를 쓴다 — 파일 목록 화면은 이름·설명· + // 크기·수정일만 표시하고, 편집 대상 본문은 상세 엔드포인트가 따로 제공한다. + // 종전에는 목록·상세가 같은 Resource 를 공용해 102개 레이아웃의 본문이 전부 실렸고 + // (응답 18.30MB 중 content 17.41MB), 디버그 모드에서 렌더러가 메모리 한계를 넘었다. + $layouts = $this->layoutService->getLayoutListByTemplateId($template->id); // 레이아웃 이름 → 라우트 path 매핑 — 코드 편집기가 파일 선택 시 ?route= URL // 동기화 / 위지윅에서 넘어온 ?route= 로 해당 파일 복원에 사용한다. $routePathMap = $this->templateService->getLayoutRoutePathMap($templateName); - $collection = LayoutResource::collection($layouts); + // 레이아웃 설명(`meta.description`)은 그 레이아웃을 소유한 템플릿의 사전 키를 쓴다. + // 코드 편집 화면은 관리자 템플릿 사전으로 렌더하므로 유저 템플릿 키를 알지 못해, + // 해석하지 않고 내보내면 설명 칸에 `$t:user.…` 가 원문으로 노출된다. + $translations = $this->resolveTemplateTranslations($templateName); + + $collection = LayoutListResource::collection($layouts); $collection->collection->transform( - fn (LayoutResource $resource) => $resource->withRoutePathMap($routePathMap) + fn (LayoutListResource $resource) => $resource + ->withRoutePathMap($routePathMap) + ->withTranslations($translations) ); return $this->success('common.success', $collection); @@ -75,7 +87,8 @@ class LayoutController extends AdminBaseController return $this->success( 'common.success', - new LayoutResource($layout) + (new LayoutResource($layout)) + ->withTranslations($this->resolveTemplateTranslations($templateName)) ); } @@ -104,7 +117,8 @@ class LayoutController extends AdminBaseController return $this->success( 'common.success', - new LayoutResource($layout) + (new LayoutResource($layout)) + ->withTranslations($this->resolveTemplateTranslations($templateName)) ); } catch (ConcurrentModificationException $e) { DB::rollBack(); @@ -259,4 +273,25 @@ class LayoutController extends AdminBaseController ); } } + + /** + * 목록 설명 해석에 쓸 소유 템플릿 사전을 로드합니다. + * + * 활성 로케일 기준이며, 로드에 실패하면 빈 배열을 돌려준다 — 그 경우 + * {@see LayoutListResource} 가 레이아웃 이름으로 폴백하므로 목록은 계속 그려진다. + * + * @param string $templateName 템플릿 식별자 + * @return array 템플릿 프론트엔드 다국어 데이터 + */ + private function resolveTemplateTranslations(string $templateName): array + { + $result = $this->templateService->getLanguageDataWithModules( + $templateName, + app()->getLocale() + ); + + return ($result['success'] ?? false) && is_array($result['data'] ?? null) + ? $result['data'] + : []; + } } diff --git a/app/Http/Resources/LayoutListResource.php b/app/Http/Resources/LayoutListResource.php new file mode 100644 index 00000000..bf8ddb71 --- /dev/null +++ b/app/Http/Resources/LayoutListResource.php @@ -0,0 +1,130 @@ + 레이아웃 이름 → 라우트 path 매핑 */ + private array $routePathMap = []; + + /** @var array 소유 템플릿의 프론트엔드 다국어 사전 (점 표기 중첩) */ + private array $translations = []; + + /** + * 라우트 path 매핑을 주입합니다. + * + * @param array $routePathMap 레이아웃 이름 → 라우트 path + * @return $this + */ + public function withRoutePathMap(array $routePathMap): self + { + $this->routePathMap = $routePathMap; + + return $this; + } + + /** + * 설명 해석에 쓸 소유 템플릿 사전을 주입합니다. + * + * @param array $translations 템플릿 프론트엔드 다국어 데이터 + * @return $this + */ + public function withTranslations(array $translations): self + { + $this->translations = $translations; + + return $this; + } + + /** + * 목록 행을 배열로 변환합니다. + * + * @param Request $request 요청 객체 + * @return array 목록 행 데이터 + */ + public function toArray(Request $request): array + { + $name = $this->getValue('name'); + $size = (int) $this->getValue('size', 0); + + return [ + 'id' => $this->getValue('id'), + 'template_id' => $this->getValue('template_id'), + 'name' => $name, + 'description' => LayoutDescription::resolve($this->getValue('description'), $name, $this->translations), + + // 이 레이아웃을 사용하는 라우트의 path (routes.json 기준). 파일 선택 시 ?route= + // 동기화 / 위지윅에서 넘어온 ?route= 로 해당 파일 복원에 사용. + 'route_path' => $this->routePathMap[$name] ?? null, + + 'size' => $size, + 'size_formatted' => $this->formatFileSize($size), + 'has_update' => false, + + // 낙관적 잠금 — 편집 진입 후 저장 요청에 expected_lock_version 으로 전달 + 'lock_version' => (int) $this->getValue('lock_version', 0), + + // 상세 응답(LayoutResource)의 formatTimestamps() 와 같은 규약 — 사용자 timezone + // 기준 문자열이다. 원시 ISO 를 그대로 내보내면 목록의 수정일 표기만 형식이 달라진다. + 'created_at' => $this->formatDateTimeStringForUser($this->toCarbon($this->getValue('created_at'))), + 'updated_at' => $this->formatDateTimeStringForUser($this->toCarbon($this->getValue('updated_at'))), + ]; + } + + /** + * 값을 Carbon 인스턴스로 변환합니다. + * + * 경량 조회는 배열 행을 넘기므로 값이 Carbon 일 수도, 문자열일 수도, null 일 수도 있다. + * + * @param mixed $value 변환할 값 + * @return Carbon|null 변환된 Carbon 또는 null + */ + private function toCarbon(mixed $value): ?Carbon + { + if ($value === null || $value === '') { + return null; + } + + return $value instanceof Carbon ? $value : Carbon::parse($value); + } + + /** + * 파일 크기를 사람이 읽는 단위로 변환합니다. + * + * @param int $bytes 바이트 수 + * @return string 변환된 크기 문자열 + */ + private function formatFileSize(int $bytes): string + { + if ($bytes < 1024) { + return $bytes.' B'; + } + + if ($bytes < 1048576) { + return round($bytes / 1024, 1).' KB'; + } + + return round($bytes / 1048576, 1).' MB'; + } +} diff --git a/app/Http/Resources/LayoutResource.php b/app/Http/Resources/LayoutResource.php index c7c2e53c..c8be3f80 100644 --- a/app/Http/Resources/LayoutResource.php +++ b/app/Http/Resources/LayoutResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Support\LayoutDescription; use Illuminate\Http\Request; class LayoutResource extends BaseApiResource @@ -16,6 +17,22 @@ class LayoutResource extends BaseApiResource */ protected array $routePathMap = []; + /** @var array 소유 템플릿의 프론트엔드 다국어 사전 */ + protected array $translations = []; + + /** + * 설명 해석에 쓸 소유 템플릿 사전을 주입합니다. + * + * @param array $translations 템플릿 프론트엔드 다국어 데이터 + * @return $this + */ + public function withTranslations(array $translations): static + { + $this->translations = $translations; + + return $this; + } + /** * 레이아웃 이름 → 라우트 path 매핑을 주입합니다. * @@ -45,7 +62,9 @@ class LayoutResource extends BaseApiResource 'id' => $this->getValue('id'), 'template_id' => $this->getValue('template_id'), 'name' => $name, - 'description' => $content['meta']['description'] ?? $name, + // 설명은 소유 템플릿 사전 키를 쓰므로 서버가 해석한다 — 목록(LayoutListResource)과 + // 같은 규칙이어야 파일 목록과 선택 파일 헤더의 표기가 어긋나지 않는다. + 'description' => LayoutDescription::resolve($content['meta']['description'] ?? null, $name, $this->translations), 'endpoint' => $content['endpoint'] ?? null, // 이 레이아웃을 사용하는 라우트의 path (routes.json 기준). 코드 편집기가 // 파일 선택 시 ?route= 동기화 / 위지윅에서 넘어온 ?route= 복원에 사용. diff --git a/app/Repositories/LayoutRepository.php b/app/Repositories/LayoutRepository.php index 67cf71c9..8a214465 100644 --- a/app/Repositories/LayoutRepository.php +++ b/app/Repositories/LayoutRepository.php @@ -7,9 +7,13 @@ use App\Enums\LayoutSourceType; use App\Models\TemplateLayout; use App\Models\TemplateLayoutVersion; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Collection as SupportCollection; class LayoutRepository implements LayoutRepositoryInterface { + /** @var int 목록 조회 시 본문을 흘려 읽는 청크 크기 */ + private const LIST_CHUNK_SIZE = 25; + /** * 특정 템플릿의 모든 레이아웃 조회 * @@ -23,6 +27,55 @@ class LayoutRepository implements LayoutRepositoryInterface ->get(); } + /** + * 목록 표시용 경량 레이아웃 행을 조회합니다. + * + * 목록 화면은 이름·설명·크기·수정일만 쓰고 본문은 상세 엔드포인트가 따로 제공한다. + * 그런데 `getByTemplateId()` 로 전체 컬럼을 읽으면 `content`(레이아웃 본문)까지 전부 + * 메모리에 올라간다 — sirsoft-admin_basic 기준 102행 합계 17MB 이상이다. + * + * 여기서는 chunk 로 흘려 읽으며 본문에서 파생되는 값(설명·크기)만 뽑고 본문 자체는 + * 즉시 버린다. 결과적으로 반환 컬렉션에 본문이 남지 않고, PHP 피크 메모리도 청크 + * 단위로 묶인다. + * + * @param int $templateId 대상 템플릿 ID + * @return Collection 목록 행 배열 (id, template_id, name, description, size, lock_version, created_at, updated_at) + */ + public function getListByTemplateId(int $templateId): SupportCollection + { + $rows = collect(); + + // 순회 커서는 기본키다 — `chunkById` 는 `where id > lastId` 로 다음 페이지를 잡으므로 + // 여기에 `orderBy('name')` 을 함께 걸면 정렬 순서와 커서 기준이 어긋나 행이 조용히 + // 누락된다(실측: 102행 중 58행만 반환). 표시 정렬은 순회를 마친 뒤에 적용한다. + TemplateLayout::query() + ->where('template_id', $templateId) + ->select(['id', 'template_id', 'name', 'content', 'lock_version', 'created_at', 'updated_at']) + ->chunkById(self::LIST_CHUNK_SIZE, function (Collection $chunk) use ($rows) { + foreach ($chunk as $layout) { + $content = $layout->content; + $contentJson = is_array($content) + ? json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + : '{}'; + + $rows->push([ + 'id' => $layout->id, + 'template_id' => $layout->template_id, + 'name' => $layout->name, + // 본문에서 파생 — 이 값만 남기고 본문은 버린다 + 'description' => $content['meta']['description'] ?? null, + 'size' => strlen($contentJson), + 'lock_version' => $layout->lock_version, + 'created_at' => $layout->created_at, + 'updated_at' => $layout->updated_at, + ]); + } + }); + + // 표시 정렬은 순회와 분리 — 커서 기준(id)과 섞으면 행이 누락된다 + return $rows->sortBy('name', SORT_STRING)->values(); + } + /** * 특정 레이아웃 조회 (템플릿 ID와 이름으로) * diff --git a/app/Seo/ComponentHtmlMapper.php b/app/Seo/ComponentHtmlMapper.php index c2420e64..07a237d5 100644 --- a/app/Seo/ComponentHtmlMapper.php +++ b/app/Seo/ComponentHtmlMapper.php @@ -235,7 +235,13 @@ class ComponentHtmlMapper $component = $this->applyResponsiveOverrides($component); // 조건부 렌더링 (if / condition / conditions) - if (! $this->shouldRender($component, $context, $evaluator)) { + // + // iteration 이 있으면 여기서 끊지 않는다. 조건이 항목 변수(`{{user.is_active}}` 등)를 + // 참조하면 이 시점 컨텍스트에는 그 변수가 없어 항상 거짓이 되고, 목록 전체가 + // 사라진다. 항목별 컨텍스트에서 renderIteration 이 같은 조건을 다시 평가한다 + // (iteration 만 제거한 정의를 넘기므로 `if` 는 그대로 남는다). + // React DynamicRenderer 와 동일한 순서 — 한쪽만 고치면 봇 화면에서만 목록이 빈다. + if (! isset($component['iteration']) && ! $this->shouldRender($component, $context, $evaluator)) { return ''; } @@ -1654,6 +1660,8 @@ class ComponentHtmlMapper $iterContext = array_merge($context, [ $itemVar => $item, $indexVar => $index, + // `{item_var}_index` 자동 변수 — React 반복 렌더 경로와 동일하게 제공한다. + $itemVar.'_index' => $index, ]); $html .= $this->renderComponent($templateComponent, $iterContext, $evaluator); } diff --git a/app/Services/LayoutService.php b/app/Services/LayoutService.php index 0e5fe794..4cc5e8cd 100644 --- a/app/Services/LayoutService.php +++ b/app/Services/LayoutService.php @@ -1350,6 +1350,28 @@ class LayoutService return $layouts; } + /** + * 목록 표시용 경량 레이아웃 행을 조회합니다. + * + * 본문을 반환에 담지 않으므로 파일 목록 화면이 편집 대상이 아닌 레이아웃의 본문까지 + * 내려받는 일이 없다. 편집 대상 본문은 {@see getLayoutByName()} 이 제공한다. + * + * @param int $templateId 대상 템플릿 ID + * @return \Illuminate\Support\Collection 목록 행 배열 컬렉션 + */ + public function getLayoutListByTemplateId(int $templateId) + { + // Before 훅 - 레이아웃 목록 조회 전 + HookManager::doAction('core.layout.before_index', $templateId); + + $rows = $this->layoutRepository->getListByTemplateId($templateId); + + // After 훅 - 레이아웃 목록 조회 후 + HookManager::doAction('core.layout.after_index', $rows, $templateId); + + return $rows; + } + /** * 특정 레이아웃 조회 (이름으로) * diff --git a/app/Support/LayoutDescription.php b/app/Support/LayoutDescription.php new file mode 100644 index 00000000..0e51b23c --- /dev/null +++ b/app/Support/LayoutDescription.php @@ -0,0 +1,56 @@ + $translations 소유 템플릿의 프론트엔드 다국어 데이터 + * @return string 표시용 설명 + */ + public static function resolve(mixed $description, string $name, array $translations = []): string + { + if (! is_string($description) || $description === '') { + return $name; + } + + // 런타임 컨텍스트(`route` 등)가 필요한 식은 이 시점에 해석할 수 없다. + // 예: `{{route.id ? '$t:board.edit' : '$t:board.new'}}` + if (str_contains($description, '{{')) { + return $name; + } + + if (! str_starts_with($description, self::TRANSLATION_PREFIX)) { + return $description; + } + + $key = substr($description, strlen(self::TRANSLATION_PREFIX)); + $resolved = data_get($translations, $key); + + return is_string($resolved) && $resolved !== '' ? $resolved : $name; + } +} diff --git a/docs/ai-tools/agents/src/prompts/template.ts b/docs/ai-tools/agents/src/prompts/template.ts index 322932f8..0907cb93 100644 --- a/docs/ai-tools/agents/src/prompts/template.ts +++ b/docs/ai-tools/agents/src/prompts/template.ts @@ -89,8 +89,8 @@ php artisan core:build 용도: DynamicRenderer가 해당 props의 바인딩을 지연 처리 예시: -- DataGrid: cellChildren, expandChildren, expandContext, render -- CardGrid: cardChildren +- DataGrid: cellChildren, expandChildren, expandContext, render, subRowChildren, subRowCondition, footerCells, footerCardChildren +- CardGrid: cardColumns - Select: optionRenderer \`\`\` diff --git a/docs/backend/api/templates.md b/docs/backend/api/templates.md index 527a2d1f..90df19cc 100644 --- a/docs/backend/api/templates.md +++ b/docs/backend/api/templates.md @@ -2155,13 +2155,60 @@ Accept: application/json Authorization: Bearer {YOUR_TOKEN} ``` -**응답 필드** (`data` 내부) +**응답 필드** (`data` 내부, 배열의 각 항목) - +| 필드 | 타입 | 설명 | +| --- | --- | --- | +| id | integer | 레이아웃 ID | +| template_id | integer | 소속 템플릿 ID | +| name | string | 레이아웃 이름 (예: `_admin_base`, `admin_user_list`) | +| description | string | 레이아웃 설명. 본문의 `meta.description` 에서 파생한다. 그 값은 레이아웃을 **소유한 템플릿**의 사전 키(`$t:user.base_layout_description` 등)일 수 있으므로 서버가 해당 템플릿 사전(활성 로케일)으로 해석해 내보낸다. 사전에 키가 없거나 `{{...}}` 표현식이어서 해석할 수 없으면 레이아웃 **이름**으로 폴백한다 — 번역 토큰이나 표현식 원문이 화면에 노출되지 않는다. 목록·상세 응답이 같은 규칙을 쓴다 | +| route_path | string\|null | 이 레이아웃을 사용하는 라우트 path (routes.json 기준). 매핑이 없으면 null | +| size | integer | 본문 크기(바이트) | +| size_formatted | string | 사람이 읽는 크기 표기 (예: `176.9 KB`) | +| has_update | boolean | 템플릿 파일 대비 갱신 여부 (현재 항상 false) | +| lock_version | integer | 낙관적 잠금 버전. 편집 저장 시 `expected_lock_version` 으로 전달 | +| created_at | string | 생성 일시 | +| updated_at | string | 최종 수정 일시 | + +목록 응답에는 본문(`content`)과 본문에서 파생되는 구조(`components` · `data_sources` · `metadata` · `endpoint`)가 **포함되지 않습니다**. 파일 목록 화면은 위 필드만 사용하고, 편집 대상 본문은 상세 엔드포인트(`GET .../layouts/{name}`)가 제공합니다. 목록에 본문을 함께 실으면 레이아웃 수에 비례해 응답이 커져(실측: 102개 기준 18MB) 브라우저 메모리를 압박합니다. **응답 예시** - +```json +{ + "success": true, + "message": "요청이 성공했습니다.", + "data": [ + { + "id": 1, + "template_id": 1, + "name": "_admin_base", + "description": "관리자 기본 레이아웃", + "route_path": null, + "size": 181146, + "size_formatted": "176.9 KB", + "has_update": false, + "lock_version": 3, + "created_at": "2026-07-28T09:35:50.000000Z", + "updated_at": "2026-07-28T09:35:50.000000Z" + }, + { + "id": 2, + "template_id": 1, + "name": "admin_user_list", + "description": "사용자 관리", + "route_path": "*/admin/users", + "size": 42130, + "size_formatted": "41.1 KB", + "has_update": false, + "lock_version": 0, + "created_at": "2026-07-28T09:35:50.000000Z", + "updated_at": "2026-07-28T09:35:50.000000Z" + } + ] +} +``` **에러 응답** @@ -2173,7 +2220,7 @@ Authorization: Bearer {YOUR_TOKEN} -**설명** 특정 템플릿의 모든 레이아웃 목록을 조회합니다. 각 레이아웃에 이름 → 라우트 path 매핑을 부착해, 코드 편집기가 파일 선택 시 `?route=` URL 동기화나 위지윅에서 넘어온 라우트로 파일을 복원할 수 있게 합니다(LayoutService::getLayoutsByTemplateId). `core.templates.read` 권한이 필요합니다. +**설명** 특정 템플릿의 모든 레이아웃 목록을 조회합니다. 각 레이아웃에 이름 → 라우트 path 매핑을 부착해, 코드 편집기가 파일 선택 시 `?route=` URL 동기화나 위지윅에서 넘어온 라우트로 파일을 복원할 수 있게 합니다(LayoutService::getLayoutListByTemplateId). 목록은 본문을 담지 않는 경량 조회를 사용합니다. `core.templates.read` 권한이 필요합니다. ### GET /api/admin/templates/{templateName}/layouts/{name} diff --git a/docs/frontend/components-patterns.md b/docs/frontend/components-patterns.md index fd15f237..da34cfb3 100644 --- a/docs/frontend/components-patterns.md +++ b/docs/frontend/components-patterns.md @@ -298,8 +298,8 @@ DynamicRenderer가 props를 처리할 때 `{{...}}` 바인딩 표현식을 즉 | 컴포넌트 | skipBindingKeys | 이유 | |----------|-----------------|------| -| DataGrid | `cellChildren`, `expandChildren`, `expandContext`, `render` | row 컨텍스트로 평가 필요 | -| CardGrid | `cardChildren` | item 컨텍스트로 평가 필요 | +| DataGrid | `cellChildren`, `expandChildren`, `expandContext`, `render`, `subRowChildren`, `subRowCondition`, `footerCells`, `footerCardChildren` | row 컨텍스트로 평가 필요 | +| CardGrid | `cardColumns` | item 컨텍스트로 평가 필요 | ### 관련 파일 diff --git a/docs/frontend/templates/sirsoft-admin_basic/components.md b/docs/frontend/templates/sirsoft-admin_basic/components.md index 96cff166..a1478fff 100644 --- a/docs/frontend/templates/sirsoft-admin_basic/components.md +++ b/docs/frontend/templates/sirsoft-admin_basic/components.md @@ -125,7 +125,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| | `DataGrid` | 정렬/필터링/페이지네이션 데이터 그리드 | columns, data, sortable, pagination, pageSize, ... | -| `CardGrid` | 카드 그리드 레이아웃 (스켈레톤 로딩, 페이지네이션) | data, cardChildren, columns, gap, responsiveColumns, ... | +| `CardGrid` | 카드 그리드 레이아웃 (스켈레톤 로딩, 페이지네이션) | data, cardColumns, columns, gap, responsiveColumns, ... | | `Pagination` | 페이지네이션 | currentPage, totalPages, onPageChange, maxVisiblePages, showFirstLast, ... | | `Badge` | 색상 기반 라벨 뱃지 | color, text, size, style | | `StatusBadge` | 상태 뱃지 (아이콘 포함) | status, label, showIcon, iconName, style | diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 6276b040..bd5ec181 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -10,6 +10,7 @@ - 무통장입금 자동취소 기한을 비운 채 저장할 때 나오는 안내 문구 일본어 번역 추가. - 유효기간이 설정되지 않은 쿠폰을 발급할 때 나오는 안내 문구 일본어 번역 추가. +- 마일리지 설정의 통화별 적립 절사 기준(단위·방식) 항목명과 선택지, 허용값 위반 안내 문구 일본어 번역 추가. ### Fixed diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php index c0e72585..287e114e 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php @@ -1296,6 +1296,8 @@ return [ 'mileage.currency_rules.*.use_unit' => '使用単位', 'mileage.currency_rules.*.max_use_percent' => '最大使用率', 'mileage.currency_rules.*.max_use_value' => '最大使用金額', + 'mileage.currency_rules.*.earn_rounding_unit' => '積立端数処理単位', + 'mileage.currency_rules.*.earn_rounding_method' => '積立端数処理方式', 'mileage.expiry_days' => '有効期限', 'mileage.expiry_notification_days_before' => '失効予定通知日', 'country_settings' => '国別設定', @@ -1714,6 +1716,12 @@ return [ 'numeric' => '1ポイント当たりの金額は数字である必要があります。', 'min' => '1ポイント当たりの金額は0より大きい必要があります。', ], + 'earn_rounding_unit' => [ + 'in' => '積立端数処理単位は1、10、100のいずれかである必要があります。', + ], + 'earn_rounding_method' => [ + 'in' => '積立端数処理方式はfloor、round、ceilのいずれかである必要があります。', + ], 'max_use_value' => [ 'integer' => '最大使用金額は整数である必要があります。', 'min' => '最大使用金額は0以上である必要があります。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/mileage_settings.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/mileage_settings.json index 2812b7be..e4e26c09 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/mileage_settings.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/mileage_settings.json @@ -39,7 +39,19 @@ "empty": "登録された通貨ルールはありません。", "code_placeholder": "例: USD", "duplicate_currency_error": "すでに登録されている通貨です。", - "max_use_fixed_suffix": "ウォン" + "max_use_fixed_suffix": "ウォン", + "earn_rounding": "積立端数処理", + "earn_rounding_hint": "積立ポイントの算出・按分時に適用", + "earn_rounding_units": { + "1": "1ポイント", + "10": "10ポイント", + "100": "100ポイント" + }, + "earn_rounding_methods": { + "floor": "切り捨て", + "round": "四捨五入", + "ceil": "切り上げ" + } }, "expiry": { "title": "有効期限", diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 7246fc03..18dd5c24 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -21,6 +21,7 @@ - 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. - 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다. - 이커머스 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시). +- 마일리지 설정의 통화별 규칙에 적립 절사 기준(1점 / 10점 / 100점 단위 × 버림 / 반올림 / 올림)을 추가했습니다. 적립 포인트를 계산할 때와 부분취소로 주문이 나뉠 때 모두 이 기준이 적용됩니다. 기존 쇼핑몰은 지금까지와 같은 "1점 단위 버림"으로 시작하므로 적립액이 달라지지 않습니다. 이 기준은 주문 시점의 값이 함께 보관되어, 이후 기준을 바꿔도 과거 주문의 적립액이 소급해서 바뀌지 않습니다. 언어/통화 설정의 환율 절사와는 별개 항목입니다(그쪽은 외화 표시 환산에만 적용됩니다). ### Changed @@ -34,6 +35,13 @@ ### Fixed +- 모바일·태블릿 화면에서 쿠폰 목록의 카드 보기가 내용 없이 나오던 문제를 수정했습니다. 목록의 각 쿠폰 정보가 카드에 전달되지 않아, 쿠폰이 있어도 빈 카드만 표시됐습니다. +- 주문 검색 프리셋 관리 창에서 "프리셋 추가" 버튼을 눌러도 저장 창이 열리지 않던 문제를 수정했습니다. +- 상품 리뷰 목록의 작성자 이름과 쿠폰 목록의 쿠폰 이름을 키보드로 선택해 이동할 수 있게 했습니다. 이전에는 마우스 클릭으로만 이동할 수 있었습니다. +- 주문 검색 프리셋 관리 창의 수정·삭제 아이콘이 의도한 크기보다 작게 보이던 문제를 수정했습니다. +- 할인코드 목록에서 선택 개수·전체 건수·페이지당 표시 개수 문구에 숫자가 채워지지 않고 자리표시자가 그대로 보이던 문제를 수정했습니다. +- 마이페이지와 비회원 주문 조회의 주문 상세에서 상품별 배송정책명과 개별 배송비가 아무 안내 없이 표시되지 않던 문제를 수정했습니다. 주문에 보관된 배송정책 기록의 저장 형태가 화면이 기대하는 형태와 달라 해당 줄이 통째로 빠져 있었습니다. 이미 저장된 주문도 업그레이드 시 함께 정리됩니다. +- 비회원 주문 조회의 주문 상세에서 상품별 배송정책명과 개별 배송비가 표시되지 않던 문제를 추가로 수정했습니다. 회원 주문 상세와 같은 화면 구성을 쓰는데 비회원 응답에만 배송정책 정보가 빠져 있어, 오류 없이 그 줄만 비어 있었습니다. 표시에 필요한 정책명과 배송비만 내려주며 내부 계산 정보는 종전대로 제공하지 않습니다. - 미입금 자동취소 기한을 비운 채 저장하면 "저장되었습니다"라고 안내되면서도 값은 사라지던 문제를 수정했습니다. 숫자 항목에 숫자가 아닌 값을 입력하면 입력칸이 비워지는데, 그대로 저장하면 설정이 없는 상태로 남아 화면에 보이는 값과 실제 동작이 어긋났습니다. 이제 비워 둔 채로는 저장되지 않고 값을 입력하라고 안내합니다. - 미입금 자동취소 기한 입력 안내가 실제 입력 하한과 다른 "0일 이상"으로 표시되던 문구를 "1일 이상"으로 바로잡았습니다. - 쿠폰 수정 화면에서 유효기간을 "발급일로부터"로 두고 일수를 비운 채 저장할 수 있던 문제를 수정했습니다. 그렇게 저장된 쿠폰은 발급되는 즉시 만료 상태가 되어, 받은 회원이 사용할 수 없으면서도 아무 오류가 나지 않았습니다. 이제 저장 단계에서 일수를 입력하도록 안내하고, 이미 그렇게 저장돼 있던 쿠폰은 발급 시 유효기간이 설정되지 않았다고 알려 줍니다. diff --git a/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json b/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json index 824d8773..fb974294 100644 --- a/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json +++ b/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json @@ -303,7 +303,9 @@ "use_unit": 10, "max_use_type": "fixed", "max_use_percent": 30, - "max_use_value": 50000 + "max_use_value": 50000, + "earn_rounding_unit": "1", + "earn_rounding_method": "floor" } ], "expiry_enabled": true, diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md index 2d97bdd7..f9dfed9f 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md @@ -497,7 +497,8 @@ _단건 응답: `data` 객체의 필드._ | shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) | | cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) | | promotions_applied_snapshot | object | `{"coupon_issue_ids":[7330],"item_coupons":[],"discount_co…` | 적용된 프로모션 스냅샷 (재계산용) | -| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) | +| 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` | 생성 일시 | @@ -1523,7 +1524,7 @@ _단건 응답: `data` 객체의 필드._ | shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) | | cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) | | promotions_applied_snapshot | null | `null` | 적용된 프로모션 스냅샷 (재계산용) | -| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) | +| shipping_policy_applied_snapshot | object | `{"items": [], "address": {}}` | 적용된 배송정책 스냅샷 (재계산용). `items` 는 옵션별 적용 정책 목록(각 항목: `product_option_id`, `policy`), `address` 는 주문 시점 배송지 메타(`country_code`, `zipcode`). 항목이 없어도 `items` 는 빈 배열이다 | | admin_memo | null | `null` | 관리자 메모 (내부 관리용) | | customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) | | created_at | string | `2026-07-08T01:44:49+00:00` | 생성 일시 | @@ -2206,7 +2207,7 @@ _단건 응답: `data` 객체의 필드._ | shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) | | cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) | | promotions_applied_snapshot | null | `null` | 적용된 프로모션 스냅샷 (재계산용) | -| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) | +| shipping_policy_applied_snapshot | object | `{"items": [], "address": {}}` | 적용된 배송정책 스냅샷 (재계산용). `items` 는 옵션별 적용 정책 목록(각 항목: `product_option_id`, `policy`), `address` 는 주문 시점 배송지 메타(`country_code`, `zipcode`). 항목이 없어도 `items` 는 빈 배열이다 | | admin_memo | null | `null` | 관리자 메모 (내부 관리용) | | customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) | | created_at | string | `2026-07-08T01:44:49+00:00` | 생성 일시 | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md index 643854ab..4ed45540 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md @@ -864,7 +864,9 @@ HTTP/1.1 200 "use_unit": 10, "max_use_type": "fixed", "max_use_percent": 30, - "max_use_value": 50000 + "max_use_value": 50000, + "earn_rounding_unit": "1", + "earn_rounding_method": "floor" } ], "expiry_enabled": true, @@ -1905,7 +1907,9 @@ HTTP/1.1 200 "use_unit": 10, "max_use_type": "fixed", "max_use_percent": 30, - "max_use_value": 50000 + "max_use_value": 50000, + "earn_rounding_unit": "1", + "earn_rounding_method": "floor" } ], "expiry_enabled": true, @@ -2056,6 +2060,19 @@ HTTP/1.1 200 - 정규화 제외: 비숫자 문자열(`"abc"`), 빈 문자열, `null`, 불리언, 그리고 `integer` 필드에 전달된 소수 문자열(`"3.7"`) — 검증을 느슨하게 만들지 않기 위해 캐스트하지 않고 그대로 검증 실패시킵니다 - 조회(`GET`) 응답도 `defaults.json` 스키마의 숫자 타입으로 정규화되어 반환되므로, 저장 시점의 표현 형태와 무관하게 숫자로 수신됩니다 +**마일리지 적립 절사 기준** `mileage.currency_rules.*` 의 두 필드가 적립 포인트 산출·안분의 절사 기준을 정합니다. + +| 필드 | 타입 | 허용값 | 기본값 | 설명 | +| --- | --- | --- | --- | --- | +| `earn_rounding_unit` | string | `1` · `10` · `100` | `1` | 적립 포인트를 맞출 단위(점). 마일리지는 원장에 정수로 확정되므로 소수 단위는 허용하지 않습니다 | +| `earn_rounding_method` | string | `floor` · `round` · `ceil` | `floor` | 단위에 맞출 방식(버림 / 반올림 / 올림) | + +- 적용 지점: 옵션 정액 적립 · 옵션 정률 적립 · 기본 적립률 세 갈래 전부, 그리고 부분취소로 주문옵션이 분할될 때의 적립액 안분 +- 기준 통화 선택: 마일리지는 표시 통화가 아니라 기준 통화(`currency_snapshot.base_currency`)로 적립·정산되므로, 그 통화의 규칙을 사용하고 없으면 첫 규칙(기본 통화)으로 폴백합니다 +- 주문 시점 고정: 주문 생성 시 `mileage_policy_snapshot.rule` 에 함께 기록되며, 부분취소·추가결제 재계산은 현재 설정이 아니라 이 스냅샷을 사용합니다. 그렇지 않으면 이후 설정 변경이 과거 주문에 소급돼, 취소하지 않은 잔여분의 적립액이 취소 처리만으로 달라집니다 +- 값이 없는 경우(이 필드 도입 이전 설치본·주문): 기본값 `1` / `floor` 로 해석되며 이는 도입 이전 동작과 동일한 금액을 산출합니다 +- 통화 환산 절사(`language_currency.currencies.*.rounding_unit` / `rounding_method`)와는 별개입니다 — 그쪽은 외화 표시 환산에만 적용되어 기본 통화에는 적용되지 않으므로 적립 규칙으로 쓸 수 없습니다 + ### PUT /api/modules/sirsoft-ecommerce/admin/settings/banks - **라우트명**: `api.modules.sirsoft-ecommerce.admin.settings.store-banks` @@ -2574,7 +2591,9 @@ HTTP/1.1 200 "use_unit": 10, "max_use_type": "fixed", "max_use_percent": 30, - "max_use_value": 50000 + "max_use_value": 50000, + "earn_rounding_unit": "1", + "earn_rounding_method": "floor" } ], "expiry_enabled": true, diff --git a/modules/_bundled/sirsoft-ecommerce/editor-spec.json b/modules/_bundled/sirsoft-ecommerce/editor-spec.json index 7882bc1d..071bf224 100644 --- a/modules/_bundled/sirsoft-ecommerce/editor-spec.json +++ b/modules/_bundled/sirsoft-ecommerce/editor-spec.json @@ -1361,8 +1361,20 @@ } }, "shipping_policy_applied_snapshot": { - "policy_name": "기본 배송 정책", - "shipping_amount": 3000 + "items": [ + { + "product_option_id": 9001, + "policy": { + "policy_name": "기본 배송 정책", + "standalone_shipping_amount": 3000, + "standalone_shipping_amount_formatted": "3,000원" + } + } + ], + "address": { + "country_code": "KR", + "zipcode": "06234" + } } } }, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx index 121d99c6..3d41d1d4 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx @@ -291,9 +291,11 @@ describe('DataGrid (_transactions_table.json)', () => { expect(grid.props.data).toContain('transactions?.data?.data'); }); - it('컬럼 8종(no/일시/회원/유형/금액/잔여/유효기간/주문/부여)', () => { + it('컬럼 9종(번호/일시/회원/유형/금액/잔여/유효기간/주문/부여)', () => { + // 첫 컬럼 field 는 `number` 다 (표시 헤더는 "NO"). 종전 `no` 표기는 레이아웃에서 + // 이미 바뀌었는데 이 단언만 남아 있던 stale 케이스였다. const fields = grid.props.columns.map((c: any) => c.field); - expect(fields).toEqual(expect.arrayContaining(['no', 'created_at', 'member', 'type', 'amount', 'remaining', 'expiry', 'order', 'granted_by'])); + expect(fields).toEqual(expect.arrayContaining(['number', 'created_at', 'member', 'type', 'amount', 'remaining', 'expiry', 'order', 'granted_by'])); }); it('유효기간 컬럼이 expiry_state 기준 소멸(일부/전체)·만료예정·무기한·없음을 렌더한다', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx index c896ec5b..bd2504f9 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx @@ -247,19 +247,19 @@ describe('통화 규칙 테이블 (_tab_mileage_currency_table.json)', () => { expect(percentInput.props.disabled).toContain("rule.max_use_type !== 'percent'"); }); - it('max_use_type 라디오는 부모 Label 클릭으로 상태를 바꾼다 (게시판 검증 패턴 — 라디오 자동바인딩 회피)', () => { + it('max_use_type 라디오는 Label 클릭과 키보드 change 양쪽으로 같은 상태를 만든다', () => { // 회귀: 라디오 자동바인딩은 문자열 값을 value 바인딩으로 처리해 라디오 그룹을 깨뜨린다. - // 게시판 date_display_format 의 검증된 패턴: 라디오는 pointer-events-none(시각 전용), - // 부모 Label 에 click 액션을 달아 해당 행(rule._idx)의 max_use_type 을 배열 map 교체로 갱신. + // 그래서 Label click 으로 상태를 갱신한다. 다만 라디오를 pointer-events-none 으로만 두면 + // 키보드 방향키(change)로 옮겼을 때 화면 표시와 저장될 값이 어긋난다(#493) — + // 라디오 자체에도 click 과 **동일한 params** 를 갖는 change 액션을 둔다. const radios = findAll(currencyTable, (n) => n.name === 'Input' && n.props?.type === 'radio' && String(n.props?.name).includes('max_use_type')); // currency_rules 행(2) + 인라인 추가 행(2) = 4 expect(radios.length).toBe(4); for (const radio of radios) { - // 라디오는 클릭 이벤트를 받지 않는다 (부모 Label 이 처리) - expect(radio.props.className, `radio(${radio.props.value}) 에 pointer-events-none 누락`).toContain('pointer-events-none'); - // 라디오 자체에는 actions 가 없다 (자동바인딩/onChange 미사용) - expect(radio.actions ?? []).toHaveLength(0); + const change = (radio.actions ?? []).find((a: any) => a.type === 'change' && a.handler === 'setState'); + expect(change, `radio(${radio.props.value}) 에 키보드 조작용 change 액션 누락`).toBeTruthy(); + expect(change.params.target).toBe('local'); } // 부모 Label 이 click → setState 로 max_use_type 갱신 @@ -349,6 +349,67 @@ describe('통화 규칙 모바일 카드 (_tab_mileage_currency_cards.json)', () }); }); +describe('적립 절사 기준 (earn_rounding_unit / earn_rounding_method)', () => { + const 절사_컨트롤 = (root: any, 접두: string) => ({ + unit: findFirst(root, (n) => n.name === 'Select' && String(n.props?.name).includes(`${접두}earn_rounding_unit`)), + method: findFirst(root, (n) => n.name === 'Select' && String(n.props?.name).includes(`${접두}earn_rounding_method`)), + }); + + it('테이블 행에 단위/방식 Select 가 통화 규칙 인덱스로 바인딩된다', () => { + const { unit, method } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '."); + + expect(unit).toBeTruthy(); + expect(method).toBeTruthy(); + // 값이 없는 기존 행도 종전 동작(1점 버림)으로 보이도록 폴백을 둔다 + expect(unit.props.value).toContain("?? '1'"); + expect(method.props.value).toContain("?? 'floor'"); + }); + + it('절사 방식 옵션은 서버 허용 어휘(floor/round/ceil)와 일치한다', () => { + const { method } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '."); + + // 화면 옵션이 서버 게이트를 벗어나면 저장이 422 로 막히는데 화면에는 선택지로 보인다 + expect(method.props.options.map((o: any) => o.value)).toEqual(['floor', 'round', 'ceil']); + }); + + it('절사 단위 옵션은 정수 포인트 단위만 제공한다 (통화 환산 절사의 소수 단위와 다름)', () => { + const { unit } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '."); + const opts = String(unit.props.options); + + expect(opts).toContain("value: '1'"); + expect(opts).toContain("value: '10'"); + expect(opts).toContain("value: '100'"); + // 마일리지는 원장에 정수로 확정된다 — 0.01 같은 소수 단위는 선택지가 될 수 없다 + expect(opts).not.toContain("value: '0.01'"); + }); + + it('모바일 카드 뷰에도 동일 항목이 있다 (한쪽만 있으면 모바일에서 설정 불가)', () => { + const { unit, method } = 절사_컨트롤(currencyCards, "mileage.currency_rules.' + rule._idx + '."); + + expect(unit).toBeTruthy(); + expect(method).toBeTruthy(); + }); + + it.each([ + ['테이블', currencyTable, 'new_mileage_currency_row'], + ['모바일 카드', currencyCards, 'new_mileage_currency_card'], + ])('%s 의 신규 통화 추가 행에도 절사 컨트롤이 있고 저장 payload 에 포함된다', (_label, root, rowId) => { + const newRow = findById(root, rowId); + const { unit, method } = 절사_컨트롤(newRow, 'newMileageCurrency.'); + + expect(unit).toBeTruthy(); + expect(method).toBeTruthy(); + + const seq = findFirst(newRow, (n) => n.handler === 'sequence'); + const actions = seq.actions ?? seq.params?.actions ?? []; + const push = actions.find((a: any) => a.handler === 'setState' && a.params?.['form.mileage.currency_rules']); + + // 컨트롤만 두고 payload 에 빠뜨리면 입력해도 저장되지 않는다 + expect(String(push.params['form.mileage.currency_rules'])).toContain('earn_rounding_unit:'); + expect(String(push.params['form.mileage.currency_rules'])).toContain('earn_rounding_method:'); + }); +}); + describe('유효기간 / 소멸 알림 카드', () => { it('expiry_enabled / expiry_notification_enabled 토글 바인딩', () => { const expiryToggle = findFirst(expiryCard, (n) => n.name === 'Toggle' && n.props?.name === 'mileage.expiry_enabled'); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/mileage_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/mileage_settings.json index 4de0e4f9..164b53ef 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/mileage_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/mileage_settings.json @@ -35,7 +35,19 @@ "empty": "No currency rules registered.", "code_placeholder": "e.g. USD", "duplicate_currency_error": "This currency is already registered.", - "max_use_fixed_suffix": "" + "max_use_fixed_suffix": "", + "earn_rounding": "Earning Rounding", + "earn_rounding_hint": "Applied when earned points are calculated and prorated", + "earn_rounding_units": { + "1": "1 pt", + "10": "10 pts", + "100": "100 pts" + }, + "earn_rounding_methods": { + "floor": "Round down", + "round": "Round", + "ceil": "Round up" + } }, "expiry": { "title": "Expiry", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/mileage_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/mileage_settings.json index a0995471..d3bf4264 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/mileage_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/mileage_settings.json @@ -35,7 +35,19 @@ "empty": "등록된 통화 규칙이 없습니다.", "code_placeholder": "예: USD", "duplicate_currency_error": "이미 등록된 통화입니다.", - "max_use_fixed_suffix": "원" + "max_use_fixed_suffix": "원", + "earn_rounding": "적립 절사", + "earn_rounding_hint": "적립 포인트 산출·안분 시 적용", + "earn_rounding_units": { + "1": "1점", + "10": "10점", + "100": "100점" + }, + "earn_rounding_methods": { + "floor": "버림", + "round": "반올림", + "ceil": "올림" + } }, "expiry": { "title": "유효기간", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_review_index.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_review_index.json index dc6c827b..3c326c9b 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_review_index.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_review_index.json @@ -1434,7 +1434,7 @@ "name": "Button", "props": { "type": "button", - "className": "px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed", + "className": "px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed", "disabled": "{{!_local.batchAction || (_local.selectedReviews ?? []).length === 0}}" }, "text": "$t:sirsoft-ecommerce.admin.product_review.batch.apply", @@ -1718,6 +1718,7 @@ "name": "A", "if": "{{!!row.user_id}}", "props": { + "href": "#", "className": "text-blue-600 dark:text-blue-400 hover:underline cursor-pointer font-medium" }, "text": "{{row.user?.name ?? ''}}", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_promotion_discount_code_index.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_promotion_discount_code_index.json index 82bec05a..c33280df 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_promotion_discount_code_index.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_promotion_discount_code_index.json @@ -67,7 +67,7 @@ "name": "Icon", "props": { "name": "percent", - "className": "w-6 h-6 text-gray-700 dark:text-gray-300" + "className": "text-2xl text-gray-700 dark:text-gray-300" } }, { @@ -107,7 +107,7 @@ "name": "Icon", "props": { "name": "plus", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -214,7 +214,7 @@ "name": "Icon", "props": { "name": "search", - "className": "w-4 h-4 absolute right-3 top-1/2 -translate-y-1/2 icon-muted pointer-events-none" + "className": "text-base absolute right-3 top-1/2 -translate-y-1/2 icon-muted pointer-events-none" } } ] @@ -239,7 +239,7 @@ "name": "Icon", "props": { "name": "settings-2", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -270,7 +270,7 @@ "name": "Icon", "props": { "name": "filter", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -776,7 +776,7 @@ "name": "Icon", "props": { "name": "calendar", - "className": "w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 icon-muted pointer-events-none" + "className": "text-base absolute left-3 top-1/2 -translate-y-1/2 icon-muted pointer-events-none" } }, { @@ -821,7 +821,7 @@ "name": "Icon", "props": { "name": "calendar", - "className": "w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 icon-muted pointer-events-none" + "className": "text-base absolute left-3 top-1/2 -translate-y-1/2 icon-muted pointer-events-none" } }, { @@ -938,7 +938,7 @@ "name": "Icon", "props": { "name": "search", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -961,7 +961,7 @@ "name": "Icon", "props": { "name": "refresh-cw", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -1010,7 +1010,7 @@ "props": { "className": "text-sm text-secondary" }, - "text": "0$t:sirsoft-ecommerce.admin.promotion_discount_code.batch.selected_count" + "text": "$t:sirsoft-ecommerce.admin.promotion_discount_code.batch.selected_count|count=0" }, { "type": "composite", @@ -1088,7 +1088,7 @@ "props": { "className": "text-sm text-secondary" }, - "text": "$t:sirsoft-ecommerce.admin.promotion_discount_code.table.total_count" + "text": "$t:sirsoft-ecommerce.admin.promotion_discount_code.table.total_count|count=2" }, { "type": "basic", @@ -1144,15 +1144,15 @@ "options": [ { "value": "10", - "label": "10$t:sirsoft-ecommerce.admin.promotion_discount_code.table.per_page" + "label": "$t:sirsoft-ecommerce.admin.promotion_discount_code.table.per_page|count=10" }, { "value": "20", - "label": "20$t:sirsoft-ecommerce.admin.promotion_discount_code.table.per_page" + "label": "$t:sirsoft-ecommerce.admin.promotion_discount_code.table.per_page|count=20" }, { "value": "50", - "label": "50$t:sirsoft-ecommerce.admin.promotion_discount_code.table.per_page" + "label": "$t:sirsoft-ecommerce.admin.promotion_discount_code.table.per_page|count=50" } ] }, @@ -1238,7 +1238,7 @@ "name": "Icon", "props": { "name": "more-horizontal", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -1386,7 +1386,7 @@ "name": "Icon", "props": { "name": "eye", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -1481,7 +1481,7 @@ "name": "Icon", "props": { "name": "more-horizontal", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -1629,7 +1629,7 @@ "name": "Icon", "props": { "name": "eye", - "className": "w-3 h-3" + "className": "text-xs" } }, { @@ -2016,7 +2016,7 @@ "name": "Icon", "props": { "name": "more-horizontal", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -2209,7 +2209,7 @@ "name": "Icon", "props": { "name": "more-horizontal", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -2253,7 +2253,7 @@ "name": "Icon", "props": { "name": "chevrons-left", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -2272,7 +2272,7 @@ "name": "Icon", "props": { "name": "chevron-left", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -2317,7 +2317,7 @@ "name": "Icon", "props": { "name": "chevron-right", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -2335,7 +2335,7 @@ "name": "Icon", "props": { "name": "chevrons-right", - "className": "w-4 h-4" + "className": "text-base" } } ] diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json index ec7d4d4b..6cfc3877 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json @@ -513,6 +513,7 @@ "type": "basic", "name": "Button", "props": { + "data-testid": "ecommerce-settings-save", "disabled": "{{_computed.isReadOnly || !_local.hasChanges || _local.isSaving}}", "className": "btn btn-primary flex-center gap-1.5" }, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_index/_modal_preset_manage.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_index/_modal_preset_manage.json index 6ed7ed74..51a22c92 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_index/_modal_preset_manage.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_index/_modal_preset_manage.json @@ -58,7 +58,7 @@ "name": "Icon", "props": { "name": "edit", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -76,7 +76,7 @@ "name": "Icon", "props": { "name": "trash-2", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -120,7 +120,7 @@ "name": "Icon", "props": { "name": "edit", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -138,7 +138,7 @@ "name": "Icon", "props": { "name": "trash-2", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -165,9 +165,7 @@ { "type": "click", "handler": "openModal", - "params": { - "modalId": "modal_preset_save" - } + "target": "modal_preset_save" } ] } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_partial_coupon_datagrid.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_partial_coupon_datagrid.json index b2e125f9..768dd69e 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_partial_coupon_datagrid.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_list/_partial_coupon_datagrid.json @@ -73,7 +73,7 @@ "name": "Button", "props": { "type": "button", - "className": "px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50", + "className": "px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 font-medium transition-colors disabled:opacity-50", "disabled": "{{!(_local.selectedItems || []).length || !_local.bulkIssueStatus || coupons?.data?.abilities?.can_update !== true}}" }, "text": "$t:sirsoft-ecommerce.admin.promotion_coupon.bulk.apply", @@ -303,6 +303,7 @@ "type": "basic", "name": "A", "props": { + "href": "#", "className": "text-blue-600 dark:text-blue-400 hover:underline cursor-pointer font-medium" }, "text": "{{row.localized_name}}", @@ -708,7 +709,7 @@ { "iteration": { "source": "coupons?.data?.data || []", - "item": "coupon", + "item_var": "coupon", "key": "coupon.id" }, "children": [ diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_cards.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_cards.json index ba54f8c8..98f46c46 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_cards.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_cards.json @@ -336,6 +336,75 @@ ] } ] + }, + { + "comment": "적립 절사 기준 — 데스크톱 테이블 뷰와 동일 항목 (한쪽만 두면 모바일에서 설정 불가)", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" + }, + "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-2" + }, + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_unit'}}", + "value": "{{rule.earn_rounding_unit ?? '1'}}", + "options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}", + "className": "flex-1", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "{{'mileage-earn-rounding-unit-' + (rule.currency_code ?? rule._idx)}}" + } + }, + { + "type": "composite", + "name": "Select", + "props": { + "name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_method'}}", + "value": "{{rule.earn_rounding_method ?? 'floor'}}", + "options": [ + { + "value": "floor", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor" + }, + { + "value": "round", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round" + }, + { + "value": "ceil", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil" + } + ], + "className": "flex-1", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "{{'mileage-earn-rounding-method-' + (rule.currency_code ?? rule._idx)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-xs text-gray-400 dark:text-gray-500 mt-1" + }, + "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_hint" + } + ] } ] }, @@ -618,6 +687,66 @@ } ] }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" + }, + "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-2" + }, + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "name": "newMileageCurrency.earn_rounding_unit", + "value": "{{_local.form?.newMileageCurrency?.earn_rounding_unit ?? '1'}}", + "options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}", + "className": "flex-1", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "mileage-new-earn-rounding-unit" + } + }, + { + "type": "composite", + "name": "Select", + "props": { + "name": "newMileageCurrency.earn_rounding_method", + "value": "{{_local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor'}}", + "options": [ + { + "value": "floor", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor" + }, + { + "value": "round", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round" + }, + { + "value": "ceil", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil" + } + ], + "className": "flex-1", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "mileage-new-earn-rounding-method" + } + } + ] + } + ] + }, { "type": "basic", "name": "Div", @@ -657,7 +786,7 @@ "name": "Button", "props": { "type": "button", - "className": "flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50", + "className": "flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50", "disabled": "{{_computed.isReadOnly}}" }, "actions": [ @@ -686,7 +815,7 @@ "if": "{{_local.form?.newMileageCurrency?.currency_code?.trim() && !((_local.form?.mileage?.currency_rules ?? []).some(c => (c.currency_code || '').toUpperCase() === (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim()))}}", "params": { "target": "local", - "form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0 }]}}", + "form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0, earn_rounding_unit: _local.form?.newMileageCurrency?.earn_rounding_unit ?? '1', earn_rounding_method: _local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor' }]}}", "isAddingMileageCurrency": false, "form.newMileageCurrency": null, "hasChanges": true diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_table.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_table.json index ccfe196c..a2056984 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_table.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_table.json @@ -50,6 +50,12 @@ "name": "Th", "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.max_use" }, + { + "comment": "적립 절사 기준 — 언어/통화 탭의 환산 절사와 별개(그쪽은 외화 표시 전용)", + "type": "basic", + "name": "Th", + "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding" + }, { "type": "basic", "name": "Th", @@ -377,6 +383,70 @@ } ] }, + { + "comment": "적립 절사 기준 (단위 + 방식). 미설정 행은 1점 버림으로 표시 — 종전 동작과 같다.", + "type": "basic", + "name": "Td", + "props": { + "className": "align-top" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-1" + }, + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_unit'}}", + "value": "{{rule.earn_rounding_unit ?? '1'}}", + "options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}", + "className": "w-24", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "{{'mileage-earn-rounding-unit-' + (rule.currency_code ?? rule._idx)}}" + } + }, + { + "type": "composite", + "name": "Select", + "props": { + "name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_method'}}", + "value": "{{rule.earn_rounding_method ?? 'floor'}}", + "options": [ + { + "value": "floor", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor" + }, + { + "value": "round", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round" + }, + { + "value": "ceil", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil" + } + ], + "className": "w-24", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "{{'mileage-earn-rounding-method-' + (rule.currency_code ?? rule._idx)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-xs text-gray-400 dark:text-gray-500 mt-1" + }, + "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_hint" + } + ] + }, { "type": "basic", "name": "Td", @@ -707,6 +777,61 @@ } ] }, + { + "type": "basic", + "name": "Td", + "props": { + "className": "align-top" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-1" + }, + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "name": "newMileageCurrency.earn_rounding_unit", + "value": "{{_local.form?.newMileageCurrency?.earn_rounding_unit ?? '1'}}", + "options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}", + "className": "w-24", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "mileage-new-earn-rounding-unit" + } + }, + { + "type": "composite", + "name": "Select", + "props": { + "name": "newMileageCurrency.earn_rounding_method", + "value": "{{_local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor'}}", + "options": [ + { + "value": "floor", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor" + }, + { + "value": "round", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round" + }, + { + "value": "ceil", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil" + } + ], + "className": "w-24", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "mileage-new-earn-rounding-method" + } + } + ] + } + ] + }, { "type": "basic", "name": "Td", @@ -755,7 +880,7 @@ "if": "{{_local.form?.newMileageCurrency?.currency_code?.trim() && !((_local.form?.mileage?.currency_rules ?? []).some(c => (c.currency_code || '').toUpperCase() === (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim()))}}", "params": { "target": "local", - "form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0 }]}}", + "form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0, earn_rounding_unit: _local.form?.newMileageCurrency?.earn_rounding_unit ?? '1', earn_rounding_method: _local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor' }]}}", "isAddingMileageCurrency": false, "form.newMileageCurrency": null, "hasChanges": true diff --git a/modules/_bundled/sirsoft-ecommerce/src/DTO/CalculationInput.php b/modules/_bundled/sirsoft-ecommerce/src/DTO/CalculationInput.php index b0d6d56c..d7895607 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/DTO/CalculationInput.php +++ b/modules/_bundled/sirsoft-ecommerce/src/DTO/CalculationInput.php @@ -25,6 +25,7 @@ class CalculationInput * @param array|null $shippingPolicySnapshots 배송정책 스냅샷 (환불 재계산용) * @param array|null $promotionSnapshots 프로모션 스냅샷 (환불 재계산용) * @param int|null $userId 주문 사용자 ID (per_user_limit 과거 사용 검증용, 비회원은 null) + * @param array|null $mileagePolicySnapshot 마일리지 정책 스냅샷 (환불 재계산 시 적립 절사 기준 고정용) */ public function __construct( public array $items = [], @@ -38,6 +39,7 @@ class CalculationInput public ?array $shippingPolicySnapshots = null, public ?array $promotionSnapshots = null, public ?int $userId = null, + public ?array $mileagePolicySnapshot = null, ) {} /** @@ -70,6 +72,7 @@ class CalculationInput shippingPolicySnapshots: $data['shipping_policy_snapshots'] ?? null, promotionSnapshots: $data['promotion_snapshots'] ?? null, userId: $data['user_id'] ?? null, + mileagePolicySnapshot: $data['mileage_policy_snapshot'] ?? null, ); } @@ -92,6 +95,7 @@ class CalculationInput 'shipping_policy_snapshots' => $this->shippingPolicySnapshots, 'promotion_snapshots' => $this->promotionSnapshots, 'user_id' => $this->userId, + 'mileage_policy_snapshot' => $this->mileagePolicySnapshot, ]; } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php index 2aa94ffa..dac453c1 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php @@ -9,6 +9,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService; use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver; +use Modules\Sirsoft\Ecommerce\Support\MileageRounding; /** * 이커머스 설정 저장 요청 검증 @@ -311,6 +312,11 @@ class StoreEcommerceSettingsRequest extends FormRequest 'mileage.currency_rules.*.max_use_percent' => ['nullable', 'numeric', 'min:'.config('sirsoft-ecommerce.limits.max_use_percent_min', 0), 'max:'.config('sirsoft-ecommerce.limits.max_use_percent_max', 100)], // 최대 사용한도(고정금액): 비합리적 거액 방지를 위해 10억 상한 'mileage.currency_rules.*.max_use_value' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.max_use_value_min', 0), 'max:'.config('sirsoft-ecommerce.limits.max_use_value_max', 1000000000)], + // 적립 절사 기준 — 통화 환산 절사(language_currency.currencies.*.rounding_*)와 별개다. + // 그쪽은 외화 표시 전용이라 기본 통화에는 적용되지 않는 반면, 적립은 기본 통화 원장에 + // 확정 기록되는 값이므로 자기 규칙을 갖는다. 어휘는 MileageRounding 이 SSoT. + 'mileage.currency_rules.*.earn_rounding_unit' => ['nullable', 'string', 'in:'.implode(',', MileageRounding::UNITS)], + 'mileage.currency_rules.*.earn_rounding_method' => ['nullable', 'string', 'in:'.implode(',', MileageRounding::METHODS)], 'mileage.expiry_enabled' => ['nullable', 'boolean'], 'mileage.expiry_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.expiry_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.expiry_days_max', 3650)], 'mileage.expiry_notification_enabled' => ['nullable', 'boolean'], diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/GuestOrderResource.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/GuestOrderResource.php index c9dac9cf..1869a199 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/GuestOrderResource.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/GuestOrderResource.php @@ -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(), ]; diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderResource.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderResource.php index c4abdbd7..36a0524d 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderResource.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderResource.php @@ -6,6 +6,7 @@ use App\Helpers\PermissionHelper; use App\Http\Resources\BaseApiResource; use Illuminate\Http\Request; use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices; +use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot; /** * 주문 상세 리소스 @@ -188,7 +189,12 @@ class OrderResource extends BaseApiResource // 프로모션/배송정책 스냅샷 'promotions_applied_snapshot' => $this->promotions_applied_snapshot, - 'shipping_policy_applied_snapshot' => $this->shipping_policy_applied_snapshot, + // 항상 신형 구조(`{items: [...], address: {...}}`)로 정규화해 내보낸다. + // 백필 전 구형 행이 남아 있어도 화면은 한 형태만 보면 되고, `items` 가 JSON + // 배열로 직렬화되므로 프론트의 `.find(...)` 가 안전하다. + 'shipping_policy_applied_snapshot' => ShippingPolicySnapshot::normalize( + $this->shipping_policy_applied_snapshot + ), // 메모 'admin_memo' => $this->admin_memo, diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php index dd3c0173..6dc0d8ef 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php @@ -15,6 +15,7 @@ use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; use Modules\Sirsoft\Ecommerce\Enums\RefundPriorityEnum; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot; use Modules\Sirsoft\Ecommerce\Support\VatCalculator; /** @@ -391,8 +392,9 @@ class OrderAdjustmentService // 배송지 복원 $shippingAddress = null; $shippingSnapshot = $order->shipping_policy_applied_snapshot ?? []; - if (! empty($shippingSnapshot['address'])) { - $shippingAddress = ShippingAddress::fromArray($shippingSnapshot['address']); + $snapshotAddress = ShippingPolicySnapshot::address($shippingSnapshot); + if (! empty($snapshotAddress)) { + $shippingAddress = ShippingAddress::fromArray($snapshotAddress); } // 배송정책 스냅샷을 product_option_id 키 맵으로 변환 @@ -416,6 +418,9 @@ class OrderAdjustmentService ], shippingPolicySnapshots: $shippingPolicySnapshots, promotionSnapshots: $order->promotions_applied_snapshot, + // 적립 절사 기준은 주문 시점 값으로 고정한다 — 설정을 다시 읽으면 운영자가 이후에 + // 바꾼 기준이 소급 적용돼, 취소하지 않은 잔여분의 적립액이 취소 처리만으로 달라진다. + mileagePolicySnapshot: $order->mileage_policy_snapshot, ); } @@ -909,25 +914,10 @@ class OrderAdjustmentService */ private function buildShippingPolicySnapshotMap(array $shippingSnapshot): array { - $map = []; - - foreach ($shippingSnapshot as $key => $entry) { - // 이미 product_option_id를 키로 사용하는 경우 (숫자 키 + policy 데이터) - if (is_int($key) && isset($entry['product_option_id'], $entry['policy'])) { - $map[$entry['product_option_id']] = $entry['policy']; - } elseif (is_int($key) && isset($entry['policy_id'])) { - // product_option_id 키 맵 형태 (테스트 헬퍼 등에서 직접 구성한 경우) - $map[$key] = $entry; - } elseif ($key === 'address') { - // address 키는 스킵 (배송지 정보) - continue; - } else { - // 기타 (이미 product_option_id => data 형태) - $map[$key] = $entry; - } - } - - return $map; + // 형태 판별은 ShippingPolicySnapshot 단일 출처에 위임한다 — 종전에는 이 메서드와 + // OrderCancellationService::buildShippingSnapshot() 이 `is_int($key)` 판별을 + // 각자 복제하고 있어, 구조가 바뀔 때 한쪽만 고쳐질 위험이 있었다. + return ShippingPolicySnapshot::toOptionPolicyMap($shippingSnapshot); } /** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php index 893fdb92..2338de1e 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php @@ -37,6 +37,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterf use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductAdditionalOptionValueRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingPolicyRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\MileageRounding; use Modules\Sirsoft\Ecommerce\Support\VatCalculator; /** @@ -316,7 +317,8 @@ class OrderCalculationService $pointsPerItem = $this->calculatePointsEarning( $preparedItems, $itemsAfterOrderDiscount, - $pointsUsageResult + $pointsUsageResult, + $input ); $pointsPerItem = HookManager::applyFilters( 'sirsoft-ecommerce.calculation.after_points_earning', @@ -855,12 +857,20 @@ class OrderCalculationService * @param array $preparedItems 준비된 아이템 배열 * @param array $discountedItems 할인 후 아이템 배열 (주문쿠폰 적용 후) * @param array $pointsUsageResult 마일리지 사용 결과 (points_by_option 포함) + * @param CalculationInput|null $input 계산 입력 (재계산 시 주문 시점 절사 기준 복원용) * @return array 옵션별 마일리지 배열 [product_option_id => points] */ - protected function calculatePointsEarning(array $preparedItems, array $discountedItems, array $pointsUsageResult = []): array + protected function calculatePointsEarning(array $preparedItems, array $discountedItems, array $pointsUsageResult = [], ?CalculationInput $input = null): array { $pointsPerItem = []; + // 적립 절사 기준. 재계산(부분취소·추가결제)은 주문 스냅샷을 우선한다 — 설정을 다시 읽으면 + // 운영자가 이후에 바꾼 절사 기준이 과거 주문에 소급 적용돼, 취소하지 않은 잔여분의 + // 적립액이 취소 처리만으로 달라진다. + $rounding = $input?->mileagePolicySnapshot !== null + ? MileageRounding::fromOrderSnapshot($input->mileagePolicySnapshot) + : MileageRounding::normalize($this->resolveMileageCurrencyRule($input)); + foreach ($preparedItems as $item) { $optionId = $item['product_option_id']; $option = $item['product_option']; @@ -890,21 +900,52 @@ class OrderCalculationService if ($option->mileage_value !== null && $option->mileage_type !== null) { if ($option->mileage_type === 'fixed') { // 정액 적립: mileage_value를 그대로 사용 (수량 곱) - $pointsPerItem[$optionId] = (int) floor($option->mileage_value * $item['quantity']); + $pointsPerItem[$optionId] = MileageRounding::apply($option->mileage_value * $item['quantity'], $rounding); } else { // 정률 적립: earnableAmount 기준으로 계산 - $pointsPerItem[$optionId] = (int) floor($earnableAmount * $option->mileage_value / 100); + $pointsPerItem[$optionId] = MileageRounding::apply($earnableAmount * $option->mileage_value / 100, $rounding); } } else { // 기본 마일리지 적립율 (설정값, 기본 1%) $defaultRate = (float) $this->settingsService->getSetting('mileage.default_earn_rate', 1); - $pointsPerItem[$optionId] = (int) floor($earnableAmount * $defaultRate / 100); + $pointsPerItem[$optionId] = MileageRounding::apply($earnableAmount * $defaultRate / 100, $rounding); } } return $pointsPerItem; } + /** + * 적립 절사 기준을 담은 통화별 마일리지 규칙을 조회합니다. + * + * 마일리지는 표시통화가 아니라 기준통화(base_currency)로 적립·정산되므로, 규칙도 + * 기준통화 기준으로 고른다. 스냅샷 모드에서는 주문 시점 기준통화를, 신규 주문에서는 + * 현재 기본 통화를 쓴다. 해당 통화 규칙이 없으면 첫 행(기본 통화 규칙)으로 폴백한다 — + * `UserMileageService::currencyRule()` 과 같은 해석 규칙이다. + * + * @param CalculationInput|null $input 계산 입력 + * @return array|null 통화별 마일리지 규칙 (없으면 null → 절사 기본값 적용) + */ + protected function resolveMileageCurrencyRule(?CalculationInput $input): ?array + { + $rules = (array) $this->settingsService->getSetting('mileage.currency_rules', []); + + if ($rules === []) { + return null; + } + + $currency = $input->metadata['currency_snapshot']['base_currency'] + ?? $this->currencyService->getDefaultCurrency(); + + foreach ($rules as $rule) { + if (is_array($rule) && ($rule['currency_code'] ?? null) === $currency) { + return $rule; + } + } + + return is_array($rules[0] ?? null) ? $rules[0] : null; + } + /** * 단계 2-b: 과세/면세 금액을 분류합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php index 324f6012..e3f14cb5 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php @@ -32,6 +32,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterf use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundOptionRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot; /** * 주문 취소 서비스 @@ -472,7 +473,7 @@ class OrderCancellationService protected function buildShippingSnapshot(Order $order, array $cancelItems): array { $orderSnapshot = $order->shipping_policy_applied_snapshot ?? []; - $address = $orderSnapshot['address'] ?? []; + $address = ShippingPolicySnapshot::address($orderSnapshot); // 주문 스냅샷에 배송지가 없으면 현재 배송주소에서 폴백 복원(과거 주문 호환). if (empty($address['country_code'])) { @@ -492,13 +493,8 @@ class OrderCancellationService } } - $policies = []; - foreach ($orderSnapshot as $key => $entry) { - if (is_int($key) && isset($entry['product_option_id'], $entry['policy']) - && isset($cancelOptionIds[$entry['product_option_id']])) { - $policies[] = $entry; - } - } + // 형태 판별은 ShippingPolicySnapshot 단일 출처에 위임 (구형 혼합 배열도 흡수). + $policies = ShippingPolicySnapshot::itemsForOptions($orderSnapshot, $cancelOptionIds); return [ 'country_code' => strtoupper((string) ($address['country_code'] ?? 'KR')), diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php index 1934fd6c..d3594f3c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php @@ -16,6 +16,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterf use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductReviewRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\MileageRounding; /** * 주문 옵션 서비스 @@ -203,7 +204,16 @@ class OrderOptionService } // 분할 레코드 금액 = 원본 × ratio - $this->applySplitAmounts($splitOption, $origAmounts, $ratio, $quantity); + // 적립 포인트는 주문 시점 절사 기준으로 정수화한다 — 안분을 소수 2자리로 남기면 + // 원장에 없는 소수점 포인트(390.5P)가 옵션 행에 생기고, 그 합이 적립 목표액이 되어 + // 실제 지급 가능한 정수 포인트와 어긋난다. + $this->applySplitAmounts( + $splitOption, + $origAmounts, + $ratio, + $quantity, + MileageRounding::fromOrderSnapshot($option->order?->mileage_policy_snapshot) + ); $this->orderOptionRepository->save($splitOption); // 원본 레코드 = 원본 - 분할 (잔여분) @@ -439,8 +449,9 @@ class OrderOptionService * @param array $origAmounts 원본 금액 캡처 데이터 * @param float $ratio 분할 비율 (변경수량 / 원본수량) * @param int $quantity 분할 수량 + * @param array{unit: string, method: string}|null $earnRounding 적립 절사 기준 (주문 스냅샷 기준) */ - private function applySplitAmounts(OrderOption $splitOption, array $origAmounts, float $ratio, int $quantity): void + private function applySplitAmounts(OrderOption $splitOption, array $origAmounts, float $ratio, int $quantity, ?array $earnRounding = null): void { $splitOption->subtotal_price = round($splitOption->unit_price * $quantity, 2); $splitOption->subtotal_discount_amount = round($origAmounts['subtotal_discount_amount'] * $ratio, 2); @@ -450,7 +461,11 @@ class OrderOptionService $splitOption->subtotal_deposit_used_amount = round($origAmounts['subtotal_deposit_used_amount'] * $ratio, 2); $splitOption->subtotal_tax_amount = round($origAmounts['subtotal_tax_amount'] * $ratio, 2); $splitOption->subtotal_tax_free_amount = round($origAmounts['subtotal_tax_free_amount'] * $ratio, 2); - $splitOption->subtotal_earned_points_amount = round($origAmounts['subtotal_earned_points_amount'] * $ratio, 2); + // 적립 포인트는 절사 기준으로 정수화 — 잔여분은 `원본 − 분할` 이라 총액은 그대로 보존된다. + $splitOption->subtotal_earned_points_amount = MileageRounding::apply( + $origAmounts['subtotal_earned_points_amount'] * $ratio, + $earnRounding + ); $splitOption->subtotal_weight = round($splitOption->unit_weight * $quantity, 3); $splitOption->subtotal_volume = round($splitOption->unit_volume * $quantity, 3); $splitOption->subtotal_paid_amount = $splitOption->subtotal_price diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php index 30f7e021..c411f16f 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderProcessingService.php @@ -36,6 +36,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingTypeRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot; use Modules\Sirsoft\Ecommerce\Support\VatCalculator; /** @@ -1428,10 +1429,10 @@ class OrderProcessingService */ protected function buildShippingPolicyAppliedSnapshot(OrderCalculationResult $result, array $shippingInfo = []): array { - $policies = []; + $items = []; foreach ($result->items as $item) { if ($item->appliedShippingPolicy) { - $policies[] = [ + $items[] = [ 'product_option_id' => $item->productOptionId, 'policy' => $item->appliedShippingPolicy->toArray(), ]; @@ -1439,13 +1440,16 @@ class OrderProcessingService } // 배송지(국가/우편번호) 스냅샷 보존 (B5 — 환불/취소 재계산 시 도서산간/국가별 정책 판단 복원). - // OrderAdjustmentService::buildRecalcInput 가 'address' 키로 ShippingAddress 를 복원한다. - $policies['address'] = [ + // + // 항목 목록과 배송지 메타는 **별도 키**로 분리한다. 종전에는 `$policies[]` 리스트에 + // `$policies['address']` 를 덧붙여 한 배열에 섞었고, 그 결과 PHP 배열이 + // non-sequential 이 되어 `json_encode` 가 객체로 직렬화했다. 서버는 `is_int($key)` 로 + // 버텼지만 배열을 전제하는 프론트에서 `.find is not a function` 이 나며 마이페이지· + // 비회원 주문 상세의 배송정책 블록이 조용히 사라졌다. + return ShippingPolicySnapshot::make($items, [ 'country_code' => strtoupper((string) ($shippingInfo['country_code'] ?? 'KR')), 'zipcode' => $shippingInfo['zipcode'] ?? $shippingInfo['intl_postal_code'] ?? null, - ]; - - return $policies; + ]); } /** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php index cfd981f7..53c5aa43 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php @@ -17,6 +17,7 @@ use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderOption; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageBalanceRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageTransactionRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\MileageRounding; /** * 사용자 마일리지 서비스 (원장 SSoT + 표시 캐시) @@ -265,6 +266,10 @@ class UserMileageService $usable = $this->isMileageUsable(); $rule = $usable ? $this->currencyRule($currency) : []; + // 적립 절사 기준은 사용 가능 여부와 무관하게 기록한다 — 마일리지 "사용" 이 막혀 있어도 + // 적립은 별개로 일어나므로, 사용 불가 주문의 재계산에도 절사 기준이 필요하다. + $earnRounding = MileageRounding::normalize($this->currencyRule($currency)); + return [ 'usable' => $usable, 'currency' => $currency, @@ -275,6 +280,8 @@ class UserMileageService 'max_use_type' => (string) ($rule['max_use_type'] ?? 'percent'), 'max_use_percent' => (float) ($rule['max_use_percent'] ?? 100), 'max_use_value' => (int) ($rule['max_use_value'] ?? 0), + MileageRounding::UNIT_KEY => $earnRounding['unit'], + MileageRounding::METHOD_KEY => $earnRounding['method'], ], // 판정 근거 — 이 결제금액에 이 정책을 적용해 아래 상한이 나왔고, 그 안에서 사용됐다. 'payment_amount_basis' => $paymentAmount, diff --git a/modules/_bundled/sirsoft-ecommerce/src/Support/MileageRounding.php b/modules/_bundled/sirsoft-ecommerce/src/Support/MileageRounding.php new file mode 100644 index 00000000..6492d5b0 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Support/MileageRounding.php @@ -0,0 +1,133 @@ + + */ + public const UNITS = ['1', '10', '100']; + + /** + * 선택 가능한 절사 방식 (통화 환산 절사와 동일 어휘). + * + * @var array + */ + public const METHODS = ['floor', 'round', 'ceil']; + + /** + * 통화 규칙에서 절사 기준을 해석합니다. + * + * 미설정·오염값은 기본값으로 접는다 — 절사 기준이 해석되지 않는다고 적립을 멈출 수는 + * 없고, 알 수 없는 값에서 임의 방식을 고르면 지급액이 조용히 달라지기 때문이다. + * + * @param array|null $rule 통화별 마일리지 규칙 (mileage.currency_rules 의 한 행) + * @return array{unit: string, method: string} 정규화된 절사 기준 + */ + public static function normalize(?array $rule): array + { + $unit = (string) ($rule[self::UNIT_KEY] ?? self::DEFAULT_UNIT); + $method = (string) ($rule[self::METHOD_KEY] ?? self::DEFAULT_METHOD); + + return [ + 'unit' => in_array($unit, self::UNITS, true) ? $unit : self::DEFAULT_UNIT, + 'method' => in_array($method, self::METHODS, true) ? $method : self::DEFAULT_METHOD, + ]; + } + + /** + * 주문 스냅샷에서 절사 기준을 해석합니다. + * + * 재계산(부분취소 · 추가결제)은 주문 시점 기준을 그대로 써야 한다. 설정을 다시 읽으면 + * 운영자가 이후에 바꾼 절사 기준이 과거 주문에 소급 적용돼, 취소하지 않은 잔여분의 + * 적립액이 취소 처리만으로 달라진다. + * + * 이 기능 이전 주문의 스냅샷에는 키가 없다 — 그때의 동작이 곧 기본값(`1`/`floor`)이므로 + * 폴백이 당시 산출과 일치한다. + * + * @param array|null $snapshot 주문의 mileage_policy_snapshot + * @return array{unit: string, method: string} 정규화된 절사 기준 + */ + public static function fromOrderSnapshot(?array $snapshot): array + { + return self::normalize(is_array($snapshot['rule'] ?? null) ? $snapshot['rule'] : null); + } + + /** + * 절사 기준을 적용해 정수 포인트를 산출합니다. + * + * @param float|int $points 절사 전 포인트 (계산 결과 실수) + * @param array{unit: string, method: string}|null $rounding 정규화된 절사 기준 (null 이면 기본값) + * @return int 절사된 정수 포인트 (음수 방지 없음 — 호출자가 부호 의미를 정한다) + */ + public static function apply(float|int $points, ?array $rounding = null): int + { + $unit = (float) ($rounding['unit'] ?? self::DEFAULT_UNIT); + $method = (string) ($rounding['method'] ?? self::DEFAULT_METHOD); + + if ($unit <= 0) { + $unit = 1.0; + } + + $divided = ((float) $points) / $unit; + + // 부동소수 오차 보정 — 7809.999999999999 가 floor 에서 7809 로 떨어지는 것을 막는다. + // 마일리지 단위(1/10/100)에서 9자리 이하 오차는 계산 잔여물일 뿐 의미 있는 차이가 아니다. + $divided = round($divided, 9); + + $quantized = match ($method) { + 'ceil' => ceil($divided), + 'round' => round($divided), + default => floor($divided), + }; + + return (int) ($quantized * $unit); + } + + /** + * 통화 규칙에서 곧바로 절사를 적용합니다 (normalize + apply 축약). + * + * @param float|int $points 절사 전 포인트 + * @param array|null $rule 통화별 마일리지 규칙 + * @return int 절사된 정수 포인트 + */ + public static function applyRule(float|int $points, ?array $rule): int + { + return self::apply($points, self::normalize($rule)); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Support/ShippingPolicySnapshot.php b/modules/_bundled/sirsoft-ecommerce/src/Support/ShippingPolicySnapshot.php new file mode 100644 index 00000000..527e55d4 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Support/ShippingPolicySnapshot.php @@ -0,0 +1,210 @@ +, address: array} 신형 스냅샷 + */ + public static function make(array $items, array $address): array + { + return [ + self::ITEMS => array_values($items), + self::ADDRESS => $address, + ]; + } + + /** + * 신형/구형 어느 형태든 신형 구조로 정규화합니다 (멱등). + * + * 구형은 정수 키에 항목이, `'address'` 문자열 키에 배송지가 섞여 있다. + * + * @param array|null $snapshot 원본 스냅샷 (신형 · 구형 · null 모두 허용) + * @return array{items: array, address: array} 신형 스냅샷 + */ + public static function normalize(?array $snapshot): array + { + if (empty($snapshot)) { + return self::make([], []); + } + + // 이미 신형이면 items 만 재인덱싱해 반환 (멱등) + if (array_key_exists(self::ITEMS, $snapshot)) { + return self::make( + is_array($snapshot[self::ITEMS]) ? $snapshot[self::ITEMS] : [], + is_array($snapshot[self::ADDRESS] ?? null) ? $snapshot[self::ADDRESS] : [] + ); + } + + $items = []; + $address = []; + + foreach ($snapshot as $key => $entry) { + if ($key === self::ADDRESS) { + $address = is_array($entry) ? $entry : []; + + continue; + } + + if (is_array($entry)) { + $items[] = $entry; + } + } + + return self::make($items, $address); + } + + /** + * 스냅샷에서 배송지 메타만 추출합니다. + * + * @param array|null $snapshot 원본 스냅샷 (신형 · 구형 모두 허용) + * @return array 배송지 메타 (country_code, zipcode) + */ + public static function address(?array $snapshot): array + { + return self::normalize($snapshot)[self::ADDRESS]; + } + + /** + * 스냅샷에서 항목 목록만 추출합니다. + * + * @param array|null $snapshot 원본 스냅샷 (신형 · 구형 모두 허용) + * @return array 옵션별 적용 정책 목록 + */ + public static function items(?array $snapshot): array + { + return self::normalize($snapshot)[self::ITEMS]; + } + + /** + * 스냅샷을 `product_option_id => policy` 맵으로 변환합니다. + * + * `OrderCalculationService::groupByShippingPolicy()` 가 `$snapshots[$optionId]` 로 + * 접근하므로 재계산 경로에서 필요하다. 테스트 헬퍼 등이 직접 구성한 + * `optionId => policy` 형태도 그대로 통과시킨다. + * + * @param array|null $snapshot 원본 스냅샷 (신형 · 구형 · 옵션 키 맵 모두 허용) + * @return array product_option_id => 정책 데이터 + */ + public static function toOptionPolicyMap(?array $snapshot): array + { + if (empty($snapshot)) { + return []; + } + + $map = []; + + // 신형이 아니면서 정수 키에 policy_id 를 직접 갖는 형태 = optionId => policy 맵 + if (! array_key_exists(self::ITEMS, $snapshot)) { + foreach ($snapshot as $key => $entry) { + if ($key === self::ADDRESS || ! is_array($entry)) { + continue; + } + + if (! isset($entry['product_option_id'], $entry['policy'])) { + $map[$key] = $entry; + } + } + } + + foreach (self::items($snapshot) as $entry) { + if (isset($entry['product_option_id'], $entry['policy'])) { + $map[$entry['product_option_id']] = $entry['policy']; + } + } + + 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} 표시용 스냅샷 + * + * @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 집합에 해당하는 항목만 추립니다. + * + * @param array|null $snapshot 원본 스냅샷 (신형 · 구형 모두 허용) + * @param array $optionIds product_option_id => true 형태의 대상 집합 + * @return array 추려진 항목 목록 + */ + public static function itemsForOptions(?array $snapshot, array $optionIds): array + { + $picked = []; + + foreach (self::items($snapshot) as $entry) { + if (isset($entry['product_option_id'], $entry['policy']) + && isset($optionIds[$entry['product_option_id']])) { + $picked[] = $entry; + } + } + + return $picked; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php index bcc48049..315979bb 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php @@ -1390,6 +1390,8 @@ return [ 'mileage.currency_rules.*.use_unit' => 'Use Unit', 'mileage.currency_rules.*.max_use_percent' => 'Max Use Percent', 'mileage.currency_rules.*.max_use_value' => 'Max Use Amount', + 'mileage.currency_rules.*.earn_rounding_unit' => 'Earning Rounding Unit', + 'mileage.currency_rules.*.earn_rounding_method' => 'Earning Rounding Method', 'mileage.expiry_days' => 'Expiry Days', 'mileage.expiry_notification_days_before' => 'Expiry Notification Days', @@ -1616,6 +1618,12 @@ return [ 'min' => 'Max usable amount must be 0 or greater.', 'max' => 'Max usable amount is too large (max 1 billion).', ], + 'earn_rounding_unit' => [ + 'in' => 'The earning rounding unit must be one of: 1, 10, 100.', + ], + 'earn_rounding_method' => [ + 'in' => 'The earning rounding method must be one of: floor, round, ceil.', + ], ], ], 'seo' => [ diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php index 15f7009a..b3912d60 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php @@ -1390,6 +1390,8 @@ return [ 'mileage.currency_rules.*.use_unit' => '사용 단위', 'mileage.currency_rules.*.max_use_percent' => '최대 사용 비율', 'mileage.currency_rules.*.max_use_value' => '최대 사용 금액', + 'mileage.currency_rules.*.earn_rounding_unit' => '적립 절사 단위', + 'mileage.currency_rules.*.earn_rounding_method' => '적립 절사 방식', 'mileage.expiry_days' => '유효기간', 'mileage.expiry_notification_days_before' => '소멸 예정 알림일', @@ -1616,6 +1618,12 @@ return [ 'min' => '최대 사용 금액은 0 이상이어야 합니다.', 'max' => '최대 사용 금액이 너무 큽니다. (최대 10억)', ], + 'earn_rounding_unit' => [ + 'in' => '적립 절사 단위는 1, 10, 100 중 하나여야 합니다.', + ], + 'earn_rounding_method' => [ + 'in' => '적립 절사 방식은 floor, round, ceil 중 하나여야 합니다.', + ], ], ], 'seo' => [ diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsMileageTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsMileageTest.php index a976395a..819bd4e6 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsMileageTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsMileageTest.php @@ -374,6 +374,67 @@ class EcommerceSettingsMileageTest extends ModuleTestCase } } + /** + * 통화별 적립 절사 기준이 저장·조회된다. + * + * 적립 절사는 통화 환산 절사(language_currency.currencies.*.rounding_*)와 별개 항목이다. + * 그쪽은 외화 표시 전용이라 기본 통화에는 적용되지 않는 반면, 적립은 기본 통화 원장에 + * 확정 기록되는 값이므로 자기 규칙을 갖는다. + */ + public function test_save_mileage_earn_rounding_rule(): void + { + $response = $this->actingAs($this->adminUser)->putJson($this->apiBase, [ + '_tab' => 'mileage', + 'mileage' => [ + 'enabled' => true, + 'default_earn_rate' => 1, + '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, + 'earn_rounding_unit' => '10', 'earn_rounding_method' => 'ceil', + ], + ], + ], + ]); + + $response->assertOk(); + + $settings = app(EcommerceSettingsService::class); + $settings->clearCache(); + $rules = $settings->getSetting('mileage.currency_rules'); + + $this->assertSame('10', $rules[0]['earn_rounding_unit']); + $this->assertSame('ceil', $rules[0]['earn_rounding_method']); + } + + /** + * 허용 목록 밖의 적립 절사 값은 422 로 거부된다. + */ + public function test_invalid_mileage_earn_rounding_rejected(): void + { + $response = $this->actingAs($this->adminUser)->putJson($this->apiBase, [ + '_tab' => 'mileage', + 'mileage' => [ + 'enabled' => true, + 'default_earn_rate' => 1, + '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, + 'earn_rounding_unit' => '7', 'earn_rounding_method' => 'trunc', + ], + ], + ], + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors([ + 'mileage.currency_rules.0.earn_rounding_unit', + 'mileage.currency_rules.0.earn_rounding_method', + ]); + } + /** * settings.update 권한 없는 계정은 차단. */ diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mileage-earn-rounding.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mileage-earn-rounding.spec.ts new file mode 100644 index 00000000..a676cea5 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mileage-earn-rounding.spec.ts @@ -0,0 +1,109 @@ +/** + * 관리자 마일리지 설정 — 통화별 적립 절사 기준. + * + * @scenario settings_mileage_earn_rounding + * @effects settings_controls_render_in_both_viewports, + * settings_screen_options_match_server_vocabulary, + * settings_rounding_persists_across_reload, + * settings_earn_rounding_separate_from_exchange + * + * 배경: 적립 포인트 산출은 종전에 `(int) floor` 로 하드코딩돼 운영자가 조정할 수 없었다. + * 통화별 마일리지 규칙에 절사 단위(1/10/100)와 방식(버림/반올림/올림)을 두어 + * 주문계산·부분취소 안분이 같은 기준을 따르게 한다. + * + * 참고: 관리자 템플릿의 Select 는 options 가 주어지면 네이티브 `