Ability to hold payment for disputed invoices #12048 (#13298)
* add new fields to Supplier Master:
- on_hold: To signal the Customer is blocked from completing certain transactions
- hold_type: 3 options - All, invoices and payments
* sanitize `on_hold` field input
* show hold status in list view
* add `release_date` field to Supplier Master:
- specifies the date when transaction restraint will be removed
* reset release date if supplier is not on hold
* add validation to stop transactions when Supplier is blocked
* add test cases
* return empty list for outstanding references if supplier is blocked
* block make button:payment if supplier is blocked
* adjust test cases
* PEP 8 clean up
* more tests
* adds new fields to Purchase Invoice:
- release_date: once set, invoice will be on hold until set date
- hold_comment: so user can add comment pertaining to why invoice is on hold
* implement individual purchase invoice on hold logic
* allow user to change release date
* update manual
* final cleanup including more validation and tests
* update supplier manual
* make default for release_date argument todays date
* remove Auto Repeat added by mistake
* add on_hold_field to purchase invoice
* add 'On Hold' or 'Temporarily on Hold' status for purchase invoice in list view
* implement explicit payment hold in purchase invoice
* update manual
* add dialog for saving comment
* bug fix, refactor, clean up
* more test cases
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index 96b997f..8539c36 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -5,14 +5,14 @@
from __future__ import unicode_literals
import frappe, erpnext, json
from frappe import _, scrub, ValidationError
-from frappe.utils import flt, comma_or, nowdate
+from frappe.utils import flt, comma_or, nowdate, getdate
from erpnext.accounts.utils import get_outstanding_invoices, get_account_currency, get_balance_on
from erpnext.accounts.party import get_party_account, get_patry_tax_withholding_details
from erpnext.accounts.doctype.journal_entry.journal_entry import get_default_bank_cash_account
from erpnext.setup.utils import get_exchange_rate
from erpnext.accounts.general_ledger import make_gl_entries
from erpnext.hr.doctype.expense_claim.expense_claim import update_reimbursed_amount
-from erpnext.controllers.accounts_controller import AccountsController
+from erpnext.controllers.accounts_controller import AccountsController, get_supplier_block_status
from six import string_types
@@ -59,6 +59,7 @@
self.set_remarks()
self.validate_duplicate_entry()
self.validate_allocated_amount()
+ self.ensure_supplier_is_not_blocked()
def on_submit(self):
self.setup_party_account_field()
@@ -537,6 +538,16 @@
if isinstance(args, string_types):
args = json.loads(args)
+ # confirm that Supplier is not blocked
+ if args.get('party_type') == 'Supplier':
+ supplier_status = get_supplier_block_status(args['party'])
+ if supplier_status['on_hold']:
+ if supplier_status['hold_type'] == 'All':
+ return []
+ elif supplier_status['hold_type'] == 'Payments':
+ if not supplier_status['release_date'] or getdate(nowdate()) <= supplier_status['release_date']:
+ return []
+
party_account_currency = get_account_currency(args.get("party_account"))
company_currency = frappe.db.get_value("Company", args.get("company"), "default_currency")
@@ -621,6 +632,9 @@
def get_negative_outstanding_invoices(party_type, party, party_account, party_account_currency, company_currency):
voucher_type = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
+ supplier_condition = ""
+ if voucher_type == "Purchase Invoice":
+ supplier_condition = "and (release_date is null or release_date <= CURDATE())"
if party_account_currency == company_currency:
grand_total_field = "base_grand_total"
rounded_total_field = "base_rounded_total"
@@ -638,9 +652,11 @@
`tab{voucher_type}`
where
{party_type} = %s and {party_account} = %s and docstatus = 1 and outstanding_amount < 0
+ {supplier_condition}
order by
posting_date, name
""".format(**{
+ "supplier_condition": supplier_condition,
"rounded_total_field": rounded_total_field,
"grand_total_field": grand_total_field,
"voucher_type": voucher_type,
@@ -854,6 +870,9 @@
pe.mode_of_payment = doc.get("mode_of_payment")
pe.party_type = party_type
pe.party = doc.get(scrub(party_type))
+
+ pe.ensure_supplier_is_not_blocked()
+
pe.paid_from = party_account if payment_type=="Receive" else bank.account
pe.paid_to = party_account if payment_type=="Pay" else bank.account
pe.paid_from_account_currency = party_account_currency \
@@ -864,15 +883,19 @@
pe.allocate_payment_amount = 1
pe.letter_head = doc.get("letter_head")
- pe.append("references", {
- 'reference_doctype': dt,
- 'reference_name': dn,
- "bill_no": doc.get("bill_no"),
- "due_date": doc.get("due_date"),
- 'total_amount': grand_total,
- 'outstanding_amount': outstanding_amount,
- 'allocated_amount': outstanding_amount
- })
+ # only Purchase Invoice can be blocked individually
+ if doc.doctype == "Purchase Invoice" and doc.invoice_is_blocked():
+ frappe.msgprint(_('{0} is on hold till {1}'.format(doc.name, doc.release_date)))
+ else:
+ pe.append("references", {
+ 'reference_doctype': dt,
+ 'reference_name': dn,
+ "bill_no": doc.get("bill_no"),
+ "due_date": doc.get("due_date"),
+ 'total_amount': grand_total,
+ 'outstanding_amount': outstanding_amount,
+ 'allocated_amount': outstanding_amount
+ })
pe.setup_party_account_field()
pe.set_missing_values()
diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
index 435bee0..57516a1 100644
--- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
@@ -40,6 +40,69 @@
so_advance_paid = frappe.db.get_value("Sales Order", so.name, "advance_paid")
self.assertEqual(so_advance_paid, 0)
+ def test_payment_entry_for_blocked_supplier_invoice(self):
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.on_hold = 1
+ supplier.hold_type = 'Invoices'
+ supplier.save()
+
+ self.assertRaises(frappe.ValidationError, make_purchase_invoice)
+
+ supplier.on_hold = 0
+ supplier.save()
+
+ def test_payment_entry_for_blocked_supplier_payments(self):
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.on_hold = 1
+ supplier.hold_type = 'Payments'
+ supplier.save()
+
+ pi = make_purchase_invoice()
+
+ self.assertRaises(
+ frappe.ValidationError, get_payment_entry, dt='Purchase Invoice', dn=pi.name,
+ bank_account="_Test Bank - _TC")
+
+ supplier.on_hold = 0
+ supplier.save()
+
+ def test_payment_entry_for_blocked_supplier_payments_today_date(self):
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.on_hold = 1
+ supplier.hold_type = 'Payments'
+ supplier.release_date = nowdate()
+ supplier.save()
+
+ pi = make_purchase_invoice()
+
+ self.assertRaises(
+ frappe.ValidationError, get_payment_entry, dt='Purchase Invoice', dn=pi.name,
+ bank_account="_Test Bank - _TC")
+
+ supplier.on_hold = 0
+ supplier.save()
+
+ def test_payment_entry_for_blocked_supplier_payments_past_date(self):
+ # this test is meant to fail only if something fails in the try block
+ with self.assertRaises(Exception):
+ try:
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.on_hold = 1
+ supplier.hold_type = 'Payments'
+ supplier.release_date = '2018-03-01'
+ supplier.save()
+
+ pi = make_purchase_invoice()
+
+ get_payment_entry('Purchase Invoice', pi.name, bank_account="_Test Bank - _TC")
+
+ supplier.on_hold = 0
+ supplier.save()
+ except:
+ pass
+ else:
+ raise Exception
+
def test_payment_entry_against_si_usd_to_usd(self):
si = create_sales_invoice(customer="_Test Customer USD", debit_to="_Test Receivable USD - _TC",
currency="USD", conversion_rate=50)
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
index d2c4193..c1a2c97 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
@@ -27,6 +27,7 @@
},
refresh: function(doc) {
+ const me = this;
this._super();
hide_fields(this.frm.doc);
@@ -37,6 +38,27 @@
this.show_stock_ledger();
}
+ if(!doc.is_return && doc.docstatus == 1 && doc.outstanding_amount != 0){
+ if(doc.on_hold) {
+ this.frm.add_custom_button(
+ __('Change Release Date'),
+ function() {me.change_release_date()},
+ __('Hold Invoice')
+ );
+ this.frm.add_custom_button(
+ __('Unblock Invoice'),
+ function() {me.unblock_invoice()},
+ __('Make')
+ );
+ } else if (!doc.on_hold) {
+ this.frm.add_custom_button(
+ __('Block Invoice'),
+ function() {me.block_invoice()},
+ __('Make')
+ );
+ }
+ }
+
if(!doc.is_return && doc.docstatus==1) {
if(doc.outstanding_amount != 0) {
this.frm.add_custom_button(__('Payment'), this.make_payment_entry, __("Make"));
@@ -56,7 +78,6 @@
}
if(doc.docstatus===0) {
- var me = this;
this.frm.add_custom_button(__('Purchase Order'), function() {
erpnext.utils.map_current_doc({
method: "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice",
@@ -109,6 +130,104 @@
}
},
+ unblock_invoice: function() {
+ const me = this;
+ frappe.call({
+ 'method': 'erpnext.accounts.doctype.purchase_invoice.purchase_invoice.unblock_invoice',
+ 'args': {'name': me.frm.doc.name},
+ 'callback': (r) => me.frm.reload_doc()
+ });
+ },
+
+ block_invoice: function() {
+ this.make_comment_dialog_and_block_invoice();
+ },
+
+ change_release_date: function() {
+ this.make_dialog_and_set_release_date();
+ },
+
+ can_change_release_date: function(date) {
+ const diff = frappe.datetime.get_diff(date, frappe.datetime.nowdate());
+ if (diff < 0) {
+ frappe.throw('New release date should be in the future');
+ return false;
+ } else {
+ return true;
+ }
+ },
+
+ make_comment_dialog_and_block_invoice: function(){
+ const me = this;
+
+ const title = __('Add Comment');
+ const fields = [
+ {
+ fieldname: 'hold_comment',
+ read_only: 0,
+ fieldtype:'Small Text',
+ label: __('Reason For Putting On Hold'),
+ default: ""
+ },
+ ];
+
+ this.dialog = new frappe.ui.Dialog({
+ title: title,
+ fields: fields
+ });
+
+ this.dialog.set_primary_action(__('Save'), function() {
+ const dialog_data = me.dialog.get_values();
+ frappe.call({
+ 'method': 'erpnext.accounts.doctype.purchase_invoice.purchase_invoice.block_invoice',
+ 'args': {'name': me.frm.doc.name, 'hold_comment': dialog_data.hold_comment},
+ 'callback': (r) => me.frm.reload_doc()
+ });
+ me.dialog.hide();
+ });
+
+ this.dialog.show();
+ },
+
+ make_dialog_and_set_release_date: function() {
+ const me = this;
+
+ const title = __('Set New Release Date');
+ const fields = [
+ {
+ fieldname: 'release_date',
+ read_only: 0,
+ fieldtype:'Date',
+ label: __('Release Date'),
+ default: me.frm.doc.release_date
+ },
+ ];
+
+ this.dialog = new frappe.ui.Dialog({
+ title: title,
+ fields: fields
+ });
+
+ this.dialog.set_primary_action(__('Save'), function() {
+ me.dialog_data = me.dialog.get_values();
+ if(me.can_change_release_date(me.dialog_data.release_date)) {
+ me.dialog_data.name = me.frm.doc.name;
+ me.set_release_date(me.dialog_data);
+ me.dialog.hide();
+ }
+ });
+
+ this.dialog.show();
+ },
+
+ set_release_date: function(data) {
+ return frappe.call({
+ 'method': 'erpnext.accounts.doctype.purchase_invoice.purchase_invoice.change_release_date',
+ 'args': data,
+ 'callback': (r) => this.frm.reload_doc()
+ });
+ },
+
supplier: function() {
var me = this;
if(this.frm.updating_party_details)
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
index 8acdf5c..a8fa9f7 100755
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
@@ -437,6 +437,165 @@
"allow_on_submit": 0,
"bold": 0,
"collapsible": 1,
+ "collapsible_depends_on": "eval:doc.on_hold",
+ "columns": 0,
+ "fieldname": "sb_14",
+ "fieldtype": "Section Break",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Hold Invoice",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "default": "0",
+ "fieldname": "on_hold",
+ "fieldtype": "Check",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Hold Invoice",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "depends_on": "eval:doc.on_hold",
+ "description": "Once set, this invoice will be on hold till the set date",
+ "fieldname": "release_date",
+ "fieldtype": "Date",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Release Date",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "cb_17",
+ "fieldtype": "Column Break",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "depends_on": "eval:doc.on_hold",
+ "fieldname": "hold_comment",
+ "fieldtype": "Small Text",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Reason For Putting On Hold",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 1,
"collapsible_depends_on": "bill_no",
"columns": 0,
"fieldname": "supplier_invoice_details",
@@ -4072,7 +4231,7 @@
"istable": 0,
"max_attachments": 0,
"menu_index": 0,
- "modified": "2018-04-19 15:48:29.457594",
+ "modified": "2018-04-19 15:48:29.457594",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index f6103cc..9599d1f 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -3,7 +3,7 @@
from __future__ import unicode_literals
import frappe, erpnext
-from frappe.utils import cint, formatdate, flt, getdate
+from frappe.utils import cint, cstr, formatdate, flt, getdate, nowdate
from frappe import _, throw
import frappe.defaults
@@ -41,6 +41,13 @@
'overflow_type': 'billing'
}]
+ def before_save(self):
+ if not self.on_hold:
+ self.release_date = ''
+
+ def invoice_is_blocked(self):
+ return self.on_hold and (not self.release_date or self.release_date > getdate(nowdate()))
+
def validate(self):
if not self.is_opening:
self.is_opening = 'No'
@@ -61,6 +68,7 @@
if self._action=="submit" and self.update_stock:
self.make_batches('warehouse')
+ self.validate_release_date()
self.check_conversion_rate()
self.validate_credit_to_acc()
self.clear_unallocated_advances("Purchase Invoice Advance", "advances")
@@ -78,6 +86,10 @@
self.set_status()
validate_inter_company_party(self.doctype, self.supplier, self.company, self.inter_company_invoice_reference)
+ def validate_release_date(self):
+ if self.release_date and getdate(nowdate()) >= getdate(self.release_date):
+ frappe.msgprint('Release date must be in the future', raise_exception=True)
+
def validate_cash(self):
if not self.cash_bank_account and flt(self.paid_amount):
frappe.throw(_("Cash or Bank Account is mandatory for making payment entry"))
@@ -730,7 +742,15 @@
def on_recurring(self, reference_doc, auto_repeat_doc):
self.due_date = None
- def set_tax_withholding(self):
+ def block_invoice(self, hold_comment=None):
+ self.db_set('on_hold', 1)
+ self.db_set('hold_comment', cstr(hold_comment))
+
+ def unblock_invoice(self):
+ self.db_set('on_hold', 0)
+ self.db_set('release_date', None)
+
+ def set_tax_withholding(self):
"""
1. Get TDS Configurations against Supplier
"""
@@ -769,6 +789,27 @@
return doc
@frappe.whitelist()
+def change_release_date(name, release_date=None):
+ if frappe.db.exists('Purchase Invoice', name):
+ pi = frappe.get_doc('Purchase Invoice', name)
+ pi.db_set('release_date', release_date)
+
+
+@frappe.whitelist()
+def unblock_invoice(name):
+ if frappe.db.exists('Purchase Invoice', name):
+ pi = frappe.get_doc('Purchase Invoice', name)
+ pi.unblock_invoice()
+
+
+@frappe.whitelist()
+def block_invoice(name, hold_comment):
+ if frappe.db.exists('Purchase Invoice', name):
+ pi = frappe.get_doc('Purchase Invoice', name)
+ pi.block_invoice(hold_comment)
+
+@frappe.whitelist()
def make_inter_company_sales_invoice(source_name, target_doc=None):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_invoice
- return make_inter_company_invoice("Purchase Invoice", source_name, target_doc)
\ No newline at end of file
+ return make_inter_company_invoice("Purchase Invoice", source_name, target_doc)
+
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js
index 8283acc..4103e57 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js
@@ -4,12 +4,16 @@
// render
frappe.listview_settings['Purchase Invoice'] = {
add_fields: ["supplier", "supplier_name", "base_grand_total", "outstanding_amount", "due_date", "company",
- "currency", "is_return"],
+ "currency", "is_return", "release_date", "on_hold"],
get_indicator: function(doc) {
if(cint(doc.is_return)==1) {
return [__("Return"), "darkgrey", "is_return,=,Yes"];
} else if(flt(doc.outstanding_amount) > 0 && doc.docstatus==1) {
- if(frappe.datetime.get_diff(doc.due_date) < 0) {
+ if(cint(doc.on_hold) && !doc.release_date) {
+ return [__("On Hold"), "darkgrey"];
+ } else if(cint(doc.on_hold) && doc.release_date && frappe.datetime.get_diff(doc.release_date, frappe.datetime.nowdate()) > 0) {
+ return [__("Temporarily on Hold"), "darkgrey"];
+ } else if(frappe.datetime.get_diff(doc.due_date) < 0) {
return [__("Overdue"), "red", "outstanding_amount,>,0|due_date,<,Today"];
} else {
return [__("Unpaid"), "orange", "outstanding_amount,>,0|due,>=,Today"];
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index a99a86a..339d275 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -6,6 +6,7 @@
import unittest
import frappe, erpnext
import frappe.model
+from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from frappe.utils import cint, flt, today, nowdate, add_days
import frappe.defaults
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import set_perpetual_inventory, \
@@ -91,6 +92,106 @@
self.assertRaises(frappe.LinkExistsError, pi_doc.cancel)
+ def test_purchase_invoice_for_blocked_supplier(self):
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.on_hold = 1
+ supplier.save()
+
+ self.assertRaises(frappe.ValidationError, make_purchase_invoice)
+
+ supplier.on_hold = 0
+ supplier.save()
+
+ def test_purchase_invoice_for_blocked_supplier_invoice(self):
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.on_hold = 1
+ supplier.hold_type = 'Invoices'
+ supplier.save()
+
+ self.assertRaises(frappe.ValidationError, make_purchase_invoice)
+
+ supplier.on_hold = 0
+ supplier.save()
+
+ def test_purchase_invoice_for_blocked_supplier_payment(self):
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.on_hold = 1
+ supplier.hold_type = 'Payments'
+ supplier.save()
+
+ pi = make_purchase_invoice()
+
+ self.assertRaises(
+ frappe.ValidationError, get_payment_entry, dt='Purchase Invoice', dn=pi.name, bank_account="_Test Bank - _TC")
+
+ supplier.on_hold = 0
+ supplier.save()
+
+ def test_purchase_invoice_for_blocked_supplier_payment_today_date(self):
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.on_hold = 1
+ supplier.hold_type = 'Payments'
+ supplier.release_date = nowdate()
+ supplier.save()
+
+ pi = make_purchase_invoice()
+
+ self.assertRaises(
+ frappe.ValidationError, get_payment_entry, dt='Purchase Invoice', dn=pi.name,
+ bank_account="_Test Bank - _TC")
+
+ supplier.on_hold = 0
+ supplier.save()
+
+ def test_purchase_invoice_for_blocked_supplier_payment_past_date(self):
+ # this test is meant to fail only if something fails in the try block
+ with self.assertRaises(Exception):
+ try:
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.on_hold = 1
+ supplier.hold_type = 'Payments'
+ supplier.release_date = '2018-03-01'
+ supplier.save()
+
+ pi = make_purchase_invoice()
+
+ get_payment_entry('Purchase Invoice', dn=pi.name, bank_account="_Test Bank - _TC")
+
+ supplier.on_hold = 0
+ supplier.save()
+ except:
+ pass
+ else:
+ raise Exception
+
+ def test_purchase_invoice_blocked_invoice_must_be_in_future(self):
+ pi = make_purchase_invoice(do_not_save=True)
+ pi.release_date = nowdate()
+
+ self.assertRaises(frappe.ValidationError, pi.save)
+ pi.release_date = ''
+ pi.save()
+
+ def test_purchase_invoice_temporary_blocked(self):
+ pi = make_purchase_invoice(do_not_save=True)
+ pi.release_date = add_days(nowdate(), 10)
+ pi.save()
+ pi.submit()
+
+ pe = get_payment_entry('Purchase Invoice', dn=pi.name, bank_account="_Test Bank - _TC")
+
+ self.assertRaises(frappe.ValidationError, pe.save)
+
+ def test_purchase_invoice_explicit_block(self):
+ pi = make_purchase_invoice()
+ pi.block_invoice()
+
+ self.assertEqual(pi.on_hold, 1)
+
+ pi.unblock_invoice()
+
+ self.assertEqual(pi.on_hold, 0)
+
def test_gl_entries_with_perpetual_inventory_against_pr(self):
pr = frappe.copy_doc(pr_test_records[0])
set_perpetual_inventory(1, pr.company)
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index 9c7310f..8c86887 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -572,6 +572,22 @@
return flt(stock_rbnb) + flt(sys_bal)
+def get_held_invoices(party_type, party):
+ """
+ Returns a list of names Purchase Invoices for the given party that are on hold
+ """
+ held_invoices = None
+
+ if party_type == 'Supplier':
+ held_invoices = frappe.db.sql(
+ 'select name from `tabPurchase Invoice` where release_date IS NOT NULL and release_date > CURDATE()',
+ as_dict=1
+ )
+ held_invoices = [d['name'] for d in held_invoices]
+
+ return held_invoices
+
+
def get_outstanding_invoices(party_type, party, account, condition=None):
outstanding_invoices = []
precision = frappe.get_precision("Sales Invoice", "outstanding_amount")
@@ -584,7 +600,9 @@
payment_dr_or_cr = "payment_gl_entry.debit_in_account_currency - payment_gl_entry.credit_in_account_currency"
invoice = 'Sales Invoice' if erpnext.get_party_account_type(party_type) == 'Receivable' else 'Purchase Invoice'
- invoice_list = frappe.db.sql("""
+ held_invoices = get_held_invoices(party_type, party)
+
+ invoice_list = frappe.db.sql("""
select
voucher_no, voucher_type, posting_date, ifnull(sum({dr_or_cr}), 0) as invoice_amount,
(
@@ -622,20 +640,21 @@
}, as_dict=True)
for d in invoice_list:
- due_date = frappe.db.get_value(d.voucher_type, d.voucher_no,
- "posting_date" if party_type == "Employee" else "due_date")
+ if not d.voucher_type == "Purchase Invoice" or d.voucher_no not in held_invoices:
+ due_date = frappe.db.get_value(
+ d.voucher_type, d.voucher_no, "posting_date" if party_type == "Employee" else "due_date")
- outstanding_invoices.append(
- frappe._dict({
- 'voucher_no': d.voucher_no,
- 'voucher_type': d.voucher_type,
- 'posting_date': d.posting_date,
- 'invoice_amount': flt(d.invoice_amount),
- 'payment_amount': flt(d.payment_amount),
- 'outstanding_amount': flt(d.invoice_amount - d.payment_amount, precision),
- 'due_date': due_date
- })
- )
+ outstanding_invoices.append(
+ frappe._dict({
+ 'voucher_no': d.voucher_no,
+ 'voucher_type': d.voucher_type,
+ 'posting_date': d.posting_date,
+ 'invoice_amount': flt(d.invoice_amount),
+ 'payment_amount': flt(d.payment_amount),
+ 'outstanding_amount': flt(d.invoice_amount - d.payment_amount, precision),
+ 'due_date': due_date
+ })
+ )
outstanding_invoices = sorted(outstanding_invoices, key=lambda k: k['due_date'] or getdate(nowdate()))
diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
index 873fc74..0d46318 100644
--- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
@@ -5,6 +5,7 @@
import unittest
import frappe
import frappe.defaults
+from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from frappe.utils import flt, add_days, nowdate
from erpnext.buying.doctype.purchase_order.purchase_order import (make_purchase_receipt, make_purchase_invoice, make_rm_stock_entry as make_subcontract_transfer_entry)
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
@@ -163,6 +164,77 @@
self.assertTrue(po.get('payment_schedule'))
+ def test_po_for_blocked_supplier_all(self):
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.on_hold = 1
+ supplier.save()
+
+ self.assertEqual(supplier.hold_type, 'All')
+ self.assertRaises(frappe.ValidationError, create_purchase_order)
+
+ supplier.on_hold = 0
+ supplier.save()
+
+ def test_po_for_blocked_supplier_invoices(self):
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.on_hold = 1
+ supplier.hold_type = 'Invoices'
+ supplier.save()
+
+ self.assertRaises(frappe.ValidationError, create_purchase_order)
+
+ supplier.on_hold = 0
+ supplier.save()
+
+ def test_po_for_blocked_supplier_payments(self):
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.on_hold = 1
+ supplier.hold_type = 'Payments'
+ supplier.save()
+
+ po = create_purchase_order()
+
+ self.assertRaises(
+ frappe.ValidationError, get_payment_entry, dt='Purchase Order', dn=po.name, bank_account="_Test Bank - _TC")
+
+ supplier.on_hold = 0
+ supplier.save()
+
+ def test_po_for_blocked_supplier_payments_with_today_date(self):
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.on_hold = 1
+ supplier.release_date = nowdate()
+ supplier.hold_type = 'Payments'
+ supplier.save()
+
+ po = create_purchase_order()
+
+ self.assertRaises(
+ frappe.ValidationError, get_payment_entry, dt='Purchase Order', dn=po.name, bank_account="_Test Bank - _TC")
+
+ supplier.on_hold = 0
+ supplier.save()
+
+ def test_po_for_blocked_supplier_payments_past_date(self):
+ # this test is meant to fail only if something fails in the try block
+ with self.assertRaises(Exception):
+ try:
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.on_hold = 1
+ supplier.hold_type = 'Payments'
+ supplier.release_date = '2018-03-01'
+ supplier.save()
+
+ po = create_purchase_order()
+ get_payment_entry('Purchase Order', po.name, bank_account='_Test Bank - _TC')
+
+ supplier.on_hold = 0
+ supplier.save()
+ except:
+ pass
+ else:
+ raise Exception
+
def test_terms_does_not_copy(self):
po = create_purchase_order()
diff --git a/erpnext/buying/doctype/supplier/supplier.json b/erpnext/buying/doctype/supplier/supplier.json
index eedbac1..181b214 100644
--- a/erpnext/buying/doctype/supplier/supplier.json
+++ b/erpnext/buying/doctype/supplier/supplier.json
@@ -108,7 +108,7 @@
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
- "translatable": 0,
+ "translatable": 0,
"unique": 0
},
{
@@ -770,7 +770,135 @@
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
- "translatable": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "cb_21",
+ "fieldtype": "Column Break",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "default": "0",
+ "fieldname": "on_hold",
+ "fieldtype": "Check",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Block Supplier",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "depends_on": "eval:doc.on_hold",
+ "fieldname": "hold_type",
+ "fieldtype": "Select",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Hold Type",
+ "length": 0,
+ "no_copy": 0,
+ "options": "\nAll\nInvoices\nPayments",
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "depends_on": "eval:doc.on_hold",
+ "description": "Leave blank if the Supplier is blocked indefinitely",
+ "fieldname": "release_date",
+ "fieldtype": "Date",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Release Date",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "translatable": 0,
"unique": 0
},
{
diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py
index 6aa3b01..b6d588e 100644
--- a/erpnext/buying/doctype/supplier/supplier.py
+++ b/erpnext/buying/doctype/supplier/supplier.py
@@ -10,6 +10,7 @@
from erpnext.utilities.transaction_base import TransactionBase
from erpnext.accounts.party import validate_party_accounts, get_dashboard_info, get_timeline_data # keep this
+
class Supplier(TransactionBase):
def get_feed(self):
return self.supplier_name
@@ -19,6 +20,13 @@
load_address_and_contact(self)
self.load_dashboard_info()
+ def before_save(self):
+ if not self.on_hold:
+ self.hold_type = ''
+ self.release_date = ''
+ elif self.on_hold and not self.hold_type:
+ self.hold_type = 'All'
+
def load_dashboard_info(self):
info = get_dashboard_info(self.doctype, self.name)
self.set_onload('dashboard_info', info)
@@ -35,7 +43,7 @@
self.naming_series = ''
def validate(self):
- #validation for Naming Series mandatory field...
+ # validation for Naming Series mandatory field...
if frappe.defaults.get_global_default('supp_master_name') == 'Naming Series':
if not self.naming_series:
msgprint(_("Series is mandatory"), raise_exception=1)
diff --git a/erpnext/buying/doctype/supplier/supplier_list.js b/erpnext/buying/doctype/supplier/supplier_list.js
index d99e3f8..c776b00 100644
--- a/erpnext/buying/doctype/supplier/supplier_list.js
+++ b/erpnext/buying/doctype/supplier/supplier_list.js
@@ -1,3 +1,8 @@
frappe.listview_settings['Supplier'] = {
- add_fields: ["supplier_name", "supplier_group", "image"],
+ add_fields: ["supplier_name", "supplier_group", "image", "on_hold"],
+ get_indicator: function(doc) {
+ if(cint(doc.on_hold)) {
+ return [__("On Hold"), "red"];
+ }
+ }
};
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index d3deb06..4802e02 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -4,7 +4,7 @@
from __future__ import unicode_literals
import frappe, erpnext
from frappe import _, throw
-from frappe.utils import today, flt, cint, fmt_money, formatdate, getdate, add_days, add_months, get_last_day
+from frappe.utils import today, flt, cint, fmt_money, formatdate, getdate, add_days, add_months, get_last_day, nowdate
from erpnext.setup.utils import get_exchange_rate
from erpnext.accounts.utils import get_fiscal_years, validate_fiscal_year, get_account_currency
from erpnext.utilities.transaction_base import TransactionBase
@@ -36,10 +36,29 @@
if self.doctype in relevant_docs:
self.set_payment_schedule()
+ def ensure_supplier_is_not_blocked(self):
+ is_supplier_payment = self.doctype == 'Payment Entry' and self.party_type == 'Supplier'
+ is_buying_invoice = self.doctype in ['Purchase Invoice', 'Purchase Order']
+ supplier = None
+ supplier_name = None
+
+ if is_buying_invoice or is_supplier_payment:
+ supplier_name = self.supplier if is_buying_invoice else self.party
+ supplier = frappe.get_doc('Supplier', supplier_name)
+
+ if supplier and supplier_name and supplier.on_hold:
+ if (is_buying_invoice and supplier.hold_type in ['All', 'Invoices']) or \
+ (is_supplier_payment and supplier.hold_type in ['All', 'Payments']):
+ if not supplier.release_date or getdate(nowdate()) <= supplier.release_date:
+ frappe.msgprint(
+ _('{0} is blocked so this transaction cannot proceed'.format(supplier_name)), raise_exception=1)
+
def validate(self):
if self.get("_action") and self._action != "update_after_submit":
self.set_missing_values(for_validate=True)
+ self.ensure_supplier_is_not_blocked()
+
self.validate_date_with_fiscal_year()
if self.meta.get_field("currency"):
@@ -969,3 +988,18 @@
elif term.due_date_based_on == "Month(s) after the end of the invoice month":
due_date = add_months(get_last_day(date), term.credit_months)
return due_date
+
+
+def get_supplier_block_status(party_name):
+ """
+ Returns a dict containing the values of `on_hold`, `release_date` and `hold_type` of
+ a `Supplier`
+ """
+ supplier = frappe.get_doc('Supplier', party_name)
+ info = {
+ 'on_hold': supplier.on_hold,
+ 'release_date': supplier.release_date,
+ 'hold_type': supplier.hold_type
+ }
+ return info
+
diff --git a/erpnext/docs/assets/img/accounts/purchase-invoice-hold.png b/erpnext/docs/assets/img/accounts/purchase-invoice-hold.png
new file mode 100644
index 0000000..e87f9f5
--- /dev/null
+++ b/erpnext/docs/assets/img/accounts/purchase-invoice-hold.png
Binary files differ
diff --git a/erpnext/docs/user/manual/en/accounts/purchase-invoice.md b/erpnext/docs/user/manual/en/accounts/purchase-invoice.md
index 8076bd8..122dfa3 100644
--- a/erpnext/docs/user/manual/en/accounts/purchase-invoice.md
+++ b/erpnext/docs/user/manual/en/accounts/purchase-invoice.md
@@ -92,4 +92,42 @@
For more help, please contact your Accountant!
+#### Hold Payments For A Purchase Invoice
+There are two ways to put a purchase invoice on hold:
+- Date Span Hold
+- Explicit Hold
+
+##### Explicit Hold
+Explicit hold holds the purchase invoice indefinitely.
+To do it, in the "Hold Invoice" section of the purchase invoice form, simply
+check the "Hold Invoice" checkbox. In the "Reason For Putting On Hold" text
+field, type a comment explaining why the invoice is to be put on hold.
+
+If you need to hold a submitted invoice, click the "Make" drop down button
+and click "Block Invoice". Also add a comment explaining why the invoice is
+to be put on hold in the dialog that pops up and click "Save".
+
+##### Date Span Hold
+Date span hold holds the purchase invoice until a
+specified date. To do it, in the "Hold Invoice" section of the purchase
+invoice form, check the "Hold Invoice" checkbox. Next, input the release date
+in the dialog that pops up and click "Save". The release date is the date
+that the hold on the document expires.
+
+After the invoice has been saved, you can change the release date by clicking
+on the "Hold Invoice" drop down button and then "Change Release Date". This
+action will cause a dialog to appear.
+
+<img class="screenshot" alt="Purchase Invoice on hold" src="{{docs_base_url}}/assets/img/accounts/purchase-invoice-hold.png">
+
+Select the new release date and click "Save". You should also enter a comment
+in the "Reason For Putting On Hold" field.
+
+Take note of the following:
+- All purchases that have been placed on hold will not included in a Payment Entry's references table
+- The release date cannot be in the past.
+- You can only block or unblock a purchase invoice if it is unpaid.
+- You can only change the release date if the invoice is unpaid.
+
+
{next}
diff --git a/erpnext/docs/user/manual/en/buying/supplier.md b/erpnext/docs/user/manual/en/buying/supplier.md
index baf0fed..f116c88 100644
--- a/erpnext/docs/user/manual/en/buying/supplier.md
+++ b/erpnext/docs/user/manual/en/buying/supplier.md
@@ -38,7 +38,20 @@
You can add multiple companies in your ERPNext instance, and one Supplier can be used across multiple companies. In this case, you should define Companywise Payable Account for the Supplier in the "Default Payable Accounts" table.
<iframe width="660" height="371" src="https://www.youtube.com/embed/anoGi_RpQ20" frameborder="0" allowfullscreen></iframe>
-
(Check from 2:20)
+### Place Supplier On Hold
+In the Supplier form, check the "Block Supplier" checkbox. Next, choose the "Hold Type".
+
+The hold types are as follows:
+- Invoices: ERPNext will not allow Purchase Invoices or Purchase Orders to be created for the supplier
+- Payments: ERPNext will not allow Payment Entries to be created for the Supplier
+- All: ERPNext will apply both hold types above
+
+After selecting the hold type, you can optionally set a release date in the "Release Date" field.
+
+Take note of the following:
+- If you do not select a hold type, ERPNext will set it to "All"
+- If you do not set a release date, ERPNext will hold the Supplier indefinitely
+
{next}