feat(nestjs): migrate to NestJS

This commit is contained in:
Ahmed Bouhuolia
2025-04-07 11:51:24 +02:00
parent f068218a16
commit 55fcc908ef
3779 changed files with 631 additions and 195332 deletions

View File

@@ -0,0 +1,69 @@
import { Inject, Injectable } from '@nestjs/common';
import {
ISaleEstimateApprovedEvent,
ISaleEstimateApprovingEvent,
} from '../types/SaleEstimates.types';
import { ERRORS } from '../constants';
import { Knex } from 'knex';
import * as moment from 'moment';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { SaleEstimate } from '../models/SaleEstimate';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { ServiceError } from '@/modules/Items/ServiceError';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class ApproveSaleEstimateService {
constructor(
@Inject(SaleEstimate.name)
private saleEstimateModel: TenantModelProxy<typeof SaleEstimate>,
private uow: UnitOfWork,
private eventPublisher: EventEmitter2,
) {}
/**
* Mark the sale estimate as approved from the customer.
* @param {number} saleEstimateId
* @return {Promise<void>}
*/
public async approveSaleEstimate(saleEstimateId: number): Promise<void> {
// Retrieve details of the given sale estimate id.
const oldSaleEstimate = await this.saleEstimateModel()
.query()
.findById(saleEstimateId)
.throwIfNotFound();
// Throws error in case the sale estimate still not delivered to customer.
if (!oldSaleEstimate.isDelivered) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_DELIVERED);
}
// Throws error in case the sale estimate already approved.
if (oldSaleEstimate.isApproved) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_ALREADY_APPROVED);
}
// Triggers `onSaleEstimateApproving` event.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers `onSaleEstimateApproving` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onApproving, {
trx,
oldSaleEstimate,
} as ISaleEstimateApprovingEvent);
// Update estimate as approved.
const saleEstimate = await this.saleEstimateModel()
.query(trx)
.patchAndFetchById(saleEstimateId, {
approvedAt: moment().toMySqlDateTime(),
rejectedAt: null,
});
// Triggers `onSaleEstimateApproved` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onApproved, {
trx,
oldSaleEstimate,
saleEstimate,
} as ISaleEstimateApprovedEvent);
});
}
}

View File

@@ -0,0 +1,46 @@
import * as moment from 'moment';
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Knex } from 'knex';
import { SaleEstimate } from '../models/SaleEstimate';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class ConvertSaleEstimate {
constructor(
private readonly eventPublisher: EventEmitter2,
@Inject(SaleEstimate.name)
private readonly saleEstimateModel: TenantModelProxy<typeof SaleEstimate>,
) {}
/**
* Converts estimate to invoice.
* @param {number} estimateId -
* @return {Promise<void>}
*/
public async convertEstimateToInvoice(
estimateId: number,
invoiceId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieve details of the given sale estimate.
const saleEstimate = await this.saleEstimateModel()
.query()
.findById(estimateId)
.throwIfNotFound();
// Marks the estimate as converted from the givne invoice.
await this.saleEstimateModel().query(trx).where('id', estimateId).patch({
convertedToInvoiceId: invoiceId,
convertedToInvoiceAt: moment().toMySqlDateTime(),
});
// Triggers `onSaleEstimateConvertedToInvoice` event.
await this.eventPublisher.emitAsync(
events.saleEstimate.onConvertedToInvoice,
{},
);
}
}

View File

