@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.
@@ -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,
@@ -65,8 +74,131 @@ const descriptions = {
65
74
  "1인 발급 수 한도(셀러 지급·다운로드). 생략하면 1인 1장. 회원가입 쿠폰은 1장 고정이라 1만 받는다",
66
75
  perMemberUseLimit:
67
76
  "1인 사용 수 한도(코드 입력형). 생략하면 무제한. 비회원에게 여는 쿠폰은 둘 수 없다",
77
+ eligibility:
78
+ "사용 조건 트리(이슈 #222) — `{ combinator: and|or, rules: [{ field, operator, value } | 그룹] }`, 그룹 3단·규칙 50개까지. " +
79
+ "주면 `conditions`의 최소 주문 금액·첫 구매를 쓰지 않는다(`conditions`에는 `memberOnly`만 둔다). 필드 목록은 `COUPON_CONDITION_FIELDS`",
80
+ targetRules:
81
+ "할인 대상 규칙(이슈 #222) — `include`(하나라도 맞으면 대상, 비면 전 상품)·`exclude`(하나라도 맞으면 제외, 포함보다 우선). " +
82
+ "주면 `target`과 `conditions.stackWithInstantDiscount`를 쓰지 않는다(즉시할인 제외는 `line.hasInstantDiscount` 규칙)",
83
+ audience:
84
+ "발급 대상 트리(이슈 #222) — 셀러 지급·받기·회원가입 지급에서 누가 받을 수 있는가. 구매자 조건만. 코드 입력형에는 두지 않는다",
85
+ stacking:
86
+ "중복 사용 정책(이슈 #222). 생략하면 종류별 한 장·서로 결합·적립금과 함께. 결합은 양쪽 쿠폰이 모두 허용해야 성립한다",
87
+ issueStartsAt: "받기 시작 시각(받기 쿠폰만, 이슈 #222). 생략하면 사용 시작 시각을 따른다",
88
+ issueEndsAt:
89
+ "받기 종료 시각(받기 쿠폰만, 이 시각부터 못 받는다). 생략하면 사용 종료 시각을 따른다",
90
+ visibleFrom:
91
+ "쿠폰 목록 노출 시작(받기 쿠폰만). 이 시각부터 받기 시작 전까지 목록에 보이고 받기는 「시작 전」이다. 생략하면 받기 시작부터 보인다",
68
92
  } as const;
69
93
 
