From d0e263182ebd33f9b9c18cf04f609fae215e34a9 Mon Sep 17 00:00:00 2001 From: yoonsojung Date: Mon, 27 Jul 2026 11:38:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B8=B0=EC=82=AC=20=EA=B2=AC=EC=A0=81?= =?UTF-8?q?=20=EB=B0=98=EB=A0=A4=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/estimate/estimate.controller.ts | 23 ++++++ src/modules/estimate/estimate.repository.ts | 32 +++++++- src/modules/estimate/estimate.route.ts | 23 +++++- src/modules/estimate/estimate.service.ts | 90 +++++++++++++++++++-- src/modules/estimate/estimate.type.ts | 18 ++++- src/modules/estimate/estimate.validator.ts | 9 +++ 6 files changed, 184 insertions(+), 11 deletions(-) diff --git a/src/modules/estimate/estimate.controller.ts b/src/modules/estimate/estimate.controller.ts index b479fd40..fc956ab8 100644 --- a/src/modules/estimate/estimate.controller.ts +++ b/src/modules/estimate/estimate.controller.ts @@ -8,6 +8,7 @@ import type { ReceivedEstimateDetailParam, ReceivedEstimateIdParam, ReceivedEstimateRequestIdParam, + RejectEstimateInput, SendEstimateInput, SendEstimateParam, } from "./estimate.type"; @@ -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); + } +}; + // ============================================================================= // 고객: 기사에게 받은 견적 목록·상세 조회 및 견적 확정 // ============================================================================= @@ -206,6 +228,7 @@ export const estimateController = { getList, getReceivedEstimatePanels, sendEstimate, + rejectEstimate, getReceivedEstimateList, getReceivedEstimateDetail, getReceivedEstimateDetailById, diff --git a/src/modules/estimate/estimate.repository.ts b/src/modules/estimate/estimate.repository.ts index 8fa3a9ae..f546b673 100644 --- a/src/modules/estimate/estimate.repository.ts +++ b/src/modules/estimate/estimate.repository.ts @@ -23,6 +23,13 @@ type CreateEstimateData = { isDesignated: boolean; }; +// 기사 - 견적 요청 반려 생성 +type CreateEstimateRejectionData = { + estimateRequestId: number; + moverId: string; + reason: string; +}; + // ============================================================================= // 기사: 고객의 견적 요청 목록 조회 조건 // ============================================================================= @@ -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, @@ -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, + }, + }); + }, }; // ============================================================================= diff --git a/src/modules/estimate/estimate.route.ts b/src/modules/estimate/estimate.route.ts index e19ef915..ac9d4c4a 100644 --- a/src/modules/estimate/estimate.route.ts +++ b/src/modules/estimate/estimate.route.ts @@ -5,6 +5,7 @@ import { validate } from "../../middlewares/validate"; import { estimateController } from "./estimate.controller"; import { moverEstimateRequestListQuerySchema, + rejectEstimateBodySchema, sendEstimateBodySchema, receivedEstimateIdParamSchema, sendEstimateParamSchema, @@ -13,10 +14,9 @@ import { const estimateRouter = Router(); /* -2026.07.21 add 윤소정 +- 2026.07.21 add 윤소정 기사 견적 요청 목록 */ - estimateRouter.get( "/requests", authenticate, @@ -25,6 +25,10 @@ estimateRouter.get( estimateController.getList, ); +/* +- 2026.07.24 add 윤소정 +기사 견적 제안 +*/ estimateRouter.post( "/requests/:estimateRequestId", authenticate, @@ -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", diff --git a/src/modules/estimate/estimate.service.ts b/src/modules/estimate/estimate.service.ts index 20b287bf..15a5e42d 100644 --- a/src/modules/estimate/estimate.service.ts +++ b/src/modules/estimate/estimate.service.ts @@ -10,6 +10,7 @@ import type { MoverEstimateRequestListItem, MoverEstimateRequestListQuery, MoverEstimateRequestListResult, + RejectEstimateParams, SendEstimateParams, } from "./estimate.type"; @@ -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"); @@ -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, + ); + }); + }, }; // ============================================================================= diff --git a/src/modules/estimate/estimate.type.ts b/src/modules/estimate/estimate.type.ts index f87af811..7ed63341 100644 --- a/src/modules/estimate/estimate.type.ts +++ b/src/modules/estimate/estimate.type.ts @@ -4,6 +4,7 @@ import type { z } from "zod"; import type { confirmReceivedEstimateParamSchema, moverEstimateRequestListQuerySchema, + rejectEstimateBodySchema, receivedEstimateDetailParamSchema, receivedEstimateIdParamSchema, receivedEstimateRequestIdParamSchema, @@ -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 윤소정 +기사 견적 반려 추가 */ /* @@ -63,14 +68,23 @@ export type ConfirmReceivedEstimateParams = GetReceivedEstimateListParams & { export type SendEstimateParam = z.infer; //기사가 견적 보낼 때 전달하는 요청 본문 export type SendEstimateInput = z.infer; +// 견적 요청 반려 요청 본문 -- POST /api/estimates/requests/:estimateRequestId/reject +export type RejectEstimateInput = z.infer; -//견적 전송 인자 +//견적 제안 인자 export type SendEstimateParams = { estimateRequestId: number; moverId: string; input: SendEstimateInput; }; +// 견적 요청 반려 인자 +export type RejectEstimateParams = { + estimateRequestId: number; + moverId: string; + input: RejectEstimateInput; +}; + //기사에게 노출되는 견적 요청 목록의 단일 항목 export type MoverEstimateRequestListItem = { id: number; diff --git a/src/modules/estimate/estimate.validator.ts b/src/modules/estimate/estimate.validator.ts index 433316c5..6d47c843 100644 --- a/src/modules/estimate/estimate.validator.ts +++ b/src/modules/estimate/estimate.validator.ts @@ -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자까지 입력할 수 있습니다."), +}); + // ============================================================================= // 고객: 기사에게 받은 견적 목록·상세 조회 및 견적 확정 // =============================================================================