Merge pull request #26159 from GangaManoj/asset-credit-note
diff --git a/.github/helper/install.sh b/.github/helper/install.sh
index f7a7122..455ab86 100644
--- a/.github/helper/install.sh
+++ b/.github/helper/install.sh
@@ -42,6 +42,6 @@
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
bench get-app erpnext "${GITHUB_WORKSPACE}"
-bench start &
+bench start &> bench_run_logs.txt &
bench --site test_site reinstall --yes
bench build --app frappe
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
index 4bc55da..412a05b 100644
--- a/.github/workflows/ui-tests.yml
+++ b/.github/workflows/ui-tests.yml
@@ -102,3 +102,7 @@
run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests erpnext --headless
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
+
+ - name: Show bench console if tests failed
+ if: ${{ failure() }}
+ run: cat ~/frappe-bench/bench_run_logs.txt
diff --git a/.gitignore b/.gitignore
index 63c51c4..89f5626 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,4 @@
.idea/
.vscode/
node_modules/
+.backportrc.json
\ No newline at end of file
diff --git a/cypress/integration/test_item.js b/cypress/integration/test_item.js
new file mode 100644
index 0000000..fcb7533
--- /dev/null
+++ b/cypress/integration/test_item.js
@@ -0,0 +1,44 @@
+describe("Test Item Dashboard", () => {
+ before(() => {
+ cy.login();
+ cy.visit("/app/item");
+ cy.insert_doc(
+ "Item",
+ {
+ item_code: "e2e_test_item",
+ item_group: "All Item Groups",
+ opening_stock: 42,
+ valuation_rate: 100,
+ },
+ true
+ );
+ cy.go_to_doc("item", "e2e_test_item");
+ });
+
+ it("should show dashboard with correct data on first load", () => {
+ cy.get(".stock-levels").contains("Stock Levels").should("be.visible");
+ cy.get(".stock-levels").contains("e2e_test_item").should("exist");
+
+ // reserved and available qty
+ cy.get(".stock-levels .inline-graph-count")
+ .eq(0)
+ .contains("0")
+ .should("exist");
+ cy.get(".stock-levels .inline-graph-count")
+ .eq(1)
+ .contains("42")
+ .should("exist");
+ });
+
+ it("should persist on field change", () => {
+ cy.get('input[data-fieldname="disabled"]').check();
+ cy.wait(500);
+ cy.get(".stock-levels").contains("Stock Levels").should("be.visible");
+ cy.get(".stock-levels").should("have.length", 1);
+ });
+
+ it("should persist on reload", () => {
+ cy.reload();
+ cy.get(".stock-levels").contains("Stock Levels").should("be.visible");
+ });
+});
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index 7929a2e..7ddc80a 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -23,3 +23,9 @@
//
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... });
+
+const slug = (name) => name.toLowerCase().replace(" ", "-");
+
+Cypress.Commands.add("go_to_doc", (doctype, name) => {
+ cy.visit(`/app/${slug(doctype)}/${encodeURIComponent(name)}`);
+});
diff --git a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py
index 3b764aa..cb1f2df 100644
--- a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py
+++ b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py
@@ -13,7 +13,9 @@
from frappe.utils.xlsxutils import read_xlsx_file_from_attached_file, read_xls_file_from_attached_file
class ChartofAccountsImporter(Document):
- pass
+ def validate(self):
+ validate_accounts(self.import_file)
+
@frappe.whitelist()
def validate_company(company):
@@ -301,28 +303,28 @@
if account["parent_account"] and accounts_dict.get(account["parent_account"]):
accounts_dict[account["parent_account"]]["is_group"] = 1
- message = validate_root(accounts_dict)
- if message: return message
- message = validate_account_types(accounts_dict)
- if message: return message
+ validate_root(accounts_dict)
+
+ validate_account_types(accounts_dict)
+
return [True, len(accounts)]
def validate_root(accounts):
roots = [accounts[d] for d in accounts if not accounts[d].get('parent_account')]
if len(roots) < 4:
- return _("Number of root accounts cannot be less than 4")
+ frappe.throw(_("Number of root accounts cannot be less than 4"))
error_messages = []
for account in roots:
if not account.get("root_type") and account.get("account_name"):
- error_messages.append("Please enter Root Type for account- {0}".format(account.get("account_name")))
+ error_messages.append(_("Please enter Root Type for account- {0}").format(account.get("account_name")))
elif account.get("root_type") not in get_root_types() and account.get("account_name"):
- error_messages.append("Root Type for {0} must be one of the Asset, Liability, Income, Expense and Equity".format(account.get("account_name")))
+ error_messages.append(_("Root Type for {0} must be one of the Asset, Liability, Income, Expense and Equity").format(account.get("account_name")))
if error_messages:
- return "<br>".join(error_messages)
+ frappe.throw("<br>".join(error_messages))
def get_root_types():
return ('Asset', 'Liability', 'Expense', 'Income', 'Equity')
@@ -356,7 +358,7 @@
missing = list(set(account_types_for_ledger) - set(account_types))
if missing:
- return _("Please identify/create Account (Ledger) for type - {0}").format(' , '.join(missing))
+ frappe.throw(_("Please identify/create Account (Ledger) for type - {0}").format(' , '.join(missing)))
account_types_for_group = ["Bank", "Cash", "Stock"]
# fix logic bug
@@ -364,7 +366,7 @@
missing = list(set(account_types_for_group) - set(account_groups))
if missing:
- return _("Please identify/create Account (Group) for type - {0}").format(' , '.join(missing))
+ frappe.throw(_("Please identify/create Account (Group) for type - {0}").format(' , '.join(missing)))
def unset_existing_data(company):
linked = frappe.db.sql('''select fieldname from tabDocField
diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py
index c6c6892..1ef512a 100644
--- a/erpnext/accounts/doctype/dunning/dunning.py
+++ b/erpnext/accounts/doctype/dunning/dunning.py
@@ -25,7 +25,7 @@
def validate_amount(self):
amounts = calculate_interest_and_amount(
- self.posting_date, self.outstanding_amount, self.rate_of_interest, self.dunning_fee, self.overdue_days)
+ self.outstanding_amount, self.rate_of_interest, self.dunning_fee, self.overdue_days)
if self.interest_amount != amounts.get('interest_amount'):
self.interest_amount = flt(amounts.get('interest_amount'), self.precision('interest_amount'))
if self.dunning_amount != amounts.get('dunning_amount'):
@@ -91,13 +91,13 @@
for dunning in dunnings:
frappe.db.set_value("Dunning", dunning.name, "status", 'Resolved')
-def calculate_interest_and_amount(posting_date, outstanding_amount, rate_of_interest, dunning_fee, overdue_days):
+def calculate_interest_and_amount(outstanding_amount, rate_of_interest, dunning_fee, overdue_days):
interest_amount = 0
- grand_total = 0
+ grand_total = flt(outstanding_amount) + flt(dunning_fee)
if rate_of_interest:
interest_per_year = flt(outstanding_amount) * flt(rate_of_interest) / 100
interest_amount = (interest_per_year * cint(overdue_days)) / 365
- grand_total = flt(outstanding_amount) + flt(interest_amount) + flt(dunning_fee)
+ grand_total += flt(interest_amount)
dunning_amount = flt(interest_amount) + flt(dunning_fee)
return {
'interest_amount': interest_amount,
diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py
index e2d4d82..31cb078 100644
--- a/erpnext/accounts/doctype/dunning/test_dunning.py
+++ b/erpnext/accounts/doctype/dunning/test_dunning.py
@@ -16,6 +16,7 @@
@classmethod
def setUpClass(self):
create_dunning_type()
+ create_dunning_type_with_zero_interest_rate()
unlink_payment_on_cancel_of_invoice()
@classmethod
@@ -25,11 +26,19 @@
def test_dunning(self):
dunning = create_dunning()
amounts = calculate_interest_and_amount(
- dunning.posting_date, dunning.outstanding_amount, dunning.rate_of_interest, dunning.dunning_fee, dunning.overdue_days)
+ dunning.outstanding_amount, dunning.rate_of_interest, dunning.dunning_fee, dunning.overdue_days)
self.assertEqual(round(amounts.get('interest_amount'), 2), 0.44)
self.assertEqual(round(amounts.get('dunning_amount'), 2), 20.44)
self.assertEqual(round(amounts.get('grand_total'), 2), 120.44)
+ def test_dunning_with_zero_interest_rate(self):
+ dunning = create_dunning_with_zero_interest_rate()
+ amounts = calculate_interest_and_amount(
+ dunning.outstanding_amount, dunning.rate_of_interest, dunning.dunning_fee, dunning.overdue_days)
+ self.assertEqual(round(amounts.get('interest_amount'), 2), 0)
+ self.assertEqual(round(amounts.get('dunning_amount'), 2), 20)
+ self.assertEqual(round(amounts.get('grand_total'), 2), 120)
+
def test_gl_entries(self):
dunning = create_dunning()
dunning.submit()
@@ -83,6 +92,27 @@
dunning.save()
return dunning
+def create_dunning_with_zero_interest_rate():
+ posting_date = add_days(today(), -20)
+ due_date = add_days(today(), -15)
+ sales_invoice = create_sales_invoice_against_cost_center(
+ posting_date=posting_date, due_date=due_date, status='Overdue')
+ dunning_type = frappe.get_doc("Dunning Type", 'First Notice with 0% Rate of Interest')
+ dunning = frappe.new_doc("Dunning")
+ dunning.sales_invoice = sales_invoice.name
+ dunning.customer_name = sales_invoice.customer_name
+ dunning.outstanding_amount = sales_invoice.outstanding_amount
+ dunning.debit_to = sales_invoice.debit_to
+ dunning.currency = sales_invoice.currency
+ dunning.company = sales_invoice.company
+ dunning.posting_date = nowdate()
+ dunning.due_date = sales_invoice.due_date
+ dunning.dunning_type = 'First Notice with 0% Rate of Interest'
+ dunning.rate_of_interest = dunning_type.rate_of_interest
+ dunning.dunning_fee = dunning_type.dunning_fee
+ dunning.save()
+ return dunning
+
def create_dunning_type():
dunning_type = frappe.new_doc("Dunning Type")
dunning_type.dunning_type = 'First Notice'
@@ -98,3 +128,19 @@
}
)
dunning_type.save()
+
+def create_dunning_type_with_zero_interest_rate():
+ dunning_type = frappe.new_doc("Dunning Type")
+ dunning_type.dunning_type = 'First Notice with 0% Rate of Interest'
+ dunning_type.start_day = 10
+ dunning_type.end_day = 20
+ dunning_type.dunning_fee = 20
+ dunning_type.rate_of_interest = 0
+ dunning_type.append(
+ "dunning_letter_text", {
+ 'language': 'en',
+ 'body_text': 'We have still not received payment for our invoice ',
+ 'closing_text': 'We kindly request that you pay the outstanding amount immediately, and late fees.'
+ }
+ )
+ dunning_type.save()
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index adaf99a..ff00fde 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -183,6 +183,13 @@
d.reference_name, self.party_account_currency)
for field, value in iteritems(ref_details):
+ if d.exchange_gain_loss:
+ # for cases where gain/loss is booked into invoice
+ # exchange_gain_loss is calculated from invoice & populated
+ # and row.exchange_rate is already set to payment entry's exchange rate
+ # refer -> `update_reference_in_payment_entry()` in utils.py
+ continue
+
if field == 'exchange_rate' or not d.get(field) or force:
d.db_set(field, value)
@@ -664,8 +671,8 @@
gl_entries.append(gle)
if self.unallocated_amount:
- base_unallocated_amount = self.unallocated_amount * \
- (self.source_exchange_rate if self.payment_type=="Receive" else self.target_exchange_rate)
+ exchange_rate = self.get_exchange_rate()
+ base_unallocated_amount = (self.unallocated_amount * exchange_rate)
gle = party_gl_dict.copy()
@@ -806,10 +813,17 @@
if account_details:
row.update(account_details)
+
+ if not row.get('amount'):
+ # if no difference amount
+ return
self.append('deductions', row)
self.set_unallocated_amount()
+ def get_exchange_rate(self):
+ return self.source_exchange_rate if self.payment_type=="Receive" else self.target_exchange_rate
+
def initialize_taxes(self):
for tax in self.get("taxes"):
validate_taxes_and_charges(tax)
@@ -1318,9 +1332,9 @@
return frappe._dict({
"due_date": ref_doc.get("due_date"),
- "total_amount": total_amount,
- "outstanding_amount": outstanding_amount,
- "exchange_rate": exchange_rate,
+ "total_amount": flt(total_amount),
+ "outstanding_amount": flt(outstanding_amount),
+ "exchange_rate": flt(exchange_rate),
"bill_no": bill_no
})
diff --git a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
index 912ad09..43eb0b6 100644
--- a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
+++ b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
@@ -14,7 +14,8 @@
"total_amount",
"outstanding_amount",
"allocated_amount",
- "exchange_rate"
+ "exchange_rate",
+ "exchange_gain_loss"
],
"fields": [
{
@@ -90,12 +91,19 @@
"fieldtype": "Link",
"label": "Payment Term",
"options": "Payment Term"
+ },
+ {
+ "fieldname": "exchange_gain_loss",
+ "fieldtype": "Currency",
+ "label": "Exchange Gain/Loss",
+ "options": "Company:company:default_currency",
+ "read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-02-10 11:25:47.144392",
+ "modified": "2021-04-21 13:30:11.605388",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Reference",
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index c1cc092..b99d75e 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -451,6 +451,7 @@
self.get_asset_gl_entry(gl_entries)
self.make_tax_gl_entries(gl_entries)
+ self.make_exchange_gain_loss_gl_entries(gl_entries)
self.make_internal_transfer_gl_entries(gl_entries)
self.allocate_advance_taxes(gl_entries)
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index 189260a..a04d082 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -974,6 +974,109 @@
acc_settings.submit_journal_entriessubmit_journal_entries = 0
acc_settings.save()
+ def test_gain_loss_with_advance_entry(self):
+ unlink_enabled = frappe.db.get_value("Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice")
+ frappe.db.set_value("Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice", 1)
+ pay = frappe.get_doc({
+ 'doctype': 'Payment Entry',
+ 'company': '_Test Company',
+ 'payment_type': 'Pay',
+ 'party_type': 'Supplier',
+ 'party': '_Test Supplier USD',
+ 'paid_to': '_Test Payable USD - _TC',
+ 'paid_from': 'Cash - _TC',
+ 'paid_amount': 70000,
+ 'target_exchange_rate': 70,
+ 'received_amount': 1000,
+ })
+ pay.insert()
+ pay.submit()
+
+ pi = make_purchase_invoice(supplier='_Test Supplier USD', currency="USD",
+ conversion_rate=75, rate=500, do_not_save=1, qty=1)
+ pi.cost_center = "_Test Cost Center - _TC"
+ pi.advances = []
+ pi.append("advances", {
+ "reference_type": "Payment Entry",
+ "reference_name": pay.name,
+ "advance_amount": 1000,
+ "remarks": pay.remarks,
+ "allocated_amount": 500,
+ "ref_exchange_rate": 70
+ })
+ pi.save()
+ pi.submit()
+
+ expected_gle = [
+ ["_Test Account Cost for Goods Sold - _TC", 37500.0],
+ ["_Test Payable USD - _TC", -40000.0],
+ ["Exchange Gain/Loss - _TC", 2500.0]
+ ]
+
+ gl_entries = frappe.db.sql("""
+ select account, sum(debit - credit) as balance from `tabGL Entry`
+ where voucher_no=%s
+ group by account order by account asc""", (pi.name), as_dict=1)
+
+ for i, gle in enumerate(gl_entries):
+ self.assertEqual(expected_gle[i][0], gle.account)
+ self.assertEqual(expected_gle[i][1], gle.balance)
+
+ pi_2 = make_purchase_invoice(supplier='_Test Supplier USD', currency="USD",
+ conversion_rate=73, rate=500, do_not_save=1, qty=1)
+ pi_2.cost_center = "_Test Cost Center - _TC"
+ pi_2.advances = []
+ pi_2.append("advances", {
+ "reference_type": "Payment Entry",
+ "reference_name": pay.name,
+ "advance_amount": 500,
+ "remarks": pay.remarks,
+ "allocated_amount": 500,
+ "ref_exchange_rate": 70
+ })
+ pi_2.save()
+ pi_2.submit()
+
+ expected_gle = [
+ ["_Test Account Cost for Goods Sold - _TC", 36500.0],
+ ["_Test Payable USD - _TC", -38000.0],
+ ["Exchange Gain/Loss - _TC", 1500.0]
+ ]
+
+ gl_entries = frappe.db.sql("""
+ select account, sum(debit - credit) as balance from `tabGL Entry`
+ where voucher_no=%s
+ group by account order by account asc""", (pi_2.name), as_dict=1)
+
+ for i, gle in enumerate(gl_entries):
+ self.assertEqual(expected_gle[i][0], gle.account)
+ self.assertEqual(expected_gle[i][1], gle.balance)
+
+ expected_gle = [
+ ["_Test Payable USD - _TC", 70000.0],
+ ["Cash - _TC", -70000.0]
+ ]
+
+ gl_entries = frappe.db.sql("""
+ select account, sum(debit - credit) as balance from `tabGL Entry`
+ where voucher_no=%s and is_cancelled=0
+ group by account order by account asc""", (pay.name), as_dict=1)
+
+ for i, gle in enumerate(gl_entries):
+ self.assertEqual(expected_gle[i][0], gle.account)
+ self.assertEqual(expected_gle[i][1], gle.balance)
+
+ pi.reload()
+ pi.cancel()
+
+ pi_2.reload()
+ pi_2.cancel()
+
+ pay.reload()
+ pay.cancel()
+
+ frappe.db.set_value("Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice", unlink_enabled)
+
def test_purchase_invoice_advance_taxes(self):
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
diff --git a/erpnext/accounts/doctype/purchase_invoice_advance/purchase_invoice_advance.json b/erpnext/accounts/doctype/purchase_invoice_advance/purchase_invoice_advance.json
index 5801b17..63dfff8 100644
--- a/erpnext/accounts/doctype/purchase_invoice_advance/purchase_invoice_advance.json
+++ b/erpnext/accounts/doctype/purchase_invoice_advance/purchase_invoice_advance.json
@@ -1,235 +1,127 @@
{
- "allow_copy": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "beta": 0,
- "creation": "2013-03-08 15:36:46",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "Document",
- "editable_grid": 1,
+ "actions": [],
+ "creation": "2013-03-08 15:36:46",
+ "doctype": "DocType",
+ "document_type": "Document",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "reference_type",
+ "reference_name",
+ "remarks",
+ "reference_row",
+ "col_break1",
+ "advance_amount",
+ "allocated_amount",
+ "exchange_gain_loss",
+ "ref_exchange_rate"
+ ],
"fields": [
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "reference_type",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "label": "Reference Type",
- "length": 0,
- "no_copy": 1,
- "oldfieldname": "journal_voucher",
- "oldfieldtype": "Link",
- "options": "DocType",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": "180px",
- "read_only": 1,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "fieldname": "reference_type",
+ "fieldtype": "Link",
+ "label": "Reference Type",
+ "no_copy": 1,
+ "oldfieldname": "journal_voucher",
+ "oldfieldtype": "Link",
+ "options": "DocType",
+ "print_width": "180px",
+ "read_only": 1,
"width": "180px"
- },
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 3,
- "fieldname": "reference_name",
- "fieldtype": "Dynamic Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "label": "Reference Name",
- "length": 0,
- "no_copy": 1,
- "options": "reference_type",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "columns": 3,
+ "fieldname": "reference_name",
+ "fieldtype": "Dynamic Link",
+ "in_list_view": 1,
+ "label": "Reference Name",
+ "no_copy": 1,
+ "options": "reference_type",
+ "read_only": 1
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 3,
- "fieldname": "remarks",
- "fieldtype": "Text",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "label": "Remarks",
- "length": 0,
- "no_copy": 1,
- "oldfieldname": "remarks",
- "oldfieldtype": "Small Text",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": "150px",
- "read_only": 1,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "columns": 3,
+ "fieldname": "remarks",
+ "fieldtype": "Text",
+ "in_list_view": 1,
+ "label": "Remarks",
+ "no_copy": 1,
+ "oldfieldname": "remarks",
+ "oldfieldtype": "Small Text",
+ "print_width": "150px",
+ "read_only": 1,
"width": "150px"
- },
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "reference_row",
- "fieldtype": "Data",
- "hidden": 1,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "label": "Reference Row",
- "length": 0,
- "no_copy": 1,
- "oldfieldname": "jv_detail_no",
- "oldfieldtype": "Date",
- "permlevel": 0,
- "print_hide": 1,
- "print_hide_if_no_value": 0,
- "print_width": "80px",
- "read_only": 1,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "fieldname": "reference_row",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "Reference Row",
+ "no_copy": 1,
+ "oldfieldname": "jv_detail_no",
+ "oldfieldtype": "Date",
+ "print_hide": 1,
+ "print_width": "80px",
+ "read_only": 1,
"width": "80px"
- },
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "col_break1",
- "fieldtype": "Column Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "col_break1",
+ "fieldtype": "Column Break"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 2,
- "fieldname": "advance_amount",
- "fieldtype": "Currency",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "label": "Advance Amount",
- "length": 0,
- "no_copy": 1,
- "oldfieldname": "advance_amount",
- "oldfieldtype": "Currency",
- "options": "party_account_currency",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": "100px",
- "read_only": 1,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "columns": 2,
+ "fieldname": "advance_amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Advance Amount",
+ "no_copy": 1,
+ "oldfieldname": "advance_amount",
+ "oldfieldtype": "Currency",
+ "options": "party_account_currency",
+ "print_width": "100px",
+ "read_only": 1,
"width": "100px"
- },
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 2,
- "fieldname": "allocated_amount",
- "fieldtype": "Currency",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "label": "Allocated Amount",
- "length": 0,
- "no_copy": 1,
- "oldfieldname": "allocated_amount",
- "oldfieldtype": "Currency",
- "options": "party_account_currency",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": "100px",
- "read_only": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "columns": 2,
+ "fieldname": "allocated_amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Allocated Amount",
+ "no_copy": 1,
+ "oldfieldname": "allocated_amount",
+ "oldfieldtype": "Currency",
+ "options": "party_account_currency",
+ "print_width": "100px",
"width": "100px"
+ },
+ {
+ "fieldname": "exchange_gain_loss",
+ "fieldtype": "Currency",
+ "label": "Exchange Gain/Loss",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "ref_exchange_rate",
+ "fieldtype": "Float",
+ "label": "Reference Exchange Rate",
+ "non_negative": 1,
+ "read_only": 1
}
- ],
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 1,
- "image_view": 0,
- "in_create": 0,
-
- "is_submittable": 0,
- "issingle": 0,
- "istable": 1,
- "max_attachments": 0,
- "menu_index": 0,
- "modified": "2016-08-26 02:30:54.407138",
- "modified_by": "Administrator",
- "module": "Accounts",
- "name": "Purchase Invoice Advance",
- "owner": "Administrator",
- "permissions": [],
- "quick_entry": 1,
- "read_only": 0,
- "read_only_onload": 0,
- "sort_order": "DESC",
- "track_seen": 0
+ ],
+ "idx": 1,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-04-20 16:26:53.820530",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Purchase Invoice Advance",
+ "owner": "Administrator",
+ "permissions": [],
+ "quick_entry": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC"
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index 8ef5bcb..8889913 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -840,6 +840,7 @@
self.make_customer_gl_entry(gl_entries)
self.make_tax_gl_entries(gl_entries)
+ self.make_exchange_gain_loss_gl_entries(gl_entries)
self.make_internal_transfer_gl_entries(gl_entries)
self.allocate_advance_taxes(gl_entries)
diff --git a/erpnext/accounts/doctype/sales_invoice_advance/sales_invoice_advance.json b/erpnext/accounts/doctype/sales_invoice_advance/sales_invoice_advance.json
index 14bf4d8..29422d6 100644
--- a/erpnext/accounts/doctype/sales_invoice_advance/sales_invoice_advance.json
+++ b/erpnext/accounts/doctype/sales_invoice_advance/sales_invoice_advance.json
@@ -1,235 +1,128 @@
{
- "allow_copy": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "beta": 0,
- "creation": "2013-02-22 01:27:41",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "Document",
- "editable_grid": 1,
+ "actions": [],
+ "creation": "2013-02-22 01:27:41",
+ "doctype": "DocType",
+ "document_type": "Document",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "reference_type",
+ "reference_name",
+ "remarks",
+ "reference_row",
+ "col_break1",
+ "advance_amount",
+ "allocated_amount",
+ "exchange_gain_loss",
+ "ref_exchange_rate"
+ ],
"fields": [
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "reference_type",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "label": "Reference Type",
- "length": 0,
- "no_copy": 1,
- "oldfieldname": "journal_voucher",
- "oldfieldtype": "Link",
- "options": "DocType",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": "250px",
- "read_only": 1,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "fieldname": "reference_type",
+ "fieldtype": "Link",
+ "label": "Reference Type",
+ "no_copy": 1,
+ "oldfieldname": "journal_voucher",
+ "oldfieldtype": "Link",
+ "options": "DocType",
+ "print_width": "250px",
+ "read_only": 1,
"width": "250px"
- },
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 3,
- "fieldname": "reference_name",
- "fieldtype": "Dynamic Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "label": "Reference Name",
- "length": 0,
- "no_copy": 1,
- "options": "reference_type",
- "permlevel": 0,
- "precision": "",
- "print_hide": 1,
- "print_hide_if_no_value": 0,
- "read_only": 1,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "columns": 3,
+ "fieldname": "reference_name",
+ "fieldtype": "Dynamic Link",
+ "in_list_view": 1,
+ "label": "Reference Name",
+ "no_copy": 1,
+ "options": "reference_type",
+ "print_hide": 1,
+ "read_only": 1
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 3,
- "fieldname": "remarks",
- "fieldtype": "Text",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "label": "Remarks",
- "length": 0,
- "no_copy": 1,
- "oldfieldname": "remarks",
- "oldfieldtype": "Small Text",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": "150px",
- "read_only": 1,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "columns": 3,
+ "fieldname": "remarks",
+ "fieldtype": "Text",
+ "in_list_view": 1,
+ "label": "Remarks",
+ "no_copy": 1,
+ "oldfieldname": "remarks",
+ "oldfieldtype": "Small Text",
+ "print_width": "150px",
+ "read_only": 1,
"width": "150px"
- },
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "reference_row",
- "fieldtype": "Data",
- "hidden": 1,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "label": "Reference Row",
- "length": 0,
- "no_copy": 1,
- "oldfieldname": "jv_detail_no",
- "oldfieldtype": "Data",
- "permlevel": 0,
- "print_hide": 1,
- "print_hide_if_no_value": 0,
- "print_width": "120px",
- "read_only": 1,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "fieldname": "reference_row",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "Reference Row",
+ "no_copy": 1,
+ "oldfieldname": "jv_detail_no",
+ "oldfieldtype": "Data",
+ "print_hide": 1,
+ "print_width": "120px",
+ "read_only": 1,
"width": "120px"
- },
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "col_break1",
- "fieldtype": "Column Break",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 0,
- "length": 0,
- "no_copy": 0,
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "fieldname": "col_break1",
+ "fieldtype": "Column Break"
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 2,
- "fieldname": "advance_amount",
- "fieldtype": "Currency",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "label": "Advance amount",
- "length": 0,
- "no_copy": 1,
- "oldfieldname": "advance_amount",
- "oldfieldtype": "Currency",
- "options": "party_account_currency",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": "120px",
- "read_only": 1,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "columns": 2,
+ "fieldname": "advance_amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Advance amount",
+ "no_copy": 1,
+ "oldfieldname": "advance_amount",
+ "oldfieldtype": "Currency",
+ "options": "party_account_currency",
+ "print_width": "120px",
+ "read_only": 1,
"width": "120px"
- },
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 2,
- "fieldname": "allocated_amount",
- "fieldtype": "Currency",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "label": "Allocated amount",
- "length": 0,
- "no_copy": 1,
- "oldfieldname": "allocated_amount",
- "oldfieldtype": "Currency",
- "options": "party_account_currency",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": "120px",
- "read_only": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "columns": 2,
+ "fieldname": "allocated_amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Allocated amount",
+ "no_copy": 1,
+ "oldfieldname": "allocated_amount",
+ "oldfieldtype": "Currency",
+ "options": "party_account_currency",
+ "print_width": "120px",
"width": "120px"
+ },
+ {
+ "fieldname": "exchange_gain_loss",
+ "fieldtype": "Currency",
+ "label": "Exchange Gain/Loss",
+ "options": "Company:company:default_currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "ref_exchange_rate",
+ "fieldtype": "Float",
+ "label": "Reference Exchange Rate",
+ "non_negative": 1,
+ "read_only": 1
}
- ],
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 1,
- "image_view": 0,
- "in_create": 0,
-
- "is_submittable": 0,
- "issingle": 0,
- "istable": 1,
- "max_attachments": 0,
- "menu_index": 0,
- "modified": "2016-08-26 02:36:10.718057",
- "modified_by": "Administrator",
- "module": "Accounts",
- "name": "Sales Invoice Advance",
- "owner": "Administrator",
- "permissions": [],
- "quick_entry": 1,
- "read_only": 0,
- "read_only_onload": 0,
- "sort_order": "DESC",
- "track_seen": 0
+ ],
+ "idx": 1,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-06-04 20:25:49.832052",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Sales Invoice Advance",
+ "owner": "Administrator",
+ "permissions": [],
+ "quick_entry": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC"
}
\ No newline at end of file
diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py
index 744ada9..e724e9b 100644
--- a/erpnext/accounts/report/general_ledger/general_ledger.py
+++ b/erpnext/accounts/report/general_ledger/general_ledger.py
@@ -48,13 +48,12 @@
if not filters.get("from_date") and not filters.get("to_date"):
frappe.throw(_("{0} and {1} are mandatory").format(frappe.bold(_("From Date")), frappe.bold(_("To Date"))))
-
- for account in filters.account:
- if not account_details.get(account):
- frappe.throw(_("Account {0} does not exists").format(account))
if filters.get('account'):
filters.account = frappe.parse_json(filters.get('account'))
+ for account in filters.account:
+ if not account_details.get(account):
+ frappe.throw(_("Account {0} does not exists").format(account))
if (filters.get("account") and filters.get("group_by") == _('Group by Account')
and account_details[filters.account].is_group == 0):
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index 66a9b60..a8e4b15 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -472,7 +472,8 @@
"total_amount": d.grand_total,
"outstanding_amount": d.outstanding_amount,
"allocated_amount": d.allocated_amount,
- "exchange_rate": d.exchange_rate
+ "exchange_rate": d.exchange_rate if not d.exchange_gain_loss else payment_entry.get_exchange_rate(),
+ "exchange_gain_loss": d.exchange_gain_loss # only populated from invoice in case of advance allocation
}
if d.voucher_detail_no:
@@ -498,12 +499,15 @@
payment_entry.set_amounts()
if d.difference_amount and d.difference_account:
- payment_entry.set_gain_or_loss(account_details={
+ account_details = {
'account': d.difference_account,
'cost_center': payment_entry.cost_center or frappe.get_cached_value('Company',
- payment_entry.company, "cost_center"),
- 'amount': d.difference_amount
- })
+ payment_entry.company, "cost_center")
+ }
+ if d.difference_amount:
+ account_details['amount'] = d.difference_amount
+
+ payment_entry.set_gain_or_loss(account_details=account_details)
if not do_not_save:
payment_entry.save(ignore_permissions=True)
diff --git a/erpnext/buying/doctype/supplier/supplier.js b/erpnext/buying/doctype/supplier/supplier.js
index 4ddc458..1766c2c 100644
--- a/erpnext/buying/doctype/supplier/supplier.js
+++ b/erpnext/buying/doctype/supplier/supplier.js
@@ -60,10 +60,23 @@
erpnext.utils.make_pricing_rule(frm.doc.doctype, frm.doc.name);
}, __('Create'));
+ frm.add_custom_button(__('Get Supplier Group Details'), function () {
+ frm.trigger("get_supplier_group_details");
+ }, __('Actions'));
+
// indicators
erpnext.utils.set_party_dashboard_indicators(frm);
}
},
+ get_supplier_group_details: function(frm) {
+ frappe.call({
+ method: "get_supplier_group_details",
+ doc: frm.doc,
+ callback: function() {
+ frm.refresh();
+ }
+ });
+ },
is_internal_supplier: function(frm) {
if (frm.doc.is_internal_supplier == 1) {
diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py
index edeb135..fd16b23 100644
--- a/erpnext/buying/doctype/supplier/supplier.py
+++ b/erpnext/buying/doctype/supplier/supplier.py
@@ -51,6 +51,23 @@
validate_party_accounts(self)
self.validate_internal_supplier()
+ @frappe.whitelist()
+ def get_supplier_group_details(self):
+ doc = frappe.get_doc('Supplier Group', self.supplier_group)
+ self.payment_terms = ""
+ self.accounts = []
+
+ if doc.accounts:
+ for account in doc.accounts:
+ child = self.append('accounts')
+ child.company = account.company
+ child.account = account.account
+
+ if doc.payment_terms:
+ self.payment_terms = doc.payment_terms
+
+ self.save()
+
def validate_internal_supplier(self):
internal_supplier = frappe.db.get_value("Supplier",
{"is_internal_supplier": 1, "represents_company": self.represents_company, "name": ("!=", self.name)}, "name")
@@ -86,4 +103,4 @@
create_contact(supplier, 'Supplier',
doc.name, args.get('supplier_email_' + str(i)))
except frappe.NameError:
- pass
\ No newline at end of file
+ pass
diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py
index f9c8d35..8980466 100644
--- a/erpnext/buying/doctype/supplier/test_supplier.py
+++ b/erpnext/buying/doctype/supplier/test_supplier.py
@@ -13,6 +13,30 @@
class TestSupplier(unittest.TestCase):
+ def test_get_supplier_group_details(self):
+ doc = frappe.new_doc("Supplier Group")
+ doc.supplier_group_name = "_Testing Supplier Group"
+ doc.payment_terms = "_Test Payment Term Template 3"
+ doc.accounts = []
+ test_account_details = {
+ "company": "_Test Company",
+ "account": "Creditors - _TC",
+ }
+ doc.append("accounts", test_account_details)
+ doc.save()
+ s_doc = frappe.new_doc("Supplier")
+ s_doc.supplier_name = "Testing Supplier"
+ s_doc.supplier_group = "_Testing Supplier Group"
+ s_doc.payment_terms = ""
+ s_doc.accounts = []
+ s_doc.insert()
+ s_doc.get_supplier_group_details()
+ self.assertEqual(s_doc.payment_terms, "_Test Payment Term Template 3")
+ self.assertEqual(s_doc.accounts[0].company, "_Test Company")
+ self.assertEqual(s_doc.accounts[0].account, "Creditors - _TC")
+ s_doc.delete()
+ doc.delete()
+
def test_supplier_default_payment_terms(self):
# Payment Term based on Days after invoice date
frappe.db.set_value(
@@ -136,4 +160,4 @@
return doc
except frappe.DuplicateEntryError:
- return frappe.get_doc("Supplier", args.supplier_name)
\ No newline at end of file
+ return frappe.get_doc("Supplier", args.supplier_name)
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 1c086e9..a9860ed 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -124,6 +124,8 @@
if cint(self.allocate_advances_automatically) and not cint(self.get(pos_check_field)):
self.set_advances()
+ self.set_advance_gain_or_loss()
+
if self.is_return:
self.validate_qty()
else:
@@ -584,15 +586,18 @@
allocated_amount = min(amount - advance_allocated, d.amount)
advance_allocated += flt(allocated_amount)
- self.append("advances", {
+ advance_row = {
"doctype": self.doctype + " Advance",
"reference_type": d.reference_type,
"reference_name": d.reference_name,
"reference_row": d.reference_row,
"remarks": d.remarks,
"advance_amount": flt(d.amount),
- "allocated_amount": allocated_amount
- })
+ "allocated_amount": allocated_amount,
+ "ref_exchange_rate": flt(d.exchange_rate) # exchange_rate of advance entry
+ }
+
+ self.append("advances", advance_row)
def get_advance_entries(self, include_unallocated=True):
if self.doctype == "Sales Invoice":
@@ -650,6 +655,66 @@
"Payment Entry {0} is linked against Order {1}, check if it should be pulled as advance in this invoice.")
.format(d.reference_name, d.against_order))
+ def set_advance_gain_or_loss(self):
+ if not self.get("advances"):
+ return
+
+ for d in self.get("advances"):
+ advance_exchange_rate = d.ref_exchange_rate
+ if (d.allocated_amount and self.conversion_rate != 1
+ and self.conversion_rate != advance_exchange_rate):
+
+ base_allocated_amount_in_ref_rate = advance_exchange_rate * d.allocated_amount
+ base_allocated_amount_in_inv_rate = self.conversion_rate * d.allocated_amount
+ difference = base_allocated_amount_in_ref_rate - base_allocated_amount_in_inv_rate
+
+ d.exchange_gain_loss = difference
+
+ def make_exchange_gain_loss_gl_entries(self, gl_entries):
+ if self.get('doctype') in ['Purchase Invoice', 'Sales Invoice']:
+ for d in self.get("advances"):
+ if d.exchange_gain_loss:
+ party = self.supplier if self.get('doctype') == 'Purchase Invoice' else self.customer
+ party_account = self.credit_to if self.get('doctype') == 'Purchase Invoice' else self.debit_to
+ party_type = "Supplier" if self.get('doctype') == 'Purchase Invoice' else "Customer"
+
+ gain_loss_account = frappe.db.get_value('Company', self.company, 'exchange_gain_loss_account')
+ account_currency = get_account_currency(gain_loss_account)
+ if account_currency != self.company_currency:
+ frappe.throw(_("Currency for {0} must be {1}").format(d.account, self.company_currency))
+
+ # for purchase
+ dr_or_cr = 'debit' if d.exchange_gain_loss > 0 else 'credit'
+ # just reverse for sales?
+ dr_or_cr = 'debit' if dr_or_cr == 'credit' else 'credit'
+
+ gl_entries.append(
+ self.get_gl_dict({
+ "account": gain_loss_account,
+ "account_currency": account_currency,
+ "against": party,
+ dr_or_cr + "_in_account_currency": abs(d.exchange_gain_loss),
+ dr_or_cr: abs(d.exchange_gain_loss),
+ "cost_center": self.cost_center,
+ "project": self.project
+ }, item=d)
+ )
+
+ dr_or_cr = 'debit' if dr_or_cr == 'credit' else 'credit'
+
+ gl_entries.append(
+ self.get_gl_dict({
+ "account": party_account,
+ "party_type": party_type,
+ "party": party,
+ "against": gain_loss_account,
+ dr_or_cr + "_in_account_currency": flt(abs(d.exchange_gain_loss) / self.conversion_rate),
+ dr_or_cr: abs(d.exchange_gain_loss),
+ "cost_center": self.cost_center,
+ "project": self.project
+ }, self.party_account_currency, item=self)
+ )
+
def update_against_document_in_jv(self):
"""
Links invoice and advance voucher:
@@ -690,7 +755,9 @@
if self.party_account_currency != self.company_currency else 1),
'grand_total': (self.base_grand_total
if self.party_account_currency == self.company_currency else self.grand_total),
- 'outstanding_amount': self.outstanding_amount
+ 'outstanding_amount': self.outstanding_amount,
+ 'difference_account': frappe.db.get_value('Company', self.company, 'exchange_gain_loss_account'),
+ 'exchange_gain_loss': flt(d.get('exchange_gain_loss'))
})
lst.append(args)
@@ -1289,6 +1356,8 @@
party_account_field = "paid_from" if party_type == "Customer" else "paid_to"
currency_field = "paid_from_account_currency" if party_type == "Customer" else "paid_to_account_currency"
payment_type = "Receive" if party_type == "Customer" else "Pay"
+ exchange_rate_field = "source_exchange_rate" if payment_type == "Receive" else "target_exchange_rate"
+
payment_entries_against_order, unallocated_payment_entries = [], []
limit_cond = "limit %s" % limit if limit else ""
@@ -1305,27 +1374,28 @@
"Payment Entry" as reference_type, t1.name as reference_name,
t1.remarks, t2.allocated_amount as amount, t2.name as reference_row,
t2.reference_name as against_order, t1.posting_date,
- t1.{0} as currency
+ t1.{0} as currency, t1.{4} as exchange_rate
from `tabPayment Entry` t1, `tabPayment Entry Reference` t2
where
t1.name = t2.parent and t1.{1} = %s and t1.payment_type = %s
and t1.party_type = %s and t1.party = %s and t1.docstatus = 1
and t2.reference_doctype = %s {2}
order by t1.posting_date {3}
- """.format(currency_field, party_account_field, reference_condition, limit_cond),
+ """.format(currency_field, party_account_field, reference_condition, limit_cond, exchange_rate_field),
[party_account, payment_type, party_type, party,
order_doctype] + order_list, as_dict=1)
if include_unallocated:
unallocated_payment_entries = frappe.db.sql("""
select "Payment Entry" as reference_type, name as reference_name,
- remarks, unallocated_amount as amount
+ remarks, unallocated_amount as amount, {2} as exchange_rate
from `tabPayment Entry`
where
{0} = %s and party_type = %s and party = %s and payment_type = %s
and docstatus = 1 and unallocated_amount > 0
order by posting_date {1}
- """.format(party_account_field, limit_cond), (party_account, party_type, party, payment_type), as_dict=1)
+ """.format(party_account_field, limit_cond, exchange_rate_field),
+ (party_account, party_type, party, payment_type), as_dict=1)
return list(payment_entries_against_order) + list(unallocated_payment_entries)
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index 8196cff..2526e6d 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -356,42 +356,68 @@
}, update_modified)
def validate_inspection(self):
- '''Checks if quality inspection is set for Items that require inspection.
- On submit, throw an exception'''
- inspection_required_fieldname = None
- if self.doctype in ["Purchase Receipt", "Purchase Invoice"]:
- inspection_required_fieldname = "inspection_required_before_purchase"
- elif self.doctype in ["Delivery Note", "Sales Invoice"]:
- inspection_required_fieldname = "inspection_required_before_delivery"
+ """Checks if quality inspection is set/ is valid for Items that require inspection."""
+ inspection_fieldname_map = {
+ "Purchase Receipt": "inspection_required_before_purchase",
+ "Purchase Invoice": "inspection_required_before_purchase",
+ "Sales Invoice": "inspection_required_before_delivery",
+ "Delivery Note": "inspection_required_before_delivery"
+ }
+ inspection_required_fieldname = inspection_fieldname_map.get(self.doctype)
+ # return if inspection is not required on document level
if ((not inspection_required_fieldname and self.doctype != "Stock Entry") or
(self.doctype == "Stock Entry" and not self.inspection_required) or
(self.doctype in ["Sales Invoice", "Purchase Invoice"] and not self.update_stock)):
return
- for d in self.get('items'):
- qa_required = False
- if (inspection_required_fieldname and not d.quality_inspection and
- frappe.db.get_value("Item", d.item_code, inspection_required_fieldname)):
- qa_required = True
- elif self.doctype == "Stock Entry" and not d.quality_inspection and d.t_warehouse:
- qa_required = True
- if self.docstatus == 1 and d.quality_inspection:
- qa_doc = frappe.get_doc("Quality Inspection", d.quality_inspection)
- if qa_doc.docstatus == 0:
- link = frappe.utils.get_link_to_form('Quality Inspection', d.quality_inspection)
- frappe.throw(_("Quality Inspection: {0} is not submitted for the item: {1} in row {2}").format(link, d.item_code, d.idx), QualityInspectionNotSubmittedError)
+ for row in self.get('items'):
+ qi_required = False
+ if (inspection_required_fieldname and frappe.db.get_value("Item", row.item_code, inspection_required_fieldname)):
+ qi_required = True
+ elif self.doctype == "Stock Entry" and row.t_warehouse:
+ qi_required = True # inward stock needs inspection
- if qa_doc.status != 'Accepted':
- frappe.throw(_("Row {0}: Quality Inspection rejected for item {1}")
- .format(d.idx, d.item_code), QualityInspectionRejectedError)
- elif qa_required :
- action = frappe.get_doc('Stock Settings').action_if_quality_inspection_is_not_submitted
- if self.docstatus==1 and action == 'Stop':
- frappe.throw(_("Quality Inspection required for Item {0} to submit").format(frappe.bold(d.item_code)),
- exc=QualityInspectionRequiredError)
- else:
- frappe.msgprint(_("Create Quality Inspection for Item {0}").format(frappe.bold(d.item_code)))
+ if qi_required: # validate row only if inspection is required on item level
+ self.validate_qi_presence(row)
+ if self.docstatus == 1:
+ self.validate_qi_submission(row)
+ self.validate_qi_rejection(row)
+
+ def validate_qi_presence(self, row):
+ """Check if QI is present on row level. Warn on save and stop on submit if missing."""
+ if not row.quality_inspection:
+ msg = f"Row #{row.idx}: Quality Inspection is required for Item {frappe.bold(row.item_code)}"
+ if self.docstatus == 1:
+ frappe.throw(_(msg), title=_("Inspection Required"), exc=QualityInspectionRequiredError)
+ else:
+ frappe.msgprint(_(msg), title=_("Inspection Required"), indicator="blue")
+
+ def validate_qi_submission(self, row):
+ """Check if QI is submitted on row level, during submission"""
+ action = frappe.db.get_single_value("Stock Settings", "action_if_quality_inspection_is_not_submitted")
+ qa_docstatus = frappe.db.get_value("Quality Inspection", row.quality_inspection, "docstatus")
+
+ if not qa_docstatus == 1:
+ link = frappe.utils.get_link_to_form('Quality Inspection', row.quality_inspection)
+ msg = f"Row #{row.idx}: Quality Inspection {link} is not submitted for the item: {row.item_code}"
+ if action == "Stop":
+ frappe.throw(_(msg), title=_("Inspection Submission"), exc=QualityInspectionNotSubmittedError)
+ else:
+ frappe.msgprint(_(msg), alert=True, indicator="orange")
+
+ def validate_qi_rejection(self, row):
+ """Check if QI is rejected on row level, during submission"""
+ action = frappe.db.get_single_value("Stock Settings", "action_if_quality_inspection_is_rejected")
+ qa_status = frappe.db.get_value("Quality Inspection", row.quality_inspection, "status")
+
+ if qa_status == "Rejected":
+ link = frappe.utils.get_link_to_form('Quality Inspection', row.quality_inspection)
+ msg = f"Row #{row.idx}: Quality Inspection {link} was rejected for item {row.item_code}"
+ if action == "Stop":
+ frappe.throw(_(msg), title=_("Inspection Rejected"), exc=QualityInspectionRejectedError)
+ else:
+ frappe.msgprint(_(msg), alert=True, indicator="orange")
def update_blanket_order(self):
blanket_orders = list(set([d.blanket_order for d in self.items if d.blanket_order]))
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index ba10b58..9717bb9 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -245,7 +245,10 @@
"erpnext.portal.utils.set_default_role"]
},
"Communication": {
- "on_update": "erpnext.support.doctype.service_level_agreement.service_level_agreement.update_hold_time"
+ "on_update": [
+ "erpnext.support.doctype.service_level_agreement.service_level_agreement.update_hold_time",
+ "erpnext.support.doctype.issue.issue.set_first_response_time"
+ ]
},
("Sales Taxes and Charges Template", 'Price List'): {
"on_update": "erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings.validate_cart_settings"
diff --git a/erpnext/hr/doctype/training_event/training_event.js b/erpnext/hr/doctype/training_event/training_event.js
index 064dfb2..d5f6e5f 100644
--- a/erpnext/hr/doctype/training_event/training_event.js
+++ b/erpnext/hr/doctype/training_event/training_event.js
@@ -33,7 +33,8 @@
frm.set_query("employee", "employees", function () {
return {
filters: {
- name: ["NOT IN", emp]
+ name: ["NOT IN", emp],
+ status: "Active"
}
};
});
diff --git a/erpnext/loan_management/doctype/loan/loan.js b/erpnext/loan_management/doctype/loan/loan.js
index 28af3a9..f9c201a 100644
--- a/erpnext/loan_management/doctype/loan/loan.js
+++ b/erpnext/loan_management/doctype/loan/loan.js
@@ -28,7 +28,8 @@
frm.set_query("loan_type", function () {
return {
"filters": {
- "docstatus": 1
+ "docstatus": 1,
+ "company": frm.doc.company
}
};
});
diff --git a/erpnext/loan_management/doctype/loan_application/loan_application.js b/erpnext/loan_management/doctype/loan_application/loan_application.js
index 1365274..017026c 100644
--- a/erpnext/loan_management/doctype/loan_application/loan_application.js
+++ b/erpnext/loan_management/doctype/loan_application/loan_application.js
@@ -14,11 +14,18 @@
refresh: function(frm) {
frm.trigger("toggle_fields");
frm.trigger("add_toolbar_buttons");
+ frm.set_query("loan_type", () => {
+ return {
+ filters: {
+ company: frm.doc.company
+ }
+ };
+ });
},
repayment_method: function(frm) {
- frm.doc.repayment_amount = frm.doc.repayment_periods = ""
- frm.trigger("toggle_fields")
- frm.trigger("toggle_required")
+ frm.doc.repayment_amount = frm.doc.repayment_periods = "";
+ frm.trigger("toggle_fields");
+ frm.trigger("toggle_required");
},
toggle_fields: function(frm) {
frm.toggle_enable("repayment_amount", frm.doc.repayment_method=="Repay Fixed Amount per Period")
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 986b0c5..c93f7a7 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -290,5 +290,6 @@
erpnext.patches.v13_0.set_training_event_attendance
erpnext.patches.v13_0.bill_for_rejected_quantity_in_purchase_invoice
erpnext.patches.v13_0.rename_issue_status_hold_to_on_hold
+erpnext.patches.v13_0.update_response_by_variance
erpnext.patches.v13_0.bill_for_rejected_quantity_in_purchase_invoice
erpnext.patches.v13_0.update_job_card_details
diff --git a/erpnext/patches/v13_0/update_response_by_variance.py b/erpnext/patches/v13_0/update_response_by_variance.py
new file mode 100644
index 0000000..ef4d976
--- /dev/null
+++ b/erpnext/patches/v13_0/update_response_by_variance.py
@@ -0,0 +1,31 @@
+# Copyright (c) 2020, Frappe and Contributors
+# License: GNU General Public License v3. See license.txt
+
+from __future__ import unicode_literals
+import frappe
+
+def execute():
+ if frappe.db.exists('DocType', 'Issue') and frappe.db.count('Issue'):
+ invalid_issues = frappe.get_all('Issue', {
+ 'first_responded_on': ['is', 'set'],
+ 'response_by_variance': ['<', 0]
+ }, ["name", "response_by_variance", "timestampdiff(Second, `first_responded_on`, `response_by`) as variance"])
+
+ # issues which has response_by_variance set as -ve
+ # but diff between first_responded_on & response_by is +ve i.e SLA isn't failed
+ invalid_issues = [d for d in invalid_issues if d.get('variance') > 0]
+
+ for issue in invalid_issues:
+ frappe.db.set_value('Issue', issue.get('name'), 'response_by_variance', issue.get('variance'), update_modified=False)
+
+ invalid_issues = frappe.get_all('Issue', {
+ 'resolution_date': ['is', 'set'],
+ 'resolution_by_variance': ['<', 0]
+ }, ["name", "resolution_by_variance", "timestampdiff(Second, `resolution_date`, `resolution_by`) as variance"])
+
+ # issues which has resolution_by_variance set as -ve
+ # but diff between resolution_date & resolution_by is +ve i.e SLA isn't failed
+ invalid_issues = [d for d in invalid_issues if d.get('variance') > 0]
+
+ for issue in invalid_issues:
+ frappe.db.set_value('Issue', issue.get('name'), 'resolution_by_variance', issue.get('variance'), update_modified=False)
diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py
index c55bec8..f82b0d5 100644
--- a/erpnext/payroll/doctype/salary_slip/salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py
@@ -1088,6 +1088,7 @@
"applicant": self.employee,
"docstatus": 1,
"repay_from_salary": 1,
+ "company": self.company
})
def make_loan_repayment_entry(self):
diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
index ce88cc3..d730fcf 100644
--- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
@@ -481,15 +481,19 @@
if not salary_structure:
salary_structure = payroll_frequency + " Salary Structure Test for Salary Slip"
+ employee = frappe.db.get_value("Employee",
+ {
+ "user_id": user
+ },
+ ["name", "company", "employee_name"],
+ as_dict=True)
- employee = frappe.db.get_value("Employee", {"user_id": user})
- salary_structure_doc = make_salary_structure(salary_structure, payroll_frequency, employee=employee)
+ salary_structure_doc = make_salary_structure(salary_structure, payroll_frequency, employee=employee.name, company=employee.company)
salary_slip_name = frappe.db.get_value("Salary Slip", {"employee": frappe.db.get_value("Employee", {"user_id": user})})
if not salary_slip_name:
- salary_slip = make_salary_slip(salary_structure_doc.name, employee = employee)
- salary_slip.employee_name = frappe.get_value("Employee",
- {"name":frappe.db.get_value("Employee", {"user_id": user})}, "employee_name")
+ salary_slip = make_salary_slip(salary_structure_doc.name, employee = employee.name)
+ salary_slip.employee_name = employee.employee_name
salary_slip.payroll_frequency = payroll_frequency
salary_slip.posting_date = nowdate()
salary_slip.insert()
diff --git a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py
index e7d123c..374dd7e 100644
--- a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py
+++ b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py
@@ -119,26 +119,25 @@
if test_tax:
frappe.db.sql("""delete from `tabSalary Structure` where name=%s""",(salary_structure))
- if not frappe.db.exists('Salary Structure', salary_structure):
- details = {
- "doctype": "Salary Structure",
- "name": salary_structure,
- "company": company or erpnext.get_default_company(),
- "earnings": make_earning_salary_component(setup=True, test_tax=test_tax, company_list=["_Test Company"]),
- "deductions": make_deduction_salary_component(setup=True, test_tax=test_tax, company_list=["_Test Company"]),
- "payroll_frequency": payroll_frequency,
- "payment_account": get_random("Account", filters={'account_currency': currency}),
- "currency": currency
- }
- if other_details and isinstance(other_details, dict):
- details.update(other_details)
- salary_structure_doc = frappe.get_doc(details)
- salary_structure_doc.insert()
- if not dont_submit:
- salary_structure_doc.submit()
+ if frappe.db.exists("Salary Structure", salary_structure):
+ frappe.db.delete("Salary Structure", salary_structure)
- else:
- salary_structure_doc = frappe.get_doc("Salary Structure", salary_structure)
+ details = {
+ "doctype": "Salary Structure",
+ "name": salary_structure,
+ "company": company or erpnext.get_default_company(),
+ "earnings": make_earning_salary_component(setup=True, test_tax=test_tax, company_list=["_Test Company"]),
+ "deductions": make_deduction_salary_component(setup=True, test_tax=test_tax, company_list=["_Test Company"]),
+ "payroll_frequency": payroll_frequency,
+ "payment_account": get_random("Account", filters={"account_currency": currency}),
+ "currency": currency
+ }
+ if other_details and isinstance(other_details, dict):
+ details.update(other_details)
+ salary_structure_doc = frappe.get_doc(details)
+ salary_structure_doc.insert()
+ if not dont_submit:
+ salary_structure_doc.submit()
filters = {'employee':employee, 'docstatus': 1}
if not from_date and payroll_period:
diff --git a/erpnext/payroll/report/bank_remittance/bank_remittance.py b/erpnext/payroll/report/bank_remittance/bank_remittance.py
index 500543c..05a5366 100644
--- a/erpnext/payroll/report/bank_remittance/bank_remittance.py
+++ b/erpnext/payroll/report/bank_remittance/bank_remittance.py
@@ -95,6 +95,7 @@
"amount": salary.net_pay,
}
data.append(row)
+
return columns, data
def get_bank_accounts():
@@ -116,7 +117,7 @@
entries = get_all("Payroll Entry", payroll_filter, ["name", "payment_account"])
payment_accounts = [d.payment_account for d in entries]
- set_company_account(payment_accounts, entries)
+ entries = set_company_account(payment_accounts, entries)
return entries
def get_salary_slips(payroll_entries):
diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js
index 0471704..181e340 100644
--- a/erpnext/public/js/controllers/taxes_and_totals.js
+++ b/erpnext/public/js/controllers/taxes_and_totals.js
@@ -67,6 +67,8 @@
calculate_discount_amount(){
if (frappe.meta.get_docfield(this.frm.doc.doctype, "discount_amount")) {
+ this.calculate_item_values();
+ this.calculate_net_total();
this.set_discount_amount();
this.apply_discount_amount();
}
diff --git a/erpnext/public/js/help_links.js b/erpnext/public/js/help_links.js
index aa9bba1..5c9a453 100644
--- a/erpnext/public/js/help_links.js
+++ b/erpnext/public/js/help_links.js
@@ -991,7 +991,7 @@
label: "Nested BOM Structure",
url:
docsUrl +
- "user/manual/en/manufacturing/articles/nested-bom-structure",
+ "user/manual/en/manufacturing/articles/managing-multi-level-bom",
},
];
diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js
index 23d4fe9..8ad30fa 100644
--- a/erpnext/regional/india/e_invoice/einvoice.js
+++ b/erpnext/regional/india/e_invoice/einvoice.js
@@ -1,6 +1,8 @@
erpnext.setup_einvoice_actions = (doctype) => {
frappe.ui.form.on(doctype, {
async refresh(frm) {
+ if (frm.doc.docstatus == 2) return;
+
const res = await frappe.call({
method: 'erpnext.regional.india.e_invoice.utils.validate_eligibility',
args: { doc: frm.doc }
@@ -111,7 +113,7 @@
if (irn && ewaybill && !irn_cancelled && !eway_bill_cancelled) {
const action = () => {
- let message = __('Cancellation of e-way bill is currently not supported. ');
+ let message = __('Cancellation of e-way bill is currently not supported.') + ' ';
message += '<br><br>';
message += __('You must first use the portal to cancel the e-way bill and then update the cancelled status in the ERPNext system.');
diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py
index 5d33c1b..81c7a6b 100644
--- a/erpnext/regional/india/e_invoice/utils.py
+++ b/erpnext/regional/india/e_invoice/utils.py
@@ -42,7 +42,10 @@
invalid_company = not frappe.db.get_value('E Invoice User', { 'company': doc.get('company') })
invalid_supply_type = doc.get('gst_category') not in ['Registered Regular', 'SEZ', 'Overseas', 'Deemed Export']
company_transaction = doc.get('billing_address_gstin') == doc.get('company_gstin')
- no_taxes_applied = not doc.get('taxes')
+
+ # if export invoice, then taxes can be empty
+ # invoice can only be ineligible if no taxes applied and is not an export invoice
+ no_taxes_applied = not doc.get('taxes') and not doc.get('gst_category') == 'Overseas'
has_non_gst_item = any(d for d in doc.get('items', []) if d.get('is_non_gst'))
if invalid_company or invalid_supply_type or company_transaction or no_taxes_applied or has_non_gst_item:
@@ -188,9 +191,10 @@
item.qty = abs(item.qty)
- item.unit_rate = abs((abs(item.taxable_value) - item.discount_amount)/ item.qty)
- item.gross_amount = abs(item.taxable_value) + item.discount_amount
+ item.unit_rate = abs(item.taxable_value / item.qty)
+ item.gross_amount = abs(item.taxable_value)
item.taxable_value = abs(item.taxable_value)
+ item.discount_amount = 0
item.batch_expiry_date = frappe.db.get_value('Batch', d.batch_no, 'expiry_date') if d.batch_no else None
item.batch_expiry_date = format_date(item.batch_expiry_date, 'dd/mm/yyyy') if item.batch_expiry_date else None
diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py
index 1096159..cfcb8c3 100644
--- a/erpnext/regional/report/gstr_1/gstr_1.py
+++ b/erpnext/regional/report/gstr_1/gstr_1.py
@@ -584,7 +584,7 @@
def get_json(filters, report_name, data):
filters = json.loads(filters)
report_data = json.loads(data)
- gstin = get_company_gstin_number(filters["company"], filters["company_address"])
+ gstin = get_company_gstin_number(filters.get("company"), filters.get("company_address"))
fp = "%02d%s" % (getdate(filters["to_date"]).month, getdate(filters["to_date"]).year)
diff --git a/erpnext/selling/doctype/customer/customer.js b/erpnext/selling/doctype/customer/customer.js
index 825b170..2849466 100644
--- a/erpnext/selling/doctype/customer/customer.js
+++ b/erpnext/selling/doctype/customer/customer.js
@@ -130,6 +130,10 @@
erpnext.utils.make_pricing_rule(frm.doc.doctype, frm.doc.name);
}, __('Create'));
+ frm.add_custom_button(__('Get Customer Group Details'), function () {
+ frm.trigger("get_customer_group_details");
+ }, __('Actions'));
+
// indicator
erpnext.utils.set_party_dashboard_indicators(frm);
@@ -145,4 +149,15 @@
if(frm.doc.lead_name) frappe.model.clear_doc("Lead", frm.doc.lead_name);
},
-});
\ No newline at end of file
+ get_customer_group_details: function(frm) {
+ frappe.call({
+ method: "get_customer_group_details",
+ doc: frm.doc,
+ callback: function() {
+ frm.refresh();
+ }
+ });
+
+ }
+});
+
diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py
index 818888c..3b62081 100644
--- a/erpnext/selling/doctype/customer/customer.py
+++ b/erpnext/selling/doctype/customer/customer.py
@@ -78,6 +78,29 @@
if sum(member.allocated_percentage or 0 for member in self.sales_team) != 100:
frappe.throw(_("Total contribution percentage should be equal to 100"))
+ @frappe.whitelist()
+ def get_customer_group_details(self):
+ doc = frappe.get_doc('Customer Group', self.customer_group)
+ self.accounts = self.credit_limits = []
+ self.payment_terms = self.default_price_list = ""
+
+ tables = [["accounts", "account"], ["credit_limits", "credit_limit"]]
+ fields = ["payment_terms", "default_price_list"]
+
+ for row in tables:
+ table, field = row[0], row[1]
+ if not doc.get(table): continue
+
+ for entry in doc.get(table):
+ child = self.append(table)
+ child.update({"company": entry.company, field: entry.get(field)})
+
+ for field in fields:
+ if not doc.get(field): continue
+ self.update({field: doc.get(field)})
+
+ self.save()
+
def check_customer_group_change(self):
frappe.flags.customer_group_changed = False
diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py
index 7761aa7..b1a5b52 100644
--- a/erpnext/selling/doctype/customer/test_customer.py
+++ b/erpnext/selling/doctype/customer/test_customer.py
@@ -27,6 +27,42 @@
def tearDown(self):
set_credit_limit('_Test Customer', '_Test Company', 0)
+ def test_get_customer_group_details(self):
+ doc = frappe.new_doc("Customer Group")
+ doc.customer_group_name = "_Testing Customer Group"
+ doc.payment_terms = "_Test Payment Term Template 3"
+ doc.accounts = []
+ doc.default_price_list = "Standard Buying"
+ doc.credit_limits = []
+ test_account_details = {
+ "company": "_Test Company",
+ "account": "Creditors - _TC",
+ }
+ test_credit_limits = {
+ "company": "_Test Company",
+ "credit_limit": 350000
+ }
+ doc.append("accounts", test_account_details)
+ doc.append("credit_limits", test_credit_limits)
+ doc.insert()
+
+ c_doc = frappe.new_doc("Customer")
+ c_doc.customer_name = "Testing Customer"
+ c_doc.customer_group = "_Testing Customer Group"
+ c_doc.payment_terms = c_doc.default_price_list = ""
+ c_doc.accounts = c_doc.credit_limits= []
+ c_doc.insert()
+ c_doc.get_customer_group_details()
+ self.assertEqual(c_doc.payment_terms, "_Test Payment Term Template 3")
+
+ self.assertEqual(c_doc.accounts[0].company, "_Test Company")
+ self.assertEqual(c_doc.accounts[0].account, "Creditors - _TC")
+
+ self.assertEqual(c_doc.credit_limits[0].company, "_Test Company")
+ self.assertEqual(c_doc.credit_limits[0].credit_limit, 350000)
+ c_doc.delete()
+ doc.delete()
+
def test_party_details(self):
from erpnext.accounts.party import get_party_details
diff --git a/erpnext/selling/page/point_of_sale/pos_item_cart.js b/erpnext/selling/page/point_of_sale/pos_item_cart.js
index 7cae0e4..f7b2c1d 100644
--- a/erpnext/selling/page/point_of_sale/pos_item_cart.js
+++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js
@@ -472,12 +472,7 @@
const grand_total = cint(frappe.sys_defaults.disable_rounded_total) ? frm.doc.grand_total : frm.doc.rounded_total;
this.render_grand_total(grand_total);
- const taxes = frm.doc.taxes.map(t => {
- return {
- description: t.description, rate: t.rate
- };
- });
- this.render_taxes(frm.doc.total_taxes_and_charges, taxes);
+ this.render_taxes(frm.doc.taxes);
}
render_net_total(value) {
@@ -502,14 +497,14 @@
);
}
- render_taxes(value, taxes) {
+ render_taxes(taxes) {
if (taxes.length) {
const currency = this.events.get_frm().doc.currency;
const taxes_html = taxes.map(t => {
const description = /[0-9]+/.test(t.description) ? t.description : `${t.description} @ ${t.rate}%`;
return `<div class="tax-row">
<div class="tax-label">${description}</div>
- <div class="tax-value">${format_currency(value, currency)}</div>
+ <div class="tax-value">${format_currency(t.tax_amount_after_discount_amount, currency)}</div>
</div>`;
}).join('');
this.$totals_section.find('.taxes-container').css('display', 'flex').html(taxes_html);
@@ -970,8 +965,23 @@
});
}
+ attach_refresh_field_event(frm) {
+ $(frm.wrapper).off('refresh-fields');
+ $(frm.wrapper).on('refresh-fields', () => {
+ if (frm.doc.items.length) {
+ frm.doc.items.forEach(item => {
+ this.update_item_html(item);
+ });
+ }
+ this.update_totals_section(frm);
+ });
+ }
+
load_invoice() {
const frm = this.events.get_frm();
+
+ this.attach_refresh_field_event(frm);
+
this.fetch_customer_details(frm.doc.customer).then(() => {
this.events.customer_details_updated(this.customer_info);
this.update_customer_section();
diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js
index c484873..f1a166b 100644
--- a/erpnext/selling/page/point_of_sale/pos_payment.js
+++ b/erpnext/selling/page/point_of_sale/pos_payment.js
@@ -56,7 +56,7 @@
);
let df_events = {
onchange: function() {
- frm.set_value(this.df.fieldname, this.value);
+ frm.set_value(this.df.fieldname, this.get_value());
}
};
if (df.fieldtype == "Button") {
diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py
index 0427abe..8fd905d 100644
--- a/erpnext/setup/doctype/company/company.py
+++ b/erpnext/setup/doctype/company/company.py
@@ -395,7 +395,7 @@
@frappe.whitelist()
def enqueue_replace_abbr(company, old, new):
- kwargs = dict(company=company, old=old, new=new)
+ kwargs = dict(queue="long", company=company, old=old, new=new)
frappe.enqueue('erpnext.setup.doctype.company.company.replace_abbr', **kwargs)
diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
index ece9fb5..691d331 100644
--- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
+++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
@@ -12,10 +12,14 @@
class TransactionDeletionRecord(Document):
def validate(self):
frappe.only_for('System Manager')
+ self.validate_doctypes_to_be_ignored()
+
+ def validate_doctypes_to_be_ignored(self):
doctypes_to_be_ignored_list = get_doctypes_to_be_ignored()
for doctype in self.doctypes_to_be_ignored:
if doctype.doctype_name not in doctypes_to_be_ignored_list:
- frappe.throw(_("DocTypes should not be added manually to the 'Excluded DocTypes' table. You are only allowed to remove entries from it. "), title=_("Not Allowed"))
+ frappe.throw(_("DocTypes should not be added manually to the 'Excluded DocTypes' table. You are only allowed to remove entries from it. "),
+ title=_("Not Allowed"))
def before_submit(self):
if not self.doctypes_to_be_ignored:
@@ -23,54 +27,9 @@
self.delete_bins()
self.delete_lead_addresses()
-
- company_obj = frappe.get_doc('Company', self.company)
- # reset company values
- company_obj.total_monthly_sales = 0
- company_obj.sales_monthly_history = None
- company_obj.save()
- # Clear notification counts
+ self.reset_company_values()
clear_notifications()
-
- singles = frappe.get_all('DocType', filters = {'issingle': 1}, pluck = 'name')
- tables = frappe.get_all('DocType', filters = {'istable': 1}, pluck = 'name')
- doctypes_to_be_ignored_list = singles
- for doctype in self.doctypes_to_be_ignored:
- doctypes_to_be_ignored_list.append(doctype.doctype_name)
-
- docfields = frappe.get_all('DocField',
- filters = {
- 'fieldtype': 'Link',
- 'options': 'Company',
- 'parent': ['not in', doctypes_to_be_ignored_list]},
- fields=['parent', 'fieldname'])
-
- for docfield in docfields:
- if docfield['parent'] != self.doctype:
- no_of_docs = frappe.db.count(docfield['parent'], {
- docfield['fieldname'] : self.company
- })
-
- if no_of_docs > 0:
- self.delete_version_log(docfield['parent'], docfield['fieldname'])
- self.delete_communications(docfield['parent'], docfield['fieldname'])
-
- # populate DocTypes table
- if docfield['parent'] not in tables:
- self.append('doctypes', {
- 'doctype_name' : docfield['parent'],
- 'no_of_docs' : no_of_docs
- })
-
- # delete the docs linked with the specified company
- frappe.db.delete(docfield['parent'], {
- docfield['fieldname'] : self.company
- })
-
- naming_series = frappe.db.get_value('DocType', docfield['parent'], 'autoname')
- if naming_series:
- if '#' in naming_series:
- self.update_naming_series(naming_series, docfield['parent'])
+ self.delete_company_transactions()
def populate_doctypes_to_be_ignored_table(self):
doctypes_to_be_ignored_list = get_doctypes_to_be_ignored()
@@ -79,6 +38,111 @@
'doctype_name' : doctype
})
+ def delete_bins(self):
+ frappe.db.sql("""delete from tabBin where warehouse in
+ (select name from tabWarehouse where company=%s)""", self.company)
+
+ def delete_lead_addresses(self):
+ """Delete addresses to which leads are linked"""
+ leads = frappe.get_all('Lead', filters={'company': self.company})
+ leads = ["'%s'" % row.get("name") for row in leads]
+ addresses = []
+ if leads:
+ addresses = frappe.db.sql_list("""select parent from `tabDynamic Link` where link_name
+ in ({leads})""".format(leads=",".join(leads)))
+
+ if addresses:
+ addresses = ["%s" % frappe.db.escape(addr) for addr in addresses]
+
+ frappe.db.sql("""delete from tabAddress where name in ({addresses}) and
+ name not in (select distinct dl1.parent from `tabDynamic Link` dl1
+ inner join `tabDynamic Link` dl2 on dl1.parent=dl2.parent
+ and dl1.link_doctype<>dl2.link_doctype)""".format(addresses=",".join(addresses)))
+
+ frappe.db.sql("""delete from `tabDynamic Link` where link_doctype='Lead'
+ and parenttype='Address' and link_name in ({leads})""".format(leads=",".join(leads)))
+
+ frappe.db.sql("""update tabCustomer set lead_name=NULL where lead_name in ({leads})""".format(leads=",".join(leads)))
+
+ def reset_company_values(self):
+ company_obj = frappe.get_doc('Company', self.company)
+ company_obj.total_monthly_sales = 0
+ company_obj.sales_monthly_history = None
+ company_obj.save()
+
+ def delete_company_transactions(self):
+ doctypes_to_be_ignored_list = self.get_doctypes_to_be_ignored_list()
+ docfields = self.get_doctypes_with_company_field(doctypes_to_be_ignored_list)
+
+ tables = self.get_all_child_doctypes()
+ for docfield in docfields:
+ if docfield['parent'] != self.doctype:
+ no_of_docs = self.get_number_of_docs_linked_with_specified_company(docfield['parent'], docfield['fieldname'])
+
+ if no_of_docs > 0:
+ self.delete_version_log(docfield['parent'], docfield['fieldname'])
+ self.delete_communications(docfield['parent'], docfield['fieldname'])
+ self.populate_doctypes_table(tables, docfield['parent'], no_of_docs)
+
+ self.delete_child_tables(docfield['parent'], docfield['fieldname'])
+ self.delete_docs_linked_with_specified_company(docfield['parent'], docfield['fieldname'])
+
+ naming_series = frappe.db.get_value('DocType', docfield['parent'], 'autoname')
+ if naming_series:
+ if '#' in naming_series:
+ self.update_naming_series(naming_series, docfield['parent'])
+
+ def get_doctypes_to_be_ignored_list(self):
+ singles = frappe.get_all('DocType', filters = {'issingle': 1}, pluck = 'name')
+ doctypes_to_be_ignored_list = singles
+ for doctype in self.doctypes_to_be_ignored:
+ doctypes_to_be_ignored_list.append(doctype.doctype_name)
+
+ return doctypes_to_be_ignored_list
+
+ def get_doctypes_with_company_field(self, doctypes_to_be_ignored_list):
+ docfields = frappe.get_all('DocField',
+ filters = {
+ 'fieldtype': 'Link',
+ 'options': 'Company',
+ 'parent': ['not in', doctypes_to_be_ignored_list]},
+ fields=['parent', 'fieldname'])
+
+ return docfields
+
+ def get_all_child_doctypes(self):
+ return frappe.get_all('DocType', filters = {'istable': 1}, pluck = 'name')
+
+ def get_number_of_docs_linked_with_specified_company(self, doctype, company_fieldname):
+ return frappe.db.count(doctype, {company_fieldname : self.company})
+
+ def populate_doctypes_table(self, tables, doctype, no_of_docs):
+ if doctype not in tables:
+ self.append('doctypes', {
+ 'doctype_name' : doctype,
+ 'no_of_docs' : no_of_docs
+ })
+
+ def delete_child_tables(self, doctype, company_fieldname):
+ parent_docs_to_be_deleted = frappe.get_all(doctype, {
+ company_fieldname : self.company
+ }, pluck = 'name')
+
+ child_tables = frappe.get_all('DocField', filters = {
+ 'fieldtype': 'Table',
+ 'parent': doctype
+ }, pluck = 'options')
+
+ for table in child_tables:
+ frappe.db.delete(table, {
+ 'parent': ['in', parent_docs_to_be_deleted]
+ })
+
+ def delete_docs_linked_with_specified_company(self, doctype, company_fieldname):
+ frappe.db.delete(doctype, {
+ company_fieldname : self.company
+ })
+
def update_naming_series(self, naming_series, doctype_name):
if '.' in naming_series:
prefix, hashes = naming_series.rsplit('.', 1)
@@ -107,32 +171,6 @@
frappe.delete_doc('Communication', communication_names, ignore_permissions=True)
- def delete_bins(self):
- frappe.db.sql("""delete from tabBin where warehouse in
- (select name from tabWarehouse where company=%s)""", self.company)
-
- def delete_lead_addresses(self):
- """Delete addresses to which leads are linked"""
- leads = frappe.get_all('Lead', filters={'company': self.company})
- leads = ["'%s'" % row.get("name") for row in leads]
- addresses = []
- if leads:
- addresses = frappe.db.sql_list("""select parent from `tabDynamic Link` where link_name
- in ({leads})""".format(leads=",".join(leads)))
-
- if addresses:
- addresses = ["%s" % frappe.db.escape(addr) for addr in addresses]
-
- frappe.db.sql("""delete from tabAddress where name in ({addresses}) and
- name not in (select distinct dl1.parent from `tabDynamic Link` dl1
- inner join `tabDynamic Link` dl2 on dl1.parent=dl2.parent
- and dl1.link_doctype<>dl2.link_doctype)""".format(addresses=",".join(addresses)))
-
- frappe.db.sql("""delete from `tabDynamic Link` where link_doctype='Lead'
- and parenttype='Address' and link_name in ({leads})""".format(leads=",".join(leads)))
-
- frappe.db.sql("""update tabCustomer set lead_name=NULL where lead_name in ({leads})""".format(leads=",".join(leads)))
-
@frappe.whitelist()
def get_doctypes_to_be_ignored():
doctypes_to_be_ignored_list = ['Account', 'Cost Center', 'Warehouse', 'Budget',
diff --git a/erpnext/setup/utils.py b/erpnext/setup/utils.py
index 13269a8..d5dbd4c 100644
--- a/erpnext/setup/utils.py
+++ b/erpnext/setup/utils.py
@@ -28,21 +28,21 @@
from frappe.desk.page.setup_wizard.setup_wizard import setup_complete
if not frappe.get_list("Company"):
setup_complete({
- "currency" :"USD",
- "full_name" :"Test User",
- "company_name" :"Wind Power LLC",
- "timezone" :"America/New_York",
- "company_abbr" :"WP",
- "industry" :"Manufacturing",
- "country" :"United States",
- "fy_start_date" :"2011-01-01",
- "fy_end_date" :"2011-12-31",
- "language" :"english",
- "company_tagline" :"Testing",
- "email" :"test@erpnext.com",
- "password" :"test",
+ "currency" :"USD",
+ "full_name" :"Test User",
+ "company_name" :"Wind Power LLC",
+ "timezone" :"America/New_York",
+ "company_abbr" :"WP",
+ "industry" :"Manufacturing",
+ "country" :"United States",
+ "fy_start_date" :"2021-01-01",
+ "fy_end_date" :"2021-12-31",
+ "language" :"english",
+ "company_tagline" :"Testing",
+ "email" :"test@erpnext.com",
+ "password" :"test",
"chart_of_accounts" : "Standard",
- "domains" : ["Manufacturing"],
+ "domains" : ["Manufacturing"],
})
frappe.db.sql("delete from `tabLeave Allocation`")
diff --git a/erpnext/shopping_cart/product_query.py b/erpnext/shopping_cart/product_query.py
index 3eab4ff..6c92d96 100644
--- a/erpnext/shopping_cart/product_query.py
+++ b/erpnext/shopping_cart/product_query.py
@@ -87,7 +87,8 @@
filters=self.filters,
or_filters=self.or_filters,
start=start,
- limit=self.page_length
+ limit=self.page_length,
+ order_by="weightage desc"
)
# Combine results having context of website item groups into item results
diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js
index 45e3c21..568f0ef 100644
--- a/erpnext/stock/doctype/item/item.js
+++ b/erpnext/stock/doctype/item/item.js
@@ -93,7 +93,7 @@
erpnext.item.edit_prices_button(frm);
erpnext.item.toggle_attributes(frm);
-
+
if (!frm.doc.is_fixed_asset) {
erpnext.item.make_dashboard(frm);
}
@@ -381,7 +381,8 @@
// Show Stock Levels only if is_stock_item
if (frm.doc.is_stock_item) {
frappe.require('item-dashboard.bundle.js', function() {
- const section = frm.dashboard.add_section('', __("Stock Levels"));
+ frm.dashboard.parent.find('.stock-levels').remove();
+ const section = frm.dashboard.add_section('', __("Stock Levels"), 'stock-levels');
erpnext.item.item_dashboard = new erpnext.stock.ItemDashboard({
parent: section,
item_code: frm.doc.name,
diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py
index 5df4d87..bf969f9 100644
--- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py
+++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py
@@ -41,7 +41,7 @@
def validate(self):
self.check_mandatory()
- self.validate_purchase_receipts()
+ self.validate_receipt_documents()
init_landed_taxes_and_totals(self)
self.set_total_taxes_and_charges()
if not self.get("items"):
@@ -56,14 +56,23 @@
frappe.throw(_("Please enter Receipt Document"))
- def validate_purchase_receipts(self):
+ def validate_receipt_documents(self):
receipt_documents = []
for d in self.get("purchase_receipts"):
- if frappe.db.get_value(d.receipt_document_type, d.receipt_document, "docstatus") != 1:
- frappe.throw(_("Receipt document must be submitted"))
- else:
- receipt_documents.append(d.receipt_document)
+ docstatus = frappe.db.get_value(d.receipt_document_type, d.receipt_document, "docstatus")
+ if docstatus != 1:
+ msg = f"Row {d.idx}: {d.receipt_document_type} {frappe.bold(d.receipt_document)} must be submitted"
+ frappe.throw(_(msg), title=_("Invalid Document"))
+
+ if d.receipt_document_type == "Purchase Invoice":
+ update_stock = frappe.db.get_value(d.receipt_document_type, d.receipt_document, "update_stock")
+ if not update_stock:
+ msg = _("Row {0}: Purchase Invoice {1} has no stock impact.").format(d.idx, frappe.bold(d.receipt_document))
+ msg += "<br>" + _("Please create Landed Cost Vouchers against Invoices that have 'Update Stock' enabled.")
+ frappe.throw(msg, title=_("Incorrect Invoice"))
+
+ receipt_documents.append(d.receipt_document)
for item in self.get("items"):
if not item.receipt_document:
diff --git a/erpnext/stock/doctype/putaway_rule/putaway_rule.py b/erpnext/stock/doctype/putaway_rule/putaway_rule.py
index ea26cac..0f50bcd 100644
--- a/erpnext/stock/doctype/putaway_rule/putaway_rule.py
+++ b/erpnext/stock/doctype/putaway_rule/putaway_rule.py
@@ -97,7 +97,7 @@
at_capacity, rules = get_ordered_putaway_rules(item_code, company, source_warehouse=source_warehouse)
if not rules:
- warehouse = source_warehouse or item.warehouse
+ warehouse = source_warehouse or item.get('warehouse')
if at_capacity:
# rules available, but no free space
items_not_accomodated.append([item_code, pending_qty])
diff --git a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py
index 7f3d701..f5d076a 100644
--- a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py
+++ b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py
@@ -14,7 +14,7 @@
)
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.item.test_item import create_item
-from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
# test_records = frappe.get_test_records('Quality Inspection')
@@ -159,6 +159,47 @@
frappe.delete_doc("Quality Inspection", qi)
dn.delete()
+ def test_rejected_qi_validation(self):
+ """Test if rejected QI blocks Stock Entry as per Stock Settings."""
+ se = make_stock_entry(
+ item_code="_Test Item with QA",
+ target="_Test Warehouse - _TC",
+ qty=1,
+ basic_rate=100,
+ inspection_required=True,
+ do_not_submit=True
+ )
+
+ readings = [
+ {
+ "specification": "Iron Content",
+ "min_value": 0.1,
+ "max_value": 0.9,
+ "reading_1": "0.4"
+ }
+ ]
+
+ qa = create_quality_inspection(
+ reference_type="Stock Entry",
+ reference_name=se.name,
+ readings=readings,
+ status="Rejected"
+ )
+
+ frappe.db.set_value("Stock Settings", None, "action_if_quality_inspection_is_rejected", "Stop")
+ se.reload()
+ self.assertRaises(QualityInspectionRejectedError, se.submit) # when blocked in Stock settings, block rejected QI
+
+ frappe.db.set_value("Stock Settings", None, "action_if_quality_inspection_is_rejected", "Warn")
+ se.reload()
+ se.submit() # when allowed in Stock settings, allow rejected QI
+
+ # teardown
+ qa.reload()
+ qa.cancel()
+ se.reload()
+ se.cancel()
+ frappe.db.set_value("Stock Settings", None, "action_if_quality_inspection_is_rejected", "Stop")
def create_quality_inspection(**args):
args = frappe._dict(args)
@@ -175,12 +216,11 @@
if not args.readings:
create_quality_inspection_parameter("Size")
readings = {"specification": "Size", "min_value": 0, "max_value": 10}
+ if args.status == "Rejected":
+ readings["reading_1"] = "12" # status is auto set in child on save
else:
readings = args.readings
- if args.status == "Rejected":
- readings["reading_1"] = "12" # status is auto set in child on save
-
if isinstance(readings, list):
for entry in readings:
create_quality_inspection_parameter(entry["specification"])
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index 8f27ef4..90b81dd 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -529,7 +529,7 @@
scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_scrap_item])
# Get raw materials cost from BOM if multiple material consumption entries
- if frappe.db.get_single_value("Manufacturing Settings", "material_consumption"):
+ if frappe.db.get_single_value("Manufacturing Settings", "material_consumption", cache=True):
bom_items = self.get_bom_raw_materials(finished_item_qty)
outgoing_items_cost = sum([flt(row.qty)*flt(row.rate) for row in bom_items.values()])
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py
index b12a854..563fcb0 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py
@@ -45,6 +45,8 @@
s.posting_date = args.posting_date
if args.posting_time:
s.posting_time = args.posting_time
+ if args.inspection_required:
+ s.inspection_required = args.inspection_required
# map names
if args.from_warehouse:
diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
index a178283..22f412a 100644
--- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
+++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
@@ -307,6 +307,7 @@
"fieldname": "quality_inspection",
"fieldtype": "Link",
"label": "Quality Inspection",
+ "no_copy": 1,
"options": "Quality Inspection"
},
{
@@ -548,7 +549,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-04-22 20:08:23.799715",
+ "modified": "2021-06-21 16:03:18.834880",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock 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 0febcb6..cb939e6 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
@@ -178,3 +178,4 @@
frappe.db.add_index("Stock Ledger Entry", ["voucher_no", "voucher_type"])
frappe.db.add_index("Stock Ledger Entry", ["batch_no", "item_code", "warehouse"])
+ frappe.db.add_index("Stock Ledger Entry", ["voucher_detail_no"])
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json
index cf5d98d..2a9dcfb 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.json
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.json
@@ -23,7 +23,10 @@
"allow_negative_stock",
"show_barcode_field",
"clean_description_html",
+ "quality_inspection_settings_section",
"action_if_quality_inspection_is_not_submitted",
+ "column_break_21",
+ "action_if_quality_inspection_is_rejected",
"section_break_7",
"automatically_set_serial_nos_based_on_fifo",
"set_qty_in_transactions_based_on_serial_no_input",
@@ -264,6 +267,22 @@
{
"fieldname": "column_break_31",
"fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "quality_inspection_settings_section",
+ "fieldtype": "Section Break",
+ "label": "Quality Inspection Settings"
+ },
+ {
+ "fieldname": "column_break_21",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "Stop",
+ "fieldname": "action_if_quality_inspection_is_rejected",
+ "fieldtype": "Select",
+ "label": "Action If Quality Inspection Is Rejected",
+ "options": "Stop\nWarn"
}
],
"icon": "icon-cog",
@@ -271,7 +290,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2021-04-30 17:27:42.709231",
+ "modified": "2021-07-10 16:17:42.159829",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 4e9c768..c15d1ed 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -6,13 +6,14 @@
import erpnext
import copy
from frappe import _
-from frappe.utils import cint, flt, cstr, now, get_link_to_form
+from frappe.utils import cint, flt, cstr, now, get_link_to_form, getdate
from frappe.model.meta import get_field_precision
from erpnext.stock.utils import get_valuation_method, get_incoming_outgoing_rate_for_cancel
from erpnext.stock.utils import get_bin
import json
from six import iteritems
+
# future reposting
class NegativeStockError(frappe.ValidationError): pass
class SerialNoExistsInFutureTransaction(frappe.ValidationError):
@@ -130,7 +131,13 @@
if not args and voucher_type and voucher_no:
args = get_args_for_voucher(voucher_type, voucher_no)
- distinct_item_warehouses = [(d.item_code, d.warehouse) for d in args]
+ distinct_item_warehouses = {}
+ for i, d in enumerate(args):
+ distinct_item_warehouses.setdefault((d.item_code, d.warehouse), frappe._dict({
+ "reposting_status": False,
+ "sle": d,
+ "args_idx": i
+ }))
i = 0
while i < len(args):
@@ -139,13 +146,21 @@
"warehouse": args[i].warehouse,
"posting_date": args[i].posting_date,
"posting_time": args[i].posting_time,
- "creation": args[i].get("creation")
+ "creation": args[i].get("creation"),
+ "distinct_item_warehouses": distinct_item_warehouses
}, allow_negative_stock=allow_negative_stock, via_landed_cost_voucher=via_landed_cost_voucher)
- for item_wh, new_sle in iteritems(obj.new_items):
- if item_wh not in distinct_item_warehouses:
- args.append(new_sle)
+ distinct_item_warehouses[(args[i].item_code, args[i].warehouse)].reposting_status = True
+ if obj.new_items_found:
+ for item_wh, data in iteritems(distinct_item_warehouses):
+ if ('args_idx' not in data and not data.reposting_status) or (data.sle_changed and data.reposting_status):
+ data.args_idx = len(args)
+ args.append(data.sle)
+ elif data.sle_changed and not data.reposting_status:
+ args[data.args_idx] = data.sle
+
+ data.sle_changed = False
i += 1
def get_args_for_voucher(voucher_type, voucher_no):
@@ -186,11 +201,12 @@
self.company = frappe.get_cached_value("Warehouse", self.args.warehouse, "company")
self.get_precision()
self.valuation_method = get_valuation_method(self.item_code)
- self.new_items = {}
+
+ self.new_items_found = False
+ self.distinct_item_warehouses = args.get("distinct_item_warehouses", frappe._dict())
self.data = frappe._dict()
self.initialize_previous_data(self.args)
-
self.build()
def get_precision(self):
@@ -296,11 +312,29 @@
elif dependant_sle.item_code == self.item_code and dependant_sle.warehouse == self.args.warehouse:
return entries_to_fix
elif dependant_sle.item_code != self.item_code:
- if (dependant_sle.item_code, dependant_sle.warehouse) not in self.new_items:
- self.new_items[(dependant_sle.item_code, dependant_sle.warehouse)] = dependant_sle
+ self.update_distinct_item_warehouses(dependant_sle)
return entries_to_fix
elif dependant_sle.item_code == self.item_code and dependant_sle.warehouse in self.data:
return entries_to_fix
+ else:
+ return self.append_future_sle_for_dependant(dependant_sle, entries_to_fix)
+
+ def update_distinct_item_warehouses(self, dependant_sle):
+ key = (dependant_sle.item_code, dependant_sle.warehouse)
+ val = frappe._dict({
+ "sle": dependant_sle
+ })
+ if key not in self.distinct_item_warehouses:
+ self.distinct_item_warehouses[key] = val
+ self.new_items_found = True
+ else:
+ existing_sle_posting_date = self.distinct_item_warehouses[key].get("sle", {}).get("posting_date")
+ if getdate(dependant_sle.posting_date) < getdate(existing_sle_posting_date):
+ val.sle_changed = True
+ self.distinct_item_warehouses[key] = val
+ self.new_items_found = True
+
+ def append_future_sle_for_dependant(self, dependant_sle, entries_to_fix):
self.initialize_previous_data(dependant_sle)
args = self.data[dependant_sle.warehouse].previous_sle \
@@ -393,6 +427,7 @@
rate = 0
# Material Transfer, Repack, Manufacturing
if sle.voucher_type == "Stock Entry":
+ self.recalculate_amounts_in_stock_entry(sle.voucher_no)
rate = frappe.db.get_value("Stock Entry Detail", sle.voucher_detail_no, "valuation_rate")
# Sales and Purchase Return
elif sle.voucher_type in ("Purchase Receipt", "Purchase Invoice", "Delivery Note", "Sales Invoice"):
@@ -442,7 +477,11 @@
frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate)
# Update outgoing item's rate, recalculate FG Item's rate and total incoming/outgoing amount
- stock_entry = frappe.get_doc("Stock Entry", sle.voucher_no, for_update=True)
+ if not sle.dependant_sle_voucher_detail_no:
+ self.recalculate_amounts_in_stock_entry(sle.voucher_no)
+
+ def recalculate_amounts_in_stock_entry(self, voucher_no):
+ stock_entry = frappe.get_doc("Stock Entry", voucher_no, for_update=True)
stock_entry.calculate_rate_and_amount(reset_outgoing_rate=False, raise_error_if_no_rate=False)
stock_entry.db_update()
for d in stock_entry.items:
diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py
index dd6d647..b9a65b6 100644
--- a/erpnext/support/doctype/issue/issue.py
+++ b/erpnext/support/doctype/issue/issue.py
@@ -5,10 +5,10 @@
import frappe
import json
from frappe import _
-from frappe import utils
from frappe.model.document import Document
-from frappe.utils import now_datetime
-from datetime import datetime, timedelta
+from frappe.utils import now_datetime, time_diff_in_seconds, get_datetime, date_diff
+from frappe.core.utils import get_parent_doc
+from datetime import timedelta
from frappe.model.mapper import get_mapped_doc
from frappe.utils.user import is_website_user
from frappe.email.inbox import link_communication_to_document
@@ -212,7 +212,129 @@
return issue.name
+def get_time_in_timedelta(time):
+ """
+ Converts datetime.time(10, 36, 55, 961454) to datetime.timedelta(seconds=38215)
+ """
+ return timedelta(hours=time.hour, minutes=time.minute, seconds=time.second)
+
+def set_first_response_time(communication, method):
+ if communication.get('reference_doctype') == "Issue":
+ issue = get_parent_doc(communication)
+ if is_first_response(issue):
+ first_response_time = calculate_first_response_time(issue, get_datetime(issue.first_responded_on))
+ issue.db_set("first_response_time", first_response_time)
+
+def is_first_response(issue):
+ responses = frappe.get_all('Communication', filters = {'reference_name': issue.name, 'sent_or_received': 'Sent'})
+ if len(responses) == 1:
+ return True
+ return False
+
+def calculate_first_response_time(issue, first_responded_on):
+ issue_creation_date = issue.creation
+ issue_creation_time = get_time_in_seconds(issue_creation_date)
+ first_responded_on_in_seconds = get_time_in_seconds(first_responded_on)
+ support_hours = frappe.get_cached_doc("Service Level Agreement", issue.service_level_agreement).support_and_resolution
+
+ if issue_creation_date.day == first_responded_on.day:
+ if is_work_day(issue_creation_date, support_hours):
+ start_time, end_time = get_working_hours(issue_creation_date, support_hours)
+
+ # issue creation and response on the same day during working hours
+ if is_during_working_hours(issue_creation_date, support_hours) and is_during_working_hours(first_responded_on, support_hours):
+ return get_elapsed_time(issue_creation_date, first_responded_on)
+
+ # issue creation is during working hours, but first response was after working hours
+ elif is_during_working_hours(issue_creation_date, support_hours):
+ return get_elapsed_time(issue_creation_time, end_time)
+
+ # issue creation was before working hours but first response is during working hours
+ elif is_during_working_hours(first_responded_on, support_hours):
+ return get_elapsed_time(start_time, first_responded_on_in_seconds)
+
+ # both issue creation and first response were after working hours
+ else:
+ return 1.0 # this should ideally be zero, but it gets reset when the next response is sent if the value is zero
+
+ else:
+ return 1.0
+
+ else:
+ # response on the next day
+ if date_diff(first_responded_on, issue_creation_date) == 1:
+ first_response_time = 0
+ else:
+ first_response_time = calculate_initial_frt(issue_creation_date, date_diff(first_responded_on, issue_creation_date)- 1, support_hours)
+
+ # time taken on day of issue creation
+ if is_work_day(issue_creation_date, support_hours):
+ start_time, end_time = get_working_hours(issue_creation_date, support_hours)
+
+ if is_during_working_hours(issue_creation_date, support_hours):
+ first_response_time += get_elapsed_time(issue_creation_time, end_time)
+ elif is_before_working_hours(issue_creation_date, support_hours):
+ first_response_time += get_elapsed_time(start_time, end_time)
+
+ # time taken on day of first response
+ if is_work_day(first_responded_on, support_hours):
+ start_time, end_time = get_working_hours(first_responded_on, support_hours)
+
+ if is_during_working_hours(first_responded_on, support_hours):
+ first_response_time += get_elapsed_time(start_time, first_responded_on_in_seconds)
+ elif not is_before_working_hours(first_responded_on, support_hours):
+ first_response_time += get_elapsed_time(start_time, end_time)
+
+ if first_response_time:
+ return first_response_time
+ else:
+ return 1.0
+
+def get_time_in_seconds(date):
+ return timedelta(hours=date.hour, minutes=date.minute, seconds=date.second)
+
+def get_working_hours(date, support_hours):
+ if is_work_day(date, support_hours):
+ weekday = frappe.utils.get_weekday(date)
+ for day in support_hours:
+ if day.workday == weekday:
+ return day.start_time, day.end_time
+
+def is_work_day(date, support_hours):
+ weekday = frappe.utils.get_weekday(date)
+ for day in support_hours:
+ if day.workday == weekday:
+ return True
+ return False
+
+def is_during_working_hours(date, support_hours):
+ start_time, end_time = get_working_hours(date, support_hours)
+ time = get_time_in_seconds(date)
+ if time >= start_time and time <= end_time:
+ return True
+ return False
+
+def get_elapsed_time(start_time, end_time):
+ return round(time_diff_in_seconds(end_time, start_time), 2)
+
+def calculate_initial_frt(issue_creation_date, days_in_between, support_hours):
+ initial_frt = 0
+ for i in range(days_in_between):
+ date = issue_creation_date + timedelta(days = (i+1))
+ if is_work_day(date, support_hours):
+ start_time, end_time = get_working_hours(date, support_hours)
+ initial_frt += get_elapsed_time(start_time, end_time)
+
+ return initial_frt
+
+def is_before_working_hours(date, support_hours):
+ start_time, end_time = get_working_hours(date, support_hours)
+ time = get_time_in_seconds(date)
+ if time < start_time:
+ return True
+ return False
+
def get_holidays(holiday_list_name):
holiday_list = frappe.get_cached_doc("Holiday List", holiday_list_name)
holidays = [holiday.holiday_date for holiday in holiday_list.holidays]
- return holidays
\ No newline at end of file
+ return holidays
diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py
index 7b9b144..84f8c39 100644
--- a/erpnext/support/doctype/issue/test_issue.py
+++ b/erpnext/support/doctype/issue/test_issue.py
@@ -5,16 +5,18 @@
import frappe
import unittest
from erpnext.support.doctype.service_level_agreement.test_service_level_agreement import create_service_level_agreements_for_issues
-from frappe.utils import now_datetime, get_datetime, flt
+from frappe.core.doctype.user_permission.test_user_permission import create_user
+from frappe.utils import get_datetime, flt
import datetime
from datetime import timedelta
-class TestIssue(unittest.TestCase):
+class TestSetUp(unittest.TestCase):
def setUp(self):
frappe.db.sql("delete from `tabService Level Agreement`")
frappe.db.set_value("Support Settings", None, "track_service_level_agreement", 1)
create_service_level_agreements_for_issues()
+class TestIssue(TestSetUp):
def test_response_time_and_resolution_time_based_on_different_sla(self):
creation = datetime.datetime(2019, 3, 4, 12, 0)
@@ -133,6 +135,223 @@
issue.reload()
self.assertEqual(flt(issue.total_hold_time, 2), 2700)
+class TestFirstResponseTime(TestSetUp):
+ # working hours used in all cases: Mon-Fri, 10am to 6pm
+ # all dates are in the mm-dd-yyyy format
+
+ # issue creation and first response are on the same day
+ def test_first_response_time_case1(self):
+ """
+ Test frt when issue creation and first response are during working hours on the same day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 11:00"), get_datetime("06-28-2021 12:00"))
+ self.assertEqual(issue.first_response_time, 3600.0)
+
+ def test_first_response_time_case2(self):
+ """
+ Test frt when issue creation was during working hours, but first response is sent after working hours on the same day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("06-28-2021 20:00"))
+ self.assertEqual(issue.first_response_time, 21600.0)
+
+ def test_first_response_time_case3(self):
+ """
+ Test frt when issue creation was before working hours but first response is sent during working hours on the same day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("06-28-2021 12:00"))
+ self.assertEqual(issue.first_response_time, 7200.0)
+
+ def test_first_response_time_case4(self):
+ """
+ Test frt when both issue creation and first response were after working hours on the same day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 19:00"), get_datetime("06-28-2021 20:00"))
+ self.assertEqual(issue.first_response_time, 1.0)
+
+ def test_first_response_time_case5(self):
+ """
+ Test frt when both issue creation and first response are on the same day, but it's not a work day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-27-2021 10:00"), get_datetime("06-27-2021 11:00"))
+ self.assertEqual(issue.first_response_time, 1.0)
+
+ # issue creation and first response are on consecutive days
+ def test_first_response_time_case6(self):
+ """
+ Test frt when the issue was created before working hours and the first response is also sent before working hours, but on the next day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("06-29-2021 6:00"))
+ self.assertEqual(issue.first_response_time, 28800.0)
+
+ def test_first_response_time_case7(self):
+ """
+ Test frt when the issue was created before working hours and the first response is sent during working hours, but on the next day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("06-29-2021 11:00"))
+ self.assertEqual(issue.first_response_time, 32400.0)
+
+ def test_first_response_time_case8(self):
+ """
+ Test frt when the issue was created before working hours and the first response is sent after working hours, but on the next day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("06-29-2021 20:00"))
+ self.assertEqual(issue.first_response_time, 57600.0)
+
+ def test_first_response_time_case9(self):
+ """
+ Test frt when the issue was created before working hours and the first response is sent on the next day, which is not a work day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-25-2021 6:00"), get_datetime("06-26-2021 11:00"))
+ self.assertEqual(issue.first_response_time, 28800.0)
+
+ def test_first_response_time_case10(self):
+ """
+ Test frt when the issue was created during working hours and the first response is sent before working hours, but on the next day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("06-29-2021 6:00"))
+ self.assertEqual(issue.first_response_time, 21600.0)
+
+ def test_first_response_time_case11(self):
+ """
+ Test frt when the issue was created during working hours and the first response is also sent during working hours, but on the next day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("06-29-2021 11:00"))
+ self.assertEqual(issue.first_response_time, 25200.0)
+
+ def test_first_response_time_case12(self):
+ """
+ Test frt when the issue was created during working hours and the first response is sent after working hours, but on the next day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("06-29-2021 20:00"))
+ self.assertEqual(issue.first_response_time, 50400.0)
+
+ def test_first_response_time_case13(self):
+ """
+ Test frt when the issue was created during working hours and the first response is sent on the next day, which is not a work day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-25-2021 12:00"), get_datetime("06-26-2021 11:00"))
+ self.assertEqual(issue.first_response_time, 21600.0)
+
+ def test_first_response_time_case14(self):
+ """
+ Test frt when the issue was created after working hours and the first response is sent before working hours, but on the next day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("06-29-2021 6:00"))
+ self.assertEqual(issue.first_response_time, 1.0)
+
+ def test_first_response_time_case15(self):
+ """
+ Test frt when the issue was created after working hours and the first response is sent during working hours, but on the next day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("06-29-2021 11:00"))
+ self.assertEqual(issue.first_response_time, 3600.0)
+
+ def test_first_response_time_case16(self):
+ """
+ Test frt when the issue was created after working hours and the first response is also sent after working hours, but on the next day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("06-29-2021 20:00"))
+ self.assertEqual(issue.first_response_time, 28800.0)
+
+ def test_first_response_time_case17(self):
+ """
+ Test frt when the issue was created after working hours and the first response is sent on the next day, which is not a work day.
+ """
+ issue = create_issue_and_communication(get_datetime("06-25-2021 20:00"), get_datetime("06-26-2021 11:00"))
+ self.assertEqual(issue.first_response_time, 1.0)
+
+ # issue creation and first response are a few days apart
+ def test_first_response_time_case18(self):
+ """
+ Test frt when the issue was created before working hours and the first response is also sent before working hours, but after a few days.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("07-01-2021 6:00"))
+ self.assertEqual(issue.first_response_time, 86400.0)
+
+ def test_first_response_time_case19(self):
+ """
+ Test frt when the issue was created before working hours and the first response is sent during working hours, but after a few days.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("07-01-2021 11:00"))
+ self.assertEqual(issue.first_response_time, 90000.0)
+
+ def test_first_response_time_case20(self):
+ """
+ Test frt when the issue was created before working hours and the first response is sent after working hours, but after a few days.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("07-01-2021 20:00"))
+ self.assertEqual(issue.first_response_time, 115200.0)
+
+ def test_first_response_time_case21(self):
+ """
+ Test frt when the issue was created before working hours and the first response is sent after a few days, on a holiday.
+ """
+ issue = create_issue_and_communication(get_datetime("06-25-2021 6:00"), get_datetime("06-27-2021 11:00"))
+ self.assertEqual(issue.first_response_time, 28800.0)
+
+ def test_first_response_time_case22(self):
+ """
+ Test frt when the issue was created during working hours and the first response is sent before working hours, but after a few days.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("07-01-2021 6:00"))
+ self.assertEqual(issue.first_response_time, 79200.0)
+
+ def test_first_response_time_case23(self):
+ """
+ Test frt when the issue was created during working hours and the first response is also sent during working hours, but after a few days.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("07-01-2021 11:00"))
+ self.assertEqual(issue.first_response_time, 82800.0)
+
+ def test_first_response_time_case24(self):
+ """
+ Test frt when the issue was created during working hours and the first response is sent after working hours, but after a few days.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("07-01-2021 20:00"))
+ self.assertEqual(issue.first_response_time, 108000.0)
+
+ def test_first_response_time_case25(self):
+ """
+ Test frt when the issue was created during working hours and the first response is sent after a few days, on a holiday.
+ """
+ issue = create_issue_and_communication(get_datetime("06-25-2021 12:00"), get_datetime("06-27-2021 11:00"))
+ self.assertEqual(issue.first_response_time, 21600.0)
+
+ def test_first_response_time_case26(self):
+ """
+ Test frt when the issue was created after working hours and the first response is sent before working hours, but after a few days.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("07-01-2021 6:00"))
+ self.assertEqual(issue.first_response_time, 57600.0)
+
+ def test_first_response_time_case27(self):
+ """
+ Test frt when the issue was created after working hours and the first response is sent during working hours, but after a few days.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("07-01-2021 11:00"))
+ self.assertEqual(issue.first_response_time, 61200.0)
+
+ def test_first_response_time_case28(self):
+ """
+ Test frt when the issue was created after working hours and the first response is also sent after working hours, but after a few days.
+ """
+ issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("07-01-2021 20:00"))
+ self.assertEqual(issue.first_response_time, 86400.0)
+
+ def test_first_response_time_case29(self):
+ """
+ Test frt when the issue was created after working hours and the first response is sent after a few days, on a holiday.
+ """
+ issue = create_issue_and_communication(get_datetime("06-25-2021 20:00"), get_datetime("06-27-2021 11:00"))
+ self.assertEqual(issue.first_response_time, 1.0)
+
+def create_issue_and_communication(issue_creation, first_responded_on):
+ issue = make_issue(issue_creation, index=1)
+ sender = create_user("test@admin.com")
+ 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):
issue = frappe.get_doc({
@@ -185,7 +404,7 @@
def create_communication(reference_name, sender, sent_or_received, creation):
- issue = frappe.get_doc({
+ communication = frappe.get_doc({
"doctype": "Communication",
"communication_type": "Communication",
"communication_medium": "Email",
@@ -199,4 +418,4 @@
"creation": creation,
"reference_name": reference_name
})
- issue.save()
+ communication.save()
\ No newline at end of file
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 308bce4..ae2080c 100644
--- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.js
+++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.js
@@ -5,15 +5,15 @@
setup: function(frm) {
if (cint(frm.doc.apply_sla_for_resolution) === 1) {
frm.get_field('priorities').grid.editable_fields = [
- {fieldname: 'priority', columns: 1},
{fieldname: 'default_priority', columns: 1},
+ {fieldname: 'priority', columns: 2},
{fieldname: 'response_time', columns: 2},
{fieldname: 'resolution_time', columns: 2}
];
} else {
frm.get_field('priorities').grid.editable_fields = [
- {fieldname: 'priority', columns: 1},
{fieldname: 'default_priority', columns: 1},
+ {fieldname: 'priority', columns: 2},
{fieldname: 'response_time', columns: 3},
];
}
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 de3389a..ef14b29 100644
--- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.json
+++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.json
@@ -1,6 +1,6 @@
{
"actions": [],
- "autoname": "format:SLA-{document_type}-{service_level}-{####}",
+ "autoname": "format:SLA-{document_type}-{service_level}",
"creation": "2018-12-26 21:08:15.448812",
"doctype": "DocType",
"editable_grid": 1,
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 60e5fbe..8739cb2 100644
--- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py
+++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py
@@ -797,7 +797,7 @@
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"):
+ 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)
@@ -805,7 +805,7 @@
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"):
+ 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)
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 7c18a65..7bc97d6 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
@@ -217,6 +217,42 @@
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 tearDown(self):
for d in frappe.get_all("Service Level Agreement"):
frappe.delete_doc("Service Level Agreement", d.name, force=1)
@@ -249,7 +285,7 @@
"doctype": "Service Level Agreement",
"enabled": 1,
"document_type": doctype,
- "service_level": "__Test Service Level",
+ "service_level": "__Test {} SLA".format(entity_type if entity_type else "Default"),
"default_service_level_agreement": default_service_level_agreement,
"default_priority": "Medium",
"holiday_list": holiday_list,
@@ -303,16 +339,6 @@
"workday": "Friday",
"start_time": "10:00:00",
"end_time": "18:00:00",
- },
- {
- "workday": "Saturday",
- "start_time": "10:00:00",
- "end_time": "18:00:00",
- },
- {
- "workday": "Sunday",
- "start_time": "10:00:00",
- "end_time": "18:00:00",
}
]
})
diff --git a/erpnext/support/doctype/service_level_priority/service_level_priority.json b/erpnext/support/doctype/service_level_priority/service_level_priority.json
index 0367fc6..b410fe6 100644
--- a/erpnext/support/doctype/service_level_priority/service_level_priority.json
+++ b/erpnext/support/doctype/service_level_priority/service_level_priority.json
@@ -5,9 +5,9 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
- "priority",
- "cb_01",
"default_priority",
+ "cb_01",
+ "priority",
"sb_00",
"response_time",
"cb_00",
@@ -15,7 +15,7 @@
],
"fields": [
{
- "columns": 1,
+ "columns": 2,
"fieldname": "priority",
"fieldtype": "Link",
"in_list_view": 1,
@@ -64,7 +64,7 @@
],
"istable": 1,
"links": [],
- "modified": "2021-05-29 19:52:51.733248",
+ "modified": "2021-06-21 12:00:58.089962",
"modified_by": "Administrator",
"module": "Support",
"name": "Service Level Priority",