Merge branch 'overtime-feature' of https://github.com/anurag810/erpnext into overtime-feature

This commit is contained in:
Anurag Mishra
2021-08-11 15:18:08 +05:30
88 changed files with 3257 additions and 1340 deletions

View File

@@ -147,10 +147,15 @@
"Chart": true, "Chart": true,
"Cypress": true, "Cypress": true,
"cy": true, "cy": true,
"describe": true,
"expect": true,
"it": true, "it": true,
"context": true, "context": true,
"before": true, "before": true,
"beforeEach": true, "beforeEach": true,
"onScan": true "onScan": true,
"html2canvas": true,
"extend_cscript": true,
"localforage": true
} }
} }

View File

@@ -42,5 +42,5 @@ sed -i 's/socketio:/# socketio:/g' Procfile
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
bench get-app erpnext "${GITHUB_WORKSPACE}" bench get-app erpnext "${GITHUB_WORKSPACE}"
bench start & bench start &> bench_run_logs.txt &
bench --site test_site reinstall --yes bench --site test_site reinstall --yes

108
.github/workflows/ui-tests.yml vendored Normal file
View File

@@ -0,0 +1,108 @@
name: UI
on:
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-18.04
strategy:
fail-fast: false
name: UI Tests (Cypress)
services:
mysql:
image: mariadb:10.3
env:
MYSQL_ALLOW_EMPTY_PASSWORD: YES
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
steps:
- name: Clone
uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: 3.7
- uses: actions/setup-node@v2
with:
node-version: 14
check-latest: true
- name: Add to Hosts
run: |
echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
- name: Cache pip
uses: actions/cache@v2
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
- name: Cache node modules
uses: actions/cache@v2
env:
cache-name: cache-node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v2
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Cache cypress binary
uses: actions/cache@v2
with:
path: ~/.cache
key: ${{ runner.os }}-cypress-
restore-keys: |
${{ runner.os }}-cypress-
${{ runner.os }}-
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
DB: mariadb
TYPE: ui
- name: Site Setup
run: cd ~/frappe-bench/ && bench --site test_site execute erpnext.setup.utils.before_tests
- name: cypress pre-requisites
run: cd ~/frappe-bench/apps/frappe && yarn add cypress-file-upload@^5 --no-lockfile
- name: Build Assets
run: cd ~/frappe-bench/ && bench build
- name: UI Tests
run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests erpnext --headless
env:
CYPRESS_RECORD_KEY: 60a8e3bf-08f5-45b1-9269-2b207d7d30cd
- name: Show bench console if tests failed
if: ${{ failure() }}
run: cat ~/frappe-bench/bench_run_logs.txt

11
cypress.json Normal file
View File

@@ -0,0 +1,11 @@
{
"baseUrl": "http://test_site:8000",
"projectId": "da59y9",
"adminPassword": "admin",
"defaultCommandTimeout": 20000,
"pageLoadTimeout": 15000,
"retries": {
"runMode": 2,
"openMode": 2
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -0,0 +1,13 @@
context('Customer', () => {
before(() => {
cy.login();
});
it('Check Customer Group', () => {
cy.visit(`app/customer/`);
cy.get('.primary-action').click();
cy.wait(500);
cy.get('.custom-actions > .btn').click();
cy.get_field('customer_group', 'Link').should('have.value', 'All Customer Groups');
});
});

View File

@@ -0,0 +1,111 @@
context('Organizational Chart', () => {
before(() => {
cy.login();
cy.visit('/app/website');
cy.awesomebar('Organizational Chart');
cy.window().its('frappe.csrf_token').then(csrf_token => {
return cy.request({
url: `/api/method/erpnext.tests.ui_test_helpers.create_employee_records`,
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Frappe-CSRF-Token': csrf_token
},
timeout: 60000
}).then(res => {
expect(res.status).eq(200);
cy.get('.frappe-control[data-fieldname=company] input').focus().as('input');
cy.get('@input')
.clear({ force: true })
.type('Test Org Chart{enter}', { force: true })
.blur({ force: true });
});
});
});
it('renders root nodes and loads children for the first expandable node', () => {
// check rendered root nodes and the node name, title, connections
cy.get('.hierarchy').find('.root-level ul.node-children').children()
.should('have.length', 2)
.first()
.as('first-child');
cy.get('@first-child').get('.node-name').contains('Test Employee 1');
cy.get('@first-child').get('.node-info').find('.node-title').contains('CEO');
cy.get('@first-child').get('.node-info').find('.node-connections').contains('· 2 Connections');
cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
// children of 1st root visible
cy.get(`div[data-parent="${employee_records.message[0]}"]`).as('child-node');
cy.get('@child-node')
.should('have.length', 1)
.should('be.visible');
cy.get('@child-node').get('.node-name').contains('Test Employee 3');
// connectors between first root node and immediate child
cy.get(`path[data-parent="${employee_records.message[0]}"]`)
.should('be.visible')
.invoke('attr', 'data-child')
.should('equal', employee_records.message[2]);
});
});
it('hides active nodes children and connectors on expanding sibling node', () => {
cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
// click sibling
cy.get(`#${employee_records.message[1]}`)
.click()
.should('have.class', 'active');
// child nodes and connectors hidden
cy.get(`[data-parent="${employee_records.message[0]}"]`).should('not.be.visible');
cy.get(`path[data-parent="${employee_records.message[0]}"]`).should('not.be.visible');
});
});
it('collapses previous level nodes and refreshes connectors on expanding child node', () => {
cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
// click child node
cy.get(`#${employee_records.message[3]}`)
.click()
.should('have.class', 'active');
// previous level nodes: parent should be on active-path; other nodes should be collapsed
cy.get(`#${employee_records.message[0]}`).should('have.class', 'collapsed');
cy.get(`#${employee_records.message[1]}`).should('have.class', 'active-path');
// previous level connectors refreshed
cy.get(`path[data-parent="${employee_records.message[1]}"]`)
.should('have.class', 'collapsed-connector');
// child node's children and connectors rendered
cy.get(`[data-parent="${employee_records.message[3]}"]`).should('be.visible');
cy.get(`path[data-parent="${employee_records.message[3]}"]`).should('be.visible');
});
});
it('expands previous level nodes', () => {
cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
cy.get(`#${employee_records.message[0]}`)
.click()
.should('have.class', 'active');
cy.get(`[data-parent="${employee_records.message[0]}"]`)
.should('be.visible');
cy.get('ul.hierarchy').children().should('have.length', 2);
cy.get(`#connectors`).children().should('have.length', 1);
});
});
it('edit node navigates to employee master', () => {
cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
cy.get(`#${employee_records.message[0]}`).find('.btn-edit-node')
.click();
cy.url().should('include', `/employee/${employee_records.message[0]}`);
});
});
});

View File

@@ -0,0 +1,190 @@
context('Organizational Chart Mobile', () => {
before(() => {
cy.login();
cy.viewport(375, 667);
cy.visit('/app/website');
cy.awesomebar('Organizational Chart');
cy.window().its('frappe.csrf_token').then(csrf_token => {
return cy.request({
url: `/api/method/erpnext.tests.ui_test_helpers.create_employee_records`,
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Frappe-CSRF-Token': csrf_token
},
timeout: 60000
}).then(res => {
expect(res.status).eq(200);
cy.get('.frappe-control[data-fieldname=company] input').focus().as('input');
cy.get('@input')
.clear({ force: true })
.type('Test Org Chart{enter}', { force: true })
.blur({ force: true });
});
});
});
it('renders root nodes', () => {
// check rendered root nodes and the node name, title, connections
cy.get('.hierarchy-mobile').find('.root-level').children()
.should('have.length', 2)
.first()
.as('first-child');
cy.get('@first-child').get('.node-name').contains('Test Employee 1');
cy.get('@first-child').get('.node-info').find('.node-title').contains('CEO');
cy.get('@first-child').get('.node-info').find('.node-connections').contains('· 2');
});
it('expands root node', () => {
cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
cy.get(`#${employee_records.message[1]}`)
.click()
.should('have.class', 'active');
// other root node removed
cy.get(`#${employee_records.message[0]}`).should('not.exist');
// children of active root node
cy.get('.hierarchy-mobile').find('.level').first().find('ul.node-children').children()
.should('have.length', 2);
cy.get(`div[data-parent="${employee_records.message[1]}"]`).first().as('child-node');
cy.get('@child-node').should('be.visible');
cy.get('@child-node')
.get('.node-name')
.contains('Test Employee 4');
// connectors between root node and immediate children
cy.get(`path[data-parent="${employee_records.message[1]}"]`).as('connectors');
cy.get('@connectors')
.should('have.length', 2)
.should('be.visible');
cy.get('@connectors')
.first()
.invoke('attr', 'data-child')
.should('eq', employee_records.message[3]);
});
});
it('expands child node', () => {
cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
cy.get(`#${employee_records.message[3]}`)
.click()
.should('have.class', 'active')
.as('expanded_node');
// 2 levels on screen; 1 on active path; 1 collapsed
cy.get('.hierarchy-mobile').children().should('have.length', 2);
cy.get(`#${employee_records.message[1]}`).should('have.class', 'active-path');
// children of expanded node visible
cy.get('@expanded_node')
.next()
.should('have.class', 'node-children')
.as('node-children');
cy.get('@node-children').children().should('have.length', 1);
cy.get('@node-children')
.first()
.get('.node-card')
.should('have.class', 'active-child')
.contains('Test Employee 7');
// orphan connectors removed
cy.get(`#connectors`).children().should('have.length', 2);
});
});
it('renders sibling group', () => {
cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
// sibling group visible for parent
cy.get(`#${employee_records.message[1]}`)
.next()
.as('sibling_group');
cy.get('@sibling_group')
.should('have.attr', 'data-parent', 'undefined')
.should('have.class', 'node-group')
.and('have.class', 'collapsed');
cy.get('@sibling_group').get('.avatar-group').children().as('siblings');
cy.get('@siblings').should('have.length', 1);
cy.get('@siblings')
.first()
.should('have.attr', 'title', 'Test Employee 1');
});
});
it('expands previous level nodes', () => {
cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
cy.get(`#${employee_records.message[6]}`)
.click()
.should('have.class', 'active');
// clicking on previous level node should remove all the nodes ahead
// and expand that node
cy.get(`#${employee_records.message[3]}`).click();
cy.get(`#${employee_records.message[3]}`)
.should('have.class', 'active')
.should('not.have.class', 'active-path');
cy.get(`#${employee_records.message[6]}`).should('have.class', 'active-child');
cy.get('.hierarchy-mobile').children().should('have.length', 2);
cy.get(`#connectors`).children().should('have.length', 2);
});
});
it('expands sibling group', () => {
cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
// sibling group visible for parent
cy.get(`#${employee_records.message[6]}`).click();
cy.get(`#${employee_records.message[3]}`)
.next()
.click();
// siblings of parent should be visible
cy.get('.hierarchy-mobile').prev().as('sibling_group');
cy.get('@sibling_group')
.should('exist')
.should('have.class', 'sibling-group')
.should('not.have.class', 'collapsed');
cy.get(`#${employee_records.message[1]}`)
.should('be.visible')
.should('have.class', 'active');
cy.get(`[data-parent="${employee_records.message[1]}"]`)
.should('be.visible')
.should('have.length', 2)
.should('have.class', 'active-child');
});
});
it('goes to the respective level after clicking on non-collapsed sibling group', () => {
cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(() => {
// click on non-collapsed sibling group
cy.get('.hierarchy-mobile')
.prev()
.click();
// should take you to that level
cy.get('.hierarchy-mobile').find('li.level .node-card').should('have.length', 2);
});
});
it('edit node navigates to employee master', () => {
cy.call('erpnext.tests.ui_test_helpers.get_employee_records').then(employee_records => {
cy.get(`#${employee_records.message[0]}`).find('.btn-edit-node')
.click();
cy.url().should('include', `/employee/${employee_records.message[0]}`);
});
});
});

17
cypress/plugins/index.js Normal file
View File

@@ -0,0 +1,17 @@
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
module.exports = () => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
};

View File

@@ -0,0 +1,31 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... });
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... });
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... });
//
//
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... });
const slug = (name) => name.toLowerCase().replace(" ", "-");
Cypress.Commands.add("go_to_doc", (doctype, name) => {
cy.visit(`/app/${slug(doctype)}/${encodeURIComponent(name)}`);
});

26
cypress/support/index.js Normal file
View File

@@ -0,0 +1,26 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands';
import '../../../frappe/cypress/support/commands' // eslint-disable-line
// Alternatively you can use CommonJS syntax:
// require('./commands')
Cypress.Cookies.defaults({
preserve: 'sid'
});

12
cypress/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"allowJs": true,
"baseUrl": "../node_modules",
"types": [
"cypress"
]
},
"include": [
"**/*.*"
]
}

View File

@@ -0,0 +1,31 @@
{
"actions": [],
"creation": "2021-05-06 16:18:25.410476",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"campaign"
],
"fields": [
{
"fieldname": "campaign",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Campaign",
"options": "Campaign"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-05-07 10:43:49.717633",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Campaign Item",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class CampaignItem(Document):
pass

View File

@@ -57,7 +57,7 @@ def test_create_test_data():
}) })
item_price.insert() item_price.insert()
# create test item pricing rule # create test item pricing rule
if not frappe.db.exists("Pricing Rule","_Test Pricing Rule for _Test Item"): if not frappe.db.exists("Pricing Rule", {"title": "_Test Pricing Rule for _Test Item"}):
item_pricing_rule = frappe.get_doc({ item_pricing_rule = frappe.get_doc({
"doctype": "Pricing Rule", "doctype": "Pricing Rule",
"title": "_Test Pricing Rule for _Test Item", "title": "_Test Pricing Rule for _Test Item",
@@ -86,14 +86,15 @@ def test_create_test_data():
sales_partner.insert() sales_partner.insert()
# create test item coupon code # create test item coupon code
if not frappe.db.exists("Coupon Code", "SAVE30"): if not frappe.db.exists("Coupon Code", "SAVE30"):
pricing_rule = frappe.db.get_value("Pricing Rule", {"title": "_Test Pricing Rule for _Test Item"}, ['name'])
coupon_code = frappe.get_doc({ coupon_code = frappe.get_doc({
"doctype": "Coupon Code", "doctype": "Coupon Code",
"coupon_name":"SAVE30", "coupon_name":"SAVE30",
"coupon_code":"SAVE30", "coupon_code":"SAVE30",
"pricing_rule": "_Test Pricing Rule for _Test Item", "pricing_rule": pricing_rule,
"valid_from": "2014-01-01", "valid_from": "2014-01-01",
"maximum_use":1, "maximum_use":1,
"used":0 "used":0
}) })
coupon_code.insert() coupon_code.insert()
@@ -102,7 +103,7 @@ class TestCouponCode(unittest.TestCase):
test_create_test_data() test_create_test_data()
def tearDown(self): def tearDown(self):
frappe.set_user("Administrator") frappe.set_user("Administrator")
def test_sales_order_with_coupon_code(self): def test_sales_order_with_coupon_code(self):
frappe.db.set_value("Coupon Code", "SAVE30", "used", 0) frappe.db.set_value("Coupon Code", "SAVE30", "used", 0)

View File

@@ -0,0 +1,31 @@
{
"actions": [],
"creation": "2021-05-06 16:12:42.558878",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"customer_group"
],
"fields": [
{
"fieldname": "customer_group",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Customer Group",
"options": "Customer Group"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-05-07 10:39:21.563506",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Customer Group Item",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class CustomerGroupItem(Document):
pass

View File

@@ -0,0 +1,31 @@
{
"actions": [],
"creation": "2021-05-05 14:04:54.266353",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"customer"
],
"fields": [
{
"fieldname": "customer",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Customer ",
"options": "Customer"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-05-06 10:02:32.967841",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Customer Item",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class CustomerItem(Document):
pass

View File

@@ -58,8 +58,8 @@ class GLEntry(Document):
if not self.get(k): if not self.get(k):
frappe.throw(_("{0} is required").format(_(self.meta.get_label(k)))) frappe.throw(_("{0} is required").format(_(self.meta.get_label(k))))
account_type = frappe.get_cached_value("Account", self.account, "account_type")
if not (self.party_type and self.party): if not (self.party_type and self.party):
account_type = frappe.get_cached_value("Account", self.account, "account_type")
if account_type == "Receivable": if account_type == "Receivable":
frappe.throw(_("{0} {1}: Customer is required against Receivable account {2}") frappe.throw(_("{0} {1}: Customer is required against Receivable account {2}")
.format(self.voucher_type, self.voucher_no, self.account)) .format(self.voucher_type, self.voucher_no, self.account))
@@ -73,15 +73,19 @@ class GLEntry(Document):
.format(self.voucher_type, self.voucher_no, self.account)) .format(self.voucher_type, self.voucher_no, self.account))
def pl_must_have_cost_center(self): def pl_must_have_cost_center(self):
if frappe.get_cached_value("Account", self.account, "report_type") == "Profit and Loss": """Validate that profit and loss type account GL entries have a cost center."""
if not self.cost_center and self.voucher_type != 'Period Closing Voucher':
msg = _("{0} {1}: Cost Center is required for 'Profit and Loss' account {2}.").format(
self.voucher_type, self.voucher_no, self.account)
msg += " "
msg += _("Please set the cost center field in {0} or setup a default Cost Center for the Company.").format(
self.voucher_type)
frappe.throw(msg, title=_("Missing Cost Center")) if self.cost_center or self.voucher_type == 'Period Closing Voucher':
return
if frappe.get_cached_value("Account", self.account, "report_type") == "Profit and Loss":
msg = _("{0} {1}: Cost Center is required for 'Profit and Loss' account {2}.").format(
self.voucher_type, self.voucher_no, self.account)
msg += " "
msg += _("Please set the cost center field in {0} or setup a default Cost Center for the Company.").format(
self.voucher_type)
frappe.throw(msg, title=_("Missing Cost Center"))
def validate_dimensions_for_pl_and_bs(self): def validate_dimensions_for_pl_and_bs(self):
account_type = frappe.db.get_value("Account", self.account, "report_type") account_type = frappe.db.get_value("Account", self.account, "report_type")

View File

@@ -2,12 +2,13 @@
"actions": [], "actions": [],
"allow_import": 1, "allow_import": 1,
"allow_rename": 1, "allow_rename": 1,
"autoname": "field:title", "autoname": "naming_series:",
"creation": "2014-02-21 15:02:51", "creation": "2014-02-21 15:02:51",
"doctype": "DocType", "doctype": "DocType",
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"applicability_section", "applicability_section",
"naming_series",
"title", "title",
"disable", "disable",
"apply_on", "apply_on",
@@ -95,8 +96,7 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Title", "label": "Title",
"no_copy": 1, "no_copy": 1,
"reqd": 1, "reqd": 1
"unique": 1
}, },
{ {
"default": "0", "default": "0",
@@ -571,6 +571,13 @@
"fieldname": "is_recursive", "fieldname": "is_recursive",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Is Recursive" "label": "Is Recursive"
},
{
"default": "PRLE-.####",
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Naming Series",
"options": "PRLE-.####"
} }
], ],
"icon": "fa fa-gift", "icon": "fa fa-gift",
@@ -634,5 +641,6 @@
], ],
"show_name_in_global_search": 1, "show_name_in_global_search": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC" "sort_order": "DESC",
} "title_field": "title"
}

