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
23 changes: 23 additions & 0 deletions src/modules/estimate/estimate.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
ReceivedEstimateDetailParam,
ReceivedEstimateIdParam,
ReceivedEstimateRequestIdParam,
RejectEstimateInput,
SendEstimateInput,
SendEstimateParam,
} from "./estimate.type";
Expand Down Expand Up @@ -81,6 +82,27 @@ const sendEstimate: RequestHandler = async (req, res, next) => {
}
};

// 기사가 고객의 견적 요청을 반려
const rejectEstimate: RequestHandler = async (req, res, next) => {
try {
const { estimateRequestId } = res.locals.params as SendEstimateParam;
const input = req.body as RejectEstimateInput;

const rejection = await moverEstimateRequestService.rejectEstimate({
estimateRequestId,
moverId: getMoverId(req),
input,
});

res.status(201).json({
success: true,
data: rejection,
});
} catch (error) {
next(error);
}
};

// =============================================================================
// 고객: 기사에게 받은 견적 목록·상세 조회 및 견적 확정
// =============================================================================
Expand Down Expand Up @@ -206,6 +228,7 @@ export const estimateController = {
getList,
getReceivedEstimatePanels,
sendEstimate,
rejectEstimate,
getReceivedEstimateList,
getReceivedEstimateDetail,
getReceivedEstimateDetailById,
Expand Down
32 changes: 30 additions & 2 deletions src/modules/estimate/estimate.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ type CreateEstimateData = {
isDesignated: boolean;
};

// 기사 - 견적 요청 반려 생성
type CreateEstimateRejectionData = {
estimateRequestId: number;
moverId: string;
reason: string;
};

// =============================================================================
// 기사: 고객의 견적 요청 목록 조회 조건
// =============================================================================
Expand Down Expand Up @@ -304,8 +311,12 @@ export const moverEstimateRequestRepository = {
});
},

