diff --git a/dictation_server/src/common/error/code.ts b/dictation_server/src/common/error/code.ts index 4825b43..7692f36 100644 --- a/dictation_server/src/common/error/code.ts +++ b/dictation_server/src/common/error/code.ts @@ -47,5 +47,6 @@ export const ErrorCodes = [ 'E010805', // ライセンス有効期限切れエラー 'E010806', // ライセンス割り当て不可エラー 'E010807', // ライセンス割り当て解除済みエラー + 'E010808', // ライセンス注文キャンセル不可エラー 'E010908', // タイピストグループ不在エラー ] as const; diff --git a/dictation_server/src/common/error/message.ts b/dictation_server/src/common/error/message.ts index ceac9e4..5b2338b 100644 --- a/dictation_server/src/common/error/message.ts +++ b/dictation_server/src/common/error/message.ts @@ -36,5 +36,6 @@ export const errors: Errors = { E010805: 'License is expired Error', E010806: 'License is unavailable Error', E010807: 'License is already deallocated Error', + E010808: 'Order cancel failed Error', E010908: 'Typist Group not exist Error', }; diff --git a/dictation_server/src/constants/index.ts b/dictation_server/src/constants/index.ts index cdf2352..98bd997 100644 --- a/dictation_server/src/constants/index.ts +++ b/dictation_server/src/constants/index.ts @@ -89,16 +89,14 @@ export const USER_ROLES = { } as const; /** - * ライセンス注文ステータス(発行待ち) - * @const {string} + * ライセンス注文状態 + * @const {string[]} */ -export const LICENSE_STATUS_ISSUE_REQUESTING = 'Issue Requesting'; - -/** - * ライセンス注文ステータス(発行済み) - * @const {string} - */ -export const LICENSE_STATUS_ISSUED = 'Issued'; +export const LICENSE_ISSUE_STATUS = { + ISSUE_REQUESTING: 'Issue Requesting', + ISSUED: 'Issued', + CANCELED: 'Order Canceled', +}; /** * ライセンス種別 diff --git a/dictation_server/src/features/licenses/licenses.controller.ts b/dictation_server/src/features/licenses/licenses.controller.ts index 2d88908..d739ccd 100644 --- a/dictation_server/src/features/licenses/licenses.controller.ts +++ b/dictation_server/src/features/licenses/licenses.controller.ts @@ -250,11 +250,11 @@ export class LicensesController { const context = makeContext(payload.userId); - // 注文キャンセル処理(仮) - // await this.licensesService.cancelOrder( - // context, - // body.poNumber, - // ); + await this.licensesService.cancelOrder( + context, + payload.userId, + body.poNumber, + ); return {}; } } diff --git a/dictation_server/src/features/licenses/licenses.service.spec.ts b/dictation_server/src/features/licenses/licenses.service.spec.ts index f7224c5..f14ed0c 100644 --- a/dictation_server/src/features/licenses/licenses.service.spec.ts +++ b/dictation_server/src/features/licenses/licenses.service.spec.ts @@ -31,11 +31,17 @@ import { selectCardLicense, selectLicense, selectLicenseAllocationHistory, + createOrder, + selectOrderLicense, } from './test/utility'; import { UsersService } from '../users/users.service'; import { makeContext } from '../../common/log'; import { LICENSE_ALLOCATED_STATUS, LICENSE_TYPE } from '../../constants'; -import { makeTestSimpleAccount, makeTestUser } from '../../common/test/utility'; +import { + makeHierarchicalAccounts, + makeTestSimpleAccount, + makeTestUser, +} from '../../common/test/utility'; describe('LicensesService', () => { it('ライセンス注文が完了する', async () => { @@ -1001,3 +1007,118 @@ describe('ライセンス割り当て解除', () => { ); }); }); + +describe('ライセンス注文キャンセル', () => { + let source: DataSource = null; + beforeEach(async () => { + source = new DataSource({ + type: 'sqlite', + database: ':memory:', + logging: false, + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: true, // trueにすると自動的にmigrationが行われるため注意 + }); + return source.initialize(); + }); + + afterEach(async () => { + await source.destroy(); + source = null; + }); + + it('ライセンス注文のキャンセルが完了する', async () => { + const module = await makeTestingModule(source); + const { tier2Accounts: tier2Accounts } = await makeHierarchicalAccounts( + source, + ); + const poNumber = 'CANCEL_TEST'; + await createOrder( + source, + poNumber, + tier2Accounts[0].account.id, + tier2Accounts[0].account.parent_account_id, + 10, + 'Issue Requesting', + ); + // キャンセル済みの同名poNumoberが存在しても正常に動作することの確認用order + await createOrder( + source, + poNumber, + tier2Accounts[0].account.id, + tier2Accounts[0].account.parent_account_id, + 10, + 'Order Canceled', + ); + + const service = module.get(LicensesService); + await service.cancelOrder( + makeContext('trackingId'), + tier2Accounts[0].users[0].external_id, + poNumber, + ); + + // 割り当て解除したライセンスの状態確認 + const orderRecord = await selectOrderLicense( + source, + tier2Accounts[0].account.id, + poNumber, + ); + expect(orderRecord.orderLicense.canceled_at).toBeDefined(); + expect(orderRecord.orderLicense.status).toBe('Order Canceled'); + }); + + it('ライセンスが既に発行済みの場合、エラーとなる', async () => { + const module = await makeTestingModule(source); + const { tier2Accounts: tier2Accounts } = await makeHierarchicalAccounts( + source, + ); + const poNumber = 'CANCEL_TEST'; + await createOrder( + source, + poNumber, + tier2Accounts[0].account.id, + tier2Accounts[0].account.parent_account_id, + 10, + 'Issued', + ); + + const service = module.get(LicensesService); + await expect( + service.cancelOrder( + makeContext('trackingId'), + tier2Accounts[0].users[0].external_id, + poNumber, + ), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010808'), HttpStatus.BAD_REQUEST), + ); + }); + + it('ライセンスが既にキャンセル済みの場合、エラーとなる', async () => { + const module = await makeTestingModule(source); + + const { tier2Accounts: tier2Accounts } = await makeHierarchicalAccounts( + source, + ); + const poNumber = 'CANCEL_TEST'; + await createOrder( + source, + poNumber, + tier2Accounts[0].account.id, + tier2Accounts[0].account.parent_account_id, + 10, + 'Order Canceled', + ); + + const service = module.get(LicensesService); + await expect( + service.cancelOrder( + makeContext('trackingId'), + tier2Accounts[0].users[0].external_id, + poNumber, + ), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010808'), HttpStatus.BAD_REQUEST), + ); + }); +}); diff --git a/dictation_server/src/features/licenses/licenses.service.ts b/dictation_server/src/features/licenses/licenses.service.ts index a60e4fd..2c23bd8 100644 --- a/dictation_server/src/features/licenses/licenses.service.ts +++ b/dictation_server/src/features/licenses/licenses.service.ts @@ -8,6 +8,7 @@ import { PoNumberAlreadyExistError, LicenseNotExistError, LicenseKeyAlreadyActivatedError, + CancelOrderFailedError, } from '../../repositories/licenses/errors/types'; import { LicensesRepositoryService } from '../../repositories/licenses/licenses.repository.service'; import { UserNotFoundError } from '../../repositories/users/errors/types'; @@ -255,4 +256,54 @@ export class LicensesService { ); } } + + /** + * ライセンス注文をキャンセルする + * @param context + * @param externalId + * @param poNumber + */ + async cancelOrder( + context: Context, + externalId: string, + poNumber: string, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.cancelOrder.name} | params: { ` + + `externalId: ${externalId}, ` + + `poNumber: ${poNumber}, };`, + ); + let myAccountId: number; + + try { + // ユーザIDからアカウントIDを取得する + myAccountId = ( + await this.usersRepository.findUserByExternalId(externalId) + ).account_id; + // 注文キャンセル処理 + await this.licensesRepository.cancelOrder(myAccountId, poNumber); + } catch (e) { + this.logger.error(`error=${e}`); + switch (e.constructor) { + case UserNotFoundError: + throw new HttpException( + makeErrorResponse('E010204'), + HttpStatus.BAD_REQUEST, + ); + case CancelOrderFailedError: + throw new HttpException( + makeErrorResponse('E010808'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } finally { + this.logger.log(`[OUT] [${context.trackingId}] ${this.cancelOrder.name}`); + } + return; + } } diff --git a/dictation_server/src/features/licenses/test/utility.ts b/dictation_server/src/features/licenses/test/utility.ts index 58a3b06..da636c5 100644 --- a/dictation_server/src/features/licenses/test/utility.ts +++ b/dictation_server/src/features/licenses/test/utility.ts @@ -4,6 +4,7 @@ import { CardLicense, CardLicenseIssue, LicenseAllocationHistory, + LicenseOrder, } from '../../../repositories/licenses/entity/license.entity'; export const createLicense = async ( @@ -94,6 +95,31 @@ export const createLicenseAllocationHistory = async ( identifiers.pop() as LicenseAllocationHistory; }; +export const createOrder = async ( + datasource: DataSource, + poNumber: string, + fromId: number, + toId: number, + quantity: number, + status: string, +): Promise => { + const { identifiers } = await datasource.getRepository(LicenseOrder).insert({ + po_number: poNumber, + from_account_id: fromId, + to_account_id: toId, + ordered_at: new Date(), + issued_at: null, + quantity: quantity, + status: status, + canceled_at: null, + created_by: null, + created_at: new Date(), + updated_by: null, + updated_at: new Date(), + }); + identifiers.pop() as LicenseOrder; +}; + export const selectCardLicensesCount = async ( datasource: DataSource, ): Promise<{ count: number }> => { @@ -143,3 +169,17 @@ export const selectLicenseAllocationHistory = async ( }); return { licenseAllocationHistory }; }; + +export const selectOrderLicense = async ( + datasource: DataSource, + accountId: number, + poNumber: string, +): Promise<{ orderLicense: LicenseOrder }> => { + const orderLicense = await datasource.getRepository(LicenseOrder).findOne({ + where: { + from_account_id: accountId, + po_number: poNumber, + }, + }); + return { orderLicense }; +}; diff --git a/dictation_server/src/repositories/accounts/accounts.repository.service.ts b/dictation_server/src/repositories/accounts/accounts.repository.service.ts index e58669a..835c0db 100644 --- a/dictation_server/src/repositories/accounts/accounts.repository.service.ts +++ b/dictation_server/src/repositories/accounts/accounts.repository.service.ts @@ -20,7 +20,7 @@ import { } from '../../common/types/sort/util'; import { LICENSE_ALLOCATED_STATUS, - LICENSE_STATUS_ISSUE_REQUESTING, + LICENSE_ISSUE_STATUS, TIERS, } from '../../constants'; import { @@ -330,7 +330,7 @@ export class AccountsRepositoryService { const numberOfRequesting = await licenseOrder.count({ where: { from_account_id: id, - status: LICENSE_STATUS_ISSUE_REQUESTING, + status: LICENSE_ISSUE_STATUS.ISSUE_REQUESTING, }, }); @@ -340,7 +340,7 @@ export class AccountsRepositoryService { .select('SUM(license_orders.quantity)', 'sum') .where('license_orders.from_account_id = :id', { id }) .andWhere('license_orders.status = :status', { - status: LICENSE_STATUS_ISSUE_REQUESTING, + status: LICENSE_ISSUE_STATUS.ISSUE_REQUESTING, }) .getRawOne(); const issueRequesting = parseInt(result.sum, 10) || 0; @@ -415,7 +415,7 @@ export class AccountsRepositoryService { .select('SUM(license_orders.quantity)', 'sum') .where('license_orders.to_account_id = :id', { id }) .andWhere('license_orders.status = :status', { - status: LICENSE_STATUS_ISSUE_REQUESTING, + status: LICENSE_ISSUE_STATUS.ISSUE_REQUESTING, }) .getRawOne(); const issuedRequested = parseInt(issuedRequestedSqlResult.sum, 10) || 0; @@ -426,7 +426,7 @@ export class AccountsRepositoryService { .select('SUM(license_orders.quantity)', 'sum') .where('license_orders.from_account_id = :id', { id }) .andWhere('license_orders.status = :status', { - status: LICENSE_STATUS_ISSUE_REQUESTING, + status: LICENSE_ISSUE_STATUS.ISSUE_REQUESTING, }) .getRawOne(); const issuedRequesting = parseInt(issuedRequestingSqlResult.sum, 10) || 0; diff --git a/dictation_server/src/repositories/licenses/errors/types.ts b/dictation_server/src/repositories/licenses/errors/types.ts index 9da7aad..904bbfa 100644 --- a/dictation_server/src/repositories/licenses/errors/types.ts +++ b/dictation_server/src/repositories/licenses/errors/types.ts @@ -21,3 +21,6 @@ export class LicenseUnavailableError extends Error {} // ライセンス割り当て解除済みエラー export class LicenseAlreadyDeallocatedError extends Error {} + +// 注文キャンセル失敗エラー +export class CancelOrderFailedError extends Error {} diff --git a/dictation_server/src/repositories/licenses/licenses.repository.service.ts b/dictation_server/src/repositories/licenses/licenses.repository.service.ts index fff76eb..a581dff 100644 --- a/dictation_server/src/repositories/licenses/licenses.repository.service.ts +++ b/dictation_server/src/repositories/licenses/licenses.repository.service.ts @@ -10,8 +10,7 @@ import { import { CARD_LICENSE_LENGTH, LICENSE_ALLOCATED_STATUS, - LICENSE_STATUS_ISSUE_REQUESTING, - LICENSE_STATUS_ISSUED, + LICENSE_ISSUE_STATUS, LICENSE_TYPE, SWITCH_FROM_TYPE, TIERS, @@ -26,6 +25,7 @@ import { LicenseExpiredError, LicenseUnavailableError, LicenseAlreadyDeallocatedError, + CancelOrderFailedError, } from './errors/types'; import { AllocatableLicenseInfo, @@ -49,7 +49,7 @@ export class LicensesRepositoryService { licenseOrder.from_account_id = fromAccountId; licenseOrder.to_account_id = toAccountId; licenseOrder.quantity = quantity; - licenseOrder.status = LICENSE_STATUS_ISSUE_REQUESTING; + licenseOrder.status = LICENSE_ISSUE_STATUS.ISSUE_REQUESTING; // ライセンス注文テーブルに登録する const createdEntity = await this.dataSource.transaction( @@ -62,12 +62,12 @@ export class LicensesRepositoryService { { po_number: poNumber, from_account_id: fromAccountId, - status: LICENSE_STATUS_ISSUED, + status: LICENSE_ISSUE_STATUS.ISSUED, }, { po_number: poNumber, from_account_id: fromAccountId, - status: LICENSE_STATUS_ISSUE_REQUESTING, + status: LICENSE_ISSUE_STATUS.ISSUE_REQUESTING, }, ], }); @@ -351,7 +351,7 @@ export class LicensesRepositoryService { throw new OrderNotFoundError(`No order found for PONumber:${poNumber}`); } // 既に発行済みの注文の場合、エラー - if (issuingOrder.status !== LICENSE_STATUS_ISSUE_REQUESTING) { + if (issuingOrder.status !== LICENSE_ISSUE_STATUS.ISSUE_REQUESTING) { throw new AlreadyIssuedError( `An order for PONumber:${poNumber} has already been issued.`, ); @@ -371,7 +371,7 @@ export class LicensesRepositoryService { { id: issuingOrder.id }, { issued_at: nowDate, - status: LICENSE_STATUS_ISSUED, + status: LICENSE_ISSUE_STATUS.ISSUED, }, ); // ライセンステーブルを登録(注文元) @@ -597,4 +597,36 @@ export class LicensesRepositoryService { await licenseAllocationHistoryRepo.save(deallocationHistory); }); } + + /** + * ライセンス注文をキャンセルする + * @param accountId + * @param poNumber + */ + async cancelOrder(accountId: number, poNumber: string): Promise { + await this.dataSource.transaction(async (entityManager) => { + const orderRepo = entityManager.getRepository(LicenseOrder); + + // キャンセル対象の注文を取得 + const targetOrder = await orderRepo.findOne({ + where: { + from_account_id: accountId, + po_number: poNumber, + status: LICENSE_ISSUE_STATUS.ISSUE_REQUESTING, + }, + }); + + // キャンセル対象の注文が存在しない場合エラー + if (!targetOrder) { + throw new CancelOrderFailedError( + `Cancel order is failed. accountId: ${accountId}, poNumber: ${poNumber}`, + ); + } + + // 注文キャンセル処理 + targetOrder.status = LICENSE_ISSUE_STATUS.CANCELED; + targetOrder.canceled_at = new Date(); + await orderRepo.save(targetOrder); + }); + } }