Merge branch 'version-13-pre-release' into version-13

This commit is contained in:
Rohit Waghchaure
2021-10-20 21:30:20 +05:30
219 changed files with 4536 additions and 1018 deletions

View File

@@ -1,6 +1,8 @@
[flake8]
ignore =
B007,
B009,
B010,
B950,
E101,
E111,

View File

@@ -131,3 +131,21 @@ rules:
key `$X` is uselessly assigned twice. This could be a potential bug.
languages: [python]
severity: ERROR
- id: frappe-manual-commit
patterns:
- pattern: frappe.db.commit()
- pattern-not-inside: |
try:
...
except ...:
...
message: |
Manually commiting a transaction is highly discouraged. Read about the transaction model implemented by Frappe Framework before adding manual commits: https://frappeframework.com/docs/user/en/api/database#database-transaction-model If you think manual commit is required then add a comment explaining why and `// nosemgrep` on the same line.
paths:
exclude:
- "**/patches/**"
- "**/demo/**"
languages: [python]
severity: ERROR

View File

@@ -99,6 +99,8 @@ jobs:
- name: Build Assets
run: cd ~/frappe-bench/ && bench build
env:
CI: Yes
- name: UI Tests
run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests erpnext --headless

58
.mergify.yml Normal file
View File

@@ -0,0 +1,58 @@
pull_request_rules:
- name: Auto-close PRs on stable branch
conditions:
- and:
- and:
- author!=surajshetty3416
- author!=gavindsouza
- author!=rohitwaghchaure
- author!=nabinhait
- or:
- base=version-13
- base=version-12
actions:
close:
comment:
message: |
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
- name: backport to version-13-hotfix
conditions:
- label="backport version-13-hotfix"
actions:
backport:
branches:
- version-13-hotfix
assignees:
- "{{ author }}"
- name: backport to version-13-pre-release
conditions:
- label="backport version-13-pre-release"
actions:
backport:
branches:
- version-13-pre-release
assignees:
- "{{ author }}"
- name: backport to version-12-hotfix
conditions:
- label="backport version-12-hotfix"
actions:
backport:
branches:
- version-12-hotfix
assignees:
- "{{ author }}"
- name: backport to version-12-pre-release
conditions:
- label="backport version-12-pre-release"
actions:
backport:
branches:
- version-12-pre-release
assignees:
- "{{ author }}"

View File

@@ -20,6 +20,9 @@ repos:
rev: 3.9.2
hooks:
- id: flake8
additional_dependencies: [
'flake8-bugbear',
]
args: ['--config', '.github/helper/.flake8_strict']
exclude: ".*setup.py$"

View File

@@ -24,7 +24,7 @@ context('Organizational Chart', () => {
cy.get('.frappe-control[data-fieldname=company] input').focus().as('input');
cy.get('@input')
.clear({ force: true })
.type('Test Org Chart{enter}', { force: true })
.type('Test Org Chart{downarrow}{enter}', { force: true })
.blur({ force: true });
});
});

View File

@@ -25,7 +25,7 @@ context('Organizational Chart Mobile', () => {
cy.get('.frappe-control[data-fieldname=company] input').focus().as('input');
cy.get('@input')
.clear({ force: true })
.type('Test Org Chart{enter}', { force: true })
.type('Test Org Chart{downarrow}{enter}', { force: true })
.blur({ force: true });
});
});

View File

@@ -7,7 +7,7 @@ import frappe
from erpnext.hooks import regional_overrides
__version__ = '13.12.1'
__version__ = '13.13.0'
def get_default_company(user=None):
'''Get default company for user'''

View File

@@ -8,6 +8,8 @@ from frappe import _, throw
from frappe.utils import cint, cstr
from frappe.utils.nestedset import NestedSet, get_ancestors_of, get_descendants_of
import erpnext
class RootNotEditable(frappe.ValidationError): pass
class BalanceMismatchError(frappe.ValidationError): pass
@@ -196,7 +198,7 @@ class Account(NestedSet):
"company": company,
# parent account's currency should be passed down to child account's curreny
# if it is None, it picks it up from default company currency, which might be unintended
"account_currency": self.account_currency,
"account_currency": erpnext.get_company_currency(company),
"parent_account": parent_acc_name_map[company]
})
@@ -207,8 +209,7 @@ class Account(NestedSet):
# update the parent company's value in child companies
doc = frappe.get_doc("Account", child_account)
parent_value_changed = False
for field in ['account_type', 'account_currency',
'freeze_account', 'balance_must_be']:
for field in ['account_type', 'freeze_account', 'balance_must_be']:
if doc.get(field) != self.get(field):
parent_value_changed = True
doc.set(field, self.get(field))

View File