View File

@@ -25,22 +25,31 @@ product_discount_fields = ['free_item', 'free_qty', 'free_item_uom',
class PromotionalScheme(Document): class PromotionalScheme(Document):
def validate(self): def validate(self):
if not self.selling and not self.buying:
frappe.throw(_("Either 'Selling' or 'Buying' must be selected"), title=_("Mandatory"))
if not (self.price_discount_slabs if not (self.price_discount_slabs
or self.product_discount_slabs): or self.product_discount_slabs):
frappe.throw(_("Price or product discount slabs are required")) frappe.throw(_("Price or product discount slabs are required"))
def on_update(self): def on_update(self):
data = frappe.get_all('Pricing Rule', fields = ["promotional_scheme_id", "name"], pricing_rules = frappe.get_all(
filters = {'promotional_scheme': self.name}) or {} 'Pricing Rule',
fields = ["promotional_scheme_id", "name", "creation"],
filters = {
'promotional_scheme': self.name,
'applicable_for': self.applicable_for
},
order_by = 'creation asc',
) or {}
self.update_pricing_rules(pricing_rules)
self.update_pricing_rules(data) def update_pricing_rules(self, pricing_rules):
def update_pricing_rules(self, data):
rules = {} rules = {}
count = 0 count = 0
names = []
for d in data: for rule in pricing_rules:
rules[d.get('promotional_scheme_id')] = d.get('name') names.append(rule.name)
rules[rule.get('promotional_scheme_id')] = names
docs = get_pricing_rules(self, rules) docs = get_pricing_rules(self, rules)
@@ -57,9 +66,9 @@ class PromotionalScheme(Document):
frappe.msgprint(_("New {0} pricing rules are created").format(count)) frappe.msgprint(_("New {0} pricing rules are created").format(count))
def on_trash(self): def on_trash(self):
for d in frappe.get_all('Pricing Rule', for rule in frappe.get_all('Pricing Rule',
{'promotional_scheme': self.name}): {'promotional_scheme': self.name}):
frappe.delete_doc('Pricing Rule', d.name) frappe.delete_doc('Pricing Rule', rule.name)
def get_pricing_rules(doc, rules = {}): def get_pricing_rules(doc, rules = {}):
new_doc = [] new_doc = []
@@ -73,42 +82,80 @@ def get_pricing_rules(doc, rules = {}):
def _get_pricing_rules(doc, child_doc, discount_fields, rules = {}): def _get_pricing_rules(doc, child_doc, discount_fields, rules = {}):
new_doc = [] new_doc = []
args = get_args_for_pricing_rule(doc) args = get_args_for_pricing_rule(doc)
for d in doc.get(child_doc): applicable_for = frappe.scrub(doc.get('applicable_for'))
for idx, d in enumerate(doc.get(child_doc)):
if d.name in rules: if d.name in rules:
pr = frappe.get_doc('Pricing Rule', rules.get(d.name)) for applicable_for_value in args.get(applicable_for):
temp_args = args.copy()
docname = frappe.get_all(
'Pricing Rule',
fields = ["promotional_scheme_id", "name", applicable_for],
filters = {
'promotional_scheme_id': d.name,
applicable_for: applicable_for_value
}
)
if docname:
pr = frappe.get_doc('Pricing Rule', docname[0].get('name'))
temp_args[applicable_for] = applicable_for_value
pr = set_args(temp_args, pr, doc, child_doc, discount_fields, d)
else:
pr = frappe.new_doc("Pricing Rule")
pr.title = doc.name
temp_args[applicable_for] = applicable_for_value
pr = set_args(temp_args, pr, doc, child_doc, discount_fields, d)
new_doc.append(pr)
else: else:
pr = frappe.new_doc("Pricing Rule") applicable_for_values = args.get(applicable_for) or []
pr.title = make_autoname("{0}/.####".format(doc.name)) for applicable_for_value in applicable_for_values:
pr = frappe.new_doc("Pricing Rule")
pr.update(args) pr.title = doc.name
for field in (other_fields + discount_fields): temp_args = args.copy()
pr.set(field, d.get(field)) temp_args[applicable_for] = applicable_for_value
pr = set_args(temp_args, pr, doc, child_doc, discount_fields, d)
pr.promotional_scheme_id = d.name new_doc.append(pr)
pr.promotional_scheme = doc.name
pr.disable = d.disable if d.disable else doc.disable
pr.price_or_product_discount = ('Price'
if child_doc == 'price_discount_slabs' else 'Product')
for field in ['items', 'item_groups', 'brands']:
if doc.get(field):
pr.set(field, [])
apply_on = frappe.scrub(doc.get('apply_on'))
for d in doc.get(field):
pr.append(field, {
apply_on: d.get(apply_on),
'uom': d.uom
})
new_doc.append(pr)
return new_doc return new_doc
def set_args(args, pr, doc, child_doc, discount_fields, child_doc_fields):
pr.update(args)
for field in (other_fields + discount_fields):
pr.set(field, child_doc_fields.get(field))
pr.promotional_scheme_id = child_doc_fields.name
pr.promotional_scheme = doc.name
pr.disable = child_doc_fields.disable if child_doc_fields.disable else doc.disable
pr.price_or_product_discount = ('Price'
if child_doc == 'price_discount_slabs' else 'Product')
for field in ['items', 'item_groups', 'brands']:
if doc.get(field):
pr.set(field, [])
apply_on = frappe.scrub(doc.get('apply_on'))
for d in doc.get(field):
pr.append(field, {
apply_on: d.get(apply_on),
'uom': d.uom
})
return pr
def get_args_for_pricing_rule(doc): def get_args_for_pricing_rule(doc):
args = { 'promotional_scheme': doc.name } args = { 'promotional_scheme': doc.name }
applicable_for = frappe.scrub(doc.get('applicable_for'))
for d in pricing_rule_fields: for d in pricing_rule_fields:
args[d] = doc.get(d) if d == applicable_for:
items = []
for applicable_for_values in doc.get(applicable_for):
items.append(applicable_for_values.get(applicable_for))
args[d] = items
else:
args[d] = doc.get(d)
return args return args

View File

@@ -7,4 +7,54 @@ import frappe
import unittest import unittest
class TestPromotionalScheme(unittest.TestCase): class TestPromotionalScheme(unittest.TestCase):
pass def test_promotional_scheme(self):
ps = make_promotional_scheme()
price_rules = frappe.get_all('Pricing Rule', fields = ["promotional_scheme_id", "name", "creation"],
filters = {'promotional_scheme': ps.name})
self.assertTrue(len(price_rules),1)
price_doc_details = frappe.db.get_value('Pricing Rule', price_rules[0].name, ['customer', 'min_qty', 'discount_percentage'], as_dict = 1)
self.assertTrue(price_doc_details.customer, '_Test Customer')
self.assertTrue(price_doc_details.min_qty, 4)
self.assertTrue(price_doc_details.discount_percentage, 20)
ps.price_discount_slabs[0].min_qty = 6
ps.append('customer', {
'customer': "_Test Customer 2"})
ps.save()
price_rules = frappe.get_all('Pricing Rule', fields = ["promotional_scheme_id", "name"],
filters = {'promotional_scheme': ps.name})
self.assertTrue(len(price_rules), 2)
price_doc_details = frappe.db.get_value('Pricing Rule', price_rules[1].name, ['customer', 'min_qty', 'discount_percentage'], as_dict = 1)
self.assertTrue(price_doc_details.customer, '_Test Customer 2')
self.assertTrue(price_doc_details.min_qty, 6)
self.assertTrue(price_doc_details.discount_percentage, 20)
price_doc_details = frappe.db.get_value('Pricing Rule', price_rules[0].name, ['customer', 'min_qty', 'discount_percentage'], as_dict = 1)
self.assertTrue(price_doc_details.customer, '_Test Customer')
self.assertTrue(price_doc_details.min_qty, 6)
frappe.delete_doc('Promotional Scheme', ps.name)
price_rules = frappe.get_all('Pricing Rule', fields = ["promotional_scheme_id", "name"],
filters = {'promotional_scheme': ps.name})
self.assertEqual(price_rules, [])
def make_promotional_scheme():
ps = frappe.new_doc('Promotional Scheme')
ps.name = '_Test Scheme'
ps.append('items',{
'item_code': '_Test Item'
})
ps.selling = 1
ps.append('price_discount_slabs',{
'min_qty': 4,
'discount_percentage': 20,
'rule_description': 'Test'
})
ps.applicable_for = 'Customer'
ps.append('customer',{
'customer': "_Test Customer"
})
ps.save()
return ps

View File

@@ -22,7 +22,7 @@ from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accoun
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
from six import iteritems from six import iteritems
from erpnext.accounts.doctype.sales_invoice.sales_invoice import validate_inter_company_party, update_linked_doc,\ from erpnext.accounts.doctype.sales_invoice.sales_invoice import validate_inter_company_party, update_linked_doc,\
unlink_inter_company_doc unlink_inter_company_doc, check_if_return_invoice_linked_with_payment_entry
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import get_party_tax_withholding_details from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import get_party_tax_withholding_details
from erpnext.accounts.deferred_revenue import validate_service_stop_date from erpnext.accounts.deferred_revenue import validate_service_stop_date
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import get_item_account_wise_additional_cost from erpnext.stock.doctype.purchase_receipt.purchase_receipt import get_item_account_wise_additional_cost
@@ -988,6 +988,8 @@ class PurchaseInvoice(BuyingController):
}, item=self)) }, item=self))
def on_cancel(self): def on_cancel(self):
check_if_return_invoice_linked_with_payment_entry(self)
super(PurchaseInvoice, self).on_cancel() super(PurchaseInvoice, self).on_cancel()
self.check_on_hold_or_closed_status() self.check_on_hold_or_closed_status()

View File

@@ -447,6 +447,15 @@ erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.exte
this.frm.refresh_field("outstanding_amount"); this.frm.refresh_field("outstanding_amount");
this.frm.refresh_field("paid_amount"); this.frm.refresh_field("paid_amount");
this.frm.refresh_field("base_paid_amount"); this.frm.refresh_field("base_paid_amount");
},
currency() {
this._super();
$.each(cur_frm.doc.timesheets, function(i, d) {
let row = frappe.get_doc(d.doctype, d.name)
set_timesheet_detail_rate(row.doctype, row.name, cur_frm.doc.currency, row.timesheet_detail)
});
calculate_total_billing_amount(cur_frm)
} }
}); });
@@ -846,7 +855,8 @@ frappe.ui.form.on('Sales Invoice', {
'time_sheet': row.parent, 'time_sheet': row.parent,
'billing_hours': row.billing_hours, 'billing_hours': row.billing_hours,
'billing_amount': flt(row.billing_amount) * flt(exchange_rate), 'billing_amount': flt(row.billing_amount) * flt(exchange_rate),
'timesheet_detail': row.name 'timesheet_detail': row.name,
'project_name': row.project_name
}); });
frm.refresh_field('timesheets'); frm.refresh_field('timesheets');
calculate_total_billing_amount(frm); calculate_total_billing_amount(frm);
@@ -965,43 +975,34 @@ frappe.ui.form.on('Sales Invoice', {
} }
}) })
frappe.ui.form.on('Sales Invoice Timesheet', {
time_sheet: function(frm, cdt, cdn){
var d = locals[cdt][cdn];
if(d.time_sheet) {
frappe.call({
method: "erpnext.projects.doctype.timesheet.timesheet.get_timesheet_data",
args: {
'name': d.time_sheet,
'project': frm.doc.project || null
},
callback: function(r, rt) {
if(r.message){
let data = r.message;
frappe.model.set_value(cdt, cdn, "billing_hours", data.billing_hours);
frappe.model.set_value(cdt, cdn, "billing_amount", data.billing_amount);
frappe.model.set_value(cdt, cdn, "timesheet_detail", data.timesheet_detail);
calculate_total_billing_amount(frm)
}
}
})
}
}
})
var calculate_total_billing_amount = function(frm) { var calculate_total_billing_amount = function(frm) {
var doc = frm.doc; var doc = frm.doc;
doc.total_billing_amount = 0.0 doc.total_billing_amount = 0.0
if(doc.timesheets) { if (doc.timesheets) {
$.each(doc.timesheets, function(index, data){ $.each(doc.timesheets, function(index, data){
doc.total_billing_amount += data.billing_amount doc.total_billing_amount += flt(data.billing_amount)
}) })
} }
refresh_field('total_billing_amount') refresh_field('total_billing_amount')
} }
var set_timesheet_detail_rate = function(cdt, cdn, currency, timelog) {
frappe.call({
method: "erpnext.projects.doctype.timesheet.timesheet.get_timesheet_detail_rate",
args: {
timelog: timelog,
currency: currency
},
callback: function(r) {
if (!r.exc && r.message) {
frappe.model.set_value(cdt, cdn, 'billing_amount', r.message);
}
}
});
}
var select_loyalty_program = function(frm, loyalty_programs) { var select_loyalty_program = function(frm, loyalty_programs) {
var dialog = new frappe.ui.Dialog({ var dialog = new frappe.ui.Dialog({
title: __("Select Loyalty Program"), title: __("Select Loyalty Program"),

View File

@@ -290,6 +290,8 @@ class SalesInvoice(SellingController):
self.update_time_sheet(None) self.update_time_sheet(None)
def on_cancel(self): def on_cancel(self):
check_if_return_invoice_linked_with_payment_entry(self)
super(SalesInvoice, self).on_cancel() super(SalesInvoice, self).on_cancel()
self.check_sales_order_on_hold_or_close("sales_order") self.check_sales_order_on_hold_or_close("sales_order")
@@ -480,7 +482,7 @@ class SalesInvoice(SellingController):
if not self.pos_profile: if not self.pos_profile:
pos_profile = get_pos_profile(self.company) or {} pos_profile = get_pos_profile(self.company) or {}
if not pos_profile: if not pos_profile:
frappe.throw(_("No POS Profile found. Please create a New POS Profile first")) return
self.pos_profile = pos_profile.get('name') self.pos_profile = pos_profile.get('name')
pos = {} pos = {}
@@ -971,7 +973,7 @@ class SalesInvoice(SellingController):
def set_asset_status(self, asset): def set_asset_status(self, asset):
if self.is_return: if self.is_return:
asset.set_status() asset.set_status()
else: else:
asset.set_status("Sold" if self.docstatus==1 else None) asset.set_status("Sold" if self.docstatus==1 else None)
def make_loyalty_point_redemption_gle(self, gl_entries): def make_loyalty_point_redemption_gle(self, gl_entries):
@@ -1939,3 +1941,41 @@ def create_dunning(source_name, target_doc=None):
} }
}, target_doc, set_missing_values) }, target_doc, set_missing_values)
return doclist return doclist
def check_if_return_invoice_linked_with_payment_entry(self):
# If a Return invoice is linked with payment entry along with other invoices,
# the cancellation of the Return causes allocated amount to be greater than paid
if not frappe.db.get_single_value('Accounts Settings', 'unlink_payment_on_cancellation_of_invoice'):
return
payment_entries = []
if self.is_return and self.return_against:
invoice = self.return_against
else:
invoice = self.name
payment_entries = frappe.db.sql_list("""
SELECT
t1.name
FROM
`tabPayment Entry` t1, `tabPayment Entry Reference` t2
WHERE
t1.name = t2.parent
and t1.docstatus = 1
and t2.reference_name = %s
and t2.allocated_amount < 0
""", invoice)
links_to_pe = []
if payment_entries:
for payment in payment_entries:
payment_entry = frappe.get_doc("Payment Entry", payment)
if len(payment_entry.references) > 1:
links_to_pe.append(payment_entry.name)
if links_to_pe:
payment_entries_link = [get_link_to_form('Payment Entry', name, label=name) for name in links_to_pe]
message = _("Please cancel and amend the Payment Entry")
message += " " + ", ".join(payment_entries_link) + " "
message += _("to unallocate the amount of this Return Invoice before cancelling it.")
frappe.throw(message)

