feat: bulk transcations delete (#844)

* feat: bulk transcations delete
This commit is contained in:
Ahmed Bouhuolia
2025-11-20 23:11:06 +02:00
committed by GitHub
239 changed files with 6065 additions and 595 deletions

View File

@@ -0,0 +1,55 @@
import { IsArray, IsInt, ArrayNotEmpty, IsBoolean, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { parseBoolean } from '@/utils/parse-boolean';
export class BulkDeleteDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
@ApiProperty({
description: 'Array of IDs to delete',
type: [Number],
example: [1, 2, 3],
})
ids: number[];
@IsOptional()
@IsBoolean()
@Transform(({ value, obj }) => parseBoolean(value ?? obj?.skip_undeletable, false))
@ApiPropertyOptional({
description: 'When true, undeletable items will be skipped and only deletable ones will be removed.',
type: Boolean,
default: false,
})
skipUndeletable?: boolean;
}
export class ValidateBulkDeleteResponseDto {
@ApiProperty({
description: 'Number of items that can be deleted',
example: 2,
})
deletableCount: number;
@ApiProperty({
description: 'Number of items that cannot be deleted',
example: 1,
})
nonDeletableCount: number;
@ApiProperty({
description: 'IDs of items that can be deleted',
type: [Number],
example: [1, 2],
})
deletableIds: number[];
@ApiProperty({
description: 'IDs of items that cannot be deleted',
type: [Number],
example: [3],
})
nonDeletableIds: number[];
}

View File

@@ -27,16 +27,56 @@ import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionRe
import { GetAccountTransactionsQueryDto } from './dtos/GetAccountTransactionsQuery.dto';
import { GetAccountsQueryDto } from './dtos/GetAccountsQuery.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('accounts')
@ApiTags('Accounts')
@ApiExtraModels(AccountResponseDto)
@ApiExtraModels(AccountTypeResponseDto)
@ApiExtraModels(GetAccountTransactionResponseDto)
@ApiExtraModels(ValidateBulkDeleteResponseDto)
@ApiCommonHeaders()
export class AccountsController {
constructor(private readonly accountsApplication: AccountsApplication) { }
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which accounts can be deleted and returns counts of deletable and non-deletable accounts.',
})
@ApiResponse({
status: 200,
description:
'Validation completed. Returns counts and IDs of deletable and non-deletable accounts.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
async validateBulkDeleteAccounts(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.accountsApplication.validateBulkDeleteAccounts(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple accounts in bulk.' })
@ApiResponse({
status: 200,
description: 'The accounts have been successfully deleted.',
})
async bulkDeleteAccounts(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<void> {
return this.accountsApplication.bulkDeleteAccounts(bulkDeleteDto.ids, {
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
});
}
@Post()
@ApiOperation({ summary: 'Create an account' })
@ApiResponse({

View File

@@ -19,6 +19,8 @@ import { GetAccountsService } from './GetAccounts.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { AccountsExportable } from './AccountsExportable.service';
import { AccountsImportable } from './AccountsImportable.service';
import { BulkDeleteAccountsService } from './BulkDeleteAccounts.service';
import { ValidateBulkDeleteAccountsService } from './ValidateBulkDeleteAccounts.service';
const models = [RegisterTenancyModel(BankAccount)];
@@ -40,7 +42,9 @@ const models = [RegisterTenancyModel(BankAccount)];
GetAccountTransactionsService,
GetAccountsService,
AccountsExportable,
AccountsImportable
AccountsImportable,
BulkDeleteAccountsService,
ValidateBulkDeleteAccountsService,
],
exports: [
AccountRepository,

View File

@@ -15,6 +15,9 @@ import { GetAccountsService } from './GetAccounts.service';
import { IFilterMeta } from '@/interfaces/Model';
import { GetAccountTransactionResponseDto } from './dtos/GetAccountTransactionResponse.dto';
import { GetAccountsQueryDto } from './dtos/GetAccountsQuery.dto';
import { BulkDeleteAccountsService } from './BulkDeleteAccounts.service';
import { ValidateBulkDeleteAccountsService } from './ValidateBulkDeleteAccounts.service';
import { ValidateBulkDeleteResponseDto } from '@/common/dtos/BulkDelete.dto';
@Injectable()
export class AccountsApplication {
@@ -37,6 +40,8 @@ export class AccountsApplication {
private readonly getAccountService: GetAccount,
private readonly getAccountTransactionsService: GetAccountTransactionsService,
private readonly getAccountsService: GetAccountsService,
private readonly bulkDeleteAccountsService: BulkDeleteAccountsService,
private readonly validateBulkDeleteAccountsService: ValidateBulkDeleteAccountsService,
) { }
/**
@@ -128,4 +133,28 @@ export class AccountsApplication {
): Promise<Array<GetAccountTransactionResponseDto>> => {
return this.getAccountTransactionsService.getAccountsTransactions(filter);
};
/**
* Validates which accounts can be deleted in bulk.
*/
public validateBulkDeleteAccounts = (
accountIds: number[],
): Promise<ValidateBulkDeleteResponseDto> => {
return this.validateBulkDeleteAccountsService.validateBulkDeleteAccounts(
accountIds,
);
};
/**
* Deletes multiple accounts in bulk.
*/
public bulkDeleteAccounts = (
accountIds: number[],
options?: { skipUndeletable?: boolean },
): Promise<void> => {
return this.bulkDeleteAccountsService.bulkDeleteAccounts(
accountIds,
options,
);
};
}

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteAccount } from './DeleteAccount.service';
@Injectable()
export class BulkDeleteAccountsService {
constructor(private readonly deleteAccountService: DeleteAccount) { }
/**
* Deletes multiple accounts.
* @param {number | Array<number>} accountIds - The account id or ids.
* @param {Knex.Transaction} trx - The transaction.
*/
async bulkDeleteAccounts(
accountIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const accountsIds = uniq(castArray(accountIds));
const results = await PromisePool.withConcurrency(1)
.for(accountsIds)
.process(async (accountId: number) => {
try {
await this.deleteAccountService.deleteAccount(accountId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -50,8 +50,12 @@ export class DeleteAccount {
/**
* Deletes the account from the storage.
* @param {number} accountId
* @param {Knex.Transaction} trx - Database transaction instance.
*/
public deleteAccount = async (accountId: number): Promise<void> => {
public deleteAccount = async (
accountId: number,
trx?: Knex.Transaction,
): Promise<void> => {
// Retrieve account or not found service error.
const oldAccount = await this.accountModel().query().findById(accountId);
@@ -82,6 +86,6 @@ export class DeleteAccount {
oldAccount,
trx,
} as IAccountEventDeletedPayload);
});
}, trx);
};
}

View File

@@ -0,0 +1,63 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteAccount } from './DeleteAccount.service';
import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception';
@Injectable()
export class ValidateBulkDeleteAccountsService {
constructor(
private readonly deleteAccountService: DeleteAccount,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) {}
/**
* Validates which accounts from the provided IDs can be deleted.
* Uses the actual deleteAccount service to validate, ensuring the same validation logic.
* Uses a transaction that is always rolled back to ensure no database changes.
* @param {number[]} accountIds - Array of account IDs to validate
* @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>}
*/
public async validateBulkDeleteAccounts(accountIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const accountId of accountIds) {
try {
await this.deleteAccountService.deleteAccount(accountId, trx);
deletableIds.push(accountId);
} catch (error) {
if (error instanceof ModelHasRelationsError) {
nonDeletableIds.push(accountId);
} else {
nonDeletableIds.push(accountId);
}
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -9,6 +9,8 @@ import { Injectable } from '@nestjs/common';
import { GetBillsService } from './queries/GetBills.service';
import { CreateBillDto, EditBillDto } from './dtos/Bill.dto';
import { GetBillPaymentTransactionsService } from './queries/GetBillPayments';
import { BulkDeleteBillsService } from './BulkDeleteBills.service';
import { ValidateBulkDeleteBillsService } from './ValidateBulkDeleteBills.service';
// import { GetBillPayments } from './queries/GetBillPayments';
// import { GetBills } from './queries/GetBills';
@@ -23,7 +25,9 @@ export class BillsApplication {
private openBillService: OpenBillService,
private getBillsService: GetBillsService,
private getBillPaymentTransactionsService: GetBillPaymentTransactionsService,
) {}
private bulkDeleteBillsService: BulkDeleteBillsService,
private validateBulkDeleteBillsService: ValidateBulkDeleteBillsService,
) { }
/**
* Creates a new bill with associated GL entries.
@@ -53,6 +57,25 @@ export class BillsApplication {
return this.deleteBillService.deleteBill(billId);
}
/**
* Deletes multiple bills.
* @param {number[]} billIds
*/
public bulkDeleteBills(
billIds: number[],
options?: { skipUndeletable?: boolean },
) {
return this.bulkDeleteBillsService.bulkDeleteBills(billIds, options);
}
/**
* Validates which bills can be deleted.
* @param {number[]} billIds
*/
public validateBulkDeleteBills(billIds: number[]) {
return this.validateBulkDeleteBillsService.validateBulkDeleteBills(billIds);
}
/**
* Retrieve bills data table list.
* @param {IBillsFilter} billsFilter -

View File

@@ -22,14 +22,51 @@ import { CreateBillDto, EditBillDto } from './dtos/Bill.dto';
import { BillResponseDto } from './dtos/BillResponse.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('bills')
@ApiTags('Bills')
@ApiExtraModels(BillResponseDto)
@ApiExtraModels(PaginatedResponseDto)
@ApiCommonHeaders()
@ApiExtraModels(ValidateBulkDeleteResponseDto)
export class BillsController {
constructor(private billsApplication: BillsApplication) {}
constructor(private billsApplication: BillsApplication) { }
@Post('validate-bulk-delete')
@ApiOperation({
summary: 'Validate which bills can be deleted and return the results.',
})
@ApiResponse({
status: 200,
description:
'Validation completed with counts and IDs of deletable and non-deletable bills.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
validateBulkDeleteBills(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.billsApplication.validateBulkDeleteBills(bulkDeleteDto.ids);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple bills.' })
@ApiResponse({
status: 200,
description: 'Bills deleted successfully',
})
bulkDeleteBills(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<void> {
return this.billsApplication.bulkDeleteBills(bulkDeleteDto.ids, {
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
});
}
@Post()
@ApiOperation({ summary: 'Create a new bill.' })

View File

@@ -29,6 +29,8 @@ import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
import { BillsExportable } from './commands/BillsExportable';
import { BillsImportable } from './commands/BillsImportable';
import { GetBillPaymentTransactionsService } from './queries/GetBillPayments';
import { BulkDeleteBillsService } from './BulkDeleteBills.service';
import { ValidateBulkDeleteBillsService } from './ValidateBulkDeleteBills.service';
@Module({
imports: [
@@ -63,8 +65,10 @@ import { GetBillPaymentTransactionsService } from './queries/GetBillPayments';
BillsExportable,
BillsImportable,
GetBillPaymentTransactionsService,
BulkDeleteBillsService,
ValidateBulkDeleteBillsService,
],
controllers: [BillsController],
exports: [BillsExportable, BillsImportable],
})
export class BillsModule {}
export class BillsModule { }

View File

@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteBill } from './commands/DeleteBill.service';
@Injectable()
export class BulkDeleteBillsService {
constructor(private readonly deleteBillService: DeleteBill) { }
async bulkDeleteBills(
billIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const billsIds = uniq(castArray(billIds));
const results = await PromisePool.withConcurrency(1)
.for(billsIds)
.process(async (billId: number) => {
try {
await this.deleteBillService.deleteBill(billId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -0,0 +1,51 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteBill } from './commands/DeleteBill.service';
@Injectable()
export class ValidateBulkDeleteBillsService {
constructor(
private readonly deleteBillService: DeleteBill,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) { }
public async validateBulkDeleteBills(billIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const billId of billIds) {
try {
await this.deleteBillService.deleteBill(billId, trx);
deletableIds.push(billId);
} catch (error) {
nonDeletableIds.push(billId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -29,9 +29,10 @@ export class DeleteBill {
/**
* Deletes the bill with associated entries.
* @param {number} billId
* @param {Knex.Transaction} trx - Database transaction instance.
* @return {void}
*/
public async deleteBill(billId: number) {
public async deleteBill(billId: number, trx?: Knex.Transaction) {
// Retrieve the given bill or throw not found error.
const oldBill = await this.billModel()
.query()
@@ -75,6 +76,6 @@ export class DeleteBill {
oldBill,
trx,
} as IBIllEventDeletedPayload);
});
}, trx);
}
}

View File

@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteCreditNoteService } from './commands/DeleteCreditNote.service';
@Injectable()
export class BulkDeleteCreditNotesService {
constructor(
private readonly deleteCreditNoteService: DeleteCreditNoteService,
) { }
async bulkDeleteCreditNotes(
creditNoteIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const notesIds = uniq(castArray(creditNoteIds));
const results = await PromisePool.withConcurrency(1)
.for(notesIds)
.process(async (creditNoteId: number) => {
try {
await this.deleteCreditNoteService.deleteCreditNote(creditNoteId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -9,6 +9,8 @@ import { GetCreditNotesService } from './queries/GetCreditNotes.service';
import { CreateCreditNoteDto, EditCreditNoteDto } from './dtos/CreditNote.dto';
import { GetCreditNoteState } from './queries/GetCreditNoteState.service';
import { GetCreditNoteService } from './queries/GetCreditNote.service';
import { BulkDeleteCreditNotesService } from './BulkDeleteCreditNotes.service';
import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCreditNotes.service';
@Injectable()
export class CreditNoteApplication {
@@ -20,8 +22,10 @@ export class CreditNoteApplication {
private readonly getCreditNotePdfService: GetCreditNotePdf,
private readonly getCreditNotesService: GetCreditNotesService,
private readonly getCreditNoteStateService: GetCreditNoteState,
private readonly getCreditNoteService: GetCreditNoteService
) {}
private readonly getCreditNoteService: GetCreditNoteService,
private readonly bulkDeleteCreditNotesService: BulkDeleteCreditNotesService,
private readonly validateBulkDeleteCreditNotesService: ValidateBulkDeleteCreditNotesService,
) { }
/**
* Creates a new credit note.
@@ -97,4 +101,30 @@ export class CreditNoteApplication {
getCreditNote(creditNoteId: number) {
return this.getCreditNoteService.getCreditNote(creditNoteId);
}
/**
* Deletes multiple credit notes.
* @param {number[]} creditNoteIds
* @returns {Promise<void>}
*/
bulkDeleteCreditNotes(
creditNoteIds: number[],
options?: { skipUndeletable?: boolean },
) {
return this.bulkDeleteCreditNotesService.bulkDeleteCreditNotes(
creditNoteIds,
options,
);
}
/**
* Validates which credit notes can be deleted.
* @param {number[]} creditNoteIds
* @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>}
*/
validateBulkDeleteCreditNotes(creditNoteIds: number[]) {
return this.validateBulkDeleteCreditNotesService.validateBulkDeleteCreditNotes(
creditNoteIds,
);
}
}

View File

@@ -22,17 +22,22 @@ import { CreateCreditNoteDto, EditCreditNoteDto } from './dtos/CreditNote.dto';
import { CreditNoteResponseDto } from './dtos/CreditNoteResponse.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('credit-notes')
@ApiTags('Credit Notes')
@ApiExtraModels(CreditNoteResponseDto)
@ApiExtraModels(PaginatedResponseDto)
@ApiExtraModels(ValidateBulkDeleteResponseDto)
@ApiCommonHeaders()
export class CreditNotesController {
/**
* @param {CreditNoteApplication} creditNoteApplication - The credit note application service.
*/
constructor(private creditNoteApplication: CreditNoteApplication) {}
constructor(private creditNoteApplication: CreditNoteApplication) { }
@Post()
@ApiOperation({ summary: 'Create a new credit note' })
@@ -112,6 +117,42 @@ export class CreditNotesController {
return this.creditNoteApplication.openCreditNote(creditNoteId);
}
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which credit notes can be deleted and returns the results.',
})
@ApiResponse({
status: 200,
description:
'Validation completed with counts and IDs of deletable and non-deletable credit notes.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
validateBulkDeleteCreditNotes(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.creditNoteApplication.validateBulkDeleteCreditNotes(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple credit notes.' })
@ApiResponse({
status: 200,
description: 'Credit notes deleted successfully',
})
bulkDeleteCreditNotes(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<void> {
return this.creditNoteApplication.bulkDeleteCreditNotes(
bulkDeleteDto.ids,
{ skipUndeletable: bulkDeleteDto.skipUndeletable ?? false },
);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a credit note' })
@ApiParam({ name: 'id', description: 'Credit note ID', type: 'number' })

View File

@@ -34,6 +34,8 @@ import { CreditNoteInventoryTransactions } from './commands/CreditNotesInventory
import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
import { CreditNoteRefundsModule } from '../CreditNoteRefunds/CreditNoteRefunds.module';
import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/CreditNotesApplyInvoice.module';
import { BulkDeleteCreditNotesService } from './BulkDeleteCreditNotes.service';
import { ValidateBulkDeleteCreditNotesService } from './ValidateBulkDeleteCreditNotes.service';
@Module({
imports: [
@@ -73,6 +75,8 @@ import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/Credit
RefundSyncCreditNoteBalanceSubscriber,
DeleteCustomerLinkedCreditSubscriber,
CreditNoteAutoSerialSubscriber,
BulkDeleteCreditNotesService,
ValidateBulkDeleteCreditNotesService,
],
exports: [
CreateCreditNoteService,

View File

@@ -0,0 +1,54 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteCreditNoteService } from './commands/DeleteCreditNote.service';
@Injectable()
export class ValidateBulkDeleteCreditNotesService {
constructor(
private readonly deleteCreditNoteService: DeleteCreditNoteService,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) { }
public async validateBulkDeleteCreditNotes(creditNoteIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const creditNoteId of creditNoteIds) {
try {
await this.deleteCreditNoteService.deleteCreditNote(
creditNoteId,
trx,
);
deletableIds.push(creditNoteId);
} catch (error) {
nonDeletableIds.push(creditNoteId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -49,9 +49,13 @@ export class DeleteCreditNoteService {
/**
* Deletes the given credit note transactions.
* @param {number} creditNoteId
* @param {Knex.Transaction} trx - Database transaction instance.
* @returns {Promise<void>}
*/
public async deleteCreditNote(creditNoteId: number): Promise<void> {
public async deleteCreditNote(
creditNoteId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieve the credit note or throw not found service error.
const oldCreditNote = await this.creditNoteModel()
.query()
@@ -88,7 +92,7 @@ export class DeleteCreditNoteService {
creditNoteId,
trx,
} as ICreditNoteDeletedPayload);
});
}, trx);
}
/**

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { castArray, uniq } from 'lodash';
import { PromisePool } from '@supercharge/promise-pool';
import { DeleteCustomer } from './commands/DeleteCustomer.service';
@Injectable()
export class BulkDeleteCustomersService {
constructor(private readonly deleteCustomerService: DeleteCustomer) {}
public async bulkDeleteCustomers(
customerIds: number[],
options?: { skipUndeletable?: boolean },
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const ids = uniq(castArray(customerIds));
const results = await PromisePool.withConcurrency(1)
.for(ids)
.process(async (customerId: number) => {
try {
await this.deleteCustomerService.deleteCustomer(customerId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw ?? results.errors[0];
}
}
}

View File

@@ -24,6 +24,10 @@ import { CreateCustomerDto } from './dtos/CreateCustomer.dto';
import { EditCustomerDto } from './dtos/EditCustomer.dto';
import { CustomerResponseDto } from './dtos/CustomerResponse.dto';
import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto';
import {
BulkDeleteCustomersDto,
ValidateBulkDeleteCustomersResponseDto,
} from './dtos/BulkDeleteCustomers.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@Controller('customers')
@@ -31,7 +35,7 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@ApiExtraModels(CustomerResponseDto)
@ApiCommonHeaders()
export class CustomersController {
constructor(private customersApplication: CustomersApplication) {}
constructor(private customersApplication: CustomersApplication) { }
@Get(':id')
@ApiOperation({ summary: 'Retrieves the customer details.' })
@@ -109,4 +113,37 @@ export class CustomersController {
openingBalanceDTO,
);
}
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which customers can be deleted and returns counts of deletable and non-deletable customers.',
})
@ApiResponse({
status: 200,
description:
'Validation completed. Returns counts and IDs of deletable and non-deletable customers.',
schema: { $ref: getSchemaPath(ValidateBulkDeleteCustomersResponseDto) },
})
validateBulkDeleteCustomers(
@Body() bulkDeleteDto: BulkDeleteCustomersDto,
): Promise<ValidateBulkDeleteCustomersResponseDto> {
return this.customersApplication.validateBulkDeleteCustomers(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple customers in bulk.' })
@ApiResponse({
status: 200,
description: 'The customers have been successfully deleted.',
})
async bulkDeleteCustomers(
@Body() bulkDeleteDto: BulkDeleteCustomersDto,
): Promise<void> {
return this.customersApplication.bulkDeleteCustomers(bulkDeleteDto.ids, {
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
});
}
}

View File

@@ -16,6 +16,8 @@ import { CustomersExportable } from './CustomersExportable';
import { CustomersImportable } from './CustomersImportable';
import { GetCustomers } from './queries/GetCustomers.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { BulkDeleteCustomersService } from './BulkDeleteCustomers.service';
import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service';
@Module({
imports: [TenancyDatabaseModule, DynamicListModule],
@@ -37,6 +39,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module';
CustomersExportable,
CustomersImportable,
GetCustomers,
BulkDeleteCustomersService,
ValidateBulkDeleteCustomersService,
],
})
export class CustomersModule {}

View File

@@ -12,6 +12,8 @@ import { CreateCustomerDto } from './dtos/CreateCustomer.dto';
import { EditCustomerDto } from './dtos/EditCustomer.dto';
import { GetCustomers } from './queries/GetCustomers.service';
import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto';
import { BulkDeleteCustomersService } from './BulkDeleteCustomers.service';
import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service';
@Injectable()
export class CustomersApplication {
@@ -22,6 +24,8 @@ export class CustomersApplication {
private deleteCustomerService: DeleteCustomer,
private editOpeningBalanceService: EditOpeningBalanceCustomer,
private getCustomersService: GetCustomers,
private readonly bulkDeleteCustomersService: BulkDeleteCustomersService,
private readonly validateBulkDeleteCustomersService: ValidateBulkDeleteCustomersService,
) {}
/**
@@ -83,4 +87,20 @@ export class CustomersApplication {
public getCustomers = (filterDTO: GetCustomersQueryDto) => {
return this.getCustomersService.getCustomersList(filterDTO);
};
public bulkDeleteCustomers = (
customerIds: number[],
options?: { skipUndeletable?: boolean },
) => {
return this.bulkDeleteCustomersService.bulkDeleteCustomers(
customerIds,
options,
);
};
public validateBulkDeleteCustomers = (customerIds: number[]) => {
return this.validateBulkDeleteCustomersService.validateBulkDeleteCustomers(
customerIds,
);
};
}

View File

@@ -0,0 +1,61 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteCustomer } from './commands/DeleteCustomer.service';
import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception';
import { ServiceError } from '@/modules/Items/ServiceError';
@Injectable()
export class ValidateBulkDeleteCustomersService {
constructor(
private readonly deleteCustomerService: DeleteCustomer,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) {}
public async validateBulkDeleteCustomers(customerIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const customerId of customerIds) {
try {
await this.deleteCustomerService.deleteCustomer(customerId, trx);
deletableIds.push(customerId);
} catch (error) {
if (
error instanceof ModelHasRelationsError ||
(error instanceof ServiceError &&
error.errorType === 'CUSTOMER_HAS_TRANSACTIONS')
) {
nonDeletableIds.push(customerId);
} else {
nonDeletableIds.push(customerId);
}
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -31,12 +31,13 @@ export class DeleteCustomer {
* @param {number} customerId - Customer ID.
* @return {Promise<void>}
*/
public async deleteCustomer(customerId: number): Promise<void> {
public async deleteCustomer(
customerId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieve the customer or throw not found service error.
const oldCustomer = await this.customerModel()
.query()
.findById(customerId)
.throwIfNotFound();
const query = this.customerModel().query(trx);
const oldCustomer = await query.findById(customerId).throwIfNotFound();
// Triggers `onCustomerDeleting` event.
await this.eventPublisher.emitAsync(events.customers.onDeleting, {
@@ -45,10 +46,10 @@ export class DeleteCustomer {
} as ICustomerDeletingPayload);
// Deletes the customer and associated entities under UOW transaction.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
return this.uow.withTransaction(async (transaction: Knex.Transaction) => {
// Delete the customer from the storage.
await this.customerModel()
.query(trx)
.query(transaction)
.findById(customerId)
.deleteIfNoRelations({
type: ERRORS.CUSTOMER_HAS_TRANSACTIONS,
@@ -58,8 +59,8 @@ export class DeleteCustomer {
await this.eventPublisher.emitAsync(events.customers.onDeleted, {
customerId,
oldCustomer,
trx,
trx: transaction,
} as ICustomerEventDeletedPayload);
});
}, trx);
}
}

View File

@@ -0,0 +1,62 @@
import {
ArrayNotEmpty,
IsArray,
IsInt,
IsOptional,
IsBoolean,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { parseBoolean } from '@/utils/parse-boolean';
export class BulkDeleteCustomersDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
@ApiProperty({
description: 'Array of customer IDs to delete',
type: [Number],
example: [1, 2, 3],
})
ids: number[];
@IsOptional()
@IsBoolean()
@Transform(({ value, obj }) => parseBoolean(value ?? obj?.skip_undeletable, false))
@ApiPropertyOptional({
description:
'When true, undeletable customers will be skipped and only deletable ones removed.',
type: Boolean,
default: false,
})
skipUndeletable?: boolean;
}
export class ValidateBulkDeleteCustomersResponseDto {
@ApiProperty({
description: 'Number of customers that can be deleted',
example: 2,
})
deletableCount: number;
@ApiProperty({
description: 'Number of customers that cannot be deleted',
example: 1,
})
nonDeletableCount: number;
@ApiProperty({
description: 'IDs of customers that can be deleted',
type: [Number],
example: [1, 2],
})
deletableIds: number[];
@ApiProperty({
description: 'IDs of customers that cannot be deleted',
type: [Number],
example: [3],
})
nonDeletableIds: number[];
}

View File

@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteExpense } from './commands/DeleteExpense.service';
@Injectable()
export class BulkDeleteExpensesService {
constructor(private readonly deleteExpenseService: DeleteExpense) { }
async bulkDeleteExpenses(
expenseIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const expensesIds = uniq(castArray(expenseIds));
const results = await PromisePool.withConcurrency(1)
.for(expensesIds)
.process(async (expenseId: number) => {
try {
await this.deleteExpenseService.deleteExpense(expenseId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -21,13 +21,55 @@ import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { ExpenseResponseDto } from './dtos/ExpenseResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('expenses')
@ApiTags('Expenses')
@ApiExtraModels(PaginatedResponseDto, ExpenseResponseDto)
@ApiExtraModels(
PaginatedResponseDto,
ExpenseResponseDto,
ValidateBulkDeleteResponseDto,
)
@ApiCommonHeaders()
export class ExpensesController {
constructor(private readonly expensesApplication: ExpensesApplication) {}
constructor(private readonly expensesApplication: ExpensesApplication) { }
@Post('validate-bulk-delete')
@ApiOperation({
summary: 'Validate which expenses can be deleted and return the results.',
})
@ApiResponse({
status: 200,
description:
'Validation completed with counts and IDs of deletable and non-deletable expenses.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
public validateBulkDeleteExpenses(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.expensesApplication.validateBulkDeleteExpenses(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple expenses.' })
@ApiResponse({
status: 200,
description: 'Expenses deleted successfully',
})
public bulkDeleteExpenses(
@Body() bulkDeleteDto: BulkDeleteDto,
) {
return this.expensesApplication.bulkDeleteExpenses(bulkDeleteDto.ids, {
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
});
}
/**
* Create a new expense transaction.

View File

@@ -19,6 +19,8 @@ import { GetExpensesService } from './queries/GetExpenses.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { ExpensesExportable } from './ExpensesExportable';
import { ExpensesImportable } from './ExpensesImportable';
import { BulkDeleteExpensesService } from './BulkDeleteExpenses.service';
import { ValidateBulkDeleteExpensesService } from './ValidateBulkDeleteExpenses.service';
@Module({
imports: [LedgerModule, BranchesModule, DynamicListModule],
@@ -41,6 +43,8 @@ import { ExpensesImportable } from './ExpensesImportable';
GetExpensesService,
ExpensesExportable,
ExpensesImportable,
BulkDeleteExpensesService,
ValidateBulkDeleteExpensesService,
],
})
export class ExpensesModule {}

View File

@@ -7,6 +7,8 @@ import { GetExpenseService } from './queries/GetExpense.service';
import { IExpensesFilter } from './interfaces/Expenses.interface';
import { GetExpensesService } from './queries/GetExpenses.service';
import { CreateExpenseDto, EditExpenseDto } from './dtos/Expense.dto';
import { BulkDeleteExpensesService } from './BulkDeleteExpenses.service';
import { ValidateBulkDeleteExpensesService } from './ValidateBulkDeleteExpenses.service';
@Injectable()
export class ExpensesApplication {
@@ -17,7 +19,9 @@ export class ExpensesApplication {
private readonly publishExpenseService: PublishExpense,
private readonly getExpenseService: GetExpenseService,
private readonly getExpensesService: GetExpensesService,
) {}
private readonly bulkDeleteExpensesService: BulkDeleteExpensesService,
private readonly validateBulkDeleteExpensesService: ValidateBulkDeleteExpensesService,
) { }
/**
* Create a new expense transaction.
@@ -47,6 +51,30 @@ export class ExpensesApplication {
return this.deleteExpenseService.deleteExpense(expenseId);
}
/**
* Deletes expenses in bulk.
* @param {number[]} expenseIds - Expense ids.
*/
public bulkDeleteExpenses(
expenseIds: number[],
options?: { skipUndeletable?: boolean },
) {
return this.bulkDeleteExpensesService.bulkDeleteExpenses(
expenseIds,
options,
);
}
/**
* Validates which expenses can be deleted.
* @param {number[]} expenseIds - Expense ids.
*/
public validateBulkDeleteExpenses(expenseIds: number[]) {
return this.validateBulkDeleteExpensesService.validateBulkDeleteExpenses(
expenseIds,
);
}
/**
* Publishes the given expense.
* @param {number} expenseId - Expense id.

View File

@@ -0,0 +1,51 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteExpense } from './commands/DeleteExpense.service';
@Injectable()
export class ValidateBulkDeleteExpensesService {
constructor(
private readonly deleteExpenseService: DeleteExpense,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) { }
public async validateBulkDeleteExpenses(expenseIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const expenseId of expenseIds) {
try {
await this.deleteExpenseService.deleteExpense(expenseId, trx);
deletableIds.push(expenseId);
} catch (error) {
nonDeletableIds.push(expenseId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -36,9 +36,12 @@ export class DeleteExpense {
/**
* Deletes the given expense.
* @param {number} expenseId
* @param {ISystemUser} authorizedUser
* @param {Knex.Transaction} trx - Database transaction instance.
*/
public async deleteExpense(expenseId: number): Promise<void> {
public async deleteExpense(
expenseId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieves the expense transaction with associated entries or
// throw not found error.
const oldExpense = await this.expenseModel()
@@ -74,6 +77,6 @@ export class DeleteExpense {
oldExpense,
trx,
} as IExpenseEventDeletePayload);
});
}, trx);
}
}

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service';
@Injectable()
export class BulkDeleteItemCategoriesService {
constructor(
private readonly deleteItemCategoryService: DeleteItemCategoryService,
) { }
async bulkDeleteItemCategories(
itemCategoryIds: number | Array<number>,
trx?: Knex.Transaction,
): Promise<void> {
const categoriesIds = uniq(castArray(itemCategoryIds));
const results = await PromisePool.withConcurrency(1)
.for(categoriesIds)
.process(async (itemCategoryId: number) => {
await this.deleteItemCategoryService.deleteItemCategory(itemCategoryId);
});
if (results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -0,0 +1,54 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service';
@Injectable()
export class ValidateBulkDeleteItemCategoriesService {
constructor(
private readonly deleteItemCategoryService: DeleteItemCategoryService,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) {}
public async validateBulkDeleteItemCategories(itemCategoryIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const itemCategoryId of itemCategoryIds) {
try {
await this.deleteItemCategoryService.deleteItemCategory(
itemCategoryId,
trx,
);
deletableIds.push(itemCategoryId);
} catch (error) {
nonDeletableIds.push(itemCategoryId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -32,9 +32,13 @@ export class DeleteItemCategoryService {
/**
* Deletes the given item category.
* @param {number} itemCategoryId - Item category id.
* @param {Knex.Transaction} trx - Database transaction instance.
* @return {Promise<void>}
*/
public async deleteItemCategory(itemCategoryId: number) {
public async deleteItemCategory(
itemCategoryId: number,
trx?: Knex.Transaction,
) {
// Retrieve item category or throw not found error.
const oldItemCategory = await this.itemCategoryModel()
.query()
@@ -56,7 +60,7 @@ export class DeleteItemCategoryService {
itemCategoryId,
oldItemCategory,
} as IItemCategoryDeletedPayload);
});
}, trx);
}
/**

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteItemService } from './DeleteItem.service';
@Injectable()
export class BulkDeleteItemsService {
constructor(private readonly deleteItemService: DeleteItemService) {}
/**
* Deletes multiple items.
* @param {number | Array<number>} itemIds - The item id or ids.
* @param {Knex.Transaction} trx - The transaction.
*/
async bulkDeleteItems(
itemIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const itemsIds = uniq(castArray(itemIds));
const results = await PromisePool.withConcurrency(1)
.for(itemsIds)
.process(async (itemId: number) => {
try {
await this.deleteItemService.deleteItem(itemId, trx);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw ?? results.errors[0];
}
}
}

View File

@@ -29,6 +29,10 @@ import { ItemEstimatesResponseDto } from './dtos/ItemEstimatesResponse.dto';
import { ItemBillsResponseDto } from './dtos/ItemBillsResponse.dto';
import { ItemReceiptsResponseDto } from './dtos/ItemReceiptsResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteItemsDto,
ValidateBulkDeleteItemsResponseDto,
} from './dtos/BulkDeleteItems.dto';
@Controller('/items')
@ApiTags('Items')
@@ -39,6 +43,7 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@ApiExtraModels(ItemBillsResponseDto)
@ApiExtraModels(ItemEstimatesResponseDto)
@ApiExtraModels(ItemReceiptsResponseDto)
@ApiExtraModels(ValidateBulkDeleteItemsResponseDto)
@ApiCommonHeaders()
export class ItemsController extends TenantController {
constructor(private readonly itemsApplication: ItemsApplicationService) {
@@ -340,4 +345,37 @@ export class ItemsController extends TenantController {
const itemId = parseInt(id, 10);
return this.itemsApplication.getItemReceiptsTransactions(itemId);
}
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which items can be deleted and returns counts of deletable and non-deletable items.',
})
@ApiResponse({
status: 200,
description:
'Validation completed. Returns counts and IDs of deletable and non-deletable items.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteItemsResponseDto),
},
})
async validateBulkDeleteItems(
@Body() bulkDeleteDto: BulkDeleteItemsDto,
): Promise<ValidateBulkDeleteItemsResponseDto> {
return this.itemsApplication.validateBulkDeleteItems(bulkDeleteDto.ids);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple items in bulk.' })
@ApiResponse({
status: 200,
description: 'The items have been successfully deleted.',
})
async bulkDeleteItems(
@Body() bulkDeleteDto: BulkDeleteItemsDto,
): Promise<void> {
return this.itemsApplication.bulkDeleteItems(bulkDeleteDto.ids, {
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
});
}
}

View File

@@ -18,6 +18,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { InventoryAdjustmentsModule } from '../InventoryAdjutments/InventoryAdjustments.module';
import { ItemsExportable } from './ItemsExportable.service';
import { ItemsImportable } from './ItemsImportable.service';
import { BulkDeleteItemsService } from './BulkDeleteItems.service';
import { ValidateBulkDeleteItemsService } from './ValidateBulkDeleteItems.service';
@Module({
imports: [
@@ -41,8 +43,10 @@ import { ItemsImportable } from './ItemsImportable.service';
TransformerInjectable,
ItemsEntriesService,
ItemsExportable,
ItemsImportable
ItemsImportable,
BulkDeleteItemsService,
ValidateBulkDeleteItemsService,
],
exports: [ItemsEntriesService, ItemsExportable, ItemsImportable],
})
export class ItemsModule {}
export class ItemsModule { }

View File

@@ -13,6 +13,8 @@ import { GetItemsService } from './GetItems.service';
import { IItemsFilter } from './types/Items.types';
import { EditItemDto, CreateItemDto } from './dtos/Item.dto';
import { GetItemsQueryDto } from './dtos/GetItemsQuery.dto';
import { BulkDeleteItemsService } from './BulkDeleteItems.service';
import { ValidateBulkDeleteItemsService } from './ValidateBulkDeleteItems.service';
@Injectable()
export class ItemsApplicationService {
@@ -25,7 +27,9 @@ export class ItemsApplicationService {
private readonly getItemService: GetItemService,
private readonly getItemsService: GetItemsService,
private readonly itemTransactionsService: ItemTransactionsService,
) {}
private readonly bulkDeleteItemsService: BulkDeleteItemsService,
private readonly validateBulkDeleteItemsService: ValidateBulkDeleteItemsService,
) { }
/**
* Creates a new item.
@@ -134,4 +138,30 @@ export class ItemsApplicationService {
async getItemReceiptsTransactions(itemId: number): Promise<any> {
return this.itemTransactionsService.getItemReceiptTransactions(itemId);
}
/**
* Validates which items can be deleted in bulk.
* @param {number[]} itemIds - Array of item IDs to validate
* @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>}
*/
async validateBulkDeleteItems(itemIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
return this.validateBulkDeleteItemsService.validateBulkDeleteItems(itemIds);
}
/**
* Deletes multiple items in bulk.
* @param {number[]} itemIds - Array of item IDs to delete
* @returns {Promise<void>}
*/
async bulkDeleteItems(
itemIds: number[],
options?: { skipUndeletable?: boolean },
): Promise<void> {
return this.bulkDeleteItemsService.bulkDeleteItems(itemIds, options);
}
}

View File

@@ -0,0 +1,74 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteItemService } from './DeleteItem.service';
import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception';
@Injectable()
export class ValidateBulkDeleteItemsService {
constructor(
private readonly deleteItemService: DeleteItemService,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) { }
/**
* Validates which items from the provided IDs can be deleted.
* Uses the actual deleteItem service to validate, ensuring the same validation logic.
* Uses a transaction that is always rolled back to ensure no database changes.
* @param {number[]} itemIds - Array of item IDs to validate
* @returns {Promise<{deletableCount: number, nonDeletableCount: number, deletableIds: number[], nonDeletableIds: number[]}>}
*/
public async validateBulkDeleteItems(itemIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
// Create a transaction that will be rolled back
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
// Check each item to see if it can be deleted by attempting deletion in transaction
for (const itemId of itemIds) {
try {
// Attempt to delete the item using the deleteItem service with the transaction
// This will use the exact same validation logic as the actual delete
await this.deleteItemService.deleteItem(itemId, trx);
// If deletion succeeds, item is deletable
deletableIds.push(itemId);
} catch (error) {
// If error occurs, check the type of error
if (error instanceof ModelHasRelationsError) {
// Item has associated transactions/relations, cannot be deleted
nonDeletableIds.push(itemId);
} else {
// Other errors (e.g., item not found), also mark as non-deletable
nonDeletableIds.push(itemId);
}
}
}
// Always rollback the transaction to ensure no changes are persisted
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
// Rollback in case of any error
await trx.rollback();
throw error;
}
}
}

View File

@@ -0,0 +1,62 @@
import {
IsArray,
IsInt,
ArrayNotEmpty,
IsOptional,
IsBoolean,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { parseBoolean } from '@/utils/parse-boolean';
export class BulkDeleteItemsDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
@ApiProperty({
description: 'Array of item IDs to delete',
type: [Number],
example: [1, 2, 3],
})
ids: number[];
@IsOptional()
@IsBoolean()
@Transform(({ value, obj }) => parseBoolean(value ?? obj?.skip_undeletable, false))
@ApiPropertyOptional({
description:
'When true, undeletable items will be skipped and only deletable ones removed.',
type: Boolean,
default: false,
})
skipUndeletable?: boolean;
}
export class ValidateBulkDeleteItemsResponseDto {
@ApiProperty({
description: 'Number of items that can be deleted',
example: 2,
})
deletableCount: number;
@ApiProperty({
description: 'Number of items that cannot be deleted',
example: 1,
})
nonDeletableCount: number;
@ApiProperty({
description: 'IDs of items that can be deleted',
type: [Number],
example: [1, 2],
})
deletableIds: number[];
@ApiProperty({
description: 'IDs of items that cannot be deleted',
type: [Number],
example: [3],
})
nonDeletableIds: number[];
}

View File

@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteManualJournalService } from './commands/DeleteManualJournal.service';
@Injectable()
export class BulkDeleteManualJournalsService {
constructor(
private readonly deleteManualJournalService: DeleteManualJournalService,
) { }
async bulkDeleteManualJournals(
manualJournalIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const journalsIds = uniq(castArray(manualJournalIds));
const results = await PromisePool.withConcurrency(1)
.for(journalsIds)
.process(async (manualJournalId: number) => {
try {
await this.deleteManualJournalService.deleteManualJournal(
manualJournalId,
);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -25,13 +25,54 @@ import {
import { IManualJournalsFilter } from './types/ManualJournals.types';
import { ManualJournalResponseDto } from './dtos/ManualJournalResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('manual-journals')
@ApiTags('Manual Journals')
@ApiExtraModels(ManualJournalResponseDto)
@ApiExtraModels(ValidateBulkDeleteResponseDto)
@ApiCommonHeaders()
export class ManualJournalsController {
constructor(private manualJournalsApplication: ManualJournalsApplication) {}
constructor(private manualJournalsApplication: ManualJournalsApplication) { }
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validate which manual journals can be deleted and return the results.',
})
@ApiResponse({
status: 200,
description:
'Validation completed with counts and IDs of deletable and non-deletable manual journals.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
public validateBulkDeleteManualJournals(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.manualJournalsApplication.validateBulkDeleteManualJournals(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple manual journals.' })
@ApiResponse({
status: 200,
description: 'Manual journals deleted successfully',
})
public bulkDeleteManualJournals(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<void> {
return this.manualJournalsApplication.bulkDeleteManualJournals(
bulkDeleteDto.ids,
{ skipUndeletable: bulkDeleteDto.skipUndeletable ?? false },
);
}
@Post()
@ApiOperation({ summary: 'Create a new manual journal.' })

View File

@@ -19,6 +19,8 @@ import { ManualJournalsExportable } from './commands/ManualJournalExportable';
import { ManualJournalImportable } from './commands/ManualJournalsImport';
import { GetManualJournals } from './queries/GetManualJournals.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { BulkDeleteManualJournalsService } from './BulkDeleteManualJournals.service';
import { ValidateBulkDeleteManualJournalsService } from './ValidateBulkDeleteManualJournals.service';
@Module({
imports: [BranchesModule, LedgerModule, DynamicListModule],
@@ -41,6 +43,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module';
ManualJournalWriteGLSubscriber,
ManualJournalsExportable,
ManualJournalImportable,
BulkDeleteManualJournalsService,
ValidateBulkDeleteManualJournalsService,
],
exports: [ManualJournalsExportable, ManualJournalImportable],
})

View File

@@ -10,6 +10,8 @@ import {
EditManualJournalDto,
} from './dtos/ManualJournal.dto';
import { GetManualJournals } from './queries/GetManualJournals.service';
import { BulkDeleteManualJournalsService } from './BulkDeleteManualJournals.service';
import { ValidateBulkDeleteManualJournalsService } from './ValidateBulkDeleteManualJournals.service';
// import { GetManualJournals } from './queries/GetManualJournals';
@Injectable()
@@ -21,7 +23,9 @@ export class ManualJournalsApplication {
private publishManualJournalService: PublishManualJournal,
private getManualJournalService: GetManualJournal,
private getManualJournalsService: GetManualJournals,
) {}
private bulkDeleteManualJournalsService: BulkDeleteManualJournalsService,
private validateBulkDeleteManualJournalsService: ValidateBulkDeleteManualJournalsService,
) { }
/**
* Make journal entries.
@@ -57,6 +61,30 @@ export class ManualJournalsApplication {
return this.deleteManualJournalService.deleteManualJournal(manualJournalId);
};
/**
* Bulk deletes manual journals.
* @param {number[]} manualJournalIds
*/
public bulkDeleteManualJournals = (
manualJournalIds: number[],
options?: { skipUndeletable?: boolean },
) => {
return this.bulkDeleteManualJournalsService.bulkDeleteManualJournals(
manualJournalIds,
options,
);
};
/**
* Validates which manual journals can be deleted.
* @param {number[]} manualJournalIds
*/
public validateBulkDeleteManualJournals = (manualJournalIds: number[]) => {
return this.validateBulkDeleteManualJournalsService.validateBulkDeleteManualJournals(
manualJournalIds,
);
};
/**
* Publish the given manual journal.
* @param {number} manualJournalId - Manual journal id.

View File

@@ -0,0 +1,56 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteManualJournalService } from './commands/DeleteManualJournal.service';
@Injectable()
export class ValidateBulkDeleteManualJournalsService {
constructor(
private readonly deleteManualJournalService: DeleteManualJournalService,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) {}
public async validateBulkDeleteManualJournals(
manualJournalIds: number[],
): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const manualJournalId of manualJournalIds) {
try {
await this.deleteManualJournalService.deleteManualJournal(
manualJournalId,
trx,
);
deletableIds.push(manualJournalId);
} catch (error) {
nonDeletableIds.push(manualJournalId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -29,10 +29,12 @@ export class DeleteManualJournalService {
/**
* Deletes the given manual journal
* @param {number} manualJournalId
* @param {Knex.Transaction} trx - Database transaction instance.
* @return {Promise<void>}
*/
public deleteManualJournal = async (
manualJournalId: number,
trx?: Knex.Transaction,
): Promise<{
oldManualJournal: ManualJournal;
}> => {
@@ -70,6 +72,6 @@ export class DeleteManualJournalService {
} as IManualJournalEventDeletedPayload);
return { oldManualJournal };
});
}, trx);
};
}

View File

@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeletePaymentReceivedService } from './commands/DeletePaymentReceived.service';
@Injectable()
export class BulkDeletePaymentReceivedService {
constructor(
private readonly deletePaymentReceivedService: DeletePaymentReceivedService,
) { }
async bulkDeletePaymentReceived(
paymentReceiveIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const paymentsIds = uniq(castArray(paymentReceiveIds));
const results = await PromisePool.withConcurrency(1)
.for(paymentsIds)
.process(async (paymentReceiveId: number) => {
try {
await this.deletePaymentReceivedService.deletePaymentReceive(
paymentReceiveId,
);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -18,6 +18,8 @@ import {
} from './dtos/PaymentReceived.dto';
import { PaymentsReceivedPagesService } from './queries/PaymentsReceivedPages.service';
import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailState.service';
import { BulkDeletePaymentReceivedService } from './BulkDeletePaymentReceived.service';
import { ValidateBulkDeletePaymentReceivedService } from './ValidateBulkDeletePaymentReceived.service';
@Injectable()
export class PaymentReceivesApplication {
@@ -33,7 +35,9 @@ export class PaymentReceivesApplication {
private getPaymentReceivePdfService: GetPaymentReceivedPdfService,
private getPaymentReceivedStateService: GetPaymentReceivedStateService,
private paymentsReceivedPagesService: PaymentsReceivedPagesService,
) {}
private bulkDeletePaymentReceivedService: BulkDeletePaymentReceivedService,
private validateBulkDeletePaymentReceivedService: ValidateBulkDeletePaymentReceivedService,
) { }
/**
* Creates a new payment receive.
@@ -73,6 +77,29 @@ export class PaymentReceivesApplication {
);
}
/**
* Deletes multiple payment receives.
* @param {number[]} paymentReceiveIds
*/
public bulkDeletePaymentReceives(
paymentReceiveIds: number[],
options?: { skipUndeletable?: boolean },
) {
return this.bulkDeletePaymentReceivedService.bulkDeletePaymentReceived(
paymentReceiveIds,
options,
);
}
/**
* Validates which payment receives can be deleted.
* @param {number[]} paymentReceiveIds
*/
public validateBulkDeletePaymentReceives(paymentReceiveIds: number[]) {
return this.validateBulkDeletePaymentReceivedService
.validateBulkDeletePaymentReceived(paymentReceiveIds);
}
/**
* Retrieve payment receives paginated and filterable.
* @param {number} tenantId

View File

@@ -32,15 +32,20 @@ import { PaymentReceivedResponseDto } from './dtos/PaymentReceivedResponse.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { PaymentReceivedStateResponseDto } from './dtos/PaymentReceivedStateResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('payments-received')
@ApiTags('Payments Received')
@ApiExtraModels(PaymentReceivedResponseDto)
@ApiExtraModels(PaginatedResponseDto)
@ApiExtraModels(PaymentReceivedStateResponseDto)
@ApiExtraModels(ValidateBulkDeleteResponseDto)
@ApiCommonHeaders()
export class PaymentReceivesController {
constructor(private paymentReceivesApplication: PaymentReceivesApplication) {}
constructor(private paymentReceivesApplication: PaymentReceivesApplication) { }
@Post(':id/mail')
@HttpCode(200)
@@ -143,6 +148,42 @@ export class PaymentReceivesController {
return this.paymentReceivesApplication.getPaymentsReceived(filterDTO);
}
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which payments received can be deleted and returns the results.',
})
@ApiResponse({
status: 200,
description:
'Validation completed with counts and IDs of deletable and non-deletable payments received.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
public validateBulkDeletePaymentsReceived(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.paymentReceivesApplication.validateBulkDeletePaymentReceives(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple payments received.' })
@ApiResponse({
status: 200,
description: 'Payments received deleted successfully.',
})
public bulkDeletePaymentsReceived(
@Body() bulkDeleteDto: BulkDeleteDto,
) {
return this.paymentReceivesApplication.bulkDeletePaymentReceives(
bulkDeleteDto.ids,
{ skipUndeletable: bulkDeleteDto.skipUndeletable ?? false },
);
}
@Get('state')
@ApiOperation({ summary: 'Retrieves the payment received state.' })
@ApiResponse({

View File

@@ -39,6 +39,8 @@ import { PaymentsReceivedImportable } from './commands/PaymentsReceivedImportabl
import { PaymentsReceivedPagesService } from './queries/PaymentsReceivedPages.service';
import { GetPaymentReceivedMailTemplate } from './queries/GetPaymentReceivedMailTemplate.service';
import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailState.service';
import { BulkDeletePaymentReceivedService } from './BulkDeletePaymentReceived.service';
import { ValidateBulkDeletePaymentReceivedService } from './ValidateBulkDeletePaymentReceived.service';
@Module({
controllers: [PaymentReceivesController],
@@ -68,7 +70,9 @@ import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailSta
PaymentsReceivedImportable,
PaymentsReceivedPagesService,
GetPaymentReceivedMailTemplate,
GetPaymentReceivedMailState
GetPaymentReceivedMailState,
BulkDeletePaymentReceivedService,
ValidateBulkDeletePaymentReceivedService,
],
exports: [
PaymentReceivesApplication,
@@ -76,7 +80,7 @@ import { GetPaymentReceivedMailState } from './queries/GetPaymentReceivedMailSta
PaymentReceivedGLEntries,
PaymentsReceivedExportable,
PaymentsReceivedImportable,
PaymentReceivedValidators
PaymentReceivedValidators,
],
imports: [
ChromiumlyTenancyModule,

View File

@@ -0,0 +1,56 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeletePaymentReceivedService } from './commands/DeletePaymentReceived.service';
@Injectable()
export class ValidateBulkDeletePaymentReceivedService {
constructor(
private readonly deletePaymentReceivedService: DeletePaymentReceivedService,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) { }
public async validateBulkDeletePaymentReceived(
paymentReceiveIds: number[],
): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const paymentReceiveId of paymentReceiveIds) {
try {
await this.deletePaymentReceivedService.deletePaymentReceive(
paymentReceiveId,
trx,
);
deletableIds.push(paymentReceiveId);
} catch (error) {
nonDeletableIds.push(paymentReceiveId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -30,7 +30,7 @@ export class DeletePaymentReceivedService {
private paymentReceiveEntryModel: TenantModelProxy<
typeof PaymentReceivedEntry
>,
) {}
) { }
/**
* Deletes the given payment receive with associated entries
@@ -43,9 +43,12 @@ export class DeletePaymentReceivedService {
* - Revert the payment amount of the associated invoices.
* @async
* @param {Integer} paymentReceiveId - Payment receive id.
* @param {IPaymentReceived} paymentReceive - Payment receive object.
* @param {Knex.Transaction} trx - Database transaction instance.
*/
public async deletePaymentReceive(paymentReceiveId: number) {
public async deletePaymentReceive(
paymentReceiveId: number,
trx?: Knex.Transaction,
) {
// Retreive payment receive or throw not found service error.
const oldPaymentReceive = await this.paymentReceiveModel()
.query()
@@ -79,6 +82,6 @@ export class DeletePaymentReceivedService {
oldPaymentReceive,
trx,
} as IPaymentReceivedDeletedPayload);
});
}, trx);
}
}

View File

@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteSaleEstimate } from './commands/DeleteSaleEstimate.service';
@Injectable()
export class BulkDeleteSaleEstimatesService {
constructor(private readonly deleteSaleEstimateService: DeleteSaleEstimate) { }
async bulkDeleteSaleEstimates(
saleEstimateIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const estimatesIds = uniq(castArray(saleEstimateIds));
const results = await PromisePool.withConcurrency(1)
.for(estimatesIds)
.process(async (saleEstimateId: number) => {
try {
await this.deleteSaleEstimateService.deleteEstimate(saleEstimateId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -19,6 +19,8 @@ import {
EditSaleEstimateDto,
} from './dtos/SaleEstimate.dto';
import { GetSaleEstimateMailStateService } from './queries/GetSaleEstimateMailState.service';
import { BulkDeleteSaleEstimatesService } from './BulkDeleteSaleEstimates.service';
import { ValidateBulkDeleteSaleEstimatesService } from './ValidateBulkDeleteSaleEstimates.service';
@Injectable()
export class SaleEstimatesApplication {
@@ -35,7 +37,9 @@ export class SaleEstimatesApplication {
private readonly getSaleEstimateStateService: GetSaleEstimateState,
private readonly saleEstimatesPdfService: GetSaleEstimatePdf,
private readonly getSaleEstimateMailStateService: GetSaleEstimateMailStateService,
) {}
private readonly bulkDeleteSaleEstimatesService: BulkDeleteSaleEstimatesService,
private readonly validateBulkDeleteSaleEstimatesService: ValidateBulkDeleteSaleEstimatesService,
) { }
/**
* Create a sale estimate.
@@ -68,6 +72,31 @@ export class SaleEstimatesApplication {
return this.deleteSaleEstimateService.deleteEstimate(estimateId);
}
/**
* Deletes multiple sale estimates.
* @param {number[]} saleEstimateIds
* @return {Promise<void>}
*/
public bulkDeleteSaleEstimates(
saleEstimateIds: number[],
options?: { skipUndeletable?: boolean },
) {
return this.bulkDeleteSaleEstimatesService.bulkDeleteSaleEstimates(
saleEstimateIds,
options,
);
}
/**
* Validates which sale estimates can be deleted.
* @param {number[]} saleEstimateIds
*/
public validateBulkDeleteSaleEstimates(saleEstimateIds: number[]) {
return this.validateBulkDeleteSaleEstimatesService.validateBulkDeleteSaleEstimates(
saleEstimateIds,
);
}
/**
* Retrieves the given sale estimate.
* @param {number} estimateId - Sale estimate ID.

View File

@@ -36,6 +36,10 @@ import { SaleEstimateResponseDto } from './dtos/SaleEstimateResponse.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { SaleEstiamteStateResponseDto } from './dtos/SaleEstimateStateResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('sale-estimates')
@ApiTags('Sale Estimates')
@@ -43,13 +47,50 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@ApiExtraModels(PaginatedResponseDto)
@ApiExtraModels(SaleEstiamteStateResponseDto)
@ApiCommonHeaders()
@ApiExtraModels(ValidateBulkDeleteResponseDto)
export class SaleEstimatesController {
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which sale estimates can be deleted and returns the results.',
})
@ApiResponse({
status: 200,
description:
'Validation completed with counts and IDs of deletable and non-deletable sale estimates.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
public validateBulkDeleteSaleEstimates(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.saleEstimatesApplication.validateBulkDeleteSaleEstimates(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple sale estimates.' })
@ApiResponse({
status: 200,
description: 'Sale estimates deleted successfully',
})
public bulkDeleteSaleEstimates(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<void> {
return this.saleEstimatesApplication.bulkDeleteSaleEstimates(
bulkDeleteDto.ids,
{ skipUndeletable: bulkDeleteDto.skipUndeletable ?? false },
);
}
/**
* @param {SaleEstimatesApplication} saleEstimatesApplication - Sale estimates application.
*/
constructor(
private readonly saleEstimatesApplication: SaleEstimatesApplication,
) {}
) { }
@Post()
@ApiOperation({ summary: 'Create a new sale estimate.' })

View File

@@ -40,6 +40,8 @@ import { SaleEstimatesImportable } from './SaleEstimatesImportable';
import { GetSaleEstimateMailStateService } from './queries/GetSaleEstimateMailState.service';
import { GetSaleEstimateMailTemplateService } from './queries/GetSaleEstimateMailTemplate.service';
import { SaleEstimateAutoIncrementSubscriber } from './subscribers/SaleEstimateAutoIncrementSubscriber';
import { BulkDeleteSaleEstimatesService } from './BulkDeleteSaleEstimates.service';
import { ValidateBulkDeleteSaleEstimatesService } from './ValidateBulkDeleteSaleEstimates.service';
@Module({
imports: [
@@ -85,6 +87,8 @@ import { SaleEstimateAutoIncrementSubscriber } from './subscribers/SaleEstimateA
GetSaleEstimateMailStateService,
GetSaleEstimateMailTemplateService,
SaleEstimateAutoIncrementSubscriber,
BulkDeleteSaleEstimatesService,
ValidateBulkDeleteSaleEstimatesService,
],
exports: [
SaleEstimatesExportable,

View File

@@ -0,0 +1,55 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteSaleEstimate } from './commands/DeleteSaleEstimate.service';
@Injectable()
export class ValidateBulkDeleteSaleEstimatesService {
constructor(
private readonly deleteSaleEstimateService: DeleteSaleEstimate,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) { }
public async validateBulkDeleteSaleEstimates(
saleEstimateIds: number[],
): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const saleEstimateId of saleEstimateIds) {
try {
await this.deleteSaleEstimateService.deleteEstimate(
saleEstimateId,
trx,
);
deletableIds.push(saleEstimateId);
} catch (error) {
nonDeletableIds.push(saleEstimateId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -24,18 +24,22 @@ export class DeleteSaleEstimate {
private readonly eventPublisher: EventEmitter2,
private readonly uow: UnitOfWork,
) {}
) { }
/**
* Deletes the given estimate id with associated entries.
* @async
* @param {number} estimateId
* @param {number} estimateId - Sale estimate id.
* @param {Knex.Transaction} trx - Database transaction instance.
* @return {Promise<void>}
*/
public async deleteEstimate(estimateId: number): Promise<void> {
public async deleteEstimate(
estimateId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieve sale estimate or throw not found service error.
const oldSaleEstimate = await this.saleEstimateModel()
.query()
.query(trx)
.findById(estimateId)
.throwIfNotFound();
@@ -70,6 +74,6 @@ export class DeleteSaleEstimate {
oldSaleEstimate,
trx,
} as ISaleEstimateDeletedPayload);
});
}, trx);
}
}

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteSaleInvoice } from './commands/DeleteSaleInvoice.service';
@Injectable()
export class BulkDeleteSaleInvoicesService {
constructor(private readonly deleteSaleInvoiceService: DeleteSaleInvoice) { }
async bulkDeleteSaleInvoices(
saleInvoiceIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const invoicesIds = uniq(castArray(saleInvoiceIds));
const results = await PromisePool.withConcurrency(1)
.for(invoicesIds)
.process(async (saleInvoiceId: number) => {
try {
await this.deleteSaleInvoiceService.deleteSaleInvoice(saleInvoiceId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -23,6 +23,8 @@ import {
EditSaleInvoiceDto,
} from './dtos/SaleInvoice.dto';
import { GenerateShareLink } from './commands/GenerateInvoicePaymentLink.service';
import { BulkDeleteSaleInvoicesService } from './BulkDeleteSaleInvoices.service';
import { ValidateBulkDeleteSaleInvoicesService } from './ValidateBulkDeleteSaleInvoices.service';
@Injectable()
export class SaleInvoiceApplication {
@@ -41,7 +43,9 @@ export class SaleInvoiceApplication {
private sendSaleInvoiceMailService: SendSaleInvoiceMail,
private getSaleInvoiceMailStateService: GetSaleInvoiceMailState,
private generateShareLinkService: GenerateShareLink,
) {}
private bulkDeleteSaleInvoicesService: BulkDeleteSaleInvoicesService,
private validateBulkDeleteSaleInvoicesService: ValidateBulkDeleteSaleInvoicesService,
) { }
/**
* Creates a new sale invoice with associated GL entries.
@@ -78,6 +82,31 @@ export class SaleInvoiceApplication {
return this.deleteSaleInvoiceService.deleteSaleInvoice(saleInvoiceId);
}
/**
* Deletes multiple sale invoices.
* @param {number[]} saleInvoiceIds
* @return {Promise<void>}
*/
public bulkDeleteSaleInvoices(
saleInvoiceIds: number[],
options?: { skipUndeletable?: boolean },
) {
return this.bulkDeleteSaleInvoicesService.bulkDeleteSaleInvoices(
saleInvoiceIds,
options,
);
}
/**
* Validates which sale invoices can be deleted.
* @param {number[]} saleInvoiceIds
*/
public validateBulkDeleteSaleInvoices(saleInvoiceIds: number[]) {
return this.validateBulkDeleteSaleInvoicesService.validateBulkDeleteSaleInvoices(
saleInvoiceIds,
);
}
/**
* Retrieves the given sale invoice details.
* @param {ISalesInvoicesFilter} filterDTO

View File

@@ -39,6 +39,10 @@ import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { SaleInvoiceStateResponseDto } from './dtos/SaleInvoiceState.dto';
import { GenerateSaleInvoiceSharableLinkResponseDto } from './dtos/GenerateSaleInvoiceSharableLinkResponse.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('sale-invoices')
@ApiTags('Sale Invoices')
@@ -47,9 +51,46 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@ApiExtraModels(SaleInvoiceStateResponseDto)
@ApiExtraModels(GenerateSaleInvoiceSharableLinkResponseDto)
@ApiCommonHeaders()
@ApiExtraModels(ValidateBulkDeleteResponseDto)
export class SaleInvoicesController {
constructor(private saleInvoiceApplication: SaleInvoiceApplication) { }
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which sale invoices can be deleted and returns the results.',
})
@ApiResponse({
status: 200,
description:
'Validation completed with counts and IDs of deletable and non-deletable sale invoices.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
validateBulkDeleteSaleInvoices(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.saleInvoiceApplication.validateBulkDeleteSaleInvoices(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple sale invoices.' })
@ApiResponse({
status: 200,
description: 'Sale invoices deleted successfully',
})
bulkDeleteSaleInvoices(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<void> {
return this.saleInvoiceApplication.bulkDeleteSaleInvoices(
bulkDeleteDto.ids,
{ skipUndeletable: bulkDeleteDto.skipUndeletable ?? false },
);
}
@Post()
@ApiOperation({ summary: 'Create a new sale invoice.' })
@ApiResponse({

View File

@@ -60,6 +60,8 @@ import { SaleInvoicesCost } from './SalesInvoicesCost';
import { SaleInvoicesExportable } from './commands/SaleInvoicesExportable';
import { SaleInvoicesImportable } from './commands/SaleInvoicesImportable';
import { PaymentLinksModule } from '../PaymentLinks/PaymentLinks.module';
import { BulkDeleteSaleInvoicesService } from './BulkDeleteSaleInvoices.service';
import { ValidateBulkDeleteSaleInvoicesService } from './ValidateBulkDeleteSaleInvoices.service';
@Module({
imports: [
@@ -126,6 +128,8 @@ import { PaymentLinksModule } from '../PaymentLinks/PaymentLinks.module';
SaleInvoicesCost,
SaleInvoicesExportable,
SaleInvoicesImportable,
BulkDeleteSaleInvoicesService,
ValidateBulkDeleteSaleInvoicesService,
],
exports: [
GetSaleInvoice,

View File

@@ -0,0 +1,54 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteSaleInvoice } from './commands/DeleteSaleInvoice.service';
@Injectable()
export class ValidateBulkDeleteSaleInvoicesService {
constructor(
private readonly deleteSaleInvoiceService: DeleteSaleInvoice,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) { }
public async validateBulkDeleteSaleInvoices(saleInvoiceIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const saleInvoiceId of saleInvoiceIds) {
try {
await this.deleteSaleInvoiceService.deleteSaleInvoice(
saleInvoiceId,
trx,
);
deletableIds.push(saleInvoiceId);
} catch (error) {
nonDeletableIds.push(saleInvoiceId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -47,7 +47,7 @@ export class DeleteSaleInvoice {
@Inject(ItemEntry.name)
private itemEntryModel: TenantModelProxy<typeof ItemEntry>,
) {}
) { }
/**
* Validate the sale invoice has no payment entries.
@@ -86,9 +86,12 @@ export class DeleteSaleInvoice {
* Deletes the given sale invoice with associated entries
* and journal transactions.
* @param {Number} saleInvoiceId - The given sale invoice id.
* @param {ISystemUser} authorizedUser -
* @param {Knex.Transaction} trx - Database transaction instance.
*/
public async deleteSaleInvoice(saleInvoiceId: number): Promise<void> {
public async deleteSaleInvoice(
saleInvoiceId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieve the given sale invoice with associated entries
// or throw not found error.
const oldSaleInvoice = await this.saleInvoiceModel()
@@ -138,6 +141,6 @@ export class DeleteSaleInvoice {
saleInvoiceId,
trx,
} as ISaleInvoiceDeletedPayload);
});
}, trx);
}
}

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteSaleReceipt } from './commands/DeleteSaleReceipt.service';
@Injectable()
export class BulkDeleteSaleReceiptsService {
constructor(
private readonly deleteSaleReceiptService: DeleteSaleReceipt,
) { }
async bulkDeleteSaleReceipts(
saleReceiptIds: number | number[],
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const receiptIds = uniq(castArray(saleReceiptIds));
const results = await PromisePool.withConcurrency(1)
.for(receiptIds)
.process(async (saleReceiptId: number) => {
try {
await this.deleteSaleReceiptService.deleteSaleReceipt(saleReceiptId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -22,6 +22,8 @@ import {
EditSaleReceiptDto,
} from './dtos/SaleReceipt.dto';
import { GetSaleReceiptMailStateService } from './queries/GetSaleReceiptMailState.service';
import { BulkDeleteSaleReceiptsService } from './BulkDeleteSaleReceipts.service';
import { ValidateBulkDeleteSaleReceiptsService } from './ValidateBulkDeleteSaleReceipts.service';
@Injectable()
export class SaleReceiptApplication {
@@ -36,7 +38,9 @@ export class SaleReceiptApplication {
private getSaleReceiptStateService: GetSaleReceiptState,
private saleReceiptNotifyByMailService: SaleReceiptMailNotification,
private getSaleReceiptMailStateService: GetSaleReceiptMailStateService,
) {}
private bulkDeleteSaleReceiptsService: BulkDeleteSaleReceiptsService,
private validateBulkDeleteSaleReceiptsService: ValidateBulkDeleteSaleReceiptsService,
) { }
/**
* Creates a new sale receipt with associated entries.
@@ -85,6 +89,30 @@ export class SaleReceiptApplication {
return this.deleteSaleReceiptService.deleteSaleReceipt(saleReceiptId);
}
/**
* Deletes multiple sale receipts.
* @param {number[]} saleReceiptIds
*/
public async bulkDeleteSaleReceipts(
saleReceiptIds: number[],
options?: { skipUndeletable?: boolean },
) {
return this.bulkDeleteSaleReceiptsService.bulkDeleteSaleReceipts(
saleReceiptIds,
options,
);
}
/**
* Validates which sale receipts can be deleted.
* @param {number[]} saleReceiptIds
*/
public async validateBulkDeleteSaleReceipts(saleReceiptIds: number[]) {
return this.validateBulkDeleteSaleReceiptsService.validateBulkDeleteSaleReceipts(
saleReceiptIds,
);
}
/**
* Retrieve sales receipts paginated and filterable list.
* @param {ISalesReceiptsFilter} filterDTO

View File

@@ -32,6 +32,10 @@ import { SaleReceiptResponseDto } from './dtos/SaleReceiptResponse.dto';
import { PaginatedResponseDto } from '@/common/dtos/PaginatedResults.dto';
import { SaleReceiptStateResponseDto } from './dtos/SaleReceiptState.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('sale-receipts')
@ApiTags('Sale Receipts')
@@ -39,8 +43,45 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@ApiExtraModels(PaginatedResponseDto)
@ApiExtraModels(SaleReceiptStateResponseDto)
@ApiCommonHeaders()
@ApiExtraModels(ValidateBulkDeleteResponseDto)
export class SaleReceiptsController {
constructor(private saleReceiptApplication: SaleReceiptApplication) {}
constructor(private saleReceiptApplication: SaleReceiptApplication) { }
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which sale receipts can be deleted and returns the results.',
})
@ApiResponse({
status: 200,
description:
'Validation completed with counts and IDs of deletable and non-deletable sale receipts.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
validateBulkDeleteSaleReceipts(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.saleReceiptApplication.validateBulkDeleteSaleReceipts(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple sale receipts.' })
@ApiResponse({
status: 200,
description: 'Sale receipts deleted successfully',
})
bulkDeleteSaleReceipts(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<void> {
return this.saleReceiptApplication.bulkDeleteSaleReceipts(
bulkDeleteDto.ids,
{ skipUndeletable: bulkDeleteDto.skipUndeletable ?? false },
);
}
@Post()
@ApiOperation({ summary: 'Create a new sale receipt.' })

View File

@@ -40,6 +40,8 @@ import { SaleReceiptsImportable } from './commands/SaleReceiptsImportable';
import { GetSaleReceiptMailStateService } from './queries/GetSaleReceiptMailState.service';
import { GetSaleReceiptMailTemplateService } from './queries/GetSaleReceiptMailTemplate.service';
import { SaleReceiptAutoIncrementSubscriber } from './subscribers/SaleReceiptAutoIncrementSubscriber';
import { BulkDeleteSaleReceiptsService } from './BulkDeleteSaleReceipts.service';
import { ValidateBulkDeleteSaleReceiptsService } from './ValidateBulkDeleteSaleReceipts.service';
@Module({
controllers: [SaleReceiptsController],
@@ -85,6 +87,8 @@ import { SaleReceiptAutoIncrementSubscriber } from './subscribers/SaleReceiptAut
GetSaleReceiptMailStateService,
GetSaleReceiptMailTemplateService,
SaleReceiptAutoIncrementSubscriber,
BulkDeleteSaleReceiptsService,
ValidateBulkDeleteSaleReceiptsService,
],
})
export class SaleReceiptsModule { }

View File

@@ -0,0 +1,57 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteSaleReceipt } from './commands/DeleteSaleReceipt.service';
@Injectable()
export class ValidateBulkDeleteSaleReceiptsService {
constructor(
private readonly deleteSaleReceiptService: DeleteSaleReceipt,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) {}
public async validateBulkDeleteSaleReceipts(
saleReceiptIds: number[],
): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const saleReceiptId of saleReceiptIds) {
try {
await this.deleteSaleReceiptService.deleteSaleReceipt(
saleReceiptId,
trx,
);
deletableIds.push(saleReceiptId);
} catch (error) {
nonDeletableIds.push(saleReceiptId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -29,9 +29,13 @@ export class DeleteSaleReceipt {
/**
* Deletes the sale receipt with associated entries.
* @param {Integer} saleReceiptId - Sale receipt identifier.
* @param {Knex.Transaction} trx - Database transaction instance.
* @return {void}
*/
public async deleteSaleReceipt(saleReceiptId: number) {
public async deleteSaleReceipt(
saleReceiptId: number,
trx?: Knex.Transaction,
) {
const oldSaleReceipt = await this.saleReceiptModel()
.query()
.findById(saleReceiptId)
@@ -65,6 +69,6 @@ export class DeleteSaleReceipt {
oldSaleReceipt,
trx,
} as ISaleReceiptEventDeletedPayload);
});
}, trx);
}
}

View File

@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteVendorCreditService } from './commands/DeleteVendorCredit.service';
@Injectable()
export class BulkDeleteVendorCreditsService {
constructor(
private readonly deleteVendorCreditService: DeleteVendorCreditService,
) { }
async bulkDeleteVendorCredits(
vendorCreditIds: number | Array<number>,
options?: { skipUndeletable?: boolean },
trx?: Knex.Transaction,
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const creditsIds = uniq(castArray(vendorCreditIds));
const results = await PromisePool.withConcurrency(1)
.for(creditsIds)
.process(async (vendorCreditId: number) => {
try {
await this.deleteVendorCreditService.deleteVendorCredit(
vendorCreditId,
);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw;
}
}
}

View File

@@ -0,0 +1,56 @@
import { Injectable, Inject } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteVendorCreditService } from './commands/DeleteVendorCredit.service';
@Injectable()
export class ValidateBulkDeleteVendorCreditsService {
constructor(
private readonly deleteVendorCreditService: DeleteVendorCreditService,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) { }
public async validateBulkDeleteVendorCredits(
vendorCreditIds: number[],
): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const vendorCreditId of vendorCreditIds) {
try {
await this.deleteVendorCreditService.deleteVendorCredit(
vendorCreditId,
trx,
);
deletableIds.push(vendorCreditId);
} catch (error) {
nonDeletableIds.push(vendorCreditId);
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -10,20 +10,67 @@ import {
} from '@nestjs/common';
import { VendorCreditsApplicationService } from './VendorCreditsApplication.service';
import { IVendorCreditsQueryDTO } from './types/VendorCredit.types';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiExtraModels,
ApiOperation,
ApiResponse,
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
import {
CreateVendorCreditDto,
EditVendorCreditDto,
} from './dtos/VendorCredit.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteDto,
ValidateBulkDeleteResponseDto,
} from '@/common/dtos/BulkDelete.dto';
@Controller('vendor-credits')
@ApiTags('Vendor Credits')
@ApiCommonHeaders()
@ApiExtraModels(ValidateBulkDeleteResponseDto)
export class VendorCreditsController {
constructor(
private readonly vendorCreditsApplication: VendorCreditsApplicationService,
) {}
) { }
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which vendor credits can be deleted and returns the results.',
})
@ApiResponse({
status: 200,
description:
'Validation completed with counts and IDs of deletable and non-deletable vendor credits.',
schema: {
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
},
})
async validateBulkDeleteVendorCredits(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<ValidateBulkDeleteResponseDto> {
return this.vendorCreditsApplication.validateBulkDeleteVendorCredits(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple vendor credits.' })
@ApiResponse({
status: 200,
description: 'Vendor credits deleted successfully',
})
async bulkDeleteVendorCredits(
@Body() bulkDeleteDto: BulkDeleteDto,
): Promise<void> {
return this.vendorCreditsApplication.bulkDeleteVendorCredits(
bulkDeleteDto.ids,
{ skipUndeletable: bulkDeleteDto.skipUndeletable ?? false },
);
}
@Post()
@ApiOperation({ summary: 'Create a new vendor credit.' })

View File

@@ -28,6 +28,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
import { VendorCreditsExportable } from './commands/VendorCreditsExportable';
import { VendorCreditsImportable } from './commands/VendorCreditsImportable';
import { BulkDeleteVendorCreditsService } from './BulkDeleteVendorCredits.service';
import { ValidateBulkDeleteVendorCreditsService } from './ValidateBulkDeleteVendorCredits.service';
@Module({
imports: [
@@ -61,6 +63,8 @@ import { VendorCreditsImportable } from './commands/VendorCreditsImportable';
VendorCreditAutoSerialSubscriber,
VendorCreditsExportable,
VendorCreditsImportable,
BulkDeleteVendorCreditsService,
ValidateBulkDeleteVendorCreditsService,
],
exports: [
CreateVendorCreditService,
@@ -74,6 +78,8 @@ import { VendorCreditsImportable } from './commands/VendorCreditsImportable';
OpenVendorCreditService,
VendorCreditsExportable,
VendorCreditsImportable,
BulkDeleteVendorCreditsService,
ValidateBulkDeleteVendorCreditsService,
],
controllers: [VendorCreditsController],
})

View File

@@ -3,12 +3,21 @@ import { CreateVendorCreditService } from './commands/CreateVendorCredit.service
import { DeleteVendorCreditService } from './commands/DeleteVendorCredit.service';
import { EditVendorCreditService } from './commands/EditVendorCredit.service';
import { GetVendorCreditService } from './queries/GetVendorCredit.service';
import { IVendorCreditEditDTO, IVendorCreditsQueryDTO } from './types/VendorCredit.types';
import {
IVendorCreditEditDTO,
IVendorCreditsQueryDTO,
} from './types/VendorCredit.types';
import { IVendorCreditCreateDTO } from './types/VendorCredit.types';
import { Injectable } from '@nestjs/common';
import { OpenVendorCreditService } from './commands/OpenVendorCredit.service';
import { GetVendorCreditsService } from './queries/GetVendorCredits.service';
import { CreateVendorCreditDto, EditVendorCreditDto } from './dtos/VendorCredit.dto';
import {
CreateVendorCreditDto,
EditVendorCreditDto,
} from './dtos/VendorCredit.dto';
import { BulkDeleteVendorCreditsService } from './BulkDeleteVendorCredits.service';
import { ValidateBulkDeleteVendorCreditsService } from './ValidateBulkDeleteVendorCredits.service';
import { ValidateBulkDeleteResponseDto } from '@/common/dtos/BulkDelete.dto';
@Injectable()
export class VendorCreditsApplicationService {
@@ -25,7 +34,9 @@ export class VendorCreditsApplicationService {
private readonly getVendorCreditService: GetVendorCreditService,
private readonly openVendorCreditService: OpenVendorCreditService,
private readonly getVendorCreditsService: GetVendorCreditsService,
) {}
private readonly bulkDeleteVendorCreditsService: BulkDeleteVendorCreditsService,
private readonly validateBulkDeleteVendorCreditsService: ValidateBulkDeleteVendorCreditsService,
) { }
/**
* Creates a new vendor credit.
@@ -90,4 +101,22 @@ export class VendorCreditsApplicationService {
getVendorCredits(query: IVendorCreditsQueryDTO) {
return this.getVendorCreditsService.getVendorCredits(query);
}
bulkDeleteVendorCredits(
vendorCreditIds: number[],
options?: { skipUndeletable?: boolean },
) {
return this.bulkDeleteVendorCreditsService.bulkDeleteVendorCredits(
vendorCreditIds,
options,
);
}
validateBulkDeleteVendorCredits(
vendorCreditIds: number[],
): Promise<ValidateBulkDeleteResponseDto> {
return this.validateBulkDeleteVendorCreditsService.validateBulkDeleteVendorCredits(
vendorCreditIds,
);
}
}

View File

@@ -93,7 +93,7 @@ export class DeleteVendorCreditService {
oldVendorCredit,
trx,
} as IVendorCreditDeletedPayload);
});
}, trx);
};
/**

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { castArray, uniq } from 'lodash';
import { PromisePool } from '@supercharge/promise-pool';
import { DeleteVendorService } from './commands/DeleteVendor.service';
@Injectable()
export class BulkDeleteVendorsService {
constructor(private readonly deleteVendorService: DeleteVendorService) {}
public async bulkDeleteVendors(
vendorIds: number[],
options?: { skipUndeletable?: boolean },
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const ids = uniq(castArray(vendorIds));
const results = await PromisePool.withConcurrency(1)
.for(ids)
.process(async (vendorId: number) => {
try {
await this.deleteVendorService.deleteVendor(vendorId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw ?? results.errors[0];
}
}
}

View File

@@ -0,0 +1,61 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteVendorService } from './commands/DeleteVendor.service';
import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception';
import { ServiceError } from '@/modules/Items/ServiceError';
@Injectable()
export class ValidateBulkDeleteVendorsService {
constructor(
private readonly deleteVendorService: DeleteVendorService,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) {}
public async validateBulkDeleteVendors(vendorIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const vendorId of vendorIds) {
try {
await this.deleteVendorService.deleteVendor(vendorId, trx);
deletableIds.push(vendorId);
} catch (error) {
if (
error instanceof ModelHasRelationsError ||
(error instanceof ServiceError &&
error.errorType === 'VENDOR_HAS_TRANSACTIONS')
) {
nonDeletableIds.push(vendorId);
} else {
nonDeletableIds.push(vendorId);
}
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -13,11 +13,20 @@ import {
IVendorOpeningBalanceEditDTO,
IVendorsFilter,
} from './types/Vendors.types';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiOperation,
ApiResponse,
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
import { CreateVendorDto } from './dtos/CreateVendor.dto';
import { EditVendorDto } from './dtos/EditVendor.dto';
import { GetVendorsQueryDto } from './dtos/GetVendorsQuery.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
BulkDeleteVendorsDto,
ValidateBulkDeleteVendorsResponseDto,
} from './dtos/BulkDeleteVendors.dto';
@Controller('vendors')
@ApiTags('Vendors')
@@ -66,4 +75,37 @@ export class VendorsController {
openingBalanceDTO,
);
}
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which vendors can be deleted and returns counts of deletable and non-deletable vendors.',
})
@ApiResponse({
status: 200,
description:
'Validation completed. Returns counts and IDs of deletable and non-deletable vendors.',
schema: { $ref: getSchemaPath(ValidateBulkDeleteVendorsResponseDto) },
})
validateBulkDeleteVendors(
@Body() bulkDeleteDto: BulkDeleteVendorsDto,
): Promise<ValidateBulkDeleteVendorsResponseDto> {
return this.vendorsApplication.validateBulkDeleteVendors(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple vendors in bulk.' })
@ApiResponse({
status: 200,
description: 'The vendors have been successfully deleted.',
})
async bulkDeleteVendors(
@Body() bulkDeleteDto: BulkDeleteVendorsDto,
): Promise<void> {
return this.vendorsApplication.bulkDeleteVendors(bulkDeleteDto.ids, {
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
});
}
}

View File

@@ -16,6 +16,8 @@ import { GetVendorsService } from './queries/GetVendors.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { VendorsExportable } from './VendorsExportable';
import { VendorsImportable } from './VendorsImportable';
import { BulkDeleteVendorsService } from './BulkDeleteVendors.service';
import { ValidateBulkDeleteVendorsService } from './ValidateBulkDeleteVendors.service';
@Module({
imports: [TenancyDatabaseModule, DynamicListModule],
@@ -31,6 +33,8 @@ import { VendorsImportable } from './VendorsImportable';
VendorValidators,
DeleteVendorService,
VendorsApplication,
BulkDeleteVendorsService,
ValidateBulkDeleteVendorsService,
TransformerInjectable,
TenancyContext,
VendorsExportable,

View File

@@ -13,6 +13,8 @@ import { GetVendorsService } from './queries/GetVendors.service';
import { CreateVendorDto } from './dtos/CreateVendor.dto';
import { EditVendorDto } from './dtos/EditVendor.dto';
import { GetVendorsQueryDto } from './dtos/GetVendorsQuery.dto';
import { BulkDeleteVendorsService } from './BulkDeleteVendors.service';
import { ValidateBulkDeleteVendorsService } from './ValidateBulkDeleteVendors.service';
@Injectable()
export class VendorsApplication {
@@ -23,6 +25,8 @@ export class VendorsApplication {
private editOpeningBalanceService: EditOpeningBalanceVendorService,
private getVendorService: GetVendorService,
private getVendorsService: GetVendorsService,
private readonly bulkDeleteVendorsService: BulkDeleteVendorsService,
private readonly validateBulkDeleteVendorsService: ValidateBulkDeleteVendorsService,
) {}
/**
@@ -86,4 +90,20 @@ export class VendorsApplication {
public getVendors(filterDTO: GetVendorsQueryDto) {
return this.getVendorsService.getVendorsList(filterDTO);
}
public bulkDeleteVendors(
vendorIds: number[],
options?: { skipUndeletable?: boolean },
) {
return this.bulkDeleteVendorsService.bulkDeleteVendors(
vendorIds,
options,
);
}
public validateBulkDeleteVendors(vendorIds: number[]) {
return this.validateBulkDeleteVendorsService.validateBulkDeleteVendors(
vendorIds,
);
}
}

View File

@@ -29,12 +29,10 @@ export class DeleteVendorService {
* @param {number} vendorId
* @return {Promise<void>}
*/
public async deleteVendor(vendorId: number) {
public async deleteVendor(vendorId: number, trx?: Knex.Transaction) {
// Retrieves the old vendor or throw not found service error.
const oldVendor = await this.vendorModel()
.query()
.findById(vendorId)
.throwIfNotFound();
const query = this.vendorModel().query(trx);
const oldVendor = await query.findById(vendorId).throwIfNotFound();
// Triggers `onVendorDeleting` event.
await this.eventPublisher.emitAsync(events.vendors.onDeleting, {
@@ -43,10 +41,10 @@ export class DeleteVendorService {
} as IVendorEventDeletingPayload);
// Deletes vendor contact under unit-of-work.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
return this.uow.withTransaction(async (transaction: Knex.Transaction) => {
// Deletes the vendor contact from the storage.
await this.vendorModel()
.query(trx)
.query(transaction)
.findById(vendorId)
.deleteIfNoRelations({
type: ERRORS.VENDOR_HAS_TRANSACTIONS,
@@ -56,8 +54,8 @@ export class DeleteVendorService {
await this.eventPublisher.emitAsync(events.vendors.onDeleted, {
vendorId,
oldVendor,
trx,
trx: transaction,
} as IVendorEventDeletedPayload);
});
}, trx);
}
}

View File

@@ -0,0 +1,62 @@
import {
ArrayNotEmpty,
IsArray,
IsInt,
IsOptional,
IsBoolean,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { parseBoolean } from '@/utils/parse-boolean';
export class BulkDeleteVendorsDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
@ApiProperty({
description: 'Array of vendor IDs to delete',
type: [Number],
example: [1, 2, 3],
})
ids: number[];
@IsOptional()
@IsBoolean()
@Transform(({ value, obj }) => parseBoolean(value ?? obj?.skip_undeletable, false))
@ApiPropertyOptional({
description:
'When true, undeletable vendors will be skipped and only deletable ones removed.',
type: Boolean,
default: false,
})
skipUndeletable?: boolean;
}
export class ValidateBulkDeleteVendorsResponseDto {
@ApiProperty({
description: 'Number of vendors that can be deleted',
example: 2,
})
deletableCount: number;
@ApiProperty({
description: 'Number of vendors that cannot be deleted',
example: 1,
})
nonDeletableCount: number;
@ApiProperty({
description: 'IDs of vendors that can be deleted',
type: [Number],
example: [1, 2],
})
deletableIds: number[];
@ApiProperty({
description: 'IDs of vendors that cannot be deleted',
type: [Number],
example: [3],
})
nonDeletableIds: number[];
}