@sayren/store-sdk 0.29.7 → 0.30.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.
@@ -69,9 +69,86 @@ export function fulfillmentRuleOf(type: string | null | undefined): FulfillmentR
69
69
  return FULFILLMENT_RULES[fulfillmentTypeOf(type)];
70
70
  }
71
71
 
72
- /** 택배 배송이 필요한가 — 배송비 묶음·배송지·발송 흐름의 판정은 이 함수 하나를 지난다 */
73
- export function requiresShipping(type: string | null | undefined): boolean {
74
- return fulfillmentRuleOf(type).requiresShipping;
72
+ // ─── 이행 방식 (이슈 #115 결정 28) ─────────────────────────────
73
+ //
74
+ // 유형(`SHIPPING`·`MANUAL`)은 그대로 두고 그 안의 **이행 방식**을 고른다. 상품이 허용 방식(`fulfillment.methods`)을 두고 구매자가
75
+ // 주문서에서 하나를 고른다(생략하면 허용 목록의 첫 방식). 배송지·배송비 판정은 방식 규칙을 지난다 — 배송 상품도 방문 수령이면
76
+ // 배송지·배송비가 없다.
77
+
78
+ /**
79
+ * 이행 방식.
80
+ * - `CARRIER` 택배(송장·배송 추적)
81
+ * - `DIRECT` 직접 배달(배송지 있음, 송장 없음)
82
+ * - `PICKUP` 방문 수령(배송지·배송비 없음)
83
+ * - `PROVIDED` 제공 처리(배송 없는 상품)
84
+ */
85
+ export const FULFILLMENT_METHODS = ["CARRIER", "DIRECT", "PICKUP", "PROVIDED"] as const;
86
+
87
+ export const fulfillmentMethodSchema = z.enum(FULFILLMENT_METHODS);
88
+
89
+ export type FulfillmentMethod = z.infer<typeof fulfillmentMethodSchema>;
90
+
91
+ /** 방식 하나의 규칙 */
92
+ export interface FulfillmentMethodRule {
93
+ /** 배송지를 받는가 — 배송비 묶음·배송지 필수 판정의 대상인가 */
94
+ requiresAddress: boolean;
95
+ /** 송장 등록·배송 추적으로 배송완료가 되는가 */
96
+ carrierTracking: boolean;
97
+ /** 구매자가 픽업 장소를 고르는가 */
98
+ pickupLocation: boolean;
99
+ }
100
+
101
+ /** 방식별 규칙표 — **유일한 원천이다.** */
102
+ export const FULFILLMENT_METHOD_RULES: Readonly<Record<FulfillmentMethod, FulfillmentMethodRule>> =
103
+ {
104
+ CARRIER: { requiresAddress: true, carrierTracking: true, pickupLocation: false },
105
+ DIRECT: { requiresAddress: true, carrierTracking: false, pickupLocation: false },
106
+ PICKUP: { requiresAddress: false, carrierTracking: false, pickupLocation: true },
107
+ PROVIDED: { requiresAddress: false, carrierTracking: false, pickupLocation: false },
108
+ };
109
+
110
+ /** 유형별 허용 방식 — 첫 값이 유형 기본이다 */
111
+ export const FULFILLMENT_METHODS_BY_TYPE: Readonly<
112
+ Record<FulfillmentType, readonly FulfillmentMethod[]>
113
+ > = {
114
+ SHIPPING: ["CARRIER", "DIRECT", "PICKUP"],
115
+ MANUAL: ["PROVIDED", "PICKUP"],
116
+ };
117
+
118
+ /** 유형의 기본 방식 — 허용 방식을 두지 않은 상품·이 필드 이전 주문상품 */
119
+ export function defaultFulfillmentMethodOf(type: string | null | undefined): FulfillmentMethod {
120
+ return FULFILLMENT_METHODS_BY_TYPE[fulfillmentTypeOf(type)][0] as FulfillmentMethod;
121
+ }
122
+
123
+ /** 상품의 허용 방식 — 두지 않았으면 유형 기본 하나다 */
124
+ export function fulfillmentMethodsOf(fulfillment: {
125
+ type: string;
126
+ methods?: readonly string[] | null;
127
+ }): FulfillmentMethod[] {
128
+ const methods = (fulfillment.methods ?? []).filter((m): m is FulfillmentMethod =>
129
+ (FULFILLMENT_METHODS as readonly string[]).includes(m),
130
+ );
131
+ return methods.length > 0 ? methods : [defaultFulfillmentMethodOf(fulfillment.type)];
132
+ }
133
+
134
+ /** 저장된 방식 → 방식. null·모르는 값은 유형 기본이다 */
135
+ export function fulfillmentMethodOf(
136
+ type: string | null | undefined,
137
+ method: string | null | undefined,
138
+ ): FulfillmentMethod {
139
+ const parsed = fulfillmentMethodSchema.safeParse(method);
140
+ return parsed.success ? parsed.data : defaultFulfillmentMethodOf(type);
141
+ }
142
+
143
+ /**
144
+ * 택배 배송이 필요한가 — 배송비 묶음·배송지·발송 흐름의 판정은 이 함수 하나를 지난다.
145
+ * 방식을 주면 방식 규칙도 본다(배송 상품의 방문 수령은 배송이 없다). 방식을 모르면 유형 규칙만 본다.
146
+ */
147
+ export function requiresShipping(type: string | null | undefined, method?: string | null): boolean {
148
+ if (!fulfillmentRuleOf(type).requiresShipping) return false;
149
+ return (
150
+ method == null || FULFILLMENT_METHOD_RULES[fulfillmentMethodOf(type, method)].requiresAddress
151
+ );
75
152
  }
76
153
 
77
154
  /** 반품에 수거가 필요한가 — 비실물 반품은 수거 등록·수거 완료 없이 승인한다 */
@@ -150,6 +227,28 @@ function refineShippingConsistency(
150
227
  const deliveryTypeDescription =
151
228
  "배송비 유형. `FREE` 무료, `PAID` 유료, `CONDITIONAL_FREE` 조건부 무료";
152
229
 
230
+ // ── 이행 방식(#115) ──
231
+
232
+ /** 요청의 허용 방식 — 유형마다 받는 값이 다르다. 겹치면 400 */
233
+ function methodsRequestSchema(type: FulfillmentType) {
234
+ return z
235
+ .array(z.enum(FULFILLMENT_METHODS_BY_TYPE[type] as [FulfillmentMethod, ...FulfillmentMethod[]]))
236
+ .min(1)
237
+ .refine((methods) => new Set(methods).size === methods.length, {
238
+ message: "이행 방식이 겹칩니다",
239
+ })
240
+ .describe(
241
+ `허용 이행 방식(이슈 #115). ${type === "SHIPPING" ? "`CARRIER`·`DIRECT`·`PICKUP`" : "`PROVIDED`·`PICKUP`"} 중 1개 이상, 첫 값이 주문서 기본이다. 생략하면 등록은 유형 기본(${type === "SHIPPING" ? "`CARRIER`" : "`PROVIDED`"}), 수정은 지금 값 유지(유형을 바꾸면 유형 기본)`,
242
+ );
243
+ }
244
+
245
+ const storedMethodsSchema = z
246
+ .array(z.string())
247
+ .optional()
248
+ .describe(
249
+ "허용 이행 방식(이슈 #115). 현재 값은 `CARRIER`·`DIRECT`·`PICKUP`·`PROVIDED`이고 값이 추가될 수 있어 문자열로 받는다. 셀러가 두지 않았으면 생략되고 유형 기본(`SHIPPING`은 `CARRIER`, `MANUAL`은 `PROVIDED`)이다",
250
+ );
251
+
153
252
  // ── 등록 요청 ──
154
253
 
155
254
  const shippingCreateSchema = z
@@ -179,8 +278,12 @@ const shippingCreateSchema = z
179
278
  * 상품 이행 — 등록(POST). 배송 상품의 `shipping`을 생략하면 기본값(유료 배송·상점 기본 배송비·기본 출고지·반품지)이다.
180
279
  */
181
280
  export const productFulfillmentCreateSchema = z.discriminatedUnion("type", [
182
- z.strictObject({ type: z.literal("SHIPPING"), shipping: shippingCreateSchema.optional() }),
183
- z.strictObject({ type: z.literal("MANUAL") }),
281
+ z.strictObject({
282
+ type: z.literal("SHIPPING"),
283
+ shipping: shippingCreateSchema.optional(),
284
+ methods: methodsRequestSchema("SHIPPING").optional(),
285
+ }),
286
+ z.strictObject({ type: z.literal("MANUAL"), methods: methodsRequestSchema("MANUAL").optional() }),
184
287
  ]);
185
288
 
186
289
  export type ProductFulfillmentCreate = z.infer<typeof productFulfillmentCreateSchema>;
@@ -209,8 +312,12 @@ const shippingUpdateSchema = z
209
312
  * `fulfillment` 자체를 생략하면 이행을 바꾸지 않는다.
210
313
  */
211
314
  export const productFulfillmentUpdateSchema = z.discriminatedUnion("type", [
212
- z.strictObject({ type: z.literal("SHIPPING"), shipping: shippingUpdateSchema }),
213
- z.strictObject({ type: z.literal("MANUAL") }),
315
+ z.strictObject({
316
+ type: z.literal("SHIPPING"),
317
+ shipping: shippingUpdateSchema,
318
+ methods: methodsRequestSchema("SHIPPING").optional(),
319
+ }),
320
+ z.strictObject({ type: z.literal("MANUAL"), methods: methodsRequestSchema("MANUAL").optional() }),
214
321
  ]);
215
322
 
216
323
  export type ProductFulfillmentUpdate = z.infer<typeof productFulfillmentUpdateSchema>;
@@ -241,8 +348,9 @@ export const productFulfillmentSchema = z.discriminatedUnion("type", [
241
348
  .nullable()
242
349
  .describe("반품지 주소 id. null이면 상점 기본 반품지를 따른다"),
243
350
  }),
351
+ methods: storedMethodsSchema,
244
352
  }),