View File

@@ -9,7 +9,9 @@
"description", "description",
"billing_hours", "billing_hours",
"billing_amount", "billing_amount",
"column_break_5",
"time_sheet", "time_sheet",
"project_name",
"timesheet_detail" "timesheet_detail"
], ],
"fields": [ "fields": [
@@ -61,11 +63,21 @@
"in_list_view": 1, "in_list_view": 1,
"label": "Description", "label": "Description",
"read_only": 1 "read_only": 1
},
{
"fieldname": "column_break_5",
"fieldtype": "Column Break"
},
{
"fieldname": "project_name",
"fieldtype": "Data",
"label": "Project Name",
"read_only": 1
} }
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2021-05-20 22:33:57.234846", "modified": "2021-06-08 14:43:02.748981",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Sales Invoice Timesheet", "name": "Sales Invoice Timesheet",

View File

@@ -0,0 +1,31 @@
{
"actions": [],
"creation": "2021-05-06 16:17:44.329943",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"sales_partner"
],
"fields": [
{
"fieldname": "sales_partner",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Sales Partner ",
"options": "Sales Partner"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-05-07 10:43:37.532095",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Partner Item",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SalesPartnerItem(Document):
pass

View File

@@ -6,7 +6,7 @@ import frappe
from frappe import _ from frappe import _
from frappe.utils import flt from frappe.utils import flt
from frappe.model.document import Document from frappe.model.document import Document
from erpnext.controllers.accounts_controller import validate_taxes_and_charges, validate_inclusive_tax from erpnext.controllers.accounts_controller import validate_taxes_and_charges, validate_inclusive_tax, validate_cost_center, validate_account_head
class SalesTaxesandChargesTemplate(Document): class SalesTaxesandChargesTemplate(Document):
def validate(self): def validate(self):
@@ -39,6 +39,8 @@ def valdiate_taxes_and_charges_template(doc):
for tax in doc.get("taxes"): for tax in doc.get("taxes"):
validate_taxes_and_charges(tax) validate_taxes_and_charges(tax)
validate_account_head(tax, doc)
validate_cost_center(tax, doc)
validate_inclusive_tax(tax, doc) validate_inclusive_tax(tax, doc)
def validate_disabled(doc): def validate_disabled(doc):

View File

@@ -8,6 +8,7 @@
"charge_type": "On Net Total", "charge_type": "On Net Total",
"description": "VAT", "description": "VAT",
"doctype": "Sales Taxes and Charges", "doctype": "Sales Taxes and Charges",
"cost_center": "Main - _TC",
"parentfield": "taxes", "parentfield": "taxes",
"rate": 6 "rate": 6
}, },
@@ -16,6 +17,7 @@
"charge_type": "On Net Total", "charge_type": "On Net Total",
"description": "Service Tax", "description": "Service Tax",
"doctype": "Sales Taxes and Charges", "doctype": "Sales Taxes and Charges",
"cost_center": "Main - _TC",
"parentfield": "taxes", "parentfield": "taxes",
"rate": 6.36 "rate": 6.36
} }
@@ -114,6 +116,7 @@
"charge_type": "On Net Total", "charge_type": "On Net Total",
"description": "VAT", "description": "VAT",
"doctype": "Sales Taxes and Charges", "doctype": "Sales Taxes and Charges",
"cost_center": "Main - _TC",
"parentfield": "taxes", "parentfield": "taxes",
"rate": 12 "rate": 12
}, },
@@ -122,6 +125,7 @@
"charge_type": "On Net Total", "charge_type": "On Net Total",
"description": "Service Tax", "description": "Service Tax",
"doctype": "Sales Taxes and Charges", "doctype": "Sales Taxes and Charges",
"cost_center": "Main - _TC",
"parentfield": "taxes", "parentfield": "taxes",
"rate": 4 "rate": 4
} }
@@ -137,6 +141,7 @@
"charge_type": "On Net Total", "charge_type": "On Net Total",
"description": "VAT", "description": "VAT",
"doctype": "Sales Taxes and Charges", "doctype": "Sales Taxes and Charges",
"cost_center": "Main - _TC",
"parentfield": "taxes", "parentfield": "taxes",
"rate": 12 "rate": 12
}, },
@@ -145,6 +150,7 @@
"charge_type": "On Net Total", "charge_type": "On Net Total",
"description": "Service Tax", "description": "Service Tax",
"doctype": "Sales Taxes and Charges", "doctype": "Sales Taxes and Charges",
"cost_center": "Main - _TC",
"parentfield": "taxes", "parentfield": "taxes",
"rate": 4 "rate": 4
} }
@@ -160,6 +166,7 @@
"charge_type": "On Net Total", "charge_type": "On Net Total",
"description": "VAT", "description": "VAT",
"doctype": "Sales Taxes and Charges", "doctype": "Sales Taxes and Charges",
"cost_center": "Main - _TC",
"parentfield": "taxes", "parentfield": "taxes",
"rate": 12 "rate": 12
}, },
@@ -168,6 +175,7 @@
"charge_type": "On Net Total", "charge_type": "On Net Total",
"description": "Service Tax", "description": "Service Tax",
"doctype": "Sales Taxes and Charges", "doctype": "Sales Taxes and Charges",
"cost_center": "Main - _TC",
"parentfield": "taxes", "parentfield": "taxes",
"rate": 4 "rate": 4
} }

View File