94
+ /** 조건 트리 칸 — 생성·수정 요청이 같이 쓴다 */
95
+ const ruleFields = {
96
+ eligibility: couponConditionTreeSchema.optional().describe(descriptions.eligibility),
97
+ targetRules: couponTargetRulesSchema.optional().describe(descriptions.targetRules),
98
+ audience: couponConditionTreeSchema.optional().describe(descriptions.audience),
99
+ stacking: couponStackingSchema.optional().describe(descriptions.stacking),
100
+ };
101
+
102
+ /**
103
+ * 트리와 옛 칸을 한 번에 검사한다 — 트리를 주면 그 트리, 아니면 옛 칸에서 만든 트리로 같은 규칙을 본다(검증 원천 `couponRuleIssues`).
104
+ * 생성 요청과 수정 뒤 합친 결과(`CouponsService.update`)가 이 함수를 지난다.
105
+ */
106
+ export function couponRequestRuleIssues(body: {
107
+ kind?: string;
108
+ issueMethod: string;
109
+ conditions?: Partial<{
110
+ minOrderAmount: number;
111
+ firstOrderOnly: boolean;
112
+ memberOnly: boolean;
113
+ stackWithInstantDiscount: boolean;
114
+ }>;
115
+ target?: Partial<
116
+ Record<
117
+ "includeProductIds" | "includeCategoryIds" | "excludeProductIds" | "excludeCategoryIds",
118
+ string[]
119
+ >
120
+ >;
121
+ eligibility?: CouponRuleTrees["eligibility"];
122
+ targetRules?: CouponRuleTrees["targetRules"];
123
+ audience?: CouponRuleTrees["eligibility"];
124
+ stacking?: { perOrderLimit: number };
125
+ perMemberUseLimit?: number | null;
126
+ issueStartsAt?: string | null;
127
+ issueEndsAt?: string | null;
128
+ visibleFrom?: string | null;
129
+ endsAt?: string | null;
130
+ }): { path: (string | number)[]; message: string }[] {
131
+ const issues: { path: (string | number)[]; message: string }[] = [];
132
+ const conditions = { ...COUPON_CONDITIONS_DEFAULTS, ...(body.conditions ?? {}) };
133
+ if (
134
+ body.eligibility &&
135
+ (body.conditions?.minOrderAmount !== undefined || body.conditions?.firstOrderOnly !== undefined)
136
+ )
137
+ issues.push({
138
+ path: ["conditions"],
139
+ message: "사용 조건 트리를 주면 최소 주문 금액·첫 구매는 트리로 둡니다",
140
+ });
141
+ if (body.targetRules && (body.target || body.conditions?.stackWithInstantDiscount !== undefined))
142
+ issues.push({
143
+ path: ["target"],
144
+ message: "할인 대상 규칙을 주면 대상 상품·즉시할인 제외는 규칙으로 둡니다",
145
+ });
146
+ const legacy = couponRulesFromLegacy(conditions, {
147
+ includeProductIds: body.target?.includeProductIds ?? [],
148
+ includeCategoryIds: body.target?.includeCategoryIds ?? [],
149
+ excludeProductIds: body.target?.excludeProductIds ?? [],
150
+ excludeCategoryIds: body.target?.excludeCategoryIds ?? [],
151
+ });
152
+ issues.push(
153
+ ...couponRuleIssues({
154
+ memberOnly: conditions.memberOnly,
155
+ issueMethod: body.issueMethod,
156
+ perMemberUseLimit: body.perMemberUseLimit ?? null,
157
+ eligibility: body.eligibility ?? legacy.eligibility,
158
+ targetRules: body.targetRules ?? legacy.targetRules,
159
+ ...(body.audience ? { audience: body.audience } : {}),
160
+ }),
161
+ );
162
+ if (body.stacking && body.stacking.perOrderLimit > 1 && body.kind !== "PRODUCT")
163
+ issues.push({
164
+ path: ["stacking", "perOrderLimit"],
165
+ message: "한 주문에 두 장 이상은 상품 쿠폰만 쓸 수 있습니다",
166
+ });
167
+ if (
168
+ body.issueMethod !== "DOWNLOAD" &&
169
+ (body.issueStartsAt != null || body.issueEndsAt != null || body.visibleFrom != null)
170
+ )
171
+ issues.push({
172
+ path: ["issueStartsAt"],
173
+ message: "받기 기간·노출 시작은 받기 쿠폰에만 둡니다",
174
+ });
175
+ if (
176
+ body.issueStartsAt &&
177
+ body.issueEndsAt &&
178
+ Date.parse(body.issueEndsAt) <= Date.parse(body.issueStartsAt)
179
+ )
180
+ issues.push({
181
+ path: ["issueEndsAt"],
182
+ message: "받기 종료 시각은 받기 시작 시각보다 뒤여야 합니다",
183
+ });
184
+ // 받기 기간은 사용 기간 안이다 — 넘으면 받는 순간 유효 기간이 지난 쿠폰을 준다
185
+ if (body.endsAt && body.issueEndsAt && Date.parse(body.issueEndsAt) > Date.parse(body.endsAt))
186
+ issues.push({
187
+ path: ["issueEndsAt"],
188
+ message: "받기 종료 시각은 사용 종료 시각보다 늦을 수 없습니다",
189
+ });
190
+ if (
191
+ body.endsAt &&
192
+ body.issueStartsAt &&
193
+ Date.parse(body.issueStartsAt) >= Date.parse(body.endsAt)
194
+ )
195
+ issues.push({
196
+ path: ["issueStartsAt"],
197
+ message: "받기 시작 시각은 사용 종료 시각보다 앞이어야 합니다",
198
+ });
199
+ return issues;
200
+ }
201
+
70
202
  /** 조건 입력 — 생략한 항목은 기본값(`COUPON_CONDITIONS_DEFAULTS`)이다 */
