mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-20 14:50:32 +00:00
Merge branch 'develop' into frontend-import-resource
This commit is contained in:
@@ -25,6 +25,7 @@
|
|||||||
"@types/i18n": "^0.8.7",
|
"@types/i18n": "^0.8.7",
|
||||||
"@types/knex": "^0.16.1",
|
"@types/knex": "^0.16.1",
|
||||||
"@types/mathjs": "^6.0.12",
|
"@types/mathjs": "^6.0.12",
|
||||||
|
"@types/yup": "^0.29.13",
|
||||||
"accepts": "^1.3.7",
|
"accepts": "^1.3.7",
|
||||||
"accounting": "^0.4.1",
|
"accounting": "^0.4.1",
|
||||||
"agenda": "^4.2.1",
|
"agenda": "^4.2.1",
|
||||||
@@ -53,7 +54,6 @@
|
|||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"express-basic-auth": "^1.2.0",
|
"express-basic-auth": "^1.2.0",
|
||||||
"express-boom": "^3.0.0",
|
"express-boom": "^3.0.0",
|
||||||
"express-fileupload": "^1.1.7-alpha.3",
|
|
||||||
"express-oauth-server": "^2.0.0",
|
"express-oauth-server": "^2.0.0",
|
||||||
"express-validator": "^6.12.2",
|
"express-validator": "^6.12.2",
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.0",
|
||||||
@@ -77,6 +77,7 @@
|
|||||||
"moment-timezone": "^0.5.43",
|
"moment-timezone": "^0.5.43",
|
||||||
"mongodb": "^6.1.0",
|
"mongodb": "^6.1.0",
|
||||||
"mongoose": "^5.10.0",
|
"mongoose": "^5.10.0",
|
||||||
|
"multer": "1.4.5-lts.1",
|
||||||
"mustache": "^3.0.3",
|
"mustache": "^3.0.3",
|
||||||
"mysql": "^2.17.1",
|
"mysql": "^2.17.1",
|
||||||
"mysql2": "^1.6.5",
|
"mysql2": "^1.6.5",
|
||||||
@@ -105,7 +106,8 @@
|
|||||||
"typedi": "^0.8.0",
|
"typedi": "^0.8.0",
|
||||||
"uniqid": "^5.2.0",
|
"uniqid": "^5.2.0",
|
||||||
"winston": "^3.2.1",
|
"winston": "^3.2.1",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5",
|
||||||
|
"yup": "^0.28.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/lodash": "^4.14.158",
|
"@types/lodash": "^4.14.158",
|
||||||
|
|||||||
180
packages/server/src/api/controllers/Import/ImportController.ts
Normal file
180
packages/server/src/api/controllers/Import/ImportController.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { Inject, Service } from 'typedi';
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { body, param } from 'express-validator';
|
||||||
|
import BaseController from '@/api/controllers/BaseController';
|
||||||
|
import { ServiceError } from '@/exceptions';
|
||||||
|
import { ImportResourceApplication } from '@/services/Import/ImportResourceApplication';
|
||||||
|
import { uploadImportFile } from './_utils';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class ImportController extends BaseController {
|
||||||
|
@Inject()
|
||||||
|
private importResourceApp: ImportResourceApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router constructor method.
|
||||||
|
*/
|
||||||
|
router() {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/file',
|
||||||
|
uploadImportFile.single('file'),
|
||||||
|
this.importValidationSchema,
|
||||||
|
this.validationResult,
|
||||||
|
this.asyncMiddleware(this.fileUpload.bind(this)),
|
||||||
|
this.catchServiceErrors
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/:import_id/import',
|
||||||
|
this.asyncMiddleware(this.import.bind(this)),
|
||||||
|
this.catchServiceErrors
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/:import_id/mapping',
|
||||||
|
[
|
||||||
|
param('import_id').exists().isString(),
|
||||||
|
body('mapping').exists().isArray({ min: 1 }),
|
||||||
|
body('mapping.*.from').exists(),
|
||||||
|
body('mapping.*.to').exists(),
|
||||||
|
],
|
||||||
|
this.validationResult,
|
||||||
|
this.asyncMiddleware(this.mapping.bind(this)),
|
||||||
|
this.catchServiceErrors
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/:import_id/preview',
|
||||||
|
this.asyncMiddleware(this.preview.bind(this)),
|
||||||
|
this.catchServiceErrors
|
||||||
|
);
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import validation schema.
|
||||||
|
* @returns {ValidationSchema[]}
|
||||||
|
*/
|
||||||
|
private get importValidationSchema() {
|
||||||
|
return [body('resource').exists()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports xlsx/csv to the given resource type.
|
||||||
|
* @param {Request} req -
|
||||||
|
* @param {Response} res -
|
||||||
|
* @param {NextFunction} next -
|
||||||
|
*/
|
||||||
|
private async fileUpload(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const { tenantId } = req;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.importResourceApp.import(
|
||||||
|
tenantId,
|
||||||
|
req.body.resource,
|
||||||
|
req.file.filename
|
||||||
|
);
|
||||||
|
return res.status(200).send(data);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the columns of the imported file.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
*/
|
||||||
|
private async mapping(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const { tenantId } = req;
|
||||||
|
const { import_id: importId } = req.params;
|
||||||
|
const body = this.matchedBodyData(req);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mapping = await this.importResourceApp.mapping(
|
||||||
|
tenantId,
|
||||||
|
importId,
|
||||||
|
body?.mapping
|
||||||
|
);
|
||||||
|
return res.status(200).send(mapping);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview the imported file before actual importing.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
*/
|
||||||
|
private async preview(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const { tenantId } = req;
|
||||||
|
const { import_id: importId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const preview = await this.importResourceApp.preview(tenantId, importId);
|
||||||
|
|
||||||
|
return res.status(200).send(preview);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importing the imported file to the application storage.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
*/
|
||||||
|
private async import(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const { tenantId } = req;
|
||||||
|
const { import_id: importId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.importResourceApp.process(tenantId, importId);
|
||||||
|
|
||||||
|
return res.status(200).send(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms service errors to response.
|
||||||
|
* @param {Error}
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {ServiceError} error
|
||||||
|
*/
|
||||||
|
private catchServiceErrors(
|
||||||
|
error,
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
if (error instanceof ServiceError) {
|
||||||
|
if (error.errorType === 'INVALID_MAP_ATTRS') {
|
||||||
|
return res.status(400).send({
|
||||||
|
errors: [{ type: 'INVALID_MAP_ATTRS' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error.errorType === 'DUPLICATED_FROM_MAP_ATTR') {
|
||||||
|
return res.status(400).send({
|
||||||
|
errors: [{ type: 'DUPLICATED_FROM_MAP_ATTR' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error.errorType === 'DUPLICATED_TO_MAP_ATTR') {
|
||||||
|
return res.status(400).send({
|
||||||
|
errors: [{ type: 'DUPLICATED_TO_MAP_ATTR' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error.errorType === 'IMPORTED_FILE_EXTENSION_INVALID') {
|
||||||
|
return res.status(400).send({
|
||||||
|
errors: [{ type: 'IMPORTED_FILE_EXTENSION_INVALID' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
packages/server/src/api/controllers/Import/_utils.ts
Normal file
20
packages/server/src/api/controllers/Import/_utils.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import Multer from 'multer';
|
||||||
|
import { ServiceError } from '@/exceptions';
|
||||||
|
|
||||||
|
export function allowSheetExtensions(req, file, cb) {
|
||||||
|
if (
|
||||||
|
file.mimetype !== 'text/csv' &&
|
||||||
|
file.mimetype !== 'application/vnd.ms-excel'
|
||||||
|
) {
|
||||||
|
cb(new ServiceError('IMPORTED_FILE_EXTENSION_INVALID'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadImportFile = Multer({
|
||||||
|
dest: './public/imports',
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
fileFilter: allowSheetExtensions,
|
||||||
|
});
|
||||||
@@ -56,6 +56,7 @@ import { ProjectsController } from './controllers/Projects/Projects';
|
|||||||
import { ProjectTasksController } from './controllers/Projects/Tasks';
|
import { ProjectTasksController } from './controllers/Projects/Tasks';
|
||||||
import { ProjectTimesController } from './controllers/Projects/Times';
|
import { ProjectTimesController } from './controllers/Projects/Times';
|
||||||
import { TaxRatesController } from './controllers/TaxRates/TaxRates';
|
import { TaxRatesController } from './controllers/TaxRates/TaxRates';
|
||||||
|
import { ImportController } from './controllers/Import/ImportController';
|
||||||
import { BankingController } from './controllers/Banking/BankingController';
|
import { BankingController } from './controllers/Banking/BankingController';
|
||||||
import { Webhooks } from './controllers/Webhooks/Webhooks';
|
import { Webhooks } from './controllers/Webhooks/Webhooks';
|
||||||
|
|
||||||
@@ -135,6 +136,9 @@ export default () => {
|
|||||||
dashboard.use('/warehouses', Container.get(WarehousesController).router());
|
dashboard.use('/warehouses', Container.get(WarehousesController).router());
|
||||||
dashboard.use('/projects', Container.get(ProjectsController).router());
|
dashboard.use('/projects', Container.get(ProjectsController).router());
|
||||||
dashboard.use('/tax-rates', Container.get(TaxRatesController).router());
|
dashboard.use('/tax-rates', Container.get(TaxRatesController).router());
|
||||||
|
|
||||||
|
dashboard.use('/import', Container.get(ImportController).router());
|
||||||
|
|
||||||
dashboard.use('/', Container.get(ProjectTasksController).router());
|
dashboard.use('/', Container.get(ProjectTasksController).router());
|
||||||
dashboard.use('/', Container.get(ProjectTimesController).router());
|
dashboard.use('/', Container.get(ProjectTimesController).router());
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema.createTable('imports', (table) => {
|
||||||
|
table.increments();
|
||||||
|
table.string('filename');
|
||||||
|
table.string('import_id');
|
||||||
|
table.string('resource');
|
||||||
|
table.json('columns');
|
||||||
|
table.json('mapping');
|
||||||
|
table.timestamps();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema.dropTableIfExists('imports');
|
||||||
|
};
|
||||||
@@ -34,6 +34,7 @@ export interface IModelMetaFieldCommon {
|
|||||||
columnable?: boolean;
|
columnable?: boolean;
|
||||||
fieldType: IModelColumnType;
|
fieldType: IModelColumnType;
|
||||||
customQuery?: Function;
|
customQuery?: Function;
|
||||||
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IModelMetaFieldNumber {
|
export interface IModelMetaFieldNumber {
|
||||||
@@ -77,5 +78,6 @@ export type IModelMetaRelationField = IModelMetaRelationFieldCommon & (
|
|||||||
export interface IModelMeta {
|
export interface IModelMeta {
|
||||||
defaultFilterField: string;
|
defaultFilterField: string;
|
||||||
defaultSort: IModelMetaDefaultSort;
|
defaultSort: IModelMetaDefaultSort;
|
||||||
|
importable?: boolean;
|
||||||
fields: { [key: string]: IModelMetaField };
|
fields: { [key: string]: IModelMetaField };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import helmet from 'helmet';
|
|||||||
import boom from 'express-boom';
|
import boom from 'express-boom';
|
||||||
import errorHandler from 'errorhandler';
|
import errorHandler from 'errorhandler';
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import fileUpload from 'express-fileupload';
|
|
||||||
import { Server } from 'socket.io';
|
import { Server } from 'socket.io';
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
import routes from 'api';
|
import routes from 'api';
|
||||||
@@ -47,13 +46,6 @@ export default ({ app }) => {
|
|||||||
|
|
||||||
app.use('/public', express.static(path.join(global.__storage_dir)));
|
app.use('/public', express.static(path.join(global.__storage_dir)));
|
||||||
|
|
||||||
// Handle multi-media requests.
|
|
||||||
app.use(
|
|
||||||
fileUpload({
|
|
||||||
createParentPath: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Logger middleware.
|
// Logger middleware.
|
||||||
app.use(LoggerMiddleware);
|
app.use(LoggerMiddleware);
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import Task from 'models/Task';
|
|||||||
import TaxRate from 'models/TaxRate';
|
import TaxRate from 'models/TaxRate';
|
||||||
import TaxRateTransaction from 'models/TaxRateTransaction';
|
import TaxRateTransaction from 'models/TaxRateTransaction';
|
||||||
import Attachment from 'models/Attachment';
|
import Attachment from 'models/Attachment';
|
||||||
|
import Import from 'models/Import';
|
||||||
import PlaidItem from 'models/PlaidItem';
|
import PlaidItem from 'models/PlaidItem';
|
||||||
import UncategorizedCashflowTransaction from 'models/UncategorizedCashflowTransaction';
|
import UncategorizedCashflowTransaction from 'models/UncategorizedCashflowTransaction';
|
||||||
|
|
||||||
@@ -127,6 +128,7 @@ export default (knex) => {
|
|||||||
TaxRate,
|
TaxRate,
|
||||||
TaxRateTransaction,
|
TaxRateTransaction,
|
||||||
Attachment,
|
Attachment,
|
||||||
|
Import,
|
||||||
PlaidItem,
|
PlaidItem,
|
||||||
UncategorizedCashflowTransaction
|
UncategorizedCashflowTransaction
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,16 +6,21 @@ export default {
|
|||||||
sortOrder: 'DESC',
|
sortOrder: 'DESC',
|
||||||
sortField: 'name',
|
sortField: 'name',
|
||||||
},
|
},
|
||||||
|
importable: true,
|
||||||
fields: {
|
fields: {
|
||||||
name: {
|
name: {
|
||||||
name: 'account.field.name',
|
name: 'account.field.name',
|
||||||
column: 'name',
|
column: 'name',
|
||||||
fieldType: 'text',
|
fieldType: 'text',
|
||||||
|
unique: true,
|
||||||
|
required: true,
|
||||||
|
importable: true,
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
name: 'account.field.description',
|
name: 'account.field.description',
|
||||||
column: 'description',
|
column: 'description',
|
||||||
fieldType: 'text',
|
fieldType: 'text',
|
||||||
|
importable: true,
|
||||||
},
|
},
|
||||||
slug: {
|
slug: {
|
||||||
name: 'account.field.slug',
|
name: 'account.field.slug',
|
||||||
@@ -23,13 +28,17 @@ export default {
|
|||||||
fieldType: 'text',
|
fieldType: 'text',
|
||||||
columnable: false,
|
columnable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
importable: false,
|
||||||
},
|
},
|
||||||
code: {
|
code: {
|
||||||
name: 'account.field.code',
|
name: 'account.field.code',
|
||||||
column: 'code',
|
column: 'code',
|
||||||
fieldType: 'text',
|
fieldType: 'text',
|
||||||
|
importable: true,
|
||||||
|
minLength: 3,
|
||||||
|
maxLength: 6,
|
||||||
},
|
},
|
||||||
root_type: {
|
rootType: {
|
||||||
name: 'account.field.root_type',
|
name: 'account.field.root_type',
|
||||||
fieldType: 'enumeration',
|
fieldType: 'enumeration',
|
||||||
options: [
|
options: [
|
||||||
@@ -41,6 +50,7 @@ export default {
|
|||||||
],
|
],
|
||||||
filterCustomQuery: RootTypeFieldFilterQuery,
|
filterCustomQuery: RootTypeFieldFilterQuery,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
|
importable: false,
|
||||||
},
|
},
|
||||||
normal: {
|
normal: {
|
||||||
name: 'account.field.normal',
|
name: 'account.field.normal',
|
||||||
@@ -51,37 +61,50 @@ export default {
|
|||||||
],
|
],
|
||||||
filterCustomQuery: NormalTypeFieldFilterQuery,
|
filterCustomQuery: NormalTypeFieldFilterQuery,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
|
importable: false,
|
||||||
},
|
},
|
||||||
type: {
|
accountType: {
|
||||||
name: 'account.field.type',
|
name: 'account.field.type',
|
||||||
column: 'account_type',
|
column: 'account_type',
|
||||||
fieldType: 'enumeration',
|
fieldType: 'enumeration',
|
||||||
options: ACCOUNT_TYPES.map((accountType) => ({
|
options: ACCOUNT_TYPES.map((accountType) => ({
|
||||||
label: accountType.label,
|
label: accountType.label,
|
||||||
key: accountType.key
|
key: accountType.key,
|
||||||
})),
|
})),
|
||||||
|
required: true,
|
||||||
|
importable: true,
|
||||||
},
|
},
|
||||||
active: {
|
active: {
|
||||||
name: 'account.field.active',
|
name: 'account.field.active',
|
||||||
column: 'active',
|
column: 'active',
|
||||||
fieldType: 'boolean',
|
fieldType: 'boolean',
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
importable: true,
|
||||||
},
|
},
|
||||||
balance: {
|
openingBalance: {
|
||||||
name: 'account.field.balance',
|
name: 'account.field.balance',
|
||||||
column: 'amount',
|
column: 'amount',
|
||||||
fieldType: 'number',
|
fieldType: 'number',
|
||||||
|
importable: true,
|
||||||
},
|
},
|
||||||
currency: {
|
currencyCode: {
|
||||||
name: 'account.field.currency',
|
name: 'account.field.currency',
|
||||||
column: 'currency_code',
|
column: 'currency_code',
|
||||||
fieldType: 'text',
|
fieldType: 'text',
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
importable: true,
|
||||||
},
|
},
|
||||||
created_at: {
|
parentAccount: {
|
||||||
|
name: 'account.field.parent_account',
|
||||||
|
column: 'parent_account_id',
|
||||||
|
fieldType: 'relation',
|
||||||
|
to: { model: 'Account', to: 'id' },
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
name: 'account.field.created_at',
|
name: 'account.field.created_at',
|
||||||
column: 'created_at',
|
column: 'created_at',
|
||||||
fieldType: 'date',
|
fieldType: 'date',
|
||||||
|
importable: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
59
packages/server/src/models/Import.ts
Normal file
59
packages/server/src/models/Import.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import TenantModel from 'models/TenantModel';
|
||||||
|
|
||||||
|
export default class Import extends TenantModel {
|
||||||
|
mapping!: string;
|
||||||
|
columns!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table name.
|
||||||
|
*/
|
||||||
|
static get tableName() {
|
||||||
|
return 'imports';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtual attributes.
|
||||||
|
*/
|
||||||
|
static get virtualAttributes() {
|
||||||
|
return ['mappingParsed'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamps columns.
|
||||||
|
*/
|
||||||
|
get timestamps() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relationship mapping.
|
||||||
|
*/
|
||||||
|
static get relationMappings() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detarmines whether the import is mapped.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
public get isMapped() {
|
||||||
|
return Boolean(this.mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get columnsParsed() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(this.columns);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public get mappingParsed() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(this.mapping);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { ActivateAccount } from './ActivateAccount';
|
|||||||
import { GetAccounts } from './GetAccounts';
|
import { GetAccounts } from './GetAccounts';
|
||||||
import { GetAccount } from './GetAccount';
|
import { GetAccount } from './GetAccount';
|
||||||
import { GetAccountTransactions } from './GetAccountTransactions';
|
import { GetAccountTransactions } from './GetAccountTransactions';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class AccountsApplication {
|
export class AccountsApplication {
|
||||||
@@ -48,9 +49,10 @@ export class AccountsApplication {
|
|||||||
*/
|
*/
|
||||||
public createAccount = (
|
public createAccount = (
|
||||||
tenantId: number,
|
tenantId: number,
|
||||||
accountDTO: IAccountCreateDTO
|
accountDTO: IAccountCreateDTO,
|
||||||
|
trx?: Knex.Transaction
|
||||||
): Promise<IAccount> => {
|
): Promise<IAccount> => {
|
||||||
return this.createAccountService.createAccount(tenantId, accountDTO);
|
return this.createAccountService.createAccount(tenantId, accountDTO, trx);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
37
packages/server/src/services/Accounts/AccountsImportable.ts
Normal file
37
packages/server/src/services/Accounts/AccountsImportable.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Inject, Service } from 'typedi';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { IAccountCreateDTO } from '@/interfaces';
|
||||||
|
import { CreateAccount } from './CreateAccount';
|
||||||
|
import { Importable } from '../Import/Importable';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class AccountsImportable extends Importable {
|
||||||
|
@Inject()
|
||||||
|
private createAccountService: CreateAccount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importing to account service.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {IAccountCreateDTO} createAccountDTO
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public importable(
|
||||||
|
tenantId: number,
|
||||||
|
createAccountDTO: IAccountCreateDTO,
|
||||||
|
trx?: Knex.Transaction
|
||||||
|
) {
|
||||||
|
return this.createAccountService.createAccount(
|
||||||
|
tenantId,
|
||||||
|
createAccountDTO,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concurrrency controlling of the importing process.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
public get concurrency() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,13 +97,14 @@ export class CreateAccount {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new account on the storage.
|
* Creates a new account on the storage.
|
||||||
* @param {number} tenantId
|
* @param {number} tenantId
|
||||||
* @param {IAccountCreateDTO} accountDTO
|
* @param {IAccountCreateDTO} accountDTO
|
||||||
* @returns {Promise<IAccount>}
|
* @returns {Promise<IAccount>}
|
||||||
*/
|
*/
|
||||||
public createAccount = async (
|
public createAccount = async (
|
||||||
tenantId: number,
|
tenantId: number,
|
||||||
accountDTO: IAccountCreateDTO
|
accountDTO: IAccountCreateDTO,
|
||||||
|
trx?: Knex.Transaction
|
||||||
): Promise<IAccount> => {
|
): Promise<IAccount> => {
|
||||||
const { Account } = this.tenancy.models(tenantId);
|
const { Account } = this.tenancy.models(tenantId);
|
||||||
|
|
||||||
@@ -119,27 +120,31 @@ export class CreateAccount {
|
|||||||
tenantMeta.baseCurrency
|
tenantMeta.baseCurrency
|
||||||
);
|
);
|
||||||
// Creates a new account with associated transactions under unit-of-work envirement.
|
// Creates a new account with associated transactions under unit-of-work envirement.
|
||||||
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
|
return this.uow.withTransaction(
|
||||||
// Triggers `onAccountCreating` event.
|
tenantId,
|
||||||
await this.eventPublisher.emitAsync(events.accounts.onCreating, {
|
async (trx: Knex.Transaction) => {
|
||||||
tenantId,
|
// Triggers `onAccountCreating` event.
|
||||||
accountDTO,
|
await this.eventPublisher.emitAsync(events.accounts.onCreating, {
|
||||||
trx,
|
tenantId,
|
||||||
} as IAccountEventCreatingPayload);
|
accountDTO,
|
||||||
|
trx,
|
||||||
|
} as IAccountEventCreatingPayload);
|
||||||
|
|
||||||
// Inserts account to the storage.
|
// Inserts account to the storage.
|
||||||
const account = await Account.query(trx).insertAndFetch({
|
const account = await Account.query(trx).insertAndFetch({
|
||||||
...accountInputModel,
|
...accountInputModel,
|
||||||
});
|
});
|
||||||
// Triggers `onAccountCreated` event.
|
// Triggers `onAccountCreated` event.
|
||||||
await this.eventPublisher.emitAsync(events.accounts.onCreated, {
|
await this.eventPublisher.emitAsync(events.accounts.onCreated, {
|
||||||
tenantId,
|
tenantId,
|
||||||
account,
|
account,
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
trx,
|
trx,
|
||||||
} as IAccountEventCreatedPayload);
|
} as IAccountEventCreatedPayload);
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
});
|
},
|
||||||
|
trx
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
141
packages/server/src/services/Import/ImportFileCommon.ts
Normal file
141
packages/server/src/services/Import/ImportFileCommon.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
import XLSX from 'xlsx';
|
||||||
|
import bluebird from 'bluebird';
|
||||||
|
import * as R from 'ramda';
|
||||||
|
import { Inject, Service } from 'typedi';
|
||||||
|
import { first } from 'lodash';
|
||||||
|
import { ImportFileDataValidator } from './ImportFileDataValidator';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import {
|
||||||
|
ImportInsertError,
|
||||||
|
ImportOperError,
|
||||||
|
ImportOperSuccess,
|
||||||
|
} from './interfaces';
|
||||||
|
import { AccountsImportable } from '../Accounts/AccountsImportable';
|
||||||
|
import { ServiceError } from '@/exceptions';
|
||||||
|
import { trimObject } from './_utils';
|
||||||
|
import { ImportableResources } from './ImportableResources';
|
||||||
|
import ResourceService from '../Resource/ResourceService';
|
||||||
|
import HasTenancyService from '../Tenancy/TenancyService';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class ImportFileCommon {
|
||||||
|
@Inject()
|
||||||
|
private tenancy: HasTenancyService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private importFileValidator: ImportFileDataValidator;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private importable: ImportableResources;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private resource: ResourceService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the columns of the imported data based on the provided mapping attributes.
|
||||||
|
* @param {Record<string, any>[]} body - The array of data objects to map.
|
||||||
|
* @param {ImportMappingAttr[]} map - The mapping attributes.
|
||||||
|
* @returns {Record<string, any>[]} - The mapped data objects.
|
||||||
|
*/
|
||||||
|
public parseXlsxSheet(buffer: Buffer): Record<string, unknown>[] {
|
||||||
|
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
||||||
|
|
||||||
|
const firstSheetName = workbook.SheetNames[0];
|
||||||
|
const worksheet = workbook.Sheets[firstSheetName];
|
||||||
|
|
||||||
|
return XLSX.utils.sheet_to_json(worksheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the import file.
|
||||||
|
* @param {string} filename
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
*/
|
||||||
|
public readImportFile(filename: string) {
|
||||||
|
return fs.readFile(`public/imports/${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports the given parsed data to the resource storage through registered importable service.
|
||||||
|
* @param {number} tenantId -
|
||||||
|
* @param {string} resourceName - Resource name.
|
||||||
|
* @param {Record<string, any>} parsedData - Parsed data.
|
||||||
|
* @param {Knex.Transaction} trx - Knex transaction.
|
||||||
|
* @returns {Promise<[ImportOperSuccess[], ImportOperError[]]>}
|
||||||
|
*/
|
||||||
|
public async import(
|
||||||
|
tenantId: number,
|
||||||
|
resourceName: string,
|
||||||
|
parsedData: Record<string, any>[],
|
||||||
|
trx?: Knex.Transaction
|
||||||
|
): Promise<[ImportOperSuccess[], ImportOperError[]]> {
|
||||||
|
const importableFields = this.resource.getResourceImportableFields(
|
||||||
|
tenantId,
|
||||||
|
resourceName
|
||||||
|
);
|
||||||
|
const ImportableRegistry = this.importable.registry;
|
||||||
|
const importable = ImportableRegistry.getImportable(resourceName);
|
||||||
|
|
||||||
|
const concurrency = importable.concurrency || 10;
|
||||||
|
|
||||||
|
const success: ImportOperSuccess[] = [];
|
||||||
|
const failed: ImportOperError[] = [];
|
||||||
|
|
||||||
|
const importAsync = async (objectDTO, index: number): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// Validate the DTO object before passing it to the service layer.
|
||||||
|
await this.importFileValidator.validateData(
|
||||||
|
importableFields,
|
||||||
|
objectDTO
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
// Run the importable function and listen to the errors.
|
||||||
|
const data = await importable.importable(tenantId, objectDTO, trx);
|
||||||
|
success.push({ index, data });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceError) {
|
||||||
|
const error = [
|
||||||
|
{
|
||||||
|
errorCode: 'ValidationError',
|
||||||
|
errorMessage: err.message || err.errorType,
|
||||||
|
rowNumber: index + 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
failed.push({ index, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (errors) {
|
||||||
|
const error = errors.map((er) => ({ ...er, rowNumber: index + 1 }));
|
||||||
|
failed.push({ index, error });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await bluebird.map(parsedData, importAsync, { concurrency });
|
||||||
|
|
||||||
|
return [success, failed];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the sheet columns from the given sheet data.
|
||||||
|
* @param {unknown[]} json
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
public parseSheetColumns(json: unknown[]): string[] {
|
||||||
|
return R.compose(Object.keys, trimObject, first)(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the imported file from the storage and database.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {} importFile
|
||||||
|
*/
|
||||||
|
public async deleteImportFile(tenantId: number, importFile: any) {
|
||||||
|
const { Import } = this.tenancy.models(tenantId);
|
||||||
|
|
||||||
|
// Deletes the import row.
|
||||||
|
await Import.query().findById(importFile.id).delete();
|
||||||
|
|
||||||
|
// Deletes the imported file.
|
||||||
|
await fs.unlink(`public/imports/${importFile.filename}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
packages/server/src/services/Import/ImportFileDataTransformer.ts
Normal file
101
packages/server/src/services/Import/ImportFileDataTransformer.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Service } from 'typedi';
|
||||||
|
import * as R from 'ramda';
|
||||||
|
import { isUndefined, get, chain } from 'lodash';
|
||||||
|
import { ImportMappingAttr, ResourceMetaFieldsMap } from './interfaces';
|
||||||
|
import { parseBoolean } from '@/utils';
|
||||||
|
import { trimObject } from './_utils';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class ImportFileDataTransformer {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} tenantId -
|
||||||
|
* @param {}
|
||||||
|
*/
|
||||||
|
public parseSheetData(
|
||||||
|
importFile: any,
|
||||||
|
importableFields: any,
|
||||||
|
data: Record<string, unknown>[]
|
||||||
|
) {
|
||||||
|
// Sanitize the sheet data.
|
||||||
|
const sanitizedData = this.sanitizeSheetData(data);
|
||||||
|
|
||||||
|
// Map the sheet columns key with the given map.
|
||||||
|
const mappedDTOs = this.mapSheetColumns(
|
||||||
|
sanitizedData,
|
||||||
|
importFile.mappingParsed
|
||||||
|
);
|
||||||
|
// Parse the mapped sheet values.
|
||||||
|
const parsedValues = this.parseExcelValues(importableFields, mappedDTOs);
|
||||||
|
|
||||||
|
return parsedValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes the data in the imported sheet by trimming object keys.
|
||||||
|
* @param json - The JSON data representing the imported sheet.
|
||||||
|
* @returns {string[][]} - The sanitized data with trimmed object keys.
|
||||||
|
*/
|
||||||
|
public sanitizeSheetData(json) {
|
||||||
|
return R.compose(R.map(trimObject))(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the columns of the imported data based on the provided mapping attributes.
|
||||||
|
* @param {Record<string, any>[]} body - The array of data objects to map.
|
||||||
|
* @param {ImportMappingAttr[]} map - The mapping attributes.
|
||||||
|
* @returns {Record<string, any>[]} - The mapped data objects.
|
||||||
|
*/
|
||||||
|
public mapSheetColumns(
|
||||||
|
body: Record<string, any>[],
|
||||||
|
map: ImportMappingAttr[]
|
||||||
|
): Record<string, any>[] {
|
||||||
|
return body.map((item) => {
|
||||||
|
const newItem = {};
|
||||||
|
map
|
||||||
|
.filter((mapping) => !isUndefined(item[mapping.from]))
|
||||||
|
.forEach((mapping) => {
|
||||||
|
newItem[mapping.to] = item[mapping.from];
|
||||||
|
});
|
||||||
|
return newItem;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses sheet values before passing to the service layer.
|
||||||
|
* @param {ResourceMetaFieldsMap} fields -
|
||||||
|
* @param {Record<string, any>} valueDTOS -
|
||||||
|
* @returns {Record<string, any>}
|
||||||
|
*/
|
||||||
|
public parseExcelValues(
|
||||||
|
fields: ResourceMetaFieldsMap,
|
||||||
|
valueDTOs: Record<string, any>[]
|
||||||
|
): Record<string, any> {
|
||||||
|
const parser = (value, key) => {
|
||||||
|
let _value = value;
|
||||||
|
|
||||||
|
// Parses the boolean value.
|
||||||
|
if (fields[key].fieldType === 'boolean') {
|
||||||
|
_value = parseBoolean(value, false);
|
||||||
|
|
||||||
|
// Parses the enumeration value.
|
||||||
|
} else if (fields[key].fieldType === 'enumeration') {
|
||||||
|
const field = fields[key];
|
||||||
|
const option = get(field, 'options', []).find(
|
||||||
|
(option) => option.label === value
|
||||||
|
);
|
||||||
|
_value = get(option, 'key');
|
||||||
|
// Prases the numeric value.
|
||||||
|
} else if (fields[key].fieldType === 'number') {
|
||||||
|
_value = parseFloat(value);
|
||||||
|
}
|
||||||
|
return _value;
|
||||||
|
};
|
||||||
|
return valueDTOs.map((DTO) => {
|
||||||
|
return chain(DTO)
|
||||||
|
.pickBy((value, key) => !isUndefined(fields[key]))
|
||||||
|
.mapValues(parser)
|
||||||
|
.value();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Service } from 'typedi';
|
||||||
|
import { ImportInsertError, ResourceMetaFieldsMap } from './interfaces';
|
||||||
|
import { ERRORS, convertFieldsToYupValidation } from './_utils';
|
||||||
|
import { IModelMeta } from '@/interfaces';
|
||||||
|
import { ServiceError } from '@/exceptions';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class ImportFileDataValidator {
|
||||||
|
/**
|
||||||
|
* Validates the given resource is importable.
|
||||||
|
* @param {IModelMeta} resourceMeta
|
||||||
|
*/
|
||||||
|
public validateResourceImportable(resourceMeta: IModelMeta) {
|
||||||
|
// Throw service error if the resource does not support importing.
|
||||||
|
if (!resourceMeta.importable) {
|
||||||
|
throw new ServiceError(ERRORS.RESOURCE_NOT_IMPORTABLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the given mapped DTOs and returns errors with their index.
|
||||||
|
* @param {Record<string, any>} mappedDTOs
|
||||||
|
* @returns {Promise<void | ImportInsertError[]>}
|
||||||
|
*/
|
||||||
|
public async validateData(
|
||||||
|
importableFields: ResourceMetaFieldsMap,
|
||||||
|
data: Record<string, any>
|
||||||
|
): Promise<void | ImportInsertError[]> {
|
||||||
|
const YupSchema = convertFieldsToYupValidation(importableFields);
|
||||||
|
const _data = { ...data };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await YupSchema.validate(_data, { abortEarly: false });
|
||||||
|
} catch (validationError) {
|
||||||
|
const errors = validationError.inner.map((error) => ({
|
||||||
|
errorCode: 'ValidationError',
|
||||||
|
errorMessage: error.errors,
|
||||||
|
}));
|
||||||
|
throw errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
packages/server/src/services/Import/ImportFileMapping.ts
Normal file
109
packages/server/src/services/Import/ImportFileMapping.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { fromPairs } from 'lodash';
|
||||||
|
import { Inject, Service } from 'typedi';
|
||||||
|
import HasTenancyService from '../Tenancy/TenancyService';
|
||||||
|
import { ImportFileMapPOJO, ImportMappingAttr } from './interfaces';
|
||||||
|
import ResourceService from '../Resource/ResourceService';
|
||||||
|
import { ServiceError } from '@/exceptions';
|
||||||
|
import { ERRORS } from './_utils';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class ImportFileMapping {
|
||||||
|
@Inject()
|
||||||
|
private tenancy: HasTenancyService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private resource: ResourceService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping the excel sheet columns with resource columns.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} importId
|
||||||
|
* @param {ImportMappingAttr} maps
|
||||||
|
*/
|
||||||
|
public async mapping(
|
||||||
|
tenantId: number,
|
||||||
|
importId: number,
|
||||||
|
maps: ImportMappingAttr[]
|
||||||
|
): Promise<ImportFileMapPOJO> {
|
||||||
|
const { Import } = this.tenancy.models(tenantId);
|
||||||
|
|
||||||
|
const importFile = await Import.query()
|
||||||
|
.findOne('filename', importId)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
// Invalidate the from/to map attributes.
|
||||||
|
this.validateMapsAttrs(tenantId, importFile, maps);
|
||||||
|
|
||||||
|
// Validate the diplicated relations of map attrs.
|
||||||
|
this.validateDuplicatedMapAttrs(maps);
|
||||||
|
|
||||||
|
const mappingStringified = JSON.stringify(maps);
|
||||||
|
|
||||||
|
await Import.query().findById(importFile.id).patch({
|
||||||
|
mapping: mappingStringified,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
import: {
|
||||||
|
importId: importFile.importId,
|
||||||
|
resource: importFile.resource,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the mapping attributes.
|
||||||
|
* @param {number} tenantId -
|
||||||
|
* @param {} importFile -
|
||||||
|
* @param {ImportMappingAttr[]} maps
|
||||||
|
* @throws {ServiceError(ERRORS.INVALID_MAP_ATTRS)}
|
||||||
|
*/
|
||||||
|
private validateMapsAttrs(
|
||||||
|
tenantId: number,
|
||||||
|
importFile: any,
|
||||||
|
maps: ImportMappingAttr[]
|
||||||
|
) {
|
||||||
|
const fields = this.resource.getResourceImportableFields(
|
||||||
|
tenantId,
|
||||||
|
importFile.resource
|
||||||
|
);
|
||||||
|
const columnsMap = fromPairs(
|
||||||
|
importFile.columnsParsed.map((field) => [field, ''])
|
||||||
|
);
|
||||||
|
const invalid = [];
|
||||||
|
|
||||||
|
maps.forEach((map) => {
|
||||||
|
if (
|
||||||
|
'undefined' === typeof fields[map.to] ||
|
||||||
|
'undefined' === typeof columnsMap[map.from]
|
||||||
|
) {
|
||||||
|
invalid.push(map);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (invalid.length > 0) {
|
||||||
|
throw new ServiceError(ERRORS.INVALID_MAP_ATTRS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the map attrs relation should be one-to-one relation only.
|
||||||
|
* @param {ImportMappingAttr[]} maps
|
||||||
|
*/
|
||||||
|
private validateDuplicatedMapAttrs(maps: ImportMappingAttr[]) {
|
||||||
|
const fromMap = {};
|
||||||
|
const toMap = {};
|
||||||
|
|
||||||
|
maps.forEach((map) => {
|
||||||
|
if (fromMap[map.from]) {
|
||||||
|
throw new ServiceError(ERRORS.DUPLICATED_FROM_MAP_ATTR);
|
||||||
|
} else {
|
||||||
|
fromMap[map.from] = true;
|
||||||
|
}
|
||||||
|
if (toMap[map.to]) {
|
||||||
|
throw new ServiceError(ERRORS.DUPLICATED_TO_MAP_ATTR);
|
||||||
|
} else {
|
||||||
|
toMap[map.to] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
34
packages/server/src/services/Import/ImportFilePreview.ts
Normal file
34
packages/server/src/services/Import/ImportFilePreview.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Inject, Service } from 'typedi';
|
||||||
|
import HasTenancyService from '../Tenancy/TenancyService';
|
||||||
|
import { ImportFilePreviewPOJO } from './interfaces';
|
||||||
|
import { ImportFileProcess } from './ImportFileProcess';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class ImportFilePreview {
|
||||||
|
@Inject()
|
||||||
|
private tenancy: HasTenancyService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private importFile: ImportFileProcess;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview the imported file results before commiting the transactions.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} importId
|
||||||
|
* @returns {Promise<ImportFilePreviewPOJO>}
|
||||||
|
*/
|
||||||
|
public async preview(
|
||||||
|
tenantId: number,
|
||||||
|
importId: number
|
||||||
|
): Promise<ImportFilePreviewPOJO> {
|
||||||
|
const knex = this.tenancy.knex(tenantId);
|
||||||
|
const trx = await knex.transaction({ isolationLevel: 'read uncommitted' });
|
||||||
|
|
||||||
|
const meta = await this.importFile.import(tenantId, importId, trx);
|
||||||
|
|
||||||
|
// Rollback the successed transaction.
|
||||||
|
await trx.rollback();
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
packages/server/src/services/Import/ImportFileProcess.ts
Normal file
101
packages/server/src/services/Import/ImportFileProcess.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Inject, Service } from 'typedi';
|
||||||
|
import { chain } from 'lodash';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { ServiceError } from '@/exceptions';
|
||||||
|
import { ERRORS, getSheetColumns, getUnmappedSheetColumns } from './_utils';
|
||||||
|
import HasTenancyService from '../Tenancy/TenancyService';
|
||||||
|
import { ImportFileCommon } from './ImportFileCommon';
|
||||||
|
import { ImportFileDataTransformer } from './ImportFileDataTransformer';
|
||||||
|
import ResourceService from '../Resource/ResourceService';
|
||||||
|
import UnitOfWork from '../UnitOfWork';
|
||||||
|
import { ImportFilePreviewPOJO } from './interfaces';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class ImportFileProcess {
|
||||||
|
@Inject()
|
||||||
|
private tenancy: HasTenancyService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private resource: ResourceService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private importCommon: ImportFileCommon;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private importParser: ImportFileDataTransformer;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private uow: UnitOfWork;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview the imported file results before commiting the transactions.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} importId
|
||||||
|
* @returns {Promise<ImportFilePreviewPOJO>}
|
||||||
|
*/
|
||||||
|
public async import(
|
||||||
|
tenantId: number,
|
||||||
|
importId: number,
|
||||||
|
trx?: Knex.Transaction
|
||||||
|
): Promise<ImportFilePreviewPOJO> {
|
||||||
|
const { Import } = this.tenancy.models(tenantId);
|
||||||
|
|
||||||
|
const importFile = await Import.query()
|
||||||
|
.findOne('importId', importId)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
// Throw error if the import file is not mapped yet.
|
||||||
|
if (!importFile.isMapped) {
|
||||||
|
throw new ServiceError(ERRORS.IMPORT_FILE_NOT_MAPPED);
|
||||||
|
}
|
||||||
|
// Read the imported file.
|
||||||
|
const buffer = await this.importCommon.readImportFile(importFile.filename);
|
||||||
|
const sheetData = this.importCommon.parseXlsxSheet(buffer);
|
||||||
|
const header = getSheetColumns(sheetData);
|
||||||
|
|
||||||
|
const importableFields = this.resource.getResourceImportableFields(
|
||||||
|
tenantId,
|
||||||
|
importFile.resource
|
||||||
|
);
|
||||||
|
// Prases the sheet json data.
|
||||||
|
const parsedData = this.importParser.parseSheetData(
|
||||||
|
importFile,
|
||||||
|
importableFields,
|
||||||
|
sheetData
|
||||||
|
);
|
||||||
|
// Runs the importing operation with ability to return errors that will happen.
|
||||||
|
const [successedImport, failedImport] = await this.uow.withTransaction(
|
||||||
|
tenantId,
|
||||||
|
(trx: Knex.Transaction) =>
|
||||||
|
this.importCommon.import(
|
||||||
|
tenantId,
|
||||||
|
importFile.resource,
|
||||||
|
parsedData,
|
||||||
|
trx
|
||||||
|
),
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
const mapping = importFile.mappingParsed;
|
||||||
|
const errors = chain(failedImport)
|
||||||
|
.map((oper) => oper.error)
|
||||||
|
.flatten()
|
||||||
|
.value();
|
||||||
|
|
||||||
|
const unmappedColumns = getUnmappedSheetColumns(header, mapping);
|
||||||
|
const totalCount = parsedData.length;
|
||||||
|
|
||||||
|
const createdCount = successedImport.length;
|
||||||
|
const errorsCount = failedImport.length;
|
||||||
|
const skippedCount = errorsCount;
|
||||||
|
|
||||||
|
return {
|
||||||
|
createdCount,
|
||||||
|
skippedCount,
|
||||||
|
totalCount,
|
||||||
|
errorsCount,
|
||||||
|
errors,
|
||||||
|
unmappedColumns: unmappedColumns,
|
||||||
|
unmappedColumnsCount: unmappedColumns.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
80
packages/server/src/services/Import/ImportFileUpload.ts
Normal file
80
packages/server/src/services/Import/ImportFileUpload.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Inject, Service } from 'typedi';
|
||||||
|
import HasTenancyService from '../Tenancy/TenancyService';
|
||||||
|
import { sanitizeResourceName } from './_utils';
|
||||||
|
import ResourceService from '../Resource/ResourceService';
|
||||||
|
import { IModelMetaField } from '@/interfaces';
|
||||||
|
import { ImportFileCommon } from './ImportFileCommon';
|
||||||
|
import { ImportFileDataValidator } from './ImportFileDataValidator';
|
||||||
|
import { ImportFileUploadPOJO } from './interfaces';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class ImportFileUploadService {
|
||||||
|
@Inject()
|
||||||
|
private tenancy: HasTenancyService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private resourceService: ResourceService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private importFileCommon: ImportFileCommon;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private importValidator: ImportFileDataValidator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the imported file and stores the import file meta under unqiue id.
|
||||||
|
* @param {number} tenantId - Tenant id.
|
||||||
|
* @param {string} resource - Resource name.
|
||||||
|
* @param {string} filePath - File path.
|
||||||
|
* @param {string} fileName - File name.
|
||||||
|
* @returns {Promise<ImportFileUploadPOJO>}
|
||||||
|
*/
|
||||||
|
public async import(
|
||||||
|
tenantId: number,
|
||||||
|
resourceName: string,
|
||||||
|
filename: string
|
||||||
|
): Promise<ImportFileUploadPOJO> {
|
||||||
|
const { Import } = this.tenancy.models(tenantId);
|
||||||
|
|
||||||
|
const resourceMeta = this.resourceService.getResourceMeta(
|
||||||
|
tenantId,
|
||||||
|
resourceName
|
||||||
|
);
|
||||||
|
// Throw service error if the resource does not support importing.
|
||||||
|
this.importValidator.validateResourceImportable(resourceMeta);
|
||||||
|
|
||||||
|
// Reads the imported file into buffer.
|
||||||
|
const buffer = await this.importFileCommon.readImportFile(filename);
|
||||||
|
|
||||||
|
// Parse the buffer file to array data.
|
||||||
|
const sheetData = this.importFileCommon.parseXlsxSheet(buffer);
|
||||||
|
|
||||||
|
const sheetColumns = this.importFileCommon.parseSheetColumns(sheetData);
|
||||||
|
const coumnsStringified = JSON.stringify(sheetColumns);
|
||||||
|
|
||||||
|
const _resourceName = sanitizeResourceName(resourceName);
|
||||||
|
|
||||||
|
// Store the import model with related metadata.
|
||||||
|
const importFile = await Import.query().insert({
|
||||||
|
filename,
|
||||||
|
importId: filename,
|
||||||
|
resource: _resourceName,
|
||||||
|
columns: coumnsStringified,
|
||||||
|
});
|
||||||
|
const resourceColumns = this.resourceService.getResourceImportableFields(
|
||||||
|
tenantId,
|
||||||
|
_resourceName
|
||||||
|
);
|
||||||
|
const resourceColumnsTransformeed = Object.entries(resourceColumns).map(
|
||||||
|
([key, { name }]: [string, IModelMetaField]) => ({ key, name })
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
import: {
|
||||||
|
importId: importFile.importId,
|
||||||
|
resource: importFile.resource,
|
||||||
|
},
|
||||||
|
sheetColumns,
|
||||||
|
resourceColumns: resourceColumnsTransformeed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Inject } from 'typedi';
|
||||||
|
import { ImportFileUploadService } from './ImportFileUpload';
|
||||||
|
import { ImportFileMapping } from './ImportFileMapping';
|
||||||
|
import { ImportMappingAttr } from './interfaces';
|
||||||
|
import { ImportFileProcess } from './ImportFileProcess';
|
||||||
|
import { ImportFilePreview } from './ImportFilePreview';
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
export class ImportResourceApplication {
|
||||||
|
@Inject()
|
||||||
|
private importFileService: ImportFileUploadService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private importMappingService: ImportFileMapping;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private importProcessService: ImportFileProcess;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private ImportFilePreviewService: ImportFilePreview;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the imported file and stores the import file meta under unqiue id.
|
||||||
|
* @param {number} tenantId -
|
||||||
|
* @param {string} resource -
|
||||||
|
* @param {string} fileName -
|
||||||
|
* @returns {Promise<ImportFileUploadPOJO>}
|
||||||
|
*/
|
||||||
|
public async import(
|
||||||
|
tenantId: number,
|
||||||
|
resource: string,
|
||||||
|
filename: string
|
||||||
|
) {
|
||||||
|
return this.importFileService.import(tenantId, resource, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping the excel sheet columns with resource columns.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} importId
|
||||||
|
* @param {ImportMappingAttr} maps
|
||||||
|
*/
|
||||||
|
public async mapping(
|
||||||
|
tenantId: number,
|
||||||
|
importId: number,
|
||||||
|
maps: ImportMappingAttr[]
|
||||||
|
) {
|
||||||
|
return this.importMappingService.mapping(tenantId, importId, maps);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview the mapped results before process importing.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} importId
|
||||||
|
* @returns {Promise<ImportFilePreviewPOJO>}
|
||||||
|
*/
|
||||||
|
public async preview(tenantId: number, importId: number) {
|
||||||
|
return this.ImportFilePreviewService.preview(tenantId, importId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the import file sheet through service for creating entities.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} importId
|
||||||
|
* @returns {Promise<ImportFilePreviewPOJO>}
|
||||||
|
*/
|
||||||
|
public async process(tenantId: number, importId: number) {
|
||||||
|
return this.importProcessService.import(tenantId, importId);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
packages/server/src/services/Import/Importable.ts
Normal file
23
packages/server/src/services/Import/Importable.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
|
export abstract class Importable {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {any} createDTO
|
||||||
|
* @param {Knex.Transaction} trx
|
||||||
|
*/
|
||||||
|
public importable(tenantId: number, createDTO: any, trx?: Knex.Transaction) {
|
||||||
|
throw new Error(
|
||||||
|
'The `importable` function is not defined in service importable.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concurrency controlling of the importing process.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
public get concurrency() {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
packages/server/src/services/Import/ImportableRegistry.ts
Normal file
46
packages/server/src/services/Import/ImportableRegistry.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { camelCase, upperFirst } from 'lodash';
|
||||||
|
import { Importable } from './Importable';
|
||||||
|
|
||||||
|
export class ImportableRegistry {
|
||||||
|
private static instance: ImportableRegistry;
|
||||||
|
private importables: Record<string, Importable>;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.importables = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets singleton instance of registry.
|
||||||
|
* @returns {ImportableRegistry}
|
||||||
|
*/
|
||||||
|
public static getInstance(): ImportableRegistry {
|
||||||
|
if (!ImportableRegistry.instance) {
|
||||||
|
ImportableRegistry.instance = new ImportableRegistry();
|
||||||
|
}
|
||||||
|
return ImportableRegistry.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the given importable service.
|
||||||
|
* @param {string} resource
|
||||||
|
* @param {Importable} importable
|
||||||
|
*/
|
||||||
|
public registerImportable(resource: string, importable: Importable): void {
|
||||||
|
const _resource = this.sanitizeResourceName(resource);
|
||||||
|
this.importables[_resource] = importable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the importable service instance of the given resource name.
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {Importable}
|
||||||
|
*/
|
||||||
|
public getImportable(name: string): Importable {
|
||||||
|
const _name = this.sanitizeResourceName(name);
|
||||||
|
return this.importables[_name];
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeResourceName(resource: string) {
|
||||||
|
return upperFirst(camelCase(resource));
|
||||||
|
}
|
||||||
|
}
|
||||||
38
packages/server/src/services/Import/ImportableResources.ts
Normal file
38
packages/server/src/services/Import/ImportableResources.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Container, { Service } from 'typedi';
|
||||||
|
import { AccountsImportable } from '../Accounts/AccountsImportable';
|
||||||
|
import { ImportableRegistry } from './ImportableRegistry';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class ImportableResources {
|
||||||
|
private static registry: ImportableRegistry;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.boot();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importable instances.
|
||||||
|
*/
|
||||||
|
private importables = [
|
||||||
|
{ resource: 'Account', importable: AccountsImportable },
|
||||||
|
];
|
||||||
|
|
||||||
|
public get registry() {
|
||||||
|
return ImportableResources.registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boots all the registered importables.
|
||||||
|
*/
|
||||||
|
public boot() {
|
||||||
|
if (!ImportableResources.registry) {
|
||||||
|
const instance = ImportableRegistry.getInstance();
|
||||||
|
|
||||||
|
this.importables.forEach((importable) => {
|
||||||
|
const importableInstance = Container.get(importable.importable);
|
||||||
|
instance.registerImportable(importable.resource, importableInstance);
|
||||||
|
});
|
||||||
|
ImportableResources.registry = instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
packages/server/src/services/Import/_utils.ts
Normal file
79
packages/server/src/services/Import/_utils.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import * as Yup from 'yup';
|
||||||
|
import { upperFirst, camelCase, first } from 'lodash';
|
||||||
|
import pluralize from 'pluralize';
|
||||||
|
import { ResourceMetaFieldsMap } from './interfaces';
|
||||||
|
import { IModelMetaField } from '@/interfaces';
|
||||||
|
|
||||||
|
export function trimObject(obj) {
|
||||||
|
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||||
|
// Trim the key
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
|
||||||
|
// Trim the value if it's a string, otherwise leave it as is
|
||||||
|
const trimmedValue = typeof value === 'string' ? value.trim() : value;
|
||||||
|
|
||||||
|
// Assign the trimmed key and value to the accumulator object
|
||||||
|
return { ...acc, [trimmedKey]: trimmedValue };
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => {
|
||||||
|
const yupSchema = {};
|
||||||
|
Object.keys(fields).forEach((fieldName: string) => {
|
||||||
|
const field = fields[fieldName] as IModelMetaField;
|
||||||
|
let fieldSchema;
|
||||||
|
fieldSchema = Yup.string().label(field.name);
|
||||||
|
|
||||||
|
if (field.fieldType === 'text') {
|
||||||
|
if (field.minLength) {
|
||||||
|
fieldSchema = fieldSchema.min(
|
||||||
|
field.minLength,
|
||||||
|
`Minimum length is ${field.minLength} characters`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (field.maxLength) {
|
||||||
|
fieldSchema = fieldSchema.max(
|
||||||
|
field.maxLength,
|
||||||
|
`Maximum length is ${field.maxLength} characters`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (field.fieldType === 'number') {
|
||||||
|
fieldSchema = Yup.number().label(field.name);
|
||||||
|
} else if (field.fieldType === 'boolean') {
|
||||||
|
fieldSchema = Yup.boolean().label(field.name);
|
||||||
|
} else if (field.fieldType === 'enumeration') {
|
||||||
|
const options = field.options.reduce((acc, option) => {
|
||||||
|
acc[option.key] = option.label;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
fieldSchema = Yup.string().oneOf(Object.keys(options)).label(field.name);
|
||||||
|
}
|
||||||
|
if (field.required) {
|
||||||
|
fieldSchema = fieldSchema.required();
|
||||||
|
}
|
||||||
|
yupSchema[fieldName] = fieldSchema;
|
||||||
|
});
|
||||||
|
return Yup.object().shape(yupSchema);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ERRORS = {
|
||||||
|
RESOURCE_NOT_IMPORTABLE: 'RESOURCE_NOT_IMPORTABLE',
|
||||||
|
INVALID_MAP_ATTRS: 'INVALID_MAP_ATTRS',
|
||||||
|
DUPLICATED_FROM_MAP_ATTR: 'DUPLICATED_FROM_MAP_ATTR',
|
||||||
|
DUPLICATED_TO_MAP_ATTR: 'DUPLICATED_TO_MAP_ATTR',
|
||||||
|
IMPORT_FILE_NOT_MAPPED: 'IMPORT_FILE_NOT_MAPPED',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUnmappedSheetColumns = (columns, mapping) => {
|
||||||
|
return columns.filter(
|
||||||
|
(column) => !mapping.some((map) => map.from === column)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sanitizeResourceName = (resourceName: string) => {
|
||||||
|
return upperFirst(camelCase(pluralize.singular(resourceName)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSheetColumns = (sheetData: unknown[]) => {
|
||||||
|
return Object.keys(first(sheetData));
|
||||||
|
};
|
||||||
57
packages/server/src/services/Import/interfaces.ts
Normal file
57
packages/server/src/services/Import/interfaces.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { IModelMetaField } from '@/interfaces';
|
||||||
|
|
||||||
|
export interface ImportMappingAttr {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportValidationError {
|
||||||
|
index: number;
|
||||||
|
property: string;
|
||||||
|
constraints: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResourceMetaFieldsMap = { [key: string]: IModelMetaField };
|
||||||
|
|
||||||
|
export interface ImportInsertError {
|
||||||
|
rowNumber: number;
|
||||||
|
errorCode: string;
|
||||||
|
errorMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportFileUploadPOJO {
|
||||||
|
import: {
|
||||||
|
importId: string;
|
||||||
|
resource: string;
|
||||||
|
};
|
||||||
|
sheetColumns: string[];
|
||||||
|
resourceColumns: { key: string; name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportFileMapPOJO {
|
||||||
|
import: {
|
||||||
|
importId: string;
|
||||||
|
resource: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportFilePreviewPOJO {
|
||||||
|
createdCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
errorsCount: number;
|
||||||
|
errors: ImportInsertError[];
|
||||||
|
unmappedColumns: string[];
|
||||||
|
unmappedColumnsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface ImportOperSuccess {
|
||||||
|
data: unknown;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportOperError {
|
||||||
|
error: ImportInsertError;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
@@ -21,25 +21,19 @@ import { ERRORS } from './constants';
|
|||||||
@Service()
|
@Service()
|
||||||
export default class OrganizationService {
|
export default class OrganizationService {
|
||||||
@Inject()
|
@Inject()
|
||||||
eventPublisher: EventPublisher;
|
private eventPublisher: EventPublisher;
|
||||||
|
|
||||||
@Inject('logger')
|
|
||||||
logger: any;
|
|
||||||
|
|
||||||
@Inject('repositories')
|
|
||||||
sysRepositories: any;
|
|
||||||
|
|
||||||
@Inject()
|
@Inject()
|
||||||
tenantsManager: TenantsManager;
|
private tenantsManager: TenantsManager;
|
||||||
|
|
||||||
@Inject('agenda')
|
@Inject('agenda')
|
||||||
agenda: any;
|
private agenda: any;
|
||||||
|
|
||||||
@Inject()
|
@Inject()
|
||||||
baseCurrencyMutateLocking: OrganizationBaseCurrencyLocking;
|
private baseCurrencyMutateLocking: OrganizationBaseCurrencyLocking;
|
||||||
|
|
||||||
@Inject()
|
@Inject()
|
||||||
tenancy: HasTenancyService;
|
private tenancy: HasTenancyService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the database schema and seed data of the given organization id.
|
* Builds the database schema and seed data of the given organization id.
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Service, Inject } from 'typedi';
|
import { Service, Inject } from 'typedi';
|
||||||
import { camelCase, upperFirst } from 'lodash';
|
import { camelCase, upperFirst, pickBy } from 'lodash';
|
||||||
import * as qim from 'qim';
|
import * as qim from 'qim';
|
||||||
import pluralize from 'pluralize';
|
import pluralize from 'pluralize';
|
||||||
import { IModelMeta } from '@/interfaces';
|
import { IModelMeta, IModelMetaField } from '@/interfaces';
|
||||||
import TenancyService from '@/services/Tenancy/TenancyService';
|
import TenancyService from '@/services/Tenancy/TenancyService';
|
||||||
import { ServiceError } from '@/exceptions';
|
import { ServiceError } from '@/exceptions';
|
||||||
import I18nService from '@/services/I18n/I18nService';
|
import I18nService from '@/services/I18n/I18nService';
|
||||||
import { tenantKnexConfig } from 'config/knexConfig';
|
|
||||||
|
|
||||||
const ERRORS = {
|
const ERRORS = {
|
||||||
RESOURCE_MODEL_NOT_FOUND: 'RESOURCE_MODEL_NOT_FOUND',
|
RESOURCE_MODEL_NOT_FOUND: 'RESOURCE_MODEL_NOT_FOUND',
|
||||||
@@ -24,7 +23,7 @@ export default class ResourceService {
|
|||||||
* Transform resource to model name.
|
* Transform resource to model name.
|
||||||
* @param {string} resourceName
|
* @param {string} resourceName
|
||||||
*/
|
*/
|
||||||
private resourceToModelName(resourceName: string): string {
|
public resourceToModelName(resourceName: string): string {
|
||||||
return upperFirst(camelCase(pluralize.singular(resourceName)));
|
return upperFirst(camelCase(pluralize.singular(resourceName)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +62,33 @@ export default class ResourceService {
|
|||||||
return this.getResourceMetaLocalized(resourceMeta, tenantId);
|
return this.getResourceMetaLocalized(resourceMeta, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public getResourceFields(
|
||||||
|
tenantId: number,
|
||||||
|
modelName: string
|
||||||
|
): { [key: string]: IModelMetaField } {
|
||||||
|
const meta = this.getResourceMeta(tenantId, modelName);
|
||||||
|
|
||||||
|
return meta.fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {string} modelName
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public getResourceImportableFields(
|
||||||
|
tenantId: number,
|
||||||
|
modelName: string
|
||||||
|
): { [key: string]: IModelMetaField } {
|
||||||
|
const fields = this.getResourceFields(tenantId, modelName);
|
||||||
|
|
||||||
|
return pickBy(fields, (field) => field.importable);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve the resource meta localized based on the current user language.
|
* Retrieve the resource meta localized based on the current user language.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Service, Inject } from 'typedi';
|
import { Service, Inject } from 'typedi';
|
||||||
import TenancyService from '@/services/Tenancy/TenancyService';
|
import TenancyService from '@/services/Tenancy/TenancyService';
|
||||||
|
import { Transaction } from 'objection';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enumeration that represents transaction isolation levels for use with the {@link Transactional} annotation
|
* Enumeration that represents transaction isolation levels for use with the {@link Transactional} annotation
|
||||||
@@ -38,18 +39,26 @@ export default class UnitOfWork {
|
|||||||
public withTransaction = async (
|
public withTransaction = async (
|
||||||
tenantId: number,
|
tenantId: number,
|
||||||
work,
|
work,
|
||||||
|
trx?: Transaction,
|
||||||
isolationLevel: IsolationLevel = IsolationLevel.READ_UNCOMMITTED
|
isolationLevel: IsolationLevel = IsolationLevel.READ_UNCOMMITTED
|
||||||
) => {
|
) => {
|
||||||
const knex = this.tenancy.knex(tenantId);
|
const knex = this.tenancy.knex(tenantId);
|
||||||
const trx = await knex.transaction({ isolationLevel });
|
let _trx = trx;
|
||||||
|
|
||||||
|
if (!_trx) {
|
||||||
|
_trx = await knex.transaction({ isolationLevel });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = await work(trx);
|
const result = await work(_trx);
|
||||||
trx.commit();
|
|
||||||
|
|
||||||
|
if (!trx) {
|
||||||
|
_trx.commit();
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trx.rollback();
|
if (!trx) {
|
||||||
|
_trx.rollback();
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
@@ -47,6 +47,9 @@ importers:
|
|||||||
'@types/mathjs':
|
'@types/mathjs':
|
||||||
specifier: ^6.0.12
|
specifier: ^6.0.12
|
||||||
version: 6.0.12
|
version: 6.0.12
|
||||||
|
'@types/yup':
|
||||||
|
specifier: ^0.29.13
|
||||||
|
version: 0.29.14
|
||||||
accepts:
|
accepts:
|
||||||
specifier: ^1.3.7
|
specifier: ^1.3.7
|
||||||
version: 1.3.8
|
version: 1.3.8
|
||||||
@@ -131,9 +134,6 @@ importers:
|
|||||||
express-boom:
|
express-boom:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
express-fileupload:
|
|
||||||
specifier: ^1.1.7-alpha.3
|
|
||||||
version: 1.4.0
|
|
||||||
express-oauth-server:
|
express-oauth-server:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
@@ -203,6 +203,9 @@ importers:
|
|||||||
mongoose:
|
mongoose:
|
||||||
specifier: ^5.10.0
|
specifier: ^5.10.0
|
||||||
version: 5.13.20
|
version: 5.13.20
|
||||||
|
multer:
|
||||||
|
specifier: 1.4.5-lts.1
|
||||||
|
version: 1.4.5-lts.1
|
||||||
mustache:
|
mustache:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.2.1
|
version: 3.2.1
|
||||||
@@ -290,6 +293,9 @@ importers:
|
|||||||
xlsx:
|
xlsx:
|
||||||
specifier: ^0.18.5
|
specifier: ^0.18.5
|
||||||
version: 0.18.5
|
version: 0.18.5
|
||||||
|
yup:
|
||||||
|
specifier: ^0.28.1
|
||||||
|
version: 0.28.5
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/lodash':
|
'@types/lodash':
|
||||||
specifier: ^4.14.158
|
specifier: ^4.14.158
|
||||||
@@ -7902,6 +7908,10 @@ packages:
|
|||||||
buffer-equal: 1.0.1
|
buffer-equal: 1.0.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/append-field@1.0.0:
|
||||||
|
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/append-transform@1.0.0:
|
/append-transform@1.0.0:
|
||||||
resolution: {integrity: sha512-P009oYkeHyU742iSZJzZZywj4QRJdnTWffaKuJQLablCZ1uz6/cW4yaRgcDaoQ+uwOxxnt0gRUcwfsNP2ri0gw==}
|
resolution: {integrity: sha512-P009oYkeHyU742iSZJzZZywj4QRJdnTWffaKuJQLablCZ1uz6/cW4yaRgcDaoQ+uwOxxnt0gRUcwfsNP2ri0gw==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -12476,13 +12486,6 @@ packages:
|
|||||||
boom: 7.3.0
|
boom: 7.3.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/express-fileupload@1.4.0:
|
|
||||||
resolution: {integrity: sha512-RjzLCHxkv3umDeZKeFeMg8w7qe0V09w3B7oGZprr/oO2H/ISCgNzuqzn7gV3HRWb37GjRk429CCpSLS2KNTqMQ==}
|
|
||||||
engines: {node: '>=12.0.0'}
|
|
||||||
dependencies:
|
|
||||||
busboy: 1.6.0
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/express-oauth-server@2.0.0:
|
/express-oauth-server@2.0.0:
|
||||||
resolution: {integrity: sha512-+UrTbvU7u3LVnoUavzO7QJgSqiEZREKprCZYrDEVoSszrO4t8f/BBPbY3hQOuuatoS0PgDFLaDKQsGNtAgPm5w==}
|
resolution: {integrity: sha512-+UrTbvU7u3LVnoUavzO7QJgSqiEZREKprCZYrDEVoSszrO4t8f/BBPbY3hQOuuatoS0PgDFLaDKQsGNtAgPm5w==}
|
||||||
engines: {node: '>=0.11'}
|
engines: {node: '>=0.11'}
|
||||||
@@ -17924,6 +17927,19 @@ packages:
|
|||||||
/ms@2.1.3:
|
/ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
|
/multer@1.4.5-lts.1:
|
||||||
|
resolution: {integrity: sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==}
|
||||||
|
engines: {node: '>= 6.0.0'}
|
||||||
|
dependencies:
|
||||||
|
append-field: 1.0.0
|
||||||
|
busboy: 1.6.0
|
||||||
|
concat-stream: 1.6.2
|
||||||
|
mkdirp: 0.5.6
|
||||||
|
object-assign: 4.1.1
|
||||||
|
type-is: 1.6.18
|
||||||
|
xtend: 4.0.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
/multicast-dns@7.2.5:
|
/multicast-dns@7.2.5:
|
||||||
resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==}
|
resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|||||||
Reference in New Issue
Block a user