@@ -0,0 +1,31 @@
{
"actions": [],
"creation": "2021-05-06 16:19:22.040795",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"supplier_group"
],
"fields": [
{
"fieldname": "supplier_group",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Supplier Group",
"options": "Supplier Group"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-05-07 10:43:59.877938",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Supplier Group Item",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SupplierGroupItem(Document):
pass

View File

@@ -0,0 +1,31 @@
{
"actions": [],
"creation": "2021-05-06 16:18:54.758468",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"supplier"
],
"fields": [
{
"fieldname": "supplier",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Supplier",
"options": "Supplier"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-05-07 10:44:09.707778",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Supplier Item",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SupplierItem(Document):
pass

View File

@@ -0,0 +1,31 @@
{
"actions": [],
"creation": "2021-05-06 16:16:51.885441",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"territory"
],
"fields": [
{
"fieldname": "territory",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Territory",
"options": "Territory"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-05-07 10:43:26.641030",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Territory Item",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class TerritoryItem(Document):
pass

View File

@@ -100,8 +100,8 @@ def merge_similar_entries(gl_map, precision=None):
return merged_gl_map return merged_gl_map
def check_if_in_list(gle, gl_map, dimensions=None): def check_if_in_list(gle, gl_map, dimensions=None):
account_head_fieldnames = ['party_type', 'party', 'against_voucher', 'against_voucher_type', account_head_fieldnames = ['voucher_detail_no', 'party', 'against_voucher',
'cost_center', 'project', 'voucher_detail_no'] 'cost_center', 'against_voucher_type', 'party_type', 'project']
if dimensions: if dimensions:
account_head_fieldnames = account_head_fieldnames + dimensions account_head_fieldnames = account_head_fieldnames + dimensions
@@ -110,10 +110,12 @@ def check_if_in_list(gle, gl_map, dimensions=None):
same_head = True same_head = True
if e.account != gle.account: if e.account != gle.account:
same_head = False same_head = False
continue
for fieldname in account_head_fieldnames: for fieldname in account_head_fieldnames:
if cstr(e.get(fieldname)) != cstr(gle.get(fieldname)): if cstr(e.get(fieldname)) != cstr(gle.get(fieldname)):
same_head = False same_head = False
break
if same_head: if same_head:
return e return e
@@ -143,16 +145,19 @@ def make_entry(args, adv_adj, update_outstanding, from_repost=False):
validate_expense_against_budget(args) validate_expense_against_budget(args)
def validate_cwip_accounts(gl_map): def validate_cwip_accounts(gl_map):
cwip_enabled = any(cint(ac.enable_cwip_accounting) for ac in frappe.db.get_all("Asset Category","enable_cwip_accounting")) """Validate that CWIP account are not used in Journal Entry"""
if gl_map and gl_map[0].voucher_type != "Journal Entry":
return
if cwip_enabled and gl_map[0].voucher_type == "Journal Entry": cwip_enabled = any(cint(ac.enable_cwip_accounting) for ac in frappe.db.get_all("Asset Category", "enable_cwip_accounting"))
cwip_accounts = [d[0] for d in frappe.db.sql("""select name from tabAccount if cwip_enabled:
where account_type = 'Capital Work in Progress' and is_group=0""")] cwip_accounts = [d[0] for d in frappe.db.sql("""select name from tabAccount
where account_type = 'Capital Work in Progress' and is_group=0""")]
for entry in gl_map: for entry in gl_map:
if entry.account in cwip_accounts: if entry.account in cwip_accounts:
frappe.throw( frappe.throw(
_("Account: <b>{0}</b> is capital Work in progress and can not be updated by Journal Entry").format(entry.account)) _("Account: <b>{0}</b> is capital Work in progress and can not be updated by Journal Entry").format(entry.account))
def round_off_debit_credit(gl_map): def round_off_debit_credit(gl_map):
precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit"), precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit"),

View File

@@ -920,7 +920,6 @@ def repost_gle_for_stock_vouchers(stock_vouchers, posting_date, company=None, wa
_delete_gl_entries(voucher_type, voucher_no) _delete_gl_entries(voucher_type, voucher_no)
def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, for_items=None, company=None): def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, for_items=None, company=None):
future_stock_vouchers = []
values = [] values = []
condition = "" condition = ""
@@ -936,30 +935,46 @@ def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, f
condition += " and company = %s" condition += " and company = %s"
values.append(company) values.append(company)
for d in frappe.db.sql("""select distinct sle.voucher_type, sle.voucher_no future_stock_vouchers = frappe.db.sql("""select distinct sle.voucher_type, sle.voucher_no
from `tabStock Ledger Entry` sle from `tabStock Ledger Entry` sle
where where
timestamp(sle.posting_date, sle.posting_time) >= timestamp(%s, %s) timestamp(sle.posting_date, sle.posting_time) >= timestamp(%s, %s)
and is_cancelled = 0 and is_cancelled = 0
{condition} {condition}
order by timestamp(sle.posting_date, sle.posting_time) asc, creation asc for update""".format(condition=condition), order by timestamp(sle.posting_date, sle.posting_time) asc, creation asc for update""".format(condition=condition),
tuple([posting_date, posting_time] + values), as_dict=True): tuple([posting_date, posting_time] + values), as_dict=True)
future_stock_vouchers.append([d.voucher_type, d.voucher_no])
return future_stock_vouchers return [(d.voucher_type, d.voucher_no) for d in future_stock_vouchers]
def get_voucherwise_gl_entries(future_stock_vouchers, posting_date): def get_voucherwise_gl_entries(future_stock_vouchers, posting_date):
""" Get voucherwise list of GL entries.
Only fetches GLE fields required for comparing with new GLE.
Check compare_existing_and_expected_gle function below.
"""
gl_entries = {} gl_entries = {}
if future_stock_vouchers: if not future_stock_vouchers:
for d in frappe.db.sql("""select * from `tabGL Entry` return gl_entries
where posting_date >= %s and voucher_no in (%s)""" %
('%s', ', '.join(['%s']*len(future_stock_vouchers))), voucher_nos = [d[1] for d in future_stock_vouchers]
tuple([posting_date] + [d[1] for d in future_stock_vouchers]), as_dict=1):
gl_entries.setdefault((d.voucher_type, d.voucher_no), []).append(d) gles = frappe.db.sql("""
select name, account, credit, debit, cost_center, project
from `tabGL Entry`
where
posting_date >= %s and voucher_no in (%s)""" %
('%s', ', '.join(['%s'] * len(voucher_nos))),
tuple([posting_date] + voucher_nos), as_dict=1)
for d in gles:
gl_entries.setdefault((d.voucher_type, d.voucher_no), []).append(d)
return gl_entries return gl_entries
def compare_existing_and_expected_gle(existing_gle, expected_gle, precision): def compare_existing_and_expected_gle(existing_gle, expected_gle, precision):
if len(existing_gle) != len(expected_gle):
return False
matched = True matched = True
for entry in expected_gle: for entry in expected_gle:
account_existed = False account_existed = False

View File

@@ -639,7 +639,7 @@ class TestAsset(unittest.TestCase):
asset_name = frappe.db.get_value("Asset", {"purchase_receipt": pr.name}, 'name') asset_name = frappe.db.get_value("Asset", {"purchase_receipt": pr.name}, 'name')
asset = frappe.get_doc('Asset', asset_name) asset = frappe.get_doc('Asset', asset_name)
asset.calculate_depreciation = 1 asset.calculate_depreciation = 1
asset.available_for_use_date = '2030-06-12' asset.available_for_use_date = '2030-07-12'
asset.purchase_date = '2030-01-01' asset.purchase_date = '2030-01-01'
asset.append("finance_books", { asset.append("finance_books", {
"expected_value_after_useful_life": 1000, "expected_value_after_useful_life": 1000,
@@ -653,10 +653,10 @@ class TestAsset(unittest.TestCase):
self.assertEqual(asset.finance_books[0].rate_of_depreciation, 50.0) self.assertEqual(asset.finance_books[0].rate_of_depreciation, 50.0)
expected_schedules = [ expected_schedules = [
["2030-12-31", 1106.85, 1106.85], ["2030-12-31", 942.47, 942.47],
["2031-12-31", 3446.58, 4553.43], ["2031-12-31", 3528.77, 4471.24],
["2032-12-31", 1723.29, 6276.72], ["2032-12-31", 1764.38, 6235.62],
["2033-06-12", 723.28, 7000.00] ["2033-07-12", 764.38, 7000.00]
] ]
schedules = [[cstr(d.schedule_date), flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2)] schedules = [[cstr(d.schedule_date), flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2)]

View File

@@ -1437,6 +1437,27 @@ def validate_taxes_and_charges(tax):
tax.rate = None tax.rate = None
def validate_account_head(tax, doc):
company = frappe.get_cached_value('Account',
tax.account_head, 'company')
if company != doc.company:
frappe.throw(_('Row {0}: Account {1} does not belong to Company {2}')
.format(tax.idx, frappe.bold(tax.account_head), frappe.bold(doc.company)), title=_('Invalid Account'))
def validate_cost_center(tax, doc):
if not tax.cost_center:
return
company = frappe.get_cached_value('Cost Center',
tax.cost_center, 'company')
if company != doc.company:
frappe.throw(_('Row {0}: Cost Center {1} does not belong to Company {2}')
.format(tax.idx, frappe.bold(tax.cost_center), frappe.bold(doc.company)), title=_('Invalid Cost Center'))
def validate_inclusive_tax(tax, doc): def validate_inclusive_tax(tax, doc):
def _on_previous_row_error(row_range): def _on_previous_row_error(row_range):
throw(_("To include tax in row {0} in Item rate, taxes in rows {1} must also be included").format(tax.idx, row_range)) throw(_("To include tax in row {0} in Item rate, taxes in rows {1} must also be included").format(tax.idx, row_range))

View File

@@ -27,6 +27,7 @@ class StockController(AccountsController):
if not self.get('is_return'): if not self.get('is_return'):
self.validate_inspection() self.validate_inspection()
self.validate_serialized_batch() self.validate_serialized_batch()
self.clean_serial_nos()
self.validate_customer_provided_item() self.validate_customer_provided_item()
self.set_rate_of_stock_uom() self.set_rate_of_stock_uom()
self.validate_internal_transfer() self.validate_internal_transfer()
@@ -72,6 +73,12 @@ class StockController(AccountsController):
frappe.throw(_("Row #{0}: The batch {1} has already expired.") frappe.throw(_("Row #{0}: The batch {1} has already expired.")
.format(d.idx, get_link_to_form("Batch", d.get("batch_no")))) .format(d.idx, get_link_to_form("Batch", d.get("batch_no"))))
def clean_serial_nos(self):
for row in self.get("items"):
if hasattr(row, "serial_no") and row.serial_no:
# replace commas by linefeed and remove all spaces in string
row.serial_no = row.serial_no.replace(",", "\n").replace(" ", "")
def get_gl_entries(self, warehouse_account=None, default_expense_account=None, def get_gl_entries(self, warehouse_account=None, default_expense_account=None,
default_cost_center=None): default_cost_center=None):

View File

@@ -679,17 +679,13 @@ class calculate_taxes_and_totals(object):
default_mode_of_payment = frappe.db.get_value('POS Payment Method', default_mode_of_payment = frappe.db.get_value('POS Payment Method',
{'parent': self.doc.pos_profile, 'default': 1}, ['mode_of_payment'], as_dict=1) {'parent': self.doc.pos_profile, 'default': 1}, ['mode_of_payment'], as_dict=1)
self.doc.payments = []
if default_mode_of_payment: if default_mode_of_payment:
self.doc.payments = []
self.doc.append('payments', { self.doc.append('payments', {
'mode_of_payment': default_mode_of_payment.mode_of_payment, 'mode_of_payment': default_mode_of_payment.mode_of_payment,
'amount': total_amount_to_pay, 'amount': total_amount_to_pay,
'default': 1 'default': 1
}) })
else:
self.doc.is_pos = 0
self.doc.pos_profile = ''
self.calculate_paid_amount() self.calculate_paid_amount()

View File

@@ -0,0 +1,21 @@
frappe.pages['organizational-chart'].on_page_load = function(wrapper) {
frappe.ui.make_app_page({
parent: wrapper,
title: __('Organizational Chart'),
single_column: true
});
$(wrapper).bind('show', () => {
frappe.require('/assets/js/hierarchy-chart.min.js', () => {
let organizational_chart = undefined;
let method = 'erpnext.hr.page.organizational_chart.organizational_chart.get_children';
if (frappe.is_mobile()) {
organizational_chart = new erpnext.HierarchyChartMobile('Employee', wrapper, method);
} else {
organizational_chart = new erpnext.HierarchyChart('Employee', wrapper, method);
}
organizational_chart.show();
});
});
};

View File

@@ -0,0 +1,26 @@
{
"content": null,
"creation": "2021-05-25 10:53:10.107241",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2021-05-25 10:53:18.201931",
"modified_by": "Administrator",
"module": "HR",
"name": "organizational-chart",
"owner": "Administrator",
"page_name": "Organizational Chart",
"roles": [
{
"role": "HR User"
},
{
"role": "HR Manager"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "Organizational Chart"
}

View File

@@ -0,0 +1,47 @@
from __future__ import unicode_literals
import frappe
@frappe.whitelist()
def get_children(parent=None, company=None, exclude_node=None):
filters = [['status', '!=', 'Left']]
if company and company != 'All Companies':
filters.append(['company', '=', company])
if parent and company and parent != company:
filters.append(['reports_to', '=', parent])
else:
filters.append(['reports_to', '=', ''])
if exclude_node:
filters.append(['name', '!=', exclude_node])
employees = frappe.get_list('Employee',
fields=['employee_name as name', 'name as id', 'reports_to', 'image', 'designation as title'],
filters=filters,
order_by='name'
)
for employee in employees:
is_expandable = frappe.db.count('Employee', filters={'reports_to': employee.get('id')})
employee.connections = get_connections(employee.id)
employee.expandable = 1 if is_expandable else 0
return employees
def get_connections(employee):
num_connections = 0
connections = frappe.get_list('Employee', filters=[
['reports_to', '=', employee]
])
num_connections += len(connections)
while connections:
for entry in connections:
connections = frappe.get_list('Employee', filters=[
['reports_to', '=', entry.name]
])
num_connections += len(connections)
return num_connections

View File

@@ -294,6 +294,7 @@ erpnext.patches.v13_0.update_level_in_bom #1234sswef
erpnext.patches.v13_0.add_missing_fg_item_for_stock_entry erpnext.patches.v13_0.add_missing_fg_item_for_stock_entry
erpnext.patches.v13_0.update_subscription_status_in_memberships erpnext.patches.v13_0.update_subscription_status_in_memberships
erpnext.patches.v13_0.update_amt_in_work_order_required_items erpnext.patches.v13_0.update_amt_in_work_order_required_items
erpnext.patches.v12_0.show_einvoice_irn_cancelled_field
erpnext.patches.v13_0.delete_orphaned_tables erpnext.patches.v13_0.delete_orphaned_tables
erpnext.patches.v13_0.update_export_type_for_gst erpnext.patches.v13_0.update_export_type_for_gst
erpnext.patches.v13_0.update_tds_check_field #3 erpnext.patches.v13_0.update_tds_check_field #3

View File

@@ -0,0 +1,12 @@
from __future__ import unicode_literals
import frappe
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
irn_cancelled_field = frappe.db.exists('Custom Field', {'dt': 'Sales Invoice', 'fieldname': 'irn_cancelled'})
if irn_cancelled_field:
frappe.db.set_value('Custom Field', irn_cancelled_field, 'depends_on', 'eval: doc.irn')
frappe.db.set_value('Custom Field', irn_cancelled_field, 'read_only', 0)

View File

@@ -310,6 +310,7 @@
"read_only": 1 "read_only": 1
}, },
{ {
"default": "1",
"fieldname": "exchange_rate", "fieldname": "exchange_rate",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Exchange Rate" "label": "Exchange Rate"

View File

@@ -249,7 +249,8 @@ def get_projectwise_timesheet_data(project=None, parent=None, from_time=None, to
return frappe.db.sql("""SELECT tsd.name as name, return frappe.db.sql("""SELECT tsd.name as name,
tsd.parent as parent, tsd.billing_hours as billing_hours, tsd.parent as parent, tsd.billing_hours as billing_hours,
tsd.billing_amount as billing_amount, tsd.activity_type as activity_type, tsd.billing_amount as billing_amount, tsd.activity_type as activity_type,
tsd.description as description, ts.currency as currency tsd.description as description, ts.currency as currency,
tsd.project_name as project_name
FROM `tabTimesheet Detail` tsd FROM `tabTimesheet Detail` tsd
INNER JOIN `tabTimesheet` ts ON ts.name = tsd.parent INNER JOIN `tabTimesheet` ts ON ts.name = tsd.parent
WHERE tsd.parenttype = 'Timesheet' WHERE tsd.parenttype = 'Timesheet'
@@ -257,6 +258,19 @@ def get_projectwise_timesheet_data(project=None, parent=None, from_time=None, to
and tsd.is_billable = 1 and tsd.is_billable = 1
and tsd.sales_invoice is null""".format(condition), {'project': project, 'parent': parent, 'from_time': from_time, 'to_time': to_time}, as_dict=1) and tsd.sales_invoice is null""".format(condition), {'project': project, 'parent': parent, 'from_time': from_time, 'to_time': to_time}, as_dict=1)
@frappe.whitelist()
def get_timesheet_detail_rate(timelog, currency):
timelog_detail = frappe.db.sql("""SELECT tsd.billing_amount as billing_amount,
ts.currency as currency FROM `tabTimesheet Detail` tsd
INNER JOIN `tabTimesheet` ts ON ts.name=tsd.parent
WHERE tsd.name = '{0}'""".format(timelog), as_dict = 1)[0]
if timelog_detail.currency:
exchange_rate = get_exchange_rate(timelog_detail.currency, currency)
return timelog_detail.billing_amount * exchange_rate
return timelog_detail.billing_amount
@frappe.whitelist() @frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs @frappe.validate_and_sanitize_search_inputs
def get_timesheet(doctype, txt, searchfield, start, page_len, filters): def get_timesheet(doctype, txt, searchfield, start, page_len, filters):

View File

@@ -3,7 +3,8 @@
"public/less/erpnext.less", "public/less/erpnext.less",
"public/less/hub.less", "public/less/hub.less",
"public/scss/call_popup.scss", "public/scss/call_popup.scss",
"public/scss/point-of-sale.scss" "public/scss/point-of-sale.scss",
"public/scss/hierarchy_chart.scss"
], ],
"css/marketplace.css": [ "css/marketplace.css": [
"public/less/hub.less" "public/less/hub.less"
@@ -43,7 +44,8 @@
"public/js/call_popup/call_popup.js", "public/js/call_popup/call_popup.js",
"public/js/utils/dimension_tree_filter.js", "public/js/utils/dimension_tree_filter.js",
"public/js/telephony.js", "public/js/telephony.js",
"public/js/templates/call_link.html" "public/js/templates/call_link.html",
"public/js/templates/node_card.html"
], ],
"js/item-dashboard.min.js": [ "js/item-dashboard.min.js": [
"stock/dashboard/item_dashboard.html", "stock/dashboard/item_dashboard.html",
@@ -66,5 +68,9 @@
"public/js/bank_reconciliation_tool/data_table_manager.js", "public/js/bank_reconciliation_tool/data_table_manager.js",
"public/js/bank_reconciliation_tool/number_card.js", "public/js/bank_reconciliation_tool/number_card.js",
"public/js/bank_reconciliation_tool/dialog_manager.js" "public/js/bank_reconciliation_tool/dialog_manager.js"
],
"js/hierarchy-chart.min.js": [
"public/js/hierarchy_chart/hierarchy_chart_desktop.js",
"public/js/hierarchy_chart/hierarchy_chart_mobile.js"
] ]
} }

View File

@@ -31,6 +31,14 @@ frappe.ui.form.on(cur_frm.doctype, {
} }
} }
}); });
frm.set_query("cost_center", "taxes", function(doc) {
return {
filters: {
"company": doc.company,
"is_group": 0
}
};
});
} }
}, },
validate: function(frm) { validate: function(frm) {

View File

@@ -47,7 +47,10 @@ erpnext.taxes_and_totals = erpnext.payments.extend({
if (in_list(["Sales Invoice", "POS Invoice"], this.frm.doc.doctype) && this.frm.doc.is_pos && if (in_list(["Sales Invoice", "POS Invoice"], this.frm.doc.doctype) && this.frm.doc.is_pos &&
this.frm.doc.is_return) { this.frm.doc.is_return) {
this.update_paid_amount_for_return(); if (this.frm.doc.doctype == "Sales Invoice") {
this.set_total_amount_to_default_mop();
}
this.calculate_paid_amount();
} }
// Sales person's commission // Sales person's commission
@@ -730,7 +733,7 @@ erpnext.taxes_and_totals = erpnext.payments.extend({
} }
}, },
update_paid_amount_for_return: function() { set_total_amount_to_default_mop: function() {
var grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total; var grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total;
if(this.frm.doc.party_account_currency == this.frm.doc.currency) { if(this.frm.doc.party_account_currency == this.frm.doc.currency) {
@@ -743,17 +746,12 @@ erpnext.taxes_and_totals = erpnext.payments.extend({
precision("base_grand_total") precision("base_grand_total")
); );
} }
this.frm.doc.payments.find(pay => { this.frm.doc.payments.find(pay => {
if (pay.default) { if (pay.default) {
pay.amount = total_amount_to_pay; pay.amount = total_amount_to_pay;
} else {
pay.amount = 0.0
} }
}); });
this.frm.refresh_fields(); this.frm.refresh_fields();
this.calculate_paid_amount();
}, },
set_default_payment: function(total_amount_to_pay, update_paid_amount) { set_default_payment: function(total_amount_to_pay, update_paid_amount) {

View File

@@ -732,7 +732,7 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
this.frm.trigger("item_code", cdt, cdn); this.frm.trigger("item_code", cdt, cdn);
} }
else { else {
// Replacing all occurences of comma with carriage return // Replace all occurences of comma with line feed
item.serial_no = item.serial_no.replace(/,/g, '\n'); item.serial_no = item.serial_no.replace(/,/g, '\n');
item.conversion_factor = item.conversion_factor || 1; item.conversion_factor = item.conversion_factor || 1;
refresh_field("serial_no", item.name, item.parentfield); refresh_field("serial_no", item.name, item.parentfield);

View File

@@ -0,0 +1,591 @@
import html2canvas from 'html2canvas';
erpnext.HierarchyChart = class {
/* Options:
- doctype
- wrapper: wrapper for the hierarchy view
- method:
- to get the data for each node
- this method should return id, name, title, image, and connections for each node
*/
constructor(doctype, wrapper, method) {
this.page = wrapper.page;
this.method = method;
this.doctype = doctype;
this.setup_page_style();
this.page.main.addClass('frappe-card');
this.nodes = {};
this.setup_node_class();
}
setup_page_style() {
this.page.main.css({
'min-height': '300px',
'max-height': '600px',
'overflow': 'auto',
'position': 'relative'
});
}
setup_node_class() {
let me = this;
this.Node = class {
constructor({
id, parent, parent_id, image, name, title, expandable, connections, is_root // eslint-disable-line
}) {
// to setup values passed via constructor
$.extend(this, arguments[0]);
this.expanded = 0;
me.nodes[this.id] = this;
me.make_node_element(this);
if (!me.all_nodes_expanded) {
me.setup_node_click_action(this);
}
me.setup_edit_node_action(this);
}
};
}
make_node_element(node) {
let node_card = frappe.render_template('node_card', {
id: node.id,
name: node.name,
title: node.title,
image: node.image,
parent: node.parent_id,
connections: node.connections,
is_mobile: false
});
node.parent.append(node_card);
node.$link = $(`#${node.id}`);
}
show() {
frappe.breadcrumbs.add('HR');
this.setup_actions();
if ($(`[data-fieldname="company"]`).length) return;
let me = this;
let company = this.page.add_field({
fieldtype: 'Link',
options: 'Company',
fieldname: 'company',
placeholder: __('Select Company'),
default: frappe.defaults.get_default('company'),
only_select: true,
reqd: 1,
change: () => {
me.company = undefined;
if (company.get_value() && me.company != company.get_value()) {
me.company = company.get_value();
// svg for connectors
me.make_svg_markers();
me.setup_hierarchy();
me.render_root_nodes();
me.all_nodes_expanded = false;
}
}
});
company.refresh();
$(`[data-fieldname="company"]`).trigger('change');
}
setup_actions() {
let me = this;
this.page.add_inner_button(__('Export'), function() {
me.export_chart();
});
this.page.add_inner_button(__('Expand All'), function() {
me.load_children(me.root_node, true);
me.all_nodes_expanded = true;
me.page.remove_inner_button(__('Expand All'));
me.page.add_inner_button(__('Collapse All'), function() {
me.setup_hierarchy();
me.render_root_nodes();
me.all_nodes_expanded = false;
me.page.remove_inner_button(__('Collapse All'));
me.setup_actions();
});
});
}
export_chart() {
this.page.main.css({
'min-height': '',
'max-height': '',
'overflow': 'visible',
'position': 'fixed',
'left': '0',
'top': '0'
});
$('.node-card').addClass('exported');
html2canvas(document.querySelector('#hierarchy-chart-wrapper'), {
scrollY: -window.scrollY,
scrollX: 0
}).then(function(canvas) {
// Export the canvas to its data URI representation
let dataURL = canvas.toDataURL('image/png');
// download the image
let a = document.createElement('a');
a.href = dataURL;
a.download = 'hierarchy_chart';
a.click();
});
this.setup_page_style();
$('.node-card').removeClass('exported');
}
setup_hierarchy() {
if (this.$hierarchy)
this.$hierarchy.remove();
$(`#connectors`).empty();
// setup hierarchy
this.$hierarchy = $(
`<ul class="hierarchy">
<li class="root-level level">
<ul class="node-children"></ul>
</li>
</ul>`);
this.page.main
.find('#hierarchy-chart-wrapper')
.append(this.$hierarchy);
this.nodes = {};
}
make_svg_markers() {
$('#hierarchy-chart-wrapper').remove();
this.page.main.append(`
<div id="hierarchy-chart-wrapper">
<svg id="arrows" width="100%" height="100%">
<defs>
<marker id="arrowhead-active" viewBox="0 0 10 10" refX="3" refY="5" markerWidth="6" markerHeight="6" orient="auto" fill="var(--blue-500)">
<path d="M 0 0 L 10 5 L 0 10 z"></path>
</marker>
<marker id="arrowhead-collapsed" viewBox="0 0 10 10" refX="3" refY="5" markerWidth="6" markerHeight="6" orient="auto" fill="var(--blue-300)">
<path d="M 0 0 L 10 5 L 0 10 z"></path>
</marker>
<marker id="arrowstart-active" viewBox="0 0 10 10" refX="3" refY="5" markerWidth="8" markerHeight="8" orient="auto" fill="var(--blue-500)">
<circle cx="4" cy="4" r="3.5" fill="white" stroke="var(--blue-500)"/>
</marker>
<marker id="arrowstart-collapsed" viewBox="0 0 10 10" refX="3" refY="5" markerWidth="8" markerHeight="8" orient="auto" fill="var(--blue-300)">
<circle cx="4" cy="4" r="3.5" fill="white" stroke="var(--blue-300)"/>
</marker>
</defs>
<g id="connectors" fill="none">
</g>
</svg>
</div>`);
}
render_root_nodes(expanded_view=false) {
let me = this;
frappe.call({
method: me.method,
args: {
company: me.company
}
}).then(r => {
if (r.message.length) {
let expand_node = undefined;
let node = undefined;
$.each(r.message, (i, data) => {
node = new me.Node({
id: data.id,
parent: $('<li class="child-node"></li>').appendTo(me.$hierarchy.find('.node-children')),
parent_id: undefined,
image: data.image,
name: data.name,
title: data.title,
expandable: true,
connections: data.connections,
is_root: true
});
if (!expand_node && data.connections)
expand_node = node;
});
if (!expanded_view) {
me.root_node = expand_node;
me.expand_node(expand_node);
}
}
});
}
expand_node(node) {
const is_sibling = this.selected_node && this.selected_node.parent_id === node.parent_id;
this.set_selected_node(node);
this.show_active_path(node);
this.collapse_previous_level_nodes(node);
// since the previous node collapses, all connections to that node need to be rebuilt
// if a sibling node is clicked, connections don't need to be rebuilt
if (!is_sibling) {
// rebuild outgoing connections
this.refresh_connectors(node.parent_id);
// rebuild incoming connections
let grandparent = $(`#${node.parent_id}`).attr('data-parent');
this.refresh_connectors(grandparent);
}
if (node.expandable && !node.expanded) {
return this.load_children(node);
}
}
collapse_node() {
if (this.selected_node.expandable) {
this.selected_node.$children.hide();
$(`path[data-parent="${this.selected_node.id}"]`).hide();
this.selected_node.expanded = false;
}
}
show_active_path(node) {
// mark node parent on active path
$(`#${node.parent_id}`).addClass('active-path');
}
load_children(node, deep=false) {
if (!deep) {
frappe.run_serially([
() => this.get_child_nodes(node.id),
(child_nodes) => this.render_child_nodes(node, child_nodes)
]);
} else {
frappe.run_serially([
() => this.setup_hierarchy(),
() => this.render_root_nodes(true),
() => this.get_all_nodes(node.id, node.name),
(data_list) => this.render_children_of_all_nodes(data_list)
]);
}
}
get_child_nodes(node_id) {
return new Promise(resolve => {
frappe.call({
method: this.method,
args: {
parent: node_id,
company: this.company
}
}).then(r => resolve(r.message));
});
}
render_child_nodes(node, child_nodes) {
const last_level = this.$hierarchy.find('.level:last').index();
const current_level = $(`#${node.id}`).parent().parent().parent().index();
if (last_level === current_level) {
this.$hierarchy.append(`
<li class="level"></li>
`);
}
if (!node.$children) {
node.$children = $('<ul class="node-children"></ul>')
.hide()
.appendTo(this.$hierarchy.find('.level:last'));
node.$children.empty();
if (child_nodes) {
$.each(child_nodes, (_i, data) => {
this.add_node(node, data);
setTimeout(() => {
this.add_connector(node.id, data.id);
}, 250);
});
}
}
node.$children.show();
$(`path[data-parent="${node.id}"]`).show();
node.expanded = true;
}
get_all_nodes(node_id, node_name) {
return new Promise(resolve => {
frappe.call({
method: 'erpnext.utilities.hierarchy_chart.get_all_nodes',
args: {
method: this.method,
company: this.company,
parent: node_id,
parent_name: node_name
},
callback: (r) => {
resolve(r.message);
}
});
});
}
render_children_of_all_nodes(data_list) {
let entry = undefined;
let node = undefined;
while (data_list.length) {
// to avoid overlapping connectors
entry = data_list.shift();
node = this.nodes[entry.parent];
if (node) {
this.render_child_nodes_for_expanded_view(node, entry.data);
} else {
data_list.push(entry);
}
}
}
render_child_nodes_for_expanded_view(node, child_nodes) {
node.$children = $('<ul class="node-children"></ul>');
const last_level = this.$hierarchy.find('.level:last').index();
const node_level = $(`#${node.id}`).parent().parent().parent().index();
if (last_level === node_level) {
this.$hierarchy.append(`
<li class="level"></li>
`);
node.$children.appendTo(this.$hierarchy.find('.level:last'));
} else {
node.$children.appendTo(this.$hierarchy.find('.level:eq(' + (node_level + 1) + ')'));
}
node.$children.hide().empty();
if (child_nodes) {
$.each(child_nodes, (_i, data) => {
this.add_node(node, data);
setTimeout(() => {
this.add_connector(node.id, data.id);
}, 250);
});
}
node.$children.show();
$(`path[data-parent="${node.id}"]`).show();
node.expanded = true;
}
add_node(node, data) {
return new this.Node({
id: data.id,
parent: $('<li class="child-node"></li>').appendTo(node.$children),
parent_id: node.id,
image: data.image,
name: data.name,
title: data.title,
expandable: data.expandable,
connections: data.connections,
children: undefined
});
}
add_connector(parent_id, child_id) {
// using pure javascript for better performance
const parent_node = document.querySelector(`#${parent_id}`);
const child_node = document.querySelector(`#${child_id}`);
let path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
// we need to connect right side of the parent to the left side of the child node
const pos_parent_right = {
x: parent_node.offsetLeft + parent_node.offsetWidth,
y: parent_node.offsetTop + parent_node.offsetHeight / 2
};
const pos_child_left = {
x: child_node.offsetLeft - 5,
y: child_node.offsetTop + child_node.offsetHeight / 2
};
const connector = this.get_connector(pos_parent_right, pos_child_left);
path.setAttribute('d', connector);
this.set_path_attributes(path, parent_id, child_id);
document.getElementById('connectors').appendChild(path);
}
get_connector(pos_parent_right, pos_child_left) {
if (pos_parent_right.y === pos_child_left.y) {
// don't add arcs if it's a straight line
return "M" +
(pos_parent_right.x) + "," + (pos_parent_right.y) + " " +
"L"+
(pos_child_left.x) + "," + (pos_child_left.y);
} else {
let arc_1 = "";
let arc_2 = "";
let offset = 0;
if (pos_parent_right.y > pos_child_left.y) {
// if child is above parent on Y axis 1st arc is anticlocwise
// second arc is clockwise
arc_1 = "a10,10 1 0 0 10,-10 ";
arc_2 = "a10,10 0 0 1 10,-10 ";
offset = 10;
} else {
// if child is below parent on Y axis 1st arc is clockwise
// second arc is anticlockwise
arc_1 = "a10,10 0 0 1 10,10 ";
arc_2 = "a10,10 1 0 0 10,10 ";
offset = -10;
}
return "M" + (pos_parent_right.x) + "," + (pos_parent_right.y) + " " +
"L" +
(pos_parent_right.x + 40) + "," + (pos_parent_right.y) + " " +
arc_1 +
"L" +
(pos_parent_right.x + 50) + "," + (pos_child_left.y + offset) + " " +
arc_2 +
"L"+
(pos_child_left.x) + "," + (pos_child_left.y);
}
}
set_path_attributes(path, parent_id, child_id) {
path.setAttribute("data-parent", parent_id);
path.setAttribute("data-child", child_id);
const parent = $(`#${parent_id}`);
if (parent.hasClass('active')) {
path.setAttribute("class", "active-connector");
path.setAttribute("marker-start", "url(#arrowstart-active)");
path.setAttribute("marker-end", "url(#arrowhead-active)");
} else {
path.setAttribute("class", "collapsed-connector");
path.setAttribute("marker-start", "url(#arrowstart-collapsed)");
path.setAttribute("marker-end", "url(#arrowhead-collapsed)");
}
}
set_selected_node(node) {
// remove active class from the current node
if (this.selected_node)
this.selected_node.$link.removeClass('active');
// add active class to the newly selected node
this.selected_node = node;
node.$link.addClass('active');
}
collapse_previous_level_nodes(node) {
let node_parent = $(`#${node.parent_id}`);
let previous_level_nodes = node_parent.parent().parent().children('li');
let node_card = undefined;
previous_level_nodes.each(function() {
node_card = $(this).find('.node-card');
if (!node_card.hasClass('active-path')) {
node_card.addClass('collapsed');
}
});
}
refresh_connectors(node_parent) {
if (!node_parent) return;
$(`path[data-parent="${node_parent}"]`).remove();
frappe.run_serially([
() => this.get_child_nodes(node_parent),
(child_nodes) => {
if (child_nodes) {
$.each(child_nodes, (_i, data) => {
this.add_connector(node_parent, data.id);
});
}
}
]);
}
setup_node_click_action(node) {
let me = this;
let node_element = $(`#${node.id}`);
node_element.click(function() {
const is_sibling = me.selected_node.parent_id === node.parent_id;
if (is_sibling) {
me.collapse_node();
} else if (node_element.is(':visible')
&& (node_element.hasClass('collapsed') || node_element.hasClass('active-path'))) {
me.remove_levels_after_node(node);
me.remove_orphaned_connectors();
}
me.expand_node(node);
});
}
setup_edit_node_action(node) {
let node_element = $(`#${node.id}`);
let me = this;
node_element.find('.btn-edit-node').click(function() {
frappe.set_route('Form', me.doctype, node.id);
});
}
remove_levels_after_node(node) {
let level = $(`#${node.id}`).parent().parent().parent().index();
level = $('.hierarchy > li:eq('+ level + ')');
level.nextAll('li').remove();
let nodes = level.find('.node-card');
let node_object = undefined;
$.each(nodes, (_i, element) => {
node_object = this.nodes[element.id];
node_object.expanded = 0;
node_object.$children = undefined;
});
nodes.removeClass('collapsed active-path');
}
remove_orphaned_connectors() {
let paths = $('#connectors > path');
$.each(paths, (_i, path) => {
const parent = $(path).data('parent');
const child = $(path).data('child');
if ($(`#${parent}`).length && $(`#${child}`).length)
return;
$(path).remove();
});
}
};

View File

@@ -0,0 +1,551 @@
erpnext.HierarchyChartMobile = class {
/* Options:
- doctype
- wrapper: wrapper for the hierarchy view
- method:
- to get the data for each node
- this method should return id, name, title, image, and connections for each node
*/
constructor(doctype, wrapper, method) {
this.page = wrapper.page;
this.method = method;
this.doctype = doctype;
this.page.main.css({
'min-height': '300px',
'max-height': '600px',
'overflow': 'auto',
'position': 'relative'
});
this.page.main.addClass('frappe-card');
this.nodes = {};
this.setup_node_class();
}
setup_node_class() {
let me = this;
this.Node = class {
constructor({
id, parent, parent_id, image, name, title, expandable, connections, is_root // eslint-disable-line
}) {
// to setup values passed via constructor
$.extend(this, arguments[0]);
this.expanded = 0;
me.nodes[this.id] = this;
me.make_node_element(this);
me.setup_node_click_action(this);
me.setup_edit_node_action(this);
}
};
}
make_node_element(node) {
let node_card = frappe.render_template('node_card', {
id: node.id,
name: node.name,
title: node.title,
image: node.image,
parent: node.parent_id,
connections: node.connections,
is_mobile: true
});
node.parent.append(node_card);
node.$link = $(`#${node.id}`);
node.$link.addClass('mobile-node');
}
show() {
frappe.breadcrumbs.add('HR');
let me = this;
if ($(`[data-fieldname="company"]`).length) return;
let company = this.page.add_field({
fieldtype: 'Link',
options: 'Company',
fieldname: 'company',
placeholder: __('Select Company'),
default: frappe.defaults.get_default('company'),
only_select: true,
reqd: 1,
change: () => {
me.company = undefined;
if (company.get_value() && me.company != company.get_value()) {
me.company = company.get_value();
// svg for connectors
me.make_svg_markers();
if (me.$sibling_group)
me.$sibling_group.remove();
// setup sibling group wrapper
me.$sibling_group = $(`<div class="sibling-group mt-4 mb-4"></div>`);
me.page.main.append(me.$sibling_group);
me.setup_hierarchy();
me.render_root_nodes();
}
}
});
company.refresh();
$(`[data-fieldname="company"]`).trigger('change');
}
make_svg_markers() {
$('#arrows').remove();
this.page.main.prepend(`
<svg id="arrows" width="100%" height="100%">
<defs>
<marker id="arrowhead-active" viewBox="0 0 10 10" refX="3" refY="5" markerWidth="6" markerHeight="6" orient="auto" fill="var(--blue-500)">
<path d="M 0 0 L 10 5 L 0 10 z"></path>
</marker>
<marker id="arrowhead-collapsed" viewBox="0 0 10 10" refX="3" refY="5" markerWidth="6" markerHeight="6" orient="auto" fill="var(--blue-300)">
<path d="M 0 0 L 10 5 L 0 10 z"></path>
</marker>
<marker id="arrowstart-active" viewBox="0 0 10 10" refX="3" refY="5" markerWidth="8" markerHeight="8" orient="auto" fill="var(--blue-500)">
<circle cx="4" cy="4" r="3.5" fill="white" stroke="var(--blue-500)"/>
</marker>
<marker id="arrowstart-collapsed" viewBox="0 0 10 10" refX="3" refY="5" markerWidth="8" markerHeight="8" orient="auto" fill="var(--blue-300)">
<circle cx="4" cy="4" r="3.5" fill="white" stroke="var(--blue-300)"/>
</marker>
</defs>
<g id="connectors" fill="none">
</g>
</svg>`);
}
setup_hierarchy() {
$(`#connectors`).empty();
if (this.$hierarchy)
this.$hierarchy.remove();
if (this.$sibling_group)
this.$sibling_group.empty();
this.$hierarchy = $(
`<ul class="hierarchy-mobile">
<li class="root-level level"></li>
</ul>`);
this.page.main.append(this.$hierarchy);
}
render_root_nodes() {
let me = this;
frappe.call({
method: me.method,
args: {
company: me.company
},
}).then(r => {
if (r.message.length) {
let root_level = me.$hierarchy.find('.root-level');
root_level.empty();
$.each(r.message, (_i, data) => {
return new me.Node({
id: data.id,
parent: root_level,
parent_id: undefined,
image: data.image,
name: data.name,
title: data.title,
expandable: true,
connections: data.connections,
is_root: true
});
});
}
});
}
expand_node(node) {
const is_same_node = (this.selected_node && this.selected_node.id === node.id);
this.set_selected_node(node);
this.show_active_path(node);
if (this.$sibling_group) {
const sibling_parent = this.$sibling_group.find('.node-group').attr('data-parent');
if (node.parent_id !== undefined && node.parent_id != sibling_parent)
this.$sibling_group.empty();
}
if (!is_same_node) {
// since the previous/parent node collapses, all connections to that node need to be rebuilt
// rebuild outgoing connections of parent
this.refresh_connectors(node.parent_id, node.id);
// rebuild incoming connections of parent
let grandparent = $(`#${node.parent_id}`).attr('data-parent');
this.refresh_connectors(grandparent, node.parent_id);
}
if (node.expandable && !node.expanded) {
return this.load_children(node);
}
}
collapse_node() {
let node = this.selected_node;
if (node.expandable && node.$children) {
node.$children.hide();
node.expanded = 0;
// add a collapsed level to show the collapsed parent
// and a button beside it to move to that level
let node_parent = node.$link.parent();
node_parent.prepend(
`<div class="collapsed-level d-flex flex-row"></div>`
);
node_parent
.find('.collapsed-level')
.append(node.$link);
frappe.run_serially([
() => this.get_child_nodes(node.parent_id, node.id),
(child_nodes) => this.get_node_group(child_nodes, node.parent_id),
(node_group) => node_parent.find('.collapsed-level').append(node_group),
() => this.setup_node_group_action()
]);
}
}
show_active_path(node) {
// mark node parent on active path
$(`#${node.parent_id}`).addClass('active-path');
}
load_children(node) {
frappe.run_serially([
() => this.get_child_nodes(node.id),
(child_nodes) => this.render_child_nodes(node, child_nodes)
]);
}
get_child_nodes(node_id, exclude_node=null) {
let me = this;
return new Promise(resolve => {
frappe.call({
method: this.method,
args: {
parent: node_id,
company: me.company,
exclude_node: exclude_node
}
}).then(r => resolve(r.message));
});
}
render_child_nodes(node, child_nodes) {
if (!node.$children) {
node.$children = $('<ul class="node-children"></ul>')
.hide()
.appendTo(node.$link.parent());
node.$children.empty();
if (child_nodes) {
$.each(child_nodes, (_i, data) => {
this.add_node(node, data);
$(`#${data.id}`).addClass('active-child');
setTimeout(() => {
this.add_connector(node.id, data.id);
}, 250);
});
}
}
node.$children.show();
node.expanded = 1;
}
add_node(node, data) {
var $li = $('<li class="child-node"></li>');
return new this.Node({
id: data.id,
parent: $li.appendTo(node.$children),
parent_id: node.id,
image: data.image,
name: data.name,
title: data.title,
expandable: data.expandable,
connections: data.connections,
children: undefined
});
}
add_connector(parent_id, child_id) {
const parent_node = document.querySelector(`#${parent_id}`);
const child_node = document.querySelector(`#${child_id}`);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
let connector = undefined;
if ($(`#${parent_id}`).hasClass('active')) {
connector = this.get_connector_for_active_node(parent_node, child_node);
} else if ($(`#${parent_id}`).hasClass('active-path')) {
connector = this.get_connector_for_collapsed_node(parent_node, child_node);
}
path.setAttribute('d', connector);
this.set_path_attributes(path, parent_id, child_id);
document.getElementById('connectors').appendChild(path);
}
get_connector_for_active_node(parent_node, child_node) {
// we need to connect the bottom left of the parent to the left side of the child node
let pos_parent_bottom = {
x: parent_node.offsetLeft + 20,
y: parent_node.offsetTop + parent_node.offsetHeight
};
let pos_child_left = {
x: child_node.offsetLeft - 5,
y: child_node.offsetTop + child_node.offsetHeight / 2
};
let connector =
"M" +
(pos_parent_bottom.x) + "," + (pos_parent_bottom.y) + " " +
"L" +
(pos_parent_bottom.x) + "," + (pos_child_left.y - 10) + " " +
"a10,10 1 0 0 10,10 " +
"L" +
(pos_child_left.x) + "," + (pos_child_left.y);
return connector;
}
get_connector_for_collapsed_node(parent_node, child_node) {
// we need to connect the bottom left of the parent to the top left of the child node
let pos_parent_bottom = {
x: parent_node.offsetLeft + 20,
y: parent_node.offsetTop + parent_node.offsetHeight
};
let pos_child_top = {
x: child_node.offsetLeft + 20,
y: child_node.offsetTop
};
let connector =
"M" +
(pos_parent_bottom.x) + "," + (pos_parent_bottom.y) + " " +
"L" +
(pos_child_top.x) + "," + (pos_child_top.y);
return connector;
}
set_path_attributes(path, parent_id, child_id) {
path.setAttribute("data-parent", parent_id);
path.setAttribute("data-child", child_id);
const parent = $(`#${parent_id}`);
if (parent.hasClass('active')) {
path.setAttribute("class", "active-connector");
path.setAttribute("marker-start", "url(#arrowstart-active)");
path.setAttribute("marker-end", "url(#arrowhead-active)");
} else if (parent.hasClass('active-path')) {
path.setAttribute("class", "collapsed-connector");
}
}
set_selected_node(node) {
// remove .active class from the current node
if (this.selected_node)
this.selected_node.$link.removeClass('active');
// add active class to the newly selected node
this.selected_node = node;
node.$link.addClass('active');
}
setup_node_click_action(node) {
let me = this;
let node_element = $(`#${node.id}`);
node_element.click(function() {
let el = undefined;
if (node.is_root) {
el = $(this).detach();
me.$hierarchy.empty();
$(`#connectors`).empty();
me.add_node_to_hierarchy(el, node);
} else if (node_element.is(':visible') && node_element.hasClass('active-path')) {
me.remove_levels_after_node(node);
me.remove_orphaned_connectors();
} else {
el = $(this).detach();
me.add_node_to_hierarchy(el, node);
me.collapse_node();
}
me.expand_node(node);
});
}
setup_edit_node_action(node) {
let node_element = $(`#${node.id}`);
let me = this;
node_element.find('.btn-edit-node').click(function() {
frappe.set_route('Form', me.doctype, node.id);
});
}
setup_node_group_action() {
let me = this;
$('.node-group').on('click', function() {
let parent = $(this).attr('data-parent');
if (parent === 'undefined') {
me.setup_hierarchy();
me.render_root_nodes();
} else {
me.expand_sibling_group_node(parent);
}
});
}
add_node_to_hierarchy(node_element, node) {
this.$hierarchy.append(`<li class="level"></li>`);
node_element.removeClass('active-child active-path');
this.$hierarchy.find('.level:last').append(node_element);
let node_object = this.nodes[node.id];
node_object.expanded = 0;
node_object.$children = undefined;
this.nodes[node.id] = node_object;
}
get_node_group(nodes, parent, collapsed=true) {
let limit = 2;
const display_nodes = nodes.slice(0, limit);
const extra_nodes = nodes.slice(limit);
let html = display_nodes.map(node =>
this.get_avatar(node)
).join('');
if (extra_nodes.length === 1) {
let node = extra_nodes[0];
html += this.get_avatar(node);
} else if (extra_nodes.length > 1) {
html = `
${html}
<span class="avatar avatar-small">
<div class="avatar-frame standard-image avatar-extra-count"
title="${extra_nodes.map(node => node.name).join(', ')}">
+${extra_nodes.length}
</div>
</span>
`;
}
if (html) {
const $node_group =
$(`<div class="node-group card cursor-pointer" data-parent=${parent}>
<div class="avatar-group right overlap">
${html}
</div>
</div>`);
if (collapsed)
$node_group.addClass('collapsed');
return $node_group;
}
return null;
}
get_avatar(node) {
return `<span class="avatar avatar-small" title="${node.name}">
<span class="avatar-frame" src=${node.image} style="background-image: url(${node.image})"></span>
</span>`;
}
expand_sibling_group_node(parent) {
let node_object = this.nodes[parent];
let node = node_object.$link;
node.removeClass('active-child active-path');
node_object.expanded = 0;
node_object.$children = undefined;
this.nodes[node.id] = node_object;
// show parent's siblings and expand parent node
frappe.run_serially([
() => this.get_child_nodes(node_object.parent_id, node_object.id),
(child_nodes) => this.get_node_group(child_nodes, node_object.parent_id, false),
(node_group) => {
if (node_group)
this.$sibling_group.empty().append(node_group);
},
() => this.setup_node_group_action(),
() => this.reattach_and_expand_node(node, node_object)
]);
}
reattach_and_expand_node(node, node_object) {
var el = node.detach();
this.$hierarchy.empty().append(`
<li class="level"></li>
`);
this.$hierarchy.find('.level').append(el);
$(`#connectors`).empty();
this.expand_node(node_object);
}
remove_levels_after_node(node) {
let level = $(`#${node.id}`).parent().parent().index();
level = $('.hierarchy-mobile > li:eq('+ level + ')');
level.nextAll('li').remove();
let node_object = this.nodes[node.id];
let current_node = level.find(`#${node.id}`).detach();
current_node.removeClass('active-child active-path');
node_object.expanded = 0;
node_object.$children = undefined;
level.empty().append(current_node);
}
remove_orphaned_connectors() {
let paths = $('#connectors > path');
$.each(paths, (_i, path) => {
const parent = $(path).data('parent');
const child = $(path).data('child');
if ($(`#${parent}`).length && $(`#${child}`).length)
return;
$(path).remove();
});
}
refresh_connectors(node_parent, node_id) {
if (!node_parent) return;
$(`path[data-parent="${node_parent}"]`).remove();
this.add_connector(node_parent, node_id);
}
};

View File

@@ -0,0 +1,33 @@
<div class="node-card card cursor-pointer" id="{%= id %}" data-parent="{%= parent %}">
<div class="node-meta d-flex flex-row">
<div class="mr-3">
<span class="avatar node-image" title="{{ name }}">
<span class="avatar-frame" src={{image}} style="background-image: url(\'{%= image %}\')"></span>
</span>
</div>
<div>
<div class="node-name d-flex flex-row mb-1">
<span class="ellipsis">{{ name }}</span>
<div class="btn-xs btn-edit-node d-flex flex-row">
<a class="node-edit-icon">{{ frappe.utils.icon("edit", "xs") }}</a>
<span class="edit-chart-node text-xs">{{ __("Edit") }}</span>
</div>
</div>
<div class="node-info d-flex flex-row mb-1">
<div class="node-title text-muted ellipsis">{{ title }}</div>
{% if is_mobile %}
<div class="node-connections text-muted ml-2 ellipsis">
· {{ connections }} <span class="fa fa-level-down"></span>
</div>
{% else %}
{% if connections == 1 %}
<div class="node-connections text-muted ml-2 ellipsis">· {{ connections }} Connection</div>
{% else %}
<div class="node-connections text-muted ml-2 ellipsis">· {{ connections }} Connections</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,311 @@
.node-card {
background: white;
stroke: 1px solid var(--gray-200);
box-shadow: var(--shadow-base);
border-radius: 0.5rem;
padding: 0.75rem;
margin-left: 3rem;
width: 18rem;
overflow: hidden;
.btn-edit-node {
display: none;
}
.edit-chart-node {
display: none;
}
.node-edit-icon {
display: none;
}
}
.node-card.exported {
box-shadow: none
}
.node-image {
width: 3.0rem;
height: 3.0rem;
}
.node-name {
font-size: 1rem;
line-height: 1.72;
}
.node-title {
font-size: 0.75rem;
line-height: 1.35;
}
.node-info {
width: 12.7rem;
}
.node-connections {
font-size: 0.75rem;
line-height: 1.35;
}
.node-card.active {
background: var(--blue-50);
border: 1px solid var(--blue-500);
box-shadow: var(--shadow-md);
border-radius: 0.5rem;
padding: 0.75rem;
width: 18rem;
.btn-edit-node {
display: flex;
background: var(--blue-100);
color: var(--blue-500);
padding: .25rem .5rem;
font-size: .75rem;
justify-content: center;
box-shadow: var(--shadow-sm);
margin-left: auto;
}
.edit-chart-node {
display: block;
margin-right: 0.25rem;
}
.node-edit-icon {
display: block;
}
.node-edit-icon > .icon{
stroke: var(--blue-500);
}
.node-name {
align-items: center;
justify-content: space-between;
margin-bottom: 2px;
width: 12.2rem;
}
}
.node-card.active-path {
background: var(--blue-100);
border: 1px solid var(--blue-300);
box-shadow: var(--shadow-sm);
border-radius: 0.5rem;
padding: 0.75rem;
width: 15rem;
height: 3.0rem;
.btn-edit-node {
display: none !important;
}
.edit-chart-node {
display: none;
}
.node-edit-icon {
display: none;
}
.node-info {
display: none;
}
.node-title {
display: none;
}
.node-connections {
display: none;
}
.node-name {
font-size: 0.85rem;
line-height: 1.35;
}
.node-image {
width: 1.5rem;
height: 1.5rem;
}
.node-meta {
align-items: baseline;
}
}
.node-card.collapsed {
background: white;
stroke: 1px solid var(--gray-200);
box-shadow: var(--shadow-sm);
border-radius: 0.5rem;
padding: 0.75rem;
width: 15rem;
height: 3.0rem;
.btn-edit-node {
display: none !important;
}
.edit-chart-node {
display: none;
}
.node-edit-icon {
display: none;
}
.node-info {
display: none;
}
.node-title {
display: none;
}
.node-connections {
display: none;
}
.node-name {
font-size: 0.85rem;
line-height: 1.35;
}
.node-image {
width: 1.5rem;
height: 1.5rem;
}
.node-meta {
align-items: baseline;
}
}
// horizontal hierarchy tree view
#hierarchy-chart-wrapper {
padding-top: 30px;
#arrows {
margin-top: -80px;
}
}
.hierarchy {
display: flex;
}
.hierarchy li {
list-style-type: none;
}
.child-node {
margin: 0px 0px 16px 0px;
}
.level {
margin-right: 8px;
align-items: flex-start;
flex-direction: column;
}
#arrows {
position: absolute;
overflow: visible;
}
.active-connector {
stroke: var(--blue-500);
}
.collapsed-connector {
stroke: var(--blue-300);
}
// mobile
.hierarchy-mobile {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 10px;
padding-left: 0px;
}
.hierarchy-mobile li {
list-style-type: none;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.mobile-node {
margin-left: 0;
}
.mobile-node.active-path {
width: 12.25rem;
}
.active-child {
width: 15.5rem;
}
.mobile-node .node-connections {
max-width: 80px;
}
.hierarchy-mobile .node-children {
margin-top: 16px;
}
.root-level .node-card {
margin: 0 0 16px;
}
// node group
.collapsed-level {
margin-bottom: 16px;
width: 18rem;
}
.node-group {
background: white;
border: 1px solid var(--gray-300);
box-shadow: var(--shadow-sm);
border-radius: 0.5rem;
padding: 0.75rem;
width: 18rem;
height: 3rem;
overflow: hidden;
align-items: center;
}
.node-group .avatar-group {
margin-left: 0px;
}
.node-group .avatar-extra-count {
background-color: var(--blue-100);
color: var(--blue-500);
}
.node-group .avatar-frame {
width: 1.5rem;
height: 1.5rem;
}
.node-group.collapsed {
width: 5rem;
margin-left: 12px;
}
.sibling-group {
display: flex;
flex-direction: column;
align-items: center;
}

View File

@@ -457,7 +457,7 @@ def make_custom_fields(update=True):
depends_on='eval:in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0'), depends_on='eval:in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0'),
dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1, dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
depends_on='eval:(doc.irn_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'), depends_on='eval: doc.irn', allow_on_submit=1, insert_after='customer'),
dict(fieldname='eway_bill_validity', label='E-Way Bill Validity', fieldtype='Data', no_copy=1, print_hide=1, dict(fieldname='eway_bill_validity', label='E-Way Bill Validity', fieldtype='Data', no_copy=1, print_hide=1,
depends_on='ewaybill', read_only=1, allow_on_submit=1, insert_after='ewaybill'), depends_on='ewaybill', read_only=1, allow_on_submit=1, insert_after='ewaybill'),
@@ -985,4 +985,4 @@ def create_gratuity_rule():
def update_accounts_settings_for_taxes(): def update_accounts_settings_for_taxes():
if frappe.db.count('Company') == 1: if frappe.db.count('Company') == 1:
frappe.db.set_value('Accounts Settings', None, "add_taxes_from_item_tax_template", 0) frappe.db.set_value('Accounts Settings', None, "add_taxes_from_item_tax_template", 0)

