fix(core,ecommerce,board,basic,pay_kginicis,pay_nhnkcp,pay_nicepayments): 7.0.6 회귀 3건·잠복 결함 3건 해소 + 통화 하드코딩 전수 제거
7.0.6 사이클에서 유입된 회귀 3건을 출시 전에 닫는다. 추가옵션 저장소의 반환 타입 선언이 실제 반환(중첩 맵)과 어긋나 추가옵션 있는 상품의 담기· 바로구매가 죽었고, 검색 엔진이 결과 소비 시점에 SELECT 를 재작성해 정렬이 참조하던 스코어 별칭을 지웠으며, 미들웨어 targets 가 늘어난 라우트를 따라가지 못해 비회원 현금영수증이 전면 불능이었다. 셋 다 정적 검사의 사각지대였으므로 룰 2건과 계약 테스트를 함께 세워 재발을 막는다. 같은 유형의 잠복 결함 3건(빈 반환의 타입 불일치 2건, 없는 메서드 호출 1건)도 호출 경로가 생기는 순간 fatal 이므로 함께 닫았다. 브라우저 검수 중 드러난 선행 결함 5건을 이어서 수정한다. 엔진이 event 키로 선언한 액션을 React prop 으로 연결하지 않아 오류 없이 조용히 무반응이었고, 연결한 뒤에는 커스텀 컴포넌트의 이벤트 payload 가 유실됐다. 비회원 주문상세 응답에 현금영수증 키가 없어 발급 결과에 도달할 수 없었으며, 입금확인과 현금영수증 발급이 base 통화 금액을 써서 결제 통화가 다른 주문에서 각각 저장 실패와 잘못된 증빙을 냈다. 마지막으로 통화 단위를 코드·문구에서 걷어내 설정으로 이관한다. 다국어 문구의 원/円, 프론트의 KRW 고정표와 리터럴 폴백 173건, 서버 포맷의 통화 코드 리터럴이 대상이며, 기본 통화가 원이 아닌 상점에서 값은 맞고 단위만 틀린 금액이 예외 없이 나가던 문제였다. 통화 판정을 설정 단일 출처로 모으고 규칙 3건을 정적 검사로 고정했다.
This commit is contained in:
@@ -466,6 +466,27 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
||||
|
||||
> 상세: [search-system.md "키워드 술어는 활성 엔진이 만든다"](docs/backend/search-system.md)
|
||||
|
||||
### 통화 단위는 설정이 정한다
|
||||
|
||||
G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통화**(구매자가 고른 통화), **결제 통화**(PG 청구 통화)를 각각 따로 설정한다. 셋은 같을 수도, 모두 다를 수도 있다. 금액을 다루는 지점이 특정 통화를 전제하면 값은 맞고 **단위만 틀린** 금액이 나가며, 예외도 경고도 없다.
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 다국어 문구에 `:amount원` / `:amount円` | 문구는 `:amount` 로 중립, 호출부가 `ecommerce_format_price($amount, $currency)` 로 포맷해 전달 |
|
||||
| 레이아웃에서 `{{금액.toLocaleString()}}원` 조립 | 서버가 준 `*_formatted` / `multi_currency_*[통화].formatted` 를 그대로 출력 |
|
||||
| `formatCurrencyPrice($price, 'KRW')` (통화 코드 리터럴) | `formatBaseCurrency()` / `formatOrderCurrency()` (설정·주문 스냅샷이 통화를 정한다) |
|
||||
| `number_format($amount).'원'` | 같은 도메인의 통화 인지 헬퍼(`formatOrderChargeAmount()` 등) 경유 |
|
||||
| `_global.preferredCurrency ?? 'KRW'` | `_global.preferredCurrency ?? _global.defaultCurrency` (둘 다 없으면 `*_formatted` 로 내려간다) |
|
||||
| 통화표를 코드에 고정(기호·자릿수 5종 표 + 특정 통화 폴백) | 설정의 `symbol` / `decimal_places` 를 읽고, 미설정 시에만 폴백표 |
|
||||
| `code === 'KRW' ? 0자리 : 2자리` 식 코드 분기 | `decimal_places` 로 판정 (운영자가 추가한 0자리 통화도 포함) |
|
||||
| 통화 선택 입력의 기본값을 `"KRW"` 로 시드 | 설정의 `default_currency` — 마일리지처럼 **통화별 원장**을 쓰는 도메인은 표시가 아니라 **기록이 틀어진다** |
|
||||
|
||||
언어는 통화가 아니다. 한국어 문구에 `원`, 일본어에 `円` 을 박으면 기본 통화가 다른 상점에서 UI 언어가 통화를 결정하게 된다 — 영어 문구가 `:amount` 로 중립인 것이 정답이다.
|
||||
|
||||
주문·결제·환불 금액은 **거래 시점 통화로 동결**한다(`currency_snapshot.base_currency`). 운영자가 이후 기본 통화를 바꿔도 과거 주문의 표기는 불변이어야 한다.
|
||||
|
||||
> 상세: [api-resources.md](docs/backend/api-resources.md), [service-repository.md](docs/backend/service-repository.md)
|
||||
|
||||
### Listener 데이터 접근
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|
||||
@@ -107,6 +107,10 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
#### 화면 동작
|
||||
|
||||
- 화면에서 선택·클릭 같은 동작에 반응하도록 지정한 항목이 아무 반응도 하지 않던 문제를 수정했습니다. 오류나 경고 없이 조용히 동작하지 않아 원인을 찾기 어려웠습니다. 게시판 글쓰기의 분류 선택이 이에 해당해, 분류를 골라도 글에 반영되지 않았습니다. 함께, 반응은 하되 선택한 값만 비어 저장되던 경우도 수정했습니다.
|
||||
|
||||
#### 레이아웃 코드 편집
|
||||
|
||||
- 레이아웃 코드 편집 화면이 파일 목록을 불러올 때 편집하지 않는 레이아웃의 본문까지 전부 내려받던 문제를 수정했습니다. 레이아웃이 많은 템플릿에서는 목록을 여는 것만으로 수십 MB가 브라우저에 쌓여 화면이 크게 느려지거나, 디버그 모드에서는 탭이 메모리 부족으로 종료됐습니다. 이제 목록에는 이름·설명·크기·수정일만 실리고 본문은 선택한 파일만 불러옵니다.
|
||||
|
||||
@@ -32,7 +32,8 @@ class AttachmentRepository implements AttachmentRepositoryInterface
|
||||
public function findByIds(array $ids): Collection
|
||||
{
|
||||
if (empty($ids)) {
|
||||
return collect();
|
||||
// `collect()` 는 Support 라 선언과 어긋난다 — 빈 결과도 모델 컬렉션이어야 한다.
|
||||
return new Collection;
|
||||
}
|
||||
|
||||
return Attachment::whereIn('id', $ids)
|
||||
|
||||
@@ -114,6 +114,13 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
|
||||
/**
|
||||
* 검색 결과에서 모델 ID를 추출합니다.
|
||||
*
|
||||
* 넘겨받은 쿼리의 SELECT 는 건드리지 않는다 — ORDER BY 가 `_ft_score`(그리고 소비자
|
||||
* `->query()` 콜백이 얹은 별칭)를 참조하므로 SELECT 를 갈아끼우면 정렬 대상이 사라져
|
||||
* `Unknown column ... in 'order clause'` 로 죽는다. 키만 읽어야 하는 경로는
|
||||
* `applySelect(keysOnly: true)` 가 조립 시점에 이미 좁혀 두었고, `pluck()` 은 기존
|
||||
* SELECT 를 보존하므로(`onceWithColumns` 는 columns 가 있으면 덮어쓰지 않는다)
|
||||
* 여기서 다시 좁힐 이유가 없다. `clone()` 은 호출자 쿼리 불변을 위해 유지한다.
|
||||
*
|
||||
* @param array $results 검색 결과
|
||||
*/
|
||||
public function mapIds($results): \Illuminate\Support\Collection
|
||||
@@ -123,13 +130,7 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
|
||||
return collect();
|
||||
}
|
||||
|
||||
// `pluck()` 은 이미 걸려 있는 SELECT 를 보존한다(onceWithColumns). 검색 쿼리는
|
||||
// 관련도 점수를 위해 `table.*` 를 선택해 두므로, 그대로 두면 ID 만 필요한 자리에서
|
||||
// 전 컬럼을 읽는다. 사본에서 키 컬럼만 남겨 좁힌다.
|
||||
$model = $query->getModel();
|
||||
$key = $model->getQualifiedKeyName();
|
||||
|
||||
return $query->clone()->select($key)->pluck($model->getKeyName());
|
||||
return $query->clone()->pluck($query->getModel()->getKeyName());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -649,6 +650,10 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
|
||||
* 키만 필요한 경로에서는 키 컬럼과 정렬용 스코어로 좁힙니다. 스코어는 `ORDER BY` 가
|
||||
* 참조하므로 좁힐 때도 함께 남겨야 합니다.
|
||||
*
|
||||
* 검색 쿼리의 SELECT 를 재작성하는 유일한 지점입니다. `mapIds()`/`map()`/`lazyMap()` 은
|
||||
* 넘겨받은 쿼리의 SELECT·ORDER BY 를 바꾸지 않습니다 — 소비자가 `->query()` 로 얹은
|
||||
* 별칭에도 정렬이 걸릴 수 있어, 재작성은 알려진 별칭 하나만 보존해도 안전하지 않습니다.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query 대상 쿼리
|
||||
* @param Model $model 기준 모델
|
||||
* @param string $qualifiedTable 프리픽스 포함 테이블명
|
||||
|
||||
@@ -141,6 +141,30 @@ public function getMiddleware(): array
|
||||
|
||||
`everything`·`*`, `all_extensions`, `core` 는 코어·타 확장에 개입하는 **광역 타게팅** 이다. 선언에 대상이 명문화되므로 감사 가능하며, 리뷰 시 주의 대상으로 표기한다. 자기 라우트 중 일부만 노려야 하면 `self` 대신 원시 glob 으로 정밀화한다.
|
||||
|
||||
### targets 커버리지 규율
|
||||
|
||||
인증·소유권 검증 같은 **보호형** 미들웨어는 라우트를 개별 나열하지 않고 **서브트리 glob** 으로 선언한다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
| ------- | -------------- |
|
||||
| 보호 대상 라우트를 하나씩 열거 | 서브트리 glob (`api.modules.foo.guest.orders.cash-receipt.*`) |
|
||||
| PHPDoc 에 부착 대상 **개수**를 기재 ("4개 라우트에만 부착") | 무엇을·왜 부착/제외하는지 서술만 남긴다 |
|
||||
| 상위 glob 으로 뭉뚱그리기 (`guest.orders.*`) | 인증 전 단계 라우트를 삼키지 않는 수준까지 정밀화 |
|
||||
|
||||
개별 나열은 하위 라우트가 늘 때 갱신이 누락되어 **새 엔드포인트가 무보호로 열린다**. 실제로 현금영수증 라우트 2건이 추가될 때 targets 갱신이 누락돼 그 기능이 전면 불능이었다. glob 은 그 서브트리의 기본값을 '보호' 로 만들어 실패 방향을 안전한 쪽으로 뒤집는다. 개수를 문서에 박는 것 자체가 다음 누락의 씨앗이다.
|
||||
|
||||
다만 상위 glob 이 **인증 전 단계** 라우트(토큰 발급 등)를 삼키면 기능이 통째로 막힌다 — 토큰을 받으러 오는 요청에는 아직 토큰이 없기 때문이다. 삼키는 경우에만 한 단계 아래로 정밀화하고, 제외 이유를 PHPDoc 에 남긴다.
|
||||
|
||||
targets 정합성은 정적 검사가 불가능하다. 라우트 전체 이름이 확장 라우트 파일의 접미부와 모듈 로더가 부팅 시 붙이는 prefix 의 조합으로 결정될 뿐 아니라, "이 라우트가 보호되어야 하는가" 자체가 의미적 판단이기 때문이다. 그러므로 확장은 **계약 테스트**를 둔다:
|
||||
|
||||
- `Route::getRoutes()` 에서 그 서브트리 라우트를 **전수 수집**하고, 클래스 상수로 선언한 면제 목록(사유 주석 필수)을 뺀 나머지가 전부 `resolveForRoute()` 결과에 그 미들웨어를 포함하는지 단언한다. 모집단을 라우트 테이블에서 파생시키므로, 새 라우트를 추가하고 targets 를 안 고치면 테스트를 손대지 않아도 자동으로 실패한다.
|
||||
- 면제 대상은 **미부착임을 별도로 단언**한다 (상위 glob 으로 뭉뚱그리는 회귀 차단).
|
||||
- 케이스 간 오염을 막으려면 `ExtensionMiddlewareRegistry::flush()` 를 `setUp`/`tearDown` 양쪽에서 호출한다 — 인덱스가 태그 캐시다.
|
||||
|
||||
참조 구현: `modules/_bundled/sirsoft-ecommerce/tests/Unit/Providers/GuestOrderTokenMiddlewareRegistrationTest.php`
|
||||
|
||||
미부착 시 응답이 다른 원인의 응답과 구별되지 않을 수 있다는 점도 함께 본다. 예컨대 토큰 미들웨어의 404 와 FormRequest 의 404 가 같은 코드·메시지면, "토큰 없이는 실패한다" 는 테스트는 **미들웨어가 아예 없어도 통과한다**. 구별을 위해 미들웨어 쪽 상태코드나 메시지를 갈라서는 안 된다 — 리소스 존재 여부가 응답으로 새어 열거 공격 표면이 생긴다. 동일 응답을 유지하고, 부착 증명은 위 계약 테스트가 담당한다.
|
||||
|
||||
### 이중 매칭 (라우트명 + URI) — 무명 catch-all 대응
|
||||
|
||||
target 이 `/` 로 시작하면 **URI 패턴**(`$request->path()` 매칭), 아니면 **라우트명 패턴**(`$request->route()?->getName()` 매칭)이다. 라우트명은 dot-notation 이라 `/` 로 시작하지 않으므로 구분이 명확하다. 코어 SSR 셸 catch-all 라우트는 `->name()` 이 없어(무명) 라우트명으로 타게팅할 수 없다 → 이런 무명 라우트를 대상으로 하려면 URI 패턴(`/`)이나 `everything` 을 쓴다. 라우트명 계열 target 은 무명 라우트에서 항상 miss 한다.
|
||||
|
||||
@@ -126,6 +126,22 @@ ORDER BY _ft_score DESC
|
||||
|
||||
LIKE fallback 시 `_ft_score`는 항상 0 (관련성 순위 불가).
|
||||
|
||||
### `_ft_score` SELECT 와 ORDER BY 는 불가분
|
||||
|
||||
`_ft_score` 는 실제 컬럼이 아니라 **SELECT 별칭**이다. SELECT 에서 빠지면 정렬이 참조할 대상이 사라져 `SQLSTATE[42S22] Unknown column '_ft_score' in 'order clause'` 로 조회가 통째로 실패한다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
| ------- | -------------- |
|
||||
| 결과 소비 메서드(`mapIds()`/`map()`/`lazyMap()`/`getTotalCount()`)가 넘겨받은 쿼리에 `select()`/`selectRaw()`/`addSelect()` 를 걸어 SELECT 를 재작성 | 소비 메서드는 실행만 한다. `pluck()` 은 기존 SELECT 를 보존하므로 좁힐 필요가 없다 |
|
||||
| 스코어 별칭만 보존해 SELECT 를 재조립 | 재작성 자체를 하지 않는다 — 소비자가 `->query()` 로 얹은 별칭(`withCount()` 의 `*_count` 등)에도 정렬이 걸릴 수 있어 알려진 별칭 하나만 막으면 다른 이름으로 재발한다 |
|
||||
| `reorder()` 로 정렬을 지워 회피 | `keys()` 는 Scout 공개 계약상 **관련도 순** 키 목록을 약속한다 — 정렬을 지우면 오류 없이 무순서 키를 돌려주는 무음 회귀가 된다 |
|
||||
|
||||
SELECT 를 재작성하는 지점은 엔진이 쿼리를 **조립하는 자리 하나뿐**이며(`applySelect()`), 키만 필요한 경로에서도 스코어를 함께 남긴다. 조립 지점이 남긴 것을 소비 지점이 되돌리면 두 지점이 정면으로 상충한다.
|
||||
|
||||
`->query()` 콜백의 `orderBy` 는 Scout `Builder::$orders` 를 채우지 않으므로 엔진이 `_ft_score DESC` 를 항상 tiebreak 으로 덧붙인다. 즉 **검색 쿼리의 ORDER BY 에는 사실상 언제나 `_ft_score` 가 있다** — "이 경로는 스코어 정렬을 안 쓰니 괜찮다" 는 판단은 성립하지 않는다.
|
||||
|
||||
정적 검사가 결과 소비 메서드 안의 SELECT·정렬 재작성을 차단한다.
|
||||
|
||||
### 현재 FulltextSearchable 구현 모델
|
||||
|
||||
| 모듈 | 모델 | 검색 컬럼 |
|
||||
|
||||
@@ -832,6 +832,25 @@ public function getAll(): Collection
|
||||
}
|
||||
```
|
||||
|
||||
### 평면 맵은 Eloquent, 중첩 맵은 Support
|
||||
|
||||
`Illuminate\Database\Eloquent\Collection` 의 계약은 "**모델의** 컬렉션" 이다 — `load()` / `modelKeys()` / `fresh()` 가 전부 항목이 `Model` 임을 전제한다. 그래서 `map()` / `mapWithKeys()` / `pluck()` / `flatMap()` / `countBy()` / `partition()` 은 결과 항목 중 하나라도 Model 이 아니면 `toBase()` 로 **Support 컬렉션으로 강등**한다. 이것은 버그가 아니라 그 불변식을 지키는 프레임워크의 의도된 방어다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
| ------- | -------------- |
|
||||
| 중첩 맵(`id => Collection`)을 만들면서 반환 선언은 `Eloquent\Collection` | 선언을 `Illuminate\Support\Collection` 으로 정정 |
|
||||
| 빈 입력 early return 만 `collect()` (선언은 Eloquent) | `new Collection` / `$this->model->newCollection()` — 분기마다 타입이 갈리지 않게 |
|
||||
| `new EloquentCollection($grouped->all())` 로 강제 캐스팅해 선언을 지키기 | 항목이 Model 이 아닌 Eloquent 컬렉션이 만들어져 `->load()` 즉시 fatal — 타입 선언을 지키려고 타입 계약을 깨는 자기모순 |
|
||||
| `return $q->get()->groupBy('col');` 을 `Eloquent\Collection` 선언으로 두기 | 선언을 Support 로 정정 — `groupBy()` 는 강등하지 않아 **TypeError 조차 나지 않는다** |
|
||||
|
||||
강등은 **결과가 비면 일어나지 않는다.** `groupBy()` 결과가 비면 `map()` 이 항목을 하나도 보지 않아 Eloquent 인 채로 통과한다. 그래서 이 결함은 "일부 데이터에서만 500" 이라는 형태로 나타나고, 픽스처가 빈 테스트는 green 이 된다 — 실제로 추가옵션 선택지가 있는 상품만 장바구니 담기가 죽었다.
|
||||
|
||||
**즉사하지 않는 변종이 더 위험하다.** `Eloquent\Collection` 은 `groupBy()` 를 오버라이드하지 않으므로 Support 구현의 `new static` 이 그대로 쓰인다 — 외곽은 Eloquent 인데 항목은 컬렉션인 값이 반환 타입 검사를 **통과**한다. 위 표의 강제 캐스팅과 결과물이 같은 형태이며, 터지는 곳은 반환 지점이 아니라 그 값을 받아 `->load()` / `->modelKeys()` 를 부르는 훨씬 나중의 호출부다. `map()` 말단이 즉시 TypeError 로 드러나는 것과 달리, 이쪽은 소비자가 생길 때까지 조용히 산다.
|
||||
|
||||
판정 기준은 단순하다. **꺼내는 값이 Model 이면 Eloquent, 컬렉션·배열이면 Support.** `*Keyed` 류 메서드 대부분은 평면 `id => Model` 맵이라 Eloquent 선언이 정당하고 실제로 지켜진다. 중첩 맵을 만들면서 옆 메서드의 평면 맵 선언을 복사하는 것이 전형적인 실수다.
|
||||
|
||||
정적 검사가 이 불일치를 차단하지만, 정규식 기반이라 강등 여부를 의미적으로 판정하지는 못한다. 권위 있는 가드는 **저장소 단위 테스트**다 — 반환 타입을 바꾸거나 중첩 맵을 새로 만들 때는 `assertInstanceOf` / `assertNotInstanceOf` 로 외곽과 내부 항목의 타입을 함께 고정한다.
|
||||
|
||||
### 목록의 eager load 는 목록이 직렬화하는 관계만
|
||||
|
||||
eager load 는 N+1 을 막지만, **목록에 필요 없는 관계까지 로드하면** 이번에는 페이로드가 커진다. 한 페이지를 여는 것만으로 그 페이지 전 행의 하위 컬렉션이 통째로 메모리에 올라오고 응답에 실린다.
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.1] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- 金額表示から通貨単位(円)の固定表記を削除 — マイレージ・注文金額・一括変更の要約などが、ショップに設定された基準通貨の単位で表示されます。
|
||||
|
||||
## [1.1.0] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -69,7 +69,7 @@ return [
|
||||
'order_cancel' => '注文 全体 キャンセル (:order_number)',
|
||||
'order_partial_cancel' => '注文 部分 キャンセル (:order_number)',
|
||||
'order_coupon_restore' => '注文 キャンセル クーポン 復元 (:order_number)',
|
||||
'order_mileage_restore' => '注文 キャンセル マイレージ 復元 (:order_number, :amount円)',
|
||||
'order_mileage_restore' => '注文 キャンセル マイレージ 復元 (:order_number, :amount)',
|
||||
'order_bulk_update' => '注文 一括 変更 (:count件)',
|
||||
'order_bulk_status_update' => '注文 一括 ステータス 変更 (:count件)',
|
||||
'order_bulk_shipping_update' => '送り状番号 一括 入力 (:count件)',
|
||||
@@ -163,16 +163,16 @@ return [
|
||||
'user_coupon_download' => 'クーポンダウンロード (:coupon_name)',
|
||||
'user_order_create' => '注文完了 (#:order_id)',
|
||||
'user_order_option_confirm' => '購入確認 (オプション #:option_id)',
|
||||
'mileage_earn' => 'マイレージ積立 (:amount円)',
|
||||
'mileage_use' => 'マイレージ使用 (:amount円)',
|
||||
'mileage_restore' => 'マイレージ復元 (:amount円)',
|
||||
'mileage_earn' => 'マイレージ積立 (:amount)',
|
||||
'mileage_use' => 'マイレージ使用 (:amount)',
|
||||
'mileage_restore' => 'マイレージ復元 (:amount)',
|
||||
'order_reset_guest_password' => '非会員照会 パスワード再設定 (:order_number)',
|
||||
'mileage_expire' => 'マイレージ失効 (:amount円)',
|
||||
'mileage_earn_cancel' => 'マイレージ回収 (:amount円)',
|
||||
'mileage_admin_earn' => '管理者マイレージ付与 (:amount円)',
|
||||
'mileage_admin_deduct' => '管理者マイレージ減額 (:amount円)',
|
||||
'mileage_expire' => 'マイレージ失効 (:amount)',
|
||||
'mileage_earn_cancel' => 'マイレージ回収 (:amount)',
|
||||
'mileage_admin_earn' => '管理者マイレージ付与 (:amount)',
|
||||
'mileage_admin_deduct' => '管理者マイレージ減額 (:amount)',
|
||||
'mileage_extend_expiry' => 'マイレージ有効期限延長 (:days日)',
|
||||
'mileage_adjust' => 'マイレージ加算の編集 (:amount円)',
|
||||
'mileage_adjust' => 'マイレージ加算の編集 (:amount)',
|
||||
'product_notice_template_toggle_active' => '商品告示情報テンプレート有効化状態変更 (:template_name)',
|
||||
'ecommerce_settings_update' => 'イーコマース設定保存 (:categories)',
|
||||
'coupon_direct_issue' => 'クーポン直接発行 (:coupon_name → 会員 #:user_id)',
|
||||
|
||||
@@ -38,9 +38,9 @@ return [
|
||||
'order_option_cannot_confirm' => '現在のステータスでは購入確定できません。',
|
||||
'cancel_quantity_exceeds' => 'キャンセル数量が現在数量(:max個)を超えています。',
|
||||
'insufficient_stock' => '在庫が不足している商品が:count個あります。',
|
||||
'payment_amount_mismatch' => '決済金額が一致しません。(予想: :expected円、実際: :actual円)',
|
||||
'payment_amount_mismatch' => '決済金額が一致しません。(予想: :expected、実際: :actual)',
|
||||
'cart_unavailable' => '購入できない商品があります。',
|
||||
'order_amount_changed' => '注文金額が変動しました。チェックアウトページを更新してから再度お試しください。(以前: :stored円、現在: :recalculated円)',
|
||||
'order_amount_changed' => '注文金額が変動しました。チェックアウトページを更新してから再度お試しください。(以前: :stored、現在: :recalculated)',
|
||||
'order_calculation_validation_failed' => '注文計算検証に失敗しました。クーポン有効期限切れまたは在庫変動が発生した可能性があります。',
|
||||
'cancel_option_not_found' => 'キャンセル対象の注文オプションが見つかりません。',
|
||||
'cancel_option_already_cancelled' => '既にキャンセルされた注文オプションです。',
|
||||
|
||||
@@ -1015,8 +1015,8 @@ return [
|
||||
'bulk_summary_sales_status' => '販売状態: :status',
|
||||
'bulk_summary_display_status' => '表示状態: :status',
|
||||
'bulk_summary_name_changed' => '商品名変更',
|
||||
'bulk_summary_list_price' => '定価: :price円',
|
||||
'bulk_summary_selling_price' => '販売価格: :price円',
|
||||
'bulk_summary_list_price' => '定価: :price',
|
||||
'bulk_summary_selling_price' => '販売価格: :price',
|
||||
'bulk_summary_inline_modified' => 'インライン編集完了',
|
||||
'bulk_summary_bulk_applied' => '一括変更を適用',
|
||||
'bulk_summary_price_adjustment' => '価格調整: :method :value',
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@
|
||||
"status": "ステータス",
|
||||
"actions": "操作"
|
||||
},
|
||||
"benefit_max": "最大{{amount}}円"
|
||||
"benefit_max": "最大{{amount}}"
|
||||
},
|
||||
"mobile": {
|
||||
"code_label": "コード",
|
||||
|
||||
+8
-8
@@ -112,8 +112,8 @@
|
||||
"fee_summary": {
|
||||
"label": "配送料金:",
|
||||
"free": "送料無料",
|
||||
"fixed": "配送料金: {{fee}}円",
|
||||
"conditional": "{{threshold}}円未満 {{fee}}円 / {{threshold}}円以上 送料無料",
|
||||
"fixed": "配送料金: {{fee}}",
|
||||
"conditional": "{{threshold}}未満 {{fee}} / {{threshold}}以上 送料無料",
|
||||
"range_prefix": {
|
||||
"amount": "",
|
||||
"quantity": "個",
|
||||
@@ -121,11 +121,11 @@
|
||||
"volume": "cm³"
|
||||
},
|
||||
"api": "外部API連携(リアルタイム計算)",
|
||||
"per_quantity": "{{unit}}個当たり {{fee}}円",
|
||||
"per_weight": "{{unit}}kg当たり {{fee}}円",
|
||||
"per_volume": "{{unit}}L当たり {{fee}}円",
|
||||
"per_volume_weight": "{{unit}}kg当たり {{fee}}円",
|
||||
"per_amount": "{{unit}}円当たり {{fee}}円"
|
||||
"per_quantity": "{{unit}}個当たり {{fee}}",
|
||||
"per_weight": "{{unit}}kg当たり {{fee}}",
|
||||
"per_volume": "{{unit}}L当たり {{fee}}",
|
||||
"per_volume_weight": "{{unit}}kg当たり {{fee}}",
|
||||
"per_amount": "{{unit}}当たり {{fee}}"
|
||||
},
|
||||
"fee_details": {
|
||||
"base_fee": "配送料金",
|
||||
@@ -258,7 +258,7 @@
|
||||
"api_response_path_hint": "ネストされた応答はドット(.)でパスを指定してください。例: data.shipping.fee",
|
||||
"api_test_button": "テスト呼び出し",
|
||||
"api_test_loading": "呼び出し中...",
|
||||
"api_test_extracted": "抽出された配送料金: :fee 円",
|
||||
"api_test_extracted": "抽出された配送料金: :fee",
|
||||
"api_test_no_fee": "応答は受信されましたが、配送料金の値を抽出できませんでした。応答形式·パスを確認してください。",
|
||||
"api_test_failed": "API呼び出しに失敗しました (接続不可またはタイムアウト)。",
|
||||
"api_test_request_body": "リクエスト本文:",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
|
||||
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"scope": "module",
|
||||
"target_identifier": "sirsoft-ecommerce",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.2] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- 返金金額の案内から通貨単位(円)の固定表記を削除 — 決済通貨の単位で表示されます。
|
||||
|
||||
## [1.1.1] - 2026-08-07
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -5,6 +5,6 @@ return [
|
||||
'cancel' => 'PG決済キャンセル',
|
||||
],
|
||||
'description' => [
|
||||
'payment_cancel' => 'KGイニシス決済キャンセルが確認されました(注文 :order_number、返金金額 :refund_amount円)。',
|
||||
'payment_cancel' => 'KGイニシス決済キャンセルが確認されました(注文 :order_number、返金金額 :refund_amount)。',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_kginicis",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- 返金金額の案内から通貨単位(円)の固定表記を削除 — 決済通貨の単位で表示されます。
|
||||
|
||||
## [1.0.2] - 2026-08-07
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -5,6 +5,6 @@ return [
|
||||
'cancel' => 'PG決済キャンセル',
|
||||
],
|
||||
'description' => [
|
||||
'payment_cancel' => 'NHN KCP決済キャンセルが確認されました(注文 :order_number、返金金額 :refund_amount円)。',
|
||||
'payment_cancel' => 'NHN KCP決済キャンセルが確認されました(注文 :order_number、返金金額 :refund_amount)。',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_nhnkcp",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- 返金金額の案内から通貨単位(円)の固定表記を削除 — 決済通貨の単位で表示されます。
|
||||
|
||||
## [1.0.2] - 2026-08-07
|
||||
|
||||
### Changed
|
||||
|
||||
+1
-1
@@ -5,6 +5,6 @@ return [
|
||||
'cancel' => 'PG決済キャンセル',
|
||||
],
|
||||
'description' => [
|
||||
'payment_cancel' => 'NICEペイメンツ決済キャンセルが確認されました (注文 :order_number、返金金額 :refund_amount円)。',
|
||||
'payment_cancel' => 'NICEペイメンツ決済キャンセルが確認されました (注文 :order_number、返金金額 :refund_amount)。',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -8,7 +8,7 @@ return [
|
||||
'invalid_amount' => 'リクエスト金額が有効ではありません。',
|
||||
'vbank_refund_required_fields' => 'TID、注文番号、返金金額、返金口座情報(口座番号·銀行コード·口座名義人)をすべて入力してください。',
|
||||
'vbank_completed_requires_bank_info' => '仮想口座入金完了の場合は返金口座情報が必要です。管理者APIを通じて返金を進めてください。',
|
||||
'invalid_refund_amount' => '返金金額が有効ではありません。(リクエスト::amount円)',
|
||||
'invalid_refund_amount' => '返金金額が有効ではありません。(リクエスト::amount)',
|
||||
'vbank_refund_order_not_found' => '返金する注文が見つかりません。',
|
||||
'vbank_refund_invalid_payment' => '返金可能なNICEペイメンツ仮想口座入金完了決済ではありません。',
|
||||
'vbank_refund_amount_mismatch' => '要求された返金金額がDBのキャンセル可能金額と一致していません。',
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_nicepayments",
|
||||
|
||||
@@ -87,7 +87,8 @@ class AttachmentRepository implements AttachmentRepositoryInterface
|
||||
public function findByIds(string $slug, array $ids): Collection
|
||||
{
|
||||
if (empty($ids)) {
|
||||
return collect();
|
||||
// `collect()` 는 Support 라 선언과 어긋난다 — 빈 결과도 모델 컬렉션이어야 한다.
|
||||
return new Collection;
|
||||
}
|
||||
|
||||
$board = Board::where('slug', $slug)->first();
|
||||
|
||||
@@ -203,13 +203,14 @@ class AttachmentService
|
||||
public function linkTempAttachments(string $slug, string $tempKey, int $postId): int
|
||||
{
|
||||
// 연결 대상 후보를 먼저 조회하여 링크 후 재조회 → 훅 발화를 위한 식별자 확보
|
||||
// (getByTempKey → linkTempAttachments → findById 순)
|
||||
$tempAttachments = $this->repository->getByTempKey($slug, $tempKey);
|
||||
|
||||
$linkedCount = $this->repository->linkTempAttachments($slug, $tempKey, $postId);
|
||||
|
||||
// 각 첨부에 대해 after_link 훅 발화 → 카운트 리스너가 post_id 기준으로 동기화 가능
|
||||
foreach ($tempAttachments as $tempAttachment) {
|
||||
$linked = $this->repository->getById($slug, $tempAttachment->id);
|
||||
$linked = $this->repository->findById($slug, $tempAttachment->id);
|
||||
if ($linked && $linked->post_id === $postId) {
|
||||
HookManager::doAction('sirsoft-board.attachment.after_link', $linked);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Modules\Sirsoft\Board\Tests\Unit;
|
||||
require_once __DIR__.'/../ModuleTestCase.php';
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Modules\Sirsoft\Board\Models\Attachment;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
use Modules\Sirsoft\Board\Repositories\AttachmentRepository;
|
||||
@@ -222,7 +223,7 @@ class AttachmentRepositoryTest extends ModuleTestCase
|
||||
public function test_get_by_temp_key_returns_temp_attachments(): void
|
||||
{
|
||||
// Given: 고유 temp_key로 임시 첨부파일 2개 생성 (테스트 격리)
|
||||
$tempKey = 'get-temp-test-' . uniqid();
|
||||
$tempKey = 'get-temp-test-'.uniqid();
|
||||
$this->createTempAttachment([
|
||||
'temp_key' => $tempKey,
|
||||
'original_filename' => 'temp1.pdf',
|
||||
@@ -273,4 +274,21 @@ class AttachmentRepositoryTest extends ModuleTestCase
|
||||
// Then: 0 반환
|
||||
$this->assertEquals(0, $maxOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 입력 분기도 선언된 Eloquent 컬렉션이어야 한다.
|
||||
*
|
||||
* `findByIds()` 는 진짜 모델 컬렉션을 반환하므로 Eloquent 선언이 정당한데, 빈 입력
|
||||
* 분기만 `collect()`(Support)를 돌려주면 분기별 타입이 갈려 호출 즉시 TypeError 다.
|
||||
*/
|
||||
#[Test]
|
||||
public function test_find_by_ids_returns_eloquent_collection_for_empty_input(): void
|
||||
{
|
||||
// When: 빈 ID 배열로 조회
|
||||
$result = $this->repository->findByIds('attach-repo-test', []);
|
||||
|
||||
// Then: 선언과 동일한 Eloquent 컬렉션
|
||||
$this->assertInstanceOf(Collection::class, $result);
|
||||
$this->assertTrue($result->isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ require_once __DIR__.'/../ModuleTestCase.php';
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
use Modules\Sirsoft\Board\Models\Attachment;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
use Modules\Sirsoft\Board\Repositories\Contracts\AttachmentRepositoryInterface;
|
||||
@@ -19,6 +21,7 @@ use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
|
||||
use Modules\Sirsoft\Board\Services\AttachmentService;
|
||||
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* AttachmentService 단위 테스트
|
||||
@@ -27,16 +30,15 @@ use PHPUnit\Framework\Attributes\Test;
|
||||
*/
|
||||
class AttachmentServiceTest extends ModuleTestCase
|
||||
{
|
||||
|
||||
private AttachmentService $service;
|
||||
|
||||
/** @var \Mockery\MockInterface&AttachmentRepositoryInterface */
|
||||
/** @var MockInterface&AttachmentRepositoryInterface */
|
||||
private $repository;
|
||||
|
||||
/** @var \Mockery\MockInterface&BoardRepositoryInterface */
|
||||
/** @var MockInterface&BoardRepositoryInterface */
|
||||
private $boardRepository;
|
||||
|
||||
/** @var \Mockery\MockInterface&StorageInterface */
|
||||
/** @var MockInterface&StorageInterface */
|
||||
private $storage;
|
||||
|
||||
private User $user;
|
||||
@@ -522,13 +524,24 @@ class AttachmentServiceTest extends ModuleTestCase
|
||||
$tempKey = 'temp-uuid-123';
|
||||
$postId = 5;
|
||||
|
||||
// Service 는 훅 발화를 위해 getByTempKey → linkTempAttachments → getById 순으로 호출
|
||||
// Service 는 훅 발화를 위해 getByTempKey → linkTempAttachments → findById 순으로 호출한다.
|
||||
// Repository 시그니처: Eloquent\Collection 반환 필수 (Support\Collection 불일치)
|
||||
//
|
||||
// 후보 컬렉션이 비어 있으면 재조회 루프가 0회 실행되어 재조회 메서드 이름이 틀려도
|
||||
// 통과한다(실제로 없는 `getById()` 를 호출하고 있었는데 그 상태로 green 이었다).
|
||||
// 따라서 첨부 1건이 든 컬렉션으로 루프를 반드시 태운다.
|
||||
$tempAttachment = new Attachment(['temp_key' => $tempKey]);
|
||||
$tempAttachment->id = 11;
|
||||
|
||||
$linkedAttachment = new Attachment;
|
||||
$linkedAttachment->id = 11;
|
||||
$linkedAttachment->post_id = $postId;
|
||||
|
||||
$this->repository
|
||||
->shouldReceive('getByTempKey')
|
||||
->once()
|
||||
->with($slug, $tempKey)
|
||||
->andReturn(new \Illuminate\Database\Eloquent\Collection());
|
||||
->andReturn(new Collection([$tempAttachment]));
|
||||
|
||||
$this->repository
|
||||
->shouldReceive('linkTempAttachments')
|
||||
@@ -536,6 +549,12 @@ class AttachmentServiceTest extends ModuleTestCase
|
||||
->with($slug, $tempKey, $postId)
|
||||
->andReturn(3);
|
||||
|
||||
$this->repository
|
||||
->shouldReceive('findById')
|
||||
->once()
|
||||
->with($slug, 11)
|
||||
->andReturn($linkedAttachment);
|
||||
|
||||
// Act
|
||||
$result = $this->service->linkTempAttachments($slug, $tempKey, $postId);
|
||||
|
||||
@@ -613,7 +632,7 @@ class AttachmentServiceTest extends ModuleTestCase
|
||||
->with('notice', 1)
|
||||
->andReturn($attachment);
|
||||
|
||||
$expectedResponse = new \Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
$expectedResponse = new StreamedResponse;
|
||||
|
||||
$this->storage->shouldReceive('response')
|
||||
->once()
|
||||
@@ -632,7 +651,7 @@ class AttachmentServiceTest extends ModuleTestCase
|
||||
$result = $this->service->download('notice', 1);
|
||||
|
||||
// Assert
|
||||
$this->assertInstanceOf(\Symfony\Component\HttpFoundation\StreamedResponse::class, $result);
|
||||
$this->assertInstanceOf(StreamedResponse::class, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -667,7 +686,7 @@ class AttachmentServiceTest extends ModuleTestCase
|
||||
->with('notice', 1)
|
||||
->andReturn($attachment);
|
||||
|
||||
$expectedResponse = new \Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
$expectedResponse = new StreamedResponse;
|
||||
|
||||
$this->storage->shouldReceive('response')
|
||||
->once()
|
||||
@@ -688,7 +707,7 @@ class AttachmentServiceTest extends ModuleTestCase
|
||||
$result = $this->service->download('notice', 1);
|
||||
|
||||
// Assert
|
||||
$this->assertInstanceOf(\Symfony\Component\HttpFoundation\StreamedResponse::class, $result);
|
||||
$this->assertInstanceOf(StreamedResponse::class, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -722,7 +741,7 @@ class AttachmentServiceTest extends ModuleTestCase
|
||||
|
||||
$this->storage->shouldReceive('response')
|
||||
->once()
|
||||
->andReturn(new \Symfony\Component\HttpFoundation\StreamedResponse);
|
||||
->andReturn(new StreamedResponse);
|
||||
|
||||
// Act (user 컨텍스트)
|
||||
$this->service->download('notice', 1, context: 'user');
|
||||
@@ -763,7 +782,7 @@ class AttachmentServiceTest extends ModuleTestCase
|
||||
|
||||
$this->storage->shouldReceive('response')
|
||||
->once()
|
||||
->andReturn(new \Symfony\Component\HttpFoundation\StreamedResponse);
|
||||
->andReturn(new StreamedResponse);
|
||||
|
||||
// Act (admin 컨텍스트 기본값)
|
||||
$this->service->download('notice', 1);
|
||||
|
||||
@@ -98,6 +98,16 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 기본 통화가 원화가 아닌 상점에서 금액에 원화 단위가 붙던 문제를 수정했습니다. 마일리지 적립·사용·복원·소멸 내역, 결제금액 불일치 안내, 상품 일괄수정 요약, 할인코드 안내가 이제 상점에 설정된 통화의 단위로 표시됩니다.
|
||||
- 통합 검색 결과의 상품 가격이 상점 기본 통화가 아닌 원화 단위로 표기되던 문제를 수정했습니다.
|
||||
- 관리자에게 발송되는 신규 주문 알림의 금액이 구매자 알림과 달리 원화로 고정되던 문제를 수정했습니다. 이제 두 알림 모두 결제 통화로 표시됩니다.
|
||||
- 관리자 주문 상세와 주문 취소 화면의 상품 단가·소계·쿠폰 할인액·추가옵션 금액이 원화 단위로 고정 표시되던 문제를 수정했습니다.
|
||||
- 상품의 기본 통화와 다른 통화로 결제한 주문에서 관리자가 입금확인을 저장할 수 없던 문제를 수정했습니다. 입금액이 기본 통화 기준 금액으로 미리 채워져 실제 청구된 결제 통화 금액과 어긋난 탓에 저장할 때마다 금액이 맞지 않는다며 거절됐습니다. 이제 실제 청구된 결제 통화 금액이 채워집니다.
|
||||
- 관리자 마일리지 수동 지급·차감 화면의 통화가 원화로 고정돼, 기본 통화가 다른 상점에서 실제로 쓰지 않는 통화로 지급될 수 있던 문제를 수정했습니다. 이제 상점 기본 통화가 기본 선택됩니다.
|
||||
- 환경설정 통화 관리의 절사 단위 선택지가 원화·엔화에서만 정수 단위로 나오던 문제를 수정했습니다. 이제 소수점을 쓰지 않는 통화라면 관리자가 추가한 통화에서도 정수 단위가 제공됩니다.
|
||||
- 배송비 계산 API 테스트 결과의 추출 금액에 원화 단위가 붙던 문제를 수정했습니다.
|
||||
- 기본 통화가 원(KRW)이 아닌 쇼핑몰에서, 다른 통화로 볼 때 추가옵션 추가금만 기준 통화 금액으로 표시되고 그 금액이 그대로 합계에 더해지던 문제를 수정했습니다. 예를 들어 기본 통화가 엔(JPY)인 상점을 원화로 보면 상품가는 환산되는데 추가금은 `+¥5,000` 그대로 남아, 총 금액이 두 통화가 섞인 값으로 표시됐습니다. 이제 상품 상세·장바구니·주문서의 추가옵션 금액이 보고 있는 통화로 환산되어 표시되고 합계에도 같은 통화로 더해집니다. 기본 통화가 원인 상점은 이전과 동일합니다.
|
||||
- 장바구니 담기와 장바구니 옵션 변경의 응답에 선택한 추가옵션이 빠져 있던 문제를 수정했습니다. 선택 자체는 정상 저장되어 장바구니 화면에는 제대로 표시됐지만, 담기·옵션 변경 응답만 읽으면 추가옵션이 없는 것으로 보이고 소계도 추가금이 빠진 금액으로 나왔습니다. 이제 두 응답 모두 장바구니 조회와 동일한 값을 돌려줍니다.
|
||||
- 쇼핑몰 주소를 기본값이 아닌 값으로 바꾸거나 주소 없이 운영하는 상점에서, 서버가 만들어 보내는 상점 주소가 기본 주소로 잘못 만들어지던 문제를 수정했습니다. 주문 완료 후 이동, 비회원 주문 조회 안내, 알림 메일의 주문 조회 주소, 통합검색·상품문의 목록의 상품 링크, 검색엔진용 주소(사이트맵·봇 화면 캐시)가 모두 해당됐습니다. 링크가 만들어지기는 해서 오류로 드러나지 않고 없는 페이지로만 이어졌습니다. 기본 주소를 쓰는 상점은 이전과 동일합니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
|
||||
- 쇼핑몰 주소를 바꾸거나 주소 없이 운영하는 상점에서 화면 편집기의 상품·장바구니·주문서 등 상점 화면을 편집할 때 상태 미리보기 전환(품절/재고 있음 등)이 표시되지 않던 문제를 수정했습니다. 편집기가 기본 주소인 상점만 알아보던 탓이며, 이제 어떤 주소로 바꾸어도 — 주소 없이 운영하는 상점까지 — 동일하게 표시됩니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
|
||||
- 상품 상세 화면에서만 상태 미리보기 전환(재고 있음/품절/판매중지/판매예정)이 어떤 쇼핑몰 주소에서도 표시되지 않던 문제를 수정했습니다. 편집기가 찾는 화면 주소와 실제 상품 상세 주소의 표기가 서로 달라 끝내 연결되지 않았습니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -240,6 +240,10 @@ _단건 응답: `data` 객체의 필드._
|
||||
| items | array | `[{"id":962,"product_id":201,"quantity":1, …}]` | 이번 요청으로 담긴 장바구니 아이템 목록 (CartItemResource — 아래 "장바구니 아이템 필드" 참조) |
|
||||
| cart_count | integer | `3` | 담기 후 장바구니에 들어있는 전체 아이템 수 (`CartService::bulkAddToCart()` 반환값) |
|
||||
|
||||
> 추가옵션 추가금(`price_adjustment`)은 쇼핑몰 **기본 통화** 기준 값입니다. 기본 통화가 아닌 통화로 표시할 때는 `multi_currency_price_adjustment` 의 해당 통화 값을 사용하세요 — 통화코드별로 `{ price, formatted, is_default, exchange_rate }` 를 담으며 `formatted` 에는 부호(`+`/`-`)가 붙습니다. 상품가·옵션가의 `multi_currency_*` 와 같은 형태입니다.
|
||||
|
||||
> 담기 응답의 아이템은 목록 조회(`GET /cart`)가 돌려주는 아이템과 **동일한 필드·동일한 값**을 가집니다. 추가옵션을 선택해 담았다면 `additional_options`·`additional_options_total` 이 채워지고 그 금액이 `subtotal` 에 반영되므로, 담기 직후 목록을 다시 조회하지 않고 이 응답만으로 화면을 갱신해도 됩니다.
|
||||
|
||||
**장바구니 아이템 필드** (`items[]` — CartItemResource)
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
@@ -248,8 +252,8 @@ _단건 응답: `data` 객체의 필드._
|
||||
| product_id | integer | `201` | 담긴 상품 ID |
|
||||
| product_option_id | integer | `1086` | 선택된 상품 옵션 ID |
|
||||
| quantity | integer | `1` | 구매 수량 |
|
||||
| additional_options | array | `[]` | 선택된 추가옵션 해석 결과 (항목별 `additional_option_id`, `value_id`, `group_name`, `name`, `price_adjustment`, `price_adjustment_formatted`, `custom_text`) |
|
||||
| additional_options_total | integer | `0` | 추가옵션 단위 추가금 합계 (수량 미곱셈) |
|
||||
| additional_options | array | `[]` | 선택된 추가옵션 해석 결과 (항목별 `additional_option_id`, `value_id`, `group_name`, `name`, `price_adjustment`, `price_adjustment_formatted`, `multi_currency_price_adjustment`, `custom_text`) |
|
||||
| additional_options_total | integer | `0` | 추가옵션 단위 추가금 합계 (수량 미곱셈, 기본 통화 기준) |
|
||||
| created_at | string | `2026-07-14 10:00:00` | 담은 일시 (사용자 타임존 포맷) |
|
||||
| updated_at | string | `2026-07-14 10:00:00` | 최종 수정 일시 (사용자 타임존 포맷) |
|
||||
| product | object\|null | `{"id":201,"name":"티셔츠", …}` | 상품 요약 (`id`, `name`, `product_code`, `thumbnail_url`, `sales_status`, `display_status`) |
|
||||
@@ -787,6 +791,8 @@ _단건 응답: `data` 객체의 필드._
|
||||
| --- | --- | --- | --- |
|
||||
| item | object | `{"id":962,"product_option_id":1090, …}` | 옵션·수량이 반영된 장바구니 아이템 1건 (CartItemResource — 필드 구성은 POST `/cart` 의 "장바구니 아이템 필드" 표와 동일) |
|
||||
|
||||
> 값도 목록 조회와 동일합니다. 재선택한 추가옵션이 `additional_options` 에 실리고 그 금액이 `subtotal` 에 반영되므로, 이 응답만으로 해당 행을 갱신해도 금액이 어긋나지 않습니다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
|
||||
@@ -295,7 +295,9 @@ Accept: application/json
|
||||
| issuable | boolean | 지금 발급이 가능한지 여부 (무통장 + 입금완료 + 미발급 + 현금성 금액 > 0 + 프로바이더 설정됨) |
|
||||
| cash_receipt | object\|null | 현재 활성 영수증 1건 (`CashReceiptResource`). 발급 전이거나 전액 취소된 경우 `null` |
|
||||
|
||||
`cash_receipt` 의 하위 필드 구성은 [발급 API 의 응답 필드 표](orders.md)와 동일합니다.
|
||||
`cash_receipt` 의 하위 필드 구성은 [발급 API 의 응답 필드 표](orders.md)와 동일합니다. 금액(`amount`)은 **결제 통화 기준 실청구액**입니다 — base 통화와 결제 통화가 다른 상점에서도 구매자가 실제로 낸 금액으로 발행됩니다.
|
||||
|
||||
비회원 **주문 상세** 응답(`GET user/orders/{orderNumber}` + `X-Guest-Order-Token`)도 회원과 동일하게 `cash_receipt`(활성 영수증 1건 또는 `null`)와 `cash_receipts`(발급·취소 이력 배열)를 포함합니다. 주문상세 화면의 현금영수증 카드가 이 두 필드로 발급완료/미발급을 가르므로, 누락되면 발급에 성공해도 화면이 계속 미발급으로 표시됩니다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
|
||||
@@ -476,6 +476,8 @@ _단건 응답: `data` 객체의 필드._
|
||||
| total_paid_amount_formatted | string | `¥176,000` | `total_paid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
|
||||
| total_due_amount | integer | `0` | 총 결제예정금액 (무통장 등) |
|
||||
| total_due_amount_formatted | string | `¥0` | `total_due_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
|
||||
| total_due_charge_amount | integer | `0` | **결제 통화(`payment_currency`) 기준 실청구액**, 최소 화폐단위 정수 (KRW ×1 / 소수통화 ×10^n). `total_due_amount` 는 base 통화 금액이므로 base≠결제 통화면 값이 다릅니다. 무통장 입금확인이 검증하는 금액이 이 값이며, 화면은 이 값을 그대로 입금액으로 보내야 합니다 |
|
||||
| total_due_charge_amount_formatted | string | `0원` | `total_due_charge_amount` 를 결제 통화 기호로 표기한 문자열 |
|
||||
| depositor_name | null | `null` | 무통장 입금자명 (입금확인 모달 기본값, payment 관계 로드 시에만 노출) |
|
||||
| total_cancelled_amount | integer | `0` | 총 취소금액 |
|
||||
| total_cancelled_amount_formatted | string | `¥0` | `total_cancelled_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
|
||||
@@ -550,6 +552,8 @@ _단건 응답: `data` 객체의 필드._
|
||||
| payments | array | `[{"id":1,"payment_status":"paid","payment_status_label":"…` | 결제 이력 목록 (OrderPaymentResource 배열 — 다회 결제/부분결제 포함) |
|
||||
| cash_receipt | null | `null` | 현재 유효한 현금영수증 (CashReceiptResource — 취소되지 않은 발급 건, 없으면 null) |
|
||||
| cash_receipts | array | `[]` | 현금영수증 발급·취소 이력 전체 (CashReceiptResource 배열 — 취소된 건 포함) |
|
||||
|
||||
> `CashReceiptResource` 의 `amount`/`tax_free_amount` 는 **결제 통화 기준 실청구액**입니다 (구매자가 실제로 낸 금액으로 세금 증빙이 발행되어야 하므로). `amount_formatted` 도 결제 통화 기호로 표기합니다.
|
||||
| shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) |
|
||||
| cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) |
|
||||
| promotions_applied_snapshot | object | `{"coupon_issue_ids":[7330],"item_coupons":[],"discount_co…` | 적용된 프로모션 스냅샷 (재계산용) |
|
||||
@@ -2214,6 +2218,8 @@ _단건 응답: `data` 객체의 필드._
|
||||
| total_paid_amount_formatted | string | `0원` | `total_paid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
|
||||
| total_due_amount | integer | `327327` | 총 결제예정금액 (무통장 등) |
|
||||
| total_due_amount_formatted | string | `327,327원` | `total_due_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
|
||||
| total_due_charge_amount | integer | `327327` | **결제 통화(`payment_currency`) 기준 실청구액**, 최소 화폐단위 정수. base≠결제 통화면 `total_due_amount` 와 다릅니다 |
|
||||
| total_due_charge_amount_formatted | string | `327,327원` | `total_due_charge_amount` 를 결제 통화 기호로 표기한 문자열 |
|
||||
| depositor_name | null | `null` | 무통장 입금자명 (입금확인 모달 기본값, payment 관계 로드 시에만 노출) |
|
||||
| total_cancelled_amount | integer | `0` | 총 취소금액 |
|
||||
| total_cancelled_amount_formatted | string | `0원` | `total_cancelled_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
|
||||
@@ -3040,6 +3046,8 @@ _단건 응답: `data` 객체의 필드._
|
||||
| total_paid_amount_formatted | string | `0원` | `total_paid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
|
||||
| total_due_amount | integer | `327327` | 총 결제예정금액 (무통장 등) |
|
||||
| total_due_amount_formatted | string | `327,327원` | `total_due_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
|
||||
| total_due_charge_amount | integer | `327327` | **결제 통화(`payment_currency`) 기준 실청구액**, 최소 화폐단위 정수. base≠결제 통화면 `total_due_amount` 와 다릅니다 |
|
||||
| total_due_charge_amount_formatted | string | `327,327원` | `total_due_charge_amount` 를 결제 통화 기호로 표기한 문자열 |
|
||||
| depositor_name | null | `null` | 무통장 입금자명 (입금확인 모달 기본값, payment 관계 로드 시에만 노출) |
|
||||
| total_cancelled_amount | integer | `0` | 총 취소금액 |
|
||||
| total_cancelled_amount_formatted | string | `0원` | `total_cancelled_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
|
||||
|
||||
@@ -401,7 +401,7 @@ _단건 응답: `data` 객체의 필드 (`ProductResource`). 성공 시 HTTP 201
|
||||
| has_options | boolean | `false` | 옵션 사용 여부 |
|
||||
| option_groups | array | `[]` | 옵션 그룹 정의 (예: `[{"name":"색상","values":["빨강","파랑"]}]`) |
|
||||
| options | array | `[]` | 생성된 옵션(SKU) 목록 (`ProductOptionResource`) |
|
||||
| additional_options | array | `[]` | 추가옵션 그룹 목록 |
|
||||
| additional_options | array | `[]` | 추가옵션 그룹 목록. 각 그룹의 `values[]` 는 `id`, `name`, `price_adjustment`(기본 통화 기준 추가금), `price_adjustment_formatted`, `multi_currency_price_adjustment`(통화별 추가금 — 기본 통화가 아닌 통화로 표시할 때 사용), `is_default`, `allow_custom_text` 를 가집니다 |
|
||||
| created_at | string | `2026-07-08 10:44:49` | 생성 일시 |
|
||||
| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 |
|
||||
| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 상품에 수행 가능한 작업 불리언 맵 |
|
||||
@@ -846,11 +846,34 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| (ProductResource) | object | `{"id":201,"product_code":"P-201", …}` | 상품 상세 1건. 필드 구성은 `GET /admin/products/{identifier}` 와 동일하며, 목록·옵션·이미지·상품고시·공통정보·추가옵션을 포함합니다 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "상품을 조회했습니다.",
|
||||
"data": {
|
||||
"id": 201,
|
||||
"product_code": "P-201",
|
||||
"name": { "ko": "티셔츠", "en": "T-Shirt" },
|
||||
"selling_price": 19000,
|
||||
"selling_price_formatted": "19,000원",
|
||||
"sales_status": "on_sale",
|
||||
"display_status": "visible",
|
||||
"options": [],
|
||||
"images": [],
|
||||
"additional_options": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
@@ -1049,7 +1072,7 @@ _단건 응답: `data` 객체의 필드 (`ProductResource` — 수정 후 상품
|
||||
| has_options | boolean | `false` | 옵션 사용 여부 |
|
||||
| option_groups | array | `[]` | 옵션 그룹 정의 |
|
||||
| options | array | `[]` | 옵션(SKU) 목록 (`ProductOptionResource`) |
|
||||
| additional_options | array | `[]` | 추가옵션 그룹 목록 |
|
||||
| additional_options | array | `[]` | 추가옵션 그룹 목록. 각 그룹의 `values[]` 는 `id`, `name`, `price_adjustment`(기본 통화 기준 추가금), `price_adjustment_formatted`, `multi_currency_price_adjustment`(통화별 추가금 — 기본 통화가 아닌 통화로 표시할 때 사용), `is_default`, `allow_custom_text` 를 가집니다 |
|
||||
| created_at | string | `2026-07-08 10:44:49` | 생성 일시 |
|
||||
| updated_at | string | `2026-07-08 11:02:13` | 최종 수정 일시 (이번 수정 시각으로 갱신) |
|
||||
| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 상품에 수행 가능한 작업 불리언 맵 |
|
||||
@@ -1442,11 +1465,41 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| options | object | `{"201":[{"id":1086, …}]}` | 상품 ID(문자열 키) ⇒ 해당 상품의 옵션 목록(ProductOptionResource 배열). 조회 결과가 없어도 JSON 객체(`{}`)로 나갑니다 |
|
||||
| product_ids | array | `[201, 202]` | 요청한 상품 ID 목록 (응답의 `options` 키 순서 기준) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "상품 옵션을 조회했습니다.",
|
||||
"data": {
|
||||
"options": {
|
||||
"201": [
|
||||
{
|
||||
"id": 1086,
|
||||
"option_code": "OPT-1086",
|
||||
"option_name": "색상/사이즈",
|
||||
"option_name_localized": "색상/사이즈",
|
||||
"selling_price": 19000,
|
||||
"selling_price_formatted": "19,000원",
|
||||
"stock_quantity": 30,
|
||||
"is_active": true
|
||||
}
|
||||
],
|
||||
"202": []
|
||||
},
|
||||
"product_ids": [201, 202]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
@@ -1488,11 +1541,34 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| (ProductResource) | object | `{"id":201,"product_code":"P-201", …}` | 상품 상세 1건. 필드 구성은 `GET /admin/products/{identifier}` 와 동일하며, 목록·옵션·이미지·상품고시·공통정보·추가옵션을 포함합니다 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "상품을 조회했습니다.",
|
||||
"data": {
|
||||
"id": 201,
|
||||
"product_code": "P-201",
|
||||
"name": { "ko": "티셔츠", "en": "T-Shirt" },
|
||||
"selling_price": 19000,
|
||||
"selling_price_formatted": "19,000원",
|
||||
"sales_status": "on_sale",
|
||||
"display_status": "visible",
|
||||
"options": [],
|
||||
"images": [],
|
||||
"additional_options": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
@@ -1701,11 +1777,25 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-409 — 응답 필드는 사람이 작성하세요. -->
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| deleted | boolean | `true` | 삭제 성공 여부. 주문 이력이 있는 상품은 삭제되지 않고 409 로 차단됩니다 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-409 — 응답 예시는 사람이 작성하세요. -->
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "상품이 삭제되었습니다.",
|
||||
"data": {
|
||||
"deleted": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
@@ -1904,7 +1994,7 @@ _단건 응답: `data` 객체의 필드 (`ProductResource` — 수정 후 상품
|
||||
| has_options | boolean | `false` | 옵션 사용 여부 |
|
||||
| option_groups | array | `[]` | 옵션 그룹 정의 |
|
||||
| options | array | `[]` | 옵션(SKU) 목록 (`ProductOptionResource`) |
|
||||
| additional_options | array | `[]` | 추가옵션 그룹 목록 |
|
||||
| additional_options | array | `[]` | 추가옵션 그룹 목록. 각 그룹의 `values[]` 는 `id`, `name`, `price_adjustment`(기본 통화 기준 추가금), `price_adjustment_formatted`, `multi_currency_price_adjustment`(통화별 추가금 — 기본 통화가 아닌 통화로 표시할 때 사용), `is_default`, `allow_custom_text` 를 가집니다 |
|
||||
| created_at | string | `2026-07-08 10:44:49` | 생성 일시 |
|
||||
| updated_at | string | `2026-07-08 11:02:13` | 최종 수정 일시 (이번 수정 시각으로 갱신) |
|
||||
| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 상품에 수행 가능한 작업 불리언 맵 |
|
||||
@@ -3302,7 +3392,7 @@ HTTP/1.1 200
|
||||
| product | path | string | 예 | — | 대상 product의 식별자 |
|
||||
| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
|
||||
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
|
||||
| exclude_secret | query | boolean | 아니오 | — | <!-- TODO: 용도 --> |
|
||||
| exclude_secret | query | boolean | 아니오 | — | 비밀글 제외 여부 (기본 `false` — 포함). `true` 면 비밀 문의를 목록에서 제외합니다. 쿼리 문자열 `"true"`/`"false"` 도 해석되며, 해석할 수 없는 값은 그대로 검증되어 422 가 됩니다 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
|
||||
@@ -2181,8 +2181,13 @@ class Module extends AbstractModule
|
||||
* 타게팅(모든 web 요청 = 기존 web 그룹 append 와 동등). InjectAppConfigDeviceListener 가
|
||||
* appConfig.isIos 로 주입(체크아웃 애플페이 iOS 게이팅).
|
||||
* - ResolveShippingCountry: 상품/장바구니/체크아웃/주문생성 라우트에만 부착 (배송국가 해석).
|
||||
* - VerifyGuestOrderToken: 비회원 주문 후속 액션 4개 라우트에만 부착 (verify 는 미부착 —
|
||||
* 토큰 발급 전 단계라 개별 지정).
|
||||
* - VerifyGuestOrderToken: 비회원 주문 후속 액션 라우트에 부착 (취소·환불예상·배송지수정·구매확정
|
||||
* 개별 지정 + 현금영수증 하위 전체 glob). `guest.orders.verify` 는 조회 토큰을 **발급**하는
|
||||
* 엔드포인트라 호출 시점에 토큰이 없다 — 부착하면 최초 인증 요청이 404 로 막혀 비회원 조회가
|
||||
* 통째로 죽으므로 제외한다(요청 빈도 제한은 throttle 과 GuestOrderAuthService 의 실패 잠금 담당).
|
||||
* 부착 대상 개수를 이 주석에 적지 않는다 — 개수를 문서에 박는 것이 다음 누락의 씨앗이다
|
||||
* (실제로 현금영수증 라우트 2건이 추가될 때 targets 갱신이 누락돼 기능이 전면 불능이었다).
|
||||
* 정합성은 `GuestOrderTokenMiddlewareRegistrationTest` 가 라우트 테이블 전수로 강제한다.
|
||||
*
|
||||
* @return array<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
|
||||
*/
|
||||
@@ -2212,6 +2217,10 @@ class Module extends AbstractModule
|
||||
'api.modules.sirsoft-ecommerce.guest.orders.estimate-refund',
|
||||
'api.modules.sirsoft-ecommerce.guest.orders.update-shipping-address',
|
||||
'api.modules.sirsoft-ecommerce.guest.orders.confirm-option',
|
||||
// 현금영수증 하위 전체(show/issue/향후 추가분). glob 은 이 서브트리의 기본값을
|
||||
// '보호' 로 만들어 실패 방향을 안전한 쪽으로 뒤집는다. `cash-receipt` 가 리터럴
|
||||
// 세그먼트라 구조적으로 `verify` 와 매칭될 수 없다.
|
||||
'api.modules.sirsoft-ecommerce.guest.orders.cash-receipt.*',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "ecommerce_preferred_currency",
|
||||
"value": "{{_local.form?.ecommerce_preferred_currency ?? user?.data?.ecommerce_preferred_currency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}}",
|
||||
"value": "{{_local.form?.ecommerce_preferred_currency ?? user?.data?.ecommerce_preferred_currency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency}}",
|
||||
"options": "{{(_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).filter(c => c.is_default || (c.exchange_rate ?? 0) > 0).map(c => ({ value: c.code, label: c.code + ' (' + (c.symbol ?? c.code) + ')' }))}}",
|
||||
"className": "w-full sm:w-64"
|
||||
}
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@
|
||||
"comment": "관리자 헤더 표시 통화 복원 — 호스트 _admin_base 는 통화/이커머스를 모른다. 이커머스가 이 init 단계를 layout_extensions(init_actions 병합)로 기여한다. localStorage(g7_preferred_currency) → _global.preferredCurrency, 없으면 default_currency. 표시 전용(영속 저장 없음). 핸들러는 이커머스 모듈 소유.",
|
||||
"handler": "sirsoft-ecommerce.initPreferredCurrency",
|
||||
"params": {
|
||||
"defaultCurrency": "{{_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}}"
|
||||
"defaultCurrency": "{{_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -87,7 +87,7 @@
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": { "className": "font-medium" },
|
||||
"text": "{{_global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}}"
|
||||
"text": "{{_global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency}}"
|
||||
},
|
||||
{
|
||||
"comment": "접힘 상태 요약 ② 현재 배송국가명 (해외배송 활성 + 국가 2개 이상일 때만).",
|
||||
@@ -120,7 +120,7 @@
|
||||
"text": "{{_global.preferredShippingCountry ?? _global.modules?.['sirsoft-ecommerce']?.shipping?.default_country ?? 'KR'}}"
|
||||
},
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "coins", "className": "text-base" } },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{_global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}}" },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{_global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency}}" },
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "chevron-down", "className": "text-xs" } }
|
||||
],
|
||||
"actions": [
|
||||
|
||||
+2
-2
@@ -55,7 +55,7 @@
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": { "className": "font-medium" },
|
||||
"text": "{{_global.preferredCurrency ?? _global.defaultCurrency ?? 'KRW'}}"
|
||||
"text": "{{_global.preferredCurrency ?? _global.defaultCurrency}}"
|
||||
},
|
||||
{
|
||||
"comment": "접힘 상태 요약 ② 현재 배송국가명 (해외배송 활성 + 국가 2개 이상일 때만). 통화와 시각적으로 구분되도록 옅은 색.",
|
||||
@@ -88,7 +88,7 @@
|
||||
"text": "{{_global.preferredShippingCountry ?? _global.modules?.['sirsoft-ecommerce']?.shipping?.default_country ?? 'KR'}}"
|
||||
},
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "coins", "className": "text-base" } },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{_global.preferredCurrency ?? _global.defaultCurrency ?? 'KRW'}}" },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{_global.preferredCurrency ?? _global.defaultCurrency}}" },
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "chevron-down", "className": "text-xs" } }
|
||||
],
|
||||
"actions": [
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "preferred_currency",
|
||||
"value": "{{_local?.registerForm?.preferred_currency ?? _global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}}",
|
||||
"value": "{{_local?.registerForm?.preferred_currency ?? _global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency}}",
|
||||
"options": "{{(_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).filter(c => c.is_default || (c.exchange_rate ?? 0) > 0).map(c => ({ value: c.code, label: c.code + ' (' + (c.symbol ?? c.code) + ')' }))}}",
|
||||
"className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
|
||||
"disabled": "{{_local?.isRegistering}}"
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 설정 통화 기준 금액 포맷 회귀 테스트
|
||||
*
|
||||
* 관리자 주문 취소 모달은 단가·소계·쿠폰 할인액을 `금액.toLocaleString() + '원'` 으로
|
||||
* 조립했다. 기본 통화가 원화가 아닌 상점에서는 값은 기준 통화인데 단위만 원으로 표기돼,
|
||||
* 운영자가 환불 금액을 잘못 읽을 수 있었다.
|
||||
*
|
||||
* 통화 단위는 설정(`language_currency.currencies`)이 정한다는 것을 고정한다.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { formatAmountInCurrency } from '../../handlers/calculateCurrencyPrices';
|
||||
|
||||
/**
|
||||
* 전역 통화 설정을 심습니다.
|
||||
*
|
||||
* @param defaultCode 기본 통화 코드
|
||||
*/
|
||||
function installCurrencies(defaultCode: string): void {
|
||||
(window as any).G7Core = {
|
||||
state: {
|
||||
get: () => ({
|
||||
modules: {
|
||||
'sirsoft-ecommerce': {
|
||||
language_currency: {
|
||||
default_currency: defaultCode,
|
||||
currencies: [
|
||||
{ code: 'KRW', symbol: '₩', decimal_places: 0, is_default: defaultCode === 'KRW' },
|
||||
{ code: 'JPY', symbol: '¥', decimal_places: 0, is_default: defaultCode === 'JPY' },
|
||||
{ code: 'USD', symbol: '$', decimal_places: 2, is_default: defaultCode === 'USD' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('formatAmountInCurrency', () => {
|
||||
afterEach(() => {
|
||||
delete (window as any).G7Core;
|
||||
});
|
||||
|
||||
it('기본 통화가 JPY 면 엔화 기호로 표기한다 (원화 고정 회귀 차단)', () => {
|
||||
installCurrencies('JPY');
|
||||
|
||||
const formatted = formatAmountInCurrency(25000);
|
||||
|
||||
expect(formatted).toContain('¥');
|
||||
expect(formatted).not.toContain('원');
|
||||
});
|
||||
|
||||
it('기본 통화가 KRW 면 기존과 동일하게 금액 뒤에 원을 붙인다', () => {
|
||||
installCurrencies('KRW');
|
||||
|
||||
expect(formatAmountInCurrency(25000)).toBe('25,000원');
|
||||
});
|
||||
|
||||
it('통화 코드를 명시하면 기본 통화가 아니어도 그 통화로 표기한다', () => {
|
||||
installCurrencies('JPY');
|
||||
|
||||
const formatted = formatAmountInCurrency(12.5, 'USD');
|
||||
|
||||
expect(formatted).toBe('$12.50');
|
||||
});
|
||||
|
||||
it('설정을 읽지 못하면 단위를 임의로 붙이지 않고 숫자만 돌려준다', () => {
|
||||
// 통화 설정 없음 — 원화를 추측해 붙이면 그것이 곧 하드코딩이다.
|
||||
(window as any).G7Core = { state: { get: () => ({}) } };
|
||||
|
||||
const formatted = formatAmountInCurrency(3000);
|
||||
|
||||
expect(formatted).not.toContain('원');
|
||||
expect(formatted).not.toContain('₩');
|
||||
expect(formatted).toBe((3000).toLocaleString());
|
||||
});
|
||||
});
|
||||
+11
-2
@@ -92,7 +92,7 @@ const orderListLayout = {
|
||||
className: 'text-lg font-bold',
|
||||
'data-testid': 'order-total',
|
||||
},
|
||||
text: "{{order.mc_total_amount?.[_global.preferredCurrency ?? 'KRW']?.formatted ?? order.total_amount_formatted}}",
|
||||
text: "{{order.mc_total_amount?.[_global.preferredCurrency ?? _global.defaultCurrency]?.formatted ?? order.total_amount_formatted}}",
|
||||
},
|
||||
{
|
||||
id: 'order-total-mc-{{orderIdx}}',
|
||||
@@ -105,7 +105,7 @@ const orderListLayout = {
|
||||
},
|
||||
iteration: {
|
||||
source:
|
||||
"{{Object.entries(order.mc_total_amount ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? 'KRW'))}}",
|
||||
"{{Object.entries(order.mc_total_amount ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? _global.defaultCurrency))}}",
|
||||
item_var: 'currency',
|
||||
},
|
||||
children: [
|
||||
@@ -190,6 +190,9 @@ describe('마이페이지 주문내역 - 다통화 표시', () => {
|
||||
initialData: {
|
||||
orders: mockOrdersResponse,
|
||||
},
|
||||
// 표시 통화 미선택 상태 — 실제 화면도 init_actions 가 설정의 기본 통화를 주입한다.
|
||||
// 레이아웃이 특정 통화를 리터럴 폴백으로 갖지 않으므로 테스트도 같은 입력을 준다.
|
||||
initialState: { _global: { defaultCurrency: 'KRW' } },
|
||||
});
|
||||
|
||||
await testUtils.render();
|
||||
@@ -207,6 +210,9 @@ describe('마이페이지 주문내역 - 다통화 표시', () => {
|
||||
initialData: {
|
||||
orders: mockOrdersResponse,
|
||||
},
|
||||
// 표시 통화 미선택 상태 — 실제 화면도 init_actions 가 설정의 기본 통화를 주입한다.
|
||||
// 레이아웃이 특정 통화를 리터럴 폴백으로 갖지 않으므로 테스트도 같은 입력을 준다.
|
||||
initialState: { _global: { defaultCurrency: 'KRW' } },
|
||||
});
|
||||
|
||||
await testUtils.render();
|
||||
@@ -230,6 +236,9 @@ describe('마이페이지 주문내역 - 다통화 표시', () => {
|
||||
initialData: {
|
||||
orders: mockOrdersResponse,
|
||||
},
|
||||
// 표시 통화 미선택 상태 — 실제 화면도 init_actions 가 설정의 기본 통화를 주입한다.
|
||||
// 레이아웃이 특정 통화를 리터럴 폴백으로 갖지 않으므로 테스트도 같은 입력을 준다.
|
||||
initialState: { _global: { defaultCurrency: 'KRW' } },
|
||||
});
|
||||
|
||||
testUtils.mockApi('orders', { response: mockOrdersResponse });
|
||||
|
||||
+1
-1
@@ -779,7 +779,7 @@ describe('Partial 레이아웃 구조 검증', () => {
|
||||
it('다통화 표시에 preferredCurrency 필터가 적용된다', () => {
|
||||
const json = JSON.stringify(orderInfoPartial);
|
||||
// 보조 통화만 표시 (기본 통화 제외)
|
||||
expect(json).toContain("_global.preferredCurrency ?? 'KRW'");
|
||||
expect(json).toContain("_global.preferredCurrency ?? _global.defaultCurrency");
|
||||
});
|
||||
|
||||
it('다통화 표시에 iteration 패턴이 사용된다 (engine-v1.19.1)', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { ActionContext } from '../types';
|
||||
import { formatAmountInCurrency } from './calculateCurrencyPrices';
|
||||
import { reloadExpandedOptions } from './expandedOptionsHandlers';
|
||||
|
||||
// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)
|
||||
@@ -757,7 +758,7 @@ export function buildConfirmDataHandler(action: ActionWithParams, context: Actio
|
||||
if (optModifiedFields.includes('list_price')) {
|
||||
optChanges.push(
|
||||
G7Core.t('sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price', {
|
||||
price: opt.list_price?.toLocaleString?.() ?? opt.list_price,
|
||||
price: formatAmountInCurrency(Number(opt.list_price ?? 0)),
|
||||
})
|
||||
|| `List price: ${opt.list_price}`
|
||||
);
|
||||
@@ -830,13 +831,13 @@ export function buildConfirmDataHandler(action: ActionWithParams, context: Actio
|
||||
}
|
||||
if (pModifiedFields.includes('list_price')) {
|
||||
changes.push(
|
||||
G7Core.t('sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price', { price: p.list_price?.toLocaleString?.() || p.list_price })
|
||||
G7Core.t('sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price', { price: formatAmountInCurrency(Number(p.list_price ?? 0)) })
|
||||
|| `List price: ${p.list_price?.toLocaleString?.() || p.list_price}`
|
||||
);
|
||||
}
|
||||
if (pModifiedFields.includes('selling_price')) {
|
||||
changes.push(
|
||||
G7Core.t('sirsoft-ecommerce.admin.product.messages.bulk_summary_selling_price', { price: p.selling_price?.toLocaleString?.() || p.selling_price })
|
||||
G7Core.t('sirsoft-ecommerce.admin.product.messages.bulk_summary_selling_price', { price: formatAmountInCurrency(Number(p.selling_price ?? 0)) })
|
||||
|| `Selling price: ${p.selling_price?.toLocaleString?.() || p.selling_price}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,6 +147,47 @@ export function formatCurrency(
|
||||
return symbol + formattedNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정에 등록된 통화 목록을 전역 상태에서 읽습니다.
|
||||
*
|
||||
* @returns currencies 배열 (없으면 빈 배열)
|
||||
*/
|
||||
function getConfiguredCurrencies(): Currency[] {
|
||||
try {
|
||||
const G7Core = (window as any).G7Core;
|
||||
const state = G7Core?.state?.get?.() || {};
|
||||
const lc = state?.modules?.['sirsoft-ecommerce']?.language_currency;
|
||||
|
||||
return Array.isArray(lc?.currencies) ? lc.currencies : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 금액을 지정 통화(미지정 시 설정의 기본 통화)로 포맷합니다.
|
||||
*
|
||||
* 화면이 `금액.toLocaleString() + '원'` 처럼 통화 단위를 직접 이어 붙이면, 기본 통화가
|
||||
* 원화가 아닌 상점에서 값은 기준 통화인데 단위만 원으로 표기된다. 그 자리에 이 함수를 쓴다.
|
||||
* 서버가 이미 포맷 문자열(`*_formatted`)을 주는 값은 그대로 쓰고, 화면에서 새로 계산해야
|
||||
* 하는 금액(예: 단가 × 수량)에만 사용한다.
|
||||
*
|
||||
* @param amount 금액 (기준 통화 기준)
|
||||
* @param currencyCode 통화 코드 (미지정 시 설정의 기본 통화)
|
||||
* @returns 포맷된 금액 문자열
|
||||
*/
|
||||
export function formatAmountInCurrency(amount: number, currencyCode?: string | null): string {
|
||||
const currencies = getConfiguredCurrencies();
|
||||
const code = currencyCode || currencies.find((c) => c.is_default)?.code;
|
||||
|
||||
if (!code) {
|
||||
// 통화 설정을 읽지 못한 경우 — 단위를 임의로 붙이지 않고 숫자만 돌려준다.
|
||||
return amount.toLocaleString();
|
||||
}
|
||||
|
||||
return formatCurrency(amount, currencies.find((c) => c.code === code) ?? code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 환율 계산 핸들러
|
||||
*
|
||||
|
||||
@@ -142,7 +142,7 @@ function getCurrentUser(): any {
|
||||
/**
|
||||
* 표시 통화(preferredCurrency)를 _global 에 동기 주입합니다.
|
||||
*
|
||||
* @param action params.defaultCurrency — 관리자 설정 기본 통화(미지정 시 KRW),
|
||||
* @param action params.defaultCurrency — 관리자 설정 기본 통화(미지정 시 설정의 is_default 통화),
|
||||
* params.accountCurrency — onSuccess 가 전달하는 계정 영속 통화(init_actions 에선 미전달)
|
||||
* @param _context 액션 컨텍스트 (사용하지 않음 — _global 직접 set)
|
||||
*/
|
||||
@@ -150,7 +150,10 @@ export function initPreferredCurrencyHandler(
|
||||
action?: any,
|
||||
_context?: any
|
||||
): void {
|
||||
const defaultCurrency = action?.params?.defaultCurrency || 'KRW';
|
||||
// 통화를 못 박지 않는다 — 호출부 미지정 시 설정의 기본 통화를 읽는다.
|
||||
const defaultCurrency = action?.params?.defaultCurrency
|
||||
|| getCurrencies().find((c: any) => c?.is_default)?.code
|
||||
|| '';
|
||||
const isValid = (code: unknown): code is string =>
|
||||
typeof code === 'string' && /^[A-Z]{3}$/.test(code);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { ActionContext } from '../types';
|
||||
import { formatAmountInCurrency } from './calculateCurrencyPrices';
|
||||
|
||||
// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)
|
||||
const logger = ((window as any).G7Core?.createLogger?.('Ecom:OrderDetail')) ?? {
|
||||
@@ -252,19 +253,32 @@ export function buildOrderDetailBulkConfirmDataHandler(
|
||||
const orderData = G7Core.dataSource?.get?.('order')?.data;
|
||||
const options = orderData?.options || [];
|
||||
|
||||
// 주문 표기 통화 = 주문 시점 기준 통화 (운영자가 이후 기본 통화를 바꿔도 불변)
|
||||
const orderCurrency = orderData?.currency_snapshot?.base_currency ?? null;
|
||||
|
||||
// 취소 가능한 옵션만 필터 (이미 취소된 옵션 제외)
|
||||
const cancelItems = selectedProducts
|
||||
.map((optionId: number) => {
|
||||
const opt = options.find((o: { id: number }) => o.id === optionId);
|
||||
if (!opt || opt.option_status === 'cancelled') return null;
|
||||
const quantity = opt.quantity ?? 1;
|
||||
|
||||
return {
|
||||
id: opt.id,
|
||||
product_name: opt.product_name,
|
||||
product_option_name: opt.product_option_name,
|
||||
thumbnail_url: opt.product_snapshot?.thumbnail_url || '',
|
||||
unit_price: opt.unit_price,
|
||||
quantity: opt.quantity,
|
||||
cancel_quantity: opt.quantity,
|
||||
// 통화 표기는 화면에서 조립하지 않는다 — 서버 포맷을 그대로 쓰고,
|
||||
// 화면에서 새로 계산되는 소계만 설정 통화로 포맷한다.
|
||||
unit_price_formatted: opt.unit_price_formatted
|
||||
?? formatAmountInCurrency(Number(opt.unit_price ?? 0), orderCurrency),
|
||||
quantity,
|
||||
subtotal_formatted: formatAmountInCurrency(
|
||||
Number(opt.unit_price ?? 0) * quantity,
|
||||
orderCurrency
|
||||
),
|
||||
cancel_quantity: quantity,
|
||||
option_status: opt.option_status,
|
||||
option_status_label: opt.option_status_label || opt.option_status,
|
||||
};
|
||||
@@ -616,7 +630,11 @@ export function openConfirmDepositModalHandler(
|
||||
|
||||
const orderData = G7Core.dataSource?.get?.('order')?.data;
|
||||
const depositorName = orderData?.depositor_name ?? '';
|
||||
const depositAmount = orderData?.total_due_amount ?? 0;
|
||||
// 입금액 기본값은 **결제 통화 기준 실청구액**(total_due_charge_amount)이다.
|
||||
// 서버 검증(validatePaymentAmount 2단계)이 이 값과 대조하므로, base 금액
|
||||
// (total_due_amount)을 넣으면 base≠결제 통화인 주문은 항상 금액 불일치 422 가 된다.
|
||||
// 구버전 응답 호환을 위해 값이 없을 때만 base 금액으로 폴백한다.
|
||||
const depositAmount = orderData?.total_due_charge_amount ?? orderData?.total_due_amount ?? 0;
|
||||
|
||||
// 모달 열기 전 페이지 _local 에 입력 시드 + 상태 초기화 (취소 모달과 동일 패턴)
|
||||
G7Core.state.setLocal({
|
||||
|
||||
@@ -117,7 +117,10 @@ export function updatePriceHandler(
|
||||
const state = G7Core.state.getLocal() || {};
|
||||
const currencyData = context.datasources?.currencies?.data || {};
|
||||
const currencies = currencyData?.list || [];
|
||||
const defaultCurrency = currencyData?.default_currency || 'KRW';
|
||||
// 통화를 못 박지 않는다 — 응답에 기본 통화가 없으면 is_default 통화로 해석한다.
|
||||
const defaultCurrency = currencyData?.default_currency
|
||||
|| currencies.find((c: any) => c?.is_default)?.code
|
||||
|| '';
|
||||
const roundingMethod = currencyData?.rounding_method || 'round';
|
||||
|
||||
const newPrices = { ...(state.form?.multi_currency_prices || {}) };
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@
|
||||
"status": "상태",
|
||||
"actions": "작업"
|
||||
},
|
||||
"benefit_max": "최대 {{amount}}원"
|
||||
"benefit_max": "최대 {{amount}}"
|
||||
},
|
||||
"mobile": {
|
||||
"code_label": "코드",
|
||||
|
||||
+8
-8
@@ -119,8 +119,8 @@
|
||||
"fee_summary": {
|
||||
"label": "배송비:",
|
||||
"free": "무료배송",
|
||||
"fixed": "배송비: {{fee}}원",
|
||||
"conditional": "{{threshold}}원 미만 {{fee}}원 / {{threshold}}원 이상 무료",
|
||||
"fixed": "배송비: {{fee}}",
|
||||
"conditional": "{{threshold}} 미만 {{fee}} / {{threshold}} 이상 무료",
|
||||
"range_prefix": {
|
||||
"amount": "",
|
||||
"quantity": "개",
|
||||
@@ -128,11 +128,11 @@
|
||||
"volume": "cm³"
|
||||
},
|
||||
"api": "외부 API 연동 (실시간 계산)",
|
||||
"per_quantity": "{{unit}}개당 {{fee}}원",
|
||||
"per_weight": "{{unit}}kg당 {{fee}}원",
|
||||
"per_volume": "{{unit}}L당 {{fee}}원",
|
||||
"per_volume_weight": "{{unit}}kg당 {{fee}}원",
|
||||
"per_amount": "{{unit}}원당 {{fee}}원"
|
||||
"per_quantity": "{{unit}}개당 {{fee}}",
|
||||
"per_weight": "{{unit}}kg당 {{fee}}",
|
||||
"per_volume": "{{unit}}L당 {{fee}}",
|
||||
"per_volume_weight": "{{unit}}kg당 {{fee}}",
|
||||
"per_amount": "{{unit}}당 {{fee}}"
|
||||
},
|
||||
|
||||
"fee_details": {
|
||||
@@ -245,7 +245,7 @@
|
||||
"api_response_path_hint": "중첩 응답은 점(.)으로 경로를 지정하세요. 예: data.shipping.fee",
|
||||
"api_test_button": "테스트 호출",
|
||||
"api_test_loading": "호출 중...",
|
||||
"api_test_extracted": "추출된 배송비: :fee 원",
|
||||
"api_test_extracted": "추출된 배송비: :fee",
|
||||
"api_test_no_fee": "응답은 받았으나 배송비 값을 추출하지 못했습니다. 응답 형식·경로를 확인하세요.",
|
||||
"api_test_failed": "API 호출에 실패했습니다 (연결 불가 또는 타임아웃).",
|
||||
"api_test_request_body": "요청 본문:",
|
||||
|
||||
+3
-2
@@ -28,7 +28,8 @@
|
||||
"manual": {
|
||||
"action": "earn",
|
||||
"amount": "",
|
||||
"currency": "KRW",
|
||||
"_comment_currency": "모달 진입 시 설정의 기본 통화로 채운다 — 특정 통화를 박으면 그 통화 원장이 없는 상점에서 잘못된 통화로 적립된다",
|
||||
"currency": "",
|
||||
"reason": "",
|
||||
"useDefaultExpiry": true,
|
||||
"expiresAt": ""
|
||||
@@ -181,7 +182,7 @@
|
||||
"memberSearchResults": [],
|
||||
"action": "earn",
|
||||
"amount": "",
|
||||
"currency": "KRW",
|
||||
"currency": "{{_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? ''}}",
|
||||
"reason": "",
|
||||
"useDefaultExpiry": true,
|
||||
"expiresAt": ""
|
||||
|
||||
+1
-1
@@ -781,7 +781,7 @@
|
||||
"props": {
|
||||
"className": "text-sm font-medium text-primary-600 dark:text-primary-400"
|
||||
},
|
||||
"text": "{{(copy_source?.data?.selling_price ?? 0) | number}}{{(_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW')}}"
|
||||
"text": "{{(copy_source?.data?.selling_price ?? 0) | number}}{{(_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency)}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+2
-2
@@ -15,8 +15,8 @@
|
||||
"shippingMethodOptions": "{{(ecommerce_settings?.data?.shipping?.types ?? []).filter(t => t.is_active).map(t => ({value: t.code, label: t.name?.[$locale] ?? t.name?.[_global.locale] ?? t.name?.ko ?? t.code}))}}",
|
||||
"chargePolicyOptions": "{{[{value: 'free', label: $t('sirsoft-ecommerce.admin.shipping_policy.enums.charge_policy.free')}, {value: 'fixed', label: $t('sirsoft-ecommerce.admin.shipping_policy.enums.charge_policy.fixed')}, {value: 'conditional_free', label: $t('sirsoft-ecommerce.admin.shipping_policy.enums.charge_policy.conditional_free')}, {value: 'range_amount', label: $t('sirsoft-ecommerce.admin.shipping_policy.enums.charge_policy.range_amount')}, {value: 'range_quantity', label: $t('sirsoft-ecommerce.admin.shipping_policy.enums.charge_policy.range_quantity')}, {value: 'range_weight', label: $t('sirsoft-ecommerce.admin.shipping_policy.enums.charge_policy.range_weight')}, {value: 'range_volume', label: $t('sirsoft-ecommerce.admin.shipping_policy.enums.charge_policy.range_volume')}, {value: 'range_volume_weight', label: $t('sirsoft-ecommerce.admin.shipping_policy.enums.charge_policy.range_volume_weight')}, {value: 'per_quantity', label: $t('sirsoft-ecommerce.admin.shipping_policy.enums.charge_policy.per_quantity')}, {value: 'per_weight', label: $t('sirsoft-ecommerce.admin.shipping_policy.enums.charge_policy.per_weight')}, {value: 'per_volume', label: $t('sirsoft-ecommerce.admin.shipping_policy.enums.charge_policy.per_volume')}, {value: 'per_volume_weight', label: $t('sirsoft-ecommerce.admin.shipping_policy.enums.charge_policy.per_volume_weight')}, {value: 'per_amount', label: $t('sirsoft-ecommerce.admin.shipping_policy.enums.charge_policy.per_amount')}, {value: 'api', label: $t('sirsoft-ecommerce.admin.shipping_policy.enums.charge_policy.api')}]}}",
|
||||
"currencyOptions": "{{(ecommerce_settings?.data?.language_currency?.currencies ?? []).map(c => ({ value: c.code, label: $localized(c.name) }))}}",
|
||||
"defaultCurrencyCode": "{{ecommerce_settings?.data?.language_currency?.default_currency ?? 'KRW'}}",
|
||||
"defaultCurrencyLabel": "{{(() => { const code = ecommerce_settings?.data?.language_currency?.default_currency ?? 'KRW'; const match = (ecommerce_settings?.data?.language_currency?.currencies ?? []).find(c => c.code === code); const name = match ? $localized(match.name) : ''; if (!name) return code; return name.includes(code) ? name : name + ' (' + code + ')'; })()}}",
|
||||
"defaultCurrencyCode": "{{ecommerce_settings?.data?.language_currency?.default_currency}}",
|
||||
"defaultCurrencyLabel": "{{(() => { const code = ecommerce_settings?.data?.language_currency?.default_currency; const match = (ecommerce_settings?.data?.language_currency?.currencies ?? []).find(c => c.code === code); const name = match ? $localized(match.name) : ''; if (!name) return code; return name.includes(code) ? name : name + ' (' + code + ')'; })()}}",
|
||||
"apiRequestFieldOptions": "{{ecommerce_settings?.data?.shipping?.api_request_fields ?? []}}",
|
||||
"apiHttpMethodOptions": "{{ecommerce_settings?.data?.shipping?.api_http_methods ?? []}}",
|
||||
"apiAuthTypeOptions": "{{ecommerce_settings?.data?.shipping?.api_auth_types ?? []}}",
|
||||
|
||||
+15
-15
@@ -294,7 +294,7 @@
|
||||
"props": {
|
||||
"className": "text-body whitespace-nowrap"
|
||||
},
|
||||
"text": "{{(cancelItem.unit_price ?? 0).toLocaleString()}}원 × {{cancelItem.quantity ?? 1}}"
|
||||
"text": "{{cancelItem.unit_price_formatted ?? ''}} × {{cancelItem.quantity ?? 1}}"
|
||||
},
|
||||
{
|
||||
"comment": "break-keep + whitespace-nowrap 조합 대신 break-keep 만 — `소계`(한글 라벨) + 금액이 한 문자열이라 nowrap 을 주면 좁은 셀에서 넘친다. break-keep 은 어절 경계에서만 줄바꿈해 `소계` / `249,000원` 으로 끊고 숫자 내부는 보존한다.",
|
||||
@@ -303,7 +303,7 @@
|
||||
"props": {
|
||||
"className": "text-tertiary break-keep"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.product_subtotal {{((cancelItem.unit_price ?? 0) * (cancelItem.quantity ?? 1)).toLocaleString()}}원"
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.modal.cancel.product_subtotal {{cancelItem.subtotal_formatted ?? ''}}"
|
||||
}
|
||||
],
|
||||
"responsive": {
|
||||
@@ -919,7 +919,7 @@
|
||||
"className": "text-hint block"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "{{Object.entries(_local.refundEstimate?.mc_original_snapshot?.mc_total_list_price_amount_formatted ?? {}).filter(([code]) => code !== (_local.refundEstimate?.original_snapshot?.base_currency ?? _global.preferredCurrency ?? 'KRW'))}}",
|
||||
"source": "{{Object.entries(_local.refundEstimate?.mc_original_snapshot?.mc_total_list_price_amount_formatted ?? {}).filter(([code]) => code !== (_local.refundEstimate?.original_snapshot?.base_currency ?? _global.preferredCurrency ?? _global.defaultCurrency))}}",
|
||||
"item_var": "mc"
|
||||
},
|
||||
"text": "{{mc[1]}}"
|
||||
@@ -958,7 +958,7 @@
|
||||
"className": "text-hint block"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "{{Object.entries(_local.refundEstimate?.mc_recalculated_snapshot?.mc_total_list_price_amount_formatted ?? {}).filter(([code]) => code !== (_local.refundEstimate?.original_snapshot?.base_currency ?? _global.preferredCurrency ?? 'KRW'))}}",
|
||||
"source": "{{Object.entries(_local.refundEstimate?.mc_recalculated_snapshot?.mc_total_list_price_amount_formatted ?? {}).filter(([code]) => code !== (_local.refundEstimate?.original_snapshot?.base_currency ?? _global.preferredCurrency ?? _global.defaultCurrency))}}",
|
||||
"item_var": "mc"
|
||||
},
|
||||
"text": "{{mc[1]}}"
|
||||
@@ -1015,7 +1015,7 @@
|
||||
"className": "text-hint block"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "{{Object.entries(_local.refundEstimate?.mc_original_snapshot?.mc_subtotal_amount_formatted ?? {}).filter(([code]) => code !== (_local.refundEstimate?.original_snapshot?.base_currency ?? _global.preferredCurrency ?? 'KRW'))}}",
|
||||
"source": "{{Object.entries(_local.refundEstimate?.mc_original_snapshot?.mc_subtotal_amount_formatted ?? {}).filter(([code]) => code !== (_local.refundEstimate?.original_snapshot?.base_currency ?? _global.preferredCurrency ?? _global.defaultCurrency))}}",
|
||||
"item_var": "mc"
|
||||
},
|
||||
"text": "{{mc[1]}}"
|
||||
@@ -1054,7 +1054,7 @@
|
||||
"className": "text-hint block"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "{{Object.entries(_local.refundEstimate?.mc_recalculated_snapshot?.mc_subtotal_amount_formatted ?? {}).filter(([code]) => code !== (_local.refundEstimate?.original_snapshot?.base_currency ?? _global.preferredCurrency ?? 'KRW'))}}",
|
||||
"source": "{{Object.entries(_local.refundEstimate?.mc_recalculated_snapshot?.mc_subtotal_amount_formatted ?? {}).filter(([code]) => code !== (_local.refundEstimate?.original_snapshot?.base_currency ?? _global.preferredCurrency ?? _global.defaultCurrency))}}",
|
||||
"item_var": "mc"
|
||||
},
|
||||
"text": "{{mc[1]}}"
|
||||
@@ -1137,7 +1137,7 @@
|
||||
"props": {
|
||||
"className": "text-hint"
|
||||
},
|
||||
"text": "{{pc.name}} -{{(pc.discount_amount ?? 0).toLocaleString()}}원"
|
||||
"text": "{{pc.name}} -{{pc.discount_amount_formatted ?? ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1159,7 +1159,7 @@
|
||||
"props": {
|
||||
"className": "text-hint"
|
||||
},
|
||||
"text": "{{rpc.name}} -{{(rpc.discount_amount ?? 0).toLocaleString()}}원"
|
||||
"text": "{{rpc.name}} -{{rpc.discount_amount_formatted ?? ''}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1239,7 +1239,7 @@
|
||||
"props": {
|
||||
"className": "text-hint"
|
||||
},
|
||||
"text": "{{oc.name}} -{{(oc.discount_amount ?? 0).toLocaleString()}}원"
|
||||
"text": "{{oc.name}} -{{oc.discount_amount_formatted ?? ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1261,7 +1261,7 @@
|
||||
"props": {
|
||||
"className": "text-hint"
|
||||
},
|
||||
"text": "{{roc.name}} -{{(roc.discount_amount ?? 0).toLocaleString()}}원"
|
||||
"text": "{{roc.name}} -{{roc.discount_amount_formatted ?? ''}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1375,7 +1375,7 @@
|
||||
"props": {
|
||||
"className": "text-hint"
|
||||
},
|
||||
"text": "{{sc.name}} -{{(sc.discount_amount ?? 0).toLocaleString()}}원"
|
||||
"text": "{{sc.name}} -{{sc.discount_amount_formatted ?? ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1397,7 +1397,7 @@
|
||||
"props": {
|
||||
"className": "text-hint"
|
||||
},
|
||||
"text": "{{rsc.name}} -{{(rsc.discount_amount ?? 0).toLocaleString()}}원"
|
||||
"text": "{{rsc.name}} -{{rsc.discount_amount_formatted ?? ''}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1621,7 +1621,7 @@
|
||||
"className": "text-hint block font-normal"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "{{Object.entries(_local.refundEstimate?.mc_original_snapshot?.mc_total_paid_amount_formatted ?? {}).filter(([code]) => code !== (_local.refundEstimate?.original_snapshot?.base_currency ?? _global.preferredCurrency ?? 'KRW'))}}",
|
||||
"source": "{{Object.entries(_local.refundEstimate?.mc_original_snapshot?.mc_total_paid_amount_formatted ?? {}).filter(([code]) => code !== (_local.refundEstimate?.original_snapshot?.base_currency ?? _global.preferredCurrency ?? _global.defaultCurrency))}}",
|
||||
"item_var": "mc"
|
||||
},
|
||||
"text": "{{mc[1]}}"
|
||||
@@ -1660,7 +1660,7 @@
|
||||
"className": "text-hint block font-normal"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "{{Object.entries(_local.refundEstimate?.mc_recalculated_snapshot?.mc_total_paid_amount_formatted ?? {}).filter(([code]) => code !== (_local.refundEstimate?.original_snapshot?.base_currency ?? _global.preferredCurrency ?? 'KRW'))}}",
|
||||
"source": "{{Object.entries(_local.refundEstimate?.mc_recalculated_snapshot?.mc_total_paid_amount_formatted ?? {}).filter(([code]) => code !== (_local.refundEstimate?.original_snapshot?.base_currency ?? _global.preferredCurrency ?? _global.defaultCurrency))}}",
|
||||
"item_var": "mc"
|
||||
},
|
||||
"text": "{{mc[1]}}"
|
||||
@@ -1888,7 +1888,7 @@
|
||||
"props": {
|
||||
"className": "text-gray-700 dark:text-gray-300"
|
||||
},
|
||||
"text": "{{(coupon.discount_amount ?? 0).toLocaleString()}}원"
|
||||
"text": "{{coupon.discount_amount_formatted ?? ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+2
-1
@@ -50,7 +50,8 @@
|
||||
"props": {
|
||||
"className": "font-semibold text-gray-900 dark:text-gray-100"
|
||||
},
|
||||
"text": "{{order.data?.total_due_amount_formatted ?? ''}}"
|
||||
"comment": "결제 통화 기준 실청구액 — 입금액 입력·서버 검증과 같은 SSoT. base 금액(total_due_amount_formatted)을 쓰면 base≠결제 통화 주문에서 운영자가 보는 금액과 저장 가능한 금액이 어긋난다.",
|
||||
"text": "{{order.data?.total_due_charge_amount_formatted ?? order.data?.total_due_amount_formatted ?? ''}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+8
-8
@@ -354,7 +354,7 @@
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": { "className": "text-xs text-gray-500 dark:text-gray-400 block" },
|
||||
"text": "{{addOpt.name}}{{(addOpt.custom_text ?? '') !== '' ? ' (' + addOpt.custom_text + ')' : ''}}{{(addOpt.price_adjustment ?? 0) > 0 ? ' (+' + addOpt.price_adjustment.toLocaleString() + '원)' : ''}}"
|
||||
"text": "{{addOpt.name}}{{(addOpt.custom_text ?? '') !== '' ? ' (' + addOpt.custom_text + ')' : ''}}{{(addOpt.price_adjustment ?? 0) !== 0 ? ' (' + (addOpt.price_adjustment_formatted ?? '') + ')' : ''}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -402,7 +402,7 @@
|
||||
"className": "flex flex-wrap gap-x-2 justify-end"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "{{Object.entries(row.mc_unit_price ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? 'KRW'))}}",
|
||||
"source": "{{Object.entries(row.mc_unit_price ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? _global.defaultCurrency))}}",
|
||||
"item_var": "currency"
|
||||
},
|
||||
"children": [
|
||||
@@ -788,7 +788,7 @@
|
||||
"props": {
|
||||
"className": "text-xs text-gray-600 dark:text-gray-300 block"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.order_info.delivery_info.shipping_fee: {{row.shipping_amount_formatted ?? '0원'}}"
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.order_info.delivery_info.shipping_fee: {{row.shipping_amount_formatted ?? ''}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -855,7 +855,7 @@
|
||||
"className": "flex flex-wrap gap-x-2 justify-end mt-0.5"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "{{Object.entries(row.mc_subtotal_price ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? 'KRW'))}}",
|
||||
"source": "{{Object.entries(row.mc_subtotal_price ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? _global.defaultCurrency))}}",
|
||||
"item_var": "currency"
|
||||
},
|
||||
"children": [
|
||||
@@ -923,7 +923,7 @@
|
||||
"className": "flex flex-wrap gap-x-2 justify-end mt-0.5"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "{{Object.entries(order.data?.mc_subtotal_amount ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? 'KRW'))}}",
|
||||
"source": "{{Object.entries(order.data?.mc_subtotal_amount ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? _global.defaultCurrency))}}",
|
||||
"item_var": "currency"
|
||||
},
|
||||
"children": [
|
||||
@@ -1055,7 +1055,7 @@
|
||||
},
|
||||
{
|
||||
"field": "delivery",
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.order_info.summary.shipping_fee: {{order.data?.total_shipping_amount_formatted ?? '0원'}}",
|
||||
"text": "$t:sirsoft-ecommerce.admin.order.detail.order_info.summary.shipping_fee: {{order.data?.total_shipping_amount_formatted ?? ''}}",
|
||||
"className": "text-label whitespace-nowrap"
|
||||
},
|
||||
{
|
||||
@@ -1084,7 +1084,7 @@
|
||||
"className": "flex flex-wrap gap-x-2 justify-end mt-0.5"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "{{Object.entries(order.data?.mc_subtotal_amount ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? 'KRW'))}}",
|
||||
"source": "{{Object.entries(order.data?.mc_subtotal_amount ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? _global.defaultCurrency))}}",
|
||||
"item_var": "currency"
|
||||
},
|
||||
"children": [
|
||||
@@ -1150,7 +1150,7 @@
|
||||
"className": "flex flex-wrap gap-x-2 justify-end"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "{{Object.entries(order.data?.mc_subtotal_amount ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? 'KRW'))}}",
|
||||
"source": "{{Object.entries(order.data?.mc_subtotal_amount ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? _global.defaultCurrency))}}",
|
||||
"item_var": "currency"
|
||||
},
|
||||
"children": [
|
||||
|
||||
+11
-10
@@ -1261,7 +1261,7 @@
|
||||
"props": {
|
||||
"name": "currency",
|
||||
"className": "w-20",
|
||||
"value": "{{_global.modules['sirsoft-ecommerce']?.language_currency?.default_currency || 'KRW'}}",
|
||||
"value": "{{_global.modules['sirsoft-ecommerce']?.language_currency?.default_currency}}",
|
||||
"options": "{{(_global.modules['sirsoft-ecommerce']?.language_currency?.currencies || []).map(c => ({ value: c.code, label: c.code }))}}"
|
||||
}
|
||||
}
|
||||
@@ -1884,7 +1884,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "xmark",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -2024,7 +2024,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "sliders",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2045,12 +2045,13 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "preset_buttons_dynamic",
|
||||
"id": "preset_buttons_dynamic_{{presetIdx}}",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": {
|
||||
"source": "presets.data || []",
|
||||
"item_var": "preset"
|
||||
"item_var": "preset",
|
||||
"index_var": "presetIdx"
|
||||
},
|
||||
"props": {
|
||||
"className": "{{query.preset_id === String(preset.id) ? 'btn-preset ring-1 ring-gray-400 bg-gray-900 text-white dark:bg-white dark:text-gray-900' : 'btn-preset cursor-pointer'}}"
|
||||
@@ -2260,7 +2261,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"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -2381,7 +2382,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "filter",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2394,7 +2395,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "{{_local.showDetailFilter ? 'chevron-up' : 'chevron-down'}}",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -2524,7 +2525,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "sliders",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2738,7 +2739,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "sliders",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -2011,7 +2011,7 @@
|
||||
"props": {
|
||||
"className": "text-tertiary text-xs whitespace-nowrap"
|
||||
},
|
||||
"text": "{{(_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW')}}"
|
||||
"text": "{{(_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency)}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+2
-2
@@ -89,7 +89,7 @@
|
||||
"props": {
|
||||
"type": "text",
|
||||
"className": "input w-40",
|
||||
"value": "{{_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}} ({{(_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW')}})",
|
||||
"value": "{{_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency}} ({{(_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency)}})",
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@
|
||||
"props": {
|
||||
"type": "text",
|
||||
"className": "input",
|
||||
"value": "{{_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}} ({{(_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW')}})",
|
||||
"value": "{{_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency}} ({{(_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency)}})",
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -146,7 +146,7 @@
|
||||
"props": {
|
||||
"value": "won"
|
||||
},
|
||||
"text": "{{(_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW')}}"
|
||||
"text": "{{(_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency)}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -178,7 +178,7 @@
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.product.modal.bulk_price.preview_increase|value={{_local.bulkPriceValue || 0}}|unit={{(_local.bulkPriceUnit || 'won') === 'percent' ? '%' : ((_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'))}}"
|
||||
"text": "$t:sirsoft-ecommerce.admin.product.modal.bulk_price.preview_increase|value={{_local.bulkPriceValue || 0}}|unit={{(_local.bulkPriceUnit || 'won') === 'percent' ? '%' : ((_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency))}}"
|
||||
},
|
||||
{
|
||||
"id": "preview_decrease",
|
||||
@@ -188,7 +188,7 @@
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.product.modal.bulk_price.preview_decrease|value={{_local.bulkPriceValue || 0}}|unit={{(_local.bulkPriceUnit || 'won') === 'percent' ? '%' : ((_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'))}}"
|
||||
"text": "$t:sirsoft-ecommerce.admin.product.modal.bulk_price.preview_decrease|value={{_local.bulkPriceValue || 0}}|unit={{(_local.bulkPriceUnit || 'won') === 'percent' ? '%' : ((_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency))}}"
|
||||
},
|
||||
{
|
||||
"id": "preview_fixed",
|
||||
@@ -198,7 +198,7 @@
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.product.modal.bulk_price.preview_fixed|value={{_local.bulkPriceValue || 0}}|unit={{(_local.bulkPriceUnit || 'won') === 'percent' ? '%' : ((_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'))}}"
|
||||
"text": "$t:sirsoft-ecommerce.admin.product.modal.bulk_price.preview_fixed|value={{_local.bulkPriceValue || 0}}|unit={{(_local.bulkPriceUnit || 'won') === 'percent' ? '%' : ((_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency))}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -243,7 +243,7 @@
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"bulkPriceCondition": "{{(() => { const unit = (_local.bulkPriceUnit || 'won') === 'percent' ? '%' : ((_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW')); const val = _local.bulkPriceValue || 0; return (_local.bulkPriceMethod || 'increase') === 'fixed' ? val + unit : ((_local.bulkPriceMethod || 'increase') === 'increase' ? '+' : '-') + val + unit; })()}}",
|
||||
"bulkPriceCondition": "{{(() => { const unit = (_local.bulkPriceUnit || 'won') === 'percent' ? '%' : ((_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).find(c => c.is_default)?.symbol ?? (_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency)); const val = _local.bulkPriceValue || 0; return (_local.bulkPriceMethod || 'increase') === 'fixed' ? val + unit : ((_local.bulkPriceMethod || 'increase') === 'increase' ? '+' : '-') + val + unit; })()}}",
|
||||
"bulkPriceMethod": "{{_local.bulkPriceMethod || 'increase'}}",
|
||||
"bulkPriceValue": "{{_local.bulkPriceValue || 0}}",
|
||||
"bulkPriceUnit": "{{_local.bulkPriceUnit || 'won'}}"
|
||||
|
||||
+8
-8
@@ -12,7 +12,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": {
|
||||
"source": "(Array.isArray(_local.form?.language_currency?.currencies) ? _local.form.language_currency.currencies : []).map((c, i) => ({...c, _idx: i})).sort((a, b) => { const defaultCurrency = _local.form?.language_currency?.default_currency ?? 'KRW'; if (a.code === defaultCurrency) return -1; if (b.code === defaultCurrency) return 1; return 0; })",
|
||||
"source": "(Array.isArray(_local.form?.language_currency?.currencies) ? _local.form.language_currency.currencies : []).map((c, i) => ({...c, _idx: i})).sort((a, b) => { const defaultCurrency = _local.form?.language_currency?.default_currency; if (a.code === defaultCurrency) return -1; if (b.code === defaultCurrency) return 1; return 0; })",
|
||||
"item_var": "currency",
|
||||
"index_var": "currencyIndex"
|
||||
},
|
||||
@@ -60,7 +60,7 @@
|
||||
"props": {
|
||||
"className": "text-tertiary"
|
||||
},
|
||||
"text": "{{(typeof currency.name === 'object' ? $localized(currency.name, 'sirsoft-ecommerce::settings.currencies.' + (currency.code ?? '') + '.name') : currency.name) + (currency.code === (_local.form?.language_currency?.default_currency ?? 'KRW') ? ' (' + $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.default_label') + ')' : '')}}"
|
||||
"text": "{{(typeof currency.name === 'object' ? $localized(currency.name, 'sirsoft-ecommerce::settings.currencies.' + (currency.code ?? '') + '.name') : currency.name) + (currency.code === (_local.form?.language_currency?.default_currency) ? ' (' + $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.default_label') + ')' : '')}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -186,8 +186,8 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "{{currency.code === (_local.form?.language_currency?.default_currency ?? 'KRW') ? 'btn-icon text-gray-300 cursor-not-allowed' : 'btn-icon text-error'}} disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{_computed.isReadOnly || currency.code === (_local.form?.language_currency?.default_currency ?? 'KRW')}}"
|
||||
"className": "{{currency.code === (_local.form?.language_currency?.default_currency) ? 'btn-icon text-gray-300 cursor-not-allowed' : 'btn-icon text-error'}} disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{_computed.isReadOnly || currency.code === (_local.form?.language_currency?.default_currency)}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -350,7 +350,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{currency.code !== (_local.form?.language_currency?.default_currency ?? 'KRW')}}",
|
||||
"if": "{{currency.code !== (_local.form?.language_currency?.default_currency)}}",
|
||||
"props": {
|
||||
"className": "form-group"
|
||||
},
|
||||
@@ -376,7 +376,7 @@
|
||||
"props": {
|
||||
"className": "text-label-subtle whitespace-nowrap"
|
||||
},
|
||||
"text": "{{(_local.form?.language_currency?.default_currency ?? 'KRW') + ' ' + (((_local.form?.language_currency?.currencies ?? []).find(c => c.code === (_local.form?.language_currency?.default_currency ?? 'KRW'))?.base_unit ?? 1).toLocaleString()) + ' ='}}"
|
||||
"text": "{{(_local.form?.language_currency?.default_currency) + ' ' + (((_local.form?.language_currency?.currencies ?? []).find(c => c.code === (_local.form?.language_currency?.default_currency))?.base_unit ?? 1).toLocaleString()) + ' ='}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -493,7 +493,7 @@
|
||||
"props": {
|
||||
"name": "{{'language_currency.currencies.' + currency._idx + '.rounding_unit'}}",
|
||||
"value": "{{currency.rounding_unit}}",
|
||||
"options": "{{currency.code === 'KRW' || currency.code === 'JPY' ? [{ value: '1', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.100') }] : [{ value: '0.01', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.0_01') }, { value: '0.1', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.0_1') }, { value: '1', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.1') }]}}",
|
||||
"options": "{{(currency.decimal_places ?? 2) === 0 ? [{ value: '1', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.100') }] : [{ value: '0.01', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.0_01') }, { value: '0.1', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.0_1') }, { value: '1', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.1') }]}}",
|
||||
"className": "w-full",
|
||||
"disabled": "{{_computed.isReadOnly}}"
|
||||
}
|
||||
@@ -896,7 +896,7 @@
|
||||
"props": {
|
||||
"className": "text-label-subtle whitespace-nowrap"
|
||||
},
|
||||
"text": "{{(_local.form?.language_currency?.default_currency ?? 'KRW') + ' ' + (((_local.form?.language_currency?.currencies ?? []).find(c => c.code === (_local.form?.language_currency?.default_currency ?? 'KRW'))?.base_unit ?? 1).toLocaleString()) + ' ='}}"
|
||||
"text": "{{(_local.form?.language_currency?.default_currency) + ' ' + (((_local.form?.language_currency?.currencies ?? []).find(c => c.code === (_local.form?.language_currency?.default_currency))?.base_unit ?? 1).toLocaleString()) + ' ='}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
|
||||
+9
-9
@@ -87,7 +87,7 @@
|
||||
"type": "basic",
|
||||
"name": "Tr",
|
||||
"iteration": {
|
||||
"source": "(Array.isArray(_local.form?.language_currency?.currencies) ? _local.form.language_currency.currencies : []).map((c, i) => ({...c, _idx: i})).sort((a, b) => { const defaultCurrency = _local.form?.language_currency?.default_currency ?? 'KRW'; if (a.code === defaultCurrency) return -1; if (b.code === defaultCurrency) return 1; return 0; })",
|
||||
"source": "(Array.isArray(_local.form?.language_currency?.currencies) ? _local.form.language_currency.currencies : []).map((c, i) => ({...c, _idx: i})).sort((a, b) => { const defaultCurrency = _local.form?.language_currency?.default_currency; if (a.code === defaultCurrency) return -1; if (b.code === defaultCurrency) return 1; return 0; })",
|
||||
"item_var": "currency",
|
||||
"index_var": "currencyIndex"
|
||||
},
|
||||
@@ -158,7 +158,7 @@
|
||||
"props": {
|
||||
"className": "text-sm text-primary"
|
||||
},
|
||||
"text": "{{(typeof currency.name === 'object' ? $localized(currency.name, 'sirsoft-ecommerce::settings.currencies.' + (currency.code ?? '') + '.name') : currency.name) + (currency.code === (_local.form?.language_currency?.default_currency ?? 'KRW') ? ' (' + $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.default_label') + ')' : '')}}"
|
||||
"text": "{{(typeof currency.name === 'object' ? $localized(currency.name, 'sirsoft-ecommerce::settings.currencies.' + (currency.code ?? '') + '.name') : currency.name) + (currency.code === (_local.form?.language_currency?.default_currency) ? ' (' + $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.default_label') + ')' : '')}}"
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
@@ -203,7 +203,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{currency.code === (_local.form?.language_currency?.default_currency ?? 'KRW')}}",
|
||||
"if": "{{currency.code === (_local.form?.language_currency?.default_currency)}}",
|
||||
"props": {
|
||||
"className": "text-label-subtle"
|
||||
},
|
||||
@@ -212,7 +212,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{currency.code !== (_local.form?.language_currency?.default_currency ?? 'KRW')}}",
|
||||
"if": "{{currency.code !== (_local.form?.language_currency?.default_currency)}}",
|
||||
"props": {
|
||||
"className": "flex-center gap-2"
|
||||
},
|
||||
@@ -223,7 +223,7 @@
|
||||
"props": {
|
||||
"className": "text-label-subtle whitespace-nowrap"
|
||||
},
|
||||
"text": "{{(_local.form?.language_currency?.default_currency ?? 'KRW') + ' ' + (((_local.form?.language_currency?.currencies ?? []).find(c => c.code === (_local.form?.language_currency?.default_currency ?? 'KRW'))?.base_unit ?? 1).toLocaleString()) + ' ='}}"
|
||||
"text": "{{(_local.form?.language_currency?.default_currency) + ' ' + (((_local.form?.language_currency?.currencies ?? []).find(c => c.code === (_local.form?.language_currency?.default_currency))?.base_unit ?? 1).toLocaleString()) + ' ='}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -312,7 +312,7 @@
|
||||
"props": {
|
||||
"name": "{{'language_currency.currencies.' + currency._idx + '.rounding_unit'}}",
|
||||
"value": "{{currency.rounding_unit}}",
|
||||
"options": "{{currency.code === 'KRW' || currency.code === 'JPY' ? [{ value: '1', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.100') }] : [{ value: '0.01', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.0_01') }, { value: '0.1', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.0_1') }, { value: '1', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.1') }]}}",
|
||||
"options": "{{(currency.decimal_places ?? 2) === 0 ? [{ value: '1', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.100') }] : [{ value: '0.01', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.0_01') }, { value: '0.1', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.0_1') }, { value: '1', label: $t('sirsoft-ecommerce.admin.settings.language_currency.exchange_settings.rounding_units.1') }]}}",
|
||||
"className": "w-24",
|
||||
"disabled": "{{_computed.isReadOnly}}"
|
||||
}
|
||||
@@ -448,8 +448,8 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "{{currency.code === (_local.form?.language_currency?.default_currency ?? 'KRW') ? 'btn-icon text-gray-300 cursor-not-allowed' : 'btn-icon text-error'}} disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{_computed.isReadOnly || currency.code === (_local.form?.language_currency?.default_currency ?? 'KRW')}}"
|
||||
"className": "{{currency.code === (_local.form?.language_currency?.default_currency) ? 'btn-icon text-gray-300 cursor-not-allowed' : 'btn-icon text-error'}} disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{_computed.isReadOnly || currency.code === (_local.form?.language_currency?.default_currency)}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -668,7 +668,7 @@
|
||||
"props": {
|
||||
"className": "text-label-subtle whitespace-nowrap"
|
||||
},
|
||||
"text": "{{(_local.form?.language_currency?.default_currency ?? 'KRW') + ' ' + (((_local.form?.language_currency?.currencies ?? []).find(c => c.code === (_local.form?.language_currency?.default_currency ?? 'KRW'))?.base_unit ?? 1).toLocaleString()) + ' ='}}"
|
||||
"text": "{{(_local.form?.language_currency?.default_currency) + ' ' + (((_local.form?.language_currency?.currencies ?? []).find(c => c.code === (_local.form?.language_currency?.default_currency))?.base_unit ?? 1).toLocaleString()) + ' ='}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
|
||||
+2
-2
@@ -64,7 +64,7 @@
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "language_currency.default_currency",
|
||||
"value": "{{_local.form?.language_currency?.default_currency ?? 'KRW'}}",
|
||||
"value": "{{_local.form?.language_currency?.default_currency}}",
|
||||
"options": "{{(Array.isArray(_local.form?.language_currency?.currencies) ? _local.form.language_currency.currencies : []).map(c => ({ value: c.code, label: typeof c.name === 'object' ? $localized(c.name) : c.name }))}}",
|
||||
"className": "{{_local.errors?.['language_currency.default_currency'] ? 'w-full select-error' : 'w-full'}}",
|
||||
"disabled": "{{_computed.isReadOnly}}"
|
||||
@@ -168,7 +168,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "plus",
|
||||
"className": "w-4 h-4 mr-1"
|
||||
"className": "text-base mr-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -402,7 +402,7 @@
|
||||
"name": "Tbody",
|
||||
"children": [
|
||||
{
|
||||
"id": "range_tier_row",
|
||||
"id": "range_tier_row_{{tierIdx}}",
|
||||
"type": "basic",
|
||||
"name": "Tr",
|
||||
"iteration": {
|
||||
@@ -1149,7 +1149,7 @@
|
||||
"props": {
|
||||
"className": "text-green-600 dark:text-green-400 font-semibold"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.shipping_policy.form.api_test_extracted|fee={{_global.apiTestResult?.extracted_fee ?? '-'}}"
|
||||
"text": "$t:sirsoft-ecommerce.admin.shipping_policy.form.api_test_extracted|fee={{_global.apiTestResult?.extracted_fee_formatted ?? _global.apiTestResult?.extracted_fee ?? '-'}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
|
||||
@@ -22,8 +22,8 @@ class OrderAmountChangedException extends Exception
|
||||
) {
|
||||
parent::__construct(
|
||||
__('sirsoft-ecommerce::exceptions.order_amount_changed', [
|
||||
'stored' => number_format($storedAmount),
|
||||
'recalculated' => number_format($recalculatedAmount),
|
||||
'stored' => ecommerce_format_price($storedAmount),
|
||||
'recalculated' => ecommerce_format_price($recalculatedAmount),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@ class PaymentAmountMismatchException extends Exception
|
||||
) {
|
||||
parent::__construct(
|
||||
__('sirsoft-ecommerce::exceptions.payment_amount_mismatch', [
|
||||
'expected' => number_format($expectedAmount),
|
||||
'actual' => number_format($actualAmount),
|
||||
'expected' => ecommerce_format_price($expectedAmount),
|
||||
'actual' => ecommerce_format_price($actualAmount),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -64,7 +64,7 @@ class CashReceiptController extends AdminBaseController
|
||||
|
||||
return ResponseHelper::success(
|
||||
'sirsoft-ecommerce::cash_receipt.messages.issued',
|
||||
new CashReceiptResource($receipt)
|
||||
new CashReceiptResource($receipt->setRelation('order', $order))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ class CashReceiptController extends AdminBaseController
|
||||
|
||||
return ResponseHelper::success(
|
||||
'sirsoft-ecommerce::cash_receipt.messages.reissued',
|
||||
$receipt ? new CashReceiptResource($receipt) : null
|
||||
$receipt ? new CashReceiptResource($receipt->setRelation('order', $order)) : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ use Modules\Sirsoft\Ecommerce\Http\Requests\Public\GetCartRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\MergeGuestCartRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\UpdateCartQuantityRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\CartItemResource;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Cart;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CartService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ShippingPolicyResolver;
|
||||
|
||||
@@ -147,7 +148,7 @@ class CartController extends PublicBaseController
|
||||
|
||||
return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.cart.added', [
|
||||
'items' => CartItemResource::collection(
|
||||
collect($result['items'])->map(fn ($item) => $item->load(['product', 'productOption']))
|
||||
collect($result['items'])->map(fn ($item) => $item->load(Cart::displayRelations()))
|
||||
),
|
||||
'cart_count' => $result['cart_count'],
|
||||
], 201);
|
||||
@@ -271,7 +272,7 @@ class CartController extends PublicBaseController
|
||||
);
|
||||
|
||||
return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.cart.option_changed', [
|
||||
'item' => new CartItemResource($cart->load(['product', 'productOption'])),
|
||||
'item' => new CartItemResource($cart->load(Cart::displayRelations())),
|
||||
]);
|
||||
} catch (CartUnavailableException $e) {
|
||||
// 판매불가/재고/구매수량 한도 위반 — generic 500 이 아닌 사유별 422 매핑
|
||||
|
||||
+2
-2
@@ -40,7 +40,7 @@ class CashReceiptController extends PublicBaseController
|
||||
|
||||
return ResponseHelper::success('sirsoft-ecommerce::cash_receipt.messages.status_retrieved', [
|
||||
'issuable' => $this->cashReceiptService->resolveIssueBlocker($order) === null,
|
||||
'cash_receipt' => $receipt ? new CashReceiptResource($receipt) : null,
|
||||
'cash_receipt' => $receipt ? new CashReceiptResource($receipt->setRelation('order', $order)) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ class CashReceiptController extends PublicBaseController
|
||||
|
||||
return ResponseHelper::success(
|
||||
'sirsoft-ecommerce::cash_receipt.messages.issued',
|
||||
new CashReceiptResource($receipt)
|
||||
new CashReceiptResource($receipt->setRelation('order', $order))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -129,8 +129,8 @@ trait HandlesOrderCreation
|
||||
} catch (PaymentAmountMismatchException $e) {
|
||||
return ResponseHelper::error(
|
||||
__('sirsoft-ecommerce::exceptions.payment_amount_mismatch', [
|
||||
'expected' => number_format($e->getExpectedAmount()),
|
||||
'actual' => number_format($e->getActualAmount()),
|
||||
'expected' => ecommerce_format_price($e->getExpectedAmount()),
|
||||
'actual' => ecommerce_format_price($e->getActualAmount()),
|
||||
]),
|
||||
422
|
||||
);
|
||||
|
||||
+2
-2
@@ -49,7 +49,7 @@ class CashReceiptController extends AuthBaseController
|
||||
|
||||
return ResponseHelper::success('sirsoft-ecommerce::cash_receipt.messages.status_retrieved', [
|
||||
'issuable' => $this->cashReceiptService->resolveIssueBlocker($order) === null,
|
||||
'cash_receipt' => $receipt ? new CashReceiptResource($receipt) : null,
|
||||
'cash_receipt' => $receipt ? new CashReceiptResource($receipt->setRelation('order', $order)) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ class CashReceiptController extends AuthBaseController
|
||||
|
||||
return ResponseHelper::success(
|
||||
'sirsoft-ecommerce::cash_receipt.messages.issued',
|
||||
new CashReceiptResource($receipt)
|
||||
new CashReceiptResource($receipt->setRelation('order', $order))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ abstract class BaseOrderItemResource extends BaseApiResource
|
||||
*
|
||||
* @param Product|null $product 로드된 상품 모델 (additionalOptions.values eager-load 필요)
|
||||
* @param array<int, array{additional_option_id?: int, value_id?: int, custom_text?: string}> $selections 선택된 추가옵션 배열
|
||||
* @return array<int, array{additional_option_id: int, value_id: int, group_name: string, name: string, price_adjustment: int, custom_text: string}>
|
||||
* @return array<int, array{additional_option_id: int, value_id: int, group_name: string, name: string, price_adjustment: int, multi_currency_price_adjustment: array, custom_text: string}>
|
||||
*/
|
||||
protected function resolveAdditionalOptions(?Product $product, array $selections): array
|
||||
{
|
||||
@@ -174,6 +174,9 @@ abstract class BaseOrderItemResource extends BaseApiResource
|
||||
// 추가금 표시 문자열 — 통화 기호를 하드코딩하지 않고 기본 통화 기호로 포맷 (레이아웃의 '+N원' 하드코딩 대체)
|
||||
'price_adjustment_formatted' => ($priceAdjustment >= 0 ? '+' : '-')
|
||||
.$this->formatCurrencyPrice(abs($priceAdjustment), $this->getDefaultCurrencyCode()),
|
||||
// 통화별 추가금 — 같은 줄의 상품가·소계가 표시통화로 나가므로 추가금도 함께 제공한다.
|
||||
// 이 맵이 없으면 한 줄 안에서 기준통화와 표시통화가 섞여 구매자가 금액을 검산할 수 없다
|
||||
'multi_currency_price_adjustment' => $this->buildMultiCurrencyPriceAdjustments($priceAdjustment),
|
||||
'custom_text' => $customText,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ class CashReceiptResource extends BaseApiResource
|
||||
{
|
||||
use HasMultiCurrencyPrices;
|
||||
|
||||
/** 영수증 금액 표기 통화 (부모가 주입). 미주입 시 기본 통화로 폴백. */
|
||||
protected ?string $receiptCurrencyCode = null;
|
||||
|
||||
/**
|
||||
* 리소스를 배열로 변환
|
||||
*
|
||||
@@ -24,6 +27,11 @@ class CashReceiptResource extends BaseApiResource
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
// 현금영수증 금액은 구매자가 실제로 낸 금액, 즉 **결제 통화(order_currency)** 기준이다
|
||||
// (CashReceiptService::calculateIssuableAmount 가 그 통화로 산출한다).
|
||||
// base 통화 기호로 포맷하면 값은 맞는데 단위만 틀린 증빙이 화면에 뜬다(¥310 vs 310원).
|
||||
$receiptCurrency = $this->resolveReceiptCurrencyCode();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'provider' => $this->provider,
|
||||
@@ -32,10 +40,16 @@ class CashReceiptResource extends BaseApiResource
|
||||
'transaction_type_label' => $this->transaction_type?->label(),
|
||||
'receipt_type' => $this->receipt_type,
|
||||
'receipt_type_label' => $this->receipt_type?->label(),
|
||||
'amount' => $this->roundToOrderCurrency($this->amount),
|
||||
'amount_formatted' => $this->formatOrderCurrency($this->amount),
|
||||
'tax_free_amount' => $this->roundToOrderCurrency($this->tax_free_amount),
|
||||
'tax_free_amount_formatted' => $this->formatOrderCurrency($this->tax_free_amount),
|
||||
'amount' => $this->roundToCurrency($this->amount, $receiptCurrency),
|
||||
'amount_formatted' => $this->formatCurrencyPrice(
|
||||
$this->roundToCurrency($this->amount, $receiptCurrency),
|
||||
$receiptCurrency
|
||||
),
|
||||
'tax_free_amount' => $this->roundToCurrency($this->tax_free_amount, $receiptCurrency),
|
||||
'tax_free_amount_formatted' => $this->formatCurrencyPrice(
|
||||
$this->roundToCurrency($this->tax_free_amount, $receiptCurrency),
|
||||
$receiptCurrency
|
||||
),
|
||||
'identifier_masked' => $this->identifier_masked,
|
||||
'receipt_url' => $this->receipt_url,
|
||||
'issue_number' => $this->issue_number,
|
||||
@@ -55,4 +69,45 @@ class CashReceiptResource extends BaseApiResource
|
||||
'occurred_at_formatted' => $this->formatDateTimeStringForUser($this->issued_at ?? $this->created_at),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 영수증 금액의 표기 통화를 주입합니다.
|
||||
*
|
||||
* 부모 리소스(주문)가 이미 알고 있는 결제 통화를 내려 준다. 영수증마다 order 관계를
|
||||
* 되짚으면 발급 이력 목록에서 N+1 이 된다.
|
||||
*
|
||||
* @param string|null $currencyCode 결제 통화 코드
|
||||
* @return static
|
||||
*/
|
||||
public function withReceiptCurrency(?string $currencyCode): static
|
||||
{
|
||||
$this->receiptCurrencyCode = $currencyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영수증 금액의 표기 통화를 반환합니다.
|
||||
*
|
||||
* 발급액은 결제 통화 기준으로 산출·저장되므로 표기도 그 통화를 따른다.
|
||||
* 부모가 주입한 base 통화(withOrderCurrency)에 흔들리지 않는다.
|
||||
*
|
||||
* @return string 통화 코드 (미주입 시 기본 통화)
|
||||
*/
|
||||
protected function resolveReceiptCurrencyCode(): string
|
||||
{
|
||||
if (is_string($this->receiptCurrencyCode) && $this->receiptCurrencyCode !== '') {
|
||||
return $this->receiptCurrencyCode;
|
||||
}
|
||||
|
||||
// 관계가 이미 로드된 경우에만 참조 (추가 쿼리 금지)
|
||||
if ($this->resource->relationLoaded('order') && $this->resource->order !== null) {
|
||||
$order = $this->resource->order;
|
||||
|
||||
return $order->currency
|
||||
?: (data_get($order->currency_snapshot, 'order_currency') ?: $this->getDefaultCurrencyCode());
|
||||
}
|
||||
|
||||
return $this->getDefaultCurrencyCode();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\Models\OrderCashReceipt;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
|
||||
|
||||
/**
|
||||
@@ -39,6 +40,10 @@ class GuestOrderResource extends BaseApiResource
|
||||
// 주문 시점 기준 통화 — 과거 주문의 *_formatted 는 설정 변경과 무관하게 이 통화로 고정 표기한다.
|
||||
$orderCurrency = $this->resolveOrderBaseCurrencyCode($this->resource);
|
||||
|
||||
// 결제 통화(order_currency) — 현금영수증 금액 표기 기준(구매자가 실제 낸 통화).
|
||||
$paymentCurrency = $this->currency
|
||||
?: (data_get($this->currency_snapshot, 'order_currency') ?: $orderCurrency);
|
||||
|
||||
return [
|
||||
'order_number' => $this->order_number,
|
||||
'order_status' => $this->order_status,
|
||||
@@ -126,6 +131,24 @@ class GuestOrderResource extends BaseApiResource
|
||||
// 취소 이력 (취소 사유·상세 사유·취소일시 표시용) — 최근 취소가 먼저 오도록 정렬
|
||||
'cancels' => OrderCancelResource::collection($this->whenLoaded('cancels')),
|
||||
|
||||
// 현금영수증 — 회원 OrderResource 와 동일 노출. 비회원 주문상세도 회원과 같은
|
||||
// 확장 레이아웃(mypage_order_cash_receipt)을 재사용하며, 그 카드는 `cash_receipt`
|
||||
// 하나로 발급완료/미발급을 가른다. 이 두 키가 없으면 발급에 성공해도 카드가 영구히
|
||||
// "미발급" 으로 남고 영수증 URL 에 도달할 방법이 사라진다(오류·경고 없음).
|
||||
// CashReceiptResource 는 식별번호를 마스킹 값으로만 내보내고 프로바이더 원응답을
|
||||
// 노출하지 않으므로 비회원에게 내려도 안전하다.
|
||||
'cash_receipt' => $this->whenLoaded('cashReceipts', function () use ($orderCurrency, $paymentCurrency) {
|
||||
$active = OrderCashReceipt::filterActive($this->cashReceipts)[0] ?? null;
|
||||
|
||||
return $active
|
||||
? (new CashReceiptResource($active))->withOrderCurrency($orderCurrency)->withReceiptCurrency($paymentCurrency)
|
||||
: null;
|
||||
}),
|
||||
// 발급/취소 전체 이력 — 카드의 "직전 발급이 실패했는가" 분기가 cash_receipts[0] 를 본다.
|
||||
'cash_receipts' => $this->whenLoaded('cashReceipts', fn () => CashReceiptResource::collection(
|
||||
$this->cashReceipts->sortByDesc('id')->values()
|
||||
)->each(fn ($r) => $r->withOrderCurrency($orderCurrency)->withReceiptCurrency($paymentCurrency))),
|
||||
|
||||
// 배송정책 — **표시용 필드만**. 비회원 주문 상세 화면은 회원 마이페이지와 같은
|
||||
// partial 을 써서 상품별 정책명·개별 배송비를 그리는데, 이 필드를 통째로 빼면
|
||||
// 오류 없이 그 줄만 사라진다(옵셔널 체이닝이 빈 배열로 떨어져 조건이 false).
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Http\Resources\BaseApiResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
|
||||
|
||||
/**
|
||||
@@ -60,9 +61,18 @@ class OrderResource extends BaseApiResource
|
||||
'total_amount_formatted' => $this->formatOrderCurrency($this->total_amount, $orderCurrency),
|
||||
'total_paid_amount' => $this->roundToOrderCurrency($this->total_paid_amount, $orderCurrency),
|
||||
'total_paid_amount_formatted' => $this->formatOrderCurrency($this->total_paid_amount, $orderCurrency),
|
||||
// 입금 필요액(무통장 입금확인 모달의 기본 입금액·결제예정금액 표시 SSoT)
|
||||
// 입금 필요액 — base 통화(base_currency) 기준. 금액 구성 표시에만 쓴다.
|
||||
'total_due_amount' => $this->roundToOrderCurrency((float) $this->total_due_amount, $orderCurrency),
|
||||
'total_due_amount_formatted' => $this->formatOrderCurrency((float) $this->total_due_amount, $orderCurrency),
|
||||
// 결제 통화(payment_currency) 기준 실청구액 — 무통장 입금확인 모달의 표시·기본 입금액 SSoT.
|
||||
// 서버의 금액 검증(OrderProcessingService::validatePaymentAmount 2단계)이 이 값과 대조하므로,
|
||||
// 화면이 base 금액을 대신 보내면 base≠결제 통화인 주문은 **항상** 422 가 되어 입금확인이 불가능해진다.
|
||||
// 최소 화폐단위 정수(KRW ×1, 소수통화 ×10^n) — 서버가 받는 단위와 동일하다.
|
||||
'total_due_charge_amount' => $this->resolvePaymentChargeAmount(),
|
||||
'total_due_charge_amount_formatted' => $this->formatCurrencyPrice(
|
||||
$this->resolvePaymentChargeAmount() / (10 ** $this->getDecimalPlacesForCurrency($paymentCurrency)),
|
||||
$paymentCurrency
|
||||
),
|
||||
// 입금자명(무통장 입금확인 모달 기본값) — payment 관계 로드 시에만 노출 (N+1 방지)
|
||||
'depositor_name' => $this->whenLoaded('payment', fn () => $this->payment?->depositor_name),
|
||||
'total_cancelled_amount' => $this->roundToOrderCurrency($this->total_cancelled_amount, $orderCurrency),
|
||||
@@ -181,17 +191,17 @@ class OrderResource extends BaseApiResource
|
||||
)),
|
||||
|
||||
// 현금영수증 — 현재 활성 영수증 1건(없으면 null). 발급 카드의 "발급완료" 상태 근거.
|
||||
'cash_receipt' => $this->whenLoaded('cashReceipts', function () use ($orderCurrency) {
|
||||
'cash_receipt' => $this->whenLoaded('cashReceipts', function () use ($orderCurrency, $paymentCurrency) {
|
||||
$active = OrderCashReceipt::filterActive($this->cashReceipts)[0] ?? null;
|
||||
|
||||
return $active
|
||||
? (new CashReceiptResource($active))->withOrderCurrency($orderCurrency)
|
||||
? (new CashReceiptResource($active))->withOrderCurrency($orderCurrency)->withReceiptCurrency($paymentCurrency)
|
||||
: null;
|
||||
}),
|
||||
// 발급/취소 전체 이력 — 관리자 화면의 "취소 성공 + 재발급 실패" 경고 배지 판정 근거.
|
||||
'cash_receipts' => $this->whenLoaded('cashReceipts', fn () => CashReceiptResource::collection(
|
||||
$this->cashReceipts->sortByDesc('id')->values()
|
||||
)->each(fn ($r) => $r->withOrderCurrency($orderCurrency))),
|
||||
)->each(fn ($r) => $r->withOrderCurrency($orderCurrency)->withReceiptCurrency($paymentCurrency))),
|
||||
|
||||
// 배송 정보
|
||||
'shippings' => $this->whenLoaded('shippings', fn () => OrderShippingResource::collection($this->shippings)->each(
|
||||
@@ -269,4 +279,22 @@ class OrderResource extends BaseApiResource
|
||||
|
||||
return $abilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제 통화 기준 실청구액(최소 화폐단위 정수)을 반환합니다.
|
||||
*
|
||||
* 서버의 금액 검증과 같은 해석기를 쓴다 — 화면과 서버가 다른 함수로 각자 계산하면
|
||||
* 반올림·절사 규칙이 어긋나는 순간 조용히 갈라진다.
|
||||
*
|
||||
* @return int 결제 통화 최소 화폐단위 청구액 (환율 미설정 등 산출 불가 시 base 금액으로 폴백)
|
||||
*/
|
||||
protected function resolvePaymentChargeAmount(): int
|
||||
{
|
||||
try {
|
||||
return app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($this->resource);
|
||||
} catch (\Throwable $e) {
|
||||
// 환율 스냅샷이 비었거나 손상된 과거 주문 — 응답 전체를 500 으로 만들지 않는다.
|
||||
return (int) round((float) $this->total_due_amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,9 @@ class PublicProductResource extends BaseApiResource
|
||||
// 추가금 표시 문자열 — 통화 기호 하드코딩 없이 기본 통화 기호로 포맷 (옵션 선택 UI의 '+N원' 대체)
|
||||
'price_adjustment_formatted' => ($v->getPriceAdjustment() >= 0 ? '+' : '-')
|
||||
.$this->formatCurrencyPrice(abs($v->getPriceAdjustment()), $this->getDefaultCurrencyCode()),
|
||||
// 통화별 추가금 — 상품가·옵션가와 동일한 형태. 표시통화가 기본통화가 아닐 때
|
||||
// 화면이 이 맵에서 값을 읽는다 (없으면 기준통화 금액이 그대로 표시·합산된다)
|
||||
'multi_currency_price_adjustment' => $this->buildMultiCurrencyPriceAdjustments($v->getPriceAdjustment()),
|
||||
'is_default' => $v->is_default,
|
||||
// 직접입력 허용 — 유저단이 이 선택지 선택 시 텍스트 입력칸 노출 판정
|
||||
'allow_custom_text' => $v->allow_custom_text,
|
||||
|
||||
+33
@@ -86,6 +86,39 @@ trait HasMultiCurrencyPrices
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가감액(추가금)의 다중 통화 정보를 생성합니다.
|
||||
*
|
||||
* 가격과 달리 추가금은 부호를 가지며 화면에 `+3,000원` / `-2,000원` 형태로 표시된다.
|
||||
* 환산은 절대값으로 수행한 뒤 부호를 되돌린다 — 통화별 반올림 규칙이 음수에서
|
||||
* 방향이 뒤집히는 것(예: floor 가 -2,850 을 -2,851 로)을 막기 위함이다.
|
||||
*
|
||||
* 추가옵션 추가금은 기본 통화 기준으로 저장되므로, 기본 통화가 아닌 통화로 보는
|
||||
* 구매자에게는 이 맵이 없으면 환산할 근거가 없다. 상품가·옵션가와 같은 형태로
|
||||
* 내보내 화면이 동일한 방식(`[표시통화].formatted`)으로 읽게 한다.
|
||||
*
|
||||
* @param float|int $baseAdjustment 기본 통화 기준 가감액 (음수 가능)
|
||||
* @return array 통화별 가감액 정보 (price 는 부호 있는 값, formatted 는 부호 접두)
|
||||
*/
|
||||
protected function buildMultiCurrencyPriceAdjustments(float|int $baseAdjustment): array
|
||||
{
|
||||
$isNegative = $baseAdjustment < 0;
|
||||
$converted = $this->buildMultiCurrencyPrices(abs($baseAdjustment));
|
||||
|
||||
$result = [];
|
||||
foreach ($converted as $code => $entry) {
|
||||
$price = $entry['price'] ?? 0;
|
||||
|
||||
$result[$code] = [
|
||||
...$entry,
|
||||
'price' => $isNegative ? -$price : $price,
|
||||
'formatted' => ($isNegative ? '-' : '+').$this->formatCurrencyPrice($price, $code),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 통화 설정을 조회합니다 (캐시 적용).
|
||||
*
|
||||
|
||||
+6
-1
@@ -330,7 +330,12 @@ class EcommerceNotificationDataListener implements HookListenerInterface
|
||||
'app_name' => config('app.name'),
|
||||
'order_number' => $order->order_number,
|
||||
'customer_name' => $order->user?->name ?? $order->getOrdererName() ?? '',
|
||||
'total_amount' => number_format((float) $order->total_paid_amount > 0 ? $order->total_paid_amount : $order->total_amount).'원',
|
||||
// 구매자 알림과 같은 경로로 포맷한다 — 원화를 이어 붙이면 기준통화가
|
||||
// KRW 가 아닌 상점에서 값은 기준통화인데 단위만 원으로 나간다.
|
||||
'total_amount' => $this->formatOrderChargeAmount(
|
||||
$order,
|
||||
(float) $order->total_paid_amount > 0 ? (float) $order->total_paid_amount : (float) $order->total_amount
|
||||
),
|
||||
'order_url' => "{$baseUrl}/admin/ecommerce/orders/{$order->order_number}",
|
||||
'site_url' => $baseUrl,
|
||||
],
|
||||
|
||||
@@ -501,7 +501,7 @@ class OrderActivityLogListener implements HookListenerInterface
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_restore',
|
||||
'description_params' => [
|
||||
'order_number' => $order->order_number,
|
||||
'amount' => $amount,
|
||||
'amount' => $this->formatOrderAmount($order, (float) $amount),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -593,7 +593,7 @@ class OrderActivityLogListener implements HookListenerInterface
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_use',
|
||||
'description_params' => [
|
||||
'order_number' => $order->order_number,
|
||||
'amount' => $usedPoints,
|
||||
'amount' => $this->formatOrderAmount($order, (float) $usedPoints),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -611,9 +611,23 @@ class OrderActivityLogListener implements HookListenerInterface
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn',
|
||||
'description_params' => [
|
||||
'order_number' => $order->order_number,
|
||||
'amount' => $earnedPoints,
|
||||
'amount' => $this->formatOrderAmount($order, (float) $earnedPoints),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문 시점 기준 통화로 금액을 포맷합니다.
|
||||
*
|
||||
* 활동 로그 문구는 통화 단위를 담지 않는다 — 값과 단위를 함께 기록해야
|
||||
* 기본 통화가 원화가 아닌 상점에서도 로그를 그대로 읽을 수 있다.
|
||||
*
|
||||
* @param Order $order 대상 주문
|
||||
* @param float $amount 기준 통화 금액
|
||||
* @return string 주문 통화로 포맷된 금액
|
||||
*/
|
||||
private function formatOrderAmount(Order $order, float $amount): string
|
||||
{
|
||||
return ecommerce_format_price($amount, $order->currency_snapshot['base_currency'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,9 +262,11 @@ class SearchProductsListener implements HookListenerInterface
|
||||
'sales_status' => $product->sales_status?->value,
|
||||
'sales_status_label' => $product->sales_status?->label(),
|
||||
'selling_price' => $product->selling_price,
|
||||
'selling_price_formatted' => $this->formatCurrencyPrice($product->selling_price, 'KRW'),
|
||||
// 통화 코드를 못 박지 않는다 — 이 문자열은 화면이 표시 통화 환산값을 찾지 못했을 때
|
||||
// 쓰는 폴백이라, 기준통화 금액에 다른 통화 기호가 붙으면 그대로 노출된다.
|
||||
'selling_price_formatted' => $this->formatBaseCurrency($product->selling_price),
|
||||
'list_price' => $product->list_price,
|
||||
'list_price_formatted' => $this->formatCurrencyPrice($product->list_price, 'KRW'),
|
||||
'list_price_formatted' => $this->formatBaseCurrency($product->list_price),
|
||||
'discount_rate' => $product->getDiscountRate(),
|
||||
'multi_currency_selling_price' => $this->buildMultiCurrencyPrices($product->selling_price),
|
||||
'multi_currency_list_price' => $this->buildMultiCurrencyPrices($product->list_price),
|
||||
|
||||
@@ -60,6 +60,43 @@ class Cart extends Model
|
||||
return $this->belongsTo(ProductOption::class, 'product_option_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 장바구니 표현(CartItemResource)에 필요한 적재 관계 목록
|
||||
*
|
||||
* 목록 조회(Repository)와 담기·옵션변경 응답(Controller)이 같은 목록을 쓰도록
|
||||
* 한 곳에 둔다. `CartItemResource` 는 `relationLoaded()` 로 방어하므로, 여기서
|
||||
* 빠진 관계는 예외도 경고도 없이 응답에서 값만 사라진다 — 추가옵션이 빠지면
|
||||
* `additional_options` 가 빈 배열이 되고 그 금액이 `subtotal` 에서도 누락된다.
|
||||
*
|
||||
* 상품 이미지는 컬럼을 좁혀 싣는다. 장바구니는 상품당 **대표 이미지 1장의 URL** 만
|
||||
* 쓴다(`BaseOrderItemResource::formatProductInfo`). 대표 지정이 없으면 첫 이미지로
|
||||
* 폴백하므로 `thumbnail` 관계로 바꾸면 그 폴백이 깨진다. 그래서 관계는 그대로 두고
|
||||
* 선택 컬럼만 좁힌다 — `download_url` 은 `hash` 만 필요하고, 폴백 판정에
|
||||
* `is_thumbnail`/`sort_order`, eager load 매칭에 `id`/`product_id` 가 필요하다.
|
||||
* 파일명·경로·용량·크기 등 나머지 컬럼은 장바구니 응답 어디에도 쓰이지 않는다.
|
||||
*
|
||||
* `product.shippingPolicy.countrySettings` 는 응답에 직렬화되지 않지만 의도적으로
|
||||
* 유지한다. 배송비 계산(`OrderCalculationService`)이 이 관계를 읽고, 로드돼 있지
|
||||
* 않으면 정책 인스턴스마다 `load('countrySettings')` 로 다시 조회한다. 장바구니
|
||||
* 항목마다 정책 인스턴스가 따로 잡히므로 eager load 를 빼면 페이로드 대신 쿼리
|
||||
* 수가 늘어난다 — 결제 금액 산출 경로라 그 교환은 하지 않는다.
|
||||
*
|
||||
* 활성 설정만 싣는 방식도 검토했으나 계산기의 지연 조회 경로가 `is_active` 를
|
||||
* 거르지 않아 로드 여부에 따라 배송비가 달라지게 된다. 좁히려면 계산기 쪽 기준을
|
||||
* 먼저 통일해야 한다.
|
||||
*
|
||||
* @return array<int, string> with()/load() 에 그대로 넘길 관계 목록
|
||||
*/
|
||||
public static function displayRelations(): array
|
||||
{
|
||||
return [
|
||||
'product.images:id,product_id,hash,is_thumbnail,sort_order',
|
||||
'product.additionalOptions.values',
|
||||
'product.shippingPolicy.countrySettings',
|
||||
'productOption',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 비회원 장바구니 여부 확인
|
||||
*
|
||||
|
||||
@@ -66,9 +66,13 @@ class ProductAdditionalOptionValue extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가금 반환 (KRW 기준)
|
||||
* 추가금 반환 (기본 통화 기준)
|
||||
*
|
||||
* @return int 추가금
|
||||
* 저장값은 쇼핑몰 기본 통화(`language_currency.default_currency`) 기준이다 — KRW 로
|
||||
* 고정된 값이 아니다. 표시통화가 기본통화와 다르면 그대로 쓰면 안 되고, 리소스의
|
||||
* `buildMultiCurrencyPriceAdjustments()` 로 환산한 값을 화면에 내보낸다.
|
||||
*
|
||||
* @return int 기본 통화 기준 추가금
|
||||
*/
|
||||
public function getPriceAdjustment(): int
|
||||
{
|
||||
|
||||
@@ -51,32 +51,13 @@ class CartRepository implements CartRepositoryInterface
|
||||
return $cart->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 장바구니 조회의 상품 이미지 eager load 컬럼.
|
||||
*
|
||||
* 장바구니는 상품당 **대표 이미지 1장의 URL** 만 쓴다(BaseOrderItemResource::formatProductInfo).
|
||||
* 대표 지정이 없으면 첫 이미지로 폴백하므로 `thumbnail` 관계로 바꾸면 그 폴백이 깨진다.
|
||||
* 그래서 관계는 그대로 두고 선택 컬럼만 좁힌다 — `download_url` 은 `hash` 만 필요하고,
|
||||
* 폴백 판정에 `is_thumbnail`/`sort_order`, eager load 매칭에 `id`/`product_id` 가 필요하다.
|
||||
* 파일명·경로·용량·크기 등 나머지 컬럼은 장바구니 응답 어디에도 쓰이지 않는다.
|
||||
*
|
||||
* `product.shippingPolicy.countrySettings` 는 응답에 직렬화되지 않지만 의도적으로 유지한다.
|
||||
* 배송비 계산(`OrderCalculationService`)이 이 관계를 읽고, 로드돼 있지 않으면 정책 인스턴스
|
||||
* 마다 `load('countrySettings')` 로 다시 조회한다(`:1884-1891`, `:1953-1955`). 장바구니 항목
|
||||
* 마다 정책 인스턴스가 따로 잡히므로 eager load 를 빼면 페이로드 대신 쿼리 수가 늘어난다 —
|
||||
* 결제 금액 산출 경로라 그 교환은 하지 않는다.
|
||||
*
|
||||
* 활성 설정만 싣는 방식도 검토했으나 계산기의 지연 조회 경로가 `is_active` 를 거르지 않아
|
||||
* (`:1955`) 로드 여부에 따라 배송비가 달라지게 된다. 좁히려면 계산기 쪽 기준을 먼저 통일해야
|
||||
* 하며 그것은 이 변경의 범위 밖이다.
|
||||
*/
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function findByUserId(int $userId): Collection
|
||||
{
|
||||
return $this->model
|
||||
->with(['product.images:id,product_id,hash,is_thumbnail,sort_order', 'product.additionalOptions.values', 'product.shippingPolicy.countrySettings', 'productOption'])
|
||||
->with(Cart::displayRelations())
|
||||
->where('user_id', $userId)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
@@ -88,7 +69,7 @@ class CartRepository implements CartRepositoryInterface
|
||||
public function findByCartKeyWithoutUser(string $cartKey): Collection
|
||||
{
|
||||
return $this->model
|
||||
->with(['product.images:id,product_id,hash,is_thumbnail,sort_order', 'product.additionalOptions.values', 'product.shippingPolicy.countrySettings', 'productOption'])
|
||||
->with(Cart::displayRelations())
|
||||
->where('cart_key', $cartKey)
|
||||
->whereNull('user_id')
|
||||
->orderBy('created_at', 'desc')
|
||||
@@ -173,7 +154,7 @@ class CartRepository implements CartRepositoryInterface
|
||||
public function findByIds(array $ids): Collection
|
||||
{
|
||||
return $this->model
|
||||
->with(['product.images:id,product_id,hash,is_thumbnail,sort_order', 'product.additionalOptions.values', 'product.shippingPolicy.countrySettings', 'productOption'])
|
||||
->with(Cart::displayRelations())
|
||||
->whereIn('id', $ids)
|
||||
->get();
|
||||
}
|
||||
|
||||
+8
-2
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Repositories\Contracts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Collection as SupportCollection;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductAdditionalOptionValue;
|
||||
|
||||
/**
|
||||
@@ -36,10 +37,15 @@ interface ProductAdditionalOptionValueRepositoryInterface
|
||||
* 장바구니·바로구매처럼 항목이 여러 개인 경로에서 상품마다 조회하지 않기 위한
|
||||
* 일괄 진입점입니다.
|
||||
*
|
||||
* 외곽이 Support 인 이유: 항목이 Model 이 아니라 Collection 인 **중첩 맵**이므로
|
||||
* Eloquent\Collection 의 계약(load/modelKeys/fresh 가 instanceof Model 을 전제)을
|
||||
* 만족할 수 없습니다. 같은 이유로 `Eloquent\Collection::map()` 도 항목이 Model 이
|
||||
* 아니면 toBase() 로 강등합니다. 평면 `id => Model` 맵은 Eloquent 로 둡니다.
|
||||
*
|
||||
* @param array<int, int> $productIds 상품 ID 목록
|
||||
* @return Collection<int, Collection<int, ProductAdditionalOptionValue>> 상품 ID 키 맵
|
||||
* @return SupportCollection<int, Collection<int, ProductAdditionalOptionValue>> 상품 ID 키 맵
|
||||
*/
|
||||
public function getActiveByProductIdsKeyed(array $productIds): Collection;
|
||||
public function getActiveByProductIdsKeyed(array $productIds): SupportCollection;
|
||||
|
||||
/**
|
||||
* 추가옵션 그룹 ID 목록에 속한 모든 선택지를 삭제합니다.
|
||||
|
||||
+6
-2
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Repositories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Collection as SupportCollection;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductAdditionalOptionValue;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductAdditionalOptionValueRepositoryInterface;
|
||||
|
||||
@@ -44,12 +45,12 @@ class ProductAdditionalOptionValueRepository implements ProductAdditionalOptionV
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getActiveByProductIdsKeyed(array $productIds): Collection
|
||||
public function getActiveByProductIdsKeyed(array $productIds): SupportCollection
|
||||
{
|
||||
$productIds = array_values(array_unique(array_filter(array_map('intval', $productIds))));
|
||||
|
||||
if (empty($productIds)) {
|
||||
return new Collection;
|
||||
return new SupportCollection;
|
||||
}
|
||||
|
||||
// 상품별로 한 번씩 조회하면 장바구니 항목 수만큼 쿼리가 난다. 한 번에 읽고
|
||||
@@ -60,6 +61,9 @@ class ProductAdditionalOptionValueRepository implements ProductAdditionalOptionV
|
||||
->whereHas('additionalOption', fn ($query) => $query->whereIn('product_id', $productIds))
|
||||
->get()
|
||||
->groupBy(fn ($value) => (int) $value->additionalOption?->product_id)
|
||||
// 클로저 인자는 Eloquent 가 맞다 — groupBy() 가 `new static` 으로 그룹을 만든다.
|
||||
// 바깥 map() 의 결과 항목은 Model 이 아니므로 반환은 Support 로 강등되며,
|
||||
// 그것이 이 중첩 맵의 올바른 타입이다 (상세는 인터페이스 PHPDoc).
|
||||
->map(fn (Collection $values) => $values->keyBy('id'));
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ class CashReceiptService
|
||||
private OrderCashReceiptRepositoryInterface $repository,
|
||||
private OrderPaymentRepositoryInterface $paymentRepository,
|
||||
private EcommerceSettingsService $settingsService,
|
||||
private CurrencyConversionService $currencyConversionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -386,14 +387,14 @@ class CashReceiptService
|
||||
*/
|
||||
public function calculateIssuableAmount(Order $order): array
|
||||
{
|
||||
$cashEquivalent = (int) round((float) $order->total_cash_equivalent_amount);
|
||||
$cashEquivalent = $this->toPaymentCurrencyAmount($order, (float) $order->total_cash_equivalent_amount);
|
||||
|
||||
if ($cashEquivalent <= 0) {
|
||||
return ['amount' => 0, 'tax_free_amount' => 0];
|
||||
}
|
||||
|
||||
$taxFree = (int) round((float) $order->total_tax_free_amount);
|
||||
$taxable = (int) round((float) $order->total_tax_amount);
|
||||
$taxFree = $this->toPaymentCurrencyAmount($order, (float) $order->total_tax_free_amount);
|
||||
$taxable = $this->toPaymentCurrencyAmount($order, (float) $order->total_tax_amount);
|
||||
$classified = $taxFree + $taxable;
|
||||
|
||||
// 과세/면세 분류 합이 현금성 금액과 다르면(마일리지 사용, 할인 등) 면세 비율로 안분한다.
|
||||
@@ -414,6 +415,42 @@ class CashReceiptService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* base 통화 금액을 결제 통화(order_currency) 기준 청구액으로 환산합니다.
|
||||
*
|
||||
* 현금영수증은 구매자가 **실제로 낸 금액**으로 발행되어야 한다. 주문 테이블의 금액은
|
||||
* base 통화 기준이므로, base≠결제 통화 주문에서 그대로 쓰면 실청구액과 다른 금액이
|
||||
* 국세청에 신고된다(화면 표시 오류가 아니라 증빙 오류다).
|
||||
*
|
||||
* 환산은 결제·입금 검증과 같은 해석기(`resolveSnapshotPaymentCharge`)를 쓴다 —
|
||||
* 각자 계산하면 반올림·절사 규칙이 어긋나는 순간 조용히 갈라진다.
|
||||
* base == 결제 통화면 환산이 항등이라 단일통화 상점의 값은 종전과 같다.
|
||||
*
|
||||
* @param Order $order 주문
|
||||
* @param float $baseAmount base 통화 금액
|
||||
* @return int 결제 통화 최소 화폐단위 금액 (환율 스냅샷 손상 시 base 금액으로 폴백)
|
||||
*/
|
||||
protected function toPaymentCurrencyAmount(Order $order, float $baseAmount): int
|
||||
{
|
||||
if ($baseAmount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->currencyConversionService->resolveSnapshotPaymentCharge(
|
||||
$baseAmount,
|
||||
$order->currency_snapshot ?? []
|
||||
)['minor_unit_amount'];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('현금영수증 발급액 환산 실패 — base 금액으로 폴백', [
|
||||
'order_id' => $order->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return (int) round($baseAmount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정된 현금영수증 발급 프로바이더 ID를 반환합니다.
|
||||
*
|
||||
|
||||
@@ -131,7 +131,7 @@ class OrderAdjustmentService
|
||||
$restoredCouponIssueIds = $this->detectRestoredCoupons($order, $recalcResult);
|
||||
|
||||
// 12. 복원 쿠폰 상세 정보 구성
|
||||
$restoredCoupons = $this->buildRestoredCouponsInfo($restoredCouponIssueIds);
|
||||
$restoredCoupons = $this->buildRestoredCouponsInfo($restoredCouponIssueIds, $order->currency_snapshot);
|
||||
|
||||
// 13. 배송비 정책별 상세
|
||||
$shippingDetails = $this->buildShippingDetails($order, $recalcResult);
|
||||
@@ -173,8 +173,8 @@ class OrderAdjustmentService
|
||||
$mcRecalculatedSnapshot = $this->buildMcRecalcSnapshot($recalculatedSnapshot, $order->currency_snapshot);
|
||||
|
||||
// 17. 쿠폰 상세 (전/후)
|
||||
$originalCoupons = $this->extractCouponDetails($order->promotions_applied_snapshot ?? []);
|
||||
$recalculatedCoupons = $this->extractCouponDetails($recalcResult->promotions?->toArray() ?? []);
|
||||
$originalCoupons = $this->extractCouponDetails($order->promotions_applied_snapshot ?? [], $order->currency_snapshot);
|
||||
$recalculatedCoupons = $this->extractCouponDetails($recalcResult->promotions?->toArray() ?? [], $order->currency_snapshot);
|
||||
|
||||
// 18. 스냅샷 각 줄을 base 통화로 포맷(취소 모달 primary 표기 = base 통화 기호 고정)
|
||||
$originalSnapshot = $this->enrichSnapshotWithBaseFormat($originalSnapshot, $order->currency_snapshot);
|
||||
@@ -472,7 +472,7 @@ class OrderAdjustmentService
|
||||
$remainingPointsBalance = max(0, $originalPointsUsed - $refundPointsAmount);
|
||||
|
||||
// 복원 쿠폰 상세 정보
|
||||
$restoredCoupons = $this->buildRestoredCouponsInfo($restoredCouponIds);
|
||||
$restoredCoupons = $this->buildRestoredCouponsInfo($restoredCouponIds, $order->currency_snapshot);
|
||||
|
||||
// 배송비 정책별 상세 (전체취소: 모든 배송비 환불)
|
||||
$shippingDetails = [];
|
||||
@@ -546,7 +546,7 @@ class OrderAdjustmentService
|
||||
}
|
||||
|
||||
// 쿠폰 상세 (전체취소 시 취소 후는 빈 배열)
|
||||
$originalCoupons = $this->extractCouponDetails($order->promotions_applied_snapshot ?? []);
|
||||
$originalCoupons = $this->extractCouponDetails($order->promotions_applied_snapshot ?? [], $order->currency_snapshot);
|
||||
|
||||
// 환불 총액/잔액 base 포맷 + 결제 통화 병기
|
||||
$refundFormatted = $this->buildRefundFormatted([
|
||||
@@ -995,21 +995,26 @@ class OrderAdjustmentService
|
||||
* 복원 쿠폰 상세 정보를 구성합니다.
|
||||
*
|
||||
* @param array $restoredCouponIssueIds 복원 대상 쿠폰 발급 ID 배열
|
||||
* @return array [{coupon_name, discount_amount}]
|
||||
* @param array|null $currencySnapshot 주문 시점 통화 스냅샷
|
||||
* @return array [{coupon_name, discount_amount, discount_amount_formatted}]
|
||||
*/
|
||||
private function buildRestoredCouponsInfo(array $restoredCouponIssueIds): array
|
||||
private function buildRestoredCouponsInfo(array $restoredCouponIssueIds, ?array $currencySnapshot = null): array
|
||||
{
|
||||
if (empty($restoredCouponIssueIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$couponIssues = $this->couponIssueRepository->findByIdsWithRelations($restoredCouponIssueIds, ['coupon']);
|
||||
$baseCurrency = $currencySnapshot['base_currency'] ?? $this->currencyService->getDefaultCurrency();
|
||||
|
||||
$restoredCoupons = [];
|
||||
foreach ($couponIssues as $issue) {
|
||||
$discountAmount = (float) ($issue->discount_amount ?? 0);
|
||||
|
||||
$restoredCoupons[] = [
|
||||
'coupon_name' => $issue->coupon?->getLocalizedName() ?? '',
|
||||
'discount_amount' => (float) ($issue->discount_amount ?? 0),
|
||||
'discount_amount' => $discountAmount,
|
||||
'discount_amount_formatted' => $this->currencyService->formatPrice($discountAmount, $baseCurrency),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1065,21 +1070,30 @@ class OrderAdjustmentService
|
||||
/**
|
||||
* 프로모션 스냅샷에서 쿠폰 상세 배열을 추출합니다.
|
||||
*
|
||||
* 스냅샷 금액 줄(enrichSnapshotWithBaseFormat)과 같은 규칙으로, raw 금액에 더해
|
||||
* 주문 시점 기준 통화로 포맷한 동반 키를 함께 싣는다. 화면이 통화 기호를 직접
|
||||
* 이어 붙이면 기준 통화가 원화가 아닌 상점에서 단위만 원으로 표기된다.
|
||||
*
|
||||
* @param array $promotionsSnapshot promotions_applied_snapshot 또는 PromotionsSummary::toArray()
|
||||
* @return array [{name, target_type, discount_amount}]
|
||||
* @param array|null $currencySnapshot 주문 시점 통화 스냅샷
|
||||
* @return array [{name, target_type, discount_amount, discount_amount_formatted}]
|
||||
*/
|
||||
private function extractCouponDetails(array $promotionsSnapshot): array
|
||||
private function extractCouponDetails(array $promotionsSnapshot, ?array $currencySnapshot = null): array
|
||||
{
|
||||
$baseCurrency = $currencySnapshot['base_currency'] ?? $this->currencyService->getDefaultCurrency();
|
||||
$details = [];
|
||||
|
||||
$sections = ['product_promotions', 'order_promotions'];
|
||||
foreach ($sections as $section) {
|
||||
$coupons = $promotionsSnapshot[$section]['coupons'] ?? [];
|
||||
foreach ($coupons as $coupon) {
|
||||
$discountAmount = (float) ($coupon['total_discount'] ?? 0);
|
||||
|
||||
$details[] = [
|
||||
'name' => $coupon['name'] ?? '',
|
||||
'target_type' => $coupon['target_type'] ?? '',
|
||||
'discount_amount' => (float) ($coupon['total_discount'] ?? 0),
|
||||
'discount_amount' => $discountAmount,
|
||||
'discount_amount_formatted' => $this->currencyService->formatPrice($discountAmount, $baseCurrency),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2466,6 +2466,11 @@ class OrderCalculationService
|
||||
'body' => mb_substr($response->body(), 0, 4096),
|
||||
],
|
||||
'extracted_fee' => $extracted,
|
||||
// 화면이 통화 단위를 이어 붙이지 않도록 포맷 문자열을 함께 내보낸다 —
|
||||
// 배송비는 기본 통화 기준이므로 원화를 못 박으면 그 상점에서 단위만 틀린다.
|
||||
'extracted_fee_formatted' => $extracted !== null
|
||||
? ecommerce_format_price((float) $extracted, $countrySetting->currency_code ?? null)
|
||||
: null,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
// 연결 실패·타임아웃 등 — 요청 미리보기 + 에러 메시지를 함께 반환 (진단)
|
||||
|
||||
@@ -388,7 +388,7 @@ class UserMileageService
|
||||
$this->logActivity('mileage.earn', [
|
||||
'loggable' => $existingLot,
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn',
|
||||
'description_params' => ['amount' => (int) $amount],
|
||||
'description_params' => ['amount' => ecommerce_format_price((int) $amount, $currency)],
|
||||
'properties' => ['order_id' => $order->id, 'order_option_id' => $option->id, 'currency' => $currency, 'delta' => (int) $amount],
|
||||
]);
|
||||
|
||||
@@ -407,7 +407,7 @@ class UserMileageService
|
||||
'order_id' => $order->id,
|
||||
'order_option_id' => $option->id,
|
||||
'expires_at' => $expiresAt,
|
||||
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_earn', ['amount' => $amount]),
|
||||
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_earn', ['amount' => ecommerce_format_price($amount, $currency)]),
|
||||
]);
|
||||
|
||||
$this->cache->recalculateForUser($order->user_id, $currency);
|
||||
@@ -416,7 +416,7 @@ class UserMileageService
|
||||
$this->logActivity('mileage.earn', [
|
||||
'loggable' => $tx,
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn',
|
||||
'description_params' => ['amount' => (int) $amount],
|
||||
'description_params' => ['amount' => ecommerce_format_price((int) $amount, $currency)],
|
||||
'properties' => ['order_id' => $order->id, 'order_option_id' => $option->id, 'currency' => $currency],
|
||||
]);
|
||||
|
||||
@@ -480,7 +480,7 @@ class UserMileageService
|
||||
'order_id' => $orderId,
|
||||
'order_cancel_id' => $orderCancelId,
|
||||
'expires_at' => $expiresAt,
|
||||
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_restore', ['amount' => $amount]),
|
||||
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_restore', ['amount' => ecommerce_format_price($amount, $currency)]),
|
||||
]);
|
||||
|
||||
// 원 사용 거래에 동일 order_cancel_id 역주입 — 복원 ↔ 원 사용 거래 연결 (행 확장 정합)
|
||||
@@ -491,7 +491,7 @@ class UserMileageService
|
||||
$this->logActivity('mileage.restore', [
|
||||
'loggable' => $tx,
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_restore',
|
||||
'description_params' => ['amount' => (int) $amount],
|
||||
'description_params' => ['amount' => ecommerce_format_price((int) $amount, $currency)],
|
||||
'properties' => ['order_id' => $orderId, 'order_cancel_id' => $orderCancelId, 'currency' => $currency],
|
||||
]);
|
||||
|
||||
@@ -530,7 +530,7 @@ class UserMileageService
|
||||
'balance_after' => $this->ledger->getBalanceByCurrency($userId, $currency) + $amount,
|
||||
'order_id' => $orderId,
|
||||
'expires_at' => $expiresAt,
|
||||
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_restore', ['amount' => $amount]),
|
||||
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_restore', ['amount' => ecommerce_format_price($amount, $currency)]),
|
||||
]);
|
||||
|
||||
$this->cache->recalculateForUser($userId, $currency);
|
||||
@@ -538,7 +538,7 @@ class UserMileageService
|
||||
$this->logActivity('mileage.restore', [
|
||||
'loggable' => $tx,
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_restore',
|
||||
'description_params' => ['amount' => (int) $amount],
|
||||
'description_params' => ['amount' => ecommerce_format_price((int) $amount, $currency)],
|
||||
'properties' => ['order_id' => $orderId, 'currency' => $currency, 'reason' => 'payment_failed'],
|
||||
]);
|
||||
|
||||
@@ -582,7 +582,7 @@ class UserMileageService
|
||||
'order_option_id' => $option->id,
|
||||
'source_transaction_id' => $earnLot->id,
|
||||
'metadata' => $shortfall > 0 ? ['shortfall' => $shortfall] : null,
|
||||
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_earn_cancel', ['amount' => $toRecover]),
|
||||
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_earn_cancel', ['amount' => ecommerce_format_price($toRecover, $currency)]),
|
||||
]);
|
||||
|
||||
$this->cache->recalculateForUser($order->user_id, $currency);
|
||||
@@ -591,7 +591,7 @@ class UserMileageService
|
||||
$this->logActivity('mileage.earn_cancel', [
|
||||
'loggable' => $tx,
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn_cancel',
|
||||
'description_params' => ['amount' => (int) $toRecover],
|
||||
'description_params' => ['amount' => ecommerce_format_price((int) $toRecover, $currency)],
|
||||
'properties' => [
|
||||
'order_id' => $order->id,
|
||||
'order_option_id' => $option->id,
|
||||
@@ -627,7 +627,7 @@ class UserMileageService
|
||||
'memo' => $dto->memo,
|
||||
'expires_at' => $expiresAt,
|
||||
'description' => $dto->description
|
||||
?? __('sirsoft-ecommerce::activity_log.description.mileage_admin_earn', ['amount' => $dto->amount]),
|
||||
?? __('sirsoft-ecommerce::activity_log.description.mileage_admin_earn', ['amount' => ecommerce_format_price($dto->amount, $dto->currency)]),
|
||||
]);
|
||||
|
||||
$this->cache->recalculateForUser($userId, $dto->currency);
|
||||
@@ -635,7 +635,7 @@ class UserMileageService
|
||||
$this->logActivity('mileage.admin_earn', [
|
||||
'loggable' => $tx,
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_admin_earn',
|
||||
'description_params' => ['amount' => $dto->amount],
|
||||
'description_params' => ['amount' => ecommerce_format_price($dto->amount, $dto->currency)],
|
||||
'properties' => ['granted_by' => $dto->grantedBy, 'currency' => $dto->currency],
|
||||
]);
|
||||
|
||||
@@ -773,7 +773,7 @@ class UserMileageService
|
||||
$this->logActivity('mileage.adjust', [
|
||||
'loggable' => $transaction,
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_adjust',
|
||||
'description_params' => ['amount' => (int) abs((float) $transaction->amount)],
|
||||
'description_params' => ['amount' => ecommerce_format_price((int) abs((float) $transaction->amount), $transaction->currency)],
|
||||
'properties' => [
|
||||
'user_id' => $transaction->user_id,
|
||||
'currency' => $transaction->currency,
|
||||
@@ -811,7 +811,7 @@ class UserMileageService
|
||||
'balance_after' => $this->ledger->getBalanceByCurrency($lot->user_id, $lot->currency) - $remaining,
|
||||
'source_transaction_id' => $lot->id,
|
||||
'expired_at' => $now,
|
||||
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_expire', ['amount' => $remaining]),
|
||||
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_expire', ['amount' => ecommerce_format_price($remaining, $lot->currency)]),
|
||||
]);
|
||||
|
||||
$this->ledger->markExpired($lot, $now);
|
||||
@@ -820,7 +820,7 @@ class UserMileageService
|
||||
$this->logActivity('mileage.expire', [
|
||||
'loggable' => $tx,
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_expire',
|
||||
'description_params' => ['amount' => (int) $remaining],
|
||||
'description_params' => ['amount' => ecommerce_format_price((int) $remaining, $lot->currency)],
|
||||
'properties' => ['user_id' => $lot->user_id, 'currency' => $lot->currency],
|
||||
]);
|
||||
|
||||
@@ -852,7 +852,7 @@ class UserMileageService
|
||||
|
||||
$this->logActivity('mileage.expire', [
|
||||
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_expire',
|
||||
'description_params' => ['amount' => (int) $remaining],
|
||||
'description_params' => ['amount' => ecommerce_format_price((int) $remaining, $lot->currency)],
|
||||
'properties' => [
|
||||
'user_id' => $userId,
|
||||
'currency' => $lot->currency,
|
||||
@@ -944,7 +944,7 @@ class UserMileageService
|
||||
'remaining_amount' => 0,
|
||||
'balance_after' => $this->ledger->getBalanceByCurrency($userId, $currency),
|
||||
'source_transaction_id' => $firstSource,
|
||||
'description' => __($descriptionKey, ['amount' => $amount]),
|
||||
'description' => __($descriptionKey, ['amount' => ecommerce_format_price($amount, $currency)]),
|
||||
], $extra));
|
||||
|
||||
$this->cache->recalculateForUser($userId, $currency);
|
||||
@@ -957,7 +957,7 @@ class UserMileageService
|
||||
$this->logActivity($action, [
|
||||
'loggable' => $tx,
|
||||
'description_key' => $descKey,
|
||||
'description_params' => ['amount' => $amount],
|
||||
'description_params' => ['amount' => ecommerce_format_price($amount, $currency)],
|
||||
'properties' => array_merge(['currency' => $currency], $grantedBy !== null ? ['granted_by' => $grantedBy] : []),
|
||||
]);
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ return [
|
||||
'order_cancel' => '주문 전체 취소 (:order_number)',
|
||||
'order_partial_cancel' => '주문 부분 취소 (:order_number)',
|
||||
'order_coupon_restore' => '주문 취소 쿠폰 복원 (:order_number)',
|
||||
'order_mileage_restore' => '주문 취소 마일리지 복원 (:order_number, :amount원)',
|
||||
'order_mileage_restore' => '주문 취소 마일리지 복원 (:order_number, :amount)',
|
||||
'order_bulk_update' => '주문 일괄 변경 (:count건)',
|
||||
'order_bulk_status_update' => '주문 일괄 상태 변경 (:count건)',
|
||||
'order_bulk_shipping_update' => '운송장 일괄 입력 (:count건)',
|
||||
@@ -210,15 +210,15 @@ return [
|
||||
'user_coupon_download' => '쿠폰 다운로드 (:coupon_name)',
|
||||
'user_order_create' => '주문 완료 (#:order_id)',
|
||||
'user_order_option_confirm' => '구매확인 (옵션 #:option_id)',
|
||||
'mileage_earn' => '마일리지 적립 (:amount원)',
|
||||
'mileage_use' => '마일리지 사용 (:amount원)',
|
||||
'mileage_restore' => '마일리지 복원 (:amount원)',
|
||||
'mileage_expire' => '마일리지 소멸 (:amount원)',
|
||||
'mileage_earn_cancel' => '마일리지 적립 회수 (:amount원)',
|
||||
'mileage_admin_earn' => '관리자 마일리지 지급 (:amount원)',
|
||||
'mileage_admin_deduct' => '관리자 마일리지 차감 (:amount원)',
|
||||
'mileage_earn' => '마일리지 적립 (:amount)',
|
||||
'mileage_use' => '마일리지 사용 (:amount)',
|
||||
'mileage_restore' => '마일리지 복원 (:amount)',
|
||||
'mileage_expire' => '마일리지 소멸 (:amount)',
|
||||
'mileage_earn_cancel' => '마일리지 적립 회수 (:amount)',
|
||||
'mileage_admin_earn' => '관리자 마일리지 지급 (:amount)',
|
||||
'mileage_admin_deduct' => '관리자 마일리지 차감 (:amount)',
|
||||
'mileage_extend_expiry' => '마일리지 유효기간 연장 (:days일)',
|
||||
'mileage_adjust' => '마일리지 적립건 수정 (:amount원)',
|
||||
'mileage_adjust' => '마일리지 적립건 수정 (:amount)',
|
||||
],
|
||||
|
||||
// ChangeDetector 필드 라벨
|
||||
|
||||
@@ -69,11 +69,11 @@ return [
|
||||
|
||||
// 주문 결제 관련
|
||||
'insufficient_stock' => '재고가 부족한 상품이 :count개 있습니다.',
|
||||
'payment_amount_mismatch' => '결제 금액이 일치하지 않습니다. (예상: :expected원, 실제: :actual원)',
|
||||
'payment_amount_mismatch' => '결제 금액이 일치하지 않습니다. (예상: :expected, 실제: :actual)',
|
||||
'cart_unavailable' => '구매할 수 없는 상품이 있습니다.',
|
||||
'purchase_not_allowed' => '구매 권한이 없는 상품입니다.',
|
||||
'country_not_shippable' => '선택하신 배송국가로는 배송할 수 없는 상품입니다.',
|
||||
'order_amount_changed' => '주문 금액이 변동되었습니다. 체크아웃 페이지를 새로고침 후 다시 시도해주세요. (이전: :stored원, 현재: :recalculated원)',
|
||||
'order_amount_changed' => '주문 금액이 변동되었습니다. 체크아웃 페이지를 새로고침 후 다시 시도해주세요. (이전: :stored, 현재: :recalculated)',
|
||||
'order_calculation_validation_failed' => '주문 계산 검증에 실패했습니다. 쿠폰 만료 또는 재고 변동이 발생했을 수 있습니다.',
|
||||
|
||||
// 주문 취소/환불 관련
|
||||
|
||||
@@ -1112,8 +1112,8 @@ return [
|
||||
'bulk_summary_sales_status' => '판매상태: :status',
|
||||
'bulk_summary_display_status' => '진열상태: :status',
|
||||
'bulk_summary_name_changed' => '상품명 변경',
|
||||
'bulk_summary_list_price' => '정가: :price원',
|
||||
'bulk_summary_selling_price' => '판매가: :price원',
|
||||
'bulk_summary_list_price' => '정가: :price',
|
||||
'bulk_summary_selling_price' => '판매가: :price',
|
||||
'bulk_summary_inline_modified' => '인라인 수정됨',
|
||||
'bulk_summary_bulk_applied' => '일괄 변경 적용',
|
||||
'bulk_summary_price_adjustment' => '가격 조정: :method :value',
|
||||
|
||||
+59
@@ -473,6 +473,19 @@ class CashReceiptControllerTest extends ModuleTestCase
|
||||
->assertJsonPath('data.identifier_masked', '*******5678');
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 계약(토큰 없이는 발급 불가)으로는 유효하지만, **미들웨어 부착의 증거는 아니다.**
|
||||
*
|
||||
* 토큰 미들웨어의 404 와 FormRequest 의 404 는 코드·메시지 키가 같아 응답으로 구별할 수 없고,
|
||||
* payload 가 유효해 422 로 갈라지지도 않는다. 즉 미들웨어가 아예 부착되지 않아도 green 이며
|
||||
* 실제로 그 상태가 오래 유지됐다(현금영수증 라우트 targets 누락). 상태코드나 메시지를 갈라
|
||||
* 구별 가능하게 만드는 방식은 채택하지 않는다 — 주문 존재 여부가 응답으로 새어 열거 공격
|
||||
* 표면이 생긴다. 동일 404 는 의도된 설계다.
|
||||
*
|
||||
* 따라서 증명 책임은 다음이 나눠 진다:
|
||||
* - 미들웨어 부착 자체 → `GuestOrderTokenMiddlewareRegistrationTest`
|
||||
* - 토큰이 실제로 통한다 → `비회원은_조회_토큰으로_사후_발급할_수_있다`
|
||||
*/
|
||||
#[Test]
|
||||
public function 비회원은_토큰_없이_발급할_수_없다(): void
|
||||
{
|
||||
@@ -508,4 +521,50 @@ class CashReceiptControllerTest extends ModuleTestCase
|
||||
->assertJsonPath('data.issuable', false)
|
||||
->assertJsonPath('data.cash_receipt.identifier_masked', '*******5678');
|
||||
}
|
||||
|
||||
/**
|
||||
* 비회원 주문상세 응답이 발급 결과를 실어야 한다.
|
||||
*
|
||||
* 주문상세 화면의 현금영수증 카드는 `order.data.cash_receipt` 로 발급완료/미발급을 가른다
|
||||
* (회원·비회원이 같은 레이아웃 `mypage_order_cash_receipt.json` 을 공유한다).
|
||||
* 비회원 응답에 이 키가 없으면 발급에 성공해도 카드가 영구히 "미발급" 으로 남고
|
||||
* 영수증 URL 에 도달할 방법이 없다 — 오류도 경고도 없이 기능만 사라진다.
|
||||
*
|
||||
* `payment.is_cash_receipt_issued` 만으로는 대체되지 않는다. 카드가 보는 키가 다르다.
|
||||
*/
|
||||
#[Test]
|
||||
public function 비회원_주문상세_응답에_발급된_현금영수증이_실린다(): void
|
||||
{
|
||||
$this->registerProvider();
|
||||
[$order, $token] = $this->makeGuestOrderWithToken();
|
||||
$headers = ['X-Guest-Order-Token' => $token];
|
||||
|
||||
$detailUrl = "/api/modules/sirsoft-ecommerce/user/orders/{$order->order_number}";
|
||||
|
||||
// 발급 전 — 키는 존재하되 값이 없다 (카드의 "미발급" 분기 근거)
|
||||
$this->getJson($detailUrl, $headers)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.cash_receipt', null)
|
||||
->assertJsonPath('data.cash_receipts', []);
|
||||
|
||||
$this->postJson(
|
||||
"/api/modules/sirsoft-ecommerce/guest/orders/{$order->order_number}/cash-receipt",
|
||||
$this->issuePayload(),
|
||||
$headers,
|
||||
)->assertOk();
|
||||
|
||||
$response = $this->getJson($detailUrl, $headers)->assertOk();
|
||||
|
||||
$response->assertJsonPath('data.cash_receipt.identifier_masked', '*******5678');
|
||||
$response->assertJsonPath('data.cash_receipt.receipt_url', 'https://example.test/receipt/receipt-1');
|
||||
$response->assertJsonPath('data.cash_receipt.issue_number', '12345678');
|
||||
$this->assertNotNull($response->json('data.cash_receipt.issued_at_formatted'));
|
||||
$this->assertNotNull($response->json('data.cash_receipt.receipt_type_label'));
|
||||
|
||||
// 이력 배열도 함께 — 카드의 "직전 발급 실패" 분기가 cash_receipts[0] 를 본다
|
||||
$this->assertCount(1, $response->json('data.cash_receipts'));
|
||||
|
||||
// 비회원 응답에도 식별번호 평문은 절대 실리지 않는다
|
||||
$this->assertStringNotContainsString(self::IDENTIFIER, $response->getContent());
|
||||
}
|
||||
}
|
||||
|
||||
+170
@@ -10,6 +10,8 @@ use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ChargePolicyEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Cart;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductAdditionalOption;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductAdditionalOptionValue;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
@@ -773,6 +775,173 @@ class CartControllerTest extends ModuleTestCase
|
||||
&& ! str_contains($m, ':deleted_count'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가옵션 그룹 + 선택지 1개를 가진 상품을 생성합니다.
|
||||
*
|
||||
* @param int $priceAdjustment 선택지 추가금
|
||||
* @param bool $allowCustomText 직접입력 허용 여부
|
||||
* @return array{product: Product, option: ProductOption, value: ProductAdditionalOptionValue}
|
||||
*/
|
||||
protected function createProductWithAdditionalOption(int $priceAdjustment = 5000, bool $allowCustomText = false): array
|
||||
{
|
||||
$shippingPolicy = $this->createShippingPolicy();
|
||||
$product = ProductFactory::new()->create([
|
||||
'shipping_policy_id' => $shippingPolicy->id,
|
||||
'selling_price' => 10000,
|
||||
'list_price' => 10000,
|
||||
]);
|
||||
$option = ProductOptionFactory::new()->forProduct($product)->create([
|
||||
'stock_quantity' => 100,
|
||||
'price_adjustment' => 0,
|
||||
]);
|
||||
|
||||
$group = ProductAdditionalOption::create([
|
||||
'product_id' => $product->id,
|
||||
'name' => ['ko' => '포장 방식', 'en' => 'Packaging'],
|
||||
'is_required' => false,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$value = ProductAdditionalOptionValue::create([
|
||||
'additional_option_id' => $group->id,
|
||||
'name' => ['ko' => '선물 포장', 'en' => 'Gift Box'],
|
||||
'price_adjustment' => $priceAdjustment,
|
||||
'is_default' => false,
|
||||
'is_active' => true,
|
||||
'allow_custom_text' => $allowCustomText,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
return ['product' => $product, 'option' => $option, 'value' => $value];
|
||||
}
|
||||
|
||||
/**
|
||||
* 담기 응답의 아이템은 목록 조회와 동일하게 추가옵션·소계를 실어야 한다.
|
||||
*
|
||||
* 회귀: 담기 응답은 `product`/`productOption` 만 적재해 `CartItemResource` 의
|
||||
* `relationLoaded('additionalOptions')` 가드에 걸렸다. 그래서 선택이 저장되었는데도
|
||||
* `additional_options` 가 빈 배열, `additional_options_total` 이 0, `subtotal` 이
|
||||
* 추가금을 뺀 값으로 나갔다. 오류가 아니라 값만 조용히 빠지므로, 담기 응답만 읽는
|
||||
* 소비자는 잘못된 금액을 그대로 표시하게 된다.
|
||||
*
|
||||
* @scenario surface=cart,observation=response_shape
|
||||
*
|
||||
* @effects store_response_matches_index_response, additional_option_amount_is_included_in_subtotal
|
||||
*/
|
||||
public function test_store_response_includes_additional_options_and_subtotal(): void
|
||||
{
|
||||
$data = $this->createProductWithAdditionalOption(priceAdjustment: 5000, allowCustomText: true);
|
||||
$cartKey = 'ck_'.str_repeat('j', 32);
|
||||
|
||||
$response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $data['product']->id,
|
||||
'items' => [[
|
||||
'product_option_id' => $data['option']->id,
|
||||
'quantity' => 2,
|
||||
'additional_option_selections' => [[
|
||||
'additional_option_id' => $data['value']->additional_option_id,
|
||||
'value_id' => $data['value']->id,
|
||||
'custom_text' => '각인 문구',
|
||||
]],
|
||||
]],
|
||||
], ['X-Cart-Key' => $cartKey]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$item = $response->json('data.items.0');
|
||||
|
||||
$this->assertCount(1, $item['additional_options'], '담기 응답에 선택한 추가옵션이 실려야 한다');
|
||||
$this->assertSame($data['value']->id, $item['additional_options'][0]['value_id']);
|
||||
$this->assertSame('각인 문구', $item['additional_options'][0]['custom_text']);
|
||||
$this->assertSame(5000, $item['additional_options_total']);
|
||||
|
||||
// 소계 = (옵션 판매가 10,000 + 추가옵션 5,000) × 수량 2
|
||||
$this->assertSame(30000, $item['subtotal']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 담기 응답의 아이템은 목록 조회 응답과 같은 값을 가져야 한다.
|
||||
*
|
||||
* 위 테스트가 "담기 응답이 맞다" 를 고정한다면, 이 테스트는 두 엔드포인트가 서로
|
||||
* 어긋나지 않음을 고정한다. 한쪽 적재 목록만 바뀌어도 red 가 된다.
|
||||
*
|
||||
* @scenario surface=cart,observation=response_shape
|
||||
*
|
||||
* @effects store_response_matches_index_response
|
||||
*/
|
||||
public function test_store_response_item_matches_index_response_item(): void
|
||||
{
|
||||
$data = $this->createProductWithAdditionalOption(priceAdjustment: 3000);
|
||||
$cartKey = 'ck_'.str_repeat('k', 32);
|
||||
|
||||
$storeResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $data['product']->id,
|
||||
'items' => [[
|
||||
'product_option_id' => $data['option']->id,
|
||||
'quantity' => 1,
|
||||
'additional_option_selections' => [[
|
||||
'additional_option_id' => $data['value']->additional_option_id,
|
||||
'value_id' => $data['value']->id,
|
||||
]],
|
||||
]],
|
||||
], ['X-Cart-Key' => $cartKey]);
|
||||
|
||||
$storeResponse->assertStatus(201);
|
||||
$stored = $storeResponse->json('data.items.0');
|
||||
|
||||
$indexResponse = $this->getJson('/api/modules/sirsoft-ecommerce/cart', ['X-Cart-Key' => $cartKey]);
|
||||
$indexResponse->assertStatus(200);
|
||||
$listed = $indexResponse->json('data.items.0');
|
||||
|
||||
foreach (['additional_options', 'additional_options_total', 'subtotal', 'product'] as $key) {
|
||||
$this->assertSame(
|
||||
$listed[$key],
|
||||
$stored[$key],
|
||||
"담기 응답과 목록 응답의 `{$key}` 가 어긋나면 안 된다"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 옵션 변경 응답의 아이템도 추가옵션·소계를 실어야 한다.
|
||||
*
|
||||
* 담기와 같은 원인(적재 목록 누락)이 옵션 변경 응답에도 있었다. 이쪽은 변경된 단건을
|
||||
* 그대로 돌려주는 자리라 소비자가 응답만으로 행을 갱신하면 금액이 어긋난다.
|
||||
*
|
||||
* @scenario surface=cart,observation=response_shape
|
||||
*
|
||||
* @effects change_option_response_carries_additional_options
|
||||
*/
|
||||
public function test_change_option_response_includes_additional_options_and_subtotal(): void
|
||||
{
|
||||
$data = $this->createProductWithAdditionalOption(priceAdjustment: 4000);
|
||||
$cartKey = 'ck_'.str_repeat('l', 32);
|
||||
|
||||
$this->postJson('/api/modules/sirsoft-ecommerce/cart', [
|
||||
'product_id' => $data['product']->id,
|
||||
'items' => [['product_option_id' => $data['option']->id, 'quantity' => 1]],
|
||||
], ['X-Cart-Key' => $cartKey])->assertStatus(201);
|
||||
|
||||
$cartId = Cart::where('cart_key', $cartKey)->value('id');
|
||||
|
||||
$response = $this->putJson("/api/modules/sirsoft-ecommerce/cart/{$cartId}/option", [
|
||||
'product_option_id' => $data['option']->id,
|
||||
'quantity' => 3,
|
||||
'additional_option_selections' => [[
|
||||
'additional_option_id' => $data['value']->additional_option_id,
|
||||
'value_id' => $data['value']->id,
|
||||
]],
|
||||
], ['X-Cart-Key' => $cartKey]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$item = $response->json('data.item');
|
||||
|
||||
$this->assertCount(1, $item['additional_options'], '옵션 변경 응답에 추가옵션이 실려야 한다');
|
||||
$this->assertSame(4000, $item['additional_options_total']);
|
||||
$this->assertSame(42000, $item['subtotal']); // (10,000 + 4,000) × 3
|
||||
}
|
||||
|
||||
/**
|
||||
* 장바구니 조회는 상품 이미지의 필요한 컬럼만 읽는다.
|
||||
*
|
||||
@@ -781,6 +950,7 @@ class CartControllerTest extends ModuleTestCase
|
||||
* 관계는 두고 컬럼만 좁힌다.
|
||||
*
|
||||
* @scenario surface=cart,observation=query_shape
|
||||
*
|
||||
* @effects list_response_shape_is_unchanged, image_columns_stay_sufficient_for_thumbnail_assembly
|
||||
*/
|
||||
public function test_cart_loads_only_needed_product_image_columns(): void
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 추가옵션 추가금의 표시통화 환산 — 기본통화 ≠ 표시통화 쇼핑몰에서 추가금이
|
||||
* 상품가와 같은 통화로 표시되고 합계에도 환산되어 더해지는지 검증.
|
||||
* 템플릿 sirsoft-basic (유저 화면). (skeleton, placeholder)
|
||||
*
|
||||
* @scenario currency-symbol-display
|
||||
* @effects additional_option_adjustment_uses_configured_symbol,
|
||||
* detail_total_converts_additional_option_amount,
|
||||
* cart_line_additional_option_uses_display_currency,
|
||||
* checkout_line_additional_option_uses_display_currency
|
||||
*
|
||||
* 배경(회귀): 추가옵션 추가금은 쇼핑몰 기본 통화 기준으로 저장되는데, 서버가 그 값을
|
||||
* 통화별로 환산해 주지 않았고 화면 합계 계산은 그 값을 KRW 로 간주했다. 기본통화가 KRW 인
|
||||
* 쇼핑몰에서만 우연히 맞았고, 기본통화 JPY + 표시통화 KRW 인 쇼핑몰에서는
|
||||
* "환산된 상품가 + 환산 안 된 추가금" 이라는 통화가 섞인 합계가 나왔다
|
||||
* (상품가 ₩114,000 + 추가금 ¥5,000 → ₩119,000 표기).
|
||||
*
|
||||
* e2e:allow 재현에 쇼핑몰 전역 통화 설정(기본통화를 KRW 가 아닌 통화로 전환 + 환율 지정)이
|
||||
* 필요해 공용 개발 사이트를 흔들지 않고는 자동화할 수 없다. 통화 설정 시드가
|
||||
* 갖춰지기 전까지는 아래 커버리지로 대체한다.
|
||||
* (1) 서버 계약 — tests/Unit/Resources/AdditionalOptionMultiCurrencyTest.php
|
||||
* (상품상세·장바구니 응답의 multi_currency_price_adjustment, 음수 추가금 부호 유지) green.
|
||||
* (2) 합계 계산 — templates/_bundled/sirsoft-basic/src/handlers/__tests__/
|
||||
* productOptionsAdditionalCurrency.test.ts
|
||||
* (표시통화 합계에 환산 추가금 가산, 수량 배수, 미선택 0, 항목 생성 시 통화별 합계 적재) green.
|
||||
* (3) 폴백 — productOptionsAdditional.test.ts 의 기존 케이스가 서버 맵이 없는
|
||||
* 응답에서 기본통화 피봇 환산으로 종전 값을 유지함을 고정 green.
|
||||
* 라이브 검수는 Chrome MCP 실측으로 기록됨 — 기본통화 JPY / 표시통화 KRW 상태에서
|
||||
* 수정 전 ₩119,000·"+¥3,000" → 수정 후 ₩142,500·"+28,500원" 확인.
|
||||
*
|
||||
* 활성화 조건:
|
||||
* 1. 기본통화가 KRW 가 아닌 쇼핑몰 통화 설정 시드(예: JPY 기본 + KRW 환율)
|
||||
* 2. _purchase_card 추가옵션 Select / 추가옵션 금액 라인에 data-testid
|
||||
* 3. _cart_item·_checkout_items 추가옵션 라인에 data-testid
|
||||
* 4. PLAYWRIGHT_BASE_URL = 실 도메인, test.describe.skip → test.describe
|
||||
*
|
||||
* 매트릭스:
|
||||
* T1 상품상세 선택지 라벨 — 표시통화로 환산 표기 (기준통화 기호가 남지 않는다)
|
||||
* T2 상품상세 "추가옵션 금액" + 총 금액 — 상품가와 같은 통화로 합산
|
||||
* T3 장바구니 줄 — 추가옵션 라벨이 그 줄의 상품가·소계와 같은 통화
|
||||
* T4 주문서 줄 — 동일
|
||||
* T5 통화 전환 — 표시통화를 바꾸면 추가금 표기·합계가 함께 따라간다
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
|
||||
|
||||
// 추가옵션 보유 시드 상품 상세 (실 도메인 시드 후 경로 확정)
|
||||
const PRODUCT_PATH = '/shop/products/28';
|
||||
|
||||
test.describe.skip('추가옵션 추가금 표시통화 환산 (기본통화 ≠ 표시통화, skeleton)', () => {
|
||||
test('T1/T2 상품상세: 추가옵션 라벨과 총 금액이 표시통화로 환산된다', async ({ page, userToken }) => {
|
||||
await authenticatePage(page, userToken);
|
||||
await page.goto(PRODUCT_PATH);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
// 기본옵션 선택 → 추가옵션 블럭 노출
|
||||
await page.getByTestId('option-select-0').selectOption({ index: 1 });
|
||||
await page.getByTestId('option-select-1').selectOption({ index: 1 });
|
||||
|
||||
// 추가옵션 선택지 라벨에 기준통화 기호(¥)가 남아 있으면 안 된다
|
||||
const addOption = page.getByTestId('add-option-0-10');
|
||||
await expect(addOption).toBeVisible();
|
||||
await expect(addOption).not.toContainText('¥');
|
||||
|
||||
await addOption.selectOption({ label: /선물 포장/ });
|
||||
|
||||
// 추가옵션 금액 라인과 총 금액이 같은 통화(표시통화)로 표기된다
|
||||
const additionalAmount = page.getByTestId('additional-options-amount');
|
||||
await expect(additionalAmount).toContainText('원');
|
||||
|
||||
const total = page.getByTestId('purchase-total-amount');
|
||||
await expect(total).toContainText('₩');
|
||||
});
|
||||
|
||||
test('T3 장바구니: 추가옵션 라벨이 그 줄의 소계와 같은 통화로 표기된다', async ({ page, userToken }) => {
|
||||
await authenticatePage(page, userToken);
|
||||
await page.goto('/shop/cart');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
const line = page.getByTestId('cart-item-additional-option').first();
|
||||
await expect(line).toContainText('원');
|
||||
await expect(line).not.toContainText('¥');
|
||||
});
|
||||
|
||||
test('T4 주문서: 추가옵션 라벨이 표시통화로 표기된다', async ({ page, userToken }) => {
|
||||
await authenticatePage(page, userToken);
|
||||
await page.goto('/shop/checkout');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
const line = page.getByTestId('checkout-item-additional-option').first();
|
||||
await expect(line).toContainText('원');
|
||||
await expect(line).not.toContainText('¥');
|
||||
});
|
||||
|
||||
test('T5 통화 전환: 표시통화를 바꾸면 추가금 표기와 합계가 함께 따라간다', async ({ page, userToken }) => {
|
||||
await authenticatePage(page, userToken);
|
||||
await page.goto(PRODUCT_PATH);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId('option-select-0').selectOption({ index: 1 });
|
||||
await page.getByTestId('option-select-1').selectOption({ index: 1 });
|
||||
await page.getByTestId('add-option-0-10').selectOption({ label: /선물 포장/ });
|
||||
|
||||
const before = await page.getByTestId('additional-options-amount').innerText();
|
||||
|
||||
await page.getByTestId('currency-switcher').click();
|
||||
await page.getByRole('option', { name: 'USD' }).click();
|
||||
|
||||
await expect(page.getByTestId('additional-options-amount')).not.toHaveText(before);
|
||||
await expect(page.getByTestId('additional-options-amount')).toContainText('$');
|
||||
});
|
||||
});
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 비회원 현금영수증 — 발급 결과 표시 + 결제 통화 금액 정합 회귀.
|
||||
*
|
||||
* 배경 (2026-08-07 검수 2에서 브라우저 실측으로 확인된 3건):
|
||||
*
|
||||
* 1. 비회원 주문상세 응답에 `cash_receipt`/`cash_receipts` 가 없어, 발급에 성공해도 카드가
|
||||
* 영구히 "발급된 현금영수증이 없습니다" 로 남고 영수증 URL 에 도달할 방법이 없었다.
|
||||
* (회원용 OrderResource 에는 있고 GuestOrderResource 에만 빠져 있었다. 두 화면은 같은
|
||||
* 확장 레이아웃 mypage_order_cash_receipt.json 을 공유한다.)
|
||||
*
|
||||
* 2. 관리자 입금확인 모달이 base 통화 금액(total_due_amount)을 입금액으로 채워 넣는데
|
||||
* 서버는 결제 통화 실청구액으로 검증해, base≠결제 통화 주문은 **항상** 422 였다.
|
||||
*
|
||||
* 3. 현금영수증 발급액이 base 통화 금액이라, 구매자가 실제로 낸 금액과 다른 금액으로
|
||||
* 세금 증빙이 발행됐다.
|
||||
*
|
||||
* 세 결함 모두 오류 로그·콘솔·네트워크 실패 없이 "값만 조용히 어긋나는" 형태라
|
||||
* 단위 테스트만으로는 화면 도달을 증명하지 못한다. 그래서 종단 spec 을 둔다.
|
||||
*
|
||||
* 실행 전제 (환경 의존이라 기본 skip):
|
||||
* - 이커머스 설정 > 주문/결제 > 현금영수증 프로바이더 지정
|
||||
* - 해당 결제 플러그인의 테스트 API 키 설정 (샌드박스 실호출)
|
||||
* - 무통장입금 결제수단 활성 + 입금 계좌 1건 이상
|
||||
* 위 전제가 갖춰진 환경에서 test.describe.skip → test.describe 로 바꿔 실행한다.
|
||||
*
|
||||
* @scenario guest_cash_receipt_issue
|
||||
* @effects cash_receipt_issued, order_detail_reflects_receipt, payment_currency_amount_consistent
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const ORDER_NUMBER = process.env.G7_GUEST_ORDER_NUMBER ?? '';
|
||||
const ORDER_PHONE = process.env.G7_GUEST_ORDER_PHONE ?? '';
|
||||
const ORDER_PASSWORD = process.env.G7_GUEST_ORDER_PASSWORD ?? '';
|
||||
|
||||
/**
|
||||
* 비회원 주문 조회로 주문상세에 진입한다.
|
||||
*/
|
||||
async function lookupGuestOrder(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.goto('/shop/guest/orders');
|
||||
await page.locator('input[name="order_number"]').fill(ORDER_NUMBER);
|
||||
await page.locator('input[name="orderer_phone"]').fill(ORDER_PHONE);
|
||||
await page.locator('input[name="guest_lookup_password"]').fill(ORDER_PASSWORD);
|
||||
await page.getByRole('button', { name: '주문 조회' }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/shop/guest/orders/${ORDER_NUMBER}`));
|
||||
}
|
||||
|
||||
test.describe.skip('비회원 현금영수증', () => {
|
||||
// @scenario guest_cash_receipt_issue:issue_then_render
|
||||
// @effects cash_receipt_issued, order_detail_reflects_receipt
|
||||
test('발급에 성공하면 주문상세 카드가 즉시 발급완료로 바뀌고 영수증 링크가 노출된다', async ({ page }) => {
|
||||
await lookupGuestOrder(page);
|
||||
|
||||
// 발급 전: 미발급 안내 + 발급 버튼
|
||||
await expect(page.getByText('발급된 현금영수증이 없습니다')).toBeVisible();
|
||||
|
||||
const issueResponse = page.waitForResponse(
|
||||
(r) => r.url().includes('/guest/orders/') && r.url().includes('/cash-receipt') && r.request().method() === 'POST'
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: '현금영수증 발급', exact: true }).first().click();
|
||||
await page.locator('input[name="identifier"]').fill(ORDER_PHONE.replace(/\D/g, ''));
|
||||
await page.getByRole('button', { name: '현금영수증 발급', exact: true }).last().click();
|
||||
|
||||
const response = await issueResponse;
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// 결함 1 회귀: 재조회 없이도 카드가 발급완료로 갱신되어야 한다.
|
||||
await expect(page.getByText('발급된 현금영수증이 없습니다')).not.toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText('발급일시')).toBeVisible();
|
||||
await expect(page.getByText('소득공제용')).toBeVisible();
|
||||
|
||||
// 영수증 URL 에 실제로 도달 가능해야 한다 (링크가 없으면 구매자는 증빙을 볼 수 없다).
|
||||
const receiptLink = page.getByRole('link', { name: '영수증 보기' });
|
||||
await expect(receiptLink).toBeVisible();
|
||||
await expect(receiptLink).toHaveAttribute('href', /receipts?\//);
|
||||
});
|
||||
|
||||
// @scenario guest_cash_receipt_issue:amount_matches_charge
|
||||
// @effects payment_currency_amount_consistent
|
||||
test('발급 금액이 구매자가 실제로 청구받은 결제 통화 금액과 일치한다', async ({ page }) => {
|
||||
// 주문상세 응답의 결제 통화 실청구액과 발급 응답의 금액이 같아야 한다.
|
||||
// base 통화 금액을 쓰면 두 값이 갈라진다(결함 3).
|
||||
const detailPromise = page.waitForResponse(
|
||||
(r) => /\/user\/orders\/[^/]+$/.test(r.url()) && r.request().method() === 'GET'
|
||||
);
|
||||
|
||||
await lookupGuestOrder(page);
|
||||
|
||||
const detail = await (await detailPromise).json();
|
||||
const chargeAmount: number = detail?.data?.total_due_charge_amount;
|
||||
expect(typeof chargeAmount).toBe('number');
|
||||
|
||||
const receipt = detail?.data?.cash_receipt;
|
||||
|
||||
// 결함 1 회귀: 비회원 응답에도 키가 존재해야 한다 (없으면 화면이 판정 불가).
|
||||
expect(detail?.data).toHaveProperty('cash_receipt');
|
||||
expect(detail?.data).toHaveProperty('cash_receipts');
|
||||
|
||||
if (receipt) {
|
||||
expect(receipt.amount).toBe(chargeAmount);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.skip('관리자 무통장 입금확인 — 결제 통화 정합', () => {
|
||||
// @scenario guest_cash_receipt_issue:deposit_confirm_prefill
|
||||
// @effects payment_currency_amount_consistent
|
||||
test('입금확인 모달의 기본 입금액이 서버 검증 금액과 같아 첫 시도에 저장된다', async ({ page }) => {
|
||||
await page.goto(`/admin/ecommerce/orders/${ORDER_NUMBER}`);
|
||||
|
||||
await page.getByRole('button', { name: '입금확인' }).first().click();
|
||||
|
||||
// 결함 2 회귀: prefill 이 base 금액이면 서버가 422 로 되돌린다.
|
||||
const amountInput = page.locator('input[type="number"]').first();
|
||||
await expect(amountInput).not.toHaveValue('');
|
||||
|
||||
const confirmResponse = page.waitForResponse(
|
||||
(r) => r.url().includes('/confirm-deposit') && r.request().method() === 'PATCH'
|
||||
);
|
||||
await page.getByRole('button', { name: '입금확인', exact: true }).last().click();
|
||||
|
||||
const response = await confirmResponse;
|
||||
expect(response.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners;
|
||||
|
||||
use App\Enums\TotalRelation;
|
||||
use App\Support\Query\BoundedCount;
|
||||
use App\Support\Query\BoundedPage;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\EcommerceNotificationDataListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SearchProductsListener;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 금액 표기가 기본 통화를 따르는지 검증하는 회귀 테스트
|
||||
*
|
||||
* 통화 기호/자릿수는 쇼핑몰 설정(`language_currency.default_currency`)이 정한다.
|
||||
* 그런데 일부 지점이 통화 코드를 'KRW' 로 못 박거나 문자열 '원' 을 직접 이어 붙여,
|
||||
* 기본 통화가 KRW 가 아닌 상점에서 값은 기준통화인데 단위만 원화로 표기됐다.
|
||||
*
|
||||
* 기본 통화가 KRW 인 상점에서는 우연히 맞으므로, 이 테스트는 기본 통화를 JPY 로
|
||||
* 두고 검증한다.
|
||||
*/
|
||||
class BaseCurrencyFormattingTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 기본 통화를 JPY 로 설정합니다.
|
||||
*/
|
||||
private function setJpyBase(): void
|
||||
{
|
||||
$languageCurrency = [
|
||||
'default_currency' => 'JPY',
|
||||
'currencies' => [
|
||||
[
|
||||
'code' => 'JPY', 'name' => ['ja' => '円'], 'symbol' => '¥',
|
||||
'is_default' => true, 'decimal_places' => 0, 'base_unit' => 1000,
|
||||
'exchange_rate' => null,
|
||||
],
|
||||
[
|
||||
'code' => 'KRW', 'name' => ['ko' => '원'], 'symbol' => '₩',
|
||||
'is_default' => false, 'decimal_places' => 0, 'base_unit' => 1,
|
||||
'exchange_rate' => 950, 'rounding_unit' => '1', 'rounding_method' => 'floor',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
app(EcommerceSettingsService::class)->setSetting('language_currency', $languageCurrency);
|
||||
config(['g7_settings.modules.sirsoft-ecommerce.language_currency' => $languageCurrency]);
|
||||
SearchProductsListener::clearCurrencySettingsCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 검색 결과의 가격 표기가 기본 통화를 따라야 한다.
|
||||
*
|
||||
* 회귀: `formatCurrencyPrice($price, 'KRW')` 로 통화 코드가 못 박혀 있어,
|
||||
* JPY 기준 상품가에 원화 접미(원)가 붙었다. 이 문자열은 검색 결과 레이아웃이
|
||||
* 표시 통화 환산값이 없을 때 쓰는 폴백이라 그대로 화면에 나간다.
|
||||
*/
|
||||
public function test_search_result_price_uses_base_currency(): void
|
||||
{
|
||||
$this->setJpyBase();
|
||||
Gate::before(fn () => true);
|
||||
|
||||
$product = ProductFactory::new()->create([
|
||||
'selling_price' => 10000,
|
||||
'list_price' => 12000,
|
||||
]);
|
||||
|
||||
$productService = $this->createMock(ProductService::class);
|
||||
$productService->method('searchByKeyword')->willReturn(
|
||||
new BoundedPage([$product], 1, 10, 1, TotalRelation::Exact, null, false)
|
||||
);
|
||||
$productService->method('countByKeyword')->willReturn(
|
||||
new BoundedCount(1, TotalRelation::Exact, null)
|
||||
);
|
||||
|
||||
$listener = new SearchProductsListener($productService);
|
||||
$results = $listener->searchProducts([], ['q' => '테스트', 'type' => 'products']);
|
||||
|
||||
$item = $results['products']['items'][0];
|
||||
|
||||
$this->assertStringContainsString('¥', $item['selling_price_formatted']);
|
||||
$this->assertStringNotContainsString('원', $item['selling_price_formatted']);
|
||||
$this->assertStringContainsString('¥', $item['list_price_formatted']);
|
||||
$this->assertStringNotContainsString('원', $item['list_price_formatted']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 신규주문 알림의 금액이 결제 통화 포맷을 따라야 한다.
|
||||
*
|
||||
* 회귀: `number_format(...).'원'` 으로 원화가 못 박혀 있었다. 같은 리스너의
|
||||
* 구매자 알림은 `formatOrderChargeAmount()` 를 거치므로 관리자 경로만 어긋났다.
|
||||
*/
|
||||
public function test_new_order_admin_amount_uses_order_currency(): void
|
||||
{
|
||||
$this->setJpyBase();
|
||||
|
||||
$order = new Order([
|
||||
'total_amount' => 10000,
|
||||
'total_paid_amount' => 10000,
|
||||
]);
|
||||
$order->currency_snapshot = ['base_currency' => 'JPY', 'order_currency' => 'JPY'];
|
||||
|
||||
$listener = app(EcommerceNotificationDataListener::class);
|
||||
$result = $listener->extractData(
|
||||
['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => []],
|
||||
'new_order_admin',
|
||||
[$order],
|
||||
);
|
||||
|
||||
$this->assertStringContainsString('¥', $result['data']['total_amount']);
|
||||
$this->assertStringNotContainsString('원', $result['data']['total_amount']);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -708,7 +708,7 @@ class OrderActivityLogListenerTest extends ModuleTestCase
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($log);
|
||||
$this->assertEquals(5000.0, $log->description_params['amount']);
|
||||
$this->assertSame(ecommerce_format_price(5000.0), $log->description_params['amount']);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -784,7 +784,7 @@ class OrderActivityLogListenerTest extends ModuleTestCase
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($log);
|
||||
$this->assertEquals(3000.0, $log->description_params['amount']);
|
||||
$this->assertSame(ecommerce_format_price(3000.0), $log->description_params['amount']);
|
||||
}
|
||||
|
||||
public function test_handleMileageEarn_logs_activity(): void
|
||||
@@ -798,7 +798,7 @@ class OrderActivityLogListenerTest extends ModuleTestCase
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($log);
|
||||
$this->assertEquals(1500.0, $log->description_params['amount']);
|
||||
$this->assertSame(ecommerce_format_price(1500.0), $log->description_params['amount']);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Providers;
|
||||
|
||||
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
||||
use App\Extension\ExtensionMiddlewareRegistry;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Middleware\VerifyGuestOrderToken;
|
||||
use Modules\Sirsoft\Ecommerce\Module;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* VerifyGuestOrderToken 이 비회원 주문 액션 라우트 전체에 부착되는지 검증.
|
||||
*
|
||||
* 회귀 배경: targets 에 라우트명을 개별 나열하고 있었는데 현금영수증 라우트 2건
|
||||
* (`guest.orders.cash-receipt.show` / `.issue`) 이 추가될 때 targets 가 갱신되지 않아
|
||||
* 미들웨어가 부착되지 않았다. 그 결과 `$request->attributes->set('guest_order', ...)` 가
|
||||
* 실행되지 않아 FormRequest 가 404 를 던지고 비회원 현금영수증 기능이 전면 불능이었다.
|
||||
*
|
||||
* 이 테스트의 핵심은 `test_all_guest_order_action_routes_are_token_protected` 다 —
|
||||
* 라우트 테이블에서 대상을 파생시키므로, 앞으로 `guest/orders/` 밑에 라우트를 추가하고
|
||||
* targets 를 갱신하지 않으면 손대지 않아도 자동으로 red 가 된다. 개별 케이스 나열은
|
||||
* 이번 결함을 재현하지 못한다(나열한 것만 검사하므로 누락된 라우트를 영원히 못 본다).
|
||||
*/
|
||||
class GuestOrderTokenMiddlewareRegistrationTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 비회원 주문 라우트명 공통 접두사 (모듈 로더가 붙이는 `api.modules.{id}.` 포함).
|
||||
*/
|
||||
private const GUEST_ORDER_PREFIX = 'api.modules.sirsoft-ecommerce.guest.orders.';
|
||||
|
||||
/**
|
||||
* 토큰 미들웨어에서 의도적으로 제외되는 라우트.
|
||||
*
|
||||
* `guest.orders.verify` 는 조회 토큰을 **발급**하는 엔드포인트다. 즉 호출 시점에
|
||||
* 아직 토큰이 없으므로 토큰 검증 미들웨어를 붙이면 최초 인증 요청이 404 로 막혀
|
||||
* 비회원 주문 조회가 통째로 죽는다. 요청 빈도 제한은 `throttle:20,1` 과
|
||||
* `GuestOrderAuthService` 의 실패 잠금이 담당한다.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const EXEMPT = [
|
||||
self::GUEST_ORDER_PREFIX.'verify',
|
||||
];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 인덱스가 태그 캐시라 케이스 간 오염을 막으려면 앞뒤로 비운다.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현금영수증 조회·발급 라우트에 토큰 미들웨어가 부착되는지 (직접 회귀 테스트).
|
||||
*/
|
||||
public function test_gate_resolves_token_middleware_for_cash_receipt_routes(): void
|
||||
{
|
||||
$registry = $this->app->make(ExtensionMiddlewareRegistryInterface::class);
|
||||
|
||||
foreach (['cash-receipt.show', 'cash-receipt.issue'] as $suffix) {
|
||||
$routeName = self::GUEST_ORDER_PREFIX.$suffix;
|
||||
|
||||
$matched = $registry->resolveForRoute(
|
||||
$routeName,
|
||||
'api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/cash-receipt',
|
||||
'api',
|
||||
'after_core',
|
||||
);
|
||||
|
||||
$this->assertContains(
|
||||
VerifyGuestOrderToken::class,
|
||||
$matched,
|
||||
"{$routeName} 에 VerifyGuestOrderToken 이 부착되어야 합니다. 미부착이면 guest_order 속성이 "
|
||||
.'설정되지 않아 FormRequest 가 404 를 던지고 현금영수증 기능이 전면 불능이 됩니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비회원 주문 라우트 전수가 `보호 ∪ 명시적 면제` 로 완전 분류되는지.
|
||||
*
|
||||
* 라우트 테이블에서 모집단을 파생시키므로 새 라우트가 늘면 자동으로 검사 대상이 된다.
|
||||
*/
|
||||
public function test_all_guest_order_action_routes_are_token_protected(): void
|
||||
{
|
||||
$registry = $this->app->make(ExtensionMiddlewareRegistryInterface::class);
|
||||
|
||||
$guestRoutes = [];
|
||||
foreach (Route::getRoutes() as $route) {
|
||||
$name = $route->getName();
|
||||
if (is_string($name) && str_starts_with($name, self::GUEST_ORDER_PREFIX)) {
|
||||
$guestRoutes[$name] = $route->uri();
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotEmpty(
|
||||
$guestRoutes,
|
||||
'비회원 주문 라우트가 하나도 수집되지 않았습니다. 접두사('.self::GUEST_ORDER_PREFIX
|
||||
.')가 바뀌었다면 이 테스트는 아무것도 검사하지 못하는 상태이므로 접두사를 함께 고쳐야 합니다.',
|
||||
);
|
||||
|
||||
$unprotected = [];
|
||||
foreach ($guestRoutes as $name => $uri) {
|
||||
if (in_array($name, self::EXEMPT, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matched = $registry->resolveForRoute($name, $uri, 'api', 'after_core');
|
||||
|
||||
if (! in_array(VerifyGuestOrderToken::class, $matched, true)) {
|
||||
$unprotected[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$unprotected,
|
||||
'토큰 미검증 상태로 열려 있는 비회원 주문 라우트가 있습니다: '.implode(', ', $unprotected)
|
||||
.'. Module::getMiddleware() 의 VerifyGuestOrderToken targets 를 갱신하거나, '
|
||||
.'보호하면 안 되는 라우트라면 이 테스트의 EXEMPT 에 사유와 함께 등재하십시오.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 발급 라우트(`verify`)는 미부착이어야 한다.
|
||||
*
|
||||
* targets 를 `guest.orders.*` 로 뭉뚱그리면 이 라우트까지 삼켜 비회원 조회가 통째로 막힌다.
|
||||
*/
|
||||
public function test_verify_route_is_not_token_protected(): void
|
||||
{
|
||||
$registry = $this->app->make(ExtensionMiddlewareRegistryInterface::class);
|
||||
|
||||
$matched = $registry->resolveForRoute(
|
||||
self::GUEST_ORDER_PREFIX.'verify',
|
||||
'api/modules/sirsoft-ecommerce/guest/orders/verify',
|
||||
'api',
|
||||
'after_core',
|
||||
);
|
||||
|
||||
$this->assertNotContains(
|
||||
VerifyGuestOrderToken::class,
|
||||
$matched,
|
||||
'guest.orders.verify 는 토큰을 발급하는 엔드포인트라 토큰 검증 미들웨어가 붙으면 '
|
||||
.'최초 인증 요청이 404 로 막힙니다. targets 를 guest.orders.* 로 넓히지 마십시오.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 선언 계약: api 그룹 전용.
|
||||
*/
|
||||
public function test_module_declares_token_middleware_for_api_group_only(): void
|
||||
{
|
||||
$declaration = collect((new Module)->getMiddleware())
|
||||
->firstWhere('class', VerifyGuestOrderToken::class);
|
||||
|
||||
$this->assertNotNull(
|
||||
$declaration,
|
||||
'Module::getMiddleware() 가 VerifyGuestOrderToken 을 선언해야 합니다.',
|
||||
);
|
||||
$this->assertSame(['api'], $declaration['groups']);
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Repositories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection as SupportCollection;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductAdditionalOption;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductAdditionalOptionValue;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductAdditionalOptionValueRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 추가옵션 선택지 저장소의 컬렉션 타입 계약 테스트.
|
||||
*
|
||||
* 회귀 배경: `getActiveByProductIdsKeyed()` 는 `id => Collection` 중첩 맵을 만드는데
|
||||
* 반환 타입 선언이 `Eloquent\Collection` 이었다. `Eloquent\Collection::map()` 은 결과 항목 중
|
||||
* 하나라도 Model 이 아니면 `toBase()` 로 강등하므로(모델 컬렉션이라는 불변식을 지키는 의도된
|
||||
* 방어) 이 메서드는 **항상** Support 를 반환했고, 추가옵션 선택지가 하나라도 있는 상품의
|
||||
* 장바구니 담기·바로구매가 TypeError 로 500 이 됐다. 선택지가 없는 상품은 `groupBy` 결과가
|
||||
* 비어 `map()` 이 강등하지 않아 통과했기 때문에 일부 상품에서만 죽는 형태로 나타났다.
|
||||
*
|
||||
* 계약 요약: **평면 `id => Model` 맵은 Eloquent, 중첩 맵은 Support.**
|
||||
*/
|
||||
class ProductAdditionalOptionValueRepositoryTest extends ModuleTestCase
|
||||
{
|
||||
protected ProductAdditionalOptionValueRepositoryInterface $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->repository = app(ProductAdditionalOptionValueRepositoryInterface::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가옵션 그룹 + 선택지를 생성합니다.
|
||||
*
|
||||
* @param Product $product 상품
|
||||
* @param bool $isActive 활성 여부
|
||||
* @return ProductAdditionalOptionValue 생성된 선택지
|
||||
*/
|
||||
protected function createValue(Product $product, bool $isActive = true): ProductAdditionalOptionValue
|
||||
{
|
||||
$group = ProductAdditionalOption::create([
|
||||
'product_id' => $product->id,
|
||||
'name' => ['ko' => '각인', 'en' => 'Engraving'],
|
||||
'is_required' => false,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
return ProductAdditionalOptionValue::create([
|
||||
'additional_option_id' => $group->id,
|
||||
'name' => ['ko' => '추가', 'en' => 'Add'],
|
||||
'price_adjustment' => 1000,
|
||||
'is_default' => false,
|
||||
'is_active' => $isActive,
|
||||
'allow_custom_text' => false,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품을 생성합니다.
|
||||
*
|
||||
* @return Product 생성된 상품
|
||||
*/
|
||||
protected function createProduct(): Product
|
||||
{
|
||||
return ProductFactory::new()->create(['selling_price' => 10000, 'list_price' => 10000]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩 맵의 외곽은 Support, 각 그룹은 Eloquent 여야 한다.
|
||||
*
|
||||
* 수정 전에는 첫 단언에 닿기 전에 반환 타입 TypeError 로 죽는다.
|
||||
*/
|
||||
public function test_batch_keyed_returns_support_collection_of_eloquent_groups(): void
|
||||
{
|
||||
$productA = $this->createProduct();
|
||||
$productB = $this->createProduct();
|
||||
|
||||
$valueA1 = $this->createValue($productA);
|
||||
$valueA2 = $this->createValue($productA);
|
||||
$valueB1 = $this->createValue($productB);
|
||||
|
||||
$result = $this->repository->getActiveByProductIdsKeyed([$productA->id, $productB->id]);
|
||||
|
||||
$this->assertInstanceOf(SupportCollection::class, $result);
|
||||
$this->assertNotInstanceOf(
|
||||
EloquentCollection::class,
|
||||
$result,
|
||||
'중첩 맵의 항목은 Model 이 아니므로 Eloquent\Collection 계약(load/modelKeys/fresh 가 '
|
||||
.'instanceof Model 을 전제)을 만족할 수 없습니다. 외곽은 Support 여야 합니다.',
|
||||
);
|
||||
|
||||
$groupA = $result->get($productA->id);
|
||||
$this->assertInstanceOf(EloquentCollection::class, $groupA);
|
||||
$this->assertTrue($groupA->has($valueA1->id));
|
||||
$this->assertTrue($groupA->has($valueA2->id));
|
||||
|
||||
$groupB = $result->get($productB->id);
|
||||
$this->assertInstanceOf(EloquentCollection::class, $groupB);
|
||||
$this->assertTrue($groupB->has($valueB1->id));
|
||||
$this->assertFalse($groupB->has($valueA1->id), '상품 경계를 넘어 섞이면 안 됩니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 입력의 early return 분기도 같은 타입이어야 한다.
|
||||
*
|
||||
* 이 분기를 고치지 않으면 정정이 반쪽이 된다 — 빈 입력에서만 Eloquent 를 돌려주는
|
||||
* 분기별 타입 불일치가 남는다.
|
||||
*/
|
||||
public function test_batch_keyed_returns_empty_support_collection_for_empty_input(): void
|
||||
{
|
||||
// [0] 은 array_filter 로 걸러져 결과적으로 빈 배열이 되는 입력이다.
|
||||
foreach ([[], [0]] as $input) {
|
||||
$result = $this->repository->getActiveByProductIdsKeyed($input);
|
||||
|
||||
$this->assertInstanceOf(SupportCollection::class, $result);
|
||||
$this->assertNotInstanceOf(EloquentCollection::class, $result);
|
||||
$this->assertTrue($result->isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 상품 조회는 진짜 모델 컬렉션이므로 Eloquent 가 정당하다.
|
||||
*/
|
||||
public function test_single_product_keyed_returns_eloquent_collection_keyed_by_value_id(): void
|
||||
{
|
||||
$product = $this->createProduct();
|
||||
$value = $this->createValue($product);
|
||||
|
||||
$result = $this->repository->getActiveByProductKeyed($product->id);
|
||||
|
||||
$this->assertInstanceOf(EloquentCollection::class, $result);
|
||||
$this->assertInstanceOf(ProductAdditionalOptionValue::class, $result->get($value->id));
|
||||
$this->assertTrue(
|
||||
$result->get($value->id)->relationLoaded('additionalOption'),
|
||||
'additionalOption 이 eager load 되어야 소속 검증에서 N+1 이 나지 않습니다.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택지가 없는 상품은 위임의 `?? new Collection` 분기를 탄다.
|
||||
*/
|
||||
public function test_single_product_keyed_returns_empty_eloquent_collection_when_no_values(): void
|
||||
{
|
||||
$product = $this->createProduct();
|
||||
|
||||
$result = $this->repository->getActiveByProductKeyed($product->id);
|
||||
|
||||
$this->assertInstanceOf(EloquentCollection::class, $result);
|
||||
$this->assertTrue($result->isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 비활성 선택지는 제외된다 — 타입 리팩터가 필터를 조용히 날리지 못하게 하는 동작 가드.
|
||||
*/
|
||||
public function test_batch_keyed_excludes_inactive_values(): void
|
||||
{
|
||||
$product = $this->createProduct();
|
||||
$active = $this->createValue($product);
|
||||
$inactive = $this->createValue($product, isActive: false);
|
||||
|
||||
$group = $this->repository->getActiveByProductIdsKeyed([$product->id])->get($product->id);
|
||||
|
||||
$this->assertTrue($group->has($active->id));
|
||||
$this->assertFalse($group->has($inactive->id));
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\CartItemResource;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\PublicProductResource;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Cart;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductAdditionalOption;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductAdditionalOptionValue;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 추가옵션 추가금의 다통화 노출 회귀 테스트
|
||||
*
|
||||
* 상품가·옵션가는 `multi_currency_*` 맵으로 통화별 환산값을 함께 내보내는데,
|
||||
* 추가옵션 추가금만 기본통화 단일 값으로 나갔다. 그래서 기본통화가 아닌 통화로
|
||||
* 보는 구매자에게 추가금만 기준통화 표기(예: `+¥3,000`)로 남고, 화면이 그 값을
|
||||
* 환산된 상품가에 그대로 더해 통화가 섞인 합계가 표시됐다.
|
||||
*
|
||||
* 기본통화가 KRW 일 때만 우연히 맞았으므로, 이 테스트는 기본통화를 KRW 가 아닌
|
||||
* 통화(JPY)로 두고 검증한다.
|
||||
*/
|
||||
class AdditionalOptionMultiCurrencyTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 기본통화 JPY + KRW 환율을 설정합니다.
|
||||
*
|
||||
* base_unit 1000 / exchange_rate 950 → 1,000 JPY = 950 KRW.
|
||||
*/
|
||||
private function setJpyBaseWithKrw(): void
|
||||
{
|
||||
$languageCurrency = [
|
||||
'default_currency' => 'JPY',
|
||||
'currencies' => [
|
||||
[
|
||||
'code' => 'JPY', 'name' => ['ja' => '円'], 'symbol' => '¥',
|
||||
'is_default' => true, 'decimal_places' => 0, 'base_unit' => 1000,
|
||||
'exchange_rate' => null,
|
||||
],
|
||||
[
|
||||
'code' => 'KRW', 'name' => ['ko' => '원'], 'symbol' => '₩',
|
||||
'is_default' => false, 'decimal_places' => 0, 'base_unit' => 1,
|
||||
'exchange_rate' => 950, 'rounding_unit' => '1', 'rounding_method' => 'floor',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
app(EcommerceSettingsService::class)->setSetting('language_currency', $languageCurrency);
|
||||
config(['g7_settings.modules.sirsoft-ecommerce.language_currency' => $languageCurrency]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가옵션 그룹 1개 + 선택지 1개를 가진 상품을 만듭니다.
|
||||
*
|
||||
* @param int $priceAdjustment 선택지 추가금 (기본통화 기준)
|
||||
* @return array{product: Product, value: ProductAdditionalOptionValue}
|
||||
*/
|
||||
private function createProductWithAdditionalOption(int $priceAdjustment): array
|
||||
{
|
||||
$product = ProductFactory::new()->create([
|
||||
'selling_price' => 10000,
|
||||
'list_price' => 10000,
|
||||
]);
|
||||
|
||||
$group = ProductAdditionalOption::create([
|
||||
'product_id' => $product->id,
|
||||
'name' => ['ko' => '포장 방식', 'en' => 'Packaging'],
|
||||
'is_required' => false,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$value = ProductAdditionalOptionValue::create([
|
||||
'additional_option_id' => $group->id,
|
||||
'name' => ['ko' => '선물 포장', 'en' => 'Gift Box'],
|
||||
'price_adjustment' => $priceAdjustment,
|
||||
'is_default' => false,
|
||||
'is_active' => true,
|
||||
'allow_custom_text' => false,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
return ['product' => $product, 'value' => $value];
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 상세 응답의 추가옵션 선택지가 통화별 추가금을 함께 내보낸다.
|
||||
*
|
||||
* 화면의 선택지 라벨과 합계가 표시통화를 따르려면 서버가 환산값을 줘야 한다.
|
||||
* 수정 전에는 `price_adjustment`(기본통화) 하나뿐이라 화면이 환산할 근거가 없었다.
|
||||
*/
|
||||
public function test_public_product_resource_exposes_multi_currency_price_adjustment(): void
|
||||
{
|
||||
$this->setJpyBaseWithKrw();
|
||||
$data = $this->createProductWithAdditionalOption(3000);
|
||||
|
||||
$product = Product::with('additionalOptions.values')->find($data['product']->id);
|
||||
$array = (new PublicProductResource($product))->toArray(Request::create('/'));
|
||||
|
||||
$value = $array['additional_options'][0]['values'][0];
|
||||
|
||||
$this->assertArrayHasKey(
|
||||
'multi_currency_price_adjustment',
|
||||
$value,
|
||||
'추가옵션 선택지도 상품가·옵션가와 같이 통화별 추가금을 내보내야 한다'
|
||||
);
|
||||
|
||||
$map = $value['multi_currency_price_adjustment'];
|
||||
|
||||
// 기본통화 JPY: 원값 그대로
|
||||
$this->assertSame(3000, (int) $map['JPY']['price']);
|
||||
$this->assertTrue($map['JPY']['is_default']);
|
||||
|
||||
// KRW: 3,000 / base_unit 1000 × 950 = 2,850
|
||||
$this->assertSame(2850, (int) $map['KRW']['price']);
|
||||
$this->assertFalse($map['KRW']['is_default']);
|
||||
|
||||
// 부호가 붙은 표시 문자열도 통화별로 제공된다
|
||||
$this->assertStringStartsWith('+', $map['KRW']['formatted']);
|
||||
$this->assertStringContainsString('2,850', $map['KRW']['formatted']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 장바구니 아이템의 추가옵션도 통화별 추가금을 함께 내보낸다.
|
||||
*
|
||||
* 장바구니 줄은 상품가·소계를 표시통화로 보여주면서 추가금 라벨만 기준통화로
|
||||
* 남아 있었다. 같은 줄 안에서 통화가 섞이면 구매자가 금액을 검산할 수 없다.
|
||||
*/
|
||||
public function test_cart_item_resource_exposes_multi_currency_price_adjustment(): void
|
||||
{
|
||||
$this->setJpyBaseWithKrw();
|
||||
$data = $this->createProductWithAdditionalOption(5000);
|
||||
$option = ProductOptionFactory::new()->forProduct($data['product'])->create([
|
||||
'stock_quantity' => 10,
|
||||
'price_adjustment' => 0,
|
||||
]);
|
||||
|
||||
$cart = Cart::create([
|
||||
'cart_key' => 'ck_'.str_repeat('m', 32),
|
||||
'user_id' => null,
|
||||
'product_id' => $data['product']->id,
|
||||
'product_option_id' => $option->id,
|
||||
'quantity' => 1,
|
||||
'additional_option_selections' => [[
|
||||
'additional_option_id' => $data['value']->additional_option_id,
|
||||
'value_id' => $data['value']->id,
|
||||
]],
|
||||
]);
|
||||
|
||||
$cart->load(Cart::displayRelations());
|
||||
$array = (new CartItemResource($cart))->toArray(Request::create('/'));
|
||||
|
||||
$addl = $array['additional_options'][0];
|
||||
|
||||
$this->assertArrayHasKey(
|
||||
'multi_currency_price_adjustment',
|
||||
$addl,
|
||||
'장바구니 줄의 추가옵션도 통화별 추가금을 내보내야 한다'
|
||||
);
|
||||
|
||||
// 5,000 / 1000 × 950 = 4,750
|
||||
$this->assertSame(4750, (int) $addl['multi_currency_price_adjustment']['KRW']['price']);
|
||||
$this->assertSame(5000, (int) $addl['multi_currency_price_adjustment']['JPY']['price']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 음수 추가금(할인)도 통화별로 환산되고 부호가 유지된다.
|
||||
*/
|
||||
public function test_negative_price_adjustment_keeps_sign_across_currencies(): void
|
||||
{
|
||||
$this->setJpyBaseWithKrw();
|
||||
$data = $this->createProductWithAdditionalOption(-2000);
|
||||
|
||||
$product = Product::with('additionalOptions.values')->find($data['product']->id);
|
||||
$array = (new PublicProductResource($product))->toArray(Request::create('/'));
|
||||
|
||||
$map = $array['additional_options'][0]['values'][0]['multi_currency_price_adjustment'];
|
||||
|
||||
// 부호는 표시 문자열에 있고, price 는 절대값이 아닌 부호 있는 값이다
|
||||
$this->assertSame(-2000, (int) $map['JPY']['price']);
|
||||
$this->assertSame(-1900, (int) $map['KRW']['price']);
|
||||
$this->assertStringStartsWith('-', $map['KRW']['formatted']);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user