@@ -0,0 +1,92 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import {
ISaleEstimateCreatedPayload,
ISaleEstimateCreatingPayload,
ISaleEstimateDTO,
} from '../types/SaleEstimates.types';
import { SaleEstimateDTOTransformer } from './SaleEstimateDTOTransformer.service';
import { SaleEstimateValidators } from './SaleEstimateValidators.service';
import { SaleEstimate } from '../models/SaleEstimate';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { ItemsEntriesService } from '@/modules/Items/ItemsEntries.service';
import { Customer } from '@/modules/Customers/models/Customer';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { CreateSaleEstimateDto } from '../dtos/SaleEstimate.dto';
@Injectable()
export class CreateSaleEstimate {
constructor(
@Inject(SaleEstimate.name)
private saleEstimateModel: TenantModelProxy<typeof SaleEstimate>,
@Inject(Customer.name)
private customerModel: TenantModelProxy<typeof Customer>,
private itemsEntriesService: ItemsEntriesService,
private eventPublisher: EventEmitter2,
private uow: UnitOfWork,
private transformerDTO: SaleEstimateDTOTransformer,
private validators: SaleEstimateValidators,
) {}
/**
* Creates a new estimate with associated entries.
* @param {ISaleEstimateDTO} estimateDTO - Sale estimate DTO object.
* @return {Promise<ISaleEstimate>}
*/
public async createEstimate(
estimateDTO: CreateSaleEstimateDto,
trx?: Knex.Transaction,
): Promise<SaleEstimate> {
// Retrieve the given customer or throw not found service error.
const customer = await this.customerModel()
.query()
.findById(estimateDTO.customerId)
.throwIfNotFound();
// Transform DTO object to model object.
const estimateObj = await this.transformerDTO.transformDTOToModel(
estimateDTO,
customer,
);
// Validate estimate number uniquiness on the storage.
await this.validators.validateEstimateNumberExistance(
estimateObj.estimateNumber,
);
// Validate items IDs existance on the storage.
await this.itemsEntriesService.validateItemsIdsExistance(
estimateDTO.entries,
);
// Validate non-sellable items.
await this.itemsEntriesService.validateNonSellableEntriesItems(
estimateDTO.entries,
);
// Creates a sale estimate transaction with associated transactions as UOW.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers `onSaleEstimateCreating` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onCreating, {
estimateDTO,
trx,
} as ISaleEstimateCreatingPayload);
// Upsert the sale estimate graph to the storage.
const saleEstimate = await this.saleEstimateModel()
.query(trx)
.upsertGraphAndFetch({
...estimateObj,
});
// Triggers `onSaleEstimateCreated` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onCreated, {
saleEstimate,
saleEstimateId: saleEstimate.id,
saleEstimateDTO: estimateDTO,
trx,
} as ISaleEstimateCreatedPayload);
return saleEstimate;
}, trx);
}
}

View File

@@ -0,0 +1,75 @@
import { Inject, Injectable } from '@nestjs/common';
import {
ISaleEstimateDeletedPayload,
ISaleEstimateDeletingPayload,
} from '../types/SaleEstimates.types';
import { ERRORS } from '../constants';
import { Knex } from 'knex';
import { SaleEstimate } from '../models/SaleEstimate';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { ServiceError } from '@/modules/Items/ServiceError';
import { ItemEntry } from '@/modules/TransactionItemEntry/models/ItemEntry';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class DeleteSaleEstimate {
constructor(
@Inject(SaleEstimate.name)
private readonly saleEstimateModel: TenantModelProxy<typeof SaleEstimate>,
@Inject(ItemEntry.name)
private readonly itemEntryModel: TenantModelProxy<typeof ItemEntry>,
private readonly eventPublisher: EventEmitter2,
private readonly uow: UnitOfWork,
) {}
/**
* Deletes the given estimate id with associated entries.
* @async
* @param {number} estimateId
* @return {Promise<void>}
*/
public async deleteEstimate(estimateId: number): Promise<void> {
// Retrieve sale estimate or throw not found service error.
const oldSaleEstimate = await this.saleEstimateModel()
.query()
.findById(estimateId)
.throwIfNotFound();
// Throw error if the sale estimate converted to sale invoice.
if (oldSaleEstimate.convertedToInvoiceId) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_CONVERTED_TO_INVOICE);
}
// Updates the estimate with associated transactions under UOW enivrement.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers `onSaleEstimatedDeleting` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onDeleting, {
trx,
oldSaleEstimate,
} as ISaleEstimateDeletingPayload);
// Delete sale estimate entries.
await this.itemEntryModel()
.query(trx)
.where('reference_id', estimateId)
.where('reference_type', 'SaleEstimate')
.delete();
// Delete sale estimate transaction.
await this.saleEstimateModel()
.query(trx)
.where('id', estimateId)
.delete();
// Triggers `onSaleEstimatedDeleted` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onDeleted, {
saleEstimateId: estimateId,
oldSaleEstimate,
trx,
} as ISaleEstimateDeletedPayload);
});
}
}

View File

@@ -0,0 +1,65 @@
import { Knex } from 'knex';
import * as moment from 'moment';
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
ISaleEstimateEventDeliveredPayload,
ISaleEstimateEventDeliveringPayload,
} from '../types/SaleEstimates.types';
import { ERRORS } from '../constants';
import { SaleEstimate } from '../models/SaleEstimate';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { ServiceError } from '@/modules/Items/ServiceError';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class DeliverSaleEstimateService {
constructor(
@Inject(SaleEstimate.name)
private readonly saleEstimateModel: TenantModelProxy<typeof SaleEstimate>,
private readonly eventPublisher: EventEmitter2,
private readonly uow: UnitOfWork,
) {}
/**
* Mark the sale estimate as delivered.
* @param {number} saleEstimateId - Sale estimate id.
*/
public async deliverSaleEstimate(saleEstimateId: number): Promise<void> {
// Retrieve details of the given sale estimate id.
const oldSaleEstimate = await this.saleEstimateModel()
.query()
.findById(saleEstimateId)
.throwIfNotFound();
// Throws error in case the sale estimate already published.
if (oldSaleEstimate.isDelivered) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_ALREADY_DELIVERED);
}
// Updates the sale estimate transaction with assocaited transactions
// under UOW envirement.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers `onSaleEstimateDelivering` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onDelivering, {
oldSaleEstimate,
trx,
} as ISaleEstimateEventDeliveringPayload);
// Record the delivered at on the storage.
const saleEstimate = await this.saleEstimateModel()
.query(trx)
.patchAndFetchById(saleEstimateId, {
deliveredAt: moment().toMySqlDateTime(),
});
// Triggers `onSaleEstimateDelivered` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onDelivered, {
saleEstimate,
trx,
} as ISaleEstimateEventDeliveredPayload);
});
}
}

