- 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:
@@ -26,4 +26,5 @@ module.exports = {
|
||||
BUSINESS_PRICING_READ:"pricing.business.read",BUSINESS_PRICING_MANAGE:"pricing.business.manage",PROMOTIONS_READ:"promotions.read",PROMOTIONS_MANAGE:"promotions.manage",BANNERS_MANAGE:"merchandising.banners.manage",
|
||||
SHIPPING_ZONES_READ:"shipping.zones.read",SHIPPING_ZONES_MANAGE:"shipping.zones.manage",SHIPPING_METHODS_READ:"shipping.methods.read",SHIPPING_METHODS_MANAGE:"shipping.methods.manage",SHIPPING_RATES_READ:"shipping.rates.read",SHIPPING_RATES_MANAGE:"shipping.rates.manage",
|
||||
ORDERS_READ:"orders.read",ORDERS_MANAGE:"orders.manage",ORDERS_CANCEL:"orders.cancel",PAYMENTS_READ:"payments.read",PAYMENTS_MANAGE:"payments.manage",PAYMENTS_REFUND:"payments.refund",INVOICES_READ:"invoices.read",RETURNS_READ:"returns.read",RETURNS_MANAGE:"returns.manage",
|
||||
SHIPMENTS_READ:"shipments.read",SHIPMENTS_CREATE:"shipments.create",SHIPMENTS_MANAGE:"shipments.manage",SHIPMENTS_ASSIGN:"shipments.assign",RIDERS_READ:"riders.read",RIDERS_MANAGE:"riders.manage",DISPATCH_READ:"dispatch.read",DISPATCH_MANAGE:"dispatch.manage",DELIVERY_PROOF_READ:"delivery.proof.read",RETURNS_LOGISTICS_READ:"returns.logistics.read",RETURNS_LOGISTICS_MANAGE:"returns.logistics.manage",
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),service=require("../../services/logistics/shipment.service"),state=require("../../services/logistics/shipmentState.service"),{logActivity}=require("../../services/activity.service");const key=req=>req.get("Idempotency-Key")||crypto.randomUUID(),audit=(req,type,id)=>logActivity({user:req.user,type,module:"Logistics",targetType:"SHIPMENT",targetId:id,requestId:req.id});exports.shipments=async(req,res,next)=>{try{const where={};if(req.query.status)where.status=req.query.status;res.json({success:true,data:await db.Shipment.findAll({where,include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentAssignment,as:"assignments"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.shipment=async(req,res,next)=>{try{const row=await db.Shipment.findByPk(req.params.id,{include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentAssignment,as:"assignments"},{model:db.ShipmentEvent,as:"events"},{model:db.ShipmentProof,as:"proofs"}]});if(!row)throw Object.assign(new Error("Shipment not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.create=async(req,res,next)=>{try{const result=await service.createDelivery({...req.body,actorUserId:req.user.id,eventId:`create:${key(req)}`});await audit(req,"SHIPMENT_CREATED",result.shipment.id);res.status(result.idempotent?200:201).json({success:true,data:result.shipment});}catch(e){next(e);}};exports.returnPickup=async(req,res,next)=>{try{const result=await service.createReturnPickup({...req.body,actorUserId:req.user.id,eventId:`return-pickup:${key(req)}`});res.status(result.idempotent?200:201).json({success:true,data:result.shipment});}catch(e){next(e);}};exports.assign=async(req,res,next)=>{try{const result=await service.assign({shipmentId:req.params.id,riderId:req.body.riderId,actorUserId:req.user.id,eventId:`assign:${key(req)}`});await audit(req,"RIDER_ASSIGNED",result.shipment.id);res.json({success:true,data:result.shipment});}catch(e){next(e);}};exports.unassign=async(req,res,next)=>{try{const row=await service.unassign({shipmentId:req.params.id,actorUserId:req.user.id,eventId:`unassign:${key(req)}`,reason:req.body.note});await audit(req,"RIDER_UNASSIGNED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.reschedule=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Shipment.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Shipment not found"),{status:404,code:"NOT_FOUND"});state.assertTransition(row.status,"RESCHEDULED");await row.update({status:"RESCHEDULED",scheduled_date:req.body.scheduledDate,time_window_start:req.body.timeWindowStart,time_window_end:req.body.timeWindowEnd,notes:req.body.reason},{transaction:t});await db.ShipmentEvent.create({id:crypto.randomUUID(),shipment_id:row.id,event_id:`reschedule:${key(req)}`,type:"SHIPMENT_RESCHEDULED",status:"RESCHEDULED",actor_user_id:req.user.id,note:req.body.reason,occurred_at:new Date()},{transaction:t});});await audit(req,"SHIPMENT_RESCHEDULED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Shipment.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});state.assertTransition(row.status,"CANCELLED");await row.update({status:"CANCELLED",cancelled_at:new Date()},{transaction:t});});await audit(req,"SHIPMENT_CANCELLED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.dispatch=async(req,res,next)=>{try{res.json({success:true,data:{unassigned:await db.Shipment.findAll({where:{status:"READY_FOR_ASSIGNMENT"}}),active:await db.Shipment.findAll({where:{status:{[Op.in]:["ASSIGNED","PICKUP_PENDING","PICKED_UP","IN_TRANSIT","OUT_FOR_DELIVERY"]}}}),attention:await db.Shipment.findAll({where:{status:{[Op.in]:["DELIVERY_FAILED","RESCHEDULED"]}}})}});}catch(e){next(e);}};
|
||||
@@ -0,0 +1,4 @@
|
||||
const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),service=require("../../services/logistics/shipment.service"),{logActivity}=require("../../services/activity.service");const key=req=>req.get("Idempotency-Key")||crypto.randomUUID();exports.list=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}});if(!rider)throw Object.assign(new Error("Active rider required"),{status:403,code:"RIDER_UNAVAILABLE"});res.json({success:true,data:await db.Shipment.findAll({where:{assigned_rider_id:rider.id},include:[{model:db.ShipmentItem,as:"items"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}}),row=rider&&await db.Shipment.findOne({where:{id:req.params.id,assigned_rider_id:rider.id},include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentEvent,as:"events"}]});if(!row)throw Object.assign(new Error("Assigned shipment not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:{id:row.id,shipmentNumber:row.shipment_number,recipientName:row.recipient_name,recipientPhone:row.recipient_phone,address:row.address_snapshot,status:row.status,items:row.items,events:row.events}});}catch(e){next(e);}};const action=to=>async(req,res,next)=>{try{const result=await service.riderAction({shipmentId:req.params.id,userId:req.user.id,to,eventId:`${to}:${key(req)}`,note:req.body.note,proof:req.body.type?req.body:undefined});await logActivity({user:req.user,type:`SHIPMENT_${to}`,module:"Logistics",targetId:result.shipment.id,requestId:req.id});res.json({success:true,data:result.shipment});}catch(e){next(e);}};exports.accept=action("PICKUP_PENDING");exports.pickup=action("PICKED_UP");exports.inTransit=action("IN_TRANSIT");exports.outForDelivery=action("OUT_FOR_DELIVERY");exports.deliver=action("DELIVERED");exports.fail=action("DELIVERY_FAILED");exports.location=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}});if(!rider)throw Object.assign(new Error("Active rider required"),{status:403,code:"RIDER_UNAVAILABLE"});await rider.update({current_latitude:req.body.latitude,current_longitude:req.body.longitude,last_location_at:new Date()});res.status(204).end();}catch(e){next(e);}};
|
||||
exports.reject=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}}),shipment=rider&&await db.Shipment.findOne({where:{id:req.params.id,assigned_rider_id:rider.id}});if(!shipment)throw Object.assign(new Error("Assigned shipment not found"),{status:404,code:"NOT_FOUND"});const row=await service.unassign({shipmentId:shipment.id,actorUserId:req.user.id,eventId:`reject:${key(req)}`,reason:req.body.note});await db.ShipmentAssignment.update({status:"REJECTED",rejected_at:new Date()},{where:{shipment_id:shipment.id,rider_id:rider.id,status:"UNASSIGNED"}});res.json({success:true,data:row});}catch(e){next(e);}};
|
||||
exports.adminUpdate=async(req,res,next)=>{try{const row=await db.RiderProfile.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Rider not found"),{status:404,code:"NOT_FOUND"});const b=req.body;await row.update({employee_code:b.employeeCode,status:b.status,availability_status:b.availabilityStatus,phone_override:b.phoneOverride,vehicle_type:b.vehicleType,vehicle_registration:b.vehicleRegistration,max_active_assignments:b.maxActiveAssignments});await logActivity({user:req.user,type:"RIDER_UPDATED",module:"Logistics",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};
|
||||
exports.adminList=async(req,res,next)=>{try{res.json({success:true,data:await db.RiderProfile.findAll({include:[{model:db.User,as:"user",attributes:["id","firstName","lastName","accountStatus"]}]})});}catch(e){next(e);}};exports.adminGet=async(req,res,next)=>{try{const row=await db.RiderProfile.findByPk(req.params.id,{include:[{model:db.User,as:"user",attributes:["id","firstName","lastName","accountStatus"]}]});if(!row)throw Object.assign(new Error("Rider not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.adminCreate=async(req,res,next)=>{try{const user=await db.User.findOne({where:{id:req.body.userId,accountType:"rider"}});if(!user)throw Object.assign(new Error("Existing RIDER user required"),{status:400,code:"RIDER_USER_REQUIRED"});const b=req.body,row=await db.RiderProfile.create({id:crypto.randomUUID(),user_id:user.id,employee_code:b.employeeCode,status:b.status,availability_status:b.availabilityStatus,phone_override:b.phoneOverride,vehicle_type:b.vehicleType,vehicle_registration:b.vehicleRegistration,max_active_assignments:b.maxActiveAssignments});await logActivity({user:req.user,type:"RIDER_CREATED",module:"Logistics",targetId:row.id,requestId:req.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.assignments=async(req,res,next)=>{try{res.json({success:true,data:await db.ShipmentAssignment.findAll({where:{rider_id:req.params.id},order:[["assigned_at","DESC"]]})});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../../models");const project=s=>({shipmentNumber:s.shipment_number,type:s.type,status:s.status,shippingMethod:s.shipping_method_name,deliveredAt:s.delivered_at,events:s.events.map(e=>({type:e.type,status:e.status,note:e.note,occurredAt:e.occurred_at}))});exports.order=async(req,res,next)=>{try{const order=await db.Order.findOne({where:{id:req.params.orderId,user_id:req.user.id}});if(!order)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});const shipments=await db.Shipment.findAll({where:{order_id:order.id,type:"CUSTOMER_DELIVERY"},include:[{model:db.ShipmentEvent,as:"events",attributes:["type","status","note","occurred_at"]}],order:[["createdAt","ASC"]]});res.json({success:true,data:{orderNumber:order.order_number,shipments:shipments.map(project)}});}catch(e){next(e);}};exports.return=async(req,res,next)=>{try{const request=await db.ReturnRequest.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!request)throw Object.assign(new Error("Return not found"),{status:404,code:"NOT_FOUND"});const shipment=await db.Shipment.findOne({where:{return_request_id:request.id,type:"RETURN_PICKUP"},include:[{model:db.ShipmentEvent,as:"events",attributes:["type","status","note","occurred_at"]}]});res.json({success:true,data:{returnNumber:request.return_number,shipment:shipment&&project(shipment)}});}catch(e){next(e);}};
|
||||
@@ -96,6 +96,7 @@ db.Promotion=require("./merchandising/promotion.model")(sequelize,DataTypes);db.
|
||||
db.Cart=require("./shopping/cart.model")(sequelize,DataTypes);db.CartItem=require("./shopping/cartItem.model")(sequelize,DataTypes);db.WishlistItem=require("./shopping/wishlistItem.model")(sequelize,DataTypes);db.CheckoutSession=require("./shopping/checkoutSession.model")(sequelize,DataTypes);db.CheckoutItem=require("./shopping/checkoutItem.model")(sequelize,DataTypes);
|
||||
db.ShippingZone=require("./shipping/shippingZone.model")(sequelize,DataTypes);db.ShippingZoneRegion=require("./shipping/shippingZoneRegion.model")(sequelize,DataTypes);db.ShippingMethod=require("./shipping/shippingMethod.model")(sequelize,DataTypes);db.ShippingRate=require("./shipping/shippingRate.model")(sequelize,DataTypes);
|
||||
db.Order=require("./commerce/order.model")(sequelize,DataTypes);db.OrderItem=require("./commerce/orderItem.model")(sequelize,DataTypes);db.Payment=require("./commerce/payment.model")(sequelize,DataTypes);db.PaymentAttempt=require("./commerce/paymentAttempt.model")(sequelize,DataTypes);db.PaymentWebhookEvent=require("./commerce/paymentWebhookEvent.model")(sequelize,DataTypes);db.Invoice=require("./commerce/invoice.model")(sequelize,DataTypes);db.Refund=require("./commerce/refund.model")(sequelize,DataTypes);db.RefundItem=require("./commerce/refundItem.model")(sequelize,DataTypes);db.ReturnRequest=require("./commerce/returnRequest.model")(sequelize,DataTypes);db.ReturnItem=require("./commerce/returnItem.model")(sequelize,DataTypes);db.CouponRedemption=require("./commerce/couponRedemption.model")(sequelize,DataTypes);
|
||||
db.RiderProfile=require("./logistics/riderProfile.model")(sequelize,DataTypes);db.Shipment=require("./logistics/shipment.model")(sequelize,DataTypes);db.ShipmentItem=require("./logistics/shipmentItem.model")(sequelize,DataTypes);db.ShipmentAssignment=require("./logistics/shipmentAssignment.model")(sequelize,DataTypes);db.ShipmentEvent=require("./logistics/shipmentEvent.model")(sequelize,DataTypes);db.ShipmentProof=require("./logistics/shipmentProof.model")(sequelize,DataTypes);
|
||||
|
||||
/* Associations */
|
||||
Object.keys(db).forEach(model => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>{const M=s.define("RiderProfile",{id:{type:D.STRING,primaryKey:true},user_id:{type:D.STRING,allowNull:false,unique:true},employee_code:{type:D.STRING(60),allowNull:false,unique:true},phone_override:D.STRING(30),vehicle_type:D.STRING(60),vehicle_registration:D.STRING(60),status:{type:D.ENUM("ACTIVE","INACTIVE","SUSPENDED"),allowNull:false,defaultValue:"ACTIVE"},availability_status:{type:D.ENUM("AVAILABLE","BUSY","OFFLINE"),allowNull:false,defaultValue:"OFFLINE"},current_latitude:D.DECIMAL(10,7),current_longitude:D.DECIMAL(10,7),last_location_at:D.DATE,max_active_assignments:{type:D.INTEGER,allowNull:false,defaultValue:1}},{tableName:"rider_profiles",timestamps:true});M.associate=db=>M.belongsTo(db.User,{foreignKey:"user_id",as:"user"});return M;};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>{const M=s.define("Shipment",{id:{type:D.STRING,primaryKey:true},shipment_number:{type:D.STRING(80),allowNull:false,unique:true},order_id:D.STRING,return_request_id:D.STRING,type:{type:D.ENUM("CUSTOMER_DELIVERY","RETURN_PICKUP"),allowNull:false},status:{type:D.ENUM("PENDING","READY_FOR_ASSIGNMENT","ASSIGNED","PICKUP_PENDING","PICKED_UP","IN_TRANSIT","OUT_FOR_DELIVERY","DELIVERED","DELIVERY_FAILED","RESCHEDULED","CANCELLED"),allowNull:false,defaultValue:"PENDING"},shipping_method_code:D.STRING(60),shipping_method_name:D.STRING(150),recipient_name:{type:D.STRING(150),allowNull:false},recipient_phone:{type:D.STRING(30),allowNull:false},address_snapshot:{type:D.JSON,allowNull:false},assigned_rider_id:D.STRING,assigned_at:D.DATE,picked_up_at:D.DATE,out_for_delivery_at:D.DATE,delivered_at:D.DATE,failed_at:D.DATE,cancelled_at:D.DATE,scheduled_date:D.DATE,time_window_start:D.STRING(20),time_window_end:D.STRING(20),delivery_attempt_count:{type:D.INTEGER,allowNull:false,defaultValue:0},notes:D.STRING(500)},{tableName:"shipments",timestamps:true});M.associate=db=>{M.hasMany(db.ShipmentItem,{foreignKey:"shipment_id",as:"items"});M.hasMany(db.ShipmentAssignment,{foreignKey:"shipment_id",as:"assignments"});M.hasMany(db.ShipmentEvent,{foreignKey:"shipment_id",as:"events"});M.hasMany(db.ShipmentProof,{foreignKey:"shipment_id",as:"proofs"});M.belongsTo(db.Order,{foreignKey:"order_id",as:"order"});};return M;};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("ShipmentAssignment",{id:{type:D.STRING,primaryKey:true},shipment_id:{type:D.STRING,allowNull:false},rider_id:{type:D.STRING,allowNull:false},status:{type:D.ENUM("ASSIGNED","ACCEPTED","REJECTED","UNASSIGNED","COMPLETED"),allowNull:false},assigned_by:{type:D.STRING,allowNull:false},assigned_at:{type:D.DATE,allowNull:false},accepted_at:D.DATE,rejected_at:D.DATE,unassigned_at:D.DATE,completed_at:D.DATE,reason:D.STRING(500)},{tableName:"shipment_assignments",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("ShipmentEvent",{id:{type:D.STRING,primaryKey:true},shipment_id:{type:D.STRING,allowNull:false},event_id:{type:D.STRING(160),allowNull:false,unique:true},type:{type:D.STRING(80),allowNull:false},status:{type:D.STRING(40),allowNull:false},actor_user_id:D.STRING,latitude:D.DECIMAL(10,7),longitude:D.DECIMAL(10,7),note:D.STRING(500),occurred_at:{type:D.DATE,allowNull:false}},{tableName:"shipment_events",timestamps:true,updatedAt:false});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>{const M=s.define("ShipmentItem",{id:{type:D.STRING,primaryKey:true},shipment_id:{type:D.STRING,allowNull:false},order_item_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false}},{tableName:"shipment_items",timestamps:true,indexes:[{unique:true,fields:["shipment_id","order_item_id"]}]});M.associate=db=>M.belongsTo(db.Shipment,{foreignKey:"shipment_id",as:"shipment"});return M;};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("ShipmentProof",{id:{type:D.STRING,primaryKey:true},shipment_id:{type:D.STRING,allowNull:false},event_id:{type:D.STRING(160),allowNull:false,unique:true},type:{type:D.ENUM("SIGNATURE","PHOTO","RECIPIENT_CONFIRMATION","RETURN_PICKUP_PHOTO"),allowNull:false},recipient_name:D.STRING(150),signature_upload_id:D.INTEGER,photo_upload_id:D.INTEGER,notes:D.STRING(500),captured_at:{type:D.DATE,allowNull:false},captured_by:{type:D.STRING,allowNull:false}},{tableName:"shipment_proofs",timestamps:true});
|
||||
@@ -36,6 +36,9 @@ const shoppingRoutes = require("./shopping.routes");
|
||||
const shippingAdminRoutes = require("./shipping/admin.routes");
|
||||
const commerceRoutes = require("./commerce.routes");
|
||||
const commerceAdminRoutes = require("./commerce/admin.routes");
|
||||
const logisticsAdminRoutes = require("./logistics/admin.routes");
|
||||
const logisticsRiderRoutes = require("./logistics/rider.routes");
|
||||
const logisticsTrackingRoutes = require("./logistics/tracking.routes");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -64,5 +67,8 @@ router.use("/", shoppingRoutes);
|
||||
router.use("/admin", shippingAdminRoutes);
|
||||
router.use("/", commerceRoutes);
|
||||
router.use("/admin", commerceAdminRoutes);
|
||||
router.use("/admin", logisticsAdminRoutes);
|
||||
router.use("/rider", logisticsRiderRoutes);
|
||||
router.use("/", logisticsTrackingRoutes);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../../controllers/logistics/admin.controller"),riders=require("../../controllers/logistics/rider.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/logistics.schemas");r.use(authenticate);r.get("/shipments",checkPermission("shipments.read",{custom:true}),c.shipments);r.get("/shipments/:id",checkPermission("shipments.read",{custom:true}),c.shipment);r.post("/shipments",checkPermission("shipments.create",{custom:true}),validate(s.shipment),c.create);r.post("/shipments/return-pickup",checkPermission("returns.logistics.manage",{custom:true}),validate(s.returnPickup),c.returnPickup);r.post("/shipments/:id/assign",checkPermission("shipments.assign",{custom:true}),validate(s.assign),c.assign);r.post("/shipments/:id/unassign",checkPermission("shipments.assign",{custom:true}),validate(s.note),c.unassign);r.post("/shipments/:id/reschedule",checkPermission("shipments.manage",{custom:true}),validate(s.reschedule),c.reschedule);r.post("/shipments/:id/cancel",checkPermission("shipments.manage",{custom:true}),c.cancel);r.get("/dispatch",checkPermission("dispatch.read",{custom:true}),c.dispatch);r.get("/riders",checkPermission("riders.read",{custom:true}),riders.adminList);r.get("/riders/:id",checkPermission("riders.read",{custom:true}),riders.adminGet);r.post("/riders",checkPermission("riders.manage",{custom:true}),validate(s.rider),riders.adminCreate);r.patch("/riders/:id",checkPermission("riders.manage",{custom:true}),validate(s.rider),riders.adminUpdate);r.get("/riders/:id/assignments",checkPermission("riders.read",{custom:true}),riders.assignments);module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../../controllers/logistics/rider.controller"),{authenticate}=require("../../middleware/auth.middleware"),{authorizedAccountType}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/logistics.schemas");r.use(authenticate,authorizedAccountType(["rider"]));r.get("/shipments",c.list);r.get("/shipments/:id",c.get);r.post("/shipments/:id/accept",validate(s.note),c.accept);r.post("/shipments/:id/reject",validate(s.note),c.reject);r.post("/shipments/:id/pickup",validate(s.note),c.pickup);r.post("/shipments/:id/in-transit",validate(s.note),c.inTransit);r.post("/shipments/:id/out-for-delivery",validate(s.note),c.outForDelivery);r.post("/shipments/:id/deliver",validate(s.proof),c.deliver);r.post("/shipments/:id/fail-delivery",validate(s.reason),c.fail);r.put("/location",validate(s.location),c.location);module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../../controllers/logistics/tracking.controller"),{authenticate}=require("../../middleware/auth.middleware");r.use(authenticate);r.get("/orders/:orderId/tracking",c.order);r.get("/returns/:id/tracking",c.return);module.exports=r;
|
||||
@@ -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};
|
||||
@@ -0,0 +1 @@
|
||||
const{z}=require("zod"),wrap=body=>z.object({body:body.strict(),params:z.object({}).passthrough(),query:z.object({}).passthrough()}),id=z.string().min(1).max(160);exports.shipment=wrap(z.object({orderId:id,items:z.array(z.object({orderItemId:id,quantity:z.number().int().positive()}).strict()).min(1),notes:z.string().max(500).optional()}));exports.returnPickup=wrap(z.object({returnRequestId:id}));exports.assign=wrap(z.object({riderId:id}));exports.reason=wrap(z.object({reasonCode:z.enum(["CUSTOMER_UNAVAILABLE","ADDRESS_NOT_FOUND","CUSTOMER_REFUSED","ACCESS_RESTRICTED","VEHICLE_ISSUE","OTHER"]),note:z.string().max(500).optional()}));exports.note=wrap(z.object({note:z.string().max(500).optional()}));exports.reschedule=wrap(z.object({scheduledDate:z.coerce.date(),timeWindowStart:z.string().max(20).optional(),timeWindowEnd:z.string().max(20).optional(),reason:z.string().min(1).max(500)}));exports.proof=wrap(z.object({type:z.enum(["SIGNATURE","PHOTO","RECIPIENT_CONFIRMATION","RETURN_PICKUP_PHOTO"]),recipientName:z.string().max(150).optional(),photoUploadId:z.number().int().positive().optional(),signatureUploadId:z.number().int().positive().optional(),notes:z.string().max(500).optional()}));exports.rider=wrap(z.object({userId:id,employeeCode:z.string().min(1).max(60),phoneOverride:z.string().max(30).optional(),vehicleType:z.string().max(60).optional(),vehicleRegistration:z.string().max(60).optional(),status:z.enum(["ACTIVE","INACTIVE","SUSPENDED"]).default("ACTIVE"),availabilityStatus:z.enum(["AVAILABLE","BUSY","OFFLINE"]).default("OFFLINE"),maxActiveAssignments:z.number().int().positive().max(20).default(1)}));exports.location=wrap(z.object({latitude:z.number().min(-90).max(90),longitude:z.number().min(-180).max(180)}));
|
||||
Reference in New Issue
Block a user