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:
HeuJung
2026-08-04 09:56:00 +09:00
parent 0ae3289c60
commit 60cccbf62d
36 changed files with 1793 additions and 65 deletions
@@ -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以上である必要があります。',
@@ -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"
}
}
}
},
@@ -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 기준 소멸(일부/전체)·만료예정·무기한·없음을 렌더한다', () => {
@@ -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');
@@ -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",
@@ -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": "유효기간",
@@ -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"
},
@@ -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
@@ -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,
];
}
}
@@ -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' => [
@@ -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 권한 없는 계정은 차단.
*/
@@ -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]);
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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
@@ -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');
}
}