+ {/*- Customer name -*/}
}
inline={true}
- className={classNames('form-group--select-list', Classes.FILL)}
+ className={classNames(
+ 'form-group--select-list',
+ Classes.FILL,
+ 'form-group--customer',
+ )}
labelInfo={
}
intent={errors.customer_id && touched.customer_id && Intent.DANGER}
helperText={
@@ -110,10 +114,11 @@ function ReceiptFormHeader({
/>
+ {/*- Deposit account -*/}
}
className={classNames(
- 'form-group--deposit_account_id',
+ 'form-group--deposit-account',
'form-group--select-list',
Classes.FILL,
)}
@@ -172,6 +177,7 @@ function ReceiptFormHeader({
/>
*/}
+ {/*- Reference -*/}
}
inline={true}
@@ -185,6 +191,8 @@ function ReceiptFormHeader({
{...getFieldProps('reference_no')}
/>
+
+ {/*- Send to email -*/}
}
inline={true}
diff --git a/client/src/style/App.scss b/client/src/style/App.scss
index ba6f77aaf..b01485625 100644
--- a/client/src/style/App.scss
+++ b/client/src/style/App.scss
@@ -59,10 +59,13 @@ $pt-font-family: Noto Sans, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
@import 'pages/invite-user.scss';
@import 'pages/exchange-rate.scss';
@import 'pages/customer.scss';
-@import 'pages/estimate.scss';
+@import 'pages/estimates';
+@import 'pages/receipts';
+@import 'pages/invoices';
- // Views
- @import 'views/filter-dropdown';
+
+// Views
+@import 'views/filter-dropdown';
@import 'views/sidebar';
.App {
diff --git a/client/src/style/pages/estimate.scss b/client/src/style/pages/estimate.scss
deleted file mode 100644
index fe0f1867b..000000000
--- a/client/src/style/pages/estimate.scss
+++ /dev/null
@@ -1,379 +0,0 @@
-.estimate-form {
- padding-bottom: 30px;
- display: flex;
- flex-direction: column;
- .bp3-form-group {
- width: 100%;
- margin: 25px 20px 15px;
- }
- .bp3-label {
- margin: 0 20px 0;
- font-weight: 500;
- font-size: 13px;
- color: #444;
- width: 130px;
- }
- .bp3-form-content {
- width: 35%;
- .bp3-button:not([class*='bp3-intent-']):not(.bp3-minimal) {
- width: 120%;
- }
- }
-
- &__table {
- padding: 15px 15px 0;
-
- .bp3-form-group {
- margin-bottom: 0;
- }
- .table {
- border: 1px dotted rgb(195, 195, 195);
- border-bottom: transparent;
- border-left: transparent;
-
- .th,
- .td {
- border-left: 1px dotted rgb(195, 195, 195);
-
- &.index {
- > span,
- > div {
- text-align: center;
- width: 100%;
- font-weight: 500;
- }
- }
- }
-
- .thead {
- .tr .th {
- padding: 10px 10px;
- background-color: #f2f5fa;
- font-size: 14px;
- font-weight: 500;
- color: #333;
- }
- }
-
- .tbody {
- .tr .td {
- padding: 7px;
- border-bottom: 1px dotted rgb(195, 195, 195);
- min-height: 46px;
-
- &.index {
- background-color: #f2f5fa;
- text-align: center;
-
- > span {
- margin-top: auto;
- margin-bottom: auto;
- }
- }
- }
- .tr {
- .bp3-form-group .bp3-input,
- .form-group--select-list .bp3-button {
- border-radius: 3px;
- padding-left: 8px;
- padding-right: 8px;
- }
-
- .bp3-form-group:not(.bp3-intent-danger) .bp3-input,
- .form-group--select-list:not(.bp3-intent-danger) .bp3-button {
- border-color: #e5e5e5;
- }
-
- &:last-of-type {
- .td {
- border-bottom: transparent;
-
- .bp3-button,
- .bp3-input-group {
- display: none;
- }
- }
- }
-
- .td.actions {
- .bp3-button {
- background-color: transparent;
- color: #e68f8e;
-
- &:hover {
- color: #c23030;
- }
- }
- }
-
- &.row--total {
- .td.amount {
- font-weight: bold;
- }
- }
- }
- }
- .th {
- color: #444;
- font-weight: 600;
- border-bottom: 1px dotted #666;
- }
-
- .td {
- border-bottom: 1px dotted #999;
-
- &.description {
- .bp3-form-group {
- width: 100%;
- }
- }
- }
-
- .actions.td {
- .bp3-button {
- background: transparent;
- margin: 0;
- }
- }
- }
- }
-
- &__floating-footer {
- position: fixed;
- bottom: 0;
- width: 100%;
- background: #fff;
- padding: 18px 18px;
- border-top: 1px solid #ececec;
-
- .has-mini-sidebar & {
- left: 50px;
- }
- }
- .bp3-button {
- &.button--clear-lines {
- background-color: #fcefef;
- }
- }
- .button--clear-lines,
- .button--new-line {
- padding-left: 14px;
- padding-right: 14px;
- }
- .dropzone-container {
- margin-top: 0;
- align-self: flex-end;
- }
- .dropzone {
- width: 300px;
- height: 75px;
- }
-
- .form-group--description {
- .bp3-label {
- font-weight: 500;
- font-size: 13px;
- color: #444;
- }
- .bp3-form-content {
- // width: 280px;
- textarea {
- width: 450px;
- min-height: 75px;
- }
- }
- }
-}
-
-
-// .estimate-form {
-// padding-bottom: 30px;
-// display: flex;
-// flex-direction: column;
-
-// .bp3-form-group {
-// margin: 25px 20px 15px;
-// width: 100%;
-// .bp3-label {
-// font-weight: 500;
-// font-size: 13px;
-// color: #444;
-// width: 130px;
-// }
-// .bp3-form-content {
-// // width: 400px;
-// width: 45%;
-// }
-// }
-// // .expense-form-footer {
-// // display: flex;
-// // padding: 30px 25px 0;
-// // justify-content: space-between;
-// // }
-
-// &__primary-section {
-// background: #fbfbfb;
-// }
-// &__table {
-// padding: 15px 15px 0;
-
-// .bp3-form-group {
-// margin-bottom: 0;
-// }
-// .table {
-// border: 1px dotted rgb(195, 195, 195);
-// border-bottom: transparent;
-// border-left: transparent;
-
-// .th,
-// .td {
-// border-left: 1px dotted rgb(195, 195, 195);
-
-// &.index {
-// > span,
-// > div {
-// text-align: center;
-// width: 100%;
-// font-weight: 500;
-// }
-// }
-// }
-
-// .thead {
-// .tr .th {
-// padding: 10px 10px;
-// background-color: #f2f5fa;
-// font-size: 14px;
-// font-weight: 500;
-// color: #333;
-// }
-// }
-
-// .tbody {
-// .tr .td {
-// padding: 7px;
-// border-bottom: 1px dotted rgb(195, 195, 195);
-// min-height: 46px;
-
-// &.index {
-// background-color: #f2f5fa;
-// text-align: center;
-
-// > span {
-// margin-top: auto;
-// margin-bottom: auto;
-// }
-// }
-// }
-// .tr {
-// .bp3-form-group .bp3-input,
-// .form-group--select-list .bp3-button {
-// border-radius: 3px;
-// padding-left: 8px;
-// padding-right: 8px;
-// }
-
-// .bp3-form-group:not(.bp3-intent-danger) .bp3-input,
-// .form-group--select-list:not(.bp3-intent-danger) .bp3-button {
-// border-color: #e5e5e5;
-// }
-
-// &:last-of-type {
-// .td {
-// border-bottom: transparent;
-
-// .bp3-button,
-// .bp3-input-group {
-// display: none;
-// }
-// }
-// }
-
-// .td.actions {
-// .bp3-button {
-// background-color: transparent;
-// color: #e68f8e;
-
-// &:hover {
-// color: #c23030;
-// }
-// }
-// }
-
-// &.row--total {
-// .td.amount {
-// font-weight: bold;
-// }
-// }
-// }
-// }
-// .th {
-// color: #444;
-// font-weight: 600;
-// border-bottom: 1px dotted #666;
-// }
-
-// .td {
-// border-bottom: 1px dotted #999;
-
-// &.description {
-// .bp3-form-group {
-// width: 100%;
-// }
-// }
-// }
-
-// .actions.td {
-// .bp3-button {
-// background: transparent;
-// margin: 0;
-// }
-// }
-// }
-// }
-// &__floating-footer {
-// position: fixed;
-// bottom: 0;
-// width: 100%;
-// background: #fff;
-// padding: 18px 18px;
-// border-top: 1px solid #ececec;
-
-// .has-mini-sidebar & {
-// left: 50px;
-// }
-// }
-// .bp3-button {
-// &.button--clear-lines {
-// background-color: #fcefef;
-// }
-// }
-// .button--clear-lines,
-// .button--new-line {
-// padding-left: 14px;
-// padding-right: 14px;
-// }
-// .dropzone-container {
-// margin-top: 0;
-// align-self: flex-end;
-// }
-// .dropzone {
-// width: 300px;
-// height: 75px;
-// }
-
-// .form-group--description {
-// .bp3-label {
-// font-weight: 500;
-// font-size: 13px;
-// color: #444;
-// }
-// .bp3-form-content {
-// // width: 280px;
-// textarea {
-// width: 450px;
-// min-height: 75px;
-// }
-// }
-// }
-// }
diff --git a/client/src/style/pages/estimates.scss b/client/src/style/pages/estimates.scss
new file mode 100644
index 000000000..92dfb78cf
--- /dev/null
+++ b/client/src/style/pages/estimates.scss
@@ -0,0 +1,159 @@
+.page-form{
+ padding: 15px;
+
+ .bp3-form-group{
+
+ .bp3-label{
+ width: 100%;
+ max-width: 170px;
+ min-width: 140px;
+ }
+ .bp3-form-content{
+ width: 100%;
+ max-width: 300px;
+ }
+ }
+
+ &__primary-section {
+ background: #fbfbfb;
+ margin: -15px -15px 25px;
+ padding: 30px 15px 10px;
+ }
+
+ .form-group{
+
+ &--customer{
+
+ .bp3-form-content{
+ max-width: 420px;
+ }
+ }
+ }
+}
+
+.datatable-editor {
+ padding: 15px 15px 0;
+
+ &-actions{
+ padding: 0 15px;
+
+ .bp3-button.button--clear-lines {
+ background-color: #fcefef;
+ }
+ }
+
+ .bp3-form-group {
+ margin-bottom: 0;
+ }
+ .table {
+ border: 1px dotted rgb(195, 195, 195);
+ border-bottom: transparent;
+ border-left: transparent;
+
+ .th,
+ .td {
+ border-left: 1px dotted rgb(195, 195, 195);
+
+ &.index {
+ > span,
+ > div {
+ text-align: center;
+ width: 100%;
+ font-weight: 500;
+ }
+ }
+ }
+
+ .thead {
+ .tr .th {
+ padding: 10px 10px;
+ background-color: #f2f5fa;
+ font-size: 14px;
+ font-weight: 500;
+ color: #333;
+ }
+ }
+
+ .tbody {
+ .tr .td {
+ padding: 7px;
+ border-bottom: 1px dotted rgb(195, 195, 195);
+ min-height: 46px;
+
+ &.index {
+ background-color: #f2f5fa;
+ text-align: center;
+
+ > span {
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+ }
+ }
+ .tr {
+ .bp3-form-group .bp3-input,
+ .form-group--select-list .bp3-button {
+ border-radius: 3px;
+ padding-left: 8px;
+ padding-right: 8px;
+ }
+
+ .bp3-form-group:not(.bp3-intent-danger) .bp3-input,
+ .form-group--select-list:not(.bp3-intent-danger) .bp3-button {
+ border-color: #E5E5E5;
+ }
+
+ &:last-of-type {
+ .td {
+ border-bottom: transparent;
+
+ .bp3-button,
+ .bp3-input-group {
+ display: none;
+ }
+ }
+ }
+
+ .td.actions {
+ .bp3-button {
+ background-color: transparent;
+
+ svg{
+ color: #e68f8e;
+ }
+ &:hover svg{
+ color: #c23030;
+ }
+ }
+ }
+
+ &.row--total {
+
+ .td.amount{
+ font-weight: bold;
+ }
+ }
+ }
+ }
+ .th {
+ color: #444;
+ font-weight: 600;
+ border-bottom: 1px dotted #666;
+ }
+ .td {
+ border-bottom: 1px dotted #999;
+
+ &.description{
+ .bp3-form-group{
+ width: 100%;
+ }
+ }
+ }
+ .actions.td {
+ .bp3-button {
+ background: transparent;
+ margin: 0;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/src/style/pages/invoices.scss b/client/src/style/pages/invoices.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/style/pages/receipts.scss b/client/src/style/pages/receipts.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/server/src/database/seeds/seed_accounts.js b/server/src/database/seeds/seed_accounts.js
index 5fb1d0e7e..36c45d2d5 100644
--- a/server/src/database/seeds/seed_accounts.js
+++ b/server/src/database/seeds/seed_accounts.js
@@ -135,6 +135,26 @@ exports.seed = (knex) => {
index: 1,
active: 1,
description: 1,
+ },
+ {
+ id: 13,
+ name: 'Inventory Asset',
+ account_type_id: 14,
+ predefined: 1,
+ parent_account_id: null,
+ index: 1,
+ active: 1,
+ description: '',
+ },
+ {
+ id: 14,
+ name: 'Sales of Product Income',
+ account_type_id: 7,
+ predefined: 1,
+ parent_account_id: null,
+ index: 1,
+ active: 1,
+ description: '',
}
]);
});
diff --git a/server/src/http/controllers/Items.ts b/server/src/http/controllers/Items.ts
index 59bba9e49..a54a72dab 100644
--- a/server/src/http/controllers/Items.ts
+++ b/server/src/http/controllers/Items.ts
@@ -1,5 +1,5 @@
import { Router, Request, Response } from 'express';
-import { check, param, query, oneOf, ValidationChain } from 'express-validator';
+import { check, param, query, ValidationChain } from 'express-validator';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import validateMiddleware from '@/http/middleware/validateMiddleware';
import ItemsService from '@/services/Items/ItemsService';
@@ -124,9 +124,6 @@ export default class ItemsController {
/**
* Validate specific item params schema.
- * @param {Request} req
- * @param {Response} res
- * @param {NextFunction} next
*/
static get validateSpecificItemSchema(): ValidationChain[] {
return [
@@ -135,6 +132,9 @@ export default class ItemsController {
}
+ /**
+ * Validate list query schema
+ */
static get validateListQuerySchema() {
return [
query('column_sort_order').optional().isIn(['created_at', 'name', 'amount', 'sku']),
@@ -221,16 +221,21 @@ export default class ItemsController {
* @param {Function} next
*/
static async validateCostAccountExistance(req: Request, res: Response, next: Function) {
- const { Account } = req.models;
+ const { Account, AccountType } = req.models;
const item = req.body;
if (item.cost_account_id) {
- const foundAccount = await Account.query().findById(item.cost_account_id);
+ const COGSType = await AccountType.query().findOne('key', 'cost_of_goods_sold');
+ 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 }],
});
+ } else if (foundAccount.accountTypeId !== COGSType.id) {
+ return res.status(400).send({
+ errors: [{ type: 'COST.ACCOUNT.NOT.COGS.TYPE', code: 220 }],
+ });
}
}
next();
@@ -243,16 +248,21 @@ export default class ItemsController {
* @param {NextFunction} next
*/
static async validateSellAccountExistance(req: Request, res: Response, next: Function) {
- const { Account } = req.models;
+ const { Account, AccountType } = req.models;
const item = req.body;
if (item.sell_account_id) {
+ const incomeType = await AccountType.query().findOne('key', 'income');
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 }],
});
+ } else if (foundAccount.accountTypeId !== incomeType.id) {
+ return res.status(400).send({
+ errors: [{ type: 'SELL.ACCOUNT.NOT.INCOME.TYPE', code: 230 }],
+ })
}
}
next();
@@ -265,16 +275,21 @@ export default class ItemsController {
* @param {NextFunction} next
*/
static async validateInventoryAccountExistance(req: Request, res: Response, next: Function) {
- const { Account } = req.models;
+ const { Account, AccountType } = req.models;
const item = req.body;
if (item.inventory_account_id) {
+ const otherAsset = await AccountType.query().findOne('key', 'other_asset');
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}],
});
+ } else if (otherAsset.id !== foundAccount.accountTypeId) {
+ return res.status(400).send({
+ errors: [{ type: 'INVENTORY.ACCOUNT.NOT.CURRENT.ASSET', code: 300 }],
+ });
}
}
next();
diff --git a/server/src/http/controllers/Purchases/Bills.js b/server/src/http/controllers/Purchases/Bills.js
index cf81b57ab..c23418b87 100644
--- a/server/src/http/controllers/Purchases/Bills.js
+++ b/server/src/http/controllers/Purchases/Bills.js
@@ -25,6 +25,7 @@ export default class BillsController extends BaseController {
asyncMiddleware(this.validateVendorExistance),
asyncMiddleware(this.validateItemsIds),
asyncMiddleware(this.validateBillNumberExists),
+ asyncMiddleware(this.validateNonPurchasableEntriesItems),
asyncMiddleware(this.newBill)
);
router.post(
@@ -35,6 +36,7 @@ export default class BillsController extends BaseController {
asyncMiddleware(this.validateVendorExistance),
asyncMiddleware(this.validateItemsIds),
asyncMiddleware(this.validateEntriesIdsExistance),
+ asyncMiddleware(this.validateNonPurchasableEntriesItems),
asyncMiddleware(this.editBill)
);
router.get(
@@ -201,6 +203,32 @@ export default class BillsController extends BaseController {
next();
}
+ /**
+ * Validate the entries items that not purchase-able.
+ * @param {Request} req
+ * @param {Response} res
+ * @param {Function} next
+ */
+ static async validateNonPurchasableEntriesItems(req, res, next) {
+ const { Item } = req.models;
+ const bill = { ...req.body };
+ const itemsIds = bill.entries.map(e => e.item_id);
+
+ const purchasbleItems = await Item.query()
+ .where('purchasable', true)
+ .whereIn('id', itemsIds);
+
+ const purchasbleItemsIds = purchasbleItems.map((item) => item.id);
+ const notPurchasableItems = difference(itemsIds, purchasbleItemsIds);
+
+ if (notPurchasableItems.length > 0) {
+ return res.status(400).send({
+ errors: [{ type: 'NOT.PURCHASE.ABLE.ITEMS', code: 600 }],
+ });
+ }
+ next();
+ }
+
/**
* Creates a new bill and records journal transactions.
* @param {Request} req
diff --git a/server/src/http/controllers/Sales/SalesInvoices.js b/server/src/http/controllers/Sales/SalesInvoices.js
index d15b8a032..1e77faff7 100644
--- a/server/src/http/controllers/Sales/SalesInvoices.js
+++ b/server/src/http/controllers/Sales/SalesInvoices.js
@@ -11,7 +11,7 @@ import CustomersService from '@/services/Customers/CustomersService';
import DynamicListing from '@/services/DynamicListing/DynamicListing';
import DynamicListingBuilder from '@/services/DynamicListing/DynamicListingBuilder';
import { dynamicListingErrorsToResponse } from '@/services/DynamicListing/hasDynamicListing';
-import { Customer } from '../../../models';
+import { Customer, Item } from '../../../models';
export default class SaleInvoicesController {
/**
@@ -27,6 +27,7 @@ export default class SaleInvoicesController {
asyncMiddleware(this.validateInvoiceCustomerExistance),
asyncMiddleware(this.validateInvoiceNumberUnique),
asyncMiddleware(this.validateInvoiceItemsIdsExistance),
+ asyncMiddleware(this.validateNonSellableEntriesItems),
asyncMiddleware(this.newSaleInvoice)
);
router.post(
@@ -42,6 +43,7 @@ export default class SaleInvoicesController {
asyncMiddleware(this.validateInvoiceItemsIdsExistance),
asyncMiddleware(this.valdiateInvoiceEntriesIdsExistance),
asyncMiddleware(this.validateEntriesIdsExistance),
+ asyncMiddleware(this.validateNonSellableEntriesItems),
asyncMiddleware(this.editSaleInvoice)
);
router.delete(
@@ -257,6 +259,32 @@ export default class SaleInvoicesController {
next();
}
+ /**
+ * Validate the entries items that not sellable.
+ * @param {Request} req
+ * @param {Response} res
+ * @param {Function} next
+ */
+ static async validateNonSellableEntriesItems(req, res, next) {
+ const { Item } = req.models;
+ const saleInvoice = { ...req.body };
+ const itemsIds = saleInvoice.entries.map(e => e.item_id);
+
+ const sellableItems = await Item.query()
+ .where('sellable', true)
+ .whereIn('id', itemsIds);
+
+ const sellableItemsIds = sellableItems.map((item) => item.id);
+ const notSellableItems = difference(itemsIds, sellableItemsIds);
+
+ if (notSellableItems.length > 0) {
+ return res.status(400).send({
+ errors: [{ type: 'NOT.SELLABLE.ITEMS', code: 600 }],
+ });
+ }
+ next();
+ }
+
/**
* Creates a new sale invoice.
* @param {Request} req
diff --git a/server/src/jobs/ComputeItemCost.ts b/server/src/jobs/ComputeItemCost.ts
index e296c051f..b2a3fe8ef 100644
--- a/server/src/jobs/ComputeItemCost.ts
+++ b/server/src/jobs/ComputeItemCost.ts
@@ -8,7 +8,7 @@ export default class ComputeItemCostJob {
try {
await InventoryService.computeItemCost(startingDate, itemId, costMethod);
- Logger.log(`Compute item cost: ${job.attrs.data}`);
+ Logger.debug(`Compute item cost: ${job.attrs.data}`);
done();
} catch(e) {
console.log(e);
diff --git a/server/src/models/InventoryCostLotTracker.js b/server/src/models/InventoryCostLotTracker.js
index ca4fe007b..9ebcbfc44 100644
--- a/server/src/models/InventoryCostLotTracker.js
+++ b/server/src/models/InventoryCostLotTracker.js
@@ -1,4 +1,5 @@
import { Model } from 'objection';
+import moment from 'moment';
import TenantModel from '@/models/TenantModel';
export default class InventoryCostLotTracker extends TenantModel {
@@ -16,6 +17,27 @@ export default class InventoryCostLotTracker extends TenantModel {
return [];
}
+ /**
+ * Model modifiers.
+ */
+ static get modifiers() {
+ return {
+ filterDateRange(query, startDate, endDate, type = 'day') {
+ const dateFormat = 'YYYY-MM-DD HH:mm:ss';
+ const fromDate = moment(startDate).startOf(type).format(dateFormat);
+ const toDate = moment(endDate).endOf(type).format(dateFormat);
+
+ if (startDate) {
+ query.where('date', '>=', fromDate);
+ }
+ if (endDate) {
+ query.where('date', '<=', toDate);
+ }
+ },
+ };
+ }
+
+
/**
* Relationship mapping.
*/
diff --git a/server/src/models/InventoryTransaction.js b/server/src/models/InventoryTransaction.js
index d108dd3a6..cf2e3dc3e 100644
--- a/server/src/models/InventoryTransaction.js
+++ b/server/src/models/InventoryTransaction.js
@@ -1,4 +1,5 @@
import { Model } from 'objection';
+import moment from 'moment';
import TenantModel from '@/models/TenantModel';
export default class InventoryTransaction extends TenantModel {
@@ -16,6 +17,28 @@ export default class InventoryTransaction extends TenantModel {
return ['createdAt', 'updatedAt'];
}
+
+ /**
+ * Model modifiers.
+ */
+ static get modifiers() {
+ return {
+ filterDateRange(query, startDate, endDate, type = 'day') {
+ const dateFormat = 'YYYY-MM-DD HH:mm:ss';
+ const fromDate = moment(startDate).startOf(type).format(dateFormat);
+ const toDate = moment(endDate).endOf(type).format(dateFormat);
+
+ if (startDate) {
+ query.where('date', '>=', fromDate);
+ }
+ if (endDate) {
+ query.where('date', '<=', toDate);
+ }
+ },
+ };
+ }
+
+
/**
* Relationship mapping.
*/
diff --git a/server/src/services/Accounting/JournalCommands.ts b/server/src/services/Accounting/JournalCommands.ts
index 52d4709f1..964becd23 100644
--- a/server/src/services/Accounting/JournalCommands.ts
+++ b/server/src/services/Accounting/JournalCommands.ts
@@ -21,6 +21,22 @@ interface IInventoryCostEntity {
income: number,
};
+interface NonInventoryJEntries {
+ date: Date,
+
+ referenceType: string,
+ referenceId: number,
+
+ receivable: number,
+ payable: number,
+
+ incomeAccountId: number,
+ income: number,
+
+ costAccountId: number,
+ cost: number,
+};
+
export default class JournalCommands{
journal: JournalPoster;
@@ -64,6 +80,50 @@ export default class JournalCommands{
);
}
+ public async nonInventoryEntries(
+ transactions: NonInventoryJEntries[]
+ ) {
+ const receivableAccount = { id: 10 };
+ const payableAccount = {id: 11};
+
+ transactions.forEach((trans: NonInventoryJEntries) => {
+ const commonEntry = {
+ date: trans.date,
+ referenceId: trans.referenceId,
+ referenceType: trans.referenceType,
+ };
+
+ switch(trans.referenceType) {
+ case 'Bill':
+ const payableEntry: JournalEntry = new JournalEntry({
+ ...commonEntry,
+ credit: trans.payable,
+ account: payableAccount.id,
+ });
+ const costEntry: JournalEntry = new JournalEntry({
+ ...commonEntry,
+ });
+ this.journal.credit(payableEntry);
+ this.journal.debit(costEntry);
+ break;
+ case 'SaleInvoice':
+ const receivableEntry: JournalEntry = new JournalEntry({
+ ...commonEntry,
+ debit: trans.receivable,
+ account: receivableAccount.id,
+ });
+ const saleIncomeEntry: JournalEntry = new JournalEntry({
+ ...commonEntry,
+ credit: trans.income,
+ account: trans.incomeAccountId,
+ });
+ this.journal.debit(receivableEntry);
+ this.journal.credit(saleIncomeEntry);
+ break;
+ }
+ });
+ }
+
/**
*
* @param {string} referenceType -
diff --git a/server/src/services/Inventory/Inventory.ts b/server/src/services/Inventory/Inventory.ts
index d82b34970..f99e7fd50 100644
--- a/server/src/services/Inventory/Inventory.ts
+++ b/server/src/services/Inventory/Inventory.ts
@@ -59,7 +59,6 @@ export default class InventoryService {
entries: [],
deleteOld: boolean,
) {
- const storedOpers: any = [];
const entriesItemsIds = entries.map((e: any) => e.item_id);
const inventoryItems = await Item.tenant()
.query()
@@ -79,15 +78,11 @@ export default class InventoryService {
entry.transactionType,
);
}
- const oper = InventoryTransaction.tenant().query().insert({
+ await InventoryTransaction.tenant().query().insert({
...entry,
lotNumber: entry.lotNumber,
});
- storedOpers.push(oper);
- });
- return Promise.all([
- ...storedOpers,
- ]);
+ });
}
/**
diff --git a/server/src/services/Inventory/InventoryCostLotTracker.ts b/server/src/services/Inventory/InventoryCostLotTracker.ts
index db4701680..b2b3a7515 100644
--- a/server/src/services/Inventory/InventoryCostLotTracker.ts
+++ b/server/src/services/Inventory/InventoryCostLotTracker.ts
@@ -1,4 +1,5 @@
import { omit, pick, chain } from 'lodash';
+import moment from 'moment';
import {
InventoryTransaction,
InventoryLotCostTracker,
@@ -19,6 +20,13 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
itemId: number;
costMethod: TCostMethod;
itemsById: Map
;
+ inventoryINTrans: any;
+ inventoryByItem: any;
+ costLotsTransactions: IInventoryLotCost[];
+ inTransactions: any[];
+ outTransactions: IInventoryTransaction[];
+ revertInvoiceTrans: any[];
+ revertJEntriesTransactions: IInventoryTransaction[];
/**
* Constructor method.
@@ -30,6 +38,19 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
this.startingDate = startingDate;
this.itemId = itemId;
this.costMethod = costMethod;
+
+ // Collect cost lots transactions to insert them to the storage in bulk.
+ this.costLotsTransactions= [];
+ // Collect inventory transactions by item id.
+ this.inventoryByItem = {};
+ // Collection `IN` inventory tranaction by transaction id.
+ this.inventoryINTrans = {};
+ // Collects `IN` transactions.
+ this.inTransactions = [];
+ // Collects `OUT` transactions.
+ this.outTransactions = [];
+ // Collects journal entries reference id and type that should be reverted.
+ this.revertInvoiceTrans = [];
}
/**
@@ -55,48 +76,24 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
*/
public async computeItemCost(): Promise {
await this.revertInventoryLots(this.startingDate);
-
- const afterInvTransactions: IInventoryTransaction[] =
- await InventoryTransaction.tenant()
- .query()
- .where('date', '>=', this.startingDate)
- .orderBy('date', 'ASC')
- .orderBy('lot_number', 'ASC')
- .where('item_id', this.itemId)
- .withGraphFetched('item');
-
- const availiableINLots: IInventoryLotCost[] =
- await InventoryLotCostTracker.tenant()
- .query()
- .where('date', '<', this.startingDate)
- .orderBy('date', 'ASC')
- .orderBy('lot_number', '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]));
+ await this.fetchInvINTransactions();
+ await this.fetchInvOUTTransactions();
+ await this.fetchRevertInvJReferenceIds();
+ await this.fetchItemsMapped();
+
+ this.trackingInventoryINLots(this.inTransactions);
+ this.trackingInventoryOUTLots(this.outTransactions);
// Re-tracking the inventory `IN` and `OUT` lots costs.
- const trackedInvLotsCosts = this.trackingInventoryLotsCost(merged);
- const storedTrackedInvLotsOper = this.storeInventoryLotsCost(trackedInvLotsCosts);
+ const storedTrackedInvLotsOper = this.storeInventoryLotsCost(
+ this.costLotsTransactions,
+ );
// Remove and revert accounts balance journal entries from inventory transactions.
- const revertJEntriesOper = this.revertJournalEntries(afterInvTransactions);
+ const revertJEntriesOper = this.revertJournalEntries(this.revertJEntriesTransactions);
// Records the journal entries operation.
- this.recordJournalEntries(trackedInvLotsCosts);
+ this.recordJournalEntries(this.costLotsTransactions);
return Promise.all([
storedTrackedInvLotsOper,
@@ -110,6 +107,84 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
]);
}
+ /**
+ * Fetched inventory transactions that has date from the starting date and
+ * fetches availiable IN LOTs transactions that has remaining bigger than zero.
+ * @private
+ */
+ private async fetchInvINTransactions() {
+ const commonBuilder = (builder: any) => {
+ builder.where('direction', 'IN');
+ builder.orderBy('date', 'ASC');
+ builder.where('item_id', this.itemId);
+ };
+ const afterInvTransactions: IInventoryTransaction[] =
+ await InventoryTransaction.tenant()
+ .query()
+ .modify('filterDateRange', this.startingDate)
+ .orderBy('lot_number', (this.costMethod === 'LIFO') ? 'DESC' : 'ASC')
+ .onBuild(commonBuilder)
+ .withGraphFetched('item');
+
+ const availiableINLots: IInventoryLotCost[] =
+ await InventoryLotCostTracker.tenant()
+ .query()
+ .modify('filterDateRange', null, this.startingDate)
+ .orderBy('date', 'ASC')
+ .orderBy('lot_number', 'ASC')
+ .onBuild(commonBuilder)
+ .whereNot('remaining', 0);
+
+ this.inTransactions = [
+ ...availiableINLots.map((trans) => ({ lotTransId: trans.id, ...trans })),
+ ...afterInvTransactions.map((trans) => ({ invTransId: trans.id, ...trans })),
+ ];
+ }
+
+ /**
+ * Fetches inventory OUT transactions that has date from the starting date.
+ * @private
+ */
+ private async fetchInvOUTTransactions() {
+ const afterOUTTransactions: IInventoryTransaction[] =
+ await InventoryTransaction.tenant()
+ .query()
+ .modify('filterDateRange', this.startingDate)
+ .orderBy('date', 'ASC')
+ .orderBy('lot_number', 'ASC')
+ .where('item_id', this.itemId)
+ .where('direction', 'OUT')
+ .withGraphFetched('item');
+
+ this.outTransactions = [ ...afterOUTTransactions ];
+ }
+
+ private async fetchItemsMapped() {
+ const itemsIds = chain(this.inTransactions).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]));
+ }
+
+ /**
+ * Fetch the inventory transactions that should revert its journal entries.
+ * @private
+ */
+ private async fetchRevertInvJReferenceIds() {
+ const revertJEntriesTransactions: IInventoryTransaction[] =
+ await InventoryTransaction.tenant()
+ .query()
+ .select(['transactionId', 'transactionType'])
+ .modify('filterDateRange', this.startingDate)
+ .where('direction', 'OUT')
+ .where('item_id', this.itemId);
+
+ this.revertJEntriesTransactions = revertJEntriesTransactions;
+ }
+
/**
* Revert the inventory lots to the given date by removing the inventory lots
* transactions after the given date and increment the remaining that
@@ -121,14 +196,14 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
const asyncOpers: any[] = [];
const inventoryLotsTrans = await InventoryLotCostTracker.tenant()
.query()
+ .modify('filterDateRange', this.startingDate)
.orderBy('date', 'DESC')
.where('item_id', this.itemId)
- .where('date', '>=', startingDate)
.where('direction', 'OUT');
const deleteInvLotsTrans = InventoryLotCostTracker.tenant()
.query()
- .where('date', '>=', startingDate)
+ .modify('filterDateRange', this.startingDate)
.where('item_id', this.itemId)
.delete();
@@ -151,13 +226,10 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
* @param {} inventoryLots
*/
async revertJournalEntries(
- inventoryLots: IInventoryLotCost[],
+ transactions: IInventoryLotCost[],
) {
- const invoiceTransactions = inventoryLots
- .filter(e => e.transactionType === 'SaleInvoice');
-
return this.journalCommands
- .revertEntriesFromInventoryTransactions(invoiceTransactions);
+ .revertEntriesFromInventoryTransactions(transactions);
}
/**
@@ -237,23 +309,17 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
}
/**
- * Tracking the given inventory transactions to lots costs transactions.
- * @param {IInventoryTransaction[]} inventoryTransactions - Inventory transactions.
- * @return {IInventoryLotCost[]}
+ * Tracking inventory `IN` lots transactions.
+ * @public
+ * @param {IInventoryTransaction[]} inventoryTransactions -
+ * @return {void}
*/
- public trackingInventoryLotsCost(
+ public trackingInventoryINLots(
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] = []));
+ (this.inventoryByItem[itemId] || (this.inventoryByItem[itemId] = []));
const commonLotTransaction: IInventoryLotCost = {
...pick(transaction, [
@@ -261,62 +327,91 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
'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,
- };
- costLotsTransactions.push(inventoryINTrans[id]);
-
- // Record inventory 'OUT' cost lots from 'IN' transactions.
- } else if (transaction.direction === 'OUT') {
- let invRemaining = transaction.quantity;
- const idsShouldDel: number[] = [];
-
- inventoryByItem?.[itemId]?.some((
- _invTransactionId: 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;
- const maxDecrement = Math.min(decrement, invRemaining);
-
- _invINTransaction.decrement += maxDecrement;
- _invINTransaction.remaining = Math.max(
- _invINTransaction.remaining - maxDecrement,
- 0,
- );
- invRemaining = Math.max(invRemaining - maxDecrement, 0);
-
- costLotsTransactions.push({
- ...commonLotTransaction,
- quantity: maxDecrement,
- lotNumber: _invINTransaction.lotNumber,
- });
- // Pop the 'IN' lots that has zero remaining.
- if (_invINTransaction.remaining === 0) {
- idsShouldDel.push(_invTransactionId);
- }
- return false;
- });
- if (invRemaining > 0) {
- costLotsTransactions.push({
- ...commonLotTransaction,
- quantity: invRemaining,
- });
- }
- // Remove the IN transactions that has zero remaining amount.
- inventoryByItem[itemId] = inventoryByItem?.[itemId]
- ?.filter((transId: number) => idsShouldDel.indexOf(transId) === -1);
- }
+ this.inventoryByItem[itemId].push(id);
+ this.inventoryINTrans[id] = {
+ ...commonLotTransaction,
+ decrement: 0,
+ remaining: commonLotTransaction.remaining || commonLotTransaction.quantity,
+ };
+ this.costLotsTransactions.push(this.inventoryINTrans[id]);
});
- return costLotsTransactions;
}
+ /**
+ * Tracking inventory `OUT` lots transactions.
+ * @public
+ * @param {IInventoryTransaction[]} inventoryTransactions -
+ * @return {void}
+ */
+ public trackingInventoryOUTLots(
+ inventoryTransactions: IInventoryTransaction[],
+ ) {
+ inventoryTransactions.forEach((transaction: IInventoryTransaction) => {
+ const { itemId, id } = transaction;
+ (this.inventoryByItem[itemId] || (this.inventoryByItem[itemId] = []));
+
+ const commonLotTransaction: IInventoryLotCost = {
+ ...pick(transaction, [
+ 'date', 'rate', 'itemId', 'quantity', 'invTransId', 'lotTransId',
+ 'direction', 'transactionType', 'transactionId', 'lotNumber', 'remaining'
+ ]),
+ };
+ let invRemaining = transaction.quantity;
+ const idsShouldDel: number[] = [];
+
+ this.inventoryByItem?.[itemId]?.some((_invTransactionId: number) => {
+ const _invINTransaction = this.inventoryINTrans[_invTransactionId];
+
+ // Can't continue if the IN transaction remaining equals zero.
+ if (invRemaining <= 0) { return true; }
+
+ // Can't continue if the IN transaction date is after the current transaction date.
+ if (moment(_invINTransaction.date).isAfter(transaction.date)) {
+ 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;
+ const maxDecrement = Math.min(decrement, invRemaining);
+
+ _invINTransaction.decrement += maxDecrement;
+ _invINTransaction.remaining = Math.max(
+ _invINTransaction.remaining - maxDecrement,
+ 0,
+ );
+ invRemaining = Math.max(invRemaining - maxDecrement, 0);
+
+ this.costLotsTransactions.push({
+ ...commonLotTransaction,
+ quantity: maxDecrement,
+ lotNumber: _invINTransaction.lotNumber,
+ });
+ // Pop the 'IN' lots that has zero remaining.
+ if (_invINTransaction.remaining === 0) {
+ idsShouldDel.push(_invTransactionId);
+ }
+ return false;
+ });
+ if (invRemaining > 0) {
+ this.costLotsTransactions.push({
+ ...commonLotTransaction,
+ quantity: invRemaining,
+ });
+ }
+ this.removeInventoryItems(itemId, idsShouldDel);
+ });
+ }
+
+ /**
+ * Remove inventory transactions for specific item id.
+ * @private
+ * @param {number} itemId
+ * @param {number[]} idsShouldDel
+ * @return {void}
+ */
+ private removeInventoryItems(itemId: number, idsShouldDel: number[]) {
+ // Remove the IN transactions that has zero remaining amount.
+ this.inventoryByItem[itemId] = this.inventoryByItem?.[itemId]
+ ?.filter((transId: number) => idsShouldDel.indexOf(transId) === -1);
+ }
}
\ No newline at end of file
diff --git a/server/src/services/Sales/SalesInvoices.ts b/server/src/services/Sales/SalesInvoices.ts
index 837ac7fe7..f3c45c75e 100644
--- a/server/src/services/Sales/SalesInvoices.ts
+++ b/server/src/services/Sales/SalesInvoices.ts
@@ -12,12 +12,34 @@ import HasItemsEntries from '@/services/Sales/HasItemsEntries';
import CustomerRepository from '@/repositories/CustomerRepository';
import InventoryService from '@/services/Inventory/Inventory';
import { formatDateFields } from '@/utils';
+import { Item } from '../../models';
+import JournalCommands from '../Accounting/JournalCommands';
/**
* Sales invoices service
* @service
*/
export default class SaleInvoicesService {
+
+ static filterNonInventoryEntries(entries: [], items: []) {
+ const nonInventoryItems = items.filter((item: any) => item.type !== 'inventory');
+ const nonInventoryItemsIds = nonInventoryItems.map((i: any) => i.id);
+
+ return entries
+ .filter((entry: any) => (
+ (nonInventoryItemsIds.indexOf(entry.item_id)) !== -1
+ ));
+ }
+
+ static filterInventoryEntries(entries: [], items: []) {
+ const inventoryItems = items.filter((item: any) => item.type === 'inventory');
+ const inventoryItemsIds = inventoryItems.map((i: any) => i.id);
+
+ return entries
+ .filter((entry: any) => (
+ (inventoryItemsIds.indexOf(entry.item_id)) !== -1
+ ));
+ }
/**
* Creates a new sale invoices and store it to the storage
* with associated to entries and journal transactions.
@@ -60,19 +82,65 @@ export default class SaleInvoicesService {
const recordInventoryTransOpers = this.recordInventoryTranscactions(
saleInvoice, storedInvoice.id
);
+ // Records the non-inventory transactions of the entries items.
+ const recordNonInventoryJEntries = this.recordNonInventoryEntries(
+ saleInvoice, storedInvoice.id,
+ );
// Await all async operations.
await Promise.all([
...opers,
incrementOper,
+ recordNonInventoryJEntries,
recordInventoryTransOpers,
]);
// Schedule sale invoice re-compute based on the item cost
// method and starting date.
- await this.scheduleComputeItemsCost(saleInvoice);
-
+ // await this.scheduleComputeItemsCost(saleInvoice);
return storedInvoice;
}
+ /**
+ * Records the journal entries for non-inventory entries.
+ * @param {SaleInvoice} saleInvoice
+ */
+ static async recordNonInventoryEntries(saleInvoice: any, saleInvoiceId: number) {
+ const saleInvoiceItems = saleInvoice.entries.map((entry: any) => entry.item_id);
+
+ // Retrieves items data to detarmines whether the item type.
+ const itemsMeta = await Item.tenant().query().whereIn('id', saleInvoiceItems);
+ const storedItemsMap = new Map(itemsMeta.map((item) => [item.id, item]));
+
+ // Filters the non-inventory and inventory entries based on the item type.
+ const nonInventoryEntries: any[] = this.filterNonInventoryEntries(saleInvoice.entries, itemsMeta);
+
+ const transactions: any = [];
+ const common = {
+ referenceType: 'SaleInvoice',
+ referenceId: saleInvoiceId,
+ date: saleInvoice.invoice_date,
+ };
+ nonInventoryEntries.forEach((entry) => {
+ const item = storedItemsMap.get(entry.item_id);
+
+ transactions.push({
+ ...common,
+ income: entry.amount,
+ incomeAccountId: item.incomeAccountId,
+ })
+ });
+ const accountsDepGraph = await Account.tenant().depGraph().query();
+ const journal = new JournalPoster(accountsDepGraph);
+ const journalCommands = new JournalCommands(journal);
+
+ journalCommands.nonInventoryEntries(transactions);
+
+ return Promise.all([
+ journal.deleteEntries(),
+ journal.saveEntries(),
+ journal.saveBalance(),
+ ]);
+ }
+
/**
* Edit the given sale invoice.
* @async