This commit is contained in:
Ahmed Bouhuolia
2024-03-10 14:53:10 +02:00
parent e5bcb1c19a
commit b1d5390bfc
11 changed files with 462 additions and 1 deletions

View File

@@ -0,0 +1,149 @@
import { Inject, Service } from 'typedi';
import { Router, Request, Response, NextFunction } from 'express';
import { query } from 'express-validator';
import Multer from 'multer';
import BaseController from '@/api/controllers/BaseController';
import { ServiceError } from '@/exceptions';
import { ImportResourceInjectable } from '@/services/Import/ImportResourceInjectable';
import { ImportResourceApplication } from '@/services/Import/ImportResourceApplication';
const upload = Multer({
dest: './public/imports',
limits: { fileSize: 5 * 1024 * 1024 },
});
@Service()
export class ImportController extends BaseController {
@Inject()
private importResource: ImportResourceInjectable;
@Inject()
private importResourceApp: ImportResourceApplication;
/**
* Router constructor method.
*/
router() {
const router = Router();
// router.post(
// '/:import_id/import',
// this.asyncMiddleware(this.import.bind(this)),
// this.catchServiceErrors
// );
router.post(
'/file',
// [...this.importValidationSchema],
upload.single('file'),
this.asyncMiddleware(this.fileUpload.bind(this))
// this.catchServiceErrors
);
// router.post(
// '/:import_id/mapping',
// this.asyncMiddleware(this.mapping.bind(this)),
// this.catchServiceErrors
// );
// router.get(
// '/:import_id/preview',
// this.asyncMiddleware(this.preview.bind(this)),
// this.catchServiceErrors
// );
return router;
}
/**
* Import validation schema.
* @returns {ValidationSchema[]}
*/
get importValidationSchema() {
return [query('resource').exists().isString().toString()];
}
/**
* Imports xlsx/csv to the given resource type.
*
* - Save the xlsx/csv file and give it a random name.
* - Save the file metadata on the DB storage.
* -
*
* @param {Request} req -
* @param {Response} res -
* @param {NextFunction} next -
*/
async fileUpload(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
try {
const data = await this.importResourceApp.import(
tenantId,
req.body.resource,
req.file.path,
req.file.filename
);
return res.status(200).send(data);
} catch (error) {
next(error);
}
}
/**
*
* @param req
* @param res
* @param next
*/
async mapping(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
try {
await this.importResource.mapping(tenantId);
} catch (error) {
next(error);
}
}
/**
*
* @param req
* @param res
* @param next
*/
async preview(req: Request, res: Response, next: NextFunction) {}
/**
*
* @param req
* @param res
* @param next
*/
async import(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const { import_id: importId } = req.params;
try {
await this.importResource.importFile(tenantId, importId);
return res.status(200).send({});
} 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) {
}
next(error);
}
}

View File

