diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 6276b040..bd5ec181 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -10,6 +10,7 @@ - 무통장입금 자동취소 기한을 비운 채 저장할 때 나오는 안내 문구 일본어 번역 추가. - 유효기간이 설정되지 않은 쿠폰을 발급할 때 나오는 안내 문구 일본어 번역 추가. +- 마일리지 설정의 통화별 적립 절사 기준(단위·방식) 항목명과 선택지, 허용값 위반 안내 문구 일본어 번역 추가. ### Fixed diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php index c0e72585..287e114e 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php @@ -1296,6 +1296,8 @@ return [ 'mileage.currency_rules.*.use_unit' => '使用単位', 'mileage.currency_rules.*.max_use_percent' => '最大使用率', 'mileage.currency_rules.*.max_use_value' => '最大使用金額', + 'mileage.currency_rules.*.earn_rounding_unit' => '積立端数処理単位', + 'mileage.currency_rules.*.earn_rounding_method' => '積立端数処理方式', 'mileage.expiry_days' => '有効期限', 'mileage.expiry_notification_days_before' => '失効予定通知日', 'country_settings' => '国別設定', @@ -1714,6 +1716,12 @@ return [ 'numeric' => '1ポイント当たりの金額は数字である必要があります。', 'min' => '1ポイント当たりの金額は0より大きい必要があります。', ], + 'earn_rounding_unit' => [ + 'in' => '積立端数処理単位は1、10、100のいずれかである必要があります。', + ], + 'earn_rounding_method' => [ + 'in' => '積立端数処理方式はfloor、round、ceilのいずれかである必要があります。', + ], 'max_use_value' => [ 'integer' => '最大使用金額は整数である必要があります。', 'min' => '最大使用金額は0以上である必要があります。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/mileage_settings.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/mileage_settings.json index 2812b7be..e4e26c09 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/mileage_settings.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/mileage_settings.json @@ -39,7 +39,19 @@ "empty": "登録された通貨ルールはありません。", "code_placeholder": "例: USD", "duplicate_currency_error": "すでに登録されている通貨です。", - "max_use_fixed_suffix": "ウォン" + "max_use_fixed_suffix": "ウォン", + "earn_rounding": "積立端数処理", + "earn_rounding_hint": "積立ポイントの算出・按分時に適用", + "earn_rounding_units": { + "1": "1ポイント", + "10": "10ポイント", + "100": "100ポイント" + }, + "earn_rounding_methods": { + "floor": "切り捨て", + "round": "四捨五入", + "ceil": "切り上げ" + } }, "expiry": { "title": "有効期限", diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 92a900eb..ab13675b 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -21,6 +21,7 @@ - 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. - 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다. - 이커머스 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시). +- 마일리지 설정의 통화별 규칙에 적립 절사 기준(1점 / 10점 / 100점 단위 × 버림 / 반올림 / 올림)을 추가했습니다. 적립 포인트를 계산할 때와 부분취소로 주문이 나뉠 때 모두 이 기준이 적용됩니다. 기존 쇼핑몰은 지금까지와 같은 "1점 단위 버림"으로 시작하므로 적립액이 달라지지 않습니다. 이 기준은 주문 시점의 값이 함께 보관되어, 이후 기준을 바꿔도 과거 주문의 적립액이 소급해서 바뀌지 않습니다. 언어/통화 설정의 환율 절사와는 별개 항목입니다(그쪽은 외화 표시 환산에만 적용됩니다). ### Changed diff --git a/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json b/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json index 824d8773..fb974294 100644 --- a/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json +++ b/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json @@ -303,7 +303,9 @@ "use_unit": 10, "max_use_type": "fixed", "max_use_percent": 30, - "max_use_value": 50000 + "max_use_value": 50000, + "earn_rounding_unit": "1", + "earn_rounding_method": "floor" } ], "expiry_enabled": true, diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md index 2d97bdd7..40cc97a4 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md @@ -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` | 생성 일시 | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md index 643854ab..4ed45540 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md @@ -864,7 +864,9 @@ HTTP/1.1 200 "use_unit": 10, "max_use_type": "fixed", "max_use_percent": 30, - "max_use_value": 50000 + "max_use_value": 50000, + "earn_rounding_unit": "1", + "earn_rounding_method": "floor" } ], "expiry_enabled": true, @@ -1905,7 +1907,9 @@ HTTP/1.1 200 "use_unit": 10, "max_use_type": "fixed", "max_use_percent": 30, - "max_use_value": 50000 + "max_use_value": 50000, + "earn_rounding_unit": "1", + "earn_rounding_method": "floor" } ], "expiry_enabled": true, @@ -2056,6 +2060,19 @@ HTTP/1.1 200 - 정규화 제외: 비숫자 문자열(`"abc"`), 빈 문자열, `null`, 불리언, 그리고 `integer` 필드에 전달된 소수 문자열(`"3.7"`) — 검증을 느슨하게 만들지 않기 위해 캐스트하지 않고 그대로 검증 실패시킵니다 - 조회(`GET`) 응답도 `defaults.json` 스키마의 숫자 타입으로 정규화되어 반환되므로, 저장 시점의 표현 형태와 무관하게 숫자로 수신됩니다 +**마일리지 적립 절사 기준** `mileage.currency_rules.*` 의 두 필드가 적립 포인트 산출·안분의 절사 기준을 정합니다. + +| 필드 | 타입 | 허용값 | 기본값 | 설명 | +| --- | --- | --- | --- | --- | +| `earn_rounding_unit` | string | `1` · `10` · `100` | `1` | 적립 포인트를 맞출 단위(점). 마일리지는 원장에 정수로 확정되므로 소수 단위는 허용하지 않습니다 | +| `earn_rounding_method` | string | `floor` · `round` · `ceil` | `floor` | 단위에 맞출 방식(버림 / 반올림 / 올림) | + +- 적용 지점: 옵션 정액 적립 · 옵션 정률 적립 · 기본 적립률 세 갈래 전부, 그리고 부분취소로 주문옵션이 분할될 때의 적립액 안분 +- 기준 통화 선택: 마일리지는 표시 통화가 아니라 기준 통화(`currency_snapshot.base_currency`)로 적립·정산되므로, 그 통화의 규칙을 사용하고 없으면 첫 규칙(기본 통화)으로 폴백합니다 +- 주문 시점 고정: 주문 생성 시 `mileage_policy_snapshot.rule` 에 함께 기록되며, 부분취소·추가결제 재계산은 현재 설정이 아니라 이 스냅샷을 사용합니다. 그렇지 않으면 이후 설정 변경이 과거 주문에 소급돼, 취소하지 않은 잔여분의 적립액이 취소 처리만으로 달라집니다 +- 값이 없는 경우(이 필드 도입 이전 설치본·주문): 기본값 `1` / `floor` 로 해석되며 이는 도입 이전 동작과 동일한 금액을 산출합니다 +- 통화 환산 절사(`language_currency.currencies.*.rounding_unit` / `rounding_method`)와는 별개입니다 — 그쪽은 외화 표시 환산에만 적용되어 기본 통화에는 적용되지 않으므로 적립 규칙으로 쓸 수 없습니다 + ### PUT /api/modules/sirsoft-ecommerce/admin/settings/banks - **라우트명**: `api.modules.sirsoft-ecommerce.admin.settings.store-banks` @@ -2574,7 +2591,9 @@ HTTP/1.1 200 "use_unit": 10, "max_use_type": "fixed", "max_use_percent": 30, - "max_use_value": 50000 + "max_use_value": 50000, + "earn_rounding_unit": "1", + "earn_rounding_method": "floor" } ], "expiry_enabled": true, diff --git a/modules/_bundled/sirsoft-ecommerce/editor-spec.json b/modules/_bundled/sirsoft-ecommerce/editor-spec.json index 7882bc1d..071bf224 100644 --- a/modules/_bundled/sirsoft-ecommerce/editor-spec.json +++ b/modules/_bundled/sirsoft-ecommerce/editor-spec.json @@ -1361,8 +1361,20 @@ } }, "shipping_policy_applied_snapshot": { - "policy_name": "기본 배송 정책", - "shipping_amount": 3000 + "items": [ + { + "product_option_id": 9001, + "policy": { + "policy_name": "기본 배송 정책", + "standalone_shipping_amount": 3000, + "standalone_shipping_amount_formatted": "3,000원" + } + } + ], + "address": { + "country_code": "KR", + "zipcode": "06234" + } } } }, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx index 121d99c6..3d41d1d4 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx @@ -291,9 +291,11 @@ describe('DataGrid (_transactions_table.json)', () => { expect(grid.props.data).toContain('transactions?.data?.data'); }); - it('컬럼 8종(no/일시/회원/유형/금액/잔여/유효기간/주문/부여)', () => { + it('컬럼 9종(번호/일시/회원/유형/금액/잔여/유효기간/주문/부여)', () => { + // 첫 컬럼 field 는 `number` 다 (표시 헤더는 "NO"). 종전 `no` 표기는 레이아웃에서 + // 이미 바뀌었는데 이 단언만 남아 있던 stale 케이스였다. const fields = grid.props.columns.map((c: any) => c.field); - expect(fields).toEqual(expect.arrayContaining(['no', 'created_at', 'member', 'type', 'amount', 'remaining', 'expiry', 'order', 'granted_by'])); + expect(fields).toEqual(expect.arrayContaining(['number', 'created_at', 'member', 'type', 'amount', 'remaining', 'expiry', 'order', 'granted_by'])); }); it('유효기간 컬럼이 expiry_state 기준 소멸(일부/전체)·만료예정·무기한·없음을 렌더한다', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx index c896ec5b..bd2504f9 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx @@ -247,19 +247,19 @@ describe('통화 규칙 테이블 (_tab_mileage_currency_table.json)', () => { expect(percentInput.props.disabled).toContain("rule.max_use_type !== 'percent'"); }); - it('max_use_type 라디오는 부모 Label 클릭으로 상태를 바꾼다 (게시판 검증 패턴 — 라디오 자동바인딩 회피)', () => { + it('max_use_type 라디오는 Label 클릭과 키보드 change 양쪽으로 같은 상태를 만든다', () => { // 회귀: 라디오 자동바인딩은 문자열 값을 value 바인딩으로 처리해 라디오 그룹을 깨뜨린다. - // 게시판 date_display_format 의 검증된 패턴: 라디오는 pointer-events-none(시각 전용), - // 부모 Label 에 click 액션을 달아 해당 행(rule._idx)의 max_use_type 을 배열 map 교체로 갱신. + // 그래서 Label click 으로 상태를 갱신한다. 다만 라디오를 pointer-events-none 으로만 두면 + // 키보드 방향키(change)로 옮겼을 때 화면 표시와 저장될 값이 어긋난다(#493) — + // 라디오 자체에도 click 과 **동일한 params** 를 갖는 change 액션을 둔다. const radios = findAll(currencyTable, (n) => n.name === 'Input' && n.props?.type === 'radio' && String(n.props?.name).includes('max_use_type')); // currency_rules 행(2) + 인라인 추가 행(2) = 4 expect(radios.length).toBe(4); for (const radio of radios) { - // 라디오는 클릭 이벤트를 받지 않는다 (부모 Label 이 처리) - expect(radio.props.className, `radio(${radio.props.value}) 에 pointer-events-none 누락`).toContain('pointer-events-none'); - // 라디오 자체에는 actions 가 없다 (자동바인딩/onChange 미사용) - expect(radio.actions ?? []).toHaveLength(0); + const change = (radio.actions ?? []).find((a: any) => a.type === 'change' && a.handler === 'setState'); + expect(change, `radio(${radio.props.value}) 에 키보드 조작용 change 액션 누락`).toBeTruthy(); + expect(change.params.target).toBe('local'); } // 부모 Label 이 click → setState 로 max_use_type 갱신 @@ -349,6 +349,67 @@ describe('통화 규칙 모바일 카드 (_tab_mileage_currency_cards.json)', () }); }); +describe('적립 절사 기준 (earn_rounding_unit / earn_rounding_method)', () => { + const 절사_컨트롤 = (root: any, 접두: string) => ({ + unit: findFirst(root, (n) => n.name === 'Select' && String(n.props?.name).includes(`${접두}earn_rounding_unit`)), + method: findFirst(root, (n) => n.name === 'Select' && String(n.props?.name).includes(`${접두}earn_rounding_method`)), + }); + + it('테이블 행에 단위/방식 Select 가 통화 규칙 인덱스로 바인딩된다', () => { + const { unit, method } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '."); + + expect(unit).toBeTruthy(); + expect(method).toBeTruthy(); + // 값이 없는 기존 행도 종전 동작(1점 버림)으로 보이도록 폴백을 둔다 + expect(unit.props.value).toContain("?? '1'"); + expect(method.props.value).toContain("?? 'floor'"); + }); + + it('절사 방식 옵션은 서버 허용 어휘(floor/round/ceil)와 일치한다', () => { + const { method } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '."); + + // 화면 옵션이 서버 게이트를 벗어나면 저장이 422 로 막히는데 화면에는 선택지로 보인다 + expect(method.props.options.map((o: any) => o.value)).toEqual(['floor', 'round', 'ceil']); + }); + + it('절사 단위 옵션은 정수 포인트 단위만 제공한다 (통화 환산 절사의 소수 단위와 다름)', () => { + const { unit } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '."); + const opts = String(unit.props.options); + + expect(opts).toContain("value: '1'"); + expect(opts).toContain("value: '10'"); + expect(opts).toContain("value: '100'"); + // 마일리지는 원장에 정수로 확정된다 — 0.01 같은 소수 단위는 선택지가 될 수 없다 + expect(opts).not.toContain("value: '0.01'"); + }); + + it('모바일 카드 뷰에도 동일 항목이 있다 (한쪽만 있으면 모바일에서 설정 불가)', () => { + const { unit, method } = 절사_컨트롤(currencyCards, "mileage.currency_rules.' + rule._idx + '."); + + expect(unit).toBeTruthy(); + expect(method).toBeTruthy(); + }); + + it.each([ + ['테이블', currencyTable, 'new_mileage_currency_row'], + ['모바일 카드', currencyCards, 'new_mileage_currency_card'], + ])('%s 의 신규 통화 추가 행에도 절사 컨트롤이 있고 저장 payload 에 포함된다', (_label, root, rowId) => { + const newRow = findById(root, rowId); + const { unit, method } = 절사_컨트롤(newRow, 'newMileageCurrency.'); + + expect(unit).toBeTruthy(); + expect(method).toBeTruthy(); + + const seq = findFirst(newRow, (n) => n.handler === 'sequence'); + const actions = seq.actions ?? seq.params?.actions ?? []; + const push = actions.find((a: any) => a.handler === 'setState' && a.params?.['form.mileage.currency_rules']); + + // 컨트롤만 두고 payload 에 빠뜨리면 입력해도 저장되지 않는다 + expect(String(push.params['form.mileage.currency_rules'])).toContain('earn_rounding_unit:'); + expect(String(push.params['form.mileage.currency_rules'])).toContain('earn_rounding_method:'); + }); +}); + describe('유효기간 / 소멸 알림 카드', () => { it('expiry_enabled / expiry_notification_enabled 토글 바인딩', () => { const expiryToggle = findFirst(expiryCard, (n) => n.name === 'Toggle' && n.props?.name === 'mileage.expiry_enabled'); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/mileage_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/mileage_settings.json index 4de0e4f9..164b53ef 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/mileage_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/mileage_settings.json @@ -35,7 +35,19 @@ "empty": "No currency rules registered.", "code_placeholder": "e.g. USD", "duplicate_currency_error": "This currency is already registered.", - "max_use_fixed_suffix": "" + "max_use_fixed_suffix": "", + "earn_rounding": "Earning Rounding", + "earn_rounding_hint": "Applied when earned points are calculated and prorated", + "earn_rounding_units": { + "1": "1 pt", + "10": "10 pts", + "100": "100 pts" + }, + "earn_rounding_methods": { + "floor": "Round down", + "round": "Round", + "ceil": "Round up" + } }, "expiry": { "title": "Expiry", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/mileage_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/mileage_settings.json index a0995471..d3bf4264 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/mileage_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/mileage_settings.json @@ -35,7 +35,19 @@ "empty": "등록된 통화 규칙이 없습니다.", "code_placeholder": "예: USD", "duplicate_currency_error": "이미 등록된 통화입니다.", - "max_use_fixed_suffix": "원" + "max_use_fixed_suffix": "원", + "earn_rounding": "적립 절사", + "earn_rounding_hint": "적립 포인트 산출·안분 시 적용", + "earn_rounding_units": { + "1": "1점", + "10": "10점", + "100": "100점" + }, + "earn_rounding_methods": { + "floor": "버림", + "round": "반올림", + "ceil": "올림" + } }, "expiry": { "title": "유효기간", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json index ec7d4d4b..6cfc3877 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_settings.json @@ -513,6 +513,7 @@ "type": "basic", "name": "Button", "props": { + "data-testid": "ecommerce-settings-save", "disabled": "{{_computed.isReadOnly || !_local.hasChanges || _local.isSaving}}", "className": "btn btn-primary flex-center gap-1.5" }, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_cards.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_cards.json index ba54f8c8..98f46c46 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_cards.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_cards.json @@ -336,6 +336,75 @@ ] } ] + }, + { + "comment": "적립 절사 기준 — 데스크톱 테이블 뷰와 동일 항목 (한쪽만 두면 모바일에서 설정 불가)", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" + }, + "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-2" + }, + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_unit'}}", + "value": "{{rule.earn_rounding_unit ?? '1'}}", + "options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}", + "className": "flex-1", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "{{'mileage-earn-rounding-unit-' + (rule.currency_code ?? rule._idx)}}" + } + }, + { + "type": "composite", + "name": "Select", + "props": { + "name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_method'}}", + "value": "{{rule.earn_rounding_method ?? 'floor'}}", + "options": [ + { + "value": "floor", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor" + }, + { + "value": "round", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round" + }, + { + "value": "ceil", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil" + } + ], + "className": "flex-1", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "{{'mileage-earn-rounding-method-' + (rule.currency_code ?? rule._idx)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-xs text-gray-400 dark:text-gray-500 mt-1" + }, + "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_hint" + } + ] } ] }, @@ -618,6 +687,66 @@ } ] }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" + }, + "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-2" + }, + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "name": "newMileageCurrency.earn_rounding_unit", + "value": "{{_local.form?.newMileageCurrency?.earn_rounding_unit ?? '1'}}", + "options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}", + "className": "flex-1", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "mileage-new-earn-rounding-unit" + } + }, + { + "type": "composite", + "name": "Select", + "props": { + "name": "newMileageCurrency.earn_rounding_method", + "value": "{{_local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor'}}", + "options": [ + { + "value": "floor", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor" + }, + { + "value": "round", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round" + }, + { + "value": "ceil", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil" + } + ], + "className": "flex-1", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "mileage-new-earn-rounding-method" + } + } + ] + } + ] + }, { "type": "basic", "name": "Div", @@ -657,7 +786,7 @@ "name": "Button", "props": { "type": "button", - "className": "flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50", + "className": "flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50", "disabled": "{{_computed.isReadOnly}}" }, "actions": [ @@ -686,7 +815,7 @@ "if": "{{_local.form?.newMileageCurrency?.currency_code?.trim() && !((_local.form?.mileage?.currency_rules ?? []).some(c => (c.currency_code || '').toUpperCase() === (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim()))}}", "params": { "target": "local", - "form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0 }]}}", + "form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0, earn_rounding_unit: _local.form?.newMileageCurrency?.earn_rounding_unit ?? '1', earn_rounding_method: _local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor' }]}}", "isAddingMileageCurrency": false, "form.newMileageCurrency": null, "hasChanges": true diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_table.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_table.json index ccfe196c..a2056984 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_table.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_currency_table.json @@ -50,6 +50,12 @@ "name": "Th", "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.max_use" }, + { + "comment": "적립 절사 기준 — 언어/통화 탭의 환산 절사와 별개(그쪽은 외화 표시 전용)", + "type": "basic", + "name": "Th", + "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding" + }, { "type": "basic", "name": "Th", @@ -377,6 +383,70 @@ } ] }, + { + "comment": "적립 절사 기준 (단위 + 방식). 미설정 행은 1점 버림으로 표시 — 종전 동작과 같다.", + "type": "basic", + "name": "Td", + "props": { + "className": "align-top" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-1" + }, + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_unit'}}", + "value": "{{rule.earn_rounding_unit ?? '1'}}", + "options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}", + "className": "w-24", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "{{'mileage-earn-rounding-unit-' + (rule.currency_code ?? rule._idx)}}" + } + }, + { + "type": "composite", + "name": "Select", + "props": { + "name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_method'}}", + "value": "{{rule.earn_rounding_method ?? 'floor'}}", + "options": [ + { + "value": "floor", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor" + }, + { + "value": "round", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round" + }, + { + "value": "ceil", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil" + } + ], + "className": "w-24", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "{{'mileage-earn-rounding-method-' + (rule.currency_code ?? rule._idx)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "block text-xs text-gray-400 dark:text-gray-500 mt-1" + }, + "text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_hint" + } + ] + }, { "type": "basic", "name": "Td", @@ -707,6 +777,61 @@ } ] }, + { + "type": "basic", + "name": "Td", + "props": { + "className": "align-top" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-1" + }, + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "name": "newMileageCurrency.earn_rounding_unit", + "value": "{{_local.form?.newMileageCurrency?.earn_rounding_unit ?? '1'}}", + "options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}", + "className": "w-24", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "mileage-new-earn-rounding-unit" + } + }, + { + "type": "composite", + "name": "Select", + "props": { + "name": "newMileageCurrency.earn_rounding_method", + "value": "{{_local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor'}}", + "options": [ + { + "value": "floor", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor" + }, + { + "value": "round", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round" + }, + { + "value": "ceil", + "label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil" + } + ], + "className": "w-24", + "disabled": "{{_computed.isReadOnly}}", + "data-testid": "mileage-new-earn-rounding-method" + } + } + ] + } + ] + }, { "type": "basic", "name": "Td", @@ -755,7 +880,7 @@ "if": "{{_local.form?.newMileageCurrency?.currency_code?.trim() && !((_local.form?.mileage?.currency_rules ?? []).some(c => (c.currency_code || '').toUpperCase() === (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim()))}}", "params": { "target": "local", - "form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0 }]}}", + "form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0, earn_rounding_unit: _local.form?.newMileageCurrency?.earn_rounding_unit ?? '1', earn_rounding_method: _local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor' }]}}", "isAddingMileageCurrency": false, "form.newMileageCurrency": null, "hasChanges": true diff --git a/modules/_bundled/sirsoft-ecommerce/src/DTO/CalculationInput.php b/modules/_bundled/sirsoft-ecommerce/src/DTO/CalculationInput.php index b0d6d56c..d7895607 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/DTO/CalculationInput.php +++ b/modules/_bundled/sirsoft-ecommerce/src/DTO/CalculationInput.php @@ -25,6 +25,7 @@ class CalculationInput * @param array|null $shippingPolicySnapshots 배송정책 스냅샷 (환불 재계산용) * @param array|null $promotionSnapshots 프로모션 스냅샷 (환불 재계산용) * @param int|null $userId 주문 사용자 ID (per_user_limit 과거 사용 검증용, 비회원은 null) + * @param array|null $mileagePolicySnapshot 마일리지 정책 스냅샷 (환불 재계산 시 적립 절사 기준 고정용) */ public function __construct( public array $items = [], @@ -38,6 +39,7 @@ class CalculationInput public ?array $shippingPolicySnapshots = null, public ?array $promotionSnapshots = null, public ?int $userId = null, + public ?array $mileagePolicySnapshot = null, ) {} /** @@ -70,6 +72,7 @@ class CalculationInput shippingPolicySnapshots: $data['shipping_policy_snapshots'] ?? null, promotionSnapshots: $data['promotion_snapshots'] ?? null, userId: $data['user_id'] ?? null, + mileagePolicySnapshot: $data['mileage_policy_snapshot'] ?? null, ); } @@ -92,6 +95,7 @@ class CalculationInput 'shipping_policy_snapshots' => $this->shippingPolicySnapshots, 'promotion_snapshots' => $this->promotionSnapshots, 'user_id' => $this->userId, + 'mileage_policy_snapshot' => $this->mileagePolicySnapshot, ]; } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php index 2aa94ffa..dac453c1 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php @@ -9,6 +9,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService; use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver; +use Modules\Sirsoft\Ecommerce\Support\MileageRounding; /** * 이커머스 설정 저장 요청 검증 @@ -311,6 +312,11 @@ class StoreEcommerceSettingsRequest extends FormRequest 'mileage.currency_rules.*.max_use_percent' => ['nullable', 'numeric', 'min:'.config('sirsoft-ecommerce.limits.max_use_percent_min', 0), 'max:'.config('sirsoft-ecommerce.limits.max_use_percent_max', 100)], // 최대 사용한도(고정금액): 비합리적 거액 방지를 위해 10억 상한 'mileage.currency_rules.*.max_use_value' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.max_use_value_min', 0), 'max:'.config('sirsoft-ecommerce.limits.max_use_value_max', 1000000000)], + // 적립 절사 기준 — 통화 환산 절사(language_currency.currencies.*.rounding_*)와 별개다. + // 그쪽은 외화 표시 전용이라 기본 통화에는 적용되지 않는 반면, 적립은 기본 통화 원장에 + // 확정 기록되는 값이므로 자기 규칙을 갖는다. 어휘는 MileageRounding 이 SSoT. + 'mileage.currency_rules.*.earn_rounding_unit' => ['nullable', 'string', 'in:'.implode(',', MileageRounding::UNITS)], + 'mileage.currency_rules.*.earn_rounding_method' => ['nullable', 'string', 'in:'.implode(',', MileageRounding::METHODS)], 'mileage.expiry_enabled' => ['nullable', 'boolean'], 'mileage.expiry_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.expiry_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.expiry_days_max', 3650)], 'mileage.expiry_notification_enabled' => ['nullable', 'boolean'], diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderResource.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderResource.php index c4abdbd7..36a0524d 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderResource.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/OrderResource.php @@ -6,6 +6,7 @@ use App\Helpers\PermissionHelper; use App\Http\Resources\BaseApiResource; use Illuminate\Http\Request; use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices; +use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot; /** * 주문 상세 리소스 @@ -188,7 +189,12 @@ class OrderResource extends BaseApiResource // 프로모션/배송정책 스냅샷 'promotions_applied_snapshot' => $this->promotions_applied_snapshot, - 'shipping_policy_applied_snapshot' => $this->shipping_policy_applied_snapshot, + // 항상 신형 구조(`{items: [...], address: {...}}`)로 정규화해 내보낸다. + // 백필 전 구형 행이 남아 있어도 화면은 한 형태만 보면 되고, `items` 가 JSON + // 배열로 직렬화되므로 프론트의 `.find(...)` 가 안전하다. + 'shipping_policy_applied_snapshot' => ShippingPolicySnapshot::normalize( + $this->shipping_policy_applied_snapshot + ), // 메모 'admin_memo' => $this->admin_memo, diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php index dd3c0173..6dc0d8ef 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php @@ -15,6 +15,7 @@ use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; use Modules\Sirsoft\Ecommerce\Enums\RefundPriorityEnum; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot; use Modules\Sirsoft\Ecommerce\Support\VatCalculator; /** @@ -391,8 +392,9 @@ class OrderAdjustmentService // 배송지 복원 $shippingAddress = null; $shippingSnapshot = $order->shipping_policy_applied_snapshot ?? []; - if (! empty($shippingSnapshot['address'])) { - $shippingAddress = ShippingAddress::fromArray($shippingSnapshot['address']); + $snapshotAddress = ShippingPolicySnapshot::address($shippingSnapshot); + if (! empty($snapshotAddress)) { + $shippingAddress = ShippingAddress::fromArray($snapshotAddress); } // 배송정책 스냅샷을 product_option_id 키 맵으로 변환 @@ -416,6 +418,9 @@ class OrderAdjustmentService ], shippingPolicySnapshots: $shippingPolicySnapshots, promotionSnapshots: $order->promotions_applied_snapshot, + // 적립 절사 기준은 주문 시점 값으로 고정한다 — 설정을 다시 읽으면 운영자가 이후에 + // 바꾼 기준이 소급 적용돼, 취소하지 않은 잔여분의 적립액이 취소 처리만으로 달라진다. + mileagePolicySnapshot: $order->mileage_policy_snapshot, ); } @@ -909,25 +914,10 @@ class OrderAdjustmentService */ private function buildShippingPolicySnapshotMap(array $shippingSnapshot): array { - $map = []; - - foreach ($shippingSnapshot as $key => $entry) { - // 이미 product_option_id를 키로 사용하는 경우 (숫자 키 + policy 데이터) - if (is_int($key) && isset($entry['product_option_id'], $entry['policy'])) { - $map[$entry['product_option_id']] = $entry['policy']; - } elseif (is_int($key) && isset($entry['policy_id'])) { - // product_option_id 키 맵 형태 (테스트 헬퍼 등에서 직접 구성한 경우) - $map[$key] = $entry; - } elseif ($key === 'address') { - // address 키는 스킵 (배송지 정보) - continue; - } else { - // 기타 (이미 product_option_id => data 형태) - $map[$key] = $entry; - } - } - - return $map; + // 형태 판별은 ShippingPolicySnapshot 단일 출처에 위임한다 — 종전에는 이 메서드와 + // OrderCancellationService::buildShippingSnapshot() 이 `is_int($key)` 판별을 + // 각자 복제하고 있어, 구조가 바뀔 때 한쪽만 고쳐질 위험이 있었다. + return ShippingPolicySnapshot::toOptionPolicyMap($shippingSnapshot); } /** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php index 893fdb92..2338de1e 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php @@ -37,6 +37,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterf use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductAdditionalOptionValueRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingPolicyRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\MileageRounding; use Modules\Sirsoft\Ecommerce\Support\VatCalculator; /** @@ -316,7 +317,8 @@ class OrderCalculationService $pointsPerItem = $this->calculatePointsEarning( $preparedItems, $itemsAfterOrderDiscount, - $pointsUsageResult + $pointsUsageResult, + $input ); $pointsPerItem = HookManager::applyFilters( 'sirsoft-ecommerce.calculation.after_points_earning', @@ -855,12 +857,20 @@ class OrderCalculationService * @param array $preparedItems 준비된 아이템 배열 * @param array $discountedItems 할인 후 아이템 배열 (주문쿠폰 적용 후) * @param array $pointsUsageResult 마일리지 사용 결과 (points_by_option 포함) + * @param CalculationInput|null $input 계산 입력 (재계산 시 주문 시점 절사 기준 복원용) * @return array 옵션별 마일리지 배열 [product_option_id => points] */ - protected function calculatePointsEarning(array $preparedItems, array $discountedItems, array $pointsUsageResult = []): array + protected function calculatePointsEarning(array $preparedItems, array $discountedItems, array $pointsUsageResult = [], ?CalculationInput $input = null): array { $pointsPerItem = []; + // 적립 절사 기준. 재계산(부분취소·추가결제)은 주문 스냅샷을 우선한다 — 설정을 다시 읽으면 + // 운영자가 이후에 바꾼 절사 기준이 과거 주문에 소급 적용돼, 취소하지 않은 잔여분의 + // 적립액이 취소 처리만으로 달라진다. + $rounding = $input?->mileagePolicySnapshot !== null + ? MileageRounding::fromOrderSnapshot($input->mileagePolicySnapshot) + : MileageRounding::normalize($this->resolveMileageCurrencyRule($input)); + foreach ($preparedItems as $item) { $optionId = $item['product_option_id']; $option = $item['product_option']; @@ -890,21 +900,52 @@ class OrderCalculationService if ($option->mileage_value !== null && $option->mileage_type !== null) { if ($option->mileage_type === 'fixed') { // 정액 적립: mileage_value를 그대로 사용 (수량 곱) - $pointsPerItem[$optionId] = (int) floor($option->mileage_value * $item['quantity']); + $pointsPerItem[$optionId] = MileageRounding::apply($option->mileage_value * $item['quantity'], $rounding); } else { // 정률 적립: earnableAmount 기준으로 계산 - $pointsPerItem[$optionId] = (int) floor($earnableAmount * $option->mileage_value / 100); + $pointsPerItem[$optionId] = MileageRounding::apply($earnableAmount * $option->mileage_value / 100, $rounding); } } else { // 기본 마일리지 적립율 (설정값, 기본 1%) $defaultRate = (float) $this->settingsService->getSetting('mileage.default_earn_rate', 1); - $pointsPerItem[$optionId] = (int) floor($earnableAmount * $defaultRate / 100); + $pointsPerItem[$optionId] = MileageRounding::apply($earnableAmount * $defaultRate / 100, $rounding); } } return $pointsPerItem; } + /** + * 적립 절사 기준을 담은 통화별 마일리지 규칙을 조회합니다. + * + * 마일리지는 표시통화가 아니라 기준통화(base_currency)로 적립·정산되므로, 규칙도 + * 기준통화 기준으로 고른다. 스냅샷 모드에서는 주문 시점 기준통화를, 신규 주문에서는 + * 현재 기본 통화를 쓴다. 해당 통화 규칙이 없으면 첫 행(기본 통화 규칙)으로 폴백한다 — + * `UserMileageService::currencyRule()` 과 같은 해석 규칙이다. + * + * @param CalculationInput|null $input 계산 입력 + * @return array|null 통화별 마일리지 규칙 (없으면 null → 절사 기본값 적용) + */ + protected function resolveMileageCurrencyRule(?CalculationInput $input): ?array + { + $rules = (array) $this->settingsService->getSetting('mileage.currency_rules', []); + + if ($rules === []) { + return null; + } + + $currency = $input->metadata['currency_snapshot']['base_currency'] + ?? $this->currencyService->getDefaultCurrency(); + + foreach ($rules as $rule) { + if (is_array($rule) && ($rule['currency_code'] ?? null) === $currency) { + return $rule; + } + } + + return is_array($rules[0] ?? null) ? $rules[0] : null; + } + /** * 단계 2-b: 과세/면세 금액을 분류합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php index 324f6012..e3f14cb5 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php @@ -32,6 +32,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterf use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundOptionRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot; /** * 주문 취소 서비스 @@ -472,7 +473,7 @@ class OrderCancellationService protected function buildShippingSnapshot(Order $order, array $cancelItems): array { $orderSnapshot = $order->shipping_policy_applied_snapshot ?? []; - $address = $orderSnapshot['address'] ?? []; + $address = ShippingPolicySnapshot::address($orderSnapshot); // 주문 스냅샷에 배송지가 없으면 현재 배송주소에서 폴백 복원(과거 주문 호환). if (empty($address['country_code'])) { @@ -492,13 +493,8 @@ class OrderCancellationService } } - $policies = []; - foreach ($orderSnapshot as $key => $entry) { - if (is_int($key) && isset($entry['product_option_id'], $entry['policy']) - && isset($cancelOptionIds[$entry['product_option_id']])) { - $policies[] = $entry; - } - } + // 형태 판별은 ShippingPolicySnapshot 단일 출처에 위임 (구형 혼합 배열도 흡수). + $policies = ShippingPolicySnapshot::itemsForOptions($orderSnapshot, $cancelOptionIds); return [ 'country_code' => strtoupper((string) ($address['country_code'] ?? 'KR')), diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php index 1934fd6c..d3594f3c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php @@ -16,6 +16,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterf use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductReviewRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\MileageRounding; /** * 주문 옵션 서비스 @@ -203,7 +204,16 @@ class OrderOptionService } // 분할 레코드 금액 = 원본 × ratio - $this->applySplitAmounts($splitOption, $origAmounts, $ratio, $quantity); + // 적립 포인트는 주문 시점 절사 기준으로 정수화한다 — 안분을 소수 2자리로 남기면 + // 원장에 없는 소수점 포인트(390.5P)가 옵션 행에 생기고, 그 합이 적립 목표액이 되어 + // 실제 지급 가능한 정수 포인트와 어긋난다. + $this->applySplitAmounts( + $splitOption, + $origAmounts, + $ratio, + $quantity, + MileageRounding::fromOrderSnapshot($option->order?->mileage_policy_snapshot) + ); $this->orderOptionRepository->save($splitOption); // 원본 레코드 = 원본 - 분할 (잔여분) @@ -439,8 +449,9 @@ class OrderOptionService * @param array $origAmounts 원본 금액 캡처 데이터 * @param float $ratio 분할 비율 (변경수량 / 원본수량) * @param int $quantity 분할 수량 + * @param array{unit: string, method: string}|null $earnRounding 적립 절사 기준 (주문 스냅샷 기준) */ - private function applySplitAmounts(OrderOption $splitOption, array $origAmounts, float $ratio, int $quantity): void + private function applySplitAmounts(OrderOption $splitOption, array $origAmounts, float $ratio, int $quantity, ?array $earnRounding = null): void { $splitOption->subtotal_price = round($splitOption->unit_price * $quantity, 2); $splitOption->subtotal_discount_amount = round($origAmounts['subtotal_discount_amount'] * $ratio, 2); @@ -450,7 +461,11 @@ class OrderOptionService $splitOption->subtotal_deposit_used_amount = round($origAmounts['subtotal_deposit_used_amount'] * $ratio, 2); $splitOption->subtotal_tax_amount = round($origAmounts['subtotal_tax_amount'] * $ratio, 2); $splitOption->subtotal_tax_free_amount = round($origAmounts['subtotal_tax_free_amount'] * $ratio, 2); - $splitOption->subtotal_earned_points_amount = round($origAmounts['subtotal_earned_points_amount'] * $ratio, 2); + // 적립 포인트는 절사 기준으로 정수화 — 잔여분은 `원본 − 분할` 이라 총액은 그대로 보존된다. + $splitOption->subtotal_earned_points_amount = MileageRounding::apply( + $origAmounts['subtotal_earned_points_amount'] * $ratio, + $earnRounding + ); $splitOption->subtotal_weight = round($splitOption->unit_weight * $quantity, 3); $splitOption->subtotal_volume = round($splitOption->unit_volume * $quantity, 3); $splitOption->subtotal_paid_amount = $splitOption->subtotal_price diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php index cfd981f7..53c5aa43 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php @@ -17,6 +17,7 @@ use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Models\OrderOption; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageBalanceRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageTransactionRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Support\MileageRounding; /** * 사용자 마일리지 서비스 (원장 SSoT + 표시 캐시) @@ -265,6 +266,10 @@ class UserMileageService $usable = $this->isMileageUsable(); $rule = $usable ? $this->currencyRule($currency) : []; + // 적립 절사 기준은 사용 가능 여부와 무관하게 기록한다 — 마일리지 "사용" 이 막혀 있어도 + // 적립은 별개로 일어나므로, 사용 불가 주문의 재계산에도 절사 기준이 필요하다. + $earnRounding = MileageRounding::normalize($this->currencyRule($currency)); + return [ 'usable' => $usable, 'currency' => $currency, @@ -275,6 +280,8 @@ class UserMileageService 'max_use_type' => (string) ($rule['max_use_type'] ?? 'percent'), 'max_use_percent' => (float) ($rule['max_use_percent'] ?? 100), 'max_use_value' => (int) ($rule['max_use_value'] ?? 0), + MileageRounding::UNIT_KEY => $earnRounding['unit'], + MileageRounding::METHOD_KEY => $earnRounding['method'], ], // 판정 근거 — 이 결제금액에 이 정책을 적용해 아래 상한이 나왔고, 그 안에서 사용됐다. 'payment_amount_basis' => $paymentAmount, diff --git a/modules/_bundled/sirsoft-ecommerce/src/Support/MileageRounding.php b/modules/_bundled/sirsoft-ecommerce/src/Support/MileageRounding.php new file mode 100644 index 00000000..6492d5b0 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Support/MileageRounding.php @@ -0,0 +1,133 @@ + + */ + public const UNITS = ['1', '10', '100']; + + /** + * 선택 가능한 절사 방식 (통화 환산 절사와 동일 어휘). + * + * @var array + */ + public const METHODS = ['floor', 'round', 'ceil']; + + /** + * 통화 규칙에서 절사 기준을 해석합니다. + * + * 미설정·오염값은 기본값으로 접는다 — 절사 기준이 해석되지 않는다고 적립을 멈출 수는 + * 없고, 알 수 없는 값에서 임의 방식을 고르면 지급액이 조용히 달라지기 때문이다. + * + * @param array|null $rule 통화별 마일리지 규칙 (mileage.currency_rules 의 한 행) + * @return array{unit: string, method: string} 정규화된 절사 기준 + */ + public static function normalize(?array $rule): array + { + $unit = (string) ($rule[self::UNIT_KEY] ?? self::DEFAULT_UNIT); + $method = (string) ($rule[self::METHOD_KEY] ?? self::DEFAULT_METHOD); + + return [ + 'unit' => in_array($unit, self::UNITS, true) ? $unit : self::DEFAULT_UNIT, + 'method' => in_array($method, self::METHODS, true) ? $method : self::DEFAULT_METHOD, + ]; + } + + /** + * 주문 스냅샷에서 절사 기준을 해석합니다. + * + * 재계산(부분취소 · 추가결제)은 주문 시점 기준을 그대로 써야 한다. 설정을 다시 읽으면 + * 운영자가 이후에 바꾼 절사 기준이 과거 주문에 소급 적용돼, 취소하지 않은 잔여분의 + * 적립액이 취소 처리만으로 달라진다. + * + * 이 기능 이전 주문의 스냅샷에는 키가 없다 — 그때의 동작이 곧 기본값(`1`/`floor`)이므로 + * 폴백이 당시 산출과 일치한다. + * + * @param array|null $snapshot 주문의 mileage_policy_snapshot + * @return array{unit: string, method: string} 정규화된 절사 기준 + */ + public static function fromOrderSnapshot(?array $snapshot): array + { + return self::normalize(is_array($snapshot['rule'] ?? null) ? $snapshot['rule'] : null); + } + + /** + * 절사 기준을 적용해 정수 포인트를 산출합니다. + * + * @param float|int $points 절사 전 포인트 (계산 결과 실수) + * @param array{unit: string, method: string}|null $rounding 정규화된 절사 기준 (null 이면 기본값) + * @return int 절사된 정수 포인트 (음수 방지 없음 — 호출자가 부호 의미를 정한다) + */ + public static function apply(float|int $points, ?array $rounding = null): int + { + $unit = (float) ($rounding['unit'] ?? self::DEFAULT_UNIT); + $method = (string) ($rounding['method'] ?? self::DEFAULT_METHOD); + + if ($unit <= 0) { + $unit = 1.0; + } + + $divided = ((float) $points) / $unit; + + // 부동소수 오차 보정 — 7809.999999999999 가 floor 에서 7809 로 떨어지는 것을 막는다. + // 마일리지 단위(1/10/100)에서 9자리 이하 오차는 계산 잔여물일 뿐 의미 있는 차이가 아니다. + $divided = round($divided, 9); + + $quantized = match ($method) { + 'ceil' => ceil($divided), + 'round' => round($divided), + default => floor($divided), + }; + + return (int) ($quantized * $unit); + } + + /** + * 통화 규칙에서 곧바로 절사를 적용합니다 (normalize + apply 축약). + * + * @param float|int $points 절사 전 포인트 + * @param array|null $rule 통화별 마일리지 규칙 + * @return int 절사된 정수 포인트 + */ + public static function applyRule(float|int $points, ?array $rule): int + { + return self::apply($points, self::normalize($rule)); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php index bcc48049..315979bb 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php @@ -1390,6 +1390,8 @@ return [ 'mileage.currency_rules.*.use_unit' => 'Use Unit', 'mileage.currency_rules.*.max_use_percent' => 'Max Use Percent', 'mileage.currency_rules.*.max_use_value' => 'Max Use Amount', + 'mileage.currency_rules.*.earn_rounding_unit' => 'Earning Rounding Unit', + 'mileage.currency_rules.*.earn_rounding_method' => 'Earning Rounding Method', 'mileage.expiry_days' => 'Expiry Days', 'mileage.expiry_notification_days_before' => 'Expiry Notification Days', @@ -1616,6 +1618,12 @@ return [ 'min' => 'Max usable amount must be 0 or greater.', 'max' => 'Max usable amount is too large (max 1 billion).', ], + 'earn_rounding_unit' => [ + 'in' => 'The earning rounding unit must be one of: 1, 10, 100.', + ], + 'earn_rounding_method' => [ + 'in' => 'The earning rounding method must be one of: floor, round, ceil.', + ], ], ], 'seo' => [ diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php index 15f7009a..b3912d60 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php @@ -1390,6 +1390,8 @@ return [ 'mileage.currency_rules.*.use_unit' => '사용 단위', 'mileage.currency_rules.*.max_use_percent' => '최대 사용 비율', 'mileage.currency_rules.*.max_use_value' => '최대 사용 금액', + 'mileage.currency_rules.*.earn_rounding_unit' => '적립 절사 단위', + 'mileage.currency_rules.*.earn_rounding_method' => '적립 절사 방식', 'mileage.expiry_days' => '유효기간', 'mileage.expiry_notification_days_before' => '소멸 예정 알림일', @@ -1616,6 +1618,12 @@ return [ 'min' => '최대 사용 금액은 0 이상이어야 합니다.', 'max' => '최대 사용 금액이 너무 큽니다. (최대 10억)', ], + 'earn_rounding_unit' => [ + 'in' => '적립 절사 단위는 1, 10, 100 중 하나여야 합니다.', + ], + 'earn_rounding_method' => [ + 'in' => '적립 절사 방식은 floor, round, ceil 중 하나여야 합니다.', + ], ], ], 'seo' => [ diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsMileageTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsMileageTest.php index a976395a..819bd4e6 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsMileageTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsMileageTest.php @@ -374,6 +374,67 @@ class EcommerceSettingsMileageTest extends ModuleTestCase } } + /** + * 통화별 적립 절사 기준이 저장·조회된다. + * + * 적립 절사는 통화 환산 절사(language_currency.currencies.*.rounding_*)와 별개 항목이다. + * 그쪽은 외화 표시 전용이라 기본 통화에는 적용되지 않는 반면, 적립은 기본 통화 원장에 + * 확정 기록되는 값이므로 자기 규칙을 갖는다. + */ + public function test_save_mileage_earn_rounding_rule(): void + { + $response = $this->actingAs($this->adminUser)->putJson($this->apiBase, [ + '_tab' => 'mileage', + 'mileage' => [ + 'enabled' => true, + 'default_earn_rate' => 1, + 'currency_rules' => [ + [ + 'currency_code' => 'KRW', 'point_value' => 1, 'min_use_amount' => 0, 'use_unit' => 1, + 'max_use_type' => 'percent', 'max_use_percent' => 100, 'max_use_value' => 0, + 'earn_rounding_unit' => '10', 'earn_rounding_method' => 'ceil', + ], + ], + ], + ]); + + $response->assertOk(); + + $settings = app(EcommerceSettingsService::class); + $settings->clearCache(); + $rules = $settings->getSetting('mileage.currency_rules'); + + $this->assertSame('10', $rules[0]['earn_rounding_unit']); + $this->assertSame('ceil', $rules[0]['earn_rounding_method']); + } + + /** + * 허용 목록 밖의 적립 절사 값은 422 로 거부된다. + */ + public function test_invalid_mileage_earn_rounding_rejected(): void + { + $response = $this->actingAs($this->adminUser)->putJson($this->apiBase, [ + '_tab' => 'mileage', + 'mileage' => [ + 'enabled' => true, + 'default_earn_rate' => 1, + 'currency_rules' => [ + [ + 'currency_code' => 'KRW', 'point_value' => 1, 'min_use_amount' => 0, 'use_unit' => 1, + 'max_use_type' => 'percent', 'max_use_percent' => 100, 'max_use_value' => 0, + 'earn_rounding_unit' => '7', 'earn_rounding_method' => 'trunc', + ], + ], + ], + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors([ + 'mileage.currency_rules.0.earn_rounding_unit', + 'mileage.currency_rules.0.earn_rounding_method', + ]); + } + /** * settings.update 권한 없는 계정은 차단. */ diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mileage-earn-rounding.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mileage-earn-rounding.spec.ts new file mode 100644 index 00000000..a676cea5 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mileage-earn-rounding.spec.ts @@ -0,0 +1,109 @@ +/** + * 관리자 마일리지 설정 — 통화별 적립 절사 기준. + * + * @scenario settings_mileage_earn_rounding + * @effects settings_controls_render_in_both_viewports, + * settings_screen_options_match_server_vocabulary, + * settings_rounding_persists_across_reload, + * settings_earn_rounding_separate_from_exchange + * + * 배경: 적립 포인트 산출은 종전에 `(int) floor` 로 하드코딩돼 운영자가 조정할 수 없었다. + * 통화별 마일리지 규칙에 절사 단위(1/10/100)와 방식(버림/반올림/올림)을 두어 + * 주문계산·부분취소 안분이 같은 기준을 따르게 한다. + * + * 참고: 관리자 템플릿의 Select 는 options 가 주어지면 네이티브 `