feat: Implement Phase 8 Delivery and Rider Management
CI / test (push) Successful in 10m26s

- Introduced new Delivery and Rider API documentation.
- Added new models for RiderProfile, Shipment, ShipmentItem, ShipmentAssignment, ShipmentEvent, and ShipmentProof.
- Developed controllers for admin and rider logistics, including shipment management and rider actions.
- Created services for handling shipment creation, assignment, and state transitions.
- Implemented validation schemas for shipment and rider operations.
- Added new routes for admin and rider logistics, including tracking endpoints.
- Established a state machine for shipment status transitions.
- Created migration scripts for new database tables and relationships.
- Added unit tests for shipment state transitions and validation security.
This commit is contained in:
Sathira Sri Sathara
2026-09-09 12:55:32 +05:30
parent cd2c1c6d08
commit 222483d194
24 changed files with 168 additions and 0 deletions
@@ -0,0 +1,9 @@
const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),state=require("./shipmentState.service"),{nextSequence,pad}=require("../../utils/referenceNumber.util");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s}),activeAssignments=["ASSIGNED","ACCEPTED"];
const event=async({shipment,eventId,type,status,actorUserId,note,latitude,longitude},transaction)=>db.ShipmentEvent.create({id:crypto.randomUUID(),shipment_id:shipment.id,event_id:eventId,type,status,actor_user_id:actorUserId,note,latitude,longitude,occurred_at:new Date()},{transaction});
async function createDelivery({orderId,items,actorUserId,eventId}){return db.sequelize.transaction(async transaction=>{const prior=await db.ShipmentEvent.findOne({where:{event_id:eventId},transaction});if(prior)return{shipment:await db.Shipment.findByPk(prior.shipment_id,{transaction}),idempotent:true};const order=await db.Order.findOne({where:{id:orderId,status:{[Op.in]:["PAID","PROCESSING","READY_FOR_FULFILLMENT"]},payment_status:"PAID"},include:[{model:db.OrderItem,as:"items"}],transaction,lock:transaction.LOCK.UPDATE});if(!order)throw fault("Order is not eligible for fulfillment","ORDER_NOT_FULFILLABLE",409);const allocations=[];for(const requested of items){const item=order.items.find(x=>x.id===requested.orderItemId);if(!item||!Number.isInteger(requested.quantity)||requested.quantity<=0)throw fault("Invalid shipment item","INVALID_SHIPMENT_ITEM");const shipped=await db.ShipmentItem.sum("quantity",{where:{order_item_id:item.id},include:[{model:db.Shipment,as:"shipment",where:{status:{[Op.ne]:"CANCELLED"}},attributes:[]}],transaction})||0;if(Number(shipped)+requested.quantity>item.quantity)throw fault("Shipment quantity exceeds purchased quantity","SHIPMENT_OVERALLOCATED",409);allocations.push(requested);}const a=order.shipping_address_snapshot||{},method=order.shipping_method_snapshot||{},shipment=await db.Shipment.create({id:crypto.randomUUID(),shipment_number:`SHP-${new Date().getUTCFullYear()}-${pad(await nextSequence("shipments",transaction),6)}`,order_id:order.id,type:"CUSTOMER_DELIVERY",status:"READY_FOR_ASSIGNMENT",shipping_method_code:method.code,shipping_method_name:method.name,recipient_name:a.recipientName,recipient_phone:a.phone,address_snapshot:a},{transaction});await db.ShipmentItem.bulkCreate(allocations.map(x=>({id:crypto.randomUUID(),shipment_id:shipment.id,order_item_id:x.orderItemId,quantity:x.quantity})),{transaction});await event({shipment,eventId,type:"SHIPMENT_CREATED",status:shipment.status,actorUserId},transaction);await order.update({status:"READY_FOR_FULFILLMENT"},{transaction});return{shipment,idempotent:false};});}
async function createReturnPickup({returnRequestId,actorUserId,eventId}){return db.sequelize.transaction(async transaction=>{const request=await db.ReturnRequest.findOne({where:{id:returnRequestId,status:{[Op.in]:["APPROVED","AWAITING_RETURN"]}},transaction,lock:transaction.LOCK.UPDATE});if(!request)throw fault("Return is not ready for pickup","RETURN_NOT_READY",409);const existing=await db.Shipment.findOne({where:{return_request_id:request.id,type:"RETURN_PICKUP"},transaction,lock:transaction.LOCK.UPDATE});if(existing)return{shipment:existing,idempotent:true};const order=await db.Order.findByPk(request.order_id,{transaction}),a=order.shipping_address_snapshot||{},shipment=await db.Shipment.create({id:crypto.randomUUID(),shipment_number:`SHP-${new Date().getUTCFullYear()}-${pad(await nextSequence("shipments",transaction),6)}`,return_request_id:request.id,order_id:order.id,type:"RETURN_PICKUP",status:"READY_FOR_ASSIGNMENT",recipient_name:a.recipientName,recipient_phone:a.phone,address_snapshot:a},{transaction});await request.update({status:"AWAITING_RETURN"},{transaction});await event({shipment,eventId,type:"RETURN_PICKUP_CREATED",status:shipment.status,actorUserId},transaction);return{shipment,idempotent:false};});}
async function assign({shipmentId,riderId,actorUserId,eventId}){return db.sequelize.transaction(async transaction=>{const shipment=await db.Shipment.findByPk(shipmentId,{transaction,lock:transaction.LOCK.UPDATE});if(!shipment)throw fault("Shipment not found","NOT_FOUND",404);if(shipment.assigned_rider_id===riderId&&shipment.status==="ASSIGNED")return{shipment,idempotent:true};if(!["READY_FOR_ASSIGNMENT","RESCHEDULED"].includes(shipment.status))throw fault("Shipment is not assignable","SHIPMENT_NOT_ASSIGNABLE",409);const active=await db.ShipmentAssignment.findOne({where:{shipment_id:shipment.id,status:{[Op.in]:activeAssignments}},transaction,lock:transaction.LOCK.UPDATE});if(active)throw fault("Shipment already has an active rider","ACTIVE_ASSIGNMENT_EXISTS",409);const rider=await db.RiderProfile.findOne({where:{id:riderId,status:"ACTIVE",availability_status:{[Op.ne]:"OFFLINE"}},transaction,lock:transaction.LOCK.UPDATE});if(!rider)throw fault("Rider is unavailable","RIDER_UNAVAILABLE",409);const count=await db.ShipmentAssignment.count({where:{rider_id:rider.id,status:{[Op.in]:activeAssignments}},transaction});if(count>=rider.max_active_assignments)throw fault("Rider capacity reached","RIDER_CAPACITY",409);await db.ShipmentAssignment.create({id:crypto.randomUUID(),shipment_id:shipment.id,rider_id:rider.id,status:"ASSIGNED",assigned_by:actorUserId,assigned_at:new Date()},{transaction});await shipment.update({status:"ASSIGNED",assigned_rider_id:rider.id,assigned_at:new Date()},{transaction});await rider.update({availability_status:count+1>=rider.max_active_assignments?"BUSY":rider.availability_status},{transaction});await event({shipment,eventId,type:"RIDER_ASSIGNED",status:"ASSIGNED",actorUserId},transaction);return{shipment,idempotent:false};});}
async function unassign({shipmentId,actorUserId,eventId,reason}){return db.sequelize.transaction(async transaction=>{const shipment=await db.Shipment.findByPk(shipmentId,{transaction,lock:transaction.LOCK.UPDATE});if(!shipment||!["ASSIGNED","PICKUP_PENDING"].includes(shipment.status))throw fault("Shipment cannot be unassigned","SHIPMENT_NOT_ASSIGNABLE",409);const assignment=await db.ShipmentAssignment.findOne({where:{shipment_id:shipment.id,status:{[Op.in]:activeAssignments}},transaction,lock:transaction.LOCK.UPDATE});if(assignment)await assignment.update({status:"UNASSIGNED",unassigned_at:new Date(),reason},{transaction});state.assertTransition(shipment.status,"READY_FOR_ASSIGNMENT");await shipment.update({status:"READY_FOR_ASSIGNMENT",assigned_rider_id:null},{transaction});if(assignment)await db.RiderProfile.update({availability_status:"AVAILABLE"},{where:{id:assignment.rider_id},transaction});await event({shipment,eventId,type:"RIDER_UNASSIGNED",status:shipment.status,actorUserId,note:reason},transaction);return shipment;});}
async function riderAction({shipmentId,userId,to,eventId,note,proof}){return db.sequelize.transaction(async transaction=>{const rider=await db.RiderProfile.findOne({where:{user_id:userId,status:"ACTIVE"},transaction,lock:transaction.LOCK.UPDATE});if(!rider)throw fault("Active rider profile required","RIDER_UNAVAILABLE",403);const shipment=await db.Shipment.findOne({where:{id:shipmentId,assigned_rider_id:rider.id},transaction,lock:transaction.LOCK.UPDATE});if(!shipment)throw fault("Assigned shipment not found","NOT_FOUND",404);const prior=await db.ShipmentEvent.findOne({where:{event_id:eventId},transaction});if(prior)return{shipment,idempotent:true};state.assertTransition(shipment.status,to);if(proof){for(const uploadId of [proof.photoUploadId,proof.signatureUploadId].filter(Boolean)){const upload=await db.Upload.findOne({where:{id:uploadId,uploaded_by:userId,status:"AVAILABLE"},transaction});if(!upload||!String(upload.file_type).startsWith("image/"))throw fault("Owned available proof image required","INVALID_PROOF",400);}await db.ShipmentProof.create({id:crypto.randomUUID(),shipment_id:shipment.id,event_id:`proof:${eventId}`,type:proof.type,recipient_name:proof.recipientName,photo_upload_id:proof.photoUploadId,signature_upload_id:proof.signatureUploadId,notes:proof.notes,captured_at:new Date(),captured_by:userId},{transaction});}const fields={status:to,...(to==="PICKED_UP"&&{picked_up_at:new Date()}),...(to==="OUT_FOR_DELIVERY"&&{out_for_delivery_at:new Date()}),...(to==="DELIVERED"&&{delivered_at:new Date()}),...(to==="DELIVERY_FAILED"&&{failed_at:new Date(),delivery_attempt_count:shipment.delivery_attempt_count+1})};await shipment.update(fields,{transaction});await event({shipment,eventId,type:`SHIPMENT_${to}`,status:to,actorUserId:userId,note},transaction);const assignment=await db.ShipmentAssignment.findOne({where:{shipment_id:shipment.id,status:{[Op.in]:activeAssignments}},transaction,lock:transaction.LOCK.UPDATE});if(to==="DELIVERED"&&assignment){await assignment.update({status:"COMPLETED",completed_at:new Date()},{transaction});await rider.update({availability_status:"AVAILABLE"},{transaction});if(shipment.type==="RETURN_PICKUP")await db.ReturnRequest.update({status:"RECEIVED",received_at:new Date()},{where:{id:shipment.return_request_id,status:{[Op.in]:["APPROVED","AWAITING_RETURN"]}},transaction});else await recalculateOrder(shipment.order_id,transaction);}return{shipment,idempotent:false};});}
async function recalculateOrder(orderId,transaction){const order=await db.Order.findByPk(orderId,{include:[{model:db.OrderItem,as:"items"}],transaction,lock:transaction.LOCK.UPDATE}),delivered=await db.ShipmentItem.findAll({include:[{model:db.Shipment,as:"shipment",where:{order_id:orderId,status:"DELIVERED"},attributes:[]}],transaction}),total=order.items.reduce((n,x)=>n+x.quantity,0),done=delivered.reduce((n,x)=>n+x.quantity,0),fulfillment=done===0?"UNFULFILLED":done>=total?"FULFILLED":"PARTIALLY_FULFILLED";await order.update({fulfillment_status:fulfillment,...(fulfillment==="FULFILLED"&&{status:"FULFILLED"})},{transaction});}
module.exports={createDelivery,createReturnPickup,assign,unassign,riderAction,recalculateOrder,activeAssignments};
@@ -0,0 +1 @@
const transitions={PENDING:new Set(["READY_FOR_ASSIGNMENT","CANCELLED"]),READY_FOR_ASSIGNMENT:new Set(["ASSIGNED","CANCELLED"]),ASSIGNED:new Set(["PICKUP_PENDING","READY_FOR_ASSIGNMENT","CANCELLED"]),PICKUP_PENDING:new Set(["PICKED_UP","READY_FOR_ASSIGNMENT","CANCELLED"]),PICKED_UP:new Set(["IN_TRANSIT"]),IN_TRANSIT:new Set(["OUT_FOR_DELIVERY","DELIVERED"]),OUT_FOR_DELIVERY:new Set(["DELIVERED","DELIVERY_FAILED"]),DELIVERY_FAILED:new Set(["RESCHEDULED","CANCELLED"]),RESCHEDULED:new Set(["ASSIGNED","OUT_FOR_DELIVERY","CANCELLED"]),DELIVERED:new Set(),CANCELLED:new Set()};const canTransition=(from,to)=>Boolean(transitions[from]?.has(to));const assertTransition=(from,to)=>{if(!canTransition(from,to))throw Object.assign(new Error(`Illegal shipment transition ${from} -> ${to}`),{code:"ILLEGAL_SHIPMENT_TRANSITION",status:409});};module.exports={transitions,canTransition,assertTransition};