@@ -45,6 +45,49 @@ frappe.treeview_settings["Account"] = {
],
root_label: "Accounts",
get_tree_nodes: 'erpnext.accounts.utils.get_children',
on_get_node: function(nodes, deep=false) {
if (frappe.boot.user.can_read.indexOf("GL Entry") == -1) return;
let accounts = [];
if (deep) {
// in case of `get_all_nodes`
accounts = nodes.reduce((acc, node) => [...acc, ...node.data], []);
} else {
accounts = nodes;
}
const get_balances = frappe.call({
method: 'erpnext.accounts.utils.get_account_balances',
args: {
accounts: accounts,
company: cur_tree.args.company
},
});
get_balances.then(r => {
if (!r.message || r.message.length == 0) return;
for (let account of r.message) {
const node = cur_tree.nodes && cur_tree.nodes[account.value];
if (!node || node.is_root) continue;
// show Dr if positive since balance is calculated as debit - credit else show Cr
const balance = account.balance_in_account_currency || account.balance;
const dr_or_cr = balance > 0 ? "Dr": "Cr";
const format = (value, currency) => format_currency(Math.abs(value), currency);
if (account.balance!==undefined) {
$('<span class="balance-area pull-right">'
+ (account.balance_in_account_currency ?
(format(account.balance_in_account_currency, account.account_currency) + " / ") : "")
+ format(account.balance, account.company_currency)
+ " " + dr_or_cr
+ '</span>').insertBefore(node.$ul);
}
}
});
},
add_tree_node: 'erpnext.accounts.utils.add_ac',
menu_items:[
{
@@ -122,24 +165,6 @@ frappe.treeview_settings["Account"] = {
}
}, "add");
},
onrender: function(node) {
if (frappe.boot.user.can_read.indexOf("GL Entry") !== -1) {
// show Dr if positive since balance is calculated as debit - credit else show Cr
let balance = node.data.balance_in_account_currency || node.data.balance;
let dr_or_cr = balance > 0 ? "Dr": "Cr";
if (node.data && node.data.balance!==undefined) {
$('<span class="balance-area pull-right">'
+ (node.data.balance_in_account_currency ?
(format_currency(Math.abs(node.data.balance_in_account_currency),
node.data.account_currency) + " / ") : "")
+ format_currency(Math.abs(node.data.balance), node.data.company_currency)
+ " " + dr_or_cr
+ '</span>').insertBefore(node.$ul);
}
}
},
toolbar: [
{
label:__("Add Child"),

View File

@@ -12,7 +12,7 @@ from six import iteritems
from unidecode import unidecode
def create_charts(company, chart_template=None, existing_company=None, custom_chart=None):
def create_charts(company, chart_template=None, existing_company=None, custom_chart=None, from_coa_importer=None):
chart = custom_chart or get_chart(chart_template, existing_company)
if chart:
accounts = []
@@ -22,7 +22,7 @@ def create_charts(company, chart_template=None, existing_company=None, custom_ch
if root_account:
root_type = child.get("root_type")
if account_name not in ["account_number", "account_type",
if account_name not in ["account_name", "account_number", "account_type",
"root_type", "is_group", "tax_rate"]:
account_number = cstr(child.get("account_number")).strip()
@@ -35,7 +35,7 @@ def create_charts(company, chart_template=None, existing_company=None, custom_ch
account = frappe.get_doc({
"doctype": "Account",
"account_name": account_name,
"account_name": child.get('account_name') if from_coa_importer else account_name,
"company": company,
"parent_account": parent,
"is_group": is_group,
@@ -213,7 +213,7 @@ def validate_bank_account(coa, bank_account):
return (bank_account in accounts)
@frappe.whitelist()
def build_tree_from_json(chart_template, chart_data=None):
def build_tree_from_json(chart_template, chart_data=None, from_coa_importer=False):
''' get chart template from its folder and parse the json to be rendered as tree '''
chart = chart_data or get_chart(chart_template)
@@ -226,9 +226,12 @@ def build_tree_from_json(chart_template, chart_data=None):
''' recursively called to form a parent-child based list of dict from chart template '''
for account_name, child in iteritems(children):
account = {}
if account_name in ["account_number", "account_type",\
if account_name in ["account_name", "account_number", "account_type",\
"root_type", "is_group", "tax_rate"]: continue
if from_coa_importer:
account_name = child['account_name']
account['parent_account'] = parent
account['expandable'] = True if identify_is_group(child) else False
account['value'] = (cstr(child.get('account_number')).strip() + ' - ' + account_name) \

View File

@@ -175,7 +175,7 @@
"default": "0",
"fieldname": "automatically_fetch_payment_terms",
"fieldtype": "Check",
"label": "Automatically Fetch Payment Terms"
"label": "Automatically Fetch Payment Terms from Order"
},
{
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
@@ -283,7 +283,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2021-08-19 11:17:38.788054",
"modified": "2021-10-11 17:42:36.427699",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -69,7 +69,7 @@ def import_coa(file_name, company):
frappe.local.flags.ignore_root_company_validation = True
forest = build_forest(data)
create_charts(company, custom_chart=forest)
create_charts(company, custom_chart=forest, from_coa_importer=True)
# trigger on_update for company to reset default accounts
set_default_accounts(company)
@@ -148,7 +148,7 @@ def get_coa(doctype, parent, is_root=False, file_name=None, for_validate=0):
if not for_validate:
forest = build_forest(data)
accounts = build_tree_from_json("", chart_data=forest) # returns a list of dict in a tree render-able form
accounts = build_tree_from_json("", chart_data=forest, from_coa_importer=True) # returns a list of dict in a tree render-able form
# filter out to show data for the selected node only
accounts = [d for d in accounts if d['parent_account']==parent]
@@ -212,11 +212,14 @@ def build_forest(data):
if not account_name:
error_messages.append("Row {0}: Please enter Account Name".format(line_no))
name = account_name
if account_number:
account_number = cstr(account_number).strip()
account_name = "{} - {}".format(account_number, account_name)
charts_map[account_name] = {}
charts_map[account_name]['account_name'] = name
if account_number: charts_map[account_name]["account_number"] = account_number
if cint(is_group) == 1: charts_map[account_name]["is_group"] = is_group
if account_type: charts_map[account_name]["account_type"] = account_type
if root_type: charts_map[account_name]["root_type"] = root_type

View File

@@ -16,7 +16,7 @@ class LoyaltyPointEntry(Document):
def get_loyalty_point_entries(customer, loyalty_program, company, expiry_date=None):
if not expiry_date:
date = today()
expiry_date = today()
return frappe.db.sql('''
select name, loyalty_points, expiry_date, loyalty_program_tier, invoice_type, invoice

View File

@@ -390,6 +390,9 @@ class PaymentEntry(AccountsController):
invoice_paid_amount_map[invoice_key]['discounted_amt'] = ref.total_amount * (term.discount / 100)
for key, allocated_amount in iteritems(invoice_payment_amount_map):
if not invoice_paid_amount_map.get(key):
frappe.throw(_('Payment term {0} not used in {1}').format(key[0], key[1]))
outstanding = flt(invoice_paid_amount_map.get(key, {}).get('outstanding'))
discounted_amt = flt(invoice_paid_amount_map.get(key, {}).get('discounted_amt'))
@@ -502,12 +505,13 @@ class PaymentEntry(AccountsController):
def validate_received_amount(self):
if self.paid_from_account_currency == self.paid_to_account_currency:
if self.paid_amount != self.received_amount:
frappe.throw(_("Received Amount should be same as Paid Amount"))
if self.paid_amount < self.received_amount:
frappe.throw(_("Received Amount cannot be greater than Paid Amount"))
def set_received_amount(self):
self.base_received_amount = self.base_paid_amount
if self.paid_from_account_currency == self.paid_to_account_currency:
if self.paid_from_account_currency == self.paid_to_account_currency \
and not self.payment_type == 'Internal Transfer':
self.received_amount = self.paid_amount
def set_amounts_after_tax(self):
@@ -709,10 +713,14 @@ class PaymentEntry(AccountsController):
dr_or_cr = "credit" if erpnext.get_party_account_type(self.party_type) == 'Receivable' else "debit"
for d in self.get("references"):
cost_center = self.cost_center
if d.reference_doctype == "Sales Invoice" and not cost_center:
cost_center = frappe.db.get_value(d.reference_doctype, d.reference_name, "cost_center")
gle = party_gl_dict.copy()
gle.update({
"against_voucher_type": d.reference_doctype,
"against_voucher": d.reference_name
"against_voucher": d.reference_name,
"cost_center": cost_center
})
allocated_amount_in_company_currency = flt(flt(d.allocated_amount) * flt(d.exchange_rate),

View File

@@ -52,21 +52,35 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext
refresh: function() {
this.frm.disable_save();
this.frm.set_df_property('invoices', 'cannot_delete_rows', true);
this.frm.set_df_property('payments', 'cannot_delete_rows', true);
this.frm.set_df_property('allocation', 'cannot_delete_rows', true);
this.frm.set_df_property('invoices', 'cannot_add_rows', true);
this.frm.set_df_property('payments', 'cannot_add_rows', true);
this.frm.set_df_property('allocation', 'cannot_add_rows', true);
if (this.frm.doc.receivable_payable_account) {
this.frm.add_custom_button(__('Get Unreconciled Entries'), () =>
this.frm.trigger("get_unreconciled_entries")
);
this.frm.change_custom_button_type('Get Unreconciled Entries', null, 'primary');
}
if (this.frm.doc.invoices.length && this.frm.doc.payments.length) {
this.frm.add_custom_button(__('Allocate'), () =>
this.frm.trigger("allocate")
);
this.frm.change_custom_button_type('Allocate', null, 'primary');
this.frm.change_custom_button_type('Get Unreconciled Entries', null, 'default');
}
if (this.frm.doc.allocation.length) {
this.frm.add_custom_button(__('Reconcile'), () =>
this.frm.trigger("reconcile")
);
this.frm.change_custom_button_type('Reconcile', null, 'primary');
this.frm.change_custom_button_type('Get Unreconciled Entries', null, 'default');
this.frm.change_custom_button_type('Allocate', null, 'default');
}
},

View File

@@ -12,15 +12,16 @@
"receivable_payable_account",
"col_break1",
"from_invoice_date",
"to_invoice_date",
"minimum_invoice_amount",
"maximum_invoice_amount",
"invoice_limit",
"column_break_13",
"from_payment_date",
"to_payment_date",
"minimum_invoice_amount",
"minimum_payment_amount",
"column_break_11",
"to_invoice_date",
"to_payment_date",
"maximum_invoice_amount",
"maximum_payment_amount",
"column_break_13",
"invoice_limit",
"payment_limit",
"bank_cash_account",
"sec_break1",
@@ -79,6 +80,7 @@
},
{
"depends_on": "eval:(doc.payments).length || (doc.invoices).length",
"description": "If you need to reconcile particular transactions against each other, then please select accordingly. If not, all the transactions will be allocated in FIFO order.",
"fieldname": "sec_break1",
"fieldtype": "Section Break",
"label": "Unreconciled Entries"
@@ -163,6 +165,7 @@
"label": "Maximum Payment Amount"
},
{
"description": "System will fetch all the entries if limit value is zero.",
"fieldname": "payment_limit",
"fieldtype": "Int",
"label": "Payment Limit"
@@ -171,13 +174,17 @@
"fieldname": "maximum_invoice_amount",
"fieldtype": "Currency",
"label": "Maximum Invoice Amount"
},
{
"fieldname": "column_break_11",
"fieldtype": "Column Break"
}
],
"hide_toolbar": 1,
"icon": "icon-resize-horizontal",
"issingle": 1,
"links": [],
"modified": "2021-08-30 13:05:51.977861",
"modified": "2021-10-04 20:27:11.114194",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Reconciliation",

View File

@@ -14,8 +14,8 @@
"section_break_6",
"allocated_amount",
"unreconciled_amount",
"amount",
"column_break_8",
"amount",
"is_advance",
"section_break_5",
"difference_amount",
@@ -127,12 +127,13 @@
"fieldname": "reference_row",
"fieldtype": "Data",
"hidden": 1,
"label": "Reference Row"
"label": "Reference Row",
"read_only": 1
}
],
"istable": 1,
"links": [],
"modified": "2021-09-20 17:23:09.455803",
"modified": "2021-10-06 11:48:59.616562",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Reconciliation Allocation",

View File

@@ -33,7 +33,9 @@ class TestPOSProfile(unittest.TestCase):
frappe.db.sql("delete from `tabPOS Profile`")
def get_customers_list(pos_profile={}):
def get_customers_list(pos_profile=None):
if pos_profile is None:
pos_profile = {}
cond = "1=1"
customer_groups = []
if pos_profile.get('customer_groups'):

View File

@@ -398,7 +398,9 @@ def get_qty_and_rate_for_other_item(doc, pr_doc, pricing_rules):
pricing_rules[0].apply_rule_on_other_items = items
return pricing_rules
def get_qty_amount_data_for_cumulative(pr_doc, doc, items=[]):
def get_qty_amount_data_for_cumulative(pr_doc, doc, items=None):
if items is None:
items = []
sum_qty, sum_amt = [0, 0]
doctype = doc.get('parenttype') or doc.doctype

View File

@@ -69,7 +69,9 @@ class PromotionalScheme(Document):
{'promotional_scheme': self.name}):
frappe.delete_doc('Pricing Rule', rule.name)
def get_pricing_rules(doc, rules = {}):
def get_pricing_rules(doc, rules=None):
if rules is None:
rules = {}
new_doc = []
for child_doc, fields in {'price_discount_slabs': price_discount_fields,
'product_discount_slabs': product_discount_fields}.items():
@@ -78,7 +80,9 @@ def get_pricing_rules(doc, rules = {}):
return new_doc
def _get_pricing_rules(doc, child_doc, discount_fields, rules = {}):
def _get_pricing_rules(doc, child_doc, discount_fields, rules=None):
if rules is None:
rules = {}
new_doc = []
args = get_args_for_pricing_rule(doc)
applicable_for = frappe.scrub(doc.get('applicable_for'))

View File

@@ -149,16 +149,18 @@
"cb_17",
"hold_comment",
"more_info",
"status",
"inter_company_invoice_reference",
"represents_company",
"column_break_147",
"is_internal_supplier",
"accounting_details_section",
"credit_to",
"party_account_currency",
"is_opening",
"against_expense_account",
"column_break_63",
"unrealized_profit_loss_account",
"status",
"inter_company_invoice_reference",
"is_internal_supplier",
"represents_company",
"remarks",
"subscription_section",
"from_date",
@@ -1171,6 +1173,15 @@
"options": "fa fa-file-text",
"print_hide": 1
},
{
"default": "0",
"fetch_from": "supplier.is_internal_supplier",
"fieldname": "is_internal_supplier",
"fieldtype": "Check",
"ignore_user_permissions": 1,
"label": "Is Internal Supplier",
"read_only": 1
},
{
"fieldname": "credit_to",
"fieldtype": "Link",
@@ -1196,7 +1207,7 @@
"default": "No",
"fieldname": "is_opening",
"fieldtype": "Select",
"label": "Is Opening",
"label": "Is Opening Entry",
"oldfieldname": "is_opening",
"oldfieldtype": "Select",
"options": "No\nYes",
@@ -1298,15 +1309,6 @@
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{
"default": "0",
"fetch_from": "supplier.is_internal_supplier",
"fieldname": "is_internal_supplier",
"fieldtype": "Check",
"ignore_user_permissions": 1,
"label": "Is Internal Supplier",
"read_only": 1
},
{
"fieldname": "tax_withholding_category",
"fieldtype": "Link",
@@ -1395,13 +1397,24 @@
"hidden": 1,
"label": "Ignore Default Payment Terms Template",
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "accounting_details_section",
"fieldtype": "Section Break",
"label": "Accounting Details",
"print_hide": 1
},
{
"fieldname": "column_break_147",
"fieldtype": "Column Break"
}
],
"icon": "fa fa-file-text",
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2021-09-28 13:10:28.351810",
"modified": "2021-10-12 20:55:16.145651",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -15,6 +15,7 @@ from erpnext.accounts.deferred_revenue import validate_service_stop_date
from erpnext.accounts.doctype.gl_entry.gl_entry import update_outstanding_amt
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
check_if_return_invoice_linked_with_payment_entry,
get_total_in_party_account_currency,
is_overdue,
unlink_inter_company_doc,
update_linked_doc,
@@ -1147,6 +1148,7 @@ class PurchaseInvoice(BuyingController):
return
outstanding_amount = flt(self.outstanding_amount, self.precision("outstanding_amount"))
total = get_total_in_party_account_currency(self)
if not status:
if self.docstatus == 2:
@@ -1154,9 +1156,9 @@ class PurchaseInvoice(BuyingController):
elif self.docstatus == 1:
if self.is_internal_transfer():
self.status = 'Internal Transfer'
elif is_overdue(self):
elif is_overdue(self, total):
self.status = "Overdue"
elif 0 < outstanding_amount < flt(self.grand_total, self.precision("grand_total")):
elif 0 < outstanding_amount < total:
self.status = "Partly Paid"
elif outstanding_amount > 0 and getdate(self.due_date) >= getdate():
self.status = "Unpaid"

View File

@@ -446,12 +446,15 @@ erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.exte
},
currency() {
var me = this;
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)
if (this.frm.doc.timesheets) {
this.frm.doc.timesheets.forEach((d) => {
let row = frappe.get_doc(d.doctype, d.name)
set_timesheet_detail_rate(row.doctype, row.name, me.frm.doc.currency, row.timesheet_detail)
});
frm.trigger("calculate_timesheet_totals");
}
}
});
@@ -999,7 +1002,7 @@ frappe.ui.form.on('Sales Invoice', {
frappe.ui.form.on("Sales Invoice Timesheet", {
timesheets_remove(frm, cdt, cdn) {
timesheets_remove(frm) {
frm.trigger("calculate_timesheet_totals");
}
});

View File

@@ -124,6 +124,13 @@
"total_advance",
"outstanding_amount",
"disable_rounded_total",
"column_break4",
"write_off_amount",
"base_write_off_amount",
"write_off_outstanding_amount_automatically",
"column_break_74",
"write_off_account",
"write_off_cost_center",
"advances_section",
"allocate_advances_automatically",
"get_advances",
@@ -144,13 +151,6 @@
"column_break_90",
"change_amount",
"account_for_change_amount",
"column_break4",
"write_off_amount",
"base_write_off_amount",
"write_off_outstanding_amount_automatically",
"column_break_74",
"write_off_account",
"write_off_cost_center",
"terms_section_break",
"tc_name",
"terms",
@@ -161,14 +161,14 @@
"column_break_84",
"language",
"more_information",
"status",
"inter_company_invoice_reference",
"is_internal_customer",
"represents_company",
"customer_group",
"campaign",
"is_discounted",
"col_break23",
"status",
"is_internal_customer",
"is_discounted",
"source",
"more_info",
"debit_to",
@@ -1990,16 +1990,6 @@
"label": "Additional Discount Account",
"options": "Account"
},
{
"default": "0",
"fieldname": "ignore_default_payment_terms_template",
"fieldtype": "Check",
"hidden": 1,
"label": "Ignore Default Payment Terms Template",
"read_only": 1,
"show_days": 1,
"show_seconds": 1
},
{
"allow_on_submit": 1,
"fieldname": "dispatch_address_name",
@@ -2015,6 +2005,14 @@
"label": "Dispatch Address",
"read_only": 1
},
{
"default": "0",
"fieldname": "ignore_default_payment_terms_template",
"fieldtype": "Check",
"hidden": 1,
"label": "Ignore Default Payment Terms Template",
"read_only": 1
},
{
"fieldname": "total_billing_hours",
"fieldtype": "Float",
@@ -2033,7 +2031,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2021-09-28 13:09:34.391799",
"modified": "2021-10-11 20:19:38.667508",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",
@@ -2088,4 +2086,4 @@
"title_field": "title",
"track_changes": 1,
"track_seen": 1
}
}

View File

@@ -1296,12 +1296,20 @@ class SalesInvoice(SellingController):
serial_nos = item.serial_no or ""
si_serial_nos = set(get_serial_nos(serial_nos))
serial_no_diff = si_serial_nos - dn_serial_nos
if si_serial_nos - dn_serial_nos:
frappe.throw(_("Serial Numbers in row {0} does not match with Delivery Note").format(item.idx))
if serial_no_diff:
dn_link = frappe.utils.get_link_to_form("Delivery Note", item.delivery_note)
serial_no_msg = ", ".join(frappe.bold(d) for d in serial_no_diff)
msg = _("Row #{0}: The following Serial Nos are not present in Delivery Note {1}:").format(
item.idx, dn_link)
msg += " " + serial_no_msg
frappe.throw(msg=msg, title=_("Serial Nos Mismatch"))
if item.serial_no and cint(item.qty) != len(si_serial_nos):
frappe.throw(_("Row {0}: {1} Serial numbers required for Item {2}. You have provided {3}.").format(
frappe.throw(_("Row #{0}: {1} Serial numbers required for Item {2}. You have provided {3}.").format(
item.idx, item.qty, item.item_code, len(si_serial_nos)))
def update_project(self):
@@ -1470,6 +1478,7 @@ class SalesInvoice(SellingController):
return
outstanding_amount = flt(self.outstanding_amount, self.precision("outstanding_amount"))
total = get_total_in_party_account_currency(self)
if not status:
if self.docstatus == 2:
@@ -1477,9 +1486,9 @@ class SalesInvoice(SellingController):
elif self.docstatus == 1:
if self.is_internal_transfer():
self.status = 'Internal Transfer'
elif is_overdue(self):
elif is_overdue(self, total):
self.status = "Overdue"
elif 0 < outstanding_amount < flt(self.grand_total, self.precision("grand_total")):
elif 0 < outstanding_amount < total:
self.status = "Partly Paid"
elif outstanding_amount > 0 and getdate(self.due_date) >= getdate():
self.status = "Unpaid"
@@ -1506,27 +1515,42 @@ class SalesInvoice(SellingController):
if update:
self.db_set('status', self.status, update_modified = update_modified)
def is_overdue(doc):
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
def get_total_in_party_account_currency(doc):
total_fieldname = (
"grand_total"
if doc.disable_rounded_total
else "rounded_total"
)
if doc.party_account_currency != doc.currency:
total_fieldname = "base_" + total_fieldname
return flt(doc.get(total_fieldname), doc.precision(total_fieldname))
def is_overdue(doc, total):
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
if outstanding_amount <= 0:
return
grand_total = flt(doc.grand_total, doc.precision("grand_total"))
nowdate = getdate()
if doc.payment_schedule:
# calculate payable amount till date
payable_amount = sum(
payment.payment_amount
for payment in doc.payment_schedule
if getdate(payment.due_date) < nowdate
)
today = getdate()
if doc.get('is_pos') or not doc.get('payment_schedule'):
return getdate(doc.due_date) < today
if (grand_total - outstanding_amount) < payable_amount:
return True
# calculate payable amount till date
payment_amount_field = (
"base_payment_amount"
if doc.party_account_currency != doc.currency
else "payment_amount"
)
payable_amount = sum(
payment.get(payment_amount_field)
for payment in doc.payment_schedule
if getdate(payment.due_date) < today
)
return (total - outstanding_amount) < payable_amount
elif getdate(doc.due_date) < nowdate:
return True
def get_discounting_status(sales_invoice):
status = None

View File

@@ -1085,8 +1085,6 @@ class TestSalesInvoice(unittest.TestCase):
actual_qty_1 = get_qty_after_transaction(item_code = "_Test Item", warehouse = "Stores - TCP1")
frappe.db.commit()
self.assertEqual(actual_qty_0 - 5, actual_qty_1)
# outgoing_rate
@@ -2341,6 +2339,18 @@ class TestSalesInvoice(unittest.TestCase):
si.reload()
self.assertEqual(si.status, "Paid")
def test_sales_invoice_submission_post_account_freezing_date(self):
frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', add_days(getdate(), 1))
si = create_sales_invoice(do_not_save=True)
si.posting_date = add_days(getdate(), 1)
si.save()
self.assertRaises(frappe.ValidationError, si.submit)
si.posting_date = getdate()
si.submit()
frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', None)
def get_sales_invoice_for_e_invoice():
si = make_sales_invoice_for_ewaybill()
si.naming_series = 'INV-2020-.#####'

View File

@@ -16,9 +16,9 @@
"column_break_9",
"billing_amount",
"section_break_11",
"timesheet_detail",
"column_break_5",
"time_sheet",
"timesheet_detail",
"column_break_13",
"project_name"
],
"fields": [
@@ -91,7 +91,6 @@
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_7",
"fieldtype": "Section Break",
"label": "Totals"
@@ -110,11 +109,15 @@
"fieldtype": "Data",
"label": "Project Name",
"read_only": 1
},
{
"fieldname": "column_break_13",
"fieldtype": "Column Break"
}
],
"istable": 1,
"links": [],
"modified": "2021-08-15 18:37:08.084930",
"modified": "2021-10-02 03:48:44.979777",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Timesheet",

View File

@@ -33,7 +33,7 @@ class Subscription(Document):
# update start just before the subscription doc is created
self.update_subscription_period(self.start_date)
def update_subscription_period(self, date=None):
def update_subscription_period(self, date=None, return_date=False):
"""
Subscription period is the period to be billed. This method updates the
beginning of the billing period and end of the billing period.
@@ -41,28 +41,41 @@ class Subscription(Document):
The beginning of the billing period is represented in the doctype as
`current_invoice_start` and the end of the billing period is represented
as `current_invoice_end`.
"""
self.set_current_invoice_start(date)
self.set_current_invoice_end()
def set_current_invoice_start(self, date=None):
If return_date is True, it wont update the start and end dates.
This is implemented to get the dates to check if is_current_invoice_generated
"""
This sets the date of the beginning of the current billing period.
_current_invoice_start = self.get_current_invoice_start(date)
_current_invoice_end = self.get_current_invoice_end(_current_invoice_start)
if return_date:
return _current_invoice_start, _current_invoice_end
self.current_invoice_start = _current_invoice_start
self.current_invoice_end = _current_invoice_end
def get_current_invoice_start(self, date=None):
"""
This returns the date of the beginning of the current billing period.
If the `date` parameter is not given , it will be automatically set as today's
date.
"""
if self.is_new_subscription() and self.trial_period_end and getdate(self.trial_period_end) > getdate(self.start_date):
self.current_invoice_start = add_days(self.trial_period_end, 1)
elif self.trial_period_start and self.is_trialling():
self.current_invoice_start = self.trial_period_start
elif date:
self.current_invoice_start = date
else:
self.current_invoice_start = nowdate()
_current_invoice_start = None
def set_current_invoice_end(self):
if self.is_new_subscription() and self.trial_period_end and getdate(self.trial_period_end) > getdate(self.start_date):
_current_invoice_start = add_days(self.trial_period_end, 1)
elif self.trial_period_start and self.is_trialling():
_current_invoice_start = self.trial_period_start
elif date:
_current_invoice_start = date
else:
_current_invoice_start = nowdate()
return _current_invoice_start
def get_current_invoice_end(self, date=None):
"""
This sets the date of the end of the current billing period.
This returns the date of the end of the current billing period.
If the subscription is in trial period, it will be set as the end of the
trial period.
@@ -71,44 +84,47 @@ class Subscription(Document):
current billing period where `x` is the billing interval from the
`Subscription Plan` in the `Subscription`.
"""
if self.is_trialling() and getdate(self.current_invoice_start) < getdate(self.trial_period_end):
self.current_invoice_end = self.trial_period_end
_current_invoice_end = None
if self.is_trialling() and getdate(date) < getdate(self.trial_period_end):
_current_invoice_end = self.trial_period_end
else:
billing_cycle_info = self.get_billing_cycle_data()
if billing_cycle_info:
if self.is_new_subscription() and getdate(self.start_date) < getdate(self.current_invoice_start):
self.current_invoice_end = add_to_date(self.start_date, **billing_cycle_info)
if self.is_new_subscription() and getdate(self.start_date) < getdate(date):
_current_invoice_end = add_to_date(self.start_date, **billing_cycle_info)
# For cases where trial period is for an entire billing interval
if getdate(self.current_invoice_end) < getdate(self.current_invoice_start):
self.current_invoice_end = add_to_date(self.current_invoice_start, **billing_cycle_info)
if getdate(self.current_invoice_end) < getdate(date):
_current_invoice_end = add_to_date(date, **billing_cycle_info)
else:
self.current_invoice_end = add_to_date(self.current_invoice_start, **billing_cycle_info)
_current_invoice_end = add_to_date(date, **billing_cycle_info)
else:
self.current_invoice_end = get_last_day(self.current_invoice_start)
_current_invoice_end = get_last_day(date)
if self.follow_calendar_months:
billing_info = self.get_billing_cycle_and_interval()
billing_interval_count = billing_info[0]['billing_interval_count']
calendar_months = get_calendar_months(billing_interval_count)
calendar_month = 0
current_invoice_end_month = getdate(self.current_invoice_end).month
current_invoice_end_year = getdate(self.current_invoice_end).year
current_invoice_end_month = getdate(_current_invoice_end).month
current_invoice_end_year = getdate(_current_invoice_end).year
for month in calendar_months:
if month <= current_invoice_end_month:
calendar_month = month
if cint(calendar_month - billing_interval_count) <= 0 and \
getdate(self.current_invoice_start).month != 1:
getdate(date).month != 1:
calendar_month = 12
current_invoice_end_year -= 1
self.current_invoice_end = get_last_day(cstr(current_invoice_end_year) + '-' \
+ cstr(calendar_month) + '-01')
_current_invoice_end = get_last_day(cstr(current_invoice_end_year) + '-' + cstr(calendar_month) + '-01')
if self.end_date and getdate(self.current_invoice_end) > getdate(self.end_date):
self.current_invoice_end = self.end_date
if self.end_date and getdate(_current_invoice_end) > getdate(self.end_date):
_current_invoice_end = self.end_date
return _current_invoice_end
@staticmethod
def validate_plans_billing_cycle(billing_cycle_data):
@@ -484,8 +500,9 @@ class Subscription(Document):
def is_current_invoice_generated(self):
invoice = self.get_current_invoice()
_current_start_date, _current_end_date = self.update_subscription_period(date=add_days(self.current_invoice_end, 1), return_date=True)
if invoice and getdate(self.current_invoice_start) <= getdate(invoice.posting_date) <= getdate(self.current_invoice_end):
if invoice and getdate(_current_start_date) <= getdate(invoice.posting_date) <= getdate(_current_end_date):
return True
return False
@@ -538,15 +555,15 @@ class Subscription(Document):
else:
self.set_status_grace_period()
if getdate() > getdate(self.current_invoice_end):
self.update_subscription_period(add_days(self.current_invoice_end, 1))
# Generate invoices periodically even if current invoice are unpaid
if self.generate_new_invoices_past_due_date and not self.is_current_invoice_generated() and (self.is_postpaid_to_invoice()
or self.is_prepaid_to_invoice()):
prorate = frappe.db.get_single_value('Subscription Settings', 'prorate')
self.generate_invoice(prorate)
if getdate() > getdate(self.current_invoice_end):
self.update_subscription_period(add_days(self.current_invoice_end, 1))
@staticmethod
def is_paid(invoice):
"""

View File

@@ -18,6 +18,7 @@ from frappe.utils.data import (
from erpnext.accounts.doctype.subscription.subscription import get_prorata_factor
test_dependencies = ("UOM", "Item Group", "Item")
def create_plan():
if not frappe.db.exists('Subscription Plan', '_Test Plan Name'):
@@ -68,7 +69,6 @@ def create_plan():
supplier.insert()
class TestSubscription(unittest.TestCase):
def setUp(self):
create_plan()

View File

@@ -203,6 +203,9 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N
# then chargeable value is "prev invoices + advances" value which cross the threshold
tax_amount = get_tcs_amount(parties, inv, tax_details, vouchers, advance_vouchers)
if cint(tax_details.round_off_tax_amount):
tax_amount = round(tax_amount)
return tax_amount, tax_deducted
def get_invoice_vouchers(parties, tax_details, company, party_type='Supplier'):
@@ -322,9 +325,6 @@ def get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers):
else:
tds_amount = supp_credit_amt * tax_details.rate / 100 if supp_credit_amt > 0 else 0
if cint(tax_details.round_off_tax_amount):
tds_amount = round(tds_amount)
return tds_amount
def get_tcs_amount(parties, inv, tax_details, vouchers, adv_vouchers):

View File

@@ -293,7 +293,7 @@ def check_freezing_date(posting_date, adv_adj=False):
if acc_frozen_upto:
frozen_accounts_modifier = frappe.db.get_value( 'Accounts Settings', None,'frozen_accounts_modifier')
if getdate(posting_date) <= getdate(acc_frozen_upto) \
and not frozen_accounts_modifier in frappe.get_roles() or frappe.session.user == 'Administrator':
and (frozen_accounts_modifier not in frappe.get_roles() or frappe.session.user == 'Administrator'):
frappe.throw(_("You are not authorized to add or update entries before {0}").format(formatdate(acc_frozen_upto)))
def set_as_cancel(voucher_type, voucher_no):

View File

@@ -139,9 +139,9 @@ def get_account_type_based_data(company, account_type, period_list, accumulated_
data["total"] = total
return data
def get_account_type_based_gl_data(company, start_date, end_date, account_type, filters={}):
def get_account_type_based_gl_data(company, start_date, end_date, account_type, filters=None):
cond = ""
filters = frappe._dict(filters)
filters = frappe._dict(filters or {})
if filters.include_default_book_entries:
company_fb = frappe.db.get_value("Company", company, 'default_finance_book')

View File

@@ -103,8 +103,11 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() {
column.is_tree = true;
}
value = default_formatter(value, row, column, data);
if (data && data.account && column.apply_currency_formatter) {
data.currency = erpnext.get_currency(column.company_name);
}
value = default_formatter(value, row, column, data);
if (!data.parent_account) {
value = $(`<span>${value}</span>`);

View File

@@ -3,12 +3,14 @@
from __future__ import unicode_literals
from collections import defaultdict
import frappe
from frappe import _
from frappe.utils import cint, flt, getdate
import erpnext
from erpnext.accounts.report.balance_sheet.balance_sheet import (
check_opening_balance,
get_chart_data,
get_provisional_profit_loss,
)
@@ -31,7 +33,7 @@ from erpnext.accounts.report.profit_and_loss_statement.profit_and_loss_statement
from erpnext.accounts.report.profit_and_loss_statement.profit_and_loss_statement import (
get_report_summary as get_pl_summary,
)
from erpnext.accounts.report.utils import convert_to_presentation_currency
from erpnext.accounts.report.utils import convert, convert_to_presentation_currency
def execute(filters=None):
@@ -42,7 +44,7 @@ def execute(filters=None):
fiscal_year = get_fiscal_year_data(filters.get('from_fiscal_year'), filters.get('to_fiscal_year'))
companies_column, companies = get_companies(filters)
columns = get_columns(companies_column)
columns = get_columns(companies_column, filters)
if filters.get('report') == "Balance Sheet":
data, message, chart, report_summary = get_balance_sheet_data(fiscal_year, companies, columns, filters)
@@ -73,21 +75,24 @@ def get_balance_sheet_data(fiscal_year, companies, columns, filters):
provisional_profit_loss, total_credit = get_provisional_profit_loss(asset, liability, equity,
companies, filters.get('company'), company_currency, True)
message, opening_balance = check_opening_balance(asset, liability, equity)
message, opening_balance = prepare_companywise_opening_balance(asset, liability, equity, companies)
if opening_balance and round(opening_balance,2) !=0:
unclosed ={
if opening_balance:
unclosed = {
"account_name": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'",
"account": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'",
"warn_if_negative": True,
"currency": company_currency
}
for company in companies:
unclosed[company] = opening_balance
if provisional_profit_loss:
provisional_profit_loss[company] = provisional_profit_loss[company] - opening_balance
unclosed["total"]=opening_balance
for company in companies:
unclosed[company] = opening_balance.get(company)
if provisional_profit_loss and provisional_profit_loss.get(company):
provisional_profit_loss[company] = (
flt(provisional_profit_loss[company]) - flt(opening_balance.get(company))
)
unclosed["total"] = opening_balance.get(company)
data.append(unclosed)
if provisional_profit_loss:
@@ -102,6 +107,37 @@ def get_balance_sheet_data(fiscal_year, companies, columns, filters):
return data, message, chart, report_summary
def prepare_companywise_opening_balance(asset_data, liability_data, equity_data, companies):
opening_balance = {}
for company in companies:
opening_value = 0
# opening_value = Aseet - liability - equity
for data in [asset_data, liability_data, equity_data]:
account_name = get_root_account_name(data[0].root_type, company)
opening_value += get_opening_balance(account_name, data, company)
opening_balance[company] = opening_value
if opening_balance:
return _("Previous Financial Year is not closed"), opening_balance
return '', {}
def get_opening_balance(account_name, data, company):
for row in data:
if row.get('account_name') == account_name:
return row.get('company_wise_opening_bal', {}).get(company, 0.0)
def get_root_account_name(root_type, company):
return frappe.get_all(
'Account',
fields=['account_name'],
filters = {'root_type': root_type, 'is_group': 1,
'company': company, 'parent_account': ('is', 'not set')},
as_list=1
)[0][0]
def get_profit_loss_data(fiscal_year, companies, columns, filters):
income, expense, net_profit_loss = get_income_expense_data(companies, fiscal_year, filters)
company_currency = get_company_currency(filters)
@@ -193,30 +229,37 @@ def get_account_type_based_data(account_type, companies, fiscal_year, filters):
data["total"] = total
return data
def get_columns(companies):
columns = [{
"fieldname": "account",
"label": _("Account"),
"fieldtype": "Link",
"options": "Account",
"width": 300
}]
columns.append({
"fieldname": "currency",
"label": _("Currency"),
"fieldtype": "Link",
"options": "Currency",
"hidden": 1
})
def get_columns(companies, filters):
columns = [
{
"fieldname": "account",
"label": _("Account"),
"fieldtype": "Link",
"options": "Account",
"width": 300
}, {
"fieldname": "currency",
"label": _("Currency"),
"fieldtype": "Link",
"options": "Currency",
"hidden": 1
}
]
for company in companies:
apply_currency_formatter = 1 if not filters.presentation_currency else 0
currency = filters.presentation_currency
if not currency:
currency = erpnext.get_company_currency(company)
columns.append({
"fieldname": company,
"label": company,
"label": f'{company} ({currency})',
"fieldtype": "Currency",
"options": "currency",
"width": 150
"width": 150,
"apply_currency_formatter": apply_currency_formatter,
"company_name": company
})
return columns
@@ -236,6 +279,8 @@ def get_data(companies, root_type, balance_must_be, fiscal_year, filters=None, i
start_date = filters.period_start_date if filters.report != 'Balance Sheet' else None
end_date = filters.period_end_date
filters.end_date = end_date
gl_entries_by_account = {}
for root in frappe.db.sql("""select lft, rgt from tabAccount
where root_type=%s and ifnull(parent_account, '') = ''""", root_type, as_dict=1):
@@ -244,9 +289,10 @@ def get_data(companies, root_type, balance_must_be, fiscal_year, filters=None, i
end_date, root.lft, root.rgt, filters,
gl_entries_by_account, accounts_by_name, accounts, ignore_closing_entries=False)
calculate_values(accounts_by_name, gl_entries_by_account, companies, start_date, filters)
calculate_values(accounts_by_name, gl_entries_by_account, companies, filters, fiscal_year)
accumulate_values_into_parents(accounts, accounts_by_name, companies)
out = prepare_data(accounts, start_date, end_date, balance_must_be, companies, company_currency)
out = prepare_data(accounts, start_date, end_date, balance_must_be, companies, company_currency, filters)
if out:
add_total_row(out, root_type, balance_must_be, companies, company_currency)
@@ -257,7 +303,10 @@ def get_company_currency(filters=None):
return (filters.get('presentation_currency')
or frappe.get_cached_value('Company', filters.company, "default_currency"))
def calculate_values(accounts_by_name, gl_entries_by_account, companies, start_date, filters):
def calculate_values(accounts_by_name, gl_entries_by_account, companies, filters, fiscal_year):
start_date = (fiscal_year.year_start_date
if filters.filter_based_on == 'Fiscal Year' else filters.period_start_date)
for entries in gl_entries_by_account.values():
for entry in entries:
if entry.account_number:
@@ -266,15 +315,32 @@ def calculate_values(accounts_by_name, gl_entries_by_account, companies, start_d
account_name = entry.account_name
d = accounts_by_name.get(account_name)
if d:
debit, credit = 0, 0
for company in companies:
# check if posting date is within the period
if (entry.company == company or (filters.get('accumulated_in_group_company'))
and entry.company in companies.get(company)):
d[company] = d.get(company, 0.0) + flt(entry.debit) - flt(entry.credit)
parent_company_currency = erpnext.get_company_currency(d.company)
child_company_currency = erpnext.get_company_currency(entry.company)
debit, credit = flt(entry.debit), flt(entry.credit)
if (not filters.get('presentation_currency')
and entry.company != company
and parent_company_currency != child_company_currency
and filters.get('accumulated_in_group_company')):
debit = convert(debit, parent_company_currency, child_company_currency, filters.end_date)
credit = convert(credit, parent_company_currency, child_company_currency, filters.end_date)
d[company] = d.get(company, 0.0) + flt(debit) - flt(credit)
if entry.posting_date < getdate(start_date):
d['company_wise_opening_bal'][company] += (flt(debit) - flt(credit))
if entry.posting_date < getdate(start_date):
d["opening_balance"] = d.get("opening_balance", 0.0) + flt(entry.debit) - flt(entry.credit)
d["opening_balance"] = d.get("opening_balance", 0.0) + flt(debit) - flt(credit)
def accumulate_values_into_parents(accounts, accounts_by_name, companies):
"""accumulate children's values in parent accounts"""
@@ -282,17 +348,18 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies):
if d.parent_account:
account = d.parent_account_name
if not accounts_by_name.get(account):
continue
# if not accounts_by_name.get(account):
# continue
for company in companies:
accounts_by_name[account][company] = \
accounts_by_name[account].get(company, 0.0) + d.get(company, 0.0)
accounts_by_name[account]['company_wise_opening_bal'][company] += d.get('company_wise_opening_bal', {}).get(company, 0.0)
accounts_by_name[account]["opening_balance"] = \
accounts_by_name[account].get("opening_balance", 0.0) + d.get("opening_balance", 0.0)
def get_account_heads(root_type, companies, filters):
accounts = get_accounts(root_type, filters)
@@ -353,7 +420,7 @@ def get_accounts(root_type, filters):
`tabAccount` where company = %s and root_type = %s
""" , (filters.get('company'), root_type), as_dict=1)
def prepare_data(accounts, start_date, end_date, balance_must_be, companies, company_currency):
def prepare_data(accounts, start_date, end_date, balance_must_be, companies, company_currency, filters):
data = []
for d in accounts:
@@ -367,10 +434,13 @@ def prepare_data(accounts, start_date, end_date, balance_must_be, companies, com
"parent_account": _(d.parent_account),
"indent": flt(d.indent),
"year_start_date": start_date,
"root_type": d.root_type,
"year_end_date": end_date,
"currency": company_currency,
"currency": filters.presentation_currency,
"company_wise_opening_bal": d.company_wise_opening_bal,
"opening_balance": d.get("opening_balance", 0.0) * (1 if balance_must_be == "Debit" else -1)
})
for company in companies:
if d.get(company) and balance_must_be == "Credit":
# change sign based on Debit or Credit, since calculation is done using (debit - credit)
@@ -385,6 +455,7 @@ def prepare_data(accounts, start_date, end_date, balance_must_be, companies, com
row["has_value"] = has_value
row["total"] = total
data.append(row)
return data
@@ -447,6 +518,7 @@ def get_account_details(account):
'is_group', 'account_name', 'account_number', 'parent_account', 'lft', 'rgt'], as_dict=1)
def validate_entries(key, entry, accounts_by_name, accounts):
# If an account present in the child company and not in the parent company
if key not in accounts_by_name:
args = get_account_details(entry.account)
@@ -456,12 +528,23 @@ def validate_entries(key, entry, accounts_by_name, accounts):
args.update({
'lft': parent_args.lft + 1,
'rgt': parent_args.rgt - 1,
'indent': 3,
'root_type': parent_args.root_type,
'report_type': parent_args.report_type
'report_type': parent_args.report_type,
'parent_account_name': parent_args.account_name,
'company_wise_opening_bal': defaultdict(float)
})
accounts_by_name.setdefault(key, args)
accounts.append(args)
idx = len(accounts)
# To identify parent account index
for index, row in enumerate(accounts):
if row.parent_account_name == args.parent_account_name:
idx = index
break
accounts.insert(idx+1, args)
def get_additional_conditions(from_date, ignore_closing_entries, filters):
additional_conditions = []
@@ -491,7 +574,6 @@ def add_total_row(out, root_type, balance_must_be, companies, company_currency):
for company in companies:
total_row.setdefault(company, 0.0)
total_row[company] += row.get(company, 0.0)
row[company] = 0.0
total_row.setdefault("total", 0.0)
total_row["total"] += flt(row["total"])
@@ -511,6 +593,7 @@ def filter_accounts(accounts, depth=10):
account_name = d.account_number + ' - ' + d.account_name
else:
account_name = d.account_name
d['company_wise_opening_bal'] = defaultdict(float)
accounts_by_name[account_name] = d
parent_children_map.setdefault(d.parent_account or None, []).append(d)

View File

@@ -421,8 +421,6 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map):
update_value_in_dict(totals, 'closing', gle)
elif gle.posting_date <= to_date:
update_value_in_dict(gle_map[gle.get(group_by)].totals, 'total', gle)
update_value_in_dict(totals, 'total', gle)
if filters.get("group_by") != 'Group by Voucher (Consolidated)':
gle_map[gle.get(group_by)].entries.append(gle)
elif filters.get("group_by") == 'Group by Voucher (Consolidated)':
@@ -436,10 +434,11 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map):
else:
update_value_in_dict(consolidated_gle, key, gle)
update_value_in_dict(gle_map[gle.get(group_by)].totals, 'closing', gle)
update_value_in_dict(totals, 'closing', gle)
for key, value in consolidated_gle.items():
update_value_in_dict(gle_map[value.get(group_by)].totals, 'total', value)
update_value_in_dict(totals, 'total', value)
update_value_in_dict(gle_map[value.get(group_by)].totals, 'closing', value)
update_value_in_dict(totals, 'closing', value)
entries.append(value)
return totals, entries

View File

@@ -4,11 +4,14 @@
from __future__ import unicode_literals
from json import loads
import frappe
import frappe.defaults
from frappe import _, throw
from frappe.model.meta import get_field_precision
from frappe.utils import cint, cstr, flt, formatdate, get_number_format_info, getdate, now, nowdate
from six import string_types
import erpnext
@@ -787,16 +790,28 @@ def get_children(doctype, parent, company, is_root=False):
if doctype == 'Account':
sort_accounts(acc, is_root, key="value")
company_currency = frappe.get_cached_value('Company', company, "default_currency")
for each in acc:
each["company_currency"] = company_currency
each["balance"] = flt(get_balance_on(each.get("value"), in_account_currency=False, company=company))
if each.account_currency != company_currency:
each["balance_in_account_currency"] = flt(get_balance_on(each.get("value"), company=company))
return acc
@frappe.whitelist()
def get_account_balances(accounts, company):
if isinstance(accounts, string_types):
accounts = loads(accounts)
if not accounts:
return []
company_currency = frappe.get_cached_value("Company", company, "default_currency")
for account in accounts:
account["company_currency"] = company_currency
account["balance"] = flt(get_balance_on(account["value"], in_account_currency=False, company=company))
if account["account_currency"] and account["account_currency"] != company_currency:
account["balance_in_account_currency"] = flt(get_balance_on(account["value"], company=company))
return accounts
def create_payment_gateway_account(gateway, payment_channel="Email"):
from erpnext.setup.setup_wizard.operations.install_fixtures import create_bank_account

View File

@@ -194,7 +194,7 @@ class Asset(AccountsController):
start = self.clear_depreciation_schedule()
# value_after_depreciation - current Asset value
if d.value_after_depreciation:
if self.docstatus == 1 and d.value_after_depreciation:
value_after_depreciation = (flt(d.value_after_depreciation) -
flt(self.opening_accumulated_depreciation))
else:

View File

@@ -682,6 +682,27 @@ class TestAsset(unittest.TestCase):
# reset indian company
frappe.flags.company = company_flag
def test_expected_value_change(self):
"""
tests if changing `expected_value_after_useful_life`
affects `value_after_depreciation`
"""
asset = create_asset(calculate_depreciation=1)
asset.opening_accumulated_depreciation = 2000
asset.number_of_depreciations_booked = 1
asset.finance_books[0].expected_value_after_useful_life = 100
asset.save()
asset.reload()
self.assertEquals(asset.finance_books[0].value_after_depreciation, 98000.0)
# changing expected_value_after_useful_life shouldn't affect value_after_depreciation
asset.finance_books[0].expected_value_after_useful_life = 200
asset.save()
asset.reload()
self.assertEquals(asset.finance_books[0].value_after_depreciation, 98000.0)
def create_asset_data():
if not frappe.db.exists("Asset Category", "Computers"):
create_asset_category()

View File

@@ -22,7 +22,7 @@ class TestAssetRepair(unittest.TestCase):
frappe.db.sql("delete from `tabTax Rule`")
def test_update_status(self):
asset = create_asset()
asset = create_asset(submit=1)
initial_status = asset.status
asset_repair = create_asset_repair(asset = asset)
@@ -76,7 +76,7 @@ class TestAssetRepair(unittest.TestCase):
self.assertEqual(stock_entry.items[0].qty, asset_repair.stock_items[0].consumed_quantity)
def test_increase_in_asset_value_due_to_stock_consumption(self):
asset = create_asset(calculate_depreciation = 1)
asset = create_asset(calculate_depreciation = 1, submit=1)
initial_asset_value = get_asset_value(asset)
asset_repair = create_asset_repair(asset= asset, stock_consumption = 1, submit = 1)
asset.reload()
@@ -85,7 +85,7 @@ class TestAssetRepair(unittest.TestCase):
self.assertEqual(asset_repair.stock_items[0].total_value, increase_in_asset_value)
def test_increase_in_asset_value_due_to_repair_cost_capitalisation(self):
asset = create_asset(calculate_depreciation = 1)
asset = create_asset(calculate_depreciation = 1, submit=1)
initial_asset_value = get_asset_value(asset)
asset_repair = create_asset_repair(asset= asset, capitalize_repair_cost = 1, submit = 1)
asset.reload()
@@ -103,7 +103,7 @@ class TestAssetRepair(unittest.TestCase):
self.assertEqual(asset_repair.name, gl_entry.voucher_no)
def test_increase_in_asset_life(self):
asset = create_asset(calculate_depreciation = 1)
asset = create_asset(calculate_depreciation = 1, submit=1)
initial_num_of_depreciations = num_of_depreciations(asset)
create_asset_repair(asset= asset, capitalize_repair_cost = 1, submit = 1)
asset.reload()
@@ -126,7 +126,7 @@ def create_asset_repair(**args):
if args.asset:
asset = args.asset
else:
asset = create_asset(is_existing_asset = 1)
asset = create_asset(is_existing_asset = 1, submit=1)
asset_repair = frappe.new_doc("Asset Repair")
asset_repair.update({
"asset": asset.name,

View File

@@ -11,7 +11,7 @@ frappe.tour['Buying Settings'] = [
{
fieldname: "supp_master_name",
title: "Supplier Naming By",
description: __("By default, the Supplier Name is set as per the Supplier Name entered. If you want Suppliers to be named by a ") + "<a href='https://docs.erpnext.com/docs/user/manual/en/setting-up/settings/naming-series' target='_blank'>Naming Series</a>" + __(" choose the 'Naming Series' option."),
description: __("By default, the Supplier Name is set as per the Supplier Name entered. If you want Suppliers to be named by a <a href='https://docs.erpnext.com/docs/user/manual/en/setting-up/settings/naming-series' target='_blank'>Naming Series</a> choose the 'Naming Series' option."),
},
{
fieldname: "buying_price_list",

View File

@@ -0,0 +1,77 @@
{
"creation": "2021-07-28 11:51:42.319984",
"docstatus": 0,
"doctype": "Form Tour",
"idx": 0,
"is_standard": 1,
"modified": "2021-10-05 13:06:56.414584",
"modified_by": "Administrator",
"module": "Buying",
"name": "Buying Settings",
"owner": "Administrator",
"reference_doctype": "Buying Settings",
"save_on_complete": 0,
"steps": [
{
"description": "When a Supplier is saved, system generates a unique identity or name for that Supplier which can be used to refer the Supplier in various Buying transactions.",
"field": "",
"fieldname": "supp_master_name",
"fieldtype": "Select",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Supplier Naming By",
"parent_field": "",
"position": "Bottom",
"title": "Supplier Naming By"
},
{
"description": "Configure what should be the default value of Supplier Group when creating a new Supplier.",
"field": "",
"fieldname": "supplier_group",
"fieldtype": "Link",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Default Supplier Group",
"parent_field": "",
"position": "Right",
"title": "Default Supplier Group"
},
{
"description": "Item prices will be fetched from this Price List.",
"field": "",
"fieldname": "buying_price_list",
"fieldtype": "Link",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Default Buying Price List",
"parent_field": "",
"position": "Bottom",
"title": "Default Buying Price List"
},
{
"description": "If this option is configured \"Yes\", ERPNext will prevent you from creating a Purchase Invoice or a Purchase Receipt directly without creating a Purchase Order first.",
"field": "",
"fieldname": "po_required",
"fieldtype": "Select",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Is Purchase Order Required for Purchase Invoice & Receipt Creation?",
"parent_field": "",
"position": "Bottom",
"title": "Purchase Order Required"
},
{
"description": "If this option is configured \"Yes\", ERPNext will prevent you from creating a Purchase Invoice without creating a Purchase Receipt first.",
"field": "",
"fieldname": "pr_required",
"fieldtype": "Select",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Is Purchase Receipt Required for Purchase Invoice Creation?",
"parent_field": "",
"position": "Bottom",
"title": "Purchase Receipt Required"
}
],
"title": "Buying Settings"
}

View File

@@ -0,0 +1,82 @@
{
"creation": "2021-07-29 14:11:58.271113",
"docstatus": 0,
"doctype": "Form Tour",
"idx": 0,
"is_standard": 1,
"modified": "2021-10-05 13:11:31.436135",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",
"owner": "Administrator",
"reference_doctype": "Purchase Order",
"save_on_complete": 1,
"steps": [
{
"description": "Select a Supplier",
"field": "",
"fieldname": "supplier",
"fieldtype": "Link",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Supplier",
"parent_field": "",
"position": "Right",
"title": "Supplier"
},
{
"description": "Set the \"Required By\" date for the materials. This sets the \"Required By\" date for all the items.",
"field": "",
"fieldname": "schedule_date",
"fieldtype": "Date",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Required By",
"parent_field": "",
"position": "Left",
"title": "Required By"
},
{
"description": "Items to be purchased can be added here.",
"field": "",
"fieldname": "items",
"fieldtype": "Table",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Items",
"parent_field": "",
"position": "Bottom",
"title": "Items Table"
},
{
"child_doctype": "Purchase Order Item",
"description": "Enter the Item Code.",
"field": "",
"fieldname": "item_code",
"fieldtype": "Link",
"has_next_condition": 1,
"is_table_field": 1,
"label": "Item Code",
"next_step_condition": "eval: doc.item_code",
"parent_field": "",
"parent_fieldname": "items",
"position": "Right",
"title": "Item Code"
},
{
"child_doctype": "Purchase Order Item",
"description": "Enter the required quantity for the material.",
"field": "",
"fieldname": "qty",
"fieldtype": "Float",
"has_next_condition": 0,
"is_table_field": 1,
"label": "Quantity",
"parent_field": "",
"parent_fieldname": "items",
"position": "Bottom",
"title": "Quantity"
}
],
"title": "Purchase Order"
}

View File

@@ -19,7 +19,7 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/buying",
"idx": 0,
"is_complete": 0,
"modified": "2020-07-08 14:05:28.273641",
"modified": "2021-08-24 18:13:42.463776",
"modified_by": "Administrator",
"module": "Buying",
"name": "Buying",
@@ -28,23 +28,11 @@
{
"step": "Introduction to Buying"
},
{
"step": "Create a Supplier"
},
{
"step": "Setup your Warehouse"
},
{
"step": "Create a Product"
},
{
"step": "Create a Material Request"
},
{
"step": "Create your first Purchase Order"
},
{
"step": "Buying Settings"
}
],
"subtitle": "Products, Purchases, Analysis, and more.",

View File

@@ -1,19 +1,21 @@
{
"action": "Create Entry",
"action": "Show Form Tour",
"action_label": "Let\u2019s create your first Material Request",
"creation": "2020-05-15 14:39:09.818764",
"description": "# Track Material Request\n\n\nAlso known as Purchase Request or an Indent, is a document identifying a requirement of a set of items (products or services) for various purposes like procurement, transfer, issue, or manufacturing. Once the Material Request is validated, a purchase manager can take the next actions for purchasing items like requesting RFQ from a supplier or directly placing an order with an identified Supplier.\n\n",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_mandatory": 1,
"is_single": 0,
"is_skipped": 0,
"modified": "2020-05-15 14:39:09.818764",
"modified": "2021-08-24 18:08:08.347501",
"modified_by": "Administrator",
"name": "Create a Material Request",
"owner": "Administrator",
"reference_document": "Material Request",
"show_form_tour": 1,
"show_full_form": 1,
"title": "Create a Material Request",
"title": "Track Material Request",
"validate_action": 1
}

View File

@@ -1,19 +1,21 @@
{
"action": "Create Entry",
"action": "Show Form Tour",
"action_label": "Let\u2019s create your first Purchase Order",
"creation": "2020-05-12 18:17:49.976035",
"description": "# Create first Purchase Order\n\nPurchase Order is at the heart of your buying transactions. In ERPNext, Purchase Order can can be created against a Purchase Material Request (indent) and Supplier Quotation as well. Purchase Orders is also linked to Purchase Receipt and Purchase Invoices, allowing you to keep a birds-eye view on your purchase deals.\n\n",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_mandatory": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2020-05-12 18:31:56.856112",
"modified": "2021-08-24 18:08:08.936484",
"modified_by": "Administrator",
"name": "Create your first Purchase Order",
"owner": "Administrator",
"reference_document": "Purchase Order",
"show_form_tour": 0,
"show_full_form": 0,
"title": "Create your first Purchase Order",
"title": "Create first Purchase Order",
"validate_action": 1
}

View File

@@ -1,19 +1,22 @@
{
"action": "Watch Video",
"action": "Show Form Tour",
"action_label": "Let\u2019s walk-through few Buying Settings",
"creation": "2020-05-06 15:37:09.477765",
"description": "# Buying Settings\n\n\nBuying module\u2019s features are highly configurable as per your business needs. Buying Settings is the place where you can set your preferences for:\n\n- Supplier naming and default values\n- Billing and shipping preference in buying transactions\n\n\n",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_mandatory": 0,
"is_single": 0,
"is_single": 1,
"is_skipped": 0,
"modified": "2020-05-12 18:25:08.509900",
"modified": "2021-08-24 18:08:08.345735",
"modified_by": "Administrator",
"name": "Introduction to Buying",
"owner": "Administrator",
"show_full_form": 0,
"title": "Introduction to Buying",
"reference_document": "Buying Settings",
"show_form_tour": 1,
"show_full_form": 1,
"title": "Buying Settings",
"validate_action": 1,
"video_url": "https://youtu.be/efFajTTQBa8"
}

View File

@@ -45,7 +45,6 @@ class TestProcurementTracker(unittest.TestCase):
pr = make_purchase_receipt(po.name)
pr.get("items")[0].cost_center = "Main - _TPC"
pr.submit()
frappe.db.commit()
date_obj = datetime.date(datetime.now())
po.load_from_db()

View File

@@ -0,0 +1,29 @@
# Version 13.13.0 Release Notes
### Features & Enhancements
- HR Module onboarding ([#25741](https://github.com/frappe/erpnext/pull/25741))
- Tracking multiple rounds for the interview ([#25482](https://github.com/frappe/erpnext/pull/25482))
- HSN based tax breakup table check in GST Settings (India Localization) ([#27907](https://github.com/frappe/erpnext/pull/27907))
### Fixes
- To improve stock transactions added indexes in stock queries and speed up bin updation ([#27758](https://github.com/frappe/erpnext/pull/27758))
- Interstate internal transfer invoices not visible in GSTR-1 ([#27970](https://github.com/frappe/erpnext/pull/27970))
- Account number and name incorrectly imported using COA importer ([#27967](https://github.com/frappe/erpnext/pull/27967))
- Multiple fixes to timesheets ([#27775](https://github.com/frappe/erpnext/pull/27742))
- Totals row incorrect value in GL Entry ([#27867](https://github.com/frappe/erpnext/pull/27867))
- Sales Order delivery Date not getting set via data import ([#27862](https://github.com/frappe/erpnext/pull/27862))
- Add cost center in gl entry for advance payment entry ([#27840](https://github.com/frappe/erpnext/pull/27840))
- Item Variant selection empty popup on website ([#27924](https://github.com/frappe/erpnext/pull/27924))
- Improve performance of fetching account balance in chart of accounts ([#27661](https://github.com/frappe/erpnext/pull/27661))
- Chart Of Accounts import button not visible ([#27748](https://github.com/frappe/erpnext/pull/27748))
- Website Items with same Item name unhandled, thumbnails missing ([#27720](https://github.com/frappe/erpnext/pull/27720))
- Delete linked Transaction Deletion Record docs on deleting company ([#27785](https://github.com/frappe/erpnext/pull/27785))
- Display appropriate message for Payment Term discrepancies in Payment Entry ([#27749](https://github.com/frappe/erpnext/pull/27749))
- Updated buying onboarding tours. ([#27800](https://github.com/frappe/erpnext/pull/27800))
- Fixed variant qty in BOM while making work order ([#27686](https://github.com/frappe/erpnext/pull/27686))
- Availability slots display, disabled Practitioner Schedule ([#27812](https://github.com/frappe/erpnext/pull/27812))
- Consolidated report not consider company currency ([#27863](https://github.com/frappe/erpnext/pull/27863))
- Batch Number not copied from Purchase Receipt to Stock Entry ([#27794](https://github.com/frappe/erpnext/pull/27794))
- Employee Leave Balance report should only consider ledgers of transaction type Leave Allocation ([#27728](https://github.com/frappe/erpnext/pull/27728))

View File

@@ -1691,17 +1691,58 @@ def get_advance_payment_entries(party_type, party, party_account, order_doctype,
def update_invoice_status():
"""Updates status as Overdue for applicable invoices. Runs daily."""
today = getdate()
for doctype in ("Sales Invoice", "Purchase Invoice"):
frappe.db.sql("""
update `tab{}` as dt set dt.status = 'Overdue'
where dt.docstatus = 1
and dt.status != 'Overdue'
and dt.outstanding_amount > 0
and (dt.grand_total - dt.outstanding_amount) <
(select sum(payment_amount) from `tabPayment Schedule` as ps
where ps.parent = dt.name and ps.due_date < %s)
""".format(doctype), getdate())
UPDATE `tab{doctype}` invoice SET invoice.status = 'Overdue'
WHERE invoice.docstatus = 1
AND invoice.status REGEXP '^Unpaid|^Partly Paid'
AND invoice.outstanding_amount > 0
AND (
{or_condition}
(
(
CASE
WHEN invoice.party_account_currency = invoice.currency
THEN (
CASE
WHEN invoice.disable_rounded_total
THEN invoice.grand_total
ELSE invoice.rounded_total
END
)
ELSE (
CASE
WHEN invoice.disable_rounded_total
THEN invoice.base_grand_total
ELSE invoice.base_rounded_total
END
)
END
) - invoice.outstanding_amount
) < (
SELECT SUM(
CASE
WHEN invoice.party_account_currency = invoice.currency
THEN ps.payment_amount
ELSE ps.base_payment_amount
END
)
FROM `tabPayment Schedule` ps
WHERE ps.parent = invoice.name
AND ps.due_date < %(today)s
)
)
""".format(
doctype=doctype,
or_condition=(
"invoice.is_pos AND invoice.due_date < %(today)s OR"
if doctype == "Sales Invoice"
else ""
)
), {"today": today}
)
@frappe.whitelist()
def get_payment_terms(terms_template, posting_date=None, grand_total=None, base_grand_total=None, bill_date=None):

View File

@@ -79,8 +79,15 @@ class StockController(AccountsController):
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(" ", "")
# replace commas by linefeed
row.serial_no = row.serial_no.replace(",", "\n")
# strip preceeding and succeeding spaces for each SN
# (SN could have valid spaces in between e.g. SN - 123 - 2021)
serial_no_list = row.serial_no.split("\n")
serial_no_list = [sn.strip() for sn in serial_no_list]
row.serial_no = "\n".join(serial_no_list)
def get_gl_entries(self, warehouse_account=None, default_expense_account=None,
default_cost_center=None):
@@ -591,7 +598,7 @@ def future_sle_exists(args, sl_entries=None):
data = frappe.db.sql("""
select item_code, warehouse, count(name) as total_row
from `tabStock Ledger Entry`
from `tabStock Ledger Entry` force index (item_warehouse)
where
({})
and timestamp(posting_date, posting_time)

View File

@@ -34,6 +34,7 @@ class Opportunity(TransactionBase):
self.validate_item_details()
self.validate_uom_is_integer("uom", "qty")
self.validate_cust_name()
self.map_fields()
if not self.title:
self.title = self.customer_name
@@ -41,6 +42,15 @@ class Opportunity(TransactionBase):
if not self.with_items:
self.items = []
def map_fields(self):
for field in self.meta.fields:
if not self.get(field.fieldname):
try:
value = frappe.db.get_value(self.opportunity_from, self.party_name, field.fieldname)
frappe.db.set(self, field.fieldname, value)
except Exception:
continue
def make_new_lead_if_required(self):
"""Set lead against new opportunity"""
if (not self.get("party_name")) and self.contact_email:

View File

@@ -41,7 +41,6 @@ class TestECommerceSettings(unittest.TestCase):
def test_tax_rule_validation(self):
frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 0")
frappe.db.commit()
cart_settings = self.get_cart_settings()
cart_settings.enabled = 1

View File

@@ -147,7 +147,7 @@ class WebsiteItem(WebsiteGenerator):
def make_thumbnail(self):
"""Make a thumbnail of `website_image`"""
if frappe.flags.in_import:
if frappe.flags.in_import or frappe.flags.in_migrate:
return
import requests.exceptions

View File

@@ -138,7 +138,9 @@ class Student(Document):
enrollment.submit()
return enrollment
def enroll_in_course(self, course_name, program_enrollment, enrollment_date=frappe.utils.datetime.datetime.now()):
def enroll_in_course(self, course_name, program_enrollment, enrollment_date=None):
if enrollment_date is None:
enrollment_date = frappe.utils.datetime.datetime.now()
try:
enrollment = frappe.get_doc({
"doctype": "Course Enrollment",

View File

@@ -62,7 +62,9 @@ class InpatientRecord(Document):
admit_patient(self, service_unit, check_in, expected_discharge)
@frappe.whitelist()
def discharge(self, check_out=now_datetime()):
def discharge(self, check_out=None):
if not check_out:
check_out = now_datetime()
if (getdate(check_out) < getdate(self.admitted_datetime)):
frappe.throw(_('Discharge date cannot be less than Admission date'))
discharge_patient(self, check_out)

View File

@@ -433,11 +433,12 @@ let check_and_set_availability = function(frm) {
slot_html += `<br><span> <b> ${__('Maximum Capacity:')} </b> ${slot_info.service_unit_capacity} </span>`;
}
slot_html += '</div><br><br>';
slot_html += '</div><br>';
slot_html += slot_info.avail_slot.map(slot => {
appointment_count = 0;
disabled = false;
count_class = tool_tip = '';
start_str = slot.from_time;
slot_start_time = moment(slot.from_time, 'HH:mm:ss');
slot_end_time = moment(slot.to_time, 'HH:mm:ss');
@@ -486,10 +487,11 @@ let check_and_set_availability = function(frm) {
data-duration=${interval}
data-service-unit="${slot_info.service_unit || ''}"
style="margin: 0 10px 10px 0; width: auto;" ${disabled ? 'disabled="disabled"' : ""}
data-toggle="tooltip" title="${tool_tip}">
${start_str.substring(0, start_str.length - 3)}<br>
<span class='badge ${count_class}'> ${count} </span>
data-toggle="tooltip" title="${tool_tip || ''}">
${start_str.substring(0, start_str.length - 3)}
${slot_info.service_unit_capacity ? `<br><span class='badge ${count_class}'> ${count} </span>` : ''}
</button>`;
}).join("");
if (slot_info.service_unit_capacity) {

View File

@@ -354,7 +354,7 @@ def get_available_slots(practitioner_doc, date):
validate_practitioner_schedules(schedule_entry, practitioner)
practitioner_schedule = frappe.get_doc('Practitioner Schedule', schedule_entry.schedule)
if practitioner_schedule:
if practitioner_schedule and not practitioner_schedule.disabled:
available_slots = []
for time_slot in practitioner_schedule.time_slots:
if weekday == time_slot.day:

View File

@@ -21,6 +21,7 @@ class TestPatientMedicalRecord(unittest.TestCase):
def setUp(self):
frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 0)
frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1)
frappe.db.sql('delete from `tabPatient Appointment`')
make_pos_profile()
def test_medical_record(self):

View File

@@ -6,7 +6,7 @@ from __future__ import unicode_literals
import unittest
import frappe
from frappe.utils import flt, getdate, nowdate
from frappe.utils import add_days, flt, getdate, nowdate
from erpnext.healthcare.doctype.patient_appointment.test_patient_appointment import (
create_appointment,
@@ -33,10 +33,12 @@ class TestTherapyPlan(unittest.TestCase):
self.assertEqual(plan.status, 'Not Started')
session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab', '_Test Company')
session.start_date = getdate()
frappe.get_doc(session).submit()
self.assertEqual(frappe.db.get_value('Therapy Plan', plan.name, 'status'), 'In Progress')
session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab', '_Test Company')
session.start_date = add_days(getdate(), 1)
frappe.get_doc(session).submit()
self.assertEqual(frappe.db.get_value('Therapy Plan', plan.name, 'status'), 'Completed')
@@ -44,6 +46,7 @@ class TestTherapyPlan(unittest.TestCase):
appointment = create_appointment(patient, practitioner, nowdate())
session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab', '_Test Company', appointment.name)
session.start_date = add_days(getdate(), 2)
session = frappe.get_doc(session)
session.submit()
self.assertEqual(frappe.db.get_value('Patient Appointment', appointment.name, 'status'), 'Closed')

View File

@@ -6,7 +6,7 @@ from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe.utils import flt, today
from frappe.utils import flt
class TherapyPlan(Document):
@@ -63,8 +63,6 @@ def make_therapy_session(therapy_plan, patient, therapy_type, company, appointme
therapy_session.exercises = therapy_type.exercises
therapy_session.appointment = appointment
if frappe.flags.in_test:
therapy_session.start_date = today()
return therapy_session.as_dict()

View File

@@ -611,7 +611,7 @@ def render_docs_as_html(docs):
@frappe.whitelist()
def render_doc_as_html(doctype, docname, exclude_fields = []):
def render_doc_as_html(doctype, docname, exclude_fields = None):
"""
Render document as HTML
"""
@@ -622,6 +622,9 @@ def render_doc_as_html(doctype, docname, exclude_fields = []):
sec_on = has_data = False
col_on = 0
if exclude_fields is None:
exclude_fields = []
for df in meta.fields:
# on section break append previous section and html to doc html
if df.fieldtype == "Section Break":

View File

@@ -338,6 +338,7 @@ scheduler_events = {
"all": [
"erpnext.projects.doctype.project.project.project_status_update_reminder",
"erpnext.healthcare.doctype.patient_appointment.patient_appointment.send_appointment_reminder",
"erpnext.hr.doctype.interview.interview.send_interview_reminder",
"erpnext.crm.doctype.social_media_post.social_media_post.process_scheduled_social_media_posts"
],
"hourly": [
@@ -383,6 +384,7 @@ scheduler_events = {
"erpnext.buying.doctype.supplier_quotation.supplier_quotation.set_expired_status",
"erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts.send_auto_email",
"erpnext.non_profit.doctype.membership.membership.set_expired_status"
"erpnext.hr.doctype.interview.interview.send_daily_feedback_reminder"
],
"daily_long": [
"erpnext.setup.doctype.email_digest.email_digest.send",

View File

@@ -9,83 +9,86 @@ frappe.listview_settings['Attendance'] = {
return [__(doc.status), "orange", "status,=," + doc.status];
}
},
onload: function(list_view) {
let me = this;
const months = moment.months()
list_view.page.add_inner_button( __("Mark Attendance"), function() {
const months = moment.months();
list_view.page.add_inner_button(__("Mark Attendance"), function() {
let dialog = new frappe.ui.Dialog({
title: __("Mark Attendance"),
fields: [
{
fieldname: 'employee',
label: __('For Employee'),
fieldtype: 'Link',
options: 'Employee',
get_query: () => {
return {query: "erpnext.controllers.queries.employee_query"}
},
reqd: 1,
onchange: function() {
dialog.set_df_property("unmarked_days", "hidden", 1);
dialog.set_df_property("status", "hidden", 1);
dialog.set_df_property("month", "value", '');
fields: [{
fieldname: 'employee',
label: __('For Employee'),
fieldtype: 'Link',
options: 'Employee',
get_query: () => {
return {query: "erpnext.controllers.queries.employee_query"};
},
reqd: 1,
onchange: function() {
dialog.set_df_property("unmarked_days", "hidden", 1);
dialog.set_df_property("status", "hidden", 1);
dialog.set_df_property("month", "value", '');
dialog.set_df_property("unmarked_days", "options", []);
dialog.no_unmarked_days_left = false;
}
},
{
label: __("For Month"),
fieldtype: "Select",
fieldname: "month",
options: months,
reqd: 1,
onchange: function() {
if (dialog.fields_dict.employee.value && dialog.fields_dict.month.value) {
dialog.set_df_property("status", "hidden", 0);
dialog.set_df_property("unmarked_days", "options", []);
dialog.no_unmarked_days_left = false;
me.get_multi_select_options(dialog.fields_dict.employee.value, dialog.fields_dict.month.value).then(options => {
if (options.length > 0) {
dialog.set_df_property("unmarked_days", "hidden", 0);
dialog.set_df_property("unmarked_days", "options", options);
} else {
dialog.no_unmarked_days_left = true;
}
});
}
},
{
label: __("For Month"),
fieldtype: "Select",
fieldname: "month",
options: months,
reqd: 1,
onchange: function() {
if(dialog.fields_dict.employee.value && dialog.fields_dict.month.value) {
dialog.set_df_property("status", "hidden", 0);
dialog.set_df_property("unmarked_days", "options", []);
dialog.no_unmarked_days_left = false;
me.get_multi_select_options(dialog.fields_dict.employee.value, dialog.fields_dict.month.value).then(options =>{
if (options.length > 0) {
dialog.set_df_property("unmarked_days", "hidden", 0);
dialog.set_df_property("unmarked_days", "options", options);
} else {
dialog.no_unmarked_days_left = true;
}
});
}
}
},
{
label: __("Status"),
fieldtype: "Select",
fieldname: "status",
options: ["Present", "Absent", "Half Day", "Work From Home"],
hidden:1,
reqd: 1,
}
},
{
label: __("Status"),
fieldtype: "Select",
fieldname: "status",
options: ["Present", "Absent", "Half Day", "Work From Home"],
hidden: 1,
reqd: 1,
},
{
label: __("Unmarked Attendance for days"),
fieldname: "unmarked_days",
fieldtype: "MultiCheck",
options: [],
columns: 2,
hidden: 1
},
],
primary_action(data) {
},
{
label: __("Unmarked Attendance for days"),
fieldname: "unmarked_days",
fieldtype: "MultiCheck",
options: [],
columns: 2,
hidden: 1
}],
primary_action(data) {
if (cur_dialog.no_unmarked_days_left) {
frappe.msgprint(__("Attendance for the month of {0} , has already been marked for the Employee {1}",[dialog.fields_dict.month.value, dialog.fields_dict.employee.value]));
frappe.msgprint(__("Attendance for the month of {0} , has already been marked for the Employee {1}",
[dialog.fields_dict.month.value, dialog.fields_dict.employee.value]));
} else {
frappe.confirm(__('Mark attendance as {0} for {1} on selected dates?', [data.status,data.month]), () => {
frappe.confirm(__('Mark attendance as {0} for {1} on selected dates?', [data.status, data.month]), () => {
frappe.call({
method: "erpnext.hr.doctype.attendance.attendance.mark_bulk_attendance",
args: {
data: data
},
callback: function(r) {
callback: function (r) {
if (r.message === 1) {
frappe.show_alert({message: __("Attendance Marked"), indicator: 'blue'});
frappe.show_alert({
message: __("Attendance Marked"),
indicator: 'blue'
});
cur_dialog.hide();
}
}
@@ -101,21 +104,26 @@ frappe.listview_settings['Attendance'] = {
dialog.show();
});
},
get_multi_select_options: function(employee, month){
get_multi_select_options: function(employee, month) {
return new Promise(resolve => {
frappe.call({
method: 'erpnext.hr.doctype.attendance.attendance.get_unmarked_days',
async: false,
args:{
args: {
employee: employee,
month: month,
}
}).then(r => {
var options = [];
for(var d in r.message){
for (var d in r.message) {
var momentObj = moment(r.message[d], 'YYYY-MM-DD');
var date = momentObj.format('DD-MM-YYYY');
options.push({ "label":date, "value": r.message[d] , "checked": 1});
options.push({
"label": date,
"value": r.message[d],
"checked": 1
});
}
resolve(options);
});

View File

@@ -74,7 +74,6 @@ class TestDailyWorkSummary(unittest.TestCase):
from `tabEmail Queue` as q, `tabEmail Queue Recipient` as r \
where q.name = r.parent""", as_dict=1)
frappe.db.commit()
def setup_groups(self, hour=None):
# setup email to trigger at this hour

View File

@@ -4,40 +4,46 @@
frappe.provide("erpnext.hr");
erpnext.hr.EmployeeController = frappe.ui.form.Controller.extend({
setup: function() {
this.frm.fields_dict.user_id.get_query = function(doc, cdt, cdn) {
this.frm.fields_dict.user_id.get_query = function() {
return {
query: "frappe.core.doctype.user.user.user_query",
filters: {ignore_user_type: 1}
}
}
this.frm.fields_dict.reports_to.get_query = function(doc, cdt, cdn) {
return { query: "erpnext.controllers.queries.employee_query"} }
filters: {
ignore_user_type: 1
}
};
};
this.frm.fields_dict.reports_to.get_query = function() {
return {
query: "erpnext.controllers.queries.employee_query"
};
};
},
refresh: function() {
var me = this;
erpnext.toggle_naming_series();
},
date_of_birth: function() {
return cur_frm.call({
method: "get_retirement_date",
args: {date_of_birth: this.frm.doc.date_of_birth}
args: {
date_of_birth: this.frm.doc.date_of_birth
}
});
},
salutation: function() {
if(this.frm.doc.salutation) {
if (this.frm.doc.salutation) {
this.frm.set_value("gender", {
"Mr": "Male",
"Ms": "Female"
}[this.frm.doc.salutation]);
} [this.frm.doc.salutation]);
}
},
});
frappe.ui.form.on('Employee',{
setup: function(frm) {
frappe.ui.form.on('Employee', {
setup: function (frm) {
frm.set_query("leave_policy", function() {
return {
"filters": {
@@ -46,7 +52,7 @@ frappe.ui.form.on('Employee',{
};
});
},
onload:function(frm) {
onload: function (frm) {
frm.set_query("department", function() {
return {
"filters": {
@@ -55,23 +61,28 @@ frappe.ui.form.on('Employee',{
};
});
},
prefered_contact_email:function(frm){
frm.events.update_contact(frm)
prefered_contact_email: function(frm) {
frm.events.update_contact(frm);
},
personal_email:function(frm){
frm.events.update_contact(frm)
personal_email: function(frm) {
frm.events.update_contact(frm);
},
company_email:function(frm){
frm.events.update_contact(frm)
company_email: function(frm) {
frm.events.update_contact(frm);
},
user_id:function(frm){
frm.events.update_contact(frm)
user_id: function(frm) {
frm.events.update_contact(frm);
},
update_contact:function(frm){
update_contact: function(frm) {
var prefered_email_fieldname = frappe.model.scrub(frm.doc.prefered_contact_email) || 'user_id';
frm.set_value("prefered_email",
frm.fields_dict[prefered_email_fieldname].value)
frm.fields_dict[prefered_email_fieldname].value);
},
status: function(frm) {
return frm.call({
method: "deactivate_sales_person",
@@ -81,19 +92,63 @@ frappe.ui.form.on('Employee',{
}
});
},
create_user: function(frm) {
if (!frm.doc.prefered_email)
{
frappe.throw(__("Please enter Preferred Contact Email"))
if (!frm.doc.prefered_email) {
frappe.throw(__("Please enter Preferred Contact Email"));
}
frappe.call({
method: "erpnext.hr.doctype.employee.employee.create_user",
args: { employee: frm.doc.name, email: frm.doc.prefered_email },
callback: function(r)
{
frm.set_value("user_id", r.message)
args: {
employee: frm.doc.name,
email: frm.doc.prefered_email
},
callback: function (r) {
frm.set_value("user_id", r.message);
}
});
}
});
cur_frm.cscript = new erpnext.hr.EmployeeController({frm: cur_frm});
cur_frm.cscript = new erpnext.hr.EmployeeController({
frm: cur_frm
});
frappe.tour['Employee'] = [
{
fieldname: "first_name",
title: "First Name",
description: __("Enter First and Last name of Employee, based on Which Full Name will be updated. IN transactions, it will be Full Name which will be fetched.")
},
{
fieldname: "company",
title: "Company",
description: __("Select a Company this Employee belongs to. Other HR features like Payroll. Expense Claims and Leaves for this Employee will be created for a given company only.")
},
{
fieldname: "date_of_birth",
title: "Date of Birth",
description: __("Select Date of Birth. This will validate Employees age and prevent hiring of under-age staff.")
},
{
fieldname: "date_of_joining",
title: "Date of Joining",
description: __("Select Date of joining. It will have impact on the first salary calculation, Leave allocation on pro-rata bases.")
},
{
fieldname: "holiday_list",
title: "Holiday List",
description: __("Select a default Holiday List for this Employee. The days listed in Holiday List will not be counted in Leave Application.")
},
{
fieldname: "reports_to",
title: "Reports To",
description: __("Here, you can select a senior of this Employee. Based on this, Organization Chart will be populated.")
},
{
fieldname: "leave_approver",
title: "Leave Approver",
description: __("Select Leave Approver for an employee. The user one who will look after his/her Leave application")
},
];

View File

@@ -72,6 +72,7 @@ def get_job_applicant():
applicant = frappe.new_doc('Job Applicant')
applicant.applicant_name = 'Test Researcher'
applicant.email_id = 'test@researcher.com'
applicant.designation = 'Researcher'
applicant.status = 'Open'
applicant.cover_letter = 'I am a great Researcher.'
applicant.insert()

View File

@@ -38,8 +38,10 @@ def create_job_applicant(source_name, target_doc=None):
status = "Open"
job_applicant = frappe.new_doc("Job Applicant")
job_applicant.source = "Employee Referral"
job_applicant.employee_referral = emp_ref.name
job_applicant.status = status
job_applicant.designation = emp_ref.for_designation
job_applicant.applicant_name = emp_ref.full_name
job_applicant.email_id = emp_ref.email
job_applicant.phone_number = emp_ref.contact_no

View File

@@ -17,6 +17,11 @@ from erpnext.hr.doctype.employee_referral.employee_referral import (
class TestEmployeeReferral(unittest.TestCase):
def setUp(self):
frappe.db.sql("DELETE FROM `tabJob Applicant`")
frappe.db.sql("DELETE FROM `tabEmployee Referral`")
def test_workflow_and_status_sync(self):
emp_ref = create_employee_referral()
@@ -50,6 +55,10 @@ class TestEmployeeReferral(unittest.TestCase):
add_sal = create_additional_salary(emp_ref)
self.assertTrue(add_sal.ref_docname, emp_ref.name)
def tearDown(self):
frappe.db.sql("DELETE FROM `tabJob Applicant`")
frappe.db.sql("DELETE FROM `tabEmployee Referral`")
def create_employee_referral():
emp_ref = frappe.new_doc("Employee Referral")

View File

@@ -0,0 +1,40 @@
{
"actions": [],
"creation": "2021-04-12 13:05:06.741330",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"skill",
"description"
],
"fields": [
{
"fieldname": "skill",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Skill",
"options": "Skill",
"reqd": 1
},
{
"fetch_from": "skill.description",
"fieldname": "description",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Description"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-04-12 14:26:33.062549",
"modified_by": "Administrator",
"module": "HR",
"name": "Expected Skill Set",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

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

View File

@@ -10,6 +10,26 @@ frappe.ui.form.on('Expense Claim', {
},
company: function(frm) {
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
var expenses = frm.doc.expenses;
for (var i = 0; i < expenses.length; i++) {
var expense = expenses[i];
if (!expense.expense_type) {
continue;
}
frappe.call({
method: "erpnext.hr.doctype.expense_claim.expense_claim.get_expense_claim_account_and_cost_center",
args: {
"expense_claim_type": expense.expense_type,
"company": frm.doc.company
},
callback: function(r) {
if (r.message) {
expense.default_account = r.message.account;
expense.cost_center = r.message.cost_center;
}
}
});
}
},
});

View File

@@ -176,7 +176,7 @@ def generate_taxes():
account = create_account(company=company_name, account_name="Output Tax CGST", account_type="Tax", parent_account=parent_account)
return {'taxes':[{
"account_head": account,
"rate": 0,
"rate": 9,
"description": "CGST",
"tax_amount": 10,
"total": 210

View File

@@ -56,8 +56,6 @@
},
{
"columns": 2,
"fetch_from": "account_head.tax_rate",
"fetch_if_empty": 1,
"fieldname": "rate",
"fieldtype": "Float",
"in_list_view": 1,
@@ -111,4 +109,4 @@
"sort_field": "modified",
"sort_order": "ASC",
"track_changes": 1
}
}

View File

@@ -1,10 +1,10 @@
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on('Holiday List', {
frappe.ui.form.on("Holiday List", {
refresh: function(frm) {
if (frm.doc.holidays) {
frm.set_value('total_holidays', frm.doc.holidays.length);
frm.set_value("total_holidays", frm.doc.holidays.length);
}
},
from_date: function(frm) {
@@ -14,3 +14,36 @@ frappe.ui.form.on('Holiday List', {
}
}
});
frappe.tour["Holiday List"] = [
{
fieldname: "holiday_list_name",
title: "Holiday List Name",
description: __("Enter a name for this Holiday List."),
},
{
fieldname: "from_date",
title: "From Date",
description: __("Based on your HR Policy, select your leave allocation period's start date"),
},
{
fieldname: "to_date",
title: "To Date",
description: __("Based on your HR Policy, select your leave allocation period's end date"),
},
{
fieldname: "weekly_off",
title: "Weekly Off",
description: __("Select your weekly off day"),
},
{
fieldname: "get_weekly_off_dates",
title: "Add Holidays",
description: __("Click on Add to Holidays. This will populate the holidays table with all the dates that fall on the selected weekly off. Repeat the process for populating the dates for all your weekly holidays"),
},
{
fieldname: "holidays",
title: "Holidays",
description: __("Here, your weekly offs are pre-populated based on the previous selections. You can add more rows to also add public and national holidays individually.")
},
];

View File

@@ -1,4 +1,3 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
@@ -94,9 +93,11 @@ def get_events(start, end, filters=None):
update={"allDay": 1})
def is_holiday(holiday_list, date=today()):
def is_holiday(holiday_list, date=None):
"""Returns true if the given date is a holiday in the given holiday list
"""
if date is None:
date = today()
if holiday_list:
return bool(frappe.get_all('Holiday List',
dict(name=holiday_list, holiday_date=date)))

View File

@@ -2,7 +2,22 @@
// For license information, please see license.txt
frappe.ui.form.on('HR Settings', {
restrict_backdated_leave_application: function(frm) {
frm.toggle_reqd("role_allowed_to_create_backdated_leave_application", frm.doc.restrict_backdated_leave_application);
}
});
frappe.tour['HR Settings'] = [
{
fieldname: 'emp_created_by',
title: 'Employee Naming By',
description: __('Employee can be named by Employee ID if you assign one, or via Naming Series. Select your preference here.'),
},
{
fieldname: 'standard_working_hours',
title: 'Standard Working Hours',
description: __('Enter the Standard Working Hours for a normal work day. These hours will be used in calculations of reports such as Employee Hours Utilization and Project Profitability analysis.'),
},
{
fieldname: 'leave_and_expense_claim_settings',
title: 'Leave and Expense Clain Settings',
description: __('Review various other settings related to Employee Leaves and Expense Claim')
}
];

View File

@@ -7,30 +7,36 @@
"engine": "InnoDB",
"field_order": [
"employee_settings",
"retirement_age",
"emp_created_by",
"column_break_4",
"standard_working_hours",
"expense_approver_mandatory_in_expense_claim",
"column_break_9",
"retirement_age",
"reminders_section",
"send_birthday_reminders",
"column_break_9",
"send_work_anniversary_reminders",
"column_break_11",
"send_work_anniversary_reminders",
"column_break_18",
"send_holiday_reminders",
"frequency",
"leave_settings",
"leave_and_expense_claim_settings",
"send_leave_notification",
"leave_approval_notification_template",
"leave_status_notification_template",
"role_allowed_to_create_backdated_leave_application",
"column_break_18",
"leave_approver_mandatory_in_leave_application",
"restrict_backdated_leave_application",
"role_allowed_to_create_backdated_leave_application",
"column_break_29",
"expense_approver_mandatory_in_expense_claim",
"show_leaves_of_all_department_members_in_calendar",
"auto_leave_encashment",
"restrict_backdated_leave_application",
"hiring_settings",
"check_vacancies"
"hiring_settings_section",
"check_vacancies",
"send_interview_reminder",
"interview_reminder_template",
"remind_before",
"column_break_4",
"send_interview_feedback_reminder",
"feedback_reminder_notification_template"
],
"fields": [
{
@@ -39,17 +45,16 @@
"label": "Employee Settings"
},
{
"description": "Enter retirement age in years",
"fieldname": "retirement_age",
"fieldtype": "Data",
"label": "Retirement Age"
"label": "Retirement Age (In Years)"
},
{
"default": "Naming Series",
"description": "Employee records are created using the selected field",
"description": "Employee records are created using the selected option",
"fieldname": "emp_created_by",
"fieldtype": "Select",
"label": "Employee Records to be created by",
"label": "Employee Naming By",
"options": "Naming Series\nEmployee Number\nFull Name"
},
{
@@ -62,28 +67,6 @@
"fieldtype": "Check",
"label": "Expense Approver Mandatory In Expense Claim"
},
{
"collapsible": 1,
"fieldname": "leave_settings",
"fieldtype": "Section Break",
"label": "Leave Settings"
},
{
"depends_on": "eval: doc.send_leave_notification == 1",
"fieldname": "leave_approval_notification_template",
"fieldtype": "Link",
"label": "Leave Approval Notification Template",
"mandatory_depends_on": "eval: doc.send_leave_notification == 1",
"options": "Email Template"
},
{
"depends_on": "eval: doc.send_leave_notification == 1",
"fieldname": "leave_status_notification_template",
"fieldtype": "Link",
"label": "Leave Status Notification Template",
"mandatory_depends_on": "eval: doc.send_leave_notification == 1",
"options": "Email Template"
},
{
"fieldname": "column_break_18",
"fieldtype": "Column Break"
@@ -100,35 +83,18 @@
"fieldtype": "Check",
"label": "Show Leaves Of All Department Members In Calendar"
},
{
"collapsible": 1,
"fieldname": "hiring_settings",
"fieldtype": "Section Break",
"label": "Hiring Settings"
},
{
"default": "0",
"fieldname": "check_vacancies",
"fieldtype": "Check",
"label": "Check Vacancies On Job Offer Creation"
},
{
"default": "0",
"fieldname": "auto_leave_encashment",
"fieldtype": "Check",
"label": "Auto Leave Encashment"
},
{
"default": "0",
"fieldname": "restrict_backdated_leave_application",
"fieldtype": "Check",
"label": "Restrict Backdated Leave Application"
},
{
"depends_on": "eval:doc.restrict_backdated_leave_application == 1",
"fieldname": "role_allowed_to_create_backdated_leave_application",
"fieldtype": "Link",
"label": "Role Allowed to Create Backdated Leave Application",
"mandatory_depends_on": "eval:doc.restrict_backdated_leave_application == 1",
"options": "Role"
},
{
@@ -137,11 +103,40 @@
"fieldtype": "Check",
"label": "Send Leave Notification"
},
{
"depends_on": "eval: doc.send_leave_notification == 1",
"fieldname": "leave_approval_notification_template",
"fieldtype": "Link",
"label": "Leave Approval Notification Template",
"mandatory_depends_on": "eval: doc.send_leave_notification == 1",
"options": "Email Template"
},
{
"depends_on": "eval: doc.send_leave_notification == 1",
"fieldname": "leave_status_notification_template",
"fieldtype": "Link",
"label": "Leave Status Notification Template",
"mandatory_depends_on": "eval: doc.send_leave_notification == 1",
"options": "Email Template"
},
{
"fieldname": "standard_working_hours",
"fieldtype": "Int",
"label": "Standard Working Hours"
},
{
"collapsible": 1,
"fieldname": "leave_and_expense_claim_settings",
"fieldtype": "Section Break",
"label": "Leave and Expense Claim Settings"
},
{
"default": "00:15:00",
"depends_on": "send_interview_reminder",
"fieldname": "remind_before",
"fieldtype": "Time",
"label": "Remind Before"
},
{
"collapsible": 1,
"fieldname": "reminders_section",
@@ -166,6 +161,7 @@
"fieldname": "frequency",
"fieldtype": "Select",
"label": "Set the frequency for holiday reminders",
"mandatory_depends_on": "send_holiday_reminders",
"options": "Weekly\nMonthly"
},
{
@@ -181,13 +177,62 @@
{
"fieldname": "column_break_11",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "send_interview_reminder",
"fieldtype": "Check",
"label": "Send Interview Reminder"
},
{
"default": "0",
"fieldname": "send_interview_feedback_reminder",
"fieldtype": "Check",
"label": "Send Interview Feedback Reminder"
},
{
"fieldname": "column_break_29",
"fieldtype": "Column Break"
},
{
"depends_on": "send_interview_feedback_reminder",
"fieldname": "feedback_reminder_notification_template",
"fieldtype": "Link",
"label": "Feedback Reminder Notification Template",
"mandatory_depends_on": "send_interview_feedback_reminder",
"options": "Email Template"
},
{
"depends_on": "send_interview_reminder",
"fieldname": "interview_reminder_template",
"fieldtype": "Link",
"label": "Interview Reminder Notification Template",
"mandatory_depends_on": "send_interview_reminder",
"options": "Email Template"
},
{
"default": "0",
"fieldname": "restrict_backdated_leave_application",
"fieldtype": "Check",
"label": "Restrict Backdated Leave Application"
},
{
"fieldname": "hiring_settings_section",
"fieldtype": "Section Break",
"label": "Hiring Settings"
},
{
"default": "0",
"fieldname": "check_vacancies",
"fieldtype": "Check",
"label": "Check Vacancies On Job Offer Creation"
}
],
"icon": "fa fa-cog",
"idx": 1,
"issingle": 1,
"links": [],
"modified": "2021-08-24 14:54:12.834162",
"modified": "2021-10-01 23:46:11.098236",
"modified_by": "Administrator",
"module": "HR",
"name": "HR Settings",

View File

View File

@@ -0,0 +1,237 @@
// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on('Interview', {
onload: function (frm) {
frm.events.set_job_applicant_query(frm);
frm.set_query('interviewer', 'interview_details', function () {
return {
query: 'erpnext.hr.doctype.interview.interview.get_interviewer_list'
};
});
},
refresh: function (frm) {
if (frm.doc.docstatus != 2 && !frm.doc.__islocal) {
if (frm.doc.status === 'Pending') {
frm.add_custom_button(__('Reschedule Interview'), function() {
frm.events.show_reschedule_dialog(frm);
frm.refresh();
});
}
let allowed_interviewers = [];
frm.doc.interview_details.forEach(values => {
allowed_interviewers.push(values.interviewer);
});
if ((allowed_interviewers.includes(frappe.session.user))) {
frappe.db.get_value('Interview Feedback', {'interviewer': frappe.session.user, 'interview': frm.doc.name, 'docstatus': 1}, 'name', (r) => {
if (Object.keys(r).length === 0) {
frm.add_custom_button(__('Submit Feedback'), function () {
frappe.call({
method: 'erpnext.hr.doctype.interview.interview.get_expected_skill_set',
args: {
interview_round: frm.doc.interview_round
},
callback: function (r) {
frm.events.show_feedback_dialog(frm, r.message);
frm.refresh();
}
});
}).addClass('btn-primary');
}
});
}
}
},
show_reschedule_dialog: function (frm) {
let d = new frappe.ui.Dialog({
title: 'Reschedule Interview',
fields: [
{
label: 'Schedule On',
fieldname: 'scheduled_on',
fieldtype: 'Date',
reqd: 1
},
{
label: 'From Time',
fieldname: 'from_time',
fieldtype: 'Time',
reqd: 1
},
{
label: 'To Time',
fieldname: 'to_time',
fieldtype: 'Time',
reqd: 1
}
],
primary_action_label: 'Reschedule',
primary_action(values) {
frm.call({
method: 'reschedule_interview',
doc: frm.doc,
args: {
scheduled_on: values.scheduled_on,
from_time: values.from_time,
to_time: values.to_time
}
}).then(() => {
frm.refresh();
d.hide();
});
}
});
d.show();
},
show_feedback_dialog: function (frm, data) {
let fields = frm.events.get_fields_for_feedback();
let d = new frappe.ui.Dialog({
title: __('Submit Feedback'),
fields: [
{
fieldname: 'skill_set',
fieldtype: 'Table',
label: __('Skill Assessment'),
cannot_add_rows: false,
in_editable_grid: true,
reqd: 1,
fields: fields,
data: data
},
{
fieldname: 'result',
fieldtype: 'Select',
options: ['', 'Cleared', 'Rejected'],
label: __('Result')
},
{
fieldname: 'feedback',
fieldtype: 'Small Text',
label: __('Feedback')
}
],
size: 'large',
minimizable: true,
primary_action: function(values) {
frappe.call({
method: 'erpnext.hr.doctype.interview.interview.create_interview_feedback',
args: {
data: values,
interview_name: frm.doc.name,
interviewer: frappe.session.user,
job_applicant: frm.doc.job_applicant
}
}).then(() => {
frm.refresh();
});
d.hide();
}
});
d.show();
},
get_fields_for_feedback: function () {
return [{
fieldtype: 'Link',
fieldname: 'skill',
options: 'Skill',
in_list_view: 1,
label: __('Skill')
}, {
fieldtype: 'Rating',
fieldname: 'rating',
label: __('Rating'),
in_list_view: 1,
reqd: 1,
}];
},
set_job_applicant_query: function (frm) {
frm.set_query('job_applicant', function () {
let job_applicant_filters = {
status: ['!=', 'Rejected']
};
if (frm.doc.designation) {
job_applicant_filters.designation = frm.doc.designation;
}
return {
filters: job_applicant_filters
};
});
},
interview_round: async function (frm) {
frm.events.reset_values(frm);
frm.set_value('job_applicant', '');
let round_data = (await frappe.db.get_value('Interview Round', frm.doc.interview_round, 'designation')).message;
frm.set_value('designation', round_data.designation);
frm.events.set_job_applicant_query(frm);
if (frm.doc.interview_round) {
frm.events.set_interview_details(frm);
} else {
frm.set_value('interview_details', []);
}
},
set_interview_details: function (frm) {
frappe.call({
method: 'erpnext.hr.doctype.interview.interview.get_interviewers',
args: {
interview_round: frm.doc.interview_round
},
callback: function (data) {
let interview_details = data.message;
frm.set_value('interview_details', []);
if (data.message.length) {
frm.set_value('interview_details', interview_details);
}
}
});
},
job_applicant: function (frm) {
if (!frm.doc.interview_round) {
frm.doc.job_applicant = '';
frm.refresh();
frappe.throw(__('Select Interview Round First'));
}
if (frm.doc.job_applicant) {
frm.events.set_designation_and_job_opening(frm);
} else {
frm.events.reset_values(frm);
}
},
set_designation_and_job_opening: async function (frm) {
let round_data = (await frappe.db.get_value('Interview Round', frm.doc.interview_round, 'designation')).message;
frm.set_value('designation', round_data.designation);
frm.events.set_job_applicant_query(frm);
let job_applicant_data = (await frappe.db.get_value(
'Job Applicant', frm.doc.job_applicant, ['designation', 'job_title', 'resume_link'],
)).message;
if (!round_data.designation) {
frm.set_value('designation', job_applicant_data.designation);
}
frm.set_value('job_opening', job_applicant_data.job_title);
frm.set_value('resume_link', job_applicant_data.resume_link);
},
reset_values: function (frm) {
frm.set_value('designation', '');
frm.set_value('job_opening', '');
frm.set_value('resume_link', '');
}
});

View File

@@ -0,0 +1,254 @@
{
"actions": [],
"autoname": "HR-INT-.YYYY.-.####",
"creation": "2021-04-12 15:03:11.524090",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"interview_details_section",
"interview_round",
"job_applicant",
"job_opening",
"designation",
"resume_link",
"column_break_4",
"status",
"scheduled_on",
"from_time",
"to_time",
"interview_feedback_section",
"interview_details",
"ratings_section",
"expected_average_rating",
"column_break_12",
"average_rating",
"section_break_13",
"interview_summary",
"reminded",
"amended_from"
],
"fields": [
{
"fieldname": "job_applicant",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Job Applicant",
"options": "Job Applicant",
"reqd": 1
},
{
"fieldname": "job_opening",
"fieldtype": "Link",
"label": "Job Opening",
"options": "Job Opening",
"read_only": 1
},
{
"fieldname": "interview_round",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Interview Round",
"options": "Interview Round",
"reqd": 1
},
{
"default": "Pending",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Status",
"options": "Pending\nUnder Review\nCleared\nRejected",
"reqd": 1
},
{
"fieldname": "ratings_section",
"fieldtype": "Section Break",
"label": "Ratings"
},
{
"allow_on_submit": 1,
"fieldname": "average_rating",
"fieldtype": "Rating",
"in_list_view": 1,
"label": "Obtained Average Rating",
"read_only": 1
},
{
"allow_on_submit": 1,
"fieldname": "interview_summary",
"fieldtype": "Text"
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "resume_link",
"fieldtype": "Data",
"label": "Resume link"
},
{
"fieldname": "interview_details_section",
"fieldtype": "Section Break",
"label": "Details"
},
{
"fetch_from": "interview_round.expected_average_rating",
"fieldname": "expected_average_rating",
"fieldtype": "Rating",
"label": "Expected Average Rating",
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "section_break_13",
"fieldtype": "Section Break",
"label": "Interview Summary"
},
{
"fieldname": "column_break_12",
"fieldtype": "Column Break"
},
{
"fetch_from": "interview_round.designation",
"fieldname": "designation",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Designation",
"options": "Designation",
"read_only": 1
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Interview",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "scheduled_on",
"fieldtype": "Date",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Scheduled On",
"reqd": 1,
"set_only_once": 1
},
{
"default": "0",
"fieldname": "reminded",
"fieldtype": "Check",
"hidden": 1,
"label": "Reminded"
},
{
"allow_on_submit": 1,
"fieldname": "interview_details",
"fieldtype": "Table",
"options": "Interview Detail"
},
{
"fieldname": "interview_feedback_section",
"fieldtype": "Section Break",
"label": "Feedback"
},
{
"fieldname": "from_time",
"fieldtype": "Time",
"in_list_view": 1,
"label": "From Time",
"reqd": 1,
"set_only_once": 1
},
{
"fieldname": "to_time",
"fieldtype": "Time",
"in_list_view": 1,
"label": "To Time",
"reqd": 1,
"set_only_once": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [
{
"link_doctype": "Interview Feedback",
"link_fieldname": "interview"
}
],
"modified": "2021-09-30 13:30:05.421035",
"modified_by": "Administrator",
"module": "HR",
"name": "Interview",
"owner": "Administrator",
"permissions": [
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"submit": 1,
"write": 1
},
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "HR Manager",
"share": 1,
"submit": 1,
"write": 1
},
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Interviewer",
"share": 1,
"submit": 1,
"write": 1
},
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "HR User",
"share": 1,
"submit": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"title_field": "job_applicant",
"track_changes": 1
}

View File

@@ -0,0 +1,293 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
import datetime
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cstr, get_datetime, get_link_to_form
class DuplicateInterviewRoundError(frappe.ValidationError):
pass
class Interview(Document):
def validate(self):
self.validate_duplicate_interview()
self.validate_designation()
self.validate_overlap()
def on_submit(self):
if self.status not in ['Cleared', 'Rejected']:
frappe.throw(_('Only Interviews with Cleared or Rejected status can be submitted.'), title=_('Not Allowed'))
def validate_duplicate_interview(self):
duplicate_interview = frappe.db.exists('Interview', {
'job_applicant': self.job_applicant,
'interview_round': self.interview_round,
'docstatus': 1
}
)
if duplicate_interview:
frappe.throw(_('Job Applicants are not allowed to appear twice for the same Interview round. Interview {0} already scheduled for Job Applicant {1}').format(
frappe.bold(get_link_to_form('Interview', duplicate_interview)),
frappe.bold(self.job_applicant)
))
def validate_designation(self):
applicant_designation = frappe.db.get_value('Job Applicant', self.job_applicant, 'designation')
if self.designation :
if self.designation != applicant_designation:
frappe.throw(_('Interview Round {0} is only for Designation {1}. Job Applicant has applied for the role {2}').format(
self.interview_round, frappe.bold(self.designation), applicant_designation),
exc=DuplicateInterviewRoundError)
else:
self.designation = applicant_designation
def validate_overlap(self):
interviewers = [entry.interviewer for entry in self.interview_details] or ['']
overlaps = frappe.db.sql("""
SELECT interview.name
FROM `tabInterview` as interview
INNER JOIN `tabInterview Detail` as detail
WHERE
interview.scheduled_on = %s and interview.name != %s and interview.docstatus != 2
and (interview.job_applicant = %s or detail.interviewer IN %s) and
((from_time < %s and to_time > %s) or
(from_time > %s and to_time < %s) or
(from_time = %s))
""", (self.scheduled_on, self.name, self.job_applicant, interviewers,
self.from_time, self.to_time, self.from_time, self.to_time, self.from_time))
if overlaps:
overlapping_details = _('Interview overlaps with {0}').format(get_link_to_form('Interview', overlaps[0][0]))
frappe.throw(overlapping_details, title=_('Overlap'))
@frappe.whitelist()
def reschedule_interview(self, scheduled_on, from_time, to_time):
original_date = self.scheduled_on
from_time = self.from_time
to_time = self.to_time
self.db_set({
'scheduled_on': scheduled_on,
'from_time': from_time,
'to_time': to_time
})
self.notify_update()
recipients = get_recipients(self.name)
try:
frappe.sendmail(
recipients= recipients,
subject=_('Interview: {0} Rescheduled').format(self.name),
message=_('Your Interview session is rescheduled from {0} {1} - {2} to {3} {4} - {5}').format(
original_date, from_time, to_time, self.scheduled_on, self.from_time, self.to_time),
reference_doctype=self.doctype,
reference_name=self.name
)
except Exception:
frappe.msgprint(_('Failed to send the Interview Reschedule notification. Please configure your email account.'))
frappe.msgprint(_('Interview Rescheduled successfully'), indicator='green')
def get_recipients(name, for_feedback=0):
interview = frappe.get_doc('Interview', name)
if for_feedback:
recipients = [d.interviewer for d in interview.interview_details if not d.interview_feedback]
else:
recipients = [d.interviewer for d in interview.interview_details]
recipients.append(frappe.db.get_value('Job Applicant', interview.job_applicant, 'email_id'))
return recipients
@frappe.whitelist()
def get_interviewers(interview_round):
return frappe.get_all('Interviewer', filters={'parent': interview_round}, fields=['user as interviewer'])
def send_interview_reminder():
reminder_settings = frappe.db.get_value('HR Settings', 'HR Settings',
['send_interview_reminder', 'interview_reminder_template'], as_dict=True)
if not reminder_settings.send_interview_reminder:
return
remind_before = cstr(frappe.db.get_single_value('HR Settings', 'remind_before')) or '01:00:00'
remind_before = datetime.datetime.strptime(remind_before, '%H:%M:%S')
reminder_date_time = datetime.datetime.now() + datetime.timedelta(
hours=remind_before.hour, minutes=remind_before.minute, seconds=remind_before.second)
interviews = frappe.get_all('Interview', filters={
'scheduled_on': ['between', (datetime.datetime.now(), reminder_date_time)],
'status': 'Pending',
'reminded': 0,
'docstatus': ['!=', 2]
})
interview_template = frappe.get_doc('Email Template', reminder_settings.interview_reminder_template)
for d in interviews:
doc = frappe.get_doc('Interview', d.name)
context = doc.as_dict()
message = frappe.render_template(interview_template.response, context)
recipients = get_recipients(doc.name)
frappe.sendmail(
recipients= recipients,
subject=interview_template.subject,
message=message,
reference_doctype=doc.doctype,
reference_name=doc.name
)
doc.db_set('reminded', 1)
def send_daily_feedback_reminder():
reminder_settings = frappe.db.get_value('HR Settings', 'HR Settings',
['send_interview_feedback_reminder', 'feedback_reminder_notification_template'], as_dict=True)
if not reminder_settings.send_interview_feedback_reminder:
return
interview_feedback_template = frappe.get_doc('Email Template', reminder_settings.feedback_reminder_notification_template)
interviews = frappe.get_all('Interview', filters={'status': ['in', ['Under Review', 'Pending']], 'docstatus': ['!=', 2]})
for entry in interviews:
recipients = get_recipients(entry.name, for_feedback=1)
doc = frappe.get_doc('Interview', entry.name)
context = doc.as_dict()
message = frappe.render_template(interview_feedback_template.response, context)
if len(recipients):
frappe.sendmail(
recipients= recipients,
subject=interview_feedback_template.subject,
message=message,
reference_doctype='Interview',
reference_name=entry.name
)
@frappe.whitelist()
def get_expected_skill_set(interview_round):
return frappe.get_all('Expected Skill Set', filters ={'parent': interview_round}, fields=['skill'])
@frappe.whitelist()
def create_interview_feedback(data, interview_name, interviewer, job_applicant):
import json
from six import string_types
if isinstance(data, string_types):
data = frappe._dict(json.loads(data))
if frappe.session.user != interviewer:
frappe.throw(_('Only Interviewer Are allowed to submit Interview Feedback'))
interview_feedback = frappe.new_doc('Interview Feedback')
interview_feedback.interview = interview_name
interview_feedback.interviewer = interviewer
interview_feedback.job_applicant = job_applicant
for d in data.skill_set:
d = frappe._dict(d)
interview_feedback.append('skill_assessment', {'skill': d.skill, 'rating': d.rating})
interview_feedback.feedback = data.feedback
interview_feedback.result = data.result
interview_feedback.save()
interview_feedback.submit()
frappe.msgprint(_('Interview Feedback {0} submitted successfully').format(
get_link_to_form('Interview Feedback', interview_feedback.name)))
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_interviewer_list(doctype, txt, searchfield, start, page_len, filters):
filters = [
['Has Role', 'parent', 'like', '%{}%'.format(txt)],
['Has Role', 'role', '=', 'interviewer'],
['Has Role', 'parenttype', '=', 'User']
]
if filters and isinstance(filters, list):
filters.extend(filters)
return frappe.get_all('Has Role', limit_start=start, limit_page_length=page_len,
filters=filters, fields = ['parent'], as_list=1)
@frappe.whitelist()
def get_events(start, end, filters=None):
"""Returns events for Gantt / Calendar view rendering.
:param start: Start date-time.
:param end: End date-time.
:param filters: Filters (JSON).
"""
from frappe.desk.calendar import get_event_conditions
events = []
event_color = {
"Pending": "#fff4f0",
"Under Review": "#d3e8fc",
"Cleared": "#eaf5ed",
"Rejected": "#fce7e7"
}
conditions = get_event_conditions('Interview', filters)
interviews = frappe.db.sql("""
SELECT DISTINCT
`tabInterview`.name, `tabInterview`.job_applicant, `tabInterview`.interview_round,
`tabInterview`.scheduled_on, `tabInterview`.status, `tabInterview`.from_time as from_time,
`tabInterview`.to_time as to_time
from
`tabInterview`
where
(`tabInterview`.scheduled_on between %(start)s and %(end)s)
and docstatus != 2
{conditions}
""".format(conditions=conditions), {
"start": start,
"end": end
}, as_dict=True, update={"allDay": 0})
for d in interviews:
subject_data = []
for field in ["name", "job_applicant", "interview_round"]:
if not d.get(field):
continue
subject_data.append(d.get(field))
color = event_color.get(d.status)
interview_data = {
'from': get_datetime('%s %s' % (d.scheduled_on, d.from_time or '00:00:00')),
'to': get_datetime('%s %s' % (d.scheduled_on, d.to_time or '00:00:00')),
'name': d.name,
'subject': '\n'.join(subject_data),
'color': color if color else "#89bcde"
}
events.append(interview_data)
return events

View File

@@ -0,0 +1,14 @@
frappe.views.calendar['Interview'] = {
field_map: {
'start': 'from',
'end': 'to',
'id': 'name',
'title': 'subject',
'allDay': 'allDay',
'color': 'color'
},
order_by: 'scheduled_on',
gantt: true,
get_events_method: 'erpnext.hr.doctype.interview.interview.get_events'
};

View File

@@ -0,0 +1,5 @@
<h1>Interview Feedback Reminder</h1>
<p>
Interview Feedback for Interview {{ name }} is not submitted yet. Please submit your feedback. Thank you, good day!
</p>

View File

@@ -0,0 +1,12 @@
frappe.listview_settings['Interview'] = {
has_indicator_for_draft: 1,
get_indicator: function(doc) {
let status_color = {
'Pending': 'orange',
'Under Review': 'blue',
'Cleared': 'green',
'Rejected': 'red',
};
return [__(doc.status), status_color[doc.status], 'status,=,'+doc.status];
}
};

View File

@@ -0,0 +1,5 @@
<h1>Interview Reminder</h1>
<p>
Interview: {{name}} is scheduled on {{scheduled_on}} from {{from_time}} to {{to_time}}
</p>

View File

@@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from __future__ import unicode_literals
import datetime
import os
import unittest
import frappe
from frappe import _
from frappe.core.doctype.user_permission.test_user_permission import create_user
from frappe.utils import add_days, getdate, nowtime
from erpnext.hr.doctype.designation.test_designation import create_designation
from erpnext.hr.doctype.interview.interview import DuplicateInterviewRoundError
from erpnext.hr.doctype.job_applicant.test_job_applicant import create_job_applicant
class TestInterview(unittest.TestCase):
def test_validations_for_designation(self):
job_applicant = create_job_applicant()
interview = create_interview_and_dependencies(job_applicant.name, designation='_Test_Sales_manager', save=0)
self.assertRaises(DuplicateInterviewRoundError, interview.save)
def test_notification_on_rescheduling(self):
job_applicant = create_job_applicant()
interview = create_interview_and_dependencies(job_applicant.name, scheduled_on=add_days(getdate(), -4))
previous_scheduled_date = interview.scheduled_on
frappe.db.sql("DELETE FROM `tabEmail Queue`")
interview.reschedule_interview(add_days(getdate(previous_scheduled_date), 2),
from_time=nowtime(), to_time=nowtime())
interview.reload()
self.assertEqual(interview.scheduled_on, add_days(getdate(previous_scheduled_date), 2))
notification = frappe.get_all("Email Queue", filters={"message": ("like", "%Your Interview session is rescheduled from%")})
self.assertIsNotNone(notification)
def test_notification_for_scheduling(self):
from erpnext.hr.doctype.interview.interview import send_interview_reminder
setup_reminder_settings()
job_applicant = create_job_applicant()
scheduled_on = datetime.datetime.now() + datetime.timedelta(minutes=10)
interview = create_interview_and_dependencies(job_applicant.name, scheduled_on=scheduled_on)
frappe.db.sql("DELETE FROM `tabEmail Queue`")
send_interview_reminder()
interview.reload()
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue("Subject: Interview Reminder" in email_queue[0].message)
def test_notification_for_feedback_submission(self):
from erpnext.hr.doctype.interview.interview import send_daily_feedback_reminder
setup_reminder_settings()
job_applicant = create_job_applicant()
scheduled_on = add_days(getdate(), -4)
create_interview_and_dependencies(job_applicant.name, scheduled_on=scheduled_on)
frappe.db.sql("DELETE FROM `tabEmail Queue`")
send_daily_feedback_reminder()
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue("Subject: Interview Feedback Reminder" in email_queue[0].message)
def tearDown(self):
frappe.db.rollback()
def create_interview_and_dependencies(job_applicant, scheduled_on=None, from_time=None, to_time=None, designation=None, save=1):
if designation:
designation=create_designation(designation_name = "_Test_Sales_manager").name
interviewer_1 = create_user("test_interviewer1@example.com", "Interviewer")
interviewer_2 = create_user("test_interviewer2@example.com", "Interviewer")
interview_round = create_interview_round(
"Technical Round", ["Python", "JS"],
designation=designation, save=True
)
interview = frappe.new_doc("Interview")
interview.interview_round = interview_round.name
interview.job_applicant = job_applicant
interview.scheduled_on = scheduled_on or getdate()
interview.from_time = from_time or nowtime()
interview.to_time = to_time or nowtime()
interview.append("interview_details", {"interviewer": interviewer_1.name})
interview.append("interview_details", {"interviewer": interviewer_2.name})
if save:
interview.save()
return interview
def create_interview_round(name, skill_set, interviewers=[], designation=None, save=True):
create_skill_set(skill_set)
interview_round = frappe.new_doc("Interview Round")
interview_round.round_name = name
interview_round.interview_type = create_interview_type()
interview_round.expected_average_rating = 4
if designation:
interview_round.designation = designation
for skill in skill_set:
interview_round.append("expected_skill_set", {"skill": skill})
for interviewer in interviewers:
interview_round.append("interviewer", {
"user": interviewer
})
if save:
interview_round.save()
return interview_round
def create_skill_set(skill_set):
for skill in skill_set:
if not frappe.db.exists("Skill", skill):
doc = frappe.new_doc("Skill")
doc.skill_name = skill
doc.save()
def create_interview_type(name="test_interview_type"):
if frappe.db.exists("Interview Type", name):
return frappe.get_doc("Interview Type", name).name
else:
doc = frappe.new_doc("Interview Type")
doc.name = name
doc.description = "_Test_Description"
doc.save()
return doc.name
def setup_reminder_settings():
if not frappe.db.exists('Email Template', _('Interview Reminder')):
base_path = frappe.get_app_path('erpnext', 'hr', 'doctype')
response = frappe.read_file(os.path.join(base_path, 'interview/interview_reminder_notification_template.html'))
frappe.get_doc({
'doctype': 'Email Template',
'name': _('Interview Reminder'),
'response': response,
'subject': _('Interview Reminder'),
'owner': frappe.session.user,
}).insert(ignore_permissions=True)
if not frappe.db.exists('Email Template', _('Interview Feedback Reminder')):
base_path = frappe.get_app_path('erpnext', 'hr', 'doctype')
response = frappe.read_file(os.path.join(base_path, 'interview/interview_feedback_reminder_template.html'))
frappe.get_doc({
'doctype': 'Email Template',
'name': _('Interview Feedback Reminder'),
'response': response,
'subject': _('Interview Feedback Reminder'),
'owner': frappe.session.user,
}).insert(ignore_permissions=True)
hr_settings = frappe.get_doc('HR Settings')
hr_settings.interview_reminder_template = _('Interview Reminder')
hr_settings.feedback_reminder_notification_template = _('Interview Feedback Reminder')
hr_settings.save()

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on('Interview Detail', {
// refresh: function(frm) {
// }
});

View File

@@ -0,0 +1,74 @@
{
"actions": [],
"creation": "2021-04-12 16:24:10.382863",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"interviewer",
"interview_feedback",
"average_rating",
"result",
"column_break_4",
"comments"
],
"fields": [
{
"fieldname": "interviewer",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Interviewer",
"options": "User"
},
{
"allow_on_submit": 1,
"fieldname": "interview_feedback",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Interview Feedback",
"options": "Interview Feedback",
"read_only": 1
},
{
"allow_on_submit": 1,
"fieldname": "average_rating",
"fieldtype": "Rating",
"in_list_view": 1,
"label": "Average Rating",
"read_only": 1
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fetch_from": "interview_feedback.feedback",
"fieldname": "comments",
"fieldtype": "Text",
"label": "Comments",
"read_only": 1
},
{
"allow_on_submit": 1,
"fieldname": "result",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Result",
"options": "\nCleared\nRejected",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-09-29 13:13:25.865063",
"modified_by": "Administrator",
"module": "HR",
"name": "Interview Detail",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

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

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from __future__ import unicode_literals
# import frappe
import unittest
class TestInterviewDetail(unittest.TestCase):
pass

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on('Interview Feedback', {
onload: function(frm) {
frm.ignore_doctypes_on_cancel_all = ['Interview'];
frm.set_query('interview', function() {
return {
filters: {
docstatus: ['!=', 2]
}
};
});
},
interview_round: function(frm) {
frappe.call({
method: 'erpnext.hr.doctype.interview.interview.get_expected_skill_set',
args: {
interview_round: frm.doc.interview_round
},
callback: function(r) {
frm.set_value('skill_assessment', r.message);
}
});
},
interview: function(frm) {
frappe.call({
method: 'erpnext.hr.doctype.interview_feedback.interview_feedback.get_applicable_interviewers',
args: {
interview: frm.doc.interview || ''
},
callback: function(r) {
frm.set_query('interviewer', function() {
return {
filters: {
name: ['in', r.message]
}
};
});
}
});
},
interviewer: function(frm) {
if (!frm.doc.interview) {
frappe.throw(__('Select Interview first'));
frm.set_value('interviewer', '');
}
}
});

View File

@@ -0,0 +1,171 @@
{
"actions": [],
"autoname": "HR-INT-FEED-.####",
"creation": "2021-04-12 17:03:13.833285",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"details_section",
"interview",
"interview_round",
"job_applicant",
"column_break_3",
"interviewer",
"result",
"section_break_4",
"skill_assessment",
"average_rating",
"section_break_7",
"feedback",
"amended_from"
],
"fields": [
{
"allow_in_quick_entry": 1,
"fieldname": "interview",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Interview",
"options": "Interview",
"reqd": 1
},
{
"allow_in_quick_entry": 1,
"fetch_from": "interview.interview_round",
"fieldname": "interview_round",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Interview Round",
"options": "Interview Round",
"read_only": 1,
"reqd": 1
},
{
"allow_in_quick_entry": 1,
"fieldname": "interviewer",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Interviewer",
"options": "User",
"reqd": 1
},
{
"fieldname": "section_break_4",
"fieldtype": "Section Break",
"label": "Skill Assessment"
},
{
"allow_in_quick_entry": 1,
"fieldname": "skill_assessment",
"fieldtype": "Table",
"options": "Skill Assessment",
"reqd": 1
},
{
"allow_in_quick_entry": 1,
"fieldname": "average_rating",
"fieldtype": "Rating",
"in_list_view": 1,
"label": "Average Rating",
"read_only": 1
},
{
"fieldname": "section_break_7",
"fieldtype": "Section Break",
"label": "Feedback"
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Interview Feedback",
"print_hide": 1,
"read_only": 1
},
{
"allow_in_quick_entry": 1,
"fieldname": "feedback",
"fieldtype": "Text"
},
{
"fieldname": "result",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Result",
"options": "\nCleared\nRejected",
"reqd": 1
},
{
"fieldname": "details_section",
"fieldtype": "Section Break",
"label": "Details"
},
{
"fetch_from": "interview.job_applicant",
"fieldname": "job_applicant",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Job Applicant",
"options": "Job Applicant",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2021-09-30 13:30:49.955352",
"modified_by": "Administrator",
"module": "HR",
"name": "Interview Feedback",
"owner": "Administrator",
"permissions": [
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "HR Manager",
"share": 1
},
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Interviewer",
"share": 1,
"submit": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "HR User",
"share": 1
}
],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"title_field": "interviewer",
"track_changes": 1
}

View File

@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import flt, get_link_to_form, getdate
class InterviewFeedback(Document):
def validate(self):
self.validate_interviewer()
self.validate_interview_date()
self.validate_duplicate()
self.calculate_average_rating()
def on_submit(self):
self.update_interview_details()
def on_cancel(self):
self.update_interview_details()
def validate_interviewer(self):
applicable_interviewers = get_applicable_interviewers(self.interview)
if self.interviewer not in applicable_interviewers:
frappe.throw(_('{0} is not allowed to submit Interview Feedback for the Interview: {1}').format(
frappe.bold(self.interviewer), frappe.bold(self.interview)))
def validate_interview_date(self):
scheduled_date = frappe.db.get_value('Interview', self.interview, 'scheduled_on')
if getdate() < getdate(scheduled_date) and self.docstatus == 1:
frappe.throw(_('{0} submission before {1} is not allowed').format(
frappe.bold('Interview Feedback'),
frappe.bold('Interview Scheduled Date')
))
def validate_duplicate(self):
duplicate_feedback = frappe.db.exists('Interview Feedback', {
'interviewer': self.interviewer,
'interview': self.interview,
'docstatus': 1
})
if duplicate_feedback:
frappe.throw(_('Feedback already submitted for the Interview {0}. Please cancel the previous Interview Feedback {1} to continue.').format(
self.interview, get_link_to_form('Interview Feedback', duplicate_feedback)))
def calculate_average_rating(self):
total_rating = 0
for d in self.skill_assessment:
if d.rating:
total_rating += d.rating
self.average_rating = flt(total_rating / len(self.skill_assessment) if len(self.skill_assessment) else 0)
def update_interview_details(self):
doc = frappe.get_doc('Interview', self.interview)
total_rating = 0
if self.docstatus == 2:
for entry in doc.interview_details:
if entry.interview_feedback == self.name:
entry.average_rating = entry.interview_feedback = entry.comments = entry.result = None
break
else:
for entry in doc.interview_details:
if entry.interviewer == self.interviewer:
entry.average_rating = self.average_rating
entry.interview_feedback = self.name
entry.comments = self.feedback
entry.result = self.result
if entry.average_rating:
total_rating += entry.average_rating
doc.average_rating = flt(total_rating / len(doc.interview_details) if len(doc.interview_details) else 0)
doc.save()
doc.notify_update()
@frappe.whitelist()
def get_applicable_interviewers(interview):
data = frappe.get_all('Interview Detail', filters={'parent': interview}, fields=['interviewer'])
return [d.interviewer for d in data]

View File

@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from __future__ import unicode_literals
import unittest
import frappe
from frappe.utils import add_days, flt, getdate
from erpnext.hr.doctype.interview.test_interview import (
create_interview_and_dependencies,
create_skill_set,
)
from erpnext.hr.doctype.job_applicant.test_job_applicant import create_job_applicant
class TestInterviewFeedback(unittest.TestCase):
def test_validation_for_skill_set(self):
frappe.set_user("Administrator")
job_applicant = create_job_applicant()
interview = create_interview_and_dependencies(job_applicant.name, scheduled_on=add_days(getdate(), -1))
skill_ratings = get_skills_rating(interview.interview_round)
interviewer = interview.interview_details[0].interviewer
create_skill_set(['Leadership'])
interview_feedback = create_interview_feedback(interview.name, interviewer, skill_ratings)
interview_feedback.append("skill_assessment", {"skill": 'Leadership', 'rating': 4})
frappe.set_user(interviewer)
self.assertRaises(frappe.ValidationError, interview_feedback.save)
frappe.set_user("Administrator")
def test_average_ratings_on_feedback_submission_and_cancellation(self):
job_applicant = create_job_applicant()
interview = create_interview_and_dependencies(job_applicant.name, scheduled_on=add_days(getdate(), -1))
skill_ratings = get_skills_rating(interview.interview_round)
# For First Interviewer Feedback
interviewer = interview.interview_details[0].interviewer
frappe.set_user(interviewer)
# calculating Average
feedback_1 = create_interview_feedback(interview.name, interviewer, skill_ratings)
total_rating = 0
for d in feedback_1.skill_assessment:
if d.rating:
total_rating += d.rating
avg_rating = flt(total_rating / len(feedback_1.skill_assessment) if len(feedback_1.skill_assessment) else 0)
self.assertEqual(flt(avg_rating, 3), feedback_1.average_rating)
avg_on_interview_detail = frappe.db.get_value('Interview Detail', {
'parent': feedback_1.interview,
'interviewer': feedback_1.interviewer,
'interview_feedback': feedback_1.name
}, 'average_rating')
# 1. average should be reflected in Interview Detail.
self.assertEqual(avg_on_interview_detail, round(feedback_1.average_rating))
'''For Second Interviewer Feedback'''
interviewer = interview.interview_details[1].interviewer
frappe.set_user(interviewer)
feedback_2 = create_interview_feedback(interview.name, interviewer, skill_ratings)
interview.reload()
feedback_2.cancel()
interview.reload()
frappe.set_user("Administrator")
def tearDown(self):
frappe.db.rollback()
def create_interview_feedback(interview, interviewer, skills_ratings):
interview_feedback = frappe.new_doc("Interview Feedback")
interview_feedback.interview = interview
interview_feedback.interviewer = interviewer
interview_feedback.result = "Cleared"
for rating in skills_ratings:
interview_feedback.append("skill_assessment", rating)
interview_feedback.save()
interview_feedback.submit()
return interview_feedback
def get_skills_rating(interview_round):
import random
skills = frappe.get_all("Expected Skill Set", filters={"parent": interview_round}, fields = ["skill"])
for d in skills:
d["rating"] = random.randint(1, 5)
return skills

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("Interview Round", {
refresh: function(frm) {
if (!frm.doc.__islocal) {
frm.add_custom_button(__("Create Interview"), function() {
frm.events.create_interview(frm);
});
}
},
create_interview: function(frm) {
frappe.call({
method: "erpnext.hr.doctype.interview_round.interview_round.create_interview",
args: {
doc: frm.doc
},
callback: function (r) {
var doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
}
});
}
});

Some files were not shown because too many files have changed in this diff Show More