From 32a452bdb2b17e65ebf43c3fa49a50f9c1aa89ec Mon Sep 17 00:00:00 2001 From: "oura.a" Date: Wed, 20 Dec 2023 01:24:31 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=20631:=20=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=82=BB=E3=83=B3=E3=82=B9=E8=87=AA=E5=8B=95=E5=89=B2=E3=82=8A?= =?UTF-8?q?=E5=BD=93=E3=81=A6=E5=87=A6=E7=90=86=E5=AE=9F=E8=A3=85=EF=BC=88?= =?UTF-8?q?=E3=83=A1=E3=82=A4=E3=83=B3=E5=87=A6=E7=90=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task3294: ライセンス自動割り当て処理実装(メイン処理)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3294) ライセンス自動割り当て処理を実装しました。 ラフスケッチでは1回のクエリでアカウント・ユーザーを両方取得する設計でしたが、実装難度・可読性の面から、 アカウントとユーザーを別々に取得するよう変更しています。 ## レビューポイント 処理内容に過不足がないか。 DBからのデータ取得時の条件に過不足がないか。 ## UIの変更 なし ## 動作確認状況 ローカルでUT,動作確認済み ## 補足 なし --- dictation_function/src/common/types/types.ts | 18 + dictation_function/src/constants/index.ts | 180 ++++---- .../src/entity/account.entity.ts | 4 + .../src/entity/license.entity.ts | 52 +++ dictation_function/src/entity/user.entity.ts | 9 + .../src/functions/licenseAutoAllocation.ts | 380 ++++++++++++++++ dictation_function/src/test/common/utility.ts | 33 +- .../src/test/licenseAutoAllocation.spec.ts | 425 ++++++++++++++++++ 8 files changed, 1013 insertions(+), 88 deletions(-) create mode 100644 dictation_function/src/functions/licenseAutoAllocation.ts create mode 100644 dictation_function/src/test/licenseAutoAllocation.spec.ts diff --git a/dictation_function/src/common/types/types.ts b/dictation_function/src/common/types/types.ts index 88ff9a4..3401eec 100644 --- a/dictation_function/src/common/types/types.ts +++ b/dictation_function/src/common/types/types.ts @@ -1,6 +1,7 @@ import { LICENSE_EXPIRATION_DAYS, LICENSE_EXPIRATION_THRESHOLD_DAYS, + LICENSE_EXPIRATION_TIME_WITH_TIMEZONE, TRIAL_LICENSE_EXPIRATION_DAYS, } from "../../constants"; @@ -28,6 +29,19 @@ export class DateWithDayEndTime extends Date { } } +// 翌日の日付を取得する +export class DateWithNextDayEndTime extends Date { + constructor(...args: any[]) { + if (args.length === 0) { + super(); // 引数がない場合、現在の日付で初期化 + } else { + super(...(args as [string])); // 引数がある場合、引数をそのままDateクラスのコンストラクタに渡す + } + this.setDate(this.getDate() + 1); + this.setHours(23, 59, 59, 999); // 時分秒を"23:59:59.999"に固定 + } +} + // ライセンスの算出用に、閾値となる時刻(23:59:59.999)の日付を取得する export class ExpirationThresholdDate extends Date { constructor(...args: any[]) { @@ -49,6 +63,8 @@ export class NewTrialLicenseExpirationDate extends Date { } else { super(...(args as [string])); // 引数がある場合、引数をそのままDateクラスのコンストラクタに渡す } + // タイムゾーンをカバーするために現在時刻に8時間を加算してから、30日後の日付を取得する + this.setHours(this.getHours() + LICENSE_EXPIRATION_TIME_WITH_TIMEZONE); this.setDate(this.getDate() + TRIAL_LICENSE_EXPIRATION_DAYS); this.setHours(23, 59, 59); // 時分秒を"23:59:59"に固定 this.setMilliseconds(0); @@ -63,6 +79,8 @@ export class NewAllocatedLicenseExpirationDate extends Date { } else { super(...(args as [string])); // 引数がある場合、引数をそのままDateクラスのコンストラクタに渡す } + // タイムゾーンをカバーするために現在時刻に8時間を加算してから、365日後の日付を取得する + this.setHours(this.getHours() + LICENSE_EXPIRATION_TIME_WITH_TIMEZONE); this.setDate(this.getDate() + LICENSE_EXPIRATION_DAYS); this.setHours(23, 59, 59); // 時分秒を"23:59:59"に固定 this.setMilliseconds(0); diff --git a/dictation_function/src/constants/index.ts b/dictation_function/src/constants/index.ts index 1f5ffed..a89f15e 100644 --- a/dictation_function/src/constants/index.ts +++ b/dictation_function/src/constants/index.ts @@ -19,54 +19,54 @@ export const TIERS = { * 音声ファイルをEast USに保存する国リスト * @const {number} */ -export const BLOB_STORAGE_REGION_US = ['CA', 'KY', 'US']; +export const BLOB_STORAGE_REGION_US = ["CA", "KY", "US"]; /** * 音声ファイルをAustralia Eastに保存する国リスト * @const {number} */ -export const BLOB_STORAGE_REGION_AU = ['AU', 'NZ']; +export const BLOB_STORAGE_REGION_AU = ["AU", "NZ"]; /** * 音声ファイルをNorth Europeに保存する国リスト * @const {number} */ export const BLOB_STORAGE_REGION_EU = [ - 'AT', - 'BE', - 'BG', - 'HR', - 'CY', - 'CZ', - 'DK', - 'EE', - 'FI', - 'FR', - 'DE', - 'GR', - 'HU', - 'IS', - 'IE', - 'IT', - 'LV', - 'LI', - 'LT', - 'LU', - 'MT', - 'NL', - 'NO', - 'PL', - 'PT', - 'RO', - 'RS', - 'SK', - 'SI', - 'ZA', - 'ES', - 'SE', - 'CH', - 'TR', - 'GB', + "AT", + "BE", + "BG", + "HR", + "CY", + "CZ", + "DK", + "EE", + "FI", + "FR", + "DE", + "GR", + "HU", + "IS", + "IE", + "IT", + "LV", + "LI", + "LT", + "LU", + "MT", + "NL", + "NO", + "PL", + "PT", + "RO", + "RS", + "SK", + "SI", + "ZA", + "ES", + "SE", + "CH", + "TR", + "GB", ]; /** @@ -74,8 +74,8 @@ export const BLOB_STORAGE_REGION_EU = [ * @const {string[]} */ export const ADMIN_ROLES = { - ADMIN: 'admin', - STANDARD: 'standard', + ADMIN: "admin", + STANDARD: "standard", } as const; /** @@ -83,9 +83,9 @@ export const ADMIN_ROLES = { * @const {string[]} */ export const USER_ROLES = { - NONE: 'none', - AUTHOR: 'author', - TYPIST: 'typist', + NONE: "none", + AUTHOR: "author", + TYPIST: "typist", } as const; /** @@ -93,9 +93,9 @@ export const USER_ROLES = { * @const {string[]} */ export const LICENSE_ISSUE_STATUS = { - ISSUE_REQUESTING: 'Issue Requesting', - ISSUED: 'Issued', - CANCELED: 'Order Canceled', + ISSUE_REQUESTING: "Issue Requesting", + ISSUED: "Issued", + CANCELED: "Order Canceled", }; /** @@ -103,28 +103,28 @@ export const LICENSE_ISSUE_STATUS = { * @const {string[]} */ export const LICENSE_TYPE = { - TRIAL: 'TRIAL', - NORMAL: 'NORMAL', - CARD: 'CARD', + TRIAL: "TRIAL", + NORMAL: "NORMAL", + CARD: "CARD", } as const; /** * ライセンス状態 * @const {string[]} */ export const LICENSE_ALLOCATED_STATUS = { - UNALLOCATED: 'Unallocated', - ALLOCATED: 'Allocated', - REUSABLE: 'Reusable', - DELETED: 'Deleted', + UNALLOCATED: "Unallocated", + ALLOCATED: "Allocated", + REUSABLE: "Reusable", + DELETED: "Deleted", } as const; /** * 切り替え元種別 * @const {string[]} */ export const SWITCH_FROM_TYPE = { - NONE: 'NONE', - CARD: 'CARD', - TRIAL: 'TRIAL', + NONE: "NONE", + CARD: "CARD", + TRIAL: "TRIAL", } as const; /** @@ -139,6 +139,12 @@ export const LICENSE_EXPIRATION_THRESHOLD_DAYS = 14; */ export const LICENSE_EXPIRATION_DAYS = 365; +/** + * タイムゾーンを加味したライセンスの有効期間(8時間) + * @const {number} + */ +export const LICENSE_EXPIRATION_TIME_WITH_TIMEZONE = 8; + /** * カードライセンスの桁数 * @const {number} @@ -156,36 +162,36 @@ export const OPTION_ITEM_NUM = 10; * @const {string[]} */ export const TASK_STATUS = { - UPLOADED: 'Uploaded', - PENDING: 'Pending', - IN_PROGRESS: 'InProgress', - FINISHED: 'Finished', - BACKUP: 'Backup', + UPLOADED: "Uploaded", + PENDING: "Pending", + IN_PROGRESS: "InProgress", + FINISHED: "Finished", + BACKUP: "Backup", } as const; /** * タスク一覧でソート可能な属性の一覧 */ export const TASK_LIST_SORTABLE_ATTRIBUTES = [ - 'JOB_NUMBER', - 'STATUS', - 'ENCRYPTION', - 'AUTHOR_ID', - 'WORK_TYPE', - 'FILE_NAME', - 'FILE_LENGTH', - 'FILE_SIZE', - 'RECORDING_STARTED_DATE', - 'RECORDING_FINISHED_DATE', - 'UPLOAD_DATE', - 'TRANSCRIPTION_STARTED_DATE', - 'TRANSCRIPTION_FINISHED_DATE', + "JOB_NUMBER", + "STATUS", + "ENCRYPTION", + "AUTHOR_ID", + "WORK_TYPE", + "FILE_NAME", + "FILE_LENGTH", + "FILE_SIZE", + "RECORDING_STARTED_DATE", + "RECORDING_FINISHED_DATE", + "UPLOAD_DATE", + "TRANSCRIPTION_STARTED_DATE", + "TRANSCRIPTION_FINISHED_DATE", ] as const; /** * タスク一覧のソート条件(昇順・降順) */ -export const SORT_DIRECTIONS = ['ASC', 'DESC'] as const; +export const SORT_DIRECTIONS = ["ASC", "DESC"] as const; /** * 通知タグの最大個数 @@ -198,18 +204,18 @@ export const TAG_MAX_COUNT = 20; * 通知のプラットフォーム種別文字列 */ export const PNS = { - WNS: 'wns', - APNS: 'apns', + WNS: "wns", + APNS: "apns", }; /** * ユーザーのライセンス状態 */ export const USER_LICENSE_STATUS = { - NORMAL: 'Normal', - NO_LICENSE: 'NoLicense', - ALERT: 'Alert', - RENEW: 'Renew', + NORMAL: "Normal", + NO_LICENSE: "NoLicense", + ALERT: "Alert", + RENEW: "Renew", }; /** @@ -234,9 +240,9 @@ export const WORKTYPE_MAX_COUNT = 20; * worktypeのDefault値の取りうる値 **/ export const OPTION_ITEM_VALUE_TYPE = { - DEFAULT: 'Default', - BLANK: 'Blank', - LAST_INPUT: 'LastInput', + DEFAULT: "Default", + BLANK: "Blank", + LAST_INPUT: "LastInput", } as const; /** @@ -244,20 +250,20 @@ export const OPTION_ITEM_VALUE_TYPE = { * @const {string[]} */ export const ADB2C_SIGN_IN_TYPE = { - EMAILADDRESS: 'emailAddress', + EMAILADDRESS: "emailAddress", } as const; /** * MANUAL_RECOVERY_REQUIRED * @const {string} */ -export const MANUAL_RECOVERY_REQUIRED = '[MANUAL_RECOVERY_REQUIRED]'; +export const MANUAL_RECOVERY_REQUIRED = "[MANUAL_RECOVERY_REQUIRED]"; /** * 利用規約種別 * @const {string[]} */ export const TERM_TYPE = { - EULA: 'EULA', - DPA: 'DPA', + EULA: "EULA", + DPA: "DPA", } as const; diff --git a/dictation_function/src/entity/account.entity.ts b/dictation_function/src/entity/account.entity.ts index a8fa438..3a1c9af 100644 --- a/dictation_function/src/entity/account.entity.ts +++ b/dictation_function/src/entity/account.entity.ts @@ -8,6 +8,7 @@ import { UpdateDateColumn, OneToOne, JoinColumn, + OneToMany, } from "typeorm"; @Entity({ name: "accounts" }) @@ -73,4 +74,7 @@ export class Account { @OneToOne(() => User, (user) => user.id) @JoinColumn({ name: "secondary_admin_user_id" }) secondaryAdminUser: User | null; + + @OneToMany(() => User, (user) => user.id) + user: User[] | null; } diff --git a/dictation_function/src/entity/license.entity.ts b/dictation_function/src/entity/license.entity.ts index 2f32ae3..56f2f00 100644 --- a/dictation_function/src/entity/license.entity.ts +++ b/dictation_function/src/entity/license.entity.ts @@ -6,6 +6,7 @@ import { UpdateDateColumn, JoinColumn, OneToOne, + ManyToOne, } from "typeorm"; import { bigintTransformer } from "../common/entity"; import { User } from "./user.entity"; @@ -61,3 +62,54 @@ export class License { @JoinColumn({ name: "allocated_user_id" }) user: User | null; } + +@Entity({ name: "license_allocation_history" }) +export class LicenseAllocationHistory { + @PrimaryGeneratedColumn() + id: number; + + @Column() + user_id: number; + + @Column() + license_id: number; + + @Column() + is_allocated: boolean; + + @Column() + account_id: number; + + @Column() + executed_at: Date; + + @Column() + switch_from_type: string; + + @Column({ nullable: true, type: "datetime" }) + deleted_at: Date | null; + + @Column({ nullable: true, type: "datetime" }) + created_by: string | null; + + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: "datetime", + }) + created_at: Date; + + @Column({ nullable: true, type: "datetime" }) + updated_by: string | null; + + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: "datetime", + }) + updated_at: Date; + + @ManyToOne(() => License, (licenses) => licenses.id, { + createForeignKeyConstraints: false, + }) // createForeignKeyConstraintsはSQLite用設定値.本番用は別途migrationで設定 + @JoinColumn({ name: "license_id" }) + license: License | null; +} diff --git a/dictation_function/src/entity/user.entity.ts b/dictation_function/src/entity/user.entity.ts index 573a017..78d2c7d 100644 --- a/dictation_function/src/entity/user.entity.ts +++ b/dictation_function/src/entity/user.entity.ts @@ -5,8 +5,11 @@ import { CreateDateColumn, UpdateDateColumn, OneToOne, + JoinColumn, + ManyToOne, } from "typeorm"; import { License } from "./license.entity"; +import { Account } from "./account.entity"; @Entity({ name: "users" }) export class User { @@ -73,6 +76,12 @@ export class User { }) // defaultはSQLite用設定値.本番用は別途migrationで設定 updated_at: Date; + @ManyToOne(() => Account, (account) => account.user, { + createForeignKeyConstraints: false, + }) // createForeignKeyConstraintsはSQLite用設定値.本番用は別途migrationで設定 + @JoinColumn({ name: "account_id" }) + account: Account | null; + @OneToOne(() => License, (license) => license.user) license: License | null; } diff --git a/dictation_function/src/functions/licenseAutoAllocation.ts b/dictation_function/src/functions/licenseAutoAllocation.ts new file mode 100644 index 0000000..6bddc1a --- /dev/null +++ b/dictation_function/src/functions/licenseAutoAllocation.ts @@ -0,0 +1,380 @@ +import { app, InvocationContext, Timer } from "@azure/functions"; +import { Between, DataSource, In, MoreThan, Repository } from "typeorm"; +import { User } from "../entity/user.entity"; +import { Account } from "../entity/account.entity"; +import { License, LicenseAllocationHistory } from "../entity/license.entity"; +import * as dotenv from "dotenv"; +import { + LICENSE_ALLOCATED_STATUS, + LICENSE_TYPE, + SWITCH_FROM_TYPE, + TIERS, + USER_ROLES, +} from "../constants"; +import { + DateWithDayEndTime, + DateWithNextDayEndTime, + DateWithZeroTime, + NewAllocatedLicenseExpirationDate, +} from "../common/types/types"; + +export async function licenseAutoAllocationProcessing( + context: InvocationContext, + datasource: DataSource +): Promise { + try { + context.log("[IN]licenseAutoAllocationProcessing"); + + // ライセンスの有効期間判定用 + const currentDateZeroTime = new DateWithZeroTime(); + const currentDateEndTime = new DateWithDayEndTime(); + + // 自動更新対象の候補となるアカウントを取得 + const accountRepository = datasource.getRepository(Account); + const targetAccounts = await accountRepository.find({ + where: { + tier: TIERS.TIER5, + }, + }); + + // 自動更新対象となるアカウント・ユーザーを取得 + const autoAllocationLists = await findTargetUser( + context, + datasource, + targetAccounts, + currentDateZeroTime, + currentDateEndTime + ); + + // 対象となるアカウント数分ループ + for (const autoAllocationList of autoAllocationLists) { + // ライセンスを割り当てる + await allocateLicense( + context, + datasource, + autoAllocationList, + currentDateZeroTime, + currentDateEndTime + ); + } + } catch (e) { + context.log("licenseAutoAllocationProcessing failed."); + context.error(e); + throw e; + } finally { + context.log("[OUT]licenseAutoAllocationProcessing"); + } +} + +export async function licenseAutoAllocation( + myTimer: Timer, + context: InvocationContext +): Promise { + try { + context.log("[IN]licenseAutoAllocation"); + dotenv.config({ path: ".env" }); + dotenv.config({ path: ".env.local", override: true }); + let datasource: DataSource; + try { + datasource = new DataSource({ + type: "mysql", + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + entities: [User, Account, License, LicenseAllocationHistory], + }); + await datasource.initialize(); + } catch (e) { + context.log("database initialize failed."); + context.error(e); + throw e; + } + + await licenseAutoAllocationProcessing(context, datasource); + } catch (e) { + context.log("licenseAutoAllocation failed."); + context.error(e); + throw e; + } finally { + context.log("[OUT]licenseAutoAllocation"); + } +} + +/** + * 自動更新対象のアカウント・ユーザーを取得する + * @param context + * @param datasource + * @param targetAccounts 自動更新対象候補のアカウント + * @param currentDateZeroTime + * @param currentDateEndTime + * @returns autoAllocationList[] 自動更新対象のアカウント・ユーザーのIDリスト + */ +export async function findTargetUser( + context: InvocationContext, + datasource: DataSource, + targetAccounts: Account[], + currentDateZeroTime: DateWithZeroTime, + currentDateEndTime: DateWithDayEndTime +): Promise { + try { + context.log("[IN]findTargetUser"); + + const autoAllocationList = [] as autoAllocationList[]; + // ライセンス期限が今日で自動更新対象のユーザーを取得 + const userRepository = datasource.getRepository(User); + for (const account of targetAccounts) { + // Author→Typist→Noneの優先度で割り当てたいので、roleごとに個別で取得 + const targetAuthorUsers = await userRepository.find({ + where: { + account_id: account.id, + auto_renew: true, + role: USER_ROLES.AUTHOR, + license: { + expiry_date: Between(currentDateZeroTime, currentDateEndTime), + }, + }, + relations: { + license: true, + }, + }); + + const targetTypistUsers = await userRepository.find({ + where: { + account_id: account.id, + auto_renew: true, + role: USER_ROLES.TYPIST, + license: { + expiry_date: Between(currentDateZeroTime, currentDateEndTime), + }, + }, + relations: { + license: true, + }, + }); + + const targetNoneUsers = await userRepository.find({ + where: { + account_id: account.id, + auto_renew: true, + role: USER_ROLES.NONE, + license: { + expiry_date: Between(currentDateZeroTime, currentDateEndTime), + }, + }, + relations: { + license: true, + }, + }); + // Author→Typist→Noneの順で配列に格納 + const userIds = [] as number[]; + for (const user of targetAuthorUsers) { + userIds.push(Number(user.id)); + } + for (const user of targetTypistUsers) { + userIds.push(Number(user.id)); + } + for (const user of targetNoneUsers) { + userIds.push(Number(user.id)); + } + // 対象ユーザーが0件なら自動更新リストには含めない + if (userIds.length !== 0) { + autoAllocationList.push({ + accountId: account.id, + userIds: userIds, + }); + } + } + return autoAllocationList; + } catch (e) { + context.error(e); + context.log("findTargetUser failed."); + throw e; + } finally { + context.log("[OUT]findTargetUser"); + } +} + +/** + * 割り当て可能なライセンスを取得する + * @param context + * @param licenseRepository + * @param accountId 自動更新対象のアカウントID + * @returns License 割り当て可能なライセンス + */ +export async function getAutoAllocatableLicense( + context: InvocationContext, + licenseRepository: Repository, + accountId: number +): Promise { + try { + const currentNextDateTime = new DateWithNextDayEndTime(); + // 割り当て可能なライセンスを取得 + const license = await licenseRepository.findOne({ + where: { + account_id: accountId, + status: In([ + LICENSE_ALLOCATED_STATUS.REUSABLE, + LICENSE_ALLOCATED_STATUS.UNALLOCATED, + ]), + expiry_date: MoreThan(currentNextDateTime), + }, + order: { + expiry_date: "ASC", + }, + }); + if (!license) { + // 割り当て可能なライセンスが存在しない場合でもエラーとはしたくないので、undifinedを返却する + return undefined; + } + return license; + } catch (e) { + context.error(e); + context.log("getAutoAllocatableLicense failed."); + throw e; + } +} + +/** + * ライセンスを割り当てる + * @param context + * @param datasource + * @param account アカウント・ユーザーID + * @param currentDateZeroTime + * @param currentDateEndTime + */ +export async function allocateLicense( + context: InvocationContext, + datasource: DataSource, + autoAllocationList: autoAllocationList, + currentDateZeroTime: DateWithZeroTime, + currentDateEndTime: DateWithDayEndTime +): Promise { + try { + context.log("[IN]allocateLicense"); + + // 自動更新対象ユーザーにライセンスを割り当てる + let hasAllocatebleLicense = true; + for (const userId of autoAllocationList.userIds) { + await datasource.transaction(async (entityManager) => { + const licenseRepository = entityManager.getRepository(License); + const licenseAllocationHistoryRepo = entityManager.getRepository( + LicenseAllocationHistory + ); + // 割り当て可能なライセンスを取得する(自動割り当て用) + const autoAllocatableLicense = await getAutoAllocatableLicense( + context, + licenseRepository, + autoAllocationList.accountId + ); + + // 割り当て可能なライセンスが存在しなければreturnし、その後ループ終了 + if (!autoAllocatableLicense) { + context.log(`allocatable license not exist.`); + hasAllocatebleLicense = false; + return; + } + + // ライセンスが直前で手動割り当てされていたら、自動割り当てしない + const allocatedLicense = await licenseRepository.findOne({ + where: { + allocated_user_id: userId, + expiry_date: Between(currentDateZeroTime, currentDateEndTime), + }, + }); + if (!allocatedLicense) { + context.log(`skip auto allocation. userID:${userId}`); + return; + } + + // 古いライセンスの割り当て解除 + allocatedLicense.status = LICENSE_ALLOCATED_STATUS.REUSABLE; + allocatedLicense.allocated_user_id = null; + await licenseRepository.save(allocatedLicense); + + // ライセンス割り当て履歴テーブルへ登録 + const deallocationHistory = new LicenseAllocationHistory(); + deallocationHistory.user_id = userId; + deallocationHistory.license_id = allocatedLicense.id; + deallocationHistory.account_id = autoAllocationList.accountId; + deallocationHistory.is_allocated = false; + deallocationHistory.executed_at = new Date(); + deallocationHistory.switch_from_type = SWITCH_FROM_TYPE.NONE; + await licenseAllocationHistoryRepo.save(deallocationHistory); + + // 新規ライセンス割り当て + autoAllocatableLicense.status = LICENSE_ALLOCATED_STATUS.ALLOCATED; + autoAllocatableLicense.allocated_user_id = userId; + // 有効期限が未設定なら365日後に設定 + if (!autoAllocatableLicense.expiry_date) { + autoAllocatableLicense.expiry_date = + new NewAllocatedLicenseExpirationDate(); + } + await licenseRepository.save(autoAllocatableLicense); + context.log( + `license allocated. userID:${userId}, licenseID:${autoAllocatableLicense.id}` + ); + + // ライセンス割り当て履歴テーブルを更新するための処理 + // 直近割り当てたライセンス種別を取得 + const oldLicenseType = await licenseAllocationHistoryRepo.findOne({ + relations: { + license: true, + }, + where: { user_id: userId, is_allocated: true }, + order: { executed_at: "DESC" }, + }); + + let switchFromType = ""; + if (oldLicenseType && oldLicenseType.license) { + switch (oldLicenseType.license.type) { + case LICENSE_TYPE.CARD: + switchFromType = SWITCH_FROM_TYPE.CARD; + break; + case LICENSE_TYPE.TRIAL: + switchFromType = SWITCH_FROM_TYPE.TRIAL; + break; + default: + switchFromType = SWITCH_FROM_TYPE.NONE; + break; + } + } else { + switchFromType = SWITCH_FROM_TYPE.NONE; + } + + // ライセンス割り当て履歴テーブルへ登録 + const allocationHistory = new LicenseAllocationHistory(); + allocationHistory.user_id = userId; + allocationHistory.license_id = autoAllocatableLicense.id; + allocationHistory.account_id = autoAllocationList.accountId; + allocationHistory.is_allocated = true; + allocationHistory.executed_at = new Date(); + // TODO switchFromTypeの値については「PBI1234: 第一階層として、ライセンス数推移情報をCSV出力したい」で正式対応 + allocationHistory.switch_from_type = switchFromType; + await licenseAllocationHistoryRepo.save(allocationHistory); + }); + // 割り当て可能なライセンスが存在しなければループ終了 + if (!hasAllocatebleLicense) { + break; + } + } + } catch (e) { + // エラーが発生しても次のアカウントへの処理は継続させるため、例外をthrowせずにreturnだけする + context.error(e); + context.log("allocateLicense failed."); + return; + } finally { + context.log("[OUT]allocateLicense"); + } +} + +app.timer("licenseAutoAllocation", { + schedule: "0 0 16 * * *", + handler: licenseAutoAllocation, +}); + +class autoAllocationList { + accountId: number; + userIds: number[]; +} diff --git a/dictation_function/src/test/common/utility.ts b/dictation_function/src/test/common/utility.ts index cce735a..512d975 100644 --- a/dictation_function/src/test/common/utility.ts +++ b/dictation_function/src/test/common/utility.ts @@ -3,7 +3,7 @@ import { DataSource } from "typeorm"; import { User } from "../../entity/user.entity"; import { Account } from "../../entity/account.entity"; import { ADMIN_ROLES, USER_ROLES } from "../../constants"; -import { License } from "../../entity/license.entity"; +import { License, LicenseAllocationHistory } from "../../entity/license.entity"; type InitialTestDBState = { tier1Accounts: { account: Account; users: User[] }[]; @@ -196,3 +196,34 @@ export const createLicense = async ( }); identifiers.pop() as License; }; + +export const selectLicenseByAllocatedUser = async ( + datasource: DataSource, + userId: number +): Promise<{ license: License | null }> => { + const license = await datasource.getRepository(License).findOne({ + where: { + allocated_user_id: userId, + }, + }); + return { license }; +}; + +export const selectLicenseAllocationHistory = async ( + datasource: DataSource, + userId: number, + licence_id: number +): Promise<{ licenseAllocationHistory: LicenseAllocationHistory | null }> => { + const licenseAllocationHistory = await datasource + .getRepository(LicenseAllocationHistory) + .findOne({ + where: { + user_id: userId, + license_id: licence_id, + }, + order: { + executed_at: "DESC", + }, + }); + return { licenseAllocationHistory }; +}; diff --git a/dictation_function/src/test/licenseAutoAllocation.spec.ts b/dictation_function/src/test/licenseAutoAllocation.spec.ts new file mode 100644 index 0000000..a6f790c --- /dev/null +++ b/dictation_function/src/test/licenseAutoAllocation.spec.ts @@ -0,0 +1,425 @@ +import { DataSource } from "typeorm"; +import { licenseAutoAllocationProcessing } from "../functions/licenseAutoAllocation"; +import { + ADMIN_ROLES, + LICENSE_ALLOCATED_STATUS, + LICENSE_TYPE, + SWITCH_FROM_TYPE, + TIERS, + USER_ROLES, +} from "../constants"; +import { + DateWithDayEndTime, + DateWithNextDayEndTime, + DateWithZeroTime, + ExpirationThresholdDate, + NewAllocatedLicenseExpirationDate, +} from "../common/types/types"; +import { + makeTestAccount, + createLicense, + makeTestUser, + selectLicenseByAllocatedUser, + selectLicenseAllocationHistory, +} from "./common/utility"; +import * as dotenv from "dotenv"; +import { InvocationContext } from "@azure/functions"; + +describe("licenseAlert", () => { + dotenv.config({ path: ".env" }); + dotenv.config({ path: ".env.local", override: true }); + let source: DataSource | null = 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 () => { + if (!source) return; + await source.destroy(); + source = null; + }); + + it("有効期限が本日のライセンスが自動更新されること", async () => { + if (!source) fail(); + const context = new InvocationContext(); + + const currentDateEndTime = new DateWithDayEndTime(); + console.log(currentDateEndTime); + + // アカウント + const account1 = await makeTestAccount( + source, + { tier: 5 }, + { role: `${USER_ROLES.NONE}` } + ); + const account2 = await makeTestAccount( + source, + { tier: 5 }, + { role: `${USER_ROLES.NONE}` } + ); + + // 更新対象のユーザー(3role分) + const user1 = await makeTestUser(source, { + account_id: account1.account.id, + role: `${USER_ROLES.NONE}`, + }); + const user2 = await makeTestUser(source, { + account_id: account1.account.id, + role: `${USER_ROLES.AUTHOR}`, + }); + const user3 = await makeTestUser(source, { + account_id: account1.account.id, + role: `${USER_ROLES.TYPIST}`, + }); + + // 更新対象ではないユーザー(まだ有効期限が残っている) + const user4 = await makeTestUser(source, { + account_id: account1.account.id, + role: `${USER_ROLES.NONE}`, + }); + + // 更新対象ではないユーザー(auto_renewがfalse) + const user5 = await makeTestUser(source, { + account_id: account1.account.id, + role: `${USER_ROLES.NONE}`, + auto_renew: false, + }); + + // 割り当て済みで有効期限が本日のライセンス + await createLicense( + source, + 1, + currentDateEndTime, + account1.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + user1.id, + null, + null, + null + ); + await createLicense( + source, + 2, + currentDateEndTime, + account1.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + user2.id, + null, + null, + null + ); + await createLicense( + source, + 3, + currentDateEndTime, + account1.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + user3.id, + null, + null, + null + ); + await createLicense( + source, + 20, + currentDateEndTime, + account2.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + account2.admin.id, + null, + null, + null + ); + await createLicense( + source, + 5, + currentDateEndTime, + account1.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + user5.id, + null, + null, + null + ); + + // 割り当て済みの更新対象ではないライセンス + const nextDate = new Date(); + nextDate.setDate(nextDate.getDate() + 1); + nextDate.setHours(23, 59, 59); // 時分秒を"23:59:59"に固定 + nextDate.setMilliseconds(0); + await createLicense( + source, + 4, + nextDate, + account1.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + user4.id, + null, + null, + null + ); + + // 有効期限が先の未割当ライセンスを作成 + // idが100,101のものは有効期限が当日、翌日なので自動割り当て対象外 + // idが102のものから割り当てられる + for (let i = 0; i < 10; i++) { + const date = new Date(); + date.setDate(date.getDate() + i); + date.setHours(23, 59, 59); // 時分秒を"23:59:59"に固定 + date.setMilliseconds(0); + await createLicense( + source, + i + 100, + date, + account1.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.UNALLOCATED, + null, + null, + null, + null + ); + } + + const date = new Date(); + date.setDate(date.getDate() + 30); + date.setHours(23, 59, 59); // 時分秒を"23:59:59"に固定 + date.setMilliseconds(0); + await createLicense( + source, + 200, + date, + account2.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.REUSABLE, + null, + null, + null, + null + ); + + await licenseAutoAllocationProcessing(context, source); + const user1Allocated = await selectLicenseByAllocatedUser(source, user1.id); + const user2Allocated = await selectLicenseByAllocatedUser(source, user2.id); + const user3Allocated = await selectLicenseByAllocatedUser(source, user3.id); + const user4Allocated = await selectLicenseByAllocatedUser(source, user4.id); + const user5Allocated = await selectLicenseByAllocatedUser(source, user5.id); + const admin2Allocated = await selectLicenseByAllocatedUser( + source, + account2.admin.id + ); + const licenseAllocationHistory = await selectLicenseAllocationHistory( + source, + user1.id, + 104 + ); + // Author、Typist、Noneの優先順位で割り当てられていることを確認 + expect(user1Allocated.license?.id).toBe(104); + expect(user2Allocated.license?.id).toBe(102); + expect(user3Allocated.license?.id).toBe(103); + // 有効期限がまだあるので、ライセンスが更新されていないことを確認 + expect(user4Allocated.license?.id).toBe(4); + // auto_renewがfalseなので、ライセンスが更新されていないことを確認 + expect(user5Allocated.license?.id).toBe(5); + // 複数アカウント分の処理が正常に行われていることの確認 + expect(admin2Allocated.license?.id).toBe(200); + + // ライセンス割り当て履歴テーブルが更新されていることを確認 + expect(licenseAllocationHistory.licenseAllocationHistory?.user_id).toBe( + user1.id + ); + expect( + licenseAllocationHistory.licenseAllocationHistory?.is_allocated + ).toBe(true); + expect(licenseAllocationHistory.licenseAllocationHistory?.account_id).toBe( + account1.account.id + ); + }); + + it("新たに割り当てられるライセンスが存在しないため、ライセンスが自動更新されない(エラーではない)", async () => { + if (!source) fail(); + const context = new InvocationContext(); + + const currentDateEndTime = new DateWithDayEndTime(); + console.log(currentDateEndTime); + + // アカウント + const account1 = await makeTestAccount( + source, + { tier: 5 }, + { role: `${USER_ROLES.NONE}` } + ); + + // 更新対象のユーザー(3role分) + const user1 = await makeTestUser(source, { + account_id: account1.account.id, + role: `${USER_ROLES.NONE}`, + }); + const user2 = await makeTestUser(source, { + account_id: account1.account.id, + role: `${USER_ROLES.AUTHOR}`, + }); + const user3 = await makeTestUser(source, { + account_id: account1.account.id, + role: `${USER_ROLES.TYPIST}`, + }); + + // 割り当て済みで有効期限が本日のライセンス + await createLicense( + source, + 1, + currentDateEndTime, + account1.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + user1.id, + null, + null, + null + ); + await createLicense( + source, + 2, + currentDateEndTime, + account1.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + user2.id, + null, + null, + null + ); + await createLicense( + source, + 3, + currentDateEndTime, + account1.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + user3.id, + null, + null, + null + ); + + await licenseAutoAllocationProcessing(context, source); + const user1Allocated = await selectLicenseByAllocatedUser(source, user1.id); + const user2Allocated = await selectLicenseByAllocatedUser(source, user2.id); + const user3Allocated = await selectLicenseByAllocatedUser(source, user3.id); + // ライセンスが更新されていないことを確認 + expect(user1Allocated.license?.id).toBe(1); + expect(user2Allocated.license?.id).toBe(2); + expect(user3Allocated.license?.id).toBe(3); + }); + + it("tier4のアカウントのため、ライセンスが自動更新されない", async () => { + if (!source) fail(); + const context = new InvocationContext(); + + const currentDateEndTime = new DateWithDayEndTime(); + console.log(currentDateEndTime); + + // アカウント + const account1 = await makeTestAccount( + source, + { tier: 4 }, + { role: `${USER_ROLES.NONE}` } + ); + + // 更新対象のユーザー(3role分) + const user1 = await makeTestUser(source, { + account_id: account1.account.id, + role: `${USER_ROLES.NONE}`, + }); + const user2 = await makeTestUser(source, { + account_id: account1.account.id, + role: `${USER_ROLES.AUTHOR}`, + }); + const user3 = await makeTestUser(source, { + account_id: account1.account.id, + role: `${USER_ROLES.TYPIST}`, + }); + + // 割り当て済みで有効期限が本日のライセンス + await createLicense( + source, + 1, + currentDateEndTime, + account1.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + user1.id, + null, + null, + null + ); + await createLicense( + source, + 2, + currentDateEndTime, + account1.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + user2.id, + null, + null, + null + ); + await createLicense( + source, + 3, + currentDateEndTime, + account1.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + user3.id, + null, + null, + null + ); + + // 有効期限が先の未割当ライセンスを作成 + // idが100,101のものは有効期限が当日、翌日なので自動割り当て対象外 + // idが102のものから割り当てられる + for (let i = 0; i < 10; i++) { + const date = new Date(); + date.setDate(date.getDate() + i); + date.setHours(23, 59, 59); // 時分秒を"23:59:59"に固定 + date.setMilliseconds(0); + await createLicense( + source, + i + 100, + date, + account1.account.id, + LICENSE_TYPE.CARD, + LICENSE_ALLOCATED_STATUS.UNALLOCATED, + null, + null, + null, + null + ); + } + + await licenseAutoAllocationProcessing(context, source); + const user1Allocated = await selectLicenseByAllocatedUser(source, user1.id); + const user2Allocated = await selectLicenseByAllocatedUser(source, user2.id); + const user3Allocated = await selectLicenseByAllocatedUser(source, user3.id); + // ライセンスが更新されていないことを確認 + expect(user1Allocated.license?.id).toBe(1); + expect(user2Allocated.license?.id).toBe(2); + expect(user3Allocated.license?.id).toBe(3); + }); +});