From 33d583fbbe534c26ebf93b2558829901ee5e7948 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 27 Aug 2026 22:51:38 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20prettier=203.9=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 의존성 업데이트(PR #219)로 prettier가 3.8 → 3.9로 올라가며 유니언 타입 개행 규칙이 바뀌었다. lint --fix 산출물만 반영한 커밋으로, 코드 변경은 없다. Claude-Session: https://claude.ai/code/session_01KfiGgWooJdsa4iPQPmqvBj --- src/features/auth/auth.service.ts | 3 +-- src/features/auth/services/oidc-login.service.ts | 9 +++------ .../auth/services/seller-credential.service.ts | 3 +-- src/features/auth/services/token.service.ts | 3 +-- .../seller/services/seller-banner.service.ts | 6 +----- .../services/seller-content-mappers.helper.ts | 6 +----- src/features/seller/types/seller-output.type.ts | 13 ++----------- src/global/types/log.type.ts | 4 +--- 8 files changed, 11 insertions(+), 36 deletions(-) diff --git a/src/features/auth/auth.service.ts b/src/features/auth/auth.service.ts index bc10c2a..9a57ac3 100644 --- a/src/features/auth/auth.service.ts +++ b/src/features/auth/auth.service.ts @@ -96,8 +96,7 @@ export class AuthService { */ async logout(req: Request, res: Response): Promise { const refreshToken = req.cookies?.[AUTH_COOKIE.REFRESH] as - | string - | undefined; + string | undefined; if (refreshToken) { const tokenHash = this.tokens.sha256Hex(refreshToken); diff --git a/src/features/auth/services/oidc-login.service.ts b/src/features/auth/services/oidc-login.service.ts index 0239cab..93d6441 100644 --- a/src/features/auth/services/oidc-login.service.ts +++ b/src/features/auth/services/oidc-login.service.ts @@ -119,14 +119,11 @@ export class OidcLoginService implements IOidcLoginService { returnTo: string; } { const expectedState = req.cookies?.[AUTH_COOKIE.OIDC_STATE] as - | string - | undefined; + string | undefined; const expectedNonce = req.cookies?.[AUTH_COOKIE.OIDC_NONCE] as - | string - | undefined; + string | undefined; const codeVerifier = req.cookies?.[AUTH_COOKIE.OIDC_CODE_VERIFIER] as - | string - | undefined; + string | undefined; const returnTo = (req.cookies?.[AUTH_COOKIE.OIDC_RETURN_TO] as string | undefined) ?? this.normalizeReturnTo(undefined); diff --git a/src/features/auth/services/seller-credential.service.ts b/src/features/auth/services/seller-credential.service.ts index d1d567b..319b3fc 100644 --- a/src/features/auth/services/seller-credential.service.ts +++ b/src/features/auth/services/seller-credential.service.ts @@ -131,8 +131,7 @@ export class SellerCredentialService implements ISellerCredentialService { async logoutSeller(req: Request, res: Response): Promise { const refreshToken = req.cookies?.[AUTH_COOKIE.REFRESH] as - | string - | undefined; + string | undefined; if (!refreshToken) throw new UnauthorizedException('Missing refresh token.'); diff --git a/src/features/auth/services/token.service.ts b/src/features/auth/services/token.service.ts index 51706d7..96a7167 100644 --- a/src/features/auth/services/token.service.ts +++ b/src/features/auth/services/token.service.ts @@ -93,8 +93,7 @@ export class TokenService implements ITokenService { res: Response, ): Promise<{ accessToken: string; accountId: bigint }> { const refreshToken = req.cookies?.[AUTH_COOKIE.REFRESH] as - | string - | undefined; + string | undefined; if (!refreshToken) { throw new UnauthorizedException('Missing refresh token.'); diff --git a/src/features/seller/services/seller-banner.service.ts b/src/features/seller/services/seller-banner.service.ts index 9c3e5b1..88864e5 100644 --- a/src/features/seller/services/seller-banner.service.ts +++ b/src/features/seller/services/seller-banner.service.ts @@ -218,11 +218,7 @@ export class SellerBannerService ) { return { linkType: (input.linkType ?? current.link_type) as - | 'NONE' - | 'URL' - | 'PRODUCT' - | 'STORE' - | 'CATEGORY', + 'NONE' | 'URL' | 'PRODUCT' | 'STORE' | 'CATEGORY', linkProductId: input.linkProductId !== undefined ? input.linkProductId diff --git a/src/features/seller/services/seller-content-mappers.helper.ts b/src/features/seller/services/seller-content-mappers.helper.ts index 926f1bf..ffeb0cc 100644 --- a/src/features/seller/services/seller-content-mappers.helper.ts +++ b/src/features/seller/services/seller-content-mappers.helper.ts @@ -46,11 +46,7 @@ export interface AuditLogRow { actor_account_id: bigint; store_id: bigint | null; target_type: - | 'STORE' - | 'PRODUCT' - | 'ORDER' - | 'CONVERSATION' - | 'CHANGE_PASSWORD'; + 'STORE' | 'PRODUCT' | 'ORDER' | 'CONVERSATION' | 'CHANGE_PASSWORD'; target_id: bigint; action: 'CREATE' | 'UPDATE' | 'DELETE' | 'STATUS_CHANGE'; before_json: Prisma.JsonValue | null; diff --git a/src/features/seller/types/seller-output.type.ts b/src/features/seller/types/seller-output.type.ts index f48a34a..6bd2734 100644 --- a/src/features/seller/types/seller-output.type.ts +++ b/src/features/seller/types/seller-output.type.ts @@ -146,12 +146,7 @@ export interface SellerOrderSummaryOutput { export interface SellerOrderStatusHistoryOutput { id: string; fromStatus: - | 'SUBMITTED' - | 'CONFIRMED' - | 'MADE' - | 'PICKED_UP' - | 'CANCELED' - | null; + 'SUBMITTED' | 'CONFIRMED' | 'MADE' | 'PICKED_UP' | 'CANCELED' | null; toStatus: 'SUBMITTED' | 'CONFIRMED' | 'MADE' | 'PICKED_UP' | 'CANCELED'; changedAt: Date; note: string | null; @@ -276,11 +271,7 @@ export interface SellerAuditLogOutput { actorAccountId: string; storeId: string | null; targetType: - | 'STORE' - | 'PRODUCT' - | 'ORDER' - | 'CONVERSATION' - | 'CHANGE_PASSWORD'; + 'STORE' | 'PRODUCT' | 'ORDER' | 'CONVERSATION' | 'CHANGE_PASSWORD'; targetId: string; action: 'CREATE' | 'UPDATE' | 'DELETE' | 'STATUS_CHANGE'; beforeJson: string | null; diff --git a/src/global/types/log.type.ts b/src/global/types/log.type.ts index 4956e9c..2bb04b9 100644 --- a/src/global/types/log.type.ts +++ b/src/global/types/log.type.ts @@ -149,9 +149,7 @@ export interface GraphqlExceptionLogPayload extends BaseExceptionLogPayload { * 일반 트랜잭션 로그에서 사용 가능한 페이로드 유니언 타입. */ export type TransactionLogPayload = - | RestTransactionLogPayload - | GraphqlTransactionLogPayload - | BaseLogPayload; + RestTransactionLogPayload | GraphqlTransactionLogPayload | BaseLogPayload; /** * 에러 로그에서 사용 가능한 페이로드 유니언 타입. From eafa963798b9a37491e17c0d8ec126f066c27bad Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 27 Aug 2026 22:51:52 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor(store):=20=ED=94=BD=EC=97=85=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=20=ED=8C=90=EC=A0=95=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=ED=99=94=20(evaluatePickupDay)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #206: 매장 픽업 가능 판정 규칙(특별휴무·요일 영업시간·일일 capacity 소진·리드타임 컷오프)이 store-today-pickup.service와 store-pickup-schedule.service에 각각 구현되어 있어, 규칙 불일치(한쪽만 수정) 버그 가능성이 있었다. 주문 생성(createOrder)은 이미 isPickupSlotAvailable을 경유하므로 남은 중복은 이 두 곳이었다. - store-pickup-policy.helper.ts 신설: DI-free 순수 함수 evaluatePickupDay/ buildPickupDaySlots로 판정 단일 소스화. 판정 순서(과거 → 범위 초과 → 특별휴무 → 요일 휴무/미설정 → capacity → 잔여 슬롯 없음)는 기존 evaluateDay와 동일하게 이관. - pickup-schedule.service: private evaluateDay/buildDaySlots 제거, 벌크 컨텍스트 → 정책 입력 변환(pickupDayInput)만 남김. 시간 슬롯과 주문 재검증은 정책 결과 슬롯을 재사용해 이중 계산 제거. - today-pickup.service: 매장 루프의 인라인 4분기 → 정책 호출로 대체. '오늘'은 PAST/OUT_OF_RANGE가 항상 통과라 동작 동일. - store.repository: 랭킹 후보 row에 max_days_ahead 추가(정책 입력 충족용). 회귀: 기존 spec 3종(today-pickup.service·pickup-schedule.service· today-pickup.helper) 무변경 통과. 신규 store-pickup-policy.helper.spec 11케이스(판정 순서·capacity/리드타임/max_days_ahead 경계·미래 날짜 컷오프). 전체 validate green (191 suites / 1,647 tests). Claude-Session: https://claude.ai/code/session_01KfiGgWooJdsa4iPQPmqvBj --- .../store/repositories/store.repository.ts | 2 + .../services/store-mappers.helper.spec.ts | 1 + .../store-pickup-policy.helper.spec.ts | 164 ++++++++++++++++++ .../services/store-pickup-policy.helper.ts | 128 ++++++++++++++ .../services/store-pickup-schedule.service.ts | 151 ++++------------ .../services/store-today-pickup.service.ts | 46 ++--- 6 files changed, 343 insertions(+), 149 deletions(-) create mode 100644 src/features/store/services/store-pickup-policy.helper.spec.ts create mode 100644 src/features/store/services/store-pickup-policy.helper.ts diff --git a/src/features/store/repositories/store.repository.ts b/src/features/store/repositories/store.repository.ts index 47cc6d3..541cccd 100644 --- a/src/features/store/repositories/store.repository.ts +++ b/src/features/store/repositories/store.repository.ts @@ -15,6 +15,7 @@ export interface StoreCandidateRow { region: { name: string } | null; pickup_slot_interval_minutes: number; min_lead_time_minutes: number; + max_days_ahead: number; } /** 특정 요일의 매장 영업시간 row(오늘 픽업 슬롯 산출용). */ @@ -89,6 +90,7 @@ export class StoreRepository { region: { select: { name: true } }, pickup_slot_interval_minutes: true, min_lead_time_minutes: true, + max_days_ahead: true, }, }); } diff --git a/src/features/store/services/store-mappers.helper.spec.ts b/src/features/store/services/store-mappers.helper.spec.ts index 43f1420..8ce21e9 100644 --- a/src/features/store/services/store-mappers.helper.spec.ts +++ b/src/features/store/services/store-mappers.helper.spec.ts @@ -13,6 +13,7 @@ function row(overrides: Partial): StoreCandidateRow { region: null, pickup_slot_interval_minutes: 30, min_lead_time_minutes: 30, + max_days_ahead: 7, ...overrides, }; } diff --git a/src/features/store/services/store-pickup-policy.helper.spec.ts b/src/features/store/services/store-pickup-policy.helper.spec.ts new file mode 100644 index 0000000..5a3d079 --- /dev/null +++ b/src/features/store/services/store-pickup-policy.helper.spec.ts @@ -0,0 +1,164 @@ +import { kstMidnightUtc } from '@/common/utils/kst-time'; +import { + buildPickupDaySlots, + evaluatePickupDay, + type PickupDayInput, +} from '@/features/store/services/store-pickup-policy.helper'; + +const HOUR_MS = 60 * 60 * 1000; + +/** @db.Time(0) 컬럼 표현(1970-01-01 UTC 기반)으로 시각 생성. */ +function timeCol(hours: number, minutes = 0): Date { + return new Date(Date.UTC(1970, 0, 1, hours, minutes)); +} + +/** 해당 KST 날짜의 hh시(KST) 시각. */ +function kstAt(year: number, month: number, day: number, hours: number): Date { + return new Date(kstMidnightUtc(year, month, day).getTime() + hours * HOUR_MS); +} + +describe('store-pickup-policy.helper', () => { + // 기준일: 2026-09-10(KST), 영업 10:00~18:00, 간격 30분, 리드타임 60분, 7일 전 예약 + const DAY = { year: 2026, month: 9, day: 10 }; + const dayStartUtc = kstMidnightUtc(DAY.year, DAY.month, DAY.day); + + function input(overrides: Partial = {}): PickupDayInput { + return { + store: { + pickup_slot_interval_minutes: 30, + min_lead_time_minutes: 60, + max_days_ahead: 7, + }, + hour: { + is_closed: false, + open_time: timeCol(10), + close_time: timeCol(18), + }, + isSpecialClosure: false, + capacity: undefined, + booked: 0, + now: kstAt(DAY.year, DAY.month, DAY.day, 9), + dayStartUtc, + ...overrides, + }; + } + + describe('evaluatePickupDay', () => { + it('정상 영업일은 reason null과 가용 슬롯을 반환한다', () => { + const result = evaluatePickupDay(input()); + expect(result.reason).toBeNull(); + // 10:00~17:30, 30분 간격 16개. 09:00+리드 60분 → 10:00부터 전부 가용 + expect(result.slots).toHaveLength(16); + expect(result.slots.every((slot) => slot.available)).toBe(true); + }); + + it('과거 날짜는 PAST를 반환한다', () => { + const result = evaluatePickupDay( + input({ now: kstAt(DAY.year, DAY.month, DAY.day + 1, 9) }), + ); + expect(result.reason).toBe('PAST'); + }); + + it('max_days_ahead를 넘는 날짜는 OUT_OF_RANGE, 경계일은 허용한다', () => { + const boundary = evaluatePickupDay( + input({ now: kstAt(DAY.year, DAY.month, DAY.day - 7, 9) }), + ); + expect(boundary.reason).toBeNull(); + + const beyond = evaluatePickupDay( + input({ now: kstAt(DAY.year, DAY.month, DAY.day - 8, 9) }), + ); + expect(beyond.reason).toBe('OUT_OF_RANGE'); + }); + + it('특별휴무일은 CLOSED이되 마감 표기용 슬롯은 반환한다', () => { + const result = evaluatePickupDay(input({ isSpecialClosure: true })); + expect(result.reason).toBe('CLOSED'); + expect(result.slots).toHaveLength(16); + }); + + it('영업시간 미설정·휴무 요일·시간 누락은 CLOSED와 빈 슬롯을 반환한다', () => { + const cases: (PickupDayInput['hour'] | undefined)[] = [ + undefined, + { is_closed: true, open_time: timeCol(10), close_time: timeCol(18) }, + { is_closed: false, open_time: null, close_time: timeCol(18) }, + { is_closed: false, open_time: timeCol(10), close_time: null }, + ]; + for (const hour of cases) { + const result = evaluatePickupDay(input({ hour })); + expect(result.reason).toBe('CLOSED'); + expect(result.slots).toEqual([]); + } + }); + + it('점유 수량이 capacity에 도달하면 CAPACITY_FULL, 미달이면 통과한다', () => { + expect(evaluatePickupDay(input({ capacity: 5, booked: 5 })).reason).toBe( + 'CAPACITY_FULL', + ); + expect( + evaluatePickupDay(input({ capacity: 5, booked: 4 })).reason, + ).toBeNull(); + }); + + it('capacity 레코드가 없으면 무제한으로 간주한다', () => { + expect( + evaluatePickupDay(input({ capacity: undefined, booked: 999 })).reason, + ).toBeNull(); + }); + + it('리드타임 반영 잔여 가용 슬롯이 없으면 CLOSED로 마감한다', () => { + // 17:00 + 리드 60분 = 18:00 컷오프 → 17:30 슬롯까지 전부 마감 + const result = evaluatePickupDay( + input({ now: kstAt(DAY.year, DAY.month, DAY.day, 17) }), + ); + expect(result.reason).toBe('CLOSED'); + expect(result.slots).toHaveLength(16); + expect(result.slots.every((slot) => !slot.available)).toBe(true); + }); + + it('과거이면서 특별휴무여도 PAST가 우선한다(판정 순서 고정)', () => { + const result = evaluatePickupDay( + input({ + now: kstAt(DAY.year, DAY.month, DAY.day + 1, 9), + isSpecialClosure: true, + }), + ); + expect(result.reason).toBe('PAST'); + }); + }); + + describe('buildPickupDaySlots', () => { + const store = { + pickup_slot_interval_minutes: 30, + min_lead_time_minutes: 60, + max_days_ahead: 7, + }; + + it('미래 날짜는 경과 분이 음수가 되어 컷오프가 앞당겨진다(전 슬롯 가용)', () => { + const slots = buildPickupDaySlots( + store, + timeCol(10), + timeCol(18), + dayStartUtc, + kstAt(DAY.year, DAY.month, DAY.day - 3, 23), + ); + expect(slots.every((slot) => slot.available)).toBe(true); + }); + + it('초 단위가 남으면 다음 분으로 올려 리드타임을 보수적으로 적용한다', () => { + // 09:00:30 + 리드 60분: 분 절삭이면 10:00 가용이지만, 올림으로 10:00은 마감 + const now = new Date( + kstAt(DAY.year, DAY.month, DAY.day, 9).getTime() + 30 * 1000, + ); + const slots = buildPickupDaySlots( + store, + timeCol(10), + timeCol(18), + dayStartUtc, + now, + ); + expect(slots[0]).toEqual({ time: '10:00', available: false }); + expect(slots[1]).toEqual({ time: '10:30', available: true }); + }); + }); +}); diff --git a/src/features/store/services/store-pickup-policy.helper.ts b/src/features/store/services/store-pickup-policy.helper.ts new file mode 100644 index 0000000..52f1b54 --- /dev/null +++ b/src/features/store/services/store-pickup-policy.helper.ts @@ -0,0 +1,128 @@ +import { kstDayDiff } from '@/common/utils/kst-time'; +import { STORE_PICKUP_DAY_REASON } from '@/features/store/constants/store-pickup-schedule.constants'; +import { + buildTodaySlots, + timeColumnToMinutes, +} from '@/features/store/services/store-today-pickup.helper'; +import type { TodayPickupSlot } from '@/features/store/types/store-today-pickup-output.type'; + +/** + * 매장 픽업 가능 판정 정책(단일 소스, 이슈 #206). + * today-pickup 리스트·픽업 달력/시간 슬롯·주문 생성 재검증이 모두 이 모듈을 + * 소비한다 — 판정 분기를 한쪽만 고치는 규칙 불일치를 구조적으로 차단한다. + * DI-free 순수 함수만 둔다(시각은 호출부가 ClockService로 주입). + */ + +/** 판정에 필요한 매장 픽업 정책 필드(store row 부분집합). */ +export interface PickupPolicyStore { + pickup_slot_interval_minutes: number; + min_lead_time_minutes: number; + max_days_ahead: number; +} + +/** 해당 요일의 영업시간 row 부분집합. */ +export interface PickupBusinessHour { + is_closed: boolean; + open_time: Date | null; + close_time: Date | null; +} + +export type StorePickupDayReason = + (typeof STORE_PICKUP_DAY_REASON)[keyof typeof STORE_PICKUP_DAY_REASON]; + +export interface PickupDayInput { + store: PickupPolicyStore; + /** 해당 날짜 요일의 영업시간(미설정이면 undefined). */ + hour: PickupBusinessHour | undefined; + isSpecialClosure: boolean; + /** 일일 제작 capacity. 레코드가 없으면 undefined = 무제한. */ + capacity: number | undefined; + /** 해당 날짜에 이미 점유된 수량 합(취소·soft-delete 제외). */ + booked: number; + now: Date; + /** 해당 KST 달력일 자정(UTC 시각). */ + dayStartUtc: Date; +} + +export interface PickupDayResult { + /** 선택 불가 사유. null이면 선택 가능. */ + reason: StorePickupDayReason | null; + /** + * 영업시간·간격·리드타임 반영 슬롯. 영업하지 않는 날(요일 휴무·미설정)은 + * 빈 배열. reason이 있어도 영업일이면 슬롯을 반환한다(마감 표기용). + */ + slots: TodayPickupSlot[]; +} + +/** + * 해당 KST 달력일의 픽업 가능 여부 판정. + * 판정 순서: 과거 → 범위 초과 → 특별휴무 → 요일 휴무/영업시간 미설정 + * → capacity 소진 → 리드타임 반영 잔여 가용 슬롯 없음. + */ +export function evaluatePickupDay(input: PickupDayInput): PickupDayResult { + const { store, hour, now, dayStartUtc } = input; + const activeHour = + hour && !hour.is_closed && hour.open_time && hour.close_time + ? { openTime: hour.open_time, closeTime: hour.close_time } + : undefined; + const slots = activeHour + ? buildPickupDaySlots( + store, + activeHour.openTime, + activeHour.closeTime, + dayStartUtc, + now, + ) + : []; + + const diff = kstDayDiff(now, dayStartUtc); + if (diff < 0) return { reason: STORE_PICKUP_DAY_REASON.PAST, slots }; + if (diff > store.max_days_ahead) { + return { reason: STORE_PICKUP_DAY_REASON.OUT_OF_RANGE, slots }; + } + if (input.isSpecialClosure) { + return { reason: STORE_PICKUP_DAY_REASON.CLOSED, slots }; + } + if (!activeHour) { + return { reason: STORE_PICKUP_DAY_REASON.CLOSED, slots }; + } + + // capacity 레코드가 없으면 무제한으로 간주(figma 명세 외 정책 결정) + if (input.capacity !== undefined && input.booked >= input.capacity) { + return { reason: STORE_PICKUP_DAY_REASON.CAPACITY_FULL, slots }; + } + + // 리드타임 반영 잔여 슬롯이 없는 날은 선택 불가(전역 pickupCalendar 선례 확장). + // 리드타임이 하루를 넘으면 미래 날짜도 여기서 마감된다. + if (!slots.some((slot) => slot.available)) { + return { reason: STORE_PICKUP_DAY_REASON.CLOSED, slots }; + } + return { reason: null, slots }; +} + +/** + * 영업시간·매장 슬롯 간격으로 슬롯 생성. 리드타임 컷오프는 절대 시각 + * (now + 리드타임) 기준이라 하루를 넘는 리드타임(최대 7일)도 미래 날짜에 + * 올바르게 적용된다 — 당일만 컷오프하던 방식의 릴리즈 리뷰 반영. + */ +export function buildPickupDaySlots( + store: PickupPolicyStore, + openTime: Date, + closeTime: Date, + dayStartUtc: Date, + now: Date, +): TodayPickupSlot[] { + // 해당 날짜 자정 기준 현재 시각의 경과 분. 미래 날짜면 음수가 되어 + // 컷오프(nowMinutes + 리드타임)가 그만큼 앞당겨진다. 분수 분은 다음 분으로 + // 올림(분 절삭이 리드타임을 최대 59초 짧게 만들지 않도록 보수적 처리). + const nowMinutes = Math.ceil( + (now.getTime() - dayStartUtc.getTime()) / 60_000, + ); + return buildTodaySlots({ + openMinutes: timeColumnToMinutes(openTime), + closeMinutes: timeColumnToMinutes(closeTime), + intervalMinutes: store.pickup_slot_interval_minutes, + leadTimeMinutes: store.min_lead_time_minutes, + nowMinutes, + }); +} diff --git a/src/features/store/services/store-pickup-schedule.service.ts b/src/features/store/services/store-pickup-schedule.service.ts index 367d8c7..1c55e44 100644 --- a/src/features/store/services/store-pickup-schedule.service.ts +++ b/src/features/store/services/store-pickup-schedule.service.ts @@ -6,7 +6,6 @@ import { import { ClockService } from '@/common/providers/clock.service'; import { - kstDayDiff, kstMidnightUtc, kstMinutesOfDay, parseKstDate, @@ -15,16 +14,15 @@ import { } from '@/common/utils/kst-time'; import { PICKUP_AFTERNOON_START_MINUTES } from '@/features/pickup'; import { STORE_PICKUP_SCHEDULE_ERRORS } from '@/features/store/constants/store-pickup-schedule-error-messages'; -import { STORE_PICKUP_DAY_REASON } from '@/features/store/constants/store-pickup-schedule.constants'; import { StoreRepository, type StorePickupPolicyRow, type StoreWeekdayBusinessHourRow, } from '@/features/store/repositories/store.repository'; import { - buildTodaySlots, - timeColumnToMinutes, -} from '@/features/store/services/store-today-pickup.helper'; + evaluatePickupDay, + type PickupDayInput, +} from '@/features/store/services/store-pickup-policy.helper'; import type { StorePickupCalendar, StorePickupDay, @@ -89,13 +87,8 @@ export class StorePickupScheduleService { { length: daysInMonth }, (_, index) => { const day = index + 1; - const reason = this.evaluateDay( - store, - ctx, - now, - ym.year, - ym.month, - day, + const { reason } = evaluatePickupDay( + this.pickupDayInput(store, ctx, now, ym.year, ym.month, day), ); return { date: new Date(Date.UTC(ym.year, ym.month - 1, day)) @@ -140,32 +133,19 @@ export class StorePickupScheduleService { kstMidnightUtc(year, month, day + 1), ); - const hour = ctx.hoursByWeekday.get( - new Date(Date.UTC(year, month - 1, day)).getUTCDay(), - ); - if (!hour || hour.is_closed || !hour.open_time || !hour.close_time) { - return { date, morning: [], afternoon: [] }; - } - // 특별휴무도 요일 휴무와 같은 "영업하지 않는 날" — SDL 주석대로 빈 배열로 통일한다. + const input = this.pickupDayInput(store, ctx, now, year, month, day); + const result = evaluatePickupDay(input); + // 특별휴무도 요일 휴무·영업시간 미설정과 같은 "영업하지 않는 날" — SDL 주석대로 + // 빈 배열로 통일한다(슬롯이 비면 요일 휴무·미설정, 영업일 무슬롯도 동일 표현). // (PAST/OUT_OF_RANGE/CAPACITY_FULL/당일 마감은 영업일이므로 슬롯을 마감 표기로 유지) - const dateKey = new Date(Date.UTC(year, month - 1, day)) - .toISOString() - .slice(0, 10); - if (ctx.closureDates.has(dateKey)) { + if (input.isSpecialClosure || result.slots.length === 0) { return { date, morning: [], afternoon: [] }; } - const reason = this.evaluateDay(store, ctx, now, year, month, day); - let slots = this.buildDaySlots( - store, - hour.open_time, - hour.close_time, - kstMidnightUtc(year, month, day), - now, - ); - if (reason !== null) { - slots = slots.map((slot) => ({ ...slot, available: false })); - } + const slots = + result.reason !== null + ? result.slots.map((slot) => ({ ...slot, available: false })) + : result.slots; return { date, @@ -213,32 +193,23 @@ export class StorePickupScheduleService { kstMidnightUtc(year, month, day + 1), ); - if (this.evaluateDay(store, ctx, now, year, month, day) !== null) { - return false; - } + const input = this.pickupDayInput(store, ctx, now, year, month, day); + const result = evaluatePickupDay(input); + if (result.reason !== null) return false; - // capacity 잔여: 이번 주문 수량까지 더해 초과하면 불가 + // capacity 잔여: 이번 주문 수량까지 더해 초과하면 불가 — 공용 판정(소진 여부)에 + // 얹는 주문 생성 전용 확장 검사. // (명세 외 정책 결정: capacity는 일일 제작 '수량' 소진 모델과 일관되게 해석) - const dateKey = dateOnlyUtc.toISOString().slice(0, 10); - const capacity = ctx.capacities.get(dateKey); - const booked = ctx.bookedByDate.get(dateKey) ?? 0; const quantity = args.additionalQuantity ?? 1; - if (capacity !== undefined && booked + quantity > capacity) return false; - - const hour = ctx.hoursByWeekday.get(dateOnlyUtc.getUTCDay()); - if (!hour || hour.is_closed || !hour.open_time || !hour.close_time) { + if ( + input.capacity !== undefined && + input.booked + quantity > input.capacity + ) { return false; } - const slots = this.buildDaySlots( - store, - hour.open_time, - hour.close_time, - kstMidnightUtc(year, month, day), - now, - ); const pickupMinutes = kstMinutesOfDay(args.pickupAt); - return slots.some( + return result.slots.some( (slot) => slot.available && slotMinutes(slot) === pickupMinutes, ); } @@ -273,80 +244,28 @@ export class StorePickupScheduleService { } /** - * 해당 KST 달력일의 선택 불가 사유(null이면 선택 가능). - * 판정 순서: 과거 → 범위 초과 → 특별휴무 → 요일 휴무/영업시간 미설정 - * → capacity 소진 → 리드타임 반영 잔여 가용 슬롯 없음. + * 월/일 벌크 조회 컨텍스트를 해당 KST 달력일의 정책 입력으로 변환한다. + * 판정 자체는 공용 정책(store-pickup-policy.helper)이 담당한다. */ - private evaluateDay( + private pickupDayInput( store: StorePickupPolicyRow, ctx: ScheduleContext, now: Date, year: number, month: number, day: number, - ): string | null { + ): PickupDayInput { const dateOnlyUtc = new Date(Date.UTC(year, month - 1, day)); const dateKey = dateOnlyUtc.toISOString().slice(0, 10); - const diff = kstDayDiff(now, kstMidnightUtc(year, month, day)); - - if (diff < 0) return STORE_PICKUP_DAY_REASON.PAST; - if (diff > store.max_days_ahead) { - return STORE_PICKUP_DAY_REASON.OUT_OF_RANGE; - } - if (ctx.closureDates.has(dateKey)) return STORE_PICKUP_DAY_REASON.CLOSED; - - const hour = ctx.hoursByWeekday.get(dateOnlyUtc.getUTCDay()); - if (!hour || hour.is_closed || !hour.open_time || !hour.close_time) { - return STORE_PICKUP_DAY_REASON.CLOSED; - } - - // capacity 레코드가 없으면 무제한으로 간주(todayPickupStores와 동일 해석) - const capacity = ctx.capacities.get(dateKey); - const booked = ctx.bookedByDate.get(dateKey) ?? 0; - if (capacity !== undefined && booked >= capacity) { - return STORE_PICKUP_DAY_REASON.CAPACITY_FULL; - } - - // 리드타임 반영 잔여 슬롯이 없는 날은 선택 불가(전역 pickupCalendar 선례 확장). - // 리드타임이 하루를 넘으면 미래 날짜도 여기서 마감된다. - const slots = this.buildDaySlots( + return { store, - hour.open_time, - hour.close_time, - kstMidnightUtc(year, month, day), + hour: ctx.hoursByWeekday.get(dateOnlyUtc.getUTCDay()), + isSpecialClosure: ctx.closureDates.has(dateKey), + capacity: ctx.capacities.get(dateKey), + booked: ctx.bookedByDate.get(dateKey) ?? 0, now, - ); - if (!slots.some((slot) => slot.available)) { - return STORE_PICKUP_DAY_REASON.CLOSED; - } - return null; - } - - /** - * 영업시간·매장 슬롯 간격으로 슬롯 생성. 리드타임 컷오프는 절대 시각 - * (now + 리드타임) 기준이라 하루를 넘는 리드타임(최대 7일)도 미래 날짜에 - * 올바르게 적용된다 — 당일만 컷오프하던 방식의 릴리즈 리뷰 반영. - */ - private buildDaySlots( - store: StorePickupPolicyRow, - openTime: Date, - closeTime: Date, - dayStartUtc: Date, - now: Date, - ): StorePickupSlot[] { - // 해당 날짜 자정 기준 현재 시각의 경과 분. 미래 날짜면 음수가 되어 - // 컷오프(nowMinutes + 리드타임)가 그만큼 앞당겨진다. 분수 분은 다음 분으로 - // 올림(분 절삭이 리드타임을 최대 59초 짧게 만들지 않도록 보수적 처리). - const nowMinutes = Math.ceil( - (now.getTime() - dayStartUtc.getTime()) / 60_000, - ); - return buildTodaySlots({ - openMinutes: timeColumnToMinutes(openTime), - closeMinutes: timeColumnToMinutes(closeTime), - intervalMinutes: store.pickup_slot_interval_minutes, - leadTimeMinutes: store.min_lead_time_minutes, - nowMinutes, - }); + dayStartUtc: kstMidnightUtc(year, month, day), + }; } } diff --git a/src/features/store/services/store-today-pickup.service.ts b/src/features/store/services/store-today-pickup.service.ts index 89fb858..b1a3cf6 100644 --- a/src/features/store/services/store-today-pickup.service.ts +++ b/src/features/store/services/store-today-pickup.service.ts @@ -2,11 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ClockService } from '@/common/providers/clock.service'; import { parseId } from '@/common/utils/id-parser'; -import { - kstMidnightUtc, - kstMinutesOfDay, - toKstYmd, -} from '@/common/utils/kst-time'; +import { kstMidnightUtc, toKstYmd } from '@/common/utils/kst-time'; import { DEFAULT_POPULAR_STORES_LIMIT } from '@/features/store/constants/store-ranking.constants'; import type { TodayPickupStoresInput } from '@/features/store/dto/inputs/today-pickup-stores.input'; import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; @@ -16,10 +12,7 @@ import { type ScoredStore, } from '@/features/store/services/store-listing.service'; import { buildRegionLabel } from '@/features/store/services/store-mappers.helper'; -import { - buildTodaySlots, - timeColumnToMinutes, -} from '@/features/store/services/store-today-pickup.helper'; +import { evaluatePickupDay } from '@/features/store/services/store-pickup-policy.helper'; import type { TodayPickupSlot, TodayPickupStoreConnection, @@ -67,35 +60,22 @@ export class StoreTodayPickupService { const hourByStore = new Map( businessHours.map((h) => [h.store_id.toString(), h]), ); - // 분 단위 절삭은 리드타임을 최대 59초 짧게 만들므로, 초가 남으면 다음 분으로 올린다 - const hasSubMinute = - asOf.getUTCSeconds() > 0 || asOf.getUTCMilliseconds() > 0; - const nowMinutes = kstMinutesOfDay(asOf) + (hasSubMinute ? 1 : 0); const open: { entry: ScoredStore; slots: TodayPickupSlot[] }[] = []; for (const entry of scored) { const storeId = entry.candidate.id; - if (closedStoreIds.has(storeId.toString())) continue; - - const hour = hourByStore.get(storeId.toString()); - // 영업시간 미설정·휴무 요일이면 오늘 픽업 불가 - if (!hour || hour.is_closed || !hour.open_time || !hour.close_time) { - continue; - } - - // capacity 레코드가 없으면 무제한으로 간주(figma 명세 외 정책 결정) - const capacity = capacities.get(storeId); - const booked = bookedCounts.get(storeId) ?? 0; - if (capacity !== undefined && booked >= capacity) continue; - - const slots = buildTodaySlots({ - openMinutes: timeColumnToMinutes(hour.open_time), - closeMinutes: timeColumnToMinutes(hour.close_time), - intervalMinutes: entry.candidate.pickup_slot_interval_minutes, - leadTimeMinutes: entry.candidate.min_lead_time_minutes, - nowMinutes, + // 판정은 공용 정책(store-pickup-policy.helper) 단일 소스 — 달력·주문 재검증과 + // 동일 규칙. '오늘'은 PAST/OUT_OF_RANGE가 항상 통과라 사유는 제외 여부로만 쓴다. + const { reason, slots } = evaluatePickupDay({ + store: entry.candidate, + hour: hourByStore.get(storeId.toString()), + isSpecialClosure: closedStoreIds.has(storeId.toString()), + capacity: capacities.get(storeId), + booked: bookedCounts.get(storeId) ?? 0, + now: asOf, + dayStartUtc, }); - if (!slots.some((slot) => slot.available)) continue; + if (reason !== null) continue; open.push({ entry, slots }); }