245
- z.object({ type: z.literal("MANUAL") }),
353
+ z.object({ type: z.literal("MANUAL"), methods: storedMethodsSchema }),
246
354
  ]);
247
355
 
248
356
  export type ProductFulfillment = z.infer<typeof productFulfillmentSchema>;
@@ -289,6 +397,30 @@ export type ProductFulfillmentView = z.infer<typeof productFulfillmentResponseSc
289
397
 
290
398
  // ─── 주문상품 이행 스냅샷 (이슈 #57) ─────────────────────────────
291
399
 
400
+ const snapshotMethodSchema = z
401
+ .string()
402
+ .optional()
403
+ .describe(
404
+ "주문 시점 이행 방식(이슈 #115). 현재 값은 `CARRIER`·`DIRECT`·`PICKUP`·`PROVIDED`이고 값이 추가될 수 있어 문자열로 받는다. 없으면 유형 기본",
405
+ );
406
+
407
+ /** 주문상품의 주문 시점 픽업 장소 — 장소를 고치거나 지워도 이 주문의 안내는 바뀌지 않는다 */
408
+ export const pickupLocationSnapshotSchema = z.object({
409
+ locationId: z.string(),
410
+ name: z.string(),
411
+ address: z.string(),
412
+ phone: z.string().nullable(),
413
+ hours: z.string().nullable(),
414
+ });
415
+
416
+ export type PickupLocationSnapshot = z.infer<typeof pickupLocationSnapshotSchema>;
417
+
418
+ const snapshotPickupSchema = pickupLocationSnapshotSchema
419
+ .optional()
420
+ .describe(
421
+ "방문 수령(`PICKUP`)에서 고른 장소(주문 시점 값). 픽업 장소를 두지 않은 상점이거나 다른 방식이면 생략",
422
+ );
423
+
292
424
  /**
293
425
  * 주문상품의 주문 시점 이행 — 결제 확정이 주문서 줄에서 굳힌다. 상품의 현재 값이 아니다. 서버는 주문상품 컬럼
294
426
  * (`fulfillment_type`·`delivery_bundle_key`)과 jsonb(`fulfillment_snapshot`)를 합쳐 이 모양을 만든다.
@@ -308,8 +440,14 @@ export const orderItemFulfillmentSnapshotSchema = z.discriminatedUnion("type", [
308
440
  .nullable()
309
441
  .describe("주문 시점 반품지 주소 id. null이거나 지워진 주소면 상점 기본 반품지로 안내한다"),
310
442
  }),
443
+ method: snapshotMethodSchema,
444
+ pickupLocation: snapshotPickupSchema,
445
+ }),
446
+ z.object({
447
+ type: z.literal("MANUAL"),
448
+ method: snapshotMethodSchema,
449
+ pickupLocation: snapshotPickupSchema,
311
450
  }),
312
- z.object({ type: z.literal("MANUAL") }),
313
451
  ]);
314
452
 
315
453
  export type OrderItemFulfillmentSnapshot = z.infer<typeof orderItemFulfillmentSnapshotSchema>;
@@ -323,3 +461,33 @@ export const orderItemFulfillmentSnapshotResponseSchema = openFulfillmentUnion(
323
461
  export type OrderItemFulfillmentSnapshotView = z.infer<
324
462
  typeof orderItemFulfillmentSnapshotResponseSchema
325
463
  >;
464
+
465
+ // ─── 픽업 장소 (이슈 #115 결정 28) ─────────────────────────────
466
+
467
+ /** 상점당 픽업 장소 상한 */
468
+ export const MAX_PICKUP_LOCATIONS = 20;
469
+
470
+ /** 방문 수령(`PICKUP`) 장소 — 상점 설정이다. 구매자가 주문서에서 고르고 주문상품에 주문 시점 값이 남는다 */
471
+ export const pickupLocationSchema = z.object({
472
+ locationId: z.string(),
473
+ name: z.string().describe("장소 이름(예: 본점)"),
474
+ address: z.string().describe("주소"),
475
+ phone: z.string().nullable().describe("연락처. 없으면 null"),
476
+ hours: z.string().nullable().describe("수령 가능 시간 안내. 없으면 null"),
477
+ usable: z.boolean().describe("사용 여부. false면 구매자에게 보이지 않고 주문서에서 고를 수 없다"),
478
+ sortOrder: z.number().int(),
479
+ createdAt: z.string(),
480
+ });
481
+
482
+ export type PickupLocation = z.infer<typeof pickupLocationSchema>;
483
+
484
+ export const pickupLocationRequestSchema = z.strictObject({
485
+ name: z.string().trim().min(1).max(50),
486
+ address: z.string().trim().min(1).max(200),
487
+ phone: z.string().trim().max(30).nullish(),
488
+ hours: z.string().trim().max(100).nullish(),
489
+ usable: z.boolean().default(true),
490
+ });
491
+
492
+ export type PickupLocationRequest = z.infer<typeof pickupLocationRequestSchema>;
493
+ export type PickupLocationRequestInput = z.input<typeof pickupLocationRequestSchema>;
@@ -158,6 +158,8 @@ export const eventTypeSchema = z
158
158
  "INQUIRY.CREATED",
