@sayren/store-sdk 0.30.20 → 0.32.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.30.20",
3
+ "version": "0.32.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
package/src/client.ts CHANGED
@@ -162,6 +162,22 @@ import {
162
162
  pickupLocationRequestSchema,
163
163
  pickupLocationSchema,
164
164
  } from "./schemas/fulfillment";
165
+ import {
166
+ type ClaimCollectCompleteRequest,
167
+ claimCollectCompleteRequestSchema,
168
+ type InventoryAdjustmentRequest,
169
+ type InventoryLevelListQuery,
170
+ type InventoryLocationRequest,
171
+ type InventoryMovementListQuery,
172
+ type InventoryTransferRequest,
173
+ inventoryAdjustmentRequestSchema,
174
+ inventoryLevelPageSchema,
175
+ inventoryLocationRequestSchema,
176
+ inventoryLocationSchema,
177
+ inventoryMovementPageSchema,
178
+ inventoryOperationResultSchema,
179
+ inventoryTransferRequestSchema,
180
+ } from "./schemas/inventory";
165
181
  import {
166
182
  type LoginProvider,
167
183
  loginProviderTestSchema,
@@ -226,17 +242,24 @@ import {
226
242
  paymentRecordPageSchema,
227
243
  paymentRefundRecordSchema,
228
244
  paymentSettingsSchema,
245
+ paymentStatusProviderSchema,
246
+ paymentStatusSchema,
229
247
  pgEnvironmentSchema,
230
248
  pgProviderSchema,
231
249
  type SalesReportParams,
232
250
  salesReportQuerySchema,
233
251
  salesReportSchema,
252
+ setPlatformTestKeysRequestSchema,
234
253
  type UpdatePaymentProviderRequest,
235
254
  type UpdatePaymentSettingsRequest,
255
+ type UpsertKakaoPayProviderRequest,
256
+ type UpsertNaverPayProviderRequest,
236
257
  type UpsertPortOneProviderRequest,
237
258
  type UpsertTossPaymentsProviderRequest,
238
259
  updatePaymentProviderRequestSchema,
239
260
  updatePaymentSettingsRequestSchema,
261
+ upsertKakaoPayProviderRequestSchema,
262
+ upsertNaverPayProviderRequestSchema,
240
263
  upsertPortOneProviderRequestSchema,
241
264
  upsertTossPaymentsProviderRequestSchema,
242
265
  type VerifyPaymentProviderRequest,
@@ -1042,11 +1065,26 @@ export function createStoreClient(options: StoreClientOptions) {
1042
1065
  claimId: string,
1043
1066
  body: { collectMethod: "SELLER_PICKUP" | "BUYER_SEND"; carrierCode?: string },
1044
1067
  ) => storeHttp.requestVoid("POST", path`/claims/${claimId}/collect`, { body }),
1045
- completeCollect: (claimId: string) =>
1046
- storeHttp.requestVoid("POST", path`/claims/${claimId}/collect/complete`),
1047
- registerRedelivery: (claimId: string, carrierCode: string, trackingNumber: string) =>
1068
+ /** 수거 완료 — `restock`(이슈 #132)으로 돌아온 조합·재입고 위치를 지정한다. 생략하면 할당 순서·반품지 위치다 */
1069
+ completeCollect: (claimId: string, body?: ClaimCollectCompleteRequest) =>
1070
+ storeHttp.requestVoid("POST", path`/claims/${claimId}/collect/complete`, {
1071
+ ...(body ? { body: claimCollectCompleteRequestSchema.parse(body) } : {}),
1072
+ }),
1073
+ /** 교환 재배송 — `inventoryLocationId`(이슈 #132)로 교환품을 보낼 위치를 지정한다. 생략하면 그 상품의 기준 위치다 */
1074
+ registerRedelivery: (
1075
+ claimId: string,
1076
+ carrierCode: string,
1077
+ trackingNumber: string,
1078
+ options: { inventoryLocationId?: string } = {},
1079
+ ) =>
1048
1080
  storeHttp.requestVoid("POST", path`/claims/${claimId}/redelivery`, {
1049
- body: { carrierCode, trackingNumber },
1081
+ body: {
1082
+ carrierCode,
1083
+ trackingNumber,
1084
+ ...(options.inventoryLocationId
1085
+ ? { inventoryLocationId: options.inventoryLocationId }
1086
+ : {}),
1087
+ },
1050
1088
  }),
1051
1089
  hold: (claimId: string, reason: string) =>
1052
1090
  storeHttp.request("POST", path`/claims/${claimId}/hold`, claimHoldResultSchema, {
@@ -1395,6 +1433,22 @@ export function createStoreClient(options: StoreClientOptions) {
1395
1433
  storeHttp.request("PUT", "/store/payment-providers/portone", paymentProviderSchema, {
1396
1434
  body: upsertPortOneProviderRequestSchema.parse(body),
1397
1435
  }),
1436
+ /**
1437
+ * `PUT /v1/store/payment-providers/kakaopay` — 카카오페이(직접 연동) 자격 증명 세트 저장. 규칙·에러는 `upsertTossPayments`와 같다
1438
+ * (가맹점 식별자는 `cid`, 비밀 필드는 `secretKey`). 결제 옵션은 간편결제(`KAKAOPAY`) 하나다.
1439
+ */
1440
+ upsertKakaoPay: (body: UpsertKakaoPayProviderRequest) =>
1441
+ storeHttp.request("PUT", "/store/payment-providers/kakaopay", paymentProviderSchema, {
1442
+ body: upsertKakaoPayProviderRequestSchema.parse(body),
1443
+ }),
1444
+ /**
1445
+ * `PUT /v1/store/payment-providers/naverpay` — 네이버페이 결제형(직접 연동) 자격 증명 세트 저장. 규칙·에러는 `upsertTossPayments`와 같다
1446
+ * (가맹점 식별자는 `clientId`, 비밀 필드는 `clientSecret`). 결제 옵션은 간편결제(`NAVERPAY`) 하나다.
1447
+ */
1448
+ upsertNaverPay: (body: UpsertNaverPayProviderRequest) =>
1449
+ storeHttp.request("PUT", "/store/payment-providers/naverpay", paymentProviderSchema, {
1450
+ body: upsertNaverPayProviderRequestSchema.parse(body),
1451
+ }),
1398
1452
  /**
1399
1453
  * `PATCH /v1/store/payment-providers/{provider}` — 사용 여부(`enabled`)·샌드박스 스위치(`sandboxMode`) 변경.
1400
1454
  * 하나 이상 보내야 한다(아니면 요청 전에 ZodError). 검증을 모두 통과해야 한 번에 적용된다(부분 적용 없음).
@@ -1416,6 +1470,19 @@ export function createStoreClient(options: StoreClientOptions) {
1416
1470
  */
1417
1471
  setSandboxMode: (provider: PgProvider, sandboxMode: boolean) =>
1418
1472
  updatePaymentProvider(provider, { sandboxMode }),
1473
+ /**
1474
+ * `PUT /v1/store/payment-providers/{provider}/platform-test-keys` — 테스트모드 키 활성화. 켜면 sayren 지정 테스트 키를 TEST 세트로 저장하고 PG를 켠다
1475
+ * (OWNER·ADMIN, 에이전트도 쓸 수 있다). 에러: 409 `PLATFORM_TEST_KEYS_UNAVAILABLE`(그 PG의 지정 테스트 키가 없다)
1476
+ */
1477
+ setPlatformTestKeys: (provider: PgProvider, enabled: boolean) =>
1478
+ storeHttp.request(
1479
+ "PUT",
1480
+ path`/store/payment-providers/${pgProviderSchema.parse(provider)}/platform-test-keys`,
1481
+ paymentStatusProviderSchema,
1482
+ { body: setPlatformTestKeysRequestSchema.parse({ enabled }) },
1483
+ ),
1484
+ /** `GET /v1/store/payment-status` — 지원 PG와 연결·사용·테스트모드 키 상태(비밀 값 없음, OWNER·ADMIN·에이전트) */
1485
+ status: () => storeHttp.request("GET", "/store/payment-status", paymentStatusSchema),
1419
1486
  /**
1420
1487
  * `DELETE /v1/store/payment-providers/{provider}` (204) — 연결 해제. 결제 이력과 무관하게 허용된다.
1421
1488
  * 모든 세트는 보관되고 PG 설정에서 사라진다. 이미 받은 결제의 취소·환불·대사는 보관된 자격 증명으로 계속 처리된다.
@@ -1716,6 +1783,50 @@ export function createStoreClient(options: StoreClientOptions) {
1716
1783
  },
1717
1784
 
1718
1785
  /** 픽업 장소 (이슈 #115) — 방문 수령 주문의 수령 장소. 조회 `store:r`, 쓰기 `store:rw`(상점 플랫폼 OWNER·ADMIN) */
1786
+ /**
1787
+ * 위치별 재고 (이슈 #132) — 재고 위치·위치별 가용 재고·재고 조정·실사·이동·변동 내역. 위치 쓰기는 상점 설정 표면(상점 플랫폼 OWNER·ADMIN)이고
1788
+ * 조회는 `inventory:r`, 조정·이동은 `inventory:rw`다. 같은 `idempotencyKey`로 다시 보내면 첫 결과를 돌려준다(다른 본문이면 409)
1789
+ */
1790
+ inventory: {
1791
+ locations: {
1792
+ list: () =>
1793
+ storeHttp.request("GET", "/inventory-locations", z.array(inventoryLocationSchema)),
1794
+ create: (body: InventoryLocationRequest) =>
1795
+ storeHttp.request("POST", "/inventory-locations", inventoryLocationSchema, {
1796
+ body: inventoryLocationRequestSchema.parse(body),
1797
+ }),
1798
+ update: (inventoryLocationId: string, body: InventoryLocationRequest) =>
1799
+ storeHttp.request(
1800
+ "PUT",
1801
+ path`/inventory-locations/${inventoryLocationId}`,
1802
+ inventoryLocationSchema,
1803
+ { body: inventoryLocationRequestSchema.parse(body) },
1804
+ ),
1805
+ remove: (inventoryLocationId: string) =>
1806
+ storeHttp.requestVoid("DELETE", path`/inventory-locations/${inventoryLocationId}`),
1807
+ },
1808
+ /** 커서 페이지네이션 — 다음 페이지는 응답의 `nextCursor`를 `cursor`로 넘긴다 */
1809
+ levels: (params?: InventoryLevelListQuery) =>
1810
+ storeHttp.request("GET", "/inventory/levels", inventoryLevelPageSchema, {
1811
+ query: params,
1812
+ }),
1813
+ /** 순번 오름차순 — 다음 쪽은 `nextAfter`를 `after`로 넘긴다 */
1814
+ movements: (params?: InventoryMovementListQuery) =>
1815
+ storeHttp.request("GET", "/inventory/movements", inventoryMovementPageSchema, {
1816
+ query: params,
1817
+ }),
1818
+ adjust: (body: InventoryAdjustmentRequest, idempotencyKey?: string) =>
1819
+ storeHttp.request("POST", "/inventory/adjustments", inventoryOperationResultSchema, {
1820
+ body: inventoryAdjustmentRequestSchema.parse(body),
1821
+ headers: idempotency(idempotencyKey),
1822
+ }),
1823
+ transfer: (body: InventoryTransferRequest, idempotencyKey?: string) =>
1824
+ storeHttp.request("POST", "/inventory/transfers", inventoryOperationResultSchema, {
1825
+ body: inventoryTransferRequestSchema.parse(body),
1826
+ headers: idempotency(idempotencyKey),
1827
+ }),
1828
+ },
1829
+
1719
1830
  pickupLocations: {
1720
1831
  list: () => storeHttp.request("GET", "/pickup-locations", z.array(pickupLocationSchema)),
1721
1832
  create: (body: PickupLocationRequestInput) =>
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export * from "./schemas/coupon";
33
33
  export * from "./schemas/customer";
34
34
  export * from "./schemas/customer-delegation";
35
35
  export * from "./schemas/fulfillment";
36
+ export * from "./schemas/inventory";
36
37
  export * from "./schemas/login";
37
38
  export * from "./schemas/misc";
38
39
  export * from "./schemas/order";
package/src/schemas/ai.ts CHANGED
@@ -113,9 +113,16 @@ export type AiToolChange = z.infer<typeof aiToolChangeSchema>;
113
113
 
114
114
  /**
115
115
  * 에이전트 작업 단계(2026-10-13, AI 상점 도우미 작업 공간) — 에이전트가 패널 도구로 알린다.
116
- * 상점·상품·배포 준비 → 쇼핑몰 구조 설계 → 개발 환경 준비(라이브러리·SDK) → 코드 수정 루프 → 완료
116
+ * 상점·상품·배포 준비 → 쇼핑몰 구조 설계 → 디자인(아트보드, 2026-10-14 사용자 결정) → 개발 환경 준비(라이브러리·SDK) → 코드 수정 루프 → 완료
117
117
  */
118
- export const AI_AGENT_PHASES = ["STORE", "STRUCTURE", "ENVIRONMENT", "BUILD", "DONE"] as const;
118
+ export const AI_AGENT_PHASES = [
119
+ "STORE",
120
+ "STRUCTURE",
121
+ "DESIGN",
122
+ "ENVIRONMENT",
123
+ "BUILD",
124
+ "DONE",
125
+ ] as const;
119
126
  export const aiAgentPhaseSchema = z.enum(AI_AGENT_PHASES);
120
127
  export type AiAgentPhase = z.infer<typeof aiAgentPhaseSchema>;
121
128
 
@@ -145,11 +152,106 @@ export function isAiAgentNotesPath(path: string): boolean {
145
152
  return path === AI_AGENT_NOTES_DIR || path.startsWith(`${AI_AGENT_NOTES_DIR}/`);
146
153
  }
147
154
 
155
+ /**
156
+ * 디자인 아트보드 폴더(2026-10-14 사용자 결정, Claude Code Design 캔버스의 파일 계약을 옮겼다) — DESIGN 단계에 에이전트가 쓰는 자기완결 HTML 아트보드와
157
+ * 인덱스 `canvas.json`이다. 앱 코드가 import하지 않아 발행 빌드에 들어가지 않고, 상점 플랫폼 작업 공간의 디자인 탭이 iframe(srcdoc)으로 그린다.
158
+ * 코드 탭 트리에서는 작업 메모처럼 뺀다
159
+ */
160
+ export const AI_DESIGN_DIR = "src/site/design";
161
+ export const AI_DESIGN_CANVAS_PATH = `${AI_DESIGN_DIR}/canvas.json`;
162
+ export function isAiDesignPath(path: string): boolean {
163
+ return path === AI_DESIGN_DIR || path.startsWith(`${AI_DESIGN_DIR}/`);
164
+ }
165
+ /** 아트보드 파일 이름 — `src/site/design/` 바로 아래의 `.html` 하나(폴더 없음) */
166
+ export const AI_DESIGN_BOARD_FILE_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}\.html$/;
167
+ export const AI_DESIGN_BOARD_KINDS = ["direction", "page", "scene"] as const;
168
+ export const AI_DESIGN_VIEWPORTS = ["desktop", "mobile"] as const;
169
+ /** 아트보드 상한 — 사이트맵의 화면마다 아트보드를 그린다(2026-10-15 사용자 결정, 화면 로드맵 29개 + 방향 후보 + 모바일). 24에서 올렸다 */
170
+ export const AI_DESIGN_BOARDS_MAX = 64;
171
+ /**
172
+ * 아트보드 하나 — `file`은 `src/site/design/` 아래 파일 이름, `kind`는 방향(저충실 후보)·페이지(고충실 화면)·장면(모션 시안, 셀러가 스크롤하며 본다),
173
+ * `viewport`는 데스크톱(1280)·모바일(390) 폭, `page`는 그 아트보드가 그리는 사이트 경로(구현 대조에 쓴다), `status`는 후보·채택·보관
174
+ */
175
+ export const aiDesignBoardSchema = z.object({
176
+ file: z.string().regex(AI_DESIGN_BOARD_FILE_PATTERN),
177
+ title: z.string().min(1).max(60),
178
+ kind: z.enum(AI_DESIGN_BOARD_KINDS),
179
+ viewport: z.enum(AI_DESIGN_VIEWPORTS).default("desktop"),
180
+ page: z.string().max(200).optional(),
181
+ status: z.enum(["candidate", "chosen", "archived"]).default("chosen"),
182
+ });
183
+ export type AiDesignBoard = z.infer<typeof aiDesignBoardSchema>;
184
+ export const aiDesignCanvasSchema = z.object({
185
+ version: z.literal(1),
186
+ /** 정한 방향 이름 — 방향 선택지에서 셀러가 고른 뒤 적는다(이름을 바꾸지 않는다) */
187
+ direction: z.string().max(80).nullable().default(null),
188
+ boards: z.array(aiDesignBoardSchema).max(AI_DESIGN_BOARDS_MAX),
189
+ /** 캔버스에 붙이는 메모(장 전환 규칙 등) */
190
+ notes: z.array(z.string().max(300)).max(20).default([]),
191
+ });
192
+ export type AiDesignCanvas = z.infer<typeof aiDesignCanvasSchema>;
193
+
194
+ /** 셀러가 디자인 탭에서 고른 아트보드(2026-10-14) — 메시지와 함께 보내면 모델은 그 아트보드를 기준으로 고친다 */
195
+ export const aiDesignSelectionSchema = z.object({
196
+ file: z.string().regex(AI_DESIGN_BOARD_FILE_PATTERN),
197
+ title: z.string().max(60),
198
+ });
199
+ export type AiDesignSelection = z.infer<typeof aiDesignSelectionSchema>;
200
+
201
+ /** 쇼핑몰 형태(#124, 2026-10-13) — 템플릿은 basic 하나이고 형태는 에이전트에게 주는 화면 구성 지시다 */
202
+ export const STORE_SHOP_TYPES = [
203
+ "BASIC",
204
+ "INTERACTIVE",
205
+ "STORYTELLING",
206
+ "MOBILE",
207
+ "CATALOG",
208
+ "LOOKBOOK",
209
+ ] as const;
210
+ export const storeShopTypeSchema = z.enum(STORE_SHOP_TYPES);
211
+ export type StoreShopType = z.infer<typeof storeShopTypeSchema>;
212
+
213
+ /**
214
+ * 선택지 하나 — `templateId`는 스토어프론트 템플릿을, `shopType`은 쇼핑몰 형태를 고르는 선택지일 때의 값이다(2026-10-14). 상점 플랫폼이 썸네일을 붙인다
215
+ */
216
+ export const aiChoiceOptionSchema = z.object({
217
+ label: z.string(),
218
+ value: z.string(),
219
+ templateId: z.string().max(64).optional(),
220
+ /** 쇼핑몰 형태를 고르는 선택지면 그 형태 — 상점 플랫폼이 형태 와이어프레임을 붙인다 */
221
+ shopType: storeShopTypeSchema.optional(),
222
+ /** 디자인 방향을 고르는 선택지면 그 아트보드 파일(`src/site/design/` 아래) — 상점 플랫폼이 아트보드를 썸네일로 붙인다(2026-10-14) */
223
+ boardFile: z.string().regex(AI_DESIGN_BOARD_FILE_PATTERN).optional(),
224
+ });
225
+ export type AiChoiceOption = z.infer<typeof aiChoiceOptionSchema>;
226
+
227
+ /**
228
+ * 쇼핑몰 만들기 확인(2026-10-14) — 에이전트가 대화로 모은 값(라벨·값)과 고른 템플릿을 보이고 「이렇게 쇼핑몰을 만들까요?」를 묻는다.
229
+ * 셀러가 승인(`AI_SETUP_CONFIRM_APPROVE` 선택지)하면 만들기를 시작한다
230
+ */
231
+ export const aiSetupConfirmItemSchema = z.object({ label: z.string(), value: z.string() });
232
+ export type AiSetupConfirmItem = z.infer<typeof aiSetupConfirmItemSchema>;
233
+ /** 확인 카드의 승인 선택지 값 */
234
+ export const AI_SETUP_CONFIRM_APPROVE = "setup:approve";
235
+
236
+ /**
237
+ * 디자인 확인(2026-10-14 사용자 결정) — DESIGN 단계 끝에 에이전트가 채택한 아트보드를 카드로 보이고 「이 디자인으로 만들까요?」를 묻는다.
238
+ * 셀러가 승인(`AI_DESIGN_CONFIRM_APPROVE` 선택지)해야 구현(ENVIRONMENT·BUILD)으로 넘어간다
239
+ */
240
+ export const aiDesignConfirmBoardSchema = z.object({
241
+ file: z.string().regex(AI_DESIGN_BOARD_FILE_PATTERN),
242
+ title: z.string().max(60),
243
+ });
244
+ export type AiDesignConfirmBoard = z.infer<typeof aiDesignConfirmBoardSchema>;
245
+ export const AI_DESIGN_CONFIRM_APPROVE = "design:approve";
246
+
148
247
  /**
149
248
  * 화면에 그리는 메시지 조각. 모델 원문(tool_use 블록 등)은 api가 보관하고 화면에는 이 형태만 준다.
150
249
  * - `text`: 글
151
250
  * - `tool`: 도구 호출 한 번(이름은 사람이 읽는 라벨, 결과 요약·링크)
152
251
  * - `choices`: 선택지 버튼(가장 최근 것만 누를 수 있다)
252
+ * - `setup_confirm`: 쇼핑몰 만들기 확인 카드(모은 값·템플릿, 승인은 뒤따르는 `choices`)
253
+ * - `design_confirm`: 디자인 확인 카드(채택한 아트보드, 승인은 뒤따르는 `choices`)
254
+ * - `design_selection`: 사용자가 디자인 탭에서 고른 아트보드
153
255
  * - `business_form`: 사업자 정보 입력 폼(가장 최근 것만 제출할 수 있다, 건너뛸 수 있다)
154
256
  * - `attachment`: 사용자가 올린 첨부
155
257
  * - `approval`: 승인 대기로 남은 쓰기
@@ -176,13 +278,23 @@ export const aiMessagePartSchema = z.discriminatedUnion("type", [
176
278
  z.object({
177
279
  type: z.literal("choices"),
178
280
  question: z.string(),
179
- options: z
180
- .array(z.object({ label: z.string(), value: z.string() }))
181
- .min(1)
182
- .max(6),
281
+ options: z.array(aiChoiceOptionSchema).min(1).max(6),
183
282
  }),
184
283
  z.object({ type: z.literal("business_form"), question: z.string() }),
284
+ /** 쇼핑몰 만들기 확인 카드 — 아래 선택지 조각이 승인을 묻는다 */
285
+ z.object({
286
+ type: z.literal("setup_confirm"),
287
+ items: z.array(aiSetupConfirmItemSchema).max(12),
288
+ templateId: z.string().max(64).optional(),
289
+ }),
290
+ /** 디자인 확인 카드 — 채택한 아트보드, 아래 선택지 조각이 승인을 묻는다 */
291
+ z.object({
292
+ type: z.literal("design_confirm"),
293
+ boards: z.array(aiDesignConfirmBoardSchema).min(1).max(AI_DESIGN_BOARDS_MAX),
294
+ }),
185
295
  z.object({ type: z.literal("attachment"), attachment: aiAttachmentSchema }),
296
+ /** 사용자가 디자인 탭에서 고른 아트보드 */
297
+ z.object({ type: z.literal("design_selection"), selection: aiDesignSelectionSchema }),
186
298
  /** 공개 뒤 승인이 필요한 쓰기를 승인 요청으로 남겼다(셀러가 승인함에서 결정한다) */
187
299
  z.object({ type: z.literal("approval"), approvalId: z.string(), title: z.string() }),
188
300
  /** 사용자가 미리보기에서 고른 요소(#65) */
@@ -220,18 +332,6 @@ export const aiConversationSchema = aiConversationSummarySchema.extend({
220
332
  });
221
333
  export type AiConversation = z.infer<typeof aiConversationSchema>;
222
334
 
223
- /** 쇼핑몰 형태(#124, 2026-10-13) — 템플릿은 basic 하나이고 형태는 에이전트에게 주는 화면 구성 지시다 */
224
- export const STORE_SHOP_TYPES = [
225
- "BASIC",
226
- "INTERACTIVE",
227
- "STORYTELLING",
228
- "MOBILE",
229
- "CATALOG",
230
- "LOOKBOOK",
231
- ] as const;
232
- export const storeShopTypeSchema = z.enum(STORE_SHOP_TYPES);
233
- export type StoreShopType = z.infer<typeof storeShopTypeSchema>;
234
-
235
335
  /**
236
336
  * AI 상점 만들기 질문 화면의 답(#124, 2026-10-13) — 화면이 고정 질문으로 모은다. 초안과 함께 첫 메시지가 된다.
237
337
  * 배송비는 묻지 않는다(설정하지 않으면 무료배송). `slug`는 바라는 주소이고 쓸 수 있는지는 에이전트가 사이트를 만들 때 확인한다
@@ -270,6 +370,8 @@ export const sendAiMessageRequestSchema = z
270
370
  choice: z.string().max(200).optional(),
271
371
  /** 편집 미리보기에서 고른 요소(#65) — 모델은 이 요소를 기준으로 고친다 */
272
372
  selection: aiPreviewSelectionSchema.optional(),
373
+ /** 디자인 탭에서 고른 아트보드(2026-10-14) — 모델은 그 아트보드를 기준으로 고친다 */
374
+ designSelection: aiDesignSelectionSchema.optional(),
273
375
  })
274
376
  .refine((body) => body.text.length > 0 || body.attachments.length > 0, {
275
377
  message: "내용이나 첨부가 있어야 합니다",
@@ -430,10 +532,23 @@ export const aiStreamEventSchema = z.discriminatedUnion("type", [
430
532
  type: z.literal("choices"),
431
533
  messageId: z.string(),
432
534
  question: z.string(),
433
- options: z.array(z.object({ label: z.string(), value: z.string() })),
535
+ options: z.array(aiChoiceOptionSchema),
434
536
  }),
435
537
  /** 사업자 정보 입력 폼을 보였다 — 답은 다음 메시지로 온다 */
436
538
  z.object({ type: z.literal("business_form"), messageId: z.string(), question: z.string() }),
539
+ /** 쇼핑몰 만들기 확인 카드를 보였다 — 이어서 승인 선택지(`choices`)가 온다 */
540
+ z.object({
541
+ type: z.literal("setup_confirm"),
542
+ messageId: z.string(),
543
+ items: z.array(aiSetupConfirmItemSchema),
544
+ templateId: z.string().max(64).optional(),
545
+ }),
546
+ /** 디자인 확인 카드를 보였다 — 이어서 승인 선택지(`choices`)가 온다 */
547
+ z.object({
548
+ type: z.literal("design_confirm"),
549
+ messageId: z.string(),
550
+ boards: z.array(aiDesignConfirmBoardSchema),
551
+ }),
437
552
  /** 공개 뒤 승인이 필요한 쓰기를 승인 요청으로 남겼다 */
438
553
  z.object({
439
554
  type: z.literal("approval"),
@@ -140,6 +140,7 @@ export const APP_EVENT_SCOPES: Record<AppEventType, string | null> = {
140
140
  "INQUIRY.CREATED": "inquiry:r",
141
141
  "REVIEW.CREATED": "review:r",
142
142
  "PRODUCT.OUT_OF_STOCK": "product:r",
143
+ "INVENTORY.CHANGED": "inventory:r",
143
144
  "APP.INSTALLED": null,
144
145
  "APP.UNINSTALLED": null,
145
146
  "APP.SETTINGS_UPDATED": null,
@@ -476,6 +476,13 @@ export const pickupLocationSchema = z.object({
476
476
  hours: z.string().nullable().describe("수령 가능 시간 안내. 없으면 null"),
477
477
  usable: z.boolean().describe("사용 여부. false면 구매자에게 보이지 않고 주문서에서 고를 수 없다"),
478
478
  sortOrder: z.number().int(),
479
+ inventoryLocationId: z
480
+ .string()
481
+ .nullable()
482
+ .optional()
483
+ .describe(
484
+ "이 장소의 재고 위치 ID — 방문 수령 주문을 이 위치 재고로 판다. 위치를 하나만 쓰는 상점은 기본 위치다",
485
+ ),
479
486
  createdAt: z.string(),
480
487
  });
481
488
 
@@ -487,6 +494,11 @@ export const pickupLocationRequestSchema = z.strictObject({
487
494
  phone: z.string().trim().max(30).nullish(),
488
495
  hours: z.string().trim().max(100).nullish(),
489
496
  usable: z.boolean().default(true),
497
+ inventoryLocationId: z
498
+ .string()
499
+ .min(1)
500
+ .optional()
501
+ .describe("재고 위치 ID. 등록에서 생략하면 기본 위치, 수정에서 생략하면 지금 연결을 유지한다"),
490
502
  });
491
503
 
492
504
  export type PickupLocationRequest = z.infer<typeof pickupLocationRequestSchema>;
@@ -0,0 +1,230 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 위치별 재고 (이슈 #132) — 재고 위치(창고·상설 매장·팝업 매장)마다 조합의 가용 재고를 따로 센다. 출고지·반품지·픽업 장소가 위치를
5
+ * 가리키고, 배송 주문은 출고지 위치에서, 방문 수령 주문은 그 매장 위치에서 뺀다. 위치를 하나만 쓰는 상점은 기본 위치 하나다.
6
+ *
7
+ * - 가용 재고(`available`)는 지금 팔 수 있는 수량이다(팔린 미출고분이 이미 빠진 수). 저장하는 값이다.
8
+ * - 출고 대기(`pendingFulfillment`)는 그 위치에 할당됐고 아직 출고 전(주문상품 `PAID`·`CONFIRMED`)인 수량이다.
9
+ * - 실재고(`onHand`)는 가용 재고 + 출고 대기다. 위치에 실제로 있어야 하는 수량이다.
10
+ * - 상품·옵션 API의 `stockQuantity`는 그 상품 기준 위치의 가용 재고다(이름·뜻 유지).
11
+ */
12
+
13
+ /** 재고 조정 사유 — `COUNT`는 실사 줄(`counted`)이 쓴다 */
14
+ export const inventoryAdjustmentReasonSchema = z
15
+ .enum(["RECEIVED", "DAMAGED", "LOST", "FOUND", "RETURN_RESTOCK", "CORRECTION", "COUNT"])
16
+ .describe(
17
+ "조정 사유 — 입고 `RECEIVED`, 파손 `DAMAGED`, 분실 `LOST`, 재발견 `FOUND`, 반품 재입고 `RETURN_RESTOCK`, 정정 `CORRECTION`, 실사 `COUNT`",
18
+ );
19
+ export type InventoryAdjustmentReason = z.infer<typeof inventoryAdjustmentReasonSchema>;
20
+
21
+ export const inventoryLocationSchema = z.object({
22
+ inventoryLocationId: z.string(),
23
+ name: z.string().describe("위치 이름(예: 본사 창고, 성수 팝업)"),
24
+ isDefault: z
25
+ .boolean()
26
+ .describe("기본 위치인가 — 상점마다 하나이고 삭제할 수 없다. 기존 재고가 있는 곳이다"),
27
+ sortOrder: z.number().int(),
28
+ createdAt: z.string(),
29
+ updatedAt: z.string(),
30
+ });
31
+ export type InventoryLocation = z.infer<typeof inventoryLocationSchema>;
32
+
33
+ export const inventoryLocationRequestSchema = z.strictObject({
34
+ name: z.string().trim().min(1).max(50),
35
+ sortOrder: z.number().int().min(0).max(10_000).optional(),
36
+ });
37
+ export type InventoryLocationRequest = z.infer<typeof inventoryLocationRequestSchema>;
38
+
39
+ export const inventoryLevelSchema = z.object({
40
+ productId: z.string(),
41
+ variantId: z
42
+ .string()
43
+ .describe("판매 단위(variant) ID. 주문 상품의 `optionId`와 같은 값 공간이다"),
44
+ inventoryLocationId: z.string(),
45
+ available: z.number().int().describe("가용 재고"),
46
+ pendingFulfillment: z.number().int().describe("출고 대기 — 할당됐고 아직 출고 전인 수량"),
47
+ onHand: z.number().int().describe("실재고 — 가용 재고 + 출고 대기"),
48
+ version: z
49
+ .number()
50
+ .int()
51
+ .describe("쓰기마다 1씩 오르는 버전(`INVENTORY.CHANGED`의 `levelVersion`)"),
52
+ updatedAt: z.string(),
53
+ });
54
+ export type InventoryLevel = z.infer<typeof inventoryLevelSchema>;
55
+
56
+ export const inventoryLevelListQuerySchema = z.object({
57
+ inventoryLocationId: z.string().optional(),
58
+ productId: z.string().optional(),
59
+ variantId: z.string().optional(),
60
+ cursor: z.string().optional().describe("이전 응답의 `nextCursor`. 생략하면 처음부터"),
61
+ limit: z.coerce.number().int().min(1).max(200).optional().describe("한 쪽 크기(기본 100)"),
62
+ });
63
+ export type InventoryLevelListQuery = z.infer<typeof inventoryLevelListQuerySchema>;
64
+
65
+ export const inventoryLevelPageSchema = z.object({
66
+ contents: z.array(inventoryLevelSchema),
67
+ nextCursor: z.string().nullable().describe("다음 페이지 커서. 마지막 페이지면 null"),
68
+ });
69
+ export type InventoryLevelPage = z.infer<typeof inventoryLevelPageSchema>;
70
+
71
+ export const inventoryMovementKindSchema = z
72
+ .enum([
73
+ "OPENING",
74
+ "SALE",
75
+ "RESTORE",
76
+ "REVERT",
77
+ "EXCHANGE_OUT",
78
+ "ADJUST",
79
+ "COUNT",
80
+ "TRANSFER_OUT",
81
+ "TRANSFER_IN",
82
+ "REMOVED",
83
+ ])
84
+ .describe(
85
+ "변동 종류 — 시작 `OPENING`, 판매 `SALE`, 복원 `RESTORE`, 복원 되돌리기 `REVERT`, 교환 출고 `EXCHANGE_OUT`, 조정 `ADJUST`, 실사 `COUNT`, 이동 출고 `TRANSFER_OUT`, 이동 입고 `TRANSFER_IN`, 조합 삭제 `REMOVED`",
86
+ );
87
+ export type InventoryMovementKind = z.infer<typeof inventoryMovementKindSchema>;
88
+
89
+ export const inventoryMovementSchema = z.object({
90
+ movementId: z.string(),
91
+ seq: z
92
+ .number()
93
+ .int()
94
+ .describe("상점별 순번 — 커밋 순서와 같고 빈 번호가 없다. `after` 커서에 쓴다"),
95
+ productId: z.string(),
96
+ variantId: z.string(),
97
+ inventoryLocationId: z.string(),
98
+ kind: z.string().describe("변동 종류(`inventoryMovementKindSchema`). 값이 늘 수 있다"),
99
+ delta: z.number().int().describe("가용 재고 변화량"),
100
+ available: z.number().int().describe("변화 뒤 가용 재고"),
101
+ levelVersion: z.number().int(),
102
+ reason: z.string().nullable(),
103
+ note: z.string().nullable(),
104
+ refType: z.string().nullable().describe("출처 유형 — `order_item`·`claim`·`operation`"),
105
+ refId: z.string().nullable(),
106
+ operationId: z.string().nullable().describe("재고 조정·이동 요청 ID"),
107
+ actorType: z.string().describe("행위자 유형 — `seller`·`app`·`agent`·`system`"),
108
+ actorId: z.string().nullable(),
109
+ createdAt: z.string(),
110
+ });
111
+ export type InventoryMovement = z.infer<typeof inventoryMovementSchema>;
112
+
113
+ export const inventoryMovementListQuerySchema = z.object({
114
+ inventoryLocationId: z.string().optional(),
115
+ productId: z.string().optional(),
116
+ variantId: z.string().optional(),
117
+ kind: inventoryMovementKindSchema.optional(),
118
+ after: z.coerce
119
+ .number()
120
+ .int()
121
+ .min(0)
122
+ .optional()
123
+ .describe("이 순번 다음부터(오름차순). 생략하면 처음부터"),
124
+ from: z.string().optional().describe("이 시각 이후(ISO 8601)"),
125
+ to: z.string().optional().describe("이 시각 이전(ISO 8601)"),
126
+ limit: z.coerce.number().int().min(1).max(200).optional().describe("한 쪽 크기(기본 100)"),
127
+ });
128
+ export type InventoryMovementListQuery = z.infer<typeof inventoryMovementListQuerySchema>;
129
+
130
+ export const inventoryMovementPageSchema = z.object({
131
+ contents: z.array(inventoryMovementSchema),
132
+ nextAfter: z
133
+ .number()
134
+ .int()
135
+ .nullable()
136
+ .describe(
137
+ "다음 쪽의 `after` 값. 더 없으면 null(새 변동을 기다릴 때는 마지막 `seq`를 그대로 쓴다)",
138
+ ),
139
+ });
140
+ export type InventoryMovementPage = z.infer<typeof inventoryMovementPageSchema>;
141
+
142
+ /** 한 요청의 줄 상한 — 30초 타임아웃·바인드 한도 */
143
+ export const MAX_INVENTORY_OPERATION_LINES = 200;
144
+
145
+ export const inventoryAdjustmentLineSchema = z
146
+ .strictObject({
147
+ variantId: z.string().min(1),
148
+ inventoryLocationId: z.string().min(1).optional().describe("생략하면 기본 위치"),
149
+ delta: z
150
+ .number()
151
+ .int()
152
+ .refine((value) => value !== 0, { message: "delta는 0이 아니어야 합니다" })
153
+ .optional()
154
+ .describe("가용 재고 증감. 감소는 가용 재고를 넘지 않는다"),
155
+ counted: z
156
+ .number()
157
+ .int()
158
+ .min(0)
159
+ .optional()
160
+ .describe(
161
+ "실사 — 센 실물 수량. 실재고가 이 값이 되도록 가용 재고를 맞춘다(센 값 − 출고 대기)",
162
+ ),
163
+ })
164
+ .refine((line) => (line.delta === undefined) !== (line.counted === undefined), {
165
+ message: "줄마다 delta와 counted 중 하나만 줍니다",
166
+ });
167
+
168
+ export const inventoryAdjustmentRequestSchema = z.strictObject({
169
+ lines: z.array(inventoryAdjustmentLineSchema).min(1).max(MAX_INVENTORY_OPERATION_LINES),
170
+ reason: inventoryAdjustmentReasonSchema
171
+ .optional()
172
+ .describe("증감 줄(`delta`)의 사유. 실사 줄은 늘 `COUNT`다. 증감 줄이 있으면 필수"),
173
+ note: z.string().trim().max(500).optional(),
174
+ reference: z.string().trim().max(100).optional().describe("외부 참조(입고서 번호 등)"),
175
+ });
176
+ export type InventoryAdjustmentRequest = z.infer<typeof inventoryAdjustmentRequestSchema>;
177
+
178
+ export const inventoryTransferRequestSchema = z.strictObject({
179
+ fromInventoryLocationId: z.string().min(1),
180
+ toInventoryLocationId: z.string().min(1),
181
+ lines: z
182
+ .array(z.strictObject({ variantId: z.string().min(1), quantity: z.number().int().min(1) }))
183
+ .min(1)
184
+ .max(MAX_INVENTORY_OPERATION_LINES),
185
+ note: z.string().trim().max(500).optional(),
186
+ });
187
+ export type InventoryTransferRequest = z.infer<typeof inventoryTransferRequestSchema>;
188
+
189
+ export const inventoryOperationResultSchema = z.object({
190
+ operationId: z.string(),
191
+ kind: z.enum(["ADJUSTMENT", "TRANSFER"]),
192
+ lines: z.array(
193
+ z.object({
194
+ variantId: z.string(),
195
+ inventoryLocationId: z.string(),
196
+ delta: z.number().int().describe("적용한 가용 재고 변화량(실사는 맞춘 차이, 같으면 0)"),
197
+ available: z.number().int().describe("적용 뒤 가용 재고"),
198
+ }),
199
+ ),
200
+ createdAt: z.string(),
201
+ });
202
+ export type InventoryOperationResult = z.infer<typeof inventoryOperationResultSchema>;
203
+
204
+ /** 수거 완료의 재입고 지정(Q3) — 생략하면 조합은 할당 순서(교환 → 판매), 위치는 반품지에 연결된 위치 → 할당 위치다 */
205
+ export const claimCollectCompleteRequestSchema = z.object({
206
+ restock: z
207
+ .object({
208
+ optionId: z
209
+ .string()
210
+ .min(1)
211
+ .optional()
212
+ .describe(
213
+ "돌아온 조합 — 그 주문상품의 할당 조합이어야 한다(아니면 400 `INVALID_RESTOCK_OPTION`)",
214
+ ),
215
+ inventoryLocationId: z.string().min(1).optional().describe("재입고할 재고 위치"),
216
+ })
217
+ .optional(),
218
+ });
219
+ export type ClaimCollectCompleteRequest = z.infer<typeof claimCollectCompleteRequestSchema>;
220
+
221
+ export const claimRedeliveryRequestSchema = z.object({
222
+ carrierCode: z.string().min(1),
223
+ trackingNumber: z.string().min(1),
224
+ inventoryLocationId: z
225
+ .string()
226
+ .min(1)
227
+ .optional()
228
+ .describe("교환품을 보낼 재고 위치. 생략하면 그 상품의 기준 위치"),
229
+ });
230
+ export type ClaimRedeliveryRequest = z.infer<typeof claimRedeliveryRequestSchema>;
@@ -162,6 +162,8 @@ export const eventTypeSchema = z
162
162
  "ORDER.FLOW_NOTIFICATION",
163
163
  // 커머스 플로우 상태 변경(이슈 #115). 흐름 단계가 바뀔 때마다 간다
164
164
  "ORDER.FLOW_STATE_CHANGED",
165
+ // 위치별 재고 변화(이슈 #132). 재고 변동 내역 한 행마다 간다
166
+ "INVENTORY.CHANGED",
165
167
  ])
166
168
  .describe(
167
169
  "웹훅 이벤트 타입. `ORDER.ADDRESS_CHANGE_REQUESTED`는 현재 발송하지 않음(호환을 위해 값만 남았고, 구독해도 이벤트가 오지 않는다)",