71
203
  export const couponConditionsInputSchema = z.strictObject({
72
204
  minOrderAmount: z.number().int().min(0).optional(),
@@ -119,6 +251,10 @@ export const createCouponRequestSchema = strictRequest(
119
251
  .describe(
120
252
  "쿠폰 통화(이슈 #130). 정액·최대 할인액·최소 주문 금액이 이 통화 값이고 같은 통화 주문서에만 적용된다. 생략하면 기본 통화(KRW). 판매 통화여야 하고 만든 뒤에는 바꿀 수 없다",
121
253
  ),
254
+ ...ruleFields,
255
+ issueStartsAt: couponTimeField(descriptions.issueStartsAt),
256
+ issueEndsAt: couponTimeField(descriptions.issueEndsAt),
257
+ visibleFrom: couponTimeField(descriptions.visibleFrom),
122
258
  })
123
259
  .superRefine((body, ctx) => {
124
260
  const issue = (path: string, message: string) =>
@@ -156,6 +292,9 @@ export const createCouponRequestSchema = strictRequest(
156
292
  if (body.startsAt && body.endsAt && Date.parse(body.endsAt) <= Date.parse(body.startsAt)) {
157
293
  issue("endsAt", "종료 시각은 시작 시각보다 뒤여야 합니다");
158
294
  }
295
+ for (const found of couponRequestRuleIssues(body)) {
296
+ ctx.addIssue({ code: "custom", path: found.path, message: found.message });
297
+ }
159
298
  }),
160
299
  );
161
300
  export type CreateCouponRequest = z.infer<typeof createCouponRequestSchema>;
@@ -179,6 +318,10 @@ export const updateCouponRequestSchema = strictRequest(
179
318
  totalUseLimit: limitField(descriptions.totalUseLimit),
180
319
  perMemberIssueLimit: limitField(descriptions.perMemberIssueLimit),
181
320
  perMemberUseLimit: limitField(descriptions.perMemberUseLimit),
321
+ ...ruleFields,
322
+ issueStartsAt: couponTimeField(descriptions.issueStartsAt),
323
+ issueEndsAt: couponTimeField(descriptions.issueEndsAt),
324
+ visibleFrom: couponTimeField(descriptions.visibleFrom),
182
325
  }),
183
326
  );
184
327
  export type UpdateCouponRequest = z.infer<typeof updateCouponRequestSchema>;
