@sayren/store-sdk 0.47.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +13232 -10717
- package/dist/index.mjs +5060 -3816
- package/dist/server.mjs +1 -1
- package/dist/{webhook-headers-CTez3xDY.mjs → webhook-headers-COVjv1yo.mjs} +113 -113
- package/package.json +1 -1
- package/src/client.ts +38 -0
- package/src/index.ts +4 -0
- package/src/schemas/condition-tree.ts +358 -0
- package/src/schemas/coupon-rules.ts +849 -0
- package/src/schemas/coupon-selection.ts +266 -0
- package/src/schemas/coupon.ts +176 -1
- package/src/schemas/order.ts +68 -1
- package/src/schemas/product-import.ts +134 -0
- package/src/schemas/promotion.ts +191 -53
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
productCreateRequestSchema,
|
|
4
|
+
productReplaceRequestSchema,
|
|
5
|
+
productStatusSchema,
|
|
6
|
+
} from "./product";
|
|
7
|
+
import { strictRequest } from "./strict";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 상품 일괄 반영(이슈 #211) — 엑셀로 만든 상품 등록·전체 수정을 작업 하나로 반영한다.
|
|
11
|
+
* 작업을 만들고(`DRAFT`) 항목을 나눠 붙인 뒤 시작하면(202) 서버가 트랜잭션 하나로 처리한다.
|
|
12
|
+
* **항목 하나라도 실패하면 아무것도 반영하지 않는다.** 상점 플랫폼 전용 표면이다.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 작업 하나의 항목 수 상한 — 쓰기 트랜잭션 하나에 담기는 양을 묶는 방어선이다 */
|
|
16
|
+
export const MAX_PRODUCT_IMPORT_ITEMS = 1000;
|
|
17
|
+
|
|
18
|
+
/** 항목 붙이기 한 번의 항목 수 상한 */
|
|
19
|
+
export const MAX_PRODUCT_IMPORT_ITEMS_PER_REQUEST = 100;
|
|
20
|
+
|
|
21
|
+
export const productImportStatusSchema = z.enum([
|
|
22
|
+
"DRAFT",
|
|
23
|
+
"QUEUED",
|
|
24
|
+
"RUNNING",
|
|
25
|
+
"SUCCEEDED",
|
|
26
|
+
"FAILED",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export type ProductImportStatus = z.infer<typeof productImportStatusSchema>;
|
|
30
|
+
|
|
31
|
+
const itemKeySchema = z
|
|
32
|
+
.string()
|
|
33
|
+
.trim()
|
|
34
|
+
.min(1)
|
|
35
|
+
.max(100)
|
|
36
|
+
.describe(
|
|
37
|
+
"항목 키 — 호출자가 정한다(엑셀의 상품 키 등). 오류·결과를 이 키로 돌려준다. 작업 안에서 유일하다",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export const productImportItemSchema = z.discriminatedUnion("action", [
|
|
41
|
+
z.object({
|
|
42
|
+
key: itemKeySchema,
|
|
43
|
+
action: z.literal("CREATE"),
|
|
44
|
+
product: productCreateRequestSchema,
|
|
45
|
+
status: productStatusSchema.optional().describe("판매 상태. 생략하면 판매중(`SALE`)"),
|
|
46
|
+
}),
|
|
47
|
+
z.object({
|
|
48
|
+
key: itemKeySchema,
|
|
49
|
+
action: z.literal("UPDATE"),
|
|
50
|
+
productId: z.string().min(1),
|
|
51
|
+
expectedUpdatedAt: z
|
|
52
|
+
.string()
|
|
53
|
+
.min(1)
|
|
54
|
+
.describe("내려받을 때의 상품 `updatedAt`. 그 뒤 상품이 바뀌었으면 409 `PRODUCT_CHANGED`"),
|
|
55
|
+
product: productReplaceRequestSchema.describe(
|
|
56
|
+
"전체 수정(`PUT /v1/products/{productId}`)과 같은 본문",
|
|
57
|
+
),
|
|
58
|
+
status: productStatusSchema.optional().describe("판매 상태. 생략하면 바꾸지 않는다"),
|
|
59
|
+
expectedStock: z
|
|
60
|
+
.record(z.string().min(1), z.number().int().min(0))
|
|
61
|
+
.optional()
|
|
62
|
+
.describe(
|
|
63
|
+
"재고를 바꾸는 조합의 내려받을 때 재고(조합 id → 수량). 반영 시점 재고가 다르면 409 `STOCK_CHANGED`. " +
|
|
64
|
+
"`product.variants[].stockQuantity`를 준 기존 조합마다 하나씩 있어야 하고 다른 조합은 넣지 않는다(400 `PRODUCT_IMPORT_STOCK_EXPECTATION_MISMATCH`)",
|
|
65
|
+
),
|
|
66
|
+
}),
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
export type ProductImportItem = z.infer<typeof productImportItemSchema>;
|
|
70
|
+
export type ProductImportItemInput = z.input<typeof productImportItemSchema>;
|
|
71
|
+
|
|
72
|
+
export const productImportItemsRequestSchema = strictRequest(
|
|
73
|
+
z.object({
|
|
74
|
+
items: z.array(productImportItemSchema).min(1).max(MAX_PRODUCT_IMPORT_ITEMS_PER_REQUEST),
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export type ProductImportItemsRequest = z.infer<typeof productImportItemsRequestSchema>;
|
|
79
|
+
|
|
80
|
+
/** 상품 상세 묶음 조회 한 번의 상한(id·상품번호 합) */
|
|
81
|
+
export const MAX_PRODUCT_LOOKUP = 100;
|
|
82
|
+
|
|
83
|
+
/** 상품 상세 묶음 조회 — 엑셀 내려받기와 올린 파일 대조가 쓴다. 이 상점에 없는 값은 결과에서 빠진다 */
|
|
84
|
+
export const productLookupRequestSchema = strictRequest(
|
|
85
|
+
z
|
|
86
|
+
.object({
|
|
87
|
+
productIds: z.array(z.string().min(1)).max(MAX_PRODUCT_LOOKUP).optional(),
|
|
88
|
+
productNos: z.array(z.number().int().positive()).max(MAX_PRODUCT_LOOKUP).optional(),
|
|
89
|
+
})
|
|
90
|
+
.refine(
|
|
91
|
+
(value) =>
|
|
92
|
+
(value.productIds?.length ?? 0) + (value.productNos?.length ?? 0) <= MAX_PRODUCT_LOOKUP,
|
|
93
|
+
{ message: `상품 id·상품번호는 합쳐 ${MAX_PRODUCT_LOOKUP}개까지입니다` },
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
export type ProductLookupRequest = z.infer<typeof productLookupRequestSchema>;
|
|
98
|
+
|
|
99
|
+
export const productImportErrorSchema = z.object({
|
|
100
|
+
key: z.string(),
|
|
101
|
+
code: z.string(),
|
|
102
|
+
message: z.string(),
|
|
103
|
+
fieldErrors: z
|
|
104
|
+
.array(z.object({ field: z.string(), code: z.string(), message: z.string() }))
|
|
105
|
+
.optional()
|
|
106
|
+
.describe("요청 본문 경로별 오류(항목의 `product` 기준). 없으면 생략"),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export type ProductImportError = z.infer<typeof productImportErrorSchema>;
|
|
110
|
+
|
|
111
|
+
export const productImportSchema = z.object({
|
|
112
|
+
importId: z.string(),
|
|
113
|
+
status: z
|
|
114
|
+
.string()
|
|
115
|
+
.describe("`DRAFT`·`QUEUED`·`RUNNING`·`SUCCEEDED`·`FAILED`. 값이 늘 수 있어 문자열이다"),
|
|
116
|
+
itemCount: z.number().int(),
|
|
117
|
+
errors: z
|
|
118
|
+
.array(productImportErrorSchema)
|
|
119
|
+
.describe("`FAILED`의 항목별 오류. 하나라도 있으면 아무것도 반영하지 않았다"),
|
|
120
|
+
results: z
|
|
121
|
+
.array(
|
|
122
|
+
z.object({
|
|
123
|
+
key: z.string(),
|
|
124
|
+
action: z.string().describe("`CREATE`·`UPDATE`"),
|
|
125
|
+
productId: z.string(),
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
.describe("`SUCCEEDED`의 항목별 결과"),
|
|
129
|
+
createdAt: z.string(),
|
|
130
|
+
startedAt: z.string().nullable(),
|
|
131
|
+
finishedAt: z.string().nullable(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export type ProductImport = z.infer<typeof productImportSchema>;
|
package/src/schemas/promotion.ts
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
+
import { type ConditionGroup, conditionGroupSchema } from "./condition-tree";
|
|
3
|
+
import {
|
|
4
|
+
assumeCouponFactRules,
|
|
5
|
+
COUPON_STACKING_DEFAULTS,
|
|
6
|
+
type CouponFacts,
|
|
7
|
+
type CouponRuleTrees,
|
|
8
|
+
type CouponStacking,
|
|
9
|
+
type CouponTargetRules,
|
|
10
|
+
couponLineContextOf,
|
|
11
|
+
couponLineMatches,
|
|
12
|
+
couponRulesFromLegacy,
|
|
13
|
+
couponStackingSchema,
|
|
14
|
+
couponTargetRulesSchema,
|
|
15
|
+
couponTimeFactsAt,
|
|
16
|
+
evaluateCouponEligibility,
|
|
17
|
+
pickCouponFacts,
|
|
18
|
+
} from "./coupon-rules";
|
|
2
19
|
import { CURRENCIES, KRW_MONEY_RULES, type MoneyRules } from "./currency";
|
|
3
20
|
import { proratedRefundBase } from "./refund-policy";
|
|
4
21
|
|
|
@@ -144,9 +161,61 @@ export const couponRuleSchema = z.object({
|
|
|
144
161
|
.datetime({ offset: true })
|
|
145
162
|
.nullable()
|
|
146
163
|
.describe("사용 종료 시각(ISO 8601, 시간대 포함, 이 시각부터 못 쓴다). null이면 제한이 없다"),
|
|
164
|
+
eligibility: (conditionGroupSchema as z.ZodType<ConditionGroup>)
|
|
165
|
+
.optional()
|
|
166
|
+
.describe(
|
|
167
|
+
"사용 조건 트리(이슈 #222). 없으면 `conditions`에서 만든다. 있으면 이것이 원천이고 `conditions`는 가장 가까운 옛 값이다",
|
|
168
|
+
),
|
|
169
|
+
targetRules: couponTargetRulesSchema
|
|
170
|
+
.optional()
|
|
171
|
+
.describe(
|
|
172
|
+
"할인 대상 규칙(이슈 #222). 없으면 `target`·`conditions.stackWithInstantDiscount`에서 만든다",
|
|
173
|
+
),
|
|
174
|
+
stacking: couponStackingSchema
|
|
175
|
+
.optional()
|
|
176
|
+
.describe("중복 사용 정책(이슈 #222). 없으면 종류별 한 장·서로 결합·적립금과 함께"),
|
|
147
177
|
});
|
|
148
178
|
export type CouponRule = z.infer<typeof couponRuleSchema>;
|
|
149
179
|
|
|
180
|
+
/** 규칙의 트리 — 트리 칸이 없으면 옛 칸에서 만든다(계산은 늘 이 한 경로) */
|
|
181
|
+
export function couponTreesOf(
|
|
182
|
+
rule: Pick<CouponRule, "conditions" | "target"> & {
|
|
183
|
+
eligibility?: ConditionGroup;
|
|
184
|
+
targetRules?: CouponTargetRules;
|
|
185
|
+
},
|
|
186
|
+
): CouponRuleTrees {
|
|
187
|
+
const legacy = couponRulesFromLegacy(rule.conditions, rule.target);
|
|
188
|
+
return {
|
|
189
|
+
eligibility: rule.eligibility ?? legacy.eligibility,
|
|
190
|
+
targetRules: rule.targetRules ?? legacy.targetRules,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function couponStackingOf(rule: { stacking?: CouponStacking }): CouponStacking {
|
|
195
|
+
return rule.stacking ?? COUPON_STACKING_DEFAULTS;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const STACKING_KEY: Record<CouponKind, keyof CouponStacking["combinesWith"]> = {
|
|
199
|
+
ORDER: "order",
|
|
200
|
+
PRODUCT: "product",
|
|
201
|
+
DELIVERY: "delivery",
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 두 쿠폰을 함께 쓸 수 있는가 — 서로가 상대 종류를 허용해야 한다. 같은 쿠폰 정의의 사본끼리는 장수 규칙(`perOrderLimit`)이 보므로 늘 참이다.
|
|
206
|
+
* `quotePricing`과 최대 조합(`bestCouponSelection`)이 같은 함수를 쓴다
|
|
207
|
+
*/
|
|
208
|
+
export function couponsCombinable(
|
|
209
|
+
a: { rule: Pick<CouponRule, "couponId" | "kind"> & { stacking?: CouponStacking } },
|
|
210
|
+
b: { rule: Pick<CouponRule, "couponId" | "kind"> & { stacking?: CouponStacking } },
|
|
211
|
+
): boolean {
|
|
212
|
+
if (a.rule.couponId === b.rule.couponId) return true;
|
|
213
|
+
return (
|
|
214
|
+
couponStackingOf(a.rule).combinesWith[STACKING_KEY[b.rule.kind]] &&
|
|
215
|
+
couponStackingOf(b.rule).combinesWith[STACKING_KEY[a.rule.kind]]
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
150
219
|
/** 쿠폰을 적용하지 못한 사유 */
|
|
151
220
|
export const couponRejectReasonSchema = z
|
|
152
221
|
.enum([
|
|
@@ -161,6 +230,8 @@ export const couponRejectReasonSchema = z
|
|
|
161
230
|
"LINE_NOT_ELIGIBLE",
|
|
162
231
|
"LINE_AMBIGUOUS",
|
|
163
232
|
"NO_DISCOUNT",
|
|
233
|
+
"CONDITION_NOT_MET",
|
|
234
|
+
"NOT_COMBINABLE",
|
|
164
235
|
])
|
|
165
236
|
.describe(
|
|
166
237
|
"쿠폰을 적용하지 못한 사유. `DUPLICATE_COUPON`=같은 쿠폰을 두 번 넣음, " +
|
|
@@ -168,7 +239,8 @@ export const couponRejectReasonSchema = z
|
|
|
168
239
|
"`NOT_STARTED`=사용 기간 전, `EXPIRED`=사용 기간 지남, `MEMBER_ONLY`=회원 전용, `FIRST_ORDER_ONLY`=첫 구매 전용, " +
|
|
169
240
|
"`NO_ELIGIBLE_ITEMS`=대상 상품이 주문에 없음, `MIN_ORDER_AMOUNT`=최소 주문 금액 미달, " +
|
|
170
241
|
"`LINE_NOT_ELIGIBLE`=지정한 주문상품이 대상이 아니거나 이미 상품 쿠폰이 있음, " +
|
|
171
|
-
"`LINE_AMBIGUOUS`=지정한 `optionId`의 줄이 여럿이라 줄을 정할 수 없음(`lineId`로 지목한다), `NO_DISCOUNT`=할인할 금액이
|
|
242
|
+
"`LINE_AMBIGUOUS`=지정한 `optionId`의 줄이 여럿이라 줄을 정할 수 없음(`lineId`로 지목한다), `NO_DISCOUNT`=할인할 금액이 없음, " +
|
|
243
|
+
"`CONDITION_NOT_MET`=사용 조건(이슈 #222)을 채우지 못함, `NOT_COMBINABLE`=이미 적용된 쿠폰과 함께 쓸 수 없음",
|
|
172
244
|
);
|
|
173
245
|
export type CouponRejectReason = z.infer<typeof couponRejectReasonSchema>;
|
|
174
246
|
|
|
@@ -210,6 +282,11 @@ export interface PricingCoupon {
|
|
|
210
282
|
/** 발급 쿠폰의 유효 시작·종료 — 쿠폰 사용 기간과 둘 중 좁은 쪽이 적용된다 */
|
|
211
283
|
validFrom?: string | null;
|
|
212
284
|
validUntil?: string | null;
|
|
285
|
+
/**
|
|
286
|
+
* 이 쿠폰을 판정할 사실 값 — 주문 스냅샷의 값(환불 정산). 주어지면 입력의 `facts`보다 우선한다.
|
|
287
|
+
* 결제 시작에서 쓴 구매자·시각 값을 그대로 다시 쓰므로 주문 뒤 바뀐 가입 일수·요일이 결과를 바꾸지 않는다
|
|
288
|
+
*/
|
|
289
|
+
facts?: CouponFacts | null;
|
|
213
290
|
}
|
|
214
291
|
|
|
215
292
|
/**
|
|
@@ -239,7 +316,7 @@ export interface PricingPoints {
|
|
|
239
316
|
}
|
|
240
317
|
|
|
241
318
|
/** 적립금을 쓰지 못한 이유 */
|
|
242
|
-
export type PointRejectReason = "MIN_BALANCE" | "MIN_AMOUNT" | "NOT_PAYABLE";
|
|
319
|
+
export type PointRejectReason = "MIN_BALANCE" | "MIN_AMOUNT" | "NOT_PAYABLE" | "COUPON_EXCLUSIVE";
|
|
243
320
|
|
|
244
321
|
export interface PricingInput {
|
|
245
322
|
lines: readonly PricingLine[];
|
|
@@ -248,16 +325,22 @@ export interface PricingInput {
|
|
|
248
325
|
coupons: readonly PricingCoupon[];
|
|
249
326
|
/** 배송비 견적. 없으면 배송비 0으로 계산하고 배송비 쿠폰은 할인할 금액이 없다 */
|
|
250
327
|
delivery?: PricingDelivery | null;
|
|
251
|
-
/**
|
|
252
|
-
|
|
328
|
+
/**
|
|
329
|
+
* 이 상점에서 결제 완료 주문이 없는 회원인가 — 옛 입력이다. `facts.customer.paidOrderCount`가 없을 때 true면 0으로 읽는다
|
|
330
|
+
* (false는 값을 모르는 것으로 둔다 — 「결제 완료 주문 수 = 0」 규칙은 거짓)
|
|
331
|
+
*/
|
|
332
|
+
firstOrder?: boolean;
|
|
253
333
|
/** 회원 주문인가 */
|
|
254
334
|
isMember: boolean;
|
|
255
|
-
/** 사용 기간 판정 시각 */
|
|
335
|
+
/** 사용 기간 판정 시각 — 시각 사실 값(요일·시각)도 여기서 나온다 */
|
|
256
336
|
now: Date;
|
|
337
|
+
/** 구매자 사실 값(이슈 #222) — 사용 조건 트리가 읽는다. 시각 값을 주지 않으면 `now`에서 만든다 */
|
|
338
|
+
facts?: CouponFacts | null;
|
|
257
339
|
/**
|
|
258
|
-
* 사용
|
|
340
|
+
* 사용 기간·회원 전용을 다시 보지 않는다 — **환불 정산의 재계산 전용이다.** 그 조건은 결제 시작에서
|
|
259
341
|
* 이미 통과했다. 주문 시각(승인 시각)으로 기간을 다시 보면 결제 시작과 승인 사이에 끝난 쿠폰이 조건을 잃은
|
|
260
|
-
* 것처럼 보이고, 탈퇴한 회원의 반품이 회원 전용 조건에 떨어진다.
|
|
342
|
+
* 것처럼 보이고, 탈퇴한 회원의 반품이 회원 전용 조건에 떨어진다. 사용 조건 트리는 쿠폰의 스냅샷 사실 값(`PricingCoupon.facts`)으로
|
|
343
|
+
* 남은 금액과 함께 다시 평가한다(트리 일부만 건너뛰면 `or` 트리의 결과가 틀린다).
|
|
261
344
|
*/
|
|
262
345
|
ignoreEligibility?: boolean;
|
|
263
346
|
/**
|
|
@@ -296,6 +379,8 @@ export interface PricingCouponResult {
|
|
|
296
379
|
lineId: string | null;
|
|
297
380
|
/** 이 쿠폰의 대상 줄 — 판정에 쓴 순서 그대로다. 환불 정산이 주문 시점 대상을 되살리는 데 쓴다 */
|
|
298
381
|
eligibleLineIds: string[];
|
|
382
|
+
/** 판정에 쓴 사실 값 중 이 쿠폰의 사용 조건이 읽은 것 — 주문 스냅샷에 굳힌다. 읽은 값이 없으면 빈 객체 */
|
|
383
|
+
facts: CouponFacts;
|
|
299
384
|
}
|
|
300
385
|
|
|
301
386
|
export interface PricingBundleResult {
|
|
@@ -380,26 +465,19 @@ export function allocateByWeight(total: number, weights: readonly number[]): num
|
|
|
380
465
|
}
|
|
381
466
|
|
|
382
467
|
/** 카테고리 경로(`/a/b/c/`)가 그 카테고리 또는 하위를 가리키는가 */
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** 줄이 쿠폰 대상인가 — 제외가 포함보다 우선하고, 포함 목록이 모두 비면 전 상품이다 */
|
|
468
|
+
/**
|
|
469
|
+
* 줄이 쿠폰 대상인가 — 제외가 포함보다 우선하고, 포함이 비면 전 상품이다. 트리(`targetRules`)가 없으면 옛 칸에서 만든 트리로 본다.
|
|
470
|
+
* `quantity`를 생략하면 1로 본다(단가 조건은 금액 ÷ 수량).
|
|
471
|
+
*/
|
|
390
472
|
export function isCouponTargetLine(
|
|
391
|
-
rule: Pick<CouponRule, "target" | "conditions"
|
|
392
|
-
line: Pick<PricingLine, "productId" | "categoryPath" | "baseAmount" | "amount"
|
|
473
|
+
rule: Pick<CouponRule, "target" | "conditions"> & { targetRules?: CouponTargetRules },
|
|
474
|
+
line: Pick<PricingLine, "productId" | "categoryPath" | "baseAmount" | "amount"> & {
|
|
475
|
+
quantity?: number;
|
|
476
|
+
},
|
|
393
477
|
): boolean {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (inCategory(line.categoryPath, target.excludeCategoryIds)) return false;
|
|
398
|
-
const noInclude = target.includeProductIds.length === 0 && target.includeCategoryIds.length === 0;
|
|
399
|
-
if (noInclude) return true;
|
|
400
|
-
return (
|
|
401
|
-
target.includeProductIds.includes(line.productId) ||
|
|
402
|
-
inCategory(line.categoryPath, target.includeCategoryIds)
|
|
478
|
+
return couponLineMatches(
|
|
479
|
+
couponTreesOf(rule).targetRules,
|
|
480
|
+
couponLineContextOf({ ...line, quantity: line.quantity ?? 1 }),
|
|
403
481
|
);
|
|
404
482
|
}
|
|
405
483
|
|
|
@@ -456,9 +534,28 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
456
534
|
rejectReason: null,
|
|
457
535
|
lineId: null,
|
|
458
536
|
eligibleLineIds: [],
|
|
537
|
+
facts: {},
|
|
459
538
|
}));
|
|
460
539
|
|
|
461
|
-
|
|
540
|
+
const subtotal = lines.reduce((sum, entry) => sum + entry.line.amount, 0);
|
|
541
|
+
const inputTime = input.facts?.time ?? couponTimeFactsAt(input.now);
|
|
542
|
+
/**
|
|
543
|
+
* 쿠폰 하나의 사실 값 — 스냅샷 값 → 입력 값 순. 트리가 없는 옛 쿠폰(첫 구매 = 옛 칸)만 옛 입력 `firstOrder: true`를
|
|
544
|
+
* 결제 완료 주문 0으로 읽는다. 트리 쿠폰은 호출부가 실제 사실 값을 넘긴다(옛 입력은 첫 구매 칸이 있을 때만 계산된 값이다)
|
|
545
|
+
*/
|
|
546
|
+
const factsOf = (coupon: PricingCoupon): CouponFacts => {
|
|
547
|
+
const own = coupon.facts ?? null;
|
|
548
|
+
const customer = { ...(input.facts?.customer ?? {}), ...(own?.customer ?? {}) };
|
|
549
|
+
if (
|
|
550
|
+
customer.paidOrderCount === undefined &&
|
|
551
|
+
input.firstOrder === true &&
|
|
552
|
+
coupon.rule.eligibility === undefined
|
|
553
|
+
)
|
|
554
|
+
customer.paidOrderCount = 0;
|
|
555
|
+
return { customer, time: own?.time ?? inputTime };
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
// 1) 쿠폰마다 공통 조건 — 기간·회원 → 구매자·시각 조건 → 대상 줄 → 주문서 집계 조건(옛 판정 순서와 같다)
|
|
462
559
|
const commonRejectOf = (
|
|
463
560
|
coupon: PricingCoupon,
|
|
464
561
|
result: PricingCouponResult,
|
|
@@ -467,17 +564,26 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
467
564
|
const period = periodRejectOf(coupon, input.now);
|
|
468
565
|
if (period) return period;
|
|
469
566
|
if (coupon.rule.conditions.memberOnly && !input.isMember) return "MEMBER_ONLY";
|
|
470
|
-
if (coupon.rule.conditions.firstOrderOnly && !input.firstOrder) return "FIRST_ORDER_ONLY";
|
|
471
567
|
}
|
|
568
|
+
const trees = couponTreesOf(coupon.rule);
|
|
569
|
+
const facts = factsOf(coupon);
|
|
570
|
+
result.facts = pickCouponFacts(facts, trees.eligibility);
|
|
571
|
+
const early = evaluateCouponEligibility(trees.eligibility, { facts }, "beforeLines");
|
|
572
|
+
if (early) return early;
|
|
472
573
|
const pinned = coupon.eligibleLineIds ? new Set(coupon.eligibleLineIds) : null;
|
|
473
574
|
const eligible = lines.filter((entry) =>
|
|
474
|
-
pinned
|
|
575
|
+
pinned
|
|
576
|
+
? pinned.has(entry.line.lineId)
|
|
577
|
+
: couponLineMatches(trees.targetRules, couponLineContextOf(entry.line)),
|
|
475
578
|
);
|
|
476
579
|
result.eligibleLineIds = eligible.map((entry) => entry.line.lineId);
|
|
477
580
|
if (eligible.length === 0) return "NO_ELIGIBLE_ITEMS";
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
581
|
+
const cart = {
|
|
582
|
+
subtotal,
|
|
583
|
+
targetSubtotal: eligible.reduce((sum, entry) => sum + entry.line.amount, 0),
|
|
584
|
+
targetQuantity: eligible.reduce((sum, entry) => sum + entry.line.quantity, 0),
|
|
585
|
+
};
|
|
586
|
+
return evaluateCouponEligibility(trees.eligibility, { facts, cart }, "afterLines");
|
|
481
587
|
};
|
|
482
588
|
const passed: number[] = [];
|
|
483
589
|
input.coupons.forEach((coupon, index) => {
|
|
@@ -487,21 +593,34 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
487
593
|
else passed.push(index);
|
|
488
594
|
});
|
|
489
595
|
|
|
490
|
-
/** 적용된 쿠폰
|
|
491
|
-
const
|
|
596
|
+
/** 적용된 쿠폰 — 같은 쿠폰 정의의 장수(`perOrderLimit`)와 결합 판정에 쓴다 */
|
|
597
|
+
const appliedCount = new Map<string, number>();
|
|
598
|
+
const applied: PricingCoupon[] = [];
|
|
492
599
|
const apply = (index: number, discount: number, lineId: string | null) => {
|
|
493
600
|
const result = results[index] as PricingCouponResult;
|
|
601
|
+
const coupon = input.coupons[index] as PricingCoupon;
|
|
494
602
|
result.applied = true;
|
|
495
603
|
result.discount = discount;
|
|
496
604
|
result.lineId = lineId;
|
|
497
|
-
|
|
605
|
+
appliedCount.set(result.couponId, (appliedCount.get(result.couponId) ?? 0) + 1);
|
|
606
|
+
applied.push(coupon);
|
|
498
607
|
};
|
|
608
|
+
/** 같은 쿠폰 정의의 장수 — 상품 쿠폰만 `perOrderLimit`까지, 나머지는 한 장 */
|
|
499
609
|
const duplicate = (index: number) => {
|
|
500
610
|
const result = results[index] as PricingCouponResult;
|
|
501
|
-
|
|
611
|
+
const coupon = input.coupons[index] as PricingCoupon;
|
|
612
|
+
const limit = coupon.rule.kind === "PRODUCT" ? couponStackingOf(coupon.rule).perOrderLimit : 1;
|
|
613
|
+
if ((appliedCount.get(result.couponId) ?? 0) < limit) return false;
|
|
502
614
|
result.rejectReason = "DUPLICATE_COUPON";
|
|
503
615
|
return true;
|
|
504
616
|
};
|
|
617
|
+
/** 결합 — 이미 적용된 쿠폰과 이 쿠폰이 서로의 종류를 허용해야 한다(같은 쿠폰 정의의 사본끼리는 장수 규칙이 본다) */
|
|
618
|
+
const notCombinable = (index: number) => {
|
|
619
|
+
const coupon = input.coupons[index] as PricingCoupon;
|
|
620
|
+
const blocked = applied.some((other) => !couponsCombinable(coupon, other));
|
|
621
|
+
if (blocked) (results[index] as PricingCouponResult).rejectReason = "NOT_COMBINABLE";
|
|
622
|
+
return blocked;
|
|
623
|
+
};
|
|
505
624
|
|
|
506
625
|
// 2) 상품 쿠폰 — 한 줄에 한 장. 줄을 지정한 쿠폰을 먼저 걸고, 나머지는 대상 줄이 적은 쿠폰부터
|
|
507
626
|
// 할인이 가장 큰 남은 줄(같으면 앞 줄)에 건다. 대상이 넓은 쿠폰이 좁은 쿠폰의 유일한 줄을 먼저 가져가지 않게 한다
|
|
@@ -519,6 +638,7 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
519
638
|
const coupon = input.coupons[index] as PricingCoupon;
|
|
520
639
|
const result = results[index] as PricingCouponResult;
|
|
521
640
|
if (duplicate(index)) continue;
|
|
641
|
+
if (notCombinable(index)) continue;
|
|
522
642
|
const candidates = result.eligibleLineIds.filter((id) => !takenLines.has(id));
|
|
523
643
|
let chosen: string | null = null;
|
|
524
644
|
let best = 0;
|
|
@@ -566,6 +686,7 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
566
686
|
result.rejectReason = "STACK_LIMIT";
|
|
567
687
|
continue;
|
|
568
688
|
}
|
|
689
|
+
if (notCombinable(index)) continue;
|
|
569
690
|
const targets = result.eligibleLineIds
|
|
570
691
|
.map((id) => lineOf(id))
|
|
571
692
|
.filter((entry): entry is (typeof lines)[number] => entry !== undefined);
|
|
@@ -603,6 +724,7 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
603
724
|
result.rejectReason = "STACK_LIMIT";
|
|
604
725
|
continue;
|
|
605
726
|
}
|
|
727
|
+
if (notCombinable(index)) continue;
|
|
606
728
|
const weights = deliveryBundles.map((bundle) => Math.max(0, bundle.fee));
|
|
607
729
|
const base = weights.reduce((sum, w) => sum + w, 0);
|
|
608
730
|
const discount = couponDiscountOf(coupon.rule.benefit, base, rules);
|
|
@@ -639,15 +761,24 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
639
761
|
const bundleWeights = deliveryBundles.map((bundle, index) =>
|
|
640
762
|
Math.max(0, bundle.fee + bundle.remoteSurcharge - (bundles[index]?.couponDiscount ?? 0)),
|
|
641
763
|
);
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
764
|
+
// 적립금과 함께 쓸 수 없는 쿠폰이 적용됐으면 적립금을 쓰지 않는다(쿠폰 우선, 이슈 #222)
|
|
765
|
+
const pointsBlocked = applied.some((entry) => !couponStackingOf(entry.rule).withPoints);
|
|
766
|
+
const points = pointsBlocked
|
|
767
|
+
? {
|
|
768
|
+
amount: 0,
|
|
769
|
+
maxUsable: 0,
|
|
770
|
+
adjustReason: null,
|
|
771
|
+
rejectReason: (input.points?.requested ?? 0) > 0 ? ("COUPON_EXCLUSIVE" as const) : null,
|
|
772
|
+
}
|
|
773
|
+
: pointUseOf(
|
|
774
|
+
input.points ?? null,
|
|
775
|
+
{
|
|
776
|
+
productPaid: productWeights.reduce((sum, w) => sum + w, 0),
|
|
777
|
+
deliveryPaid: bundleWeights.reduce((sum, w) => sum + w, 0),
|
|
778
|
+
total: beforePoints,
|
|
779
|
+
},
|
|
780
|
+
rules,
|
|
781
|
+
);
|
|
651
782
|
if (points.amount > 0) {
|
|
652
783
|
const productPaid = productWeights.reduce((sum, w) => sum + w, 0);
|
|
653
784
|
const toLines = Math.min(points.amount, productPaid);
|
|
@@ -750,6 +881,8 @@ export interface AppliedCouponSnapshot {
|
|
|
750
881
|
eligibleLineIds: string[];
|
|
751
882
|
/** 주문 시점 할인액 */
|
|
752
883
|
discount: number;
|
|
884
|
+
/** 주문 시점 사실 값(이슈 #222) — 사용 조건 트리가 읽은 구매자·시각 값. 없으면 빈 값(옛 주문) */
|
|
885
|
+
facts?: CouponFacts | null;
|
|
753
886
|
}
|
|
754
887
|
|
|
755
888
|
/** 정산이 보는 주문상품 한 줄 — 이번 취소를 **반영한 뒤**의 활성 수량이다 */
|
|
@@ -852,17 +985,22 @@ export function settleCouponShortfall(input: CouponSettlementInput): CouponSettl
|
|
|
852
985
|
});
|
|
853
986
|
const recomputed = quotePricing({
|
|
854
987
|
lines: pricingLines,
|
|
855
|
-
coupons: coupons.map((coupon) =>
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
988
|
+
coupons: coupons.map((coupon) => {
|
|
989
|
+
// 기간·회원 전용은 결제 시작에서 이미 통과했다. 사용 조건 트리는 주문 시점 사실 값 + 남은 금액으로 다시 본다.
|
|
990
|
+
// 스냅샷에 없는 구매자·시각 값(옛 주문)은 결제 시작에서 통과한 것으로 둔다 — 주문서 집계(`cart.*`)만 남은 금액으로 다시 판정한다
|
|
991
|
+
const facts = coupon.facts ?? {};
|
|
992
|
+
const eligibility = assumeCouponFactRules(couponTreesOf(coupon.rule).eligibility, facts);
|
|
993
|
+
return {
|
|
994
|
+
rule: { ...coupon.rule, eligibility },
|
|
995
|
+
couponKey: coupon.couponKey,
|
|
996
|
+
lineId: coupon.lineId,
|
|
997
|
+
eligibleLineIds: coupon.eligibleLineIds,
|
|
998
|
+
facts,
|
|
999
|
+
};
|
|
1000
|
+
}),
|
|
861
1001
|
delivery: null,
|
|
862
|
-
// 기간·회원·첫 구매 조건은 결제 시작에서 이미 통과했다 — 대상과 최소 금액만 남은 금액으로 다시 본다
|
|
863
|
-
firstOrder: true,
|
|
864
1002
|
isMember: true,
|
|
865
|
-
now: new Date(0), // ignoreEligibility라 기간을 보지
|
|
1003
|
+
now: new Date(0), // ignoreEligibility라 기간을 보지 않는다. 시각 조건은 스냅샷 값이거나 통과로 둔다
|
|
866
1004
|
ignoreEligibility: true,
|
|
867
1005
|
});
|
|
868
1006
|
|