//요청 상태, 지정 여부, 기존 견적, 반려 여부 조회
findEstimateRequestForSend(estimateRequestId: number, moverId: string, db: DbClient = prisma) {
// 기사 작업 전 요청 상태, 지정 여부, 기존 견적, 반려 여부 조회
findEstimateRequestForMoverAction(
estimateRequestId: number,
moverId: string,
db: DbClient = prisma,
) {
return db.estimateRequest.findUnique({
where: {
id: estimateRequestId,
Expand Down Expand Up @@ -371,6 +382,23 @@ export const moverEstimateRequestRepository = {
},
});
},

createEstimateRejection(data: CreateEstimateRejectionData, db: DbClient = prisma) {
return db.estimateRequestRejection.create({
data: {
estimateRequestId: data.estimateRequestId,
moverId: data.moverId,
reason: data.reason,
},
select: {
id: true,
estimateRequestId: true,
moverId: true,
reason: true,
createdAt: true,
},
});
},
};

// =============================================================================
Expand Down
23 changes: 21 additions & 2 deletions src/modules/estimate/estimate.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { validate } from "../../middlewares/validate";
import { estimateController } from "./estimate.controller";
import {
moverEstimateRequestListQuerySchema,
rejectEstimateBodySchema,
sendEstimateBodySchema,
receivedEstimateIdParamSchema,
sendEstimateParamSchema,
Expand All @@ -13,10 +14,9 @@ import {
const estimateRouter = Router();

/*
2026.07.21 add 윤소정
- 2026.07.21 add 윤소정
기사 견적 요청 목록
*/

estimateRouter.get(
"/requests",
authenticate,
Expand All @@ -25,6 +25,10 @@ estimateRouter.get(
estimateController.getList,
);

/*
- 2026.07.24 add 윤소정
기사 견적 제안
*/
estimateRouter.post(
"/requests/:estimateRequestId",
authenticate,
Expand All @@ -36,6 +40,21 @@ estimateRouter.post(
estimateController.sendEstimate,
);

/*
- 2026.07.27 add 윤소정
기사 견적 반려
*/
estimateRouter.post(
"/requests/:estimateRequestId/reject",
authenticate,
authorize("MOVER"),
validate({
params: sendEstimateParamSchema,
body: rejectEstimateBodySchema,
}),
estimateController.rejectEstimate,
);

// 2026.07.24 정슬기 - [추가] 고객 받은 견적 패널 목록 API (요청 단위)
estimateRouter.get(
"/received",
Expand Down
90 changes: 85 additions & 5 deletions src/modules/estimate/estimate.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
MoverEstimateRequestListItem,
MoverEstimateRequestListQuery,
MoverEstimateRequestListResult,
RejectEstimateParams,
SendEstimateParams,
} from "./estimate.type";

Expand Down Expand Up @@ -144,11 +145,12 @@ export const moverEstimateRequestService = {
}

//견적 요청 존재 확인
const estimateRequest = await moverEstimateRequestRepository.findEstimateRequestForSend(
estimateRequestId,
moverId,
tx,
);
const estimateRequest =
await moverEstimateRequestRepository.findEstimateRequestForMoverAction(
estimateRequestId,
moverId,
tx,
);

if (!estimateRequest) {
throw new AppError("ESTIMATE_REQUEST_NOT_FOUND");
Expand Down Expand Up @@ -212,6 +214,84 @@ export const moverEstimateRequestService = {
);
});
},

// 견적 요청 반려
async rejectEstimate({ estimateRequestId, moverId, input }: RejectEstimateParams) {
return runTransaction(async (tx) => {
//기사 프로필
const profile = await moverEstimateRequestRepository.findMoverProfile(moverId, tx);

if (!profile) {
throw new AppError("MOVER_NOT_FOUND");
}

//견적 요청 조회
const estimateRequest =
await moverEstimateRequestRepository.findEstimateRequestForMoverAction(
estimateRequestId,
moverId,
tx,
);

if (!estimateRequest) {
throw new AppError("ESTIMATE_REQUEST_NOT_FOUND");
}

//견적 요청이 OPEN이고, 활성 상태인지 확인
if (
estimateRequest.status !== "OPEN" ||
!estimateRequest.isActive ||
estimateRequest.confirmedEstimateId !== null
) {
//이미 확정된 요청인지 확인
throw new AppError("CONFLICT", {
message: "현재 반려할 수 없는 견적 요청입니다.",
});
}

//만료 여부 확인
if (estimateRequest.expiresAt.getTime() <= Date.now()) {
throw new AppError("CONFLICT", {
message: "만료된 견적 요청입니다.",
});
}

//기사가 해당 이사 유형을 서비스하는지 확인
const canHandleMoveType = profile.serviceTypes.some(
(serviceType) => serviceType.moveType === estimateRequest.moveType,
);

if (!canHandleMoveType) {
throw new AppError("FORBIDDEN", {
message: "서비스할 수 없는 이사 유형입니다.",
});
}

//이미 견적 보냈는지 확인
if (estimateRequest.estimates.length > 0) {
throw new AppError("CONFLICT", {
message: "이미 견적을 보낸 요청입니다.",
});
}

//이미 반려했는지 확인
if (estimateRequest.rejections.length > 0) {
throw new AppError("CONFLICT", {
message: "이미 반려한 견적 요청입니다.",
});
}

//데이터 생성
return moverEstimateRequestRepository.createEstimateRejection(
{
estimateRequestId,
moverId,
reason: input.reason,
},
tx,
);
});
},
};

// =============================================================================
Expand Down
18 changes: 16 additions & 2 deletions src/modules/estimate/estimate.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { z } from "zod";
import type {
confirmReceivedEstimateParamSchema,
moverEstimateRequestListQuerySchema,
rejectEstimateBodySchema,
receivedEstimateDetailParamSchema,
receivedEstimateIdParamSchema,
receivedEstimateRequestIdParamSchema,
Expand All @@ -12,8 +13,12 @@ import type {
} from "./estimate.validator";

/*
2026.07.21 add 윤소정
- 2026.07.21 add 윤소정
API에서 사용하는 데이터 형태 정의
- 2026.07.24 add 윤소정
기사 견적 제안 추가
2026.07.27 add 윤소정
기사 견적 반려 추가
*/

/*
Expand Down Expand Up @@ -63,14 +68,23 @@ export type ConfirmReceivedEstimateParams = GetReceivedEstimateListParams & {
export type SendEstimateParam = z.infer<typeof sendEstimateParamSchema>;
//기사가 견적 보낼 때 전달하는 요청 본문
export type SendEstimateInput = z.infer<typeof sendEstimateBodySchema>;
// 견적 요청 반려 요청 본문 -- POST /api/estimates/requests/:estimateRequestId/reject
export type RejectEstimateInput = z.infer<typeof rejectEstimateBodySchema>;

//견적 전송 인자
//견적 제안 인자
export type SendEstimateParams = {
estimateRequestId: number;
moverId: string;
input: SendEstimateInput;
};

// 견적 요청 반려 인자
export type RejectEstimateParams = {
estimateRequestId: number;
moverId: string;
input: RejectEstimateInput;
};

//기사에게 노출되는 견적 요청 목록의 단일 항목
export type MoverEstimateRequestListItem = {
id: number;
Expand Down
9 changes: 9 additions & 0 deletions src/modules/estimate/estimate.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ export const sendEstimateBodySchema = z.object({
.max(1000, "코멘트는 최대 1000자까지 입력할 수 있습니다."),
});

// 견적 요청 반려 Body 검증
export const rejectEstimateBodySchema = z.object({
reason: z
.string()
.trim()
.min(10, "반려 사유는 최소 10자 이상 입력해 주세요.")
.max(1000, "반려 사유는 최대 1000자까지 입력할 수 있습니다."),
});

// =============================================================================
// 고객: 기사에게 받은 견적 목록·상세 조회 및 견적 확정
// =============================================================================
Expand Down