mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-21 15:20:34 +00:00
feat: Re-compute the given items cost job.
feat: Optimize the architecture.
This commit is contained in:
@@ -28,5 +28,27 @@ module.exports = {
|
|||||||
secure: false,
|
secure: false,
|
||||||
username: '842f331d3dc005',
|
username: '842f331d3dc005',
|
||||||
password: '172f97b34f1a17',
|
password: '172f97b34f1a17',
|
||||||
}
|
},
|
||||||
|
mongoDb: {
|
||||||
|
/**
|
||||||
|
* That long string from mlab
|
||||||
|
*/
|
||||||
|
databaseURL: 'mongodb://localhost/bigcapital',
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Agenda.js stuff
|
||||||
|
*/
|
||||||
|
agenda: {
|
||||||
|
dbCollection: process.env.AGENDA_DB_COLLECTION,
|
||||||
|
pooltime: process.env.AGENDA_POOL_TIME,
|
||||||
|
concurrency: parseInt(process.env.AGENDA_CONCURRENCY, 10),
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agendash config
|
||||||
|
*/
|
||||||
|
agendash: {
|
||||||
|
user: 'agendash',
|
||||||
|
password: '123456'
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "moosher-server",
|
"name": "bigcapital-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hapi/boom": "^7.4.3",
|
"@hapi/boom": "^7.4.3",
|
||||||
|
"agenda": "^3.1.0",
|
||||||
|
"agendash": "^1.0.0",
|
||||||
"app-root-path": "^3.0.0",
|
"app-root-path": "^3.0.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bookshelf": "^0.15.1",
|
"bookshelf": "^0.15.1",
|
||||||
@@ -28,7 +30,9 @@
|
|||||||
"csurf": "^1.10.0",
|
"csurf": "^1.10.0",
|
||||||
"dotenv": "^8.1.0",
|
"dotenv": "^8.1.0",
|
||||||
"errorhandler": "^1.5.1",
|
"errorhandler": "^1.5.1",
|
||||||
|
"event-dispatch": "^0.4.1",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
|
"express-basic-auth": "^1.2.0",
|
||||||
"express-boom": "^3.0.0",
|
"express-boom": "^3.0.0",
|
||||||
"express-fileupload": "^1.1.7-alpha.3",
|
"express-fileupload": "^1.1.7-alpha.3",
|
||||||
"express-oauth-server": "^2.0.0",
|
"express-oauth-server": "^2.0.0",
|
||||||
@@ -43,6 +47,7 @@
|
|||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"moment": "^2.24.0",
|
"moment": "^2.24.0",
|
||||||
"moment-range": "^4.0.2",
|
"moment-range": "^4.0.2",
|
||||||
|
"mongoose": "^5.10.0",
|
||||||
"mustache": "^3.0.3",
|
"mustache": "^3.0.3",
|
||||||
"mysql": "^2.17.1",
|
"mysql": "^2.17.1",
|
||||||
"mysql2": "^1.6.5",
|
"mysql2": "^1.6.5",
|
||||||
@@ -83,6 +88,7 @@
|
|||||||
"nyc": "^14.1.1",
|
"nyc": "^14.1.1",
|
||||||
"sinon": "^7.4.2",
|
"sinon": "^7.4.2",
|
||||||
"ts-loader": "^8.0.1",
|
"ts-loader": "^8.0.1",
|
||||||
|
"typedi": "^0.8.0",
|
||||||
"typescript": "^3.9.7",
|
"typescript": "^3.9.7",
|
||||||
"webpack": "^4.0.0",
|
"webpack": "^4.0.0",
|
||||||
"webpack-cli": "^3.3.7",
|
"webpack-cli": "^3.3.7",
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import helmet from 'helmet';
|
|
||||||
import boom from 'express-boom';
|
|
||||||
import i18n from 'i18n';
|
|
||||||
import rootPath from 'app-root-path';
|
|
||||||
import fileUpload from 'express-fileupload';
|
|
||||||
import '../config';
|
|
||||||
import '@/database/objection';
|
|
||||||
import routes from '@/http';
|
|
||||||
|
|
||||||
global.rootPath = rootPath.path;
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
// i18n.configure({
|
|
||||||
// locales: ['en'],
|
|
||||||
// directory: `${__dirname}/resources/locale`,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// // i18n init parses req for language headers, cookies, etc.
|
|
||||||
// app.use(i18n.init);
|
|
||||||
|
|
||||||
// Express configuration
|
|
||||||
app.set('port', process.env.PORT || 3000);
|
|
||||||
|
|
||||||
app.use(helmet());
|
|
||||||
app.use(boom());
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(fileUpload({
|
|
||||||
createParentPath: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
routes(app);
|
|
||||||
|
|
||||||
export default app;
|
|
||||||
@@ -4,9 +4,9 @@ import knexfile from '@/../config/systemKnexfile';
|
|||||||
|
|
||||||
const config = knexfile[process.env.NODE_ENV];
|
const config = knexfile[process.env.NODE_ENV];
|
||||||
|
|
||||||
const knex = Knex({
|
export default () => {
|
||||||
|
return Knex({
|
||||||
...config,
|
...config,
|
||||||
...knexSnakeCaseMappers({ upperCase: true }),
|
...knexSnakeCaseMappers({ upperCase: true }),
|
||||||
});
|
});
|
||||||
|
};
|
||||||
export default knex;
|
|
||||||
@@ -6,9 +6,9 @@ exports.up = function(knex) {
|
|||||||
|
|
||||||
table.string('direction');
|
table.string('direction');
|
||||||
|
|
||||||
table.integer('item_id');
|
table.integer('item_id').unsigned();
|
||||||
table.integer('quantity');
|
table.integer('quantity').unsigned();
|
||||||
table.decimal('rate', 13, 3);
|
table.decimal('rate', 13, 3).unsigned();
|
||||||
|
|
||||||
table.string('transaction_type');
|
table.string('transaction_type');
|
||||||
table.integer('transaction_id');
|
table.integer('transaction_id');
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ exports.up = function(knex) {
|
|||||||
|
|
||||||
table.string('direction');
|
table.string('direction');
|
||||||
|
|
||||||
table.integer('item_id');
|
table.integer('item_id').unsigned();
|
||||||
|
table.integer('quantity').unsigned();
|
||||||
table.decimal('rate', 13, 3);
|
table.decimal('rate', 13, 3);
|
||||||
table.integer('remaining');
|
table.integer('remaining');
|
||||||
|
table.string('lot_number');
|
||||||
|
|
||||||
table.string('transaction_type');
|
table.string('transaction_type');
|
||||||
table.integer('transaction_id');
|
table.integer('transaction_id');
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Model } from 'objection';
|
import { Model } from 'objection';
|
||||||
import knex from '@/database/knex';
|
|
||||||
|
|
||||||
// Bind all Models to a knex instance. If you only have one database in
|
// Bind all Models to a knex instance. If you only have one database in
|
||||||
// your server this is all you have to do. For multi database systems, see
|
// your server this is all you have to do. For multi database systems, see
|
||||||
// the Model.bindKnex() method.
|
// the Model.bindKnex() method.
|
||||||
Model.knex(knex);
|
export default ({ knex }) => {
|
||||||
|
Model.knex(knex);
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ exports.seed = (knex) => {
|
|||||||
return knex('resources').insert([
|
return knex('resources').insert([
|
||||||
{ id: 1, name: 'accounts' },
|
{ id: 1, name: 'accounts' },
|
||||||
{ id: 8, name: 'accounts_types' },
|
{ id: 8, name: 'accounts_types' },
|
||||||
|
|
||||||
{ id: 2, name: 'items' },
|
{ id: 2, name: 'items' },
|
||||||
{ id: 3, name: 'expenses' },
|
{ id: 3, name: 'expenses' },
|
||||||
{ id: 4, name: 'manual_journals' },
|
{ id: 4, name: 'manual_journals' },
|
||||||
|
|||||||
16
server/src/decorators/eventDispatcher.ts
Normal file
16
server/src/decorators/eventDispatcher.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Originally taken from 'w3tecch/express-typescript-boilerplate'
|
||||||
|
* Credits to the author
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventDispatcher as EventDispatcherClass } from 'event-dispatch';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
|
export function EventDispatcher() {
|
||||||
|
return (object: any, propertyName: string, index?: number): void => {
|
||||||
|
const eventDispatcher = new EventDispatcherClass();
|
||||||
|
Container.registerHandler({ object, propertyName, index, value: () => eventDispatcher });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EventDispatcher as EventDispatcherInterface } from 'event-dispatch';
|
||||||
25
server/src/http/controllers/Agendash.ts
Normal file
25
server/src/http/controllers/Agendash.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
import { Router } from 'express'
|
||||||
|
import basicAuth from 'express-basic-auth';
|
||||||
|
import agendash from 'agendash'
|
||||||
|
import { Container } from 'typedi'
|
||||||
|
import config from '@/../config/config'
|
||||||
|
|
||||||
|
export default class AgendashController {
|
||||||
|
|
||||||
|
static router() {
|
||||||
|
const router = Router();
|
||||||
|
const agendaInstance = Container.get('agenda')
|
||||||
|
|
||||||
|
router.use('/dash',
|
||||||
|
basicAuth({
|
||||||
|
users: {
|
||||||
|
[config.agendash.user]: config.agendash.password,
|
||||||
|
},
|
||||||
|
challenge: true,
|
||||||
|
}),
|
||||||
|
agendash(agendaInstance)
|
||||||
|
);
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,512 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import { check, query, validationResult } from 'express-validator';
|
|
||||||
import { difference } from 'lodash';
|
|
||||||
import fs from 'fs';
|
|
||||||
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
|
|
||||||
import {
|
|
||||||
mapViewRolesToConditionals,
|
|
||||||
mapFilterRolesToDynamicFilter,
|
|
||||||
} from '@/lib/ViewRolesBuilder';
|
|
||||||
import {
|
|
||||||
DynamicFilter,
|
|
||||||
DynamicFilterSortBy,
|
|
||||||
DynamicFilterViews,
|
|
||||||
DynamicFilterFilterRoles,
|
|
||||||
} from '@/lib/DynamicFilter';
|
|
||||||
import Logger from '@/services/Logger';
|
|
||||||
|
|
||||||
const fsPromises = fs.promises;
|
|
||||||
|
|
||||||
|
|
||||||
export default {
|
|
||||||
/**
|
|
||||||
* Router constructor.
|
|
||||||
*/
|
|
||||||
router() {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.post('/:id',
|
|
||||||
this.editItem.validation,
|
|
||||||
asyncMiddleware(this.editItem.handler)
|
|
||||||
);
|
|
||||||
router.post('/',
|
|
||||||
this.newItem.validation,
|
|
||||||
asyncMiddleware(this.newItem.handler)
|
|
||||||
);
|
|
||||||
router.delete('/:id',
|
|
||||||
this.deleteItem.validation,
|
|
||||||
asyncMiddleware(this.deleteItem.handler)
|
|
||||||
);
|
|
||||||
router.delete('/',
|
|
||||||
this.bulkDeleteItems.validation,
|
|
||||||
asyncMiddleware(this.bulkDeleteItems.handler)
|
|
||||||
);
|
|
||||||
router.get('/',
|
|
||||||
this.listItems.validation,
|
|
||||||
asyncMiddleware(this.listItems.handler)
|
|
||||||
);
|
|
||||||
return router;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new item.
|
|
||||||
*/
|
|
||||||
newItem: {
|
|
||||||
validation: [
|
|
||||||
check('name').exists(),
|
|
||||||
check('type').exists().trim().escape()
|
|
||||||
.isIn(['service', 'non-inventory', 'inventory']),
|
|
||||||
check('sku').optional({ nullable: true }).trim().escape(),
|
|
||||||
|
|
||||||
check('purchasable').exists().isBoolean().toBoolean(),
|
|
||||||
check('sellable').exists().isBoolean().toBoolean(),
|
|
||||||
|
|
||||||
check('cost_price').exists().isNumeric().toFloat(),
|
|
||||||
check('sell_price').exists().isNumeric().toFloat(),
|
|
||||||
check('cost_account_id').exists().isInt().toInt(),
|
|
||||||
check('sell_account_id').exists().isInt().toInt(),
|
|
||||||
check('inventory_account_id')
|
|
||||||
.if(check('type').equals('inventory'))
|
|
||||||
.exists()
|
|
||||||
.isInt()
|
|
||||||
.toInt(),
|
|
||||||
|
|
||||||
check('sell_description').optional().trim().escape(),
|
|
||||||
check('cost_description').optional().trim().escape(),
|
|
||||||
|
|
||||||
check('category_id').optional({ nullable: true }).isInt().toInt(),
|
|
||||||
|
|
||||||
check('custom_fields').optional().isArray({ min: 1 }),
|
|
||||||
check('custom_fields.*.key').exists().isNumeric().toInt(),
|
|
||||||
check('custom_fields.*.value').exists(),
|
|
||||||
|
|
||||||
check('note').optional(),
|
|
||||||
|
|
||||||
check('media_ids').optional().isArray(),
|
|
||||||
check('media_ids.*').exists().isNumeric().toInt(),
|
|
||||||
],
|
|
||||||
async handler(req, res) {
|
|
||||||
const validationErrors = validationResult(req);
|
|
||||||
|
|
||||||
if (!validationErrors.isEmpty()) {
|
|
||||||
return res.boom.badData(null, {
|
|
||||||
code: 'validation_error', ...validationErrors,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const { user } = req;
|
|
||||||
const form = {
|
|
||||||
custom_fields: [],
|
|
||||||
media_ids: [],
|
|
||||||
...req.body,
|
|
||||||
};
|
|
||||||
const {
|
|
||||||
Account,
|
|
||||||
Resource,
|
|
||||||
ResourceField,
|
|
||||||
ItemCategory,
|
|
||||||
Item,
|
|
||||||
MediaLink,
|
|
||||||
} = req.models;
|
|
||||||
const errorReasons = [];
|
|
||||||
|
|
||||||
const costAccountPromise = Account.query().findById(form.cost_account_id);
|
|
||||||
const sellAccountPromise = Account.query().findById(form.sell_account_id);
|
|
||||||
const inventoryAccountPromise = (form.type === 'inventory')
|
|
||||||
? Account.query().findById(form.inventory_account_id) : null;
|
|
||||||
|
|
||||||
const itemCategoryPromise = (form.category_id)
|
|
||||||
? ItemCategory.query().findById(form.category_id) : null;
|
|
||||||
|
|
||||||
// Validate the custom fields key and value type.
|
|
||||||
if (form.custom_fields.length > 0) {
|
|
||||||
const customFieldsKeys = form.custom_fields.map((field) => field.key);
|
|
||||||
|
|
||||||
// Get resource id than get all resource fields.
|
|
||||||
const resource = await Resource.where('name', 'items').fetch();
|
|
||||||
const fields = await ResourceField.query((builder) => {
|
|
||||||
builder.where('resource_id', resource.id);
|
|
||||||
builder.whereIn('key', customFieldsKeys);
|
|
||||||
}).fetchAll();
|
|
||||||
|
|
||||||
const storedFieldsKey = fields.map((f) => f.attributes.key);
|
|
||||||
|
|
||||||
// Get all not defined resource fields.
|
|
||||||
const notFoundFields = difference(customFieldsKeys, storedFieldsKey);
|
|
||||||
|
|
||||||
if (notFoundFields.length > 0) {
|
|
||||||
errorReasons.push({ type: 'FIELD_KEY_NOT_FOUND', code: 150, fields: notFoundFields });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const [
|
|
||||||
costAccount,
|
|
||||||
sellAccount,
|
|
||||||
itemCategory,
|
|
||||||
inventoryAccount,
|
|
||||||
] = await Promise.all([
|
|
||||||
costAccountPromise,
|
|
||||||
sellAccountPromise,
|
|
||||||
itemCategoryPromise,
|
|
||||||
inventoryAccountPromise,
|
|
||||||
]);
|
|
||||||
if (!costAccount) {
|
|
||||||
errorReasons.push({ type: 'COST_ACCOUNT_NOT_FOUND', code: 100 });
|
|
||||||
}
|
|
||||||
if (!sellAccount) {
|
|
||||||
errorReasons.push({ type: 'SELL_ACCOUNT_NOT_FOUND', code: 120 });
|
|
||||||
}
|
|
||||||
if (!itemCategory && form.category_id) {
|
|
||||||
errorReasons.push({ type: 'ITEM_CATEGORY_NOT_FOUND', code: 140 });
|
|
||||||
}
|
|
||||||
if (!inventoryAccount && form.type === 'inventory') {
|
|
||||||
errorReasons.push({ type: 'INVENTORY_ACCOUNT_NOT_FOUND', code: 150 });
|
|
||||||
}
|
|
||||||
if (errorReasons.length > 0) {
|
|
||||||
return res.boom.badRequest(null, { errors: errorReasons });
|
|
||||||
}
|
|
||||||
|
|
||||||
const bulkSaveMediaLinks = [];
|
|
||||||
const item = await Item.query().insertAndFetch({
|
|
||||||
name: form.name,
|
|
||||||
type: form.type,
|
|
||||||
sku: form.sku,
|
|
||||||
cost_price: form.cost_price,
|
|
||||||
sell_price: form.sell_price,
|
|
||||||
sell_account_id: form.sell_account_id,
|
|
||||||
cost_account_id: form.cost_account_id,
|
|
||||||
currency_code: form.currency_code,
|
|
||||||
category_id: form.category_id,
|
|
||||||
user_id: user.id,
|
|
||||||
note: form.note,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.media_ids.forEach((mediaId) => {
|
|
||||||
const oper = MediaLink.query().insert({
|
|
||||||
model_name: 'Item',
|
|
||||||
media_id: mediaId,
|
|
||||||
model_id: item.id,
|
|
||||||
});
|
|
||||||
bulkSaveMediaLinks.push(oper);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save the media links.
|
|
||||||
await Promise.all([
|
|
||||||
...bulkSaveMediaLinks,
|
|
||||||
]);
|
|
||||||
return res.status(200).send({ id: item.id });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edit the given item.
|
|
||||||
*/
|
|
||||||
editItem: {
|
|
||||||
validation: [
|
|
||||||
check('name').exists(),
|
|
||||||
check('type')
|
|
||||||
.exists()
|
|
||||||
.trim()
|
|
||||||
.escape()
|
|
||||||
.isIn(['product', 'service']),
|
|
||||||
check('cost_price').exists().isNumeric(),
|
|
||||||
check('sell_price').exists().isNumeric(),
|
|
||||||
check('cost_account_id').exists().isInt(),
|
|
||||||
check('sell_account_id').exists().isInt(),
|
|
||||||
check('category_id').optional({ nullable: true }).isInt().toInt(),
|
|
||||||
check('note').optional().trim().escape(),
|
|
||||||
check('attachment').optional(),
|
|
||||||
check('sell_description').optional().trim().escape(),
|
|
||||||
check('cost_description').optional().trim().escape(),
|
|
||||||
check('purchasable').exists().isBoolean().toBoolean(),
|
|
||||||
check('sellable').exists().isBoolean().toBoolean(),
|
|
||||||
],
|
|
||||||
async handler(req, res) {
|
|
||||||
const validationErrors = validationResult(req);
|
|
||||||
|
|
||||||
if (!validationErrors.isEmpty()) {
|
|
||||||
return res.boom.badData(null, {
|
|
||||||
code: 'validation_error', ...validationErrors,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const { Account, Item, ItemCategory, MediaLink } = req.models;
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const form = {
|
|
||||||
custom_fields: [],
|
|
||||||
...req.body,
|
|
||||||
};
|
|
||||||
const item = await Item.query().findById(id).withGraphFetched('media');
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
return res.boom.notFound(null, {
|
|
||||||
errors: [{ type: 'ITEM.NOT.FOUND', code: 100 }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const errorReasons = [];
|
|
||||||
|
|
||||||
const costAccountPromise = Account.query().findById(form.cost_account_id);
|
|
||||||
const sellAccountPromise = Account.query().findById(form.sell_account_id);
|
|
||||||
const itemCategoryPromise = (form.category_id)
|
|
||||||
? ItemCategory.query().findById(form.category_id) : null;
|
|
||||||
|
|
||||||
const [costAccount, sellAccount, itemCategory] = await Promise.all([
|
|
||||||
costAccountPromise, sellAccountPromise, itemCategoryPromise,
|
|
||||||
]);
|
|
||||||
if (!costAccount) {
|
|
||||||
errorReasons.push({ type: 'COST_ACCOUNT_NOT_FOUND', code: 100 });
|
|
||||||
}
|
|
||||||
if (!sellAccount) {
|
|
||||||
errorReasons.push({ type: 'SELL_ACCOUNT_NOT_FOUND', code: 120 });
|
|
||||||
}
|
|
||||||
if (!itemCategory && form.category_id) {
|
|
||||||
errorReasons.push({ type: 'ITEM_CATEGORY_NOT_FOUND', code: 140 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachment = req.files && req.files.attachment ? req.files.attachment : null;
|
|
||||||
const attachmentsMimes = ['image/png', 'image/jpeg'];
|
|
||||||
|
|
||||||
// Validate the attachment.
|
|
||||||
if (attachment && attachmentsMimes.indexOf(attachment.mimetype) === -1) {
|
|
||||||
errorReasons.push({ type: 'ATTACHMENT.MINETYPE.NOT.SUPPORTED', code: 160 });
|
|
||||||
}
|
|
||||||
if (errorReasons.length > 0) {
|
|
||||||
return res.boom.badRequest(null, { errors: errorReasons });
|
|
||||||
}
|
|
||||||
if (attachment) {
|
|
||||||
const publicPath = 'storage/app/public/';
|
|
||||||
const tenantPath = `${publicPath}${req.organizationId}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fsPromises.unlink(`${tenantPath}/${item.attachmentFile}`);
|
|
||||||
} catch (error) {
|
|
||||||
Logger.log('error', 'Delete item attachment file delete failed.', { error });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await attachment.mv(`${tenantPath}/${attachment.md5}.png`);
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).send({
|
|
||||||
errors: [{ type: 'ATTACHMENT.UPLOAD.FAILED', code: 600 }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedItem = await Item.query().findById(id).patch({
|
|
||||||
name: form.name,
|
|
||||||
type: form.type,
|
|
||||||
cost_price: form.cost_price,
|
|
||||||
sell_price: form.sell_price,
|
|
||||||
currency_code: form.currency_code,
|
|
||||||
sell_account_id: form.sell_account_id,
|
|
||||||
cost_account_id: form.cost_account_id,
|
|
||||||
category_id: form.category_id,
|
|
||||||
note: form.note,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save links of new inserted media that associated to the item model.
|
|
||||||
const itemMediaIds = item.media.map((m) => m.id);
|
|
||||||
const newInsertedMedia = difference(form.media_ids, itemMediaIds);
|
|
||||||
const bulkSaveMediaLink = [];
|
|
||||||
|
|
||||||
newInsertedMedia.forEach((mediaId) => {
|
|
||||||
const oper = MediaLink.query().insert({
|
|
||||||
model_name: 'Journal',
|
|
||||||
model_id: manualJournal.id,
|
|
||||||
media_id: mediaId,
|
|
||||||
});
|
|
||||||
bulkSaveMediaLink.push(oper);
|
|
||||||
});
|
|
||||||
await Promise.all([ ...newInsertedMedia ]);
|
|
||||||
|
|
||||||
return res.status(200).send({ id: updatedItem.id });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the given item from the storage.
|
|
||||||
*/
|
|
||||||
deleteItem: {
|
|
||||||
validation: [],
|
|
||||||
async handler(req, res) {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { Item } = req.models;
|
|
||||||
const item = await Item.query().findById(id);
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
return res.boom.notFound(null, {
|
|
||||||
errors: [{ type: 'ITEM_NOT_FOUND', code: 100 }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Delete the fucking the given item id.
|
|
||||||
await Item.query().findById(item.id).delete();
|
|
||||||
|
|
||||||
if (item.attachmentFile) {
|
|
||||||
const publicPath = 'storage/app/public/';
|
|
||||||
const tenantPath = `${publicPath}${req.organizationId}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fsPromises.unlink(`${tenantPath}/${item.attachmentFile}`);
|
|
||||||
} catch (error) {
|
|
||||||
Logger.log('error', 'Delete item attachment file delete failed.', { error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res.status(200).send();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bulk delete the given items ids.
|
|
||||||
*/
|
|
||||||
bulkDeleteItems: {
|
|
||||||
validation: [
|
|
||||||
query('ids').isArray({ min: 2 }),
|
|
||||||
query('ids.*').isNumeric().toInt(),
|
|
||||||
],
|
|
||||||
async handler(req, res) {
|
|
||||||
const validationErrors = validationResult(req);
|
|
||||||
|
|
||||||
if (!validationErrors.isEmpty()) {
|
|
||||||
return res.boom.badData(null, {
|
|
||||||
code: 'validation_error', ...validationErrors,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const filter = { ids: [], ...req.query };
|
|
||||||
const { Item } = req.models;
|
|
||||||
|
|
||||||
const items = await Item.query().whereIn('id', filter.ids);
|
|
||||||
|
|
||||||
const storedItemsIds = items.map((a) => a.id);
|
|
||||||
const notFoundItems = difference(filter.ids, storedItemsIds);
|
|
||||||
|
|
||||||
// Validate the not found items.
|
|
||||||
if (notFoundItems.length > 0) {
|
|
||||||
return res.status(404).send({
|
|
||||||
errors: [{ type: 'ITEMS.NOT.FOUND', code: 200, ids: notFoundItems }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the given items ids.
|
|
||||||
await Item.query().whereIn('id', storedItemsIds).delete();
|
|
||||||
|
|
||||||
return res.status(200).send();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrive the list items with pagination meta.
|
|
||||||
*/
|
|
||||||
listItems: {
|
|
||||||
validation: [
|
|
||||||
query('column_sort_order').optional().isIn(['created_at', 'name', 'amount', 'sku']),
|
|
||||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
|
||||||
query('page').optional().isNumeric().toInt(),
|
|
||||||
query('page_size').optional().isNumeric().toInt(),
|
|
||||||
query('custom_view_id').optional().isNumeric().toInt(),
|
|
||||||
query('stringified_filter_roles').optional().isJSON(),
|
|
||||||
],
|
|
||||||
async handler(req, res) {
|
|
||||||
const validationErrors = validationResult(req);
|
|
||||||
|
|
||||||
if (!validationErrors.isEmpty()) {
|
|
||||||
return res.boom.badData(null, {
|
|
||||||
code: 'validation_error', ...validationErrors,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const errorReasons = [];
|
|
||||||
const viewConditions = [];
|
|
||||||
const { Resource, Item, View } = req.models;
|
|
||||||
const itemsResource = await Resource.query()
|
|
||||||
.where('name', 'items')
|
|
||||||
.withGraphFetched('fields')
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (!itemsResource) {
|
|
||||||
return res.status(400).send({
|
|
||||||
errors: [{ type: 'ITEMS_RESOURCE_NOT_FOUND', code: 200 }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const filter = {
|
|
||||||
column_sort_order: '',
|
|
||||||
sort_order: '',
|
|
||||||
page: 1,
|
|
||||||
page_size: 10,
|
|
||||||
custom_view_id: null,
|
|
||||||
filter_roles: [],
|
|
||||||
...req.query,
|
|
||||||
};
|
|
||||||
if (filter.stringified_filter_roles) {
|
|
||||||
filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
|
|
||||||
}
|
|
||||||
|
|
||||||
const view = await View.query().onBuild((builder) => {
|
|
||||||
if (filter.custom_view_id) {
|
|
||||||
builder.where('id', filter.custom_view_id);
|
|
||||||
} else {
|
|
||||||
builder.where('favourite', true);
|
|
||||||
}
|
|
||||||
builder.where('resource_id', itemsResource.id);
|
|
||||||
builder.withGraphFetched('roles.field');
|
|
||||||
builder.withGraphFetched('columns');
|
|
||||||
builder.first();
|
|
||||||
});
|
|
||||||
const resourceFieldsKeys = itemsResource.fields.map((c) => c.key);
|
|
||||||
const dynamicFilter = new DynamicFilter(Item.tableName);
|
|
||||||
|
|
||||||
// Dynamic filter with view roles.
|
|
||||||
if (view && view.roles.length > 0) {
|
|
||||||
const viewFilter = new DynamicFilterViews(
|
|
||||||
mapViewRolesToConditionals(view.roles),
|
|
||||||
view.rolesLogicExpression,
|
|
||||||
);
|
|
||||||
if (!viewFilter.validateFilterRoles()) {
|
|
||||||
errorReasons.push({ type: 'VIEW.LOGIC.EXPRESSION.INVALID', code: 400 });
|
|
||||||
}
|
|
||||||
dynamicFilter.setFilter(viewFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic filter with filter roles.
|
|
||||||
if (filter.filter_roles.length > 0) {
|
|
||||||
// Validate the accounts resource fields.
|
|
||||||
const filterRoles = new DynamicFilterFilterRoles(
|
|
||||||
mapFilterRolesToDynamicFilter(filter.filter_roles),
|
|
||||||
itemsResource.fields,
|
|
||||||
);
|
|
||||||
dynamicFilter.setFilter(filterRoles);
|
|
||||||
|
|
||||||
if (filterRoles.validateFilterRoles().length > 0) {
|
|
||||||
errorReasons.push({ type: 'ITEMS.RESOURCE.HAS.NO.FIELDS', code: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic filter with column sort order.
|
|
||||||
if (filter.column_sort_order) {
|
|
||||||
if (resourceFieldsKeys.indexOf(filter.column_sort_order) === -1) {
|
|
||||||
errorReasons.push({ type: 'COLUMN.SORT.ORDER.NOT.FOUND', code: 300 });
|
|
||||||
}
|
|
||||||
const sortByFilter = new DynamicFilterSortBy(
|
|
||||||
filter.column_sort_order,
|
|
||||||
filter.sort_order,
|
|
||||||
);
|
|
||||||
dynamicFilter.setFilter(sortByFilter);
|
|
||||||
}
|
|
||||||
if (errorReasons.length > 0) {
|
|
||||||
return res.status(400).send({ errors: errorReasons });
|
|
||||||
}
|
|
||||||
const items = await Item.query().onBuild((builder) => {
|
|
||||||
builder.withGraphFetched('costAccount');
|
|
||||||
builder.withGraphFetched('sellAccount');
|
|
||||||
builder.withGraphFetched('inventoryAccount');
|
|
||||||
builder.withGraphFetched('category');
|
|
||||||
|
|
||||||
dynamicFilter.buildQuery()(builder);
|
|
||||||
}).pagination(filter.page - 1, filter.page_size);
|
|
||||||
|
|
||||||
return res.status(200).send({
|
|
||||||
items,
|
|
||||||
...(view) && {
|
|
||||||
customViewId: view.id,
|
|
||||||
viewColumns: view.columns,
|
|
||||||
viewConditions,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
409
server/src/http/controllers/Items.ts
Normal file
409
server/src/http/controllers/Items.ts
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { check, param, query, oneOf, ValidationChain } from 'express-validator';
|
||||||
|
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
|
||||||
|
import validateMiddleware from '@/http/middleware/validateMiddleware';
|
||||||
|
import ItemsService from '@/services/Items/ItemsService';
|
||||||
|
import DynamicListing from '@/services/DynamicListing/DynamicListing';
|
||||||
|
import DynamicListingBuilder from '@/services/DynamicListing/DynamicListingBuilder';
|
||||||
|
import { dynamicListingErrorsToResponse } from '@/services/DynamicListing/hasDynamicListing';
|
||||||
|
|
||||||
|
export default class ItemsController {
|
||||||
|
/**
|
||||||
|
* Router constructor.
|
||||||
|
*/
|
||||||
|
static router() {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
this.validateItemSchema,
|
||||||
|
validateMiddleware,
|
||||||
|
asyncMiddleware(this.validateCategoryExistance),
|
||||||
|
asyncMiddleware(this.validateCostAccountExistance),
|
||||||
|
asyncMiddleware(this.validateSellAccountExistance),
|
||||||
|
asyncMiddleware(this.validateInventoryAccountExistance),
|
||||||
|
asyncMiddleware(this.validateItemNameExistance),
|
||||||
|
asyncMiddleware(this.newItem),
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/:id', [
|
||||||
|
...this.validateItemSchema,
|
||||||
|
...this.validateSpecificItemSchema,
|
||||||
|
],
|
||||||
|
validateMiddleware,
|
||||||
|
asyncMiddleware(this.validateItemExistance),
|
||||||
|
asyncMiddleware(this.validateCategoryExistance),
|
||||||
|
asyncMiddleware(this.validateCostAccountExistance),
|
||||||
|
asyncMiddleware(this.validateSellAccountExistance),
|
||||||
|
asyncMiddleware(this.validateInventoryAccountExistance),
|
||||||
|
asyncMiddleware(this.validateItemNameExistance),
|
||||||
|
asyncMiddleware(this.editItem),
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
this.validateSpecificItemSchema,
|
||||||
|
validateMiddleware,
|
||||||
|
asyncMiddleware(this.validateItemExistance),
|
||||||
|
asyncMiddleware(this.deleteItem),
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
this.validateSpecificItemSchema,
|
||||||
|
validateMiddleware,
|
||||||
|
asyncMiddleware(this.validateItemExistance),
|
||||||
|
asyncMiddleware(this.getItem),
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
this.validateListQuerySchema,
|
||||||
|
validateMiddleware,
|
||||||
|
asyncMiddleware(this.listItems),
|
||||||
|
);
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate item schema.
|
||||||
|
*
|
||||||
|
* @param {Request} req -
|
||||||
|
* @param {Response} res -
|
||||||
|
* @return {ValidationChain[]} - validation chain.
|
||||||
|
*/
|
||||||
|
static get validateItemSchema(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: Function,
|
||||||
|
): ValidationChain[] {
|
||||||
|
return [
|
||||||
|
check('name').exists(),
|
||||||
|
check('type').exists().trim().escape()
|
||||||
|
.isIn(['service', 'non-inventory', 'inventory']),
|
||||||
|
check('sku').optional({ nullable: true }).trim().escape(),
|
||||||
|
|
||||||
|
// Purchase attributes.
|
||||||
|
check('purchasable').optional().isBoolean().toBoolean(),
|
||||||
|
check('cost_price')
|
||||||
|
.if(check('purchasable').equals('true'))
|
||||||
|
.exists()
|
||||||
|
.isNumeric()
|
||||||
|
.toFloat(),
|
||||||
|
check('cost_account_id')
|
||||||
|
.if(check('purchasable').equals('true'))
|
||||||
|
.exists()
|
||||||
|
.isInt()
|
||||||
|
.toInt(),
|
||||||
|
|
||||||
|
// Sell attributes.
|
||||||
|
check('sellable').optional().isBoolean().toBoolean(),
|
||||||
|
check('sell_price')
|
||||||
|
.if(check('sellable').equals('true'))
|
||||||
|
.exists()
|
||||||
|
.isNumeric()
|
||||||
|
.toFloat(),
|
||||||
|
check('sell_account_id')
|
||||||
|
.if(check('sellable').equals('true'))
|
||||||
|
.exists()
|
||||||
|
.isInt()
|
||||||
|
.toInt(),
|
||||||
|
|
||||||
|
check('inventory_account_id')
|
||||||
|
.if(check('type').equals('inventory'))
|
||||||
|
.exists()
|
||||||
|
.isInt()
|
||||||
|
.toInt(),
|
||||||
|
check('sell_description').optional({ nullable: true }).trim().escape(),
|
||||||
|
check('cost_description').optional({ nullable: true }).trim().escape(),
|
||||||
|
|
||||||
|
check('category_id').optional({ nullable: true }).isInt().toInt(),
|
||||||
|
check('note').optional(),
|
||||||
|
|
||||||
|
check('media_ids').optional().isArray(),
|
||||||
|
check('media_ids.*').exists().isNumeric().toInt(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate specific item params schema.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
*/
|
||||||
|
static get validateSpecificItemSchema(): ValidationChain[] {
|
||||||
|
return [
|
||||||
|
param('id').exists().isNumeric().toInt(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static get validateListQuerySchema() {
|
||||||
|
return [
|
||||||
|
query('column_sort_order').optional().isIn(['created_at', 'name', 'amount', 'sku']),
|
||||||
|
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||||
|
query('page').optional().isNumeric().toInt(),
|
||||||
|
query('page_size').optional().isNumeric().toInt(),
|
||||||
|
query('custom_view_id').optional().isNumeric().toInt(),
|
||||||
|
query('stringified_filter_roles').optional().isJSON(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the given item existance on the storage.
|
||||||
|
* @param {Request} req -
|
||||||
|
* @param {Response} res -
|
||||||
|
* @param {NextFunction} next -
|
||||||
|
*/
|
||||||
|
static async validateItemExistance(req: Request, res: Response, next: Function) {
|
||||||
|
const { Item } = req.models;
|
||||||
|
const itemId: number = req.params.id;
|
||||||
|
|
||||||
|
const foundItem = await Item.query().findById(itemId);
|
||||||
|
|
||||||
|
if (!foundItem) {
|
||||||
|
return res.status(400).send({
|
||||||
|
errors: [{ type: 'ITEM.NOT.FOUND', code: 100 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate wether the given item name already exists on the storage.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
*/
|
||||||
|
static async validateItemNameExistance(req: Request, res: Response, next: Function) {
|
||||||
|
const { Item } = req.models;
|
||||||
|
const item = req.body;
|
||||||
|
const itemId: number = req.params.id;
|
||||||
|
|
||||||
|
const foundItems: [] = await Item.query().onBuild((builder: any) => {
|
||||||
|
builder.where('name', item.name);
|
||||||
|
|
||||||
|
if (itemId) {
|
||||||
|
builder.whereNot('id', itemId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (foundItems.length > 0) {
|
||||||
|
return res.status(400).send({
|
||||||
|
errors: [{ type: 'ITEM.NAME.ALREADY.EXISTS', code: 210 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate wether the given category existance on the storage.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {Function} next
|
||||||
|
*/
|
||||||
|
static async validateCategoryExistance(req: Request, res: Response, next: Function) {
|
||||||
|
const { ItemCategory } = req.models;
|
||||||
|
const item = req.body;
|
||||||
|
|
||||||
|
if (item.category_id) {
|
||||||
|
const foundCategory = await ItemCategory.query().findById(item.category_id);
|
||||||
|
|
||||||
|
if (!foundCategory) {
|
||||||
|
return res.status(400).send({
|
||||||
|
errors: [{ type: 'ITEM_CATEGORY.NOT.FOUND', code: 140 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate wether the given cost account exists on the storage.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {Function} next
|
||||||
|
*/
|
||||||
|
static async validateCostAccountExistance(req: Request, res: Response, next: Function) {
|
||||||
|
const { Account } = req.models;
|
||||||
|
const item = req.body;
|
||||||
|
|
||||||
|
if (item.cost_account_id) {
|
||||||
|
const foundAccount = await Account.query().findById(item.cost_account_id);
|
||||||
|
|
||||||
|
if (!foundAccount) {
|
||||||
|
return res.status(400).send({
|
||||||
|
errors: [{ type: 'COST.ACCOUNT.NOT.FOUND', code: 120 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate wether the given sell account exists on the storage.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
*/
|
||||||
|
static async validateSellAccountExistance(req: Request, res: Response, next: Function) {
|
||||||
|
const { Account } = req.models;
|
||||||
|
const item = req.body;
|
||||||
|
|
||||||
|
if (item.sell_account_id) {
|
||||||
|
const foundAccount = await Account.query().findById(item.sell_account_id);
|
||||||
|
|
||||||
|
if (!foundAccount) {
|
||||||
|
return res.status(400).send({
|
||||||
|
errors: [{ type: 'SELL.ACCOUNT.NOT.FOUND', code: 130 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates wether the given inventory account exists on the storage.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
*/
|
||||||
|
static async validateInventoryAccountExistance(req: Request, res: Response, next: Function) {
|
||||||
|
const { Account } = req.models;
|
||||||
|
const item = req.body;
|
||||||
|
|
||||||
|
if (item.inventory_account_id) {
|
||||||
|
const foundAccount = await Account.query().findById(item.inventory_account_id);
|
||||||
|
|
||||||
|
if (!foundAccount) {
|
||||||
|
return res.status(400).send({
|
||||||
|
errors: [{ type: 'INVENTORY.ACCOUNT.NOT.FOUND', code: 200}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the given item details to the storage.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
static async newItem(req: Request, res: Response,) {
|
||||||
|
const item = req.body;
|
||||||
|
const storedItem = await ItemsService.newItem(item);
|
||||||
|
|
||||||
|
return res.status(200).send({ id: storedItem.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the given item details on the storage.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
static async editItem(req: Request, res: Response) {
|
||||||
|
const item = req.body;
|
||||||
|
const itemId: number = req.params.id;
|
||||||
|
const updatedItem = await ItemsService.editItem(item, itemId);
|
||||||
|
|
||||||
|
return res.status(200).send({ id: itemId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the given item from the storage.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
static async deleteItem(req: Request, res: Response) {
|
||||||
|
const itemId: number = req.params.id;
|
||||||
|
await ItemsService.deleteItem(itemId);
|
||||||
|
|
||||||
|
return res.status(200).send({ id: itemId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve details the given item id.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @return {Response}
|
||||||
|
*/
|
||||||
|
static async getItem(req: Request, res: Response) {
|
||||||
|
const itemId: number = req.params.id;
|
||||||
|
const storedItem = await ItemsService.getItemWithMetadata(itemId);
|
||||||
|
|
||||||
|
return res.status(200).send({ item: storedItem });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listing items with pagination metadata.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
static async listItems(req: Request, res: Response) {
|
||||||
|
const filter = {
|
||||||
|
filter_roles: [],
|
||||||
|
sort_order: 'asc',
|
||||||
|
page: 1,
|
||||||
|
page_size: 10,
|
||||||
|
...req.query,
|
||||||
|
};
|
||||||
|
if (filter.stringified_filter_roles) {
|
||||||
|
filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
|
||||||
|
}
|
||||||
|
const { Resource, Item, View } = req.models;
|
||||||
|
const resource = await Resource.query()
|
||||||
|
.remember()
|
||||||
|
.where('name', 'items')
|
||||||
|
.withGraphFetched('fields')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return res.status(400).send({
|
||||||
|
errors: [{ type: 'ITEMS.RESOURCE.NOT_FOUND', code: 200 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const viewMeta = await View.query()
|
||||||
|
.modify('allMetadata')
|
||||||
|
.modify('specificOrFavourite', filter.custom_view_id)
|
||||||
|
.where('resource_id', resource.id)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const listingBuilder = new DynamicListingBuilder();
|
||||||
|
const errorReasons = [];
|
||||||
|
|
||||||
|
listingBuilder.addModelClass(Item);
|
||||||
|
listingBuilder.addCustomViewId(filter.custom_view_id);
|
||||||
|
listingBuilder.addFilterRoles(filter.filter_roles);
|
||||||
|
listingBuilder.addSortBy(filter.sort_by, filter.sort_order);
|
||||||
|
listingBuilder.addView(viewMeta);
|
||||||
|
|
||||||
|
const dynamicListing = new DynamicListing(listingBuilder);
|
||||||
|
|
||||||
|
if (dynamicListing instanceof Error) {
|
||||||
|
const errors = dynamicListingErrorsToResponse(dynamicListing);
|
||||||
|
errorReasons.push(...errors);
|
||||||
|
}
|
||||||
|
if (errorReasons.length > 0) {
|
||||||
|
return res.status(400).send({ errors: errorReasons });
|
||||||
|
}
|
||||||
|
const items = await Item.query().onBuild((builder: any) => {
|
||||||
|
builder.withGraphFetched('costAccount');
|
||||||
|
builder.withGraphFetched('sellAccount');
|
||||||
|
builder.withGraphFetched('inventoryAccount');
|
||||||
|
builder.withGraphFetched('category');
|
||||||
|
|
||||||
|
dynamicListing.buildQuery()(builder);
|
||||||
|
return builder;
|
||||||
|
}).pagination(filter.page - 1, filter.page_size);
|
||||||
|
|
||||||
|
return res.status(200).send({
|
||||||
|
items: {
|
||||||
|
...items,
|
||||||
|
...(viewMeta
|
||||||
|
? {
|
||||||
|
viewMeta: {
|
||||||
|
custom_view_id: viewMeta.id,
|
||||||
|
view_columns: viewMeta.columns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import InventoryService from '@/services/Inventory/Inventory';
|
|
||||||
|
|
||||||
export default class Ping {
|
export default class Ping {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router constur
|
* Router constur
|
||||||
*/
|
*/
|
||||||
@@ -17,46 +15,14 @@ export default class Ping {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* Handle the ping request.
|
||||||
* @param {Request} req
|
* @param {Request} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
static async ping(req: Request, res: Response) {
|
static async ping(req: Request, res: Response)
|
||||||
|
|
||||||
const result = await InventoryService.trackingInventoryLotsCost([
|
|
||||||
{
|
{
|
||||||
id: 1,
|
return res.status(200).send({
|
||||||
date: '2020-02-02',
|
server: true,
|
||||||
direction: 'IN',
|
});
|
||||||
itemId: 1,
|
|
||||||
quantity: 100,
|
|
||||||
rate: 10,
|
|
||||||
transactionType: 'Bill',
|
|
||||||
transactionId: 1,
|
|
||||||
remaining: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
date: '2020-02-02',
|
|
||||||
direction: 'OUT',
|
|
||||||
itemId: 1,
|
|
||||||
quantity: 80,
|
|
||||||
rate: 10,
|
|
||||||
transactionType: 'SaleInvoice',
|
|
||||||
transactionId: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
date: '2020-02-02',
|
|
||||||
direction: 'OUT',
|
|
||||||
itemId: 2,
|
|
||||||
quantity: 500,
|
|
||||||
rate: 10,
|
|
||||||
transactionType: 'SaleInvoice',
|
|
||||||
transactionId: 2,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return res.status(200).send({ id: 1231231 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,12 +23,12 @@ import Media from '@/http/controllers/Media';
|
|||||||
import JWTAuth from '@/http/middleware/jwtAuth';
|
import JWTAuth from '@/http/middleware/jwtAuth';
|
||||||
import TenancyMiddleware from '@/http/middleware/TenancyMiddleware';
|
import TenancyMiddleware from '@/http/middleware/TenancyMiddleware';
|
||||||
import Ping from '@/http/controllers/Ping';
|
import Ping from '@/http/controllers/Ping';
|
||||||
|
import Agendash from '@/http/controllers/Agendash';
|
||||||
|
|
||||||
export default (app) => {
|
export default (app) => {
|
||||||
// app.use('/api/oauth2', OAuth2.router());
|
// app.use('/api/oauth2', OAuth2.router());
|
||||||
app.use('/api/auth', Authentication.router());
|
app.use('/api/auth', Authentication.router());
|
||||||
app.use('/api/invite', InviteUsers.router());
|
app.use('/api/invite', InviteUsers.router());
|
||||||
app.use('/api/ping', Ping.router());
|
|
||||||
|
|
||||||
const dashboard = express.Router();
|
const dashboard = express.Router();
|
||||||
|
|
||||||
@@ -53,6 +53,8 @@ export default (app) => {
|
|||||||
dashboard.use('/api/resources', Resources.router());
|
dashboard.use('/api/resources', Resources.router());
|
||||||
dashboard.use('/api/exchange_rates', ExchangeRates.router());
|
dashboard.use('/api/exchange_rates', ExchangeRates.router());
|
||||||
dashboard.use('/api/media', Media.router());
|
dashboard.use('/api/media', Media.router());
|
||||||
|
dashboard.use('/api/ping', Ping.router());
|
||||||
|
|
||||||
|
app.use('/agendash', Agendash.router());
|
||||||
app.use('/', dashboard);
|
app.use('/', dashboard);
|
||||||
};
|
};
|
||||||
|
|||||||
6
server/src/interfaces/InventoryCostMethod.ts
Normal file
6
server/src/interfaces/InventoryCostMethod.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
interface IInventoryCostMethod {
|
||||||
|
computeItemsCost(fromDate: Date): void,
|
||||||
|
initialize(): void,
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export interface IInventoryLotCost {
|
|||||||
itemId: number,
|
itemId: number,
|
||||||
rate: number,
|
rate: number,
|
||||||
remaining: number,
|
remaining: number,
|
||||||
|
lotNumber: string|number,
|
||||||
transactionType: string,
|
transactionType: string,
|
||||||
transactionId: string,
|
transactionId: string,
|
||||||
}
|
}
|
||||||
11
server/src/interfaces/index.ts
Normal file
11
server/src/interfaces/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IInventoryTransaction, IInventoryLotCost } from './InventoryTransaction';
|
||||||
|
import { IBillPaymentEntry, IBillPayment } from './BillPayment';
|
||||||
|
import { IInventoryCostMethod } from './IInventoryCostMethod';
|
||||||
|
|
||||||
|
export {
|
||||||
|
IBillPaymentEntry,
|
||||||
|
IBillPayment,
|
||||||
|
IInventoryTransaction,
|
||||||
|
IInventoryLotCost,
|
||||||
|
IInventoryCostMethod,
|
||||||
|
};
|
||||||
12
server/src/jobs/ComputeItemCost.ts
Normal file
12
server/src/jobs/ComputeItemCost.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Container } from 'typedi';
|
||||||
|
import InventoryService from '@/services/Inventory/Inventory';
|
||||||
|
|
||||||
|
export default class ComputeItemCostJob {
|
||||||
|
public async handler(job, done: Function): Promise<void> {
|
||||||
|
const Logger = Container.get('logger');
|
||||||
|
const { startingDate, itemId, costMethod } = job.attrs.data;
|
||||||
|
|
||||||
|
await InventoryService.computeItemCost(startingDate, itemId, costMethod);
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
server/src/jobs/welcomeEmail.ts
Normal file
11
server/src/jobs/welcomeEmail.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Container } from 'typedi';
|
||||||
|
import MailerService from '../services/mailer';
|
||||||
|
|
||||||
|
export default class WelcomeEmailJob {
|
||||||
|
public async handler(job, done: Function): Promise<void> {
|
||||||
|
const Logger = Container.get('logger');
|
||||||
|
|
||||||
|
console.log('✌Email Sequence Job triggered!');
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
server/src/loaders/agenda.ts
Normal file
11
server/src/loaders/agenda.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import Agenda from 'agenda';
|
||||||
|
import config from '@/../config/config';
|
||||||
|
|
||||||
|
export default ({ mongoConnection }) => {
|
||||||
|
return new Agenda({
|
||||||
|
mongo: mongoConnection,
|
||||||
|
db: { collection: config.agenda.dbCollection },
|
||||||
|
processEvery: config.agenda.pooltime,
|
||||||
|
maxConcurrency: config.agenda.concurrency,
|
||||||
|
});
|
||||||
|
};
|
||||||
20
server/src/loaders/dependencyInjector.ts
Normal file
20
server/src/loaders/dependencyInjector.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Container } from 'typedi';
|
||||||
|
import LoggerInstance from '@/services/Logger';
|
||||||
|
import agendaFactory from '@/loaders/agenda';
|
||||||
|
|
||||||
|
export default ({ mongoConnection, knex }) => {
|
||||||
|
try {;
|
||||||
|
const agendaInstance = agendaFactory({ mongoConnection });
|
||||||
|
|
||||||
|
Container.set('agenda', agendaInstance);
|
||||||
|
Container.set('logger', LoggerInstance)
|
||||||
|
Container.set('knex', knex);
|
||||||
|
|
||||||
|
LoggerInstance.info('Agenda has been injected into container');
|
||||||
|
|
||||||
|
return { agenda: agendaInstance };
|
||||||
|
} catch (e) {
|
||||||
|
LoggerInstance.error('Error on dependency injector loader: %o', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
0
server/src/loaders/events.ts
Normal file
0
server/src/loaders/events.ts
Normal file
21
server/src/loaders/express.ts
Normal file
21
server/src/loaders/express.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import boom from 'express-boom';
|
||||||
|
import errorHandler from 'errorhandler';
|
||||||
|
import i18n from 'i18n';
|
||||||
|
import fileUpload from 'express-fileupload';
|
||||||
|
import routes from '@/http';
|
||||||
|
|
||||||
|
export default ({ app }) => {
|
||||||
|
// Express configuration.
|
||||||
|
app.set('port', 3000);
|
||||||
|
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(errorHandler());
|
||||||
|
app.use(boom());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(fileUpload({
|
||||||
|
createParentPath: true,
|
||||||
|
}));
|
||||||
|
routes(app);
|
||||||
|
};
|
||||||
32
server/src/loaders/index.ts
Normal file
32
server/src/loaders/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import Logger from '@/services/Logger';
|
||||||
|
import mongooseLoader from '@/loaders/mongoose';
|
||||||
|
import jobsLoader from '@/loaders/jobs';
|
||||||
|
import expressLoader from '@/loaders/express';
|
||||||
|
import databaseLoader from '@/database/knex';
|
||||||
|
import dependencyInjectorLoader from '@/loaders/dependencyInjector';
|
||||||
|
import objectionLoader from '@/database/objection';
|
||||||
|
|
||||||
|
// We have to import at least all the events once so they can be triggered
|
||||||
|
import '@/loaders/events';
|
||||||
|
|
||||||
|
export default async ({ expressApp }) => {
|
||||||
|
const mongoConnection = await mongooseLoader();
|
||||||
|
Logger.info('MongoDB loaded and connected!');
|
||||||
|
|
||||||
|
// Initialize the system database once app started.
|
||||||
|
const knex = databaseLoader();
|
||||||
|
|
||||||
|
// Initialize the objection.js from knex instance.
|
||||||
|
objectionLoader({ knex });
|
||||||
|
|
||||||
|
// It returns the agenda instance because it's needed in the subsequent loaders
|
||||||
|
const { agenda } = await dependencyInjectorLoader({
|
||||||
|
mongoConnection,
|
||||||
|
knex,
|
||||||
|
});
|
||||||
|
await jobsLoader({ agenda });
|
||||||
|
Logger.info('Jobs loaded');
|
||||||
|
|
||||||
|
expressLoader({ app: expressApp });
|
||||||
|
Logger.info('Express loaded');
|
||||||
|
};
|
||||||
17
server/src/loaders/jobs.ts
Normal file
17
server/src/loaders/jobs.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import Agenda from 'agenda';
|
||||||
|
import WelcomeEmailJob from '@/Jobs/welcomeEmail';
|
||||||
|
import ComputeItemCost from '@/Jobs/ComputeItemCost';
|
||||||
|
|
||||||
|
export default ({ agenda }: { agenda: Agenda }) => {
|
||||||
|
agenda.define(
|
||||||
|
'welcome-email',
|
||||||
|
{ priority: 'high' },
|
||||||
|
new WelcomeEmailJob().handler,
|
||||||
|
);
|
||||||
|
agenda.define(
|
||||||
|
'compute-item-cost',
|
||||||
|
{ priority: 'high' },
|
||||||
|
new ComputeItemCost().handler,
|
||||||
|
);
|
||||||
|
agenda.start();
|
||||||
|
};
|
||||||
11
server/src/loaders/mongoose.ts
Normal file
11
server/src/loaders/mongoose.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { Db } from 'mongodb';
|
||||||
|
import config from '@/../config/config';
|
||||||
|
|
||||||
|
export default async (): Promise<Db> => {
|
||||||
|
const connection = await mongoose.connect(
|
||||||
|
config.mongoDb.databaseURL,
|
||||||
|
{ useNewUrlParser: true, useCreateIndex: true },
|
||||||
|
);
|
||||||
|
return connection.connection.db;
|
||||||
|
};
|
||||||
36
server/src/models/InventoryCostLotTracker.js
Normal file
36
server/src/models/InventoryCostLotTracker.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Model } from 'objection';
|
||||||
|
import TenantModel from '@/models/TenantModel';
|
||||||
|
|
||||||
|
export default class InventoryCostLotTracker extends TenantModel {
|
||||||
|
/**
|
||||||
|
* Table name
|
||||||
|
*/
|
||||||
|
static get tableName() {
|
||||||
|
return 'inventory_cost_lot_tracker';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model timestamps.
|
||||||
|
*/
|
||||||
|
static get timestamps() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relationship mapping.
|
||||||
|
*/
|
||||||
|
static get relationMappings() {
|
||||||
|
const Item = require('@/models/Item');
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
relation: Model.BelongsToOneRelation,
|
||||||
|
modelClass: this.relationBindKnex(Item.default),
|
||||||
|
join: {
|
||||||
|
from: 'inventory_cost_lot_tracker.itemId',
|
||||||
|
to: 'items.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Model } from 'objection';
|
|
||||||
import TenantModel from '@/models/TenantModel';
|
|
||||||
|
|
||||||
export default class InventoryLotCostTracker extends TenantModel {
|
|
||||||
/**
|
|
||||||
* Table name
|
|
||||||
*/
|
|
||||||
static get tableName() {
|
|
||||||
return 'inventory_cost_lot_tracker';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Model timestamps.
|
|
||||||
*/
|
|
||||||
static get timestamps() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,4 +15,22 @@ export default class InventoryTransaction extends TenantModel {
|
|||||||
static get timestamps() {
|
static get timestamps() {
|
||||||
return ['createdAt', 'updatedAt'];
|
return ['createdAt', 'updatedAt'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relationship mapping.
|
||||||
|
*/
|
||||||
|
static get relationMappings() {
|
||||||
|
const Item = require('@/models/Item');
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
relation: Model.BelongsToOneRelation,
|
||||||
|
modelClass: this.relationBindKnex(Item.default),
|
||||||
|
join: {
|
||||||
|
from: 'inventory_transactions.itemId',
|
||||||
|
to: 'items.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export default class View extends mixin(TenantModel, [CachableModel]) {
|
|||||||
specificOrFavourite(query, viewId) {
|
specificOrFavourite(query, viewId) {
|
||||||
if (viewId) {
|
if (viewId) {
|
||||||
query.where('id', viewId)
|
query.where('id', viewId)
|
||||||
|
} else {
|
||||||
|
query.where('favourite', true);
|
||||||
}
|
}
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import View from './View';
|
|||||||
import ItemEntry from './ItemEntry';
|
import ItemEntry from './ItemEntry';
|
||||||
import InventoryTransaction from './InventoryTransaction';
|
import InventoryTransaction from './InventoryTransaction';
|
||||||
import AccountType from './AccountType';
|
import AccountType from './AccountType';
|
||||||
|
import InventoryLotCostTracker from './InventoryCostLotTracker';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Customer,
|
Customer,
|
||||||
@@ -41,5 +42,6 @@ export {
|
|||||||
View,
|
View,
|
||||||
ItemEntry,
|
ItemEntry,
|
||||||
InventoryTransaction,
|
InventoryTransaction,
|
||||||
|
InventoryLotCostTracker,
|
||||||
AccountType,
|
AccountType,
|
||||||
};
|
};
|
||||||
@@ -1,15 +1,28 @@
|
|||||||
import errorHandler from 'errorhandler';
|
import express from 'express';
|
||||||
import app from '@/app';
|
import rootPath from 'app-root-path';
|
||||||
|
import loadersFactory from '@/loaders';
|
||||||
|
import '../config';
|
||||||
|
|
||||||
app.use(errorHandler);
|
global.rootPath = rootPath.path;
|
||||||
|
|
||||||
const server = app.listen(app.get('port'), () => {
|
async function startServer() {
|
||||||
console.log(
|
const app = express();
|
||||||
' App is running at http://localhost:%d in %s mode',
|
|
||||||
app.get('port'),
|
|
||||||
app.get('env'),
|
|
||||||
);
|
|
||||||
console.log(' Press CTRL-C to stop');
|
|
||||||
});
|
|
||||||
|
|
||||||
export default server;
|
// Intiialize all registered loaders.
|
||||||
|
await loadersFactory({ expressApp: app });
|
||||||
|
|
||||||
|
app.listen(app.get('port'), (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.log(err);
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`
|
||||||
|
################################################
|
||||||
|
🛡️ Server listening on port: ${app.get('port')} 🛡️
|
||||||
|
################################################
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer();
|
||||||
135
server/src/services/Accounting/JournalCommands.ts
Normal file
135
server/src/services/Accounting/JournalCommands.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { sumBy, chain } from 'lodash';
|
||||||
|
import JournalPoster from "./JournalPoster";
|
||||||
|
import JournalEntry from "./JournalEntry";
|
||||||
|
import { AccountTransaction } from '@/models';
|
||||||
|
import { IInventoryTransaction } from '@/interfaces';
|
||||||
|
import AccountsService from '../Accounts/AccountsService';
|
||||||
|
import { IInventoryTransaction, IInventoryTransaction } from '../../interfaces';
|
||||||
|
|
||||||
|
interface IInventoryCostEntity {
|
||||||
|
date: Date,
|
||||||
|
|
||||||
|
referenceType: string,
|
||||||
|
referenceId: number,
|
||||||
|
|
||||||
|
costAccount: number,
|
||||||
|
incomeAccount: number,
|
||||||
|
inventoryAccount: number,
|
||||||
|
|
||||||
|
inventory: number,
|
||||||
|
cost: number,
|
||||||
|
income: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class JournalCommands{
|
||||||
|
journal: JournalPoster;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor method.
|
||||||
|
* @param {JournalPoster} journal -
|
||||||
|
*/
|
||||||
|
constructor(journal: JournalPoster) {
|
||||||
|
this.journal = journal;
|
||||||
|
Object.assign(this, arguments[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes and revert accounts balance journal entries that associated
|
||||||
|
* to the given inventory transactions.
|
||||||
|
* @param {IInventoryTransaction[]} inventoryTransactions
|
||||||
|
* @param {Journal} journal
|
||||||
|
*/
|
||||||
|
revertEntriesFromInventoryTransactions(inventoryTransactions: IInventoryTransaction[]) {
|
||||||
|
const groupedInvTransactions = chain(inventoryTransactions)
|
||||||
|
.groupBy((invTransaction: IInventoryTransaction) => invTransaction.transactionType)
|
||||||
|
.map((groupedTrans: IInventoryTransaction[], transType: string) => [groupedTrans, transType])
|
||||||
|
.value();
|
||||||
|
|
||||||
|
console.log(groupedInvTransactions);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
groupedInvTransactions.map(async (grouped: [IInventoryTransaction[], string]) => {
|
||||||
|
const [invTransGroup, referenceType] = grouped;
|
||||||
|
const referencesIds = invTransGroup.map((trans: IInventoryTransaction) => trans.transactionId);
|
||||||
|
|
||||||
|
const _transactions = await AccountTransaction.tenant()
|
||||||
|
.query()
|
||||||
|
.where('reference_type', referenceType)
|
||||||
|
.whereIn('reference_id', referencesIds)
|
||||||
|
.withGraphFetched('account.type');
|
||||||
|
|
||||||
|
console.log(_transactions, referencesIds);
|
||||||
|
|
||||||
|
if (_transactions.length > 0) {
|
||||||
|
this.journal.loadEntries(_transactions);
|
||||||
|
this.journal.removeEntries(_transactions.map((t: any) => t.id));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} referenceType -
|
||||||
|
* @param {number} referenceId -
|
||||||
|
* @param {ISaleInvoice[]} sales -
|
||||||
|
*/
|
||||||
|
public async inventoryEntries(
|
||||||
|
transactions: IInventoryCostEntity[],
|
||||||
|
) {
|
||||||
|
const receivableAccount = { id: 10 };
|
||||||
|
const payableAccount = { id: 11 };
|
||||||
|
|
||||||
|
transactions.forEach((sale: IInventoryCostEntity) => {
|
||||||
|
const commonEntry = {
|
||||||
|
date: sale.date,
|
||||||
|
referenceId: sale.referenceId,
|
||||||
|
referenceType: sale.referenceType,
|
||||||
|
};
|
||||||
|
switch(sale.referenceType) {
|
||||||
|
case 'Bill':
|
||||||
|
const inventoryDebit: JournalEntry = new JournalEntry({
|
||||||
|
...commonEntry,
|
||||||
|
debit: sale.inventory,
|
||||||
|
account: sale.inventoryAccount,
|
||||||
|
});
|
||||||
|
const payableEntry: JournalEntry = new JournalEntry({
|
||||||
|
...commonEntry,
|
||||||
|
credit: sale.inventory,
|
||||||
|
account: payableAccount.id,
|
||||||
|
});
|
||||||
|
this.journal.debit(inventoryDebit);
|
||||||
|
this.journal.credit(payableEntry);
|
||||||
|
break;
|
||||||
|
case 'SaleInvoice':
|
||||||
|
const receivableEntry: JournalEntry = new JournalEntry({
|
||||||
|
...commonEntry,
|
||||||
|
debit: sale.income,
|
||||||
|
account: receivableAccount.id,
|
||||||
|
});
|
||||||
|
const incomeEntry: JournalEntry = new JournalEntry({
|
||||||
|
...commonEntry,
|
||||||
|
credit: sale.income,
|
||||||
|
account: sale.incomeAccount,
|
||||||
|
});
|
||||||
|
// Cost journal transaction.
|
||||||
|
const costEntry: JournalEntry = new JournalEntry({
|
||||||
|
...commonEntry,
|
||||||
|
debit: sale.cost,
|
||||||
|
account: sale.costAccount,
|
||||||
|
});
|
||||||
|
const inventoryCredit: JournalEntry = new JournalEntry({
|
||||||
|
...commonEntry,
|
||||||
|
credit: sale.cost,
|
||||||
|
account: sale.inventoryAccount,
|
||||||
|
});
|
||||||
|
this.journal.debit(receivableEntry);
|
||||||
|
this.journal.debit(costEntry);
|
||||||
|
|
||||||
|
this.journal.credit(incomeEntry);
|
||||||
|
this.journal.credit(inventoryCredit);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -248,6 +248,17 @@ export default class JournalPoster {
|
|||||||
this.deletedEntriesIds.push(...removeEntries.map((entry) => entry.id));
|
this.deletedEntriesIds.push(...removeEntries.map((entry) => entry.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revert the given transactions.
|
||||||
|
* @param {*} entries
|
||||||
|
*/
|
||||||
|
removeTransactions(entries) {
|
||||||
|
this.loadEntries(entries);
|
||||||
|
|
||||||
|
|
||||||
|
this.deletedEntriesIds.push(...entriesIDsShouldDel);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete all the stacked entries.
|
* Delete all the stacked entries.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ export default class AccountsService {
|
|||||||
.where('account_type_id', accountType.id)
|
.where('account_type_id', accountType.id)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
console.log(account);
|
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,36 @@
|
|||||||
import { InventoryTransaction, Item } from '@/models';
|
import {
|
||||||
import InventoryCostLotTracker from './InventoryCostLotTracker';
|
InventoryTransaction,
|
||||||
import { IInventoryTransaction, IInventoryLotCost } from '@/interfaces/InventoryTransaction';
|
Item
|
||||||
import { IInventoryLotCost, IInventoryLotCost } from '../../interfaces/InventoryTransaction';
|
} from '@/models';
|
||||||
import { pick } from 'lodash';
|
import InventoryAverageCost from '@/services/Inventory/InventoryAverageCost';
|
||||||
|
import InventoryCostLotTracker from '@/services/Inventory/InventoryCostLotTracker';
|
||||||
|
|
||||||
|
type TCostMethod = 'FIFO' | 'LIFO' | 'AVG';
|
||||||
|
|
||||||
export default class InventoryService {
|
export default class InventoryService {
|
||||||
|
/**
|
||||||
|
* Computes the given item cost and records the inventory lots transactions
|
||||||
|
* and journal entries based on the cost method FIFO, LIFO or average cost rate.
|
||||||
|
* @param {Date} fromDate
|
||||||
|
* @param {number} itemId
|
||||||
|
*/
|
||||||
|
static async computeItemCost(fromDate: Date, itemId: number) {
|
||||||
|
const costMethod: TCostMethod = 'FIFO';
|
||||||
|
let costMethodComputer: IInventoryCostMethod;
|
||||||
|
|
||||||
|
switch(costMethod) {
|
||||||
|
case 'FIFO':
|
||||||
|
case 'LIFO':
|
||||||
|
costMethodComputer = new InventoryCostLotTracker(fromDate, itemId);
|
||||||
|
break;
|
||||||
|
case 'AVG':
|
||||||
|
costMethodComputer = new InventoryAverageCost(fromDate, itemId);
|
||||||
|
break
|
||||||
|
}
|
||||||
|
await costMethodComputer.initialize();
|
||||||
|
await costMethodComputer.computeItemCost()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records the inventory transactions.
|
* Records the inventory transactions.
|
||||||
* @param {Bill} bill
|
* @param {Bill} bill
|
||||||
@@ -15,6 +41,7 @@ export default class InventoryService {
|
|||||||
date: Date,
|
date: Date,
|
||||||
transactionType: string,
|
transactionType: string,
|
||||||
transactionId: number,
|
transactionId: number,
|
||||||
|
direction: string,
|
||||||
) {
|
) {
|
||||||
const storedOpers: any = [];
|
const storedOpers: any = [];
|
||||||
const entriesItemsIds = entries.map((e: any) => e.item_id);
|
const entriesItemsIds = entries.map((e: any) => e.item_id);
|
||||||
@@ -23,20 +50,19 @@ export default class InventoryService {
|
|||||||
.whereIn('id', entriesItemsIds)
|
.whereIn('id', entriesItemsIds)
|
||||||
.where('type', 'inventory');
|
.where('type', 'inventory');
|
||||||
|
|
||||||
const inventoryItemsIds = inventoryItems.map((i) => i.id);
|
const inventoryItemsIds = inventoryItems.map((i: any) => i.id);
|
||||||
|
|
||||||
// Filter the bill entries that have inventory items.
|
// Filter the bill entries that have inventory items.
|
||||||
const inventoryEntries = entries.filter(
|
const inventoryEntries = entries.filter(
|
||||||
(entry) => inventoryItemsIds.indexOf(entry.item_id) !== -1
|
(entry: any) => inventoryItemsIds.indexOf(entry.item_id) !== -1
|
||||||
);
|
);
|
||||||
inventoryEntries.forEach((entry: any) => {
|
inventoryEntries.forEach((entry: any) => {
|
||||||
const oper = InventoryTransaction.tenant().query().insert({
|
const oper = InventoryTransaction.tenant().query().insert({
|
||||||
date,
|
date,
|
||||||
|
direction,
|
||||||
item_id: entry.item_id,
|
item_id: entry.item_id,
|
||||||
quantity: entry.quantity,
|
quantity: entry.quantity,
|
||||||
rate: entry.rate,
|
rate: entry.rate,
|
||||||
|
|
||||||
transaction_type: transactionType,
|
transaction_type: transactionType,
|
||||||
transaction_id: transactionId,
|
transaction_id: transactionId,
|
||||||
});
|
});
|
||||||
@@ -64,86 +90,4 @@ export default class InventoryService {
|
|||||||
revertInventoryLotsCost(fromDate?: Date) {
|
revertInventoryLotsCost(fromDate?: Date) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Records the journal entries transactions.
|
|
||||||
* @param {IInventoryLotCost[]} inventoryTransactions -
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
static async recordJournalEntries(inventoryLots: IInventoryLotCost[]) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tracking the given inventory transactions to lots costs transactions.
|
|
||||||
* @param {IInventoryTransaction[]} inventoryTransactions - Inventory transactions.
|
|
||||||
* @return {IInventoryLotCost[]}
|
|
||||||
*/
|
|
||||||
static async trackingInventoryLotsCost(inventoryTransactions: IInventoryTransaction[]) {
|
|
||||||
// Collect cost lots transactions to insert them to the storage in bulk.
|
|
||||||
const costLotsTransactions: IInventoryLotCost[] = [];
|
|
||||||
|
|
||||||
// Collect inventory transactions by item id.
|
|
||||||
const inventoryByItem: any = {};
|
|
||||||
// Collection `IN` inventory tranaction by transaction id.
|
|
||||||
const inventoryINTrans: any = {};
|
|
||||||
|
|
||||||
inventoryTransactions.forEach((transaction: IInventoryTransaction) => {
|
|
||||||
const { itemId, id } = transaction;
|
|
||||||
(inventoryByItem[itemId] || (inventoryByItem[itemId] = []));
|
|
||||||
|
|
||||||
const commonLotTransaction: IInventoryLotCost = {
|
|
||||||
...pick(transaction, [
|
|
||||||
'date', 'rate', 'itemId', 'quantity',
|
|
||||||
'direction', 'transactionType', 'transactionId',
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
// Record inventory `IN` cost lot transaction.
|
|
||||||
if (transaction.direction === 'IN') {
|
|
||||||
inventoryByItem[itemId].push(id);
|
|
||||||
inventoryINTrans[id] = {
|
|
||||||
...commonLotTransaction,
|
|
||||||
remaining: commonLotTransaction.quantity,
|
|
||||||
};
|
|
||||||
costLotsTransactions.push(inventoryINTrans[id]);
|
|
||||||
|
|
||||||
// Record inventory 'OUT' cost lots from 'IN' transactions.
|
|
||||||
} else if (transaction.direction === 'OUT') {
|
|
||||||
let invRemaining = transaction.quantity;
|
|
||||||
|
|
||||||
inventoryByItem?.[itemId]?.forEach((
|
|
||||||
_invTransactionId: number,
|
|
||||||
index: number,
|
|
||||||
) => {
|
|
||||||
const _invINTransaction = inventoryINTrans[_invTransactionId];
|
|
||||||
|
|
||||||
// Detarmines the 'OUT' lot tranasctions whether bigger than 'IN' remaining transaction.
|
|
||||||
const biggerThanRemaining = (_invINTransaction.remaining - transaction.quantity) > 0;
|
|
||||||
const decrement = (biggerThanRemaining) ? transaction.quantity : _invINTransaction.remaining;
|
|
||||||
|
|
||||||
_invINTransaction.remaining = Math.max(
|
|
||||||
_invINTransaction.remaining - decrement, 0,
|
|
||||||
);
|
|
||||||
invRemaining = Math.max(invRemaining - decrement, 0);
|
|
||||||
|
|
||||||
costLotsTransactions.push({
|
|
||||||
...commonLotTransaction,
|
|
||||||
quantity: decrement,
|
|
||||||
});
|
|
||||||
// Pop the 'IN' lots that has zero remaining.
|
|
||||||
if (_invINTransaction.remaining === 0) {
|
|
||||||
inventoryByItem?.[itemId].splice(index, 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (invRemaining > 0) {
|
|
||||||
costLotsTransactions.push({
|
|
||||||
...commonLotTransaction,
|
|
||||||
quantity: invRemaining,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return costLotsTransactions;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
207
server/src/services/Inventory/InventoryAverageCost.ts
Normal file
207
server/src/services/Inventory/InventoryAverageCost.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { Account, InventoryTransaction } from '@/models';
|
||||||
|
import { IInventoryTransaction } from '@/interfaces';
|
||||||
|
import JournalPoster from '@/services/Accounting/JournalPoster';
|
||||||
|
import JournalCommands from '@/services/Accounting/JournalCommands';
|
||||||
|
|
||||||
|
export default class InventoryAverageCostMethod implements IInventoryCostMethod {
|
||||||
|
journal: JournalPoster;
|
||||||
|
journalCommands: JournalCommands;
|
||||||
|
fromDate: Date;
|
||||||
|
itemId: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor method.
|
||||||
|
* @param {Date} fromDate -
|
||||||
|
* @param {number} itemId -
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
fromDate: Date,
|
||||||
|
itemId: number,
|
||||||
|
) {
|
||||||
|
this.fromDate = fromDate;
|
||||||
|
this.itemId = itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the inventory average cost method.
|
||||||
|
* @async
|
||||||
|
*/
|
||||||
|
async initialize() {
|
||||||
|
const accountsDepGraph = await Account.tenant().depGraph().query();
|
||||||
|
|
||||||
|
this.journal = new JournalPoster(accountsDepGraph);
|
||||||
|
this.journalCommands = new JournalCommands(this.journal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes items costs from the given date using average cost method.
|
||||||
|
*
|
||||||
|
* - Calculate the items average cost in the given date.
|
||||||
|
* - Remove the journal entries that associated to the inventory transacions
|
||||||
|
* after the given date.
|
||||||
|
* - Re-compute the inventory transactions and re-write the journal entries
|
||||||
|
* after the given date.
|
||||||
|
* ----------
|
||||||
|
* @asycn
|
||||||
|
* @param {Date} fromDate
|
||||||
|
* @param {number} referenceId
|
||||||
|
* @param {string} referenceType
|
||||||
|
*/
|
||||||
|
public async computeItemCost() {
|
||||||
|
const openingAvgCost = await this.getOpeningAvaregeCost(this.fromDate, this.itemId);
|
||||||
|
|
||||||
|
// @todo from `invTransactions`.
|
||||||
|
const afterInvTransactions: IInventoryTransaction[] = await InventoryTransaction
|
||||||
|
.tenant()
|
||||||
|
.query()
|
||||||
|
.where('date', '>=', this.fromDate)
|
||||||
|
// .where('direction', 'OUT')
|
||||||
|
.orderBy('date', 'asc')
|
||||||
|
.withGraphFetched('item');
|
||||||
|
|
||||||
|
// Remove and revert accounts balance journal entries from
|
||||||
|
// inventory transactions.
|
||||||
|
await this.journalCommands
|
||||||
|
.revertEntriesFromInventoryTransactions(afterInvTransactions);
|
||||||
|
|
||||||
|
// Re-write the journal entries from the new recorded inventory transactions.
|
||||||
|
await this.jEntriesFromItemInvTransactions(
|
||||||
|
afterInvTransactions,
|
||||||
|
openingAvgCost,
|
||||||
|
);
|
||||||
|
// Saves the new recorded journal entries to the storage.
|
||||||
|
await Promise.all([
|
||||||
|
this.journal.deleteEntries(),
|
||||||
|
this.journal.saveEntries(),
|
||||||
|
this.journal.saveBalance(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get items Avarege cost from specific date from inventory transactions.
|
||||||
|
* @static
|
||||||
|
* @param {Date} fromDate
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
public async getOpeningAvaregeCost(fromDate: Date, itemId: number) {
|
||||||
|
const commonBuilder = (builder: any) => {
|
||||||
|
if (fromDate) {
|
||||||
|
builder.where('date', '<', fromDate);
|
||||||
|
}
|
||||||
|
builder.where('item_id', itemId);
|
||||||
|
builder.groupBy('rate');
|
||||||
|
builder.groupBy('quantity');
|
||||||
|
builder.groupBy('item_id');
|
||||||
|
builder.groupBy('direction');
|
||||||
|
builder.sum('rate as rate');
|
||||||
|
builder.sum('quantity as quantity');
|
||||||
|
};
|
||||||
|
// Calculates the total inventory total quantity and rate `IN` transactions.
|
||||||
|
|
||||||
|
// @todo total `IN` transactions.
|
||||||
|
const inInvSumationOper: Promise<any> = InventoryTransaction.tenant()
|
||||||
|
.query()
|
||||||
|
.onBuild(commonBuilder)
|
||||||
|
.where('direction', 'IN')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
// Calculates the total inventory total quantity and rate `OUT` transactions.
|
||||||
|
// @todo total `OUT` transactions.
|
||||||
|
const outInvSumationOper: Promise<any> = InventoryTransaction.tenant()
|
||||||
|
.query()
|
||||||
|
.onBuild(commonBuilder)
|
||||||
|
.where('direction', 'OUT')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const [inInvSumation, outInvSumation] = await Promise.all([
|
||||||
|
inInvSumationOper,
|
||||||
|
outInvSumationOper,
|
||||||
|
]);
|
||||||
|
return this.computeItemAverageCost(
|
||||||
|
inInvSumation?.quantity || 0,
|
||||||
|
inInvSumation?.rate || 0,
|
||||||
|
outInvSumation?.quantity || 0,
|
||||||
|
outInvSumation?.rate || 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the item average cost.
|
||||||
|
* @static
|
||||||
|
* @param {number} quantityIn
|
||||||
|
* @param {number} rateIn
|
||||||
|
* @param {number} quantityOut
|
||||||
|
* @param {number} rateOut
|
||||||
|
*/
|
||||||
|
public computeItemAverageCost(
|
||||||
|
quantityIn: number,
|
||||||
|
rateIn: number,
|
||||||
|
|
||||||
|
quantityOut: number,
|
||||||
|
rateOut: number,
|
||||||
|
) {
|
||||||
|
const totalQuantity = (quantityIn - quantityOut);
|
||||||
|
const totalRate = (rateIn - rateOut);
|
||||||
|
const averageCost = (totalRate) ? (totalQuantity / totalRate) : totalQuantity;
|
||||||
|
|
||||||
|
return averageCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the journal entries from specific item inventory transactions.
|
||||||
|
* @param {IInventoryTransaction[]} invTransactions
|
||||||
|
* @param {number} openingAverageCost
|
||||||
|
* @param {string} referenceType
|
||||||
|
* @param {number} referenceId
|
||||||
|
* @param {JournalCommand} journalCommands
|
||||||
|
*/
|
||||||
|
async jEntriesFromItemInvTransactions(
|
||||||
|
invTransactions: IInventoryTransaction[],
|
||||||
|
openingAverageCost: number,
|
||||||
|
) {
|
||||||
|
const transactions: any[] = [];
|
||||||
|
let accQuantity: number = 0;
|
||||||
|
let accCost: number = 0;
|
||||||
|
|
||||||
|
invTransactions.forEach((invTransaction: IInventoryTransaction) => {
|
||||||
|
const commonEntry = {
|
||||||
|
date: invTransaction.date,
|
||||||
|
referenceType: invTransaction.transactionType,
|
||||||
|
referenceId: invTransaction.transactionId,
|
||||||
|
};
|
||||||
|
switch(invTransaction.direction) {
|
||||||
|
case 'IN':
|
||||||
|
accQuantity += invTransaction.quantity;
|
||||||
|
accCost += invTransaction.rate * invTransaction.quantity;
|
||||||
|
|
||||||
|
const inventory = invTransaction.quantity * invTransaction.rate;
|
||||||
|
|
||||||
|
transactions.push({
|
||||||
|
...commonEntry,
|
||||||
|
inventory,
|
||||||
|
inventoryAccount: invTransaction.item.inventoryAccountId,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'OUT':
|
||||||
|
const income = invTransaction.quantity * invTransaction.rate;
|
||||||
|
const transactionAvgCost = accCost ? (accCost / accQuantity) : 0;
|
||||||
|
const averageCost = transactionAvgCost;
|
||||||
|
const cost = (invTransaction.quantity * averageCost);
|
||||||
|
|
||||||
|
accQuantity -= invTransaction.quantity;
|
||||||
|
accCost -= accCost;
|
||||||
|
|
||||||
|
transactions.push({
|
||||||
|
...commonEntry,
|
||||||
|
income,
|
||||||
|
cost,
|
||||||
|
incomeAccount: invTransaction.item.sellAccountId,
|
||||||
|
costAccount: invTransaction.item.costAccountId,
|
||||||
|
inventoryAccount: invTransaction.item.inventoryAccountId,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.journalCommands.inventoryEntries(transactions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
|
|
||||||
export default class InventoryCostLotTracker {
|
|
||||||
|
|
||||||
recalcInventoryLotsCost(inventoryTransactions) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteTransactionsFromDate(fromDate) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
318
server/src/services/Inventory/InventoryCostLotTracker.ts
Normal file
318
server/src/services/Inventory/InventoryCostLotTracker.ts
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import { omit, pick, chain } from 'lodash';
|
||||||
|
import uniqid from 'uniqid';
|
||||||
|
import {
|
||||||
|
InventoryTransaction,
|
||||||
|
InventoryLotCostTracker,
|
||||||
|
Account,
|
||||||
|
Item,
|
||||||
|
} from "@/models";
|
||||||
|
import { IInventoryLotCost, IInventoryTransaction } from "@/interfaces";
|
||||||
|
import JournalPoster from '@/services/Accounting/JournalPoster';
|
||||||
|
import JournalCommands from '@/services/Accounting/JournalCommands';
|
||||||
|
|
||||||
|
type TCostMethod = 'FIFO' | 'LIFO';
|
||||||
|
|
||||||
|
export default class InventoryCostLotTracker implements IInventoryCostMethod {
|
||||||
|
journal: JournalPoster;
|
||||||
|
journalCommands: JournalCommands;
|
||||||
|
startingDate: Date;
|
||||||
|
headDate: Date;
|
||||||
|
itemId: number;
|
||||||
|
costMethod: TCostMethod;
|
||||||
|
itemsById: Map<number, any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor method.
|
||||||
|
* @param {Date} startingDate -
|
||||||
|
* @param {number} itemId -
|
||||||
|
* @param {string} costMethod -
|
||||||
|
*/
|
||||||
|
constructor(startingDate: Date, itemId: number, costMethod: TCostMethod = 'FIFO') {
|
||||||
|
this.startingDate = startingDate;
|
||||||
|
this.itemId = itemId;
|
||||||
|
this.costMethod = costMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the inventory average cost method.
|
||||||
|
* @async
|
||||||
|
*/
|
||||||
|
public async initialize() {
|
||||||
|
const accountsDepGraph = await Account.tenant().depGraph().query();
|
||||||
|
this.journal = new JournalPoster(accountsDepGraph);
|
||||||
|
this.journalCommands = new JournalCommands(this.journal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes items costs from the given date using FIFO or LIFO cost method.
|
||||||
|
* --------
|
||||||
|
* - Revert the inventory lots after the given date.
|
||||||
|
* - Remove all the journal entries from the inventory transactions
|
||||||
|
* after the given date.
|
||||||
|
* - Re-tracking the inventory lots from inventory transactions.
|
||||||
|
* - Re-write the journal entries from the given inventory transactions.
|
||||||
|
* @async
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
public async computeItemCost(): Promise<any> {
|
||||||
|
await this.revertInventoryLots(this.startingDate);
|
||||||
|
|
||||||
|
const afterInvTransactions: IInventoryTransaction[] =
|
||||||
|
await InventoryTransaction.tenant()
|
||||||
|
.query()
|
||||||
|
.where('date', '>=', this.startingDate)
|
||||||
|
.orderBy('date', 'ASC')
|
||||||
|
.where('item_id', this.itemId)
|
||||||
|
.withGraphFetched('item');
|
||||||
|
|
||||||
|
const availiableINLots: IInventoryLotCost[] =
|
||||||
|
await InventoryLotCostTracker.tenant()
|
||||||
|
.query()
|
||||||
|
.where('date', '<', this.startingDate)
|
||||||
|
.orderBy('date', 'ASC')
|
||||||
|
.where('item_id', this.itemId)
|
||||||
|
.where('direction', 'IN')
|
||||||
|
.whereNot('remaining', 0);
|
||||||
|
|
||||||
|
const merged = [
|
||||||
|
...availiableINLots.map((trans) => ({ lotTransId: trans.id, ...trans })),
|
||||||
|
...afterInvTransactions.map((trans) => ({ invTransId: trans.id, ...trans })),
|
||||||
|
];
|
||||||
|
const itemsIds = chain(merged).map(e => e.itemId).uniq().value();
|
||||||
|
|
||||||
|
const storedItems = await Item.tenant()
|
||||||
|
.query()
|
||||||
|
.where('type', 'inventory')
|
||||||
|
.whereIn('id', itemsIds);
|
||||||
|
|
||||||
|
this.itemsById = new Map(storedItems.map((item: any) => [item.id, item]));
|
||||||
|
|
||||||
|
// Re-tracking the inventory `IN` and `OUT` lots costs.
|
||||||
|
const trackedInvLotsCosts = this.trackingInventoryLotsCost(merged);
|
||||||
|
const storedTrackedInvLotsOper = this.storeInventoryLotsCost(trackedInvLotsCosts);
|
||||||
|
|
||||||
|
// Remove and revert accounts balance journal entries from inventory transactions.
|
||||||
|
const revertJEntriesOper = this.revertJournalEntries(afterInvTransactions);
|
||||||
|
|
||||||
|
// Records the journal entries operation.
|
||||||
|
this.recordJournalEntries(trackedInvLotsCosts);
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
storedTrackedInvLotsOper,
|
||||||
|
revertJEntriesOper.then(() =>
|
||||||
|
Promise.all([
|
||||||
|
// Saves the new recorded journal entries to the storage.
|
||||||
|
this.journal.deleteEntries(),
|
||||||
|
this.journal.saveEntries(),
|
||||||
|
this.journal.saveBalance(),
|
||||||
|
])),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revert the inventory lots to the given date by removing the inventory lots
|
||||||
|
* transactions after the given date and increment the remaining that
|
||||||
|
* associate to lot number.
|
||||||
|
* @async
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
public async revertInventoryLots(startingDate: Date) {
|
||||||
|
const asyncOpers: any[] = [];
|
||||||
|
const inventoryLotsTrans = await InventoryLotCostTracker.tenant()
|
||||||
|
.query()
|
||||||
|
.orderBy('date', 'DESC')
|
||||||
|
.where('item_id', this.itemId)
|
||||||
|
.where('date', '>=', startingDate)
|
||||||
|
.where('direction', 'OUT');
|
||||||
|
|
||||||
|
const deleteInvLotsTrans = InventoryLotCostTracker.tenant()
|
||||||
|
.query()
|
||||||
|
.where('date', '>=', startingDate)
|
||||||
|
.where('item_id', this.itemId)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
inventoryLotsTrans.forEach((inventoryLot: IInventoryLotCost) => {
|
||||||
|
if (!inventoryLot.lotNumber) { return; }
|
||||||
|
|
||||||
|
const incrementOper = InventoryLotCostTracker.tenant()
|
||||||
|
.query()
|
||||||
|
.where('lot_number', inventoryLot.lotNumber)
|
||||||
|
.where('direction', 'IN')
|
||||||
|
.increment('remaining', inventoryLot.quantity);
|
||||||
|
|
||||||
|
asyncOpers.push(incrementOper);
|
||||||
|
});
|
||||||
|
return Promise.all([deleteInvLotsTrans, ...asyncOpers]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts the journal entries from inventory lots costs transaction.
|
||||||
|
* @param {} inventoryLots
|
||||||
|
*/
|
||||||
|
async revertJournalEntries(
|
||||||
|
inventoryLots: IInventoryLotCost[],
|
||||||
|
) {
|
||||||
|
const invoiceTransactions = inventoryLots
|
||||||
|
.filter(e => e.transactionType === 'SaleInvoice');
|
||||||
|
|
||||||
|
return this.journalCommands
|
||||||
|
.revertEntriesFromInventoryTransactions(invoiceTransactions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the journal entries transactions.
|
||||||
|
* @async
|
||||||
|
* @param {IInventoryLotCost[]} inventoryTransactions -
|
||||||
|
* @param {string} referenceType -
|
||||||
|
* @param {number} referenceId -
|
||||||
|
* @param {Date} date -
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
public recordJournalEntries(
|
||||||
|
inventoryLots: IInventoryLotCost[],
|
||||||
|
): void {
|
||||||
|
const outTransactions: any[] = [];
|
||||||
|
const inTransByLotNumber: any = {};
|
||||||
|
const transactions: any = [];
|
||||||
|
|
||||||
|
inventoryLots.forEach((invTransaction: IInventoryLotCost) => {
|
||||||
|
switch(invTransaction.direction) {
|
||||||
|
case 'IN':
|
||||||
|
inTransByLotNumber[invTransaction.lotNumber] = invTransaction;
|
||||||
|
break;
|
||||||
|
case 'OUT':
|
||||||
|
outTransactions.push(invTransaction);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
outTransactions.forEach((outTransaction: IInventoryLotCost) => {
|
||||||
|
const { lotNumber, quantity, rate, itemId } = outTransaction;
|
||||||
|
const income = quantity * rate;
|
||||||
|
const item = this.itemsById.get(itemId);
|
||||||
|
|
||||||
|
const transaction = {
|
||||||
|
date: outTransaction.date,
|
||||||
|
referenceType: outTransaction.transactionType,
|
||||||
|
referenceId: outTransaction.transactionId,
|
||||||
|
cost: 0,
|
||||||
|
income,
|
||||||
|
incomeAccount: item.sellAccountId,
|
||||||
|
costAccount: item.costAccountId,
|
||||||
|
inventoryAccount: item.inventoryAccountId,
|
||||||
|
};
|
||||||
|
if (lotNumber && inTransByLotNumber[lotNumber]) {
|
||||||
|
const inInvTrans = inTransByLotNumber[lotNumber];
|
||||||
|
transaction.cost = (outTransaction.quantity * inInvTrans.rate);
|
||||||
|
}
|
||||||
|
transactions.push(transaction);
|
||||||
|
});
|
||||||
|
this.journalCommands.inventoryEntries(transactions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the inventory lots costs transactions in bulk.
|
||||||
|
* @param {IInventoryLotCost[]} costLotsTransactions
|
||||||
|
* @return {Promise[]}
|
||||||
|
*/
|
||||||
|
storeInventoryLotsCost(costLotsTransactions: IInventoryLotCost[]): Promise<object> {
|
||||||
|
const opers: any = [];
|
||||||
|
|
||||||
|
costLotsTransactions.forEach((transaction: IInventoryLotCost) => {
|
||||||
|
if (transaction.lotTransId && transaction.decrement) {
|
||||||
|
const decrementOper = InventoryLotCostTracker.tenant()
|
||||||
|
.query()
|
||||||
|
.where('id', transaction.lotTransId)
|
||||||
|
.decrement('remaining', transaction.decrement);
|
||||||
|
opers.push(decrementOper);
|
||||||
|
} else if(!transaction.lotTransId) {
|
||||||
|
const operation = InventoryLotCostTracker.tenant().query()
|
||||||
|
.insert({
|
||||||
|
...omit(transaction, ['decrement', 'invTransId', 'lotTransId']),
|
||||||
|
});
|
||||||
|
opers.push(operation);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Promise.all(opers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracking the given inventory transactions to lots costs transactions.
|
||||||
|
* @param {IInventoryTransaction[]} inventoryTransactions - Inventory transactions.
|
||||||
|
* @return {IInventoryLotCost[]}
|
||||||
|
*/
|
||||||
|
public trackingInventoryLotsCost(
|
||||||
|
inventoryTransactions: IInventoryTransaction[],
|
||||||
|
) : IInventoryLotCost {
|
||||||
|
// Collect cost lots transactions to insert them to the storage in bulk.
|
||||||
|
const costLotsTransactions: IInventoryLotCost[] = [];
|
||||||
|
// Collect inventory transactions by item id.
|
||||||
|
const inventoryByItem: any = {};
|
||||||
|
// Collection `IN` inventory tranaction by transaction id.
|
||||||
|
const inventoryINTrans: any = {};
|
||||||
|
|
||||||
|
inventoryTransactions.forEach((transaction: IInventoryTransaction) => {
|
||||||
|
const { itemId, id } = transaction;
|
||||||
|
(inventoryByItem[itemId] || (inventoryByItem[itemId] = []));
|
||||||
|
|
||||||
|
const commonLotTransaction: IInventoryLotCost = {
|
||||||
|
...pick(transaction, [
|
||||||
|
'date', 'rate', 'itemId', 'quantity', 'invTransId', 'lotTransId',
|
||||||
|
'direction', 'transactionType', 'transactionId', 'lotNumber', 'remaining'
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
// Record inventory `IN` cost lot transaction.
|
||||||
|
if (transaction.direction === 'IN') {
|
||||||
|
inventoryByItem[itemId].push(id);
|
||||||
|
inventoryINTrans[id] = {
|
||||||
|
...commonLotTransaction,
|
||||||
|
decrement: 0,
|
||||||
|
remaining: commonLotTransaction.remaining || commonLotTransaction.quantity,
|
||||||
|
lotNumber: commonLotTransaction.lotNumber || uniqid.time(),
|
||||||
|
};
|
||||||
|
costLotsTransactions.push(inventoryINTrans[id]);
|
||||||
|
|
||||||
|
// Record inventory 'OUT' cost lots from 'IN' transactions.
|
||||||
|
} else if (transaction.direction === 'OUT') {
|
||||||
|
let invRemaining = transaction.quantity;
|
||||||
|
|
||||||
|
inventoryByItem?.[itemId]?.some((
|
||||||
|
_invTransactionId: number,
|
||||||
|
index: number,
|
||||||
|
) => {
|
||||||
|
const _invINTransaction = inventoryINTrans[_invTransactionId];
|
||||||
|
if (invRemaining <= 0) { return true; }
|
||||||
|
|
||||||
|
// Detarmines the 'OUT' lot tranasctions whether bigger than 'IN' remaining transaction.
|
||||||
|
const biggerThanRemaining = (_invINTransaction.remaining - transaction.quantity) > 0;
|
||||||
|
const decrement = (biggerThanRemaining) ? transaction.quantity : _invINTransaction.remaining;
|
||||||
|
|
||||||
|
_invINTransaction.decrement += decrement;
|
||||||
|
_invINTransaction.remaining = Math.max(
|
||||||
|
_invINTransaction.remaining - decrement,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
invRemaining = Math.max(invRemaining - decrement, 0);
|
||||||
|
|
||||||
|
costLotsTransactions.push({
|
||||||
|
...commonLotTransaction,
|
||||||
|
quantity: decrement,
|
||||||
|
lotNumber: _invINTransaction.lotNumber,
|
||||||
|
});
|
||||||
|
// Pop the 'IN' lots that has zero remaining.
|
||||||
|
if (_invINTransaction.remaining === 0) {
|
||||||
|
inventoryByItem?.[itemId].splice(index, 1);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (invRemaining > 0) {
|
||||||
|
costLotsTransactions.push({
|
||||||
|
...commonLotTransaction,
|
||||||
|
quantity: invRemaining,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return costLotsTransactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -3,6 +3,44 @@ import { Item } from '@/models';
|
|||||||
|
|
||||||
export default class ItemsService {
|
export default class ItemsService {
|
||||||
|
|
||||||
|
static async newItem(item) {
|
||||||
|
const storedItem = await Item.tenant()
|
||||||
|
.query()
|
||||||
|
.insertAndFetch({
|
||||||
|
...item,
|
||||||
|
});
|
||||||
|
return storedItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async editItem(item, itemId) {
|
||||||
|
const updateItem = await Item.tenant()
|
||||||
|
.query()
|
||||||
|
.findById(itemId)
|
||||||
|
.patch({
|
||||||
|
...item,
|
||||||
|
});
|
||||||
|
return updateItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteItem(itemId) {
|
||||||
|
return Item.tenant()
|
||||||
|
.query()
|
||||||
|
.findById(itemId)
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getItemWithMetadata(itemId) {
|
||||||
|
return Item.tenant()
|
||||||
|
.query()
|
||||||
|
.findById(itemId)
|
||||||
|
.withGraphFetched(
|
||||||
|
'costAccount',
|
||||||
|
'sellAccount',
|
||||||
|
'inventoryAccount',
|
||||||
|
'category'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the given items IDs exists or not returns the not found ones.
|
* Validates the given items IDs exists or not returns the not found ones.
|
||||||
* @param {Array} itemsIDs
|
* @param {Array} itemsIDs
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { omit, sumBy } from 'lodash';
|
import { omit, sumBy } from 'lodash';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
import { Container } from 'typedi';
|
||||||
import {
|
import {
|
||||||
Account,
|
Account,
|
||||||
Bill,
|
Bill,
|
||||||
@@ -22,7 +23,7 @@ import HasItemsEntries from '@/services/Sales/HasItemsEntries';
|
|||||||
export default class BillsService {
|
export default class BillsService {
|
||||||
/**
|
/**
|
||||||
* Creates a new bill and stored it to the storage.
|
* Creates a new bill and stored it to the storage.
|
||||||
*
|
*|
|
||||||
* Precedures.
|
* Precedures.
|
||||||
* ----
|
* ----
|
||||||
* - Insert bill transactions to the storage.
|
* - Insert bill transactions to the storage.
|
||||||
@@ -30,11 +31,13 @@ export default class BillsService {
|
|||||||
* - Increment the given vendor id.
|
* - Increment the given vendor id.
|
||||||
* - Record bill journal transactions on the given accounts.
|
* - Record bill journal transactions on the given accounts.
|
||||||
* - Record bill items inventory transactions.
|
* - Record bill items inventory transactions.
|
||||||
*
|
* ----
|
||||||
* @param {IBill} bill -
|
* @param {IBill} bill -
|
||||||
* @return {void}
|
* @return {void}
|
||||||
*/
|
*/
|
||||||
static async createBill(bill) {
|
static async createBill(bill) {
|
||||||
|
const agenda = Container.get('agenda');
|
||||||
|
|
||||||
const amount = sumBy(bill.entries, 'amount');
|
const amount = sumBy(bill.entries, 'amount');
|
||||||
const saveEntriesOpers = [];
|
const saveEntriesOpers = [];
|
||||||
|
|
||||||
@@ -57,20 +60,37 @@ export default class BillsService {
|
|||||||
// Increments vendor balance.
|
// Increments vendor balance.
|
||||||
const incrementOper = Vendor.changeBalance(bill.vendor_id, amount);
|
const incrementOper = Vendor.changeBalance(bill.vendor_id, amount);
|
||||||
|
|
||||||
// // Rewrite the inventory transactions for inventory items.
|
// Rewrite the inventory transactions for inventory items.
|
||||||
// const writeInvTransactionsOper = InventoryService.recordInventoryTransactions(
|
const writeInvTransactionsOper = InventoryService.recordInventoryTransactions(
|
||||||
// bill.entries, 'Bill', billId,
|
bill.entries, bill.bill_date, 'Bill', storedBill.id, 'IN',
|
||||||
// );
|
);
|
||||||
|
// Writes the journal entries for the given bill transaction.
|
||||||
|
const writeJEntriesOper = this.recordJournalTransactions({
|
||||||
|
id: storedBill.id,
|
||||||
|
...bill
|
||||||
|
});
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...saveEntriesOpers,
|
...saveEntriesOpers,
|
||||||
incrementOper,
|
incrementOper,
|
||||||
// this.recordInventoryTransactions(bill, storedBill.id),
|
writeInvTransactionsOper,
|
||||||
this.recordJournalTransactions({ ...bill, id: storedBill.id }),
|
writeJEntriesOper,
|
||||||
// writeInvTransactionsOper,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Schedule bill re-compute based on the item cost
|
||||||
|
// method and starting date.
|
||||||
|
await this.scheduleComputeItemsCost(bill);
|
||||||
|
|
||||||
return storedBill;
|
return storedBill;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleComputeItemCost(bill) {
|
||||||
|
return agenda.schedule('in 1 second', 'compute-item-cost', {
|
||||||
|
startingDate: bill.bill_date || bill.billDate,
|
||||||
|
itemId: bill.entries[0].item_id || bill.entries[0].itemId,
|
||||||
|
costMethod: 'FIFO',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edits details of the given bill id with associated entries.
|
* Edits details of the given bill id with associated entries.
|
||||||
*
|
*
|
||||||
@@ -116,21 +136,31 @@ export default class BillsService {
|
|||||||
amount,
|
amount,
|
||||||
oldBill.amount,
|
oldBill.amount,
|
||||||
);
|
);
|
||||||
// // Deletes the old inventory transactions.
|
// Re-write the inventory transactions for inventory items.
|
||||||
// const deleteInvTransactionsOper = InventorySevice.deleteInventoryTransactions(
|
const writeInvTransactionsOper = InventoryService.recordInventoryTransactions(
|
||||||
// billId, 'Bill',
|
bill.entries, bill.bill_date, 'Bill', billId, 'IN'
|
||||||
// );
|
);
|
||||||
// // Re-write the inventory transactions for inventory items.
|
// Delete bill associated inventory transactions.
|
||||||
// const writeInvTransactionsOper = InventoryService.recordInventoryTransactions(
|
const deleteInventoryTransOper = InventoryService.deleteInventoryTransactions(
|
||||||
// bill.entries, 'Bill', billId,
|
billId, 'Bill'
|
||||||
// );
|
);
|
||||||
|
// Writes the journal entries for the given bill transaction.
|
||||||
|
const writeJEntriesOper = this.recordJournalTransactions({
|
||||||
|
id: billId,
|
||||||
|
...bill,
|
||||||
|
}, billId);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
patchEntriesOper,
|
patchEntriesOper,
|
||||||
recordTransactionsOper,
|
|
||||||
changeVendorBalanceOper,
|
changeVendorBalanceOper,
|
||||||
// deleteInvTransactionsOper,
|
writeInvTransactionsOper,
|
||||||
// writeInvTransactionsOper,
|
deleteInventoryTransOper,
|
||||||
|
writeJEntriesOper,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Schedule sale invoice re-compute based on the item cost
|
||||||
|
// method and starting date.
|
||||||
|
await this.scheduleComputeItemsCost(bill);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,19 +179,15 @@ export default class BillsService {
|
|||||||
.whereIn('id', entriesItemsIds);
|
.whereIn('id', entriesItemsIds);
|
||||||
|
|
||||||
const storedItemsMap = new Map(storedItems.map((item) => [item.id, item]));
|
const storedItemsMap = new Map(storedItems.map((item) => [item.id, item]));
|
||||||
const payableAccount = await AccountsService.getAccountByType(
|
const payableAccount = await AccountsService.getAccountByType('accounts_payable');
|
||||||
'accounts_payable'
|
|
||||||
);
|
|
||||||
if (!payableAccount) {
|
|
||||||
throw new Error('New payable account on the storage.');
|
|
||||||
}
|
|
||||||
const accountsDepGraph = await Account.tenant().depGraph().query();
|
const accountsDepGraph = await Account.tenant().depGraph().query();
|
||||||
const journal = new JournalPoster(accountsDepGraph);
|
const journal = new JournalPoster(accountsDepGraph);
|
||||||
|
|
||||||
const commonJournalMeta = {
|
const commonJournalMeta = {
|
||||||
debit: 0,
|
debit: 0,
|
||||||
credit: 0,
|
credit: 0,
|
||||||
referenceId: billId,
|
referenceId: bill.id,
|
||||||
referenceType: 'Bill',
|
referenceType: 'Bill',
|
||||||
date: formattedDate,
|
date: formattedDate,
|
||||||
accural: true,
|
accural: true,
|
||||||
@@ -198,7 +224,7 @@ export default class BillsService {
|
|||||||
});
|
});
|
||||||
journal.debit(debitEntry);
|
journal.debit(debitEntry);
|
||||||
});
|
});
|
||||||
await Promise.all([
|
return Promise.all([
|
||||||
journal.deleteEntries(),
|
journal.deleteEntries(),
|
||||||
journal.saveEntries(),
|
journal.saveEntries(),
|
||||||
journal.saveBalance(),
|
journal.saveBalance(),
|
||||||
@@ -211,7 +237,10 @@ export default class BillsService {
|
|||||||
* @return {void}
|
* @return {void}
|
||||||
*/
|
*/
|
||||||
static async deleteBill(billId) {
|
static async deleteBill(billId) {
|
||||||
const bill = await Bill.tenant().query().where('id', billId).first();
|
const bill = await Bill.tenant().query()
|
||||||
|
.where('id', billId)
|
||||||
|
.withGraphFetched('entries')
|
||||||
|
.first();
|
||||||
|
|
||||||
// Delete all associated bill entries.
|
// Delete all associated bill entries.
|
||||||
const deleteBillEntriesOper = ItemEntry.tenant()
|
const deleteBillEntriesOper = ItemEntry.tenant()
|
||||||
@@ -242,6 +271,9 @@ export default class BillsService {
|
|||||||
deleteInventoryTransOper,
|
deleteInventoryTransOper,
|
||||||
revertVendorBalance,
|
revertVendorBalance,
|
||||||
]);
|
]);
|
||||||
|
// Schedule sale invoice re-compute based on the item cost
|
||||||
|
// method and starting date.
|
||||||
|
await this.scheduleComputeItemsCost(bill);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -284,7 +316,6 @@ export default class BillsService {
|
|||||||
return Bill.tenant().query().where('id', billId).first();
|
return Bill.tenant().query().where('id', billId).first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve the given bill details with associated items entries.
|
* Retrieve the given bill details with associated items entries.
|
||||||
* @param {Integer} billId -
|
* @param {Integer} billId -
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { omit, difference, sumBy, mixin } from 'lodash';
|
import { omit, difference, sumBy, mixin } from 'lodash';
|
||||||
|
import moment from 'moment';
|
||||||
import { SaleEstimate, ItemEntry } from '@/models';
|
import { SaleEstimate, ItemEntry } from '@/models';
|
||||||
import HasItemsEntries from '@/services/Sales/HasItemsEntries';
|
import HasItemsEntries from '@/services/Sales/HasItemsEntries';
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ export default class SaleEstimateService {
|
|||||||
*/
|
*/
|
||||||
static async createEstimate(estimate: any) {
|
static async createEstimate(estimate: any) {
|
||||||
const amount = sumBy(estimate.entries, 'amount');
|
const amount = sumBy(estimate.entries, 'amount');
|
||||||
|
|
||||||
const storedEstimate = await SaleEstimate.tenant()
|
const storedEstimate = await SaleEstimate.tenant()
|
||||||
.query()
|
.query()
|
||||||
.insert({
|
.insert({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { omit, sumBy, difference, chain, sum } from 'lodash';
|
import { omit, sumBy, difference } from 'lodash';
|
||||||
|
import { Container } from 'typedi';
|
||||||
import {
|
import {
|
||||||
SaleInvoice,
|
SaleInvoice,
|
||||||
AccountTransaction,
|
AccountTransaction,
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
import JournalPoster from '@/services/Accounting/JournalPoster';
|
import JournalPoster from '@/services/Accounting/JournalPoster';
|
||||||
import HasItemsEntries from '@/services/Sales/HasItemsEntries';
|
import HasItemsEntries from '@/services/Sales/HasItemsEntries';
|
||||||
import CustomerRepository from '@/repositories/CustomerRepository';
|
import CustomerRepository from '@/repositories/CustomerRepository';
|
||||||
import moment from 'moment';
|
import InventoryService from '@/services/Inventory/Inventory';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sales invoices service
|
* Sales invoices service
|
||||||
@@ -51,8 +52,8 @@ export default class SaleInvoicesService {
|
|||||||
balance,
|
balance,
|
||||||
);
|
);
|
||||||
// Records the inventory transactions for inventory items.
|
// Records the inventory transactions for inventory items.
|
||||||
const recordInventoryTransOpers = this.recordInventoryTransactions(
|
const recordInventoryTransOpers = InventoryService.recordInventoryTransactions(
|
||||||
saleInvoice, storedInvoice.id
|
saleInvoice.entries, saleInvoice.invoice_date, 'SaleInvoice', storedInvoice.id, 'OUT',
|
||||||
);
|
);
|
||||||
// Await all async operations.
|
// Await all async operations.
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -60,19 +61,13 @@ export default class SaleInvoicesService {
|
|||||||
incrementOper,
|
incrementOper,
|
||||||
recordInventoryTransOpers,
|
recordInventoryTransOpers,
|
||||||
]);
|
]);
|
||||||
|
// Schedule sale invoice re-compute based on the item cost
|
||||||
|
// method and starting date.
|
||||||
|
await this.scheduleComputeItemsCost(saleInvoice);
|
||||||
|
|
||||||
return storedInvoice;
|
return storedInvoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Records the inventory items transactions.
|
|
||||||
* @param {SaleInvoice} saleInvoice -
|
|
||||||
* @param {number} saleInvoiceId -
|
|
||||||
* @return {Promise}
|
|
||||||
*/
|
|
||||||
static async recordInventoryTransactions(saleInvoice, saleInvoiceId) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records the sale invoice journal entries and calculate the items cost
|
* Records the sale invoice journal entries and calculate the items cost
|
||||||
* based on the given cost method in the options FIFO, LIFO or average cost rate.
|
* based on the given cost method in the options FIFO, LIFO or average cost rate.
|
||||||
@@ -84,6 +79,23 @@ export default class SaleInvoicesService {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule sale invoice re-compute based on the item
|
||||||
|
* cost method and starting date
|
||||||
|
*
|
||||||
|
* @param saleInvoice
|
||||||
|
* @return {Promise<Agenda>}
|
||||||
|
*/
|
||||||
|
static scheduleComputeItemsCost(saleInvoice) {
|
||||||
|
const agenda = Container.get('agenda');
|
||||||
|
|
||||||
|
return agenda.schedule('in 1 second', 'compute-item-cost', {
|
||||||
|
startingDate: saleInvoice.invoice_date || saleInvoice.invoiceDate,
|
||||||
|
itemId: saleInvoice.entries[0].item_id || saleInvoice.entries[0].itemId,
|
||||||
|
costMethod: 'FIFO',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edit the given sale invoice.
|
* Edit the given sale invoice.
|
||||||
* @async
|
* @async
|
||||||
@@ -124,12 +136,10 @@ export default class SaleInvoicesService {
|
|||||||
patchItemsEntriesOper,
|
patchItemsEntriesOper,
|
||||||
changeCustomerBalanceOper,
|
changeCustomerBalanceOper,
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
async recalcInventoryTransactionsCost(inventoryTransactions: array) {
|
|
||||||
const inventoryTransactionsMap = this.mapInventoryTransByItem(inventoryTransactions);
|
|
||||||
|
|
||||||
|
|
||||||
|
// Schedule sale invoice re-compute based on the item cost
|
||||||
|
// method and starting date.
|
||||||
|
await this.scheduleComputeItemsCost(saleInvoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,7 +148,7 @@ export default class SaleInvoicesService {
|
|||||||
* @param {number} transactionId
|
* @param {number} transactionId
|
||||||
*/
|
*/
|
||||||
static async revertInventoryTransactions(inventoryTransactions: array) {
|
static async revertInventoryTransactions(inventoryTransactions: array) {
|
||||||
const opers = [];
|
const opers: Promise<[]>[] = [];
|
||||||
|
|
||||||
inventoryTransactions.forEach((trans: any) => {
|
inventoryTransactions.forEach((trans: any) => {
|
||||||
switch(trans.direction) {
|
switch(trans.direction) {
|
||||||
@@ -175,7 +185,9 @@ export default class SaleInvoicesService {
|
|||||||
* @param {Number} saleInvoiceId
|
* @param {Number} saleInvoiceId
|
||||||
*/
|
*/
|
||||||
static async deleteSaleInvoice(saleInvoiceId: number) {
|
static async deleteSaleInvoice(saleInvoiceId: number) {
|
||||||
const oldSaleInvoice = await SaleInvoice.tenant().query().findById(saleInvoiceId);
|
const oldSaleInvoice = await SaleInvoice.tenant().query()
|
||||||
|
.findById(saleInvoiceId)
|
||||||
|
.withGraphFetched('entries');
|
||||||
|
|
||||||
await SaleInvoice.tenant().query().where('id', saleInvoiceId).delete();
|
await SaleInvoice.tenant().query().where('id', saleInvoiceId).delete();
|
||||||
await ItemEntry.tenant()
|
await ItemEntry.tenant()
|
||||||
@@ -206,14 +218,20 @@ export default class SaleInvoicesService {
|
|||||||
.where('transaction_id', saleInvoiceId);
|
.where('transaction_id', saleInvoiceId);
|
||||||
|
|
||||||
// Revert inventory transactions.
|
// Revert inventory transactions.
|
||||||
const revertInventoryTransactionsOper = this.revertInventoryTransactions(inventoryTransactions);
|
const revertInventoryTransactionsOper = this.revertInventoryTransactions(
|
||||||
|
inventoryTransactions
|
||||||
|
);
|
||||||
|
|
||||||
|
// Await all async operations.
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
journal.deleteEntries(),
|
journal.deleteEntries(),
|
||||||
journal.saveBalance(),
|
journal.saveBalance(),
|
||||||
revertCustomerBalanceOper,
|
revertCustomerBalanceOper,
|
||||||
revertInventoryTransactionsOper,
|
revertInventoryTransactionsOper,
|
||||||
]);
|
]);
|
||||||
|
// Schedule sale invoice re-compute based on the item cost
|
||||||
|
// method and starting date.
|
||||||
|
await this.scheduleComputeItemsCost(oldSaleInvoice)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,7 +279,7 @@ export default class SaleInvoicesService {
|
|||||||
static async isSaleInvoiceNumberExists(saleInvoiceNumber: string|number, saleInvoiceId: number) {
|
static async isSaleInvoiceNumberExists(saleInvoiceNumber: string|number, saleInvoiceId: number) {
|
||||||
const foundSaleInvoice = await SaleInvoice.tenant()
|
const foundSaleInvoice = await SaleInvoice.tenant()
|
||||||
.query()
|
.query()
|
||||||
.onBuild((query) => {
|
.onBuild((query: any) => {
|
||||||
query.where('invoice_no', saleInvoiceNumber);
|
query.where('invoice_no', saleInvoiceNumber);
|
||||||
|
|
||||||
if (saleInvoiceId) {
|
if (saleInvoiceId) {
|
||||||
@@ -269,7 +287,7 @@ export default class SaleInvoicesService {
|
|||||||
}
|
}
|
||||||
return query;
|
return query;
|
||||||
});
|
});
|
||||||
return foundSaleInvoice.length !== 0;
|
return (foundSaleInvoice.length !== 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -280,7 +298,7 @@ export default class SaleInvoicesService {
|
|||||||
static async isInvoicesExist(invoicesIds: Array<number>) {
|
static async isInvoicesExist(invoicesIds: Array<number>) {
|
||||||
const storedInvoices = await SaleInvoice.tenant()
|
const storedInvoices = await SaleInvoice.tenant()
|
||||||
.query()
|
.query()
|
||||||
.onBuild((builder) => {
|
.onBuild((builder: any) => {
|
||||||
builder.whereIn('id', invoicesIds);
|
builder.whereIn('id', invoicesIds);
|
||||||
return builder;
|
return builder;
|
||||||
});
|
});
|
||||||
|
|||||||
9
server/src/subscribers/events.ts
Normal file
9
server/src/subscribers/events.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
auth: {
|
||||||
|
login: 'onLogin',
|
||||||
|
register: 'onRegister',
|
||||||
|
resetPassword: 'onResetPassword',
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user