diff --git a/package.json b/package.json index c4a624902..c08ce1cb9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iSunFA", - "version": "0.8.2+34", + "version": "0.8.2+35", "private": false, "scripts": { "dev": "next dev", diff --git a/src/constants/api_connection.ts b/src/constants/api_connection.ts index 7c1e323e1..a90931bb1 100644 --- a/src/constants/api_connection.ts +++ b/src/constants/api_connection.ts @@ -76,6 +76,7 @@ export enum APIName { JOURNAL_DELETE = 'JOURNAL_DELETE', REPORT_LIST = 'REPORT_LIST', REPORT_GET_BY_ID = 'REPORT_GET_BY_ID', + REPORT_GET_V2 = 'REPORT_GET_V2', REPORT_GENERATE = 'REPORT_GENERATE', STATUS_INFO_GET = 'STATUS_INFO_GET', ACCOUNT_LIST = 'ACCOUNT_LIST', @@ -156,6 +157,7 @@ export enum APIPath { JOURNAL_DELETE = `${apiPrefix}/company/:companyId/journal/:journalId`, REPORT_LIST = `${apiPrefix}/company/:companyId/report`, REPORT_GET_BY_ID = `${apiPrefix}/company/:companyId/report/:reportId`, + REPORT_GET_V2 = `${apiPrefixV2}/company/:companyId/report`, REPORT_GENERATE = `${apiPrefix}/company/:companyId/report`, STATUS_INFO_GET = `${apiPrefix}/status_info`, ACCOUNT_LIST = `${apiPrefix}/company/:companyId/account`, @@ -522,4 +524,14 @@ export const APIConfig: Record = { method: HttpMethod.GET, path: APIPath.CERTIFICATE_LIST, }), + + /** + * Info: (20241007 - Murky) + * Below is v2 API + */ + [APIName.REPORT_GET_V2]: createConfig({ + name: APIName.REPORT_GET_V2, + method: HttpMethod.GET, + path: APIPath.REPORT_GET_V2, + }), }; diff --git a/src/constants/financial_report.ts b/src/constants/financial_report.ts index 2057501cb..0bef77dbe 100644 --- a/src/constants/financial_report.ts +++ b/src/constants/financial_report.ts @@ -15,8 +15,11 @@ export const EMPTY_I_ACCOUNT_READY_FRONTEND: IAccountReadyForFrontend = { curPeriodAmount: 0, curPeriodAmountString: '0', curPeriodPercentage: 0, + curPeriodPercentageString: '0', prePeriodAmount: 0, prePeriodAmountString: '0', prePeriodPercentage: 0, + prePeriodPercentageString: '0', indent: 0, + children: [], }; diff --git a/src/constants/zod_schema.ts b/src/constants/zod_schema.ts index 41599c626..9c01647e8 100644 --- a/src/constants/zod_schema.ts +++ b/src/constants/zod_schema.ts @@ -24,6 +24,7 @@ import { ocrResultGetByIdValidator, ocrUploadValidator, } from '@/lib/utils/zod_schema/ocr'; +import { reportGetValidatorV2 } from '@/lib/utils/zod_schema/report'; import { voucherCreateValidator, voucherDeleteValidatorV2, @@ -77,4 +78,6 @@ export const API_ZOD_SCHEMA = { [APIName.AI_ASK_V2]: askAIPostValidatorV2, [APIName.AI_ASK_RESULT_V2]: askAIGetResultValidatorV2, + + [APIName.REPORT_GET_V2]: reportGetValidatorV2, }; diff --git a/src/interfaces/accounting_account.ts b/src/interfaces/accounting_account.ts index ece1f1298..fb43dfdbf 100644 --- a/src/interfaces/accounting_account.ts +++ b/src/interfaces/accounting_account.ts @@ -47,10 +47,13 @@ export interface IAccountReadyForFrontend { curPeriodAmount: number; curPeriodAmountString: string; curPeriodPercentage: number; + curPeriodPercentageString: string; prePeriodAmount: number; prePeriodAmountString: string; prePeriodPercentage: number; + prePeriodPercentageString: string; indent: number; + children: IAccountReadyForFrontend[]; } export type IAccountQueryArgs = { diff --git a/src/interfaces/api_connection.ts b/src/interfaces/api_connection.ts index ae36fdae9..729858d5d 100644 --- a/src/interfaces/api_connection.ts +++ b/src/interfaces/api_connection.ts @@ -41,6 +41,7 @@ export type IAPIName = | 'JOURNAL_DELETE' | 'REPORT_LIST' | 'REPORT_GET_BY_ID' + | 'REPORT_GET_V2' | 'REPORT_GENERATE' | 'STATUS_INFO_GET' | 'ACCOUNT_LIST' diff --git a/src/lib/utils/common.ts b/src/lib/utils/common.ts index e40dabd73..4f478bcd7 100644 --- a/src/lib/utils/common.ts +++ b/src/lib/utils/common.ts @@ -648,3 +648,26 @@ export function generateUUID(): string { const randomUUID = Math.random().toString(36).substring(2, 12); return randomUUID; } + +/** + * Info: (20241007 - Murky) + * Return String version of number, comma will be added, add bracket if num is negative + * Return '-' if num is undefined, null or too small (-0.1~0.1) + * @param num - {number | null | undefined | string} + * number that be transform into string, + * if already string, than it will only be add comma than return + * @returns - {string} return number with comma and bracket + */ +export function numberBeDashIfFalsy(num: number | null | undefined | string) { + if (typeof num === 'string') { + return numberWithCommas(num); + } + + if (num === null || num === undefined || (num < 0.1 && num > -0.1)) { + return '-'; + } + + const formattedNumber = numberWithCommas(Math.abs(num)); + + return num < 0 ? `(${formattedNumber})` : formattedNumber; +} diff --git a/src/lib/utils/report/financial_report_generator.ts b/src/lib/utils/report/financial_report_generator.ts index d1abf1a08..82fca8d53 100644 --- a/src/lib/utils/report/financial_report_generator.ts +++ b/src/lib/utils/report/financial_report_generator.ts @@ -16,7 +16,11 @@ import { IFinancialReportInDB, IncomeStatementOtherInfo, } from '@/interfaces/report'; -import { formatNumberSeparateByComma, getTimestampOfSameDateOfLastYear } from '@/lib/utils/common'; +import { + formatNumberSeparateByComma, + getTimestampOfSameDateOfLastYear, + numberBeDashIfFalsy, +} from '@/lib/utils/common'; export default abstract class FinancialReportGenerator extends ReportGenerator { protected lastPeriodStartDateInSecond: number; @@ -166,16 +170,22 @@ export default abstract class FinancialReportGenerator extends ReportGenerator { const prePeriodPercentage = lastPeriodAccount?.percentage ? Math.round(lastPeriodAccount.percentage * 100) : 0; + + const curPeriodPercentageString = numberBeDashIfFalsy(curPeriodPercentage); + const prePeriodPercentageString = numberBeDashIfFalsy(prePeriodPercentage); const accountReadyForFrontend: IAccountReadyForFrontend = { code: curPeriodAccount.code, name: curPeriodAccount.name, curPeriodAmount, curPeriodPercentage, curPeriodAmountString, + curPeriodPercentageString, prePeriodAmount, prePeriodPercentage, prePeriodAmountString, + prePeriodPercentageString, indent: curPeriodAccount.indent, + children: [], }; curPeriodAccountReadyForFrontendArray.push(accountReadyForFrontend); }); diff --git a/src/lib/utils/zod_schema/common.ts b/src/lib/utils/zod_schema/common.ts index 21e07ad32..4ec680d14 100644 --- a/src/lib/utils/zod_schema/common.ts +++ b/src/lib/utils/zod_schema/common.ts @@ -24,3 +24,11 @@ export function zodTimestampInSeconds(canBeUndefined: boolean = false, defaultVa .regex(/^\d+$/) .transform((val) => timestampInSeconds(Number(val))); } + +export function zodTimestampInSecondsNoDefault() { + const setting = z + .string() + .regex(/^\d+$/) + .transform((val) => timestampInSeconds(Number(val))); + return setting; +} diff --git a/src/lib/utils/zod_schema/report.ts b/src/lib/utils/zod_schema/report.ts new file mode 100644 index 000000000..84d265827 --- /dev/null +++ b/src/lib/utils/zod_schema/report.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; +import { IZodValidator } from '@/interfaces/zod_validator'; +import { zodTimestampInSecondsNoDefault } from '@/lib/utils/zod_schema/common'; +import { FinancialReportTypesKey } from '@/interfaces/report_type'; + +const reportGetQueryValidatorV2 = z.object({ + startDate: zodTimestampInSecondsNoDefault(), + endDate: zodTimestampInSecondsNoDefault(), + language: z.string(), + reportType: z.nativeEnum(FinancialReportTypesKey), +}); + +const reportGetBodyValidatorV2 = z.object({}); + +export const reportGetValidatorV2: IZodValidator< + (typeof reportGetQueryValidatorV2)['shape'], + (typeof reportGetBodyValidatorV2)['shape'] +> = { + query: reportGetQueryValidatorV2, + body: reportGetBodyValidatorV2, +}; diff --git a/src/pages/api/v1/company/[companyId]/report/[reportId]/index.ts b/src/pages/api/v1/company/[companyId]/report/[reportId]/index.ts index 38c9696b9..5842fa53d 100644 --- a/src/pages/api/v1/company/[companyId]/report/[reportId]/index.ts +++ b/src/pages/api/v1/company/[companyId]/report/[reportId]/index.ts @@ -102,10 +102,13 @@ function transformDetailsIntoGeneral( curPeriodAmount: 0, curPeriodAmountString: '0', curPeriodPercentage: 0, + curPeriodPercentageString: '0', prePeriodAmount: 0, prePeriodAmountString: '0', prePeriodPercentage: 0, + prePeriodPercentageString: '0', indent: account.indent, + children: [], }; }); return general; diff --git a/src/pages/api/v2/company/[companyId]/report/index.test.ts b/src/pages/api/v2/company/[companyId]/report/index.test.ts new file mode 100644 index 000000000..b9ece05ba --- /dev/null +++ b/src/pages/api/v2/company/[companyId]/report/index.test.ts @@ -0,0 +1,65 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { handleGetRequest } from '@/pages/api/v2/company/[companyId]/report/index'; +import { STATUS_MESSAGE } from '@/constants/status_code'; + +import { FinancialReportTypesKey } from '@/interfaces/report_type'; + +let req: jest.Mocked; +let res: jest.Mocked; + +jest.mock('../../../../../../lib/utils/session.ts', () => ({ + getSession: jest.fn().mockResolvedValue({ + userId: 1001, + companyId: 1001, + }), +})); + +jest.mock('../../../../../../lib/utils/auth_check', () => ({ + checkAuthorization: jest.fn().mockResolvedValue(true), +})); + +// Info: (20241007 - Murky) Uncomment this to check zod return +// jest.mock('../../../../../../lib/utils/logger_back', () => ({ +// loggerRequest: jest.fn().mockReturnValue({ +// info: jest.fn(), +// error: jest.fn(), +// }), +// })); + +beforeEach(() => { + req = { + headers: {}, + query: {}, + method: '', + socket: {}, + json: jest.fn(), + } as unknown as jest.Mocked; + + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as jest.Mocked; +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('company/[companyId]/certificate', () => { + describe('GET Certificate List', () => { + it('should match patter', async () => { + req.query = { + startDate: '10000000', + endDate: '16000000', + language: 'zh', + reportType: FinancialReportTypesKey.balance_sheet, + }; + req.body = {}; + + const { payload, statusMessage } = await handleGetRequest(req, res); + + expect(statusMessage).toBe(STATUS_MESSAGE.SUCCESS_GET); + expect(payload).toBeDefined(); + }); + }); +}); diff --git a/src/pages/api/v2/company/[companyId]/report/index.ts b/src/pages/api/v2/company/[companyId]/report/index.ts new file mode 100644 index 000000000..0dd19f7b6 --- /dev/null +++ b/src/pages/api/v2/company/[companyId]/report/index.ts @@ -0,0 +1,367 @@ +import { STATUS_MESSAGE } from '@/constants/status_code'; +import { IResponseData } from '@/interfaces/response_data'; +// import { AuthFunctionsKeys } from '@/interfaces/auth'; +// import { checkAuthorization } from '@/lib/utils/auth_check'; +import { formatApiResponse } from '@/lib/utils/common'; +import { getSession } from '@/lib/utils/session'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { validateRequest } from '@/lib/utils/request_validator'; +import { APIName } from '@/constants/api_connection'; + +import { loggerError } from '@/lib/utils/logger_back'; +import { FinancialReportTypesKey } from '@/interfaces/report_type'; +import { IAccountReadyForFrontend } from '@/interfaces/accounting_account'; + +type ReportObject = IAccountReadyForFrontend[]; + +type ReportReturnType = { + general: ReportObject; + detail: ReportObject; +} | null; + +type APIResponse = object | null; + +export async function balanceSheetHandler({ + // ToDo: (20241007 - Murky) Use these param in function + /* eslint-disable @typescript-eslint/no-unused-vars */ + startDate, + endDate, + language, + /* eslint-enable @typescript-eslint/no-unused-vars */ +}: { + startDate: number; + endDate: number; + language: string; +}) { + const statusMessage: string = STATUS_MESSAGE.SUCCESS_GET; + + // ToDo: (20241007 - Murky) negative number need to be in brackets + // ToDo: (20241007 - Murky) Maybe IAccountReadyForFrontEnd need to have "string" version of percentage + + const general: ReportObject = [ + { + code: '11XX', + name: '流動資產合計', + curPeriodAmount: 2194032910, + curPeriodAmountString: '2,194,032,910', + curPeriodPercentage: 40, + curPeriodPercentageString: '40', + prePeriodAmount: 2052896744, + prePeriodAmountString: '2,052,896,744', + prePeriodPercentage: 41, + prePeriodPercentageString: '41', + indent: 3, + children: [], + }, + ]; + + const detail: ReportObject = [ + { + code: '1100', + name: '現金及約當現金', + curPeriodAmount: 20000, + curPeriodAmountString: '20,000', + curPeriodPercentage: 10, + curPeriodPercentageString: '10', + prePeriodAmount: 10000, + prePeriodAmountString: '10,000', + prePeriodPercentage: 5, + prePeriodPercentageString: '5', + indent: 3, + children: [ + { + code: '1101', + name: '庫存現金', + curPeriodAmount: 10000, + curPeriodAmountString: '10,000', + curPeriodPercentage: 5, + curPeriodPercentageString: '5', + prePeriodAmount: 5000, + prePeriodAmountString: '5,000', + prePeriodPercentage: 2.5, + prePeriodPercentageString: '2.5', + indent: 4, + children: [], + }, + { + code: '1102', + name: '零用金∕週轉金', + curPeriodAmount: 10000, + curPeriodAmountString: '10,000', + curPeriodPercentage: 5, + curPeriodPercentageString: '5', + prePeriodAmount: 5000, + prePeriodAmountString: '5,000', + prePeriodPercentage: 2.5, + prePeriodPercentageString: '2.5', + indent: 4, + children: [], + }, + ], + }, + ]; + + const payload: ReportReturnType = { + general, + detail, + }; + + return { + statusMessage, + payload, + }; +} + +export async function incomeStatementHandler({ + // ToDo: (20241007 - Murky) Use these param in function + /* eslint-disable @typescript-eslint/no-unused-vars */ + startDate, + endDate, + language, + /* eslint-enable @typescript-eslint/no-unused-vars */ +}: { + startDate: number; + endDate: number; + language: string; +}) { + const statusMessage: string = STATUS_MESSAGE.SUCCESS_GET; + + // ToDo: (20241007 - Murky) negative number need to be in brackets + // ToDo: (20241007 - Murky) Maybe IAccountReadyForFrontEnd need to have "string" version of percentage + + const general: ReportObject = [ + { + code: '5950', + name: '營業毛利(毛損)淨額流動', + curPeriodAmount: 2194032910, + curPeriodAmountString: '2,194,032,910', + curPeriodPercentage: 40, + curPeriodPercentageString: '40', + prePeriodAmount: 2052896744, + prePeriodAmountString: '2,052,896,744', + prePeriodPercentage: 41, + prePeriodPercentageString: '41', + indent: 3, + children: [], + }, + ]; + + const detail: ReportObject = [ + { + code: '4110', + name: '銷貨收入', + curPeriodAmount: 20000, + curPeriodAmountString: '20,000', + curPeriodPercentage: 10, + curPeriodPercentageString: '10', + prePeriodAmount: 10000, + prePeriodAmountString: '10,000', + prePeriodPercentage: 5, + prePeriodPercentageString: '5', + indent: 3, + children: [ + { + code: '4111', + name: '銷貨收入', + curPeriodAmount: 10000, + curPeriodAmountString: '10,000', + curPeriodPercentage: 5, + curPeriodPercentageString: '5', + prePeriodAmount: 5000, + prePeriodAmountString: '5,000', + prePeriodPercentage: 2.5, + prePeriodPercentageString: '2.5', + indent: 4, + children: [], + }, + { + code: '4112', + name: '天然氣銷貨收入(天然氣業)', + curPeriodAmount: 10000, + curPeriodAmountString: '10,000', + curPeriodPercentage: 5, + curPeriodPercentageString: '5', + prePeriodAmount: 5000, + prePeriodAmountString: '5,000', + prePeriodPercentage: 2.5, + prePeriodPercentageString: '2.5', + indent: 4, + children: [], + }, + ], + }, + ]; + + const payload: ReportReturnType = { + general, + detail, + }; + + return { + statusMessage, + payload, + }; +} + +export async function cashFlowHandler({ + // ToDo: (20241007 - Murky) Use these param in function + /* eslint-disable @typescript-eslint/no-unused-vars */ + startDate, + endDate, + language, + /* eslint-enable @typescript-eslint/no-unused-vars */ +}: { + startDate: number; + endDate: number; + language: string; +}) { + const statusMessage: string = STATUS_MESSAGE.SUCCESS_GET; + + // ToDo: (20241007 - Murky) negative number need to be in brackets + // ToDo: (20241007 - Murky) Maybe IAccountReadyForFrontEnd need to have "string" version of percentage + + const general: ReportObject = [ + { + code: 'A200105950', + name: '收益費損項目合計', + curPeriodAmount: 2194032910, + curPeriodAmountString: '2,194,032,910', + curPeriodPercentage: 40, + curPeriodPercentageString: '40', + prePeriodAmount: 2052896744, + prePeriodAmountString: '2,052,896,744', + prePeriodPercentage: 41, + prePeriodPercentageString: '41', + indent: 3, + children: [], + }, + ]; + + const detail: ReportObject = [ + { + code: '4110', + name: '銷貨收入', + curPeriodAmount: 20000, + curPeriodAmountString: '20,000', + curPeriodPercentage: 10, + curPeriodPercentageString: '10', + prePeriodAmount: 10000, + prePeriodAmountString: '10,000', + prePeriodPercentage: 5, + prePeriodPercentageString: '5', + indent: 3, + children: [], + }, + ]; + + const payload: ReportReturnType = { + general, + detail, + }; + + return { + statusMessage, + payload, + }; +} + +export async function report401Handler({ + // ToDo: (20241007 - Murky) Use these param in function + /* eslint-disable @typescript-eslint/no-unused-vars */ + startDate, + endDate, + language, + /* eslint-enable @typescript-eslint/no-unused-vars */ +}: { + startDate: number; + endDate: number; + language: string; +}) { + const statusMessage: string = STATUS_MESSAGE.SUCCESS_UPDATE; + const payload = null; + return { + statusMessage, + payload, + }; +} + +type ReportHandlers = { + [K in FinancialReportTypesKey]: ({ + startDate, + endDate, + language, + }: { + startDate: number; + endDate: number; + language: string; + }) => Promise<{ statusMessage: string; payload: ReportReturnType }>; +}; + +const reportHandlers: ReportHandlers = { + [FinancialReportTypesKey.balance_sheet]: balanceSheetHandler, + [FinancialReportTypesKey.comprehensive_income_statement]: incomeStatementHandler, + [FinancialReportTypesKey.cash_flow_statement]: cashFlowHandler, + [FinancialReportTypesKey.report_401]: report401Handler, +}; + +export async function handleGetRequest(req: NextApiRequest, res: NextApiResponse) { + let statusMessage: string = STATUS_MESSAGE.BAD_REQUEST; + let payload: object | null = null; + + const session = await getSession(req, res); + const { userId } = session; + + // ToDo: (20240924 - Murky) We need to check auth + const { query } = validateRequest(APIName.REPORT_GET_V2, req, userId); + + if (query) { + // ToDo: (20240924 - Murky) Remember to use sortBy, sortOrder, startDate, endDate, searchQuery, hasBeenUsed + const { startDate, endDate, language, reportType } = query; + const reportHandler = reportHandlers[reportType]; + + ({ payload, statusMessage } = await reportHandler({ + startDate, + endDate, + language, + })); + } + + return { + statusMessage, + payload, + userId, + }; +} + +const methodHandlers: { + [key: string]: ( + req: NextApiRequest, + res: NextApiResponse + ) => Promise<{ statusMessage: string; payload: APIResponse; userId: number }>; +} = { + GET: handleGetRequest, +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse> +) { + let statusMessage: string = STATUS_MESSAGE.BAD_REQUEST; + let payload: APIResponse = null; + let userId = -1; + try { + const handleRequest = methodHandlers[req.method || '']; + if (handleRequest) { + ({ statusMessage, payload, userId } = await handleRequest(req, res)); + } else { + statusMessage = STATUS_MESSAGE.METHOD_NOT_ALLOWED; + } + } catch (_error) { + const error = _error as Error; + const logger = loggerError(userId, error.name, error.message); + logger.error(error); + statusMessage = error.message; + } + const { httpCode, result } = formatApiResponse(statusMessage, payload); + res.status(httpCode).json(result); +}