@sayren/store-sdk 0.20.0 → 0.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/store-sdk",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
package/src/client.ts CHANGED
@@ -203,6 +203,15 @@ import {
203
203
  variantStockRequestSchema,
204
204
  variantStockSchema,
205
205
  } from "./schemas/product";
206
+ import {
207
+ type SavedOptionSetFromProductRequestInput,
208
+ type SavedOptionSetListParams,
209
+ type SavedOptionSetRequestInput,
210
+ savedOptionSetFromProductRequestSchema,
211
+ savedOptionSetFromProductResultSchema,
212
+ savedOptionSetRequestSchema,
213
+ savedOptionSetSchema,
214
+ } from "./schemas/saved-option-set";
206
215
  import {
207
216
  type AddressType,
208
217
  type Invitation,
@@ -221,6 +230,8 @@ import {
221
230
  updateStoreRequestSchema,
222
231
  } from "./schemas/store";
223
232
  import {
233
+ type AddStorefrontDomainRequest,
234
+ addStorefrontDomainRequestSchema,
224
235
  type ChangeStorefrontSlugRequest,
225
236
  type ChangeStorefrontTemplateRequest,
226
237
  type CreateStorefrontEditPreviewLinkRequest,
@@ -241,8 +252,10 @@ import {
241
252
  moveStorefrontFileRequestSchema,
242
253
  type PublishStorefrontVersionRequest,
243
254
  publishStorefrontVersionRequestSchema,
255
+ type ReplaceStorefrontSourceRequest,
244
256
  type RollbackStorefrontRequest,
245
257
  type RunStorefrontCheckRequest,
258
+ replaceStorefrontSourceRequestSchema,
246
259
  rollbackStorefrontRequestSchema,
247
260
  runStorefrontCheckRequestSchema,
248
261
  type SearchStorefrontFilesRequest,
@@ -260,12 +273,21 @@ import {
260
273
  storefrontFileWriteResultSchema,
261
274
  storefrontHostingSchema,
262
275
  storefrontPreviewLinkSchema,
276
+ storefrontSiteDomainSchema,
277
+ storefrontSiteDomainsSchema,
263
278
  storefrontSiteSchema,
264
279
  storefrontSlugAvailabilitySchema,
280
+ storefrontSourceArchivePartSchema,
281
+ storefrontSourceArchiveQuerySchema,
282
+ storefrontSourceBlobsResultSchema,
283
+ storefrontSourceReplacedSchema,
284
+ storefrontSourceSchema,
265
285
  storefrontTemplateSchema,
266
286
  storefrontVerifyReportSchema,
267
287
  storefrontVersionCreatedSchema,
268
288
  storefrontVersionSchema,
289
+ type UploadStorefrontSourceBlobsRequest,
290
+ uploadStorefrontSourceBlobsRequestSchema,
269
291
  type WriteStorefrontFilesRequest,
270
292
  writeStorefrontFilesRequestSchema,
271
293
  } from "./schemas/storefront-hosting";
@@ -571,6 +593,39 @@ export function createStoreClient(options: StoreClientOptions) {
571
593
  storeHttp.requestVoid("DELETE", path`/products/${productId}/discount`),
572
594
  },
573
595
 
596
+ /**
597
+ * 저장 옵션 (이슈 #59) — 자주 쓰는 옵션 구성. 불러오기는 복사라 저장 옵션을 고쳐도 불러온 상품은 바뀌지 않는다.
598
+ * 권한은 상품과 같다(`product:r`·`product:rw`).
599
+ */
600
+ savedOptionSets: {
601
+ list: (params?: SavedOptionSetListParams) =>
602
+ storeHttp.request("GET", "/saved-option-sets", pageSchema(savedOptionSetSchema), {
603
+ query: params as Query,
604
+ }),
605
+ get: (optionSetId: string) =>
606
+ storeHttp.request("GET", path`/saved-option-sets/${optionSetId}`, savedOptionSetSchema),
607
+ /** 이름이 겹치면 409 `OPTION_SET_NAME_TAKEN`, 스토어당 200개를 넘으면 409 `OPTION_SET_LIMIT_EXCEEDED` */
608
+ create: (body: SavedOptionSetRequestInput) =>
609
+ storeHttp.request("POST", "/saved-option-sets", savedOptionSetSchema, {
610
+ body: savedOptionSetRequestSchema.parse(body),
611
+ }),
612
+ /** 통째로 바꾼다. 불러온 상품은 바뀌지 않는다 */
613
+ update: (optionSetId: string, body: SavedOptionSetRequestInput) =>
614
+ storeHttp.request("PUT", path`/saved-option-sets/${optionSetId}`, savedOptionSetSchema, {
615
+ body: savedOptionSetRequestSchema.parse(body),
616
+ }),
617
+ remove: (optionSetId: string) =>
618
+ storeHttp.requestVoid("DELETE", path`/saved-option-sets/${optionSetId}`),
619
+ /** 상품의 현재 옵션을 저장 옵션으로 만든다(또는 `replaceOptionSetId`를 덮어쓴다) */
620
+ saveFromProduct: (body: SavedOptionSetFromProductRequestInput) =>
621
+ storeHttp.request(
622
+ "POST",
623
+ "/saved-option-sets/from-product",
624
+ savedOptionSetFromProductResultSchema,
625
+ { body: savedOptionSetFromProductRequestSchema.parse(body) },
626
+ ),
627
+ },
628
+
574
629
  categories: {
575
630
  list: (params?: { parentId?: string; flat?: boolean }) =>
576
631
  storeHttp.request("GET", "/categories", z.array(categorySchema), {
@@ -1347,6 +1402,34 @@ export function createStoreClient(options: StoreClientOptions) {
1347
1402
  storeHttp.request("POST", "/storefront/site/preview-link", storefrontPreviewLinkSchema, {
1348
1403
  body: createStorefrontPreviewLinkRequestSchema.parse(body),
1349
1404
  }),
1405
+ /** `GET /v1/storefront/site/domains` — 주소 목록(기본 주소 + 내 도메인)과 DNS 안내 */
1406
+ listDomains: () =>
1407
+ storeHttp.request("GET", "/storefront/site/domains", storefrontSiteDomainsSchema),
1408
+ /**
1409
+ * `POST /v1/storefront/site/domains` (201) — 내 도메인을 추가한다. 응답의 `dnsRecords`를 DNS에 넣으면 서버가 소유를 확인하고
1410
+ * 인증서를 발급한다
1411
+ */
1412
+ addDomain: (body: AddStorefrontDomainRequest) =>
1413
+ storeHttp.request("POST", "/storefront/site/domains", storefrontSiteDomainSchema, {
1414
+ body: addStorefrontDomainRequestSchema.parse(body),
1415
+ }),
1416
+ /** `POST /v1/storefront/site/domains/{domainId}/verify` — DNS를 곧바로 다시 확인한다(실패한 도메인은 처음부터 다시) */
1417
+ verifyDomain: (domainId: string) =>
1418
+ storeHttp.request(
1419
+ "POST",
1420
+ path`/storefront/site/domains/${domainId}/verify`,
1421
+ storefrontSiteDomainSchema,
1422
+ ),
1423
+ /** `POST /v1/storefront/site/domains/{domainId}/primary` — 대표 주소로 지정한다. 다른 주소는 대표 주소로 301한다 */
1424
+ setPrimaryDomain: (domainId: string) =>
1425
+ storeHttp.request(
1426
+ "POST",
1427
+ path`/storefront/site/domains/${domainId}/primary`,
1428
+ storefrontSiteDomainsSchema,
1429
+ ),
1430
+ /** `DELETE /v1/storefront/site/domains/{domainId}` — 내 도메인 연결을 해제한다(곧바로 끊긴다) */
1431
+ removeDomain: (domainId: string) =>
1432
+ storeHttp.requestVoid("DELETE", path`/storefront/site/domains/${domainId}`),
1350
1433
  /** `POST /v1/storefront/site/disable` — 호스팅을 끈다(사이트 `CLOSED`, 슬러그 90일 보류). 다시 만들 수 있다 */
1351
1434
  disable: (body: DisableStorefrontHostingRequest) =>
1352
1435
  storeHttp.request("POST", "/storefront/site/disable", storefrontSiteSchema, {
@@ -1453,6 +1536,35 @@ export function createStoreClient(options: StoreClientOptions) {
1453
1536
  /** `GET /v1/storefront/site/builds/{buildId}` — 빌드 상태·실패 단계·로그 꼬리·규칙 결과 */
1454
1537
  getBuild: (buildId: string) =>
1455
1538
  storeHttp.request("GET", path`/storefront/site/builds/${buildId}`, storefrontBuildSchema),
1539
+
1540
+ // ─── 소스 내려받기·올리기 (#60, `sayren` CLI) ───
1541
+
1542
+ /** `GET /v1/storefront/site/source` — 지금 작업 트리를 고정하고 압축 파일(tar.gz) 크기·조각 수를 알린다 */
1543
+ getSource: () => storeHttp.request("GET", "/storefront/site/source", storefrontSourceSchema),
1544
+ /** `GET /v1/storefront/site/source/archive?sourceRef=&part=` — 압축 파일 조각(base64, 4MiB까지) */
1545
+ getSourceArchive: (params: { sourceRef: string; part?: number }) =>
1546
+ storeHttp.request(
1547
+ "GET",
1548
+ "/storefront/site/source/archive",
1549
+ storefrontSourceArchivePartSchema,
1550
+ { query: storefrontSourceArchiveQuerySchema.parse(params) as Query },
1551
+ ),
1552
+ /** `POST /v1/storefront/site/source/blobs` — 파일 내용 올리기(해시 키). 작업 트리는 바뀌지 않는다 */
1553
+ uploadSourceBlobs: (body: UploadStorefrontSourceBlobsRequest) =>
1554
+ storeHttp.request(
1555
+ "POST",
1556
+ "/storefront/site/source/blobs",
1557
+ storefrontSourceBlobsResultSchema,
1558
+ { body: uploadStorefrontSourceBlobsRequestSchema.parse(body) },
1559
+ ),
1560
+ /**
1561
+ * `PUT /v1/storefront/site/source` — 작업 트리 전체를 바꾼다. 409 `SOURCE_CHANGED`(내려받은 뒤 바뀜)·`EDIT_SESSION_OPEN`·
1562
+ * `SOURCE_BLOBS_MISSING`(`details.hashes`를 올리고 다시)
1563
+ */
1564
+ replaceSource: (body: ReplaceStorefrontSourceRequest) =>
1565
+ storeHttp.request("PUT", "/storefront/site/source", storefrontSourceReplacedSchema, {
1566
+ body: replaceStorefrontSourceRequestSchema.parse(body),
1567
+ }),
1456
1568
  },
1457
1569
 
1458
1570
  store: {
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export * from "./schemas/payment";
35
35
  export * from "./schemas/product";
36
36
  export * from "./schemas/promotion";
37
37
  export * from "./schemas/refund-policy";
38
+ export * from "./schemas/saved-option-set";
38
39
  export * from "./schemas/scope";
39
40
  export * from "./schemas/shipping-policy";
40
41
  export * from "./schemas/store";
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { changeTypeSchema, orderItemStatusSchema, type PageParams, pageSchema } from "./common";
3
3
  import { orderItemFulfillmentSnapshotResponseSchema } from "./fulfillment";
4
4
  import { cashReceiptSchema, easyPayProviderResponseSchema } from "./payment";
5
+ import { customInputValueSchema, optionSelectionSchema } from "./product";
5
6
 
6
7
  export const orderSummarySchema = z.object({
7
8
  orderId: z.string(),
@@ -59,6 +60,20 @@ export const orderItemSchema = z.object({
59
60
  productName: z.string(),
60
61
  optionId: z.string().nullable(),
61
62
  optionName: z.string().nullable(),
63
+ optionSelections: z
64
+ .array(optionSelectionSchema)
65
+ .optional()
66
+ .default([])
67
+ .describe(
68
+ "주문 시점 옵션 선택(이슈 #59) — 조합 옵션(옵션명마다 한 칸, 추가금 0)과 추가 선택(값의 추가금). 옵션이 없거나 이 필드 이전 주문은 빈 배열. 서버는 항상 싣는다",
69
+ ),
70
+ customInputs: z
71
+ .array(customInputValueSchema)
72
+ .optional()
73
+ .default([])
74
+ .describe(
75
+ "구매자가 적은 직접 입력값(주문 시점 항목명과 값, 이슈 #59). 없으면 빈 배열. 서버는 항상 싣는다",
76
+ ),
62
77
  sellerCode: z.string().nullable(),
63
78
  fulfillmentSnapshot: orderItemFulfillmentSnapshotResponseSchema.describe(
64
79
  "주문 시점 이행 스냅샷(이슈 #57). `type`이 주문 시점 상품 유형이다. 배송 상품이 아니면 발송·배송완료 대신 제공 처리를 하고, " +
@@ -76,10 +76,188 @@ export const productVariantSchema = z.object({
76
76
  weightGram: z.number().int().nullable(),
77
77
  sellerCode: z.string().nullable(),
78
78
  usable: z.boolean(),
79
+ hasOrders: z
80
+ .boolean()
81
+ .optional()
82
+ .describe(
83
+ "주문에 담긴 적이 있는 조합인가(이슈 #59). true인 조합은 옵션 교체에서 뺄 수 없고(409 `OPTION_HAS_ORDERS`) `usable: false`로 판매를 끈다. 서버는 항상 싣는다. 요청에는 쓰지 않는다",
84
+ ),
79
85
  });
80
86
 
81
87
  export type ProductVariant = z.infer<typeof productVariantSchema>;
82
88
 
89
+ /** 옵션 축(그룹) 최대 개수 — 스키마 상한의 단일 원천 */
90
+ export const MAX_OPTION_GROUPS = 3;
91
+ /** 축 하나가 가질 수 있는 값의 최대 개수 */
92
+ export const MAX_VALUES_PER_GROUP = 100;
93
+ /** 상품 하나가 가질 수 있는 variant(SKU) 최대 개수 */
94
+ export const MAX_VARIANTS = 500;
95
+
96
+ // ─── 추가 선택 옵션·직접 입력 항목 (이슈 #59) ─────────────────────
97
+ //
98
+ // 옵션명은 두 방식이다. **조합**(`optionGroups`)은 값의 조합이 판매 단위(variant)가 되고 재고·SKU·추가금이 조합에 붙는다.
99
+ // **추가 선택**(`addonGroups`)은 재고가 없고 값마다 추가금만 있다 — 구매자가 조합(옵션이 없으면 기본 판매 단위)을 고른 뒤
100
+ // 옵션명마다 값 하나를 더 고르고, 재고는 고른 조합에서 빠진다. 직접 입력 항목(`customInputs`)은 구매자가 글자를 적는 칸이다.
101
+
102
+ /** 추가 선택 옵션명 최대 개수 */
103
+ export const MAX_ADDON_GROUPS = 5;
104
+ /** 직접 입력 항목 최대 개수 */
105
+ export const MAX_CUSTOM_INPUTS = 5;
106
+ /** 직접 입력 항목 최대 글자 수의 상한 */
107
+ export const MAX_CUSTOM_INPUT_LENGTH = 200;
108
+ /** 조합 수가 이 값 이상이면 콘솔이 경고한다(상한은 `MAX_VARIANTS`) */
109
+ export const VARIANT_COUNT_WARNING = 100;
110
+
111
+ /**
112
+ * 옵션 값 정렬 순서 — 등록순·가나다순·낮은 추가금순·높은 추가금순. 서버가 저장할 때 이 순서로 값의 `sortOrder`를 매긴다.
113
+ * 추가금순은 추가 선택 값이면 값의 추가금, 조합 값이면 그 값이 든 조합 추가금의 최솟값으로 비교하고 같으면 등록순이다.
114
+ */
115
+ export const optionSortSchema = z.enum(["REGISTERED", "NAME", "PRICE_ASC", "PRICE_DESC"]);
116
+
117
+ export type OptionSort = z.infer<typeof optionSortSchema>;
118
+
119
+ /** 추가 선택 값 — 예: 옵션명 "선물 포장"의 값 "포장함"(+2,000원) */
120
+ export const addonValueSchema = z.object({
121
+ valueId: z.string(),
122
+ name: z.string(),
123
+ sortOrder: z.number().int(),
124
+ additionalPrice: z.number().int().describe("이 값을 고르면 단가에 더하는 금액(원, 0 이상)"),
125
+ usable: z.boolean().describe("사용 여부. false면 구매자에게 보이지 않고 고를 수 없다"),
126
+ });
127
+
128
+ export type AddonValue = z.infer<typeof addonValueSchema>;
129
+
130
+ /** 추가 선택 옵션명 */
131
+ export const addonGroupSchema = z.object({
132
+ groupId: z.string(),
133
+ name: z.string(),
134
+ sortOrder: z.number().int(),
135
+ required: z.boolean().describe("구매자가 꼭 골라야 하는가. false면 고르지 않아도 된다"),
136
+ values: z.array(addonValueSchema),
137
+ });
138
+
139
+ export type AddonGroup = z.infer<typeof addonGroupSchema>;
140
+
141
+ /** 직접 입력 항목 — 예: 각인 문구 */
142
+ export const customInputSchema = z.object({
143
+ inputId: z.string(),
144
+ label: z.string().describe("항목명"),
145
+ placeholder: z.string().nullable().describe("입력 안내(구매자 입력 칸의 안내 문구). 없으면 null"),
146
+ maxLength: z.number().int().describe("최대 글자 수"),
147
+ required: z.boolean(),
148
+ usable: z.boolean().describe("사용 여부. false면 구매자에게 보이지 않는다"),
149
+ sortOrder: z.number().int(),
150
+ });
151
+
152
+ export type CustomInput = z.infer<typeof customInputSchema>;
153
+
154
+ /**
155
+ * 직접 입력 항목명에 쓸 수 없는 개인정보성 단어 — 콘솔 검사와 서버 400(`CUSTOM_INPUT_LABEL_FORBIDDEN`)이 이 목록 하나를 쓴다.
156
+ * 구매자 개인정보는 주문 정보로 받는다. 직접 입력 항목으로 받으면 보관·파기 규칙 밖에 남는다.
157
+ */
158
+ export const CUSTOM_INPUT_FORBIDDEN_WORDS = [
159
+ "연락처",
160
+ "전화",
161
+ "휴대폰",
162
+ "핸드폰",
163
+ "폰번호",
164
+ "메일",
165
+ "주소",
166
+ "주민",
167
+ "생년월일",
168
+ "생일",
169
+ "실명",
170
+ "계좌",
171
+ "카드번호",
172
+ "비밀번호",
173
+ "여권",
174
+ "통관",
175
+ "phone",
176
+ "mobile",
177
+ "mail",
178
+ "address",
179
+ ] as const;
180
+
181
+ /** 금지어 비교용 정규화 — NFKC(전각·호환 문자를 보통 문자로)·소문자 뒤 문자·숫자만 남긴다(공백·기호·제로폭 문자로 쪼갠 우회를 막는다) */
182
+ function forbiddenWordKeyOf(text: string): string {
183
+ return text
184
+ .normalize("NFKC")
185
+ .toLowerCase()
186
+ .replace(/[^\p{L}\p{N}]/gu, "");
187
+ }
188
+
189
+ /**
190
+ * 직접 입력 항목명·입력 안내에 든 개인정보성 금지어. 없으면 null. 공백·기호·대소문자·전각을 무시한다.
191
+ * 서버는 항목명과 입력 안내 둘 다 이 함수로 검사한다(안내에 「연락처를 적어 주십시오」라고 쓰는 우회를 막는다).
192
+ */
193
+ export function customInputLabelForbiddenWord(label: string): string | null {
194
+ const normalized = forbiddenWordKeyOf(label);
195
+ return (
196
+ CUSTOM_INPUT_FORBIDDEN_WORDS.find((word) => normalized.includes(forbiddenWordKeyOf(word))) ??
197
+ null
198
+ );
199
+ }
200
+
201
+ /** 옵션명·값 이름 — 앞뒤 공백을 지우고 빈 값은 받지 않는다 */
202
+ const optionNameSchema = z.string().trim().min(1).max(50);
203
+
204
+ export const addonValueRequestSchema = z.object({
205
+ /** 전달 시 기존 값 행을 유지(UPDATE). 생략하면 신규 발급 */
206
+ valueId: z.string().optional(),
207
+ name: optionNameSchema,
208
+ additionalPrice: z.number().int().min(0).default(0).describe("추가금(원, 0 이상)"),
209
+ usable: z.boolean().default(true),
210
+ });
211
+
212
+ export type AddonValueRequest = z.infer<typeof addonValueRequestSchema>;
213
+
214
+ export const addonGroupRequestSchema = z.object({
215
+ /** 전달 시 기존 옵션명 행을 유지(UPDATE). 조합 옵션명의 id는 줄 수 없다(방식을 바꾸려면 새 옵션명으로 보낸다) */
216
+ groupId: z.string().optional(),
217
+ name: optionNameSchema,
218
+ required: z.boolean().default(false),
219
+ values: z.array(addonValueRequestSchema).min(1).max(MAX_VALUES_PER_GROUP),
220
+ });
221
+
222
+ export type AddonGroupRequest = z.infer<typeof addonGroupRequestSchema>;
223
+
224
+ export const customInputRequestSchema = z.object({
225
+ /** 전달 시 기존 항목 행을 유지(UPDATE). 생략하면 신규 발급 */
226
+ inputId: z.string().optional(),
227
+ label: z.string().trim().min(1).max(30),
228
+ placeholder: z.string().trim().max(100).nullish(),
229
+ maxLength: z.number().int().min(1).max(MAX_CUSTOM_INPUT_LENGTH),
230
+ required: z.boolean().default(false),
231
+ usable: z.boolean().default(true),
232
+ });
233
+
234
+ export type CustomInputRequest = z.infer<typeof customInputRequestSchema>;
235
+
236
+ /**
237
+ * 주문상품·장바구니·주문서 줄의 옵션 선택 스냅샷 한 칸 — 주문 시점 이름이다(상품을 고쳐도 바뀌지 않는다).
238
+ * 조합 옵션은 옵션명마다 한 칸이고 추가금은 조합 단위라 0이다(조합 추가금은 단가에 들어 있다). 추가 선택은 값의 추가금이다.
239
+ */
240
+ export const optionSelectionSchema = z.object({
241
+ kind: z
242
+ .string()
243
+ .describe(
244
+ "옵션명 방식. 현재 값은 `combination`·`addon`이고 값이 추가될 수 있어 문자열로 받는다",
245
+ ),
246
+ groupName: z.string(),
247
+ valueName: z.string(),
248
+ additionalPrice: z.number().int(),
249
+ });
250
+
251
+ export type OptionSelection = z.infer<typeof optionSelectionSchema>;
252
+
253
+ /** 구매자가 적은 직접 입력값 — 주문 시점 항목명과 값 */
254
+ export const customInputValueSchema = z.object({
255
+ label: z.string(),
256
+ value: z.string(),
257
+ });
258
+
259
+ export type CustomInputValue = z.infer<typeof customInputValueSchema>;
260
+
83
261
  export const discountSchema = z.object({
84
262
  productId: z.string(),
85
263
  discountType: z.enum(["RATE", "AMOUNT"]),
@@ -104,6 +282,22 @@ export const productSchema = productSummarySchema.extend({
104
282
  optionGroups: z.array(optionGroupSchema),
105
283
  /** 모든 상품이 최소 1개 variant를 갖는다 (옵션 없으면 default variant 1개) */
106
284
  variants: z.array(productVariantSchema),
285
+ addonGroups: z
286
+ .array(addonGroupSchema)
287
+ .optional()
288
+ .default([])
289
+ .describe("추가 선택 옵션명(이슈 #59). 없으면 빈 배열. 서버는 항상 싣는다"),
290
+ customInputs: z
291
+ .array(customInputSchema)
292
+ .optional()
293
+ .default([])
294
+ .describe(
295
+ "직접 입력 항목(이슈 #59). 사용하지 않는 항목도 싣는다. 없으면 빈 배열. 서버는 항상 싣는다",
296
+ ),
297
+ optionSort: optionSortSchema
298
+ .optional()
299
+ .default("REGISTERED")
300
+ .describe("옵션 값 정렬 순서(이슈 #59). 서버는 항상 싣는다"),
107
301
  fulfillment: productFulfillmentResponseSchema,
108
302
  fulfillmentTypeLocked: z
109
303
  .boolean()
@@ -187,13 +381,6 @@ export function productImageRejection(url: string): ProductImageRejection | null
187
381
  return null;
188
382
  }
189
383
 
190
- /** 옵션 축(그룹) 최대 개수 — 스키마 상한의 단일 원천 */
191
- export const MAX_OPTION_GROUPS = 3;
192
- /** 축 하나가 가질 수 있는 값의 최대 개수 */
193
- export const MAX_VALUES_PER_GROUP = 100;
194
- /** 상품 하나가 가질 수 있는 variant(SKU) 최대 개수 */
195
- export const MAX_VARIANTS = 500;
196
-
197
384
  /**
198
385
  * 식별 코드 — 빈 문자열·공백만 있는 값은 거부한다.
199
386
  * `""`를 통과시키면 서버 중복 사전 검사(falsy skip)와 DB partial unique
@@ -244,6 +431,25 @@ export type ProductVariantRequest = z.infer<typeof productVariantRequestSchema>;
244
431
  export const optionModelRequestSchema = z.object({
245
432
  optionGroups: z.array(optionGroupRequestSchema).max(MAX_OPTION_GROUPS).default([]),
246
433
  variants: z.array(productVariantRequestSchema).min(1).max(MAX_VARIANTS),
434
+ addonGroups: z
435
+ .array(addonGroupRequestSchema)
436
+ .max(MAX_ADDON_GROUPS)
437
+ .optional()
438
+ .describe(
439
+ "추가 선택 옵션명 전체(이슈 #59). 주면 통째로 바꾸고, 생략하면 등록은 없음·수정은 현재 값을 그대로 둔다. 끄려면 빈 배열을 보낸다. 기능이 열리기 전에는 비어 있지 않은 값이 409 `OPTION_ADDONS_UNAVAILABLE`이다",
440
+ ),
441
+ customInputs: z
442
+ .array(customInputRequestSchema)
443
+ .max(MAX_CUSTOM_INPUTS)
444
+ .optional()
445
+ .describe(
446
+ "직접 입력 항목 전체(이슈 #59). 배열 순서가 표시 순서다. 주면 통째로 바꾸고, 생략하면 등록은 없음·수정은 현재 값을 그대로 둔다. 항목명에 개인정보성 단어(`CUSTOM_INPUT_FORBIDDEN_WORDS`)가 있으면 400 `CUSTOM_INPUT_LABEL_FORBIDDEN`, 기능이 열리기 전에는 비어 있지 않은 값이 409 `OPTION_ADDONS_UNAVAILABLE`이다",
447
+ ),
448
+ optionSort: optionSortSchema
449
+ .optional()
450
+ .describe(
451
+ "옵션 값 정렬 순서(이슈 #59). 서버가 이 순서로 값의 `sortOrder`를 다시 매긴다. 생략하면 등록은 `REGISTERED`, 수정은 현재 값이다",
452
+ ),
247
453
  });
248
454
 
249
455
  export type OptionModelRequest = z.infer<typeof optionModelRequestSchema>;
@@ -252,6 +458,20 @@ export type OptionModelRequest = z.infer<typeof optionModelRequestSchema>;
252
458
  export const optionModelSchema = z.object({
253
459
  optionGroups: z.array(optionGroupSchema),
254
460
  variants: z.array(productVariantSchema),
461
+ addonGroups: z
462
+ .array(addonGroupSchema)
463
+ .optional()
464
+ .default([])
465
+ .describe("추가 선택 옵션명(이슈 #59). 없으면 빈 배열. 서버는 항상 싣는다"),
466
+ customInputs: z
467
+ .array(customInputSchema)
468
+ .optional()
469
+ .default([])
470
+ .describe("직접 입력 항목(이슈 #59). 없으면 빈 배열. 서버는 항상 싣는다"),
471
+ optionSort: optionSortSchema
472
+ .optional()
473
+ .default("REGISTERED")
474
+ .describe("옵션 값 정렬 순서(이슈 #59). 서버는 항상 싣는다"),
255
475
  });
256
476
 
257
477
  export type OptionModel = z.infer<typeof optionModelSchema>;
@@ -322,6 +542,13 @@ const productWriteShape = {
322
542
  "반품·교환 배송비(`returnDeliveryFee`·`exchangeDeliveryFee`)는 배송 상품에만 줄 수 있다(다른 유형은 400)",
323
543
  ),
324
544
  ...optionModelRequestSchema.shape,
545
+ sourceOptionSetIds: z
546
+ .array(z.string())
547
+ .max(10)
548
+ .optional()
549
+ .describe(
550
+ "쓰기 전용(이슈 #59) — 이 저장으로 옵션을 불러온 저장 옵션 id. 상품 저장과 같은 트랜잭션에서 불러오기 기록을 남겨 저장 옵션의 `importedProductCount`를 센다. 없는 id는 무시한다",
551
+ ),
325
552
  };
326
553
 
327
554
  export const productCreateRequestSchema = z.strictObject({
@@ -158,6 +158,7 @@ export const couponRejectReasonSchema = z
158
158
  "NO_ELIGIBLE_ITEMS",
159
159
  "MIN_ORDER_AMOUNT",
160
160
  "LINE_NOT_ELIGIBLE",
161
+ "LINE_AMBIGUOUS",
161
162
  "NO_DISCOUNT",
162
163
  ])
163
164
  .describe(
@@ -165,7 +166,8 @@ export const couponRejectReasonSchema = z
165
166
  "`STACK_LIMIT`=같은 종류의 쿠폰이 이미 적용됨(주문 쿠폰·배송비 쿠폰은 주문당 1장, 상품 쿠폰은 주문상품 한 줄에 1장), " +
166
167
  "`NOT_STARTED`=사용 기간 전, `EXPIRED`=사용 기간 지남, `MEMBER_ONLY`=회원 전용, `FIRST_ORDER_ONLY`=첫 구매 전용, " +
167
168
  "`NO_ELIGIBLE_ITEMS`=대상 상품이 주문에 없음, `MIN_ORDER_AMOUNT`=최소 주문 금액 미달, " +
168
- "`LINE_NOT_ELIGIBLE`=지정한 주문상품이 대상이 아니거나 이미 상품 쿠폰이 있음, `NO_DISCOUNT`=할인할 금액이 없음",
169
+ "`LINE_NOT_ELIGIBLE`=지정한 주문상품이 대상이 아니거나 이미 상품 쿠폰이 있음, " +
170
+ "`LINE_AMBIGUOUS`=지정한 `optionId`의 줄이 여럿이라 줄을 정할 수 없음(`lineId`로 지목한다), `NO_DISCOUNT`=할인할 금액이 없음",
169
171
  );
170
172
  export type CouponRejectReason = z.infer<typeof couponRejectReasonSchema>;
171
173