@@ -218,6 +361,38 @@ export const couponSchema = z.object({
218
361
  .describe(
219
362
  "쿠폰 통화(이슈 #130). 금액 필드는 이 통화의 최소 단위다. 옛 서버 응답은 KRW로 읽는다",
220
363
  ),
364
+ eligibility: couponConditionTreeSchema
365
+ .optional()
366
+ .describe(
367
+ "사용 조건 트리(이슈 #222). 옛 쿠폰은 `conditions`에서 만든 트리다. 서버는 항상 싣는다",
368
+ ),
369
+ targetRules: couponTargetRulesSchema
370
+ .optional()
371
+ .describe(
372
+ "할인 대상 규칙(이슈 #222). 옛 쿠폰은 `target`에서 만든 규칙이다. 서버는 항상 싣는다",
373
+ ),
374
+ audience: couponConditionTreeSchema
375
+ .optional()
376
+ .describe("발급 대상 트리(이슈 #222). 없으면 빈 트리(모두 대상). 서버는 항상 싣는다"),
377
+ stacking: couponStackingSchema
378
+ .optional()
379
+ .default(COUPON_STACKING_DEFAULTS)
380
+ .describe("중복 사용 정책(이슈 #222). 서버는 항상 싣는다"),
381
+ rulesOnly: z
382
+ .boolean()
383
+ .optional()
384
+ .default(false)
385
+ .describe(
386
+ "트리로만 표현되는 규칙이 있는가(이슈 #222). true면 `conditions`·`target`은 가장 가까운 옛 값이고 원천은 트리다",
387
+ ),
388
+ issueStartsAt: z
389
+ .string()
390
+ .nullable()
391
+ .optional()
392
+ .default(null)
393
+ .describe(descriptions.issueStartsAt),
394
+ issueEndsAt: z.string().nullable().optional().default(null).describe(descriptions.issueEndsAt),
395
+ visibleFrom: z.string().nullable().optional().default(null).describe(descriptions.visibleFrom),
221
396
  status: couponStatusSchema,
222
397
  locked: z.boolean().describe("발급·사용이 시작돼 혜택·조건·대상·사용 시작일을 바꿀 수 없는가"),
223
398
  lockedAt: z.string().nullable(),
@@ -288,7 +463,7 @@ export const issueCouponResultSchema = z.object({
288
463
  .string()
289
464
  .describe(
290
465
  "실패 사유. `CUSTOMER_NOT_FOUND`(이 상점 구매자가 아님)·`COUPON_LIMIT_REACHED`(1인 발급 한도)·" +
291
- "`COUPON_EXHAUSTED`(총 발급 한도)",
466
+ "`COUPON_EXHAUSTED`(총 발급 한도)·`AUDIENCE_NOT_MATCHED`(발급 대상 조건에 맞지 않음, 이슈 #222)",
292
467
  ),
293
468
  message: z.string(),
294
469
  }),
@@ -1,7 +1,13 @@
1
1
  import * as z from "zod";
2
2
  import { sellerCheckoutSchema } from "./checkout";
3
3
  import { commerceNumberField } from "./commerce-number";
4
- import { changeTypeSchema, orderItemStatusSchema, type PageParams, pageSchema } from "./common";
4
+ import {
5
+ changeTypeSchema,
6
+ type OrderItemStatus,
7
+ orderItemStatusSchema,
8
+ type PageParams,
9
+ pageSchema,
10
+ } from "./common";
5
11
  import { orderItemFulfillmentSnapshotResponseSchema } from "./fulfillment";
6
12
  import { cashReceiptSchema, easyPayProviderResponseSchema } from "./payment";
7
13
  import { customInputValueSchema, optionSelectionSchema } from "./product";
@@ -473,6 +479,67 @@ export const deliveryInfoSchema = z.object({
473
479
 
474
480
  export type DeliveryInfo = z.infer<typeof deliveryInfoSchema>;
475
481
 
482
+ /**
483
+ * 배송 주문상품 한 줄 — `GET /v1/order-items/shipping`(이슈 #208·#209). 발송 작업용으로 주문상품에 주문번호·주문자·배송지·발송 정보를 붙였다.
484
+ * 배송지가 필요한 줄(택배·직접 배달)만 나온다
485
+ */
486
+ export const shippingOrderItemSchema = orderItemSchema.extend({
487
+ orderNo: z.string().nullable().describe("주문번호(숫자 14자리). 이 필드 이전 주문은 null"),
488
+ ordererName: z.string(),
489
+ ordererPhone: z.string(),
490
+ shippingAddress: shippingAddressSchema,
491
+ delivery: deliveryInfoSchema.nullable().describe("발송 정보. 발송 전이면 null"),
492
+ });
493
+
494
+ export type ShippingOrderItem = z.infer<typeof shippingOrderItemSchema>;
495
+
496
+ export const shippingOrderItemPageSchema = pageSchema(shippingOrderItemSchema);
497
+ export type ShippingOrderItemPage = z.infer<typeof shippingOrderItemPageSchema>;
498
+
499
+ /**
500
+ * `GET /v1/order-items/shipping` 쿼리 — OpenAPI 문서용 형태. `orderedFrom` 누락 400 `MISSING_PARAMETER`, 형식 오류 400
501
+ * `INVALID_PARAMETER`, 366일 초과 400 `RANGE_TOO_WIDE`, `testPayment` 값 오류 400 `INVALID_PARAMETER`
502
+ */
503
+ export const shippingOrderItemListQuerySchema = z.object({
504
+ orderedFrom: z
505
+ .string()
506
+ .optional()
507
+ .describe("주문 시각 조회 시작 (ISO 8601 날짜 또는 일시, 포함). `orderItemNos`가 없으면 필수"),
508
+ orderedTo: z
509
+ .string()
510
+ .optional()
511
+ .describe(
512
+ "주문 시각 조회 끝 (ISO 8601, 포함). 생략하면 orderedFrom부터 31일. 기간은 최대 366일",
513
+ ),
514
+ status: z
515
+ .string()
516
+ .optional()
517
+ .describe("주문상품 상태 필터. 쉼표로 여러 상태를 받는다(예: `PAID,CONFIRMED`)"),
518
+ orderItemNos: z
519
+ .string()
520
+ .optional()
521
+ .describe("상품주문번호(하이픈 허용)를 쉼표로 이어 최대 100개. 이 번호의 주문상품만 조회한다"),
522
+ keyword: z
523
+ .string()
524
+ .optional()
525
+ .describe("주문번호·상품주문번호(일치, 하이픈 허용)·주문자명·수령인명·상품명(포함) 검색"),
526
+ testPayment: testPaymentQuery,
527
+ ...pageQuery,
528
+ size: z.string().optional().describe("페이지 크기 (기본 20, 최대 100)"),
529
+ });
530
+
531
+ export type ShippingOrderItemListParams = PageParams & {
532
+ orderedTo?: string;
533
+ /** 하나 또는 여러 상태 */
534
+ status?: OrderItemStatus | OrderItemStatus[];
535
+ keyword?: string;
536
+ /** 테스트 결제 필터 — `"true"` 테스트 결제 주문만, `"false"` 실결제만, 생략하면 전부 */
537
+ testPayment?: "true" | "false";
538
+ } & (
539
+ | { orderedFrom: string; orderItemNos?: string[] }
540
+ | { orderedFrom?: string; orderItemNos: string[] }
541
+ );
542
+
476
543
  /**
477
544
  * 택배 추적 단계. 응답에서는 문자열로 받는다(값이 추가될 수 있다) — 현재 값은 `ACCEPTED`(접수)·`IN_TRANSIT`(이동 중)·
478
545
  * `OUT_FOR_DELIVERY`(배송 출발)·`DELIVERED`(배송완료)다.