fix: Clear Depreciation Schedule before modification (#28507)
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index c145291..0000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,47 +0,0 @@
----
-name: Bug report
-about: Report a bug encountered while using ERPNext
-labels: bug
----
-
-<!--
-Welcome to ERPNext issue tracker! Before creating an issue, please heed the following:
-
-1. This tracker should only be used to report bugs and request features / enhancements to ERPNext
- - For questions and general support, checkout the manual https://erpnext.com/docs/user/manual/en or use https://discuss.erpnext.com
- - For documentation issues, refer to https://github.com/frappe/erpnext_com
-2. Use the search function before creating a new issue. Duplicates will be closed and directed to
- the original discussion.
-3. When making a bug report, make sure you provide all required information. The easier it is for
- maintainers to reproduce, the faster it'll be fixed.
-4. If you think you know what the reason for the bug is, share it with us. Maybe put in a PR 😉
--->
-
-## Description of the issue
-
-## Context information (for bug reports)
-
-**Output of `bench version`**
-```
-(paste here)
-```
-
-## Steps to reproduce the issue
-
-1.
-2.
-3.
-
-### Observed result
-
-### Expected result
-
-### Stacktrace / full error message
-
-```
-(paste here)
-```
-
-## Additional information
-
-OS version / distribution, `ERPNext` install method, etc.
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
new file mode 100644
index 0000000..a6e16a0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -0,0 +1,106 @@
+name: Bug Report
+description: Report a bug encountered while using ERPNext
+labels: ["bug"]
+
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Welcome to ERPNext issue tracker! Before creating an issue, please heed the following:
+
+ 1. This tracker should only be used to report bugs and request features / enhancements to ERPNext
+ - For questions and general support, checkout the [user manual](https://docs.erpnext.com/) or use [forum](https://discuss.erpnext.com)
+ - For documentation issues, propose edit on [documentation site](https://docs.erpnext.com/) directly.
+ 2. When making a bug report, make sure you provide all required information. The easier it is for
+ maintainers to reproduce, the faster it'll be fixed.
+ 3. If you think you know what the reason for the bug is, share it with us. Maybe put in a PR 😉
+
+ - type: textarea
+ id: bug-info
+ attributes:
+ label: Information about bug
+ description: Also tell us, what did you expect to happen?
+ placeholder: Please provide as much information as possible.
+ validations:
+ required: true
+
+ - type: dropdown
+ id: version
+ attributes:
+ label: Version
+ description: Affected versions.
+ multiple: true
+ options:
+ - v12
+ - v13
+ - v14
+ - develop
+ validations:
+ required: true
+
+ - type: dropdown
+ id: module
+ attributes:
+ label: Module
+ description: Select affected module of ERPNext.
+ multiple: true
+ options:
+ - accounts
+ - stock
+ - buying
+ - selling
+ - ecommerce
+ - manufacturing
+ - HR
+ - projects
+ - support
+ - assets
+ - integrations
+ - quality
+ - regional
+ - portal
+ - agriculture
+ - education
+ - non-profit
+ validations:
+ required: true
+
+ - type: textarea
+ id: exact-version
+ attributes:
+ label: Version
+ description: Share exact version number of Frappe and ERPNext you are using.
+ placeholder: |
+ Frappe version -
+ ERPNext Verion -
+ validations:
+ required: true
+
+ - type: dropdown
+ id: install-method
+ attributes:
+ label: Installation method
+ options:
+ - docker
+ - easy-install
+ - manual install
+ - FrappeCloud
+ validations:
+ required: true
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Relevant log output / Stack trace / Full Error Message.
+ description: Please copy and paste any relevant log output. This will be automatically formatted.
+ render: shell
+
+
+ - type: checkboxes
+ id: terms
+ attributes:
+ label: Code of Conduct
+ description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/frappe/erpnext/blob/develop/CODE_OF_CONDUCT.md)
+ options:
+ - label: I agree to follow this project's Code of Conduct
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index 6cdad35..418bf3c 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -1,7 +1,10 @@
---
name: Feature request
about: Suggest an idea to improve ERPNext
+title: ''
labels: feature-request
+assignees: ''
+
---
<!--
diff --git a/.github/ISSUE_TEMPLATE/question-about-using-erpnext.md b/.github/ISSUE_TEMPLATE/question-about-using-erpnext.md
deleted file mode 100644
index 2016bcc..0000000
--- a/.github/ISSUE_TEMPLATE/question-about-using-erpnext.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-name: Question about using ERPNext
-about: This is not the appropriate channel
-labels: invalid
----
-
-Please post on our forums:
-
-for questions about using `ERPNext`: https://discuss.erpnext.com
-
-for questions about using the `Frappe Framework`: ~~https://discuss.frappe.io~~ => [stackoverflow](https://stackoverflow.com/questions/tagged/frappe) tagged under `frappe`
-
-for questions about using `bench`, probably the best place to start is the [bench repo](https://github.com/frappe/bench)
-
-For documentation issues, use the [ERPNext Documentation](https://erpnext.com/docs/) or [Frappe Framework Documentation](https://frappe.io/docs/user/en) or the [developer cheetsheet](https://github.com/frappe/frappe/wiki/Developer-Cheatsheet)
-
-> **Posts that are not bug reports or feature requests will not be addressed on this issue tracker.**
\ No newline at end of file
diff --git a/.github/stale.yml b/.github/stale.yml
index 9322ae8..8b7cb9b 100644
--- a/.github/stale.yml
+++ b/.github/stale.yml
@@ -1,34 +1,36 @@
# Configuration for probot-stale - https://github.com/probot/stale
-# Number of days of inactivity before an Issue or Pull Request becomes stale
-daysUntilStale: 15
+# Label to use when marking as stale
+staleLabel: inactive
-# Number of days of inactivity before a stale Issue or Pull Request is closed.
-# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
-daysUntilClose: 3
-
-# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
-exemptLabels:
- - hotfix
+# Limit the number of actions per hour, from 1-30. Default is 30
+limitPerRun: 10
# Set to true to ignore issues in a project (defaults to false)
-exemptProjects: false
+exemptProjects: true
# Set to true to ignore issues in a milestone (defaults to false)
exemptMilestones: true
-# Label to use when marking as stale
-staleLabel: inactive
+pulls:
+ daysUntilStale: 15
+ daysUntilClose: 3
+ exemptLabels:
+ - hotfix
+ markComment: >
+ This pull request has been automatically marked as inactive because it has
+ not had recent activity. It will be closed within 3 days if no further
+ activity occurs, but it only takes a comment to keep a contribution alive
+ :) Also, even if it is closed, you can always reopen the PR when you're
+ ready. Thank you for contributing.
-# Comment to post when marking as stale. Set to `false` to disable
-markComment: >
- This pull request has been automatically marked as stale because it has not had
- recent activity. It will be closed within a week if no further activity occurs, but it
- only takes a comment to keep a contribution alive :) Also, even if it is closed,
- you can always reopen the PR when you're ready. Thank you for contributing.
-
-# Limit the number of actions per hour, from 1-30. Default is 30
-limitPerRun: 30
-
-# Limit to only `issues` or `pulls`
-only: pulls
+issues:
+ daysUntilStale: 60
+ daysUntilClose: 7
+ exemptLabels:
+ - valid
+ - to-validate
+ markComment: >
+ This issue has been automatically marked as inactive because it has not had
+ recent activity and it wasn't validated by maintainer team. It will be
+ closed within a week if no further activity occurs.
diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py
index 7451917..4839207 100644
--- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py
+++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py
@@ -10,6 +10,8 @@
from frappe.model.document import Document
from frappe.utils import cint
+from erpnext.stock.utils import check_pending_reposting
+
class AccountsSettings(Document):
def on_update(self):
@@ -25,6 +27,7 @@
self.validate_stale_days()
self.enable_payment_schedule_in_print()
self.toggle_discount_accounting_fields()
+ self.validate_pending_reposts()
def validate_stale_days(self):
if not self.allow_stale and cint(self.stale_days) <= 0:
@@ -56,3 +59,8 @@
make_property_setter(doctype, "additional_discount_account", "mandatory_depends_on", "", "Code", validate_fields_for_doctype=False)
make_property_setter("Item", "default_discount_account", "hidden", not(enable_discount_accounting), "Check", validate_fields_for_doctype=False)
+
+
+ def validate_pending_reposts(self):
+ if self.acc_frozen_upto:
+ check_pending_reposting(self.acc_frozen_upto)
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index 48b5cb9..df957d2 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -114,6 +114,9 @@
self.set_status()
self.validate_purchase_receipt_if_update_stock()
validate_inter_company_party(self.doctype, self.supplier, self.company, self.inter_company_invoice_reference)
+ self.reset_default_field_value("set_warehouse", "items", "warehouse")
+ self.reset_default_field_value("rejected_warehouse", "items", "rejected_warehouse")
+ self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse")
def validate_release_date(self):
if self.release_date and getdate(nowdate()) >= getdate(self.release_date):
@@ -294,8 +297,15 @@
item.expense_account = stock_not_billed_account
elif item.is_fixed_asset and not is_cwip_accounting_enabled(asset_category):
- item.expense_account = get_asset_category_account('fixed_asset_account', item=item.item_code,
+ asset_category_account = get_asset_category_account('fixed_asset_account', item=item.item_code,
company = self.company)
+ if not asset_category_account:
+ form_link = get_link_to_form('Asset Category', asset_category)
+ throw(
+ _("Please set Fixed Asset Account in {} against {}.").format(form_link, self.company),
+ title=_("Missing Account")
+ )
+ item.expense_account = asset_category_account
elif item.is_fixed_asset and item.pr_detail:
item.expense_account = asset_received_but_not_billed
elif not item.expense_account and for_validate:
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index c4d59f1..64712b5 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -155,6 +155,8 @@
if self.redeem_loyalty_points and self.loyalty_program and self.loyalty_points and not self.is_consolidated:
validate_loyalty_points(self, self.loyalty_points)
+ self.reset_default_field_value("set_warehouse", "items", "warehouse")
+
def validate_fixed_asset(self):
for d in self.get("items"):
if d.is_fixed_asset and d.meta.get_field("asset") and d.asset:
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
index 353f908..a990f23 100755
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
@@ -545,7 +545,9 @@
def set_ageing(self, row):
if self.filters.ageing_based_on == "Due Date":
- entry_date = row.due_date
+ # use posting date as a fallback for advances posted via journal and payment entry
+ # when ageing viewed by due date
+ entry_date = row.due_date or row.posting_date
elif self.filters.ageing_based_on == "Supplier Invoice Date":
entry_date = row.bill_date
else:
diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py
index a3a45d1..caee1a1 100644
--- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py
+++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py
@@ -36,12 +36,16 @@
posting_date = entry.posting_date
voucher_type = entry.voucher_type
+ if not tax_withholding_category:
+ tax_withholding_category = supplier_map.get(supplier, {}).get('tax_withholding_category')
+ rate = tax_rate_map.get(tax_withholding_category)
+
if entry.account in tds_accounts:
tds_deducted += (entry.credit - entry.debit)
total_amount_credited += (entry.credit - entry.debit)
- if rate and tds_deducted:
+ if tds_deducted:
row = {
'pan' if frappe.db.has_column('Supplier', 'pan') else 'tax_id': supplier_map.get(supplier, {}).get('pan'),
'supplier': supplier_map.get(supplier, {}).get('name')
@@ -67,7 +71,7 @@
def get_supplier_pan_map():
supplier_map = frappe._dict()
- suppliers = frappe.db.get_all('Supplier', fields=['name', 'pan', 'supplier_type', 'supplier_name'])
+ suppliers = frappe.db.get_all('Supplier', fields=['name', 'pan', 'supplier_type', 'supplier_name', 'tax_withholding_category'])
for d in suppliers:
supplier_map[d.name] = d
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index 5eab21b..1b5f35e 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -72,6 +72,7 @@
self.create_raw_materials_supplied("supplied_items")
self.set_received_qty_for_drop_ship_items()
validate_inter_company_party(self.doctype, self.supplier, self.company, self.inter_company_order_reference)
+ self.reset_default_field_value("set_warehouse", "items", "warehouse")
def validate_with_previous_doc(self):
super(PurchaseOrder, self).validate_with_previous_doc({
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
index 0a51a8e..023c95d 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
@@ -17,6 +17,7 @@
"company",
"transaction_date",
"valid_till",
+ "quotation_number",
"amended_from",
"address_section",
"supplier_address",
@@ -797,6 +798,11 @@
"fieldtype": "Date",
"in_list_view": 1,
"label": "Valid Till"
+ },
+ {
+ "fieldname": "quotation_number",
+ "fieldtype": "Data",
+ "label": "Quotation Number"
}
],
"icon": "fa fa-shopping-cart",
@@ -804,10 +810,11 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2021-04-19 00:58:20.995491",
+ "modified": "2021-12-11 06:43:20.924080",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
diff --git a/erpnext/controllers/tests/test_transaction_base.py b/erpnext/controllers/tests/test_transaction_base.py
new file mode 100644
index 0000000..13aa697
--- /dev/null
+++ b/erpnext/controllers/tests/test_transaction_base.py
@@ -0,0 +1,22 @@
+import unittest
+
+import frappe
+
+
+class TestUtils(unittest.TestCase):
+ def test_reset_default_field_value(self):
+ doc = frappe.get_doc({
+ "doctype": "Purchase Receipt",
+ "set_warehouse": "Warehouse 1",
+ })
+
+ # Same values
+ doc.items = [{"warehouse": "Warehouse 1"}, {"warehouse": "Warehouse 1"}, {"warehouse": "Warehouse 1"}]
+ doc.reset_default_field_value("set_warehouse", "items", "warehouse")
+ self.assertEqual(doc.set_warehouse, "Warehouse 1")
+
+ # Mixed values
+ doc.items = [{"warehouse": "Warehouse 1"}, {"warehouse": "Warehouse 2"}, {"warehouse": "Warehouse 1"}]
+ doc.reset_default_field_value("set_warehouse", "items", "warehouse")
+ self.assertEqual(doc.set_warehouse, None)
+
diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.json b/erpnext/crm/doctype/crm_settings/crm_settings.json
index 95b19fa..8f0fa31 100644
--- a/erpnext/crm/doctype/crm_settings/crm_settings.json
+++ b/erpnext/crm/doctype/crm_settings/crm_settings.json
@@ -91,8 +91,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "migration_hash": "3ae78b12dd1c64d551736c6e82092f90",
- "modified": "2021-11-03 09:00:36.883496",
+ "modified": "2021-11-03 10:00:36.883496",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",
diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json
index feb6044..dc32d9a 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.json
+++ b/erpnext/crm/doctype/opportunity/opportunity.json
@@ -510,8 +510,7 @@
"icon": "fa fa-info-sign",
"idx": 195,
"links": [],
- "migration_hash": "d87c646ea2579b6900197fd41e6c5c5a",
- "modified": "2021-10-21 11:04:30.151379",
+ "modified": "2021-10-21 12:04:30.151379",
"modified_by": "Administrator",
"module": "CRM",
"name": "Opportunity",
diff --git a/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.json b/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.json
index ae1f36e..6afd3f7 100644
--- a/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.json
+++ b/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.json
@@ -115,8 +115,7 @@
],
"issingle": 1,
"links": [],
- "migration_hash": "8ca1ea3309ed28547b19da8e6e27e96f",
- "modified": "2021-11-30 11:17:24.647979",
+ "modified": "2021-11-30 12:17:24.647979",
"modified_by": "Administrator",
"module": "ERPNext Integrations",
"name": "TaxJar Settings",
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index 05c46c5..9ceb626 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -234,7 +234,7 @@
},
"Communication": {
"on_update": [
- "erpnext.support.doctype.service_level_agreement.service_level_agreement.update_hold_time",
+ "erpnext.support.doctype.service_level_agreement.service_level_agreement.on_communication_update",
"erpnext.support.doctype.issue.issue.set_first_response_time"
]
},
@@ -265,6 +265,9 @@
"erpnext.regional.india.utils.update_taxable_values"
]
},
+ "POS Invoice": {
+ "on_submit": ["erpnext.regional.saudi_arabia.utils.create_qr_code"]
+ },
"Purchase Invoice": {
"validate": [
"erpnext.regional.india.utils.validate_reverse_charge_transaction",
@@ -340,8 +343,7 @@
"erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization",
"erpnext.projects.doctype.project.project.hourly_reminder",
"erpnext.projects.doctype.project.project.collect_project_status",
- "erpnext.hr.doctype.shift_type.shift_type.process_auto_attendance_for_all_shifts",
- "erpnext.support.doctype.service_level_agreement.service_level_agreement.set_service_level_agreement_variance"
+ "erpnext.hr.doctype.shift_type.shift_type.process_auto_attendance_for_all_shifts"
],
"hourly_long": [
"erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries"
diff --git a/erpnext/hr/doctype/employee/employee_dashboard.py b/erpnext/hr/doctype/employee/employee_dashboard.py
index a4c0af0..a1247d9 100644
--- a/erpnext/hr/doctype/employee/employee_dashboard.py
+++ b/erpnext/hr/doctype/employee/employee_dashboard.py
@@ -21,7 +21,11 @@
},
{
'label': _('Lifecycle'),
- 'items': ['Employee Transfer', 'Employee Promotion', 'Employee Separation', 'Employee Grievance']
+ 'items': ['Employee Transfer', 'Employee Promotion', 'Employee Grievance']
+ },
+ {
+ 'label': _('Exit'),
+ 'items': ['Employee Separation', 'Exit Interview', 'Full and Final Statement']
},
{
'label': _('Shift'),
diff --git a/erpnext/hr/doctype/exit_interview/__init__.py b/erpnext/hr/doctype/exit_interview/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/hr/doctype/exit_interview/__init__.py
diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.js b/erpnext/hr/doctype/exit_interview/exit_interview.js
new file mode 100644
index 0000000..502af42
--- /dev/null
+++ b/erpnext/hr/doctype/exit_interview/exit_interview.js
@@ -0,0 +1,38 @@
+// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Exit Interview', {
+ refresh: function(frm) {
+ if (!frm.doc.__islocal && !frm.doc.questionnaire_email_sent && frappe.boot.user.can_write.includes('Exit Interview')) {
+ frm.add_custom_button(__('Send Exit Questionnaire'), function () {
+ frm.trigger('send_exit_questionnaire');
+ });
+ }
+ },
+
+ employee: function(frm) {
+ frappe.db.get_value('Employee', frm.doc.employee, 'relieving_date', (message) => {
+ if (!message.relieving_date) {
+ frappe.throw({
+ message: __('Please set the relieving date for employee {0}',
+ ['<a href="/app/employee/' + frm.doc.employee +'">' + frm.doc.employee + '</a>']),
+ title: __('Relieving Date Missing')
+ });
+ }
+ });
+ },
+
+ send_exit_questionnaire: function(frm) {
+ frappe.call({
+ method: 'erpnext.hr.doctype.exit_interview.exit_interview.send_exit_questionnaire',
+ args: {
+ 'interviews': [frm.doc]
+ },
+ callback: function(r) {
+ if (!r.exc) {
+ frm.refresh_field('questionnaire_email_sent');
+ }
+ }
+ });
+ }
+});
diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.json b/erpnext/hr/doctype/exit_interview/exit_interview.json
new file mode 100644
index 0000000..989a1b8
--- /dev/null
+++ b/erpnext/hr/doctype/exit_interview/exit_interview.json
@@ -0,0 +1,246 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "naming_series:",
+ "creation": "2021-12-05 13:56:36.241690",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "email_append_to": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "naming_series",
+ "employee",
+ "employee_name",
+ "email",
+ "column_break_5",
+ "company",
+ "status",
+ "date",
+ "employee_details_section",
+ "department",
+ "designation",
+ "reports_to",
+ "column_break_9",
+ "date_of_joining",
+ "relieving_date",
+ "exit_questionnaire_section",
+ "ref_doctype",
+ "questionnaire_email_sent",
+ "column_break_10",
+ "reference_document_name",
+ "interview_summary_section",
+ "interviewers",
+ "interview_summary",
+ "employee_status_section",
+ "employee_status",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "fieldname": "employee",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Employee",
+ "options": "Employee",
+ "reqd": 1
+ },
+ {
+ "fetch_from": "employee.employee_name",
+ "fieldname": "employee_name",
+ "fieldtype": "Data",
+ "label": "Employee Name",
+ "read_only": 1
+ },
+ {
+ "fetch_from": "employee.department",
+ "fieldname": "department",
+ "fieldtype": "Link",
+ "label": "Department",
+ "options": "Department",
+ "read_only": 1
+ },
+ {
+ "fetch_from": "employee.relieving_date",
+ "fieldname": "relieving_date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Relieving Date",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_5",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "in_standard_filter": 1,
+ "label": "Company",
+ "options": "Company",
+ "reqd": 1
+ },
+ {
+ "fieldname": "date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Date",
+ "mandatory_depends_on": "eval:doc.status==='Scheduled';"
+ },
+ {
+ "fieldname": "exit_questionnaire_section",
+ "fieldtype": "Section Break",
+ "label": "Exit Questionnaire"
+ },
+ {
+ "fieldname": "ref_doctype",
+ "fieldtype": "Link",
+ "label": "Reference Document Type",
+ "options": "DocType"
+ },
+ {
+ "fieldname": "reference_document_name",
+ "fieldtype": "Dynamic Link",
+ "in_list_view": 1,
+ "label": "Reference Document Name",
+ "options": "ref_doctype"
+ },
+ {
+ "fieldname": "interview_summary_section",
+ "fieldtype": "Section Break",
+ "label": "Interview Details"
+ },
+ {
+ "fieldname": "column_break_10",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "interviewers",
+ "fieldtype": "Table MultiSelect",
+ "label": "Interviewers",
+ "mandatory_depends_on": "eval:doc.status==='Scheduled';",
+ "options": "Interviewer"
+ },
+ {
+ "fetch_from": "employee.date_of_joining",
+ "fieldname": "date_of_joining",
+ "fieldtype": "Date",
+ "label": "Date of Joining",
+ "read_only": 1
+ },
+ {
+ "fetch_from": "employee.reports_to",
+ "fieldname": "reports_to",
+ "fieldtype": "Link",
+ "in_standard_filter": 1,
+ "label": "Reports To",
+ "options": "Employee",
+ "read_only": 1
+ },
+ {
+ "fieldname": "employee_details_section",
+ "fieldtype": "Section Break",
+ "label": "Employee Details"
+ },
+ {
+ "fetch_from": "employee.designation",
+ "fieldname": "designation",
+ "fieldtype": "Link",
+ "label": "Designation",
+ "options": "Designation",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_9",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "naming_series",
+ "fieldtype": "Select",
+ "label": "Naming Series",
+ "options": "HR-EXIT-INT-"
+ },
+ {
+ "default": "0",
+ "fieldname": "questionnaire_email_sent",
+ "fieldtype": "Check",
+ "in_standard_filter": 1,
+ "label": "Questionnaire Email Sent",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "email",
+ "fieldtype": "Data",
+ "label": "Email ID",
+ "options": "Email",
+ "read_only": 1
+ },
+ {
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Status",
+ "options": "Pending\nScheduled\nCompleted\nCancelled",
+ "reqd": 1
+ },
+ {
+ "fieldname": "employee_status_section",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "employee_status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Final Decision",
+ "mandatory_depends_on": "eval:doc.status==='Completed';",
+ "options": "\nEmployee Retained\nExit Confirmed"
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Exit Interview",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "interview_summary",
+ "fieldtype": "Text Editor",
+ "label": "Interview Summary"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2021-12-07 23:39:22.645401",
+ "modified_by": "Administrator",
+ "module": "HR",
+ "name": "Exit Interview",
+ "naming_rule": "By \"Naming Series\" field",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sender_field": "email",
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "title_field": "employee_name",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.py b/erpnext/hr/doctype/exit_interview/exit_interview.py
new file mode 100644
index 0000000..30e19f1
--- /dev/null
+++ b/erpnext/hr/doctype/exit_interview/exit_interview.py
@@ -0,0 +1,131 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.utils import get_link_to_form
+
+from erpnext.hr.doctype.employee.employee import get_employee_email
+
+
+class ExitInterview(Document):
+ def validate(self):
+ self.validate_relieving_date()
+ self.validate_duplicate_interview()
+ self.set_employee_email()
+
+ def validate_relieving_date(self):
+ if not frappe.db.get_value('Employee', self.employee, 'relieving_date'):
+ frappe.throw(_('Please set the relieving date for employee {0}').format(
+ get_link_to_form('Employee', self.employee)),
+ title=_('Relieving Date Missing'))
+
+ def validate_duplicate_interview(self):
+ doc = frappe.db.exists('Exit Interview', {
+ 'employee': self.employee,
+ 'name': ('!=', self.name),
+ 'docstatus': ('!=', 2)
+ })
+ if doc:
+ frappe.throw(_('Exit Interview {0} already exists for Employee: {1}').format(
+ get_link_to_form('Exit Interview', doc), frappe.bold(self.employee)),
+ frappe.DuplicateEntryError)
+
+ def set_employee_email(self):
+ employee = frappe.get_doc('Employee', self.employee)
+ self.email = get_employee_email(employee)
+
+ def on_submit(self):
+ if self.status != 'Completed':
+ frappe.throw(_('Only Completed documents can be submitted'))
+
+ self.update_interview_date_in_employee()
+
+ def on_cancel(self):
+ self.update_interview_date_in_employee()
+ self.db_set('status', 'Cancelled')
+
+ def update_interview_date_in_employee(self):
+ if self.docstatus == 1:
+ frappe.db.set_value('Employee', self.employee, 'held_on', self.date)
+ elif self.docstatus == 2:
+ frappe.db.set_value('Employee', self.employee, 'held_on', None)
+
+
+@frappe.whitelist()
+def send_exit_questionnaire(interviews):
+ interviews = get_interviews(interviews)
+ validate_questionnaire_settings()
+
+ email_success = []
+ email_failure = []
+
+ for exit_interview in interviews:
+ interview = frappe.get_doc('Exit Interview', exit_interview.get('name'))
+ if interview.get('questionnaire_email_sent'):
+ continue
+
+ employee = frappe.get_doc('Employee', interview.employee)
+ email = get_employee_email(employee)
+
+ context = interview.as_dict()
+ context.update(employee.as_dict())
+ template_name = frappe.db.get_single_value('HR Settings', 'exit_questionnaire_notification_template')
+ template = frappe.get_doc('Email Template', template_name)
+
+ if email:
+ frappe.sendmail(
+ recipients=email,
+ subject=template.subject,
+ message=frappe.render_template(template.response, context),
+ reference_doctype=interview.doctype,
+ reference_name=interview.name
+ )
+ interview.db_set('questionnaire_email_sent', True)
+ interview.notify_update()
+ email_success.append(email)
+ else:
+ email_failure.append(get_link_to_form('Employee', employee.name))
+
+ show_email_summary(email_success, email_failure)
+
+
+def get_interviews(interviews):
+ import json
+
+ if isinstance(interviews, str):
+ interviews = json.loads(interviews)
+
+ if not len(interviews):
+ frappe.throw(_('Atleast one interview has to be selected.'))
+
+ return interviews
+
+
+def validate_questionnaire_settings():
+ settings = frappe.db.get_value('HR Settings', 'HR Settings',
+ ['exit_questionnaire_web_form', 'exit_questionnaire_notification_template'], as_dict=True)
+
+ if not settings.exit_questionnaire_web_form or not settings.exit_questionnaire_notification_template:
+ frappe.throw(
+ _('Please set {0} and {1} in {2}.').format(
+ frappe.bold('Exit Questionnaire Web Form'),
+ frappe.bold('Notification Template'),
+ get_link_to_form('HR Settings', 'HR Settings')),
+ title=_('Settings Missing')
+ )
+
+
+def show_email_summary(email_success, email_failure):
+ message = ''
+ if email_success:
+ message += _('{0}: {1}').format(
+ frappe.bold('Sent Successfully'), ', '.join(email_success))
+ if message and email_failure:
+ message += '<br><br>'
+ if email_failure:
+ message += _('{0} due to missing email information for employee(s): {1}').format(
+ frappe.bold('Sending Failed'), ', '.join(email_failure))
+
+ frappe.msgprint(message, title=_('Exit Questionnaire'), indicator='blue', is_minimizable=True, wide=True)
\ No newline at end of file
diff --git a/erpnext/hr/doctype/exit_interview/exit_interview_list.js b/erpnext/hr/doctype/exit_interview/exit_interview_list.js
new file mode 100644
index 0000000..93d7b21
--- /dev/null
+++ b/erpnext/hr/doctype/exit_interview/exit_interview_list.js
@@ -0,0 +1,27 @@
+frappe.listview_settings['Exit Interview'] = {
+ has_indicator_for_draft: 1,
+ get_indicator: function(doc) {
+ let status_color = {
+ 'Pending': 'orange',
+ 'Scheduled': 'yellow',
+ 'Completed': 'green',
+ 'Cancelled': 'red',
+ };
+ return [__(doc.status), status_color[doc.status], 'status,=,'+doc.status];
+ },
+
+ onload: function(listview) {
+ if (frappe.boot.user.can_write.includes('Exit Interview')) {
+ listview.page.add_action_item(__('Send Exit Questionnaires'), function() {
+ const interviews = listview.get_checked_items();
+ frappe.call({
+ method: 'erpnext.hr.doctype.exit_interview.exit_interview.send_exit_questionnaire',
+ freeze: true,
+ args: {
+ 'interviews': interviews
+ }
+ });
+ });
+ }
+ }
+};
diff --git a/erpnext/hr/doctype/exit_interview/exit_questionnaire_notification_template.html b/erpnext/hr/doctype/exit_interview/exit_questionnaire_notification_template.html
new file mode 100644
index 0000000..0317b1a
--- /dev/null
+++ b/erpnext/hr/doctype/exit_interview/exit_questionnaire_notification_template.html
@@ -0,0 +1,16 @@
+<h2>Exit Questionnaire</h2>
+<br>
+
+<p>
+ Dear {{ employee_name }},
+ <br><br>
+
+ Thank you for the contribution you have made during your time at {{ company }}. We value your opinion and welcome the feedback on your experience working with us.
+ Request you to take out a few minutes to fill up this Exit Questionnaire.
+
+ {% set web_form = frappe.db.get_value('HR Settings', 'HR Settings', 'exit_questionnaire_web_form') %}
+ {% set web_form_link = frappe.utils.get_url(uri=frappe.db.get_value('Web Form', web_form, 'route')) %}
+
+ <br><br>
+ <a class="btn btn-primary" href="{{ web_form_link }}" target="_blank">{{ _('Submit Now') }}</a>
+</p>
diff --git a/erpnext/hr/doctype/exit_interview/test_exit_interview.py b/erpnext/hr/doctype/exit_interview/test_exit_interview.py
new file mode 100644
index 0000000..a0bf9b3
--- /dev/null
+++ b/erpnext/hr/doctype/exit_interview/test_exit_interview.py
@@ -0,0 +1,118 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import os
+import unittest
+
+import frappe
+from frappe import _
+from frappe.core.doctype.user_permission.test_user_permission import create_user
+from frappe.tests.test_webform import create_custom_doctype, create_webform
+from frappe.utils import getdate
+
+from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.hr.doctype.exit_interview.exit_interview import send_exit_questionnaire
+
+
+class TestExitInterview(unittest.TestCase):
+ def setUp(self):
+ frappe.db.sql('delete from `tabExit Interview`')
+
+ def test_duplicate_interview(self):
+ employee = make_employee('employeeexit1@example.com')
+ frappe.db.set_value('Employee', employee, 'relieving_date', getdate())
+ interview = create_exit_interview(employee)
+
+ doc = frappe.copy_doc(interview)
+ self.assertRaises(frappe.DuplicateEntryError, doc.save)
+
+ def test_relieving_date_validation(self):
+ employee = make_employee('employeeexit2@example.com')
+ # unset relieving date
+ frappe.db.set_value('Employee', employee, 'relieving_date', None)
+
+ interview = create_exit_interview(employee, save=False)
+ self.assertRaises(frappe.ValidationError, interview.save)
+
+ # set relieving date
+ frappe.db.set_value('Employee', employee, 'relieving_date', getdate())
+ interview = create_exit_interview(employee)
+ self.assertTrue(interview.name)
+
+ def test_interview_date_updated_in_employee_master(self):
+ employee = make_employee('employeeexit3@example.com')
+ frappe.db.set_value('Employee', employee, 'relieving_date', getdate())
+
+ interview = create_exit_interview(employee)
+ interview.status = 'Completed'
+ interview.employee_status = 'Exit Confirmed'
+
+ # exit interview date updated on submit
+ interview.submit()
+ self.assertEqual(frappe.db.get_value('Employee', employee, 'held_on'), interview.date)
+
+ # exit interview reset on cancel
+ interview.reload()
+ interview.cancel()
+ self.assertEqual(frappe.db.get_value('Employee', employee, 'held_on'), None)
+
+ def test_send_exit_questionnaire(self):
+ create_custom_doctype()
+ create_webform()
+ template = create_notification_template()
+
+ webform = frappe.db.get_all('Web Form', limit=1)
+ frappe.db.set_value('HR Settings', 'HR Settings', {
+ 'exit_questionnaire_web_form': webform[0].name,
+ 'exit_questionnaire_notification_template': template
+ })
+
+ employee = make_employee('employeeexit3@example.com')
+ frappe.db.set_value('Employee', employee, 'relieving_date', getdate())
+
+ interview = create_exit_interview(employee)
+ send_exit_questionnaire([interview])
+
+ email_queue = frappe.db.get_all('Email Queue', ['name', 'message'], limit=1)
+ self.assertTrue('Subject: Exit Questionnaire Notification' in email_queue[0].message)
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+
+def create_exit_interview(employee, save=True):
+ interviewer = create_user('test_exit_interviewer@example.com')
+
+ doc = frappe.get_doc({
+ 'doctype': 'Exit Interview',
+ 'employee': employee,
+ 'company': '_Test Company',
+ 'status': 'Pending',
+ 'date': getdate(),
+ 'interviewers': [{
+ 'interviewer': interviewer.name
+ }],
+ 'interview_summary': 'Test'
+ })
+
+ if save:
+ return doc.insert()
+ return doc
+
+
+def create_notification_template():
+ template = frappe.db.exists('Email Template', _('Exit Questionnaire Notification'))
+ if not template:
+ base_path = frappe.get_app_path('erpnext', 'hr', 'doctype')
+ response = frappe.read_file(os.path.join(base_path, 'exit_interview/exit_questionnaire_notification_template.html'))
+
+ template = frappe.get_doc({
+ 'doctype': 'Email Template',
+ 'name': _('Exit Questionnaire Notification'),
+ 'response': response,
+ 'subject': _('Exit Questionnaire Notification'),
+ 'owner': frappe.session.user,
+ }).insert(ignore_permissions=True)
+ template = template.name
+
+ return template
\ No newline at end of file
diff --git a/erpnext/hr/doctype/hr_settings/hr_settings.json b/erpnext/hr/doctype/hr_settings/hr_settings.json
index 5148435..f9a3e05 100644
--- a/erpnext/hr/doctype/hr_settings/hr_settings.json
+++ b/erpnext/hr/doctype/hr_settings/hr_settings.json
@@ -36,7 +36,11 @@
"remind_before",
"column_break_4",
"send_interview_feedback_reminder",
- "feedback_reminder_notification_template"
+ "feedback_reminder_notification_template",
+ "employee_exit_section",
+ "exit_questionnaire_web_form",
+ "column_break_34",
+ "exit_questionnaire_notification_template"
],
"fields": [
{
@@ -226,13 +230,34 @@
"fieldname": "check_vacancies",
"fieldtype": "Check",
"label": "Check Vacancies On Job Offer Creation"
+ },
+ {
+ "fieldname": "employee_exit_section",
+ "fieldtype": "Section Break",
+ "label": "Employee Exit Settings"
+ },
+ {
+ "fieldname": "exit_questionnaire_web_form",
+ "fieldtype": "Link",
+ "label": "Exit Questionnaire Web Form",
+ "options": "Web Form"
+ },
+ {
+ "fieldname": "exit_questionnaire_notification_template",
+ "fieldtype": "Link",
+ "label": "Exit Questionnaire Notification Template",
+ "options": "Email Template"
+ },
+ {
+ "fieldname": "column_break_34",
+ "fieldtype": "Column Break"
}
],
"icon": "fa fa-cog",
"idx": 1,
"issingle": 1,
"links": [],
- "modified": "2021-10-01 23:46:11.098236",
+ "modified": "2021-12-05 14:48:10.884253",
"modified_by": "Administrator",
"module": "HR",
"name": "HR Settings",
diff --git a/erpnext/hr/notification/exit_interview_scheduled/__init__.py b/erpnext/hr/notification/exit_interview_scheduled/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/hr/notification/exit_interview_scheduled/__init__.py
diff --git a/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.json b/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.json
new file mode 100644
index 0000000..8323ef0
--- /dev/null
+++ b/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.json
@@ -0,0 +1,29 @@
+{
+ "attach_print": 0,
+ "channel": "Email",
+ "condition": "doc.date and doc.email and doc.docstatus != 2 and doc.status == 'Scheduled'",
+ "creation": "2021-12-05 22:11:47.263933",
+ "date_changed": "date",
+ "days_in_advance": 1,
+ "docstatus": 0,
+ "doctype": "Notification",
+ "document_type": "Exit Interview",
+ "enabled": 1,
+ "event": "Days Before",
+ "idx": 0,
+ "is_standard": 1,
+ "message": "<table class=\"panel-header\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\">\n\t<tr height=\"10\"></tr>\n\t<tr>\n\t\t<td width=\"15\"></td>\n\t\t<td>\n\t\t\t<div class=\"text-medium text-muted\">\n\t\t\t\t<span>{{_(\"Exit Interview Scheduled:\")}} {{ doc.name }}</span>\n\t\t\t</div>\n\t\t</td>\n\t\t<td width=\"15\"></td>\n\t</tr>\n\t<tr height=\"10\"></tr>\n</table>\n\n<table class=\"panel-body\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\">\n\t<tr height=\"10\"></tr>\n\t<tr>\n\t\t<td width=\"15\"></td>\n\t\t<td>\n\t\t\t<div>\n\t\t\t\t<ul class=\"list-unstyled\" style=\"line-height: 1.7\">\n\t\t\t\t\t<li>{{_(\"Employee\")}}: <b>{{ doc.employee }} - {{ doc.employee_name }}</b></li>\n\t\t\t\t\t<li>{{_(\"Date\")}}: <b>{{ doc.date }}</b></li>\n\t\t\t\t\t<li> {{_(\"Interviewers\")}}: </li>\n\t\t\t\t\t{% for entry in doc.interviewers %}\n\t\t\t\t\t\t<ul>\n\t\t\t\t\t\t\t<li>{{ entry.user }}</li>\n\t\t\t\t\t\t</ul>\n\t\t\t\t\t{% endfor %}\n\t\t\t\t\t<li>{{ _(\"Interview Document\") }}: {{ frappe.utils.get_link_to_form(doc.doctype, doc.name) }}</li>\n\t\t\t\t</ul>\n\t\t\t</div>\n\t\t</td>\n\t\t<td width=\"15\"></td>\n\t</tr>\n\t<tr height=\"10\"></tr>\n</table>\n",
+ "modified": "2021-12-05 22:26:57.096159",
+ "modified_by": "Administrator",
+ "module": "HR",
+ "name": "Exit Interview Scheduled",
+ "owner": "Administrator",
+ "recipients": [
+ {
+ "receiver_by_document_field": "email"
+ }
+ ],
+ "send_system_notification": 0,
+ "send_to_all_assignees": 1,
+ "subject": "Exit Interview Scheduled: {{ doc.name }}"
+}
\ No newline at end of file
diff --git a/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.md b/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.md
new file mode 100644
index 0000000..6d6db40
--- /dev/null
+++ b/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.md
@@ -0,0 +1,37 @@
+<table class="panel-header" border="0" cellpadding="0" cellspacing="0" width="100%">
+ <tr height="10"></tr>
+ <tr>
+ <td width="15"></td>
+ <td>
+ <div class="text-medium text-muted">
+ <h2>{{_("Exit Interview Scheduled:")}} {{ doc.name }}</h2>
+ </div>
+ </td>
+ <td width="15"></td>
+ </tr>
+ <tr height="10"></tr>
+</table>
+
+<table class="panel-body" border="0" cellpadding="0" cellspacing="0" width="100%">
+ <tr height="10"></tr>
+ <tr>
+ <td width="15"></td>
+ <td>
+ <div>
+ <ul class="list-unstyled" style="line-height: 1.7">
+ <li><b>{{_("Employee")}}: </b>{{ doc.employee }} - {{ doc.employee_name }}</li>
+ <li><b>{{_("Date")}}: </b>{{ frappe.utils.formatdate(doc.date) }}</li>
+ <li><b>{{_("Interviewers")}}:</b> </li>
+ {% for entry in doc.interviewers %}
+ <ul>
+ <li>{{ entry.user }}</li>
+ </ul>
+ {% endfor %}
+ <li><b>{{ _("Interview Document") }}:</b> {{ frappe.utils.get_link_to_form(doc.doctype, doc.name) }}</li>
+ </ul>
+ </div>
+ </td>
+ <td width="15"></td>
+ </tr>
+ <tr height="10"></tr>
+</table>
diff --git a/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.py b/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.py
new file mode 100644
index 0000000..5f697c9
--- /dev/null
+++ b/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.py
@@ -0,0 +1,6 @@
+# import frappe
+
+
+def get_context(context):
+ # do your magic here
+ pass
diff --git a/erpnext/hr/report/employee_exits/__init__.py b/erpnext/hr/report/employee_exits/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/hr/report/employee_exits/__init__.py
diff --git a/erpnext/hr/report/employee_exits/employee_exits.js b/erpnext/hr/report/employee_exits/employee_exits.js
new file mode 100644
index 0000000..ac677d8
--- /dev/null
+++ b/erpnext/hr/report/employee_exits/employee_exits.js
@@ -0,0 +1,77 @@
+// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+/* eslint-disable */
+
+frappe.query_reports["Employee Exits"] = {
+ filters: [
+ {
+ "fieldname": "from_date",
+ "label": __("From Date"),
+ "fieldtype": "Date",
+ "default": frappe.datetime.add_months(frappe.datetime.nowdate(), -12)
+ },
+ {
+ "fieldname": "to_date",
+ "label": __("To Date"),
+ "fieldtype": "Date",
+ "default": frappe.datetime.nowdate()
+ },
+ {
+ "fieldname": "company",
+ "label": __("Company"),
+ "fieldtype": "Link",
+ "options": "Company"
+ },
+ {
+ "fieldname": "department",
+ "label": __("Department"),
+ "fieldtype": "Link",
+ "options": "Department"
+ },
+ {
+ "fieldname": "designation",
+ "label": __("Designation"),
+ "fieldtype": "Link",
+ "options": "Designation"
+ },
+ {
+ "fieldname": "employee",
+ "label": __("Employee"),
+ "fieldtype": "Link",
+ "options": "Employee"
+ },
+ {
+ "fieldname": "reports_to",
+ "label": __("Reports To"),
+ "fieldtype": "Link",
+ "options": "Employee"
+ },
+ {
+ "fieldname": "interview_status",
+ "label": __("Interview Status"),
+ "fieldtype": "Select",
+ "options": ["", "Pending", "Scheduled", "Completed"]
+ },
+ {
+ "fieldname": "final_decision",
+ "label": __("Final Decision"),
+ "fieldtype": "Select",
+ "options": ["", "Employee Retained", "Exit Confirmed"]
+ },
+ {
+ "fieldname": "exit_interview_pending",
+ "label": __("Exit Interview Pending"),
+ "fieldtype": "Check"
+ },
+ {
+ "fieldname": "questionnaire_pending",
+ "label": __("Exit Questionnaire Pending"),
+ "fieldtype": "Check"
+ },
+ {
+ "fieldname": "fnf_pending",
+ "label": __("FnF Pending"),
+ "fieldtype": "Check"
+ }
+ ]
+};
diff --git a/erpnext/hr/report/employee_exits/employee_exits.json b/erpnext/hr/report/employee_exits/employee_exits.json
new file mode 100644
index 0000000..4fe9a85
--- /dev/null
+++ b/erpnext/hr/report/employee_exits/employee_exits.json
@@ -0,0 +1,33 @@
+{
+ "add_total_row": 0,
+ "columns": [],
+ "creation": "2021-12-05 19:47:18.332319",
+ "disable_prepared_report": 0,
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "letter_head": "Test",
+ "modified": "2021-12-05 19:47:18.332319",
+ "modified_by": "Administrator",
+ "module": "HR",
+ "name": "Employee Exits",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Exit Interview",
+ "report_name": "Employee Exits",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "System Manager"
+ },
+ {
+ "role": "HR Manager"
+ },
+ {
+ "role": "HR User"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/erpnext/hr/report/employee_exits/employee_exits.py b/erpnext/hr/report/employee_exits/employee_exits.py
new file mode 100644
index 0000000..8e0b07d
--- /dev/null
+++ b/erpnext/hr/report/employee_exits/employee_exits.py
@@ -0,0 +1,231 @@
+# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
+# License: MIT. See LICENSE
+
+import frappe
+from frappe import _
+from frappe.query_builder import Order
+from frappe.utils import getdate
+
+
+def execute(filters=None):
+ columns = get_columns()
+ data = get_data(filters)
+ chart = get_chart_data(data)
+ report_summary = get_report_summary(data)
+
+ return columns, data, None, chart, report_summary
+
+def get_columns():
+ return [
+ {
+ 'label': _('Employee'),
+ 'fieldname': 'employee',
+ 'fieldtype': 'Link',
+ 'options': 'Employee',
+ 'width': 150
+ },
+ {
+ 'label': _('Employee Name'),
+ 'fieldname': 'employee_name',
+ 'fieldtype': 'Data',
+ 'width': 150
+ },
+ {
+ 'label': _('Date of Joining'),
+ 'fieldname': 'date_of_joining',
+ 'fieldtype': 'Date',
+ 'width': 120
+ },
+ {
+ 'label': _('Relieving Date'),
+ 'fieldname': 'relieving_date',
+ 'fieldtype': 'Date',
+ 'width': 120
+ },
+ {
+ 'label': _('Exit Interview'),
+ 'fieldname': 'exit_interview',
+ 'fieldtype': 'Link',
+ 'options': 'Exit Interview',
+ 'width': 150
+ },
+ {
+ 'label': _('Interview Status'),
+ 'fieldname': 'interview_status',
+ 'fieldtype': 'Data',
+ 'width': 130
+ },
+ {
+ 'label': _('Final Decision'),
+ 'fieldname': 'employee_status',
+ 'fieldtype': 'Data',
+ 'width': 150
+ },
+ {
+ 'label': _('Full and Final Statement'),
+ 'fieldname': 'full_and_final_statement',
+ 'fieldtype': 'Link',
+ 'options': 'Full and Final Statement',
+ 'width': 180
+ },
+ {
+ 'label': _('Department'),
+ 'fieldname': 'department',
+ 'fieldtype': 'Link',
+ 'options': 'Department',
+ 'width': 120
+ },
+ {
+ 'label': _('Designation'),
+ 'fieldname': 'designation',
+ 'fieldtype': 'Link',
+ 'options': 'Designation',
+ 'width': 120
+ },
+ {
+ 'label': _('Reports To'),
+ 'fieldname': 'reports_to',
+ 'fieldtype': 'Link',
+ 'options': 'Employee',
+ 'width': 120
+ }
+ ]
+
+def get_data(filters):
+ employee = frappe.qb.DocType('Employee')
+ interview = frappe.qb.DocType('Exit Interview')
+ fnf = frappe.qb.DocType('Full and Final Statement')
+
+ query = (
+ frappe.qb.from_(employee)
+ .left_join(interview).on(interview.employee == employee.name)
+ .left_join(fnf).on(fnf.employee == employee.name)
+ .select(
+ employee.name.as_('employee'), employee.employee_name.as_('employee_name'),
+ employee.date_of_joining.as_('date_of_joining'), employee.relieving_date.as_('relieving_date'),
+ employee.department.as_('department'), employee.designation.as_('designation'),
+ employee.reports_to.as_('reports_to'), interview.name.as_('exit_interview'),
+ interview.status.as_('interview_status'), interview.employee_status.as_('employee_status'),
+ interview.reference_document_name.as_('questionnaire'), fnf.name.as_('full_and_final_statement'))
+ .distinct()
+ .orderby(employee.relieving_date, order=Order.asc)
+ .where(
+ ((employee.relieving_date.isnotnull()) | (employee.relieving_date != ''))
+ & ((interview.name.isnull()) | ((interview.name.isnotnull()) & (interview.docstatus != 2)))
+ & ((fnf.name.isnull()) | ((fnf.name.isnotnull()) & (fnf.docstatus != 2)))
+ )
+ )
+
+ query = get_conditions(filters, query, employee, interview, fnf)
+ result = query.run(as_dict=True)
+
+ return result
+
+
+def get_conditions(filters, query, employee, interview, fnf):
+ if filters.get('from_date') and filters.get('to_date'):
+ query = query.where(employee.relieving_date[getdate(filters.get('from_date')):getdate(filters.get('to_date'))])
+
+ elif filters.get('from_date'):
+ query = query.where(employee.relieving_date >= filters.get('from_date'))
+
+ elif filters.get('to_date'):
+ query = query.where(employee.relieving_date <= filters.get('to_date'))
+
+ if filters.get('company'):
+ query = query.where(employee.company == filters.get('company'))
+
+ if filters.get('department'):
+ query = query.where(employee.department == filters.get('department'))
+
+ if filters.get('designation'):
+ query = query.where(employee.designation == filters.get('designation'))
+
+ if filters.get('employee'):
+ query = query.where(employee.name == filters.get('employee'))
+
+ if filters.get('reports_to'):
+ query = query.where(employee.reports_to == filters.get('reports_to'))
+
+ if filters.get('interview_status'):
+ query = query.where(interview.status == filters.get('interview_status'))
+
+ if filters.get('final_decision'):
+ query = query.where(interview.employee_status == filters.get('final_decision'))
+
+ if filters.get('exit_interview_pending'):
+ query = query.where((interview.name == '') | (interview.name.isnull()))
+
+ if filters.get('questionnaire_pending'):
+ query = query.where((interview.reference_document_name == '') | (interview.reference_document_name.isnull()))
+
+ if filters.get('fnf_pending'):
+ query = query.where((fnf.name == '') | (fnf.name.isnull()))
+
+ return query
+
+
+def get_chart_data(data):
+ if not data:
+ return None
+
+ retained = 0
+ exit_confirmed = 0
+ pending = 0
+
+ for entry in data:
+ if entry.employee_status == 'Employee Retained':
+ retained += 1
+ elif entry.employee_status == 'Exit Confirmed':
+ exit_confirmed += 1
+ else:
+ pending += 1
+
+ chart = {
+ 'data': {
+ 'labels': [_('Retained'), _('Exit Confirmed'), _('Decision Pending')],
+ 'datasets': [{'name': _('Employee Status'), 'values': [retained, exit_confirmed, pending]}]
+ },
+ 'type': 'donut',
+ 'colors': ['green', 'red', 'blue'],
+ }
+
+ return chart
+
+
+def get_report_summary(data):
+ if not data:
+ return None
+
+ total_resignations = len(data)
+ interviews_pending = len([entry.name for entry in data if not entry.exit_interview])
+ fnf_pending = len([entry.name for entry in data if not entry.full_and_final_statement])
+ questionnaires_pending = len([entry.name for entry in data if not entry.questionnaire])
+
+ return [
+ {
+ 'value': total_resignations,
+ 'label': _('Total Resignations'),
+ 'indicator': 'Red' if total_resignations > 0 else 'Green',
+ 'datatype': 'Int',
+ },
+ {
+ 'value': interviews_pending,
+ 'label': _('Pending Interviews'),
+ 'indicator': 'Blue' if interviews_pending > 0 else 'Green',
+ 'datatype': 'Int',
+ },
+ {
+ 'value': fnf_pending,
+ 'label': _('Pending FnF'),
+ 'indicator': 'Blue' if fnf_pending > 0 else 'Green',
+ 'datatype': 'Int',
+ },
+ {
+ 'value': questionnaires_pending,
+ 'label': _('Pending Questionnaires'),
+ 'indicator': 'Blue' if questionnaires_pending > 0 else 'Green',
+ 'datatype': 'Int'
+ },
+ ]
+
diff --git a/erpnext/hr/report/employee_exits/test_employee_exits.py b/erpnext/hr/report/employee_exits/test_employee_exits.py
new file mode 100644
index 0000000..d7e95a6
--- /dev/null
+++ b/erpnext/hr/report/employee_exits/test_employee_exits.py
@@ -0,0 +1,242 @@
+import unittest
+
+import frappe
+from frappe.utils import add_days, getdate
+
+from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.hr.doctype.exit_interview.test_exit_interview import create_exit_interview
+from erpnext.hr.doctype.full_and_final_statement.test_full_and_final_statement import (
+ create_full_and_final_statement,
+)
+from erpnext.hr.report.employee_exits.employee_exits import execute
+
+
+class TestEmployeeExits(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ create_company()
+ frappe.db.sql("delete from `tabEmployee` where company='Test Company'")
+ frappe.db.sql("delete from `tabFull and Final Statement` where company='Test Company'")
+ frappe.db.sql("delete from `tabExit Interview` where company='Test Company'")
+
+ cls.create_records()
+
+ @classmethod
+ def tearDownClass(cls):
+ frappe.db.rollback()
+
+ @classmethod
+ def create_records(cls):
+ cls.emp1 = make_employee(
+ 'employeeexit1@example.com',
+ company='Test Company',
+ date_of_joining=getdate('01-10-2021'),
+ relieving_date=add_days(getdate(), 14),
+ designation='Accountant'
+ )
+ cls.emp2 = make_employee(
+ 'employeeexit2@example.com',
+ company='Test Company',
+ date_of_joining=getdate('01-12-2021'),
+ relieving_date=add_days(getdate(), 15),
+ designation='Accountant'
+ )
+
+ cls.emp3 = make_employee(
+ 'employeeexit3@example.com',
+ company='Test Company',
+ date_of_joining=getdate('02-12-2021'),
+ relieving_date=add_days(getdate(), 29),
+ designation='Engineer'
+ )
+ cls.emp4 = make_employee(
+ 'employeeexit4@example.com',
+ company='Test Company',
+ date_of_joining=getdate('01-12-2021'),
+ relieving_date=add_days(getdate(), 30),
+ designation='Engineer'
+ )
+
+ # exit interview for 3 employees only
+ cls.interview1 = create_exit_interview(cls.emp1)
+ cls.interview2 = create_exit_interview(cls.emp2)
+ cls.interview3 = create_exit_interview(cls.emp3)
+
+ # create fnf for some records
+ cls.fnf1 = create_full_and_final_statement(cls.emp1)
+ cls.fnf2 = create_full_and_final_statement(cls.emp2)
+
+ # link questionnaire for a few records
+ # setting employee doctype as reference instead of creating a questionnaire
+ # since this is just for a test
+ frappe.db.set_value('Exit Interview', cls.interview1.name, {
+ 'ref_doctype': 'Employee',
+ 'reference_document_name': cls.emp1
+ })
+
+ frappe.db.set_value('Exit Interview', cls.interview2.name, {
+ 'ref_doctype': 'Employee',
+ 'reference_document_name': cls.emp2
+ })
+
+ frappe.db.set_value('Exit Interview', cls.interview3.name, {
+ 'ref_doctype': 'Employee',
+ 'reference_document_name': cls.emp3
+ })
+
+
+ def test_employee_exits_summary(self):
+ filters = {
+ 'company': 'Test Company',
+ 'from_date': getdate(),
+ 'to_date': add_days(getdate(), 15),
+ 'designation': 'Accountant'
+ }
+
+ report = execute(filters)
+
+ employee1 = frappe.get_doc('Employee', self.emp1)
+ employee2 = frappe.get_doc('Employee', self.emp2)
+ expected_data = [
+ {
+ 'employee': employee1.name,
+ 'employee_name': employee1.employee_name,
+ 'date_of_joining': employee1.date_of_joining,
+ 'relieving_date': employee1.relieving_date,
+ 'department': employee1.department,
+ 'designation': employee1.designation,
+ 'reports_to': None,
+ 'exit_interview': self.interview1.name,
+ 'interview_status': self.interview1.status,
+ 'employee_status': '',
+ 'questionnaire': employee1.name,
+ 'full_and_final_statement': self.fnf1.name
+ },
+ {
+ 'employee': employee2.name,
+ 'employee_name': employee2.employee_name,
+ 'date_of_joining': employee2.date_of_joining,
+ 'relieving_date': employee2.relieving_date,
+ 'department': employee2.department,
+ 'designation': employee2.designation,
+ 'reports_to': None,
+ 'exit_interview': self.interview2.name,
+ 'interview_status': self.interview2.status,
+ 'employee_status': '',
+ 'questionnaire': employee2.name,
+ 'full_and_final_statement': self.fnf2.name
+ }
+ ]
+
+ self.assertEqual(expected_data, report[1]) # rows
+
+
+ def test_pending_exit_interviews_summary(self):
+ filters = {
+ 'company': 'Test Company',
+ 'from_date': getdate(),
+ 'to_date': add_days(getdate(), 30),
+ 'exit_interview_pending': 1
+ }
+
+ report = execute(filters)
+
+ employee4 = frappe.get_doc('Employee', self.emp4)
+ expected_data = [{
+ 'employee': employee4.name,
+ 'employee_name': employee4.employee_name,
+ 'date_of_joining': employee4.date_of_joining,
+ 'relieving_date': employee4.relieving_date,
+ 'department': employee4.department,
+ 'designation': employee4.designation,
+ 'reports_to': None,
+ 'exit_interview': None,
+ 'interview_status': None,
+ 'employee_status': None,
+ 'questionnaire': None,
+ 'full_and_final_statement': None
+ }]
+
+ self.assertEqual(expected_data, report[1]) # rows
+
+ def test_pending_exit_questionnaire_summary(self):
+ filters = {
+ 'company': 'Test Company',
+ 'from_date': getdate(),
+ 'to_date': add_days(getdate(), 30),
+ 'questionnaire_pending': 1
+ }
+
+ report = execute(filters)
+
+ employee4 = frappe.get_doc('Employee', self.emp4)
+ expected_data = [{
+ 'employee': employee4.name,
+ 'employee_name': employee4.employee_name,
+ 'date_of_joining': employee4.date_of_joining,
+ 'relieving_date': employee4.relieving_date,
+ 'department': employee4.department,
+ 'designation': employee4.designation,
+ 'reports_to': None,
+ 'exit_interview': None,
+ 'interview_status': None,
+ 'employee_status': None,
+ 'questionnaire': None,
+ 'full_and_final_statement': None
+ }]
+
+ self.assertEqual(expected_data, report[1]) # rows
+
+
+ def test_pending_fnf_summary(self):
+ filters = {
+ 'company': 'Test Company',
+ 'fnf_pending': 1
+ }
+
+ report = execute(filters)
+
+ employee3 = frappe.get_doc('Employee', self.emp3)
+ employee4 = frappe.get_doc('Employee', self.emp4)
+ expected_data = [
+ {
+ 'employee': employee3.name,
+ 'employee_name': employee3.employee_name,
+ 'date_of_joining': employee3.date_of_joining,
+ 'relieving_date': employee3.relieving_date,
+ 'department': employee3.department,
+ 'designation': employee3.designation,
+ 'reports_to': None,
+ 'exit_interview': self.interview3.name,
+ 'interview_status': self.interview3.status,
+ 'employee_status': '',
+ 'questionnaire': employee3.name,
+ 'full_and_final_statement': None
+ },
+ {
+ 'employee': employee4.name,
+ 'employee_name': employee4.employee_name,
+ 'date_of_joining': employee4.date_of_joining,
+ 'relieving_date': employee4.relieving_date,
+ 'department': employee4.department,
+ 'designation': employee4.designation,
+ 'reports_to': None,
+ 'exit_interview': None,
+ 'interview_status': None,
+ 'employee_status': None,
+ 'questionnaire': None,
+ 'full_and_final_statement': None
+ }
+ ]
+
+ self.assertEqual(expected_data, report[1]) # rows
+
+
+def create_company():
+ if not frappe.db.exists('Company', 'Test Company'):
+ frappe.get_doc({
+ 'doctype': 'Company',
+ 'company_name': 'Test Company',
+ 'default_currency': 'INR',
+ 'country': 'India'
+ }).insert()
\ No newline at end of file
diff --git a/erpnext/hr/workspace/hr/hr.json b/erpnext/hr/workspace/hr/hr.json
index 7408d63..85e641c 100644
--- a/erpnext/hr/workspace/hr/hr.json
+++ b/erpnext/hr/workspace/hr/hr.json
@@ -5,7 +5,7 @@
"label": "Outgoing Salary"
}
],
- "content": "[{\"type\": \"onboarding\", \"data\": {\"onboarding_name\":\"Human Resource\", \"col\": 12}}, {\"type\": \"chart\", \"data\": {\"chart_name\": \"Outgoing Salary\", \"col\": 12}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Employee\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Leave Application\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Attendance\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Job Applicant\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Monthly Attendance Sheet\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Dashboard\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Employee\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Employee Lifecycle\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Shift Management\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Leaves\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Attendance\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Expense Claims\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Settings\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Fleet Management\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Recruitment\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Loans\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Training\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Performance\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Key Reports\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Other Reports\", \"col\": 4}}]",
+ "content": "[{\"type\":\"onboarding\",\"data\":{\"onboarding_name\":\"Human Resource\",\"col\":12}},{\"type\":\"chart\",\"data\":{\"chart_name\":\"Outgoing Salary\",\"col\":12}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\",\"level\":4,\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Employee\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Leave Application\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Attendance\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Job Applicant\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Monthly Attendance Sheet\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":4}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\",\"level\":4,\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Employee\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Employee Lifecycle\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Employee Exit\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Shift Management\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Leaves\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Attendance\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Expense Claims\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Loans\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Recruitment\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Performance\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Fleet Management\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Training\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Key Reports\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Other Reports\",\"col\":4}}]",
"creation": "2020-03-02 15:48:58.322521",
"docstatus": 0,
"doctype": "Workspace",
@@ -16,14 +16,6 @@
"label": "HR",
"links": [
{
- "hidden": 0,
- "is_query_report": 0,
- "label": "Employee",
- "link_count": 0,
- "onboard": 0,
- "type": "Card Break"
- },
- {
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
@@ -112,14 +104,6 @@
"type": "Link"
},
{
- "hidden": 0,
- "is_query_report": 0,
- "label": "Employee Lifecycle",
- "link_count": 0,
- "onboard": 0,
- "type": "Card Break"
- },
- {
"dependencies": "Job Applicant",
"hidden": 0,
"is_query_report": 0,
@@ -228,14 +212,6 @@
"type": "Link"
},
{
- "hidden": 0,
- "is_query_report": 0,
- "label": "Shift Management",
- "link_count": 0,
- "onboard": 0,
- "type": "Card Break"
- },
- {
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
@@ -269,14 +245,6 @@
"type": "Link"
},
{
- "hidden": 0,
- "is_query_report": 0,
- "label": "Leaves",
- "link_count": 0,
- "onboard": 0,
- "type": "Card Break"
- },
- {
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
@@ -387,14 +355,6 @@
"type": "Link"
},
{
- "hidden": 0,
- "is_query_report": 0,
- "label": "Attendance",
- "link_count": 0,
- "onboard": 0,
- "type": "Card Break"
- },
- {
"dependencies": "Employee",
"hidden": 0,
"is_query_report": 0,
@@ -450,14 +410,6 @@
"type": "Link"
},
{
- "hidden": 0,
- "is_query_report": 0,
- "label": "Expense Claims",
- "link_count": 0,
- "onboard": 0,
- "type": "Card Break"
- },
- {
"dependencies": "Employee",
"hidden": 0,
"is_query_report": 0,
@@ -490,14 +442,6 @@
"type": "Link"
},
{
- "hidden": 0,
- "is_query_report": 0,
- "label": "Settings",
- "link_count": 0,
- "onboard": 0,
- "type": "Card Break"
- },
- {
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
@@ -533,14 +477,6 @@
{
"hidden": 0,
"is_query_report": 0,
- "label": "Fleet Management",
- "link_count": 0,
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
"label": "Driver",
"link_count": 0,
"link_to": "Driver",
@@ -582,14 +518,6 @@
"type": "Link"
},
{
- "hidden": 0,
- "is_query_report": 0,
- "label": "Recruitment",
- "link_count": 0,
- "onboard": 0,
- "type": "Card Break"
- },
- {
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
@@ -809,14 +737,6 @@
"type": "Link"
},
{
- "hidden": 0,
- "is_query_report": 0,
- "label": "Key Reports",
- "link_count": 0,
- "onboard": 0,
- "type": "Card Break"
- },
- {
"dependencies": "Attendance",
"hidden": 0,
"is_query_report": 1,
@@ -933,9 +853,796 @@
"link_type": "Report",
"onboard": 0,
"type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Lifecycle",
+ "link_count": 7,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "Job Applicant",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Onboarding",
+ "link_count": 0,
+ "link_to": "Employee Onboarding",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Skill Map",
+ "link_count": 0,
+ "link_to": "Employee Skill Map",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Promotion",
+ "link_count": 0,
+ "link_to": "Employee Promotion",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Transfer",
+ "link_count": 0,
+ "link_to": "Employee Transfer",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Grievance Type",
+ "link_count": 0,
+ "link_to": "Grievance Type",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Grievance",
+ "link_count": 0,
+ "link_to": "Employee Grievance",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Onboarding Template",
+ "link_count": 0,
+ "link_to": "Employee Onboarding Template",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Exit",
+ "link_count": 4,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Separation Template",
+ "link_count": 0,
+ "link_to": "Employee Separation Template",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Separation",
+ "link_count": 0,
+ "link_to": "Employee Separation",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Full and Final Statement",
+ "link_count": 0,
+ "link_to": "Full and Final Statement",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Exit Interview",
+ "link_count": 0,
+ "link_to": "Exit Interview",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee",
+ "link_count": 8,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee",
+ "link_count": 0,
+ "link_to": "Employee",
+ "link_type": "DocType",
+ "onboard": 1,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employment Type",
+ "link_count": 0,
+ "link_to": "Employment Type",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Branch",
+ "link_count": 0,
+ "link_to": "Branch",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Department",
+ "link_count": 0,
+ "link_to": "Department",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Designation",
+ "link_count": 0,
+ "link_to": "Designation",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Grade",
+ "link_count": 0,
+ "link_to": "Employee Grade",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Group",
+ "link_count": 0,
+ "link_to": "Employee Group",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Health Insurance",
+ "link_count": 0,
+ "link_to": "Employee Health Insurance",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Key Reports",
+ "link_count": 7,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "Attendance",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Monthly Attendance Sheet",
+ "link_count": 0,
+ "link_to": "Monthly Attendance Sheet",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Staffing Plan",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Recruitment Analytics",
+ "link_count": 0,
+ "link_to": "Recruitment Analytics",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Employee Analytics",
+ "link_count": 0,
+ "link_to": "Employee Analytics",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Employee Leave Balance",
+ "link_count": 0,
+ "link_to": "Employee Leave Balance",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Employee Leave Balance Summary",
+ "link_count": 0,
+ "link_to": "Employee Leave Balance Summary",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee Advance",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Employee Advance Summary",
+ "link_count": 0,
+ "link_to": "Employee Advance Summary",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Exits",
+ "link_count": 0,
+ "link_to": "Employee Exits",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Recruitment",
+ "link_count": 11,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Job Opening",
+ "link_count": 0,
+ "link_to": "Job Opening",
+ "link_type": "DocType",
+ "onboard": 1,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Referral",
+ "link_count": 0,
+ "link_to": "Employee Referral",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Job Applicant",
+ "link_count": 0,
+ "link_to": "Job Applicant",
+ "link_type": "DocType",
+ "onboard": 1,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Job Offer",
+ "link_count": 0,
+ "link_to": "Job Offer",
+ "link_type": "DocType",
+ "onboard": 1,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Staffing Plan",
+ "link_count": 0,
+ "link_to": "Staffing Plan",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Appointment Letter",
+ "link_count": 0,
+ "link_to": "Appointment Letter",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Appointment Letter Template",
+ "link_count": 0,
+ "link_to": "Appointment Letter Template",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Interview Type",
+ "link_count": 0,
+ "link_to": "Interview Type",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Interview Round",
+ "link_count": 0,
+ "link_to": "Interview Round",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Interview",
+ "link_count": 0,
+ "link_to": "Interview",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Interview Feedback",
+ "link_count": 0,
+ "link_to": "Interview Feedback",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Fleet Management",
+ "link_count": 4,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Driver",
+ "link_count": 0,
+ "link_to": "Driver",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Vehicle",
+ "link_count": 0,
+ "link_to": "Vehicle",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Vehicle Log",
+ "link_count": 0,
+ "link_to": "Vehicle Log",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Vehicle",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Vehicle Expenses",
+ "link_count": 0,
+ "link_to": "Vehicle Expenses",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Settings",
+ "link_count": 3,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "HR Settings",
+ "link_count": 0,
+ "link_to": "HR Settings",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Daily Work Summary Group",
+ "link_count": 0,
+ "link_to": "Daily Work Summary Group",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Team Updates",
+ "link_count": 0,
+ "link_to": "team-updates",
+ "link_type": "Page",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Expense Claims",
+ "link_count": 3,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Expense Claim",
+ "link_count": 0,
+ "link_to": "Expense Claim",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Advance",
+ "link_count": 0,
+ "link_to": "Employee Advance",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Travel Request",
+ "link_count": 0,
+ "link_to": "Travel Request",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Attendance",
+ "link_count": 5,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Attendance Tool",
+ "link_count": 0,
+ "link_to": "Employee Attendance Tool",
+ "link_type": "DocType",
+ "onboard": 1,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Attendance",
+ "link_count": 0,
+ "link_to": "Attendance",
+ "link_type": "DocType",
+ "onboard": 1,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Attendance Request",
+ "link_count": 0,
+ "link_to": "Attendance Request",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Upload Attendance",
+ "link_count": 0,
+ "link_to": "Upload Attendance",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Employee Checkin",
+ "link_count": 0,
+ "link_to": "Employee Checkin",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Leaves",
+ "link_count": 10,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Holiday List",
+ "link_count": 0,
+ "link_to": "Holiday List",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Leave Type",
+ "link_count": 0,
+ "link_to": "Leave Type",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Leave Period",
+ "link_count": 0,
+ "link_to": "Leave Period",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Leave Type",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Leave Policy",
+ "link_count": 0,
+ "link_to": "Leave Policy",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Leave Policy",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Leave Policy Assignment",
+ "link_count": 0,
+ "link_to": "Leave Policy Assignment",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Leave Application",
+ "link_count": 0,
+ "link_to": "Leave Application",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Leave Allocation",
+ "link_count": 0,
+ "link_to": "Leave Allocation",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Leave Encashment",
+ "link_count": 0,
+ "link_to": "Leave Encashment",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Leave Block List",
+ "link_count": 0,
+ "link_to": "Leave Block List",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "Employee",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Compensatory Leave Request",
+ "link_count": 0,
+ "link_to": "Compensatory Leave Request",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Shift Management",
+ "link_count": 3,
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Shift Type",
+ "link_count": 0,
+ "link_to": "Shift Type",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Shift Request",
+ "link_count": 0,
+ "link_to": "Shift Request",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "dependencies": "",
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Shift Assignment",
+ "link_count": 0,
+ "link_to": "Shift Assignment",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
}
],
- "modified": "2021-08-31 12:18:59.842919",
+ "modified": "2021-12-05 22:05:13.004462",
"modified_by": "Administrator",
"module": "HR",
"name": "HR",
diff --git a/erpnext/manufacturing/doctype/job_card/job_card_dashboard.py b/erpnext/manufacturing/doctype/job_card/job_card_dashboard.py
index acaa895..2c48872 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card_dashboard.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card_dashboard.py
@@ -4,10 +4,17 @@
def get_data():
return {
'fieldname': 'job_card',
+ 'non_standard_fieldnames': {
+ 'Quality Inspection': 'reference_name'
+ },
'transactions': [
{
'label': _('Transactions'),
'items': ['Material Request', 'Stock Entry']
+ },
+ {
+ 'label': _('Reference'),
+ 'items': ['Quality Inspection']
}
]
}
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index f590d68..1e74b6d 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -29,6 +29,9 @@
self.warehouse = '_Test Warehouse 2 - _TC'
self.item = '_Test Item'
+ def tearDown(self):
+ frappe.db.rollback()
+
def check_planned_qty(self):
planned0 = frappe.db.get_value("Bin", {"item_code": "_Test FG Item",
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index ee9060b..0687b42 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -313,4 +313,8 @@
erpnext.patches.v13_0.create_pan_field_for_india #2
erpnext.patches.v14_0.delete_hub_doctypes
erpnext.patches.v13_0.create_ksa_vat_custom_fields
+erpnext.patches.v14_0.rename_ongoing_status_in_sla_documents
erpnext.patches.v14_0.migrate_crm_settings
+erpnext.patches.v13_0.rename_ksa_qr_field
+erpnext.patches.v13_0.disable_ksa_print_format_for_others
+erpnext.patches.v14_0.add_default_exit_questionnaire_notification_template
\ No newline at end of file
diff --git a/erpnext/patches/v13_0/disable_ksa_print_format_for_others.py b/erpnext/patches/v13_0/disable_ksa_print_format_for_others.py
new file mode 100644
index 0000000..c815b3b
--- /dev/null
+++ b/erpnext/patches/v13_0/disable_ksa_print_format_for_others.py
@@ -0,0 +1,16 @@
+# Copyright (c) 2020, Wahni Green Technologies and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'Saudi Arabia'})
+ if company:
+ return
+
+ if frappe.db.exists('DocType', 'Print Format'):
+ frappe.reload_doc("regional", "print_format", "ksa_vat_invoice", force=True)
+ frappe.reload_doc("regional", "print_format", "ksa_pos_invoice", force=True)
+ for d in ('KSA VAT Invoice', 'KSA POS Invoice'):
+ frappe.db.set_value("Print Format", d, "disabled", 1)
diff --git a/erpnext/patches/v13_0/rename_ksa_qr_field.py b/erpnext/patches/v13_0/rename_ksa_qr_field.py
new file mode 100644
index 0000000..0bb86e0
--- /dev/null
+++ b/erpnext/patches/v13_0/rename_ksa_qr_field.py
@@ -0,0 +1,16 @@
+# Copyright (c) 2020, Wahni Green Technologies and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe.model.utils.rename_field import rename_field
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'Saudi Arabia'})
+ if not company:
+ return
+
+ if frappe.db.exists('DocType', 'Sales Invoice'):
+ frappe.reload_doc('accounts', 'doctype', 'sales_invoice', force=True)
+ if frappe.db.has_column('Sales Invoice', 'qr_code'):
+ rename_field('Sales Invoice', 'qr_code', 'ksa_einv_qr')
diff --git a/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py b/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py
new file mode 100644
index 0000000..8b1752b
--- /dev/null
+++ b/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py
@@ -0,0 +1,27 @@
+import os
+
+import frappe
+from frappe import _
+
+
+def execute():
+ frappe.reload_doc("email", "doctype", "email_template")
+ frappe.reload_doc("hr", "doctype", "hr_settings")
+
+ template = frappe.db.exists("Email Template", _("Exit Questionnaire Notification"))
+ if not template:
+ base_path = frappe.get_app_path("erpnext", "hr", "doctype")
+ response = frappe.read_file(os.path.join(base_path, "exit_interview/exit_questionnaire_notification_template.html"))
+
+ template = frappe.get_doc({
+ "doctype": "Email Template",
+ "name": _("Exit Questionnaire Notification"),
+ "response": response,
+ "subject": _("Exit Questionnaire Notification"),
+ "owner": frappe.session.user,
+ }).insert(ignore_permissions=True)
+ template = template.name
+
+ hr_settings = frappe.get_doc("HR Settings")
+ hr_settings.exit_questionnaire_notification_template = template
+ hr_settings.save()
diff --git a/erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py b/erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py
new file mode 100644
index 0000000..1cc5f38
--- /dev/null
+++ b/erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py
@@ -0,0 +1,27 @@
+import frappe
+
+
+def execute():
+ active_sla_documents = [sla.document_type for sla in frappe.get_all("Service Level Agreement", fields=["document_type"])]
+
+ for doctype in active_sla_documents:
+ doctype = frappe.qb.DocType(doctype)
+ try:
+ frappe.qb.update(
+ doctype
+ ).set(
+ doctype.agreement_status, 'First Response Due'
+ ).where(
+ doctype.first_responded_on.isnull()
+ ).run()
+
+ frappe.qb.update(
+ doctype
+ ).set(
+ doctype.agreement_status, 'Resolution Due'
+ ).where(
+ doctype.agreement_status == 'Ongoing'
+ ).run()
+
+ except Exception:
+ frappe.log_error(title='Failed to Patch SLA Status')
\ No newline at end of file
diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js
index f0facdd..6654048 100755
--- a/erpnext/public/js/utils.js
+++ b/erpnext/public/js/utils.js
@@ -84,6 +84,10 @@
});
},
+ route_to_pending_reposts: (args) => {
+ frappe.set_route('List', 'Repost Item Valuation', args);
+ },
+
proceed_save_with_reminders_frequency_change: () => {
frappe.ui.hide_open_dialog();
@@ -831,7 +835,7 @@
refresh: function(frm) {
if (frm.doc.status !== 'Closed' && frm.doc.service_level_agreement
- && frm.doc.agreement_status === 'Ongoing') {
+ && ['First Response Due', 'Resolution Due'].includes(frm.doc.agreement_status)) {
frappe.call({
'method': 'frappe.client.get',
args: {
@@ -884,8 +888,8 @@
function set_time_to_resolve_and_response(frm, apply_sla_for_resolution) {
frm.dashboard.clear_headline();
- let time_to_respond = get_status(frm.doc.response_by_variance);
- if (!frm.doc.first_responded_on && frm.doc.agreement_status === 'Ongoing') {
+ let time_to_respond = get_status(frm.doc.response_by);
+ if (!frm.doc.first_responded_on) {
time_to_respond = get_time_left(frm.doc.response_by, frm.doc.agreement_status);
}
@@ -899,8 +903,8 @@
if (apply_sla_for_resolution) {
- let time_to_resolve = get_status(frm.doc.resolution_by_variance);
- if (!frm.doc.resolution_date && frm.doc.agreement_status === 'Ongoing') {
+ let time_to_resolve = get_status(frm.doc.resolution_by);
+ if (!frm.doc.resolution_date) {
time_to_resolve = get_time_left(frm.doc.resolution_by, frm.doc.agreement_status);
}
@@ -924,8 +928,9 @@
return {'diff_display': diff_display, 'indicator': indicator};
}
-function get_status(variance) {
- if (variance > 0) {
+function get_status(timestamp) {
+ const time_left = moment(timestamp).diff(moment());
+ if (time_left >= 0) {
return {'diff_display': 'Fulfilled', 'indicator': 'green'};
} else {
return {'diff_display': 'Failed', 'indicator': 'red'};
diff --git a/erpnext/regional/print_format/ksa_pos_invoice/__init__.py b/erpnext/regional/print_format/ksa_pos_invoice/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/regional/print_format/ksa_pos_invoice/__init__.py
diff --git a/erpnext/regional/print_format/ksa_pos_invoice/ksa_pos_invoice.json b/erpnext/regional/print_format/ksa_pos_invoice/ksa_pos_invoice.json
new file mode 100644
index 0000000..c2a3092
--- /dev/null
+++ b/erpnext/regional/print_format/ksa_pos_invoice/ksa_pos_invoice.json
@@ -0,0 +1,32 @@
+{
+ "absolute_value": 0,
+ "align_labels_right": 0,
+ "creation": "2021-12-07 13:25:05.424827",
+ "css": "",
+ "custom_format": 1,
+ "default_print_language": "en",
+ "disabled": 1,
+ "doc_type": "POS Invoice",
+ "docstatus": 0,
+ "doctype": "Print Format",
+ "font_size": 0,
+ "html": "<style>\n\t.print-format table, .print-format tr, \n\t.print-format td, .print-format div, .print-format p {\n\t\tline-height: 150%;\n\t\tvertical-align: middle;\n\t}\n\t@media screen {\n\t\t.print-format {\n\t\t\twidth: 4in;\n\t\t\tpadding: 0.25in;\n\t\t\tmin-height: 8in;\n\t\t}\n\t}\n</style>\n\n{% if letter_head %}\n {{ letter_head }}\n{% endif %}\n\n<p class=\"text-center\" style=\"margin-bottom: 1rem\">\n\t{{ doc.company }}<br>\n\t<b>{{ doc.select_print_heading or _(\"Invoice\") }}</b><br>\n\t<img src={{doc.ksa_einv_qr}}>\n</p>\n<p>\n\t<b>{{ _(\"Receipt No\") }}:</b> {{ doc.name }}<br>\n\t<b>{{ _(\"Cashier\") }}:</b> {{ doc.owner }}<br>\n\t<b>{{ _(\"Customer\") }}:</b> {{ doc.customer_name }}<br>\n\t<b>{{ _(\"Date\") }}:</b> {{ doc.get_formatted(\"posting_date\") }}<br>\n\t<b>{{ _(\"Time\") }}:</b> {{ doc.get_formatted(\"posting_time\") }}<br>\n</p>\n\n<hr>\n<table class=\"table table-condensed\">\n\t<thead>\n\t\t<tr>\n\t\t\t<th width=\"40%\">{{ _(\"Item\") }}</th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Qty\") }}</th>\n\t\t\t<th width=\"35%\" class=\"text-right\">{{ _(\"Amount\") }}</th>\n\t\t</tr>\n\t</thead>\n\t<tbody>\n\t\t{%- for item in doc.items -%}\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t{{ item.item_code }}\n\t\t\t\t{%- if item.item_name != item.item_code -%}\n\t\t\t\t\t<br>{{ item.item_name }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.serial_no -%}\n\t\t\t\t\t<br><b>{{ _(\"SR.No\") }}:</b><br>\n\t\t\t\t\t{{ item.serial_no | replace(\"\\n\", \", \") }}\n\t\t\t\t{%- endif -%}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">{{ item.qty }}</td>\n\t\t\t<td class=\"text-right\">{{ item.get_formatted(\"net_amount\") }}</td>\n\t\t</tr>\n\t\t{%- endfor -%}\n\t</tbody>\n</table>\n<table class=\"table table-condensed no-border\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 60%\">\n\t\t\t\t{{ _(\"Total Excl. Tax\") }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"net_total\", doc) }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- for row in doc.taxes -%}\n\t\t\t<tr>\n\t\t\t\t<td class=\"text-right\" style=\"width: 60%\">\n\t\t\t\t {% if '%' in row.description %}\n\t\t\t\t\t {{ row.description }}\n\t\t\t\t\t{% else %}\n\t\t\t\t\t {{ row.description }}@{{ row.rate }}%\n\t\t\t\t\t{% endif %}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ row.get_formatted(\"tax_amount\", doc) }}\n\t\t\t\t</td>\n\t\t\t<tr>\n\t\t{%- endfor -%}\n\n\t\t{%- if doc.discount_amount -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 60%\">\n\t\t\t\t{{ _(\"Discount\") }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"discount_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 60%\">\n\t\t\t\t<b>{{ _(\"Grand Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"grand_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- if doc.rounded_total -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 60%\">\n\t\t\t\t<b>{{ _(\"Rounded Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"rounded_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 60%\">\n\t\t\t\t<b>{{ _(\"Paid Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"paid_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- if doc.change_amount -%}\n\t\t\t<tr>\n\t\t\t\t<td class=\"text-right\" style=\"width: 60%\">\n\t\t\t\t\t<b>{{ _(\"Change Amount\") }}</b>\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"change_amount\") }}\n\t\t\t\t</td>\n\t\t\t</tr>\n\t\t{%- endif -%}\n\t</tbody>\n</table>\n<hr>\n<p>{{ doc.terms or \"\" }}</p>\n<p class=\"text-center\">{{ _(\"Thank you, please visit again.\") }}</p>",
+ "idx": 0,
+ "line_breaks": 0,
+ "margin_bottom": 0.0,
+ "margin_left": 0.0,
+ "margin_right": 0.0,
+ "margin_top": 0.0,
+ "modified": "2021-12-08 10:25:01.930885",
+ "modified_by": "Administrator",
+ "module": "Regional",
+ "name": "KSA POS Invoice",
+ "owner": "Administrator",
+ "page_number": "Hide",
+ "print_format_builder": 0,
+ "print_format_builder_beta": 0,
+ "print_format_type": "Jinja",
+ "raw_printing": 0,
+ "show_section_headings": 0,
+ "standard": "Yes"
+}
\ No newline at end of file
diff --git a/erpnext/regional/print_format/ksa_vat_invoice/ksa_vat_invoice.json b/erpnext/regional/print_format/ksa_vat_invoice/ksa_vat_invoice.json
index 8e9a728..6b64d47 100644
--- a/erpnext/regional/print_format/ksa_vat_invoice/ksa_vat_invoice.json
+++ b/erpnext/regional/print_format/ksa_vat_invoice/ksa_vat_invoice.json
@@ -5,19 +5,19 @@
"css": ".qr-code{\n float:right;\n}\n\n.invoice-heading {\n margin: 0;\n}\n\n.ksa-invoice-table {\n border: 1px solid #888a8e;\n border-collapse: collapse;\n width: 100%;\n margin: 20px 0;\n font-size: 16px;\n}\n\n.ksa-invoice-table.two-columns td:nth-child(2) {\n direction: rtl;\n}\n\n.ksa-invoice-table th {\n border: 1px solid #888a8e;\n max-width: 50%;\n padding: 8px;\n}\n\n.ksa-invoice-table td {\n padding: 5px;\n border: 1px solid #888a8e;\n max-width: 50%;\n}\n\n.ksa-invoice-table thead,\n.ksa-invoice-table tfoot {\n text-transform: uppercase;\n}\n\n.qr-rtl {\n direction: rtl;\n}\n\n.qr-flex{\n display: flex;\n justify-content: space-between;\n}",
"custom_format": 1,
"default_print_language": "en",
- "disabled": 0,
+ "disabled": 1,
"doc_type": "Sales Invoice",
"docstatus": 0,
"doctype": "Print Format",
"font_size": 14,
- "html": "<div class=\"ksa-vat-format\">\n <div class=\"qr-flex\">\n <div style=\"qr-flex: 1\">\n <h2 class=\"invoice-heading\">TAX INVOICE</h2>\n <h2 class=\"invoice-heading\">\u0641\u0627\u062a\u0648\u0631\u0629 \u0636\u0631\u064a\u0628\u064a\u0629</h2>\n </div>\n \n <img class=\"qr-code\" src={{doc.qr_code}}>\n </div>\n {% set company = frappe.get_doc(\"Company\", doc.company)%}\n {% if (doc.company_address) %}\n {% set supplier_address_doc = frappe.get_doc('Address', doc.company_address) %}\n {% endif %}\n \n {% if(doc.customer_address) %}\n {% set customer_address = frappe.get_doc('Address', doc.customer_address ) %}\n {% endif %}\n \n {% if(doc.shipping_address_name) %}\n {% set customer_shipping_address = frappe.get_doc('Address', doc.shipping_address_name ) %}\n {% endif %} \n \n <table class=\"ksa-invoice-table two-columns\">\n <thead>\n <tr>\n <th>{{ company.name }}</th>\n <th style=\"text-align: right;\">{{ company.company_name_in_arabic }}</th>\n </tr>\n </thead>\n\n <tbody>\n <!-- Invoice Info -->\n <tr>\n <td>Invoice#: {{doc.name}}</td>\n <td>\u0631\u0642\u0645 \u0627\u0644\u0641\u0627\u062a\u0648\u0631\u0629: {{doc.name}}</td>\n </tr>\n <tr>\n <td>Invoice Date: {{doc.posting_date}}</td>\n <td>\u062a\u0627\u0631\u064a\u062e \u0627\u0644\u0641\u0627\u062a\u0648\u0631\u0629: {{doc.posting_date}}</td>\n </tr>\n <tr>\n <td>Date of Supply:{{doc.posting_date}}</td>\n <td>\u062a\u0627\u0631\u064a\u062e \u0627\u0644\u062a\u0648\u0631\u064a\u062f: {{doc.posting_date}}</td>\n </tr>\n \n <!--Supplier Info -->\n <tr>\n <td>Supplier:</td>\n <td>\u0627\u0644\u0645\u0648\u0631\u062f:</td>\n </tr>\n\t\t{% if (company.tax_id) %}\n <tr>\n <td>Supplier Tax Identification Number:</td>\n <td>\u0631\u0642\u0645 \u0627\u0644\u062a\u0639\u0631\u064a\u0641 \u0627\u0644\u0636\u0631\u064a\u0628\u064a \u0644\u0644\u0645\u0648\u0631\u062f:</td>\n </tr>\n <tr>\n <td>{{ company.tax_id }}</td>\n <td>{{ company.tax_id }}</td>\n </tr>\n {% endif %}\n <tr>\n <td>{{ company.name }}</td>\n <td>{{ company.company_name_in_arabic }} </td>\n </tr>\n \n \n {% if(supplier_address_doc) %}\n <tr>\n <td>{{ supplier_address_doc.address_line1}} </td>\n <td>{{ supplier_address_doc.address_in_arabic}} </td>\n </tr>\n <tr>\n <td>Phone: {{ supplier_address_doc.phone }}</td>\n <td>\u0647\u0627\u062a\u0641: {{ supplier_address_doc.phone }}</td>\n </tr>\n <tr>\n <td>Email: {{ supplier_address_doc.email_id }}</td>\n <td>\u0628\u0631\u064a\u062f \u0627\u0644\u0643\u062a\u0631\u0648\u0646\u064a: {{ supplier_address_doc.email_id }}</td>\n </tr>\n {% endif %}\n \n <!-- Customer Info -->\n <tr>\n <td>CUSTOMER:</td>\n <td>\u0639\u0645\u064a\u0644:</td>\n </tr>\n\t\t{% set customer_tax_id = frappe.db.get_value('Customer', doc.customer, 'tax_id') %}\n\t\t{% if customer_tax_id %}\n <tr>\n <td>Customer Tax Identification Number:</td>\n <td>\u0631\u0642\u0645 \u0627\u0644\u062a\u0639\u0631\u064a\u0641 \u0627\u0644\u0636\u0631\u064a\u0628\u064a \u0644\u0644\u0639\u0645\u064a\u0644:</td>\n </tr>\n <tr>\n <td>{{ customer_tax_id }}</td>\n <td>{{ customer_tax_id }}</td>\n </tr>\n {% endif %}\n <tr>\n <td> {{ doc.customer }}</td>\n <td> {{ doc.customer_name_in_arabic }} </td>\n </tr>\n \n {% if(customer_address) %}\n <tr>\n <td>{{ customer_address.address_line1}} </td>\n <td>{{ customer_address.address_in_arabic}} </td>\n </tr>\n {% endif %}\n \n {% if(customer_shipping_address) %}\n <tr>\n <td>SHIPPING ADDRESS:</td>\n <td>\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0634\u062d\u0646:</td>\n </tr>\n \n <tr>\n <td>{{ customer_shipping_address.address_line1}} </td>\n <td>{{ customer_shipping_address.address_in_arabic}} </td>\n </tr>\n {% endif %}\n \n\t\t{% if(doc.po_no) %}\n <tr>\n <td>OTHER INFORMATION</td>\n <td>\u0645\u0639\u0644\u0648\u0645\u0627\u062a \u0623\u062e\u0631\u0649</td>\n </tr>\n \n <tr>\n <td>Purchase Order Number: {{ doc.po_no }}</td>\n <td>\u0631\u0642\u0645 \u0623\u0645\u0631 \u0627\u0644\u0634\u0631\u0627\u0621: {{ doc.po_no }}</td>\n </tr>\n {% endif %}\n \n <tr>\n <td>Payment Due Date: {{ doc.due_date}} </td>\n <td>\u062a\u0627\u0631\u064a\u062e \u0627\u0633\u062a\u062d\u0642\u0627\u0642 \u0627\u0644\u062f\u0641\u0639: {{ doc.due_date}}</td>\n </tr>\n </tbody>\n </table>\n\n <!--Dynamic Colspan for total row columns-->\n {% set col = namespace(one = 2, two = 1) %}\n {% set length = doc.taxes | length %}\n {% set length = length / 2 | round %}\n {% set col.one = col.one + length %}\n {% set col.two = col.two + length %}\n \n {%- if(doc.taxes | length % 2 > 0 ) -%}\n {% set col.two = col.two + 1 %}\n {% endif %}\n \n <!-- Items -->\n {% set total = namespace(amount = 0) %}\n <table class=\"ksa-invoice-table\">\n <thead>\n <tr>\n <th>Nature of goods or services <br />\u0637\u0628\u064a\u0639\u0629 \u0627\u0644\u0633\u0644\u0639 \u0623\u0648 \u0627\u0644\u062e\u062f\u0645\u0627\u062a</th>\n <th>\n Unit price <br />\n \u0633\u0639\u0631 \u0627\u0644\u0648\u062d\u062f\u0629\n </th>\n <th>\n Quantity <br />\n \u0627\u0644\u0643\u0645\u064a\u0629\n </th>\n <th>\n Taxable Amount <br />\n \u0627\u0644\u0645\u0628\u0644\u063a \u0627\u0644\u062e\u0627\u0636\u0639 \u0644\u0644\u0636\u0631\u064a\u0628\u0629\n </th>\n \n {% for row in doc.taxes %}\n <th style=\"min-width: 130px\">{{row.description}}</th>\n {% endfor %}\n \n <th>\n Total <br />\n \u0627\u0644\u0645\u062c\u0645\u0648\u0639\n </th>\n </tr>\n </thead>\n <tbody>\n {%- for item in doc.items -%}\n {% set total.amount = item.amount %}\n <tr>\n <td>{{ item.item_code or item.item_name }}</td>\n <td>{{ item.get_formatted(\"rate\") }}</td>\n <td>{{ item.qty }}</td>\n <td>{{ item.get_formatted(\"amount\") }}</td>\n {% for row in doc.taxes %}\n {% set data_object = json.loads(row.item_wise_tax_detail) %}\n {% set key = item.item_code or item.item_name %}\n {% set tax_amount = frappe.utils.flt(data_object[key][1]/doc.conversion_rate, row.precision('tax_amount')) %}\n <td>\n <div class=\"qr-flex\">\n {%- if(data_object[key][0])-%}\n <span>{{ frappe.format(data_object[key][0], {'fieldtype': 'Percent'}) }}</span>\n {%- endif -%}\n <span>\n {%- if(data_object[key][1])-%}\n {{ frappe.format_value(tax_amount, currency=doc.currency) }}</span>\n {% set total.amount = total.amount + tax_amount %}\n {%- endif -%}\n </div>\n </td>\n {% endfor %}\n <td>{{ frappe.format_value(frappe.utils.flt(total.amount, doc.precision('total_taxes_and_charges')), currency=doc.currency) }}</td>\n </tr>\n {%- endfor -%}\n </tbody>\n <tfoot>\n <tr>\n <td>\n {{ doc.get_formatted(\"total\") }} <br />\n {{ doc.get_formatted(\"total_taxes_and_charges\") }}\n </td>\n \n <td colspan={{ col.one }} class=\"qr-rtl\">\n \u0627\u0644\u0625\u062c\u0645\u0627\u0644\u064a \u0628\u0627\u0633\u062a\u062b\u0646\u0627\u0621 \u0636\u0631\u064a\u0628\u0629 \u0627\u0644\u0642\u064a\u0645\u0629 \u0627\u0644\u0645\u0636\u0627\u0641\u0629\n <br />\n \u0625\u062c\u0645\u0627\u0644\u064a \u0636\u0631\u064a\u0628\u0629 \u0627\u0644\u0642\u064a\u0645\u0629 \u0627\u0644\u0645\u0636\u0627\u0641\u0629\n </td>\n <td colspan={{ col.two }}>\n Total (Excluding VAT)\n <br />\n Total VAT\n </td>\n <td>\n {{ doc.get_formatted(\"total\") }} <br />\n {{ doc.get_formatted(\"total_taxes_and_charges\") }}\n </td>\n </tr>\n <tr>\n <td>{{ doc.get_formatted(\"grand_total\") }}</td>\n <td colspan={{ col.one }} class=\"qr-rtl\">\n \u0625\u062c\u0645\u0627\u0644\u064a \u0627\u0644\u0645\u0628\u0644\u063a \u0627\u0644\u0645\u0633\u062a\u062d\u0642</td>\n <td colspan={{ col.two }}>Total Amount Due</td>\n <td>{{ doc.get_formatted(\"grand_total\") }}</td>\n </tr>\n </tfoot>\n </table>\n\n\t{%- if doc.terms -%}\n <p>\n {{doc.terms}}\n </p>\n\t{%- endif -%}\n</div>\n",
+ "html": "<div class=\"ksa-vat-format\">\n <div class=\"qr-flex\">\n <div style=\"qr-flex: 1\">\n <h2 class=\"invoice-heading\">TAX INVOICE</h2>\n <h2 class=\"invoice-heading\">\u0641\u0627\u062a\u0648\u0631\u0629 \u0636\u0631\u064a\u0628\u064a\u0629</h2>\n </div>\n \n <img class=\"qr-code\" src={{doc.ksa_einv_qr}}>\n </div>\n {% set company = frappe.get_doc(\"Company\", doc.company)%}\n {% if (doc.company_address) %}\n {% set supplier_address_doc = frappe.get_doc('Address', doc.company_address) %}\n {% endif %}\n \n {% if(doc.customer_address) %}\n {% set customer_address = frappe.get_doc('Address', doc.customer_address ) %}\n {% endif %}\n \n {% if(doc.shipping_address_name) %}\n {% set customer_shipping_address = frappe.get_doc('Address', doc.shipping_address_name ) %}\n {% endif %} \n \n <table class=\"ksa-invoice-table two-columns\">\n <thead>\n <tr>\n <th>{{ company.name }}</th>\n <th style=\"text-align: right;\">{{ company.company_name_in_arabic }}</th>\n </tr>\n </thead>\n\n <tbody>\n <!-- Invoice Info -->\n <tr>\n <td>Invoice#: {{doc.name}}</td>\n <td>\u0631\u0642\u0645 \u0627\u0644\u0641\u0627\u062a\u0648\u0631\u0629: {{doc.name}}</td>\n </tr>\n <tr>\n <td>Invoice Date: {{doc.posting_date}}</td>\n <td>\u062a\u0627\u0631\u064a\u062e \u0627\u0644\u0641\u0627\u062a\u0648\u0631\u0629: {{doc.posting_date}}</td>\n </tr>\n <tr>\n <td>Date of Supply:{{doc.posting_date}}</td>\n <td>\u062a\u0627\u0631\u064a\u062e \u0627\u0644\u062a\u0648\u0631\u064a\u062f: {{doc.posting_date}}</td>\n </tr>\n \n <!--Supplier Info -->\n <tr>\n <td>Supplier:</td>\n <td>\u0627\u0644\u0645\u0648\u0631\u062f:</td>\n </tr>\n\t\t{% if (company.tax_id) %}\n <tr>\n <td>Supplier Tax Identification Number:</td>\n <td>\u0631\u0642\u0645 \u0627\u0644\u062a\u0639\u0631\u064a\u0641 \u0627\u0644\u0636\u0631\u064a\u0628\u064a \u0644\u0644\u0645\u0648\u0631\u062f:</td>\n </tr>\n <tr>\n <td>{{ company.tax_id }}</td>\n <td>{{ company.tax_id }}</td>\n </tr>\n {% endif %}\n <tr>\n <td>{{ company.name }}</td>\n <td>{{ company.company_name_in_arabic }} </td>\n </tr>\n \n \n {% if(supplier_address_doc) %}\n <tr>\n <td>{{ supplier_address_doc.address_line1}} </td>\n <td>{{ supplier_address_doc.address_in_arabic}} </td>\n </tr>\n <tr>\n <td>Phone: {{ supplier_address_doc.phone }}</td>\n <td>\u0647\u0627\u062a\u0641: {{ supplier_address_doc.phone }}</td>\n </tr>\n <tr>\n <td>Email: {{ supplier_address_doc.email_id }}</td>\n <td>\u0628\u0631\u064a\u062f \u0627\u0644\u0643\u062a\u0631\u0648\u0646\u064a: {{ supplier_address_doc.email_id }}</td>\n </tr>\n {% endif %}\n \n <!-- Customer Info -->\n <tr>\n <td>CUSTOMER:</td>\n <td>\u0639\u0645\u064a\u0644:</td>\n </tr>\n\t\t{% set customer_tax_id = frappe.db.get_value('Customer', doc.customer, 'tax_id') %}\n\t\t{% if customer_tax_id %}\n <tr>\n <td>Customer Tax Identification Number:</td>\n <td>\u0631\u0642\u0645 \u0627\u0644\u062a\u0639\u0631\u064a\u0641 \u0627\u0644\u0636\u0631\u064a\u0628\u064a \u0644\u0644\u0639\u0645\u064a\u0644:</td>\n </tr>\n <tr>\n <td>{{ customer_tax_id }}</td>\n <td>{{ customer_tax_id }}</td>\n </tr>\n {% endif %}\n <tr>\n <td> {{ doc.customer }}</td>\n <td> {{ doc.customer_name_in_arabic }} </td>\n </tr>\n \n {% if(customer_address) %}\n <tr>\n <td>{{ customer_address.address_line1}} </td>\n <td>{{ customer_address.address_in_arabic}} </td>\n </tr>\n {% endif %}\n \n {% if(customer_shipping_address) %}\n <tr>\n <td>SHIPPING ADDRESS:</td>\n <td>\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0634\u062d\u0646:</td>\n </tr>\n \n <tr>\n <td>{{ customer_shipping_address.address_line1}} </td>\n <td>{{ customer_shipping_address.address_in_arabic}} </td>\n </tr>\n {% endif %}\n \n\t\t{% if(doc.po_no) %}\n <tr>\n <td>OTHER INFORMATION</td>\n <td>\u0645\u0639\u0644\u0648\u0645\u0627\u062a \u0623\u062e\u0631\u0649</td>\n </tr>\n \n <tr>\n <td>Purchase Order Number: {{ doc.po_no }}</td>\n <td>\u0631\u0642\u0645 \u0623\u0645\u0631 \u0627\u0644\u0634\u0631\u0627\u0621: {{ doc.po_no }}</td>\n </tr>\n {% endif %}\n \n <tr>\n <td>Payment Due Date: {{ doc.due_date}} </td>\n <td>\u062a\u0627\u0631\u064a\u062e \u0627\u0633\u062a\u062d\u0642\u0627\u0642 \u0627\u0644\u062f\u0641\u0639: {{ doc.due_date}}</td>\n </tr>\n </tbody>\n </table>\n\n <!--Dynamic Colspan for total row columns-->\n {% set col = namespace(one = 2, two = 1) %}\n {% set length = doc.taxes | length %}\n {% set length = length / 2 | round %}\n {% set col.one = col.one + length %}\n {% set col.two = col.two + length %}\n \n {%- if(doc.taxes | length % 2 > 0 ) -%}\n {% set col.two = col.two + 1 %}\n {% endif %}\n \n <!-- Items -->\n {% set total = namespace(amount = 0) %}\n <table class=\"ksa-invoice-table\">\n <thead>\n <tr>\n <th>Nature of goods or services <br />\u0637\u0628\u064a\u0639\u0629 \u0627\u0644\u0633\u0644\u0639 \u0623\u0648 \u0627\u0644\u062e\u062f\u0645\u0627\u062a</th>\n <th>\n Unit price <br />\n \u0633\u0639\u0631 \u0627\u0644\u0648\u062d\u062f\u0629\n </th>\n <th>\n Quantity <br />\n \u0627\u0644\u0643\u0645\u064a\u0629\n </th>\n <th>\n Taxable Amount <br />\n \u0627\u0644\u0645\u0628\u0644\u063a \u0627\u0644\u062e\u0627\u0636\u0639 \u0644\u0644\u0636\u0631\u064a\u0628\u0629\n </th>\n \n {% for row in doc.taxes %}\n <th style=\"min-width: 130px\">{{row.description}}</th>\n {% endfor %}\n \n <th>\n Total <br />\n \u0627\u0644\u0645\u062c\u0645\u0648\u0639\n </th>\n </tr>\n </thead>\n <tbody>\n {%- for item in doc.items -%}\n {% set total.amount = item.amount %}\n <tr>\n <td>{{ item.item_code or item.item_name }}</td>\n <td>{{ item.get_formatted(\"rate\") }}</td>\n <td>{{ item.qty }}</td>\n <td>{{ item.get_formatted(\"amount\") }}</td>\n {% for row in doc.taxes %}\n {% set data_object = json.loads(row.item_wise_tax_detail) %}\n {% set key = item.item_code or item.item_name %}\n {% set tax_amount = frappe.utils.flt(data_object[key][1]/doc.conversion_rate, row.precision('tax_amount')) %}\n <td>\n <div class=\"qr-flex\">\n {%- if(data_object[key][0])-%}\n <span>{{ frappe.format(data_object[key][0], {'fieldtype': 'Percent'}) }}</span>\n {%- endif -%}\n <span>\n {%- if(data_object[key][1])-%}\n {{ frappe.format_value(tax_amount, currency=doc.currency) }}</span>\n {% set total.amount = total.amount + tax_amount %}\n {%- endif -%}\n </div>\n </td>\n {% endfor %}\n <td>{{ frappe.format_value(frappe.utils.flt(total.amount, doc.precision('total_taxes_and_charges')), currency=doc.currency) }}</td>\n </tr>\n {%- endfor -%}\n </tbody>\n <tfoot>\n <tr>\n <td>\n {{ doc.get_formatted(\"total\") }} <br />\n {{ doc.get_formatted(\"total_taxes_and_charges\") }}\n </td>\n \n <td colspan={{ col.one }} class=\"qr-rtl\">\n \u0627\u0644\u0625\u062c\u0645\u0627\u0644\u064a \u0628\u0627\u0633\u062a\u062b\u0646\u0627\u0621 \u0636\u0631\u064a\u0628\u0629 \u0627\u0644\u0642\u064a\u0645\u0629 \u0627\u0644\u0645\u0636\u0627\u0641\u0629\n <br />\n \u0625\u062c\u0645\u0627\u0644\u064a \u0636\u0631\u064a\u0628\u0629 \u0627\u0644\u0642\u064a\u0645\u0629 \u0627\u0644\u0645\u0636\u0627\u0641\u0629\n </td>\n <td colspan={{ col.two }}>\n Total (Excluding VAT)\n <br />\n Total VAT\n </td>\n <td>\n {{ doc.get_formatted(\"total\") }} <br />\n {{ doc.get_formatted(\"total_taxes_and_charges\") }}\n </td>\n </tr>\n <tr>\n <td>{{ doc.get_formatted(\"grand_total\") }}</td>\n <td colspan={{ col.one }} class=\"qr-rtl\">\n \u0625\u062c\u0645\u0627\u0644\u064a \u0627\u0644\u0645\u0628\u0644\u063a \u0627\u0644\u0645\u0633\u062a\u062d\u0642</td>\n <td colspan={{ col.two }}>Total Amount Due</td>\n <td>{{ doc.get_formatted(\"grand_total\") }}</td>\n </tr>\n </tfoot>\n </table>\n\n\t{%- if doc.terms -%}\n <p>\n {{doc.terms}}\n </p>\n\t{%- endif -%}\n</div>\n",
"idx": 0,
"line_breaks": 0,
"margin_bottom": 15.0,
"margin_left": 15.0,
"margin_right": 15.0,
"margin_top": 15.0,
- "modified": "2021-11-29 13:47:37.870818",
+ "modified": "2021-12-07 13:43:38.018593",
"modified_by": "Administrator",
"module": "Regional",
"name": "KSA VAT Invoice",
diff --git a/erpnext/regional/saudi_arabia/setup.py b/erpnext/regional/saudi_arabia/setup.py
index 38a089c..2e31c03 100644
--- a/erpnext/regional/saudi_arabia/setup.py
+++ b/erpnext/regional/saudi_arabia/setup.py
@@ -3,7 +3,7 @@
import frappe
from frappe.permissions import add_permission, update_permission_property
-from erpnext.regional.united_arab_emirates.setup import make_custom_fields as uae_custom_fields, add_print_formats
+from erpnext.regional.united_arab_emirates.setup import make_custom_fields as uae_custom_fields
from erpnext.regional.saudi_arabia.wizard.operations.setup_ksa_vat_setting import create_ksa_vat_setting
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
@@ -13,6 +13,16 @@
add_permissions()
make_custom_fields()
+def add_print_formats():
+ frappe.reload_doc("regional", "print_format", "detailed_tax_invoice", force=True)
+ frappe.reload_doc("regional", "print_format", "simplified_tax_invoice", force=True)
+ frappe.reload_doc("regional", "print_format", "tax_invoice", force=True)
+ frappe.reload_doc("regional", "print_format", "ksa_vat_invoice", force=True)
+ frappe.reload_doc("regional", "print_format", "ksa_pos_invoice", force=True)
+
+ for d in ('Simplified Tax Invoice', 'Detailed Tax Invoice', 'Tax Invoice', 'KSA VAT Invoice', 'KSA POS Invoice'):
+ frappe.db.set_value("Print Format", d, "disabled", 0)
+
def add_permissions():
"""Add Permissions for KSA VAT Setting."""
add_permission('KSA VAT Setting', 'All', 0)
@@ -33,8 +43,16 @@
custom_fields = {
'Sales Invoice': [
dict(
- fieldname='qr_code',
- label='QR Code',
+ fieldname='ksa_einv_qr',
+ label='KSA E-Invoicing QR',
+ fieldtype='Attach Image',
+ read_only=1, no_copy=1, hidden=1
+ )
+ ],
+ 'POS Invoice': [
+ dict(
+ fieldname='ksa_einv_qr',
+ label='KSA E-Invoicing QR',
fieldtype='Attach Image',
read_only=1, no_copy=1, hidden=1
)
diff --git a/erpnext/regional/saudi_arabia/utils.py b/erpnext/regional/saudi_arabia/utils.py
index 7d00d8b..a03c3f0 100644
--- a/erpnext/regional/saudi_arabia/utils.py
+++ b/erpnext/regional/saudi_arabia/utils.py
@@ -4,144 +4,146 @@
import frappe
from frappe import _
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from frappe.utils.data import add_to_date, get_time, getdate
from pyqrcode import create as qr_create
from erpnext import get_region
-def create_qr_code(doc, method):
- """Create QR Code after inserting Sales Inv
- """
-
+def create_qr_code(doc, method=None):
region = get_region(doc.company)
if region not in ['Saudi Arabia']:
return
- # if QR Code field not present, do nothing
- if not hasattr(doc, 'qr_code'):
- return
+ # if QR Code field not present, create it. Invoices without QR are invalid as per law.
+ if not hasattr(doc, 'ksa_einv_qr'):
+ create_custom_fields({
+ doc.doctype: [
+ dict(
+ fieldname='ksa_einv_qr',
+ label='KSA E-Invoicing QR',
+ fieldtype='Attach Image',
+ read_only=1, no_copy=1, hidden=1
+ )
+ ]
+ })
# Don't create QR Code if it already exists
- qr_code = doc.get("qr_code")
+ qr_code = doc.get("ksa_einv_qr")
if qr_code and frappe.db.exists({"doctype": "File", "file_url": qr_code}):
return
- meta = frappe.get_meta('Sales Invoice')
+ meta = frappe.get_meta(doc.doctype)
- for field in meta.get_image_fields():
- if field.fieldname == 'qr_code':
- ''' TLV conversion for
- 1. Seller's Name
- 2. VAT Number
- 3. Time Stamp
- 4. Invoice Amount
- 5. VAT Amount
- '''
- tlv_array = []
- # Sellers Name
+ if "ksa_einv_qr" in [d.fieldname for d in meta.get_image_fields()]:
+ ''' TLV conversion for
+ 1. Seller's Name
+ 2. VAT Number
+ 3. Time Stamp
+ 4. Invoice Amount
+ 5. VAT Amount
+ '''
+ tlv_array = []
+ # Sellers Name
- seller_name = frappe.db.get_value(
- 'Company',
- doc.company,
- 'company_name_in_arabic')
+ seller_name = frappe.db.get_value(
+ 'Company',
+ doc.company,
+ 'company_name_in_arabic')
- if not seller_name:
- frappe.throw(_('Arabic name missing for {} in the company document').format(doc.company))
+ if not seller_name:
+ frappe.throw(_('Arabic name missing for {} in the company document').format(doc.company))
- tag = bytes([1]).hex()
- length = bytes([len(seller_name.encode('utf-8'))]).hex()
- value = seller_name.encode('utf-8').hex()
- tlv_array.append(''.join([tag, length, value]))
+ tag = bytes([1]).hex()
+ length = bytes([len(seller_name.encode('utf-8'))]).hex()
+ value = seller_name.encode('utf-8').hex()
+ tlv_array.append(''.join([tag, length, value]))
- # VAT Number
- tax_id = frappe.db.get_value('Company', doc.company, 'tax_id')
- if not tax_id:
- frappe.throw(_('Tax ID missing for {} in the company document').format(doc.company))
+ # VAT Number
+ tax_id = frappe.db.get_value('Company', doc.company, 'tax_id')
+ if not tax_id:
+ frappe.throw(_('Tax ID missing for {} in the company document').format(doc.company))
- tag = bytes([2]).hex()
- length = bytes([len(tax_id)]).hex()
- value = tax_id.encode('utf-8').hex()
- tlv_array.append(''.join([tag, length, value]))
+ tag = bytes([2]).hex()
+ length = bytes([len(tax_id)]).hex()
+ value = tax_id.encode('utf-8').hex()
+ tlv_array.append(''.join([tag, length, value]))
- # Time Stamp
- posting_date = getdate(doc.posting_date)
- time = get_time(doc.posting_time)
- seconds = time.hour * 60 * 60 + time.minute * 60 + time.second
- time_stamp = add_to_date(posting_date, seconds=seconds)
- time_stamp = time_stamp.strftime('%Y-%m-%dT%H:%M:%SZ')
+ # Time Stamp
+ posting_date = getdate(doc.posting_date)
+ time = get_time(doc.posting_time)
+ seconds = time.hour * 60 * 60 + time.minute * 60 + time.second
+ time_stamp = add_to_date(posting_date, seconds=seconds)
+ time_stamp = time_stamp.strftime('%Y-%m-%dT%H:%M:%SZ')
- tag = bytes([3]).hex()
- length = bytes([len(time_stamp)]).hex()
- value = time_stamp.encode('utf-8').hex()
- tlv_array.append(''.join([tag, length, value]))
+ tag = bytes([3]).hex()
+ length = bytes([len(time_stamp)]).hex()
+ value = time_stamp.encode('utf-8').hex()
+ tlv_array.append(''.join([tag, length, value]))
- # Invoice Amount
- invoice_amount = str(doc.grand_total)
- tag = bytes([4]).hex()
- length = bytes([len(invoice_amount)]).hex()
- value = invoice_amount.encode('utf-8').hex()
- tlv_array.append(''.join([tag, length, value]))
+ # Invoice Amount
+ invoice_amount = str(doc.grand_total)
+ tag = bytes([4]).hex()
+ length = bytes([len(invoice_amount)]).hex()
+ value = invoice_amount.encode('utf-8').hex()
+ tlv_array.append(''.join([tag, length, value]))
- # VAT Amount
- vat_amount = str(doc.total_taxes_and_charges)
+ # VAT Amount
+ vat_amount = str(doc.total_taxes_and_charges)
- tag = bytes([5]).hex()
- length = bytes([len(vat_amount)]).hex()
- value = vat_amount.encode('utf-8').hex()
- tlv_array.append(''.join([tag, length, value]))
+ tag = bytes([5]).hex()
+ length = bytes([len(vat_amount)]).hex()
+ value = vat_amount.encode('utf-8').hex()
+ tlv_array.append(''.join([tag, length, value]))
- # Joining bytes into one
- tlv_buff = ''.join(tlv_array)
+ # Joining bytes into one
+ tlv_buff = ''.join(tlv_array)
- # base64 conversion for QR Code
- base64_string = b64encode(bytes.fromhex(tlv_buff)).decode()
+ # base64 conversion for QR Code
+ base64_string = b64encode(bytes.fromhex(tlv_buff)).decode()
- qr_image = io.BytesIO()
- url = qr_create(base64_string, error='L')
- url.png(qr_image, scale=2, quiet_zone=1)
+ qr_image = io.BytesIO()
+ url = qr_create(base64_string, error='L')
+ url.png(qr_image, scale=2, quiet_zone=1)
- name = frappe.generate_hash(doc.name, 5)
+ name = frappe.generate_hash(doc.name, 5)
- # making file
- filename = f"QRCode-{name}.png".replace(os.path.sep, "__")
- _file = frappe.get_doc({
- "doctype": "File",
- "file_name": filename,
- "is_private": 0,
- "content": qr_image.getvalue(),
- "attached_to_doctype": doc.get("doctype"),
- "attached_to_name": doc.get("name"),
- "attached_to_field": "qr_code"
- })
+ # making file
+ filename = f"QRCode-{name}.png".replace(os.path.sep, "__")
+ _file = frappe.get_doc({
+ "doctype": "File",
+ "file_name": filename,
+ "is_private": 0,
+ "content": qr_image.getvalue(),
+ "attached_to_doctype": doc.get("doctype"),
+ "attached_to_name": doc.get("name"),
+ "attached_to_field": "ksa_einv_qr"
+ })
- _file.save()
+ _file.save()
- # assigning to document
- doc.db_set('qr_code', _file.file_url)
- doc.notify_update()
-
- break
+ # assigning to document
+ doc.db_set('ksa_einv_qr', _file.file_url)
+ doc.notify_update()
-def delete_qr_code_file(doc, method):
- """Delete QR Code on deleted sales invoice"""
-
+def delete_qr_code_file(doc, method=None):
region = get_region(doc.company)
if region not in ['Saudi Arabia']:
return
- if hasattr(doc, 'qr_code'):
- if doc.get('qr_code'):
+ if hasattr(doc, 'ksa_einv_qr'):
+ if doc.get('ksa_einv_qr'):
file_doc = frappe.get_list('File', {
- 'file_url': doc.get('qr_code')
+ 'file_url': doc.get('ksa_einv_qr')
})
if len(file_doc):
frappe.delete_doc('File', file_doc[0].name)
-def delete_vat_settings_for_company(doc, method):
+def delete_vat_settings_for_company(doc, method=None):
if doc.country != 'Saudi Arabia':
return
- settings_doc = frappe.get_doc('KSA VAT Setting', {'company': doc.name})
- settings_doc.delete()
\ No newline at end of file
+ if frappe.db.exists('KSA VAT Setting', doc.name):
+ frappe.delete_doc('KSA VAT Setting', doc.name)
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index e69e28d..cc95185 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -63,6 +63,8 @@
if not self.billing_status: self.billing_status = 'Not Billed'
if not self.delivery_status: self.delivery_status = 'Not Delivered'
+ self.reset_default_field_value("set_warehouse", "items", "warehouse")
+
def validate_po(self):
# validate p.o date v/s delivery date
if self.po_date and not self.skip_delivery_note:
diff --git a/erpnext/setup/setup_wizard/operations/defaults_setup.py b/erpnext/setup/setup_wizard/operations/defaults_setup.py
index e4b1fa2..ca1f57e 100644
--- a/erpnext/setup/setup_wizard/operations/defaults_setup.py
+++ b/erpnext/setup/setup_wizard/operations/defaults_setup.py
@@ -68,6 +68,8 @@
hr_settings.send_interview_feedback_reminder = 1
hr_settings.feedback_reminder_notification_template = _("Interview Feedback Reminder")
+
+ hr_settings.exit_questionnaire_notification_template = _("Exit Questionnaire Notification")
hr_settings.save()
def set_no_copy_fields_in_variant_settings():
diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py
index 98f9119..97d850b 100644
--- a/erpnext/setup/setup_wizard/operations/install_fixtures.py
+++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py
@@ -278,6 +278,11 @@
records += [{'doctype': 'Email Template', 'name': _('Interview Feedback Reminder'), 'response': response,
'subject': _('Interview Feedback Reminder'), 'owner': frappe.session.user}]
+ response = frappe.read_file(os.path.join(base_path, 'exit_interview/exit_questionnaire_notification_template.html'))
+
+ records += [{'doctype': 'Email Template', 'name': _('Exit Questionnaire Notification'), 'response': response,
+ 'subject': _('Exit Questionnaire Notification'), 'owner': frappe.session.user}]
+
base_path = frappe.get_app_path("erpnext", "stock", "doctype")
response = frappe.read_file(os.path.join(base_path, "delivery_trip/dispatch_notification_template.html"))
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index 5268460..70d48a4 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -138,6 +138,7 @@
self.update_current_stock()
if not self.installation_status: self.installation_status = 'Not Installed'
+ self.reset_default_field_value("set_warehouse", "items", "warehouse")
def validate_with_previous_doc(self):
super(DeliveryNote, self).validate_with_previous_doc({
diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py
index d717c50..103e8d6 100644
--- a/erpnext/stock/doctype/material_request/material_request.py
+++ b/erpnext/stock/doctype/material_request/material_request.py
@@ -80,6 +80,9 @@
# NOTE: Since Item BOM and FG quantities are combined, using current data, it cannot be validated
# Though the creation of Material Request from a Production Plan can be rethought to fix this
+ self.reset_default_field_value("set_warehouse", "items", "warehouse")
+ self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse")
+
def set_title(self):
'''Set title as comma separated list of items'''
if not self.title:
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index 762f45f..c97b306 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -118,6 +118,10 @@
if getdate(self.posting_date) > getdate(nowdate()):
throw(_("Posting Date cannot be future date"))
+ self.reset_default_field_value("set_warehouse", "items", "warehouse")
+ self.reset_default_field_value("rejected_warehouse", "items", "rejected_warehouse")
+ self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse")
+
def validate_cwip_accounts(self):
for item in self.get('items'):
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
index 01cceb1..b2ad07f 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
@@ -168,8 +168,8 @@
for row in riv_entries:
doc = frappe.get_doc('Repost Item Valuation', row.name)
if doc.status in ('Queued', 'In Progress'):
- doc.deduplicate_similar_repost()
repost(doc)
+ doc.deduplicate_similar_repost()
riv_entries = get_repost_item_valuation_entries()
if riv_entries:
diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
index de79316..78b432d 100644
--- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
@@ -4,12 +4,14 @@
import unittest
import frappe
+from frappe.utils import nowdate
from erpnext.controllers.stock_controller import create_item_wise_repost_entries
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import (
in_configured_timeslot,
)
+from erpnext.stock.utils import PendingRepostingError
class TestRepostItemValuation(unittest.TestCase):
@@ -138,3 +140,25 @@
# to avoid breaking other tests accidentaly
riv4.set_status("Skipped")
riv3.set_status("Skipped")
+
+ def test_stock_freeze_validation(self):
+
+ today = nowdate()
+
+ riv = frappe.get_doc(
+ doctype="Repost Item Valuation",
+ item_code="_Test Item",
+ warehouse="_Test Warehouse - _TC",
+ based_on="Item and Warehouse",
+ posting_date=today,
+ posting_time="00:01:00",
+ )
+ riv.flags.dont_run_in_test = True # keep it queued
+ riv.submit()
+
+ stock_settings = frappe.get_doc("Stock Settings")
+ stock_settings.stock_frozen_upto = today
+
+ self.assertRaises(PendingRepostingError, stock_settings.save)
+
+ riv.set_status("Skipped")
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index a38dfa5..a00d63e 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -103,6 +103,8 @@
self.set_actual_qty()
self.calculate_rate_and_amount()
self.validate_putaway_capacity()
+ self.reset_default_field_value("from_warehouse", "items", "s_warehouse")
+ self.reset_default_field_value("to_warehouse", "items", "t_warehouse")
def on_submit(self):
self.update_stock_ledger()
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py
index 1de48b6..c1293cb 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.py
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.py
@@ -11,6 +11,8 @@
from frappe.utils import cint
from frappe.utils.html_utils import clean_html
+from erpnext.stock.utils import check_pending_reposting
+
class StockSettings(Document):
def validate(self):
@@ -36,6 +38,7 @@
self.validate_warehouses()
self.cant_change_valuation_method()
self.validate_clean_description_html()
+ self.validate_pending_reposts()
def validate_warehouses(self):
warehouse_fields = ["default_warehouse", "sample_retention_warehouse"]
@@ -64,6 +67,11 @@
# changed to text
frappe.enqueue('erpnext.stock.doctype.stock_settings.stock_settings.clean_all_descriptions', now=frappe.flags.in_test)
+ def validate_pending_reposts(self):
+ if self.stock_frozen_upto:
+ check_pending_reposting(self.stock_frozen_upto)
+
+
def on_update(self):
self.toggle_warehouse_field_for_inter_warehouse_transfer()
diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py
index 72d8098..3b1ae3b 100644
--- a/erpnext/stock/utils.py
+++ b/erpnext/stock/utils.py
@@ -12,6 +12,7 @@
class InvalidWarehouseCompany(frappe.ValidationError): pass
+class PendingRepostingError(frappe.ValidationError): pass
def get_stock_value_from_bin(warehouse=None, item_code=None):
values = {}
@@ -417,3 +418,28 @@
{'docstatus': 1, 'status': ['in', ['Queued','In Progress']]})
if reposting_in_progress:
frappe.msgprint(_("Item valuation reposting in progress. Report might show incorrect item valuation."), alert=1)
+
+def check_pending_reposting(posting_date: str, throw_error: bool = True) -> bool:
+ """Check if there are pending reposting job till the specified posting date."""
+
+ filters = {
+ "docstatus": 1,
+ "status": ["in", ["Queued","In Progress", "Failed"]],
+ "posting_date": ["<=", posting_date],
+ }
+
+ reposting_pending = frappe.db.exists("Repost Item Valuation", filters)
+ if reposting_pending and throw_error:
+ msg = _("Stock/Accounts can not be frozen as processing of backdated entries is going on. Please try again later.")
+ frappe.msgprint(msg,
+ raise_exception=PendingRepostingError,
+ title="Stock Reposting Ongoing",
+ indicator="red",
+ primary_action={
+ "label": _("Show pending entries"),
+ "client_action": "erpnext.route_to_pending_reposts",
+ "args": filters,
+ }
+ )
+
+ return bool(reposting_pending)
diff --git a/erpnext/support/doctype/issue/issue.json b/erpnext/support/doctype/issue/issue.json
index 14712f8..3ff7d02 100644
--- a/erpnext/support/doctype/issue/issue.json
+++ b/erpnext/support/doctype/issue/issue.json
@@ -24,12 +24,10 @@
"service_level_section",
"service_level_agreement",
"response_by",
- "response_by_variance",
"reset_service_level_agreement",
"cb",
"agreement_status",
"resolution_by",
- "resolution_by_variance",
"service_level_agreement_creation",
"on_hold_since",
"total_hold_time",
@@ -123,7 +121,6 @@
"search_index": 1
},
{
- "default": "Medium",
"fieldname": "priority",
"fieldtype": "Link",
"in_list_view": 1,
@@ -319,22 +316,6 @@
"label": "Via Customer Portal"
},
{
- "depends_on": "eval: doc.service_level_agreement && doc.status != 'Replied';",
- "fieldname": "response_by_variance",
- "fieldtype": "Duration",
- "hide_seconds": 1,
- "label": "Response By Variance",
- "read_only": 1
- },
- {
- "depends_on": "eval: doc.service_level_agreement && doc.status != 'Replied';",
- "fieldname": "resolution_by_variance",
- "fieldtype": "Duration",
- "hide_seconds": 1,
- "label": "Resolution By Variance",
- "read_only": 1
- },
- {
"fieldname": "service_level_agreement_creation",
"fieldtype": "Datetime",
"hidden": 1,
@@ -391,12 +372,12 @@
"read_only": 1
},
{
- "default": "Ongoing",
+ "default": "First Response Due",
"depends_on": "eval: doc.service_level_agreement",
"fieldname": "agreement_status",
"fieldtype": "Select",
"label": "Service Level Agreement Status",
- "options": "Ongoing\nFulfilled\nFailed",
+ "options": "First Response Due\nResolution Due\nFulfilled\nFailed",
"read_only": 1
},
{
@@ -410,10 +391,11 @@
"icon": "fa fa-ticket",
"idx": 7,
"links": [],
- "modified": "2021-06-10 03:22:27.098898",
+ "modified": "2021-11-24 13:13:10.276630",
"modified_by": "Administrator",
"module": "Support",
"name": "Issue",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py
index 0dc3639..d5e5b78 100644
--- a/erpnext/support/doctype/issue/issue.py
+++ b/erpnext/support/doctype/issue/issue.py
@@ -87,11 +87,9 @@
if replicated_issue.service_level_agreement:
replicated_issue.service_level_agreement_creation = now_datetime()
replicated_issue.service_level_agreement = None
- replicated_issue.agreement_status = "Ongoing"
+ replicated_issue.agreement_status = "First Response Due"
replicated_issue.response_by = None
- replicated_issue.response_by_variance = None
replicated_issue.resolution_by = None
- replicated_issue.resolution_by_variance = None
replicated_issue.reset_issue_metrics()
frappe.get_doc(replicated_issue).insert()
diff --git a/erpnext/support/doctype/issue/issue_list.js b/erpnext/support/doctype/issue/issue_list.js
index e04498e..5bfecb0 100644
--- a/erpnext/support/doctype/issue/issue_list.js
+++ b/erpnext/support/doctype/issue/issue_list.js
@@ -18,7 +18,6 @@
},
get_indicator: function(doc) {
if (doc.status === 'Open') {
- if (!doc.priority) doc.priority = 'Medium';
const color = {
'Low': 'yellow',
'Medium': 'orange',
diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py
index ab9a444b..14cec46 100644
--- a/erpnext/support/doctype/issue/test_issue.py
+++ b/erpnext/support/doctype/issue/test_issue.py
@@ -1,10 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors
# See license.txt
-import datetime
import unittest
import frappe
+from frappe import _
from frappe.core.doctype.user_permission.test_user_permission import create_user
from frappe.utils import flt, get_datetime
@@ -83,30 +83,6 @@
self.assertEqual(issue.agreement_status, 'Fulfilled')
- def test_issue_metrics(self):
- creation = get_datetime("2020-03-04 4:00")
-
- issue = make_issue(creation, index=1)
- create_communication(issue.name, "test@example.com", "Received", creation)
-
- creation = get_datetime("2020-03-04 4:15")
- create_communication(issue.name, "test@admin.com", "Sent", creation)
-
- creation = get_datetime("2020-03-04 5:00")
- create_communication(issue.name, "test@example.com", "Received", creation)
-
- creation = get_datetime("2020-03-04 5:05")
- create_communication(issue.name, "test@admin.com", "Sent", creation)
-
- frappe.flags.current_time = get_datetime("2020-03-04 5:05")
- issue.reload()
- issue.status = 'Closed'
- issue.save()
-
- self.assertEqual(issue.avg_response_time, 600)
- self.assertEqual(issue.resolution_time, 3900)
- self.assertEqual(issue.user_resolution_time, 1200)
-
def test_hold_time_on_replied(self):
creation = get_datetime("2020-03-04 4:00")
@@ -142,6 +118,142 @@
issue.reload()
self.assertEqual(flt(issue.total_hold_time, 2), 2700)
+ def test_issue_close_after_on_hold(self):
+ frappe.flags.current_time = get_datetime("2021-11-01 19:00")
+
+ issue = make_issue(frappe.flags.current_time, index=1)
+ create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time)
+
+ # send a reply within SLA
+ frappe.flags.current_time = get_datetime("2021-11-02 11:00")
+ create_communication(issue.name, "test@admin.com", "Sent", frappe.flags.current_time)
+
+ issue.reload()
+ issue.status = 'Replied'
+ issue.save()
+
+ self.assertEqual(issue.on_hold_since, frappe.flags.current_time)
+
+ # close the issue after being on hold for 20 days
+ frappe.flags.current_time = get_datetime("2021-11-22 01:00")
+ issue.status = 'Closed'
+ issue.save()
+
+ self.assertEqual(issue.resolution_by, get_datetime('2021-11-22 06:00:00'))
+ self.assertEqual(issue.resolution_date, get_datetime('2021-11-22 01:00:00'))
+ self.assertEqual(issue.agreement_status, 'Fulfilled')
+
+ def test_issue_open_after_closed(self):
+
+ # Created on -> 1 pm, Response Time -> 4 hrs, Resolution Time -> 6 hrs
+ frappe.flags.current_time = get_datetime("2021-11-01 13:00")
+ issue = make_issue(frappe.flags.current_time, index=1, issue_type='Critical') # Applies 24hr working time SLA
+ create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time)
+ self.assertEquals(issue.agreement_status, 'First Response Due')
+ self.assertEquals(issue.response_by, get_datetime("2021-11-01 17:00"))
+ self.assertEquals(issue.resolution_by, get_datetime("2021-11-01 19:00"))
+
+ # Replied on → 2 pm
+ frappe.flags.current_time = get_datetime("2021-11-01 14:00")
+ create_communication(issue.name, "test@admin.com", "Sent", frappe.flags.current_time)
+ issue.reload()
+ issue.status = 'Replied'
+ issue.save()
+ self.assertEquals(issue.agreement_status, 'Resolution Due')
+ self.assertEquals(issue.on_hold_since, frappe.flags.current_time)
+ self.assertEquals(issue.first_responded_on, frappe.flags.current_time)
+
+ # Customer Replied → 3 pm
+ frappe.flags.current_time = get_datetime("2021-11-01 15:00")
+ create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time)
+ issue.reload()
+ self.assertEquals(issue.status, 'Open')
+ # Hold Time + 1 Hrs
+ self.assertEquals(issue.total_hold_time, 3600)
+ # Resolution By should increase by one hrs
+ self.assertEquals(issue.resolution_by, get_datetime("2021-11-01 20:00"))
+
+ # Replied on → 4 pm, Open → 1 hr, Resolution Due → 8 pm
+ frappe.flags.current_time = get_datetime("2021-11-01 16:00")
+ create_communication(issue.name, "test@admin.com", "Sent", frappe.flags.current_time)
+ issue.reload()
+ issue.status = 'Replied'
+ issue.save()
+ self.assertEquals(issue.agreement_status, 'Resolution Due')
+
+ # Customer Closed → 10 pm
+ frappe.flags.current_time = get_datetime("2021-11-01 22:00")
+ issue.status = 'Closed'
+ issue.save()
+ # Hold Time + 6 Hrs
+ self.assertEquals(issue.total_hold_time, 3600 + 21600)
+ # Resolution By should increase by 6 hrs
+ self.assertEquals(issue.resolution_by, get_datetime("2021-11-02 02:00"))
+ self.assertEquals(issue.agreement_status, 'Fulfilled')
+ self.assertEquals(issue.resolution_date, frappe.flags.current_time)
+
+ # Customer Open → 3 am i.e after resolution by is crossed
+ frappe.flags.current_time = get_datetime("2021-11-02 03:00")
+ create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time)
+ issue.reload()
+ # Since issue was Resolved, Resolution By should be increased by 5 hrs (3am - 10pm)
+ self.assertEquals(issue.total_hold_time, 3600 + 21600 + 18000)
+ # Resolution By should increase by 5 hrs
+ self.assertEquals(issue.resolution_by, get_datetime("2021-11-02 07:00"))
+ self.assertEquals(issue.agreement_status, 'Resolution Due')
+ self.assertFalse(issue.resolution_date)
+
+ # We Closed → 4 am, SLA should be Fulfilled
+ frappe.flags.current_time = get_datetime("2021-11-02 04:00")
+ issue.status = 'Closed'
+ issue.save()
+ self.assertEquals(issue.resolution_by, get_datetime("2021-11-02 07:00"))
+ self.assertEquals(issue.agreement_status, 'Fulfilled')
+ self.assertEquals(issue.resolution_date, frappe.flags.current_time)
+
+ def test_recording_of_assignment_on_first_reponse_failure(self):
+ from frappe.desk.form.assign_to import add as add_assignment
+
+ frappe.flags.current_time = get_datetime("2021-11-01 19:00")
+
+ issue = make_issue(frappe.flags.current_time, index=1)
+ create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time)
+ add_assignment({
+ 'doctype': issue.doctype,
+ 'name': issue.name,
+ 'assign_to': ['test@admin.com']
+ })
+ issue.reload()
+
+ # send a reply failing response SLA
+ frappe.flags.current_time = get_datetime("2021-11-02 15:00")
+ create_communication(issue.name, "test@admin.com", "Sent", frappe.flags.current_time)
+
+ # assert if a new timeline item has been added
+ # to record the assignment
+ comment = frappe.db.exists('Comment', {
+ 'reference_doctype': 'Issue',
+ 'reference_name': issue.name,
+ 'comment_type': 'Assigned',
+ 'content': _('First Response SLA Failed by {}').format('test')
+ })
+ self.assertTrue(comment)
+
+ def test_agreement_status_on_response(self):
+ frappe.flags.current_time = get_datetime("2021-11-01 19:00")
+
+ issue = make_issue(frappe.flags.current_time, index=1)
+ create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time)
+ self.assertTrue(issue.status == 'Open')
+
+ # send a reply within response SLA
+ frappe.flags.current_time = get_datetime("2021-11-02 11:00")
+ create_communication(issue.name, "test@admin.com", "Sent", frappe.flags.current_time)
+
+ issue.reload()
+ self.assertEquals(issue.first_responded_on, frappe.flags.current_time)
+ self.assertEquals(issue.agreement_status, 'Resolution Due')
+
class TestFirstResponseTime(TestSetUp):
# working hours used in all cases: Mon-Fri, 10am to 6pm
# all dates are in the mm-dd-yyyy format
@@ -355,12 +467,18 @@
def create_issue_and_communication(issue_creation, first_responded_on):
issue = make_issue(issue_creation, index=1)
sender = create_user("test@admin.com")
+ frappe.flags.current_time = first_responded_on
create_communication(issue.name, sender.email, "Sent", first_responded_on)
issue.reload()
return issue
def make_issue(creation=None, customer=None, index=0, priority=None, issue_type=None):
+ if issue_type and not frappe.db.exists('Issue Type', issue_type):
+ doc = frappe.new_doc('Issue Type')
+ doc.name = issue_type
+ doc.insert()
+
issue = frappe.get_doc({
"doctype": "Issue",
"subject": "Service Level Agreement Issue {0}".format(index),
diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.js b/erpnext/support/doctype/service_level_agreement/service_level_agreement.js
index ae2080c..bfbffe2 100644
--- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.js
+++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.js
@@ -22,10 +22,41 @@
refresh: function(frm) {
frm.trigger('fetch_status_fields');
frm.trigger('toggle_resolution_fields');
+ frm.trigger('default_service_level_agreement');
+ frm.trigger('entity');
+ },
+
+ default_service_level_agreement: function(frm) {
+ const field = frm.get_field('default_service_level_agreement');
+ if (frm.doc.default_service_level_agreement) {
+ field.set_description(__('SLA will be applied on every {0}', [frm.doc.document_type]));
+ } else {
+ field.set_description(__('Enable to apply SLA on every {0}', [frm.doc.document_type]));
+ }
},
document_type: function(frm) {
frm.trigger('fetch_status_fields');
+ frm.trigger('default_service_level_agreement');
+ },
+
+ entity_type: function(frm) {
+ frm.set_value('entity', undefined);
+ },
+
+ entity: function(frm) {
+ const field = frm.get_field('entity');
+ if (frm.doc.entity) {
+ const and_descendants = frm.doc.entity_type != 'Customer' ? ' ' + __('or its descendants') : '';
+ field.set_description(
+ __('SLA will be applied if {1} is set as {2}{3}', [
+ frm.doc.document_type, frm.doc.entity_type,
+ frm.doc.entity, and_descendants
+ ])
+ );
+ } else {
+ field.set_description('');
+ }
},
fetch_status_fields: function(frm) {
diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.json b/erpnext/support/doctype/service_level_agreement/service_level_agreement.json
index 5f470aa..1698e23 100644
--- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.json
+++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.json
@@ -6,22 +6,17 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
- "enabled",
- "section_break_2",
"document_type",
- "default_service_level_agreement",
"default_priority",
"column_break_2",
"service_level",
- "holiday_list",
- "entity_section",
- "entity_type",
- "column_break_10",
- "entity",
+ "enabled",
"filters_section",
- "condition",
+ "default_service_level_agreement",
+ "entity_type",
+ "entity",
"column_break_15",
- "condition_description",
+ "condition",
"agreement_details_section",
"start_date",
"column_break_7",
@@ -31,8 +26,10 @@
"priorities",
"status_details",
"sla_fulfilled_on",
+ "column_break_22",
"pause_sla_on",
"support_and_resolution_section_break",
+ "holiday_list",
"support_and_resolution"
],
"fields": [
@@ -42,7 +39,8 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Service Level Name",
- "reqd": 1
+ "reqd": 1,
+ "set_only_once": 1
},
{
"fieldname": "holiday_list",
@@ -56,10 +54,10 @@
"fieldtype": "Column Break"
},
{
- "depends_on": "eval: !doc.default_service_level_agreement",
+ "depends_on": "eval: doc.document_type",
"fieldname": "agreement_details_section",
"fieldtype": "Section Break",
- "label": "Agreement Details"
+ "label": "Valid From"
},
{
"fieldname": "start_date",
@@ -72,7 +70,6 @@
"fieldtype": "Column Break"
},
{
- "depends_on": "eval: !doc.default_service_level_agreement",
"fieldname": "end_date",
"fieldtype": "Date",
"label": "End Date"
@@ -80,7 +77,7 @@
{
"fieldname": "response_and_resolution_time_section",
"fieldtype": "Section Break",
- "label": "Response and Resolution Time"
+ "label": "Response and Resolution"
},
{
"fieldname": "support_and_resolution_section_break",
@@ -90,6 +87,7 @@
{
"fieldname": "support_and_resolution",
"fieldtype": "Table",
+ "label": "Working Hours",
"options": "Service Day",
"reqd": 1
},
@@ -101,10 +99,7 @@
"reqd": 1
},
{
- "fieldname": "column_break_10",
- "fieldtype": "Column Break"
- },
- {
+ "depends_on": "eval: !doc.default_service_level_agreement",
"fieldname": "entity",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
@@ -114,11 +109,6 @@
},
{
"depends_on": "eval: !doc.default_service_level_agreement",
- "fieldname": "entity_section",
- "fieldtype": "Section Break",
- "label": "Entity"
- },
- {
"fieldname": "entity_type",
"fieldtype": "Select",
"in_standard_filter": 1,
@@ -126,11 +116,6 @@
"options": "\nCustomer\nCustomer Group\nTerritory"
},
{
- "fieldname": "section_break_2",
- "fieldtype": "Section Break",
- "hide_border": 1
- },
- {
"default": "0",
"fieldname": "default_service_level_agreement",
"fieldtype": "Check",
@@ -152,7 +137,7 @@
{
"fieldname": "document_type",
"fieldtype": "Link",
- "label": "Document Type",
+ "label": "Apply On",
"options": "DocType",
"reqd": 1,
"set_only_once": 1
@@ -164,6 +149,7 @@
"label": "Enabled"
},
{
+ "depends_on": "document_type",
"fieldname": "status_details",
"fieldtype": "Section Break",
"label": "Status Details"
@@ -182,28 +168,31 @@
"label": "Apply SLA for Resolution Time"
},
{
+ "depends_on": "document_type",
"fieldname": "filters_section",
"fieldtype": "Section Break",
- "label": "Assignment Condition"
+ "label": "Assignment Conditions"
},
{
"fieldname": "column_break_15",
"fieldtype": "Column Break"
},
{
+ "depends_on": "eval: !doc.default_service_level_agreement",
+ "description": "Simple Python Expression, Example: doc.status == 'Open' and doc.issue_type == 'Bug'",
"fieldname": "condition",
"fieldtype": "Code",
"label": "Condition",
- "options": "Python"
+ "max_height": "7rem",
+ "options": "PythonExpression"
},
{
- "fieldname": "condition_description",
- "fieldtype": "HTML",
- "options": "<p><strong>Condition Examples:</strong></p>\n<pre>doc.status==\"Open\"<br>doc.due_date==nowdate()<br>doc.total > 40000\n</pre>"
+ "fieldname": "column_break_22",
+ "fieldtype": "Column Break"
}
],
"links": [],
- "modified": "2021-10-02 11:32:55.556024",
+ "modified": "2021-11-26 15:45:33.289911",
"modified_by": "Administrator",
"module": "Support",
"name": "Service Level Agreement",
diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py
index 5f8f83d..50f31fd 100644
--- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py
+++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py
@@ -10,7 +10,6 @@
from frappe.model.document import Document
from frappe.utils import (
add_to_date,
- cint,
get_datetime,
get_datetime_str,
get_link_to_form,
@@ -22,6 +21,7 @@
time_diff_in_seconds,
to_timedelta,
)
+from frappe.utils.nestedset import get_ancestors_of
from frappe.utils.safe_exec import get_safe_globals
from erpnext.support.doctype.issue.issue import get_holidays
@@ -248,7 +248,7 @@
customer = doc.get('customer')
or_filters.append(
- ["Service Level Agreement", "entity", "in", [customer, get_customer_group(customer), get_customer_territory(customer)]]
+ ["Service Level Agreement", "entity", "in", [customer] + get_customer_group(customer) + get_customer_territory(customer)]
)
default_sla_filter = filters + [["Service Level Agreement", "default_service_level_agreement", "=", 1]]
@@ -275,11 +275,23 @@
return {"doc": doc.as_dict(), "nowdate": nowdate, "frappe": frappe._dict(utils=get_safe_globals().get("frappe").get("utils"))}
def get_customer_group(customer):
- return frappe.db.get_value("Customer", customer, "customer_group") if customer else None
+ customer_groups = []
+ customer_group = frappe.db.get_value("Customer", customer, "customer_group") if customer else None
+ if customer_group:
+ ancestors = get_ancestors_of("Customer Group", customer_group)
+ customer_groups = [customer_group] + ancestors
+
+ return customer_groups
def get_customer_territory(customer):
- return frappe.db.get_value("Customer", customer, "territory") if customer else None
+ customer_territories = []
+ customer_territory = frappe.db.get_value("Customer", customer, "territory") if customer else None
+ if customer_territory:
+ ancestors = get_ancestors_of("Territory", customer_territory)
+ customer_territories = [customer_territory] + ancestors
+
+ return customer_territories
@frappe.whitelist()
@@ -299,7 +311,7 @@
if customer:
# Include SLA with No Entity and Entity Type
or_filters.append(
- ["Service Level Agreement", "entity", "in", [customer, get_customer_group(customer), get_customer_territory(customer), ""]]
+ ["Service Level Agreement", "entity", "in", [""] + [customer] + get_customer_group(customer) + get_customer_territory(customer)]
)
return {
@@ -337,84 +349,135 @@
def apply(doc, method=None):
# Applies SLA to document on validate
- if frappe.flags.in_patch or frappe.flags.in_migrate or frappe.flags.in_install or frappe.flags.in_setup_wizard or \
- doc.doctype not in get_documents_with_active_service_level_agreement():
+ if (
+ frappe.flags.in_patch
+ or frappe.flags.in_migrate
+ or frappe.flags.in_install
+ or frappe.flags.in_setup_wizard
+ or doc.doctype not in get_documents_with_active_service_level_agreement()
+ ):
return
- service_level_agreement = get_active_service_level_agreement_for(doc)
+ sla = get_active_service_level_agreement_for(doc)
- if not service_level_agreement:
+ if not sla:
return
- set_sla_properties(doc, service_level_agreement)
+ process_sla(doc, sla)
-def set_sla_properties(doc, service_level_agreement):
- if frappe.db.exists(doc.doctype, doc.name):
- from_db = frappe.get_doc(doc.doctype, doc.name)
- else:
- from_db = frappe._dict({})
-
- meta = frappe.get_meta(doc.doctype)
-
- if meta.has_field("customer") and service_level_agreement.customer and doc.get("customer") and \
- not service_level_agreement.customer == doc.get("customer"):
- frappe.throw(_("Service Level Agreement {0} is specific to Customer {1}").format(service_level_agreement.name,
- service_level_agreement.customer))
-
- doc.service_level_agreement = service_level_agreement.name
- doc.priority = doc.get("priority") or service_level_agreement.default_priority
- priority = get_priority(doc)
+def process_sla(doc, sla):
if not doc.creation:
doc.creation = now_datetime(doc.get("owner"))
-
- if meta.has_field("service_level_agreement_creation"):
+ if doc.meta.has_field("service_level_agreement_creation"):
doc.service_level_agreement_creation = now_datetime(doc.get("owner"))
+ doc.service_level_agreement = sla.name
+ doc.priority = doc.get("priority") or sla.default_priority
+
+ handle_status_change(doc, sla.apply_sla_for_resolution)
+ update_response_and_resolution_metrics(doc, sla.apply_sla_for_resolution)
+ update_agreement_status(doc, sla.apply_sla_for_resolution)
+
+
+def handle_status_change(doc, apply_sla_for_resolution):
+ now_time = frappe.flags.current_time or now_datetime(doc.get("owner"))
+ prev_status = frappe.db.get_value(doc.doctype, doc.name, 'status')
+
+ hold_statuses = get_hold_statuses(doc.service_level_agreement)
+ fulfillment_statuses = get_fulfillment_statuses(doc.service_level_agreement)
+
+ def is_hold_status(status):
+ return status in hold_statuses
+
+ def is_fulfilled_status(status):
+ return status in fulfillment_statuses
+
+ def is_open_status(status):
+ return status not in hold_statuses and status not in fulfillment_statuses
+
+ def set_first_response():
+ if doc.meta.has_field("first_responded_on") and not doc.get('first_responded_on'):
+ doc.first_responded_on = now_time
+ if get_datetime(doc.get('first_responded_on')) > get_datetime(doc.get('response_by')):
+ record_assigned_users_on_failure(doc)
+
+ def calculate_hold_hours():
+ # In case issue was closed and after few days it has been opened
+ # The hold time should be calculated from resolution_date
+
+ on_hold_since = doc.resolution_date or doc.on_hold_since
+ if on_hold_since:
+ current_hold_hours = time_diff_in_seconds(now_time, on_hold_since)
+ doc.total_hold_time = (doc.total_hold_time or 0) + current_hold_hours
+ doc.on_hold_since = None
+
+ if ((is_open_status(prev_status) and not is_open_status(doc.status)) or doc.flags.on_first_reply):
+ set_first_response()
+
+ # Open to Replied
+ if is_open_status(prev_status) and is_hold_status(doc.status):
+ # Issue is on hold -> Set on_hold_since
+ doc.on_hold_since = now_time
+
+ # Replied to Open
+ if is_hold_status(prev_status) and is_open_status(doc.status):
+ # Issue was on hold -> Calculate Total Hold Time
+ calculate_hold_hours()
+ # Issue is open -> reset resolution_date
+ reset_expected_response_and_resolution(doc)
+ reset_resolution_metrics(doc)
+
+ # Open to Closed
+ if is_open_status(prev_status) and is_fulfilled_status(doc.status):
+ # Issue is closed -> Set resolution_date
+ doc.resolution_date = now_time
+ set_resolution_time(doc)
+
+ # Closed to Open
+ if is_fulfilled_status(prev_status) and is_open_status(doc.status):
+ # Issue was closed -> Calculate Total Hold Time from resolution_date
+ calculate_hold_hours()
+ # Issue is open -> reset resolution_date
+ reset_expected_response_and_resolution(doc)
+ reset_resolution_metrics(doc)
+
+ # Closed to Replied
+ if is_fulfilled_status(prev_status) and is_hold_status(doc.status):
+ # Issue was closed -> Calculate Total Hold Time from resolution_date
+ calculate_hold_hours()
+ # Issue is on hold -> Set on_hold_since
+ doc.on_hold_since = now_time
+
+ # Replied to Closed
+ if is_hold_status(prev_status) and is_fulfilled_status(doc.status):
+ # Issue was on hold -> Calculate Total Hold Time
+ calculate_hold_hours()
+ # Issue is closed -> Set resolution_date
+ if apply_sla_for_resolution:
+ doc.resolution_date = now_time
+ set_resolution_time(doc)
+
+
+def get_fulfillment_statuses(service_level_agreement):
+ return [entry.status for entry in frappe.db.get_all("SLA Fulfilled On Status", filters={
+ "parent": service_level_agreement
+ }, fields=["status"])]
+
+
+def get_hold_statuses(service_level_agreement):
+ return [entry.status for entry in frappe.db.get_all("Pause SLA On Status", filters={
+ "parent": service_level_agreement
+ }, fields=["status"])]
+
+
+def update_response_and_resolution_metrics(doc, apply_sla_for_resolution):
+ priority = get_response_and_resolution_duration(doc)
start_date_time = get_datetime(doc.get("service_level_agreement_creation") or doc.creation)
-
- set_response_by_and_variance(doc, meta, start_date_time, priority)
- if service_level_agreement.apply_sla_for_resolution:
- set_resolution_by_and_variance(doc, meta, start_date_time, priority)
-
- update_status(doc, from_db, meta)
-
-
-def update_status(doc, from_db, meta):
- if meta.has_field("status"):
- if meta.has_field("first_responded_on") and doc.status != "Open" and \
- from_db.status == "Open" and not doc.first_responded_on:
- doc.first_responded_on = frappe.flags.current_time or now_datetime(doc.get("owner"))
-
- if meta.has_field("service_level_agreement") and doc.service_level_agreement:
- # mark sla status as fulfilled based on the configuration
- fulfillment_statuses = [entry.status for entry in frappe.db.get_all("SLA Fulfilled On Status", filters={
- "parent": doc.service_level_agreement
- }, fields=["status"])]
-
- if doc.status in fulfillment_statuses and from_db.status not in fulfillment_statuses:
- apply_sla_for_resolution = frappe.db.get_value("Service Level Agreement", doc.service_level_agreement,
- "apply_sla_for_resolution")
-
- if apply_sla_for_resolution and meta.has_field("resolution_date"):
- doc.resolution_date = frappe.flags.current_time or now_datetime(doc.get("owner"))
-
- if meta.has_field("agreement_status") and from_db.agreement_status == "Ongoing":
- set_service_level_agreement_variance(doc.doctype, doc.name)
- update_agreement_status(doc, meta)
-
- if apply_sla_for_resolution:
- set_resolution_time(doc, meta)
- set_user_resolution_time(doc, meta)
-
- if doc.status == "Open" and from_db.status != "Open":
- # if no date, it should be set as None and not a blank string "", as per mysql strict config
- # enable SLA and variance on Reopen
- reset_metrics(doc, meta)
- set_service_level_agreement_variance(doc.doctype, doc.name)
-
- handle_hold_time(doc, meta, from_db.status)
+ set_response_by(doc, start_date_time, priority)
+ if apply_sla_for_resolution:
+ set_resolution_by(doc, start_date_time, priority)
def get_expected_time_for(parameter, service_level, start_date_time):
@@ -485,37 +548,13 @@
return support_days
-def set_service_level_agreement_variance(doctype, doc=None):
+def set_resolution_time(doc):
+ start_date_time = get_datetime(doc.get("service_level_agreement_creation") or doc.creation)
+ if doc.meta.has_field("resolution_time"):
+ doc.resolution_time = time_diff_in_seconds(doc.resolution_date, start_date_time)
- filters = {"status": "Open", "agreement_status": "Ongoing"}
-
- if doc:
- filters = {"name": doc}
-
- for entry in frappe.get_all(doctype, filters=filters):
- current_doc = frappe.get_doc(doctype, entry.name)
- current_time = frappe.flags.current_time or now_datetime(current_doc.get("owner"))
- apply_sla_for_resolution = frappe.db.get_value("Service Level Agreement", current_doc.service_level_agreement,
- "apply_sla_for_resolution")
-
- if not current_doc.first_responded_on: # first_responded_on set when first reply is sent to customer
- variance = round(time_diff_in_seconds(current_doc.response_by, current_time), 2)
- frappe.db.set_value(current_doc.doctype, current_doc.name, "response_by_variance", variance, update_modified=False)
-
- if variance < 0:
- frappe.db.set_value(current_doc.doctype, current_doc.name, "agreement_status", "Failed", update_modified=False)
-
- if apply_sla_for_resolution and not current_doc.get("resolution_date"): # resolution_date set when issue has been closed
- variance = round(time_diff_in_seconds(current_doc.resolution_by, current_time), 2)
- frappe.db.set_value(current_doc.doctype, current_doc.name, "resolution_by_variance", variance, update_modified=False)
-
- if variance < 0:
- frappe.db.set_value(current_doc.doctype, current_doc.name, "agreement_status", "Failed", update_modified=False)
-
-
-def set_user_resolution_time(doc, meta):
# total time taken by a user to close the issue apart from wait_time
- if not meta.has_field("user_resolution_time"):
+ if not doc.meta.has_field("user_resolution_time"):
return
communications = frappe.get_all("Communication", filters={
@@ -531,7 +570,7 @@
pending_time.append(wait_time)
total_pending_time = sum(pending_time)
- resolution_time_in_secs = time_diff_in_seconds(doc.resolution_date, doc.creation)
+ resolution_time_in_secs = time_diff_in_seconds(doc.resolution_date, start_date_time)
doc.user_resolution_time = resolution_time_in_secs - total_pending_time
@@ -548,12 +587,12 @@
frappe.msgprint(_("Service Level Agreement has been changed to {0}.").format(self.service_level_agreement))
-def get_priority(doc):
- service_level_agreement = frappe.get_doc("Service Level Agreement", doc.service_level_agreement)
- priority = service_level_agreement.get_service_level_agreement_priority(doc.priority)
+def get_response_and_resolution_duration(doc):
+ sla = frappe.get_doc("Service Level Agreement", doc.service_level_agreement)
+ priority = sla.get_service_level_agreement_priority(doc.priority)
priority.update({
- "support_and_resolution": service_level_agreement.support_and_resolution,
- "holiday_list": service_level_agreement.holiday_list
+ "support_and_resolution": sla.support_and_resolution,
+ "holiday_list": sla.holiday_list
})
return priority
@@ -572,122 +611,99 @@
}).insert(ignore_permissions=True)
doc.service_level_agreement_creation = now_datetime(doc.get("owner"))
- doc.set_response_and_resolution_time(priority=doc.priority, service_level_agreement=doc.service_level_agreement)
- doc.agreement_status = "Ongoing"
doc.save()
-def reset_metrics(doc, meta):
- if meta.has_field("resolution_date"):
+def reset_resolution_metrics(doc):
+ if doc.meta.has_field("resolution_date"):
doc.resolution_date = None
- if not meta.has_field("resolution_time"):
+ if doc.meta.has_field("resolution_time"):
doc.resolution_time = None
- if not meta.has_field("user_resolution_time"):
+ if doc.meta.has_field("user_resolution_time"):
doc.user_resolution_time = None
- if meta.has_field("agreement_status"):
- doc.agreement_status = "Ongoing"
-
-
-def set_resolution_time(doc, meta):
- # total time taken from issue creation to closing
- if not meta.has_field("resolution_time"):
- return
-
- doc.resolution_time = time_diff_in_seconds(doc.resolution_date, doc.creation)
+ if doc.meta.has_field("agreement_status"):
+ doc.agreement_status = "First Response Due"
# called via hooks on communication update
-def update_hold_time(doc, status):
+def on_communication_update(doc, status):
+ if doc.communication_type == "Comment":
+ return
+
parent = get_parent_doc(doc)
if not parent:
return
- if doc.communication_type == "Comment":
+ if not parent.meta.has_field('service_level_agreement'):
return
- status_field = parent.meta.get_field("status")
- if status_field:
- options = (status_field.options or "").splitlines()
+ for_resolution = frappe.db.get_value('Service Level Agreement', parent.service_level_agreement, 'apply_sla_for_resolution')
- # if status has a "Replied" option, then handle hold time
- if ("Replied" in options) and doc.sent_or_received == "Received":
- meta = frappe.get_meta(parent.doctype)
- handle_hold_time(parent, meta, 'Replied')
+ if (
+ doc.sent_or_received == "Received" # a reply is received
+ and parent.get('status') == 'Open' # issue status is set as open from communication.py
+ and parent._doc_before_save
+ and parent.get('status') != parent._doc_before_save.get('status') # status changed
+ ):
+ # undo the status change in db
+ # since prev status is fetched from db
+ frappe.db.set_value(parent.doctype, parent.name, 'status', parent._doc_before_save.get('status'))
+
+ elif (
+ doc.sent_or_received == "Sent" # a reply is sent
+ and parent.get('first_responded_on') # first_responded_on is set from communication.py
+ and parent._doc_before_save
+ and not parent._doc_before_save.get('first_responded_on') # first_responded_on was not set
+ ):
+ # reset first_responded_on since it will be handled/set later on
+ parent.first_responded_on = None
+ parent.flags.on_first_reply = True
+
+ handle_status_change(parent, for_resolution)
+ update_response_and_resolution_metrics(parent, for_resolution)
+ update_agreement_status(parent, for_resolution)
+
+ parent.save()
-def handle_hold_time(doc, meta, status):
- if meta.has_field("service_level_agreement") and doc.service_level_agreement:
- # set response and resolution variance as None as the issue is on Hold for status as Replied
- hold_statuses = [entry.status for entry in frappe.db.get_all("Pause SLA On Status", filters={
- "parent": doc.service_level_agreement
- }, fields=["status"])]
-
- if not hold_statuses:
- return
-
- if meta.has_field("status") and doc.status in hold_statuses and status not in hold_statuses:
- apply_hold_status(doc, meta)
-
- # calculate hold time when status is changed from any hold status to any non-hold status
- if meta.has_field("status") and doc.status not in hold_statuses and status in hold_statuses:
- reset_hold_status_and_update_hold_time(doc, meta)
-
-
-def apply_hold_status(doc, meta):
- update_values = {'on_hold_since': frappe.flags.current_time or now_datetime(doc.get("owner"))}
-
- if meta.has_field("first_responded_on") and not doc.first_responded_on:
- update_values['response_by'] = None
- update_values['response_by_variance'] = 0
-
- update_values['resolution_by'] = None
- update_values['resolution_by_variance'] = 0
-
- doc.db_set(update_values)
-
-
-def reset_hold_status_and_update_hold_time(doc, meta):
- hold_time = doc.total_hold_time if meta.has_field("total_hold_time") and doc.total_hold_time else 0
- now_time = frappe.flags.current_time or now_datetime(doc.get("owner"))
- last_hold_time = 0
+def reset_expected_response_and_resolution(doc):
update_values = {}
-
- if meta.has_field("on_hold_since") and doc.on_hold_since:
- # last_hold_time will be added to the sla variables
- last_hold_time = time_diff_in_seconds(now_time, doc.on_hold_since)
- update_values['total_hold_time'] = hold_time + last_hold_time
-
- # re-calculate SLA variables after issue changes from any hold status to any non-hold status
- start_date_time = get_datetime(doc.get("service_level_agreement_creation") or doc.creation)
- priority = get_priority(doc)
- now_time = frappe.flags.current_time or now_datetime(doc.get("owner"))
-
- # add hold time to response by variance
- if meta.has_field("first_responded_on") and not doc.first_responded_on:
- response_by = get_expected_time_for(parameter="response", service_level=priority, start_date_time=start_date_time)
- response_by = add_to_date(response_by, seconds=round(last_hold_time))
- response_by_variance = round(time_diff_in_seconds(response_by, now_time))
-
- update_values['response_by'] = response_by
- update_values['response_by_variance'] = response_by_variance + last_hold_time
-
- # add hold time to resolution by variance
- if frappe.db.get_value("Service Level Agreement", doc.service_level_agreement, "apply_sla_for_resolution"):
- resolution_by = get_expected_time_for(parameter="resolution", service_level=priority, start_date_time=start_date_time)
- resolution_by = add_to_date(resolution_by, seconds=round(last_hold_time))
- resolution_by_variance = round(time_diff_in_seconds(resolution_by, now_time))
-
- update_values['resolution_by'] = resolution_by
- update_values['resolution_by_variance'] = resolution_by_variance + last_hold_time
-
- update_values['on_hold_since'] = None
-
+ if doc.meta.has_field("first_responded_on") and not doc.get('first_responded_on'):
+ update_values['response_by'] = None
+ if doc.meta.has_field("resolution_by") and not doc.get('resolution_date'):
+ update_values['resolution_by'] = None
doc.db_set(update_values)
+def set_response_by(doc, start_date_time, priority):
+ if doc.meta.has_field("response_by"):
+ doc.response_by = get_expected_time_for(parameter="response", service_level=priority, start_date_time=start_date_time)
+ if doc.meta.has_field("total_hold_time") and doc.get('total_hold_time') and not doc.get('first_responded_on'):
+ doc.response_by = add_to_date(doc.response_by, seconds=round(doc.get('total_hold_time')))
+
+
+def set_resolution_by(doc, start_date_time, priority):
+ if doc.meta.has_field("resolution_by"):
+ doc.resolution_by = get_expected_time_for(parameter="resolution", service_level=priority, start_date_time=start_date_time)
+ if doc.meta.has_field("total_hold_time") and doc.get('total_hold_time'):
+ doc.resolution_by = add_to_date(doc.resolution_by, seconds=round(doc.get('total_hold_time')))
+
+
+def record_assigned_users_on_failure(doc):
+ assigned_users = doc.get_assigned_users()
+ if assigned_users:
+ from frappe.utils import get_fullname
+ assigned_users = ', '.join((get_fullname(user) for user in assigned_users))
+ message = _('First Response SLA Failed by {}').format(assigned_users)
+ doc.add_comment(
+ comment_type='Assigned',
+ text=message
+ )
+
+
def get_service_level_agreement_fields():
return [
{
@@ -715,16 +731,10 @@
"read_only": 1
},
{
- "fieldname": "response_by_variance",
- "fieldtype": "Duration",
- "hide_seconds": 1,
- "label": "Response By Variance",
- "read_only": 1
- },
- {
"fieldname": "first_responded_on",
"fieldtype": "Datetime",
"label": "First Responded On",
+ "no_copy": 1,
"read_only": 1
},
{
@@ -746,11 +756,11 @@
"read_only": 1
},
{
- "default": "Ongoing",
+ "default": "First Response Due",
"fieldname": "agreement_status",
"fieldtype": "Select",
"label": "Service Level Agreement Status",
- "options": "Ongoing\nFulfilled\nFailed",
+ "options": "First Response Due\nResolution Due\nFulfilled\nFailed",
"read_only": 1
},
{
@@ -760,13 +770,6 @@
"read_only": 1
},
{
- "fieldname": "resolution_by_variance",
- "fieldtype": "Duration",
- "hide_seconds": 1,
- "label": "Resolution By Variance",
- "read_only": 1
- },
- {
"fieldname": "service_level_agreement_creation",
"fieldtype": "Datetime",
"hidden": 1,
@@ -786,43 +789,28 @@
def update_agreement_status_on_custom_status(doc):
# Update Agreement Fulfilled status using Custom Scripts for Custom Status
-
- meta = frappe.get_meta(doc.doctype)
- now_time = frappe.flags.current_time or now_datetime(doc.get("owner"))
- if meta.has_field("first_responded_on") and not doc.first_responded_on:
- # first_responded_on set when first reply is sent to customer
- doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, now_time), 2)
-
- if meta.has_field("resolution_date") and not doc.resolution_date:
- # resolution_date set when issue has been closed
- doc.resolution_by_variance = round(time_diff_in_seconds(doc.resolution_by, now_time), 2)
-
- if meta.has_field("agreement_status"):
- doc.agreement_status = "Fulfilled" if doc.response_by_variance > 0 and doc.resolution_by_variance > 0 else "Failed"
+ update_agreement_status(doc)
-def update_agreement_status(doc, meta):
- if meta.has_field("service_level_agreement") and meta.has_field("agreement_status") and \
- doc.service_level_agreement and doc.agreement_status == "Ongoing":
-
- apply_sla_for_resolution = frappe.db.get_value("Service Level Agreement", doc.service_level_agreement,
- "apply_sla_for_resolution")
-
+def update_agreement_status(doc, apply_sla_for_resolution):
+ if (doc.meta.has_field("agreement_status")):
# if SLA is applied for resolution check for response and resolution, else only response
if apply_sla_for_resolution:
- if meta.has_field("response_by_variance") and meta.has_field("resolution_by_variance"):
- if cint(frappe.db.get_value(doc.doctype, doc.name, "response_by_variance")) < 0 or \
- cint(frappe.db.get_value(doc.doctype, doc.name, "resolution_by_variance")) < 0:
-
- doc.agreement_status = "Failed"
- else:
- doc.agreement_status = "Fulfilled"
- else:
- if meta.has_field("response_by_variance") and \
- cint(frappe.db.get_value(doc.doctype, doc.name, "response_by_variance")) < 0:
- doc.agreement_status = "Failed"
- else:
+ if doc.meta.has_field("first_responded_on") and not doc.get('first_responded_on'):
+ doc.agreement_status = "First Response Due"
+ elif doc.meta.has_field("resolution_date") and not doc.get('resolution_date'):
+ doc.agreement_status = "Resolution Due"
+ elif get_datetime(doc.get('resolution_date')) <= get_datetime(doc.get('resolution_by')):
doc.agreement_status = "Fulfilled"
+ else:
+ doc.agreement_status = "Failed"
+ else:
+ if doc.meta.has_field("first_responded_on") and not doc.get('first_responded_on'):
+ doc.agreement_status = "First Response Due"
+ elif get_datetime(doc.get('first_responded_on')) <= get_datetime(doc.get('response_by')):
+ doc.agreement_status = "Fulfilled"
+ else:
+ doc.agreement_status = "Failed"
def is_holiday(date, holidays):
@@ -835,23 +823,6 @@
return datetime.timedelta(hours=time.hour, minutes=time.minute, seconds=time.second)
-def set_response_by_and_variance(doc, meta, start_date_time, priority):
- if meta.has_field("response_by"):
- doc.response_by = get_expected_time_for(parameter="response", service_level=priority, start_date_time=start_date_time)
-
- if meta.has_field("response_by_variance") and not doc.get('first_responded_on'):
- now_time = frappe.flags.current_time or now_datetime(doc.get("owner"))
- doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, now_time), 2)
-
-def set_resolution_by_and_variance(doc, meta, start_date_time, priority):
- if meta.has_field("resolution_by"):
- doc.resolution_by = get_expected_time_for(parameter="resolution", service_level=priority, start_date_time=start_date_time)
-
- if meta.has_field("resolution_by_variance") and not doc.get("resolution_date"):
- now_time = frappe.flags.current_time or now_datetime(doc.get("owner"))
- doc.resolution_by_variance = round(time_diff_in_seconds(doc.resolution_by, now_time), 2)
-
-
def now_datetime(user):
dt = convert_utc_to_user_timezone(datetime.utcnow(), user)
return dt.replace(tzinfo=None)
diff --git a/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py
index cfbe744..b07c862 100644
--- a/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py
+++ b/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py
@@ -220,42 +220,6 @@
lead.reload()
self.assertEqual(lead.agreement_status, 'Fulfilled')
- def test_changing_of_variance_after_response(self):
- # create lead
- doctype = "Lead"
- lead_sla = create_service_level_agreement(
- default_service_level_agreement=1,
- holiday_list="__Test Holiday List",
- entity_type=None, entity=None,
- response_time=14400,
- doctype=doctype,
- sla_fulfilled_on=[{"status": "Replied"}],
- apply_sla_for_resolution=0
- )
- creation = datetime.datetime(2019, 3, 4, 12, 0)
- lead = make_lead(creation=creation, index=2)
- self.assertEqual(lead.service_level_agreement, lead_sla.name)
-
- # set lead as replied to set first responded on
- frappe.flags.current_time = datetime.datetime(2019, 3, 4, 15, 30)
- lead.reload()
- lead.status = 'Replied'
- lead.save()
- lead.reload()
- self.assertEqual(lead.agreement_status, 'Fulfilled')
-
- # check response_by_variance
- self.assertEqual(lead.first_responded_on, frappe.flags.current_time)
- self.assertEqual(lead.response_by_variance, 1800.0)
-
- # make a change on the document &
- # check response_by_variance is unchanged
- frappe.flags.current_time = datetime.datetime(2019, 3, 4, 18, 30)
- lead.status = 'Open'
- lead.save()
- lead.reload()
- self.assertEqual(lead.response_by_variance, 1800.0)
-
def test_service_level_agreement_filters(self):
doctype = "Lead"
lead_sla = create_service_level_agreement(
@@ -295,7 +259,8 @@
return service_level_agreement
def create_service_level_agreement(default_service_level_agreement, holiday_list, response_time, entity_type,
- entity, resolution_time=0, doctype="Issue", condition="", sla_fulfilled_on=[], pause_sla_on=[], apply_sla_for_resolution=1):
+ entity, resolution_time=0, doctype="Issue", condition="", sla_fulfilled_on=[], pause_sla_on=[], apply_sla_for_resolution=1,
+ service_level=None, start_time="10:00:00", end_time="18:00:00"):
make_holiday_list()
make_priorities()
@@ -312,7 +277,7 @@
"doctype": "Service Level Agreement",
"enabled": 1,
"document_type": doctype,
- "service_level": "__Test {} SLA".format(entity_type if entity_type else "Default"),
+ "service_level": service_level or "__Test {} SLA".format(entity_type if entity_type else "Default"),
"default_service_level_agreement": default_service_level_agreement,
"condition": condition,
"default_priority": "Medium",
@@ -345,28 +310,28 @@
"support_and_resolution": [
{
"workday": "Monday",
- "start_time": "10:00:00",
- "end_time": "18:00:00",
+ "start_time": start_time,
+ "end_time": end_time,
},
{
"workday": "Tuesday",
- "start_time": "10:00:00",
- "end_time": "18:00:00",
+ "start_time": start_time,
+ "end_time": end_time,
},
{
"workday": "Wednesday",
- "start_time": "10:00:00",
- "end_time": "18:00:00",
+ "start_time": start_time,
+ "end_time": end_time,
},
{
"workday": "Thursday",
- "start_time": "10:00:00",
- "end_time": "18:00:00",
+ "start_time": start_time,
+ "end_time": end_time,
},
{
"workday": "Friday",
- "start_time": "10:00:00",
- "end_time": "18:00:00",
+ "start_time": start_time,
+ "end_time": end_time,
}
]
})
@@ -386,7 +351,7 @@
if sla:
frappe.delete_doc("Service Level Agreement", sla, force=1)
- return frappe.get_doc(service_level_agreement).insert(ignore_permissions=True)
+ return frappe.get_doc(service_level_agreement).insert(ignore_permissions=True, ignore_if_duplicate=True)
def create_customer():
@@ -443,6 +408,13 @@
create_service_level_agreement(default_service_level_agreement=0, holiday_list="__Test Holiday List",
entity_type="Territory", entity="_Test SLA Territory", response_time=7200, resolution_time=10800)
+ create_service_level_agreement(
+ default_service_level_agreement=0, holiday_list="__Test Holiday List",
+ entity_type=None, entity=None, response_time=14400, resolution_time=21600,
+ service_level="24-hour-SLA", start_time="00:00:00", end_time="23:59:59",
+ condition="doc.issue_type == 'Critical'"
+ )
+
def make_holiday_list():
holiday_list = frappe.db.exists("Holiday List", "__Test Holiday List")
if not holiday_list:
diff --git a/erpnext/support/report/issue_summary/issue_summary.py b/erpnext/support/report/issue_summary/issue_summary.py
index 39a5c40..67fe345 100644
--- a/erpnext/support/report/issue_summary/issue_summary.py
+++ b/erpnext/support/report/issue_summary/issue_summary.py
@@ -82,7 +82,8 @@
self.sla_status_map = {
'SLA Failed': 'failed',
'SLA Fulfilled': 'fulfilled',
- 'SLA Ongoing': 'ongoing'
+ 'First Response Due': 'first_response_due',
+ 'Resolution Due': 'resolution_due'
}
for label, fieldname in self.sla_status_map.items():
diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv
index ca03a78..d46ffb5 100644
--- a/erpnext/translations/de.csv
+++ b/erpnext/translations/de.csv
@@ -1847,7 +1847,7 @@
Overlap in scoring between {0} and {1},Überlappung beim Scoring zwischen {0} und {1},
Overlapping conditions found between:,Überlagernde Bedingungen gefunden zwischen:,
Owner,Besitzer,
-PAN,PFANNE,
+PAN,PAN,
POS,Verkaufsstelle,
POS Profile,Verkaufsstellen-Profil,
POS Profile is required to use Point-of-Sale,"POS-Profil ist erforderlich, um Point-of-Sale zu verwenden",
diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py
index 14b3afa..1d8b3a8 100644
--- a/erpnext/utilities/transaction_base.py
+++ b/erpnext/utilities/transaction_base.py
@@ -162,6 +162,28 @@
return ret
+ def reset_default_field_value(self, default_field: str, child_table: str, child_table_field: str):
+ """ Reset "Set default X" fields on forms to avoid confusion.
+
+ example:
+ doc = {
+ "set_from_warehouse": "Warehouse A",
+ "items": [{"from_warehouse": "warehouse B"}, {"from_warehouse": "warehouse A"}],
+ }
+ Since this has dissimilar values in child table, the default field will be erased.
+
+ doc.reset_default_field_value("set_from_warehouse", "items", "from_warehouse")
+ """
+ child_table_values = set()
+
+ for row in self.get(child_table):
+ child_table_values.add(row.get(child_table_field))
+
+ if len(child_table_values) > 1:
+ self.set(default_field, None)
+ else:
+ self.set(default_field, list(child_table_values)[0])
+
def delete_events(ref_type, ref_name):
events = frappe.db.sql_list(""" SELECT
distinct `tabEvent`.name