View File

@@ -0,0 +1,117 @@
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
ISaleEstimateDTO,
ISaleEstimateEditedPayload,
ISaleEstimateEditingPayload,
} from '../types/SaleEstimates.types';
import { SaleEstimateValidators } from './SaleEstimateValidators.service';
import { SaleEstimateDTOTransformer } from './SaleEstimateDTOTransformer.service';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { SaleEstimate } from '../models/SaleEstimate';
import { ItemsEntriesService } from '@/modules/Items/ItemsEntries.service';
import { Customer } from '@/modules/Customers/models/Customer';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { EditSaleEstimateDto } from '../dtos/SaleEstimate.dto';
@Injectable()
export class EditSaleEstimate {
constructor(
private readonly validators: SaleEstimateValidators,
private readonly itemsEntriesService: ItemsEntriesService,
private readonly eventPublisher: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly transformerDTO: SaleEstimateDTOTransformer,
@Inject(SaleEstimate.name)
private readonly saleEstimateModel: TenantModelProxy<typeof SaleEstimate>,
@Inject(Customer.name)
private readonly customerModel: TenantModelProxy<typeof Customer>,
) {}
/**
* Edit details of the given estimate with associated entries.
* @async
* @param {Integer} estimateId
* @param {EstimateDTO} estimate
* @return {Promise<ISaleEstimate>}
*/
public async editEstimate(
estimateId: number,
estimateDTO: EditSaleEstimateDto,
): Promise<SaleEstimate> {
// Retrieve details of the given sale estimate id.
const oldSaleEstimate = await this.saleEstimateModel()
.query()
.findById(estimateId);
// Validates the given estimate existance.
this.validators.validateEstimateExistance(oldSaleEstimate);
// Retrieve the given customer or throw not found service error.
const customer = await this.customerModel()
.query()
.findById(estimateDTO.customerId)
.throwIfNotFound();
// Transform DTO object to model object.
const estimateObj = await this.transformerDTO.transformDTOToModel(
estimateDTO,
customer,
oldSaleEstimate,
);
// Validate estimate number uniquiness on the storage.
if (estimateDTO.estimateNumber) {
await this.validators.validateEstimateNumberExistance(
estimateDTO.estimateNumber,
estimateId,
);
}
// Validate sale estimate entries existance.
await this.itemsEntriesService.validateEntriesIdsExistance(
estimateId,
'SaleEstimate',
estimateDTO.entries,
);
// Validate items IDs existance on the storage.
await this.itemsEntriesService.validateItemsIdsExistance(
estimateDTO.entries,
);
// Validate non-sellable items.
await this.itemsEntriesService.validateNonSellableEntriesItems(
estimateDTO.entries,
);
// Edits estimate transaction with associated transactions
// under unit-of-work envirement.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Trigger `onSaleEstimateEditing` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onEditing, {
oldSaleEstimate,
estimateDTO,
trx,
} as ISaleEstimateEditingPayload);
// Upsert the estimate graph to the storage.
const saleEstimate = await this.saleEstimateModel()
.query(trx)
.upsertGraphAndFetch({
id: estimateId,
...estimateObj,
});
// Trigger `onSaleEstimateEdited` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onEdited, {
estimateId,
saleEstimate,
oldSaleEstimate,
estimateDTO,
trx,
} as ISaleEstimateEditedPayload);
return saleEstimate;
});
}
}

View File

@@ -0,0 +1,54 @@
import { Inject, Injectable } from '@nestjs/common';
import * as moment from 'moment';
import { Knex } from 'knex';
import { SaleEstimate } from '../models/SaleEstimate';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { ServiceError } from '@/modules/Items/ServiceError';
import { ERRORS } from '../constants';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class RejectSaleEstimateService {
constructor(
@Inject(SaleEstimate.name)
private readonly saleEstimateModel: TenantModelProxy<typeof SaleEstimate>,
private readonly eventPublisher: EventEmitter2,
private readonly uow: UnitOfWork,
) {}
/**
* Mark the sale estimate as rejected from the customer.
* @param {number} saleEstimateId
*/
public async rejectSaleEstimate(saleEstimateId: number): Promise<void> {
// Retrieve details of the given sale estimate id.
const saleEstimate = await this.saleEstimateModel()
.query()
.findById(saleEstimateId)
.throwIfNotFound();
// Throws error in case the sale estimate still not delivered to customer.
if (!saleEstimate.isDelivered) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_DELIVERED);
}
// Throws error in case the sale estimate already rejected.
if (saleEstimate.isRejected) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_ALREADY_REJECTED);
}
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Mark the sale estimate as reject on the storage.
await this.saleEstimateModel()
.query(trx)
.where('id', saleEstimateId)
.patch({
rejectedAt: moment().toMySqlDateTime(),
approvedAt: null,
});
// Triggers `onSaleEstimateRejected` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onRejected, {});
});
}
}

