@sayren/store-sdk 0.48.0 → 0.50.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.
@@ -0,0 +1,266 @@
1
+ import { KRW_MONEY_RULES } from "./currency";
2
+ import {
3
+ couponDiscountOf,
4
+ couponStackingOf,
5
+ couponsCombinable,
6
+ type PricingCoupon,
7
+ type PricingInput,
8
+ type PricingResult,
9
+ quotePricing,
10
+ } from "./promotion";
11
+
12
+ /**
13
+ * 최대 할인 쿠폰 조합 (이슈 #222) — 후보 쿠폰 중 할인 합이 가장 큰 조합을 고르는 **순수 함수**다.
14
+ * 금액은 고른 조합을 `quotePricing`에 다시 넣어 계산한다(원천 함수 하나). 상품 상세의 최대 할인·주문서 추천이 이 함수를 지난다.
15
+ *
16
+ * 방법
17
+ * 1. 후보를 하나씩 `quotePricing`으로 돌려 혼자서도 적용되는 쿠폰만 남긴다
18
+ * 2. 주문 쿠폰·배송비 쿠폰은 자리마다 하나(없음 포함)를 고르는 경우를 모두 본다(서로 결합할 수 있는 것만)
19
+ * 3. 그 경우마다 상품 쿠폰을 줄에 배정한다 — 줄 하나에 한 장이고 할인은 줄 금액만 보므로 최대 가중 매칭이다.
20
+ * 다른 상품 쿠폰과 함께 쓸 수 없는 상품 쿠폰(`combinesWith.product: false`)은 매칭 밖이라 혼자 쓰는 경우로 따로 본다
21
+ * 4. 경우마다 `quotePricing`으로 계산하고 할인 합이 가장 큰 것을 고른다. 결제 금액이 1원 이상 최소 결제 금액 미만이 되는 조합은 결제를
22
+ * 시작할 수 없으므로, 할인이 작은 상품 쿠폰부터 하나씩 빼며 다시 본다
23
+ *
24
+ * 주문 쿠폰의 기준은 「상품 쿠폰 차감 뒤 대상 합계」라 상품 쿠폰 할인을 키워도 총 할인은 줄지 않는다 — 그래서 3의 매칭이 그 경우의 최적이다.
25
+ * 다만 정률 할인의 판매가 단위 내림 때문에 단위(KRW 10원) 안쪽의 차이는 남을 수 있다.
26
+ * 적립금은 목표에 넣지 않는다(`points` 입력은 무시한다). 고른 쿠폰에 적립금과 함께 쓸 수 없는 쿠폰이 있으면 결과의 `withPoints`가 false다.
27
+ * 동률이면 장수가 적은 조합, 그다음 먼저 끝나는 쿠폰을 쓰는 조합, 그다음 쿠폰 키 순이다.
28
+ */
29
+
30
+ export interface CouponSelectionInput extends Omit<PricingInput, "coupons" | "points"> {
31
+ /** 후보 — 쿠폰 키(`couponKey`)가 서로 달라야 한다(발급 쿠폰이면 발급 id). `lineId`는 무시한다 */
32
+ candidates: readonly PricingCoupon[];
33
+ }
34
+
35
+ export interface CouponSelection {
36
+ /** 고른 쿠폰 — 상품 쿠폰은 줄(`lineId`)이 정해져 있다. 그대로 결제 시작에 넣으면 같은 금액이다 */
37
+ coupons: PricingCoupon[];
38
+ /** 고른 조합의 계산 결과 */
39
+ result: PricingResult;
40
+ /** 쿠폰 할인 합(상품·주문 + 배송비) */
41
+ discountAmount: number;
42
+ /** 고른 조합이 적립금과 함께 쓸 수 있는가 */
43
+ withPoints: boolean;
44
+ }
45
+
46
+ type Candidate = { coupon: PricingCoupon; key: string; until: number };
47
+
48
+ const keyOf = (coupon: PricingCoupon) => coupon.couponKey ?? coupon.rule.couponId;
49
+ const untilOf = (coupon: PricingCoupon) => {
50
+ const times = [coupon.rule.endsAt, coupon.validUntil]
51
+ .filter((t): t is string => Boolean(t))
52
+ .map((t) => Date.parse(t))
53
+ .filter((t) => !Number.isNaN(t));
54
+ return times.length ? Math.min(...times) : Number.POSITIVE_INFINITY;
55
+ };
56
+
57
+ const combinable = couponsCombinable;
58
+
59
+ /**
60
+ * 최대 가중 매칭(헝가리안, 행 = 쿠폰, 열 = 줄). `weights[i][j]`가 0이면 배정하지 않는 것과 같다.
61
+ * 결과는 행마다 열 번호 또는 -1이다. 크기는 보유 쿠폰 × 주문서 줄이라 작다.
62
+ */
63
+ export function maxWeightAssignment(weights: readonly (readonly number[])[]): number[] {
64
+ const rows = weights.length;
65
+ const cols = rows ? Math.max(...weights.map((r) => r.length)) : 0;
66
+ if (!rows || !cols) return Array.from({ length: rows }, () => -1);
67
+ const n = Math.max(rows, cols);
68
+ const max = Math.max(0, ...weights.flat());
69
+ // 비용 = max − 가중치(정사각 행렬로 채운다). 1부터 세는 고전 구현
70
+ const cost = (i: number, j: number) => max - (weights[i]?.[j] ?? 0);
71
+ const u = new Array<number>(n + 1).fill(0);
72
+ const v = new Array<number>(n + 1).fill(0);
73
+ const p = new Array<number>(n + 1).fill(0);
74
+ const way = new Array<number>(n + 1).fill(0);
75
+ for (let i = 1; i <= n; i++) {
76
+ p[0] = i;
77
+ let j0 = 0;
78
+ const minv = new Array<number>(n + 1).fill(Number.POSITIVE_INFINITY);
79
+ const used = new Array<boolean>(n + 1).fill(false);
80
+ do {
81
+ used[j0] = true;
82
+ const i0 = p[j0] as number;
83
+ let delta = Number.POSITIVE_INFINITY;
84
+ let j1 = 0;
85
+ for (let j = 1; j <= n; j++) {
86
+ if (used[j]) continue;
87
+ const cur = cost(i0 - 1, j - 1) - (u[i0] as number) - (v[j] as number);
88
+ if (cur < (minv[j] as number)) {
89
+ minv[j] = cur;
90
+ way[j] = j0;
91
+ }
92
+ if ((minv[j] as number) < delta) {
93
+ delta = minv[j] as number;
94
+ j1 = j;
95
+ }
96
+ }
97
+ for (let j = 0; j <= n; j++) {
98
+ if (used[j]) {
99
+ u[p[j] as number] = (u[p[j] as number] as number) + delta;
100
+ v[j] = (v[j] as number) - delta;
101
+ } else {
102
+ minv[j] = (minv[j] as number) - delta;
103
+ }
104
+ }
105
+ j0 = j1;
106
+ } while (p[j0] !== 0);
107
+ do {
108
+ const j1 = way[j0] as number;
109
+ p[j0] = p[j1] as number;
110
+ j0 = j1;
111
+ } while (j0 !== 0);
112
+ }
113
+ const assignment = Array.from({ length: rows }, () => -1);
114
+ for (let j = 1; j <= n; j++) {
115
+ const i = (p[j] as number) - 1;
116
+ if (i >= 0 && i < rows && j - 1 < cols && (weights[i]?.[j - 1] ?? 0) > 0) assignment[i] = j - 1;
117
+ }
118
+ return assignment;
119
+ }
120
+
121
+ export function bestCouponSelection(input: CouponSelectionInput): CouponSelection {
122
+ const rules = input.moneyRules ?? KRW_MONEY_RULES;
123
+ const base: Omit<PricingInput, "coupons"> = { ...input, points: null };
124
+ const price = (coupons: PricingCoupon[]) => quotePricing({ ...base, coupons });
125
+
126
+ // 1) 혼자서도 적용되는 후보 — 먼저 끝나는 쿠폰, 쿠폰 키 순으로 줄 세워 결과가 결정적이다
127
+ const ordered: Candidate[] = input.candidates
128
+ .map((coupon) => ({
129
+ coupon: { ...coupon, lineId: null },
130
+ key: keyOf(coupon),
131
+ until: untilOf(coupon),
132
+ }))
133
+ .sort((a, b) => a.until - b.until || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
134
+ const solo = ordered
135
+ .map((candidate) => ({ candidate, result: price([candidate.coupon]).coupons[0] }))
136
+ .filter((entry) => entry.result?.applied);
137
+
138
+ const products = solo.filter((e) => e.candidate.coupon.rule.kind === "PRODUCT");
139
+ const orders = solo.filter((e) => e.candidate.coupon.rule.kind === "ORDER");
140
+ const deliveries = solo.filter((e) => e.candidate.coupon.rule.kind === "DELIVERY");
141
+ const lineIds = input.lines.map((line) => line.lineId);
142
+ const lineAmount = new Map(input.lines.map((line) => [line.lineId, line.amount]));
143
+
144
+ /** 상품 쿠폰 한 장의 줄별 할인 — 대상 줄만, 줄 금액 기준 */
145
+ const weightsOf = (entry: (typeof products)[number]) => {
146
+ const eligible = new Set(entry.result?.eligibleLineIds ?? []);
147
+ return lineIds.map((id) =>
148
+ eligible.has(id)
149
+ ? couponDiscountOf(entry.candidate.coupon.rule.benefit, lineAmount.get(id) ?? 0, rules)
150
+ : 0,
151
+ );
152
+ };
153
+
154
+ /** 같은 쿠폰 정의는 `perOrderLimit` 장까지만 후보로 둔다(사본끼리는 규칙이 같아 앞 장이면 된다) */
155
+ const withinLimit = (entries: typeof products) => {
156
+ const seen = new Map<string, number>();
157
+ return entries.filter((entry) => {
158
+ const rule = entry.candidate.coupon.rule;
159
+ const count = seen.get(rule.couponId) ?? 0;
160
+ if (count >= couponStackingOf(rule).perOrderLimit) return false;
161
+ seen.set(rule.couponId, count + 1);
162
+ return true;
163
+ });
164
+ };
165
+
166
+ const planCache = new Map<string, PricingCoupon[][]>();
167
+ /** 상품 쿠폰 배정안 — 함께 쓰는 쿠폰들의 최대 매칭 하나 + 혼자 쓰는 쿠폰마다 하나 */
168
+ const productPlans = (allowed: typeof products): PricingCoupon[][] => {
169
+ const signature = allowed.map((e) => e.candidate.key).join("\n");
170
+ const cached = planCache.get(signature);
171
+ if (cached) return cached;
172
+ const plans: PricingCoupon[][] = [];
173
+ const sociable = withinLimit(
174
+ allowed.filter((e) => couponStackingOf(e.candidate.coupon.rule).combinesWith.product),
175
+ );
176
+ const assignment = maxWeightAssignment(sociable.map(weightsOf));
177
+ plans.push(
178
+ sociable.flatMap((entry, i) => {
179
+ const col = assignment[i] ?? -1;
180
+ return col >= 0 ? [{ ...entry.candidate.coupon, lineId: lineIds[col] ?? null }] : [];
181
+ }),
182
+ );
183
+ for (const entry of allowed) {
184
+ if (couponStackingOf(entry.candidate.coupon.rule).combinesWith.product) continue;
185
+ const weights = weightsOf(entry);
186
+ const best = weights.reduce((bi, w, i) => (w > (weights[bi] ?? 0) ? i : bi), 0);
187
+ if ((weights[best] ?? 0) > 0)
188
+ plans.push([{ ...entry.candidate.coupon, lineId: lineIds[best] ?? null }]);
189
+ }
190
+ planCache.set(signature, plans);
191
+ return plans;
192
+ };
193
+
194
+ type Scored = {
195
+ coupons: PricingCoupon[];
196
+ result: PricingResult;
197
+ discount: number;
198
+ /** 동률 비교 — 쿠폰마다 (끝나는 시각, 키)를 오름차순으로 */
199
+ tie: [number, string][];
200
+ };
201
+ /** 동률 비교 — 먼저 끝나는 쿠폰을 쓰는 쪽이 앞선다(만료 없음은 가장 뒤). 숫자는 숫자로 비교한다 */
202
+ const tieLess = (a: [number, string][], b: [number, string][]) => {
203
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
204
+ const [ua, ka] = a[i] as [number, string];
205
+ const [ub, kb] = b[i] as [number, string];
206
+ if (ua !== ub) return ua < ub;
207
+ if (ka !== kb) return ka < kb;
208
+ }
209
+ return a.length < b.length;
210
+ };
211
+ let best: Scored | null = null;
212
+ const consider = (coupons: PricingCoupon[]) => {
213
+ const result = price(coupons);
214
+ if (!result.coupons.every((c) => c.applied)) return;
215
+ if (result.totalAmount > 0 && result.totalAmount < rules.minPayment) {
216
+ // 결제를 시작할 수 없는 금액 — 할인이 가장 작은 상품 쿠폰을 빼고 다시 본다
217
+ const productCoupons = result.coupons
218
+ .map((c, i) => ({ c, i }))
219
+ .filter(({ c }) => c.kind === "PRODUCT")
220
+ .sort((a, b) => a.c.discount - b.c.discount || b.i - a.i);
221
+ const drop = productCoupons[0];
222
+ if (drop) consider(coupons.filter((_, i) => i !== drop.i));
223
+ return;
224
+ }
225
+ const discount = result.couponDiscountAmount + result.deliveryDiscountAmount;
226
+ const tie = coupons
227
+ .map((c): [number, string] => [untilOf(c), keyOf(c)])
228
+ .sort((a, b) => a[0] - b[0] || (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
229
+ const current = best as Scored | null;
230
+ if (
231
+ !current ||
232
+ discount > current.discount ||
233
+ (discount === current.discount &&
234
+ (coupons.length < current.coupons.length ||
235
+ (coupons.length === current.coupons.length && tieLess(tie, current.tie))))
236
+ )
237
+ best = { coupons, result, discount, tie };
238
+ };
239
+
240
+ for (const order of [null, ...orders]) {
241
+ for (const delivery of [null, ...deliveries]) {
242
+ const o = order?.candidate.coupon ?? null;
243
+ const d = delivery?.candidate.coupon ?? null;
244
+ if (o && d && !combinable(o, d)) continue;
245
+ const allowed = products.filter(
246
+ (e) =>
247
+ (!o || combinable(e.candidate.coupon, o)) && (!d || combinable(e.candidate.coupon, d)),
248
+ );
249
+ for (const plan of productPlans(allowed)) {
250
+ consider([...plan, ...(o ? [o] : []), ...(d ? [d] : [])]);
251
+ }
252
+ }
253
+ }
254
+
255
+ const chosen = best as Scored | null;
256
+ if (!chosen) {
257
+ const result = price([]);
258
+ return { coupons: [], result, discountAmount: 0, withPoints: true };
259
+ }
260
+ return {
261
+ coupons: chosen.coupons,
262
+ result: chosen.result,
263
+ discountAmount: chosen.discount,
264
+ withPoints: chosen.coupons.every((c) => couponStackingOf(c.rule).withPoints),
265
+ };
266
+ }
@@ -1,5 +1,14 @@
1
1
  import * as z from "zod";
2
2
  import { pageQueryShape } from "./common";
3
+ import {
4
+ COUPON_STACKING_DEFAULTS,
5
+ type CouponRuleTrees,
6
+ couponConditionTreeSchema,
7
+ couponRuleIssues,
8
+ couponRulesFromLegacy,
9
+ couponStackingSchema,
10
+ couponTargetRulesSchema,
11
+ } from "./coupon-rules";
3
12
  import { currencyCodeSchema } from "./currency";
4
13
  import {
5
14
  COUPON_CONDITIONS_DEFAULTS,
@@ -61,12 +70,143 @@ const descriptions = {
61
70
  totalIssueLimit:
62
71
  "총 발급 수 한도. 생략하면 무제한. 다운로드·회원가입 쿠폰은 한도에 닿으면 더 발급하지 않는다",
63
72
  totalUseLimit: "총 사용 수 한도. 결제 중인 예약도 센다. 생략하면 무제한",
73
+ dailyIssueLimit:
74
+ "하루 발급 수 한도(이슈 #222) — 한국 시간 0시 기준 하루마다 이 장수까지 발급한다(받기·회원가입·셀러 지급). 생략하면 없다. 코드 입력형에는 두지 않는다",
64
75
  perMemberIssueLimit:
65
76
  "1인 발급 수 한도(셀러 지급·다운로드). 생략하면 1인 1장. 회원가입 쿠폰은 1장 고정이라 1만 받는다",
66
77
  perMemberUseLimit:
67
78
  "1인 사용 수 한도(코드 입력형). 생략하면 무제한. 비회원에게 여는 쿠폰은 둘 수 없다",
79
+ eligibility:
80
+ "사용 조건 트리(이슈 #222) — `{ combinator: and|or, rules: [{ field, operator, value } | 그룹] }`, 그룹 3단·규칙 50개까지. " +
81
+ "주면 `conditions`의 최소 주문 금액·첫 구매를 쓰지 않는다(`conditions`에는 `memberOnly`만 둔다). 필드 목록은 `COUPON_CONDITION_FIELDS`",
82
+ targetRules:
83
+ "할인 대상 규칙(이슈 #222) — `include`(하나라도 맞으면 대상, 비면 전 상품)·`exclude`(하나라도 맞으면 제외, 포함보다 우선). " +
84
+ "주면 `target`과 `conditions.stackWithInstantDiscount`를 쓰지 않는다(즉시할인 제외는 `line.hasInstantDiscount` 규칙)",
85
+ audience:
86
+ "발급 대상 트리(이슈 #222) — 셀러 지급·받기·회원가입 지급에서 누가 받을 수 있는가. 구매자 조건만. 코드 입력형에는 두지 않는다",
87
+ stacking:
88
+ "중복 사용 정책(이슈 #222). 생략하면 종류별 한 장·서로 결합·적립금과 함께. 결합은 양쪽 쿠폰이 모두 허용해야 성립한다",
89
+ issueStartsAt: "받기 시작 시각(받기 쿠폰만, 이슈 #222). 생략하면 사용 시작 시각을 따른다",
90
+ issueEndsAt:
91
+ "받기 종료 시각(받기 쿠폰만, 이 시각부터 못 받는다). 생략하면 사용 종료 시각을 따른다",
92
+ visibleFrom:
93
+ "쿠폰 목록 노출 시작(받기 쿠폰만). 이 시각부터 받기 시작 전까지 목록에 보이고 받기는 「시작 전」이다. 생략하면 받기 시작부터 보인다",
68
94
  } as const;
69
95
 
96
+ /** 조건 트리 칸 — 생성·수정 요청이 같이 쓴다 */
97
+ const ruleFields = {
98
+ eligibility: couponConditionTreeSchema.optional().describe(descriptions.eligibility),
99
+ targetRules: couponTargetRulesSchema.optional().describe(descriptions.targetRules),
100
+ audience: couponConditionTreeSchema.optional().describe(descriptions.audience),
101
+ stacking: couponStackingSchema.optional().describe(descriptions.stacking),
102
+ };
103
+
104
+ /**
105
+ * 트리와 옛 칸을 한 번에 검사한다 — 트리를 주면 그 트리, 아니면 옛 칸에서 만든 트리로 같은 규칙을 본다(검증 원천 `couponRuleIssues`).
106
+ * 생성 요청과 수정 뒤 합친 결과(`CouponsService.update`)가 이 함수를 지난다.
107
+ */
108
+ export function couponRequestRuleIssues(body: {
109
+ kind?: string;
110
+ issueMethod: string;
111
+ conditions?: Partial<{
112
+ minOrderAmount: number;
113
+ firstOrderOnly: boolean;
114
+ memberOnly: boolean;
115
+ stackWithInstantDiscount: boolean;
116
+ }>;
117
+ target?: Partial<
118
+ Record<
119
+ "includeProductIds" | "includeCategoryIds" | "excludeProductIds" | "excludeCategoryIds",
120
+ string[]
121
+ >
122
+ >;
123
+ eligibility?: CouponRuleTrees["eligibility"];
124
+ targetRules?: CouponRuleTrees["targetRules"];
125
+ audience?: CouponRuleTrees["eligibility"];
126
+ stacking?: { perOrderLimit: number };
127
+ perMemberUseLimit?: number | null;
128
+ dailyIssueLimit?: number | null;
129
+ issueStartsAt?: string | null;
130
+ issueEndsAt?: string | null;
131
+ visibleFrom?: string | null;
132
+ endsAt?: string | null;
133
+ }): { path: (string | number)[]; message: string }[] {
134
+ const issues: { path: (string | number)[]; message: string }[] = [];
135
+ const conditions = { ...COUPON_CONDITIONS_DEFAULTS, ...(body.conditions ?? {}) };
136
+ if (
137
+ body.eligibility &&
138
+ (body.conditions?.minOrderAmount !== undefined || body.conditions?.firstOrderOnly !== undefined)
139
+ )
140
+ issues.push({
141
+ path: ["conditions"],
142
+ message: "사용 조건 트리를 주면 최소 주문 금액·첫 구매는 트리로 둡니다",
143
+ });
144
+ if (body.targetRules && (body.target || body.conditions?.stackWithInstantDiscount !== undefined))
145
+ issues.push({
146
+ path: ["target"],
147
+ message: "할인 대상 규칙을 주면 대상 상품·즉시할인 제외는 규칙으로 둡니다",
148
+ });
149
+ const legacy = couponRulesFromLegacy(conditions, {
150
+ includeProductIds: body.target?.includeProductIds ?? [],
151
+ includeCategoryIds: body.target?.includeCategoryIds ?? [],
152
+ excludeProductIds: body.target?.excludeProductIds ?? [],
153
+ excludeCategoryIds: body.target?.excludeCategoryIds ?? [],
154
+ });
155
+ issues.push(
156
+ ...couponRuleIssues({
157
+ memberOnly: conditions.memberOnly,
158
+ issueMethod: body.issueMethod,
159
+ perMemberUseLimit: body.perMemberUseLimit ?? null,
160
+ eligibility: body.eligibility ?? legacy.eligibility,
161
+ targetRules: body.targetRules ?? legacy.targetRules,
162
+ ...(body.audience ? { audience: body.audience } : {}),
163
+ }),
164
+ );
165
+ if (body.stacking && body.stacking.perOrderLimit > 1 && body.kind !== "PRODUCT")
166
+ issues.push({
167
+ path: ["stacking", "perOrderLimit"],
168
+ message: "한 주문에 두 장 이상은 상품 쿠폰만 쓸 수 있습니다",
169
+ });
170
+ if (body.issueMethod === "CODE" && body.dailyIssueLimit != null)
171
+ issues.push({
172
+ path: ["dailyIssueLimit"],
173
+ message: "하루 발급 한도는 발급형(셀러 지급·받기·회원가입) 쿠폰에만 둡니다",
174
+ });
175
+ if (
176
+ body.issueMethod !== "DOWNLOAD" &&
177
+ (body.issueStartsAt != null || body.issueEndsAt != null || body.visibleFrom != null)
178
+ )
179
+ issues.push({
180
+ path: ["issueStartsAt"],
181
+ message: "받기 기간·노출 시작은 받기 쿠폰에만 둡니다",
182
+ });
183
+ if (
184
+ body.issueStartsAt &&
185
+ body.issueEndsAt &&
186
+ Date.parse(body.issueEndsAt) <= Date.parse(body.issueStartsAt)
187
+ )
188
+ issues.push({
189
+ path: ["issueEndsAt"],
190
+ message: "받기 종료 시각은 받기 시작 시각보다 뒤여야 합니다",
191
+ });
192
+ // 받기 기간은 사용 기간 안이다 — 넘으면 받는 순간 유효 기간이 지난 쿠폰을 준다
193
+ if (body.endsAt && body.issueEndsAt && Date.parse(body.issueEndsAt) > Date.parse(body.endsAt))
194
+ issues.push({
195
+ path: ["issueEndsAt"],
196
+ message: "받기 종료 시각은 사용 종료 시각보다 늦을 수 없습니다",
197
+ });
198
+ if (
199
+ body.endsAt &&
200
+ body.issueStartsAt &&
201
+ Date.parse(body.issueStartsAt) >= Date.parse(body.endsAt)
202
+ )
203
+ issues.push({
204
+ path: ["issueStartsAt"],
205
+ message: "받기 시작 시각은 사용 종료 시각보다 앞이어야 합니다",
206
+ });
207
+ return issues;
208
+ }
209
+
70
210
  /** 조건 입력 — 생략한 항목은 기본값(`COUPON_CONDITIONS_DEFAULTS`)이다 */
71
211
  export const couponConditionsInputSchema = z.strictObject({
72
212
  minOrderAmount: z.number().int().min(0).optional(),
@@ -111,6 +251,7 @@ export const createCouponRequestSchema = strictRequest(
111
251
  .optional()
112
252
  .describe(descriptions.validDaysAfterIssue),
113
253
  totalIssueLimit: limitField(descriptions.totalIssueLimit),
254
+ dailyIssueLimit: limitField(descriptions.dailyIssueLimit),
114
255
  totalUseLimit: limitField(descriptions.totalUseLimit),
115
256
  perMemberIssueLimit: limitField(descriptions.perMemberIssueLimit),
116
257
  perMemberUseLimit: limitField(descriptions.perMemberUseLimit),
@@ -119,6 +260,10 @@ export const createCouponRequestSchema = strictRequest(
119
260
  .describe(
120
261
  "쿠폰 통화(이슈 #130). 정액·최대 할인액·최소 주문 금액이 이 통화 값이고 같은 통화 주문서에만 적용된다. 생략하면 기본 통화(KRW). 판매 통화여야 하고 만든 뒤에는 바꿀 수 없다",
121
262
  ),
263
+ ...ruleFields,
264
+ issueStartsAt: couponTimeField(descriptions.issueStartsAt),
265
+ issueEndsAt: couponTimeField(descriptions.issueEndsAt),
266
+ visibleFrom: couponTimeField(descriptions.visibleFrom),
122
267
  })
123
268
  .superRefine((body, ctx) => {
124
269
  const issue = (path: string, message: string) =>
@@ -156,6 +301,9 @@ export const createCouponRequestSchema = strictRequest(
156
301
  if (body.startsAt && body.endsAt && Date.parse(body.endsAt) <= Date.parse(body.startsAt)) {
157
302
  issue("endsAt", "종료 시각은 시작 시각보다 뒤여야 합니다");
158
303
  }
304
+ for (const found of couponRequestRuleIssues(body)) {
305
+ ctx.addIssue({ code: "custom", path: found.path, message: found.message });
306
+ }
159
307
  }),
160
308
  );
161
309
  export type CreateCouponRequest = z.infer<typeof createCouponRequestSchema>;
@@ -176,9 +324,14 @@ export const updateCouponRequestSchema = strictRequest(
176
324
  endsAt: couponTimeField(descriptions.endsAt),
177
325
  validDaysAfterIssue: z.number().int().min(1).max(3650).nullable().optional(),
178
326
  totalIssueLimit: limitField(descriptions.totalIssueLimit),
327
+ dailyIssueLimit: limitField(descriptions.dailyIssueLimit),
179
328
  totalUseLimit: limitField(descriptions.totalUseLimit),
180
329
  perMemberIssueLimit: limitField(descriptions.perMemberIssueLimit),
181
330
  perMemberUseLimit: limitField(descriptions.perMemberUseLimit),
331
+ ...ruleFields,
332
+ issueStartsAt: couponTimeField(descriptions.issueStartsAt),
333
+ issueEndsAt: couponTimeField(descriptions.issueEndsAt),
334
+ visibleFrom: couponTimeField(descriptions.visibleFrom),
182
335
  }),
183
336
  );
184
337
  export type UpdateCouponRequest = z.infer<typeof updateCouponRequestSchema>;
@@ -202,6 +355,13 @@ export const couponSchema = z.object({
202
355
  endsAt: z.string().nullable(),
203
356
  validDaysAfterIssue: z.number().int().nullable(),
204
357
  totalIssueLimit: z.number().int().nullable(),
358
+ dailyIssueLimit: z
359
+ .number()
360
+ .int()
361
+ .nullable()
362
+ .optional()
363
+ .default(null)
364
+ .describe("하루 발급 수 한도(한국 시간 0시 기준). 없으면 null. 서버는 항상 싣는다"),
205
365
  totalUseLimit: z.number().int().nullable(),
206
366
  perMemberIssueLimit: z.number().int().nullable(),
207
367
  perMemberUseLimit: z.number().int().nullable(),
@@ -218,6 +378,38 @@ export const couponSchema = z.object({
218
378
  .describe(
219
379
  "쿠폰 통화(이슈 #130). 금액 필드는 이 통화의 최소 단위다. 옛 서버 응답은 KRW로 읽는다",
220
380
  ),
381
+ eligibility: couponConditionTreeSchema
382
+ .optional()
383
+ .describe(
384
+ "사용 조건 트리(이슈 #222). 옛 쿠폰은 `conditions`에서 만든 트리다. 서버는 항상 싣는다",
385
+ ),
386
+ targetRules: couponTargetRulesSchema
387
+ .optional()
388
+ .describe(
389
+ "할인 대상 규칙(이슈 #222). 옛 쿠폰은 `target`에서 만든 규칙이다. 서버는 항상 싣는다",
390
+ ),
391
+ audience: couponConditionTreeSchema
392
+ .optional()
393
+ .describe("발급 대상 트리(이슈 #222). 없으면 빈 트리(모두 대상). 서버는 항상 싣는다"),
394
+ stacking: couponStackingSchema
395
+ .optional()
396
+ .default(COUPON_STACKING_DEFAULTS)
397
+ .describe("중복 사용 정책(이슈 #222). 서버는 항상 싣는다"),
398
+ rulesOnly: z
399
+ .boolean()
400
+ .optional()
401
+ .default(false)
402
+ .describe(
403
+ "트리로만 표현되는 규칙이 있는가(이슈 #222). true면 `conditions`·`target`은 가장 가까운 옛 값이고 원천은 트리다",
404
+ ),
405
+ issueStartsAt: z
406
+ .string()
407
+ .nullable()
408
+ .optional()
409
+ .default(null)
410
+ .describe(descriptions.issueStartsAt),
411
+ issueEndsAt: z.string().nullable().optional().default(null).describe(descriptions.issueEndsAt),
412
+ visibleFrom: z.string().nullable().optional().default(null).describe(descriptions.visibleFrom),
221
413
  status: couponStatusSchema,
222
414
  locked: z.boolean().describe("발급·사용이 시작돼 혜택·조건·대상·사용 시작일을 바꿀 수 없는가"),
223
415
  lockedAt: z.string().nullable(),
@@ -288,7 +480,7 @@ export const issueCouponResultSchema = z.object({
288
480
  .string()
289
481
  .describe(
290
482
  "실패 사유. `CUSTOMER_NOT_FOUND`(이 상점 구매자가 아님)·`COUPON_LIMIT_REACHED`(1인 발급 한도)·" +
291
- "`COUPON_EXHAUSTED`(총 발급 한도)",
483
+ "`COUPON_EXHAUSTED`(총 발급 한도)·`AUDIENCE_NOT_MATCHED`(발급 대상 조건에 맞지 않음, 이슈 #222)",
292
484
  ),
293
485
  message: z.string(),
294
486
  }),
@@ -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>;