This commit is contained in:
mahyargdz
2025-09-14 15:15:03 +03:30
commit be82059172
534 changed files with 51310 additions and 0 deletions
@@ -0,0 +1,40 @@
import { Expose, Type } from "class-transformer";
import { ArrayMinSize, IsArray, IsNotEmpty, IsNumber, IsString, ValidateNested } from "class-validator";
import { ApiProperty } from "../../../common/decorator/swggerDocs";
import { IsValidId } from "../../../common/decorator/validation.decorator";
import { ShopModel } from "../../shop/models/shop.model";
import { ShipmentModel } from "../models/shipment.model";
class ShipperSelectionDTO {
@Expose()
@IsNotEmpty()
@IsString()
@IsValidId(ShopModel)
@ApiProperty({ type: "string", description: "shop ID", example: "60d725ebcfc0f308e6f5c92b" })
shopId: string;
@Expose()
@IsNotEmpty()
@IsNumber()
@IsValidId(ShipmentModel)
@ApiProperty({ type: "number", description: "Shipment ID for the selected seller", example: 102 })
shipmentId: number;
}
export class SaveSellerShipmentInfoDTO {
@ApiProperty({
type: "Array",
description: "Array of seller IDs and their selected shipment provider",
example: [
{ shopId: "60d725ebcfc0f308e6f5c92b", shipmentId: 102 },
{ shopId: "60d726abcfc0f308e6f5c92d", shipmentId: 104 },
],
})
@Expose()
@IsArray()
@ArrayMinSize(1, { message: "حداقل باید یک روش ارسال انتخاب شود" })
@Type(() => ShipperSelectionDTO)
@ValidateNested({ each: true })
shipmentsInfo: ShipperSelectionDTO[];
}
@@ -0,0 +1,74 @@
import { Expose, Type } from "class-transformer";
import { ArrayMinSize, IsArray, IsNotEmpty, IsNumber, IsString, ValidateNested } from "class-validator";
import { ApiProperty } from "../../../common/decorator/swggerDocs";
// DTO for Shipment Cost
class WeightRangeDTO {
@Expose()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", description: "Minimum weight in the range in kg", example: 0 })
min: number;
@Expose()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", description: "Maximum weight in the range in kg", example: 10 })
max: number;
}
class ShipmentCostDTO {
@Expose()
@IsNotEmpty()
@Type(() => WeightRangeDTO)
weightRange: WeightRangeDTO;
@Expose()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", description: "Cost of shipping for the given weight range for first items", example: 40000 })
cost_first: number;
@Expose()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", description: "Cost of shipping for the given weight range for other items ", example: 40000 })
cost_rest: number;
}
// Main DTO for Creating Shipment Providers
export class CreateShipProvidersDTO {
@Expose()
@IsNotEmpty()
@IsString()
@ApiProperty({ type: "string", description: "The name of the provider", example: "چاپار" })
name: string;
@Expose()
@IsNotEmpty()
@IsString()
@ApiProperty({ type: "string", description: "Description of provider", example: "ارائه بهترین راه حل های جهانی لجستیک چاپار" })
description: string;
@Expose()
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ShipmentCostDTO)
@ApiProperty({
type: "Array",
description: "Costs for various weight ranges",
example: [
{ weightRange: { min: 0, max: 1 }, cost_first: 40000, cost_rest: 2000 },
{ weightRange: { min: 1, max: 5 }, cost_first: 80000, cost_rest: 4000 },
{ weightRange: { min: 5, max: 10 }, cost_first: 100000, cost_rest: 5000 },
],
})
costs: ShipmentCostDTO[];
@Expose()
@IsNotEmpty()
@IsNumber()
@ApiProperty({ type: "number", description: "Delivery time in days", example: 3 })
deliveryTime: number;
}
+92
View File
@@ -0,0 +1,92 @@
import { Expose, Type, plainToInstance } from "class-transformer";
import { CartItemsDTO } from "../../cart/DTO/cart.dto";
class ShipperItemsDTO {
@Expose()
product: number;
@Expose()
variant: string;
@Expose()
quantity: number;
@Expose()
selling_price: number;
@Expose()
retail_price: number;
@Expose()
discount_percent: number;
@Expose()
shippingCost: number;
@Expose()
isFreeShip: boolean;
}
class ShipperInfoDTO {
@Expose()
shipperId: number;
@Expose()
shipperName: string;
@Expose()
shippingDaysRange: string[];
@Expose()
totalShippingCost: number;
@Expose()
@Type(() => ShipperItemsDTO)
items: ShipperItemsDTO[];
}
//***********************************
//***********************************
export class ShippingOptionDTO {
@Expose()
shopId: string;
@Expose()
shopName: string;
@Expose()
@Type(() => CartItemsDTO)
items: CartItemsDTO[];
@Expose()
@Type(() => ShipperInfoDTO)
shippers: ShipperInfoDTO[];
public static transformShipment(data: Record<string, any>): ShippingOptionDTO {
const shipmentDTO = plainToInstance(ShippingOptionDTO, data, {
excludeExtraneousValues: true,
enableImplicitConversion: true,
});
return shipmentDTO;
}
}
export class ShipmentMethodDTO {
@Expose()
_id: number;
@Expose()
name: string;
@Expose()
description: string;
@Expose()
deliveryTime: number;
@Expose()
deliveryType: string;
}
@@ -0,0 +1,16 @@
import { PartialType } from "@nestjs/mapped-types";
import { Expose } from "class-transformer";
import { IsEnum, IsNotEmpty, IsOptional } from "class-validator";
import { CreateShipProvidersDTO } from "./createShipmentProviders.dto";
import { ApiProperty } from "../../../common/decorator/swggerDocs";
import { DeliveryType } from "../../../common/enums/shipment.enum";
export class UpdateShipmentProviderDTO extends PartialType(CreateShipProvidersDTO) {
@Expose()
@IsOptional()
@IsNotEmpty()
@IsEnum(DeliveryType)
@ApiProperty({ type: "string" })
deliveryType?: DeliveryType;
}
@@ -0,0 +1,16 @@
import { DeliveryType } from "../../../../common/enums/shipment.enum";
export interface IShipmentProviders {
_id: number;
name: string;
description: string;
costs: IShipmentCost[];
deliveryType: DeliveryType;
deliveryTime: number;
deleted: boolean;
}
export interface IShipmentCost {
weightRange: { min: number; max: number }; // weight range in kg
cost_first: number;
cost_rest: number;
}
@@ -0,0 +1,39 @@
import mongoose, { Schema, model } from "mongoose";
import { IShipmentCost, IShipmentProviders } from "./Abstraction/IShipmentProviders";
import { DeliveryType } from "../../../common/enums/shipment.enum";
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
const AutoIncrement = require("mongoose-sequence")(mongoose);
const ShipmentCostSchema = new Schema<IShipmentCost>(
{
weightRange: {
min: { type: Number, required: true },
max: { type: Number, required: true },
},
cost_first: { type: Number, required: true },
cost_rest: { type: Number, required: true },
},
{ _id: false },
);
const ShipmentSchema = new Schema<IShipmentProviders>(
{
_id: Number,
name: { type: String, unique: true, required: true },
description: String,
// availableShippingDays: { type: [String], require: true },
costs: [ShipmentCostSchema],
deliveryType: { type: String, enum: DeliveryType, required: true },
deliveryTime: { type: Number, required: true },
deleted: { type: Boolean, default: false },
},
{ timestamps: true, toJSON: { versionKey: false }, _id: false, id: false },
);
ShipmentSchema.plugin(AutoIncrement, { id: "shipment_id", inc_field: "_id", start_seq: 100 });
const ShipmentModel = model<IShipmentProviders>("Shipment", ShipmentSchema);
export { ShipmentModel, IShipmentProviders };
@@ -0,0 +1,58 @@
import { Request } from "express";
import { inject } from "inversify";
import { controller, httpGet, httpPost, queryParam, request, requestBody } from "inversify-express-utils";
import { ShipmentService } from "./shipment.service";
import { HttpStatus } from "../../common";
import { SaveSellerShipmentInfoDTO } from "./DTO/calculateShipCost.dto";
import { BaseController } from "../../common/base/controller";
import { ApiAuth, ApiModel, ApiOperation, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs";
import { Guard } from "../../core/middlewares/guard.middleware";
import { ValidationMiddleware } from "../../core/middlewares/validator.middleware";
import { IOCTYPES } from "../../IOC/ioc.types";
import { IUser } from "../user/models/Abstraction/IUser";
@controller("/shipment")
@ApiTags("Shipment")
class ShipmentController extends BaseController {
@inject(IOCTYPES.ShipmentService) shipmentService: ShipmentService;
//
@ApiOperation("get list of all shipment provider")
@ApiResponse("successful", HttpStatus.Ok)
@ApiQuery("limit", "the limit of return data")
@ApiQuery("page", "the page want to get")
@httpGet("")
public async getShipmentProviders(@queryParam("limit") limit: string, @queryParam("page") page: string) {
const queries = {
limit: parseInt(limit),
page: parseInt(page),
};
const { count, shipper } = await this.shipmentService.getAllProviders(queries);
const { pager } = this.paginate(count);
return this.response({ pager, shipper });
}
@ApiOperation("Calculate shipment cost for checkout")
@ApiResponse("Successful", HttpStatus.Ok)
@ApiAuth()
@httpGet("/shipping-cost", Guard.authUser())
public async calculateShipCost(@request() req: Request) {
const user = req.user as IUser;
const data = await this.shipmentService.calculateAllShipmentOptions(user._id.toString());
return this.response(data);
}
@ApiOperation("save shipment info for checkout")
@ApiResponse("Successful", HttpStatus.Ok)
@ApiModel(SaveSellerShipmentInfoDTO)
@ApiAuth()
@httpPost("/save", Guard.authUser(), ValidationMiddleware.validateInput(SaveSellerShipmentInfoDTO))
public async saveShipment(@requestBody() saveShippingInfo: SaveSellerShipmentInfoDTO, @request() req: Request) {
const user = req.user as IUser;
const data = await this.shipmentService.selectShipmentProvider(saveShippingInfo, user._id.toString());
return this.response(data);
}
}
export { ShipmentController };
@@ -0,0 +1,25 @@
import { IShipmentProviders } from "./models/Abstraction/IShipmentProviders";
import { ShipmentModel } from "./models/shipment.model";
import { BaseRepository } from "../../common/base/repository";
class ShipmentRepository extends BaseRepository<IShipmentProviders> {
constructor() {
super(ShipmentModel);
}
async findAllShipper(queries: { limit: number; page: number }) {
const page = queries.page || 1;
const limit = queries.limit || 10;
const skip = (page - 1) * limit;
const count = await this.model.countDocuments({ deleted: false });
const shipper = await this.model.find({ deleted: false }).skip(skip).limit(limit);
return { shipper, count };
}
}
function createShipmentRepository(): ShipmentRepository {
return new ShipmentRepository();
}
export { createShipmentRepository, ShipmentRepository };
+352
View File
@@ -0,0 +1,352 @@
import { inject, injectable } from "inversify";
import { Types } from "mongoose";
import { SaveSellerShipmentInfoDTO } from "./DTO/calculateShipCost.dto";
import { CreateShipProvidersDTO } from "./DTO/createShipmentProviders.dto";
import { ShippingOptionDTO } from "./DTO/shipment.dto";
import { IShipmentProviders } from "./models/shipment.model";
import { ShipmentRepository } from "./shipment.repository";
import { Items } from "./types/items";
import { CartMessage, CommonMessage, SetShipmentMessage } from "../../common/enums/message.enum";
import { DeliveryType } from "../../common/enums/shipment.enum";
import { BadRequestError } from "../../core/app/app.errors";
import { IOCTYPES } from "../../IOC/ioc.types";
import { CartRepository, CartShipItemRepo } from "../cart/cart.repository";
import { ICart } from "../cart/models/Abstraction/ICart";
import { ProductVariantRepository } from "../product/Repository/productVarinat";
import { SellerRepository } from "../seller/seller.repository";
import { ShopRepo } from "../shop/shop.repository";
import { UpdateShipmentProviderDTO } from "./DTO/updateShipment.dto";
@injectable()
class ShipmentService {
@inject(IOCTYPES.ShipmentRepository) shipmentRepo: ShipmentRepository;
@inject(IOCTYPES.SellerRepository) sellerRepo: SellerRepository;
@inject(IOCTYPES.CartRepository) cartRepo: CartRepository;
@inject(IOCTYPES.ProductVariantRepository) productVariantRepo: ProductVariantRepository;
@inject(IOCTYPES.CartShipItemRepo) cartShipItemRepo: CartShipItemRepo;
@inject(IOCTYPES.ShopRepo) shopRepo: ShopRepo;
async getAllProviders(queries: { limit: number; page: number }) {
const { count, shipper } = await this.shipmentRepo.findAllShipper(queries);
return { count, shipper };
}
//###################################################################
//###################################################################
async createShipmentProviderS(createDto: CreateShipProvidersDTO) {
const existShipper = await this.shipmentRepo.model.exists({ name: createDto.name });
if (existShipper) throw new BadRequestError(CommonMessage.DuplicateName);
const deliveryType = this.getDeliveryType(createDto.deliveryTime);
const newProvider = await this.shipmentRepo.model.create({ deliveryType, ...createDto });
return {
message: CommonMessage.Created,
newProvider,
};
}
//###################################################################
async updateShipmentProvider(shipmentId: number, updateDto: UpdateShipmentProviderDTO) {
const existShipper = await this.shipmentRepo.model.exists({ name: updateDto.name, _id: { $ne: shipmentId } });
if (existShipper) throw new BadRequestError(CommonMessage.DuplicateName);
if (updateDto.deliveryTime) {
updateDto.deliveryType = this.getDeliveryType(updateDto.deliveryTime);
}
const updatedProvider = await this.shipmentRepo.model.findByIdAndUpdate(shipmentId, updateDto, { new: true });
if (!updatedProvider) throw new BadRequestError(CommonMessage.NotFoundById);
return {
message: CommonMessage.Updated,
updatedProvider,
};
}
//###################################################################
async deleteShipper(shipperId: number) {
const shipper = await this.shipmentRepo.model.findByIdAndUpdate(shipperId, { deleted: true }, { new: true });
if (!shipper) throw new BadRequestError(CommonMessage.NotFoundById);
return {
message: CommonMessage.Deleted,
shipper,
};
}
//###################################################################
//###################################################################
async calculateAllShipmentOptions(userId: string) {
// get the cart for the user
const cart = await this.cartRepo.getCartWithPopulate(userId);
if (!cart) throw new BadRequestError(CartMessage.CartNotFound);
console.log({ cart });
const shipping = [];
const cartItems = cart.items as unknown as Items[];
// Group items by shop
const itemsByShop = cartItems.reduce<Record<string, Items[]>>((acc, item) => {
const shopId = item.variant.shop._id.toString();
if (!acc[shopId]) {
acc[shopId] = [];
}
acc[shopId].push({
product: item.product,
variant: item.variant,
quantity: item.quantity,
});
return acc;
}, {});
console.log({ itemsByShop });
// iterate through each shops items
for (const [shopId, items] of Object.entries(itemsByShop)) {
const shop = await this.shopRepo.model.findById(shopId).lean();
console.log(shop);
if (!shop) {
continue;
}
const shopShippers = [];
// for each shop, check all available shippers
for (const shipperId of shop.shipmentMethod) {
const shipmentProvider = await this.shipmentRepo.model.findById(shipperId).lean();
console.log({ shipmentProvider });
if (!shipmentProvider) {
console.log("Shipment provider not found for shipper ID:", shipperId);
continue;
}
// group items by product and variant
const itemsByProductVariant = items.reduce<Record<string, Items & { weight: number }>>((acc, item) => {
const key = `${item.product}-${item.variant._id}`;
if (!acc[key]) {
acc[key] = { product: item.product, variant: item.variant, quantity: 0, weight: 0 };
}
acc[key].quantity += item.quantity;
acc[key].weight += item.variant.dimensions.package_weight * item.quantity;
return acc;
}, {});
let totalShippingCost = 0;
const shipperItems = [];
console.log({ itemsByProductVariant });
// calculate shipping cost and gather item details for each shipper
for (const { product, variant, quantity, weight } of Object.values(itemsByProductVariant)) {
const shippingCostForItem = await this.calculateShippingCost(shipmentProvider, weight, quantity);
totalShippingCost += shippingCostForItem;
shipperItems.push({
product: product._id,
variant: variant._id,
quantity,
selling_price: variant.price.selling_price,
retail_price: variant.price.retailPrice,
discount_percent: variant.price.discount_percent,
shippingCost: variant.isFreeShip ? 0 : shippingCostForItem,
isFreeShip: variant.isFreeShip,
});
}
// push shipper details for this seller
shopShippers.push({
shipperId: shipmentProvider._id,
shipperName: shipmentProvider.name,
shippingDaysRange: this.calculateShippingDays(shipmentProvider.deliveryType, shipmentProvider.deliveryTime),
totalShippingCost,
items: shipperItems,
});
}
// push seller info and their shippers
shipping.push({
shopId,
shopName: shop.shopName,
items,
shippers: shopShippers,
});
console.log({ shipping });
}
return { shipping: shipping.map((ship) => ShippingOptionDTO.transformShipment(ship)) };
}
//###################################################################
//###################################################################
async selectShipmentProvider(saveShippingInfo: SaveSellerShipmentInfoDTO, userId: string) {
const cart = (await this.cartRepo.getCartWithAggregate(userId))[0] as ICart;
if (!cart) throw new BadRequestError(CartMessage.CartNotFound);
const { shipping: availableShipmentOptions } = await this.calculateAllShipmentOptions(userId);
if (!availableShipmentOptions.length) throw new BadRequestError(SetShipmentMessage.ShipmentOptionsUnavailable);
const { shipmentsInfo } = saveShippingInfo;
// ensure shipment info matches the number of sellers in the cart
const shopIds = availableShipmentOptions.map((shipperOption) => shipperOption.shopId);
const shipmentShopIds = shipmentsInfo.map((shipInfo) => shipInfo.shopId);
// validate that the selected shop IDs match the cart shop IDs
const unmatchedSellers = shipmentShopIds.filter((id) => !shopIds.includes(id));
if (unmatchedSellers.length > 0) throw new BadRequestError([SetShipmentMessage.InvalidShipmentItems, unmatchedSellers.join(",")]);
// process each seller's shipment info
for (const shipInfo of shipmentsInfo) {
const shopShipmentOptions = availableShipmentOptions.find((option) => option.shopId === shipInfo.shopId);
if (!shopShipmentOptions) {
throw new BadRequestError([
SetShipmentMessage.InvalidShipmentSellers,
`shop with ID ${shipInfo.shopId} does not have shipment options.`,
]);
}
const selectedShipper = shopShipmentOptions.shippers.find((shipper) => shipper.shipperId === shipInfo.shipmentId);
if (!selectedShipper) {
throw new BadRequestError([
SetShipmentMessage.ShipperNotAvailableForSeller,
`Shipper with ID ${shipInfo.shipmentId} is not available for shop ${shipInfo.shopId}.`,
]);
}
// find existing CartShipmentItem for this seller or create a new one
const existingCartShipmentItem = await this.cartShipItemRepo.model.findOne({
cart: cart._id.toString(),
shop: new Types.ObjectId(shipInfo.shopId),
});
const shipmentItems = selectedShipper.items.map((item) => ({
product: item.product,
variant: new Types.ObjectId(item.variant),
quantity: item.quantity,
selling_price: item.selling_price,
retail_price: item.retail_price,
discount_percent: item.discount_percent,
shipmentCost: item.shippingCost,
isFreeShip: item.isFreeShip,
}));
if (existingCartShipmentItem) {
// update the existing CartShipmentItem
existingCartShipmentItem.shipmentItems = shipmentItems;
existingCartShipmentItem.shipper = selectedShipper.shipperId;
await existingCartShipmentItem.save();
} else {
const totalSellingPrice = shipmentItems.reduce((acc, item) => acc + item.selling_price * item.quantity, 0);
const totalRetailPrice = shipmentItems.reduce((acc, item) => acc + item.retail_price * item.quantity, 0);
const totalShipmentCost = shipmentItems.reduce((acc, item) => acc + item.shipmentCost, 0);
// create a new CartShipmentItem
await this.cartShipItemRepo.model.create({
cart: cart._id.toString(),
shop: new Types.ObjectId(shipInfo.shopId),
shipmentItems,
shipper: selectedShipper.shipperId,
totalSellingPrice,
totalRetailPrice,
totalShipmentCost,
totalPaymentPrice: totalShipmentCost + totalSellingPrice,
itemsCount: shipmentItems.reduce((acc, item) => acc + item.quantity, 0),
});
}
}
// calculate the total shipping cost
const cartShipmentItems = await this.cartShipItemRepo.model.find({ cart: cart._id.toString() }).lean();
const totalShippingCost = cartShipmentItems.reduce(
(sum, item) => sum + item.shipmentItems.reduce((subSum, shipmentItem) => subSum + shipmentItem.shipmentCost, 0),
0,
);
return {
cartShipmentItems: cartShipmentItems,
shippingCost: totalShippingCost,
processCost: totalShippingCost,
payable_price: cart.payable_price + totalShippingCost * 2,
};
}
//===============================> helper method
// Helper function to calculate shipping days range
private calculateShippingDays(deliveryType: DeliveryType, deliveryTime: number): string[] {
const currentDate = new Date();
let minDeliveryDays = 0;
let maxDeliveryDays = 0;
switch (deliveryType) {
case DeliveryType.SameDay:
minDeliveryDays = 0;
maxDeliveryDays = 1;
break;
case DeliveryType.Express:
minDeliveryDays = 1;
maxDeliveryDays = 2;
break;
case DeliveryType.Standard:
minDeliveryDays = 3;
maxDeliveryDays = 5;
break;
default:
minDeliveryDays = deliveryTime - 1;
maxDeliveryDays = deliveryTime;
break;
}
const shippingDaysRange: string[] = [];
let processedDays = 0;
const totalDays = maxDeliveryDays - minDeliveryDays + 1;
let i = minDeliveryDays;
while (processedDays < totalDays) {
const estimatedDate = new Date(currentDate);
estimatedDate.setDate(currentDate.getDate() + i);
// check if the day is Thursday (4) or Friday (5)
const dayNumber = estimatedDate.getDay();
// skip Thursday (4) and Friday (5)
if (dayNumber === 4 || dayNumber === 5) {
i++;
continue;
}
const options = { day: "numeric", weekday: "long", month: "long" };
const dayName = estimatedDate.toLocaleString("fa-IR", options as any);
shippingDaysRange.push(dayName);
processedDays++; // only count this day if its a valid shipping day
i++; // move to the next day
}
return shippingDaysRange;
}
// Helper function to calculate the shipping cost for a product variant based on shipment provider
private async calculateShippingCost(shipmentProvider: IShipmentProviders, package_weight: number, quantity: number): Promise<number> {
console.log({ package_weight });
console.dir({ shipmentProvider: shipmentProvider.costs }, { depth: null });
const cost = shipmentProvider.costs.find(
(cost) => package_weight / 1000 >= cost.weightRange.min && package_weight / 1000 <= cost.weightRange.max,
);
console.log({ cost });
// if (!cost) throw new BadRequestError("No shipping cost available for this weight range");
const defaulCost = cost ?? { cost_first: 100_000, cost_rest: 10_000 };
const mainCost = defaulCost.cost_first;
const restCost = defaulCost.cost_rest * (quantity - 1); // multiply the rest cost by the remaining quantity
return mainCost + restCost;
}
//helper function to calculate the delivery type based on time
private getDeliveryType(deliveryTime: number): DeliveryType {
if (deliveryTime <= 1) return DeliveryType.SameDay;
if (deliveryTime <= 2) return DeliveryType.Express;
return DeliveryType.Standard;
}
}
export { ShipmentService };
+4
View File
@@ -0,0 +1,4 @@
import { IProduct } from "../../product/models/Abstraction/IProduct";
import { IProductVariant } from "../../product/models/Abstraction/IProductVariant";
export type Items = { product: IProduct; variant: IProductVariant; quantity: number };