Merge branch 'develop' into maint_sch_link_fix
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/codecov.yml b/codecov.yml
index 67bd445..1fa602a 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -8,6 +8,16 @@
target: auto
threshold: 0.5%
+ patch:
+ default:
+ target: 85%
+ threshold: 0%
+ base: auto
+ branches:
+ - develop
+ if_ci_failed: ignore
+ only_pulls: true
+
comment:
layout: "diff, files"
require_changes: true
diff --git a/erpnext/accounts/deferred_revenue.py b/erpnext/accounts/deferred_revenue.py
index 5df8269..22c81dd 100644
--- a/erpnext/accounts/deferred_revenue.py
+++ b/erpnext/accounts/deferred_revenue.py
@@ -374,12 +374,13 @@
frappe.db.commit()
except Exception as e:
if frappe.flags.in_test:
+ traceback = frappe.get_traceback()
+ frappe.log_error(title=_('Error while processing deferred accounting for Invoice {0}').format(doc.name), message=traceback)
raise e
else:
frappe.db.rollback()
traceback = frappe.get_traceback()
- frappe.log_error(message=traceback)
-
+ frappe.log_error(title=_('Error while processing deferred accounting for Invoice {0}').format(doc.name), message=traceback)
frappe.flags.deferred_accounting_error = True
def send_mail(deferred_process):
@@ -446,10 +447,12 @@
if submit:
journal_entry.submit()
+
+ frappe.db.commit()
except Exception:
frappe.db.rollback()
traceback = frappe.get_traceback()
- frappe.log_error(message=traceback)
+ frappe.log_error(title=_('Error while processing deferred accounting for Invoice {0}').format(doc.name), message=traceback)
frappe.flags.deferred_accounting_error = True
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/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
index 2a923f0..ddb833f 100644
--- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
+++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
@@ -159,7 +159,8 @@
frappe.scrub(row.party_type): row.party,
"is_pos": 0,
"doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice",
- "update_stock": 0
+ "update_stock": 0,
+ "invoice_number": row.invoice_number
})
accounting_dimension = get_accounting_dimensions()
@@ -200,10 +201,13 @@
names = []
for idx, d in enumerate(invoices):
try:
+ invoice_number = None
+ if d.invoice_number:
+ invoice_number = d.invoice_number
publish(idx, len(invoices), d.doctype)
doc = frappe.get_doc(d)
doc.flags.ignore_mandatory = True
- doc.insert()
+ doc.insert(set_name=invoice_number)
doc.submit()
frappe.db.commit()
names.append(doc.name)
diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py
index c795e83..07c72bd 100644
--- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py
+++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py
@@ -18,10 +18,10 @@
if not frappe.db.exists("Company", "_Test Opening Invoice Company"):
make_company()
- def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None):
+ def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None, invoice_number=None):
doc = frappe.get_single("Opening Invoice Creation Tool")
args = get_opening_invoice_creation_dict(invoice_type=invoice_type, company=company,
- party_1=party_1, party_2=party_2)
+ party_1=party_1, party_2=party_2, invoice_number=invoice_number)
doc.update(args)
return doc.make_invoices()
@@ -92,6 +92,20 @@
# teardown
frappe.db.set_value("Company", company, "default_receivable_account", old_default_receivable_account)
+ def test_renaming_of_invoice_using_invoice_number_field(self):
+ company = "_Test Opening Invoice Company"
+ party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
+ self.make_invoices(company=company, party_1=party_1, party_2=party_2, invoice_number="TEST-NEW-INV-11")
+
+ sales_inv1 = frappe.get_all('Sales Invoice', filters={'customer':'Customer A'})[0].get("name")
+ sales_inv2 = frappe.get_all('Sales Invoice', filters={'customer':'Customer B'})[0].get("name")
+ self.assertEqual(sales_inv1, "TEST-NEW-INV-11")
+
+ #teardown
+ for inv in [sales_inv1, sales_inv2]:
+ doc = frappe.get_doc('Sales Invoice', inv)
+ doc.cancel()
+
def get_opening_invoice_creation_dict(**args):
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"
company = args.get("company", "_Test Company")
@@ -107,7 +121,8 @@
"item_name": "Opening Item",
"due_date": "2016-09-10",
"posting_date": "2016-09-05",
- "temporary_opening_account": get_temporary_opening_account(company)
+ "temporary_opening_account": get_temporary_opening_account(company),
+ "invoice_number": args.get("invoice_number")
},
{
"qty": 2.0,
@@ -116,7 +131,8 @@
"item_name": "Opening Item",
"due_date": "2016-09-10",
"posting_date": "2016-09-05",
- "temporary_opening_account": get_temporary_opening_account(company)
+ "temporary_opening_account": get_temporary_opening_account(company),
+ "invoice_number": None
}
]
})
diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json b/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json
index 4ce8cb9..040624f 100644
--- a/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json
+++ b/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json
@@ -1,9 +1,11 @@
{
+ "actions": [],
"creation": "2017-08-29 04:26:36.159247",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
+ "invoice_number",
"party_type",
"party",
"temporary_opening_account",
@@ -103,10 +105,17 @@
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
+ },
+ {
+ "description": "Reference number of the invoice from the previous system",
+ "fieldname": "invoice_number",
+ "fieldtype": "Data",
+ "label": "Invoice Number"
}
],
"istable": 1,
- "modified": "2019-07-25 15:00:00.460695",
+ "links": [],
+ "modified": "2021-12-13 18:15:41.295007",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool Item",
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/doctype/subscription_plan/subscription_plan.json b/erpnext/accounts/doctype/subscription_plan/subscription_plan.json
index 878ae09..563df79 100644
--- a/erpnext/accounts/doctype/subscription_plan/subscription_plan.json
+++ b/erpnext/accounts/doctype/subscription_plan/subscription_plan.json
@@ -75,7 +75,8 @@
"fieldname": "cost",
"fieldtype": "Currency",
"in_list_view": 1,
- "label": "Cost"
+ "label": "Cost",
+ "options": "currency"
},
{
"depends_on": "eval:doc.price_determination==\"Based On Price List\"",
@@ -147,7 +148,7 @@
}
],
"links": [],
- "modified": "2021-08-13 10:53:44.205774",
+ "modified": "2021-12-10 15:24:15.794477",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscription Plan",
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/deferred_revenue_and_expense/__init__.py b/erpnext/accounts/report/deferred_revenue_and_expense/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/accounts/report/deferred_revenue_and_expense/__init__.py
diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.js b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.js
new file mode 100644
index 0000000..0056b9e
--- /dev/null
+++ b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.js
@@ -0,0 +1,114 @@
+// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+/* eslint-disable */
+
+function get_filters() {
+ let filters = [
+ {
+ "fieldname":"company",
+ "label": __("Company"),
+ "fieldtype": "Link",
+ "options": "Company",
+ "default": frappe.defaults.get_user_default("Company"),
+ "reqd": 1
+ },
+ {
+ "fieldname":"filter_based_on",
+ "label": __("Filter Based On"),
+ "fieldtype": "Select",
+ "options": ["Fiscal Year", "Date Range"],
+ "default": ["Fiscal Year"],
+ "reqd": 1,
+ on_change: function() {
+ let filter_based_on = frappe.query_report.get_filter_value('filter_based_on');
+ frappe.query_report.toggle_filter_display('from_fiscal_year', filter_based_on === 'Date Range');
+ frappe.query_report.toggle_filter_display('to_fiscal_year', filter_based_on === 'Date Range');
+ frappe.query_report.toggle_filter_display('period_start_date', filter_based_on === 'Fiscal Year');
+ frappe.query_report.toggle_filter_display('period_end_date', filter_based_on === 'Fiscal Year');
+
+ frappe.query_report.refresh();
+ }
+ },
+ {
+ "fieldname":"period_start_date",
+ "label": __("Start Date"),
+ "fieldtype": "Date",
+ "hidden": 1,
+ "reqd": 1
+ },
+ {
+ "fieldname":"period_end_date",
+ "label": __("End Date"),
+ "fieldtype": "Date",
+ "hidden": 1,
+ "reqd": 1
+ },
+ {
+ "fieldname":"from_fiscal_year",
+ "label": __("Start Year"),
+ "fieldtype": "Link",
+ "options": "Fiscal Year",
+ "default": frappe.defaults.get_user_default("fiscal_year"),
+ "reqd": 1
+ },
+ {
+ "fieldname":"to_fiscal_year",
+ "label": __("End Year"),
+ "fieldtype": "Link",
+ "options": "Fiscal Year",
+ "default": frappe.defaults.get_user_default("fiscal_year"),
+ "reqd": 1
+ },
+ {
+ "fieldname": "periodicity",
+ "label": __("Periodicity"),
+ "fieldtype": "Select",
+ "options": [
+ { "value": "Monthly", "label": __("Monthly") },
+ { "value": "Quarterly", "label": __("Quarterly") },
+ { "value": "Half-Yearly", "label": __("Half-Yearly") },
+ { "value": "Yearly", "label": __("Yearly") }
+ ],
+ "default": "Monthly",
+ "reqd": 1
+ },
+ {
+ "fieldname": "type",
+ "label": __("Invoice Type"),
+ "fieldtype": "Select",
+ "options": [
+ { "value": "Revenue", "label": __("Revenue") },
+ { "value": "Expense", "label": __("Expense") }
+ ],
+ "default": "Revenue",
+ "reqd": 1
+ },
+ {
+ "fieldname" : "with_upcoming_postings",
+ "label": __("Show with upcoming revenue/expense"),
+ "fieldtype": "Check",
+ "default": 1
+ }
+ ]
+
+ return filters;
+}
+
+frappe.query_reports["Deferred Revenue and Expense"] = {
+ "filters": get_filters(),
+ "formatter": function(value, row, column, data, default_formatter){
+ return default_formatter(value, row, column, data);
+ },
+ onload: function(report){
+ let fiscal_year = frappe.defaults.get_user_default("fiscal_year");
+
+ frappe.model.with_doc("Fiscal Year", fiscal_year, function(r) {
+ var fy = frappe.model.get_doc("Fiscal Year", fiscal_year);
+ frappe.query_report.set_filter_value({
+ period_start_date: fy.year_start_date,
+ period_end_date: fy.year_end_date
+ });
+ });
+ }
+};
+
diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.json b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.json
new file mode 100644
index 0000000..c7dfb3b
--- /dev/null
+++ b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.json
@@ -0,0 +1,32 @@
+{
+ "add_total_row": 0,
+ "columns": [],
+ "creation": "2021-12-10 19:27:14.654220",
+ "disable_prepared_report": 0,
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "modified": "2021-12-10 19:27:14.654220",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Deferred Revenue and Expense",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "GL Entry",
+ "report_name": "Deferred Revenue and Expense",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "Accounts User"
+ },
+ {
+ "role": "Accounts Manager"
+ },
+ {
+ "role": "Auditor"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py
new file mode 100644
index 0000000..a4842c1
--- /dev/null
+++ b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py
@@ -0,0 +1,440 @@
+# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
+# License: MIT. See LICENSE
+
+import frappe
+from frappe import _, qb
+from frappe.query_builder import Column, functions
+from frappe.utils import add_days, date_diff, flt, get_first_day, get_last_day, rounded
+
+from erpnext.accounts.report.financial_statements import get_period_list
+
+
+class Deferred_Item(object):
+ """
+ Helper class for processing items with deferred revenue/expense
+ """
+
+ def __init__(self, item, inv, gle_entries):
+ self.name = item
+ self.parent = inv.name
+ self.item_name = gle_entries[0].item_name
+ self.service_start_date = gle_entries[0].service_start_date
+ self.service_end_date = gle_entries[0].service_end_date
+ self.base_net_amount = gle_entries[0].base_net_amount
+ self.filters = inv.filters
+ self.period_list = inv.period_list
+
+ if gle_entries[0].deferred_revenue_account:
+ self.type = "Deferred Sale Item"
+ self.deferred_account = gle_entries[0].deferred_revenue_account
+ elif gle_entries[0].deferred_expense_account:
+ self.type = "Deferred Purchase Item"
+ self.deferred_account = gle_entries[0].deferred_expense_account
+
+ self.gle_entries = []
+ # holds period wise total for item
+ self.period_total = []
+ self.last_entry_date = self.service_start_date
+
+ if gle_entries:
+ self.gle_entries = gle_entries
+ for x in self.gle_entries:
+ if self.get_amount(x):
+ self.last_entry_date = x.gle_posting_date
+
+ def report_data(self):
+ """
+ Generate report data for output
+ """
+ ret_data = frappe._dict({"name": self.item_name})
+ for period in self.period_total:
+ ret_data[period.key] = period.total
+ ret_data.indent = 1
+ return ret_data
+
+ def get_amount(self, entry):
+ """
+ For a given GL/Journal posting, get balance based on item type
+ """
+ if self.type == "Deferred Sale Item":
+ return entry.debit - entry.credit
+ elif self.type == "Deferred Purchase Item":
+ return -(entry.credit - entry.debit)
+ return 0
+
+ def get_item_total(self):
+ """
+ Helper method - calculate booked amount. Includes simulated postings as well
+ """
+ total = 0
+ for gle_posting in self.gle_entries:
+ total += self.get_amount(gle_posting)
+
+ return total
+
+ def calculate_amount(self, start_date, end_date):
+ """
+ start_date, end_date - datetime.datetime.date
+ return - estimated amount to post for given period
+ Calculated based on already booked amount and item service period
+ """
+ total_months = (
+ (self.service_end_date.year - self.service_start_date.year) * 12
+ + (self.service_end_date.month - self.service_start_date.month)
+ + 1
+ )
+
+ prorate = date_diff(self.service_end_date, self.service_start_date) / date_diff(
+ get_last_day(self.service_end_date), get_first_day(self.service_start_date)
+ )
+
+ actual_months = rounded(total_months * prorate, 1)
+
+ already_booked_amount = self.get_item_total()
+ base_amount = self.base_net_amount / actual_months
+
+ if base_amount + already_booked_amount > self.base_net_amount:
+ base_amount = self.base_net_amount - already_booked_amount
+
+ if not (get_first_day(start_date) == start_date and get_last_day(end_date) == end_date):
+ partial_month = flt(date_diff(end_date, start_date)) / flt(
+ date_diff(get_last_day(end_date), get_first_day(start_date))
+ )
+ base_amount *= rounded(partial_month, 1)
+
+ return base_amount
+
+ def make_dummy_gle(self, name, date, amount):
+ """
+ return - frappe._dict() of a dummy gle entry
+ """
+ entry = frappe._dict(
+ {"name": name, "gle_posting_date": date, "debit": 0, "credit": 0, "posted": "not"}
+ )
+ if self.type == "Deferred Sale Item":
+ entry.debit = amount
+ elif self.type == "Deferred Purchase Item":
+ entry.credit = amount
+ return entry
+
+ def simulate_future_posting(self):
+ """
+ simulate future posting by creating dummy gl entries. starts from the last posting date.
+ """
+ if add_days(self.last_entry_date, 1) < self.period_list[-1].to_date:
+ self.estimate_for_period_list = get_period_list(
+ self.filters.from_fiscal_year,
+ self.filters.to_fiscal_year,
+ add_days(self.last_entry_date, 1),
+ self.period_list[-1].to_date,
+ "Date Range",
+ "Monthly",
+ company=self.filters.company,
+ )
+ for period in self.estimate_for_period_list:
+ amount = self.calculate_amount(period.from_date, period.to_date)
+ gle = self.make_dummy_gle(period.key, period.to_date, amount)
+ self.gle_entries.append(gle)
+
+ def calculate_item_revenue_expense_for_period(self):
+ """
+ calculate item postings for each period and update period_total list
+ """
+ for period in self.period_list:
+ period_sum = 0
+ actual = 0
+ for posting in self.gle_entries:
+ # if period.from_date <= posting.posting_date <= period.to_date:
+ if period.from_date <= posting.gle_posting_date <= period.to_date:
+ period_sum += self.get_amount(posting)
+ if posting.posted == "posted":
+ actual += self.get_amount(posting)
+
+ self.period_total.append(
+ frappe._dict({"key": period.key, "total": period_sum, "actual": actual})
+ )
+ return self.period_total
+
+
+class Deferred_Invoice(object):
+ def __init__(self, invoice, items, filters, period_list):
+ """
+ Helper class for processing invoices with deferred revenue/expense items
+ invoice - string : invoice name
+ items - list : frappe._dict() with item details. Refer Deferred_Item for required fields
+ """
+ self.name = invoice
+ self.posting_date = items[0].posting_date
+ self.filters = filters
+ self.period_list = period_list
+ # holds period wise total for invoice
+ self.period_total = []
+
+ if items[0].deferred_revenue_account:
+ self.type = "Sales"
+ elif items[0].deferred_expense_account:
+ self.type = "Purchase"
+
+ self.items = []
+ # for each uniq items
+ self.uniq_items = set([x.item for x in items])
+ for item in self.uniq_items:
+ self.items.append(Deferred_Item(item, self, [x for x in items if x.item == item]))
+
+ def calculate_invoice_revenue_expense_for_period(self):
+ """
+ calculate deferred revenue/expense for all items in invoice
+ """
+ # initialize period_total list for invoice
+ for period in self.period_list:
+ self.period_total.append(frappe._dict({"key": period.key, "total": 0, "actual": 0}))
+
+ for item in self.items:
+ item_total = item.calculate_item_revenue_expense_for_period()
+ # update invoice total
+ for idx, period in enumerate(self.period_list, 0):
+ self.period_total[idx].total += item_total[idx].total
+ self.period_total[idx].actual += item_total[idx].actual
+ return self.period_total
+
+ def estimate_future(self):
+ """
+ create dummy GL entries for upcoming months for all items in invoice
+ """
+ [item.simulate_future_posting() for item in self.items]
+
+ def report_data(self):
+ """
+ generate report data for invoice, includes invoice total
+ """
+ ret_data = []
+ inv_total = frappe._dict({"name": self.name})
+ for x in self.period_total:
+ inv_total[x.key] = x.total
+ inv_total.indent = 0
+ ret_data.append(inv_total)
+ list(map(lambda item: ret_data.append(item.report_data()), self.items))
+ return ret_data
+
+
+class Deferred_Revenue_and_Expense_Report(object):
+ def __init__(self, filters=None):
+ """
+ Initialize deferred revenue/expense report with user provided filters or system defaults, if none is provided
+ """
+
+ # If no filters are provided, get user defaults
+ if not filters:
+ fiscal_year = frappe.get_doc("Fiscal Year", frappe.defaults.get_user_default("fiscal_year"))
+ self.filters = frappe._dict(
+ {
+ "company": frappe.defaults.get_user_default("Company"),
+ "filter_based_on": "Fiscal Year",
+ "period_start_date": fiscal_year.year_start_date,
+ "period_end_date": fiscal_year.year_end_date,
+ "from_fiscal_year": fiscal_year.year,
+ "to_fiscal_year": fiscal_year.year,
+ "periodicity": "Monthly",
+ "type": "Revenue",
+ "with_upcoming_postings": True,
+ }
+ )
+ else:
+ self.filters = frappe._dict(filters)
+
+ self.period_list = None
+ self.deferred_invoices = []
+ # holds period wise total for report
+ self.period_total = []
+
+ def get_period_list(self):
+ """
+ Figure out selected period based on filters
+ """
+ self.period_list = get_period_list(
+ self.filters.from_fiscal_year,
+ self.filters.to_fiscal_year,
+ self.filters.period_start_date,
+ self.filters.period_end_date,
+ self.filters.filter_based_on,
+ self.filters.periodicity,
+ company=self.filters.company,
+ )
+
+ def get_invoices(self):
+ """
+ Get all sales and purchase invoices which has deferred revenue/expense items
+ """
+ gle = qb.DocType("GL Entry")
+ # column doesn't have an alias option
+ posted = Column("posted")
+
+ if self.filters.type == "Revenue":
+ inv = qb.DocType("Sales Invoice")
+ inv_item = qb.DocType("Sales Invoice Item")
+ deferred_flag_field = inv_item["enable_deferred_revenue"]
+ deferred_account_field = inv_item["deferred_revenue_account"]
+
+ elif self.filters.type == "Expense":
+ inv = qb.DocType("Purchase Invoice")
+ inv_item = qb.DocType("Purchase Invoice Item")
+ deferred_flag_field = inv_item["enable_deferred_expense"]
+ deferred_account_field = inv_item["deferred_expense_account"]
+
+ query = (
+ qb.from_(inv_item)
+ .join(inv)
+ .on(inv.name == inv_item.parent)
+ .join(gle)
+ .on((inv_item.name == gle.voucher_detail_no) & (deferred_account_field == gle.account))
+ .select(
+ inv.name.as_("doc"),
+ inv.posting_date,
+ inv_item.name.as_("item"),
+ inv_item.item_name,
+ inv_item.service_start_date,
+ inv_item.service_end_date,
+ inv_item.base_net_amount,
+ deferred_account_field,
+ gle.posting_date.as_("gle_posting_date"),
+ functions.Sum(gle.debit).as_("debit"),
+ functions.Sum(gle.credit).as_("credit"),
+ posted,
+ )
+ .where(
+ (inv.docstatus == 1)
+ & (deferred_flag_field == 1)
+ & (
+ (
+ (self.period_list[0].from_date >= inv_item.service_start_date)
+ & (inv_item.service_end_date >= self.period_list[0].from_date)
+ )
+ | (
+ (inv_item.service_start_date >= self.period_list[0].from_date)
+ & (inv_item.service_start_date <= self.period_list[-1].to_date)
+ )
+ )
+ )
+ .groupby(inv.name, inv_item.name, gle.posting_date)
+ .orderby(gle.posting_date)
+ )
+ self.invoices = query.run(as_dict=True)
+
+ uniq_invoice = set([x.doc for x in self.invoices])
+ for inv in uniq_invoice:
+ self.deferred_invoices.append(
+ Deferred_Invoice(
+ inv, [x for x in self.invoices if x.doc == inv], self.filters, self.period_list
+ )
+ )
+
+ def estimate_future(self):
+ """
+ For all Invoices estimate upcoming postings
+ """
+ for x in self.deferred_invoices:
+ x.estimate_future()
+
+ def calculate_revenue_and_expense(self):
+ """
+ calculate the deferred revenue/expense for all invoices
+ """
+ # initialize period_total list for report
+ for period in self.period_list:
+ self.period_total.append(frappe._dict({"key": period.key, "total": 0, "actual": 0}))
+
+ for inv in self.deferred_invoices:
+ inv_total = inv.calculate_invoice_revenue_expense_for_period()
+ # calculate total for whole report
+ for idx, period in enumerate(self.period_list, 0):
+ self.period_total[idx].total += inv_total[idx].total
+ self.period_total[idx].actual += inv_total[idx].actual
+
+ def get_columns(self):
+ columns = []
+ columns.append({"label": _("Name"), "fieldname": "name", "fieldtype": "Data", "read_only": 1})
+ for period in self.period_list:
+ columns.append(
+ {
+ "label": _(period.label),
+ "fieldname": period.key,
+ "fieldtype": "Currency",
+ "read_only": 1,
+ })
+ return columns
+
+ def generate_report_data(self):
+ """
+ Generate report data for all invoices. Adds total rows for revenue and expense
+ """
+ ret = []
+
+ for inv in self.deferred_invoices:
+ ret += inv.report_data()
+
+ # empty row for padding
+ ret += [{}]
+
+ # add total row
+ if ret is not []:
+ if self.filters.type == "Revenue":
+ total_row = frappe._dict({"name": "Total Deferred Income"})
+ elif self.filters.type == "Expense":
+ total_row = frappe._dict({"name": "Total Deferred Expense"})
+
+ for idx, period in enumerate(self.period_list, 0):
+ total_row[period.key] = self.period_total[idx].total
+ ret.append(total_row)
+
+ return ret
+
+ def prepare_chart(self):
+ chart = {
+ "data": {
+ "labels": [period.label for period in self.period_list],
+ "datasets": [
+ {
+ "name": "Actual Posting",
+ "chartType": "bar",
+ "values": [x.actual for x in self.period_total],
+ }
+ ],
+ },
+ "type": "axis-mixed",
+ "height": 500,
+ "axisOptions": {"xAxisMode": "Tick", "xIsSeries": True},
+ "barOptions": {"stacked": False, "spaceRatio": 0.5},
+ }
+
+ if self.filters.with_upcoming_postings:
+ chart["data"]["datasets"].append({
+ "name": "Expected",
+ "chartType": "line",
+ "values": [x.total for x in self.period_total]
+ })
+
+ return chart
+
+ def run(self, *args, **kwargs):
+ """
+ Run report and generate data
+ """
+ self.deferred_invoices.clear()
+ self.get_period_list()
+ self.get_invoices()
+
+ if self.filters.with_upcoming_postings:
+ self.estimate_future()
+ self.calculate_revenue_and_expense()
+
+
+def execute(filters=None):
+ report = Deferred_Revenue_and_Expense_Report(filters=filters)
+ report.run()
+
+ columns = report.get_columns()
+ data = report.generate_report_data()
+ message = []
+ chart = report.prepare_chart()
+
+ return columns, data, message, chart
diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py b/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py
new file mode 100644
index 0000000..1de6fb6
--- /dev/null
+++ b/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py
@@ -0,0 +1,253 @@
+import unittest
+
+import frappe
+from frappe import qb
+from frappe.utils import nowdate
+
+from erpnext.accounts.doctype.account.test_account import create_account
+from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
+from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+from erpnext.accounts.report.deferred_revenue_and_expense.deferred_revenue_and_expense import (
+ Deferred_Revenue_and_Expense_Report,
+)
+from erpnext.buying.doctype.supplier.test_supplier import create_supplier
+from erpnext.stock.doctype.item.test_item import create_item
+
+
+class TestDeferredRevenueAndExpense(unittest.TestCase):
+ @classmethod
+ def setUpClass(self):
+ clear_old_entries()
+ create_company()
+
+ def test_deferred_revenue(self):
+ # created deferred expense accounts, if not found
+ deferred_revenue_account = create_account(
+ account_name="Deferred Revenue",
+ parent_account="Current Liabilities - _CD",
+ company="_Test Company DR",
+ )
+
+ acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
+ acc_settings.book_deferred_entries_based_on = "Months"
+ acc_settings.save()
+
+ customer = frappe.new_doc("Customer")
+ customer.customer_name = "_Test Customer DR"
+ customer.type = "Individual"
+ customer.insert()
+
+ item = create_item(
+ "_Test Internet Subscription",
+ is_stock_item=0,
+ warehouse="All Warehouses - _CD",
+ company="_Test Company DR",
+ )
+ item.enable_deferred_revenue = 1
+ item.deferred_revenue_account = deferred_revenue_account
+ item.no_of_months = 3
+ item.save()
+
+ si = create_sales_invoice(
+ item=item.name,
+ company="_Test Company DR",
+ customer="_Test Customer DR",
+ debit_to="Debtors - _CD",
+ posting_date="2021-05-01",
+ parent_cost_center="Main - _CD",
+ cost_center="Main - _CD",
+ do_not_submit=True,
+ rate=300,
+ price_list_rate=300,
+ )
+ si.items[0].enable_deferred_revenue = 1
+ si.items[0].service_start_date = "2021-05-01"
+ si.items[0].service_end_date = "2021-08-01"
+ si.items[0].deferred_revenue_account = deferred_revenue_account
+ si.items[0].income_account = "Sales - _CD"
+ si.save()
+ si.submit()
+
+ pda = frappe.get_doc(
+ dict(
+ doctype="Process Deferred Accounting",
+ posting_date=nowdate(),
+ start_date="2021-05-01",
+ end_date="2021-08-01",
+ type="Income",
+ company="_Test Company DR",
+ )
+ )
+ pda.insert()
+ pda.submit()
+
+ # execute report
+ fiscal_year = frappe.get_doc("Fiscal Year", frappe.defaults.get_user_default("fiscal_year"))
+ self.filters = frappe._dict(
+ {
+ "company": frappe.defaults.get_user_default("Company"),
+ "filter_based_on": "Date Range",
+ "period_start_date": "2021-05-01",
+ "period_end_date": "2021-08-01",
+ "from_fiscal_year": fiscal_year.year,
+ "to_fiscal_year": fiscal_year.year,
+ "periodicity": "Monthly",
+ "type": "Revenue",
+ "with_upcoming_postings": False,
+ }
+ )
+
+ report = Deferred_Revenue_and_Expense_Report(filters=self.filters)
+ report.run()
+ expected = [
+ {"key": "may_2021", "total": 100.0, "actual": 100.0},
+ {"key": "jun_2021", "total": 100.0, "actual": 100.0},
+ {"key": "jul_2021", "total": 100.0, "actual": 100.0},
+ {"key": "aug_2021", "total": 0, "actual": 0},
+ ]
+ self.assertEqual(report.period_total, expected)
+
+ def test_deferred_expense(self):
+ # created deferred expense accounts, if not found
+ deferred_expense_account = create_account(
+ account_name="Deferred Expense",
+ parent_account="Current Assets - _CD",
+ company="_Test Company DR",
+ )
+
+ acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
+ acc_settings.book_deferred_entries_based_on = "Months"
+ acc_settings.save()
+
+ supplier = create_supplier(
+ supplier_name="_Test Furniture Supplier", supplier_group="Local", supplier_type="Company"
+ )
+ supplier.save()
+
+ item = create_item(
+ "_Test Office Desk",
+ is_stock_item=0,
+ warehouse="All Warehouses - _CD",
+ company="_Test Company DR",
+ )
+ item.enable_deferred_expense = 1
+ item.deferred_expense_account = deferred_expense_account
+ item.no_of_months_exp = 3
+ item.save()
+
+ pi = make_purchase_invoice(
+ item=item.name,
+ company="_Test Company DR",
+ supplier="_Test Furniture Supplier",
+ is_return=False,
+ update_stock=False,
+ posting_date=frappe.utils.datetime.date(2021, 5, 1),
+ parent_cost_center="Main - _CD",
+ cost_center="Main - _CD",
+ do_not_save=True,
+ rate=300,
+ price_list_rate=300,
+ warehouse="All Warehouses - _CD",
+ qty=1,
+ )
+ pi.set_posting_time = True
+ pi.items[0].enable_deferred_expense = 1
+ pi.items[0].service_start_date = "2021-05-01"
+ pi.items[0].service_end_date = "2021-08-01"
+ pi.items[0].deferred_expense_account = deferred_expense_account
+ pi.items[0].expense_account = "Office Maintenance Expenses - _CD"
+ pi.save()
+ pi.submit()
+
+ pda = frappe.get_doc(
+ dict(
+ doctype="Process Deferred Accounting",
+ posting_date=nowdate(),
+ start_date="2021-05-01",
+ end_date="2021-08-01",
+ type="Expense",
+ company="_Test Company DR",
+ )
+ )
+ pda.insert()
+ pda.submit()
+
+ # execute report
+ fiscal_year = frappe.get_doc("Fiscal Year", frappe.defaults.get_user_default("fiscal_year"))
+ self.filters = frappe._dict(
+ {
+ "company": frappe.defaults.get_user_default("Company"),
+ "filter_based_on": "Date Range",
+ "period_start_date": "2021-05-01",
+ "period_end_date": "2021-08-01",
+ "from_fiscal_year": fiscal_year.year,
+ "to_fiscal_year": fiscal_year.year,
+ "periodicity": "Monthly",
+ "type": "Expense",
+ "with_upcoming_postings": False,
+ }
+ )
+
+ report = Deferred_Revenue_and_Expense_Report(filters=self.filters)
+ report.run()
+ expected = [
+ {"key": "may_2021", "total": -100.0, "actual": -100.0},
+ {"key": "jun_2021", "total": -100.0, "actual": -100.0},
+ {"key": "jul_2021", "total": -100.0, "actual": -100.0},
+ {"key": "aug_2021", "total": 0, "actual": 0},
+ ]
+ self.assertEqual(report.period_total, expected)
+
+
+def create_company():
+ company = frappe.db.exists("Company", "_Test Company DR")
+ if not company:
+ company = frappe.new_doc("Company")
+ company.company_name = "_Test Company DR"
+ company.default_currency = "INR"
+ company.chart_of_accounts = "Standard"
+ company.insert()
+
+
+def clear_old_entries():
+ item = qb.DocType("Item")
+ account = qb.DocType("Account")
+ customer = qb.DocType("Customer")
+ supplier = qb.DocType("Supplier")
+ sinv = qb.DocType("Sales Invoice")
+ sinv_item = qb.DocType("Sales Invoice Item")
+ pinv = qb.DocType("Purchase Invoice")
+ pinv_item = qb.DocType("Purchase Invoice Item")
+
+ qb.from_(account).delete().where(
+ (account.account_name == "Deferred Revenue")
+ | (account.account_name == "Deferred Expense") & (account.company == "_Test Company DR")
+ ).run()
+ qb.from_(item).delete().where(
+ (item.item_code == "_Test Internet Subscription") | (item.item_code == "_Test Office Rent")
+ ).run()
+ qb.from_(customer).delete().where(customer.customer_name == "_Test Customer DR").run()
+ qb.from_(supplier).delete().where(supplier.supplier_name == "_Test Furniture Supplier").run()
+
+ # delete existing invoices with deferred items
+ deferred_invoices = (
+ qb.from_(sinv)
+ .join(sinv_item)
+ .on(sinv.name == sinv_item.parent)
+ .select(sinv.name)
+ .where(sinv_item.enable_deferred_revenue == 1)
+ .run()
+ )
+ if deferred_invoices:
+ qb.from_(sinv).delete().where(sinv.name.isin(deferred_invoices)).run()
+
+ deferred_invoices = (
+ qb.from_(pinv)
+ .join(pinv_item)
+ .on(pinv.name == pinv_item.parent)
+ .select(pinv.name)
+ .where(pinv_item.enable_deferred_expense == 1)
+ .run()
+ )
+ if deferred_invoices:
+ qb.from_(pinv).delete().where(pinv.name.isin(deferred_invoices)).run()
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/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js
index c2b1bbc..153f5c5 100644
--- a/erpnext/assets/doctype/asset/asset.js
+++ b/erpnext/assets/doctype/asset/asset.js
@@ -110,7 +110,7 @@
if (frm.doc.status != 'Fully Depreciated') {
frm.add_custom_button(__("Adjust Asset Value"), function() {
- frm.trigger("create_asset_adjustment");
+ frm.trigger("create_asset_value_adjustment");
}, __("Manage"));
}
@@ -322,14 +322,14 @@
});
},
- create_asset_adjustment: function(frm) {
+ create_asset_value_adjustment: function(frm) {
frappe.call({
args: {
"asset": frm.doc.name,
"asset_category": frm.doc.asset_category,
"company": frm.doc.company
},
- method: "erpnext.assets.doctype.asset.asset.create_asset_adjustment",
+ method: "erpnext.assets.doctype.asset.asset.create_asset_value_adjustment",
freeze: 1,
callback: function(r) {
var doclist = frappe.model.sync(r.message);
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index 333906a..a18b03a 100644
--- a/erpnext/assets/doctype/asset/asset.py
+++ b/erpnext/assets/doctype/asset/asset.py
@@ -185,83 +185,84 @@
if not self.available_for_use_date:
return
- for d in self.get('finance_books'):
- self.validate_asset_finance_books(d)
+ start = self.clear_depreciation_schedule()
- start = self.clear_depreciation_schedule()
+ for finance_book in self.get('finance_books'):
+ self.validate_asset_finance_books(finance_book)
# value_after_depreciation - current Asset value
- if self.docstatus == 1 and d.value_after_depreciation:
- value_after_depreciation = flt(d.value_after_depreciation)
+ if self.docstatus == 1 and finance_book.value_after_depreciation:
+ value_after_depreciation = flt(finance_book.value_after_depreciation)
else:
value_after_depreciation = (flt(self.gross_purchase_amount) -
flt(self.opening_accumulated_depreciation))
- d.value_after_depreciation = value_after_depreciation
+ finance_book.value_after_depreciation = value_after_depreciation
- number_of_pending_depreciations = cint(d.total_number_of_depreciations) - \
+ number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \
cint(self.number_of_depreciations_booked)
- has_pro_rata = self.check_is_pro_rata(d)
+ has_pro_rata = self.check_is_pro_rata(finance_book)
if has_pro_rata:
number_of_pending_depreciations += 1
skip_row = False
- for n in range(start, number_of_pending_depreciations):
+
+ for n in range(start[finance_book.idx-1], number_of_pending_depreciations):
# If depreciation is already completed (for double declining balance)
if skip_row: continue
- depreciation_amount = get_depreciation_amount(self, value_after_depreciation, d)
+ depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book)
if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1:
- schedule_date = add_months(d.depreciation_start_date,
- n * cint(d.frequency_of_depreciation))
+ schedule_date = add_months(finance_book.depreciation_start_date,
+ n * cint(finance_book.frequency_of_depreciation))
# schedule date will be a year later from start date
# so monthly schedule date is calculated by removing 11 months from it
- monthly_schedule_date = add_months(schedule_date, - d.frequency_of_depreciation + 1)
+ monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1)
# if asset is being sold
if date_of_sale:
- from_date = self.get_from_date(d.finance_book)
- depreciation_amount, days, months = self.get_pro_rata_amt(d, depreciation_amount,
+ from_date = self.get_from_date(finance_book.finance_book)
+ depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
from_date, date_of_sale)
if depreciation_amount > 0:
self.append("schedules", {
"schedule_date": date_of_sale,
"depreciation_amount": depreciation_amount,
- "depreciation_method": d.depreciation_method,
- "finance_book": d.finance_book,
- "finance_book_id": d.idx
+ "depreciation_method": finance_book.depreciation_method,
+ "finance_book": finance_book.finance_book,
+ "finance_book_id": finance_book.idx
})
break
# For first row
if has_pro_rata and not self.opening_accumulated_depreciation and n==0:
- depreciation_amount, days, months = self.get_pro_rata_amt(d, depreciation_amount,
- self.available_for_use_date, d.depreciation_start_date)
+ depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
+ self.available_for_use_date, finance_book.depreciation_start_date)
# For first depr schedule date will be the start date
# so monthly schedule date is calculated by removing month difference between use date and start date
- monthly_schedule_date = add_months(d.depreciation_start_date, - months + 1)
+ monthly_schedule_date = add_months(finance_book.depreciation_start_date, - months + 1)
# For last row
elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1:
if not self.flags.increase_in_asset_life:
# In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission
self.to_date = add_months(self.available_for_use_date,
- (n + self.number_of_depreciations_booked) * cint(d.frequency_of_depreciation))
+ (n + self.number_of_depreciations_booked) * cint(finance_book.frequency_of_depreciation))
depreciation_amount_without_pro_rata = depreciation_amount
- depreciation_amount, days, months = self.get_pro_rata_amt(d,
+ depreciation_amount, days, months = self.get_pro_rata_amt(finance_book,
depreciation_amount, schedule_date, self.to_date)
depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata,
- depreciation_amount, d.finance_book)
+ depreciation_amount, finance_book.finance_book)
monthly_schedule_date = add_months(schedule_date, 1)
schedule_date = add_days(schedule_date, days)
@@ -272,10 +273,10 @@
self.precision("gross_purchase_amount"))
# Adjust depreciation amount in the last period based on the expected value after useful life
- if d.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1
- and value_after_depreciation != d.expected_value_after_useful_life)
- or value_after_depreciation < d.expected_value_after_useful_life):
- depreciation_amount += (value_after_depreciation - d.expected_value_after_useful_life)
+ if finance_book.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1
+ and value_after_depreciation != finance_book.expected_value_after_useful_life)
+ or value_after_depreciation < finance_book.expected_value_after_useful_life):
+ depreciation_amount += (value_after_depreciation - finance_book.expected_value_after_useful_life)
skip_row = True
if depreciation_amount > 0:
@@ -285,7 +286,7 @@
# In pro rata case, for first and last depreciation, month range would be different
month_range = months \
if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \
- else d.frequency_of_depreciation
+ else finance_book.frequency_of_depreciation
for r in range(month_range):
if (has_pro_rata and n == 0):
@@ -311,27 +312,52 @@
self.append("schedules", {
"schedule_date": date,
"depreciation_amount": amount,
- "depreciation_method": d.depreciation_method,
- "finance_book": d.finance_book,
- "finance_book_id": d.idx
+ "depreciation_method": finance_book.depreciation_method,
+ "finance_book": finance_book.finance_book,
+ "finance_book_id": finance_book.idx
})
else:
self.append("schedules", {
"schedule_date": schedule_date,
"depreciation_amount": depreciation_amount,
- "depreciation_method": d.depreciation_method,
- "finance_book": d.finance_book,
- "finance_book_id": d.idx
+ "depreciation_method": finance_book.depreciation_method,
+ "finance_book": finance_book.finance_book,
+ "finance_book_id": finance_book.idx
})
- # used when depreciation schedule needs to be modified due to increase in asset life
+ # depreciation schedules need to be cleared before modification due to increase in asset life/asset sales
+ # JE: Journal Entry, FB: Finance Book
def clear_depreciation_schedule(self):
- start = 0
- for n in range(len(self.schedules)):
- if not self.schedules[n].journal_entry:
- del self.schedules[n:]
- start = n
- break
+ start = []
+ num_of_depreciations_completed = 0
+ depr_schedule = []
+
+ for schedule in self.get('schedules'):
+
+ # to update start when there are JEs linked with all the schedule rows corresponding to an FB
+ if len(start) == (int(schedule.finance_book_id) - 2):
+ start.append(num_of_depreciations_completed)
+ num_of_depreciations_completed = 0
+
+ # to ensure that start will only be updated once for each FB
+ if len(start) == (int(schedule.finance_book_id) - 1):
+ if schedule.journal_entry:
+ num_of_depreciations_completed += 1
+ depr_schedule.append(schedule)
+ else:
+ start.append(num_of_depreciations_completed)
+ num_of_depreciations_completed = 0
+
+ # to update start when all the schedule rows corresponding to the last FB are linked with JEs
+ if len(start) == (len(self.finance_books) - 1):
+ start.append(num_of_depreciations_completed)
+
+ # when the Depreciation Schedule is being created for the first time
+ if start == []:
+ start = [0] * len(self.finance_books)
+ else:
+ self.schedules = depr_schedule
+
return start
def get_from_date(self, finance_book):
@@ -469,7 +495,6 @@
asset_value_after_full_schedule = flt(
flt(self.gross_purchase_amount) -
- flt(self.opening_accumulated_depreciation) -
flt(accumulated_depreciation_after_full_schedule), self.precision('gross_purchase_amount'))
if (row.expected_value_after_useful_life and
@@ -731,14 +756,14 @@
return asset_repair
@frappe.whitelist()
-def create_asset_adjustment(asset, asset_category, company):
- asset_maintenance = frappe.get_doc("Asset Value Adjustment")
- asset_maintenance.update({
+def create_asset_value_adjustment(asset, asset_category, company):
+ asset_value_adjustment = frappe.new_doc("Asset Value Adjustment")
+ asset_value_adjustment.update({
"asset": asset,
"company": company,
"asset_category": asset_category
})
- return asset_maintenance
+ return asset_value_adjustment
@frappe.whitelist()
def transfer_asset(args):
diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py
index ce2cb01..44c4ce5 100644
--- a/erpnext/assets/doctype/asset/test_asset.py
+++ b/erpnext/assets/doctype/asset/test_asset.py
@@ -955,6 +955,82 @@
self.assertEqual(len(asset.schedules), 1)
+ def test_clear_depreciation_schedule_for_multiple_finance_books(self):
+ asset = create_asset(
+ item_code = "Macbook Pro",
+ available_for_use_date = "2019-12-31",
+ do_not_save = 1
+ )
+
+ asset.calculate_depreciation = 1
+ asset.append("finance_books", {
+ "depreciation_method": "Straight Line",
+ "frequency_of_depreciation": 1,
+ "total_number_of_depreciations": 3,
+ "expected_value_after_useful_life": 10000,
+ "depreciation_start_date": "2020-01-31"
+ })
+ asset.append("finance_books", {
+ "depreciation_method": "Straight Line",
+ "frequency_of_depreciation": 1,
+ "total_number_of_depreciations": 6,
+ "expected_value_after_useful_life": 10000,
+ "depreciation_start_date": "2020-01-31"
+ })
+ asset.append("finance_books", {
+ "depreciation_method": "Straight Line",
+ "frequency_of_depreciation": 12,
+ "total_number_of_depreciations": 3,
+ "expected_value_after_useful_life": 10000,
+ "depreciation_start_date": "2020-12-31"
+ })
+ asset.submit()
+
+ post_depreciation_entries(date="2020-04-01")
+ asset.load_from_db()
+
+ asset.clear_depreciation_schedule()
+
+ self.assertEqual(len(asset.schedules), 6)
+
+ for schedule in asset.schedules:
+ if schedule.idx <= 3:
+ self.assertEqual(schedule.finance_book_id, "1")
+ else:
+ self.assertEqual(schedule.finance_book_id, "2")
+
+ def test_depreciation_schedules_are_set_up_for_multiple_finance_books(self):
+ asset = create_asset(
+ item_code = "Macbook Pro",
+ available_for_use_date = "2019-12-31",
+ do_not_save = 1
+ )
+
+ asset.calculate_depreciation = 1
+ asset.append("finance_books", {
+ "depreciation_method": "Straight Line",
+ "frequency_of_depreciation": 12,
+ "total_number_of_depreciations": 3,
+ "expected_value_after_useful_life": 10000,
+ "depreciation_start_date": "2020-12-31"
+ })
+ asset.append("finance_books", {
+ "depreciation_method": "Straight Line",
+ "frequency_of_depreciation": 12,
+ "total_number_of_depreciations": 6,
+ "expected_value_after_useful_life": 10000,
+ "depreciation_start_date": "2020-12-31"
+ })
+ asset.save()
+
+ self.assertEqual(len(asset.schedules), 9)
+
+ for schedule in asset.schedules:
+ if schedule.idx <= 3:
+ self.assertEqual(schedule.finance_book_id, 1)
+ else:
+ self.assertEqual(schedule.finance_book_id, 2)
+
def test_depreciation_entry_cancellation(self):
asset = create_asset(
item_code = "Macbook Pro",
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/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js
index bde00cb..e7049fd 100644
--- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js
+++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js
@@ -124,6 +124,14 @@
dialog.show()
},
+ schedule_date(frm) {
+ if(frm.doc.schedule_date){
+ frm.doc.items.forEach((item) => {
+ item.schedule_date = frm.doc.schedule_date;
+ })
+ }
+ refresh_field("items");
+ },
preview: (frm) => {
let dialog = new frappe.ui.Dialog({
title: __('Preview Email'),
@@ -184,7 +192,13 @@
dialog.show();
}
})
-
+frappe.ui.form.on("Request for Quotation Item", {
+ items_add(frm, cdt, cdn) {
+ if (frm.doc.schedule_date) {
+ frappe.model.set_value(cdt, cdn, 'schedule_date', frm.doc.schedule_date);
+ }
+ }
+});
frappe.ui.form.on("Request for Quotation Supplier",{
supplier: function(frm, cdt, cdn) {
var d = locals[cdt][cdn]
diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json
index 4ce4100..4993df9 100644
--- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json
+++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json
@@ -12,6 +12,7 @@
"vendor",
"column_break1",
"transaction_date",
+ "schedule_date",
"status",
"amended_from",
"suppliers_section",
@@ -246,16 +247,22 @@
"fieldname": "sec_break_email_2",
"fieldtype": "Section Break",
"hide_border": 1
+ },
+ {
+ "fieldname": "schedule_date",
+ "fieldtype": "Date",
+ "label": "Required Date"
}
],
"icon": "fa fa-shopping-cart",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2020-11-05 22:04:29.017134",
+ "modified": "2021-11-24 17:47:49.909000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
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..8e076ed
--- /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('employeeexitint1@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('employeeexitint2@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..bde5a89
--- /dev/null
+++ b/erpnext/hr/report/employee_exits/employee_exits.py
@@ -0,0 +1,230 @@
+# 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()
+ .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)))
+ ).orderby(employee.relieving_date, order=Order.asc)
+ )
+
+ 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/blanket_order/test_blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py
index d5db3fc..eff2344 100644
--- a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py
+++ b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py
@@ -1,17 +1,15 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
-
-import unittest
-
import frappe
from frappe.utils import add_months, today
from erpnext import get_company_currency
+from erpnext.tests.utils import ERPNextTestCase
from .blanket_order import make_order
-class TestBlanketOrder(unittest.TestCase):
+class TestBlanketOrder(ERPNextTestCase):
def setUp(self):
frappe.flags.args = frappe._dict()
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index 2cd8f8c..f82d9a0 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -922,7 +922,7 @@
rm_item_exists = True
if bom.item.lower() == item.lower() or \
bom.item.lower() == cstr(frappe.db.get_value("Item", item, "variant_of")).lower():
- rm_item_exists = True
+ rm_item_exists = True
if not rm_item_exists:
frappe.throw(_("BOM {0} does not belong to Item {1}").format(bom_no, item))
diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py
index 4c03230..178d92c 100644
--- a/erpnext/manufacturing/doctype/bom/test_bom.py
+++ b/erpnext/manufacturing/doctype/bom/test_bom.py
@@ -2,7 +2,6 @@
# License: GNU General Public License v3. See license.txt
-import unittest
from collections import deque
from functools import partial
@@ -18,10 +17,11 @@
create_stock_reconciliation,
)
from erpnext.tests.test_subcontracting import set_backflush_based_on
+from erpnext.tests.utils import ERPNextTestCase
test_records = frappe.get_test_records('BOM')
-class TestBOM(unittest.TestCase):
+class TestBOM(ERPNextTestCase):
def setUp(self):
if not frappe.get_value('Item', '_Test Item'):
make_test_records('Item')
diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json
index ec617f3..c7be7ef 100644
--- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json
+++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json
@@ -11,6 +11,7 @@
"col_break1",
"workstation",
"time_in_mins",
+ "fixed_time",
"costing_section",
"hour_rate",
"base_hour_rate",
@@ -80,6 +81,14 @@
"reqd": 1
},
{
+ "default": "0",
+ "description": "Operation time does not depend on quantity to produce",
+ "fieldname": "fixed_time",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Fixed Time"
+ },
+ {
"fieldname": "operating_cost",
"fieldtype": "Currency",
"in_list_view": 1,
@@ -177,12 +186,13 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-09-13 16:45:01.092868",
+ "modified": "2021-12-15 03:00:00.473173",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Operation",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py
index 526c243..12576cb 100644
--- a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py
+++ b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py
@@ -1,19 +1,16 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
-
-
-import unittest
-
import frappe
from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.stock.doctype.item.test_item import create_item
+from erpnext.tests.utils import ERPNextTestCase
test_records = frappe.get_test_records('BOM')
-class TestBOMUpdateTool(unittest.TestCase):
+class TestBOMUpdateTool(ERPNextTestCase):
def test_replace_bom(self):
current_bom = "BOM-_Test Item Home Desktop Manufactured-001"
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js
index 453ad50..dac7b36 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.js
+++ b/erpnext/manufacturing/doctype/job_card/job_card.js
@@ -75,6 +75,15 @@
&& (frm.doc.items || !frm.doc.items.length || frm.doc.for_quantity == frm.doc.transferred_qty)) {
frm.trigger("prepare_timer_buttons");
}
+
+ if (frm.doc.work_order) {
+ frappe.db.get_value('Work Order', frm.doc.work_order,
+ 'transfer_material_against').then((r) => {
+ if (r.message.transfer_material_against == 'Work Order') {
+ frm.set_df_property('items', 'hidden', 1);
+ }
+ });
+ }
},
setup_corrective_job_card: function(frm) {
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/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py
index 9b4fc8b..bb5004b 100644
--- a/erpnext/manufacturing/doctype/job_card/test_job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py
@@ -1,6 +1,5 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
-import unittest
import frappe
from frappe.utils import random_string
@@ -12,9 +11,10 @@
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+from erpnext.tests.utils import ERPNextTestCase
-class TestJobCard(unittest.TestCase):
+class TestJobCard(ERPNextTestCase):
def setUp(self):
make_bom_for_jc_tests()
@@ -329,4 +329,4 @@
bom.rm_cost_as_per = "Valuation Rate"
bom.items[0].uom = "_Test UOM 1"
bom.items[0].conversion_factor = 5
- bom.insert()
\ No newline at end of file
+ bom.insert()
diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
index a2980a7..2febc1e 100644
--- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
@@ -1,8 +1,5 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
-
-import unittest
-
import frappe
from frappe.utils import add_to_date, flt, now_datetime, nowdate
@@ -17,9 +14,10 @@
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
+from erpnext.tests.utils import ERPNextTestCase
-class TestProductionPlan(unittest.TestCase):
+class TestProductionPlan(ERPNextTestCase):
def setUp(self):
for item in ['Test Production Item 1', 'Subassembly Item 1',
'Raw Material Item 1', 'Raw Material Item 2']:
diff --git a/erpnext/manufacturing/doctype/routing/test_routing.py b/erpnext/manufacturing/doctype/routing/test_routing.py
index 68d9dec..e90b0a7 100644
--- a/erpnext/manufacturing/doctype/routing/test_routing.py
+++ b/erpnext/manufacturing/doctype/routing/test_routing.py
@@ -1,17 +1,15 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
-
-import unittest
-
import frappe
from frappe.test_runner import make_test_records
from erpnext.manufacturing.doctype.job_card.job_card import OperationSequenceError
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.tests.utils import ERPNextTestCase
-class TestRouting(unittest.TestCase):
+class TestRouting(ERPNextTestCase):
@classmethod
def setUpClass(cls):
cls.item_code = "Test Routing Item - A"
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index f590d68..86c687f 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -1,6 +1,5 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
-import unittest
import frappe
from frappe.utils import add_months, cint, flt, now, today
@@ -29,6 +28,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",
@@ -92,7 +94,7 @@
def test_reserved_qty_for_partial_completion(self):
item = "_Test Item"
- warehouse = create_warehouse("Test Warehouse for reserved_qty - _TC")
+ warehouse = "_Test Warehouse - _TC"
bin1_at_start = get_bin(item, warehouse)
@@ -844,6 +846,45 @@
close_work_order(wo_order, "Closed")
self.assertEqual(wo_order.get('status'), "Closed")
+ def test_fix_time_operations(self):
+ bom = frappe.get_doc({
+ "doctype": "BOM",
+ "item": "_Test FG Item 2",
+ "is_active": 1,
+ "is_default": 1,
+ "quantity": 1.0,
+ "with_operations": 1,
+ "operations": [
+ {
+ "operation": "_Test Operation 1",
+ "description": "_Test",
+ "workstation": "_Test Workstation 1",
+ "time_in_mins": 60,
+ "operating_cost": 140,
+ "fixed_time": 1
+ }
+ ],
+ "items": [
+ {
+ "amount": 5000.0,
+ "doctype": "BOM Item",
+ "item_code": "_Test Item",
+ "parentfield": "items",
+ "qty": 1.0,
+ "rate": 5000.0,
+ },
+ ],
+ })
+ bom.save()
+ bom.submit()
+
+
+ wo1 = make_wo_order_test_record(item=bom.item, bom_no=bom.name, qty=1, skip_transfer=1, do_not_submit=1)
+ wo2 = make_wo_order_test_record(item=bom.item, bom_no=bom.name, qty=2, skip_transfer=1, do_not_submit=1)
+
+ self.assertEqual(wo1.operations[0].time_in_mins, wo2.operations[0].time_in_mins)
+
+
def update_job_card(job_card):
job_card_doc = frappe.get_doc('Job Card', job_card)
job_card_doc.set('scrap_items', [
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 0090f4d..170454c 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -505,16 +505,19 @@
"""Fetch operations from BOM and set in 'Work Order'"""
def _get_operations(bom_no, qty=1):
- return frappe.db.sql(
- f"""select
- operation, description, workstation, idx,
- base_hour_rate as hour_rate, time_in_mins * {qty} as time_in_mins,
- "Pending" as status, parent as bom, batch_size, sequence_id
- from
- `tabBOM Operation`
- where
- parent = %s order by idx
- """, bom_no, as_dict=1)
+ data = frappe.get_all("BOM Operation",
+ filters={"parent": bom_no},
+ fields=["operation", "description", "workstation", "idx",
+ "base_hour_rate as hour_rate", "time_in_mins", "parent as bom",
+ "batch_size", "sequence_id", "fixed_time"],
+ order_by="idx")
+
+ for d in data:
+ if not d.fixed_time:
+ d.time_in_mins = flt(d.time_in_mins) * flt(qty)
+ d.status = "Pending"
+
+ return data
self.set('operations', [])
@@ -542,7 +545,8 @@
def calculate_time(self):
for d in self.get("operations"):
- d.time_in_mins = flt(d.time_in_mins) * (flt(self.qty) / flt(d.batch_size))
+ if not d.fixed_time:
+ d.time_in_mins = flt(d.time_in_mins) * (flt(self.qty) / flt(d.batch_size))
self.calculate_operating_cost()
diff --git a/erpnext/manufacturing/doctype/workstation/test_workstation.py b/erpnext/manufacturing/doctype/workstation/test_workstation.py
index 5ed5153..c298c0a 100644
--- a/erpnext/manufacturing/doctype/workstation/test_workstation.py
+++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py
@@ -1,8 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors
# See license.txt
-
-import unittest
-
import frappe
from frappe.test_runner import make_test_records
@@ -13,12 +10,13 @@
WorkstationHolidayError,
check_if_within_operating_hours,
)
+from erpnext.tests.utils import ERPNextTestCase
test_dependencies = ["Warehouse"]
test_records = frappe.get_test_records('Workstation')
make_test_records('Workstation')
-class TestWorkstation(unittest.TestCase):
+class TestWorkstation(ERPNextTestCase):
def test_validate_timings(self):
check_if_within_operating_hours("_Test Workstation 1", "Operation 1", "2013-02-02 11:00:00", "2013-02-02 19:00:00")
check_if_within_operating_hours("_Test Workstation 1", "Operation 1", "2013-02-02 10:00:00", "2013-02-02 20:00:00")
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 937465b..6bee3ac 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -165,6 +165,7 @@
erpnext.patches.v12_0.set_default_payroll_based_on
erpnext.patches.v12_0.repost_stock_ledger_entries_for_target_warehouse
erpnext.patches.v12_0.update_end_date_and_status_in_email_campaign
+erpnext.patches.v13_0.validate_options_for_data_field
erpnext.patches.v13_0.move_tax_slabs_from_payroll_period_to_income_tax_slab #123
erpnext.patches.v12_0.fix_quotation_expired_status
erpnext.patches.v12_0.rename_pos_closing_doctype
@@ -287,7 +288,6 @@
erpnext.patches.v14_0.delete_einvoicing_doctypes
erpnext.patches.v13_0.custom_fields_for_taxjar_integration #08-11-2021
erpnext.patches.v13_0.set_operation_time_based_on_operating_cost
-erpnext.patches.v13_0.validate_options_for_data_field
erpnext.patches.v13_0.create_gst_payment_entry_fields #27-11-2021
erpnext.patches.v14_0.delete_shopify_doctypes
erpnext.patches.v13_0.fix_invoice_statuses
@@ -314,4 +314,8 @@
erpnext.patches.v14_0.delete_hub_doctypes
erpnext.patches.v13_0.create_ksa_vat_custom_fields
erpnext.patches.v13_0.update_maintenance_schedule_field_in_visit
+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..f4f9b17
--- /dev/null
+++ b/erpnext/patches/v13_0/rename_ksa_qr_field.py
@@ -0,0 +1,32 @@
+# Copyright (c) 2020, Wahni Green Technologies and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+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)
+
+ # rename_field method assumes that the field already exists or the doc is synced
+ if not frappe.db.has_column('Sales Invoice', 'ksa_einv_qr'):
+ create_custom_fields({
+ 'Sales Invoice': [
+ dict(
+ fieldname='ksa_einv_qr',
+ label='KSA E-Invoicing QR',
+ fieldtype='Attach Image',
+ read_only=1, no_copy=1, hidden=1
+ )
+ ]
+ })
+
+ if frappe.db.has_column('Sales Invoice', 'qr_code'):
+ rename_field('Sales Invoice', 'qr_code', 'ksa_einv_qr')
+ frappe.delete_doc_if_exists("Custom Field", "Sales Invoice-qr_code")
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/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py
index 05af09e..b035292 100644
--- a/erpnext/payroll/doctype/salary_slip/salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py
@@ -940,10 +940,12 @@
def get_amount_based_on_payment_days(self, row, joining_date, relieving_date):
amount, additional_amount = row.amount, row.additional_amount
+ timesheet_component = frappe.db.get_value("Salary Structure", self.salary_structure, "salary_component")
+
if (self.salary_structure and
cint(row.depends_on_payment_days) and cint(self.total_working_days)
and not (row.additional_salary and row.default_amount) # to identify overwritten additional salary
- and (not self.salary_slip_based_on_timesheet or
+ and (row.salary_component != timesheet_component or
getdate(self.start_date) < joining_date or
(relieving_date and getdate(self.end_date) > relieving_date)
)):
@@ -952,7 +954,7 @@
amount = flt((flt(row.default_amount) * flt(self.payment_days)
/ cint(self.total_working_days)), row.precision("amount")) + additional_amount
- elif not self.payment_days and not self.salary_slip_based_on_timesheet and cint(row.depends_on_payment_days):
+ elif not self.payment_days and row.salary_component != timesheet_component and cint(row.depends_on_payment_days):
amount, additional_amount = 0, 0
elif not row.amount:
amount = flt(row.default_amount) + flt(row.additional_amount)
diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
index e4618c3..3052a2b 100644
--- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
@@ -134,6 +134,57 @@
frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave")
+ def test_payment_days_in_salary_slip_based_on_timesheet(self):
+ from erpnext.hr.doctype.attendance.attendance import mark_attendance
+ from erpnext.projects.doctype.timesheet.test_timesheet import (
+ make_salary_structure_for_timesheet,
+ make_timesheet,
+ )
+ from erpnext.projects.doctype.timesheet.timesheet import (
+ make_salary_slip as make_salary_slip_for_timesheet,
+ )
+
+ # Payroll based on attendance
+ frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance")
+
+ emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company")
+ frappe.db.set_value("Employee", emp, {"relieving_date": None, "status": "Active"})
+
+ # mark attendance
+ month_start_date = get_first_day(nowdate())
+ month_end_date = get_last_day(nowdate())
+
+ first_sunday = frappe.db.sql("""
+ select holiday_date from `tabHoliday`
+ where parent = 'Salary Slip Test Holiday List'
+ and holiday_date between %s and %s
+ order by holiday_date
+ """, (month_start_date, month_end_date))[0][0]
+
+ mark_attendance(emp, add_days(first_sunday, 1), 'Absent', ignore_validate=True) # counted as absent
+
+ # salary structure based on timesheet
+ make_salary_structure_for_timesheet(emp)
+ timesheet = make_timesheet(emp, simulate=True, is_billable=1)
+ salary_slip = make_salary_slip_for_timesheet(timesheet.name)
+ salary_slip.start_date = month_start_date
+ salary_slip.end_date = month_end_date
+ salary_slip.save()
+ salary_slip.submit()
+
+ no_of_days = self.get_no_of_days()
+ days_in_month = no_of_days[0]
+ no_of_holidays = no_of_days[1]
+
+ self.assertEqual(salary_slip.payment_days, days_in_month - no_of_holidays - 1)
+
+ # gross pay calculation based on attendance (payment days)
+ gross_pay = 78100 - ((78000 / (days_in_month - no_of_holidays)) * flt(salary_slip.leave_without_pay + salary_slip.absent_days))
+
+ self.assertEqual(salary_slip.gross_pay, flt(gross_pay, 2))
+
+ frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave")
+
def test_component_amount_dependent_on_another_payment_days_based_component(self):
from erpnext.hr.doctype.attendance.attendance import mark_attendance
from erpnext.payroll.doctype.salary_structure.test_salary_structure import (
diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py
index d59cc01..148d8ba 100644
--- a/erpnext/projects/doctype/timesheet/test_timesheet.py
+++ b/erpnext/projects/doctype/timesheet/test_timesheet.py
@@ -34,10 +34,6 @@
for dt in ["Salary Slip", "Salary Structure", "Salary Structure Assignment", "Timesheet"]:
frappe.db.sql("delete from `tab%s`" % dt)
- if not frappe.db.exists("Salary Component", "Timesheet Component"):
- frappe.get_doc({"doctype": "Salary Component", "salary_component": "Timesheet Component"}).insert()
-
-
def test_timesheet_billing_amount(self):
emp = make_employee("test_employee_6@salary.com")
@@ -160,6 +156,9 @@
salary_structure_name = "Timesheet Salary Structure Test"
frequency = "Monthly"
+ if not frappe.db.exists("Salary Component", "Timesheet Component"):
+ frappe.get_doc({"doctype": "Salary Component", "salary_component": "Timesheet Component"}).insert()
+
salary_structure = make_salary_structure(salary_structure_name, frequency, company=company, dont_submit=True)
salary_structure.salary_component = "Timesheet Component"
salary_structure.salary_slip_based_on_timesheet = 1
diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js
index f0facdd..93a2731 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,9 +888,11 @@
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;
+ if (!frm.doc.first_responded_on) {
time_to_respond = get_time_left(frm.doc.response_by, frm.doc.agreement_status);
+ } else {
+ time_to_respond = get_status(frm.doc.response_by, frm.doc.first_responded_on);
}
let alert = `
@@ -899,9 +905,11 @@
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;
+ if (!frm.doc.resolution_date) {
time_to_resolve = get_time_left(frm.doc.resolution_by, frm.doc.agreement_status);
+ } else {
+ time_to_resolve = get_status(frm.doc.resolution_by, frm.doc.resolution_date);
}
alert += `
@@ -924,8 +932,9 @@
return {'diff_display': diff_display, 'indicator': indicator};
}
-function get_status(variance) {
- if (variance > 0) {
+function get_status(expected, actual) {
+ const time_left = moment(expected).diff(moment(actual));
+ if (time_left >= 0) {
return {'diff_display': 'Fulfilled', 'indicator': 'green'};
} else {
return {'diff_display': 'Failed', 'indicator': 'red'};
diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py
index bbca772..fd3ec3c 100644
--- a/erpnext/regional/india/utils.py
+++ b/erpnext/regional/india/utils.py
@@ -213,10 +213,11 @@
tax_template_by_category = get_tax_template_based_on_category(master_doctype, company, party_details)
if tax_template_by_category:
- party_details.get['taxes_and_charges'] = tax_template_by_category
+ party_details['taxes_and_charges'] = tax_template_by_category
return
if not party_details.place_of_supply: return party_details
+ if not party_details.company_gstin: return party_details
if ((doctype in ("Sales Invoice", "Delivery Note", "Sales Order") and party_details.company_gstin
and party_details.company_gstin[:2] != party_details.place_of_supply[:2]) or (doctype in ("Purchase Invoice",
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/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py b/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py
index e03ad37..1c1335e 100644
--- a/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py
+++ b/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py
@@ -114,9 +114,11 @@
items = frappe.db.sql("""
select
- `tabSales Invoice Item`.name, `tabSales Invoice Item`.base_price_list_rate,
- `tabSales Invoice Item`.gst_hsn_code, `tabSales Invoice Item`.stock_qty,
- `tabSales Invoice Item`.stock_uom, `tabSales Invoice Item`.base_net_amount,
+ `tabSales Invoice Item`.gst_hsn_code,
+ `tabSales Invoice Item`.stock_uom,
+ sum(`tabSales Invoice Item`.stock_qty) as stock_qty,
+ sum(`tabSales Invoice Item`.base_net_amount) as base_net_amount,
+ sum(`tabSales Invoice Item`.base_price_list_rate) as base_price_list_rate,
`tabSales Invoice Item`.parent, `tabSales Invoice Item`.item_code,
`tabGST HSN Code`.description
from `tabSales Invoice`, `tabSales Invoice Item`, `tabGST HSN Code`
@@ -124,6 +126,8 @@
and `tabSales Invoice`.docstatus = 1
and `tabSales Invoice Item`.gst_hsn_code is not NULL
and `tabSales Invoice Item`.gst_hsn_code = `tabGST HSN Code`.name %s %s
+ group by
+ `tabSales Invoice Item`.parent, `tabSales Invoice Item`.item_code
""" % (conditions, match_conditions), filters, as_dict=1)
diff --git a/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/test_hsn_wise_summary_of_outward_supplies.py b/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/test_hsn_wise_summary_of_outward_supplies.py
new file mode 100644
index 0000000..86dc458
--- /dev/null
+++ b/erpnext/regional/report/hsn_wise_summary_of_outward_supplies/test_hsn_wise_summary_of_outward_supplies.py
@@ -0,0 +1,89 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+
+from unittest import TestCase
+
+import frappe
+
+from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+from erpnext.regional.doctype.gstr_3b_report.test_gstr_3b_report import (
+ make_company as setup_company,
+)
+from erpnext.regional.doctype.gstr_3b_report.test_gstr_3b_report import (
+ make_customers as setup_customers,
+)
+from erpnext.regional.doctype.gstr_3b_report.test_gstr_3b_report import (
+ set_account_heads as setup_gst_settings,
+)
+from erpnext.regional.report.hsn_wise_summary_of_outward_supplies.hsn_wise_summary_of_outward_supplies import (
+ execute as run_report,
+)
+from erpnext.stock.doctype.item.test_item import make_item
+
+
+class TestHSNWiseSummaryReport(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ setup_company()
+ setup_customers()
+ setup_gst_settings()
+ make_item("Golf Car", properties={ "gst_hsn_code": "999900" })
+
+ @classmethod
+ def tearDownClass(cls):
+ frappe.db.rollback()
+
+ def test_hsn_summary_for_invoice_with_duplicate_items(self):
+ si = create_sales_invoice(
+ company="_Test Company GST",
+ customer = "_Test GST Customer",
+ currency = "INR",
+ warehouse = "Finished Goods - _GST",
+ debit_to = "Debtors - _GST",
+ income_account = "Sales - _GST",
+ expense_account = "Cost of Goods Sold - _GST",
+ cost_center = "Main - _GST",
+ do_not_save=1
+ )
+
+ si.items = []
+ si.append("items", {
+ "item_code": "Golf Car",
+ "gst_hsn_code": "999900",
+ "qty": "1",
+ "rate": "120",
+ "cost_center": "Main - _GST"
+ })
+ si.append("items", {
+ "item_code": "Golf Car",
+ "gst_hsn_code": "999900",
+ "qty": "1",
+ "rate": "140",
+ "cost_center": "Main - _GST"
+ })
+ si.append("taxes", {
+ "charge_type": "On Net Total",
+ "account_head": "Output Tax IGST - _GST",
+ "cost_center": "Main - _GST",
+ "description": "IGST @ 18.0",
+ "rate": 18
+ })
+ si.posting_date = "2020-11-17"
+ si.submit()
+ si.reload()
+
+ [columns, data] = run_report(filters=frappe._dict({
+ "company": "_Test Company GST",
+ "gst_hsn_code": "999900",
+ "company_gstin": si.company_gstin,
+ "from_date": si.posting_date,
+ "to_date": si.posting_date
+ }))
+
+ filtered_rows = list(filter(lambda row: row['gst_hsn_code'] == "999900", data))
+ self.assertTrue(filtered_rows)
+
+ hsn_row = filtered_rows[0]
+ self.assertEquals(hsn_row['stock_qty'], 2.0)
+ self.assertEquals(hsn_row['total_amount'], 306.8)
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/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py
index 7d6b74d..5301fd0 100644
--- a/erpnext/selling/doctype/customer/test_customer.py
+++ b/erpnext/selling/doctype/customer/test_customer.py
@@ -2,8 +2,6 @@
# License: GNU General Public License v3. See license.txt
-import unittest
-
import frappe
from frappe.test_runner import make_test_records
from frappe.utils import flt
@@ -11,7 +9,7 @@
from erpnext.accounts.party import get_due_date
from erpnext.exceptions import PartyDisabled, PartyFrozen
from erpnext.selling.doctype.customer.customer import get_credit_limit, get_customer_outstanding
-from erpnext.tests.utils import create_test_contact_and_address
+from erpnext.tests.utils import ERPNextTestCase, create_test_contact_and_address
test_ignore = ["Price List"]
test_dependencies = ['Payment Term', 'Payment Terms Template']
@@ -19,7 +17,7 @@
-class TestCustomer(unittest.TestCase):
+class TestCustomer(ERPNextTestCase):
def setUp(self):
if not frappe.get_value('Item', '_Test Item'):
make_test_records('Item')
diff --git a/erpnext/selling/doctype/party_specific_item/test_party_specific_item.py b/erpnext/selling/doctype/party_specific_item/test_party_specific_item.py
index 874a364..b951044 100644
--- a/erpnext/selling/doctype/party_specific_item/test_party_specific_item.py
+++ b/erpnext/selling/doctype/party_specific_item/test_party_specific_item.py
@@ -6,6 +6,7 @@
import frappe
from erpnext.controllers.queries import item_query
+from erpnext.tests.utils import ERPNextTestCase
test_dependencies = ['Item', 'Customer', 'Supplier']
@@ -17,7 +18,7 @@
psi.based_on_value = args.get('based_on_value')
psi.insert()
-class TestPartySpecificItem(unittest.TestCase):
+class TestPartySpecificItem(ERPNextTestCase):
def setUp(self):
self.customer = frappe.get_last_doc("Customer")
self.supplier = frappe.get_last_doc("Supplier")
diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py
index aa83726..4357201 100644
--- a/erpnext/selling/doctype/quotation/test_quotation.py
+++ b/erpnext/selling/doctype/quotation/test_quotation.py
@@ -1,15 +1,15 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
-import unittest
-
import frappe
from frappe.utils import add_days, add_months, flt, getdate, nowdate
+from erpnext.tests.utils import ERPNextTestCase
+
test_dependencies = ["Product Bundle"]
-class TestQuotation(unittest.TestCase):
+class TestQuotation(ERPNextTestCase):
def test_make_quotation_without_terms(self):
quotation = make_quotation(do_not_save=1)
self.assertFalse(quotation.get('payment_schedule'))
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index 1c24825..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:
@@ -978,6 +980,7 @@
description=i['description']
)).insert()
work_order.set_work_order_operations()
+ work_order.flags.ignore_mandatory = True
work_order.save()
out.append(work_order)
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 2a0752e..42bc0b7 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -2,7 +2,6 @@
# License: GNU General Public License v3. See license.txt
import json
-import unittest
import frappe
import frappe.permissions
@@ -28,12 +27,14 @@
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+from erpnext.tests.utils import ERPNextTestCase
-class TestSalesOrder(unittest.TestCase):
+class TestSalesOrder(ERPNextTestCase):
@classmethod
def setUpClass(cls):
+ super().setUpClass()
cls.unlink_setting = int(frappe.db.get_value("Accounts Settings", "Accounts Settings",
"unlink_advance_payment_on_cancelation_of_order"))
@@ -42,6 +43,7 @@
# reset config to previous state
frappe.db.set_value("Accounts Settings", "Accounts Settings",
"unlink_advance_payment_on_cancelation_of_order", cls.unlink_setting)
+ super().tearDownClass()
def tearDown(self):
frappe.set_user("Administrator")
diff --git a/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py b/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py
index 9c30afc..d62915f 100644
--- a/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py
+++ b/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py
@@ -2,8 +2,6 @@
# For license information, please see license.txt
-import unittest
-
from frappe.utils import add_months, nowdate
from erpnext.selling.doctype.sales_order.sales_order import make_material_request
@@ -11,9 +9,10 @@
from erpnext.selling.report.pending_so_items_for_purchase_request.pending_so_items_for_purchase_request import (
execute,
)
+from erpnext.tests.utils import ERPNextTestCase
-class TestPendingSOItemsForPurchaseRequest(unittest.TestCase):
+class TestPendingSOItemsForPurchaseRequest(ERPNextTestCase):
def test_result_for_partial_material_request(self):
so = make_sales_order()
mr=make_material_request(so.name)
diff --git a/erpnext/selling/report/sales_analytics/test_analytics.py b/erpnext/selling/report/sales_analytics/test_analytics.py
index 8ffc5d6..f56cce2 100644
--- a/erpnext/selling/report/sales_analytics/test_analytics.py
+++ b/erpnext/selling/report/sales_analytics/test_analytics.py
@@ -2,15 +2,14 @@
# For license information, please see license.txt
-import unittest
-
import frappe
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.selling.report.sales_analytics.sales_analytics import execute
+from erpnext.tests.utils import ERPNextTestCase
-class TestAnalytics(unittest.TestCase):
+class TestAnalytics(ERPNextTestCase):
def test_sales_analytics(self):
frappe.db.sql("delete from `tabSales Order` where company='_Test Company 2'")
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/bin/bin.py b/erpnext/stock/doctype/bin/bin.py
index a33134b..37b5411 100644
--- a/erpnext/stock/doctype/bin/bin.py
+++ b/erpnext/stock/doctype/bin/bin.py
@@ -43,9 +43,9 @@
frappe.qb
.from_(wo)
.from_(wo_item)
- .select(Case()
- .when(wo.skip_transfer == 0, Sum(wo_item.required_qty - wo_item.transferred_qty))
- .else_(Sum(wo_item.required_qty - wo_item.consumed_qty))
+ .select(Sum(Case()
+ .when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty)
+ .else_(wo_item.required_qty - wo_item.consumed_qty))
)
.where(
(wo_item.item_code == self.item_code)
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/item/item.json b/erpnext/stock/doctype/item/item.json
index 4f4e691..29abd45 100644
--- a/erpnext/stock/doctype/item/item.json
+++ b/erpnext/stock/doctype/item/item.json
@@ -361,8 +361,7 @@
"fieldname": "valuation_method",
"fieldtype": "Select",
"label": "Valuation Method",
- "options": "\nFIFO\nMoving Average",
- "set_only_once": 1
+ "options": "\nFIFO\nMoving Average"
},
{
"depends_on": "is_stock_item",
@@ -1035,7 +1034,7 @@
"image_field": "image",
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2021-12-03 08:32:03.869294",
+ "modified": "2021-12-14 04:13:16.857534",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",
diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py
index 8b1224b..4028d93 100644
--- a/erpnext/stock/doctype/item/test_item.py
+++ b/erpnext/stock/doctype/item/test_item.py
@@ -534,8 +534,6 @@
def test_index_creation(self):
"check if index is getting created in db"
- from erpnext.stock.doctype.item.item import on_doctype_update
- on_doctype_update()
indices = frappe.db.sql("show index from tabItem", as_dict=1)
expected_columns = {"item_code", "item_name", "item_group", "route"}
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/packing_slip_item/packing_slip_item.json b/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json
index 29c4193..4270839 100644
--- a/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json
+++ b/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json
@@ -1,451 +1,140 @@
{
- "allow_copy": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "autoname": "hash",
- "beta": 0,
- "creation": "2013-04-08 13:10:16",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "Document",
- "editable_grid": 1,
- "engine": "InnoDB",
+ "actions": [],
+ "autoname": "hash",
+ "creation": "2013-04-08 13:10:16",
+ "doctype": "DocType",
+ "document_type": "Document",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "item_code",
+ "column_break_2",
+ "item_name",
+ "batch_no",
+ "desc_section",
+ "description",
+ "quantity_section",
+ "qty",
+ "net_weight",
+ "column_break_10",
+ "stock_uom",
+ "weight_uom",
+ "page_break",
+ "dn_detail"
+ ],
"fields": [
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "item_code",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 1,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Item Code",
- "length": 0,
- "no_copy": 0,
- "options": "Item",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": "100px",
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "in_global_search": 1,
+ "in_list_view": 1,
+ "label": "Item Code",
+ "options": "Item",
+ "print_width": "100px",
+ "reqd": 1,
"width": "100px"
- },
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "column_break_2",
- "fieldtype": "Column Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "column_break_2",
+ "fieldtype": "Column Break"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "item_name",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Item Name",
- "length": 0,
- "no_copy": 0,
- "options": "item_code.item_name",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": "200px",
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "fetch_from": "item_code.item_name",
+ "fieldname": "item_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Item Name",
+ "print_width": "200px",
+ "read_only": 1,
"width": "200px"
- },
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "batch_no",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Batch No",
- "length": 0,
- "no_copy": 0,
- "options": "Batch",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "batch_no",
+ "fieldtype": "Link",
+ "label": "Batch No",
+ "options": "Batch"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 1,
- "columns": 0,
- "fieldname": "desc_section",
- "fieldtype": "Section Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Description",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "collapsible": 1,
+ "fieldname": "desc_section",
+ "fieldtype": "Section Break",
+ "label": "Description"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "description",
- "fieldtype": "Text Editor",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Description",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "description",
+ "fieldtype": "Text Editor",
+ "label": "Description"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "quantity_section",
- "fieldtype": "Section Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Quantity",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "quantity_section",
+ "fieldtype": "Section Break",
+ "label": "Quantity"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "qty",
- "fieldtype": "Float",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Quantity",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": "100px",
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "fieldname": "qty",
+ "fieldtype": "Float",
+ "in_list_view": 1,
+ "label": "Quantity",
+ "print_width": "100px",
+ "reqd": 1,
"width": "100px"
- },
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "net_weight",
- "fieldtype": "Float",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Net Weight",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": "100px",
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "fieldname": "net_weight",
+ "fieldtype": "Float",
+ "in_list_view": 1,
+ "label": "Net Weight",
+ "print_width": "100px",
"width": "100px"
- },
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "column_break_10",
- "fieldtype": "Column Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "column_break_10",
+ "fieldtype": "Column Break"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "stock_uom",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "UOM",
- "length": 0,
- "no_copy": 0,
- "options": "UOM",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": "100px",
- "read_only": 1,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "fieldname": "stock_uom",
+ "fieldtype": "Link",
+ "label": "UOM",
+ "options": "UOM",
+ "print_width": "100px",
+ "read_only": 1,
"width": "100px"
- },
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "weight_uom",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_standard_filter": 0,
- "label": "Weight UOM",
- "length": 0,
- "no_copy": 0,
- "options": "UOM",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": "100px",
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "fieldname": "weight_uom",
+ "fieldtype": "Link",
+ "label": "Weight UOM",
+ "options": "UOM",
+ "print_width": "100px",
"width": "100px"
- },
+ },
{
- "allow_on_submit": 1,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "page_break",
- "fieldtype": "Check",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Page Break",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "allow_on_submit": 1,
+ "default": "0",
+ "fieldname": "page_break",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Page Break"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "dn_detail",
- "fieldtype": "Data",
- "hidden": 1,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "DN Detail",
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
+ "fieldname": "dn_detail",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "in_list_view": 1,
+ "label": "DN Detail"
}
- ],
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 1,
- "image_view": 0,
- "in_create": 0,
-
- "is_submittable": 0,
- "issingle": 0,
- "istable": 1,
- "max_attachments": 0,
- "modified": "2018-06-01 07:21:58.220980",
- "modified_by": "Administrator",
- "module": "Stock",
- "name": "Packing Slip Item",
- "owner": "Administrator",
- "permissions": [],
- "quick_entry": 0,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
- "track_changes": 1,
- "track_seen": 0
+ ],
+ "idx": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-12-14 01:22:00.715935",
+ "modified_by": "Administrator",
+ "module": "Stock",
+ "name": "Packing Slip Item",
+ "naming_rule": "Random",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
}
\ No newline at end of file
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_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index 5ef0770..5a9e77e 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -24,7 +24,8 @@
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
-from erpnext.stock.stock_ledger import get_previous_sle
+from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle
+from erpnext.tests.utils import ERPNextTestCase, change_settings
def get_sle(**args):
@@ -38,7 +39,7 @@
order by timestamp(posting_date, posting_time) desc, creation desc limit 1"""% condition,
values, as_dict=1)
-class TestStockEntry(unittest.TestCase):
+class TestStockEntry(ERPNextTestCase):
def tearDown(self):
frappe.set_user("Administrator")
frappe.db.set_value("Manufacturing Settings", None, "material_consumption", "0")
@@ -928,6 +929,83 @@
distributed_costs = [d.additional_cost for d in se.items]
self.assertEqual([40.0, 60.0], distributed_costs)
+ @change_settings("Stock Settings", {"allow_negative_stock": 0})
+ def test_future_negative_sle(self):
+ # Initialize item, batch, warehouse, opening qty
+ item_code = '_Test Future Neg Item'
+ batch_no = '_Test Future Neg Batch'
+ warehouses = [
+ '_Test Future Neg Warehouse Source',
+ '_Test Future Neg Warehouse Destination'
+ ]
+ warehouse_names = initialize_records_for_future_negative_sle_test(
+ item_code, batch_no, warehouses,
+ opening_qty=2, posting_date='2021-07-01'
+ )
+
+ # Executing an illegal sequence should raise an error
+ sequence_of_entries = [
+ dict(item_code=item_code,
+ qty=2,
+ from_warehouse=warehouse_names[0],
+ to_warehouse=warehouse_names[1],
+ batch_no=batch_no,
+ posting_date='2021-07-03',
+ purpose='Material Transfer'),
+ dict(item_code=item_code,
+ qty=2,
+ from_warehouse=warehouse_names[1],
+ to_warehouse=warehouse_names[0],
+ batch_no=batch_no,
+ posting_date='2021-07-04',
+ purpose='Material Transfer'),
+ dict(item_code=item_code,
+ qty=2,
+ from_warehouse=warehouse_names[0],
+ to_warehouse=warehouse_names[1],
+ batch_no=batch_no,
+ posting_date='2021-07-02', # Illegal SE
+ purpose='Material Transfer')
+ ]
+
+ self.assertRaises(NegativeStockError, create_stock_entries, sequence_of_entries)
+
+ @change_settings("Stock Settings", {"allow_negative_stock": 0})
+ def test_future_negative_sle_batch(self):
+ from erpnext.stock.doctype.batch.test_batch import TestBatch
+
+ # Initialize item, batch, warehouse, opening qty
+ item_code = '_Test MultiBatch Item'
+ TestBatch.make_batch_item(item_code)
+
+ batch_nos = [] # store generate batches
+ warehouse = '_Test Warehouse - _TC'
+
+ se1 = make_stock_entry(
+ item_code=item_code,
+ qty=2,
+ to_warehouse=warehouse,
+ posting_date='2021-09-01',
+ purpose='Material Receipt'
+ )
+ batch_nos.append(se1.items[0].batch_no)
+ se2 = make_stock_entry(
+ item_code=item_code,
+ qty=2,
+ to_warehouse=warehouse,
+ posting_date='2021-09-03',
+ purpose='Material Receipt'
+ )
+ batch_nos.append(se2.items[0].batch_no)
+
+ with self.assertRaises(NegativeStockError) as nse:
+ make_stock_entry(item_code=item_code,
+ qty=1,
+ from_warehouse=warehouse,
+ batch_no=batch_nos[1],
+ posting_date='2021-09-02', # backdated consumption of 2nd batch
+ purpose='Material Issue')
+
def make_serialized_item(**args):
args = frappe._dict(args)
se = frappe.copy_doc(test_records[0])
@@ -998,3 +1076,31 @@
]
test_records = frappe.get_test_records('Stock Entry')
+
+def initialize_records_for_future_negative_sle_test(
+ item_code, batch_no, warehouses, opening_qty, posting_date):
+ from erpnext.stock.doctype.batch.test_batch import TestBatch, make_new_batch
+ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
+ create_stock_reconciliation,
+ )
+ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
+
+ TestBatch.make_batch_item(item_code)
+ make_new_batch(item_code=item_code, batch_id=batch_no)
+ warehouse_names = [create_warehouse(w) for w in warehouses]
+ create_stock_reconciliation(
+ purpose='Opening Stock',
+ posting_date=posting_date,
+ posting_time='20:00:20',
+ item_code=item_code,
+ warehouse=warehouse_names[0],
+ valuation_rate=100,
+ qty=opening_qty,
+ batch_no=batch_no,
+ )
+ return warehouse_names
+
+
+def create_stock_entries(sequence_of_entries):
+ for entry_detail in sequence_of_entries:
+ make_stock_entry(**entry_detail)
diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
index 93bca7a..c538307 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
@@ -8,7 +8,7 @@
from frappe import _
from frappe.core.doctype.role.role import get_users
from frappe.model.document import Document
-from frappe.utils import add_days, cint, flt, formatdate, get_datetime, getdate
+from frappe.utils import add_days, cint, formatdate, get_datetime, getdate
from erpnext.accounts.utils import get_fiscal_year
from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock
@@ -43,7 +43,6 @@
def on_submit(self):
self.check_stock_frozen_date()
- self.actual_amt_check()
self.calculate_batch_qty()
if not self.get("via_landed_cost_voucher"):
@@ -57,18 +56,6 @@
"sum(actual_qty)") or 0
frappe.db.set_value("Batch", self.batch_no, "batch_qty", batch_qty)
- def actual_amt_check(self):
- """Validate that qty at warehouse for selected batch is >=0"""
- if self.batch_no and not self.get("allow_negative_stock"):
- batch_bal_after_transaction = flt(frappe.db.sql("""select sum(actual_qty)
- from `tabStock Ledger Entry`
- where is_cancelled =0 and warehouse=%s and item_code=%s and batch_no=%s""",
- (self.warehouse, self.item_code, self.batch_no))[0][0])
-
- if batch_bal_after_transaction < 0:
- frappe.throw(_("Stock balance in Batch {0} will become negative {1} for Item {2} at Warehouse {3}")
- .format(self.batch_no, batch_bal_after_transaction, self.item_code, self.warehouse))
-
def validate_mandatory(self):
mandatory = ['warehouse','posting_date','voucher_type','voucher_no','company']
for k in mandatory:
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/report/itemwise_recommended_reorder_level/itemwise_recommended_reorder_level.py b/erpnext/stock/report/itemwise_recommended_reorder_level/itemwise_recommended_reorder_level.py
index 314f160..3f49065 100644
--- a/erpnext/stock/report/itemwise_recommended_reorder_level/itemwise_recommended_reorder_level.py
+++ b/erpnext/stock/report/itemwise_recommended_reorder_level/itemwise_recommended_reorder_level.py
@@ -48,6 +48,7 @@
conditions = [get_item_group_condition(filters.get("item_group"))]
if filters.get("brand"):
conditions.append("item.brand=%(brand)s")
+ conditions.append("is_stock_item = 1")
return frappe.db.sql("""select name, item_name, description, brand, item_group,
safety_stock, lead_time_days from `tabItem` item where {}"""
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index d78632a..e95c0fc 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -1089,17 +1089,36 @@
allow_negative_stock = cint(allow_negative_stock) \
or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
- if (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation") and not allow_negative_stock:
- sle = get_future_sle_with_negative_qty(args)
- if sle:
- message = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format(
- abs(sle[0]["qty_after_transaction"]),
- frappe.get_desk_link('Item', args.item_code),
- frappe.get_desk_link('Warehouse', args.warehouse),
- sle[0]["posting_date"], sle[0]["posting_time"],
- frappe.get_desk_link(sle[0]["voucher_type"], sle[0]["voucher_no"]))
+ if allow_negative_stock:
+ return
+ if not (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation"):
+ return
- frappe.throw(message, NegativeStockError, title='Insufficient Stock')
+ neg_sle = get_future_sle_with_negative_qty(args)
+ if neg_sle:
+ message = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format(
+ abs(neg_sle[0]["qty_after_transaction"]),
+ frappe.get_desk_link('Item', args.item_code),
+ frappe.get_desk_link('Warehouse', args.warehouse),
+ neg_sle[0]["posting_date"], neg_sle[0]["posting_time"],
+ frappe.get_desk_link(neg_sle[0]["voucher_type"], neg_sle[0]["voucher_no"]))
+
+ frappe.throw(message, NegativeStockError, title='Insufficient Stock')
+
+
+ if not args.batch_no:
+ return
+
+ neg_batch_sle = get_future_sle_with_negative_batch_qty(args)
+ if neg_batch_sle:
+ message = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format(
+ abs(neg_batch_sle[0]["cumulative_total"]),
+ frappe.get_desk_link('Batch', args.batch_no),
+ frappe.get_desk_link('Warehouse', args.warehouse),
+ neg_batch_sle[0]["posting_date"], neg_batch_sle[0]["posting_time"],
+ frappe.get_desk_link(neg_batch_sle[0]["voucher_type"], neg_batch_sle[0]["voucher_no"]))
+ frappe.throw(message, NegativeStockError, title="Insufficient Stock for Batch")
+
def get_future_sle_with_negative_qty(args):
return frappe.db.sql("""
@@ -1118,6 +1137,29 @@
limit 1
""", args, as_dict=1)
+
+def get_future_sle_with_negative_batch_qty(args):
+ return frappe.db.sql("""
+ with batch_ledger as (
+ select
+ posting_date, posting_time, voucher_type, voucher_no,
+ sum(actual_qty) over (order by posting_date, posting_time, creation) as cumulative_total
+ from `tabStock Ledger Entry`
+ where
+ item_code = %(item_code)s
+ and warehouse = %(warehouse)s
+ and batch_no=%(batch_no)s
+ and is_cancelled = 0
+ order by posting_date, posting_time, creation
+ )
+ select * from batch_ledger
+ where
+ cumulative_total < 0.0
+ and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s)
+ limit 1
+ """, args, as_dict=1)
+
+
def _round_off_if_near_zero(number: float, precision: int = 6) -> float:
""" Rounds off the number to zero only if number is close to zero for decimal
specified in precision. Precision defaults to 6.
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..c94700b 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
+ reset_expected_response_and_resolution(doc)
+
+ # 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_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_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
+ reset_expected_response_and_resolution(doc)
+
+ # 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,120 +611,102 @@
}).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()
+ 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.get_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'),
+ update_modified=False
+ )
- # 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')
+ 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.get_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
+
+ else:
+ return
+
+ for_resolution = frappe.db.get_value('Service Level Agreement', parent.service_level_agreement, 'apply_sla_for_resolution')
+
+ 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 reset_expected_response_and_resolution(doc):
+ if doc.meta.has_field("first_responded_on") and not doc.get('first_responded_on'):
+ doc.response_by = None
+ if doc.meta.has_field("resolution_by") and not doc.get('resolution_date'):
+ doc.resolution_by = None
-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 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 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
- update_values = {}
+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')))
- 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
-
- doc.db_set(update_values)
+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():
@@ -715,16 +736,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 +761,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 +775,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 +794,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 +828,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