@sayren/store-sdk 0.53.0 → 0.55.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 +935 -673
- package/dist/index.mjs +168 -19
- package/package.json +1 -1
- package/src/client.ts +30 -0
- package/src/schemas/coupon-rules.ts +138 -11
- package/src/schemas/coupon.ts +24 -2
- package/src/schemas/customer.ts +14 -1
- package/src/schemas/product-info.ts +37 -0
- package/src/schemas/promotion.ts +26 -9
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
import {
|
|
3
|
-
anyConditionHolds,
|
|
4
3
|
type BirthdateFact,
|
|
5
4
|
birthdateFactOf,
|
|
6
5
|
type ConditionCatalog,
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
conditionIssues,
|
|
20
19
|
conditionRuleSchema,
|
|
21
20
|
evaluateCondition,
|
|
21
|
+
evaluateConditionNode,
|
|
22
22
|
isConditionGroup,
|
|
23
23
|
} from "./condition-tree";
|
|
24
24
|
|
|
@@ -27,7 +27,8 @@ import {
|
|
|
27
27
|
*
|
|
28
28
|
* 쿠폰은 세 트리를 갖는다.
|
|
29
29
|
* - `eligibility`(사용 조건): 구매자·주문서·시각. 그룹 중첩 3단까지
|
|
30
|
-
* - `
|
|
30
|
+
* - `targetTree`(할인 대상, 이슈 #263): 줄 단위. `{ include, exclude }` 각각 조건 그룹(3단) — 포함이 참이면 대상(비면 전 상품), 제외가 참이면 빠지고 포함보다 우선(비면 제외 없음).
|
|
31
|
+
* 옛 칸 `targetRules`(평평한 「하나라도」 목록)는 `couponTargetTreeFromRules`로 같은 트리가 되고, 응답의 `targetRules`는 트리에서 만든 가장 가까운 값이다
|
|
31
32
|
* - `audience`(발급 대상): 누가 받을 수 있는가. 구매자 사실 값만
|
|
32
33
|
*
|
|
33
34
|
* 기간·한도·혜택·`memberOnly`·중복 정책은 트리 밖 칸이다. 옛 칸(`conditions`·`target`)은 프리셋 변환(`couponRulesFromLegacy`)으로 같은 트리가 된다 —
|
|
@@ -424,6 +425,20 @@ export type CouponTargetRules = z.infer<typeof couponTargetRulesSchema>;
|
|
|
424
425
|
|
|
425
426
|
export const COUPON_TARGET_RULES_ALL: CouponTargetRules = { include: [], exclude: [] };
|
|
426
427
|
|
|
428
|
+
/** 할인 대상 트리(이슈 #263) — 포함·제외 각각 조건 그룹(3단, 규칙 50개). 다른 자리와 같은 그룹 형식이다 */
|
|
429
|
+
export const couponTargetTreeSchema = z.strictObject({
|
|
430
|
+
include: (conditionGroupSchema as z.ZodType<ConditionGroup>).describe(
|
|
431
|
+
"포함 그룹 — 참인 줄이 대상. 비면 전 상품",
|
|
432
|
+
),
|
|
433
|
+
exclude: (conditionGroupSchema as z.ZodType<ConditionGroup>).describe(
|
|
434
|
+
"제외 그룹 — 참인 줄은 대상에서 빠진다. 포함보다 우선한다. 비면 제외 없음",
|
|
435
|
+
),
|
|
436
|
+
});
|
|
437
|
+
export interface CouponTargetTree {
|
|
438
|
+
include: ConditionGroup;
|
|
439
|
+
exclude: ConditionGroup;
|
|
440
|
+
}
|
|
441
|
+
|
|
427
442
|
export const EMPTY_CONDITION_GROUP: ConditionGroup = { combinator: "and", rules: [] };
|
|
428
443
|
|
|
429
444
|
/** 사용 조건·발급 대상 트리 */
|
|
@@ -464,6 +479,60 @@ export function canonicalizeCouponTargetRules(rules: CouponTargetRules): CouponT
|
|
|
464
479
|
};
|
|
465
480
|
}
|
|
466
481
|
|
|
482
|
+
/**
|
|
483
|
+
* 규칙 1개 이하 그룹은 결합어와 무관하다 — `or`로 고정해 옛 쿠폰의 `{or,[r]}`와 화면이 보낸 `{and,[r]}`를 같은 값으로 본다.
|
|
484
|
+
* 규칙 없는 하위 그룹은 뺀다 — 빈 그룹은 참으로 평가되므로 남기면 포함 「하나 이상」은 전 상품, 제외는 모든 상품을 뺀다(화면 `treeOf`와 같은 뜻)
|
|
485
|
+
*/
|
|
486
|
+
function canonicalTargetGroup(group: ConditionGroup): ConditionGroup {
|
|
487
|
+
const rules = group.rules.flatMap((node): ConditionNode[] => {
|
|
488
|
+
if (!isConditionGroup(node)) return [canonicalizeConditionRule(node)];
|
|
489
|
+
const child = canonicalTargetGroup(node);
|
|
490
|
+
return child.rules.length > 0 ? [child] : [];
|
|
491
|
+
});
|
|
492
|
+
return { combinator: rules.length <= 1 ? "or" : group.combinator, rules };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** 할인 대상 트리의 정규형 — 저장·잠금 비교·승인 비교·스냅샷이 같은 함수다 */
|
|
496
|
+
export function canonicalizeCouponTargetTree(tree: CouponTargetTree): CouponTargetTree {
|
|
497
|
+
return {
|
|
498
|
+
include: canonicalTargetGroup(tree.include),
|
|
499
|
+
exclude: canonicalTargetGroup(tree.exclude),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** 옛 평평한 목록 → 트리. 「하나라도」가 그대로 `or` 그룹이다(같은 줄 판정) */
|
|
504
|
+
export function couponTargetTreeFromRules(rules: CouponTargetRules): CouponTargetTree {
|
|
505
|
+
return {
|
|
506
|
+
include: { combinator: "or", rules: rules.include },
|
|
507
|
+
exclude: { combinator: "or", rules: rules.exclude },
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** 그룹이 평평한 「하나라도」 목록으로 같은 뜻인가 — 하위 그룹이 없고 `or`이거나 규칙이 1개 이하 */
|
|
512
|
+
function flatRulesOf(group: ConditionGroup): ConditionRule[] | null {
|
|
513
|
+
if (group.rules.some(isConditionGroup)) return null;
|
|
514
|
+
if (group.combinator === "and" && group.rules.length > 1) return null;
|
|
515
|
+
return group.rules as ConditionRule[];
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* 트리 → 옛 평평한 목록. 같은 뜻으로 옮길 수 없으면(`모두`에 규칙 2개 이상, 하위 그룹) `rulesOnly: true`이고 목록은
|
|
520
|
+
* 그룹 최상위 규칙을 평평하게 모은 가장 가까운 값이다
|
|
521
|
+
*/
|
|
522
|
+
export function couponTargetRulesFromTree(tree: CouponTargetTree): {
|
|
523
|
+
targetRules: CouponTargetRules;
|
|
524
|
+
rulesOnly: boolean;
|
|
525
|
+
} {
|
|
526
|
+
const include = flatRulesOf(tree.include);
|
|
527
|
+
const exclude = flatRulesOf(tree.exclude);
|
|
528
|
+
const top = (group: ConditionGroup) =>
|
|
529
|
+
group.rules.filter((node): node is ConditionRule => !isConditionGroup(node));
|
|
530
|
+
return {
|
|
531
|
+
targetRules: { include: include ?? top(tree.include), exclude: exclude ?? top(tree.exclude) },
|
|
532
|
+
rulesOnly: include === null || exclude === null,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
467
536
|
export function canonicalizeCouponStacking(stacking: CouponStacking): CouponStacking {
|
|
468
537
|
return {
|
|
469
538
|
combinesWith: {
|
|
@@ -495,7 +564,15 @@ export interface LegacyCouponTarget {
|
|
|
495
564
|
|
|
496
565
|
export interface CouponRuleTrees {
|
|
497
566
|
eligibility: ConditionGroup;
|
|
567
|
+
/** 할인 대상의 옛 평평한 목록 — `targetTree`가 있으면 그 트리에서 만든 가장 가까운 값이다 */
|
|
498
568
|
targetRules: CouponTargetRules;
|
|
569
|
+
/** 할인 대상 트리(이슈 #263). 없으면 `targetRules`에서 만든다(`couponTargetTreeOf`) — 계산은 트리만 읽는다 */
|
|
570
|
+
targetTree?: CouponTargetTree;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** 묶음의 할인 대상 트리 — 트리가 없으면 옛 목록에서 만든다(읽기 한 경로) */
|
|
574
|
+
export function couponTargetTreeOf(trees: Pick<CouponRuleTrees, "targetRules" | "targetTree">) {
|
|
575
|
+
return trees.targetTree ?? couponTargetTreeFromRules(trees.targetRules);
|
|
499
576
|
}
|
|
500
577
|
|
|
501
578
|
const inRule = (field: string, ids: readonly string[]): ConditionRule[] =>
|
|
@@ -552,6 +629,10 @@ const isFirstOrderRule = (node: ConditionNode): boolean =>
|
|
|
552
629
|
const isInstantExcludeRule = (rule: ConditionRule) =>
|
|
553
630
|
rule.field === "line.hasInstantDiscount" && rule.operator === "=" && rule.value === true;
|
|
554
631
|
|
|
632
|
+
const instantExcluded = (exclude: ConditionGroup) =>
|
|
633
|
+
(exclude.combinator === "or" || exclude.rules.length <= 1) &&
|
|
634
|
+
exclude.rules.some((node) => !isConditionGroup(node) && isInstantExcludeRule(node));
|
|
635
|
+
|
|
555
636
|
const isIdRule = (rule: ConditionRule) =>
|
|
556
637
|
(rule.field === "line.productId" || rule.field === "line.categoryId") && rule.operator === "in";
|
|
557
638
|
|
|
@@ -575,9 +656,11 @@ export function legacyFromCouponRules(
|
|
|
575
656
|
.flatMap((r) => r.value as string[]),
|
|
576
657
|
),
|
|
577
658
|
].sort();
|
|
578
|
-
const
|
|
659
|
+
const flat = couponTargetRulesFromTree(couponTargetTreeOf(trees));
|
|
660
|
+
const { include, exclude } = flat.targetRules;
|
|
579
661
|
const mappedEligibility = nodes.filter((n) => n === minRule || n === firstRule).length;
|
|
580
662
|
const rulesOnly =
|
|
663
|
+
flat.rulesOnly ||
|
|
581
664
|
mappedEligibility !== trees.eligibility.rules.length ||
|
|
582
665
|
nodes.filter(isMinOrderRule).length > 1 ||
|
|
583
666
|
include.some((r) => !isIdRule(r)) ||
|
|
@@ -587,7 +670,8 @@ export function legacyFromCouponRules(
|
|
|
587
670
|
minOrderAmount: minRule ? (minRule.value as number) : 0,
|
|
588
671
|
firstOrderOnly: Boolean(firstRule),
|
|
589
672
|
memberOnly,
|
|
590
|
-
|
|
673
|
+
// 즉시할인 줄을 모두 빼는 것은 제외가 「하나 이상」(또는 규칙 하나)이고 즉시할인 규칙이 최상위에 있을 때뿐이다 — 「모두」 묶음의 일부면 일부 줄만 빠진다
|
|
674
|
+
stackWithInstantDiscount: !instantExcluded(couponTargetTreeOf(trees).exclude),
|
|
591
675
|
},
|
|
592
676
|
target: {
|
|
593
677
|
includeProductIds: ids(include, "line.productId"),
|
|
@@ -601,11 +685,22 @@ export function legacyFromCouponRules(
|
|
|
601
685
|
|
|
602
686
|
// ─── 평가(쿠폰용) ─────────────────────────────────────
|
|
603
687
|
|
|
604
|
-
/**
|
|
688
|
+
/**
|
|
689
|
+
* 줄이 대상인가(트리) — 제외가 포함보다 우선, 포함이 비면 전 상품, 제외가 비면 제외 없음(빈 그룹 참 규칙을 쓰지 않는다).
|
|
690
|
+
* 계산(`quotePricing`·최대 조합·대상 상품 판정)은 모두 이 함수를 지난다
|
|
691
|
+
*/
|
|
692
|
+
export function couponTargetTreeMatches(tree: CouponTargetTree, line: CouponLineContext): boolean {
|
|
693
|
+
if (
|
|
694
|
+
tree.exclude.rules.length > 0 &&
|
|
695
|
+
evaluateConditionNode(tree.exclude, COUPON_LINE_CONDITIONS, line)
|
|
696
|
+
)
|
|
697
|
+
return false;
|
|
698
|
+
return evaluateConditionNode(tree.include, COUPON_LINE_CONDITIONS, line);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** 줄이 대상인가(옛 평평한 목록) — 트리로 바꿔 같은 판정 함수를 지난다 */
|
|
605
702
|
export function couponLineMatches(rules: CouponTargetRules, line: CouponLineContext): boolean {
|
|
606
|
-
|
|
607
|
-
if (rules.include.length === 0) return true;
|
|
608
|
-
return anyConditionHolds(rules.include, COUPON_LINE_CONDITIONS, line);
|
|
703
|
+
return couponTargetTreeMatches(couponTargetTreeFromRules(rules), line);
|
|
609
704
|
}
|
|
610
705
|
|
|
611
706
|
/** 사용 조건 실패 사유 — 옛 칸으로 옮겨지는 실패는 옛 코드를 그대로 쓴다 */
|
|
@@ -752,6 +847,7 @@ export function couponRuleIssues(input: {
|
|
|
752
847
|
perMemberUseLimit?: number | null;
|
|
753
848
|
eligibility?: ConditionGroup;
|
|
754
849
|
targetRules?: CouponTargetRules;
|
|
850
|
+
targetTree?: CouponTargetTree;
|
|
755
851
|
audience?: ConditionGroup;
|
|
756
852
|
}): CouponRuleIssue[] {
|
|
757
853
|
const issues: CouponRuleIssue[] = [];
|
|
@@ -785,7 +881,16 @@ export function couponRuleIssues(input: {
|
|
|
785
881
|
}),
|
|
786
882
|
);
|
|
787
883
|
}
|
|
788
|
-
if (input.
|
|
884
|
+
if (input.targetTree) {
|
|
885
|
+
for (const key of ["include", "exclude"] as const) {
|
|
886
|
+
push(
|
|
887
|
+
["targetTree", key],
|
|
888
|
+
conditionIssues(input.targetTree[key], COUPON_LINE_CONDITIONS, {
|
|
889
|
+
allowed: allowedIn("target"),
|
|
890
|
+
}),
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
} else if (input.targetRules) {
|
|
789
894
|
for (const key of ["include", "exclude"] as const) {
|
|
790
895
|
push(
|
|
791
896
|
["targetRules", key],
|
|
@@ -860,9 +965,14 @@ export function couponRuleIssues(input: {
|
|
|
860
965
|
|
|
861
966
|
/** 정규형 트리 묶음 */
|
|
862
967
|
export function canonicalizeCouponTrees(trees: CouponRuleTrees): CouponRuleTrees {
|
|
968
|
+
const eligibility = canonicalizeConditionGroup(trees.eligibility);
|
|
969
|
+
if (!trees.targetTree)
|
|
970
|
+
return { eligibility, targetRules: canonicalizeCouponTargetRules(trees.targetRules) };
|
|
971
|
+
const targetTree = canonicalizeCouponTargetTree(trees.targetTree);
|
|
863
972
|
return {
|
|
864
|
-
eligibility
|
|
865
|
-
targetRules: canonicalizeCouponTargetRules(
|
|
973
|
+
eligibility,
|
|
974
|
+
targetRules: canonicalizeCouponTargetRules(couponTargetRulesFromTree(targetTree).targetRules),
|
|
975
|
+
targetTree,
|
|
866
976
|
};
|
|
867
977
|
}
|
|
868
978
|
|
|
@@ -1002,6 +1112,23 @@ export function describeCouponTargetRules(
|
|
|
1002
1112
|
return `${include} (${exclude} 제외)`;
|
|
1003
1113
|
}
|
|
1004
1114
|
|
|
1115
|
+
/**
|
|
1116
|
+
* 할인 대상 문구(트리) — 「상품 1개 그리고 판매가 30,000원 이상 (즉시할인 상품 제외)」. 포함이 비면 「전 상품」.
|
|
1117
|
+
* 「하나라도」만 쓴 옛 쿠폰은 `describeCouponTargetRules`와 같은 문구다
|
|
1118
|
+
*/
|
|
1119
|
+
export function describeCouponTargetTree(
|
|
1120
|
+
tree: CouponTargetTree,
|
|
1121
|
+
formatMoney?: (minor: number) => string,
|
|
1122
|
+
): string {
|
|
1123
|
+
const flat = couponTargetRulesFromTree(tree);
|
|
1124
|
+
if (!flat.rulesOnly) return describeCouponTargetRules(flat.targetRules, formatMoney);
|
|
1125
|
+
const include = tree.include.rules.length
|
|
1126
|
+
? describeCouponGroup(tree.include, formatMoney)
|
|
1127
|
+
: "전 상품";
|
|
1128
|
+
if (tree.exclude.rules.length === 0) return include;
|
|
1129
|
+
return `${include} (${describeCouponGroup(tree.exclude, formatMoney)} 제외)`;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1005
1132
|
/**
|
|
1006
1133
|
* 스토어프론트 `requirements` — 최상위 `and`면 노드마다 한 줄(그룹은 문구 한 줄), 최상위 `or`면 트리 전체를 한 줄로.
|
|
1007
1134
|
* `minOrderAmount`(옛 필드)와 같은 원천에서 나온다.
|
package/src/schemas/coupon.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
couponRulesFromLegacy,
|
|
9
9
|
couponStackingSchema,
|
|
10
10
|
couponTargetRulesSchema,
|
|
11
|
+
couponTargetTreeSchema,
|
|
11
12
|
} from "./coupon-rules";
|
|
12
13
|
import { currencyCodeSchema } from "./currency";
|
|
13
14
|
import {
|
|
@@ -100,7 +101,12 @@ const descriptions = {
|
|
|
100
101
|
"주면 `conditions`의 최소 주문 금액·첫 구매를 쓰지 않는다(`conditions`에는 `memberOnly`만 둔다). 필드 목록은 `COUPON_CONDITION_FIELDS`",
|
|
101
102
|
targetRules:
|
|
102
103
|
"할인 대상 규칙(이슈 #222) — `include`(하나라도 맞으면 대상, 비면 전 상품)·`exclude`(하나라도 맞으면 제외, 포함보다 우선). " +
|
|
103
|
-
"주면 `target`과 `conditions.stackWithInstantDiscount`를 쓰지 않는다(즉시할인 제외는 `line.hasInstantDiscount` 규칙)"
|
|
104
|
+
"주면 `target`과 `conditions.stackWithInstantDiscount`를 쓰지 않는다(즉시할인 제외는 `line.hasInstantDiscount` 규칙). " +
|
|
105
|
+
"포함·제외를 「모두」나 묶음으로 걸려면 `targetTree`를 쓴다",
|
|
106
|
+
targetTree:
|
|
107
|
+
"할인 대상 트리(이슈 #263) — `include`·`exclude` 각각 사용 조건과 같은 그룹 `{ combinator: and|or, rules }`(3단·규칙 50개, 줄 필드만). " +
|
|
108
|
+
"포함 그룹이 참인 줄이 대상(비면 전 상품), 제외 그룹이 참인 줄은 빠진다(포함보다 우선, 비면 제외 없음). " +
|
|
109
|
+
"주면 `targetRules`·`target`·`conditions.stackWithInstantDiscount`를 함께 주지 않는다",
|
|
104
110
|
audience:
|
|
105
111
|
"발급 대상 트리(이슈 #222) — 셀러 지급·받기·회원가입 지급에서 누가 받을 수 있는가. 구매자 조건만. 코드 입력형에는 두지 않는다",
|
|
106
112
|
stacking:
|
|
@@ -119,6 +125,7 @@ const descriptions = {
|
|
|
119
125
|
const ruleFields = {
|
|
120
126
|
eligibility: couponConditionTreeSchema.optional().describe(descriptions.eligibility),
|
|
121
127
|
targetRules: couponTargetRulesSchema.optional().describe(descriptions.targetRules),
|
|
128
|
+
targetTree: couponTargetTreeSchema.optional().describe(descriptions.targetTree),
|
|
122
129
|
audience: couponConditionTreeSchema.optional().describe(descriptions.audience),
|
|
123
130
|
stacking: couponStackingSchema.optional().describe(descriptions.stacking),
|
|
124
131
|
reward: couponRewardSchema.nullable().optional().describe(descriptions.reward),
|
|
@@ -145,6 +152,7 @@ export function couponRequestRuleIssues(body: {
|
|
|
145
152
|
>;
|
|
146
153
|
eligibility?: CouponRuleTrees["eligibility"];
|
|
147
154
|
targetRules?: CouponRuleTrees["targetRules"];
|
|
155
|
+
targetTree?: CouponRuleTrees["targetTree"];
|
|
148
156
|
audience?: CouponRuleTrees["eligibility"];
|
|
149
157
|
stacking?: { perOrderLimit: number };
|
|
150
158
|
reward?: { type: "TIERED" | "BUY_X_GET_Y" | "POINT" } | null;
|
|
@@ -171,6 +179,14 @@ export function couponRequestRuleIssues(body: {
|
|
|
171
179
|
path: ["target"],
|
|
172
180
|
message: "할인 대상 규칙을 주면 대상 상품·즉시할인 제외는 규칙으로 둡니다",
|
|
173
181
|
});
|
|
182
|
+
if (
|
|
183
|
+
body.targetTree &&
|
|
184
|
+
(body.targetRules || body.target || body.conditions?.stackWithInstantDiscount !== undefined)
|
|
185
|
+
)
|
|
186
|
+
issues.push({
|
|
187
|
+
path: ["targetTree"],
|
|
188
|
+
message: "할인 대상 트리를 주면 할인 대상 규칙·대상 상품·즉시할인 제외는 트리로 둡니다",
|
|
189
|
+
});
|
|
174
190
|
const legacy = couponRulesFromLegacy(conditions, {
|
|
175
191
|
includeProductIds: body.target?.includeProductIds ?? [],
|
|
176
192
|
includeCategoryIds: body.target?.includeCategoryIds ?? [],
|
|
@@ -184,6 +200,7 @@ export function couponRequestRuleIssues(body: {
|
|
|
184
200
|
perMemberUseLimit: body.perMemberUseLimit ?? null,
|
|
185
201
|
eligibility: body.eligibility ?? legacy.eligibility,
|
|
186
202
|
targetRules: body.targetRules ?? legacy.targetRules,
|
|
203
|
+
...(body.targetTree ? { targetTree: body.targetTree } : {}),
|
|
187
204
|
...(body.audience ? { audience: body.audience } : {}),
|
|
188
205
|
}),
|
|
189
206
|
);
|
|
@@ -445,7 +462,12 @@ export const couponSchema = z.object({
|
|
|
445
462
|
targetRules: couponTargetRulesSchema
|
|
446
463
|
.optional()
|
|
447
464
|
.describe(
|
|
448
|
-
"할인 대상 규칙(이슈 #222). 옛 쿠폰은 `target`에서 만든 규칙이다. 서버는 항상 싣는다",
|
|
465
|
+
"할인 대상 규칙(이슈 #222). 옛 쿠폰은 `target`에서 만든 규칙이다. `targetTree`가 「하나라도」 목록으로 나타나지 않으면 가장 가까운 값이고 `rulesOnly`가 true다. 서버는 항상 싣는다",
|
|
466
|
+
),
|
|
467
|
+
targetTree: couponTargetTreeSchema
|
|
468
|
+
.optional()
|
|
469
|
+
.describe(
|
|
470
|
+
"할인 대상 트리(이슈 #263). 할인 대상의 원천이다. 옛 쿠폰은 `targetRules`에서 만든 트리(「하나 이상」 그룹)다. 서버는 항상 싣는다",
|
|
449
471
|
),
|
|
450
472
|
audience: couponConditionTreeSchema
|
|
451
473
|
.optional()
|
package/src/schemas/customer.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
import { orderItemStatusSchema, pageQueryShape } from "./common";
|
|
3
3
|
import { strictRequest } from "./strict";
|
|
4
|
-
import { customerUserinfoSchema } from "./userinfo";
|
|
4
|
+
import { customerUserinfoInputSchema, customerUserinfoSchema } from "./userinfo";
|
|
5
5
|
|
|
6
6
|
/** 구매자 목록 정렬 */
|
|
7
7
|
export const customerSortSchema = z
|
|
@@ -222,3 +222,16 @@ export const customerWithdrawalBlockedDetailsSchema = z.object({
|
|
|
222
222
|
export type CustomerWithdrawalBlockedDetails = z.infer<
|
|
223
223
|
typeof customerWithdrawalBlockedDetailsSchema
|
|
224
224
|
>;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* `PATCH /v1/customers/{id}` 본문(이슈 #267) — 셀러가 구매자 생일을 바꾼다. 구매자는 생일을 한 번만 입력하므로 바꾸는 쪽은 셀러다.
|
|
228
|
+
* 준 클레임만 바뀌고 `null`은 지운다
|
|
229
|
+
*/
|
|
230
|
+
export const updateCustomerRequestSchema = strictRequest(
|
|
231
|
+
z.object({
|
|
232
|
+
userinfo: customerUserinfoInputSchema
|
|
233
|
+
.pick({ birthdate: true })
|
|
234
|
+
.describe("바꿀 OIDC 클레임 — 지금은 `birthdate`만 받는다"),
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
export type UpdateCustomerRequest = z.infer<typeof updateCustomerRequestSchema>;
|
|
@@ -217,3 +217,40 @@ export const infoSectionLocalizationRequestSchema = z.strictObject({
|
|
|
217
217
|
});
|
|
218
218
|
|
|
219
219
|
export type InfoSectionLocalizationRequest = z.infer<typeof infoSectionLocalizationRequestSchema>;
|
|
220
|
+
|
|
221
|
+
// ── 고시 템플릿 ──────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
export const MAX_NOTICE_TEMPLATE_NAME = 100;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 고시 템플릿 (이슈 #250) — 자주 쓰는 고시(품목군 + 칸 값)를 이름 붙여 둔다. **적용은 복사다** — 상품의 `notice`에 같은 값을 실어 저장할 뿐
|
|
227
|
+
* 상품과 연결하지 않는다. 템플릿을 고치거나 지워도 적용한 상품의 고시는 바뀌지 않는다. 이름은 상점 안에서 대소문자·앞뒤 공백을 무시하고 유일하다.
|
|
228
|
+
*/
|
|
229
|
+
export const noticeTemplateSchema = z.object({
|
|
230
|
+
templateId: z.string(),
|
|
231
|
+
name: z.string(),
|
|
232
|
+
schemaId: z.string().describe("품목군 코드"),
|
|
233
|
+
version: z.number().int().describe("품목군 버전"),
|
|
234
|
+
values: z.record(z.string(), z.string()).describe("칸 키 → 값. 비운 칸은 없다"),
|
|
235
|
+
createdAt: z.string(),
|
|
236
|
+
updatedAt: z.string(),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
export type NoticeTemplate = z.infer<typeof noticeTemplateSchema>;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* `POST /v1/notice-templates`·`PUT /v1/notice-templates/{templateId}` 본문 — 통째로 바꾼다. 칸 값 규칙은 상품 `notice`와 같다
|
|
243
|
+
* (법정 문구 칸 400 `INVALID_NOTICE_FIELD`, 없는 품목군 400 `INVALID_INFO_CODE`, 길이 초과 400 `NOTICE_VALUE_TOO_LONG`).
|
|
244
|
+
* 같은 이름이 있으면 409 `NOTICE_TEMPLATE_NAME_TAKEN`
|
|
245
|
+
*/
|
|
246
|
+
export const noticeTemplateRequestSchema = strictRequest(
|
|
247
|
+
z.object({
|
|
248
|
+
name: z.string().trim().min(1).max(MAX_NOTICE_TEMPLATE_NAME),
|
|
249
|
+
schemaId: productNoticeRequestSchema.shape.schemaId,
|
|
250
|
+
version: productNoticeRequestSchema.shape.version,
|
|
251
|
+
values: productNoticeRequestSchema.shape.values,
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
export type NoticeTemplateRequest = z.infer<typeof noticeTemplateRequestSchema>;
|
|
256
|
+
export type NoticeTemplateRequestInput = z.input<typeof noticeTemplateRequestSchema>;
|
package/src/schemas/promotion.ts
CHANGED
|
@@ -7,11 +7,15 @@ import {
|
|
|
7
7
|
type CouponRuleTrees,
|
|
8
8
|
type CouponStacking,
|
|
9
9
|
type CouponTargetRules,
|
|
10
|
+
type CouponTargetTree,
|
|
10
11
|
couponLineContextOf,
|
|
11
|
-
couponLineMatches,
|
|
12
12
|
couponRulesFromLegacy,
|
|
13
13
|
couponStackingSchema,
|
|
14
|
+
couponTargetRulesFromTree,
|
|
14
15
|
couponTargetRulesSchema,
|
|
16
|
+
couponTargetTreeFromRules,
|
|
17
|
+
couponTargetTreeMatches,
|
|
18
|
+
couponTargetTreeSchema,
|
|
15
19
|
couponTimeFactsAt,
|
|
16
20
|
evaluateCouponEligibility,
|
|
17
21
|
pickCouponFacts,
|
|
@@ -307,6 +311,11 @@ export const couponRuleSchema = z.object({
|
|
|
307
311
|
.describe(
|
|
308
312
|
"할인 대상 규칙(이슈 #222). 없으면 `target`·`conditions.stackWithInstantDiscount`에서 만든다",
|
|
309
313
|
),
|
|
314
|
+
targetTree: couponTargetTreeSchema
|
|
315
|
+
.optional()
|
|
316
|
+
.describe(
|
|
317
|
+
"할인 대상 트리(이슈 #263). 없으면 `targetRules`(없으면 옛 칸)에서 만든다. 있으면 이것이 원천이고 `targetRules`는 가장 가까운 옛 값이다",
|
|
318
|
+
),
|
|
310
319
|
stacking: couponStackingSchema
|
|
311
320
|
.optional()
|
|
312
321
|
.describe("중복 사용 정책(이슈 #222). 없으면 종류별 한 장·서로 결합·적립금과 함께"),
|
|
@@ -314,17 +323,22 @@ export const couponRuleSchema = z.object({
|
|
|
314
323
|
});
|
|
315
324
|
export type CouponRule = z.infer<typeof couponRuleSchema>;
|
|
316
325
|
|
|
317
|
-
/** 규칙의 트리 — 트리 칸이 없으면 옛 칸에서 만든다(계산은 늘 이 한 경로) */
|
|
326
|
+
/** 규칙의 트리 — 트리 칸이 없으면 옛 칸에서 만든다(계산은 늘 이 한 경로). 할인 대상은 늘 트리(`targetTree`)까지 채운다 */
|
|
318
327
|
export function couponTreesOf(
|
|
319
328
|
rule: Pick<CouponRule, "conditions" | "target"> & {
|
|
320
329
|
eligibility?: ConditionGroup;
|
|
321
330
|
targetRules?: CouponTargetRules;
|
|
331
|
+
targetTree?: CouponTargetTree;
|
|
322
332
|
},
|
|
323
|
-
): CouponRuleTrees {
|
|
333
|
+
): CouponRuleTrees & { targetTree: CouponTargetTree } {
|
|
324
334
|
const legacy = couponRulesFromLegacy(rule.conditions, rule.target);
|
|
335
|
+
const targetRules = rule.targetTree
|
|
336
|
+
? couponTargetRulesFromTree(rule.targetTree).targetRules
|
|
337
|
+
: (rule.targetRules ?? legacy.targetRules);
|
|
325
338
|
return {
|
|
326
339
|
eligibility: rule.eligibility ?? legacy.eligibility,
|
|
327
|
-
targetRules
|
|
340
|
+
targetRules,
|
|
341
|
+
targetTree: rule.targetTree ?? couponTargetTreeFromRules(targetRules),
|
|
328
342
|
};
|
|
329
343
|
}
|
|
330
344
|
|
|
@@ -609,17 +623,20 @@ export function allocateByWeight(total: number, weights: readonly number[]): num
|
|
|
609
623
|
|
|
610
624
|
/** 카테고리 경로(`/a/b/c/`)가 그 카테고리 또는 하위를 가리키는가 */
|
|
611
625
|
/**
|
|
612
|
-
* 줄이 쿠폰 대상인가 — 제외가 포함보다 우선하고, 포함이 비면 전 상품이다. 트리(`targetRules`)가 없으면 옛 칸에서 만든 트리로 본다.
|
|
626
|
+
* 줄이 쿠폰 대상인가 — 제외가 포함보다 우선하고, 포함이 비면 전 상품이다. 트리(`targetTree`, 없으면 `targetRules`)가 없으면 옛 칸에서 만든 트리로 본다.
|
|
613
627
|
* `quantity`를 생략하면 1로 본다(단가 조건은 금액 ÷ 수량).
|
|
614
628
|
*/
|
|
615
629
|
export function isCouponTargetLine(
|
|
616
|
-
rule: Pick<CouponRule, "target" | "conditions"> & {
|
|
630
|
+
rule: Pick<CouponRule, "target" | "conditions"> & {
|
|
631
|
+
targetRules?: CouponTargetRules;
|
|
632
|
+
targetTree?: CouponTargetTree;
|
|
633
|
+
},
|
|
617
634
|
line: Pick<PricingLine, "productId" | "categoryPath" | "baseAmount" | "amount"> & {
|
|
618
635
|
quantity?: number;
|
|
619
636
|
},
|
|
620
637
|
): boolean {
|
|
621
|
-
return
|
|
622
|
-
couponTreesOf(rule).
|
|
638
|
+
return couponTargetTreeMatches(
|
|
639
|
+
couponTreesOf(rule).targetTree,
|
|
623
640
|
couponLineContextOf({ ...line, quantity: line.quantity ?? 1 }),
|
|
624
641
|
);
|
|
625
642
|
}
|
|
@@ -719,7 +736,7 @@ export function quotePricing(input: PricingInput): PricingResult {
|
|
|
719
736
|
const eligible = lines.filter((entry) =>
|
|
720
737
|
pinned
|
|
721
738
|
? pinned.has(entry.line.lineId)
|
|
722
|
-
:
|
|
739
|
+
: couponTargetTreeMatches(trees.targetTree, couponLineContextOf(entry.line)),
|
|
723
740
|
);
|
|
724
741
|
result.eligibleLineIds = eligible.map((entry) => entry.line.lineId);
|
|
725
742
|
if (eligible.length === 0) return "NO_ELIGIBLE_ITEMS";
|