View File

@@ -0,0 +1,113 @@
import * as R from 'ramda';
import { Inject, Injectable } from '@nestjs/common';
import { omit, sumBy } from 'lodash';
import * as composeAsync from 'async/compose';
import { SaleEstimateValidators } from './SaleEstimateValidators.service';
import { formatDateFields } from '@/utils/format-date-fields';
import * as moment from 'moment';
import { SaleEstimateIncrement } from './SaleEstimateIncrement.service';
import { BranchTransactionDTOTransformer } from '@/modules/Branches/integrations/BranchTransactionDTOTransform';
import { WarehouseTransactionDTOTransform } from '@/modules/Warehouses/Integrations/WarehouseTransactionDTOTransform';
import { assocItemEntriesDefaultIndex } from '@/utils/associate-item-entries-index';
import { BrandingTemplateDTOTransformer } from '@/modules/PdfTemplate/BrandingTemplateDTOTransformer';
import { ItemEntry } from '@/modules/TransactionItemEntry/models/ItemEntry';
import { SaleEstimate } from '../models/SaleEstimate';
import { Customer } from '@/modules/Customers/models/Customer';
import { ISaleEstimateDTO } from '../types/SaleEstimates.types';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { CommandSaleEstimateDto } from '../dtos/SaleEstimate.dto';
@Injectable()
export class SaleEstimateDTOTransformer {
constructor(
@Inject(ItemEntry.name)
private itemEntryModel: TenantModelProxy<typeof ItemEntry>,
private readonly validators: SaleEstimateValidators,
private readonly branchDTOTransform: BranchTransactionDTOTransformer,
private readonly warehouseDTOTransform: WarehouseTransactionDTOTransform,
private readonly estimateIncrement: SaleEstimateIncrement,
private readonly brandingTemplatesTransformer: BrandingTemplateDTOTransformer,
) {}
/**
* Transform create DTO object ot model object.
* @param {ISaleEstimateDTO} saleEstimateDTO - Sale estimate DTO.
* @param {Customer} paymentCustomer - Payment customer.
* @param {SaleEstimate} oldSaleEstimate - Old sale estimate.
* @return {ISaleEstimate}
*/
async transformDTOToModel(
estimateDTO: CommandSaleEstimateDto,
paymentCustomer: Customer,
oldSaleEstimate?: SaleEstimate,
): Promise<SaleEstimate> {
const amount = sumBy(estimateDTO.entries, (e) =>
this.itemEntryModel().calcAmount(e),
);
// Retrieve the next invoice number.
const autoNextNumber = await this.estimateIncrement.getNextEstimateNumber();
// Retrieve the next estimate number.
const estimateNumber =
estimateDTO.estimateNumber ||
oldSaleEstimate?.estimateNumber ||
autoNextNumber;
// Validate the sale estimate number require.
this.validators.validateEstimateNoRequire(estimateNumber);
const entries = R.compose(
// Associate the reference type to item entries.
R.map((entry) => R.assoc('reference_type', 'SaleEstimate', entry)),
// Associate default index to item entries.
assocItemEntriesDefaultIndex,
)(estimateDTO.entries);
const initialDTO = {
amount,
...formatDateFields(
omit(estimateDTO, ['delivered', 'entries', 'attachments']),
['estimateDate', 'expirationDate'],
),
currencyCode: paymentCustomer.currencyCode,
exchangeRate: estimateDTO.exchangeRate || 1,
...(estimateNumber ? { estimateNumber } : {}),
entries,
// Avoid rewrite the deliver date in edit mode when already published.
...(estimateDTO.delivered &&
!oldSaleEstimate?.deliveredAt && {
deliveredAt: moment().toMySqlDateTime(),
}),
};
const asyncDto = await composeAsync(
this.branchDTOTransform.transformDTO<SaleEstimate>,
this.warehouseDTOTransform.transformDTO<SaleEstimate>,
// Assigns the default branding template id to the invoice DTO.
this.brandingTemplatesTransformer.assocDefaultBrandingTemplate(
'SaleEstimate',
),
)(initialDTO);
return asyncDto;
}
/**
* Retrieve estimate number to object model.
* @param {ISaleEstimateDTO} saleEstimateDTO
* @param {ISaleEstimate} oldSaleEstimate
*/
public async transformEstimateNumberToModel(
saleEstimateDTO: ISaleEstimateDTO,
oldSaleEstimate?: SaleEstimate,
): Promise<string> {
const autoNextNumber = await this.estimateIncrement.getNextEstimateNumber();
if (saleEstimateDTO.estimateNumber) {
return saleEstimateDTO.estimateNumber;
}
return oldSaleEstimate ? oldSaleEstimate.estimateNumber : autoNextNumber;
}
}

