mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-16 12:50:38 +00:00
WIP
This commit is contained in:
149
packages/server/src/api/controllers/Import/ImportController.ts
Normal file
149
packages/server/src/api/controllers/Import/ImportController.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
54
packages/server/src/services/Import/ImportFileUpload.ts
Normal file
54
packages/server/src/services/Import/ImportFileUpload.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
120
packages/server/src/services/Import/ImportResourceInjectable.ts
Normal file
120
packages/server/src/services/Import/ImportResourceInjectable.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
12
packages/server/src/services/Import/_utils.ts
Normal file
12
packages/server/src/services/Import/_utils.ts
Normal 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 };
|
||||
}, {});
|
||||
}
|
||||
Reference in New Issue
Block a user