Merged PR 562: キャンセルAPI修正

## 概要
[Task2972: キャンセルAPI修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2972)

- キャンセル処理に自動ルーティングを追加

## レビューポイント
- 追加したテストケースは足りているか
- 自動ルーティングを修正したが修正箇所は問題ないか(To : 福永さん)
  - 特にworktypeが空文字だった時の挙動を修正したので、そこが業務要件とあっているか
    - コメントがある場所

## UIの変更
- Before/Afterのスクショなど
- スクショ置き場

## 動作確認状況
- ローカルで確認

## 補足
- 相談、参考資料などがあれば
This commit is contained in:
saito.k 2023-11-08 00:25:45 +00:00
parent c9124f8661
commit 1e4a545bf8
5 changed files with 327 additions and 21 deletions

View File

@ -299,9 +299,7 @@ export class AccountsController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@UseGuards(RoleGuard.requireds({ delegation: true }))
@Get('typists')
async getTypists(@Req() req: Request): Promise<GetTypistsResponse> {
const accessToken = retrieveAuthorizationToken(req);

View File

@ -204,7 +204,6 @@ export class FilesService {
await this.tasksRepositoryService.autoRouting(
task.audio_file_id,
user.account_id,
workType,
user.author_id ?? undefined,
);

View File

@ -28,6 +28,15 @@ import {
import { ADMIN_ROLES, TASK_STATUS, USER_ROLES } from '../../constants';
import { makeTestingModule } from '../../common/test/modules';
import { createSortCriteria } from '../users/test/utility';
import { createWorktype } from '../accounts/test/utility';
import {
createWorkflow,
createWorkflowTypist,
} from '../workflows/test/utility';
import { createTemplateFile } from '../templates/test/utility';
import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service';
import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage';
import { Roles } from '../../common/types/role';
describe('TasksService', () => {
it('タスク一覧を取得できるadmin', async () => {
@ -2468,6 +2477,253 @@ describe('cancel', () => {
new HttpException(makeErrorResponse('E010603'), HttpStatus.NOT_FOUND),
);
});
it('API実行者のRoleがTypistの場合、自身が文字起こし実行中のタスクをキャンセルし、そのタスクの自動ルーティングを行う', async () => {
if (!source) fail();
const notificationhubServiceMockValue =
makeDefaultNotificationhubServiceMockValue();
const module = await makeTaskTestingModuleWithNotificaiton(
source,
notificationhubServiceMockValue,
);
if (!module) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
const { id: typistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
});
const { id: authorUserId, author_id } = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
//ワークタイプIDを作成
await createWorktype(source, accountId, '01');
// テンプレートファイルを作成
const { id: templateFileId } = await createTemplateFile(
source,
accountId,
'template-file-name',
'https://example.com',
);
// ワークフローを作成
const { id: workflowId } = await createWorkflow(
source,
accountId,
authorUserId,
undefined,
templateFileId,
);
// ワークフロータイピストを作成
await createWorkflowTypist(source, workflowId, typistUserId);
const { taskId } = await createTask(
source,
accountId,
authorUserId,
author_id ?? '',
'',
'01',
'00000001',
'InProgress',
typistUserId,
);
await createCheckoutPermissions(source, taskId, typistUserId);
const service = module.get<TasksService>(TasksService);
const NotificationHubService = module.get<NotificationhubService>(
NotificationhubService,
);
await service.cancel(
makeContext('trackingId'),
1,
'typist-user-external-id',
['typist', 'standard'],
);
const resultTask = await getTask(source, taskId);
const permisions = await getCheckoutPermissions(source, taskId);
expect(resultTask?.status).toEqual('Uploaded');
expect(resultTask?.typist_user_id).toEqual(null);
// タスクのテンプレートファイルIDを確認
expect(resultTask?.template_file_id).toEqual(templateFileId);
// タスクのチェックアウト権限が想定通りワークフローで設定されているのユーザーIDで作成されているか確認
expect(permisions.length).toEqual(1);
expect(permisions[0].user_id).toEqual(typistUserId);
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId'),
[`user_${typistUserId}`],
makeNotifyMessage('M000101'),
);
}, 1000000);
it('API実行者のRoleがAdminの場合、自身が文字起こし実行中のタスクをキャンセルし、そのタスクの自動ルーティングを行うAPI実行者のAuthorIDと音声ファイルに紐づくWorkType', async () => {
if (!source) fail();
const notificationhubServiceMockValue =
makeDefaultNotificationhubServiceMockValue();
const module = await makeTaskTestingModuleWithNotificaiton(
source,
notificationhubServiceMockValue,
);
if (!module) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
// タスクの文字起こし担当者
const { id: typistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
});
// 自動ルーティングされるタイピストユーザーを作成
const { id: autoRoutingTypistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'auto-routing-typist-user-external-id',
role: 'typist',
});
// API実行者
const {
id: myAuthorUserId,
external_id,
role,
} = await makeTestUser(source, {
account_id: accountId,
external_id: 'my-author-user-external-id',
role: 'author admin',
author_id: 'MY_AUTHOR_ID',
});
// 音声ファイルのアップロード者
const { id: authorUserId, author_id } = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
//ワークタイプIDを作成
const { id: workTypeId, custom_worktype_id } = await createWorktype(
source,
accountId,
'01',
);
// テンプレートファイルを作成
const { id: templateFileId } = await createTemplateFile(
source,
accountId,
'template-file-name',
'https://example.com',
);
// ワークフローを作成
const { id: workflowId } = await createWorkflow(
source,
accountId,
myAuthorUserId,
workTypeId,
templateFileId,
);
// ワークフロータイピストを作成
await createWorkflowTypist(source, workflowId, autoRoutingTypistUserId);
const { taskId } = await createTask(
source,
accountId,
authorUserId,
author_id ?? '',
custom_worktype_id,
'01',
'00000001',
'InProgress',
typistUserId,
);
await createCheckoutPermissions(source, taskId, typistUserId);
const service = module.get<TasksService>(TasksService);
const NotificationHubService = module.get<NotificationhubService>(
NotificationhubService,
);
await service.cancel(
makeContext('trackingId'),
1,
external_id,
role.split(' ') as Roles[],
);
const resultTask = await getTask(source, taskId);
const permisions = await getCheckoutPermissions(source, taskId);
expect(resultTask?.status).toEqual('Uploaded');
expect(resultTask?.typist_user_id).toEqual(null);
// タスクのテンプレートファイルIDを確認
expect(resultTask?.template_file_id).toEqual(templateFileId);
// タスクのチェックアウト権限が想定通りワークフローで設定されているのユーザーIDで作成されているか確認
expect(permisions.length).toEqual(1);
expect(permisions[0].user_id).toEqual(autoRoutingTypistUserId);
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId'),
[`user_${autoRoutingTypistUserId}`],
makeNotifyMessage('M000101'),
);
});
it('API実行者のRoleがTypistの場合、自身が文字起こし実行中のタスクをキャンセルするが、一致するワークフローがない場合は自動ルーティングを行うことができない', async () => {
if (!source) fail();
const notificationhubServiceMockValue =
makeDefaultNotificationhubServiceMockValue();
const module = await makeTaskTestingModuleWithNotificaiton(
source,
notificationhubServiceMockValue,
);
if (!module) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
// タスクの文字起こし担当者
const {
id: typistUserId,
external_id,
role,
} = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
});
// 音声ファイルのアップロード者
const { id: authorUserId, author_id } = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
const { taskId } = await createTask(
source,
accountId,
authorUserId,
author_id ?? '',
'custom_worktype_id',
'01',
'00000001',
'InProgress',
typistUserId,
);
await createCheckoutPermissions(source, taskId, typistUserId);
const service = module.get<TasksService>(TasksService);
const NotificationHubService = module.get<NotificationhubService>(
NotificationhubService,
);
await service.cancel(
makeContext('trackingId'),
1,
external_id,
role.split(' ') as Roles[],
);
const resultTask = await getTask(source, taskId);
const permisions = await getCheckoutPermissions(source, taskId);
expect(resultTask?.status).toEqual('Uploaded');
expect(resultTask?.typist_user_id).toEqual(null);
// タスクのチェックアウト権限が削除されていることを確認
expect(permisions.length).toEqual(0);
// 通知処理が想定通りの引数で呼ばれていないか確認
expect(NotificationHubService.notify).not.toHaveBeenCalled();
});
});
describe('getNextTask', () => {

View File

@ -33,6 +33,7 @@ import { NotificationhubService } from '../../gateways/notificationhub/notificat
import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service';
import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage';
import { Context } from '../../common/log';
import { User } from '../../repositories/users/entity/user.entity';
@Injectable()
export class TasksService {
@ -382,19 +383,29 @@ export class TasksService {
externalId: string,
role: Roles[],
): Promise<void> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.cancel.name} | params: { audioFileId: ${audioFileId}, externalId: ${externalId}, role: ${role} };`,
);
let user: User;
try {
this.logger.log(
`[IN] [${context.trackingId}] ${this.cancel.name} | params: { audioFileId: ${audioFileId}, externalId: ${externalId}, role: ${role} };`,
// ユーザー取得
user = await this.usersRepository.findUserByExternalId(externalId);
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.log(`[OUT] [${context.trackingId}] ${this.cancel.name}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
const { id, account_id } =
await this.usersRepository.findUserByExternalId(externalId);
}
try {
// roleにAdminが含まれていれば、文字起こし担当でなくてもキャンセルできるため、ユーザーIDは指定しない
return await this.taskRepository.cancel(
await this.taskRepository.cancel(
audioFileId,
[TASK_STATUS.IN_PROGRESS, TASK_STATUS.PENDING],
account_id,
role.includes(ADMIN_ROLES.ADMIN) ? undefined : id,
user.account_id,
role.includes(ADMIN_ROLES.ADMIN) ? undefined : user.id,
);
} catch (e) {
this.logger.error(`error=${e}`);
@ -422,6 +433,47 @@ export class TasksService {
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
// キャンセルしたタスクに自動ルーティングを行う
const { typistGroupIds, typistIds } =
await this.taskRepository.autoRouting(
audioFileId,
user.account_id,
user.author_id ?? undefined,
);
const groupMembers =
await this.userGroupsRepositoryService.getGroupMembersFromGroupIds(
typistGroupIds,
);
// 重複のない割り当て候補ユーザーID一覧を取得する
const distinctUserIds = [
...new Set([...typistIds, ...groupMembers.map((x) => x.user_id)]),
];
// 割り当てられたユーザーがいない場合は通知不要
if (distinctUserIds.length === 0) {
this.logger.log('No user assigned.');
return;
}
// タグを生成
const tags = distinctUserIds.map((x) => `user_${x}`);
this.logger.log(`tags: ${tags}`);
// タグ対象に通知送信
await this.notificationhubService.notify(
context,
tags,
makeNotifyMessage('M000101'),
);
} catch (e) {
// 処理の本筋はタスクキャンセルのため自動ルーティングに失敗してもエラーにしない
this.logger.error(`Automatic routing or notification failed.`);
this.logger.error(`error=${e}`);
} finally {
this.logger.log(`[OUT] [${context.trackingId}] ${this.cancel.name}`);
}

View File

@ -957,17 +957,15 @@ export class TasksRepositoryService {
}
/**
* worktypeIdをもとにルーティングルールを取得
*
* @param audioFileId
* @param accountId
* @param worktypeId
* @param [myAuthorId]
* @returns typistIds: タイピストIDの一覧 / typistGroupIds: タイピストグループIDの一覧
*/
async autoRouting(
audioFileId: number,
accountId: number,
worktypeId: string, // ユーザーが任意につけるworktypeId(DBのcustom_worktype_id)
myAuthorId?: string, // API実行者のAuthorId
): Promise<{ typistIds: number[]; typistGroupIds: number[] }> {
return await this.dataSource.transaction(async (entityManager) => {
@ -1008,17 +1006,20 @@ export class TasksRepositoryService {
`user not found. authorId:${audioFile.author_id}, accountId:${accountId}`,
);
}
// ユーザーが任意につけるworktypeIdをもとにworktypeを取得
// 音声ファイル上のworktypeIdをもとにworktypeを取得
const worktypeRepo = entityManager.getRepository(Worktype);
const worktypeRecord = await worktypeRepo.findOne({
where: {
custom_worktype_id: worktypeId,
custom_worktype_id: audioFile.work_type_id,
account_id: accountId,
},
});
if (!worktypeRecord) {
// 音声ファイル上のworktypeIdが設定されているが、一致するworktypeが存在しない場合はエラーを出して終了
if (!worktypeRecord && audioFile.work_type_id !== '') {
throw new Error(
`worktype not found. worktype:${worktypeId}, accountId:${accountId}`,
`worktype not found. worktype:${audioFile.work_type_id}, accountId:${accountId}`,
);
}
@ -1031,7 +1032,7 @@ export class TasksRepositoryService {
where: {
account_id: accountId,
author_id: authorUser.id,
worktype_id: worktypeRecord.id,
worktype_id: worktypeRecord?.id ?? IsNull(),
},
});
@ -1071,14 +1072,14 @@ export class TasksRepositoryService {
where: {
account_id: accountId,
author_id: myAuthorUser.id,
worktype_id: worktypeRecord.id,
worktype_id: worktypeRecord?.id ?? IsNull(),
},
});
// API実行者のAuthorIdと音声ファイルのWorktypeをもとにルーティングルールを取得できない場合はエラーを出して終了
if (!defaultWorkflow) {
throw new Error(
`workflow not found. authorUserId:${myAuthorUser.id}, accountId:${accountId}, worktype:${worktypeId}`,
`workflow not found. authorUserId:${myAuthorUser.id}, accountId:${accountId}, worktypeId:${worktypeRecord?.id}`,
);
}