View File

@@ -0,0 +1,28 @@
import { AutoIncrementOrdersService } from '@/modules/AutoIncrementOrders/AutoIncrementOrders.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class SaleEstimateIncrement {
constructor(
private readonly autoIncrementOrdersService: AutoIncrementOrdersService,
) {}
/**
* Retrieve the next unique estimate number.
* @return {Promise<string>}
*/
public getNextEstimateNumber(): Promise<string> {
return this.autoIncrementOrdersService.getNextTransactionNumber(
'sales_estimates',
);
}
/**
* Increment the estimate next number.
*/
public incrementNextEstimateNumber() {
return this.autoIncrementOrdersService.incrementSettingsNextNumber(
'sales_estimates',
);
}
}

View File

@@ -0,0 +1,217 @@
// import { Service, Inject } from 'typedi';
// import moment from 'moment';
// import events from '@/subscribers/events';
// import HasTenancyService from '@/services/Tenancy/TenancyService';
// import SaleNotifyBySms from '../SaleNotifyBySms';
// import SmsNotificationsSettingsService from '@/services/Settings/SmsNotificationsSettings';
// import {
// ICustomer,
// IPaymentReceivedSmsDetails,
// ISaleEstimate,
// SMS_NOTIFICATION_KEY,
// } from '@/interfaces';
// import { Tenant, TenantMetadata } from '@/system/models';
// import { formatNumber, formatSmsMessage } from 'utils';
// import { ServiceError } from '@/exceptions';
// import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
// const ERRORS = {
// SALE_ESTIMATE_NOT_FOUND: 'SALE_ESTIMATE_NOT_FOUND',
// };
// @Service()
// export class SaleEstimateNotifyBySms {
// @Inject()
// private tenancy: HasTenancyService;
// @Inject()
// private saleSmsNotification: SaleNotifyBySms;
// @Inject()
// private eventPublisher: EventPublisher;
// @Inject()
// private smsNotificationsSettings: SmsNotificationsSettingsService;
// /**
// *
// * @param {number} tenantId
// * @param {number} saleEstimateId
// * @returns {Promise<ISaleEstimate>}
// */
// public notifyBySms = async (
// tenantId: number,
// saleEstimateId: number
// ): Promise<ISaleEstimate> => {
// const { SaleEstimate } = this.tenancy.models(tenantId);
// // Retrieve the sale invoice or throw not found service error.
// const saleEstimate = await SaleEstimate.query()
// .findById(saleEstimateId)
// .withGraphFetched('customer');
// // Validates the estimate transaction existance.
// this.validateEstimateExistance(saleEstimate);
// // Validate the customer phone number existance and number validation.
// this.saleSmsNotification.validateCustomerPhoneNumber(
// saleEstimate.customer.personalPhone
// );
// // Triggers `onSaleEstimateNotifySms` event.
// await this.eventPublisher.emitAsync(events.saleEstimate.onNotifySms, {
// tenantId,
// saleEstimate,
// });
// await this.sendSmsNotification(tenantId, saleEstimate);
// // Triggers `onSaleEstimateNotifySms` event.
// await this.eventPublisher.emitAsync(events.saleEstimate.onNotifiedSms, {
// tenantId,
// saleEstimate,
// });
// return saleEstimate;
// };
// /**
// *
// * @param {number} tenantId
// * @param {ISaleEstimate} saleEstimate
// * @returns
// */
// private sendSmsNotification = async (
// tenantId: number,
// saleEstimate: ISaleEstimate & { customer: ICustomer }
// ) => {
// const smsClient = this.tenancy.smsClient(tenantId);
// const tenantMetadata = await TenantMetadata.query().findOne({ tenantId });
// // Retrieve the formatted sms notification message for estimate details.
// const formattedSmsMessage = this.formattedEstimateDetailsMessage(
// tenantId,
// saleEstimate,
// tenantMetadata
// );
// const phoneNumber = saleEstimate.customer.personalPhone;
// // Runs the send message job.
// return smsClient.sendMessageJob(phoneNumber, formattedSmsMessage);
// };
// /**
// * Notify via SMS message after estimate creation.
// * @param {number} tenantId
// * @param {number} saleEstimateId
// * @returns {Promise<void>}
// */
// public notifyViaSmsNotificationAfterCreation = async (
// tenantId: number,
// saleEstimateId: number
// ): Promise<void> => {
// const notification = this.smsNotificationsSettings.getSmsNotificationMeta(
// tenantId,
// SMS_NOTIFICATION_KEY.SALE_ESTIMATE_DETAILS
// );
// // Can't continue if the sms auto-notification is not enabled.
// if (!notification.isNotificationEnabled) return;
// await this.notifyBySms(tenantId, saleEstimateId);
// };
// /**
// *
// * @param {number} tenantId
// * @param {ISaleEstimate} saleEstimate
// * @param {TenantMetadata} tenantMetadata
// * @returns {string}
// */
// private formattedEstimateDetailsMessage = (
// tenantId: number,
// saleEstimate: ISaleEstimate,
// tenantMetadata: TenantMetadata
// ): string => {
// const notification = this.smsNotificationsSettings.getSmsNotificationMeta(
// tenantId,
// SMS_NOTIFICATION_KEY.SALE_ESTIMATE_DETAILS
// );
// return this.formateEstimateDetailsMessage(
// notification.smsMessage,
// saleEstimate,
// tenantMetadata
// );
// };
// /**
// * Formattes the estimate sms notification details message.
// * @param {string} smsMessage
// * @param {ISaleEstimate} saleEstimate
// * @param {TenantMetadata} tenantMetadata
// * @returns {string}
// */
// private formateEstimateDetailsMessage = (
// smsMessage: string,
// saleEstimate: ISaleEstimate & { customer: ICustomer },
// tenantMetadata: TenantMetadata
// ) => {
// const formattedAmount = formatNumber(saleEstimate.amount, {
// currencyCode: saleEstimate.currencyCode,
// });
// return formatSmsMessage(smsMessage, {
// EstimateNumber: saleEstimate.estimateNumber,
// ReferenceNumber: saleEstimate.reference,
// EstimateDate: moment(saleEstimate.estimateDate).format('YYYY/MM/DD'),
// ExpirationDate: saleEstimate.expirationDate
// ? moment(saleEstimate.expirationDate).format('YYYY/MM/DD')
// : '',
// CustomerName: saleEstimate.customer.displayName,
// Amount: formattedAmount,
// CompanyName: tenantMetadata.name,
// });
// };
// /**
// * Retrieve the SMS details of the given payment receive transaction.
// * @param {number} tenantId
// * @param {number} saleEstimateId
// * @returns {Promise<IPaymentReceivedSmsDetails>}
// */
// public smsDetails = async (
// tenantId: number,
// saleEstimateId: number
// ): Promise<IPaymentReceivedSmsDetails> => {
// const { SaleEstimate } = this.tenancy.models(tenantId);
// // Retrieve the sale invoice or throw not found service error.
// const saleEstimate = await SaleEstimate.query()
// .findById(saleEstimateId)
// .withGraphFetched('customer');
// // Validates the estimate existance.
// this.validateEstimateExistance(saleEstimate);
// // Retrieve the current tenant metadata.
// const tenantMetadata = await TenantMetadata.query().findOne({ tenantId });
// // Retrieve the formatted sms message from the given estimate model.
// const formattedSmsMessage = this.formattedEstimateDetailsMessage(
// tenantId,
// saleEstimate,
// tenantMetadata
// );
// return {
// customerName: saleEstimate.customer.displayName,
// customerPhoneNumber: saleEstimate.customer.personalPhone,
// smsMessage: formattedSmsMessage,
// };
// };
// /**
// * Validates the sale estimate existance.
// * @param {ISaleEstimate} saleEstimate -
// */
// private validateEstimateExistance(saleEstimate: ISaleEstimate) {
// if (!saleEstimate) {
// throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_FOUND);
// }
// }
// }