@@ -56,6 +56,7 @@ import { ProjectsController } from './controllers/Projects/Projects';
import { ProjectTasksController } from './controllers/Projects/Tasks';
import { ProjectTimesController } from './controllers/Projects/Times';
import { TaxRatesController } from './controllers/TaxRates/TaxRates';
import { ImportController } from './controllers/Import/ImportController';
export default () => {
const app = Router();
@@ -131,6 +132,9 @@ export default () => {
dashboard.use('/warehouses', Container.get(WarehousesController).router());
dashboard.use('/projects', Container.get(ProjectsController).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(ProjectTimesController).router());

View File

@@ -0,0 +1,14 @@
exports.up = function (knex) {
return knex.schema.createTable('imports', (table) => {
table.increments();
table.string('filename');
table.string('import_id');
table.string('resource');
table.json('mapping');
table.timestamps();
});
};
exports.down = function (knex) {
return knex.schema.dropTableIfExists('imports');
};

View File

@@ -61,6 +61,7 @@ import Task from 'models/Task';
import TaxRate from 'models/TaxRate';
import TaxRateTransaction from 'models/TaxRateTransaction';
import Attachment from 'models/Attachment';
import Import from 'models/Import';
export default (knex) => {
const models = {
@@ -124,7 +125,8 @@ export default (knex) => {
Task,
TaxRate,
TaxRateTransaction,
Attachment
Attachment,
Import
};
return mapValues(models, (model) => model.bindKnex(knex));
};

View File

@@ -0,0 +1,32 @@
import { DATATYPES_LENGTH } from '@/data/DataTypes';
import { IsInt, IsOptional, IsString, Length, Min, Max } from 'class-validator';
export class AccountDTOSchema {
@IsString()
@Length(3, DATATYPES_LENGTH.STRING)
name: string;
// @IsString()
// @IsInt()
@IsOptional()
// @Length(3, 6)
code?: string;
@IsOptional()
currencyCode?: string;
@IsString()
@Length(3, DATATYPES_LENGTH.STRING)
accountType: string;
@IsString()
@IsOptional()
@Length(0, DATATYPES_LENGTH.TEXT)
description?: string;
@IsInt()
@IsOptional()
@Min(0)
@Max(DATATYPES_LENGTH.INT_10)
parentAccountId?: number;
}

View File

@@ -0,0 +1,54 @@
import { first } from 'lodash';
import { Inject, Service } from 'typedi';
import { snakeCase, upperFirst } from 'lodash';
import XLSX from 'xlsx';
import * as R from 'ramda';
import HasTenancyService from '../Tenancy/TenancyService';
import { trimObject } from './_utils';
const fs = require('fs').promises;
@Service()
export class ImportFileUploadService {
@Inject()
private tenancy: HasTenancyService;
/**
* Reads the imported file and stores the import file meta under unqiue id.
* @param {number} tenantId -
* @param {string} filePath -
* @param {string} fileName -
* @returns
*/
public async import(
tenantId: number,
resource: string,
filePath: string,
filename: string
) {
const { Import } = this.tenancy.models(tenantId);
const buffer = await fs.readFile(filePath);
const workbook = XLSX.read(buffer, { type: 'buffer' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
const _resource = upperFirst(snakeCase(resource));
const exportFile = await Import.query().insert({
filename,
importId: filename,
resource: _resource,
});
const columns = this.getColumns(jsonData);
return {
...exportFile,
columns,
};
}
private getColumns(json: unknown[]) {
return R.compose(Object.keys, trimObject, first)(json);
}
}

View File

@@ -0,0 +1,29 @@
import { Inject } from 'typedi';
import { ImportFileUploadService } from './ImportFileUpload';
@Inject()
export class ImportResourceApplication {
@Inject()
private importFileService: ImportFileUploadService;
/**
* Reads the imported file and stores the import file meta under unqiue id.
* @param {number} tenantId -
* @param {string} filePath -
* @param {string} fileName -
* @returns
*/
public async import(
tenantId: number,
resource: string,
filePath: string,
filename: string
) {
return this.importFileService.import(
tenantId,
resource,
filePath,
filename
);
}
}

View File

@@ -0,0 +1,120 @@
import XLSX, { readFile } from 'xlsx';
import * as R from 'ramda';
import async from 'async';
import { camelCase, snakeCase, upperFirst } from 'lodash';
import HasTenancyService from '../Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import { first } from 'lodash';
import { ServiceError } from '@/exceptions';
import { validate } from 'class-validator';
import { AccountDTOSchema } from '../Accounts/CreateAccountDTOSchema';
import { AccountsApplication } from '../Accounts/AccountsApplication';
import { plainToClass, plainToInstance } from 'class-transformer';
const fs = require('fs').promises;
const ERRORS = {
IMPORT_ID_NOT_FOUND: 'IMPORT_ID_NOT_FOUND',
};
@Service()
export class ImportResourceInjectable {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private accountsApplication: AccountsApplication;
public async mapping(
tenantId: number,
importId: number,
maps: { from: string; to: string }[]
) {
const { Import } = this.tenancy.models(tenantId);
const importFile = await Import.query().find('filename', importId);
if (!importFile) {
throw new ServiceError(ERRORS.IMPORT_ID_NOT_FOUND);
}
//
await Import.query()
.findById(importFile.id)
.update({
maps: JSON.stringify(maps),
});
// - Validate the to columns.
// - Store the mapping in the import table.
// -
}
public async preview(tenantId: number, importId: string) {}
/**
*
* @param tenantId
* @param importId
*/
public async importFile(tenantId: number, importId: string) {
const { Import } = this.tenancy.models(tenantId);
const importFile = await Import.query().where('importId', importId).first();
if (!importFile) {
throw new ServiceError(ERRORS.IMPORT_ID_NOT_FOUND);
}
const buffer = await fs.readFile(`public/imports/${importFile.filename}`);
const workbook = XLSX.read(buffer, { type: 'buffer' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
const data = R.compose(R.map(Object.keys), R.map(trimObject))(jsonData);
const header = first(data);
const body = jsonData;
const mapping = JSON.parse(importFile.mapping) || [];
const newData = [];
const findToAttr = (from: string) => {
const found = mapping.find((item) => {
return item.from === from;
});
return found?.to;
};
body.forEach((row) => {
const obj = {};
header.forEach((key, index) => {
const toIndex = camelCase(findToAttr(key));
obj[toIndex] = row[key];
});
newData.push(obj);
});
const saveJob = async (data) => {
const account = {};
Object.keys(data).map((key) => {
account[key] = data[key];
});
const accountClass = plainToInstance(AccountDTOSchema, account);
const errors = await validate(accountClass);
if (errors.length > 0) {
console.log('validation failed. errors: ', errors);
} else {
return this.accountsApplication.createAccount(tenantId, account);
}
};
const saveDataQueue = async.queue(saveJob, 10);
newData.forEach((data) => {
saveDataQueue.push(data);
});
await saveDataQueue.drain();
}
}

View File

@@ -0,0 +1,12 @@
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 };
}, {});
}