feat: Item validate cost, income and inventory account type.

feat: Style sales and purchases forms - 80% progress.
feat: Validate purchase-able and sell-able items in invoices and bills.
feat: Fix bugs in inventory FIFO/LIFO cost methods.
This commit is contained in:
Ahmed Bouhuolia
2020-08-22 11:58:08 +02:00
parent b46570dc01
commit 45088b2d3b
34 changed files with 841 additions and 636 deletions

View File

@@ -0,0 +1,4 @@
export default {
DATATABLE_EDITOR: 'datatable-editor',
};

View File

@@ -9,6 +9,7 @@ import * as Yup from 'yup';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import moment from 'moment'; import moment from 'moment';
import { Intent, FormGroup, TextArea } from '@blueprintjs/core'; import { Intent, FormGroup, TextArea } from '@blueprintjs/core';
import { Row, Col } from 'react-grid-system';
import { FormattedMessage as T, useIntl } from 'react-intl'; import { FormattedMessage as T, useIntl } from 'react-intl';
import { pick } from 'lodash'; import { pick } from 'lodash';
@@ -291,15 +292,22 @@ function BillForm({
onClickAddNewRow={onClickAddNewRow} onClickAddNewRow={onClickAddNewRow}
onClickClearAllLines={onClickCleanAllLines} onClickClearAllLines={onClickCleanAllLines}
/> />
<FormGroup label={<T id={'note'} />} className={'form-group--'}> <Row>
<TextArea growVertically={true} {...formik.getFieldProps('note')} /> <Col>
</FormGroup> <FormGroup label={<T id={'note'} />} className={'form-group--'}>
<Dragzone <TextArea growVertically={true} {...formik.getFieldProps('note')} />
initialFiles={initialAttachmentFiles} </FormGroup>
onDrop={handleDropFiles} </Col>
onDeleteFile={handleDeleteFile}
hint={'Attachments: Maxiumum size: 20MB'} <Col>
/> <Dragzone
initialFiles={initialAttachmentFiles}
onDrop={handleDropFiles}
onDeleteFile={handleDeleteFile}
hint={'Attachments: Maxiumum size: 20MB'}
/>
</Col>
</Row>
</form> </form>
<BillFormFooter <BillFormFooter
formik={formik} formik={formik}

View File

@@ -9,7 +9,7 @@ export default function BillFormFooter({
bill, bill,
}) { }) {
return ( return (
<div> <div className={'form__floating-footer'}>
<Button <Button
disabled={isSubmitting} disabled={isSubmitting}
intent={Intent.PRIMARY} intent={Intent.PRIMARY}

View File

@@ -76,12 +76,10 @@ function BillFormHeader({
} }
}; };
console.log(vendorsCurrentPage, 'vendorsCurrentPage');
console.log(vendorItems, 'vendorItems');
return ( return (
<div> <div className="page-form page-form--bill">
<div> <div className={'page-form__primary-section'}>
{/* vendor account name */} {/* Vendor account name */}
<FormGroup <FormGroup
label={<T id={'vendor_name'} />} label={<T id={'vendor_name'} />}
inline={true} inline={true}

View File

@@ -11,7 +11,8 @@ import moment from 'moment';
import { Intent, FormGroup, TextArea } from '@blueprintjs/core'; import { Intent, FormGroup, TextArea } from '@blueprintjs/core';
import { FormattedMessage as T, useIntl } from 'react-intl'; import { FormattedMessage as T, useIntl } from 'react-intl';
import { pick, omit } from 'lodash'; import { omit } from 'lodash';
import { Row, Col } from 'react-grid-system';
import BillFormHeader from './BillFormHeader'; import BillFormHeader from './BillFormHeader';
import EstimatesItemsTable from 'containers/Sales/Estimate/EntriesItemsTable'; import EstimatesItemsTable from 'containers/Sales/Estimate/EntriesItemsTable';
@@ -273,15 +274,24 @@ function BillForm({
onClickAddNewRow={onClickAddNewRow} onClickAddNewRow={onClickAddNewRow}
onClickClearAllLines={onClickCleanAllLines} onClickClearAllLines={onClickCleanAllLines}
/> />
<FormGroup label={<T id={'note'} />} className={'form-group--'}>
<TextArea growVertically={true} {...formik.getFieldProps('note')} /> <Row>
</FormGroup> <Col>
<Dragzone <FormGroup label={<T id={'note'} />} className={'form-group--'}>
initialFiles={initialAttachmentFiles} <TextArea growVertically={true} {...formik.getFieldProps('note')} />
onDrop={handleDropFiles} </FormGroup>
onDeleteFile={handleDeleteFile} </Col>
hint={'Attachments: Maxiumum size: 20MB'}
/> <Col>
<Dragzone
initialFiles={initialAttachmentFiles}
onDrop={handleDropFiles}
onDeleteFile={handleDeleteFile}
hint={'Attachments: Maxiumum size: 20MB'}
/>
</Col>
</Row>
<BillFormFooter <BillFormFooter
formik={formik} formik={formik}
onSubmit={handleSubmitClick} onSubmit={handleSubmitClick}

View File

@@ -1,10 +1,11 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react'; import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { omit } from 'lodash';
import { Button, Intent, Position, Tooltip } from '@blueprintjs/core'; import { Button, Intent, Position, Tooltip } from '@blueprintjs/core';
import { FormattedMessage as T, useIntl } from 'react-intl'; import { FormattedMessage as T, useIntl } from 'react-intl';
import CLASSES from 'components/classes';
import DataTable from 'components/DataTable'; import DataTable from 'components/DataTable';
import Icon from 'components/Icon'; import Icon from 'components/Icon';
import { compose, formattedAmount } from 'utils';
import { import {
InputGroupCell, InputGroupCell,
MoneyFieldCell, MoneyFieldCell,
@@ -14,7 +15,7 @@ import {
} from 'components/DataTableCells'; } from 'components/DataTableCells';
import withItems from 'containers/Items/withItems'; import withItems from 'containers/Items/withItems';
import { omit } from 'lodash'; import { compose, formattedAmount } from 'utils';
const ActionsCellRenderer = ({ const ActionsCellRenderer = ({
row: { index }, row: { index },
@@ -92,6 +93,7 @@ function EstimateTable({
width: 40, width: 40,
disableResizing: true, disableResizing: true,
disableSortBy: true, disableSortBy: true,
className: 'index',
}, },
{ {
Header: formatMessage({ id: 'product_and_service' }), Header: formatMessage({ id: 'product_and_service' }),
@@ -108,6 +110,7 @@ function EstimateTable({
Cell: InputGroupCell, Cell: InputGroupCell,
disableSortBy: true, disableSortBy: true,
className: 'description', className: 'description',
width: 120,
}, },
{ {
@@ -123,7 +126,7 @@ function EstimateTable({
accessor: 'rate', accessor: 'rate',
Cell: TotalEstimateCellRederer(MoneyFieldCell, 'rate'), Cell: TotalEstimateCellRederer(MoneyFieldCell, 'rate'),
disableSortBy: true, disableSortBy: true,
width: 150, width: 100,
className: 'rate', className: 'rate',
}, },
{ {
@@ -230,8 +233,9 @@ function EstimateTable({
updateData: handleUpdateData, updateData: handleUpdateData,
removeRow: handleRemoveRow, removeRow: handleRemoveRow,
}} }}
className={CLASSES.DATATABLE_EDITOR}
/> />
<div className={'mt1'}> <div className={'datatable-editor-actions mt1'}>
<Button <Button
small={true} small={true}
className={'button--secondary button--new-line'} className={'button--secondary button--new-line'}

View File

@@ -5,14 +5,13 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import moment from 'moment'; import moment from 'moment';
import { Intent, FormGroup, TextArea, Button } from '@blueprintjs/core'; import { Intent, FormGroup, TextArea } from '@blueprintjs/core';
import { FormattedMessage as T, useIntl } from 'react-intl'; import { FormattedMessage as T, useIntl } from 'react-intl';
import { pick, omit } from 'lodash'; import { pick } from 'lodash';
import { queryCache } from 'react-query'; import { Row, Col } from 'react-grid-system';
import EstimateFormHeader from './EstimateFormHeader'; import EstimateFormHeader from './EstimateFormHeader';
import EstimatesItemsTable from './EntriesItemsTable'; import EstimatesItemsTable from './EntriesItemsTable';
@@ -103,13 +102,11 @@ const EstimateForm = ({
.min(1) .min(1)
.max(1024) .max(1024)
.label(formatMessage({ id: 'note' })), .label(formatMessage({ id: 'note' })),
terms_conditions: Yup.string() terms_conditions: Yup.string()
.trim() .trim()
.min(1) .min(1)
.max(1024) .max(1024)
.label(formatMessage({ id: 'note' })), .label(formatMessage({ id: 'note' })),
entries: Yup.array().of( entries: Yup.array().of(
Yup.object().shape({ Yup.object().shape({
quantity: Yup.number().nullable(), quantity: Yup.number().nullable(),
@@ -320,27 +317,35 @@ const EstimateForm = ({
formik={formik} formik={formik}
// defaultRow={defaultEstimate} // defaultRow={defaultEstimate}
/> />
<FormGroup
label={<T id={'customer_note'} />} <Row>
className={'form-group--customer_note'} <Col>
> <FormGroup
<TextArea growVertically={true} {...formik.getFieldProps('note')} /> label={<T id={'customer_note'} />}
</FormGroup> className={'form-group--customer_note'}
<FormGroup >
label={<T id={'terms_conditions'} />} <TextArea growVertically={true} {...formik.getFieldProps('note')} />
className={'form-group--terms_conditions'} </FormGroup>
> <FormGroup
<TextArea label={<T id={'terms_conditions'} />}
growVertically={true} className={'form-group--terms_conditions'}
{...formik.getFieldProps('terms_conditions')} >
/> <TextArea
</FormGroup> growVertically={true}
<Dragzone {...formik.getFieldProps('terms_conditions')}
initialFiles={initialAttachmentFiles} />
onDrop={handleDropFiles} </FormGroup>
onDeleteFile={handleDeleteFile} </Col>
hint={'Attachments: Maxiumum size: 20MB'}
/> <Col>
<Dragzone
initialFiles={initialAttachmentFiles}
onDrop={handleDropFiles}
onDeleteFile={handleDeleteFile}
hint={'Attachments: Maxiumum size: 20MB'}
/>
</Col>
</Row>
</form> </form>
<EstimateFormFooter <EstimateFormFooter
formik={formik} formik={formik}

View File

@@ -11,7 +11,7 @@ export default function EstimateFormFooter({
estimate, estimate,
}) { }) {
return ( return (
<div className={'estimate-form__floating-footer'}> <div className={'form__floating-footer'}>
<Button <Button
disabled={isSubmitting} disabled={isSubmitting}
intent={Intent.PRIMARY} intent={Intent.PRIMARY}

View File

@@ -68,13 +68,16 @@ function EstimateFormHeader({
); );
return ( return (
<div className={'estimate-form'}> <div className={'page-form page-form--estimate'}>
<div className={'estimate-form__primary-section'}> <div className={'page-form__primary-section'}>
{/* customer name */}
<FormGroup <FormGroup
label={<T id={'customer_name'} />} label={<T id={'customer_name'} />}
inline={true} inline={true}
className={classNames('form-group--select-list', Classes.FILL)} className={classNames(
'form-group--select-list',
'form-group--customer',
Classes.FILL,
)}
labelInfo={<FieldRequiredHint />} labelInfo={<FieldRequiredHint />}
intent={errors.customer_id && touched.customer_id && Intent.DANGER} intent={errors.customer_id && touched.customer_id && Intent.DANGER}
helperText={ helperText={
@@ -94,7 +97,7 @@ function EstimateFormHeader({
labelProp={'display_name'} labelProp={'display_name'}
/> />
</FormGroup> </FormGroup>
{/* estimate_date */}
<Row> <Row>
<Col <Col
@@ -104,7 +107,11 @@ function EstimateFormHeader({
label={<T id={'estimate_date'} />} label={<T id={'estimate_date'} />}
inline={true} inline={true}
labelInfo={<FieldRequiredHint />} labelInfo={<FieldRequiredHint />}
className={classNames('form-group--select-list', Classes.FILL)} className={classNames(
'form-group--select-list',
Classes.FILL,
'form-group--estimate-date'
)}
intent={ intent={
errors.estimate_date && touched.estimate_date && Intent.DANGER errors.estimate_date && touched.estimate_date && Intent.DANGER
} }
@@ -127,7 +134,11 @@ function EstimateFormHeader({
<FormGroup <FormGroup
label={<T id={'expiration_date'} />} label={<T id={'expiration_date'} />}
inline={true} inline={true}
className={classNames('form-group--select-list', Classes.FILL)} className={classNames(
'form-group--select-list',
'form-group--expiration-date',
Classes.FILL
)}
intent={ intent={
errors.expiration_date && errors.expiration_date &&
touched.expiration_date && touched.expiration_date &&
@@ -147,11 +158,12 @@ function EstimateFormHeader({
</Col> </Col>
</Row> </Row>
</div> </div>
{/* Estimate */}
{/*- Estimate -*/}
<FormGroup <FormGroup
label={<T id={'estimate'} />} label={<T id={'estimate'} />}
inline={true} inline={true}
className={('form-group--estimate', Classes.FILL)} className={('form-group--estimate-number', Classes.FILL)}
labelInfo={<FieldRequiredHint />} labelInfo={<FieldRequiredHint />}
intent={ intent={
errors.estimate_number && touched.estimate_number && Intent.DANGER errors.estimate_number && touched.estimate_number && Intent.DANGER
@@ -168,6 +180,7 @@ function EstimateFormHeader({
{...getFieldProps('estimate_number')} {...getFieldProps('estimate_number')}
/> />
</FormGroup> </FormGroup>
<FormGroup <FormGroup
label={<T id={'reference'} />} label={<T id={'reference'} />}
inline={true} inline={true}

View File

@@ -33,6 +33,7 @@ function Estimates({
requestFetchCustomers({}), requestFetchCustomers({}),
); );
//
const handleFormSubmit = useCallback( const handleFormSubmit = useCallback(
(payload) => { (payload) => {
payload.redirect && history.push('/estimates'); payload.redirect && history.push('/estimates');

View File

@@ -13,6 +13,7 @@ export default (mapState) => {
const mapStateToProps = (state, props) => { const mapStateToProps = (state, props) => {
const query = getEstimatesTableQuery(state, props); const query = getEstimatesTableQuery(state, props);
const mapped = { const mapped = {
estimatesCurrentPage: getEstimatesItems(state, props, query), estimatesCurrentPage: getEstimatesItems(state, props, query),
estimateViews: getResourceViews(state, props, 'sales_estimates'), estimateViews: getResourceViews(state, props, 'sales_estimates'),

View File

@@ -11,6 +11,7 @@ import moment from 'moment';
import { Intent, FormGroup, TextArea, Button } from '@blueprintjs/core'; import { Intent, FormGroup, TextArea, Button } from '@blueprintjs/core';
import { FormattedMessage as T, useIntl } from 'react-intl'; import { FormattedMessage as T, useIntl } from 'react-intl';
import { pick } from 'lodash'; import { pick } from 'lodash';
import { Row, Col } from 'react-grid-system';
import InvoiceFormHeader from './InvoiceFormHeader'; import InvoiceFormHeader from './InvoiceFormHeader';
import EstimatesItemsTable from 'containers/Sales/Estimate/EntriesItemsTable'; import EstimatesItemsTable from 'containers/Sales/Estimate/EntriesItemsTable';
@@ -308,31 +309,38 @@ function InvoiceForm({
onClickClearAllLines={handleClearAllLines} onClickClearAllLines={handleClearAllLines}
formik={formik} formik={formik}
/> />
<FormGroup <Row>
label={<T id={'invoice_message'} />} <Col>
className={'form-group--customer_note'} <FormGroup
> label={<T id={'invoice_message'} />}
<TextArea className={'form-group--customer_note'}
growVertically={true} >
{...formik.getFieldProps('invoice_message')} <TextArea
/> growVertically={true}
</FormGroup> {...formik.getFieldProps('invoice_message')}
<FormGroup />
label={<T id={'terms_conditions'} />} </FormGroup>
className={'form-group--terms_conditions'} <FormGroup
> label={<T id={'terms_conditions'} />}
<TextArea className={'form-group--terms_conditions'}
growVertically={true} >
{...formik.getFieldProps('terms_conditions')} <TextArea
/> growVertically={true}
</FormGroup> {...formik.getFieldProps('terms_conditions')}
<Dragzone />
initialFiles={initialAttachmentFiles} </FormGroup>
onDrop={handleDropFiles} </Col>
onDeleteFile={handleDeleteFile}
hint={'Attachments: Maxiumum size: 20MB'} <Col>
/> <Dragzone
initialFiles={initialAttachmentFiles}
onDrop={handleDropFiles}
onDeleteFile={handleDeleteFile}
hint={'Attachments: Maxiumum size: 20MB'}
/></Col>
</Row>
</form> </form>
<InvoiceFormFooter <InvoiceFormFooter
formik={formik} formik={formik}
onSubmitClick={handleSubmitClick} onSubmitClick={handleSubmitClick}

View File

@@ -9,7 +9,7 @@ export default function EstimateFormFooter({
invoice, invoice,
}) { }) {
return ( return (
<div className={'estimate-form__floating-footer'}> <div className={'form__floating-footer'}>
<Button <Button
disabled={isSubmitting} disabled={isSubmitting}
intent={Intent.PRIMARY} intent={Intent.PRIMARY}

View File

@@ -68,8 +68,8 @@ function InvoiceFormHeader({
); );
return ( return (
<div className={'invoice-form'}> <div class="page-form page-form--invoice">
<div className={'invoice__primary-section'}> <div className={'page-form__primary-section'}>
{/* customer name */} {/* customer name */}
<FormGroup <FormGroup
label={<T id={'customer_name'} />} label={<T id={'customer_name'} />}

View File

@@ -12,6 +12,7 @@ import moment from 'moment';
import { Intent, FormGroup, TextArea } from '@blueprintjs/core'; import { Intent, FormGroup, TextArea } from '@blueprintjs/core';
import { FormattedMessage as T, useIntl } from 'react-intl'; import { FormattedMessage as T, useIntl } from 'react-intl';
import { pick } from 'lodash'; import { pick } from 'lodash';
import { Row, Col } from 'react-grid-system';
import ReceiptFromHeader from './ReceiptFormHeader'; import ReceiptFromHeader from './ReceiptFormHeader';
import EstimatesItemsTable from 'containers/Sales/Estimate/EntriesItemsTable'; import EstimatesItemsTable from 'containers/Sales/Estimate/EntriesItemsTable';
@@ -296,31 +297,38 @@ function ReceiptForm({
onClickClearAllLines={handleClearAllLines} onClickClearAllLines={handleClearAllLines}
formik={formik} formik={formik}
/> />
<FormGroup
label={<T id={'receipt_message'} />}
className={'form-group--'}
>
<TextArea
growVertically={true}
{...formik.getFieldProps('receipt_message')}
/>
</FormGroup>
<FormGroup
label={<T id={'statement'} />}
className={'form-group--statement'}
>
<TextArea
growVertically={true}
{...formik.getFieldProps('statement')}
/>
</FormGroup>
<Dragzone <Row>
initialFiles={initialAttachmentFiles} <Col>
onDrop={handleDropFiles} <FormGroup
onDeleteFile={handleDeleteFile} label={<T id={'receipt_message'} />}
hint={'Attachments: Maxiumum size: 20MB'} className={'form-group--'}
/> >
<TextArea
growVertically={true}
{...formik.getFieldProps('receipt_message')}
/>
</FormGroup>
<FormGroup
label={<T id={'statement'} />}
className={'form-group--statement'}
>
<TextArea
growVertically={true}
{...formik.getFieldProps('statement')}
/>
</FormGroup>
</Col>
<Col>
<Dragzone
initialFiles={initialAttachmentFiles}
onDrop={handleDropFiles}
onDeleteFile={handleDeleteFile}
hint={'Attachments: Maxiumum size: 20MB'}
/>
</Col>
</Row>
</form> </form>
<ReceiptFormFooter <ReceiptFormFooter
formik={formik} formik={formik}

View File

@@ -9,7 +9,7 @@ export default function ReceiptFormFooter({
receipt, receipt,
}) { }) {
return ( return (
<div className={'estimate-form__floating-footer'}> <div className={'form__floating-footer'}>
<Button <Button
disabled={isSubmitting} disabled={isSubmitting}
intent={Intent.PRIMARY} intent={Intent.PRIMARY}

View File

@@ -83,13 +83,17 @@ function ReceiptFormHeader({
); );
return ( return (
<div> <div class="page-form receipt-form">
<div> <div class="page-form__primary-section">
{/* customer name */} {/*- Customer name -*/}
<FormGroup <FormGroup
label={<T id={'customer_name'} />} label={<T id={'customer_name'} />}
inline={true} inline={true}
className={classNames('form-group--select-list', Classes.FILL)} className={classNames(
'form-group--select-list',
Classes.FILL,
'form-group--customer',
)}
labelInfo={<FieldRequiredHint />} labelInfo={<FieldRequiredHint />}
intent={errors.customer_id && touched.customer_id && Intent.DANGER} intent={errors.customer_id && touched.customer_id && Intent.DANGER}
helperText={ helperText={
@@ -110,10 +114,11 @@ function ReceiptFormHeader({
/> />
</FormGroup> </FormGroup>
{/*- Deposit account -*/}
<FormGroup <FormGroup
label={<T id={'deposit_account'} />} label={<T id={'deposit_account'} />}
className={classNames( className={classNames(
'form-group--deposit_account_id', 'form-group--deposit-account',
'form-group--select-list', 'form-group--select-list',
Classes.FILL, Classes.FILL,
)} )}
@@ -172,6 +177,7 @@ function ReceiptFormHeader({
/> />
</FormGroup> */} </FormGroup> */}
{/*- Reference -*/}
<FormGroup <FormGroup
label={<T id={'reference'} />} label={<T id={'reference'} />}
inline={true} inline={true}
@@ -185,6 +191,8 @@ function ReceiptFormHeader({
{...getFieldProps('reference_no')} {...getFieldProps('reference_no')}
/> />
</FormGroup> </FormGroup>
{/*- Send to email -*/}
<FormGroup <FormGroup
label={<T id={'send_to_email'} />} label={<T id={'send_to_email'} />}
inline={true} inline={true}

View File

@@ -59,10 +59,13 @@ $pt-font-family: Noto Sans, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
@import 'pages/invite-user.scss'; @import 'pages/invite-user.scss';
@import 'pages/exchange-rate.scss'; @import 'pages/exchange-rate.scss';
@import 'pages/customer.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'; @import 'views/sidebar';
.App { .App {

View File

@@ -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;
// }
// }
// }
// }

View File

@@ -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;
}
}
}
}

View File

View File

View File

@@ -135,6 +135,26 @@ exports.seed = (knex) => {
index: 1, index: 1,
active: 1, active: 1,
description: 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: '',
} }
]); ]);
}); });

View File

@@ -1,5 +1,5 @@
import { Router, Request, Response } from 'express'; 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 asyncMiddleware from '@/http/middleware/asyncMiddleware';
import validateMiddleware from '@/http/middleware/validateMiddleware'; import validateMiddleware from '@/http/middleware/validateMiddleware';
import ItemsService from '@/services/Items/ItemsService'; import ItemsService from '@/services/Items/ItemsService';
@@ -124,9 +124,6 @@ export default class ItemsController {
/** /**
* Validate specific item params schema. * Validate specific item params schema.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/ */
static get validateSpecificItemSchema(): ValidationChain[] { static get validateSpecificItemSchema(): ValidationChain[] {
return [ return [
@@ -135,6 +132,9 @@ export default class ItemsController {
} }
/**
* Validate list query schema
*/
static get validateListQuerySchema() { static get validateListQuerySchema() {
return [ return [
query('column_sort_order').optional().isIn(['created_at', 'name', 'amount', 'sku']), query('column_sort_order').optional().isIn(['created_at', 'name', 'amount', 'sku']),
@@ -221,16 +221,21 @@ export default class ItemsController {
* @param {Function} next * @param {Function} next
*/ */
static async validateCostAccountExistance(req: Request, res: Response, next: Function) { static async validateCostAccountExistance(req: Request, res: Response, next: Function) {
const { Account } = req.models; const { Account, AccountType } = req.models;
const item = req.body; const item = req.body;
if (item.cost_account_id) { 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) { if (!foundAccount) {
return res.status(400).send({ return res.status(400).send({
errors: [{ type: 'COST.ACCOUNT.NOT.FOUND', code: 120 }], 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(); next();
@@ -243,16 +248,21 @@ export default class ItemsController {
* @param {NextFunction} next * @param {NextFunction} next
*/ */
static async validateSellAccountExistance(req: Request, res: Response, next: Function) { static async validateSellAccountExistance(req: Request, res: Response, next: Function) {
const { Account } = req.models; const { Account, AccountType } = req.models;
const item = req.body; const item = req.body;
if (item.sell_account_id) { if (item.sell_account_id) {
const incomeType = await AccountType.query().findOne('key', 'income');
const foundAccount = await Account.query().findById(item.sell_account_id); const foundAccount = await Account.query().findById(item.sell_account_id);
if (!foundAccount) { if (!foundAccount) {
return res.status(400).send({ return res.status(400).send({
errors: [{ type: 'SELL.ACCOUNT.NOT.FOUND', code: 130 }], 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(); next();
@@ -265,16 +275,21 @@ export default class ItemsController {
* @param {NextFunction} next * @param {NextFunction} next
*/ */
static async validateInventoryAccountExistance(req: Request, res: Response, next: Function) { static async validateInventoryAccountExistance(req: Request, res: Response, next: Function) {
const { Account } = req.models; const { Account, AccountType } = req.models;
const item = req.body; const item = req.body;
if (item.inventory_account_id) { if (item.inventory_account_id) {
const otherAsset = await AccountType.query().findOne('key', 'other_asset');
const foundAccount = await Account.query().findById(item.inventory_account_id); const foundAccount = await Account.query().findById(item.inventory_account_id);
if (!foundAccount) { if (!foundAccount) {
return res.status(400).send({ return res.status(400).send({
errors: [{ type: 'INVENTORY.ACCOUNT.NOT.FOUND', code: 200}], 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(); next();

View File

@@ -25,6 +25,7 @@ export default class BillsController extends BaseController {
asyncMiddleware(this.validateVendorExistance), asyncMiddleware(this.validateVendorExistance),
asyncMiddleware(this.validateItemsIds), asyncMiddleware(this.validateItemsIds),
asyncMiddleware(this.validateBillNumberExists), asyncMiddleware(this.validateBillNumberExists),
asyncMiddleware(this.validateNonPurchasableEntriesItems),
asyncMiddleware(this.newBill) asyncMiddleware(this.newBill)
); );
router.post( router.post(
@@ -35,6 +36,7 @@ export default class BillsController extends BaseController {
asyncMiddleware(this.validateVendorExistance), asyncMiddleware(this.validateVendorExistance),
asyncMiddleware(this.validateItemsIds), asyncMiddleware(this.validateItemsIds),
asyncMiddleware(this.validateEntriesIdsExistance), asyncMiddleware(this.validateEntriesIdsExistance),
asyncMiddleware(this.validateNonPurchasableEntriesItems),
asyncMiddleware(this.editBill) asyncMiddleware(this.editBill)
); );
router.get( router.get(
@@ -201,6 +203,32 @@ export default class BillsController extends BaseController {
next(); 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. * Creates a new bill and records journal transactions.
* @param {Request} req * @param {Request} req

View File

@@ -11,7 +11,7 @@ import CustomersService from '@/services/Customers/CustomersService';
import DynamicListing from '@/services/DynamicListing/DynamicListing'; import DynamicListing from '@/services/DynamicListing/DynamicListing';
import DynamicListingBuilder from '@/services/DynamicListing/DynamicListingBuilder'; import DynamicListingBuilder from '@/services/DynamicListing/DynamicListingBuilder';
import { dynamicListingErrorsToResponse } from '@/services/DynamicListing/hasDynamicListing'; import { dynamicListingErrorsToResponse } from '@/services/DynamicListing/hasDynamicListing';
import { Customer } from '../../../models'; import { Customer, Item } from '../../../models';
export default class SaleInvoicesController { export default class SaleInvoicesController {
/** /**
@@ -27,6 +27,7 @@ export default class SaleInvoicesController {
asyncMiddleware(this.validateInvoiceCustomerExistance), asyncMiddleware(this.validateInvoiceCustomerExistance),
asyncMiddleware(this.validateInvoiceNumberUnique), asyncMiddleware(this.validateInvoiceNumberUnique),
asyncMiddleware(this.validateInvoiceItemsIdsExistance), asyncMiddleware(this.validateInvoiceItemsIdsExistance),
asyncMiddleware(this.validateNonSellableEntriesItems),
asyncMiddleware(this.newSaleInvoice) asyncMiddleware(this.newSaleInvoice)
); );
router.post( router.post(
@@ -42,6 +43,7 @@ export default class SaleInvoicesController {
asyncMiddleware(this.validateInvoiceItemsIdsExistance), asyncMiddleware(this.validateInvoiceItemsIdsExistance),
asyncMiddleware(this.valdiateInvoiceEntriesIdsExistance), asyncMiddleware(this.valdiateInvoiceEntriesIdsExistance),
asyncMiddleware(this.validateEntriesIdsExistance), asyncMiddleware(this.validateEntriesIdsExistance),
asyncMiddleware(this.validateNonSellableEntriesItems),
asyncMiddleware(this.editSaleInvoice) asyncMiddleware(this.editSaleInvoice)
); );
router.delete( router.delete(
@@ -257,6 +259,32 @@ export default class SaleInvoicesController {
next(); 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. * Creates a new sale invoice.
* @param {Request} req * @param {Request} req

View File

@@ -8,7 +8,7 @@ export default class ComputeItemCostJob {
try { try {
await InventoryService.computeItemCost(startingDate, itemId, costMethod); await InventoryService.computeItemCost(startingDate, itemId, costMethod);
Logger.log(`Compute item cost: ${job.attrs.data}`); Logger.debug(`Compute item cost: ${job.attrs.data}`);
done(); done();
} catch(e) { } catch(e) {
console.log(e); console.log(e);

View File

@@ -1,4 +1,5 @@
import { Model } from 'objection'; import { Model } from 'objection';
import moment from 'moment';
import TenantModel from '@/models/TenantModel'; import TenantModel from '@/models/TenantModel';
export default class InventoryCostLotTracker extends TenantModel { export default class InventoryCostLotTracker extends TenantModel {
@@ -16,6 +17,27 @@ export default class InventoryCostLotTracker extends TenantModel {
return []; 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. * Relationship mapping.
*/ */

View File

@@ -1,4 +1,5 @@
import { Model } from 'objection'; import { Model } from 'objection';
import moment from 'moment';
import TenantModel from '@/models/TenantModel'; import TenantModel from '@/models/TenantModel';
export default class InventoryTransaction extends TenantModel { export default class InventoryTransaction extends TenantModel {
@@ -16,6 +17,28 @@ export default class InventoryTransaction extends TenantModel {
return ['createdAt', 'updatedAt']; 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. * Relationship mapping.
*/ */

View File

@@ -21,6 +21,22 @@ interface IInventoryCostEntity {
income: number, 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{ export default class JournalCommands{
journal: JournalPoster; 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 - * @param {string} referenceType -

View File

@@ -59,7 +59,6 @@ export default class InventoryService {
entries: [], entries: [],
deleteOld: boolean, deleteOld: boolean,
) { ) {
const storedOpers: any = [];
const entriesItemsIds = entries.map((e: any) => e.item_id); const entriesItemsIds = entries.map((e: any) => e.item_id);
const inventoryItems = await Item.tenant() const inventoryItems = await Item.tenant()
.query() .query()
@@ -79,15 +78,11 @@ export default class InventoryService {
entry.transactionType, entry.transactionType,
); );
} }
const oper = InventoryTransaction.tenant().query().insert({ await InventoryTransaction.tenant().query().insert({
...entry, ...entry,
lotNumber: entry.lotNumber, lotNumber: entry.lotNumber,
}); });
storedOpers.push(oper);
}); });
return Promise.all([
...storedOpers,
]);
} }
/** /**

View File

@@ -1,4 +1,5 @@
import { omit, pick, chain } from 'lodash'; import { omit, pick, chain } from 'lodash';
import moment from 'moment';
import { import {
InventoryTransaction, InventoryTransaction,
InventoryLotCostTracker, InventoryLotCostTracker,
@@ -19,6 +20,13 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
itemId: number; itemId: number;
costMethod: TCostMethod; costMethod: TCostMethod;
itemsById: Map<number, any>; itemsById: Map<number, any>;
inventoryINTrans: any;
inventoryByItem: any;
costLotsTransactions: IInventoryLotCost[];
inTransactions: any[];
outTransactions: IInventoryTransaction[];
revertInvoiceTrans: any[];
revertJEntriesTransactions: IInventoryTransaction[];
/** /**
* Constructor method. * Constructor method.
@@ -30,6 +38,19 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
this.startingDate = startingDate; this.startingDate = startingDate;
this.itemId = itemId; this.itemId = itemId;
this.costMethod = costMethod; 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<any> { public async computeItemCost(): Promise<any> {
await this.revertInventoryLots(this.startingDate); await this.revertInventoryLots(this.startingDate);
await this.fetchInvINTransactions();
await this.fetchInvOUTTransactions();
await this.fetchRevertInvJReferenceIds();
await this.fetchItemsMapped();
const afterInvTransactions: IInventoryTransaction[] = this.trackingInventoryINLots(this.inTransactions);
await InventoryTransaction.tenant() this.trackingInventoryOUTLots(this.outTransactions);
.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]));
// Re-tracking the inventory `IN` and `OUT` lots costs. // Re-tracking the inventory `IN` and `OUT` lots costs.
const trackedInvLotsCosts = this.trackingInventoryLotsCost(merged); const storedTrackedInvLotsOper = this.storeInventoryLotsCost(
const storedTrackedInvLotsOper = this.storeInventoryLotsCost(trackedInvLotsCosts); this.costLotsTransactions,
);
// Remove and revert accounts balance journal entries from inventory transactions. // 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. // Records the journal entries operation.
this.recordJournalEntries(trackedInvLotsCosts); this.recordJournalEntries(this.costLotsTransactions);
return Promise.all([ return Promise.all([
storedTrackedInvLotsOper, 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 * Revert the inventory lots to the given date by removing the inventory lots
* transactions after the given date and increment the remaining that * transactions after the given date and increment the remaining that
@@ -121,14 +196,14 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
const asyncOpers: any[] = []; const asyncOpers: any[] = [];
const inventoryLotsTrans = await InventoryLotCostTracker.tenant() const inventoryLotsTrans = await InventoryLotCostTracker.tenant()
.query() .query()
.modify('filterDateRange', this.startingDate)
.orderBy('date', 'DESC') .orderBy('date', 'DESC')
.where('item_id', this.itemId) .where('item_id', this.itemId)
.where('date', '>=', startingDate)
.where('direction', 'OUT'); .where('direction', 'OUT');
const deleteInvLotsTrans = InventoryLotCostTracker.tenant() const deleteInvLotsTrans = InventoryLotCostTracker.tenant()
.query() .query()
.where('date', '>=', startingDate) .modify('filterDateRange', this.startingDate)
.where('item_id', this.itemId) .where('item_id', this.itemId)
.delete(); .delete();
@@ -151,13 +226,10 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
* @param {} inventoryLots * @param {} inventoryLots
*/ */
async revertJournalEntries( async revertJournalEntries(
inventoryLots: IInventoryLotCost[], transactions: IInventoryLotCost[],
) { ) {
const invoiceTransactions = inventoryLots
.filter(e => e.transactionType === 'SaleInvoice');
return this.journalCommands 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. * Tracking inventory `IN` lots transactions.
* @param {IInventoryTransaction[]} inventoryTransactions - Inventory transactions. * @public
* @return {IInventoryLotCost[]} * @param {IInventoryTransaction[]} inventoryTransactions -
* @return {void}
*/ */
public trackingInventoryLotsCost( public trackingInventoryINLots(
inventoryTransactions: IInventoryTransaction[], 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) => { inventoryTransactions.forEach((transaction: IInventoryTransaction) => {
const { itemId, id } = transaction; const { itemId, id } = transaction;
(inventoryByItem[itemId] || (inventoryByItem[itemId] = [])); (this.inventoryByItem[itemId] || (this.inventoryByItem[itemId] = []));
const commonLotTransaction: IInventoryLotCost = { const commonLotTransaction: IInventoryLotCost = {
...pick(transaction, [ ...pick(transaction, [
@@ -261,62 +327,91 @@ export default class InventoryCostLotTracker implements IInventoryCostMethod {
'direction', 'transactionType', 'transactionId', 'lotNumber', 'remaining' 'direction', 'transactionType', 'transactionId', 'lotNumber', 'remaining'
]), ]),
}; };
// Record inventory `IN` cost lot transaction. this.inventoryByItem[itemId].push(id);
if (transaction.direction === 'IN') { this.inventoryINTrans[id] = {
inventoryByItem[itemId].push(id); ...commonLotTransaction,
inventoryINTrans[id] = { decrement: 0,
...commonLotTransaction, remaining: commonLotTransaction.remaining || commonLotTransaction.quantity,
decrement: 0, };
remaining: commonLotTransaction.remaining || commonLotTransaction.quantity, this.costLotsTransactions.push(this.inventoryINTrans[id]);
};
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);
}
}); });
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);
}
} }

View File

@@ -12,12 +12,34 @@ import HasItemsEntries from '@/services/Sales/HasItemsEntries';
import CustomerRepository from '@/repositories/CustomerRepository'; import CustomerRepository from '@/repositories/CustomerRepository';
import InventoryService from '@/services/Inventory/Inventory'; import InventoryService from '@/services/Inventory/Inventory';
import { formatDateFields } from '@/utils'; import { formatDateFields } from '@/utils';
import { Item } from '../../models';
import JournalCommands from '../Accounting/JournalCommands';
/** /**
* Sales invoices service * Sales invoices service
* @service * @service
*/ */
export default class SaleInvoicesService { 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 * Creates a new sale invoices and store it to the storage
* with associated to entries and journal transactions. * with associated to entries and journal transactions.
@@ -60,19 +82,65 @@ export default class SaleInvoicesService {
const recordInventoryTransOpers = this.recordInventoryTranscactions( const recordInventoryTransOpers = this.recordInventoryTranscactions(
saleInvoice, storedInvoice.id saleInvoice, storedInvoice.id
); );
// Records the non-inventory transactions of the entries items.
const recordNonInventoryJEntries = this.recordNonInventoryEntries(
saleInvoice, storedInvoice.id,
);
// Await all async operations. // Await all async operations.
await Promise.all([ await Promise.all([
...opers, ...opers,
incrementOper, incrementOper,
recordNonInventoryJEntries,
recordInventoryTransOpers, recordInventoryTransOpers,
]); ]);
// Schedule sale invoice re-compute based on the item cost // Schedule sale invoice re-compute based on the item cost
// method and starting date. // method and starting date.
await this.scheduleComputeItemsCost(saleInvoice); // await this.scheduleComputeItemsCost(saleInvoice);
return storedInvoice; 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. * Edit the given sale invoice.
* @async * @async