View File

@@ -0,0 +1,82 @@
import { Inject, Injectable } from '@nestjs/common';
import { ERRORS } from '../constants';
import { SaleEstimate } from '../models/SaleEstimate';
import { ServiceError } from '@/modules/Items/ServiceError';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class SaleEstimateValidators {
constructor(
@Inject(SaleEstimate.name)
private readonly saleEstimateModel: TenantModelProxy<typeof SaleEstimate>,
) {}
/**
* Validates the given estimate existance.
* @param {SaleEstimate | undefined | null} estimate - The sale estimate.
*/
public validateEstimateExistance(estimate: SaleEstimate | undefined | null) {
if (!estimate) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_FOUND);
}
}
/**
* Validate the estimate number unique on the storage.
* @param {string} estimateNumber - The estimate number.
* @param {number} notEstimateId - The estimate id to exclude from the search.
*/
public async validateEstimateNumberExistance(
estimateNumber: string,
notEstimateId?: number,
) {
const foundSaleEstimate = await this.saleEstimateModel()
.query()
.findOne('estimate_number', estimateNumber)
.onBuild((builder) => {
if (notEstimateId) {
builder.whereNot('id', notEstimateId);
}
});
if (foundSaleEstimate) {
throw new ServiceError(
ERRORS.SALE_ESTIMATE_NUMBER_EXISTANCE,
'The given sale estimate is not unique.',
);
}
}
/**
* Validates the given sale estimate not already converted to invoice.
* @param {SaleEstimate} saleEstimate -
*/
public validateEstimateNotConverted(saleEstimate: SaleEstimate) {
if (saleEstimate.isConvertedToInvoice) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_CONVERTED_TO_INVOICE);
}
}
/**
* Validate the sale estimate number require.
* @param {string} estimateNumber
*/
public validateEstimateNoRequire(estimateNumber: string) {
if (!estimateNumber) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_NO_IS_REQUIRED);
}
}
/**
* Validate the given customer has no sales estimates.
* @param {number} customerId - The customer id.
*/
public async validateCustomerHasNoEstimates(customerId: number) {
const estimates = await this.saleEstimateModel()
.query()
.where('customer_id', customerId);
if (estimates.length > 0) {
throw new ServiceError(ERRORS.CUSTOMER_HAS_SALES_ESTIMATES);
}
}
}

