Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/features/store/services/store-pickup-schedule.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,52 @@ describe('StorePickupScheduleService (real DB)', () => {
).resolves.toBe(false);
});

it('하루를 넘는 리드타임은 미래 날짜에도 적용된다', async () => {
// 리드 3일 → 현재(9/16 16:00) 기준 9/19 16:00 이전 슬롯은 전부 마감
const store = await createStore(prisma, {
min_lead_time_minutes: 3 * 24 * 60,
});
await openAllWeek(store);

// 9/18 14:00 — 리드타임 미달
await expect(
service.isPickupSlotAvailable({
storeId: store.id,
pickupAt: new Date('2026-09-18T05:00:00.000Z'),
}),
).resolves.toBe(false);
// 9/19 14:00 — 여전히 미달, 9/19 16:00 — 충족
await expect(
service.isPickupSlotAvailable({
storeId: store.id,
pickupAt: new Date('2026-09-19T05:00:00.000Z'),
}),
).resolves.toBe(false);
await expect(
service.isPickupSlotAvailable({
storeId: store.id,
pickupAt: new Date('2026-09-19T07:00:00.000Z'),
}),
).resolves.toBe(true);

// 달력도 동일: 리드타임에 완전히 덮인 날은 CLOSED, 부분 가용 날은 선택 가능
const calendar = await service.storePickupCalendar(store.id, '2026-09');
expect(dayOf(calendar, '2026-09-18')).toMatchObject({
selectable: false,
reason: 'CLOSED',
});
expect(dayOf(calendar, '2026-09-19').selectable).toBe(true);

// 슬롯 조회도 9/19 16:00 이전은 마감 표기
const slots = await service.storePickupTimeSlots(store.id, '2026-09-19');
expect(
slots.afternoon.find((slot) => slot.time === '15:30')?.available,
).toBe(false);
expect(
slots.afternoon.find((slot) => slot.time === '16:00')?.available,
).toBe(true);
});

it('capacity 잔여가 additionalQuantity보다 작으면 불가하다', async () => {
const store = await createStore(prisma);
await openAllWeek(store);
Expand Down
52 changes: 26 additions & 26 deletions src/features/store/services/store-pickup-schedule.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,11 @@ export class StorePickupScheduleService {
}

const reason = this.evaluateDay(store, ctx, now, year, month, day);
const isToday = kstDayDiff(now, parsed) === 0;
let slots = this.buildDaySlots(
store,
hour.open_time,
hour.close_time,
isToday,
kstMidnightUtc(year, month, day),
now,
);
if (reason !== null) {
Expand Down Expand Up @@ -231,12 +230,11 @@ export class StorePickupScheduleService {
return false;
}

const isToday = kstDayDiff(now, args.pickupAt) === 0;
const slots = this.buildDaySlots(
store,
hour.open_time,
hour.close_time,
isToday,
kstMidnightUtc(year, month, day),
now,
);
const pickupMinutes = kstMinutesOfDay(args.pickupAt);
Expand Down Expand Up @@ -277,7 +275,7 @@ export class StorePickupScheduleService {
/**
* 해당 KST 달력일의 선택 불가 사유(null이면 선택 가능).
* 판정 순서: 과거 → 범위 초과 → 특별휴무 → 요일 휴무/영업시간 미설정
* → capacity 소진 → 당일 잔여 가용 슬롯 없음.
* → capacity 소진 → 리드타임 반영 잔여 가용 슬롯 없음.
*/
private evaluateDay(
store: StorePickupPolicyRow,
Expand Down Expand Up @@ -309,37 +307,39 @@ export class StorePickupScheduleService {
return STORE_PICKUP_DAY_REASON.CAPACITY_FULL;
}

// 당일은 리드타임 반영 잔여 슬롯이 있어야 선택 가능(전역 pickupCalendar 선례와 일치)
if (diff === 0) {
const slots = this.buildDaySlots(
store,
hour.open_time,
hour.close_time,
true,
now,
);
if (!slots.some((slot) => slot.available)) {
return STORE_PICKUP_DAY_REASON.CLOSED;
}
// 리드타임 반영 잔여 슬롯이 없는 날은 선택 불가(전역 pickupCalendar 선례 확장).
// 리드타임이 하루를 넘으면 미래 날짜도 여기서 마감된다.
const slots = this.buildDaySlots(
store,
hour.open_time,
hour.close_time,
kstMidnightUtc(year, month, day),
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,
isToday: boolean,
dayStartUtc: Date,
now: Date,
): StorePickupSlot[] {
// 분 단위 절삭은 리드타임을 최대 59초 짧게 만들므로, 초가 남으면 다음 분으로 올린다
const hasSubMinute =
now.getUTCSeconds() > 0 || now.getUTCMilliseconds() > 0;
// 미래일은 컷오프 무력화(-Infinity + 리드타임 = -Infinity → 전 슬롯 가용)
const nowMinutes = isToday
? kstMinutesOfDay(now) + (hasSubMinute ? 1 : 0)
: Number.NEGATIVE_INFINITY;
// 해당 날짜 자정 기준 현재 시각의 경과 분. 미래 날짜면 음수가 되어
// 컷오프(nowMinutes + 리드타임)가 그만큼 앞당겨진다. 분수 분은 다음 분으로
// 올림(분 절삭이 리드타임을 최대 59초 짧게 만들지 않도록 보수적 처리).
const nowMinutes = Math.ceil(
(now.getTime() - dayStartUtc.getTime()) / 60_000,
);
return buildTodaySlots({
openMinutes: timeColumnToMinutes(openTime),
closeMinutes: timeColumnToMinutes(closeTime),
Expand Down
Loading