diff --git a/src/docs/schemas/delivery.schema.js b/src/docs/schemas/delivery.schema.js index 07513c4..93867ed 100644 --- a/src/docs/schemas/delivery.schema.js +++ b/src/docs/schemas/delivery.schema.js @@ -272,5 +272,76 @@ export const deliverySchemas = { } } } + }, + DeliveryOrderHistoryResponse: { + type: "object", + properties: { + content: { + type: "array", + items: { + type: "object", + properties: { + id_delivery_assignment: { type: "integer", example: 1 }, + assignment_status: { + type: "string", + enum: ["PENDING", "ACCEPTED", "REJECTED", "DELIVERED"], + example: "DELIVERED" + }, + assignment_sequence: { type: "integer", example: 1 }, + created_at: { type: "string", format: "date-time" }, + order: { + type: "object", + properties: { + id_order: { type: "integer", example: 100 }, + order_status: { type: "string", example: "DELIVERED" }, + total: { type: "number", example: 150000 }, + shipping_cost: { type: "number", example: 10000 }, + created_at: { type: "string", format: "date-time" }, + user: { + type: "object", + properties: { + id_user: { type: "integer", example: 1 }, + name: { type: "string", example: "Juan Pérez" } + } + }, + store: { + type: "object", + properties: { + id_store: { type: "integer", example: 1 }, + name: { type: "string", example: "Mi Comercio" } + } + } + } + } + } + } + }, + total_elements: { type: "integer", example: 50 }, + total_pages: { type: "integer", example: 3 }, + page: { type: "integer", example: 1 }, + size: { type: "integer", example: 20 } + } + }, + QueryValidationErrorResponse: { + type: "object", + properties: { + error: { + type: "object", + properties: { + code: { type: "integer", example: 400 }, + message: { type: "string", example: "Datos inválidos" }, + details: { + type: "array", + items: { + type: "object", + properties: { + field: { type: "string", example: "period" }, + message: { type: "string", example: "Invalid enum value" } + } + } + } + } + } + } } }; \ No newline at end of file diff --git a/src/modules/delivery/delivery-assignments/delivery-assignments.controller.js b/src/modules/delivery/delivery-assignments/delivery-assignments.controller.js index c675fba..631aa11 100644 --- a/src/modules/delivery/delivery-assignments/delivery-assignments.controller.js +++ b/src/modules/delivery/delivery-assignments/delivery-assignments.controller.js @@ -1,6 +1,6 @@ //delivery.assignments.controller.js import { ZodError } from 'zod'; -import { createAssignmentSchema, respondToAssignmentSchema } from './delivery-assignments.validation.js'; +import { createAssignmentSchema, respondToAssignmentSchema, deliveryOrderHistoryQuerySchema } from './delivery-assignments.validation.js'; import { createAssignmentService, getAssignmentByIdService, @@ -10,6 +10,7 @@ import { getAcceptedAssignmentService, completeAssignmentService, respondToAssignmentService, + getDeliveryOrderHistoryService, } from './delivery-assignments.service.js'; // Crear asignación @@ -182,4 +183,27 @@ export const respondToAssignment = async (req, res) => { error: { code: error.status || 500, message: error.message } }); } +}; + +export const getDeliveryOrderHistory = async (req, res) => { + try { + const { id } = req.params; + const validFilters = deliveryOrderHistoryQuerySchema.parse(req.query); + const result = await getDeliveryOrderHistoryService( + id, + req.user.id_user, + validFilters, + req.pagination + ); + return res.status(200).json(result); + } catch (error) { + if (error instanceof ZodError) { + return res.status(400).json({ + error: { code: 400, message: "Datos inválidos", details: error.issues } + }); + } + return res.status(error.status || 500).json({ + error: { code: error.status || 500, message: error.message } + }); + } }; \ No newline at end of file diff --git a/src/modules/delivery/delivery-assignments/delivery-assignments.routes.js b/src/modules/delivery/delivery-assignments/delivery-assignments.routes.js index 4495d17..42d9510 100644 --- a/src/modules/delivery/delivery-assignments/delivery-assignments.routes.js +++ b/src/modules/delivery/delivery-assignments/delivery-assignments.routes.js @@ -12,7 +12,9 @@ import { getAcceptedAssignment, completeAssignment, respondToAssignment, + getDeliveryOrderHistory, } from "./delivery-assignments.controller.js"; +import { parsePagination } from "../../../middlewares/pagination.middleware.js"; const router = Router(); @@ -96,4 +98,88 @@ router.post("/:id/complete", authenticate, requireRole(ROLES.DELIVERY), complete */ router.post("/orders/:orderId/delivery-response", authenticate, requireRole(ROLES.DELIVERY), respondToAssignment); +/** + * @swagger + * /api/assignments/{id}/orders: + * get: + * summary: Historial de pedidos del delivery + * description: Retorna el historial de pedidos asignados al delivery con filtros opcionales de período, estado, ID de pedido y nombre de usuario. + * tags: [Deliveries] + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: ID del delivery + * - in: query + * name: period + * schema: + * type: string + * enum: [7d, 15d, 1m, all] + * description: Período de tiempo a filtrar + * - in: query + * name: assignment_status + * schema: + * type: string + * enum: [PENDING, ACCEPTED, REJECTED, DELIVERED] + * description: Estado de la asignación + * - in: query + * name: orderId + * schema: + * type: integer + * description: ID del pedido a buscar + * - in: query + * name: userName + * schema: + * type: string + * description: Nombre del usuario que realizó el pedido + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * description: Página actual + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * description: Resultados por página (máximo 100) + * responses: + * 200: + * description: Historial de pedidos obtenido correctamente + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DeliveryOrderHistoryResponse' + * 400: + * description: Parámetros inválidos + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/QueryValidationErrorResponse' + * 401: + * description: No autenticado + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthErrorResponse' + * 403: + * description: No tiene permisos para ver este historial + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DeliveryResponseForbidden' + * 404: + * description: Delivery no encontrado + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DeliveryStatusNotFoundResponse' + */ +router.get("/:id/orders", authenticate, requireRole(ROLES.DELIVERY), parsePagination, getDeliveryOrderHistory); + export default router; \ No newline at end of file diff --git a/src/modules/delivery/delivery-assignments/delivery-assignments.service.js b/src/modules/delivery/delivery-assignments/delivery-assignments.service.js index 3ba3cdc..ce6c40d 100644 --- a/src/modules/delivery/delivery-assignments/delivery-assignments.service.js +++ b/src/modules/delivery/delivery-assignments/delivery-assignments.service.js @@ -1,6 +1,6 @@ //delivery-assignments.service.js import { prisma } from '../../../lib/prisma.js'; - + export const createAssignmentService = async (data) => { const { fk_order, fk_delivery: fk_delivery_input, status } = data; @@ -83,7 +83,7 @@ export const createAssignmentService = async (data) => { throw error; } }; - + // Obtener asignación por ID export const getAssignmentByIdService = async (id_delivery_assignment) => { const assignment = await prisma.deliveryAssignments.findUnique({ @@ -97,23 +97,23 @@ export const getAssignmentByIdService = async (id_delivery_assignment) => { } } }); - + if (!assignment) { throw { status: 404, message: "Asignación no encontrada" }; } - + return assignment; }; - // Obtener asignaciones de un pedido +// Obtener asignaciones de un pedido export const getOrderAssignmentsService = async (id_order) => { const order = await prisma.orders.findUnique({ where: { id_order } }); - + if (!order) { throw { status: 404, message: "Pedido no encontrado" }; } - + const assignments = await prisma.deliveryAssignments.findMany({ where: { fk_order: id_order }, include: { @@ -123,7 +123,7 @@ export const getOrderAssignmentsService = async (id_order) => { }, orderBy: { assignment_sequence: 'asc' } }); - + return assignments; }; // Obtener asignaciones de un delivery @@ -131,16 +131,16 @@ export const getDeliveryAssignmentsService = async (id_delivery, status = null) const delivery = await prisma.deliveries.findUnique({ where: { id_delivery } }); - + if (!delivery) { throw { status: 404, message: "Delivery no encontrado" }; } - + const where = { fk_delivery: id_delivery }; if (status) { where.assignment_status = status; // "PENDING", "ACCEPTED", "REJECTED" } - + const assignments = await prisma.deliveryAssignments.findMany({ where, include: { @@ -150,19 +150,19 @@ export const getDeliveryAssignmentsService = async (id_delivery, status = null) }, orderBy: { created_at: 'desc' } }); - + return assignments; }; - // Obtener asignaciones PENDING de un delivery +// Obtener asignaciones PENDING de un delivery export const getDeliveryPendingAssignmentsService = async (id_delivery) => { const delivery = await prisma.deliveries.findUnique({ where: { id_delivery } }); - + if (!delivery) { throw { status: 404, message: "Delivery no encontrado" }; } - + const pendingAssignments = await prisma.deliveryAssignments.findMany({ where: { fk_delivery: id_delivery, @@ -175,19 +175,19 @@ export const getDeliveryPendingAssignmentsService = async (id_delivery) => { }, orderBy: { created_at: 'desc' } }); - + return pendingAssignments; }; - // Obtener la asignación aceptada de un pedido +// Obtener la asignación aceptada de un pedido export const getAcceptedAssignmentService = async (id_order) => { const order = await prisma.orders.findUnique({ where: { id_order } }); - + if (!order) { throw { status: 404, message: "Pedido no encontrado" }; } - + const acceptedAssignment = await prisma.deliveryAssignments.findFirst({ where: { fk_order: id_order, @@ -203,15 +203,15 @@ export const getAcceptedAssignmentService = async (id_order) => { } } }); - + if (!acceptedAssignment) { throw { status: 404, message: "No hay asignación aceptada para este pedido" }; } - + return acceptedAssignment; }; - + // Marcar asignación como entregada export const completeAssignmentService = async (id_delivery_assignment, id_user) => { const assignment = await prisma.deliveryAssignments.findUnique({ @@ -336,4 +336,107 @@ export const respondToAssignmentService = async (orderId, userId, action) => { } }); }); +}; + +export const getDeliveryOrderHistoryService = async (deliveryId, authenticatedUserId, filters, pagination) => { + const parsedDeliveryId = Number(deliveryId); + if (!Number.isInteger(parsedDeliveryId) || parsedDeliveryId <= 0) { + throw { status: 400, message: "ID de delivery inválido" }; + } + + const delivery = await prisma.deliveries.findUnique({ + where: { id_delivery: parsedDeliveryId }, + select: { id_delivery: true, fk_user: true, status: true } + }); + + if (!delivery || !delivery.status) { + throw { status: 404, message: "Delivery no encontrado" }; + } + + if (delivery.fk_user !== authenticatedUserId) { + throw { status: 403, message: "No tienes permiso para ver este historial" }; + } + + const { period, assignment_status, orderId, userName } = filters; + const { page, limit, skip } = pagination; + + // ─── FILTRO DE PERÍODO ──────────────────────────────────────────────────── + let dateFilter = undefined; + if (period && period !== "all") { + const now = new Date(); + const daysMap = { "7d": 7, "15d": 15, "1m": 30 }; + const days = daysMap[period]; + const from = new Date(now); + from.setDate(from.getDate() - days); + dateFilter = { gte: from }; + } + + // ─── WHERE DE ASIGNACIONES ──────────────────────────────────────────────── + const assignmentWhere = { + fk_delivery: parsedDeliveryId, + status: true, + ...(assignment_status && { assignment_status }), + ...(dateFilter && { created_at: dateFilter }), + ...(orderId !== undefined && { fk_order: orderId }), + ...(userName && { + order: { + user: { + name: { contains: userName, mode: "insensitive" } + } + } + }) + }; + + const [assignments, totalElements] = await prisma.$transaction([ + prisma.deliveryAssignments.findMany({ + where: assignmentWhere, + orderBy: { created_at: "desc" }, + skip, + take: limit, + select: { + id_delivery_assignment: true, + assignment_status: true, + assignment_sequence: true, + created_at: true, + order: { + select: { + id_order: true, + order_status: true, + total: true, + shipping_cost: true, + created_at: true, + user: { + select: { id_user: true, name: true } + }, + store: { + select: { id_store: true, name: true } + } + } + } + } + }), + prisma.deliveryAssignments.count({ where: assignmentWhere }) + ]); + + return { + content: assignments.map((a) => ({ + id_delivery_assignment: a.id_delivery_assignment, + assignment_status: a.assignment_status, + assignment_sequence: a.assignment_sequence, + created_at: a.created_at, + order: { + id_order: a.order.id_order, + order_status: a.order.order_status, + total: Number(a.order.total), + shipping_cost: Number(a.order.shipping_cost), + created_at: a.order.created_at, + user: a.order.user, + store: a.order.store + } + })), + total_elements: totalElements, + total_pages: Math.ceil(totalElements / limit), + page, + size: limit + }; }; \ No newline at end of file diff --git a/src/modules/delivery/delivery-assignments/delivery-assignments.validation.js b/src/modules/delivery/delivery-assignments/delivery-assignments.validation.js index d7cfe32..2cfa164 100644 --- a/src/modules/delivery/delivery-assignments/delivery-assignments.validation.js +++ b/src/modules/delivery/delivery-assignments/delivery-assignments.validation.js @@ -11,4 +11,15 @@ export const respondToAssignmentSchema = z.object({ action: z.enum(["ACCEPT", "REJECT"], { error: "action debe ser ACCEPT o REJECT" }) +}); + +export const deliveryOrderHistoryQuerySchema = z.object({ + period: z.enum(["7d", "15d", "1m", "all"]).optional(), + assignment_status: z.enum(["PENDING", "ACCEPTED", "REJECTED", "DELIVERED"]).optional(), + orderId: z + .string() + .regex(/^\d+$/, "orderId debe ser un número") + .transform(Number) + .optional(), + userName: z.string().min(1).optional() }); \ No newline at end of file diff --git a/tests/unit/delivery/delivery-assignments.test.js b/tests/unit/delivery/delivery-assignments.test.js index 09ab25f..b8141f5 100644 --- a/tests/unit/delivery/delivery-assignments.test.js +++ b/tests/unit/delivery/delivery-assignments.test.js @@ -4,6 +4,7 @@ import { prisma } from "../../../src/lib/prisma.js"; import { createAssignmentService, respondToAssignmentService, + getDeliveryOrderHistoryService, } from "../../../src/modules/delivery/delivery-assignments/delivery-assignments.service.js"; // ─── MOCK DE PRISMA ────────────────────────────────────────────────────────── @@ -33,9 +34,16 @@ vi.mock("../../../src/lib/prisma.js", () => ({ }, deliveryAssignments: { findFirst: vi.fn(), + findMany: vi.fn(), create: vi.fn(), + count: vi.fn(), }, - $transaction: vi.fn((callback) => callback(mockTx)), + $transaction: vi.fn((callbackOrArray) => { + if (typeof callbackOrArray === "function") { + return callbackOrArray(mockTx); + } + return Promise.all(callbackOrArray.map(p => Promise.resolve(p))); + }), }, })); @@ -65,6 +73,30 @@ const mockAssignmentPending = { order: { fk_store: 10 }, }; +const mockPagination = { page: 1, limit: 20, skip: 0 }; + +const mockAssignmentHistory = { + id_delivery_assignment: 1, + assignment_status: "DELIVERED", + assignment_sequence: 1, + created_at: "2026-01-01T00:00:00.000Z", + order: { + id_order: 100, + order_status: "DELIVERED", + total: "150000", + shipping_cost: "10000", + created_at: "2026-01-01T00:00:00.000Z", + user: { id_user: 1, name: "Juan Pérez" }, + store: { id_store: 10, name: "Mi Comercio" } + } +}; + +const mockDeliveryForHistory = { + id_delivery: 1, + fk_user: 5, + status: true +}; + // ─── createAssignmentService ────────────────────────────────────────────────── describe("createAssignmentService", () => { @@ -285,4 +317,196 @@ describe("respondToAssignmentService", () => { }) ); }); +}); + +// ─── getDeliveryOrderHistoryService ────────────────────────────────────────── + +describe("getDeliveryOrderHistoryService", () => { + beforeEach(() => { + vi.resetAllMocks(); + prisma.$transaction.mockImplementation((callbackOrArray) => { + if (typeof callbackOrArray === "function") { + return callbackOrArray(mockTx); + } + return Promise.all(callbackOrArray.map((p) => Promise.resolve(p))); + }); + }); + + // ─── VALIDACIONES ───────────────────────────────────────────────────────── + + it("lanza 400 cuando el ID de delivery es inválido", async () => { + await expect( + getDeliveryOrderHistoryService("abc", 5, {}, mockPagination) + ).rejects.toMatchObject({ status: 400 }); + }); + + it("lanza 404 cuando el delivery no existe", async () => { + prisma.deliveries.findUnique.mockResolvedValue(null); + + await expect( + getDeliveryOrderHistoryService(1, 5, {}, mockPagination) + ).rejects.toMatchObject({ status: 404, message: /delivery no encontrado/i }); + }); + + it("lanza 403 cuando el delivery no pertenece al usuario autenticado", async () => { + prisma.deliveries.findUnique.mockResolvedValue({ + ...mockDeliveryForHistory, + fk_user: 99 + }); + + await expect( + getDeliveryOrderHistoryService(1, 5, {}, mockPagination) + ).rejects.toMatchObject({ status: 403 }); + }); + + // ─── RESPUESTA BASE ─────────────────────────────────────────────────────── + + it("retorna estructura de paginación correcta", async () => { + prisma.deliveries.findUnique.mockResolvedValue(mockDeliveryForHistory); + prisma.$transaction.mockResolvedValue([[mockAssignmentHistory], 1]); + + const result = await getDeliveryOrderHistoryService(1, 5, {}, mockPagination); + + expect(result).toMatchObject({ + total_elements: 1, + total_pages: 1, + page: 1, + size: 20 + }); + expect(Array.isArray(result.content)).toBe(true); + }); + + it("retorna contenido con los campos esperados", async () => { + prisma.deliveries.findUnique.mockResolvedValue(mockDeliveryForHistory); + prisma.$transaction.mockResolvedValue([[mockAssignmentHistory], 1]); + + const result = await getDeliveryOrderHistoryService(1, 5, {}, mockPagination); + const item = result.content[0]; + + expect(item).toHaveProperty("id_delivery_assignment"); + expect(item).toHaveProperty("assignment_status"); + expect(item).toHaveProperty("assignment_sequence"); + expect(item).toHaveProperty("created_at"); + expect(item.order).toHaveProperty("id_order"); + expect(item.order).toHaveProperty("total"); + expect(item.order).toHaveProperty("user"); + expect(item.order).toHaveProperty("store"); + }); + + // ─── FILTROS ────────────────────────────────────────────────────────────── + + it("filtra correctamente por period 7d", async () => { + prisma.deliveries.findUnique.mockResolvedValue(mockDeliveryForHistory); + prisma.deliveryAssignments.findMany.mockResolvedValue([mockAssignmentHistory]); + prisma.deliveryAssignments.count.mockResolvedValue(1); + + await getDeliveryOrderHistoryService(1, 5, { period: "7d" }, mockPagination); + + expect(prisma.deliveryAssignments.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + created_at: expect.objectContaining({ gte: expect.any(Date) }) + }) + }) + ); + }); + + it("filtra correctamente por period 15d", async () => { + prisma.deliveries.findUnique.mockResolvedValue(mockDeliveryForHistory); + prisma.deliveryAssignments.findMany.mockResolvedValue([mockAssignmentHistory]); + prisma.deliveryAssignments.count.mockResolvedValue(1); + + await getDeliveryOrderHistoryService(1, 5, { period: "15d" }, mockPagination); + + expect(prisma.deliveryAssignments.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + created_at: expect.objectContaining({ gte: expect.any(Date) }) + }) + }) + ); + }); + + it("filtra correctamente por period 1m", async () => { + prisma.deliveries.findUnique.mockResolvedValue(mockDeliveryForHistory); + prisma.deliveryAssignments.findMany.mockResolvedValue([mockAssignmentHistory]); + prisma.deliveryAssignments.count.mockResolvedValue(1); + + await getDeliveryOrderHistoryService(1, 5, { period: "1m" }, mockPagination); + + expect(prisma.deliveryAssignments.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + created_at: expect.objectContaining({ gte: expect.any(Date) }) + }) + }) + ); + }); + + it("filtra correctamente por assignment_status", async () => { + prisma.deliveries.findUnique.mockResolvedValue(mockDeliveryForHistory); + prisma.deliveryAssignments.findMany.mockResolvedValue([mockAssignmentHistory]); + prisma.deliveryAssignments.count.mockResolvedValue(1); + + await getDeliveryOrderHistoryService(1, 5, { assignment_status: "DELIVERED" }, mockPagination); + + expect(prisma.deliveryAssignments.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ assignment_status: "DELIVERED" }) + }) + ); + }); + + it("filtra correctamente por orderId", async () => { + prisma.deliveries.findUnique.mockResolvedValue(mockDeliveryForHistory); + prisma.deliveryAssignments.findMany.mockResolvedValue([mockAssignmentHistory]); + prisma.deliveryAssignments.count.mockResolvedValue(1); + + await getDeliveryOrderHistoryService(1, 5, { orderId: 100 }, mockPagination); + + expect(prisma.deliveryAssignments.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ fk_order: 100 }) + }) + ); + }); + + it("filtra correctamente por userName", async () => { + prisma.deliveries.findUnique.mockResolvedValue(mockDeliveryForHistory); + prisma.deliveryAssignments.findMany.mockResolvedValue([mockAssignmentHistory]); + prisma.deliveryAssignments.count.mockResolvedValue(1); + + await getDeliveryOrderHistoryService(1, 5, { userName: "Juan" }, mockPagination); + + expect(prisma.deliveryAssignments.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + order: { user: { name: { contains: "Juan", mode: "insensitive" } } } + }) + }) + ); + }); + + it("sin filtros retorna todos los registros", async () => { + prisma.deliveries.findUnique.mockResolvedValue(mockDeliveryForHistory); + prisma.deliveryAssignments.findMany.mockResolvedValue([mockAssignmentHistory]); + prisma.deliveryAssignments.count.mockResolvedValue(1); + + await getDeliveryOrderHistoryService(1, 5, {}, mockPagination); + + expect(prisma.deliveryAssignments.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.not.objectContaining({ + created_at: expect.anything() + }) + }) + ); + expect(prisma.deliveryAssignments.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.not.objectContaining({ + assignment_status: expect.anything() + }) + }) + ); + }); }); \ No newline at end of file