View File

@@ -0,0 +1,195 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bull';
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ContactMailNotification } from '@/modules/MailNotification/ContactMailNotification';
import {
DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT,
DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT,
} from '../constants';
import { GetSaleEstimate } from '../queries/GetSaleEstimate.service';
import { transformEstimateToMailDataArgs } from '../utils';
import { GetSaleEstimatePdf } from '../queries/GetSaleEstimatePdf';
import { events } from '@/common/events/events';
import { SaleEstimate } from '../models/SaleEstimate';
import { mergeAndValidateMailOptions } from '@/modules/MailNotification/utils';
import {
ISaleEstimateMailPresendEvent,
SaleEstimateMailOptionsDTO,
SendSaleEstimateMailJob,
SendSaleEstimateMailQueue,
} from '../types/SaleEstimates.types';
import { SaleEstimateMailOptions } from '../types/SaleEstimates.types';
import { Mail } from '@/modules/Mail/Mail';
import { MailTransporter } from '@/modules/Mail/MailTransporter.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class SendSaleEstimateMail {
/**
* @param {GetSaleEstimatePdf} estimatePdf - Estimate pdf service.
* @param {GetSaleEstimate} getSaleEstimateService - Get sale estimate service.
* @param {ContactMailNotification} contactMailNotification - Contact mail notification service.
* @param {EventEmitter2} eventPublisher - Event emitter.
* @param {MailTransporter} mailTransporter - Mail transporter service.
* @param {typeof SaleEstimate} saleEstimateModel - Sale estimate model.
*/
constructor(
private readonly estimatePdf: GetSaleEstimatePdf,
private readonly getSaleEstimateService: GetSaleEstimate,
private readonly contactMailNotification: ContactMailNotification,
private readonly eventPublisher: EventEmitter2,
private readonly mailTransporter: MailTransporter,
@Inject(SaleEstimate.name)
private readonly saleEstimateModel: TenantModelProxy<typeof SaleEstimate>,
@InjectQueue(SendSaleEstimateMailQueue)
private readonly sendEstimateMailQueue: Queue,
) {}
/**
* Triggers the reminder mail of the given sale estimate.
* @param {number} saleEstimateId - Sale estimate id.
* @param {SaleEstimateMailOptionsDTO} messageOptions - Sale estimate mail options.
* @returns {Promise<void>}
*/
public async triggerMail(
saleEstimateId: number,
messageOptions: SaleEstimateMailOptionsDTO,
): Promise<void> {
const payload = {
saleEstimateId,
messageOptions,
};
await this.sendEstimateMailQueue.add(SendSaleEstimateMailJob, payload);
// Triggers `onSaleEstimatePreMailSend` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onPreMailSend, {
saleEstimateId,
messageOptions,
} as ISaleEstimateMailPresendEvent);
}
/**
* Formate the text of the mail.
* @param {number} estimateId - Estimate id.
* @returns {Promise<Record<string, any>>}
*/
public formatterArgs = async (estimateId: number) => {
const estimate = await this.getSaleEstimateService.getEstimate(estimateId);
return transformEstimateToMailDataArgs(estimate);
};
/**
* Retrieves the mail options.
* @param {number} saleEstimateId - Sale estimate id.
* @param {string} defaultSubject - Default subject.
* @param {string} defaultMessage - Default message.
* @returns {Promise<SaleEstimateMailOptions>}
*/
public getMailOptions = async (
saleEstimateId: number,
defaultSubject: string = DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT,
defaultMessage: string = DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT,
): Promise<SaleEstimateMailOptions> => {
const saleEstimate = await this.saleEstimateModel()
.query()
.findById(saleEstimateId)
.throwIfNotFound();
const formatArgs = await this.formatterArgs(saleEstimateId);
const mailOptions =
await this.contactMailNotification.getDefaultMailOptions(
saleEstimate.customerId,
);
return {
...mailOptions,
message: defaultMessage,
subject: defaultSubject,
attachEstimate: true,
formatArgs,
};
};
/**
* Formats the given mail options.
* @param {number} saleEstimateId - Sale estimate id.
* @param {SaleEstimateMailOptions} mailOptions - Sale estimate mail options.
* @returns {Promise<SaleEstimateMailOptions>}
*/
public formatMailOptions = async (
saleEstimateId: number,
mailOptions: SaleEstimateMailOptions,
): Promise<SaleEstimateMailOptions> => {
const formatterArgs = await this.formatterArgs(saleEstimateId);
const formattedOptions =
await this.contactMailNotification.formatMailOptions(
mailOptions,
formatterArgs,
);
return { ...formattedOptions };
};
/**
* Sends the mail notification of the given sale estimate.
* @param {number} saleEstimateId - Sale estimate id.
* @param {SaleEstimateMailOptions} messageOptions - Sale estimate mail options.
* @returns {Promise<void>}
*/
public async sendMail(
saleEstimateId: number,
messageOptions: SaleEstimateMailOptionsDTO,
): Promise<void> {
const localMessageOpts = await this.getMailOptions(saleEstimateId);
// Overrides and validates the given mail options.
const parsedMessageOptions = mergeAndValidateMailOptions(
localMessageOpts,
messageOptions,
) as SaleEstimateMailOptions;
const formattedOptions = await this.formatMailOptions(
saleEstimateId,
parsedMessageOptions,
);
const mail = new Mail()
.setSubject(formattedOptions.subject)
.setTo(formattedOptions.to)
.setCC(formattedOptions.cc)
.setBCC(formattedOptions.bcc)
.setContent(formattedOptions.message);
// Attaches the estimate pdf to the mail.
if (formattedOptions.attachEstimate) {
// Retrieves the estimate pdf and attaches it to the mail.
const [estimatePdfBuffer, estimateFilename] =
await this.estimatePdf.getSaleEstimatePdf(saleEstimateId);
mail.setAttachments([
{
filename: `${estimateFilename}.pdf`,
content: estimatePdfBuffer,
},
]);
}
const eventPayload = {
saleEstimateId,
messageOptions,
formattedOptions,
};
// Triggers `onSaleEstimateMailSend` event.
await this.eventPublisher.emitAsync(
events.saleEstimate.onMailSend,
eventPayload as ISaleEstimateMailPresendEvent,
);
await this.mailTransporter.send(mail);
// Triggers `onSaleEstimateMailSent` event.
await this.eventPublisher.emitAsync(
events.saleEstimate.onMailSent,
eventPayload as ISaleEstimateMailPresendEvent,
);
}
}