View File

@@ -851,7 +851,7 @@ def get_depreciation_amount(asset, depreciable_value, row):
# if its the first depreciation # if its the first depreciation
if depreciable_value == asset.gross_purchase_amount: if depreciable_value == asset.gross_purchase_amount:
# as per IT act, if the asset is purchased in the 2nd half of fiscal year, then rate is divided by 2 # as per IT act, if the asset is purchased in the 2nd half of fiscal year, then rate is divided by 2
diff = date_diff(asset.available_for_use_date, row.depreciation_start_date) diff = date_diff(row.depreciation_start_date, asset.available_for_use_date)
if diff <= 180: if diff <= 180:
rate_of_depreciation = rate_of_depreciation / 2 rate_of_depreciation = rate_of_depreciation / 2
frappe.msgprint( frappe.msgprint(

View File

@@ -134,9 +134,7 @@ class Customer(TransactionBase):
'''If Customer created from Lead, update lead status to "Converted" '''If Customer created from Lead, update lead status to "Converted"
update Customer link in Quotation, Opportunity''' update Customer link in Quotation, Opportunity'''
if self.lead_name: if self.lead_name:
lead = frappe.get_doc('Lead', self.lead_name) frappe.db.set_value("Lead", self.lead_name, "status", "Converted")
lead.status = 'Converted'
lead.save()
def create_lead_address_contact(self): def create_lead_address_contact(self):
if self.lead_name: if self.lead_name:

View File

@@ -108,6 +108,9 @@ class Company(NestedSet):
frappe.flags.country_change = True frappe.flags.country_change = True
self.create_default_accounts() self.create_default_accounts()
self.create_default_warehouses() self.create_default_warehouses()
if not frappe.db.get_value("Cost Center", {"is_group": 0, "company": self.name}):
self.create_default_cost_center()
if frappe.flags.country_change: if frappe.flags.country_change:
install_country_fixtures(self.name, self.country) install_country_fixtures(self.name, self.country)
@@ -117,9 +120,6 @@ class Company(NestedSet):
from erpnext.setup.setup_wizard.operations.install_fixtures import install_post_company_fixtures from erpnext.setup.setup_wizard.operations.install_fixtures import install_post_company_fixtures
install_post_company_fixtures(frappe._dict({'company_name': self.name})) install_post_company_fixtures(frappe._dict({'company_name': self.name}))
if not frappe.db.get_value("Cost Center", {"is_group": 0, "company": self.name}):
self.create_default_cost_center()
if not frappe.local.flags.ignore_chart_of_accounts: if not frappe.local.flags.ignore_chart_of_accounts:
self.set_default_accounts() self.set_default_accounts()
if self.default_cash_account: if self.default_cash_account:

View File

@@ -124,7 +124,8 @@ def make_taxes_and_charges_template(company_name, doctype, template):
account_data = tax_row.get('account_head') account_data = tax_row.get('account_head')
tax_row_defaults = { tax_row_defaults = {
'category': 'Total', 'category': 'Total',
'charge_type': 'On Net Total' 'charge_type': 'On Net Total',
'cost_center': frappe.db.get_value('Company', company_name, 'cost_center')
} }
if doctype == 'Purchase Taxes and Charges Template': if doctype == 'Purchase Taxes and Charges Template':

View File

@@ -11,6 +11,7 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt \
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.account.test_account import get_inventory_account from erpnext.accounts.doctype.account.test_account import get_inventory_account
from erpnext.accounts.doctype.account.test_account import create_account from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.assets.doctype.asset.test_asset import create_asset_category, create_fixed_asset_item
class TestLandedCostVoucher(unittest.TestCase): class TestLandedCostVoucher(unittest.TestCase):
def test_landed_cost_voucher(self): def test_landed_cost_voucher(self):
@@ -250,6 +251,39 @@ class TestLandedCostVoucher(unittest.TestCase):
self.assertEqual(entry.credit, amounts[0]) self.assertEqual(entry.credit, amounts[0])
self.assertEqual(entry.credit_in_account_currency, amounts[1]) self.assertEqual(entry.credit_in_account_currency, amounts[1])
def test_asset_lcv(self):
"Check if LCV for an Asset updates the Assets Gross Purchase Amount correctly."
frappe.db.set_value("Company", "_Test Company", "capital_work_in_progress_account", "CWIP Account - _TC")
if not frappe.db.exists("Asset Category", "Computers"):
create_asset_category()
if not frappe.db.exists("Item", "Macbook Pro"):
create_fixed_asset_item()
pr = make_purchase_receipt(item_code="Macbook Pro", qty=1, rate=50000)
# check if draft asset was created
assets = frappe.db.get_all('Asset', filters={'purchase_receipt': pr.name})
self.assertEqual(len(assets), 1)
lcv = make_landed_cost_voucher(
company = pr.company,
receipt_document_type = "Purchase Receipt",
receipt_document=pr.name,
charges=80,
expense_account="Expenses Included In Valuation - _TC")
lcv.save()
lcv.submit()
# lcv updates amount in draft asset
self.assertEqual(frappe.db.get_value("Asset", assets[0].name, "gross_purchase_amount"), 50080)
# tear down
lcv.cancel()
pr.cancel()
def make_landed_cost_voucher(** args): def make_landed_cost_voucher(** args):
args = frappe._dict(args) args = frappe._dict(args)
ref_doc = frappe.get_doc(args.receipt_document_type, args.receipt_document) ref_doc = frappe.get_doc(args.receipt_document_type, args.receipt_document)
@@ -268,7 +302,7 @@ def make_landed_cost_voucher(** args):
lcv.set("taxes", [{ lcv.set("taxes", [{
"description": "Shipping Charges", "description": "Shipping Charges",
"expense_account": "Expenses Included In Valuation - TCP1", "expense_account": args.expense_account or "Expenses Included In Valuation - TCP1",
"amount": args.charges "amount": args.charges
}]) }])

View File

@@ -286,8 +286,16 @@ class PurchaseReceipt(BuyingController):
and warehouse_account_name == supplier_warehouse_account: and warehouse_account_name == supplier_warehouse_account:
continue continue
self.add_gl_entry(gl_entries, warehouse_account_name, d.cost_center, stock_value_diff, 0.0, remarks, self.add_gl_entry(
stock_rbnb, account_currency=warehouse_account_currency, item=d) gl_entries=gl_entries,
account=warehouse_account_name,
cost_center=d.cost_center,
debit=stock_value_diff,
credit=0.0,
remarks=remarks,
against_account=stock_rbnb,
account_currency=warehouse_account_currency,
item=d)
# GL Entry for from warehouse or Stock Received but not billed # GL Entry for from warehouse or Stock Received but not billed
# Intentionally passed negative debit amount to avoid incorrect GL Entry validation # Intentionally passed negative debit amount to avoid incorrect GL Entry validation
@@ -300,9 +308,17 @@ class PurchaseReceipt(BuyingController):
account = warehouse_account[d.from_warehouse]['account'] \ account = warehouse_account[d.from_warehouse]['account'] \
if d.from_warehouse else stock_rbnb if d.from_warehouse else stock_rbnb
self.add_gl_entry(gl_entries, account, d.cost_center, self.add_gl_entry(
-1 * flt(d.base_net_amount, d.precision("base_net_amount")), 0.0, remarks, warehouse_account_name, gl_entries=gl_entries,
debit_in_account_currency=-1 * credit_amount, account_currency=credit_currency, item=d) account=account,
cost_center=d.cost_center,
debit=-1 * flt(d.base_net_amount, d.precision("base_net_amount")),
credit=0.0,
remarks=remarks,
against_account=warehouse_account_name,
debit_in_account_currency=-1 * credit_amount,
account_currency=credit_currency,
item=d)
# Amount added through landed-cos-voucher # Amount added through landed-cos-voucher
if d.landed_cost_voucher_amount and landed_cost_entries: if d.landed_cost_voucher_amount and landed_cost_entries:
@@ -311,14 +327,31 @@ class PurchaseReceipt(BuyingController):
credit_amount = (flt(amount["base_amount"]) if (amount["base_amount"] or credit_amount = (flt(amount["base_amount"]) if (amount["base_amount"] or
account_currency!=self.company_currency) else flt(amount["amount"])) account_currency!=self.company_currency) else flt(amount["amount"]))
self.add_gl_entry(gl_entries, account, d.cost_center, 0.0, credit_amount, remarks, self.add_gl_entry(
warehouse_account_name, credit_in_account_currency=flt(amount["amount"]), gl_entries=gl_entries,
account_currency=account_currency, project=d.project, item=d) account=account,
cost_center=d.cost_center,
debit=0.0,
credit=credit_amount,
remarks=remarks,
against_account=warehouse_account_name,
credit_in_account_currency=flt(amount["amount"]),
account_currency=account_currency,
project=d.project,
item=d)
# sub-contracting warehouse # sub-contracting warehouse
if flt(d.rm_supp_cost) and warehouse_account.get(self.supplier_warehouse): if flt(d.rm_supp_cost) and warehouse_account.get(self.supplier_warehouse):
self.add_gl_entry(gl_entries, supplier_warehouse_account, d.cost_center, 0.0, flt(d.rm_supp_cost), self.add_gl_entry(
remarks, warehouse_account_name, account_currency=supplier_warehouse_account_currency, item=d) gl_entries=gl_entries,
account=supplier_warehouse_account,
cost_center=d.cost_center,
debit=0.0,
credit=flt(d.rm_supp_cost),
remarks=remarks,
against_account=warehouse_account_name,
account_currency=supplier_warehouse_account_currency,
item=d)
# divisional loss adjustment # divisional loss adjustment
valuation_amount_as_per_doc = flt(d.base_net_amount, d.precision("base_net_amount")) + \ valuation_amount_as_per_doc = flt(d.base_net_amount, d.precision("base_net_amount")) + \
@@ -335,8 +368,17 @@ class PurchaseReceipt(BuyingController):
cost_center = d.cost_center or frappe.get_cached_value("Company", self.company, "cost_center") cost_center = d.cost_center or frappe.get_cached_value("Company", self.company, "cost_center")
self.add_gl_entry(gl_entries, loss_account, cost_center, divisional_loss, 0.0, remarks, self.add_gl_entry(
warehouse_account_name, account_currency=credit_currency, project=d.project, item=d) gl_entries=gl_entries,
account=loss_account,
cost_center=cost_center,
debit=divisional_loss,
credit=0.0,
remarks=remarks,
against_account=warehouse_account_name,
account_currency=credit_currency,
project=d.project,
item=d)
elif d.warehouse not in warehouse_with_no_account or \ elif d.warehouse not in warehouse_with_no_account or \
d.rejected_warehouse not in warehouse_with_no_account: d.rejected_warehouse not in warehouse_with_no_account:
@@ -347,12 +389,30 @@ class PurchaseReceipt(BuyingController):
debit_currency = get_account_currency(d.expense_account) debit_currency = get_account_currency(d.expense_account)
remarks = self.get("remarks") or _("Accounting Entry for Service") remarks = self.get("remarks") or _("Accounting Entry for Service")
self.add_gl_entry(gl_entries, service_received_but_not_billed_account, d.cost_center, 0.0, d.amount, self.add_gl_entry(
remarks, d.expense_account, account_currency=credit_currency, project=d.project, gl_entries=gl_entries,
account=service_received_but_not_billed_account,
cost_center=d.cost_center,
debit=0.0,
credit=d.amount,
remarks=remarks,
against_account=d.expense_account,
account_currency=credit_currency,
project=d.project,
voucher_detail_no=d.name, item=d) voucher_detail_no=d.name, item=d)
self.add_gl_entry(gl_entries, d.expense_account, d.cost_center, d.amount, 0.0, remarks, service_received_but_not_billed_account, self.add_gl_entry(
account_currency = debit_currency, project=d.project, voucher_detail_no=d.name, item=d) gl_entries=gl_entries,
account=d.expense_account,
cost_center=d.cost_center,
debit=d.amount,
credit=0.0,
remarks=remarks,
against_account=service_received_but_not_billed_account,
account_currency = debit_currency,
project=d.project,
voucher_detail_no=d.name,
item=d)
if warehouse_with_no_account: if warehouse_with_no_account:
frappe.msgprint(_("No accounting entries for the following warehouses") + ": \n" + frappe.msgprint(_("No accounting entries for the following warehouses") + ": \n" +
@@ -402,8 +462,15 @@ class PurchaseReceipt(BuyingController):
applicable_amount = negative_expense_to_be_booked * (valuation_tax[tax.name] / total_valuation_amount) applicable_amount = negative_expense_to_be_booked * (valuation_tax[tax.name] / total_valuation_amount)
amount_including_divisional_loss -= applicable_amount amount_including_divisional_loss -= applicable_amount
self.add_gl_entry(gl_entries, account, tax.cost_center, 0.0, applicable_amount, self.remarks or _("Accounting Entry for Stock"), self.add_gl_entry(
against_account, item=tax) gl_entries=gl_entries,
account=account,
cost_center=tax.cost_center,
debit=0.0,
credit=applicable_amount,
remarks=self.remarks or _("Accounting Entry for Stock"),
against_account=against_account,
item=tax)
i += 1 i += 1
@@ -456,15 +523,31 @@ class PurchaseReceipt(BuyingController):
# debit cwip account # debit cwip account
debit_in_account_currency = (base_asset_amount debit_in_account_currency = (base_asset_amount
if cwip_account_currency == self.company_currency else asset_amount) if cwip_account_currency == self.company_currency else asset_amount)
self.add_gl_entry(gl_entries, cwip_account, item.cost_center, base_asset_amount, 0.0, remarks, self.add_gl_entry(
arbnb_account, debit_in_account_currency=debit_in_account_currency, item=item) gl_entries=gl_entries,
account=cwip_account,
cost_center=item.cost_center,
debit=base_asset_amount,
credit=0.0,
remarks=remarks,
against_account=arbnb_account,
debit_in_account_currency=debit_in_account_currency,
item=item)
asset_rbnb_currency = get_account_currency(arbnb_account) asset_rbnb_currency = get_account_currency(arbnb_account)
# credit arbnb account # credit arbnb account
credit_in_account_currency = (base_asset_amount credit_in_account_currency = (base_asset_amount
if asset_rbnb_currency == self.company_currency else asset_amount) if asset_rbnb_currency == self.company_currency else asset_amount)
self.add_gl_entry(gl_entries, arbnb_account, item.cost_center, 0.0, base_asset_amount, remarks, self.add_gl_entry(
cwip_account, credit_in_account_currency=credit_in_account_currency, item=item) gl_entries=gl_entries,
account=arbnb_account,
cost_center=item.cost_center,
debit=0.0,
credit=base_asset_amount,
remarks=remarks,
against_account=cwip_account,
credit_in_account_currency=credit_in_account_currency,
item=item)
def add_lcv_gl_entries(self, item, gl_entries): def add_lcv_gl_entries(self, item, gl_entries):
expenses_included_in_asset_valuation = self.get_company_default("expenses_included_in_asset_valuation") expenses_included_in_asset_valuation = self.get_company_default("expenses_included_in_asset_valuation")
@@ -477,11 +560,27 @@ class PurchaseReceipt(BuyingController):
remarks = self.get("remarks") or _("Accounting Entry for Stock") remarks = self.get("remarks") or _("Accounting Entry for Stock")
self.add_gl_entry(gl_entries, expenses_included_in_asset_valuation, item.cost_center, 0.0, flt(item.landed_cost_voucher_amount), self.add_gl_entry(
remarks, asset_account, project=item.project, item=item) gl_entries=gl_entries,
account=expenses_included_in_asset_valuation,
cost_center=item.cost_center,
debit=0.0,
credit=flt(item.landed_cost_voucher_amount),
remarks=remarks,
against_account=asset_account,
project=item.project,
item=item)
self.add_gl_entry(gl_entries, asset_account, item.cost_center, 0.0, flt(item.landed_cost_voucher_amount), self.add_gl_entry(
remarks, expenses_included_in_asset_valuation, project=item.project, item=item) gl_entries=gl_entries,
account=asset_account,
cost_center=item.cost_center,
debit=flt(item.landed_cost_voucher_amount),
credit=0.0,
remarks=remarks,
against_account=expenses_included_in_asset_valuation,
project=item.project,
item=item)
def update_assets(self, item, valuation_rate): def update_assets(self, item, valuation_rate):
assets = frappe.db.get_all('Asset', assets = frappe.db.get_all('Asset',

View File

@@ -165,8 +165,14 @@ class SerialNo(StockController):
) )
ORDER BY ORDER BY
posting_date desc, posting_time desc, creation desc""", posting_date desc, posting_time desc, creation desc""",
(self.item_code, self.company, (
serial_no, serial_no+'\n%', '%\n'+serial_no, '%\n'+serial_no+'\n%'), as_dict=1): self.item_code, self.company,
serial_no,
serial_no+'\n%',
'%\n'+serial_no,
'%\n'+serial_no+'\n%'
),
as_dict=1):
if serial_no.upper() in get_serial_nos(sle.serial_no): if serial_no.upper() in get_serial_nos(sle.serial_no):
if cint(sle.actual_qty) > 0: if cint(sle.actual_qty) > 0:
sle_dict.setdefault("incoming", []).append(sle) sle_dict.setdefault("incoming", []).append(sle)

View File

@@ -174,5 +174,23 @@ class TestSerialNo(unittest.TestCase):
self.assertEqual(sn_doc.warehouse, "_Test Warehouse - _TC") self.assertEqual(sn_doc.warehouse, "_Test Warehouse - _TC")
self.assertEqual(sn_doc.purchase_document_no, se.name) self.assertEqual(sn_doc.purchase_document_no, se.name)
def test_serial_no_sanitation(self):
"Test if Serial No input is sanitised before entering the DB."
item_code = "_Test Serialized Item"
test_records = frappe.get_test_records('Stock Entry')
se = frappe.copy_doc(test_records[0])
se.get("items")[0].item_code = item_code
se.get("items")[0].qty = 3
se.get("items")[0].serial_no = " _TS1, _TS2 , _TS3 "
se.get("items")[0].transfer_qty = 3
se.set_stock_entry_type()
se.insert()
se.submit()
self.assertEqual(se.get("items")[0].serial_no, "_TS1\n_TS2\n_TS3")
frappe.db.rollback()
def tearDown(self): def tearDown(self):
frappe.db.rollback() frappe.db.rollback()

View File

@@ -76,6 +76,7 @@ class StockEntry(StockController):
self.validate_difference_account() self.validate_difference_account()
self.set_job_card_data() self.set_job_card_data()
self.set_purpose_for_stock_entry() self.set_purpose_for_stock_entry()
self.clean_serial_nos()
self.validate_duplicate_serial_no() self.validate_duplicate_serial_no()
if not self.from_bom: if not self.from_bom:

View File

@@ -55,8 +55,8 @@ class StockLedgerEntry(Document):
"sum(actual_qty)") or 0 "sum(actual_qty)") or 0
frappe.db.set_value("Batch", self.batch_no, "batch_qty", batch_qty) frappe.db.set_value("Batch", self.batch_no, "batch_qty", batch_qty)
#check for item quantity available in stock
def actual_amt_check(self): def actual_amt_check(self):
"""Validate that qty at warehouse for selected batch is >=0"""
if self.batch_no and not self.get("allow_negative_stock"): if self.batch_no and not self.get("allow_negative_stock"):
batch_bal_after_transaction = flt(frappe.db.sql("""select sum(actual_qty) batch_bal_after_transaction = flt(frappe.db.sql("""select sum(actual_qty)
from `tabStock Ledger Entry` from `tabStock Ledger Entry`
@@ -107,7 +107,7 @@ class StockLedgerEntry(Document):
self.stock_uom = item_det.stock_uom self.stock_uom = item_det.stock_uom
def check_stock_frozen_date(self): def check_stock_frozen_date(self):
stock_settings = frappe.get_doc('Stock Settings', 'Stock Settings') stock_settings = frappe.get_cached_doc('Stock Settings')
if stock_settings.stock_frozen_upto: if stock_settings.stock_frozen_upto:
if (getdate(self.posting_date) <= getdate(stock_settings.stock_frozen_upto) if (getdate(self.posting_date) <= getdate(stock_settings.stock_frozen_upto)

View File

@@ -31,6 +31,7 @@ class StockReconciliation(StockController):
self.validate_expense_account() self.validate_expense_account()
self.validate_customer_provided_item() self.validate_customer_provided_item()
self.set_zero_value_for_customer_provided_items() self.set_zero_value_for_customer_provided_items()
self.clean_serial_nos()
self.set_total_qty_and_amount() self.set_total_qty_and_amount()
self.validate_putaway_capacity() self.validate_putaway_capacity()

View File

@@ -271,15 +271,13 @@ class update_entries_after(object):
} }
""" """
self.data.setdefault(args.warehouse, frappe._dict())
warehouse_dict = self.data[args.warehouse]
previous_sle = get_previous_sle_of_current_voucher(args) previous_sle = get_previous_sle_of_current_voucher(args)
warehouse_dict.previous_sle = previous_sle
for key in ("qty_after_transaction", "valuation_rate", "stock_value"): self.data[args.warehouse] = frappe._dict({
setattr(warehouse_dict, key, flt(previous_sle.get(key))) "previous_sle": previous_sle,
"qty_after_transaction": flt(previous_sle.qty_after_transaction),
warehouse_dict.update({ "valuation_rate": flt(previous_sle.valuation_rate),
"stock_value": flt(previous_sle.stock_value),
"prev_stock_value": previous_sle.stock_value or 0.0, "prev_stock_value": previous_sle.stock_value or 0.0,
"stock_queue": json.loads(previous_sle.stock_queue or "[]"), "stock_queue": json.loads(previous_sle.stock_queue or "[]"),
"stock_value_difference": 0.0 "stock_value_difference": 0.0

View File

@@ -224,7 +224,7 @@ def get_avg_purchase_rate(serial_nos):
def get_valuation_method(item_code): def get_valuation_method(item_code):
"""get valuation method from item or default""" """get valuation method from item or default"""
val_method = frappe.db.get_value('Item', item_code, 'valuation_method') val_method = frappe.db.get_value('Item', item_code, 'valuation_method', cache=True)
if not val_method: if not val_method:
val_method = frappe.db.get_value("Stock Settings", None, "valuation_method") or "FIFO" val_method = frappe.db.get_value("Stock Settings", None, "valuation_method") or "FIFO"
return val_method return val_method
@@ -275,17 +275,17 @@ def get_valid_serial_nos(sr_nos, qty=0, item_code=''):
return valid_serial_nos return valid_serial_nos
def validate_warehouse_company(warehouse, company): def validate_warehouse_company(warehouse, company):
warehouse_company = frappe.db.get_value("Warehouse", warehouse, "company") warehouse_company = frappe.db.get_value("Warehouse", warehouse, "company", cache=True)
if warehouse_company and warehouse_company != company: if warehouse_company and warehouse_company != company:
frappe.throw(_("Warehouse {0} does not belong to company {1}").format(warehouse, company), frappe.throw(_("Warehouse {0} does not belong to company {1}").format(warehouse, company),
InvalidWarehouseCompany) InvalidWarehouseCompany)
def is_group_warehouse(warehouse): def is_group_warehouse(warehouse):
if frappe.db.get_value("Warehouse", warehouse, "is_group"): if frappe.db.get_value("Warehouse", warehouse, "is_group", cache=True):
frappe.throw(_("Group node warehouse is not allowed to select for transactions")) frappe.throw(_("Group node warehouse is not allowed to select for transactions"))
def validate_disabled_warehouse(warehouse): def validate_disabled_warehouse(warehouse):
if frappe.db.get_value("Warehouse", warehouse, "disabled"): if frappe.db.get_value("Warehouse", warehouse, "disabled", cache=True):
frappe.throw(_("Disabled Warehouse {0} cannot be used for this transaction.").format(get_link_to_form('Warehouse', warehouse))) frappe.throw(_("Disabled Warehouse {0} cannot be used for this transaction.").format(get_link_to_form('Warehouse', warehouse)))
def update_included_uom_in_report(columns, result, include_uom, conversion_factors): def update_included_uom_in_report(columns, result, include_uom, conversion_factors):

View File

@@ -235,8 +235,7 @@ class Issue(Document):
self.set_response_and_resolution_time() self.set_response_and_resolution_time()
def set_response_and_resolution_time(self, priority=None, service_level_agreement=None): def set_response_and_resolution_time(self, priority=None, service_level_agreement=None):
service_level_agreement = get_active_service_level_agreement_for(priority=priority, service_level_agreement = get_active_service_level_agreement_for(self)
customer=self.customer, service_level_agreement=service_level_agreement)
if not service_level_agreement: if not service_level_agreement:
if frappe.db.get_value("Issue", self.name, "service_level_agreement"): if frappe.db.get_value("Issue", self.name, "service_level_agreement"):
@@ -247,7 +246,8 @@ class Issue(Document):
frappe.throw(_("This Service Level Agreement is specific to Customer {0}").format(service_level_agreement.customer)) frappe.throw(_("This Service Level Agreement is specific to Customer {0}").format(service_level_agreement.customer))
self.service_level_agreement = service_level_agreement.name self.service_level_agreement = service_level_agreement.name
self.priority = service_level_agreement.default_priority if not priority else priority if not self.priority:
self.priority = service_level_agreement.default_priority
priority = get_priority(self) priority = get_priority(self)

View File

@@ -18,6 +18,10 @@
"entity_type", "entity_type",
"column_break_10", "column_break_10",
"entity", "entity",
"filters_section",
"condition",
"column_break_15",
"condition_description",
"agreement_details_section", "agreement_details_section",
"start_date", "start_date",
"active", "active",
@@ -171,10 +175,30 @@
"fieldtype": "Table", "fieldtype": "Table",
"label": "Pause SLA On", "label": "Pause SLA On",
"options": "Pause SLA On Status" "options": "Pause SLA On Status"
},
{
"fieldname": "filters_section",
"fieldtype": "Section Break",
"label": "Assignment Condition"
},
{
"fieldname": "column_break_15",
"fieldtype": "Column Break"
},
{
"fieldname": "condition",
"fieldtype": "Code",
"label": "Condition",
"options": "Python"
},
{
"fieldname": "condition_description",
"fieldtype": "HTML",
"options": "<p><strong>Condition Examples:</strong></p>\n<pre>doc.status==\"Open\"<br>doc.due_date==nowdate()<br>doc.total &gt; 40000\n</pre>"
} }
], ],
"links": [], "links": [],
"modified": "2020-06-10 12:30:15.050785", "modified": "2021-07-27 11:16:45.596579",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Support", "module": "Support",
"name": "Service Level Agreement", "name": "Service Level Agreement",
@@ -208,4 +232,4 @@
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"track_changes": 1 "track_changes": 1
} }

