feat(ecommerce): 마일리지 적립 절사 기준 신설 및 주문 시점 스냅샷
적립 포인트 절사가 코드에 1점 버림으로 고정돼 있어 운영자가 조정할 수 없었다. 언어/통화 설정의 환율 절사를 따라가는 방안을 먼저 검토했으나, 그쪽은 외화 환산 분기에서만 적용되어 기본 통화 상점에는 따라갈 값 자체가 없다. 적립은 기본 통화 원장에 정수로 확정 기록되는 값이라 성격이 다르므로 마일리지 통화별 규칙에 신설했다. - earn_rounding_unit(1/10/100) + earn_rounding_method(floor/round/ceil) - 기본값은 종전 하드코딩과 정확히 같은 금액을 산출 — 기존 쇼핑몰 적립액 무변화 - 주문 계산·옵션 분할·부분취소 안분 전 경로에 동일 기준 적용 - 주문 시점 값을 스냅샷으로 보관 — 이후 기준을 바꿔도 과거 주문이 소급 변경되지 않는다 - 설정 UI·검증·다국어(ko/en/ja)·API 문서 동반, 기존 사이트는 업그레이드 스텝이 명시 기입 함께 처리: 마일리지 내역 목록 테스트가 첫 컬럼 field 를 `no` 로 단언해 실패하던 것을 현재 레이아웃 값(`number`)에 맞춰 정정했다. 이 브랜치와 무관한 stale 케이스다.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
|
||||
- 무통장입금 자동취소 기한을 비운 채 저장할 때 나오는 안내 문구 일본어 번역 추가.
|
||||
- 유효기간이 설정되지 않은 쿠폰을 발급할 때 나오는 안내 문구 일본어 번역 추가.
|
||||
- 마일리지 설정의 통화별 적립 절사 기준(단위·방식) 항목명과 선택지, 허용값 위반 안내 문구 일본어 번역 추가.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -1296,6 +1296,8 @@ return [
|
||||
'mileage.currency_rules.*.use_unit' => '使用単位',
|
||||
'mileage.currency_rules.*.max_use_percent' => '最大使用率',
|
||||
'mileage.currency_rules.*.max_use_value' => '最大使用金額',
|
||||
'mileage.currency_rules.*.earn_rounding_unit' => '積立端数処理単位',
|
||||
'mileage.currency_rules.*.earn_rounding_method' => '積立端数処理方式',
|
||||
'mileage.expiry_days' => '有効期限',
|
||||
'mileage.expiry_notification_days_before' => '失効予定通知日',
|
||||
'country_settings' => '国別設定',
|
||||
@@ -1714,6 +1716,12 @@ return [
|
||||
'numeric' => '1ポイント当たりの金額は数字である必要があります。',
|
||||
'min' => '1ポイント当たりの金額は0より大きい必要があります。',
|
||||
],
|
||||
'earn_rounding_unit' => [
|
||||
'in' => '積立端数処理単位は1、10、100のいずれかである必要があります。',
|
||||
],
|
||||
'earn_rounding_method' => [
|
||||
'in' => '積立端数処理方式はfloor、round、ceilのいずれかである必要があります。',
|
||||
],
|
||||
'max_use_value' => [
|
||||
'integer' => '最大使用金額は整数である必要があります。',
|
||||
'min' => '最大使用金額は0以上である必要があります。',
|
||||
|
||||
+13
-1
@@ -39,7 +39,19 @@
|
||||
"empty": "登録された通貨ルールはありません。",
|
||||
"code_placeholder": "例: USD",
|
||||
"duplicate_currency_error": "すでに登録されている通貨です。",
|
||||
"max_use_fixed_suffix": "ウォン"
|
||||
"max_use_fixed_suffix": "ウォン",
|
||||
"earn_rounding": "積立端数処理",
|
||||
"earn_rounding_hint": "積立ポイントの算出・按分時に適用",
|
||||
"earn_rounding_units": {
|
||||
"1": "1ポイント",
|
||||
"10": "10ポイント",
|
||||
"100": "100ポイント"
|
||||
},
|
||||
"earn_rounding_methods": {
|
||||
"floor": "切り捨て",
|
||||
"round": "四捨五入",
|
||||
"ceil": "切り上げ"
|
||||
}
|
||||
},
|
||||
"expiry": {
|
||||
"title": "有効期限",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
- 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
|
||||
- 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다.
|
||||
- 이커머스 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시).
|
||||
- 마일리지 설정의 통화별 규칙에 적립 절사 기준(1점 / 10점 / 100점 단위 × 버림 / 반올림 / 올림)을 추가했습니다. 적립 포인트를 계산할 때와 부분취소로 주문이 나뉠 때 모두 이 기준이 적용됩니다. 기존 쇼핑몰은 지금까지와 같은 "1점 단위 버림"으로 시작하므로 적립액이 달라지지 않습니다. 이 기준은 주문 시점의 값이 함께 보관되어, 이후 기준을 바꿔도 과거 주문의 적립액이 소급해서 바뀌지 않습니다. 언어/통화 설정의 환율 절사와는 별개 항목입니다(그쪽은 외화 표시 환산에만 적용됩니다).
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -303,7 +303,9 @@
|
||||
"use_unit": 10,
|
||||
"max_use_type": "fixed",
|
||||
"max_use_percent": 30,
|
||||
"max_use_value": 50000
|
||||
"max_use_value": 50000,
|
||||
"earn_rounding_unit": "1",
|
||||
"earn_rounding_method": "floor"
|
||||
}
|
||||
],
|
||||
"expiry_enabled": true,
|
||||
|
||||
@@ -497,7 +497,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) |
|
||||
| cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) |
|
||||
| promotions_applied_snapshot | object | `{"coupon_issue_ids":[7330],"item_coupons":[],"discount_co…` | 적용된 프로모션 스냅샷 (재계산용) |
|
||||
| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) |
|
||||
| shipping_policy_applied_snapshot | object | `{"items": [], "address": {}}` | 적용된 배송정책 스냅샷 (재계산용). `items` 는 옵션별 적용 정책 목록(각 항목: `product_option_id`, `policy`), `address` 는 주문 시점 배송지 메타(`country_code`, `zipcode`). 항목이 없어도 `items` 는 빈 배열이다 |
|
||||
| admin_memo | null | `null` | 관리자 메모 (내부 관리용) |
|
||||
| customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) |
|
||||
| created_at | string | `2026-06-17T02:07:25+00:00` | 생성 일시 |
|
||||
@@ -1523,7 +1523,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) |
|
||||
| cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) |
|
||||
| promotions_applied_snapshot | null | `null` | 적용된 프로모션 스냅샷 (재계산용) |
|
||||
| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) |
|
||||
| shipping_policy_applied_snapshot | object | `{"items": [], "address": {}}` | 적용된 배송정책 스냅샷 (재계산용). `items` 는 옵션별 적용 정책 목록(각 항목: `product_option_id`, `policy`), `address` 는 주문 시점 배송지 메타(`country_code`, `zipcode`). 항목이 없어도 `items` 는 빈 배열이다 |
|
||||
| admin_memo | null | `null` | 관리자 메모 (내부 관리용) |
|
||||
| customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) |
|
||||
| created_at | string | `2026-07-08T01:44:49+00:00` | 생성 일시 |
|
||||
@@ -2206,7 +2206,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) |
|
||||
| cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) |
|
||||
| promotions_applied_snapshot | null | `null` | 적용된 프로모션 스냅샷 (재계산용) |
|
||||
| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) |
|
||||
| shipping_policy_applied_snapshot | object | `{"items": [], "address": {}}` | 적용된 배송정책 스냅샷 (재계산용). `items` 는 옵션별 적용 정책 목록(각 항목: `product_option_id`, `policy`), `address` 는 주문 시점 배송지 메타(`country_code`, `zipcode`). 항목이 없어도 `items` 는 빈 배열이다 |
|
||||
| admin_memo | null | `null` | 관리자 메모 (내부 관리용) |
|
||||
| customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) |
|
||||
| created_at | string | `2026-07-08T01:44:49+00:00` | 생성 일시 |
|
||||
|
||||
@@ -864,7 +864,9 @@ HTTP/1.1 200
|
||||
"use_unit": 10,
|
||||
"max_use_type": "fixed",
|
||||
"max_use_percent": 30,
|
||||
"max_use_value": 50000
|
||||
"max_use_value": 50000,
|
||||
"earn_rounding_unit": "1",
|
||||
"earn_rounding_method": "floor"
|
||||
}
|
||||
],
|
||||
"expiry_enabled": true,
|
||||
@@ -1905,7 +1907,9 @@ HTTP/1.1 200
|
||||
"use_unit": 10,
|
||||
"max_use_type": "fixed",
|
||||
"max_use_percent": 30,
|
||||
"max_use_value": 50000
|
||||
"max_use_value": 50000,
|
||||
"earn_rounding_unit": "1",
|
||||
"earn_rounding_method": "floor"
|
||||
}
|
||||
],
|
||||
"expiry_enabled": true,
|
||||
@@ -2056,6 +2060,19 @@ HTTP/1.1 200
|
||||
- 정규화 제외: 비숫자 문자열(`"abc"`), 빈 문자열, `null`, 불리언, 그리고 `integer` 필드에 전달된 소수 문자열(`"3.7"`) — 검증을 느슨하게 만들지 않기 위해 캐스트하지 않고 그대로 검증 실패시킵니다
|
||||
- 조회(`GET`) 응답도 `defaults.json` 스키마의 숫자 타입으로 정규화되어 반환되므로, 저장 시점의 표현 형태와 무관하게 숫자로 수신됩니다
|
||||
|
||||
**마일리지 적립 절사 기준** `mileage.currency_rules.*` 의 두 필드가 적립 포인트 산출·안분의 절사 기준을 정합니다.
|
||||
|
||||
| 필드 | 타입 | 허용값 | 기본값 | 설명 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `earn_rounding_unit` | string | `1` · `10` · `100` | `1` | 적립 포인트를 맞출 단위(점). 마일리지는 원장에 정수로 확정되므로 소수 단위는 허용하지 않습니다 |
|
||||
| `earn_rounding_method` | string | `floor` · `round` · `ceil` | `floor` | 단위에 맞출 방식(버림 / 반올림 / 올림) |
|
||||
|
||||
- 적용 지점: 옵션 정액 적립 · 옵션 정률 적립 · 기본 적립률 세 갈래 전부, 그리고 부분취소로 주문옵션이 분할될 때의 적립액 안분
|
||||
- 기준 통화 선택: 마일리지는 표시 통화가 아니라 기준 통화(`currency_snapshot.base_currency`)로 적립·정산되므로, 그 통화의 규칙을 사용하고 없으면 첫 규칙(기본 통화)으로 폴백합니다
|
||||
- 주문 시점 고정: 주문 생성 시 `mileage_policy_snapshot.rule` 에 함께 기록되며, 부분취소·추가결제 재계산은 현재 설정이 아니라 이 스냅샷을 사용합니다. 그렇지 않으면 이후 설정 변경이 과거 주문에 소급돼, 취소하지 않은 잔여분의 적립액이 취소 처리만으로 달라집니다
|
||||
- 값이 없는 경우(이 필드 도입 이전 설치본·주문): 기본값 `1` / `floor` 로 해석되며 이는 도입 이전 동작과 동일한 금액을 산출합니다
|
||||
- 통화 환산 절사(`language_currency.currencies.*.rounding_unit` / `rounding_method`)와는 별개입니다 — 그쪽은 외화 표시 환산에만 적용되어 기본 통화에는 적용되지 않으므로 적립 규칙으로 쓸 수 없습니다
|
||||
|
||||
### PUT /api/modules/sirsoft-ecommerce/admin/settings/banks
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.admin.settings.store-banks -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.admin.settings.store-banks`
|
||||
@@ -2574,7 +2591,9 @@ HTTP/1.1 200
|
||||
"use_unit": 10,
|
||||
"max_use_type": "fixed",
|
||||
"max_use_percent": 30,
|
||||
"max_use_value": 50000
|
||||
"max_use_value": 50000,
|
||||
"earn_rounding_unit": "1",
|
||||
"earn_rounding_method": "floor"
|
||||
}
|
||||
],
|
||||
"expiry_enabled": true,
|
||||
|
||||
@@ -1361,8 +1361,20 @@
|
||||
}
|
||||
},
|
||||
"shipping_policy_applied_snapshot": {
|
||||
"policy_name": "기본 배송 정책",
|
||||
"shipping_amount": 3000
|
||||
"items": [
|
||||
{
|
||||
"product_option_id": 9001,
|
||||
"policy": {
|
||||
"policy_name": "기본 배송 정책",
|
||||
"standalone_shipping_amount": 3000,
|
||||
"standalone_shipping_amount_formatted": "3,000원"
|
||||
}
|
||||
}
|
||||
],
|
||||
"address": {
|
||||
"country_code": "KR",
|
||||
"zipcode": "06234"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+4
-2
@@ -291,9 +291,11 @@ describe('DataGrid (_transactions_table.json)', () => {
|
||||
expect(grid.props.data).toContain('transactions?.data?.data');
|
||||
});
|
||||
|
||||
it('컬럼 8종(no/일시/회원/유형/금액/잔여/유효기간/주문/부여)', () => {
|
||||
it('컬럼 9종(번호/일시/회원/유형/금액/잔여/유효기간/주문/부여)', () => {
|
||||
// 첫 컬럼 field 는 `number` 다 (표시 헤더는 "NO"). 종전 `no` 표기는 레이아웃에서
|
||||
// 이미 바뀌었는데 이 단언만 남아 있던 stale 케이스였다.
|
||||
const fields = grid.props.columns.map((c: any) => c.field);
|
||||
expect(fields).toEqual(expect.arrayContaining(['no', 'created_at', 'member', 'type', 'amount', 'remaining', 'expiry', 'order', 'granted_by']));
|
||||
expect(fields).toEqual(expect.arrayContaining(['number', 'created_at', 'member', 'type', 'amount', 'remaining', 'expiry', 'order', 'granted_by']));
|
||||
});
|
||||
|
||||
it('유효기간 컬럼이 expiry_state 기준 소멸(일부/전체)·만료예정·무기한·없음을 렌더한다', () => {
|
||||
|
||||
+68
-7
@@ -247,19 +247,19 @@ describe('통화 규칙 테이블 (_tab_mileage_currency_table.json)', () => {
|
||||
expect(percentInput.props.disabled).toContain("rule.max_use_type !== 'percent'");
|
||||
});
|
||||
|
||||
it('max_use_type 라디오는 부모 Label 클릭으로 상태를 바꾼다 (게시판 검증 패턴 — 라디오 자동바인딩 회피)', () => {
|
||||
it('max_use_type 라디오는 Label 클릭과 키보드 change 양쪽으로 같은 상태를 만든다', () => {
|
||||
// 회귀: 라디오 자동바인딩은 문자열 값을 value 바인딩으로 처리해 라디오 그룹을 깨뜨린다.
|
||||
// 게시판 date_display_format 의 검증된 패턴: 라디오는 pointer-events-none(시각 전용),
|
||||
// 부모 Label 에 click 액션을 달아 해당 행(rule._idx)의 max_use_type 을 배열 map 교체로 갱신.
|
||||
// 그래서 Label click 으로 상태를 갱신한다. 다만 라디오를 pointer-events-none 으로만 두면
|
||||
// 키보드 방향키(change)로 옮겼을 때 화면 표시와 저장될 값이 어긋난다(#493) —
|
||||
// 라디오 자체에도 click 과 **동일한 params** 를 갖는 change 액션을 둔다.
|
||||
const radios = findAll(currencyTable, (n) => n.name === 'Input' && n.props?.type === 'radio'
|
||||
&& String(n.props?.name).includes('max_use_type'));
|
||||
// currency_rules 행(2) + 인라인 추가 행(2) = 4
|
||||
expect(radios.length).toBe(4);
|
||||
for (const radio of radios) {
|
||||
// 라디오는 클릭 이벤트를 받지 않는다 (부모 Label 이 처리)
|
||||
expect(radio.props.className, `radio(${radio.props.value}) 에 pointer-events-none 누락`).toContain('pointer-events-none');
|
||||
// 라디오 자체에는 actions 가 없다 (자동바인딩/onChange 미사용)
|
||||
expect(radio.actions ?? []).toHaveLength(0);
|
||||
const change = (radio.actions ?? []).find((a: any) => a.type === 'change' && a.handler === 'setState');
|
||||
expect(change, `radio(${radio.props.value}) 에 키보드 조작용 change 액션 누락`).toBeTruthy();
|
||||
expect(change.params.target).toBe('local');
|
||||
}
|
||||
|
||||
// 부모 Label 이 click → setState 로 max_use_type 갱신
|
||||
@@ -349,6 +349,67 @@ describe('통화 규칙 모바일 카드 (_tab_mileage_currency_cards.json)', ()
|
||||
});
|
||||
});
|
||||
|
||||
describe('적립 절사 기준 (earn_rounding_unit / earn_rounding_method)', () => {
|
||||
const 절사_컨트롤 = (root: any, 접두: string) => ({
|
||||
unit: findFirst(root, (n) => n.name === 'Select' && String(n.props?.name).includes(`${접두}earn_rounding_unit`)),
|
||||
method: findFirst(root, (n) => n.name === 'Select' && String(n.props?.name).includes(`${접두}earn_rounding_method`)),
|
||||
});
|
||||
|
||||
it('테이블 행에 단위/방식 Select 가 통화 규칙 인덱스로 바인딩된다', () => {
|
||||
const { unit, method } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '.");
|
||||
|
||||
expect(unit).toBeTruthy();
|
||||
expect(method).toBeTruthy();
|
||||
// 값이 없는 기존 행도 종전 동작(1점 버림)으로 보이도록 폴백을 둔다
|
||||
expect(unit.props.value).toContain("?? '1'");
|
||||
expect(method.props.value).toContain("?? 'floor'");
|
||||
});
|
||||
|
||||
it('절사 방식 옵션은 서버 허용 어휘(floor/round/ceil)와 일치한다', () => {
|
||||
const { method } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '.");
|
||||
|
||||
// 화면 옵션이 서버 게이트를 벗어나면 저장이 422 로 막히는데 화면에는 선택지로 보인다
|
||||
expect(method.props.options.map((o: any) => o.value)).toEqual(['floor', 'round', 'ceil']);
|
||||
});
|
||||
|
||||
it('절사 단위 옵션은 정수 포인트 단위만 제공한다 (통화 환산 절사의 소수 단위와 다름)', () => {
|
||||
const { unit } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '.");
|
||||
const opts = String(unit.props.options);
|
||||
|
||||
expect(opts).toContain("value: '1'");
|
||||
expect(opts).toContain("value: '10'");
|
||||
expect(opts).toContain("value: '100'");
|
||||
// 마일리지는 원장에 정수로 확정된다 — 0.01 같은 소수 단위는 선택지가 될 수 없다
|
||||
expect(opts).not.toContain("value: '0.01'");
|
||||
});
|
||||
|
||||
it('모바일 카드 뷰에도 동일 항목이 있다 (한쪽만 있으면 모바일에서 설정 불가)', () => {
|
||||
const { unit, method } = 절사_컨트롤(currencyCards, "mileage.currency_rules.' + rule._idx + '.");
|
||||
|
||||
expect(unit).toBeTruthy();
|
||||
expect(method).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['테이블', currencyTable, 'new_mileage_currency_row'],
|
||||
['모바일 카드', currencyCards, 'new_mileage_currency_card'],
|
||||
])('%s 의 신규 통화 추가 행에도 절사 컨트롤이 있고 저장 payload 에 포함된다', (_label, root, rowId) => {
|
||||
const newRow = findById(root, rowId);
|
||||
const { unit, method } = 절사_컨트롤(newRow, 'newMileageCurrency.');
|
||||
|
||||
expect(unit).toBeTruthy();
|
||||
expect(method).toBeTruthy();
|
||||
|
||||
const seq = findFirst(newRow, (n) => n.handler === 'sequence');
|
||||
const actions = seq.actions ?? seq.params?.actions ?? [];
|
||||
const push = actions.find((a: any) => a.handler === 'setState' && a.params?.['form.mileage.currency_rules']);
|
||||
|
||||
// 컨트롤만 두고 payload 에 빠뜨리면 입력해도 저장되지 않는다
|
||||
expect(String(push.params['form.mileage.currency_rules'])).toContain('earn_rounding_unit:');
|
||||
expect(String(push.params['form.mileage.currency_rules'])).toContain('earn_rounding_method:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('유효기간 / 소멸 알림 카드', () => {
|
||||
it('expiry_enabled / expiry_notification_enabled 토글 바인딩', () => {
|
||||
const expiryToggle = findFirst(expiryCard, (n) => n.name === 'Toggle' && n.props?.name === 'mileage.expiry_enabled');
|
||||
|
||||
+13
-1
@@ -35,7 +35,19 @@
|
||||
"empty": "No currency rules registered.",
|
||||
"code_placeholder": "e.g. USD",
|
||||
"duplicate_currency_error": "This currency is already registered.",
|
||||
"max_use_fixed_suffix": ""
|
||||
"max_use_fixed_suffix": "",
|
||||
"earn_rounding": "Earning Rounding",
|
||||
"earn_rounding_hint": "Applied when earned points are calculated and prorated",
|
||||
"earn_rounding_units": {
|
||||
"1": "1 pt",
|
||||
"10": "10 pts",
|
||||
"100": "100 pts"
|
||||
},
|
||||
"earn_rounding_methods": {
|
||||
"floor": "Round down",
|
||||
"round": "Round",
|
||||
"ceil": "Round up"
|
||||
}
|
||||
},
|
||||
"expiry": {
|
||||
"title": "Expiry",
|
||||
|
||||
+13
-1
@@ -35,7 +35,19 @@
|
||||
"empty": "등록된 통화 규칙이 없습니다.",
|
||||
"code_placeholder": "예: USD",
|
||||
"duplicate_currency_error": "이미 등록된 통화입니다.",
|
||||
"max_use_fixed_suffix": "원"
|
||||
"max_use_fixed_suffix": "원",
|
||||
"earn_rounding": "적립 절사",
|
||||
"earn_rounding_hint": "적립 포인트 산출·안분 시 적용",
|
||||
"earn_rounding_units": {
|
||||
"1": "1점",
|
||||
"10": "10점",
|
||||
"100": "100점"
|
||||
},
|
||||
"earn_rounding_methods": {
|
||||
"floor": "버림",
|
||||
"round": "반올림",
|
||||
"ceil": "올림"
|
||||
}
|
||||
},
|
||||
"expiry": {
|
||||
"title": "유효기간",
|
||||
|
||||
+1
@@ -513,6 +513,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"data-testid": "ecommerce-settings-save",
|
||||
"disabled": "{{_computed.isReadOnly || !_local.hasChanges || _local.isSaving}}",
|
||||
"className": "btn btn-primary flex-center gap-1.5"
|
||||
},
|
||||
|
||||
+131
-2
@@ -336,6 +336,75 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "적립 절사 기준 — 데스크톱 테이블 뷰와 동일 항목 (한쪽만 두면 모바일에서 설정 불가)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_unit'}}",
|
||||
"value": "{{rule.earn_rounding_unit ?? '1'}}",
|
||||
"options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}",
|
||||
"className": "flex-1",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"data-testid": "{{'mileage-earn-rounding-unit-' + (rule.currency_code ?? rule._idx)}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_method'}}",
|
||||
"value": "{{rule.earn_rounding_method ?? 'floor'}}",
|
||||
"options": [
|
||||
{
|
||||
"value": "floor",
|
||||
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor"
|
||||
},
|
||||
{
|
||||
"value": "round",
|
||||
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round"
|
||||
},
|
||||
{
|
||||
"value": "ceil",
|
||||
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil"
|
||||
}
|
||||
],
|
||||
"className": "flex-1",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"data-testid": "{{'mileage-earn-rounding-method-' + (rule.currency_code ?? rule._idx)}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "block text-xs text-gray-400 dark:text-gray-500 mt-1"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_hint"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -618,6 +687,66 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "newMileageCurrency.earn_rounding_unit",
|
||||
"value": "{{_local.form?.newMileageCurrency?.earn_rounding_unit ?? '1'}}",
|
||||
"options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}",
|
||||
"className": "flex-1",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"data-testid": "mileage-new-earn-rounding-unit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "newMileageCurrency.earn_rounding_method",
|
||||
"value": "{{_local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor'}}",
|
||||
"options": [
|
||||
{
|
||||
"value": "floor",
|
||||
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor"
|
||||
},
|
||||
{
|
||||
"value": "round",
|
||||
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round"
|
||||
},
|
||||
{
|
||||
"value": "ceil",
|
||||
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil"
|
||||
}
|
||||
],
|
||||
"className": "flex-1",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"data-testid": "mileage-new-earn-rounding-method"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
@@ -657,7 +786,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50",
|
||||
"className": "flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50",
|
||||
"disabled": "{{_computed.isReadOnly}}"
|
||||
},
|
||||
"actions": [
|
||||
@@ -686,7 +815,7 @@
|
||||
"if": "{{_local.form?.newMileageCurrency?.currency_code?.trim() && !((_local.form?.mileage?.currency_rules ?? []).some(c => (c.currency_code || '').toUpperCase() === (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim()))}}",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0 }]}}",
|
||||
"form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0, earn_rounding_unit: _local.form?.newMileageCurrency?.earn_rounding_unit ?? '1', earn_rounding_method: _local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor' }]}}",
|
||||
"isAddingMileageCurrency": false,
|
||||
"form.newMileageCurrency": null,
|
||||
"hasChanges": true
|
||||
|
||||
+126
-1
@@ -50,6 +50,12 @@
|
||||
"name": "Th",
|
||||
"text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.max_use"
|
||||
},
|
||||
{
|
||||
"comment": "적립 절사 기준 — 언어/통화 탭의 환산 절사와 별개(그쪽은 외화 표시 전용)",
|
||||
"type": "basic",
|
||||
"name": "Th",
|
||||
"text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Th",
|
||||
@@ -377,6 +383,70 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "적립 절사 기준 (단위 + 방식). 미설정 행은 1점 버림으로 표시 — 종전 동작과 같다.",
|
||||
"type": "basic",
|
||||
"name": "Td",
|
||||
"props": {
|
||||
"className": "align-top"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_unit'}}",
|
||||
"value": "{{rule.earn_rounding_unit ?? '1'}}",
|
||||
"options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}",
|
||||
"className": "w-24",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"data-testid": "{{'mileage-earn-rounding-unit-' + (rule.currency_code ?? rule._idx)}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_method'}}",
|
||||
"value": "{{rule.earn_rounding_method ?? 'floor'}}",
|
||||
"options": [
|
||||
{
|
||||
"value": "floor",
|
||||
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor"
|
||||
},
|
||||
{
|
||||
"value": "round",
|
||||
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round"
|
||||
},
|
||||
{
|
||||
"value": "ceil",
|
||||
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil"
|
||||
}
|
||||
],
|
||||
"className": "w-24",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"data-testid": "{{'mileage-earn-rounding-method-' + (rule.currency_code ?? rule._idx)}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "block text-xs text-gray-400 dark:text-gray-500 mt-1"
|
||||
},
|
||||
"text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_hint"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Td",
|
||||
@@ -707,6 +777,61 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Td",
|
||||
"props": {
|
||||
"className": "align-top"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "newMileageCurrency.earn_rounding_unit",
|
||||
"value": "{{_local.form?.newMileageCurrency?.earn_rounding_unit ?? '1'}}",
|
||||
"options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}",
|
||||
"className": "w-24",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"data-testid": "mileage-new-earn-rounding-unit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "newMileageCurrency.earn_rounding_method",
|
||||
"value": "{{_local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor'}}",
|
||||
"options": [
|
||||
{
|
||||
"value": "floor",
|
||||
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor"
|
||||
},
|
||||
{
|
||||
"value": "round",
|
||||
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round"
|
||||
},
|
||||
{
|
||||
"value": "ceil",
|
||||
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil"
|
||||
}
|
||||
],
|
||||
"className": "w-24",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"data-testid": "mileage-new-earn-rounding-method"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Td",
|
||||
@@ -755,7 +880,7 @@
|
||||
"if": "{{_local.form?.newMileageCurrency?.currency_code?.trim() && !((_local.form?.mileage?.currency_rules ?? []).some(c => (c.currency_code || '').toUpperCase() === (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim()))}}",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0 }]}}",
|
||||
"form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0, earn_rounding_unit: _local.form?.newMileageCurrency?.earn_rounding_unit ?? '1', earn_rounding_method: _local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor' }]}}",
|
||||
"isAddingMileageCurrency": false,
|
||||
"form.newMileageCurrency": null,
|
||||
"hasChanges": true
|
||||
|
||||
@@ -25,6 +25,7 @@ class CalculationInput
|
||||
* @param array|null $shippingPolicySnapshots 배송정책 스냅샷 (환불 재계산용)
|
||||
* @param array|null $promotionSnapshots 프로모션 스냅샷 (환불 재계산용)
|
||||
* @param int|null $userId 주문 사용자 ID (per_user_limit 과거 사용 검증용, 비회원은 null)
|
||||
* @param array|null $mileagePolicySnapshot 마일리지 정책 스냅샷 (환불 재계산 시 적립 절사 기준 고정용)
|
||||
*/
|
||||
public function __construct(
|
||||
public array $items = [],
|
||||
@@ -38,6 +39,7 @@ class CalculationInput
|
||||
public ?array $shippingPolicySnapshots = null,
|
||||
public ?array $promotionSnapshots = null,
|
||||
public ?int $userId = null,
|
||||
public ?array $mileagePolicySnapshot = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -70,6 +72,7 @@ class CalculationInput
|
||||
shippingPolicySnapshots: $data['shipping_policy_snapshots'] ?? null,
|
||||
promotionSnapshots: $data['promotion_snapshots'] ?? null,
|
||||
userId: $data['user_id'] ?? null,
|
||||
mileagePolicySnapshot: $data['mileage_policy_snapshot'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +95,7 @@ class CalculationInput
|
||||
'shipping_policy_snapshots' => $this->shippingPolicySnapshots,
|
||||
'promotion_snapshots' => $this->promotionSnapshots,
|
||||
'user_id' => $this->userId,
|
||||
'mileage_policy_snapshot' => $this->mileagePolicySnapshot,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -9,6 +9,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
|
||||
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
|
||||
|
||||
/**
|
||||
* 이커머스 설정 저장 요청 검증
|
||||
@@ -311,6 +312,11 @@ class StoreEcommerceSettingsRequest extends FormRequest
|
||||
'mileage.currency_rules.*.max_use_percent' => ['nullable', 'numeric', 'min:'.config('sirsoft-ecommerce.limits.max_use_percent_min', 0), 'max:'.config('sirsoft-ecommerce.limits.max_use_percent_max', 100)],
|
||||
// 최대 사용한도(고정금액): 비합리적 거액 방지를 위해 10억 상한
|
||||
'mileage.currency_rules.*.max_use_value' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.max_use_value_min', 0), 'max:'.config('sirsoft-ecommerce.limits.max_use_value_max', 1000000000)],
|
||||
// 적립 절사 기준 — 통화 환산 절사(language_currency.currencies.*.rounding_*)와 별개다.
|
||||
// 그쪽은 외화 표시 전용이라 기본 통화에는 적용되지 않는 반면, 적립은 기본 통화 원장에
|
||||
// 확정 기록되는 값이므로 자기 규칙을 갖는다. 어휘는 MileageRounding 이 SSoT.
|
||||
'mileage.currency_rules.*.earn_rounding_unit' => ['nullable', 'string', 'in:'.implode(',', MileageRounding::UNITS)],
|
||||
'mileage.currency_rules.*.earn_rounding_method' => ['nullable', 'string', 'in:'.implode(',', MileageRounding::METHODS)],
|
||||
'mileage.expiry_enabled' => ['nullable', 'boolean'],
|
||||
'mileage.expiry_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.expiry_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.expiry_days_max', 3650)],
|
||||
'mileage.expiry_notification_enabled' => ['nullable', 'boolean'],
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Helpers\PermissionHelper;
|
||||
use App\Http\Resources\BaseApiResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
|
||||
|
||||
/**
|
||||
* 주문 상세 리소스
|
||||
@@ -188,7 +189,12 @@ class OrderResource extends BaseApiResource
|
||||
|
||||
// 프로모션/배송정책 스냅샷
|
||||
'promotions_applied_snapshot' => $this->promotions_applied_snapshot,
|
||||
'shipping_policy_applied_snapshot' => $this->shipping_policy_applied_snapshot,
|
||||
// 항상 신형 구조(`{items: [...], address: {...}}`)로 정규화해 내보낸다.
|
||||
// 백필 전 구형 행이 남아 있어도 화면은 한 형태만 보면 되고, `items` 가 JSON
|
||||
// 배열로 직렬화되므로 프론트의 `.find(...)` 가 안전하다.
|
||||
'shipping_policy_applied_snapshot' => ShippingPolicySnapshot::normalize(
|
||||
$this->shipping_policy_applied_snapshot
|
||||
),
|
||||
|
||||
// 메모
|
||||
'admin_memo' => $this->admin_memo,
|
||||
|
||||
@@ -15,6 +15,7 @@ use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\RefundPriorityEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
|
||||
use Modules\Sirsoft\Ecommerce\Support\VatCalculator;
|
||||
|
||||
/**
|
||||
@@ -391,8 +392,9 @@ class OrderAdjustmentService
|
||||
// 배송지 복원
|
||||
$shippingAddress = null;
|
||||
$shippingSnapshot = $order->shipping_policy_applied_snapshot ?? [];
|
||||
if (! empty($shippingSnapshot['address'])) {
|
||||
$shippingAddress = ShippingAddress::fromArray($shippingSnapshot['address']);
|
||||
$snapshotAddress = ShippingPolicySnapshot::address($shippingSnapshot);
|
||||
if (! empty($snapshotAddress)) {
|
||||
$shippingAddress = ShippingAddress::fromArray($snapshotAddress);
|
||||
}
|
||||
|
||||
// 배송정책 스냅샷을 product_option_id 키 맵으로 변환
|
||||
@@ -416,6 +418,9 @@ class OrderAdjustmentService
|
||||
],
|
||||
shippingPolicySnapshots: $shippingPolicySnapshots,
|
||||
promotionSnapshots: $order->promotions_applied_snapshot,
|
||||
// 적립 절사 기준은 주문 시점 값으로 고정한다 — 설정을 다시 읽으면 운영자가 이후에
|
||||
// 바꾼 기준이 소급 적용돼, 취소하지 않은 잔여분의 적립액이 취소 처리만으로 달라진다.
|
||||
mileagePolicySnapshot: $order->mileage_policy_snapshot,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -909,25 +914,10 @@ class OrderAdjustmentService
|
||||
*/
|
||||
private function buildShippingPolicySnapshotMap(array $shippingSnapshot): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
foreach ($shippingSnapshot as $key => $entry) {
|
||||
// 이미 product_option_id를 키로 사용하는 경우 (숫자 키 + policy 데이터)
|
||||
if (is_int($key) && isset($entry['product_option_id'], $entry['policy'])) {
|
||||
$map[$entry['product_option_id']] = $entry['policy'];
|
||||
} elseif (is_int($key) && isset($entry['policy_id'])) {
|
||||
// product_option_id 키 맵 형태 (테스트 헬퍼 등에서 직접 구성한 경우)
|
||||
$map[$key] = $entry;
|
||||
} elseif ($key === 'address') {
|
||||
// address 키는 스킵 (배송지 정보)
|
||||
continue;
|
||||
} else {
|
||||
// 기타 (이미 product_option_id => data 형태)
|
||||
$map[$key] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
// 형태 판별은 ShippingPolicySnapshot 단일 출처에 위임한다 — 종전에는 이 메서드와
|
||||
// OrderCancellationService::buildShippingSnapshot() 이 `is_int($key)` 판별을
|
||||
// 각자 복제하고 있어, 구조가 바뀔 때 한쪽만 고쳐질 위험이 있었다.
|
||||
return ShippingPolicySnapshot::toOptionPolicyMap($shippingSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterf
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductAdditionalOptionValueRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingPolicyRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
|
||||
use Modules\Sirsoft\Ecommerce\Support\VatCalculator;
|
||||
|
||||
/**
|
||||
@@ -316,7 +317,8 @@ class OrderCalculationService
|
||||
$pointsPerItem = $this->calculatePointsEarning(
|
||||
$preparedItems,
|
||||
$itemsAfterOrderDiscount,
|
||||
$pointsUsageResult
|
||||
$pointsUsageResult,
|
||||
$input
|
||||
);
|
||||
$pointsPerItem = HookManager::applyFilters(
|
||||
'sirsoft-ecommerce.calculation.after_points_earning',
|
||||
@@ -855,12 +857,20 @@ class OrderCalculationService
|
||||
* @param array $preparedItems 준비된 아이템 배열
|
||||
* @param array $discountedItems 할인 후 아이템 배열 (주문쿠폰 적용 후)
|
||||
* @param array $pointsUsageResult 마일리지 사용 결과 (points_by_option 포함)
|
||||
* @param CalculationInput|null $input 계산 입력 (재계산 시 주문 시점 절사 기준 복원용)
|
||||
* @return array 옵션별 마일리지 배열 [product_option_id => points]
|
||||
*/
|
||||
protected function calculatePointsEarning(array $preparedItems, array $discountedItems, array $pointsUsageResult = []): array
|
||||
protected function calculatePointsEarning(array $preparedItems, array $discountedItems, array $pointsUsageResult = [], ?CalculationInput $input = null): array
|
||||
{
|
||||
$pointsPerItem = [];
|
||||
|
||||
// 적립 절사 기준. 재계산(부분취소·추가결제)은 주문 스냅샷을 우선한다 — 설정을 다시 읽으면
|
||||
// 운영자가 이후에 바꾼 절사 기준이 과거 주문에 소급 적용돼, 취소하지 않은 잔여분의
|
||||
// 적립액이 취소 처리만으로 달라진다.
|
||||
$rounding = $input?->mileagePolicySnapshot !== null
|
||||
? MileageRounding::fromOrderSnapshot($input->mileagePolicySnapshot)
|
||||
: MileageRounding::normalize($this->resolveMileageCurrencyRule($input));
|
||||
|
||||
foreach ($preparedItems as $item) {
|
||||
$optionId = $item['product_option_id'];
|
||||
$option = $item['product_option'];
|
||||
@@ -890,21 +900,52 @@ class OrderCalculationService
|
||||
if ($option->mileage_value !== null && $option->mileage_type !== null) {
|
||||
if ($option->mileage_type === 'fixed') {
|
||||
// 정액 적립: mileage_value를 그대로 사용 (수량 곱)
|
||||
$pointsPerItem[$optionId] = (int) floor($option->mileage_value * $item['quantity']);
|
||||
$pointsPerItem[$optionId] = MileageRounding::apply($option->mileage_value * $item['quantity'], $rounding);
|
||||
} else {
|
||||
// 정률 적립: earnableAmount 기준으로 계산
|
||||
$pointsPerItem[$optionId] = (int) floor($earnableAmount * $option->mileage_value / 100);
|
||||
$pointsPerItem[$optionId] = MileageRounding::apply($earnableAmount * $option->mileage_value / 100, $rounding);
|
||||
}
|
||||
} else {
|
||||
// 기본 마일리지 적립율 (설정값, 기본 1%)
|
||||
$defaultRate = (float) $this->settingsService->getSetting('mileage.default_earn_rate', 1);
|
||||
$pointsPerItem[$optionId] = (int) floor($earnableAmount * $defaultRate / 100);
|
||||
$pointsPerItem[$optionId] = MileageRounding::apply($earnableAmount * $defaultRate / 100, $rounding);
|
||||
}
|
||||
}
|
||||
|
||||
return $pointsPerItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* 적립 절사 기준을 담은 통화별 마일리지 규칙을 조회합니다.
|
||||
*
|
||||
* 마일리지는 표시통화가 아니라 기준통화(base_currency)로 적립·정산되므로, 규칙도
|
||||
* 기준통화 기준으로 고른다. 스냅샷 모드에서는 주문 시점 기준통화를, 신규 주문에서는
|
||||
* 현재 기본 통화를 쓴다. 해당 통화 규칙이 없으면 첫 행(기본 통화 규칙)으로 폴백한다 —
|
||||
* `UserMileageService::currencyRule()` 과 같은 해석 규칙이다.
|
||||
*
|
||||
* @param CalculationInput|null $input 계산 입력
|
||||
* @return array|null 통화별 마일리지 규칙 (없으면 null → 절사 기본값 적용)
|
||||
*/
|
||||
protected function resolveMileageCurrencyRule(?CalculationInput $input): ?array
|
||||
{
|
||||
$rules = (array) $this->settingsService->getSetting('mileage.currency_rules', []);
|
||||
|
||||
if ($rules === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currency = $input->metadata['currency_snapshot']['base_currency']
|
||||
?? $this->currencyService->getDefaultCurrency();
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
if (is_array($rule) && ($rule['currency_code'] ?? null) === $currency) {
|
||||
return $rule;
|
||||
}
|
||||
}
|
||||
|
||||
return is_array($rules[0] ?? null) ? $rules[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단계 2-b: 과세/면세 금액을 분류합니다.
|
||||
*
|
||||
|
||||
@@ -32,6 +32,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterf
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundOptionRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
|
||||
|
||||
/**
|
||||
* 주문 취소 서비스
|
||||
@@ -472,7 +473,7 @@ class OrderCancellationService
|
||||
protected function buildShippingSnapshot(Order $order, array $cancelItems): array
|
||||
{
|
||||
$orderSnapshot = $order->shipping_policy_applied_snapshot ?? [];
|
||||
$address = $orderSnapshot['address'] ?? [];
|
||||
$address = ShippingPolicySnapshot::address($orderSnapshot);
|
||||
|
||||
// 주문 스냅샷에 배송지가 없으면 현재 배송주소에서 폴백 복원(과거 주문 호환).
|
||||
if (empty($address['country_code'])) {
|
||||
@@ -492,13 +493,8 @@ class OrderCancellationService
|
||||
}
|
||||
}
|
||||
|
||||
$policies = [];
|
||||
foreach ($orderSnapshot as $key => $entry) {
|
||||
if (is_int($key) && isset($entry['product_option_id'], $entry['policy'])
|
||||
&& isset($cancelOptionIds[$entry['product_option_id']])) {
|
||||
$policies[] = $entry;
|
||||
}
|
||||
}
|
||||
// 형태 판별은 ShippingPolicySnapshot 단일 출처에 위임 (구형 혼합 배열도 흡수).
|
||||
$policies = ShippingPolicySnapshot::itemsForOptions($orderSnapshot, $cancelOptionIds);
|
||||
|
||||
return [
|
||||
'country_code' => strtoupper((string) ($address['country_code'] ?? 'KR')),
|
||||
|
||||
@@ -16,6 +16,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterf
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductReviewRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
|
||||
|
||||
/**
|
||||
* 주문 옵션 서비스
|
||||
@@ -203,7 +204,16 @@ class OrderOptionService
|
||||
}
|
||||
|
||||
// 분할 레코드 금액 = 원본 × ratio
|
||||
$this->applySplitAmounts($splitOption, $origAmounts, $ratio, $quantity);
|
||||
// 적립 포인트는 주문 시점 절사 기준으로 정수화한다 — 안분을 소수 2자리로 남기면
|
||||
// 원장에 없는 소수점 포인트(390.5P)가 옵션 행에 생기고, 그 합이 적립 목표액이 되어
|
||||
// 실제 지급 가능한 정수 포인트와 어긋난다.
|
||||
$this->applySplitAmounts(
|
||||
$splitOption,
|
||||
$origAmounts,
|
||||
$ratio,
|
||||
$quantity,
|
||||
MileageRounding::fromOrderSnapshot($option->order?->mileage_policy_snapshot)
|
||||
);
|
||||
$this->orderOptionRepository->save($splitOption);
|
||||
|
||||
// 원본 레코드 = 원본 - 분할 (잔여분)
|
||||
@@ -439,8 +449,9 @@ class OrderOptionService
|
||||
* @param array $origAmounts 원본 금액 캡처 데이터
|
||||
* @param float $ratio 분할 비율 (변경수량 / 원본수량)
|
||||
* @param int $quantity 분할 수량
|
||||
* @param array{unit: string, method: string}|null $earnRounding 적립 절사 기준 (주문 스냅샷 기준)
|
||||
*/
|
||||
private function applySplitAmounts(OrderOption $splitOption, array $origAmounts, float $ratio, int $quantity): void
|
||||
private function applySplitAmounts(OrderOption $splitOption, array $origAmounts, float $ratio, int $quantity, ?array $earnRounding = null): void
|
||||
{
|
||||
$splitOption->subtotal_price = round($splitOption->unit_price * $quantity, 2);
|
||||
$splitOption->subtotal_discount_amount = round($origAmounts['subtotal_discount_amount'] * $ratio, 2);
|
||||
@@ -450,7 +461,11 @@ class OrderOptionService
|
||||
$splitOption->subtotal_deposit_used_amount = round($origAmounts['subtotal_deposit_used_amount'] * $ratio, 2);
|
||||
$splitOption->subtotal_tax_amount = round($origAmounts['subtotal_tax_amount'] * $ratio, 2);
|
||||
$splitOption->subtotal_tax_free_amount = round($origAmounts['subtotal_tax_free_amount'] * $ratio, 2);
|
||||
$splitOption->subtotal_earned_points_amount = round($origAmounts['subtotal_earned_points_amount'] * $ratio, 2);
|
||||
// 적립 포인트는 절사 기준으로 정수화 — 잔여분은 `원본 − 분할` 이라 총액은 그대로 보존된다.
|
||||
$splitOption->subtotal_earned_points_amount = MileageRounding::apply(
|
||||
$origAmounts['subtotal_earned_points_amount'] * $ratio,
|
||||
$earnRounding
|
||||
);
|
||||
$splitOption->subtotal_weight = round($splitOption->unit_weight * $quantity, 3);
|
||||
$splitOption->subtotal_volume = round($splitOption->unit_volume * $quantity, 3);
|
||||
$splitOption->subtotal_paid_amount = $splitOption->subtotal_price
|
||||
|
||||
@@ -17,6 +17,7 @@ use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageBalanceRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageTransactionRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
|
||||
|
||||
/**
|
||||
* 사용자 마일리지 서비스 (원장 SSoT + 표시 캐시)
|
||||
@@ -265,6 +266,10 @@ class UserMileageService
|
||||
$usable = $this->isMileageUsable();
|
||||
$rule = $usable ? $this->currencyRule($currency) : [];
|
||||
|
||||
// 적립 절사 기준은 사용 가능 여부와 무관하게 기록한다 — 마일리지 "사용" 이 막혀 있어도
|
||||
// 적립은 별개로 일어나므로, 사용 불가 주문의 재계산에도 절사 기준이 필요하다.
|
||||
$earnRounding = MileageRounding::normalize($this->currencyRule($currency));
|
||||
|
||||
return [
|
||||
'usable' => $usable,
|
||||
'currency' => $currency,
|
||||
@@ -275,6 +280,8 @@ class UserMileageService
|
||||
'max_use_type' => (string) ($rule['max_use_type'] ?? 'percent'),
|
||||
'max_use_percent' => (float) ($rule['max_use_percent'] ?? 100),
|
||||
'max_use_value' => (int) ($rule['max_use_value'] ?? 0),
|
||||
MileageRounding::UNIT_KEY => $earnRounding['unit'],
|
||||
MileageRounding::METHOD_KEY => $earnRounding['method'],
|
||||
],
|
||||
// 판정 근거 — 이 결제금액에 이 정책을 적용해 아래 상한이 나왔고, 그 안에서 사용됐다.
|
||||
'payment_amount_basis' => $paymentAmount,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Support;
|
||||
|
||||
/**
|
||||
* 적립 마일리지 절사 규칙의 SSoT.
|
||||
*
|
||||
* 적립 포인트 산출은 종전에 `(int) floor(...)` 로 세 갈래(옵션 정액 · 옵션 정률 · 기본
|
||||
* 적립률)에 하드코딩되어 있었고, 부분취소 분할 안분은 `round(..., 2)` 로 소수점 포인트를
|
||||
* 만들었다. 두 지점의 규칙이 서로 다르고 운영자가 어느 쪽도 조정할 수 없었다.
|
||||
*
|
||||
* 이 클래스는 통화별 마일리지 규칙(`mileage.currency_rules.*`)의 절사 단위·방식을
|
||||
* 해석해 한 곳에서 적용한다. 통화 설정(`language_currency.currencies.*.rounding_*`)과는
|
||||
* 별개다 — 그쪽은 **외화 환산 표시** 전용이라 기본 통화에는 적용조차 되지 않는다.
|
||||
* 적립은 기본 통화 원장에 확정 기록되는 값이므로 자기 규칙을 갖는다.
|
||||
*
|
||||
* 기본값(`1` / `floor`)은 종전 하드코딩 동작과 정확히 같다. 규칙이 없는 기존 설치본과
|
||||
* 이 기능 이전에 생성된 주문은 폴백만으로 과거와 동일한 금액을 산출한다.
|
||||
*
|
||||
* @since 1.0.5
|
||||
*/
|
||||
class MileageRounding
|
||||
{
|
||||
/** 절사 단위 설정 키 (통화별 마일리지 규칙 내) */
|
||||
public const UNIT_KEY = 'earn_rounding_unit';
|
||||
|
||||
/** 절사 방식 설정 키 (통화별 마일리지 규칙 내) */
|
||||
public const METHOD_KEY = 'earn_rounding_method';
|
||||
|
||||
/** 기본 절사 단위 — 1점 (종전 하드코딩과 동일) */
|
||||
public const DEFAULT_UNIT = '1';
|
||||
|
||||
/** 기본 절사 방식 — 버림 (종전 하드코딩 floor 와 동일) */
|
||||
public const DEFAULT_METHOD = 'floor';
|
||||
|
||||
/**
|
||||
* 선택 가능한 절사 단위.
|
||||
*
|
||||
* 마일리지는 원장에 정수 포인트로 확정되므로 소수 단위는 두지 않는다.
|
||||
* 통화 환산 절사(0.01/0.1/1)와 후보가 다른 이유다.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const UNITS = ['1', '10', '100'];
|
||||
|
||||
/**
|
||||
* 선택 가능한 절사 방식 (통화 환산 절사와 동일 어휘).
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const METHODS = ['floor', 'round', 'ceil'];
|
||||
|
||||
/**
|
||||
* 통화 규칙에서 절사 기준을 해석합니다.
|
||||
*
|
||||
* 미설정·오염값은 기본값으로 접는다 — 절사 기준이 해석되지 않는다고 적립을 멈출 수는
|
||||
* 없고, 알 수 없는 값에서 임의 방식을 고르면 지급액이 조용히 달라지기 때문이다.
|
||||
*
|
||||
* @param array|null $rule 통화별 마일리지 규칙 (mileage.currency_rules 의 한 행)
|
||||
* @return array{unit: string, method: string} 정규화된 절사 기준
|
||||
*/
|
||||
public static function normalize(?array $rule): array
|
||||
{
|
||||
$unit = (string) ($rule[self::UNIT_KEY] ?? self::DEFAULT_UNIT);
|
||||
$method = (string) ($rule[self::METHOD_KEY] ?? self::DEFAULT_METHOD);
|
||||
|
||||
return [
|
||||
'unit' => in_array($unit, self::UNITS, true) ? $unit : self::DEFAULT_UNIT,
|
||||
'method' => in_array($method, self::METHODS, true) ? $method : self::DEFAULT_METHOD,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문 스냅샷에서 절사 기준을 해석합니다.
|
||||
*
|
||||
* 재계산(부분취소 · 추가결제)은 주문 시점 기준을 그대로 써야 한다. 설정을 다시 읽으면
|
||||
* 운영자가 이후에 바꾼 절사 기준이 과거 주문에 소급 적용돼, 취소하지 않은 잔여분의
|
||||
* 적립액이 취소 처리만으로 달라진다.
|
||||
*
|
||||
* 이 기능 이전 주문의 스냅샷에는 키가 없다 — 그때의 동작이 곧 기본값(`1`/`floor`)이므로
|
||||
* 폴백이 당시 산출과 일치한다.
|
||||
*
|
||||
* @param array|null $snapshot 주문의 mileage_policy_snapshot
|
||||
* @return array{unit: string, method: string} 정규화된 절사 기준
|
||||
*/
|
||||
public static function fromOrderSnapshot(?array $snapshot): array
|
||||
{
|
||||
return self::normalize(is_array($snapshot['rule'] ?? null) ? $snapshot['rule'] : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 절사 기준을 적용해 정수 포인트를 산출합니다.
|
||||
*
|
||||
* @param float|int $points 절사 전 포인트 (계산 결과 실수)
|
||||
* @param array{unit: string, method: string}|null $rounding 정규화된 절사 기준 (null 이면 기본값)
|
||||
* @return int 절사된 정수 포인트 (음수 방지 없음 — 호출자가 부호 의미를 정한다)
|
||||
*/
|
||||
public static function apply(float|int $points, ?array $rounding = null): int
|
||||
{
|
||||
$unit = (float) ($rounding['unit'] ?? self::DEFAULT_UNIT);
|
||||
$method = (string) ($rounding['method'] ?? self::DEFAULT_METHOD);
|
||||
|
||||
if ($unit <= 0) {
|
||||
$unit = 1.0;
|
||||
}
|
||||
|
||||
$divided = ((float) $points) / $unit;
|
||||
|
||||
// 부동소수 오차 보정 — 7809.999999999999 가 floor 에서 7809 로 떨어지는 것을 막는다.
|
||||
// 마일리지 단위(1/10/100)에서 9자리 이하 오차는 계산 잔여물일 뿐 의미 있는 차이가 아니다.
|
||||
$divided = round($divided, 9);
|
||||
|
||||
$quantized = match ($method) {
|
||||
'ceil' => ceil($divided),
|
||||
'round' => round($divided),
|
||||
default => floor($divided),
|
||||
};
|
||||
|
||||
return (int) ($quantized * $unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 통화 규칙에서 곧바로 절사를 적용합니다 (normalize + apply 축약).
|
||||
*
|
||||
* @param float|int $points 절사 전 포인트
|
||||
* @param array|null $rule 통화별 마일리지 규칙
|
||||
* @return int 절사된 정수 포인트
|
||||
*/
|
||||
public static function applyRule(float|int $points, ?array $rule): int
|
||||
{
|
||||
return self::apply($points, self::normalize($rule));
|
||||
}
|
||||
}
|
||||
@@ -1390,6 +1390,8 @@ return [
|
||||
'mileage.currency_rules.*.use_unit' => 'Use Unit',
|
||||
'mileage.currency_rules.*.max_use_percent' => 'Max Use Percent',
|
||||
'mileage.currency_rules.*.max_use_value' => 'Max Use Amount',
|
||||
'mileage.currency_rules.*.earn_rounding_unit' => 'Earning Rounding Unit',
|
||||
'mileage.currency_rules.*.earn_rounding_method' => 'Earning Rounding Method',
|
||||
'mileage.expiry_days' => 'Expiry Days',
|
||||
'mileage.expiry_notification_days_before' => 'Expiry Notification Days',
|
||||
|
||||
@@ -1616,6 +1618,12 @@ return [
|
||||
'min' => 'Max usable amount must be 0 or greater.',
|
||||
'max' => 'Max usable amount is too large (max 1 billion).',
|
||||
],
|
||||
'earn_rounding_unit' => [
|
||||
'in' => 'The earning rounding unit must be one of: 1, 10, 100.',
|
||||
],
|
||||
'earn_rounding_method' => [
|
||||
'in' => 'The earning rounding method must be one of: floor, round, ceil.',
|
||||
],
|
||||
],
|
||||
],
|
||||
'seo' => [
|
||||
|
||||
@@ -1390,6 +1390,8 @@ return [
|
||||
'mileage.currency_rules.*.use_unit' => '사용 단위',
|
||||
'mileage.currency_rules.*.max_use_percent' => '최대 사용 비율',
|
||||
'mileage.currency_rules.*.max_use_value' => '최대 사용 금액',
|
||||
'mileage.currency_rules.*.earn_rounding_unit' => '적립 절사 단위',
|
||||
'mileage.currency_rules.*.earn_rounding_method' => '적립 절사 방식',
|
||||
'mileage.expiry_days' => '유효기간',
|
||||
'mileage.expiry_notification_days_before' => '소멸 예정 알림일',
|
||||
|
||||
@@ -1616,6 +1618,12 @@ return [
|
||||
'min' => '최대 사용 금액은 0 이상이어야 합니다.',
|
||||
'max' => '최대 사용 금액이 너무 큽니다. (최대 10억)',
|
||||
],
|
||||
'earn_rounding_unit' => [
|
||||
'in' => '적립 절사 단위는 1, 10, 100 중 하나여야 합니다.',
|
||||
],
|
||||
'earn_rounding_method' => [
|
||||
'in' => '적립 절사 방식은 floor, round, ceil 중 하나여야 합니다.',
|
||||
],
|
||||
],
|
||||
],
|
||||
'seo' => [
|
||||
|
||||
+61
@@ -374,6 +374,67 @@ class EcommerceSettingsMileageTest extends ModuleTestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 통화별 적립 절사 기준이 저장·조회된다.
|
||||
*
|
||||
* 적립 절사는 통화 환산 절사(language_currency.currencies.*.rounding_*)와 별개 항목이다.
|
||||
* 그쪽은 외화 표시 전용이라 기본 통화에는 적용되지 않는 반면, 적립은 기본 통화 원장에
|
||||
* 확정 기록되는 값이므로 자기 규칙을 갖는다.
|
||||
*/
|
||||
public function test_save_mileage_earn_rounding_rule(): void
|
||||
{
|
||||
$response = $this->actingAs($this->adminUser)->putJson($this->apiBase, [
|
||||
'_tab' => 'mileage',
|
||||
'mileage' => [
|
||||
'enabled' => true,
|
||||
'default_earn_rate' => 1,
|
||||
'currency_rules' => [
|
||||
[
|
||||
'currency_code' => 'KRW', 'point_value' => 1, 'min_use_amount' => 0, 'use_unit' => 1,
|
||||
'max_use_type' => 'percent', 'max_use_percent' => 100, 'max_use_value' => 0,
|
||||
'earn_rounding_unit' => '10', 'earn_rounding_method' => 'ceil',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$settings = app(EcommerceSettingsService::class);
|
||||
$settings->clearCache();
|
||||
$rules = $settings->getSetting('mileage.currency_rules');
|
||||
|
||||
$this->assertSame('10', $rules[0]['earn_rounding_unit']);
|
||||
$this->assertSame('ceil', $rules[0]['earn_rounding_method']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용 목록 밖의 적립 절사 값은 422 로 거부된다.
|
||||
*/
|
||||
public function test_invalid_mileage_earn_rounding_rejected(): void
|
||||
{
|
||||
$response = $this->actingAs($this->adminUser)->putJson($this->apiBase, [
|
||||
'_tab' => 'mileage',
|
||||
'mileage' => [
|
||||
'enabled' => true,
|
||||
'default_earn_rate' => 1,
|
||||
'currency_rules' => [
|
||||
[
|
||||
'currency_code' => 'KRW', 'point_value' => 1, 'min_use_amount' => 0, 'use_unit' => 1,
|
||||
'max_use_type' => 'percent', 'max_use_percent' => 100, 'max_use_value' => 0,
|
||||
'earn_rounding_unit' => '7', 'earn_rounding_method' => 'trunc',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors([
|
||||
'mileage.currency_rules.0.earn_rounding_unit',
|
||||
'mileage.currency_rules.0.earn_rounding_method',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* settings.update 권한 없는 계정은 차단.
|
||||
*/
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 관리자 마일리지 설정 — 통화별 적립 절사 기준.
|
||||
*
|
||||
* @scenario settings_mileage_earn_rounding
|
||||
* @effects settings_controls_render_in_both_viewports,
|
||||
* settings_screen_options_match_server_vocabulary,
|
||||
* settings_rounding_persists_across_reload,
|
||||
* settings_earn_rounding_separate_from_exchange
|
||||
*
|
||||
* 배경: 적립 포인트 산출은 종전에 `(int) floor` 로 하드코딩돼 운영자가 조정할 수 없었다.
|
||||
* 통화별 마일리지 규칙에 절사 단위(1/10/100)와 방식(버림/반올림/올림)을 두어
|
||||
* 주문계산·부분취소 안분이 같은 기준을 따르게 한다.
|
||||
*
|
||||
* 참고: 관리자 템플릿의 Select 는 options 가 주어지면 네이티브 `<select>` 가 아니라
|
||||
* 커스텀 드롭다운(Button + portal `role="listbox"`)으로 렌더된다. 따라서
|
||||
* `selectOption()`/`toHaveValue()` 가 아니라 열기 → `role="option"` 클릭으로 조작한다.
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
|
||||
|
||||
const SETTINGS_URL = '/admin/ecommerce/settings?tab=mileage';
|
||||
const CURRENCY_URL = '/admin/ecommerce/settings?tab=language_currency';
|
||||
|
||||
const UNIT = 'mileage-earn-rounding-unit-KRW';
|
||||
const METHOD = 'mileage-earn-rounding-method-KRW';
|
||||
|
||||
/** 커스텀 드롭다운을 열고 옵션 라벨 목록을 읽는다. */
|
||||
async function openAndReadOptions(page: Page, testId: string): Promise<string[]> {
|
||||
await page.getByTestId(testId).getByRole('button').click();
|
||||
const listbox = page.getByRole('listbox');
|
||||
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
const labels = await listbox.getByRole('option').allInnerTexts();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
return labels.map((t) => t.trim());
|
||||
}
|
||||
|
||||
/** 커스텀 드롭다운에서 라벨로 옵션을 고른다. */
|
||||
async function chooseOption(page: Page, testId: string, label: string): Promise<void> {
|
||||
await page.getByTestId(testId).getByRole('button').click();
|
||||
await expect(page.getByRole('listbox')).toBeVisible({ timeout: 5_000 });
|
||||
await page.getByRole('listbox').getByRole('option', { name: label, exact: true }).click();
|
||||
}
|
||||
|
||||
/** 드롭다운 버튼에 표시된 현재 선택 라벨. */
|
||||
function selectedLabel(page: Page, testId: string) {
|
||||
return page.getByTestId(testId).getByRole('button');
|
||||
}
|
||||
|
||||
test.describe('관리자 마일리지 설정 — 적립 절사 기준', () => {
|
||||
test('기본 통화 규칙 행에 절사 단위/방식 컨트롤이 렌더된다', async ({ page, settingsToken }) => {
|
||||
await authenticatePage(page, settingsToken);
|
||||
await page.goto(SETTINGS_URL);
|
||||
|
||||
await expect(page.getByTestId(UNIT)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId(METHOD)).toBeVisible();
|
||||
|
||||
// 단위는 정수 포인트만 — 마일리지는 원장에 정수로 확정되므로, 통화 환산 절사의
|
||||
// 소수 단위(0.01/0.1)가 선택지로 보이면 고를 수는 있는데 저장이 422 로 막힌다.
|
||||
expect(await openAndReadOptions(page, UNIT)).toEqual(['1점', '10점', '100점']);
|
||||
|
||||
// 화면 옵션 ⊆ 서버 허용 어휘(floor/round/ceil)
|
||||
expect(await openAndReadOptions(page, METHOD)).toEqual(['버림', '반올림', '올림']);
|
||||
});
|
||||
|
||||
test('절사 기준 변경이 저장되고 새로고침 후에도 유지된다', async ({ page, settingsToken }) => {
|
||||
// 저장 → 새로고침 → 원복 → 새로고침 4왕복이라 기본 30초로는 모자란다.
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await authenticatePage(page, settingsToken);
|
||||
await page.goto(SETTINGS_URL);
|
||||
await expect(page.getByTestId(UNIT)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await chooseOption(page, UNIT, '10점');
|
||||
await chooseOption(page, METHOD, '올림');
|
||||
|
||||
await page.getByTestId('ecommerce-settings-save').click();
|
||||
|
||||
// 저장 payload 에 키가 빠지면 화면에서 고를 수는 있어도 새로고침하면 되돌아간다.
|
||||
await page.reload();
|
||||
await expect(selectedLabel(page, UNIT)).toContainText('10점', { timeout: 15_000 });
|
||||
await expect(selectedLabel(page, METHOD)).toContainText('올림');
|
||||
|
||||
// 원복 — 다른 spec 이 기본값(1점/버림)을 전제한다
|
||||
await chooseOption(page, UNIT, '1점');
|
||||
await chooseOption(page, METHOD, '버림');
|
||||
await page.getByTestId('ecommerce-settings-save').click();
|
||||
await page.reload();
|
||||
await expect(selectedLabel(page, UNIT)).toContainText('1점', { timeout: 15_000 });
|
||||
await expect(selectedLabel(page, METHOD)).toContainText('버림');
|
||||
});
|
||||
|
||||
test('통화 환산 절사(언어/통화 탭)와 섞이지 않는다', async ({ page, settingsToken }) => {
|
||||
await authenticatePage(page, settingsToken);
|
||||
|
||||
// 적립 절사는 마일리지 탭에만 있다
|
||||
await page.goto(SETTINGS_URL);
|
||||
await expect(page.getByTestId(UNIT)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 언어/통화 탭에는 적립 절사 컨트롤이 없다 — 두 설정이 한 화면에 섞이면 운영자가
|
||||
// 외화 표시 반올림을 바꾸려다 적립 지급 규칙을 함께 바꾼다.
|
||||
await page.goto(CURRENCY_URL);
|
||||
await expect(page.getByText('환율/통화 설정').first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId(UNIT)).toHaveCount(0);
|
||||
await expect(page.getByTestId(METHOD)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\CalculationInput;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\CalculationItem;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductAdditionalOptionValueRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingPolicyRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ShippingPolicyResolver;
|
||||
use Modules\Sirsoft\Ecommerce\Services\UserMileageService;
|
||||
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 적립 마일리지 절사 기준의 주문 계산 반영 테스트
|
||||
*
|
||||
* 적립 산출은 종전에 세 갈래(옵션 정액 · 옵션 정률 · 기본 적립률) 모두 `(int) floor` 로
|
||||
* 하드코딩되어 운영자가 조정할 수 없었다. 통화별 마일리지 규칙의 절사 기준이 세 갈래에
|
||||
* 빠짐없이 적용되는지, 그리고 재계산이 주문 시점 기준을 쓰는지 검증한다.
|
||||
*/
|
||||
class MileageEarnRoundingTest extends ModuleTestCase
|
||||
{
|
||||
private OrderCalculationService $service;
|
||||
|
||||
private string $settingsPath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->settingsPath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
|
||||
if (! is_dir($this->settingsPath)) {
|
||||
mkdir($this->settingsPath, 0755, true);
|
||||
}
|
||||
|
||||
$this->writeCurrencySettings();
|
||||
$this->writeMileageSettings('1', 'floor');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach (['language_currency.json', 'mileage.json'] as $file) {
|
||||
$path = $this->settingsPath.'/'.$file;
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function writeCurrencySettings(): void
|
||||
{
|
||||
file_put_contents($this->settingsPath.'/language_currency.json', json_encode([
|
||||
'default_currency' => 'KRW',
|
||||
'currencies' => [
|
||||
[
|
||||
'code' => 'KRW',
|
||||
'name' => ['ko' => 'KRW (원)', 'en' => 'KRW (Won)'],
|
||||
'exchange_rate' => null,
|
||||
'base_unit' => 1000,
|
||||
'rounding_unit' => '1',
|
||||
'rounding_method' => 'floor',
|
||||
'decimal_places' => 0,
|
||||
'is_default' => true,
|
||||
],
|
||||
],
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 마일리지 설정을 지정한 절사 기준으로 기록합니다.
|
||||
*
|
||||
* @param string|null $unit 절사 단위 (null 이면 키 자체를 두지 않음 — 구형 설정 재현)
|
||||
* @param string|null $method 절사 방식 (null 이면 키 자체를 두지 않음)
|
||||
*/
|
||||
private function writeMileageSettings(?string $unit, ?string $method): void
|
||||
{
|
||||
$rule = [
|
||||
'currency_code' => 'KRW',
|
||||
'point_value' => 1,
|
||||
'min_use_amount' => 0,
|
||||
'use_unit' => 1,
|
||||
'max_use_type' => 'percent',
|
||||
'max_use_percent' => 100,
|
||||
'max_use_value' => 0,
|
||||
];
|
||||
|
||||
if ($unit !== null) {
|
||||
$rule[MileageRounding::UNIT_KEY] = $unit;
|
||||
}
|
||||
if ($method !== null) {
|
||||
$rule[MileageRounding::METHOD_KEY] = $method;
|
||||
}
|
||||
|
||||
file_put_contents($this->settingsPath.'/mileage.json', json_encode([
|
||||
'enabled' => true,
|
||||
'default_earn_rate' => 1,
|
||||
'earn_trigger' => 'confirmed',
|
||||
'earn_delay_days' => 0,
|
||||
'currency_rules' => [$rule],
|
||||
'expiry_enabled' => true,
|
||||
'expiry_days' => 365,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// 설정 서비스는 인스턴스 내부 캐시를 갖는다 — 파일만 바꾸면 이미 주입된 인스턴스는
|
||||
// 이전 값을 계속 읽는다. 컨테이너 인스턴스를 버리고 계산 서비스를 다시 조립한다.
|
||||
app()->forgetInstance(EcommerceSettingsService::class);
|
||||
$this->service = $this->makeService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 설정 파일 기준으로 계산 서비스를 조립합니다.
|
||||
*
|
||||
* @return OrderCalculationService 계산 서비스
|
||||
*/
|
||||
private function makeService(): OrderCalculationService
|
||||
{
|
||||
return new OrderCalculationService(
|
||||
new CurrencyConversionService,
|
||||
app(ProductOptionRepositoryInterface::class),
|
||||
app(CouponIssueRepositoryInterface::class),
|
||||
app(ShippingPolicyRepositoryInterface::class),
|
||||
app(EcommerceSettingsService::class),
|
||||
app(ProductAdditionalOptionValueRepositoryInterface::class),
|
||||
app(ShippingPolicyResolver::class)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 적립 설정이 붙은 상품 옵션을 만듭니다.
|
||||
*
|
||||
* @param int $price 판매가
|
||||
* @param float|null $mileageValue 옵션 적립값
|
||||
* @param string|null $mileageType 적립 방식 (fixed|percent)
|
||||
* @return int 상품옵션 ID
|
||||
*/
|
||||
private function makeOption(int $price, ?float $mileageValue = null, ?string $mileageType = null): int
|
||||
{
|
||||
$product = ProductFactory::new()->create([
|
||||
'tax_status' => 'taxable',
|
||||
'selling_price' => $price,
|
||||
'list_price' => $price,
|
||||
]);
|
||||
|
||||
$data = ['price_adjustment' => 0, 'stock_quantity' => 100, 'is_default' => true];
|
||||
if ($mileageValue !== null) {
|
||||
$data['mileage_value'] = $mileageValue;
|
||||
}
|
||||
if ($mileageType !== null) {
|
||||
$data['mileage_type'] = $mileageType;
|
||||
}
|
||||
|
||||
return ProductOptionFactory::new()->forProduct($product)->create($data)->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계산을 수행하고 첫 항목의 적립 포인트를 반환합니다.
|
||||
*
|
||||
* @param int $optionId 상품옵션 ID
|
||||
* @param int $quantity 수량
|
||||
* @param array|null $mileagePolicySnapshot 마일리지 정책 스냅샷 (재계산 시나리오)
|
||||
* @return float 적립 포인트
|
||||
*/
|
||||
private function earnedPoints(int $optionId, int $quantity = 1, ?array $mileagePolicySnapshot = null): float
|
||||
{
|
||||
$result = $this->service->calculate(new CalculationInput(
|
||||
items: [new CalculationItem(productOptionId: $optionId, quantity: $quantity)],
|
||||
mileagePolicySnapshot: $mileagePolicySnapshot,
|
||||
));
|
||||
|
||||
return (float) $result->items[0]->pointsEarning;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 기본_적립률_갈래에_절사_기준이_적용된다(): void
|
||||
{
|
||||
// 78,160원 × 1% = 781.6점
|
||||
$optionId = $this->makeOption(78160);
|
||||
|
||||
$this->assertSame(781.0, $this->earnedPoints($optionId), '기본값(1점 버림)');
|
||||
|
||||
$this->writeMileageSettings('1', 'round');
|
||||
$this->assertSame(782.0, $this->earnedPoints($optionId), '1점 반올림');
|
||||
|
||||
$this->writeMileageSettings('10', 'floor');
|
||||
$this->assertSame(780.0, $this->earnedPoints($optionId), '10점 버림');
|
||||
|
||||
$this->writeMileageSettings('100', 'ceil');
|
||||
$this->assertSame(800.0, $this->earnedPoints($optionId), '100점 올림');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 옵션_정률_갈래에_절사_기준이_적용된다(): void
|
||||
{
|
||||
// 78,160원 × 3.5% = 2,735.6점
|
||||
$optionId = $this->makeOption(78160, 3.5, 'percent');
|
||||
|
||||
$this->assertSame(2735.0, $this->earnedPoints($optionId), '기본값(1점 버림)');
|
||||
|
||||
$this->writeMileageSettings('10', 'ceil');
|
||||
$this->assertSame(2740.0, $this->earnedPoints($optionId), '10점 올림');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 옵션_정액_갈래에_절사_기준이_적용된다(): void
|
||||
{
|
||||
// 정액 505.5점 × 3개 = 1,516.5점
|
||||
$optionId = $this->makeOption(50000, 505.5, 'fixed');
|
||||
|
||||
$this->assertSame(1516.0, $this->earnedPoints($optionId, 3), '기본값(1점 버림)');
|
||||
|
||||
$this->writeMileageSettings('100', 'round');
|
||||
$this->assertSame(1500.0, $this->earnedPoints($optionId, 3), '100점 반올림');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 재계산은_설정이_아니라_주문_시점_기준을_따른다(): void
|
||||
{
|
||||
$optionId = $this->makeOption(78160);
|
||||
|
||||
// 주문 시점: 100점 올림 → 800점
|
||||
$snapshot = [
|
||||
'usable' => true,
|
||||
'currency' => 'KRW',
|
||||
'rule' => [
|
||||
MileageRounding::UNIT_KEY => '100',
|
||||
MileageRounding::METHOD_KEY => 'ceil',
|
||||
],
|
||||
];
|
||||
|
||||
// 그 뒤 운영자가 설정을 1점 버림으로 되돌려도 이 주문의 재계산은 800점이어야 한다.
|
||||
// 설정을 다시 읽으면 취소하지 않은 잔여분의 적립액이 취소 처리만으로 달라진다.
|
||||
$this->writeMileageSettings('1', 'floor');
|
||||
|
||||
$this->assertSame(800.0, $this->earnedPoints($optionId, 1, $snapshot));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 절사_키가_없는_구형_설정은_종전_금액을_그대로_산출한다(): void
|
||||
{
|
||||
$optionId = $this->makeOption(78160);
|
||||
|
||||
$this->writeMileageSettings(null, null);
|
||||
|
||||
// 이 기능 이전 설치본의 mileage.json 에는 절사 키가 없다 — 백필 전에도 금액 불변.
|
||||
$this->assertSame(781.0, $this->earnedPoints($optionId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 주문_스냅샷에_절사_기준이_기록된다(): void
|
||||
{
|
||||
$this->writeMileageSettings('10', 'ceil');
|
||||
|
||||
$snapshot = app(UserMileageService::class)->buildUsagePolicySnapshot(50000, 0, 'KRW');
|
||||
|
||||
$this->assertSame('10', $snapshot['rule'][MileageRounding::UNIT_KEY]);
|
||||
$this->assertSame('ceil', $snapshot['rule'][MileageRounding::METHOD_KEY]);
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderOptionService;
|
||||
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 부분수량 분할 시 적립 포인트 안분 절사 테스트
|
||||
*
|
||||
* 부분취소·부분 구매확정은 주문옵션 레코드를 분할하고 금액을 비율 안분한다. 적립 포인트만은
|
||||
* 원장이 정수로 확정하는 값인데 안분이 `round(..., 2)` 였다 — 3개 중 1개를 분할하면
|
||||
* 781 × (1/3) = 260.33 처럼 **원장에 존재할 수 없는 소수점 포인트**가 옵션 행에 남았고,
|
||||
* 그 값이 곧 적립 목표액(`earnForOrderOption` 의 target)이 되어 실제 지급 가능한 정수
|
||||
* 포인트와 어긋났다.
|
||||
*
|
||||
* 안분에도 주문 시점 절사 기준을 적용하고, 잔여분은 `원본 − 분할` 로 두어 총액을 보존한다.
|
||||
*/
|
||||
class OrderOptionSplitEarnedPointsRoundingTest extends ModuleTestCase
|
||||
{
|
||||
private OrderOptionService $service;
|
||||
|
||||
private string $settingsPath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->service = app(OrderOptionService::class);
|
||||
$this->settingsPath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
|
||||
|
||||
if (! is_dir($this->settingsPath)) {
|
||||
mkdir($this->settingsPath, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents($this->settingsPath.'/mileage.json', json_encode([
|
||||
'enabled' => true,
|
||||
'default_earn_rate' => 1,
|
||||
'earn_trigger' => 'confirmed',
|
||||
// 분할 자체를 관측하기 위해 적립 트리거는 뒤로 미룬다 (분할 → 적립 순서 간섭 배제)
|
||||
'earn_delay_days' => 30,
|
||||
'currency_rules' => [[
|
||||
'currency_code' => 'KRW', 'point_value' => 1, 'min_use_amount' => 0, 'use_unit' => 1,
|
||||
'max_use_type' => 'percent', 'max_use_percent' => 100, 'max_use_value' => 0,
|
||||
]],
|
||||
'expiry_enabled' => true,
|
||||
'expiry_days' => 365,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$file = $this->settingsPath.'/mileage.json';
|
||||
if (file_exists($file)) {
|
||||
unlink($file);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 적립 포인트가 실린 배송완료 주문을 만듭니다.
|
||||
*
|
||||
* @param int $quantity 옵션 수량
|
||||
* @param float $earnedPoints 옵션 적립 포인트
|
||||
* @param array|null $mileagePolicySnapshot 주문 시점 마일리지 정책 스냅샷
|
||||
* @return Order 생성된 주문
|
||||
*/
|
||||
private function makeOrder(int $quantity, float $earnedPoints, ?array $mileagePolicySnapshot = null): Order
|
||||
{
|
||||
$order = OrderFactory::new()->create([
|
||||
'order_status' => OrderStatusEnum::DELIVERED,
|
||||
'currency' => 'KRW',
|
||||
'mileage_policy_snapshot' => $mileagePolicySnapshot,
|
||||
]);
|
||||
|
||||
OrderOptionFactory::new()->forOrder($order)->create([
|
||||
'option_status' => OrderStatusEnum::DELIVERED,
|
||||
'delivered_at' => now()->subDay(),
|
||||
'quantity' => $quantity,
|
||||
'subtotal_earned_points_amount' => $earnedPoints,
|
||||
]);
|
||||
|
||||
return $order->fresh();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 분할된_적립_포인트에_소수점이_남지_않는다(): void
|
||||
{
|
||||
// 781점 / 3개 → 1개 분할 시 260.333... 점
|
||||
$order = $this->makeOrder(3, 781);
|
||||
$option = $order->options->first();
|
||||
|
||||
$this->service->changeStatusWithQuantity($option, OrderStatusEnum::CANCELLED, 1);
|
||||
|
||||
$points = Order::find($order->id)->options
|
||||
->pluck('subtotal_earned_points_amount')
|
||||
->map(fn ($v) => (float) $v);
|
||||
|
||||
foreach ($points as $value) {
|
||||
$this->assertSame(
|
||||
(float) (int) $value,
|
||||
$value,
|
||||
"분할 안분 결과에 소수점 포인트가 남았다 ({$value}) — 원장은 정수 포인트만 기록한다"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 분할해도_적립_총액이_보존된다(): void
|
||||
{
|
||||
$order = $this->makeOrder(3, 781);
|
||||
$option = $order->options->first();
|
||||
|
||||
$this->service->changeStatusWithQuantity($option, OrderStatusEnum::CANCELLED, 1);
|
||||
|
||||
$total = Order::find($order->id)->options->sum(fn ($o) => (float) $o->subtotal_earned_points_amount);
|
||||
|
||||
$this->assertSame(781.0, $total, '분할 전후 적립 총액이 달라졌다 — 잔여분은 원본 − 분할이어야 한다');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 안분도_주문_시점_절사_기준을_따른다(): void
|
||||
{
|
||||
// 주문 시점 기준: 10점 올림. 781 × (1/3) = 260.33 → 270
|
||||
$order = $this->makeOrder(3, 781, [
|
||||
'usable' => true,
|
||||
'currency' => 'KRW',
|
||||
'rule' => [
|
||||
MileageRounding::UNIT_KEY => '10',
|
||||
MileageRounding::METHOD_KEY => 'ceil',
|
||||
],
|
||||
]);
|
||||
$option = $order->options->first();
|
||||
|
||||
$this->service->changeStatusWithQuantity($option, OrderStatusEnum::CANCELLED, 1);
|
||||
|
||||
$fresh = Order::find($order->id)->options;
|
||||
$split = $fresh->firstWhere('option_status', OrderStatusEnum::CANCELLED);
|
||||
$remaining = $fresh->firstWhere('option_status', OrderStatusEnum::DELIVERED);
|
||||
|
||||
$this->assertSame(270.0, (float) $split->subtotal_earned_points_amount);
|
||||
$this->assertSame(511.0, (float) $remaining->subtotal_earned_points_amount, '잔여분 = 원본 − 분할');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 스냅샷이_없는_구형_주문은_1점_버림으로_안분된다(): void
|
||||
{
|
||||
// 이 기능 이전 주문에는 절사 기준이 없다 — 폴백이 정수화를 보장해야 한다.
|
||||
$order = $this->makeOrder(3, 781, null);
|
||||
$option = $order->options->first();
|
||||
|
||||
$this->service->changeStatusWithQuantity($option, OrderStatusEnum::CANCELLED, 1);
|
||||
|
||||
$fresh = Order::find($order->id)->options;
|
||||
$split = $fresh->firstWhere('option_status', OrderStatusEnum::CANCELLED);
|
||||
$remaining = $fresh->firstWhere('option_status', OrderStatusEnum::DELIVERED);
|
||||
|
||||
$this->assertSame(260.0, (float) $split->subtotal_earned_points_amount);
|
||||
$this->assertSame(521.0, (float) $remaining->subtotal_earned_points_amount);
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -629,11 +629,18 @@ class OrderProcessingServiceTest extends ModuleTestCase
|
||||
103000
|
||||
);
|
||||
|
||||
// shipping_policy_applied_snapshot이 비어있지 않아야 함
|
||||
// shipping_policy_applied_snapshot 은 `{items: [...], address: {...}}` 구조여야 한다.
|
||||
// 항목 목록과 배송지 메타를 한 배열에 섞으면 PHP 배열이 non-sequential 이 되어
|
||||
// json_encode 가 객체로 직렬화하고, 배열을 전제하는 화면에서 표시가 사라진다.
|
||||
$snapshot = $order->shipping_policy_applied_snapshot;
|
||||
$this->assertNotEmpty($snapshot);
|
||||
$this->assertEquals($productOption->id, $snapshot[0]['product_option_id']);
|
||||
$this->assertEquals(1, $snapshot[0]['policy']['policy_id']);
|
||||
$this->assertArrayHasKey('items', $snapshot);
|
||||
$this->assertArrayHasKey('address', $snapshot);
|
||||
$this->assertEquals($productOption->id, $snapshot['items'][0]['product_option_id']);
|
||||
$this->assertEquals(1, $snapshot['items'][0]['policy']['policy_id']);
|
||||
|
||||
// items 는 JSON 에서 반드시 배열 리터럴로 나가야 한다 (객체면 프론트 .find 가 죽는다)
|
||||
$this->assertStringContainsString('"items":[', json_encode($snapshot));
|
||||
}
|
||||
|
||||
public function test_create_from_temp_order_saves_order_meta_with_calculation_input(): void
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Support;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 적립 마일리지 절사 규칙 SSoT 단위 테스트
|
||||
*
|
||||
* 이 클래스가 규칙 해석과 적용을 단독으로 책임진다. 계산·안분·스냅샷 세 지점이 모두
|
||||
* 여기를 거치므로, 폴백이 종전 하드코딩(`(int) floor`)과 어긋나면 기존 사이트의 적립액이
|
||||
* 아무도 설정을 바꾸지 않았는데 달라진다.
|
||||
*/
|
||||
class MileageRoundingTest extends ModuleTestCase
|
||||
{
|
||||
#[Test]
|
||||
public function 규칙이_없으면_종전_동작인_1점_버림으로_폴백한다(): void
|
||||
{
|
||||
$this->assertSame(['unit' => '1', 'method' => 'floor'], MileageRounding::normalize(null));
|
||||
$this->assertSame(['unit' => '1', 'method' => 'floor'], MileageRounding::normalize([]));
|
||||
|
||||
// 폴백 등가성: 절사 전 값이 무엇이든 (int) floor 와 같은 결과여야 한다
|
||||
foreach ([0, 0.4, 1.0, 781.2, 781.6, 1234.999] as $raw) {
|
||||
$this->assertSame(
|
||||
(int) floor($raw),
|
||||
MileageRounding::apply($raw),
|
||||
"폴백이 종전 (int) floor 동작과 어긋난다 (입력: {$raw})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 허용되지_않은_값은_기본값으로_접는다(): void
|
||||
{
|
||||
$folded = MileageRounding::normalize([
|
||||
MileageRounding::UNIT_KEY => '7', // 허용 목록 밖
|
||||
MileageRounding::METHOD_KEY => 'trunc', // 허용 목록 밖
|
||||
]);
|
||||
|
||||
// 해석 불가한 값에서 임의 방식을 고르면 지급액이 조용히 달라진다 — 기본값으로 접는다.
|
||||
$this->assertSame(['unit' => '1', 'method' => 'floor'], $folded);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{0: string, 1: string, 2: float, 3: int}>
|
||||
*/
|
||||
public static function 절사_조합(): array
|
||||
{
|
||||
return [
|
||||
'1점 버림' => ['1', 'floor', 781.6, 781],
|
||||
'1점 반올림' => ['1', 'round', 781.6, 782],
|
||||
'1점 올림' => ['1', 'ceil', 781.2, 782],
|
||||
'10점 버림' => ['10', 'floor', 781.6, 780],
|
||||
'10점 반올림' => ['10', 'round', 785.0, 790],
|
||||
'10점 올림' => ['10', 'ceil', 781.0, 790],
|
||||
'100점 버림' => ['100', 'floor', 781.6, 700],
|
||||
'100점 반올림' => ['100', 'round', 781.6, 800],
|
||||
'100점 올림' => ['100', 'ceil', 701.0, 800],
|
||||
'0점은 어떤 조합에서도 0' => ['100', 'ceil', 0.0, 0],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('절사_조합')]
|
||||
public function 단위와_방식_조합이_그대로_적용된다(string $unit, string $method, float $raw, int $expected): void
|
||||
{
|
||||
$result = MileageRounding::applyRule($raw, [
|
||||
MileageRounding::UNIT_KEY => $unit,
|
||||
MileageRounding::METHOD_KEY => $method,
|
||||
]);
|
||||
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 부동소수_오차가_한_단위를_깎지_않는다(): void
|
||||
{
|
||||
// 7810 × 0.1 은 780.9999999999999 로 계산돼 버림에서 780 이 된다.
|
||||
// 절사 기준 자체와 무관한 계산 잔여물이 지급액을 1점 깎으면 안 된다.
|
||||
$this->assertSame(781, MileageRounding::apply(7810 * 0.1));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 주문_스냅샷에서_절사_기준을_읽는다(): void
|
||||
{
|
||||
$snapshot = [
|
||||
'usable' => true,
|
||||
'currency' => 'KRW',
|
||||
'rule' => [
|
||||
'use_unit' => 10,
|
||||
MileageRounding::UNIT_KEY => '100',
|
||||
MileageRounding::METHOD_KEY => 'ceil',
|
||||
],
|
||||
];
|
||||
|
||||
$this->assertSame(
|
||||
['unit' => '100', 'method' => 'ceil'],
|
||||
MileageRounding::fromOrderSnapshot($snapshot)
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 이_기능_이전_주문의_스냅샷은_종전_동작으로_해석된다(): void
|
||||
{
|
||||
// 절사 키가 없던 시절의 스냅샷 — 그때의 동작이 곧 기본값이므로 재계산 결과가 보존된다.
|
||||
$legacy = [
|
||||
'usable' => true,
|
||||
'currency' => 'KRW',
|
||||
'rule' => ['point_value' => 1.0, 'use_unit' => 10, 'max_use_type' => 'percent'],
|
||||
];
|
||||
|
||||
$this->assertSame(['unit' => '1', 'method' => 'floor'], MileageRounding::fromOrderSnapshot($legacy));
|
||||
$this->assertSame(['unit' => '1', 'method' => 'floor'], MileageRounding::fromOrderSnapshot(null));
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Upgrades;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 마일리지 적립 절사 기준 백필 마이그레이션 테스트
|
||||
*
|
||||
* 폴백이 있어 백필 없이도 금액은 같다. 이 백필의 목적은 "운영자가 버림을 선택했다" 와
|
||||
* "키가 없어서 폴백됐다" 를 설정 파일에서 구분 가능하게 만드는 것이다 — 그래야 이후 폴백
|
||||
* 기본값을 바꿔도 기존 사이트의 적립 절사가 조용히 따라 바뀌지 않는다.
|
||||
*/
|
||||
class BackfillMileageEarnRoundingTest extends ModuleTestCase
|
||||
{
|
||||
private const MIGRATION = 'App\\Upgrades\\Data\\Ext\\Modules\\SirsoftEcommerce\\V1_0_5\\Migrations\\BackfillMileageEarnRounding';
|
||||
|
||||
private string $settingsFile;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// DataMigration 클래스는 오토로드 밖 — 직접 로드
|
||||
require_once dirname(__DIR__, 3).'/upgrades/data/1.0.5/migrations/07_BackfillMileageEarnRounding.php';
|
||||
|
||||
$dir = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
|
||||
if (! File::isDirectory($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
$this->settingsFile = $dir.'/mileage.json';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::exists($this->settingsFile)) {
|
||||
File::delete($this->settingsFile);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function migration(): object
|
||||
{
|
||||
$class = self::MIGRATION;
|
||||
|
||||
return new $class;
|
||||
}
|
||||
|
||||
private function context(): UpgradeContext
|
||||
{
|
||||
return new UpgradeContext(
|
||||
fromVersion: '1.0.2',
|
||||
toVersion: '1.0.5',
|
||||
currentStep: '1.0.5',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 마일리지 설정 파일을 기록합니다.
|
||||
*
|
||||
* @param array<int, array> $rules 통화별 규칙
|
||||
*/
|
||||
private function writeSettings(array $rules): void
|
||||
{
|
||||
File::put($this->settingsFile, json_encode([
|
||||
'enabled' => true,
|
||||
'default_earn_rate' => 1,
|
||||
'currency_rules' => $rules,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array> 저장된 통화별 규칙
|
||||
*/
|
||||
private function readRules(): array
|
||||
{
|
||||
return json_decode((string) File::get($this->settingsFile), true)['currency_rules'];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 절사_키가_없는_행에_종전_동작값을_기입한다(): void
|
||||
{
|
||||
$this->writeSettings([
|
||||
['currency_code' => 'KRW', 'point_value' => 1, 'use_unit' => 10],
|
||||
['currency_code' => 'USD', 'point_value' => 0.001, 'use_unit' => 1],
|
||||
]);
|
||||
|
||||
$this->migration()->run($this->context());
|
||||
|
||||
foreach ($this->readRules() as $rule) {
|
||||
$this->assertSame('1', $rule[MileageRounding::UNIT_KEY]);
|
||||
$this->assertSame('floor', $rule[MileageRounding::METHOD_KEY]);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 이미_기입된_값은_덮어쓰지_않는다(): void
|
||||
{
|
||||
$this->writeSettings([
|
||||
[
|
||||
'currency_code' => 'KRW',
|
||||
MileageRounding::UNIT_KEY => '100',
|
||||
MileageRounding::METHOD_KEY => 'ceil',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->migration()->run($this->context());
|
||||
// 멱등 확인 — 두 번 돌려도 운영자 선택이 보존되어야 한다
|
||||
$this->migration()->run($this->context());
|
||||
|
||||
$rules = $this->readRules();
|
||||
$this->assertSame('100', $rules[0][MileageRounding::UNIT_KEY]);
|
||||
$this->assertSame('ceil', $rules[0][MileageRounding::METHOD_KEY]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 한쪽_키만_있는_행은_빠진_키만_채운다(): void
|
||||
{
|
||||
$this->writeSettings([
|
||||
['currency_code' => 'KRW', MileageRounding::METHOD_KEY => 'round'],
|
||||
]);
|
||||
|
||||
$this->migration()->run($this->context());
|
||||
|
||||
$rules = $this->readRules();
|
||||
$this->assertSame('1', $rules[0][MileageRounding::UNIT_KEY], '빠진 단위는 채워야 한다');
|
||||
$this->assertSame('round', $rules[0][MileageRounding::METHOD_KEY], '있던 방식은 보존해야 한다');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 설정_파일이_없으면_아무것도_만들지_않는다(): void
|
||||
{
|
||||
if (File::exists($this->settingsFile)) {
|
||||
File::delete($this->settingsFile);
|
||||
}
|
||||
|
||||
$this->migration()->run($this->context());
|
||||
|
||||
// 신규 설치는 config/settings/defaults.json 이 값을 갖는다 — 여기서 파일을 만들면
|
||||
// 운영자가 저장한 적 없는 설정 파일이 생겨 defaults 병합 경로가 달라진다.
|
||||
$this->assertFalse(File::exists($this->settingsFile));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 백필_후_적립_산출이_종전과_같다(): void
|
||||
{
|
||||
$this->writeSettings([['currency_code' => 'KRW', 'point_value' => 1]]);
|
||||
|
||||
$this->migration()->run($this->context());
|
||||
|
||||
$rounding = MileageRounding::normalize($this->readRules()[0]);
|
||||
|
||||
foreach ([781.6, 2735.6, 1516.5] as $raw) {
|
||||
$this->assertSame(
|
||||
(int) floor($raw),
|
||||
MileageRounding::apply($raw, $rounding),
|
||||
'백필된 값이 종전 (int) floor 동작과 어긋난다'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# audit:allow test-scenario-coverage reason: |
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 이 없어 fallback YAML 파서가
|
||||
# nested axes 배열을 읽지 못하는 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도
|
||||
# audit:allow 처리됨). 아래 조합은 PHPUnit 3종(SSoT 단위 / 주문계산 / 분할 안분) +
|
||||
# Feature(설정 저장·검증) + Vitest(레이아웃) + Playwright(설정 왕복)로 전수 커버되어
|
||||
# green 이다.
|
||||
feature: 마일리지 적립 절사 기준 (통화별 설정 신설)
|
||||
|
||||
description: |
|
||||
적립 포인트 산출이 세 갈래(옵션 정액 · 옵션 정률 · 기본 적립률) 모두 `(int) floor` 로
|
||||
하드코딩돼 운영자가 조정할 수 없었고, 부분취소 분할 안분은 `round(..., 2)` 라 원장에
|
||||
존재할 수 없는 소수점 포인트를 만들었다.
|
||||
|
||||
통화별 마일리지 규칙(`mileage.currency_rules.*`)에 절사 단위(1/10/100)와 방식
|
||||
(floor/round/ceil)을 신설하고, 산출·안분·재계산이 같은 기준을 쓰게 한다. 기준은 주문
|
||||
시점에 `mileage_policy_snapshot.rule` 로 고정되어 이후 설정 변경이 과거 주문에 소급되지
|
||||
않는다.
|
||||
|
||||
통화 환산 절사(`language_currency.currencies.*.rounding_*`)와는 별개다 — 그쪽은 외화
|
||||
표시 전용이라 기본 통화 분기에서는 적용조차 되지 않으므로 적립 규칙으로 쓸 수 없다.
|
||||
|
||||
axes:
|
||||
rounding_unit:
|
||||
- "1" # 1점 (기본 — 종전 하드코딩과 동일)
|
||||
- "10" # 10점
|
||||
- "100" # 100점
|
||||
rounding_method:
|
||||
- floor # 버림 (기본)
|
||||
- round # 반올림
|
||||
- ceil # 올림
|
||||
earn_branch:
|
||||
- option_fixed # 옵션 정액 적립 (mileage_type=fixed)
|
||||
- option_percent # 옵션 정률 적립 (mileage_type=percent)
|
||||
- default_rate # 기본 적립률 (mileage.default_earn_rate)
|
||||
rule_source:
|
||||
- live_settings # 신규 주문 — 현재 설정
|
||||
- order_snapshot # 재계산 — 주문 시점 스냅샷
|
||||
- legacy_absent # 절사 키 없음 (기능 이전 설치본/주문) → 기본값 폴백
|
||||
proration_path:
|
||||
- none # 분할 없음
|
||||
- partial_cancel # 부분취소 분할 (원본 × ratio / 잔여 = 원본 − 분할)
|
||||
settings_input:
|
||||
- allowed_value # 허용 목록 내 값 → 저장
|
||||
- out_of_vocabulary # 허용 목록 밖 값 → 422
|
||||
viewport:
|
||||
- desktop_table # 데스크톱 테이블 뷰
|
||||
- mobile_cards # 모바일 카드 뷰
|
||||
|
||||
effects:
|
||||
# 산출
|
||||
- default_rate_branch_applies_rounding_rule # 기본 적립률 갈래에 단위/방식 적용
|
||||
- option_percent_branch_applies_rounding_rule # 옵션 정률 갈래에 적용
|
||||
- option_fixed_branch_applies_rounding_rule # 옵션 정액 갈래에 적용
|
||||
- fallback_matches_legacy_int_floor # 폴백이 종전 (int) floor 와 동일 금액
|
||||
- out_of_vocabulary_rule_folds_to_default # 오염값은 기본값으로 접어 지급액 요동 차단
|
||||
- float_error_does_not_shave_one_unit # 부동소수 잔여물이 1단위를 깎지 않음
|
||||
# 스냅샷
|
||||
- order_snapshot_records_earn_rounding_rule # 주문 시점 절사 기준이 스냅샷에 기록
|
||||
- recalculation_uses_snapshot_not_live_settings # 재계산은 스냅샷 우선 (설정 소급 차단)
|
||||
- legacy_order_snapshot_resolves_to_default # 절사 키 없는 구형 주문은 기본값 해석
|
||||
# 안분
|
||||
- split_earned_points_have_no_fraction # 분할 안분에 소수점 포인트가 남지 않음
|
||||
- split_preserves_total_earned_points # 분할 전후 적립 총액 보존
|
||||
- split_follows_order_snapshot_rounding # 안분도 주문 시점 기준을 따름
|
||||
# 설정 표면
|
||||
- settings_save_persists_earn_rounding_rule # 저장 후 재조회에 값 유지
|
||||
- settings_reject_out_of_vocabulary_with_422 # 허용 목록 밖 값 422 + 필드별 오류
|
||||
- settings_controls_render_in_both_viewports # 테이블·카드 양쪽에 컨트롤 존재
|
||||
- settings_new_currency_row_includes_rounding # 신규 통화 추가 payload 에 절사 키 포함
|
||||
- settings_screen_options_match_server_vocabulary # 화면 옵션 ⊆ 서버 허용 어휘
|
||||
- settings_earn_rounding_separate_from_exchange # 통화 환산 절사와 화면상 분리
|
||||
- settings_rounding_persists_across_reload # 새로고침 후에도 선택 유지
|
||||
# 업그레이드
|
||||
- upgrade_backfills_missing_rounding_keys # 기존 설치본에 종전 동작값 명시 기입
|
||||
- upgrade_preserves_operator_chosen_values # 이미 선택된 값은 덮어쓰지 않음 (멱등)
|
||||
- upgrade_skips_when_settings_file_absent # 설정 파일 없으면 생성하지 않음
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Support/MileageRoundingTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnRoundingTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionSplitEarnedPointsRoundingTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/BackfillMileageEarnRoundingTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsMileageTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mileage-earn-rounding.spec.ts
|
||||
@@ -23,6 +23,10 @@ use App\Extension\AbstractUpgradeStep;
|
||||
* 항목 목록과 배송지 메타를 한 배열에 섞어 담아 json_encode 가 리스트가 아닌 객체로
|
||||
* 직렬화했고, 배열을 전제한 주문 상세 화면에서 배송정책 줄이 통째로 사라졌다.
|
||||
*
|
||||
* 4. 마일리지 통화별 규칙에 적립 절사 기준을 명시 기입한다.
|
||||
* 값이 없으면 종전 동작(1점 버림)으로 폴백하므로 금액은 달라지지 않지만, 명시해 두지
|
||||
* 않으면 이후 폴백 기본값이 바뀔 때 기존 사이트의 적립 절사가 조용히 따라 바뀐다.
|
||||
*
|
||||
* 모든 비즈니스 로직은 data/1.0.5/migrations/ 로 격리(AbstractUpgradeStep 규약).
|
||||
*
|
||||
* @upgrade-path A
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_5\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 마일리지 통화별 규칙에 적립 절사 기준을 명시 기입합니다.
|
||||
*
|
||||
* 적립 포인트 산출은 종전에 `(int) floor(...)` 로 하드코딩되어 운영자가 조정할 수
|
||||
* 없었습니다. 이제 통화별 규칙에 `earn_rounding_unit` / `earn_rounding_method` 를 두어
|
||||
* 선택할 수 있으며, 값이 없으면 `1` / `floor` 로 폴백해 종전과 같은 금액을 산출합니다.
|
||||
*
|
||||
* 폴백이 있으므로 백필 없이도 동작은 같습니다. 그럼에도 기존 설치본의 저장 파일에 값을
|
||||
* 명시하는 이유는 두 가지입니다.
|
||||
*
|
||||
* 1. 설정 파일이 SSoT 인데 키가 없으면 "운영자가 버림을 선택했다" 와 "키가 없어서
|
||||
* 폴백됐다" 가 구분되지 않습니다.
|
||||
* 2. 이후 폴백 기본값을 바꾸면 값을 명시하지 않은 기존 사이트의 적립 절사가 아무도
|
||||
* 건드리지 않았는데 조용히 바뀝니다. 명시해 두면 그 경로가 닫힙니다.
|
||||
*
|
||||
* 이미 값이 있는 행은 건드리지 않습니다(idempotent). 파일이 없으면 신규 설치이므로
|
||||
* `config/settings/defaults.json` 이 값을 갖고 있어 스킵합니다.
|
||||
*
|
||||
* V-1 안전: Illuminate\Support\Facades\File + 로컬 헬퍼만 사용.
|
||||
*/
|
||||
class BackfillMileageEarnRounding implements DataMigration
|
||||
{
|
||||
private const MODULE_IDENTIFIER = 'sirsoft-ecommerce';
|
||||
|
||||
/** 적립 절사 단위 키 (Support\MileageRounding::UNIT_KEY 와 동일 — 업그레이드 코드는 모듈 클래스에 의존하지 않는다) */
|
||||
private const UNIT_KEY = 'earn_rounding_unit';
|
||||
|
||||
/** 적립 절사 방식 키 */
|
||||
private const METHOD_KEY = 'earn_rounding_method';
|
||||
|
||||
/** 종전 하드코딩 동작과 같은 값 */
|
||||
private const LEGACY_UNIT = '1';
|
||||
|
||||
private const LEGACY_METHOD = 'floor';
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillMileageEarnRounding';
|
||||
}
|
||||
|
||||
/**
|
||||
* 백필을 수행합니다.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = $this->settingsFilePath();
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[ecommerce:1.0.5] mileage.json 미존재 — 적립 절사 기준 백필 스킵');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = json_decode((string) File::get($path), true);
|
||||
|
||||
if (! is_array($settings) || ! isset($settings['currency_rules']) || ! is_array($settings['currency_rules'])) {
|
||||
$context->logger->info('[ecommerce:1.0.5] mileage.currency_rules 없음 — 적립 절사 기준 백필 스킵');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$rules = $settings['currency_rules'];
|
||||
$filled = 0;
|
||||
|
||||
foreach ($rules as $idx => $rule) {
|
||||
if (! is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$touched = false;
|
||||
|
||||
if (! array_key_exists(self::UNIT_KEY, $rule)) {
|
||||
$rules[$idx][self::UNIT_KEY] = self::LEGACY_UNIT;
|
||||
$touched = true;
|
||||
}
|
||||
|
||||
if (! array_key_exists(self::METHOD_KEY, $rule)) {
|
||||
$rules[$idx][self::METHOD_KEY] = self::LEGACY_METHOD;
|
||||
$touched = true;
|
||||
}
|
||||
|
||||
if ($touched) {
|
||||
$filled++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($filled === 0) {
|
||||
$context->logger->info('[ecommerce:1.0.5] 적립 절사 기준 이미 기입됨 — 백필 스킵 (idempotent)');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$settings['currency_rules'] = $rules;
|
||||
|
||||
File::put($path, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$context->logger->info(sprintf(
|
||||
'[ecommerce:1.0.5] 적립 절사 기준 백필 완료 (%d개 통화 규칙 → %s / %s)',
|
||||
$filled,
|
||||
self::LEGACY_UNIT,
|
||||
self::LEGACY_METHOD
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* mileage.json 의 저장 경로를 반환합니다.
|
||||
*
|
||||
* 테스트 환경에서는 운영 storage 오염을 막기 위해 framework/testing 경로를 사용합니다
|
||||
* (EcommerceSettingsService 의 저장 경로 분기와 동일).
|
||||
*
|
||||
* @return string 설정 파일 절대 경로
|
||||
*/
|
||||
private function settingsFilePath(): string
|
||||
{
|
||||
$base = app()->runningUnitTests()
|
||||
? 'framework/testing/modules/'
|
||||
: 'app/modules/';
|
||||
|
||||
return storage_path($base.self::MODULE_IDENTIFIER.'/settings/mileage.json');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user