View File

@@ -0,0 +1,36 @@
// import Container, { Service } from 'typedi';
// import { SendSaleEstimateMail } from './SendSaleEstimateMail';
// @Service()
// export class SendSaleEstimateMailJob {
// /**
// * Constructor method.
// */
// constructor(agenda) {
// agenda.define(
// 'sale-estimate-mail-send',
// { priority: 'high', concurrency: 2 },
// this.handler
// );
// }
// /**
// * Triggers sending invoice mail.
// */
// private handler = async (job, done: Function) => {
// const { tenantId, saleEstimateId, messageOptions } = job.attrs.data;
// const sendSaleEstimateMail = Container.get(SendSaleEstimateMail);
// try {
// await sendSaleEstimateMail.sendMail(
// tenantId,
// saleEstimateId,
// messageOptions
// );
// done();
// } catch (error) {
// console.log(error);
// done(error);
// }
// };
// }

View File

@@ -0,0 +1,32 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { SaleEstimate } from '../models/SaleEstimate';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class UnlinkConvertedSaleEstimate {
constructor(
@Inject(SaleEstimate.name)
private readonly saleEstimateModel: TenantModelProxy<typeof SaleEstimate>,
) {}
/**
* Unlink the converted sale estimates from the given sale invoice.
* @param {number} invoiceId -
* @return {Promise<void>}
*/
public async unlinkConvertedEstimateFromInvoice(
invoiceId: number,
trx?: Knex.Transaction,
): Promise<void> {
await this.saleEstimateModel()
.query(trx)
.where({
convertedToInvoiceId: invoiceId,
})
.patch({
convertedToInvoiceId: null,
convertedToInvoiceAt: null,
});
}
}