[ERP-RETAIL] feat(ecommerce): Add cart, order, and shipping services with controller
Implement complete ecommerce backend: - CartService: cart CRUD, items, coupons, shipping, cart merging - EcommerceOrderService: order creation, payment, shipping, fulfillment workflow - ShippingRateService: rate calculation with geo/product/customer restrictions - EcommerceController: REST API endpoints for all ecommerce operations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c24290b0ee
commit
c6b8ab019a
616
src/modules/ecommerce/controllers/ecommerce.controller.ts
Normal file
616
src/modules/ecommerce/controllers/ecommerce.controller.ts
Normal file
@ -0,0 +1,616 @@
|
|||||||
|
/**
|
||||||
|
* EcommerceController - API endpoints para e-commerce
|
||||||
|
* @module Ecommerce
|
||||||
|
* @prefix /api/ecommerce
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { cartService } from '../services/cart.service';
|
||||||
|
import { ecommerceOrderService } from '../services/ecommerce-order.service';
|
||||||
|
import { shippingRateService } from '../services/shipping-rate.service';
|
||||||
|
import { EcommerceOrderStatus, PaymentStatus } from '../entities/ecommerce-order.entity';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ==================== HELPER FUNCTIONS ====================
|
||||||
|
|
||||||
|
function getTenantId(req: Request): string {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) throw new Error('X-Tenant-Id header required');
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserId(req: Request): string | undefined {
|
||||||
|
return req.headers['x-user-id'] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionId(req: Request): string | undefined {
|
||||||
|
return req.headers['x-session-id'] as string || req.cookies?.session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CART ENDPOINTS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ecommerce/cart
|
||||||
|
* Get current cart (by customer or session)
|
||||||
|
*/
|
||||||
|
router.get('/cart', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const customerId = (req as any).user?.id;
|
||||||
|
const sessionId = getSessionId(req);
|
||||||
|
|
||||||
|
const result = await cartService.getOrCreateCart(tenantId, { customerId, sessionId });
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ecommerce/cart/:id
|
||||||
|
* Get cart by ID
|
||||||
|
*/
|
||||||
|
router.get('/cart/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const result = await cartService.getById(tenantId, req.params.id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(404).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ecommerce/cart/:id/items
|
||||||
|
* Add item to cart
|
||||||
|
*/
|
||||||
|
router.post('/cart/:id/items', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await cartService.addItem(tenantId, id, req.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(201).json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/ecommerce/cart/:id/items/:itemId
|
||||||
|
* Update cart item
|
||||||
|
*/
|
||||||
|
router.patch('/cart/:id/items/:itemId', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const { id, itemId } = req.params;
|
||||||
|
|
||||||
|
const result = await cartService.updateItem(tenantId, id, itemId, req.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/ecommerce/cart/:id/items/:itemId
|
||||||
|
* Remove item from cart
|
||||||
|
*/
|
||||||
|
router.delete('/cart/:id/items/:itemId', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const { id, itemId } = req.params;
|
||||||
|
|
||||||
|
const result = await cartService.removeItem(tenantId, id, itemId);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, message: 'Item removed' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ecommerce/cart/:id/coupon
|
||||||
|
* Apply coupon to cart
|
||||||
|
*/
|
||||||
|
router.post('/cart/:id/coupon', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await cartService.applyCoupon(tenantId, id, req.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/ecommerce/cart/:id/coupon
|
||||||
|
* Remove coupon from cart
|
||||||
|
*/
|
||||||
|
router.delete('/cart/:id/coupon', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await cartService.removeCoupon(tenantId, id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/ecommerce/cart/:id/shipping
|
||||||
|
* Set shipping information
|
||||||
|
*/
|
||||||
|
router.put('/cart/:id/shipping', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await cartService.setShipping(tenantId, id, req.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/ecommerce/cart/:id/billing
|
||||||
|
* Set billing information
|
||||||
|
*/
|
||||||
|
router.put('/cart/:id/billing', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await cartService.setBilling(tenantId, id, req.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ecommerce/cart/:id/merge
|
||||||
|
* Merge guest cart into customer cart
|
||||||
|
*/
|
||||||
|
router.post('/cart/:id/merge', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const guestSessionId = req.params.id;
|
||||||
|
const customerId = (req as any).user?.id || req.body.customerId;
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'MISSING_CUSTOMER', message: 'Customer ID required' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cartService.mergeCarts(tenantId, guestSessionId, customerId);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/ecommerce/cart/:id
|
||||||
|
* Clear cart
|
||||||
|
*/
|
||||||
|
router.delete('/cart/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await cartService.clearCart(tenantId, id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== ORDER ENDPOINTS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ecommerce/orders
|
||||||
|
* List orders
|
||||||
|
*/
|
||||||
|
router.get('/orders', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const { status, paymentStatus, customerId, dateFrom, dateTo, page, limit } = req.query;
|
||||||
|
|
||||||
|
const result = await ecommerceOrderService.list(tenantId, {
|
||||||
|
status: status as EcommerceOrderStatus,
|
||||||
|
paymentStatus: paymentStatus as PaymentStatus,
|
||||||
|
customerId: customerId as string,
|
||||||
|
dateFrom: dateFrom ? new Date(dateFrom as string) : undefined,
|
||||||
|
dateTo: dateTo ? new Date(dateTo as string) : undefined,
|
||||||
|
page: page ? Number(page) : 1,
|
||||||
|
limit: limit ? Number(limit) : 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, ...result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ecommerce/orders/:id
|
||||||
|
* Get order by ID
|
||||||
|
*/
|
||||||
|
router.get('/orders/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const result = await ecommerceOrderService.getById(tenantId, req.params.id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(404).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ecommerce/orders/number/:number
|
||||||
|
* Get order by number
|
||||||
|
*/
|
||||||
|
router.get('/orders/number/:number', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const result = await ecommerceOrderService.getByNumber(tenantId, req.params.number);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(404).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ecommerce/orders
|
||||||
|
* Create order from cart
|
||||||
|
*/
|
||||||
|
router.post('/orders', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const result = await ecommerceOrderService.createFromCart(tenantId, req.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(201).json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ecommerce/orders/:id/confirm
|
||||||
|
* Confirm order
|
||||||
|
*/
|
||||||
|
router.post('/orders/:id/confirm', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const result = await ecommerceOrderService.confirm(tenantId, req.params.id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ecommerce/orders/:id/payment
|
||||||
|
* Update payment status
|
||||||
|
*/
|
||||||
|
router.post('/orders/:id/payment', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const { status, transactionId, paymentDetails } = req.body;
|
||||||
|
|
||||||
|
const result = await ecommerceOrderService.updatePaymentStatus(
|
||||||
|
tenantId,
|
||||||
|
req.params.id,
|
||||||
|
status,
|
||||||
|
transactionId,
|
||||||
|
paymentDetails
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ecommerce/orders/:id/ship
|
||||||
|
* Ship order
|
||||||
|
*/
|
||||||
|
router.post('/orders/:id/ship', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const result = await ecommerceOrderService.ship(tenantId, req.params.id, req.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ecommerce/orders/:id/deliver
|
||||||
|
* Mark order as delivered
|
||||||
|
*/
|
||||||
|
router.post('/orders/:id/deliver', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const result = await ecommerceOrderService.markDelivered(tenantId, req.params.id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ecommerce/orders/:id/ready-for-pickup
|
||||||
|
* Mark order ready for pickup
|
||||||
|
*/
|
||||||
|
router.post('/orders/:id/ready-for-pickup', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const result = await ecommerceOrderService.markReadyForPickup(tenantId, req.params.id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ecommerce/orders/:id/picked-up
|
||||||
|
* Mark order as picked up
|
||||||
|
*/
|
||||||
|
router.post('/orders/:id/picked-up', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const result = await ecommerceOrderService.markPickedUp(tenantId, req.params.id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ecommerce/orders/:id/cancel
|
||||||
|
* Cancel order
|
||||||
|
*/
|
||||||
|
router.post('/orders/:id/cancel', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const { reason } = req.body;
|
||||||
|
const result = await ecommerceOrderService.cancel(tenantId, req.params.id, reason);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== SHIPPING ENDPOINTS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ecommerce/shipping-rates
|
||||||
|
* List shipping rates
|
||||||
|
*/
|
||||||
|
router.get('/shipping-rates', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const activeOnly = req.query.activeOnly === 'true';
|
||||||
|
const result = await shippingRateService.list(tenantId, activeOnly);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ecommerce/shipping-rates/:id
|
||||||
|
* Get shipping rate by ID
|
||||||
|
*/
|
||||||
|
router.get('/shipping-rates/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const result = await shippingRateService.getById(tenantId, req.params.id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(404).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ecommerce/shipping-rates
|
||||||
|
* Create shipping rate
|
||||||
|
*/
|
||||||
|
router.post('/shipping-rates', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const result = await shippingRateService.create(tenantId, req.body, userId);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(201).json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/ecommerce/shipping-rates/:id
|
||||||
|
* Update shipping rate
|
||||||
|
*/
|
||||||
|
router.patch('/shipping-rates/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const result = await shippingRateService.update(tenantId, req.params.id, req.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/ecommerce/shipping-rates/:id
|
||||||
|
* Delete shipping rate
|
||||||
|
*/
|
||||||
|
router.delete('/shipping-rates/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const result = await shippingRateService.delete(tenantId, req.params.id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, message: 'Shipping rate deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ecommerce/shipping-rates/calculate
|
||||||
|
* Calculate available shipping options for cart
|
||||||
|
*/
|
||||||
|
router.post('/shipping-rates/calculate', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = getTenantId(req);
|
||||||
|
const result = await shippingRateService.calculateShippingOptions(tenantId, req.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
6
src/modules/ecommerce/controllers/index.ts
Normal file
6
src/modules/ecommerce/controllers/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Ecommerce Controllers Index
|
||||||
|
* @module Ecommerce
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as ecommerceController } from './ecommerce.controller';
|
||||||
509
src/modules/ecommerce/services/cart.service.ts
Normal file
509
src/modules/ecommerce/services/cart.service.ts
Normal file
@ -0,0 +1,509 @@
|
|||||||
|
/**
|
||||||
|
* CartService - Gestión de carritos de compra
|
||||||
|
* @module Ecommerce
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository, DeepPartial } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../../config/typeorm';
|
||||||
|
import { Cart, CartStatus } from '../entities/cart.entity';
|
||||||
|
import { CartItem } from '../entities/cart-item.entity';
|
||||||
|
|
||||||
|
interface ServiceResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: { code: string; message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddItemDTO {
|
||||||
|
productId: string;
|
||||||
|
productCode: string;
|
||||||
|
productName: string;
|
||||||
|
productSlug?: string;
|
||||||
|
productImage?: string;
|
||||||
|
variantId?: string;
|
||||||
|
variantName?: string;
|
||||||
|
variantSku?: string;
|
||||||
|
variantOptions?: { name: string; value: string }[];
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
originalPrice: number;
|
||||||
|
taxRate?: number;
|
||||||
|
taxIncluded?: boolean;
|
||||||
|
warehouseId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateItemDTO {
|
||||||
|
quantity?: number;
|
||||||
|
savedForLater?: boolean;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApplyCouponDTO {
|
||||||
|
couponId: string;
|
||||||
|
couponCode: string;
|
||||||
|
discountAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetShippingDTO {
|
||||||
|
shippingMethodId: string;
|
||||||
|
shippingMethodName: string;
|
||||||
|
shippingAmount: number;
|
||||||
|
shippingAddress: Cart['shippingAddress'];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetBillingDTO {
|
||||||
|
billingAddress: Cart['billingAddress'];
|
||||||
|
billingSameAsShipping?: boolean;
|
||||||
|
requiresInvoice?: boolean;
|
||||||
|
invoiceRfc?: string;
|
||||||
|
invoiceUsoCfdi?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CartService {
|
||||||
|
private repository: Repository<Cart>;
|
||||||
|
private itemRepository: Repository<CartItem>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.repository = AppDataSource.getRepository(Cart);
|
||||||
|
this.itemRepository = AppDataSource.getRepository(CartItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create cart for customer/session
|
||||||
|
*/
|
||||||
|
async getOrCreateCart(
|
||||||
|
tenantId: string,
|
||||||
|
options: { customerId?: string; sessionId?: string }
|
||||||
|
): Promise<ServiceResult<Cart>> {
|
||||||
|
try {
|
||||||
|
let cart: Cart | null = null;
|
||||||
|
|
||||||
|
if (options.customerId) {
|
||||||
|
cart = await this.repository.findOne({
|
||||||
|
where: { tenantId, customerId: options.customerId, status: CartStatus.ACTIVE },
|
||||||
|
relations: ['items'],
|
||||||
|
});
|
||||||
|
} else if (options.sessionId) {
|
||||||
|
cart = await this.repository.findOne({
|
||||||
|
where: { tenantId, sessionId: options.sessionId, status: CartStatus.ACTIVE },
|
||||||
|
relations: ['items'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cart) {
|
||||||
|
cart = this.repository.create({
|
||||||
|
tenantId,
|
||||||
|
customerId: options.customerId,
|
||||||
|
sessionId: options.sessionId,
|
||||||
|
status: CartStatus.ACTIVE,
|
||||||
|
currencyCode: 'MXN',
|
||||||
|
});
|
||||||
|
cart = await this.repository.save(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: cart };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'CART_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cart by ID
|
||||||
|
*/
|
||||||
|
async getById(tenantId: string, id: string): Promise<ServiceResult<Cart>> {
|
||||||
|
try {
|
||||||
|
const cart = await this.repository.findOne({
|
||||||
|
where: { id, tenantId },
|
||||||
|
relations: ['items'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cart) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Cart not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: cart };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'CART_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add item to cart
|
||||||
|
*/
|
||||||
|
async addItem(tenantId: string, cartId: string, data: AddItemDTO): Promise<ServiceResult<CartItem>> {
|
||||||
|
try {
|
||||||
|
const cart = await this.repository.findOne({ where: { id: cartId, tenantId } });
|
||||||
|
if (!cart) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Cart not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if item already exists
|
||||||
|
let item = await this.itemRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
cartId,
|
||||||
|
productId: data.productId,
|
||||||
|
variantId: data.variantId || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
// Update quantity
|
||||||
|
item.quantity = Number(item.quantity) + data.quantity;
|
||||||
|
} else {
|
||||||
|
// Create new item
|
||||||
|
item = this.itemRepository.create({
|
||||||
|
tenantId,
|
||||||
|
cartId,
|
||||||
|
...data,
|
||||||
|
taxRate: data.taxRate ?? 0.16,
|
||||||
|
taxIncluded: data.taxIncluded ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
this.calculateItemTotals(item);
|
||||||
|
item = await this.itemRepository.save(item);
|
||||||
|
|
||||||
|
// Recalculate cart totals
|
||||||
|
await this.recalculateCartTotals(tenantId, cartId);
|
||||||
|
|
||||||
|
return { success: true, data: item };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'ADD_ITEM_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update cart item
|
||||||
|
*/
|
||||||
|
async updateItem(tenantId: string, cartId: string, itemId: string, data: UpdateItemDTO): Promise<ServiceResult<CartItem>> {
|
||||||
|
try {
|
||||||
|
const item = await this.itemRepository.findOne({
|
||||||
|
where: { id: itemId, tenantId, cartId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Item not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.quantity !== undefined) {
|
||||||
|
if (data.quantity <= 0) {
|
||||||
|
// Remove item
|
||||||
|
await this.itemRepository.remove(item);
|
||||||
|
await this.recalculateCartTotals(tenantId, cartId);
|
||||||
|
return { success: true, data: item };
|
||||||
|
}
|
||||||
|
item.quantity = data.quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.savedForLater !== undefined) {
|
||||||
|
item.savedForLater = data.savedForLater;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.notes !== undefined) {
|
||||||
|
item.notes = data.notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.calculateItemTotals(item);
|
||||||
|
const updatedItem = await this.itemRepository.save(item);
|
||||||
|
await this.recalculateCartTotals(tenantId, cartId);
|
||||||
|
|
||||||
|
return { success: true, data: updatedItem };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'UPDATE_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove item from cart
|
||||||
|
*/
|
||||||
|
async removeItem(tenantId: string, cartId: string, itemId: string): Promise<ServiceResult<void>> {
|
||||||
|
try {
|
||||||
|
const item = await this.itemRepository.findOne({
|
||||||
|
where: { id: itemId, tenantId, cartId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Item not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.itemRepository.remove(item);
|
||||||
|
await this.recalculateCartTotals(tenantId, cartId);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'REMOVE_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply coupon to cart
|
||||||
|
*/
|
||||||
|
async applyCoupon(tenantId: string, cartId: string, data: ApplyCouponDTO): Promise<ServiceResult<Cart>> {
|
||||||
|
try {
|
||||||
|
const cart = await this.repository.findOne({ where: { id: cartId, tenantId } });
|
||||||
|
if (!cart) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Cart not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
cart.couponId = data.couponId;
|
||||||
|
cart.couponCode = data.couponCode;
|
||||||
|
cart.couponDiscount = data.discountAmount;
|
||||||
|
|
||||||
|
await this.repository.save(cart);
|
||||||
|
await this.recalculateCartTotals(tenantId, cartId);
|
||||||
|
|
||||||
|
const updatedCart = await this.repository.findOne({
|
||||||
|
where: { id: cartId, tenantId },
|
||||||
|
relations: ['items'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: updatedCart! };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'COUPON_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove coupon from cart
|
||||||
|
*/
|
||||||
|
async removeCoupon(tenantId: string, cartId: string): Promise<ServiceResult<Cart>> {
|
||||||
|
try {
|
||||||
|
const cart = await this.repository.findOne({ where: { id: cartId, tenantId } });
|
||||||
|
if (!cart) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Cart not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
cart.couponId = null as any;
|
||||||
|
cart.couponCode = null as any;
|
||||||
|
cart.couponDiscount = 0;
|
||||||
|
|
||||||
|
await this.repository.save(cart);
|
||||||
|
await this.recalculateCartTotals(tenantId, cartId);
|
||||||
|
|
||||||
|
const updatedCart = await this.repository.findOne({
|
||||||
|
where: { id: cartId, tenantId },
|
||||||
|
relations: ['items'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: updatedCart! };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'COUPON_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set shipping information
|
||||||
|
*/
|
||||||
|
async setShipping(tenantId: string, cartId: string, data: SetShippingDTO): Promise<ServiceResult<Cart>> {
|
||||||
|
try {
|
||||||
|
const cart = await this.repository.findOne({ where: { id: cartId, tenantId } });
|
||||||
|
if (!cart) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Cart not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
cart.shippingMethodId = data.shippingMethodId;
|
||||||
|
cart.shippingMethodName = data.shippingMethodName;
|
||||||
|
cart.shippingAmount = data.shippingAmount;
|
||||||
|
cart.shippingAddress = data.shippingAddress;
|
||||||
|
|
||||||
|
await this.repository.save(cart);
|
||||||
|
await this.recalculateCartTotals(tenantId, cartId);
|
||||||
|
|
||||||
|
const updatedCart = await this.repository.findOne({
|
||||||
|
where: { id: cartId, tenantId },
|
||||||
|
relations: ['items'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: updatedCart! };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'SHIPPING_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set billing information
|
||||||
|
*/
|
||||||
|
async setBilling(tenantId: string, cartId: string, data: SetBillingDTO): Promise<ServiceResult<Cart>> {
|
||||||
|
try {
|
||||||
|
const cart = await this.repository.findOne({ where: { id: cartId, tenantId } });
|
||||||
|
if (!cart) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Cart not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
cart.billingAddress = data.billingAddress;
|
||||||
|
cart.billingSameAsShipping = data.billingSameAsShipping ?? false;
|
||||||
|
cart.requiresInvoice = data.requiresInvoice ?? false;
|
||||||
|
if (data.invoiceRfc) cart.invoiceRfc = data.invoiceRfc;
|
||||||
|
if (data.invoiceUsoCfdi) cart.invoiceUsoCfdi = data.invoiceUsoCfdi;
|
||||||
|
|
||||||
|
const updatedCart = await this.repository.save(cart);
|
||||||
|
|
||||||
|
return { success: true, data: updatedCart };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'BILLING_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge guest cart into customer cart
|
||||||
|
*/
|
||||||
|
async mergeCarts(tenantId: string, guestSessionId: string, customerId: string): Promise<ServiceResult<Cart>> {
|
||||||
|
try {
|
||||||
|
const guestCart = await this.repository.findOne({
|
||||||
|
where: { tenantId, sessionId: guestSessionId, status: CartStatus.ACTIVE },
|
||||||
|
relations: ['items'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!guestCart || guestCart.items.length === 0) {
|
||||||
|
const customerResult = await this.getOrCreateCart(tenantId, { customerId });
|
||||||
|
return customerResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
let customerCart = await this.repository.findOne({
|
||||||
|
where: { tenantId, customerId, status: CartStatus.ACTIVE },
|
||||||
|
relations: ['items'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customerCart) {
|
||||||
|
// Transfer guest cart to customer
|
||||||
|
guestCart.customerId = customerId;
|
||||||
|
guestCart.sessionId = null as any;
|
||||||
|
const updatedCart = await this.repository.save(guestCart);
|
||||||
|
return { success: true, data: updatedCart };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge items
|
||||||
|
for (const guestItem of guestCart.items) {
|
||||||
|
const existingItem = customerCart.items.find(
|
||||||
|
(i) => i.productId === guestItem.productId && i.variantId === guestItem.variantId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
existingItem.quantity = Number(existingItem.quantity) + Number(guestItem.quantity);
|
||||||
|
this.calculateItemTotals(existingItem);
|
||||||
|
await this.itemRepository.save(existingItem);
|
||||||
|
} else {
|
||||||
|
guestItem.cartId = customerCart.id;
|
||||||
|
await this.itemRepository.save(guestItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark guest cart as merged
|
||||||
|
guestCart.status = CartStatus.MERGED;
|
||||||
|
guestCart.mergedIntoCartId = customerCart.id;
|
||||||
|
await this.repository.save(guestCart);
|
||||||
|
|
||||||
|
await this.recalculateCartTotals(tenantId, customerCart.id);
|
||||||
|
|
||||||
|
const mergedCart = await this.repository.findOne({
|
||||||
|
where: { id: customerCart.id, tenantId },
|
||||||
|
relations: ['items'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: mergedCart! };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'MERGE_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cart (remove all items)
|
||||||
|
*/
|
||||||
|
async clearCart(tenantId: string, cartId: string): Promise<ServiceResult<Cart>> {
|
||||||
|
try {
|
||||||
|
await this.itemRepository.delete({ tenantId, cartId });
|
||||||
|
|
||||||
|
const cart = await this.repository.findOne({ where: { id: cartId, tenantId } });
|
||||||
|
if (!cart) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Cart not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
cart.itemsCount = 0;
|
||||||
|
cart.itemsQuantity = 0;
|
||||||
|
cart.subtotal = 0;
|
||||||
|
cart.taxAmount = 0;
|
||||||
|
cart.discountAmount = 0;
|
||||||
|
cart.total = 0;
|
||||||
|
|
||||||
|
const updatedCart = await this.repository.save(cart);
|
||||||
|
return { success: true, data: updatedCart };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'CLEAR_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark cart as abandoned
|
||||||
|
*/
|
||||||
|
async markAbandoned(tenantId: string, cartId: string): Promise<ServiceResult<void>> {
|
||||||
|
try {
|
||||||
|
await this.repository.update(
|
||||||
|
{ id: cartId, tenantId, status: CartStatus.ACTIVE },
|
||||||
|
{ status: CartStatus.ABANDONED }
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'UPDATE_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate item totals
|
||||||
|
*/
|
||||||
|
private calculateItemTotals(item: CartItem): void {
|
||||||
|
const lineSubtotal = Number(item.quantity) * Number(item.unitPrice);
|
||||||
|
const discountAmount = Number(item.discountAmount) || lineSubtotal * (Number(item.discountPercent) / 100);
|
||||||
|
const afterDiscount = lineSubtotal - discountAmount;
|
||||||
|
|
||||||
|
let taxAmount: number;
|
||||||
|
if (item.taxIncluded) {
|
||||||
|
taxAmount = afterDiscount - afterDiscount / (1 + Number(item.taxRate));
|
||||||
|
} else {
|
||||||
|
taxAmount = afterDiscount * Number(item.taxRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.subtotal = Number((afterDiscount - (item.taxIncluded ? taxAmount : 0)).toFixed(2));
|
||||||
|
item.taxAmount = Number(taxAmount.toFixed(2));
|
||||||
|
item.total = Number((afterDiscount + (item.taxIncluded ? 0 : taxAmount)).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate cart totals
|
||||||
|
*/
|
||||||
|
private async recalculateCartTotals(tenantId: string, cartId: string): Promise<void> {
|
||||||
|
const items = await this.itemRepository.find({
|
||||||
|
where: { tenantId, cartId, savedForLater: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const cart = await this.repository.findOne({ where: { id: cartId, tenantId } });
|
||||||
|
if (!cart) return;
|
||||||
|
|
||||||
|
let itemsCount = 0;
|
||||||
|
let itemsQuantity = 0;
|
||||||
|
let subtotal = 0;
|
||||||
|
let taxAmount = 0;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
itemsCount++;
|
||||||
|
itemsQuantity += Number(item.quantity);
|
||||||
|
subtotal += Number(item.subtotal);
|
||||||
|
taxAmount += Number(item.taxAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
cart.itemsCount = itemsCount;
|
||||||
|
cart.itemsQuantity = itemsQuantity;
|
||||||
|
cart.subtotal = Number(subtotal.toFixed(2));
|
||||||
|
cart.taxAmount = Number(taxAmount.toFixed(2));
|
||||||
|
cart.discountAmount = Number(cart.couponDiscount) + items.reduce((sum, i) => sum + Number(i.discountAmount), 0);
|
||||||
|
|
||||||
|
const total = subtotal + taxAmount + Number(cart.shippingAmount) - Number(cart.couponDiscount) - Number(cart.loyaltyPointsValue);
|
||||||
|
cart.total = Number(Math.max(0, total).toFixed(2));
|
||||||
|
|
||||||
|
await this.repository.save(cart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cartService = new CartService();
|
||||||
|
export { CartService };
|
||||||
469
src/modules/ecommerce/services/ecommerce-order.service.ts
Normal file
469
src/modules/ecommerce/services/ecommerce-order.service.ts
Normal file
@ -0,0 +1,469 @@
|
|||||||
|
/**
|
||||||
|
* EcommerceOrderService - Gestión de pedidos e-commerce
|
||||||
|
* @module Ecommerce
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../../config/typeorm';
|
||||||
|
import { EcommerceOrder, EcommerceOrderStatus, PaymentStatus, FulfillmentType } from '../entities/ecommerce-order.entity';
|
||||||
|
import { EcommerceOrderLine } from '../entities/ecommerce-order-line.entity';
|
||||||
|
import { Cart, CartStatus } from '../entities/cart.entity';
|
||||||
|
import { CartItem } from '../entities/cart-item.entity';
|
||||||
|
|
||||||
|
interface ServiceResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: { code: string; message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateOrderDTO {
|
||||||
|
cartId: string;
|
||||||
|
customerEmail: string;
|
||||||
|
customerPhone?: string;
|
||||||
|
customerName: string;
|
||||||
|
paymentMethod?: string;
|
||||||
|
paymentGateway?: string;
|
||||||
|
fulfillmentType?: FulfillmentType;
|
||||||
|
pickupBranchId?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderFilters {
|
||||||
|
status?: EcommerceOrderStatus;
|
||||||
|
paymentStatus?: PaymentStatus;
|
||||||
|
customerId?: string;
|
||||||
|
dateFrom?: Date;
|
||||||
|
dateTo?: Date;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EcommerceOrderService {
|
||||||
|
private repository: Repository<EcommerceOrder>;
|
||||||
|
private lineRepository: Repository<EcommerceOrderLine>;
|
||||||
|
private cartRepository: Repository<Cart>;
|
||||||
|
private cartItemRepository: Repository<CartItem>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.repository = AppDataSource.getRepository(EcommerceOrder);
|
||||||
|
this.lineRepository = AppDataSource.getRepository(EcommerceOrderLine);
|
||||||
|
this.cartRepository = AppDataSource.getRepository(Cart);
|
||||||
|
this.cartItemRepository = AppDataSource.getRepository(CartItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate order number
|
||||||
|
*/
|
||||||
|
private generateOrderNumber(): string {
|
||||||
|
const date = new Date();
|
||||||
|
const dateStr = date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
|
return `ECO-${dateStr}-${random}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create order from cart
|
||||||
|
*/
|
||||||
|
async createFromCart(tenantId: string, data: CreateOrderDTO): Promise<ServiceResult<EcommerceOrder>> {
|
||||||
|
const queryRunner = AppDataSource.createQueryRunner();
|
||||||
|
await queryRunner.connect();
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cart = await this.cartRepository.findOne({
|
||||||
|
where: { id: data.cartId, tenantId, status: CartStatus.ACTIVE },
|
||||||
|
relations: ['items'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cart) {
|
||||||
|
return { success: false, error: { code: 'CART_NOT_FOUND', message: 'Cart not found or not active' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cart.items || cart.items.length === 0) {
|
||||||
|
return { success: false, error: { code: 'EMPTY_CART', message: 'Cart is empty' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create order
|
||||||
|
const order = this.repository.create({
|
||||||
|
tenantId,
|
||||||
|
number: this.generateOrderNumber(),
|
||||||
|
status: EcommerceOrderStatus.PENDING,
|
||||||
|
paymentStatus: PaymentStatus.PENDING,
|
||||||
|
fulfillmentType: data.fulfillmentType || FulfillmentType.SHIP,
|
||||||
|
customerId: cart.customerId,
|
||||||
|
customerEmail: data.customerEmail,
|
||||||
|
customerPhone: data.customerPhone,
|
||||||
|
customerName: data.customerName,
|
||||||
|
isGuest: !cart.customerId,
|
||||||
|
currencyCode: cart.currencyCode,
|
||||||
|
subtotal: cart.subtotal,
|
||||||
|
discountAmount: cart.discountAmount,
|
||||||
|
taxAmount: cart.taxAmount,
|
||||||
|
shippingAmount: cart.shippingAmount,
|
||||||
|
total: cart.total,
|
||||||
|
itemsCount: cart.itemsCount,
|
||||||
|
itemsQuantity: cart.itemsQuantity,
|
||||||
|
couponId: cart.couponId,
|
||||||
|
couponCode: cart.couponCode,
|
||||||
|
couponDiscount: cart.couponDiscount,
|
||||||
|
loyaltyPointsUsed: cart.loyaltyPointsToUse,
|
||||||
|
loyaltyPointsValue: cart.loyaltyPointsValue,
|
||||||
|
shippingAddress: cart.shippingAddress,
|
||||||
|
billingAddress: cart.billingAddress || cart.shippingAddress,
|
||||||
|
shippingMethodId: cart.shippingMethodId,
|
||||||
|
shippingMethodName: cart.shippingMethodName,
|
||||||
|
pickupBranchId: data.pickupBranchId,
|
||||||
|
paymentMethod: data.paymentMethod,
|
||||||
|
paymentGateway: data.paymentGateway,
|
||||||
|
requiresInvoice: cart.requiresInvoice,
|
||||||
|
invoiceRfc: cart.invoiceRfc,
|
||||||
|
invoiceUsoCfdi: cart.invoiceUsoCfdi,
|
||||||
|
isGift: cart.isGift,
|
||||||
|
giftMessage: cart.giftMessage,
|
||||||
|
customerNotes: data.notes || cart.notes,
|
||||||
|
cartId: cart.id,
|
||||||
|
utmSource: cart.utmSource,
|
||||||
|
utmMedium: cart.utmMedium,
|
||||||
|
utmCampaign: cart.utmCampaign,
|
||||||
|
deviceType: cart.deviceType,
|
||||||
|
ipAddress: cart.ipAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedOrder = await queryRunner.manager.save(order);
|
||||||
|
|
||||||
|
// Create order lines from cart items
|
||||||
|
const activeItems = cart.items.filter((item) => !item.savedForLater);
|
||||||
|
for (let i = 0; i < activeItems.length; i++) {
|
||||||
|
const item = activeItems[i];
|
||||||
|
const line = this.lineRepository.create({
|
||||||
|
tenantId,
|
||||||
|
orderId: savedOrder.id,
|
||||||
|
lineNumber: i + 1,
|
||||||
|
productId: item.productId,
|
||||||
|
productCode: item.productCode,
|
||||||
|
productName: item.productName,
|
||||||
|
productImage: item.productImage,
|
||||||
|
variantId: item.variantId,
|
||||||
|
variantName: item.variantName,
|
||||||
|
variantSku: item.variantSku,
|
||||||
|
variantOptions: item.variantOptions,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
originalPrice: item.originalPrice,
|
||||||
|
discountPercent: item.discountPercent,
|
||||||
|
discountAmount: item.discountAmount,
|
||||||
|
promotionId: item.promotionId,
|
||||||
|
promotionName: item.promotionName,
|
||||||
|
taxRate: item.taxRate,
|
||||||
|
taxAmount: item.taxAmount,
|
||||||
|
taxIncluded: item.taxIncluded,
|
||||||
|
subtotal: item.subtotal,
|
||||||
|
total: item.total,
|
||||||
|
warehouseId: item.warehouseId,
|
||||||
|
isGift: item.isGift,
|
||||||
|
giftMessage: item.giftMessage,
|
||||||
|
notes: item.notes,
|
||||||
|
});
|
||||||
|
await queryRunner.manager.save(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark cart as converted
|
||||||
|
cart.status = CartStatus.CONVERTED;
|
||||||
|
cart.convertedToOrderId = savedOrder.id;
|
||||||
|
cart.convertedAt = new Date();
|
||||||
|
await queryRunner.manager.save(cart);
|
||||||
|
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
|
const fullOrder = await this.repository.findOne({
|
||||||
|
where: { id: savedOrder.id, tenantId },
|
||||||
|
relations: ['lines'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: fullOrder! };
|
||||||
|
} catch (error: any) {
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
return { success: false, error: { code: 'CREATE_ORDER_ERROR', message: error.message } };
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get order by ID
|
||||||
|
*/
|
||||||
|
async getById(tenantId: string, id: string): Promise<ServiceResult<EcommerceOrder>> {
|
||||||
|
try {
|
||||||
|
const order = await this.repository.findOne({
|
||||||
|
where: { id, tenantId },
|
||||||
|
relations: ['lines'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: order };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'ORDER_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get order by number
|
||||||
|
*/
|
||||||
|
async getByNumber(tenantId: string, number: string): Promise<ServiceResult<EcommerceOrder>> {
|
||||||
|
try {
|
||||||
|
const order = await this.repository.findOne({
|
||||||
|
where: { number, tenantId },
|
||||||
|
relations: ['lines'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: order };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'ORDER_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List orders with filters
|
||||||
|
*/
|
||||||
|
async list(tenantId: string, filters: OrderFilters = {}): Promise<ServiceResult<{ data: EcommerceOrder[]; total: number }>> {
|
||||||
|
try {
|
||||||
|
const page = filters.page || 1;
|
||||||
|
const limit = filters.limit || 20;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const queryBuilder = this.repository
|
||||||
|
.createQueryBuilder('order')
|
||||||
|
.leftJoinAndSelect('order.lines', 'lines')
|
||||||
|
.where('order.tenant_id = :tenantId', { tenantId });
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
queryBuilder.andWhere('order.status = :status', { status: filters.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.paymentStatus) {
|
||||||
|
queryBuilder.andWhere('order.payment_status = :paymentStatus', { paymentStatus: filters.paymentStatus });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.customerId) {
|
||||||
|
queryBuilder.andWhere('order.customer_id = :customerId', { customerId: filters.customerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.dateFrom) {
|
||||||
|
queryBuilder.andWhere('order.created_at >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.dateTo) {
|
||||||
|
queryBuilder.andWhere('order.created_at <= :dateTo', { dateTo: filters.dateTo });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, total] = await queryBuilder
|
||||||
|
.orderBy('order.created_at', 'DESC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(limit)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return { success: true, data: { data, total } };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'LIST_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm order
|
||||||
|
*/
|
||||||
|
async confirm(tenantId: string, id: string): Promise<ServiceResult<EcommerceOrder>> {
|
||||||
|
try {
|
||||||
|
const order = await this.repository.findOne({ where: { id, tenantId } });
|
||||||
|
if (!order) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status !== EcommerceOrderStatus.PENDING) {
|
||||||
|
return { success: false, error: { code: 'INVALID_STATUS', message: 'Order cannot be confirmed' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
order.status = EcommerceOrderStatus.CONFIRMED;
|
||||||
|
order.confirmedAt = new Date();
|
||||||
|
const updatedOrder = await this.repository.save(order);
|
||||||
|
|
||||||
|
return { success: true, data: updatedOrder };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'CONFIRM_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update payment status
|
||||||
|
*/
|
||||||
|
async updatePaymentStatus(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
status: PaymentStatus,
|
||||||
|
transactionId?: string,
|
||||||
|
paymentDetails?: Record<string, any>
|
||||||
|
): Promise<ServiceResult<EcommerceOrder>> {
|
||||||
|
try {
|
||||||
|
const order = await this.repository.findOne({ where: { id, tenantId } });
|
||||||
|
if (!order) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
order.paymentStatus = status;
|
||||||
|
if (transactionId) order.paymentTransactionId = transactionId;
|
||||||
|
if (paymentDetails) order.paymentDetails = paymentDetails;
|
||||||
|
|
||||||
|
if (status === PaymentStatus.CAPTURED) {
|
||||||
|
order.amountPaid = order.total;
|
||||||
|
if (order.status === EcommerceOrderStatus.PENDING) {
|
||||||
|
order.status = EcommerceOrderStatus.CONFIRMED;
|
||||||
|
order.confirmedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedOrder = await this.repository.save(order);
|
||||||
|
return { success: true, data: updatedOrder };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'PAYMENT_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ship order
|
||||||
|
*/
|
||||||
|
async ship(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
data: { carrier?: string; trackingNumber?: string; trackingUrl?: string; estimatedDelivery?: Date }
|
||||||
|
): Promise<ServiceResult<EcommerceOrder>> {
|
||||||
|
try {
|
||||||
|
const order = await this.repository.findOne({ where: { id, tenantId } });
|
||||||
|
if (!order) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.fulfillmentType !== FulfillmentType.SHIP) {
|
||||||
|
return { success: false, error: { code: 'INVALID_FULFILLMENT', message: 'Order is not for shipping' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
order.status = EcommerceOrderStatus.SHIPPED;
|
||||||
|
order.shippedAt = new Date();
|
||||||
|
if (data.carrier) order.shippingCarrier = data.carrier;
|
||||||
|
if (data.trackingNumber) order.trackingNumber = data.trackingNumber;
|
||||||
|
if (data.trackingUrl) order.trackingUrl = data.trackingUrl;
|
||||||
|
if (data.estimatedDelivery) order.estimatedDelivery = data.estimatedDelivery;
|
||||||
|
|
||||||
|
const updatedOrder = await this.repository.save(order);
|
||||||
|
return { success: true, data: updatedOrder };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'SHIP_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark order as delivered
|
||||||
|
*/
|
||||||
|
async markDelivered(tenantId: string, id: string): Promise<ServiceResult<EcommerceOrder>> {
|
||||||
|
try {
|
||||||
|
const order = await this.repository.findOne({ where: { id, tenantId } });
|
||||||
|
if (!order) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
order.status = EcommerceOrderStatus.DELIVERED;
|
||||||
|
order.deliveredAt = new Date();
|
||||||
|
|
||||||
|
const updatedOrder = await this.repository.save(order);
|
||||||
|
return { success: true, data: updatedOrder };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'DELIVERY_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark order ready for pickup
|
||||||
|
*/
|
||||||
|
async markReadyForPickup(tenantId: string, id: string): Promise<ServiceResult<EcommerceOrder>> {
|
||||||
|
try {
|
||||||
|
const order = await this.repository.findOne({ where: { id, tenantId } });
|
||||||
|
if (!order) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.fulfillmentType !== FulfillmentType.PICKUP) {
|
||||||
|
return { success: false, error: { code: 'INVALID_FULFILLMENT', message: 'Order is not for pickup' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
order.status = EcommerceOrderStatus.READY_FOR_PICKUP;
|
||||||
|
order.pickupReadyAt = new Date();
|
||||||
|
|
||||||
|
const updatedOrder = await this.repository.save(order);
|
||||||
|
return { success: true, data: updatedOrder };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'PICKUP_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark order as picked up
|
||||||
|
*/
|
||||||
|
async markPickedUp(tenantId: string, id: string): Promise<ServiceResult<EcommerceOrder>> {
|
||||||
|
try {
|
||||||
|
const order = await this.repository.findOne({ where: { id, tenantId } });
|
||||||
|
if (!order) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
order.status = EcommerceOrderStatus.DELIVERED;
|
||||||
|
order.pickedUpAt = new Date();
|
||||||
|
order.deliveredAt = new Date();
|
||||||
|
|
||||||
|
const updatedOrder = await this.repository.save(order);
|
||||||
|
return { success: true, data: updatedOrder };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'PICKUP_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel order
|
||||||
|
*/
|
||||||
|
async cancel(tenantId: string, id: string, reason?: string): Promise<ServiceResult<EcommerceOrder>> {
|
||||||
|
try {
|
||||||
|
const order = await this.repository.findOne({ where: { id, tenantId } });
|
||||||
|
if (!order) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonCancellable = [EcommerceOrderStatus.SHIPPED, EcommerceOrderStatus.DELIVERED, EcommerceOrderStatus.CANCELLED];
|
||||||
|
if (nonCancellable.includes(order.status)) {
|
||||||
|
return { success: false, error: { code: 'INVALID_STATUS', message: 'Order cannot be cancelled' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
order.status = EcommerceOrderStatus.CANCELLED;
|
||||||
|
order.cancelledAt = new Date();
|
||||||
|
if (reason) order.cancellationReason = reason;
|
||||||
|
|
||||||
|
const updatedOrder = await this.repository.save(order);
|
||||||
|
return { success: true, data: updatedOrder };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'CANCEL_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get customer orders
|
||||||
|
*/
|
||||||
|
async getCustomerOrders(tenantId: string, customerId: string, page: number = 1, limit: number = 10): Promise<ServiceResult<{ data: EcommerceOrder[]; total: number }>> {
|
||||||
|
return this.list(tenantId, { customerId, page, limit });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ecommerceOrderService = new EcommerceOrderService();
|
||||||
|
export { EcommerceOrderService };
|
||||||
8
src/modules/ecommerce/services/index.ts
Normal file
8
src/modules/ecommerce/services/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Ecommerce Services Index
|
||||||
|
* @module Ecommerce
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './cart.service';
|
||||||
|
export * from './ecommerce-order.service';
|
||||||
|
export * from './shipping-rate.service';
|
||||||
357
src/modules/ecommerce/services/shipping-rate.service.ts
Normal file
357
src/modules/ecommerce/services/shipping-rate.service.ts
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
/**
|
||||||
|
* ShippingRateService - Gestión de tarifas de envío
|
||||||
|
* @module Ecommerce
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../../config/typeorm';
|
||||||
|
import { ShippingRate, ShippingRateStatus, ShippingCalculation } from '../entities/shipping-rate.entity';
|
||||||
|
|
||||||
|
interface ServiceResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: { code: string; message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateShippingRateDTO {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
calculationType: ShippingCalculation;
|
||||||
|
flatRate?: number;
|
||||||
|
weightRates?: { minWeight: number; maxWeight: number; rate: number }[];
|
||||||
|
priceRates?: { minPrice: number; maxPrice: number; rate: number }[];
|
||||||
|
quantityRates?: { minQuantity: number; maxQuantity: number; rate: number }[];
|
||||||
|
freeShippingThreshold?: number;
|
||||||
|
carrierCode?: string;
|
||||||
|
carrierName?: string;
|
||||||
|
carrierService?: string;
|
||||||
|
minDeliveryDays?: number;
|
||||||
|
maxDeliveryDays?: number;
|
||||||
|
deliveryMessage?: string;
|
||||||
|
allowedCountries?: string[];
|
||||||
|
allowedStates?: string[];
|
||||||
|
priority?: number;
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateShippingRateDTO extends Partial<CreateShippingRateDTO> {
|
||||||
|
status?: ShippingRateStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalculateShippingDTO {
|
||||||
|
postalCode: string;
|
||||||
|
country?: string;
|
||||||
|
state?: string;
|
||||||
|
cartSubtotal: number;
|
||||||
|
cartWeight?: number;
|
||||||
|
cartQuantity: number;
|
||||||
|
productIds?: string[];
|
||||||
|
categoryIds?: string[];
|
||||||
|
customerLevel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShippingOption {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
rate: number;
|
||||||
|
deliveryMessage?: string;
|
||||||
|
minDeliveryDays?: number;
|
||||||
|
maxDeliveryDays?: number;
|
||||||
|
carrierName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShippingRateService {
|
||||||
|
private repository: Repository<ShippingRate>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.repository = AppDataSource.getRepository(ShippingRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create shipping rate
|
||||||
|
*/
|
||||||
|
async create(tenantId: string, data: CreateShippingRateDTO, createdBy?: string): Promise<ServiceResult<ShippingRate>> {
|
||||||
|
try {
|
||||||
|
// Check code uniqueness
|
||||||
|
const existing = await this.repository.findOne({
|
||||||
|
where: { tenantId, code: data.code },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return { success: false, error: { code: 'DUPLICATE_CODE', message: 'Shipping rate code already exists' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = this.repository.create({
|
||||||
|
tenantId,
|
||||||
|
...data,
|
||||||
|
status: ShippingRateStatus.ACTIVE,
|
||||||
|
createdBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedRate = await this.repository.save(rate);
|
||||||
|
return { success: true, data: savedRate };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'CREATE_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get shipping rate by ID
|
||||||
|
*/
|
||||||
|
async getById(tenantId: string, id: string): Promise<ServiceResult<ShippingRate>> {
|
||||||
|
try {
|
||||||
|
const rate = await this.repository.findOne({
|
||||||
|
where: { id, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rate) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Shipping rate not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: rate };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'GET_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all shipping rates
|
||||||
|
*/
|
||||||
|
async list(tenantId: string, activeOnly: boolean = false): Promise<ServiceResult<ShippingRate[]>> {
|
||||||
|
try {
|
||||||
|
const where: any = { tenantId };
|
||||||
|
if (activeOnly) {
|
||||||
|
where.status = ShippingRateStatus.ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rates = await this.repository.find({
|
||||||
|
where,
|
||||||
|
order: { priority: 'ASC', name: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: rates };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'LIST_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update shipping rate
|
||||||
|
*/
|
||||||
|
async update(tenantId: string, id: string, data: UpdateShippingRateDTO): Promise<ServiceResult<ShippingRate>> {
|
||||||
|
try {
|
||||||
|
const rate = await this.repository.findOne({ where: { id, tenantId } });
|
||||||
|
if (!rate) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Shipping rate not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(rate, data);
|
||||||
|
const updatedRate = await this.repository.save(rate);
|
||||||
|
|
||||||
|
return { success: true, data: updatedRate };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'UPDATE_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete shipping rate
|
||||||
|
*/
|
||||||
|
async delete(tenantId: string, id: string): Promise<ServiceResult<void>> {
|
||||||
|
try {
|
||||||
|
const rate = await this.repository.findOne({ where: { id, tenantId } });
|
||||||
|
if (!rate) {
|
||||||
|
return { success: false, error: { code: 'NOT_FOUND', message: 'Shipping rate not found' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.repository.remove(rate);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'DELETE_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate available shipping options for cart
|
||||||
|
*/
|
||||||
|
async calculateShippingOptions(tenantId: string, data: CalculateShippingDTO): Promise<ServiceResult<ShippingOption[]>> {
|
||||||
|
try {
|
||||||
|
const rates = await this.repository.find({
|
||||||
|
where: { tenantId, status: ShippingRateStatus.ACTIVE },
|
||||||
|
order: { priority: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const options: ShippingOption[] = [];
|
||||||
|
|
||||||
|
for (const rate of rates) {
|
||||||
|
// Check geographic restrictions
|
||||||
|
if (!this.isLocationAllowed(rate, data)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check product/category restrictions
|
||||||
|
if (this.hasExcludedProducts(rate, data)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check customer level restrictions
|
||||||
|
if (!this.isCustomerLevelAllowed(rate, data.customerLevel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate rate
|
||||||
|
const calculatedRate = this.calculateRate(rate, data);
|
||||||
|
if (calculatedRate === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
id: rate.id,
|
||||||
|
code: rate.code,
|
||||||
|
name: rate.name,
|
||||||
|
displayName: rate.displayName || rate.name,
|
||||||
|
rate: calculatedRate,
|
||||||
|
deliveryMessage: rate.deliveryMessage,
|
||||||
|
minDeliveryDays: rate.minDeliveryDays,
|
||||||
|
maxDeliveryDays: rate.maxDeliveryDays,
|
||||||
|
carrierName: rate.carrierName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: options };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: { code: 'CALCULATE_ERROR', message: error.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if location is allowed
|
||||||
|
*/
|
||||||
|
private isLocationAllowed(rate: ShippingRate, data: CalculateShippingDTO): boolean {
|
||||||
|
// Check country
|
||||||
|
if (rate.allowedCountries && rate.allowedCountries.length > 0) {
|
||||||
|
if (!data.country || !rate.allowedCountries.includes(data.country)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check state
|
||||||
|
if (rate.allowedStates && rate.allowedStates.length > 0) {
|
||||||
|
if (!data.state || !rate.allowedStates.includes(data.state)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check postal code
|
||||||
|
if (rate.allowedPostalCodes && rate.allowedPostalCodes.length > 0) {
|
||||||
|
if (!rate.allowedPostalCodes.includes(data.postalCode)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check excluded postal codes
|
||||||
|
if (rate.excludedPostalCodes && rate.excludedPostalCodes.length > 0) {
|
||||||
|
if (rate.excludedPostalCodes.includes(data.postalCode)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if cart has excluded products
|
||||||
|
*/
|
||||||
|
private hasExcludedProducts(rate: ShippingRate, data: CalculateShippingDTO): boolean {
|
||||||
|
if (rate.excludedProducts && rate.excludedProducts.length > 0 && data.productIds) {
|
||||||
|
for (const productId of data.productIds) {
|
||||||
|
if (rate.excludedProducts.includes(productId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rate.excludedCategories && rate.excludedCategories.length > 0 && data.categoryIds) {
|
||||||
|
for (const categoryId of data.categoryIds) {
|
||||||
|
if (rate.excludedCategories.includes(categoryId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if customer level is allowed
|
||||||
|
*/
|
||||||
|
private isCustomerLevelAllowed(rate: ShippingRate, customerLevel?: string): boolean {
|
||||||
|
if (!rate.customerLevels || rate.customerLevels.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customerLevel) {
|
||||||
|
return true; // Allow guests if no level specified
|
||||||
|
}
|
||||||
|
|
||||||
|
return rate.customerLevels.includes(customerLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate shipping rate based on calculation type
|
||||||
|
*/
|
||||||
|
private calculateRate(rate: ShippingRate, data: CalculateShippingDTO): number | null {
|
||||||
|
// Check free shipping threshold
|
||||||
|
if (rate.freeShippingThreshold && data.cartSubtotal >= rate.freeShippingThreshold) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (rate.calculationType) {
|
||||||
|
case ShippingCalculation.FREE:
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case ShippingCalculation.FLAT_RATE:
|
||||||
|
return Number(rate.flatRate) || 0;
|
||||||
|
|
||||||
|
case ShippingCalculation.BY_WEIGHT:
|
||||||
|
if (!data.cartWeight || !rate.weightRates) return null;
|
||||||
|
for (const tier of rate.weightRates) {
|
||||||
|
if (data.cartWeight >= tier.minWeight && data.cartWeight <= tier.maxWeight) {
|
||||||
|
return tier.rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case ShippingCalculation.BY_PRICE:
|
||||||
|
if (!rate.priceRates) return null;
|
||||||
|
for (const tier of rate.priceRates) {
|
||||||
|
if (data.cartSubtotal >= tier.minPrice && data.cartSubtotal <= tier.maxPrice) {
|
||||||
|
return tier.rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case ShippingCalculation.BY_QUANTITY:
|
||||||
|
if (!rate.quantityRates) return null;
|
||||||
|
for (const tier of rate.quantityRates) {
|
||||||
|
if (data.cartQuantity >= tier.minQuantity && data.cartQuantity <= tier.maxQuantity) {
|
||||||
|
return tier.rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case ShippingCalculation.CARRIER_CALCULATED:
|
||||||
|
// Would need carrier API integration
|
||||||
|
return Number(rate.flatRate) || null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shippingRateService = new ShippingRateService();
|
||||||
|
export { ShippingRateService };
|
||||||
Loading…
Reference in New Issue
Block a user