View File

@@ -3,10 +3,12 @@
# For license information, please see license.txt # For license information, please see license.txt
from __future__ import unicode_literals from __future__ import unicode_literals
import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from frappe import _ from frappe import _
from frappe.utils import getdate, get_weekdays, get_link_to_form from frappe.utils import getdate, get_weekdays, get_link_to_form, nowdate
from frappe.utils.safe_exec import get_safe_globals
class ServiceLevelAgreement(Document): class ServiceLevelAgreement(Document):
@@ -14,6 +16,7 @@ class ServiceLevelAgreement(Document):
self.validate_doc() self.validate_doc()
self.check_priorities() self.check_priorities()
self.check_support_and_resolution() self.check_support_and_resolution()
self.validate_condition()
def check_priorities(self): def check_priorities(self):
default_priority = [] default_priority = []
@@ -92,6 +95,14 @@ class ServiceLevelAgreement(Document):
if frappe.db.exists("Service Level Agreement", {"entity_type": self.entity_type, "entity": self.entity, "name": ["!=", self.name]}): if frappe.db.exists("Service Level Agreement", {"entity_type": self.entity_type, "entity": self.entity, "name": ["!=", self.name]}):
frappe.throw(_("Service Level Agreement with Entity Type {0} and Entity {1} already exists.").format(self.entity_type, self.entity)) frappe.throw(_("Service Level Agreement with Entity Type {0} and Entity {1} already exists.").format(self.entity_type, self.entity))
def validate_condition(self):
temp_doc = frappe.new_doc('Issue')
if self.condition:
try:
frappe.safe_eval(self.condition, None, get_context(temp_doc))
except Exception:
frappe.throw(_("The Condition '{0}' is invalid").format(self.condition))
def get_service_level_agreement_priority(self, priority): def get_service_level_agreement_priority(self, priority):
priority = frappe.get_doc("Service Level Priority", {"priority": priority, "parent": self.name}) priority = frappe.get_doc("Service Level Priority", {"priority": priority, "parent": self.name})
@@ -112,7 +123,7 @@ def check_agreement_status():
if doc.end_date and getdate(doc.end_date) < getdate(frappe.utils.getdate()): if doc.end_date and getdate(doc.end_date) < getdate(frappe.utils.getdate()):
frappe.db.set_value("Service Level Agreement", service_level_agreement.name, "active", 0) frappe.db.set_value("Service Level Agreement", service_level_agreement.name, "active", 0)
def get_active_service_level_agreement_for(priority, customer=None, service_level_agreement=None): def get_active_service_level_agreement_for(doc):
if not frappe.db.get_single_value("Support Settings", "track_service_level_agreement"): if not frappe.db.get_single_value("Support Settings", "track_service_level_agreement"):
return return
@@ -121,23 +132,42 @@ def get_active_service_level_agreement_for(priority, customer=None, service_leve
["Service Level Agreement", "enable", "=", 1] ["Service Level Agreement", "enable", "=", 1]
] ]
if priority: if doc.get('priority'):
filters.append(["Service Level Priority", "priority", "=", priority]) filters.append(["Service Level Priority", "priority", "=", doc.get('priority')])
customer = doc.get('customer')
or_filters = [ or_filters = [
["Service Level Agreement", "entity", "in", [customer, get_customer_group(customer), get_customer_territory(customer)]] ["Service Level Agreement", "entity", "in", [customer, get_customer_group(customer), get_customer_territory(customer)]]
] ]
service_level_agreement = doc.get('service_level_agreement')
if service_level_agreement: if service_level_agreement:
or_filters = [ or_filters = [
["Service Level Agreement", "name", "=", service_level_agreement], ["Service Level Agreement", "name", "=", doc.get('service_level_agreement')],
] ]
or_filters.append(["Service Level Agreement", "default_service_level_agreement", "=", 1]) default_sla_filter = filters + [["Service Level Agreement", "default_service_level_agreement", "=", 1]]
default_sla = frappe.get_all("Service Level Agreement", filters=default_sla_filter,
fields=["name", "default_priority", "condition"])
agreement = frappe.get_list("Service Level Agreement", filters=filters, or_filters=or_filters, filters += [["Service Level Agreement", "default_service_level_agreement", "=", 0]]
fields=["name", "default_priority"]) agreements = frappe.get_all("Service Level Agreement", filters=filters, or_filters=or_filters,
fields=["name", "default_priority", "condition"])
# check if the current document on which SLA is to be applied fulfills all the conditions
filtered_agreements = []
for agreement in agreements:
condition = agreement.get('condition')
if not condition or (condition and frappe.safe_eval(condition, None, get_context(doc))):
filtered_agreements.append(agreement)
return agreement[0] if agreement else None # if any default sla
filtered_agreements += default_sla
return filtered_agreements[0] if filtered_agreements else None
def get_context(doc):
return {"doc": doc.as_dict(), "nowdate": nowdate, "frappe": frappe._dict(utils=get_safe_globals().get("frappe").get("utils"))}
def get_customer_group(customer): def get_customer_group(customer):
if customer: if customer:

