import { Router, Request, Response, NextFunction } from 'express'; import { body, check, param, query } from 'express-validator'; import { Inject, Service } from 'typedi'; import { AbilitySubject, ISaleEstimateDTO, SaleEstimateAction, SaleEstimateMailOptionsDTO, } from '@/interfaces'; import BaseController from '@/api/controllers/BaseController'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import DynamicListingService from '@/services/DynamicListing/DynamicListService'; import { ServiceError } from '@/exceptions'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { SaleEstimatesApplication } from '@/services/Sales/Estimates/SaleEstimatesApplication'; const ACCEPT_TYPE = { APPLICATION_PDF: 'application/pdf', APPLICATION_JSON: 'application/json', }; @Service() export default class SalesEstimatesController extends BaseController { @Inject() private saleEstimatesApplication: SaleEstimatesApplication; @Inject() private dynamicListService: DynamicListingService; /** * Router constructor. */ public router() { const router = Router(); router.post( '/', CheckPolicies(SaleEstimateAction.Create, AbilitySubject.SaleEstimate), [...this.estimateValidationSchema], this.validationResult, asyncMiddleware(this.newEstimate.bind(this)), this.handleServiceErrors ); router.post( '/:id/deliver', CheckPolicies(SaleEstimateAction.Edit, AbilitySubject.SaleEstimate), [...this.validateSpecificEstimateSchema], this.validationResult, asyncMiddleware(this.deliverSaleEstimate.bind(this)), this.handleServiceErrors ); router.post( '/:id/approve', CheckPolicies(SaleEstimateAction.Edit, AbilitySubject.SaleEstimate), [...this.validateSpecificEstimateSchema], this.validationResult, asyncMiddleware(this.approveSaleEstimate.bind(this)), this.handleServiceErrors ); router.post( '/:id/reject', CheckPolicies(SaleEstimateAction.Edit, AbilitySubject.SaleEstimate), [...this.validateSpecificEstimateSchema], this.validationResult, asyncMiddleware(this.rejectSaleEstimate.bind(this)), this.handleServiceErrors ); router.get( '/:id/sms-details', CheckPolicies( SaleEstimateAction.NotifyBySms, AbilitySubject.SaleEstimate ), [param('id').exists().isNumeric().toInt()], this.validationResult, this.asyncMiddleware(this.saleEstimateSmsDetails), this.handleServiceErrors ); router.post( '/:id/notify-by-sms', CheckPolicies( SaleEstimateAction.NotifyBySms, AbilitySubject.SaleEstimate ), [param('id').exists().isNumeric().toInt()], this.validationResult, this.asyncMiddleware(this.saleEstimateNotifyBySms), this.handleServiceErrors ); router.post( '/:id', CheckPolicies(SaleEstimateAction.Edit, AbilitySubject.SaleEstimate), [ ...this.validateSpecificEstimateSchema, ...this.estimateValidationSchema, ], this.validationResult, asyncMiddleware(this.editEstimate.bind(this)), this.handleServiceErrors ); router.delete( '/:id', CheckPolicies(SaleEstimateAction.Delete, AbilitySubject.SaleEstimate), [this.validateSpecificEstimateSchema], this.validationResult, asyncMiddleware(this.deleteEstimate.bind(this)), this.handleServiceErrors ); router.get( '/state', CheckPolicies(SaleEstimateAction.View, AbilitySubject.SaleEstimate), this.getSaleEstimateState.bind(this), this.handleServiceErrors ); router.get( '/:id', CheckPolicies(SaleEstimateAction.View, AbilitySubject.SaleEstimate), this.validateSpecificEstimateSchema, this.validationResult, asyncMiddleware(this.getEstimate.bind(this)), this.handleServiceErrors ); router.get( '/', CheckPolicies(SaleEstimateAction.View, AbilitySubject.SaleEstimate), this.validateEstimateListSchema, this.validationResult, asyncMiddleware(this.getEstimates.bind(this)), this.handleServiceErrors, this.dynamicListService.handlerErrorsToResponse ); router.post( '/:id/mail', [ ...this.validateSpecificEstimateSchema, body('subject').isString().optional(), body('from').isString().optional(), body('to').isString().optional(), body('body').isString().optional(), body('attach_invoice').optional().isBoolean().toBoolean(), ], this.validationResult, asyncMiddleware(this.sendSaleEstimateMail.bind(this)), this.handleServiceErrors ); router.get( '/:id/mail', [...this.validateSpecificEstimateSchema], this.validationResult, asyncMiddleware(this.getSaleEstimateMail.bind(this)), this.handleServiceErrors ); return router; } /** * Estimate validation schema. */ private get estimateValidationSchema() { return [ check('customer_id').exists().isNumeric().toInt(), check('estimate_date').exists().isISO8601().toDate(), check('expiration_date').exists().isISO8601().toDate(), check('reference').optional(), check('estimate_number').optional().trim(), check('delivered').default(false).isBoolean().toBoolean(), check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(), check('warehouse_id').optional({ nullable: true }).isNumeric().toInt(), check('branch_id').optional({ nullable: true }).isNumeric().toInt(), check('entries').exists().isArray({ min: 1 }), check('entries.*.index').exists().isNumeric().toInt(), check('entries.*.item_id').exists().isNumeric().toInt(), check('entries.*.quantity').exists().isNumeric().toInt(), check('entries.*.rate').exists().isNumeric().toFloat(), check('entries.*.description').optional({ nullable: true }).trim(), check('entries.*.discount') .optional({ nullable: true }) .isNumeric() .toFloat(), check('entries.*.warehouse_id') .optional({ nullable: true }) .isNumeric() .toInt(), check('note').optional().trim(), check('terms_conditions').optional().trim(), check('send_to_email').optional().trim(), check('attachments').isArray().optional(), check('attachments.*.key').exists().isString(), // Pdf template id. check('pdf_template_id').optional({ nullable: true }).isNumeric().toInt(), ]; } /** * Specific sale estimate validation schema. */ private get validateSpecificEstimateSchema() { return [param('id').exists().isNumeric().toInt()]; } /** * Sales estimates list validation schema. */ private get validateEstimateListSchema() { return [ query('view_slug').optional().isString().trim(), query('stringified_filter_roles').optional().isJSON(), query('column_sort_by').optional(), query('sort_order').optional().isIn(['desc', 'asc']), query('page').optional().isNumeric().toInt(), query('page_size').optional().isNumeric().toInt(), query('search_keyword').optional({ nullable: true }).isString().trim(), ]; } /** * Handle create a new estimate with associated entries. * @param {Request} req - * @param {Response} res - * @return {Response} res - */ private async newEstimate(req: Request, res: Response, next: NextFunction) { const { tenantId } = req; const estimateDTO: ISaleEstimateDTO = this.matchedBodyData(req); try { const storedEstimate = await this.saleEstimatesApplication.createSaleEstimate( tenantId, estimateDTO ); return res.status(200).send({ id: storedEstimate.id, message: 'The sale estimate has been created successfully.', }); } catch (error) { next(error); } } /** * Handle update estimate details with associated entries. * @param {Request} req * @param {Response} res */ private async editEstimate(req: Request, res: Response, next: NextFunction) { const { id: estimateId } = req.params; const { tenantId } = req; const estimateDTO: ISaleEstimateDTO = this.matchedBodyData(req); try { // Update estimate with associated estimate entries. await this.saleEstimatesApplication.editSaleEstimate( tenantId, estimateId, estimateDTO ); return res.status(200).send({ id: estimateId, message: 'The sale estimate has been created successfully.', }); } catch (error) { next(error); } } /** * Deletes the given estimate with associated entries. * @param {Request} req * @param {Response} res */ private async deleteEstimate( req: Request, res: Response, next: NextFunction ) { const { id: estimateId } = req.params; const { tenantId } = req; try { await this.saleEstimatesApplication.deleteSaleEstimate( tenantId, estimateId ); return res.status(200).send({ id: estimateId, message: 'The sale estimate has been deleted successfully.', }); } catch (error) { next(error); } } /** * Deliver the given sale estimate. * @param {Request} req * @param {Response} res */ private async deliverSaleEstimate( req: Request, res: Response, next: NextFunction ) { const { id: estimateId } = req.params; const { tenantId } = req; try { await this.saleEstimatesApplication.deliverSaleEstimate( tenantId, estimateId ); return res.status(200).send({ id: estimateId, message: 'The sale estimate has been delivered successfully.', }); } catch (error) { next(error); } } /** * Marks the sale estimate as approved. * @param {Request} req * @param {Response} res * @param {NextFunction} next */ private async approveSaleEstimate( req: Request, res: Response, next: NextFunction ) { const { id: estimateId } = req.params; const { tenantId } = req; try { await this.saleEstimatesApplication.approveSaleEstimate( tenantId, estimateId ); return res.status(200).send({ id: estimateId, message: 'The sale estimate has been approved successfully.', }); } catch (error) { next(error); } } /** * Marks the sale estimate as rejected. * @param {Request} req * @param {Response} res * @param {NextFunction} next */ private async rejectSaleEstimate( req: Request, res: Response, next: NextFunction ) { const { id: estimateId } = req.params; const { tenantId } = req; try { await this.saleEstimatesApplication.rejectSaleEstimate( tenantId, estimateId ); return res.status(200).send({ id: estimateId, message: 'The sale estimate has been rejected successfully.', }); } catch (error) { next(error); } } /** * Retrieve the given estimate with associated entries. * @param {Request} req * @param {Response} res * @param {NextFunction} next */ private async getEstimate(req: Request, res: Response, next: NextFunction) { const { id: estimateId } = req.params; const { tenantId } = req; const accept = this.accepts(req); const acceptType = accept.types([ ACCEPT_TYPE.APPLICATION_JSON, ACCEPT_TYPE.APPLICATION_PDF, ]); // Retrieves estimate in pdf format. if (ACCEPT_TYPE.APPLICATION_PDF == acceptType) { const [pdfContent, filename] = await this.saleEstimatesApplication.getSaleEstimatePdf( tenantId, estimateId ); res.set({ 'Content-Type': 'application/pdf', 'Content-Length': pdfContent.length, 'Content-Disposition': `attachment; filename="${filename}"`, }); res.send(pdfContent); // Retrieves estimates in json format. } else { const estimate = await this.saleEstimatesApplication.getSaleEstimate( tenantId, estimateId ); return res.status(200).send({ estimate }); } } /** * Retrieve estimates with pagination metadata. * @param {Request} req * @param {Response} res */ private async getEstimates(req: Request, res: Response, next: NextFunction) { const { tenantId } = req; const filter = { sortOrder: 'desc', columnSortBy: 'created_at', page: 1, pageSize: 12, ...this.matchedQueryData(req), }; try { const salesEstimatesWithPagination = await this.saleEstimatesApplication.getSaleEstimates(tenantId, filter); return res.status(200).send(salesEstimatesWithPagination); } catch (error) { next(error); } } private saleEstimateNotifyBySms = async ( req: Request, res: Response, next: NextFunction ) => { const { tenantId } = req; const { id: estimateId } = req.params; try { const saleEstimate = await this.saleEstimatesApplication.notifySaleEstimateBySms( tenantId, estimateId ); return res.status(200).send({ id: saleEstimate.id, message: 'The sale estimate sms notification has been sent successfully.', }); } catch (error) { next(error); } }; /** * Retrieve the sale estimate sms notification message details. * @param {Request} req * @param {Response} res * @param {NextFunction} next */ private saleEstimateSmsDetails = async ( req: Request, res: Response, next: NextFunction ) => { const { tenantId } = req; const { id: estimateId } = req.params; try { const estimateSmsDetails = await this.saleEstimatesApplication.getSaleEstimateSmsDetails( tenantId, estimateId ); return res.status(200).send({ data: estimateSmsDetails, }); } catch (error) { next(error); } }; /** * Send the sale estimate mail. * @param {Request} req * @param {Response} res * @param {NextFunction} next */ private sendSaleEstimateMail = async ( req: Request, res: Response, next: NextFunction ) => { const { tenantId } = req; const { id: invoiceId } = req.params; const saleEstimateDTO: SaleEstimateMailOptionsDTO = this.matchedBodyData( req, { includeOptionals: false, } ); try { await this.saleEstimatesApplication.sendSaleEstimateMail( tenantId, invoiceId, saleEstimateDTO ); return res.status(200).send({ code: 200, message: 'The sale estimate mail has been sent successfully.', }); } catch (error) { next(error); } }; /** * Retrieves the default mail options of the given sale estimate. * @param {Request} req * @param {Response} res * @param {NextFunction} next */ private getSaleEstimateMail = async ( req: Request, res: Response, next: NextFunction ) => { const { tenantId } = req; const { id: invoiceId } = req.params; try { const data = await this.saleEstimatesApplication.getSaleEstimateMail( tenantId, invoiceId ); return res.status(200).send({ data }); } catch (error) { next(error); } }; private getSaleEstimateState = async ( req: Request, res: Response, next: NextFunction ) => { const { tenantId } = req; try { const data = await this.saleEstimatesApplication.getSaleEstimateState( tenantId ); return res.status(200).send({ data }); } catch (error) { next(error); } }; /** * Handles service errors. * @param {Error} error * @param {Request} req * @param {Response} res * @param {NextFunction} next */ private handleServiceErrors( error: Error, req: Request, res: Response, next: NextFunction ) { if (error instanceof ServiceError) { if (error.errorType === 'ITEMS_NOT_FOUND') { return res.boom.badRequest(null, { errors: [{ type: 'ITEMS.IDS.NOT.EXISTS', code: 100 }], }); } if (error.errorType === 'ENTRIES_IDS_NOT_FOUND') { return res.boom.badRequest(null, { errors: [{ type: 'ENTRIES.IDS.NOT.EXISTS', code: 200 }], }); } if (error.errorType === 'ITEMS_IDS_NOT_EXISTS') { return res.boom.badRequest(null, { errors: [{ type: 'ITEMS.IDS.NOT.EXISTS', code: 300 }], }); } if (error.errorType === 'NOT_PURCHASE_ABLE_ITEMS') { return res.boom.badRequest(null, { errors: [{ type: 'NOT_PURCHASABLE_ITEMS', code: 400 }], }); } if (error.errorType === 'SALE_ESTIMATE_NOT_FOUND') { return res.boom.badRequest(null, { errors: [{ type: 'SALE_ESTIMATE_NOT_FOUND', code: 500 }], }); } if (error.errorType === 'CUSTOMER_NOT_FOUND') { return res.boom.badRequest(null, { errors: [{ type: 'CUSTOMER_NOT_FOUND', code: 600 }], }); } if (error.errorType === 'SALE_ESTIMATE_NUMBER_EXISTANCE') { return res.boom.badRequest(null, { errors: [{ type: 'ESTIMATE.NUMBER.IS.NOT.UNQIUE', code: 700 }], }); } if (error.errorType === 'NOT_SELL_ABLE_ITEMS') { return res.boom.badRequest(null, { errors: [{ type: 'NOT_SELL_ABLE_ITEMS', code: 800 }], }); } if (error.errorType === 'SALE_ESTIMATE_ALREADY_APPROVED') { return res.boom.badRequest(null, { errors: [{ type: 'CUSTOMER_NOT_FOUND', code: 1000 }], }); } if (error.errorType === 'SALE_ESTIMATE_NOT_DELIVERED') { return res.boom.badRequest(null, { errors: [{ type: 'SALE_ESTIMATE_NOT_DELIVERED', code: 1100 }], }); } if (error.errorType === 'SALE_ESTIMATE_ALREADY_REJECTED') { return res.boom.badRequest(null, { errors: [{ type: 'SALE_ESTIMATE_ALREADY_REJECTED', code: 1200 }], }); } if (error.errorType === 'contact_not_found') { return res.boom.badRequest(null, { errors: [{ type: 'CUSTOMER_NOT_FOUND', code: 1300 }], }); } if (error.errorType === 'SALE_ESTIMATE_NO_IS_REQUIRED') { return res.boom.badRequest(null, { errors: [{ type: 'SALE_ESTIMATE_NO_IS_REQUIRED', code: 1400 }], }); } if (error.errorType === 'SALE_ESTIMATE_CONVERTED_TO_INVOICE') { return res.boom.badRequest(null, { errors: [{ type: 'SALE_ESTIMATE_CONVERTED_TO_INVOICE', code: 1500 }], }); } if (error.errorType === 'SALE_ESTIMATE_ALREADY_DELIVERED') { return res.boom.badRequest(null, { errors: [{ type: 'SALE_ESTIMATE_ALREADY_DELIVERED', code: 1600 }], }); } if (error.errorType === 'CUSTOMER_HAS_NO_PHONE_NUMBER') { return res.boom.badRequest(null, { errors: [{ type: 'CUSTOMER_HAS_NO_PHONE_NUMBER', code: 1800 }], }); } if (error.errorType === 'CUSTOMER_SMS_NOTIFY_PHONE_INVALID') { return res.boom.badRequest(null, { errors: [{ type: 'CUSTOMER_SMS_NOTIFY_PHONE_INVALID', code: 1900 }], }); } if (error.errorType === 'TRANSACTIONS_DATE_LOCKED') { return res.boom.badRequest(null, { errors: [ { type: 'TRANSACTIONS_DATE_LOCKED', code: 4000, data: { ...error.payload }, }, ], }); } if (error.errorType === 'WAREHOUSE_ID_NOT_FOUND') { return res.boom.badRequest(null, { errors: [{ type: 'WAREHOUSE_ID_NOT_FOUND', code: 5000 }], }); } if (error.errorType === 'BRANCH_ID_REQUIRED') { return res.boom.badRequest(null, { errors: [{ type: 'BRANCH_ID_REQUIRED', code: 5100 }], }); } if (error.errorType === 'BRANCH_ID_NOT_FOUND') { return res.boom.badRequest(null, { errors: [{ type: 'BRANCH_ID_NOT_FOUND', code: 5300 }], }); } } next(error); } }