159
159
  "REVIEW.CREATED",
160
160
  "PRODUCT.OUT_OF_STOCK",
161
+ // 커머스 플로우 알림 블록(이슈 #115). 플로우에 알림 블록을 둔 상점의 구독자에게만 간다
162
+ "ORDER.FLOW_NOTIFICATION",
161
163
  ])
162
164
  .describe(
163
165
  "웹훅 이벤트 타입. `ORDER.ADDRESS_CHANGE_REQUESTED`는 현재 발송하지 않음(호환을 위해 값만 남았고, 구독해도 이벤트가 오지 않는다)",
@@ -4,6 +4,7 @@ import { changeTypeSchema, orderItemStatusSchema, type PageParams, pageSchema }
4
4
  import { orderItemFulfillmentSnapshotResponseSchema } from "./fulfillment";
5
5
  import { cashReceiptSchema, easyPayProviderResponseSchema } from "./payment";
6
6
  import { customInputValueSchema, optionSelectionSchema } from "./product";
7
+ import { staySnapshotSchema } from "./stay";
7
8
 
8
9
  export const orderSummarySchema = z.object({
9
10
  orderId: z.string(),
@@ -81,6 +82,9 @@ export const orderItemSchema = z.object({
81
82
  .describe(
82
83
  "구매자가 적은 직접 입력값(주문 시점 항목명과 값, 이슈 #59). 없으면 빈 배열. 서버는 항상 싣는다",
83
84
  ),
85
+ stay: staySnapshotSchema
86
+ .optional()
87
+ .describe("숙박 기간과 박별 가격(날짜별 재고 상품만, 이슈 #118). 없으면 생략"),
84
88
  sellerCode: z.string().nullable(),
85
89
  fulfillmentSnapshot: orderItemFulfillmentSnapshotResponseSchema.describe(
86
90
  "주문 시점 이행 스냅샷(이슈 #57). `type`이 주문 시점 상품 유형이다. 배송 상품이 아니면 발송·배송완료 대신 제공 처리를 하고, " +
@@ -8,6 +8,7 @@ import {
8
8
  } from "./fulfillment";
9
9
  import { pointEarnRateBpSchema } from "./points";
10
10
  import { productRefundPolicyPatchSchema } from "./refund-policy";
11
+ import { inventoryModeSchema } from "./stay";
11
12
  import { UPLOAD_PURPOSE_LIMITS, uploadExtensionOf } from "./upload";
12
13
 
13
14
  export const productStatusSchema = z.enum(["SALE", "SUSPENDED", "OUT_OF_STOCK", "CLOSED"]);
@@ -101,6 +102,12 @@ export const productVariantSchema = z.object({
101
102
  weightGram: z.number().int().nullable(),
102
103
  sellerCode: z.string().nullable(),
103
104
  usable: z.boolean(),
105
+ attributes: z
106
+ .record(z.string(), z.string())
107
+ .optional()
108
+ .describe(
109
+ "조합 속성(이슈 #115) — 키 → 값 문자열(예: 회차 일시 `showAt`). 플로우가 `item.option.<키>`로 읽고 타입(날짜·숫자 등)은 플로우가 선언한다. 없으면 생략",
110
+ ),
104
111
  hasOrders: z
105
112
  .boolean()
106
113
  .optional()
@@ -163,6 +170,28 @@ export const addonGroupSchema = z.object({
163
170
 
164
171
  export type AddonGroup = z.infer<typeof addonGroupSchema>;
165
172
 
173
+ /**
174
+ * 직접 입력 타입(이슈 #115 결정 24) — 값은 늘 문자열로 오가고 서버가 타입대로 검사·정규화한다.
175
+ * `number`는 정수, `select`는 `options` 중 하나, `date`는 `YYYY-MM-DD`, `datetime`은 오프셋 있는 ISO 8601이다.
176
+ * `image`는 구매자 업로드(`POST /storefront/v1/uploads/order-inputs`) 주소 목록이고 `maxLength`가 최대 장수(1~5)다
177
+ */
178
+ export const CUSTOM_INPUT_TYPES = [
179
+ "text",
180
+ "number",
181
+ "select",
182
+ "date",
183
+ "datetime",
184
+ "image",
185
+ ] as const;
186
+
187
+ /** 직접 입력 키 — 플로우 식이 `item.inputs.<key>`로 읽는다. 영문 소문자로 시작하는 영숫자·밑줄 */
188
+ const customInputKeySchema = z
189
+ .string()
190
+ .regex(/^[a-z][a-zA-Z0-9_]{0,39}$/)
191
+ .describe(
192
+ "고정 키(영문 소문자로 시작, 영숫자·밑줄 40자). 플로우 식이 `item.inputs.<key>`로 읽는다",
193
+ );
194
+
166
195
  /** 직접 입력 항목 — 예: 각인 문구 */
167
196
  export const customInputSchema = z.object({
168
197
  inputId: z.string(),
@@ -172,6 +201,18 @@ export const customInputSchema = z.object({
172
201
  required: z.boolean(),
173
202
  usable: z.boolean().describe("사용 여부. false면 구매자에게 보이지 않는다"),
174
203
  sortOrder: z.number().int(),
204
+ key: z.string().nullable().optional().describe("고정 키. 없으면 null(플로우 식이 읽지 않는다)"),
205
+ type: z
206
+ .string()
207
+ .optional()
208
+ .describe(
209
+ "입력 타입. 현재 값은 `text`·`number`·`select`·`date`·`datetime`이고 값이 추가될 수 있어 문자열로 받는다. 없으면 `text`",
210
+ ),
211
+ options: z
212
+ .array(z.string())
213
+ .nullable()
214
+ .optional()
215
+ .describe("`select`의 선택지. 그 밖의 타입은 null"),
175
216
  });
176
217
 
177
218
  export type CustomInput = z.infer<typeof customInputSchema>;
@@ -254,6 +295,18 @@ export const customInputRequestSchema = z.object({
254
295
  maxLength: z.number().int().min(1).max(MAX_CUSTOM_INPUT_LENGTH),
255
296
  required: z.boolean().default(false),
256
297
  usable: z.boolean().default(true),
298
+ /** 아래 셋은 생략하면 지금 값을 유지한다(새 항목은 키 없음·`text`) — 옛 클라이언트의 저장이 타입을 지우지 않는다 */
299
+ key: customInputKeySchema.nullish(),
300
+ type: z
301
+ .enum(CUSTOM_INPUT_TYPES)
302
+ .optional()
303
+ .describe("입력 타입. 생략하면 지금 값 유지(새 항목은 `text`)"),
304
+ options: z
305
+ .array(z.string().trim().min(1).max(50))
306
+ .min(1)
307
+ .max(30)
308
+ .nullish()
309
+ .describe("`select`의 선택지(1~30개). `select`가 아니면 생략하거나 null"),
257
310
  });
258
311
 
259
312
  export type CustomInputRequest = z.infer<typeof customInputRequestSchema>;
@@ -279,6 +332,8 @@ export type OptionSelection = z.infer<typeof optionSelectionSchema>;
279
332
  export const customInputValueSchema = z.object({
280
333
  label: z.string(),
281
334
  value: z.string(),
335
+ key: z.string().nullable().optional().describe("항목의 고정 키(주문 시점). 없으면 null"),
336
+ type: z.string().optional().describe("항목의 입력 타입(주문 시점). 없으면 `text`"),
282
337
  });
283
338
 
284
339
  export type CustomInputValue = z.infer<typeof customInputValueSchema>;
@@ -338,6 +393,17 @@ export const productSchema = productSummarySchema.extend({
338
393
  .default("REGISTERED")
339
394
  .describe("옵션 값 정렬 순서(이슈 #59). 서버는 항상 싣는다"),
340
395
  fulfillment: productFulfillmentResponseSchema,
396
+ inventoryMode: z
397
+ .string()
398
+ .optional()
399
+ .describe("재고 방식(이슈 #118). 날짜별 재고 상품만 `DATED`를 싣는다. 없으면 `STANDARD`"),
400
+ flowId: z
401
+ .string()
402
+ .nullable()
403
+ .optional()
404
+ .describe(
405
+ "주문 흐름(이슈 #115) — 상품에 지정한 흐름. null이면 카테고리의 흐름, 그것도 없으면 기본 흐름",
406
+ ),
341
407
  fulfillmentTypeLocked: z
342
408
  .boolean()
343
409
  .describe(
@@ -462,6 +528,13 @@ export const productVariantRequestSchema = z.object({
462
528
  weightGram: z.number().int().min(0).optional(),
463
529
  sellerCode: codeSchema.optional(),
464
530
  usable: z.boolean().default(true),
531
+ attributes: z
532
+ .record(z.string().regex(/^[a-z][a-zA-Z0-9_]{0,39}$/), z.string().max(100))
533
+ .refine((value) => Object.keys(value).length <= 10, { message: "조합 속성은 10개까지입니다" })
534
+ .optional()
535
+ .describe(
536
+ "조합 속성(이슈 #115) — 키 → 값 문자열(예: 회차 일시 `showAt`). 플로우가 `item.option.<키>`로 읽고 타입(날짜·숫자 등)은 플로우가 선언한다. 생략하면 지금 값 유지(새 조합은 없음), `{}`면 비운다",
537
+ ),
465
538
  });
466
539
 
467
540
  export type ProductVariantRequest = z.infer<typeof productVariantRequestSchema>;
@@ -601,6 +674,18 @@ const productWriteShape = {
601
674
  .describe(
602
675
  "쓰기 전용(이슈 #59) — 이 저장으로 옵션을 불러온 저장 옵션 id. 상품 저장과 같은 트랜잭션에서 불러오기 기록을 남겨 저장 옵션의 `importedProductCount`를 센다. 없는 id는 무시한다",
603
676
  ),
677
+ inventoryMode: inventoryModeSchema
678
+ .optional()
679
+ .describe(
680
+ "재고 방식(이슈 #118). `DATED`면 날짜별 달력(`PUT /products/{id}/calendar`)이 재고·가격이고 주문서 줄은 숙박 기간(`stay`)을 받는다. 생략하면 등록은 `STANDARD`, 수정은 지금 값. 판매 이력이 있으면 바꿀 수 없다(409 `INVENTORY_MODE_LOCKED`)",
681
+ ),
682
+ flowId: z
683
+ .string()
684
+ .nullable()
685
+ .optional()
686
+ .describe(
687
+ "주문 흐름(이슈 #115) — 게시한 흐름 id. null이면 카테고리의 흐름(없으면 기본 흐름)을 쓴다. 생략하면 등록은 null, 수정은 지금 값. 게시하지 않은 흐름은 409 `FLOW_NOT_PUBLISHED`. 이미 결제된 주문은 결제 때 붙은 흐름을 그대로 따른다",
688
+ ),
604
689
  };
605
690
 
606
691
  export const productCreateRequestSchema = z.strictObject({
@@ -634,6 +719,13 @@ export const productPatchRequestSchema = z
634
719
  name: z.string().max(100).optional(),
635
720
  salePrice: z.number().int().multipleOf(10).optional(),
636
721
  status: productStatusSchema.optional(),
722
+ flowId: z
723
+ .string()
724
+ .nullable()
725
+ .optional()
726
+ .describe(
727
+ "주문 흐름(이슈 #115) — 게시한 흐름 id, null이면 카테고리의 흐름(없으면 기본 흐름). 생략하면 바꾸지 않는다",
728
+ ),
637
729
  images: productImagesRequestSchema
638
730
  .optional()
639
731
  .describe(
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { type PageParams, pageQueryShape } from "./common";
3
3
  import {
4
+ customInputRequestSchema,
4
5
  MAX_ADDON_GROUPS,
5
6
  MAX_CUSTOM_INPUT_LENGTH,
6
7
  MAX_CUSTOM_INPUTS,
@@ -51,6 +52,9 @@ export const savedCustomInputSchema = z.object({
51
52
  placeholder: z.string().nullable(),
52
53
  maxLength: z.number().int(),
53
54
  required: z.boolean(),
55
+ key: z.string().nullable().optional().describe("고정 키(#115). 없으면 생략"),
56
+ type: z.string().optional().describe("입력 타입(#115). 없으면 `text`"),
57
+ options: z.array(z.string()).nullable().optional().describe("`select`의 선택지"),
54
58
  });
55
59
 
56
60
  export type SavedCustomInput = z.infer<typeof savedCustomInputSchema>;
@@ -93,6 +97,10 @@ export const savedCustomInputRequestSchema = z.object({
93
97
  placeholder: z.string().trim().max(100).nullish(),
94
98
  maxLength: z.number().int().min(1).max(MAX_CUSTOM_INPUT_LENGTH),
95
99
  required: z.boolean().default(false),
100
+ /** 타입 있는 주문 입력(#115) — 상품 직접 입력과 같은 규칙. 생략하면 키 없음·`text` */
101
+ key: customInputRequestSchema.shape.key,
102
+ type: customInputRequestSchema.shape.type,
103
+ options: customInputRequestSchema.shape.options,
96
104
  });
97
105
 
98
106
  /**
@@ -31,6 +31,8 @@ export const STORE_SCOPE_RESOURCES = {
31
31
  audit: ["r"],
32
32
  promotion: ["r", "rw"],
33
33
  storefront: ["r", "rw"],
34
+ // 커머스 플로우 정의·할당(이슈 #115) — 주문 처리 행동은 order 스코프다
35
+ flow: ["r", "rw"],
34
36
  } as const satisfies Record<string, readonly ScopeAccess[]>;
35
37
 
36
38
  export type StoreScopeResource = keyof typeof STORE_SCOPE_RESOURCES;
@@ -68,6 +70,7 @@ export const STORE_ROLE_SCOPES = {
68
70
  "audit:r",
69
71
  "promotion:rw",
70
72
  "storefront:rw",
73
+ "flow:rw",
71
74
  ],
72
75
  ADMIN: [
73
76
  "product:rw",
@@ -84,6 +87,7 @@ export const STORE_ROLE_SCOPES = {
84
87
  "audit:r",
85
88
  "promotion:rw",
86
89
  "storefront:rw",
90
+ "flow:rw",
87
91
  ],
88
92
  STAFF: [
89
93
  "product:rw",
@@ -94,5 +98,6 @@ export const STORE_ROLE_SCOPES = {
94
98
  "review:rw",
95
99
  "customer:r",
96
100
  "promotion:r",
101
+ "flow:r",
97
102
  ],
98
103
  } as const satisfies Record<StoreRole, readonly string[]>;
@@ -259,6 +259,8 @@ export interface DeliveryFeeLine {
259
259
  * 유형을 바꿔도 결제 시작의 견적과 주문상품 스냅샷이 같은 유형을 보게 한다. 생략하면 상품 값을 쓴다.
260
260
  */
261
261
  fulfillmentType?: FulfillmentType | null;
262
+ /** 이 줄의 이행 방식(이슈 #115) — 배송지가 없는 방식(방문 수령)은 묶음을 만들지 않는다. 생략하면 유형 규칙만 본다 */
263
+ fulfillmentMethod?: string | null;
262
264
  }
263
265
 
264
266
  /**
@@ -344,7 +346,8 @@ export function quoteDelivery(input: DeliveryQuoteInput): DeliveryQuote {
344
346
  const product = input.productOf(line.productId);
345
347
  if (!product) continue;
346
348
  // 비실물은 묶음도 무료 기준 판정 금액도 만들지 않는다 — 디지털 금액이 실물 배송비를 면제시키지 않게 한다
347
- if (!requiresShipping(line.fulfillmentType ?? product.fulfillmentType)) continue;
349
+ if (!requiresShipping(line.fulfillmentType ?? product.fulfillmentType, line.fulfillmentMethod))
350
+ continue;
348
351
  shippableAmount += line.amount;
349
352
  const key = deliveryBundleKeyOf(product);
350
353
  const group = groups.get(key) ?? { product, ids: new Set<string>(), sum: 0 };
@@ -0,0 +1,90 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 날짜별 재고·가격(이슈 #118) — 숙박·회차처럼 날짜마다 재고와 가격이 다른 상품. 상품 `inventoryMode: "DATED"`이면 조합(객실) × 날짜
5
+ * 달력이 재고·가격의 원천이고, 주문서 줄은 기간(`stay`)을 받는다. 날짜는 한국 날짜 `YYYY-MM-DD`다.
6
+ */
7
+
8
+ /** 한국 날짜 */
9
+ export const calendarDateSchema = z
10
+ .string()
11
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
12
+ .refine((value) => {
13
+ const date = new Date(`${value}T00:00:00Z`);
14
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
15
+ }, "날짜 형식이 아닙니다")
16
+ .describe("한국 날짜(YYYY-MM-DD)");
17
+
18
+ /** 한 번에 받는 최대 박수 */
19
+ export const MAX_STAY_NIGHTS = 30;
20
+ /** 달력 한 번 조회·설정 최대 일수 */
21
+ export const MAX_CALENDAR_DAYS = 400;
22
+
23
+ export const inventoryModeSchema = z
24
+ .enum(["STANDARD", "DATED"])
25
+ .describe("재고 방식. `STANDARD` 조합 재고 수량 · `DATED` 날짜별 재고·가격(숙박·회차)");
26
+ export type InventoryMode = z.infer<typeof inventoryModeSchema>;
27
+
28
+ /** 숙박 기간 — 체크인 날부터 체크아웃 전날까지가 박이다 */
29
+ export const stayRangeSchema = z
30
+ .object({
31
+ checkIn: calendarDateSchema,
32
+ checkOut: calendarDateSchema,
33
+ })
34
+ .describe("숙박 기간. 체크인 날부터 체크아웃 전날까지 박마다 재고·가격을 본다");
35
+ export type StayRange = z.infer<typeof stayRangeSchema>;
36
+
37
+ /** 주문 시점 숙박 — 박마다 그때 가격 */
38
+ export const staySnapshotSchema = z.object({
39
+ checkIn: z.string(),
40
+ checkOut: z.string(),
41
+ nights: z
42
+ .array(z.object({ date: z.string(), price: z.number().int() }))
43
+ .describe("박마다 날짜·가격(객실 1개 기준)"),
44
+ });
45
+ export type StaySnapshot = z.infer<typeof staySnapshotSchema>;
46
+
47
+ /** 체크인~체크아웃 전날 날짜 목록. 형식이 틀리거나 체크아웃이 체크인 이하면 빈 목록 */
48
+ export function nightsOf(range: StayRange): string[] {
49
+ const start = Date.parse(`${range.checkIn}T00:00:00Z`);
50
+ const end = Date.parse(`${range.checkOut}T00:00:00Z`);
51
+ if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return [];
52
+ const nights: string[] = [];
53
+ for (let at = start; at < end; at += 86_400_000) {
54
+ nights.push(new Date(at).toISOString().slice(0, 10));
55
+ }
56
+ return nights;
57
+ }
58
+
59
+ /** 관리 달력 한 칸 */
60
+ export const calendarDaySchema = z.object({
61
+ variantId: z.string(),
62
+ date: z.string(),
63
+ stock: z.number().int().describe("그날 남은 재고"),
64
+ price: z.number().int().nullable().describe("그날 가격(원). null이면 조합 가격"),
65
+ });
66
+ export type CalendarDay = z.infer<typeof calendarDaySchema>;
67
+
68
+ export const calendarQuerySchema = z.object({
69
+ variantId: z.string().optional().describe("조합. 생략하면 모든 조합"),
70
+ from: calendarDateSchema,
71
+ to: calendarDateSchema.describe("끝 날짜(포함)"),
72
+ });
73
+
74
+ /**
75
+ * 달력 설정 — 기간의 날짜마다 재고·가격을 덮어쓴다(없는 날은 만든다). `stock`·`price`는 준 것만 바꾸고, `price: null`은 조합 가격으로
76
+ * 되돌린다. `daysOfWeek`(0=일~6=토)를 주면 그 요일만 바꾼다
77
+ */
78
+ export const setCalendarRequestSchema = z
79
+ .object({
80
+ variantIds: z.array(z.string()).min(1).max(50),
81
+ from: calendarDateSchema,
82
+ to: calendarDateSchema,
83
+ stock: z.number().int().min(0).max(100_000).optional(),
84
+ price: z.number().int().min(0).multipleOf(10).nullable().optional(),
85
+ daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).max(7).optional(),
86
+ })
87
+ .refine((value) => value.stock !== undefined || value.price !== undefined, {
88
+ message: "stock이나 price 중 하나는 있어야 합니다",
89
+ });
90
+ export type SetCalendarRequest = z.input<typeof setCalendarRequestSchema>;
@@ -5,8 +5,9 @@ import { z } from "zod";
5
5
  *
6
6
  * - `product-image`: 상품 목록·상세의 상품 이미지. 목록 썸네일로 쓰이므로 정지 이미지만 받는다.
7
7
  * - `description`: 상세설명 본문에 삽입하는 미디어. 동영상과 svg도 받는다.
8
+ * - `flow-attachment`: 주문 흐름 행동의 첨부(시안 등, 이슈 #115). 구매자에게 보이므로 정지 이미지와 PDF만 받는다.
8
9
  */
9
- export const uploadPurposeSchema = z.enum(["product-image", "description"]);
10
+ export const uploadPurposeSchema = z.enum(["product-image", "description", "flow-attachment"]);
10
11
 
11
12
  export type UploadPurpose = z.infer<typeof uploadPurposeSchema>;
12
13
 
@@ -34,6 +35,10 @@ export const UPLOAD_PURPOSE_LIMITS: Record<UploadPurpose, UploadPurposeLimit> =
34
35
  extensions: ["jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "mp4", "webm", "mov"],
35
36
  maxBytes: 50 * MB,
36
37
  },
38
+ "flow-attachment": {
39
+ extensions: ["jpg", "jpeg", "png", "webp", "avif", "gif", "pdf"],
40
+ maxBytes: 20 * MB,
41
+ },
37
42
  };
38
43
 
39
44
  /** 파일명의 소문자 확장자. 확장자가 없으면 `""` */