View File

@@ -0,0 +1,59 @@
import frappe
from frappe.utils import getdate
@frappe.whitelist()
def create_employee_records():
create_company()
create_missing_designation()
emp1 = create_employee('Test Employee 1', 'CEO')
emp2 = create_employee('Test Employee 2', 'CTO')
emp3 = create_employee('Test Employee 3', 'Head of Marketing and Sales', emp1)
emp4 = create_employee('Test Employee 4', 'Project Manager', emp2)
emp5 = create_employee('Test Employee 5', 'Engineer', emp2)
emp6 = create_employee('Test Employee 6', 'Analyst', emp3)
emp7 = create_employee('Test Employee 7', 'Software Developer', emp4)
employees = [emp1, emp2, emp3, emp4, emp5, emp6, emp7]
return employees
@frappe.whitelist()
def get_employee_records():
return frappe.db.get_list('Employee', filters={
'company': 'Test Org Chart'
}, pluck='name', order_by='name')
def create_company():
company = frappe.db.exists('Company', 'Test Org Chart')
if not company:
company = frappe.get_doc({
'doctype': 'Company',
'company_name': 'Test Org Chart',
'country': 'India',
'default_currency': 'INR'
}).insert().name
return company
def create_employee(first_name, designation, reports_to=None):
employee = frappe.db.exists('Employee', {'first_name': first_name, 'designation': designation})
if not employee:
employee = frappe.get_doc({
'doctype': 'Employee',
'first_name': first_name,
'company': 'Test Org Chart',
'gender': 'Female',
'date_of_birth': getdate('08-12-1998'),
'date_of_joining': getdate('01-01-2021'),
'designation': designation,
'reports_to': reports_to
}).insert().name
return employee
def create_missing_designation():
if not frappe.db.exists('Designation', 'CTO'):
frappe.get_doc({
'doctype': 'Designation',
'designation_name': 'CTO'
}).insert()

