@sayren/store-sdk 0.31.0 → 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.
@@ -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`는 현재 발송하지 않음(호환을 위해 값만 남았고, 구독해도 이벤트가 오지 않는다)",
@@ -21,6 +21,10 @@ export const SCOPE_DESCRIPTIONS: Record<StoreScopeResource, { r: string; rw: str
21
21
  promotion: { r: "쿠폰·적립금 설정을 읽습니다", rw: "쿠폰을 발급하고 적립금을 지급합니다" },
22
22
  storefront: { r: "스토어프론트 설정을 읽습니다", rw: "스토어프론트를 편집하고 발행합니다" },
23
23
  flow: { r: "주문 흐름 설정을 읽습니다", rw: "주문 흐름을 만들고 수정합니다" },
24
+ inventory: {
25
+ r: "위치별 재고와 재고 변동 내역을 읽습니다",
26
+ rw: "재고를 조정·실사하고 위치 사이에서 이동합니다",
27
+ },
24
28
  };
25
29
 
26
30
  /** `{resource}:{r|rw}` → 문장. 카탈로그에 없는 스코프는 null */
@@ -33,6 +33,8 @@ export const STORE_SCOPE_RESOURCES = {
33
33
  storefront: ["r", "rw"],
34
34
  // 커머스 플로우 정의·할당(이슈 #115) — 주문 처리 행동은 order 스코프다
35
35
  flow: ["r", "rw"],
36
+ // 위치별 재고·재고 조정·이동·변동 내역(이슈 #132) — 기존 재고 표면(조합 재고 수정, 상품·옵션의 `stockQuantity`)은 product 스코프다
37
+ inventory: ["r", "rw"],
36
38
  } as const satisfies Record<string, readonly ScopeAccess[]>;
37
39
 
38
40
  export type StoreScopeResource = keyof typeof STORE_SCOPE_RESOURCES;
@@ -71,6 +73,7 @@ export const STORE_ROLE_SCOPES = {
71
73
  "promotion:rw",
72
74
  "storefront:rw",
73
75
  "flow:rw",
76
+ "inventory:rw",
74
77
  ],
75
78
  ADMIN: [
76
79
  "product:rw",
@@ -88,6 +91,7 @@ export const STORE_ROLE_SCOPES = {
88
91
  "promotion:rw",
89
92
  "storefront:rw",
90
93
  "flow:rw",
94
+ "inventory:rw",
91
95
  ],
92
96
  STAFF: [
93
97
  "product:rw",
@@ -99,5 +103,6 @@ export const STORE_ROLE_SCOPES = {
99
103
  "customer:r",
100
104
  "promotion:r",
101
105
  "flow:r",
106
+ "inventory:rw",
102
107
  ],
103
108
  } as const satisfies Record<StoreRole, readonly string[]>;
@@ -461,6 +461,13 @@ export const sellerAddressSchema = z.object({
461
461
  zipCode: z.string(),
462
462
  address1: z.string(),
463
463
  address2: z.string().optional(),
464
+ inventoryLocationId: z
465
+ .string()
466
+ .nullable()
467
+ .optional()
468
+ .describe(
469
+ "이 주소의 재고 위치 ID — 출고지면 배송 주문을 이 위치 재고로 팔고, 반품지면 반품을 이 위치로 들인다. 위치를 하나만 쓰는 상점은 기본 위치다",
470
+ ),
464
471
  createdAt: z.string(),
465
472
  });
466
473
 
@@ -474,6 +481,11 @@ export const sellerAddressRequestSchema = z.object({
474
481
  zipCode: z.string().regex(/^[0-9]{5}$/),
475
482
  address1: z.string().min(1),
476
483
  address2: z.string().optional(),
484
+ inventoryLocationId: z
485
+ .string()
486
+ .min(1)
487
+ .optional()
488
+ .describe("재고 위치 ID. 등록에서 생략하면 기본 위치, 수정에서 생략하면 지금 연결을 유지한다"),
477
489
  });
478
490
 
479
491
  export type SellerAddressRequest = z.infer<typeof sellerAddressRequestSchema>;
@@ -320,11 +320,55 @@ export const productOutOfStockWebhookEventDataSchema = z.object({
320
320
  variantId: z
321
321
  .string()
322
322
  .describe("재고가 0이 된 판매 단위(variant) ID. 주문 상품의 `optionId`와 같은 값 공간이다"),
323
+ inventoryLocationId: z
324
+ .string()
325
+ .optional()
326
+ .describe(
327
+ "재고가 0이 된 재고 위치 ID. 품절은 위치별 가용 재고의 0 전이다. 위치를 하나만 쓰는 상점은 기본 위치다(옛 이벤트에는 없다)",
328
+ ),
323
329
  });
324
330
  export type ProductOutOfStockWebhookEventData = z.infer<
325
331
  typeof productOutOfStockWebhookEventDataSchema
326
332
  >;
327
333
 
334
+ /**
335
+ * 위치별 재고 변화(이슈 #132) — 재고 변동 내역 한 행마다 한 건이다. 같은 위치·조합의 순서는 `levelVersion`(빈 번호 없음)으로 맞추고,
336
+ * 상점 전체 순서는 `movementSeq`(재고 변동 내역 조회의 `after` 커서와 같은 값)다. 자기가 한 조정의 반향은 `actorType`·`actorId`·`operationId`로 거른다.
337
+ * 출고 대기·실재고(상태 전이) 변화는 이벤트를 내지 않는다 — 필요하면 위치별 재고 조회를 다시 읽는다
338
+ */
339
+ export const inventoryChangedWebhookEventDataSchema = z.object({
340
+ productId: z.string().describe("상품 ID"),
341
+ variantId: z
342
+ .string()
343
+ .describe("판매 단위(variant) ID. 주문 상품의 `optionId`와 같은 값 공간이다"),
344
+ inventoryLocationId: z.string().describe("재고 위치 ID"),
345
+ available: z.number().int().describe("변화 뒤 가용 재고"),
346
+ delta: z.number().int().describe("가용 재고 변화량(0이 아니다)"),
347
+ kind: z
348
+ .string()
349
+ .describe(
350
+ "변동 종류 — `OPENING`·`SALE`·`RESTORE`·`REVERT`·`EXCHANGE_OUT`·`ADJUST`·`COUNT`·`TRANSFER_OUT`·`TRANSFER_IN`·`REMOVED`. 값이 늘 수 있다",
351
+ ),
352
+ reason: z.string().nullable().describe("조정·실사 사유 코드. 없으면 null"),
353
+ levelVersion: z
354
+ .number()
355
+ .int()
356
+ .describe("위치별 재고 행의 버전 — 같은 위치·조합 안에서 빈 번호 없이 오른다"),
357
+ movementSeq: z.number().int().describe("상점별 재고 변동 내역 순번"),
358
+ actorType: z.string().describe("행위자 유형 — `seller`·`app`·`agent`·`system`"),
359
+ actorId: z.string().nullable().describe("행위자 ID. 시스템이면 null"),
360
+ refType: z
361
+ .string()
362
+ .nullable()
363
+ .describe("출처 유형 — `order_item`·`claim`·`operation`. 없으면 null"),
364
+ refId: z.string().nullable().describe("출처 ID"),
365
+ operationId: z.string().nullable().describe("재고 조정·이동 요청 ID. 조정·이동이 아니면 null"),
366
+ occurredAt: z.string().describe("변화 시각"),
367
+ });
368
+ export type InventoryChangedWebhookEventData = z.infer<
369
+ typeof inventoryChangedWebhookEventDataSchema
370
+ >;
371
+
328
372
  export const orderWebhookEventSchema = z.object({
329
373
  ...envelopeFields,
330
374
  type: z.enum([
@@ -380,6 +424,12 @@ export const productOutOfStockWebhookEventSchema = z.object({
380
424
  data: productOutOfStockWebhookEventDataSchema,
381
425
  });
382
426
 
427
+ export const inventoryChangedWebhookEventSchema = z.object({
428
+ ...envelopeFields,
429
+ type: z.literal("INVENTORY.CHANGED"),
430
+ data: inventoryChangedWebhookEventDataSchema,
431
+ });
432
+
383
433
  /**
384
434
  * 플로우 알림(이슈 #115) — 상점이 플로우에 둔 알림 블록(`core.notify`, 채널 `webhook`)이 보낸다. 구매자 알림(알림톡·메일)은 이 이벤트를 받아
385
435
  * 상점이 보낸다(플랫폼은 구매자에게 직접 보내지 않는다). 개인정보는 싣지 않는다
@@ -436,6 +486,7 @@ export const webhookEventSchema = z
436
486
  productOutOfStockWebhookEventSchema,
437
487
  flowNotificationWebhookEventSchema,
438
488
  flowStateChangedWebhookEventSchema,
489
+ inventoryChangedWebhookEventSchema,
439
490
  ])
440
491
  .describe(
441
492
  "웹훅 요청 본문 (얇은 이벤트). 개인정보를 싣지 않으며 `data`의 id로 관리 API를 조회한다",