...
 
Commits (11)
This diff is collapsed.
......@@ -68,7 +68,7 @@
"karma-jasmine": "^3.1.1",
"karma-jasmine-html-reporter": "^1.5.4",
"prettier": "^2.0.5",
"protractor": "^5.4.4",
"protractor": "^7.0.0",
"ts-node": "^8.10.1",
"typescript": "^3.7.5"
},
......
......@@ -12,4 +12,5 @@ export interface SalesReturn {
total_qty: number;
total: number;
items: Item[];
delivery_note_names: string[];
}
......@@ -482,10 +482,21 @@ export class AddSalesReturnPage implements OnInit {
);
salesReturn.posting_time = this.getFrappeTime();
salesReturn.set_warehouse = this.warehouseFormControl.value;
salesReturn.delivery_note_names = this.deliveryNoteNames;
this.salesService.createSalesReturn(salesReturn).subscribe({
next: success => {
this.snackBar.open(`Sales Return created.`, CLOSE, { duration: 2500 });
this.location.back();
},
error: err => {
if (err.status === 400) {
this.snackBar.open(
`Invalid Serials ${err.error.invalidSerials}...`,
CLOSE,
{ duration: 2500 },
);
}
},
});
}
......
......@@ -10127,6 +10127,11 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"dev": true
},
"papaparse": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.2.0.tgz",
"integrity": "sha512-ylq1wgUSnagU+MKQtNeVqrPhZuMYBvOSL00DHycFTCxownF95gpLAk1HiHdUW77N8yxRq1qHXLdlIPyBSG9NSA=="
},
"parallel-transform": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz",
......
......@@ -33,6 +33,7 @@
"agenda": "^3.1.0",
"class-transformer": "^0.2.3",
"class-validator": "^0.12.2",
"cron": "^1.8.2",
"geteventstore-promise": "^3.2.1",
"joi": "^14.3.1",
"luxon": "^1.24.1",
......
......@@ -19,6 +19,8 @@ import {
REVOKED,
AUTHORIZATION,
TOKEN_HEADER_VALUE_PREFIX,
APPLICATION_JSON_CONTENT_TYPE,
ACCEPT,
} from '../../../constants/app-strings';
import { INVALID_SERVICE_ACCOUNT } from '../../../constants/messages';
......@@ -202,6 +204,8 @@ export class ClientTokenManagerService {
headers[AUTHORIZATION] = TOKEN_HEADER_VALUE_PREFIX;
headers[AUTHORIZATION] += settings.serviceAccountApiKey + ':';
headers[AUTHORIZATION] += settings.serviceAccountApiSecret;
headers[CONTENT_TYPE] = APPLICATION_JSON_CONTENT_TYPE;
headers[ACCEPT] = APPLICATION_JSON_CONTENT_TYPE;
return headers;
}),
);
......
import { Test, TestingModule } from '@nestjs/testing';
import { CleanExpiredTokenCacheService } from './clean-expired-token-cache.service';
import { TokenCacheService } from '../../entities/token-cache/token-cache.service';
import { ServerSettingsService } from '../../../system-settings/entities/server-settings/server-settings.service';
import { AGENDA_TOKEN } from '../../../system-settings/providers/agenda.provider';
describe('CleanExpiredTokenCacheService', () => {
let service: CleanExpiredTokenCacheService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CleanExpiredTokenCacheService,
{ provide: TokenCacheService, useValue: {} },
{ provide: ServerSettingsService, useValue: {} },
{ provide: AGENDA_TOKEN, useValue: {} },
],
}).compile();
service = module.get<CleanExpiredTokenCacheService>(
CleanExpiredTokenCacheService,
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
import * as Agenda from 'agenda';
import { TokenCacheService } from '../../entities/token-cache/token-cache.service';
import { AGENDA_TOKEN } from '../../../system-settings/providers/agenda.provider';
export const CLEAN_EXPIRED_TOKEN_QUEUE = 'CLEAN_EXPIRED_TOKEN_QUEUE';
@Injectable()
export class CleanExpiredTokenCacheService implements OnModuleInit {
constructor(
@Inject(AGENDA_TOKEN)
private readonly agenda: Agenda,
private readonly tokenCache: TokenCacheService,
) {}
onModuleInit() {
this.agenda.define(
CLEAN_EXPIRED_TOKEN_QUEUE,
{ concurrency: 1 },
async job => {
const query: { [key: string]: any } = {
exp: { $lte: Math.floor(new Date().valueOf() / 1000) },
};
await this.tokenCache.deleteMany({
...query,
...{
$or: [{ refreshToken: null }, { refreshToken: { $exists: false } }],
},
});
},
);
this.agenda
.every('15 minutes', CLEAN_EXPIRED_TOKEN_QUEUE)
.then(scheduled => {})
.catch(error => {});
}
}
import { CleanExpiredTokenCacheService } from './clean-expired-token-cache/clean-expired-token-cache.service';
import { RevokeExpiredFrappeTokensService } from './revoke-expired-frappe-tokens/revoke-expired-frappe-tokens.service';
export const AuthSchedulers = [
CleanExpiredTokenCacheService,
RevokeExpiredFrappeTokensService,
];
export const AuthSchedulers = [RevokeExpiredFrappeTokensService];
......@@ -6,11 +6,12 @@ import {
Inject,
} from '@nestjs/common';
import { from, of, Observable } from 'rxjs';
import { switchMap, mergeMap, retryWhen, take, delay } from 'rxjs/operators';
import { concatMap } from 'rxjs/operators';
import { DateTime } from 'luxon';
import { stringify } from 'querystring';
import { AxiosResponse } from 'axios';
import * as Agenda from 'agenda';
import { CronJob } from 'cron';
import { ClientTokenManagerService } from '../../aggregates/client-token-manager/client-token-manager.service';
import { ServerSettingsService } from '../../../system-settings/entities/server-settings/server-settings.service';
......@@ -25,7 +26,6 @@ import {
REVOKE_FRAPPE_TOKEN_SUCCESS,
REVOKE_FRAPPE_TOKEN_ERROR,
} from '../../../constants/messages';
import { ErrorLogService } from '../../../error-log/error-log-service/error-log.service';
import { AGENDA_TOKEN } from '../../../system-settings/providers/agenda.provider';
export const REVOKE_EXPIRED_FRAPPE_TOKEN = 'REVOKE_EXPIRED_FRAPPE_TOKEN';
......@@ -37,56 +37,62 @@ export class RevokeExpiredFrappeTokensService implements OnModuleInit {
private readonly settings: ServerSettingsService,
private readonly clientToken: ClientTokenManagerService,
private readonly http: HttpService,
private readonly errorLog: ErrorLogService,
) {}
onModuleInit() {
this.defineAgendaJob();
// every 15 minutes
// for every second '* * * * * *';
const FIFTEEN_MINUTES_CRON_STRING = '0 */15 * * * *';
const cronJob = new CronJob(FIFTEEN_MINUTES_CRON_STRING, async () => {
this.agenda.now(REVOKE_EXPIRED_FRAPPE_TOKEN);
});
cronJob.start();
}
defineAgendaJob() {
this.agenda.define(
REVOKE_EXPIRED_FRAPPE_TOKEN,
{ concurrency: 1 },
async job => {
(job: Agenda.Job, done: (err?: Error) => void) => {
from(this.settings.find())
.pipe(
switchMap(settings => {
concatMap(settings => {
const nowInServerTimeZone = new DateTime(
settings.timeZone,
).toFormat('yyyy-MM-dd HH:mm:ss');
return this.clientToken.getServiceAccountApiHeaders().pipe(
switchMap(headers => {
concatMap(headers => {
return this.getFrappeTokens(
settings,
headers,
nowInServerTimeZone,
);
}),
mergeMap(moreTokens => from(moreTokens.data.data)),
mergeMap(({ access_token }) => {
concatMap(moreTokens => from(moreTokens.data.data)),
concatMap(({ access_token }) => {
return this.revokeToken(settings, access_token);
}),
);
}),
retryWhen(errors => errors.pipe(delay(1000), take(3))),
)
.subscribe({
next: success => {
Logger.log(REVOKE_FRAPPE_TOKEN_SUCCESS, this.constructor.name);
},
error: error => {
this.errorLog.createErrorLog(error);
Logger.error(
REVOKE_FRAPPE_TOKEN_ERROR,
error,
this.constructor.name,
);
},
.toPromise()
.then(success => {
Logger.log(REVOKE_FRAPPE_TOKEN_SUCCESS, this.constructor.name);
done();
job
.remove()
.then(removed => {})
.catch(err => {});
})
.catch(error => {
done(this.getPureError(error));
Logger.error(REVOKE_FRAPPE_TOKEN_ERROR, this.constructor.name);
});
},
);
this.agenda
.every('15 minutes', REVOKE_EXPIRED_FRAPPE_TOKEN)
.then(scheduled => {})
.catch(error => {});
}
revokeToken(settings: ServerSettings, token: string): Observable<unknown> {
......@@ -120,7 +126,7 @@ export class RevokeExpiredFrappeTokensService implements OnModuleInit {
params,
})
.pipe(
switchMap(resTokens => {
concatMap(resTokens => {
if (resTokens.data.data.length === Number(HUNDRED_NUMBERSTRING)) {
iterationCount++;
return this.getFrappeTokens(
......@@ -135,4 +141,30 @@ export class RevokeExpiredFrappeTokensService implements OnModuleInit {
}),
);
}
getPureError(error) {
if (error && error.response) {
error = error.response.data ? error.response.data : error.response;
}
try {
return JSON.parse(
JSON.stringify(error, (keys, value) => {
if (value instanceof Error) {
const err = {};
Object.getOwnPropertyNames(value).forEach(key => {
err[key] = value[key];
});
return err;
}
return value;
}),
);
} catch {
return error.data ? error.data : error;
}
}
}
......@@ -108,8 +108,10 @@ export const SALES_INVOICE_STATUS_ENUM = [
];
export const PURCHASE_RECEIPT = 'purchase_receipt';
export const DELIVERY_NOTE = 'delivery_note';
export const DELIVERY_NOTE_DOCTYPE = 'Delivery Note';
export const PURCHASE_RECEIPT_SERIALS_BATCH_SIZE = 20;
export const STOCK_ENTRY_SERIALS_BATCH_SIZE = 20;
export const DELIVERY_NOTE_SERIAL_BATCH_SIZE = 1000;
export const SERIAL_NO_VALIDATION_BATCH_SIZE = 10000;
export const FRAPPE_INSERT_MANY_BATCH_COUNT = 2;
export const PURCHASE_RECEIPT_DOCTYPE_NAME = 'Purchase Receipt';
......@@ -122,6 +124,8 @@ export const STOCK_ENTRY = 'Stock Entry';
export const ITEM_DOCTYPE = 'Item';
export const SALES_INVOICE_DOCTYPE = 'Sales Invoice';
export const FRAPPE_QUEUE_JOB = 'FRAPPE_QUEUE_JOB';
export const FRAPPE_SYNC_DATA_IMPORT_QUEUE_JOB =
'FRAPPE_SYNC_DATA_IMPORT_QUEUE_JOB';
export const CREATE_DELIVERY_NOTE_JOB = 'CREATE_DELIVERY_NOTE_JOB';
export const STOCK_ENTRY_LIST_ITEM_SELECT_KEYS = [
's_warehouse',
......@@ -151,5 +155,9 @@ export const AGENDA_JOB_STATUS = {
in_queue: 'In Queue',
reset: 'Reset',
retrying: 'Retrying',
exported: 'Exported',
};
export const AGENDA_MAX_RETRIES = 1;
export const AGENDA_DATA_IMPORT_MAX_RETRIES = 4;
export const FRAPPE_DATA_IMPORT_INSERT_ACTION = 'Insert new records';
export const SYNC_DELIVERY_NOTE_JOB = 'SYNC_DELIVERY_NOTE_JOB';
......@@ -70,3 +70,8 @@ export const API_RESOURCE = '/api/resource/';
export const PURCHASE_INVOICE_ON_CANCEL_ENDPOINT =
'/api/purchase_invoice/webhook/v1/cancel';
export const STOCK_ENTRY_API_ENDPOINT = '/api/resource/Stock Entry';
export const DATA_IMPORT_API_ENDPOINT = '/api/resource/Data Import';
export const FRAPPE_START_DATA_IMPORT_API_ENDPOINT =
'/api/method/frappe.core.doctype.data_import.data_import.import_data';
export const FRAPPE_FILE_ATTACH_API_ENDPOINT =
'/api/method/frappe.client.attach_file';
......@@ -6,15 +6,8 @@ import {
Inject,
} from '@nestjs/common';
import * as Agenda from 'agenda';
import { from } from 'rxjs';
import {
switchMap,
map,
retryWhen,
delay,
take,
concatMap,
} from 'rxjs/operators';
import { of } from 'rxjs';
import { map, retryWhen, delay, take, concatMap } from 'rxjs/operators';
import { SettingsService } from '../../../system-settings/aggregates/settings/settings.service';
import { CustomerService } from '../../entity/customer/customer.service';
import { ClientTokenManagerService } from '../../../auth/aggregates/client-token-manager/client-token-manager.service';
......@@ -32,6 +25,8 @@ import {
RESET_CREDIT_LIMIT_ERROR,
} from '../../../constants/messages';
import { AGENDA_TOKEN } from '../../../system-settings/providers/agenda.provider';
import { Customer } from '../../entity/customer/customer.entity';
import { CronJob } from 'cron';
export const RESET_CUSTOMER_CREDIT_LIMIT = 'RESET_CUSTOMER_CREDIT_LIMIT';
@Injectable()
......@@ -46,18 +41,31 @@ export class ResetCreditLimitService implements OnModuleInit {
) {}
onModuleInit() {
// Run every hour
this.defineAgendaJob();
// every 15 minutes
// for every second '* * * * * *';
const FIFTEEN_MINUTES_CRON_STRING = '0 */15 * * * *';
const cronJob = new CronJob(FIFTEEN_MINUTES_CRON_STRING, async () => {
const now = new Date();
const customers = await this.customer.find({
baseCreditLimitAmount: { $exists: true },
tempCreditLimitPeriod: { $lte: now },
});
for (const customer of customers) {
this.agenda.now(RESET_CUSTOMER_CREDIT_LIMIT, { customer });
}
});
cronJob.start();
}
defineAgendaJob() {
this.agenda.define(
RESET_CUSTOMER_CREDIT_LIMIT,
{ concurrency: 1 },
async (job, done) => {
const now = new Date();
const customers = await this.customer.find({
baseCreditLimitAmount: { $exists: true },
tempCreditLimitPeriod: { $lte: now },
});
from(customers)
async (job: Agenda.Job, done) => {
of(job.attrs.data.customer as Customer)
.pipe(
concatMap(customer => {
this.customer
......@@ -68,9 +76,9 @@ export class ResetCreditLimitService implements OnModuleInit {
.then(success => {})
.catch(error => {});
return this.settings.find().pipe(
switchMap(settings => {
concatMap(settings => {
return this.clientToken.getServiceAccountApiHeaders().pipe(
switchMap(headers => {
concatMap(headers => {
headers[CONTENT_TYPE] = APPLICATION_JSON_CONTENT_TYPE;
headers[ACCEPT] = APPLICATION_JSON_CONTENT_TYPE;
return this.http
......@@ -83,7 +91,7 @@ export class ResetCreditLimitService implements OnModuleInit {
)
.pipe(
map(res => res.data),
switchMap(erpnextCustomer => {
concatMap(erpnextCustomer => {
const creditLimits: any[] =
erpnextCustomer.credit_limits || [];
......@@ -128,42 +136,42 @@ export class ResetCreditLimitService implements OnModuleInit {
.then(success => {
Logger.log(RESET_CREDIT_LIMIT_SUCCESS, this.constructor.name);
done();
job
.remove()
.then(removed => {})
.catch(err => {});
})
.catch(error => {
Logger.error(RESET_CREDIT_LIMIT_ERROR, this.constructor.name);
.catch((error: Error) => {
done(this.getPureError(error));
Logger.error(RESET_CREDIT_LIMIT_ERROR, this.constructor.name);
});
},
);
this.agenda
.every('60 minutes', RESET_CUSTOMER_CREDIT_LIMIT)
.then(scheduled => {})
.catch(error => {});
}
getPureError(error) {
if (error && error.response) {
error = error.response.data ? error.response.data : error.response;
}
try {
return JSON.parse(JSON.stringify(error, this.replaceErrors));
} catch {
return error.data ? error.data : error;
}
}
return JSON.parse(
JSON.stringify(error, (keys, value) => {
if (value instanceof Error) {
const err = {};
replaceErrors(keys, value) {
if (value instanceof Error) {
const error = {};
Object.getOwnPropertyNames(value).forEach(key => {
err[key] = value[key];
});
Object.getOwnPropertyNames(value).forEach(key => {
error[key] = value[key];
});
return err;
}
return error;
return value;
}),
);
} catch {
return error.data ? error.data : error;
}
return value;
}
}
......@@ -24,7 +24,7 @@ import {
DELIVERY_NOTE_LIST_FIELD,
COMPLETED_STATUS,
TO_DELIVER_STATUS,
STOCK_ENTRY_SERIALS_BATCH_SIZE,
DELIVERY_NOTE_SERIAL_BATCH_SIZE,
} from '../../../constants/app-strings';
import { AssignSerialDto } from '../../../serial-no/entity/serial-no/assign-serial-dto';
import { CreateDeliveryNoteInterface } from '../../../delivery-note/entity/delivery-note-service/create-delivery-note-interface';
......@@ -128,7 +128,7 @@ export class DeliveryNoteAggregateService extends AggregateRoot {
token: any,
) {
return this.serialBatchService
.batchItems(deliveryNoteBody.items, STOCK_ENTRY_SERIALS_BATCH_SIZE)
.batchItems(deliveryNoteBody.items, DELIVERY_NOTE_SERIAL_BATCH_SIZE)
.pipe(
switchMap((itemBatch: any) => {
return this.batchAddToQueue(
......
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { DeliveryNoteAggregatesManager } from './aggregates';
import { DeliveryNoteEntitiesModule } from './entity/delivery-note-entity.module';
import { DeliveryNoteController } from './controller/delivery-note/delivery-note.controller';
......@@ -14,6 +14,8 @@ import { DirectModule } from '../direct/direct.module';
import { SerialBatchService } from '../sync/aggregates/serial-batch/serial-batch.service';
import { JobQueueModule } from '../job-queue/job-queue.module';
import { DeliveryNoteJobHelperService } from './schedular/delivery-note-job-helper/delivery-note-job-helper.service';
import { SyncModule } from '../sync/sync.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
......@@ -21,7 +23,9 @@ import { DeliveryNoteJobHelperService } from './schedular/delivery-note-job-help
SerialNoEntitiesModule,
SalesInvoiceEntitiesModule,
DirectModule,
forwardRef(() => SyncModule),
JobQueueModule,
AuthModule,
],
controllers: [DeliveryNoteController, DeliveryNoteWebhookController],
providers: [
......
......@@ -4,11 +4,17 @@ export interface CreateDeliveryNoteInterface {
company?: string;
posting_date?: string;
posting_time?: string;
status?: string;
is_return?: boolean;
naming_series?: string;
issue_credit_note?: boolean;
return_against?: string;
set_warehouse?: string;
contact_email?: string;
doctype?: string;
selling_price_list?: string;
price_list_currency?: string;
plc_conversion_rate?: number;
total_qty?: number;
total?: number;
items?: CreateDeliveryNoteItemInterface[];
......
......@@ -8,6 +8,9 @@ import { DeliveryNoteJobService } from './delivery-note-job.service';
import { SalesInvoiceService } from '../../../sales-invoice/entity/sales-invoice/sales-invoice.service';
import { AgendaJobService } from '../../../job-queue/entities/agenda-job/agenda-job.service';
import { DeliveryNoteJobHelperService } from '../../schedular/delivery-note-job-helper/delivery-note-job-helper.service';
import { JsonToCsvParserService } from '../../../sync/service/data-import/json-to-csv-parser.service';
import { DataImportService } from '../../../sync/service/data-import/data-import.service';
import { ClientTokenManagerService } from '../../../auth/aggregates/client-token-manager/client-token-manager.service';
describe('DeliveryNoteJobService', () => {
let service: DeliveryNoteJobService;
......@@ -45,6 +48,9 @@ describe('DeliveryNoteJobService', () => {
provide: DeliveryNoteJobHelperService,
useValue: {},
},
{ provide: JsonToCsvParserService, useValue: {} },
{ provide: DataImportService, useValue: {} },
{ provide: ClientTokenManagerService, useValue: {} },
],
}).compile();
......
import { Injectable, HttpService, Inject } from '@nestjs/common';
import { Injectable, Inject, HttpService } from '@nestjs/common';
import { switchMap, mergeMap, catchError, retry, map } from 'rxjs/operators';
import {
VALIDATE_AUTH_STRING,
......@@ -6,12 +6,13 @@ import {
TO_DELIVER_STATUS,
CREATE_DELIVERY_NOTE_JOB,
AGENDA_JOB_STATUS,
FRAPPE_SYNC_DATA_IMPORT_QUEUE_JOB,
DELIVERY_NOTE_DOCTYPE,
SYNC_DELIVERY_NOTE_JOB,
} from '../../../constants/app-strings';
import { DirectService } from '../../../direct/aggregates/direct/direct.service';
import { SerialNoService } from '../../../serial-no/entity/serial-no/serial-no.service';
import { of, throwError, Observable } from 'rxjs';
import { POST_DELIVERY_NOTE_ENDPOINT } from '../../../constants/routes';
import { SettingsService } from '../../../system-settings/aggregates/settings/settings.service';
import { of, throwError, Observable, from } from 'rxjs';
import { CreateDeliveryNoteInterface } from '../../entity/delivery-note-service/create-delivery-note-interface';
import { ServerSettings } from '../../../system-settings/entities/server-settings/server-settings.entity';
import { DeliveryNoteResponseInterface } from '../../entity/delivery-note-service/delivery-note-response-interface';
......@@ -23,7 +24,18 @@ import Agenda = require('agenda');
import { AGENDA_TOKEN } from '../../../system-settings/providers/agenda.provider';
import { AgendaJobService } from '../../../job-queue/entities/agenda-job/agenda-job.service';
import { DeliveryNoteJobHelperService } from '../delivery-note-job-helper/delivery-note-job-helper.service';
import { JsonToCsvParserService } from '../../../sync/service/data-import/json-to-csv-parser.service';
import {
DataImportService,
DataImportSuccessResponse,
} from '../../../sync/service/data-import/data-import.service';
import * as uuid from 'uuid/v4';
import {
DATA_IMPORT_API_ENDPOINT,
LIST_DELIVERY_NOTE_ENDPOINT,
} from '../../../constants/routes';
import { ClientTokenManagerService } from '../../../auth/aggregates/client-token-manager/client-token-manager.service';
import { DataImportSuccessResponseInterface } from '../../../sync/service/data-import/data-import.interface';
export const CREATE_STOCK_ENTRY_JOB = 'CREATE_STOCK_ENTRY_JOB';
@Injectable()
......@@ -32,12 +44,14 @@ export class DeliveryNoteJobService {
@Inject(AGENDA_TOKEN)
private readonly agenda: Agenda,
private readonly tokenService: DirectService,
private readonly http: HttpService,
private readonly serialNoService: SerialNoService,
private readonly settingsService: SettingsService,
private readonly salesInvoiceService: SalesInvoiceService,
private readonly jobService: AgendaJobService,
private readonly jobHelper: DeliveryNoteJobHelperService,
private readonly csvService: JsonToCsvParserService,
private readonly importData: DataImportService,
private readonly http: HttpService,
private readonly clientToken: ClientTokenManagerService,
) {}
execute(job) {
......@@ -58,15 +72,19 @@ export class DeliveryNoteJobService {
token: any;
settings: ServerSettings;
sales_invoice_name: string;
dataImport: DataImportSuccessResponse;
uuid: string;
}) {
const payload = job.payload;
let payload = job.payload;
return of({}).pipe(
mergeMap(object => {
//
return this.http.post(
job.settings.authServerURL + POST_DELIVERY_NOTE_ENDPOINT,
payload,
{ headers: this.settingsService.getAuthorizationHeaders(job.token) },
switchMap(object => {
payload = this.setCsvDefaults(payload, job.settings);
const csvPayload = this.csvService.mapJsonToCsv(payload);
return this.importData.addDataImport(
DELIVERY_NOTE_DOCTYPE,
csvPayload,
job.settings,
job.token,
);
}),
catchError(err => {
......@@ -110,22 +128,32 @@ export class DeliveryNoteJobService {
return throwError(err);
}),
retry(3),
map(data => data.data.data),
switchMap(success => {
if (success) {
this.linkDeliveryNote(
payload,
success,
job.token,
job.settings,
job.sales_invoice_name,
);
}
switchMap((success: DataImportSuccessResponse) => {
job.dataImport = success;
job.uuid = uuid();
this.addToExportedQueue(job);
return of({});
}),
);
}
setCsvDefaults(payload, settings: ServerSettings) {
payload.naming_series = payload.naming_series
? payload.naming_series
: 'SDR-';
payload.price_list_currency = payload.price_list_currency
? payload.price_list_currency
: 'BDT';
payload.selling_price_list = payload.selling_price_list
? payload.selling_price_list
: settings.sellingPriceList;
payload.plc_conversion_rate = payload.plc_conversion_rate
? payload.plc_conversion_rate
: 1;
payload.status = payload.status ? payload.status : 'To Bill';
return payload;
}
syncExistingSerials(
job: {
payload: CreateDeliveryNoteInterface;
......@@ -173,7 +201,14 @@ export class DeliveryNoteJobService {
if (item.has_serial_no) {
this.serialNoService
.updateMany(
{ serial_no: { $in: item.serial_no.split('\n') } },
{
serial_no: {
$in:
typeof item.serial_no === 'string'
? item.serial_no.split('\n')
: item.serial_no,
},
},
{
$set: {
'warranty.salesWarrantyDate': item.warranty_date,
......@@ -189,7 +224,11 @@ export class DeliveryNoteJobService {
response.items.filter(item => {
if (item.serial_no) {
serials.push(...item.serial_no.split('\n'));
serials.push(
...(typeof item.serial_no === 'string'
? item.serial_no.split('\n')
: item.serial_no),
);
}
items.push({
item_code: item.item_code,
......@@ -288,4 +327,79 @@ export class DeliveryNoteJobService {
});
return this.agenda.now(FRAPPE_QUEUE_JOB, data);
}
syncImport(job: {
payload: DataImportSuccessResponse;
uuid: string;
type: string;
settings: ServerSettings;
}) {
const state: any = {};
return this.clientToken.getServiceAccountApiHeaders().pipe(
switchMap(headers => {
state.headers = headers;
return this.http.get(
job.settings.authServerURL +
DATA_IMPORT_API_ENDPOINT +
`/${job.payload.dataImportName}`,
{ headers },
);
}),
map(data => data.data.data),
switchMap((response: DataImportSuccessResponseInterface) => {
if (response.import_status === 'Successful') {
const parsed_response = JSON.parse(response.log_details);
const link = parsed_response.messages[0].link.split('/');
const delivery_note = link[link.length - 1];
return of(delivery_note);
}
if (
response.import_status === 'Pending' ||
response.import_status === 'In Progress'
) {
return throwError('Delivery Note is in queue');
}
return throwError(response);
}),
switchMap(delivery_note => {
return this.http.get(
job.settings.authServerURL +
LIST_DELIVERY_NOTE_ENDPOINT +
delivery_note,
{ headers: state.headers },
);
}),
map(data => data.data.data),
switchMap((delivery_note_response: DeliveryNoteResponseInterface) => {
state.delivery_note_response = delivery_note_response;
return from(this.jobService.findOne({ 'data.uuid': job.uuid }));
}),
switchMap(parent_job => {
const parent_data = parent_job.data;
this.linkDeliveryNote(
parent_data.payload,
state.delivery_note_response,
parent_data.token,
job.settings,
parent_data.parent,
);
return of();
}),
retry(2),
);
}
addToExportedQueue(job: {
dataImport: DataImportSuccessResponse;
uuid: string;
settings: ServerSettings;
}) {
const job_data = {
payload: job.dataImport,
uuid: job.uuid,
type: SYNC_DELIVERY_NOTE_JOB,
settings: job.settings,
};
return this.agenda.now(FRAPPE_SYNC_DATA_IMPORT_QUEUE_JOB, job_data);
}
}
......@@ -5,17 +5,12 @@ import { DirectService } from './aggregates/direct/direct.service';
import { DEFAULT } from '../constants/typeorm.connection';
import { RequestState } from './entities/request-state/request-state.entity';
import { RequestStateService } from './entities/request-state/request-state.service';
import { CleanExpiredTokenCacheService } from '../auth/schedulers/clean-expired-token-cache/clean-expired-token-cache.service';
import { ErrorLogModule } from '../error-log/error-logs-invoice.module';
@Module({
imports: [TypeOrmModule.forFeature([RequestState], DEFAULT), ErrorLogModule],
controllers: [DirectController],
providers: [
DirectService,
RequestStateService,
CleanExpiredTokenCacheService,
],
exports: [DirectService, RequestStateService, CleanExpiredTokenCacheService],
providers: [DirectService, RequestStateService],
exports: [DirectService, RequestStateService],
})
export class DirectModule {}
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
import * as Agenda from 'agenda';
import { TokenCacheService } from '../../../auth/entities/token-cache/token-cache.service';
import { AGENDA_TOKEN } from '../../../system-settings/providers/agenda.provider';
export const CLEAN_TOKEN_CACHE = 'CLEAN_TOKEN_CACHE';
@Injectable()
export class CleanExpiredTokenCacheService implements OnModuleInit {
constructor(
@Inject(AGENDA_TOKEN)
private readonly agenda: Agenda,
private readonly tokenCache: TokenCacheService,
) {}
onModuleInit() {
this.agenda.define(CLEAN_TOKEN_CACHE, { concurrency: 1 }, async job => {
await this.tokenCache.deleteMany({
exp: { $lte: Math.floor(new Date().valueOf() / 1000) },
});
});
this.agenda
.every('15 minutes', CLEAN_TOKEN_CACHE)
.then(scheduled => {})
.catch(error => {});
}
}
import { CleanExpiredTokenCacheService } from './clean-expired-token-cache/clean-expired-token-cache.service';
export const AuthSchedulers = [CleanExpiredTokenCacheService];
import { Column, ObjectIdColumn, ObjectID, Entity } from 'typeorm';
import { ServerSettings } from '../../../system-settings/entities/server-settings/server-settings.entity';
import { TokenCache } from '../../../auth/entities/token-cache/token-cache.entity';
import { DataImportSuccessResponse } from '../../../sync/service/data-import/data-import.service';
export class JobData {
payload: any;
......@@ -46,6 +47,9 @@ export class AgendaJob {
@Column()
repeatInterval: string;
@Column()
dataImport?: DataImportSuccessResponse;
@Column()
repeatTimezone: string;
......
......@@ -306,18 +306,22 @@ export class SalesInvoiceAggregateService extends AggregateRoot {
if (!settings) {
return throwError(new NotImplementedException());
}
return from(
this.validateSalesInvoicePolicy.validateSalesReturn(
return forkJoin({
salesInvoice: this.validateSalesInvoicePolicy.validateSalesReturn(
createReturnPayload,
),
).pipe(
switchMap((salesInvoice: SalesInvoice) => {
valid: this.validateSalesInvoicePolicy.validateReturnSerials(
createReturnPayload,
),
}).pipe(
switchMap(({ salesInvoice }) => {
this.createCreditNote(
settings,
createReturnPayload,
clientHttpRequest,
salesInvoice,
);
delete createReturnPayload.delivery_note_names;
const deliveryNote = new DeliveryNote();
Object.assign(deliveryNote, createReturnPayload);
return this.http
......
......@@ -40,6 +40,9 @@ export class CreateSalesReturnDto {
@IsNotEmpty()
items: SalesReturnItemDto[];
@IsNotEmpty()
delivery_note_names: string[];
@IsOptional()
pricing_rules: any[];
......
......@@ -6,6 +6,7 @@ import { AssignSerialNoPoliciesService } from '../../../serial-no/policies/assig
import { HttpService } from '@nestjs/common';
import { ClientTokenManagerService } from '../../../auth/aggregates/client-token-manager/client-token-manager.service';
import { SettingsService } from '../../../system-settings/aggregates/settings/settings.service';
import { SerialNoPoliciesService } from '../../../serial-no/policies/serial-no-policies/serial-no-policies.service';
describe('SalesInvoicePoliciesService', () => {
let service: SalesInvoicePoliciesService;
......@@ -26,6 +27,7 @@ describe('SalesInvoicePoliciesService', () => {
provide: AssignSerialNoPoliciesService,
useValue: {},
},
{ provide: SerialNoPoliciesService, useValue: {} },
{ provide: HttpService, useValue: {} },
{ provide: ClientTokenManagerService, useValue: {} },
{ provide: SettingsService, useValue: {} },
......
import { Injectable, BadRequestException, HttpService } from '@nestjs/common';
import { SalesInvoiceService } from '../../../sales-invoice/entity/sales-invoice/sales-invoice.service';
import { from, throwError, of, forkJoin } from 'rxjs';
import { switchMap, map } from 'rxjs/operators';
import { switchMap, map, mergeMap, toArray } from 'rxjs/operators';
import {
SALES_INVOICE_NOT_FOUND,
CUSTOMER_AND_CONTACT_INVALID,
......@@ -26,6 +26,7 @@ import {
FRAPPE_API_SALES_INVOICE_ENDPOINT,
POST_DELIVERY_NOTE_ENDPOINT,
} from '../../../constants/routes';
import { SerialNoPoliciesService } from '../../../serial-no/policies/serial-no-policies/serial-no-policies.service';
@Injectable()
export class SalesInvoicePoliciesService {
......@@ -33,6 +34,7 @@ export class SalesInvoicePoliciesService {
private readonly salesInvoiceService: SalesInvoiceService,
private readonly customerService: CustomerService,
private readonly assignSerialPolicyService: AssignSerialNoPoliciesService,
private readonly serialNoPoliciesService: SerialNoPoliciesService,
private readonly http: HttpService,
private readonly clientToken: ClientTokenManagerService,
private readonly settings: SettingsService,
......@@ -153,6 +155,36 @@ export class SalesInvoicePoliciesService {
);
}
validateReturnSerials(payload: CreateSalesReturnDto) {
return from(payload.items).pipe(
mergeMap(item => {
return this.serialNoPoliciesService.validateReturnSerials({
delivery_note_names: payload.delivery_note_names,
item_code: item.item_code,
serials: item.serial_no.split('\n'),
warehouse: payload.set_warehouse,
});
}),
toArray(),
switchMap((res: { notFoundSerials: string[] }[]) => {
const invalidSerials = [];
res.forEach(invalidSerial => {
invalidSerials.push(...invalidSerial.notFoundSerials);
});
if (invalidSerials.length > 0) {
return throwError(
new BadRequestException({
invalidSerials: invalidSerials.splice(0, 5).join(', '),
}),
);
}
return of(true);
}),
);
}
getMessage(notFoundMessage, expected, found) {
return `${notFoundMessage}, expected ${expected || 0} found ${found || 0}`;
}
......
......@@ -41,6 +41,7 @@ import { SyncModule } from '../sync/sync.module';
SerialNoEntitiesModule,
...SerialNoAggregatesManager,
AssignSerialNoPoliciesService,
SerialNoPoliciesService,
],
})
export class SerialNoModule {}
/* eslint-disable */
export const DELIVERY_NOTE_CSV_TEMPLATE = `Data Import Template,,,,,,,,,,,,,,,,,,,,,,,,,
Table:,Delivery Note,,,,,,,,,,,,,,,,,,,,,,,,
DocType:,Delivery Note,,,,,,,,,,,,~,Delivery Note Item,items,,,,,~,~,~,~,Sales Team,sales_team
Column Labels:,Series,Customer,Company,Date,Posting Time,Status,Set Source Warehouse,Total Quantity,Total,Price List,Price List Currency,Price List Exchange Rate,,Item Code,Item Name,Quantity,Amount,Against Sales Invoice,Serial No,,,,,Sales Person,
Column Name:,naming_series,customer,company,posting_date,posting_time,status,set_warehouse,total_qty,total,selling_price_list,price_list_currency,plc_conversion_rate,~,item_code,item_name,qty,amount,against_sales_invoice,serial_no,~,~,~,~,sales_person,~
Mandatory:,Yes,Yes,Yes,Yes,Yes,Yes,No,No,No,Yes,Yes,Yes,,Yes,Yes,Yes,No,No,No,,,,,Yes,
Type:,Select,Link,Link,Date,Time,Select,Link,Float,Float,Link,Link,Float,,Link,Data,Float,Currency,Link,Text,,,,,Link,
Info:,"One of: DN-, DN-RET-",Valid Customer,Valid Company,dd-mm-yyyy,,"One of: Draft, To Bill, Completed, Cancelled, Closed",Valid Warehouse,,,Valid Price List,Valid Currency,,,Valid Item,,,,Valid Sales Invoice,,,,,,Valid Sales Person,
Start entering data below this line,,,,,,,,,,,,,,,,,,,,,,,,,
`;
export const DELIVERY_NOTE_CSV_TEMPLATE_HEADERS = [
'',
'naming_series',
'customer',
'company',
'posting_date',
'posting_time',
'status',
'set_warehouse',
'total_qty',
'total',
'selling_price_list',
'price_list_currency',
'plc_conversion_rate',
'~',
['items', '0', 'item_code'],
['items', '0', 'item_name'],
['items', '0', 'qty'],
['items', '0', 'amount'],
['items', '0', 'against_sales_invoice'],
['items', '0', 'serial_no'],
'~',
'~',
'~',
'~',
'sales_person',
'~',
];
......@@ -34,7 +34,7 @@ export class FrappeJobService implements OnModuleInit {
.execute(job)
.toPromise()
.then(success => {
job.attrs.data.status = AGENDA_JOB_STATUS.success;
job.attrs.data.status = AGENDA_JOB_STATUS.exported;
return done();
})
.catch(err => {
......
import { Test, TestingModule } from '@nestjs/testing';
import { CleanExpiredTokenCacheService } from './clean-expired-token-cache.service';
import { TokenCacheService } from '../../../auth/entities/token-cache/token-cache.service';
import { AGENDA_TOKEN } from '../../../system-settings/providers/agenda.provider';
import { FrappeSyncDataImportJobService } from './frappe-sync-data-import-jobs-queue.service';
import { DeliveryNoteJobService } from '../../../delivery-note/schedular/delivery-note-job/delivery-note-job.service';
describe('CleanExpiredTokenCacheService', () => {
let service: CleanExpiredTokenCacheService;
describe('FrappeSyncDataImportJobService', () => {
let service: FrappeSyncDataImportJobService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CleanExpiredTokenCacheService,
{ provide: TokenCacheService, useValue: {} },
FrappeSyncDataImportJobService,
{ provide: AGENDA_TOKEN, useValue: {} },
{
provide: DeliveryNoteJobService,
useValue: {},
},
],
}).compile();
service = module.get<CleanExpiredTokenCacheService>(
CleanExpiredTokenCacheService,
service = module.get<FrappeSyncDataImportJobService>(
FrappeSyncDataImportJobService,
);
});
......
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
import * as Agenda from 'agenda';
import { AGENDA_TOKEN } from '../../../system-settings/providers/agenda.provider';
import {
FRAPPE_SYNC_DATA_IMPORT_QUEUE_JOB,
AGENDA_JOB_STATUS,
} from '../../../constants/app-strings';
import { DateTime } from 'luxon';
import { DeliveryNoteJobService } from '../../../delivery-note/schedular/delivery-note-job/delivery-note-job.service';
@Injectable()
export class FrappeSyncDataImportJobService implements OnModuleInit {
constructor(
@Inject(AGENDA_TOKEN)
private readonly agenda: Agenda,
public readonly SYNC_DELIVERY_NOTE_JOB: DeliveryNoteJobService,
) {}
async onModuleInit() {
this.agenda.define(
FRAPPE_SYNC_DATA_IMPORT_QUEUE_JOB,
{ concurrency: 1 },
async (job: any, done) => {
// Please note done callback will work only when concurrency is provided.
this[job.attrs.data.type]
.syncImport(job.attrs.data)
.toPromise()
.then(success => {
job.attrs.data.status = AGENDA_JOB_STATUS.success;
return done();
})
.catch(err => {
job.attrs.data.status = AGENDA_JOB_STATUS.retrying;
return done(this.getPureError(err));
});
},
);
this.agenda.on(`fail:${FRAPPE_SYNC_DATA_IMPORT_QUEUE_JOB}`, (error, job) =>
this.onJobFailure(error, job),
);
}
async onJobFailure(error: any, job: Agenda.Job<any>) {
const retryCount = job.attrs.failCount - 1;
if (!(error && error.log_details)) {
job.attrs.data.status = AGENDA_JOB_STATUS.retrying;
job.attrs.nextRunAt = this.getBackOff(
retryCount,
job.attrs.data.settings.timeZone,
);
} else {
job.attrs.data.status = AGENDA_JOB_STATUS.fail;
}
await job.save();
}
getBackOff(retryCount: number, timeZone): Date {
return new DateTime(timeZone)
.plus({ seconds: retryCount === 0 ? 150 : 300 })
.toJSDate();
}
replaceErrors(keys, value) {
if (value instanceof Error) {
const error = {};
Object.getOwnPropertyNames(value).forEach(function (key) {
error[key] = value[key];
});
return error;
}
return value;
}
getPureError(error) {
if (error && error.response) {
error = error.response.data ? error.response.data : error.response;
}
try {
return JSON.parse(JSON.stringify(error, this.replaceErrors));
} catch {
return error.data ? error.data : error;
}
}
}
export class DataImportSuccessResponseInterface {
name: string;
reference_doctype: string;
action: string;
doctype: string;
import_status: string;
insert_new: number;
log_details: string;
}
export class FileUploadSuccessResponseInterface {
file_name: string;
is_private: number;
file_url: string;
folder: string;
attached_to_doctype: string;
attached_to_name: string;
doctype: string;
}
import { Injectable, HttpService } from '@nestjs/common';
import {
DATA_IMPORT_API_ENDPOINT,
FRAPPE_FILE_ATTACH_API_ENDPOINT,
FRAPPE_START_DATA_IMPORT_API_ENDPOINT,
} from '../../../constants/routes';
import { ServerSettings } from '../../../system-settings/entities/server-settings/server-settings.entity';
import { TokenCache } from '../../../auth/entities/token-cache/token-cache.entity';
import { switchMap, map } from 'rxjs/operators';
import {
DataImportSuccessResponseInterface,
FileUploadSuccessResponseInterface,
} from './data-import.interface';
import {
FRAPPE_DATA_IMPORT_INSERT_ACTION,
AUTHORIZATION,
BEARER_HEADER_VALUE_PREFIX,
ACCEPT,
APPLICATION_JSON_CONTENT_TYPE,
} from '../../../constants/app-strings';
import { of } from 'rxjs';
@Injectable()
export class DataImportService {
constructor(private readonly http: HttpService) {}
addDataImport(
reference_doctype: string,
payload: string,
settings: ServerSettings,
token: TokenCache,
) {
const response: DataImportSuccessResponse = {};
const base64Buffer = Buffer.from(payload);
return this.http
.post(
settings.authServerURL + DATA_IMPORT_API_ENDPOINT,
{ reference_doctype, action: FRAPPE_DATA_IMPORT_INSERT_ACTION },
{ headers: this.getAuthorizationHeaders(token) },
)
.pipe(
map(data => data.data.data),
switchMap((data: DataImportSuccessResponseInterface) => {
response.dataImportName = data.name;
return this.http.post(
settings.authServerURL + FRAPPE_FILE_ATTACH_API_ENDPOINT,
{
filename: `data_import.csv`,
doctype: data.doctype,
docname: data.name,
is_private: 1,
decode_base64: 1,
filedata: base64Buffer.toString('base64'),
},
{ headers: this.getAuthorizationHeaders(token) },
);
}),
map(data => data.data.message),
switchMap((success: FileUploadSuccessResponseInterface) => {
response.file_name = success.file_name;
response.file_url = success.file_url;
return this.http.put(
settings.authServerURL +
DATA_IMPORT_API_ENDPOINT +
`/${success.attached_to_name}`,
{ import_file: success.file_url, submit_after_import: 1 },
{ headers: this.getAuthorizationHeaders(token) },
);
}),
map(data => data.data.data),
switchMap(submitted_doc => {
return this.http.post(
settings.authServerURL + FRAPPE_START_DATA_IMPORT_API_ENDPOINT,
{ data_import: submitted_doc.name },
{ headers: this.getAuthorizationHeaders(token) },
);
}),
switchMap(done => {
return of(response);
}),
);
}
getAuthorizationHeaders(token: TokenCache) {
const headers: any = {};
headers[AUTHORIZATION] = BEARER_HEADER_VALUE_PREFIX + token.accessToken;
headers[ACCEPT] = APPLICATION_JSON_CONTENT_TYPE;
return headers;
}
}
export class DataImportSuccessResponse {
dataImportName?: string;
file_name?: string;
file_url?: string;
}
import { Injectable } from '@nestjs/common';
import {
DELIVERY_NOTE_CSV_TEMPLATE_HEADERS,
DELIVERY_NOTE_CSV_TEMPLATE,
} from '../../assets/data_import_template';
@Injectable()
export class JsonToCsvParserService {
mapJsonToCsv(data: any) {
let row = '';
DELIVERY_NOTE_CSV_TEMPLATE_HEADERS.forEach((key: any) => {
if (data[key]) {
row = row + `${data[key]},`;
} else if (typeof key === 'object') {
const value = data[key[0]][key[1]][key[2]];
row = row + `"${value ? value : ''}",`;
} else {
row = row + ',';
}
});
return DELIVERY_NOTE_CSV_TEMPLATE + row;
}
}
......@@ -7,6 +7,9 @@ import { StockEntryModule } from '../stock-entry/stock-entry.module';
import { DeliveryNoteModule } from '../delivery-note/delivery-note.module';
import { FrappeJobService } from './schedular/frappe-jobs-queue/frappe-jobs-queue.service';
import { SerialBatchService } from './aggregates/serial-batch/serial-batch.service';
import { DataImportService } from './service/data-import/data-import.service';
import { JsonToCsvParserService } from './service/data-import/json-to-csv-parser.service';
import { FrappeSyncDataImportJobService } from './schedular/frappe-sync-data-import-jobs-queue/frappe-sync-data-import-jobs-queue.service';
@Module({
imports: [
......@@ -17,7 +20,21 @@ import { SerialBatchService } from './aggregates/serial-batch/serial-batch.servi
StockEntryModule,
DeliveryNoteModule,
],
providers: [SyncAggregateService, FrappeJobService, SerialBatchService],
exports: [SyncAggregateService, FrappeJobService, SerialBatchService],
providers: [
SyncAggregateService,
FrappeJobService,
SerialBatchService,
DataImportService,
JsonToCsvParserService,
FrappeSyncDataImportJobService,
],
exports: [
SyncAggregateService,
FrappeJobService,
SerialBatchService,
DataImportService,
JsonToCsvParserService,
FrappeSyncDataImportJobService,
],
})
export class SyncModule {}