View File

@@ -0,0 +1,29 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
from __future__ import unicode_literals
import frappe
from frappe import _
@frappe.whitelist()
def get_all_nodes(parent, parent_name, method, company):
'''Recursively gets all data from nodes'''
method = frappe.get_attr(method)
if method not in frappe.whitelisted:
frappe.throw(_('Not Permitted'), frappe.PermissionError)
data = method(parent, company)
result = [dict(parent=parent, parent_name=parent_name, data=data)]
nodes_to_expand = [{'id': d.get('id'), 'name': d.get('name')} for d in data if d.get('expandable')]
while nodes_to_expand:
parent = nodes_to_expand.pop(0)
data = method(parent.get('id'), company)
result.append(dict(parent=parent.get('id'), parent_name=parent.get('name'), data=data))
for d in data:
if d.get('expandable'):
nodes_to_expand.append({'id': d.get('id'), 'name': d.get('name')})
return result

View File

@@ -15,7 +15,8 @@
"snyk": "^1.518.0" "snyk": "^1.518.0"
}, },
"dependencies": { "dependencies": {
"onscan.js": "^1.5.2" "onscan.js": "^1.5.2",
"html2canvas": "^1.1.4"
}, },
"scripts": { "scripts": {
"snyk-protect": "snyk protect", "snyk-protect": "snyk protect",

View File

@@ -688,6 +688,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
base64-arraybuffer@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz#4b944fac0191aa5907afe2d8c999ccc57ce80f45"
integrity sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ==
base64-js@^1.3.1: base64-js@^1.3.1:
version "1.5.1" version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@@ -997,6 +1002,13 @@ crypto-random-string@^2.0.0:
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
css-line-break@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-1.1.1.tgz#d5e9bdd297840099eb0503c7310fd34927a026ef"
integrity sha512-1feNVaM4Fyzdj4mKPIQNL2n70MmuYzAXZ1aytlROFX1JsOo070OsugwGjj7nl6jnDJWHDM8zRZswkmeYVWZJQA==
dependencies:
base64-arraybuffer "^0.2.0"
debug@^3.1.0, debug@^3.2.6: debug@^3.1.0, debug@^3.2.6:
version "3.2.6" version "3.2.6"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
@@ -1472,6 +1484,13 @@ hosted-git-info@^3.0.4, hosted-git-info@^3.0.7:
dependencies: dependencies:
lru-cache "^6.0.0" lru-cache "^6.0.0"
html2canvas@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.1.4.tgz#53ae91cd26e9e9e623c56533cccb2e3f57c8124c"
integrity sha512-uHgQDwrXsRmFdnlOVFvHin9R7mdjjZvoBoXxicPR+NnucngkaLa5zIDW9fzMkiip0jSffyTyWedE8iVogYOeWg==
dependencies:
css-line-break "1.1.1"
http-cache-semantics@^4.0.0: http-cache-semantics@^4.0.0:
version "4.1.0" version "4.1.0"
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"