Merge pull request #37430 from frappe/version-14-hotfix

chore: release v14
This commit is contained in:
Deepesh Garg
2023-10-12 07:43:46 +05:30
committed by GitHub
60 changed files with 1980 additions and 515 deletions

View File

@@ -352,10 +352,11 @@ frappe.ui.form.on("Bank Statement Import", {
export_errored_rows(frm) { export_errored_rows(frm) {
open_url_post( open_url_post(
"/api/method/frappe.core.doctype.data_import.data_import.download_errored_template", "/api/method/erpnext.accounts.doctype.bank_statement_import.bank_statement_import.download_errored_template",
{ {
data_import_name: frm.doc.name, data_import_name: frm.doc.name,
} },
true
); );
}, },

View File

@@ -6,8 +6,10 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"api_details_section", "api_details_section",
"disabled",
"service_provider", "service_provider",
"api_endpoint", "api_endpoint",
"access_key",
"url", "url",
"column_break_3", "column_break_3",
"help", "help",
@@ -77,12 +79,24 @@
"label": "Service Provider", "label": "Service Provider",
"options": "frankfurter.app\nexchangerate.host\nCustom", "options": "frankfurter.app\nexchangerate.host\nCustom",
"reqd": 1 "reqd": 1
},
{
"default": "0",
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disabled"
},
{
"depends_on": "eval:doc.service_provider == 'exchangerate.host';",
"fieldname": "access_key",
"fieldtype": "Data",
"label": "Access Key"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2022-01-10 15:51:14.521174", "modified": "2023-10-04 15:30:25.333860",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Currency Exchange Settings", "name": "Currency Exchange Settings",

View File

@@ -18,11 +18,21 @@ class CurrencyExchangeSettings(Document):
def set_parameters_and_result(self): def set_parameters_and_result(self):
if self.service_provider == "exchangerate.host": if self.service_provider == "exchangerate.host":
if not self.access_key:
frappe.throw(
_("Access Key is required for Service Provider: {0}").format(
frappe.bold(self.service_provider)
)
)
self.set("result_key", []) self.set("result_key", [])
self.set("req_params", []) self.set("req_params", [])
self.api_endpoint = "https://api.exchangerate.host/convert" self.api_endpoint = "https://api.exchangerate.host/convert"
self.append("result_key", {"key": "result"}) self.append("result_key", {"key": "result"})
self.append("req_params", {"key": "access_key", "value": self.access_key})
self.append("req_params", {"key": "amount", "value": "1"})
self.append("req_params", {"key": "date", "value": "{transaction_date}"}) self.append("req_params", {"key": "date", "value": "{transaction_date}"})
self.append("req_params", {"key": "from", "value": "{from_currency}"}) self.append("req_params", {"key": "from", "value": "{from_currency}"})
self.append("req_params", {"key": "to", "value": "{to_currency}"}) self.append("req_params", {"key": "to", "value": "{to_currency}"})

View File

@@ -50,8 +50,18 @@ frappe.ui.form.on("Journal Entry", {
frm.trigger("make_inter_company_journal_entry"); frm.trigger("make_inter_company_journal_entry");
}, __('Make')); }, __('Make'));
} }
},
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm);
},
before_save: function(frm) {
if ((frm.doc.docstatus == 0) && (!frm.doc.is_system_generated)) {
let payment_entry_references = frm.doc.accounts.filter(elem => (elem.reference_type == "Payment Entry"));
if (payment_entry_references.length > 0) {
let rows = payment_entry_references.map(x => "#"+x.idx);
frappe.throw(__("Rows: {0} have 'Payment Entry' as reference_type. This should not be set manually.", [frappe.utils.comma_and(rows)]));
}
}
},
make_inter_company_journal_entry: function(frm) { make_inter_company_journal_entry: function(frm) {
var d = new frappe.ui.Dialog({ var d = new frappe.ui.Dialog({
title: __("Select Company"), title: __("Select Company"),

View File

@@ -7,7 +7,7 @@ cur_frm.cscript.tax_table = "Advance Taxes and Charges";
frappe.ui.form.on('Payment Entry', { frappe.ui.form.on('Payment Entry', {
onload: function(frm) { onload: function(frm) {
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger']; frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger', 'Unreconcile Payments', 'Unreconcile Payment Entries'];
if(frm.doc.__islocal) { if(frm.doc.__islocal) {
if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null); if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null);
@@ -152,6 +152,7 @@ frappe.ui.form.on('Payment Entry', {
frm.events.hide_unhide_fields(frm); frm.events.hide_unhide_fields(frm);
frm.events.set_dynamic_labels(frm); frm.events.set_dynamic_labels(frm);
frm.events.show_general_ledger(frm); frm.events.show_general_ledger(frm);
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm);
}, },
validate_company: (frm) => { validate_company: (frm) => {

View File

@@ -107,6 +107,8 @@ class PaymentEntry(AccountsController):
"Repost Payment Ledger Items", "Repost Payment Ledger Items",
"Repost Accounting Ledger", "Repost Accounting Ledger",
"Repost Accounting Ledger Items", "Repost Accounting Ledger Items",
"Unreconcile Payments",
"Unreconcile Payment Entries",
) )
super(PaymentEntry, self).on_cancel() super(PaymentEntry, self).on_cancel()
self.make_gl_entries(cancel=1) self.make_gl_entries(cancel=1)
@@ -227,16 +229,18 @@ class PaymentEntry(AccountsController):
# if no payment template is used by invoice and has a custom term(no `payment_term`), then invoice outstanding will be in 'None' key # if no payment template is used by invoice and has a custom term(no `payment_term`), then invoice outstanding will be in 'None' key
latest = latest.get(d.payment_term) or latest.get(None) latest = latest.get(d.payment_term) or latest.get(None)
# The reference has already been fully paid # The reference has already been fully paid
if not latest: if not latest:
frappe.throw( frappe.throw(
_("{0} {1} has already been fully paid.").format(_(d.reference_doctype), d.reference_name) _("{0} {1} has already been fully paid.").format(_(d.reference_doctype), d.reference_name)
) )
# The reference has already been partly paid # The reference has already been partly paid
elif latest.outstanding_amount < latest.invoice_amount and flt( elif (
d.outstanding_amount, d.precision("outstanding_amount") latest.outstanding_amount < latest.invoice_amount
) != flt(latest.outstanding_amount, d.precision("outstanding_amount")): and flt(d.outstanding_amount, d.precision("outstanding_amount"))
!= flt(latest.outstanding_amount, d.precision("outstanding_amount"))
and d.payment_term == ""
):
frappe.throw( frappe.throw(
_( _(
"{0} {1} has already been partly paid. Please use the 'Get Outstanding Invoice' or the 'Get Outstanding Orders' button to get the latest outstanding amounts." "{0} {1} has already been partly paid. Please use the 'Get Outstanding Invoice' or the 'Get Outstanding Orders' button to get the latest outstanding amounts."
@@ -1600,11 +1604,10 @@ def split_invoices_based_on_payment_terms(outstanding_invoices, company):
"voucher_type": d.voucher_type, "voucher_type": d.voucher_type,
"posting_date": d.posting_date, "posting_date": d.posting_date,
"invoice_amount": flt(d.invoice_amount), "invoice_amount": flt(d.invoice_amount),
"outstanding_amount": flt(d.outstanding_amount), "outstanding_amount": payment_term_outstanding
"payment_term_outstanding": payment_term_outstanding,
"allocated_amount": payment_term_outstanding
if payment_term_outstanding if payment_term_outstanding
else d.outstanding_amount, else d.outstanding_amount,
"payment_term_outstanding": payment_term_outstanding,
"payment_amount": payment_term.payment_amount, "payment_amount": payment_term.payment_amount,
"payment_term": payment_term.payment_term, "payment_term": payment_term.payment_term,
} }

View File

@@ -19,7 +19,7 @@ from erpnext.accounts.utils import (
get_outstanding_invoices, get_outstanding_invoices,
reconcile_against_document, reconcile_against_document,
) )
from erpnext.controllers.accounts_controller import get_advance_payment_entries from erpnext.controllers.accounts_controller import get_advance_payment_entries_for_regional
class PaymentReconciliation(Document): class PaymentReconciliation(Document):
@@ -62,7 +62,7 @@ class PaymentReconciliation(Document):
if self.payment_name: if self.payment_name:
condition += "name like '%%{0}%%'".format(self.payment_name) condition += "name like '%%{0}%%'".format(self.payment_name)
payment_entries = get_advance_payment_entries( payment_entries = get_advance_payment_entries_for_regional(
self.party_type, self.party_type,
self.party, self.party,
self.receivable_payable_account, self.receivable_payable_account,
@@ -350,6 +350,7 @@ class PaymentReconciliation(Document):
) )
def reconcile_allocations(self, skip_ref_details_update_for_pe=False): def reconcile_allocations(self, skip_ref_details_update_for_pe=False):
adjust_allocations_for_taxes(self)
dr_or_cr = ( dr_or_cr = (
"credit_in_account_currency" "credit_in_account_currency"
if erpnext.get_party_account_type(self.party_type) == "Receivable" if erpnext.get_party_account_type(self.party_type) == "Receivable"
@@ -650,3 +651,8 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
None, None,
inv.cost_center, inv.cost_center,
) )
@erpnext.allow_regional
def adjust_allocations_for_taxes(doc):
pass

View File

@@ -249,7 +249,7 @@ class PaymentRequest(Document):
if ( if (
party_account_currency == ref_doc.company_currency and party_account_currency != self.currency party_account_currency == ref_doc.company_currency and party_account_currency != self.currency
): ):
party_amount = ref_doc.base_grand_total party_amount = ref_doc.get("base_rounded_total") or ref_doc.get("base_grand_total")
else: else:
party_amount = self.grand_total party_amount = self.grand_total

View File

@@ -47,6 +47,20 @@ class ProcessStatementOfAccounts(Document):
def get_report_pdf(doc, consolidated=True): def get_report_pdf(doc, consolidated=True):
statement_dict = get_statement_dict(doc)
if not bool(statement_dict):
return False
elif consolidated:
delimiter = '<div style="page-break-before: always;"></div>' if doc.include_break else ""
result = delimiter.join(list(statement_dict.values()))
return get_pdf(result, {"orientation": doc.orientation})
else:
for customer, statement_html in statement_dict.items():
statement_dict[customer] = get_pdf(statement_html, {"orientation": doc.orientation})
return statement_dict
def get_statement_dict(doc, get_statement_dict=False):
statement_dict = {} statement_dict = {}
ageing = "" ageing = ""
@@ -77,17 +91,11 @@ def get_report_pdf(doc, consolidated=True):
if not res: if not res:
continue continue
statement_dict[entry.customer] = get_html(doc, filters, entry, col, res, ageing) statement_dict[entry.customer] = (
[res, ageing] if get_statement_dict else get_html(doc, filters, entry, col, res, ageing)
)
if not bool(statement_dict): return statement_dict
return False
elif consolidated:
result = "".join(list(statement_dict.values()))
return get_pdf(result, {"orientation": doc.orientation})
else:
for customer, statement_html in statement_dict.items():
statement_dict[customer] = get_pdf(statement_html, {"orientation": doc.orientation})
return statement_dict
def set_ageing(doc, entry): def set_ageing(doc, entry):
@@ -100,7 +108,8 @@ def set_ageing(doc, entry):
"range2": 60, "range2": 60,
"range3": 90, "range3": 90,
"range4": 120, "range4": 120,
"customer": entry.customer, "party_type": "Customer",
"party": [entry.customer],
} }
) )
col1, ageing = get_ageing(ageing_filters) col1, ageing = get_ageing(ageing_filters)

View File

@@ -4,39 +4,107 @@
import unittest import unittest
import frappe import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, getdate, today from frappe.utils import add_days, getdate, today
from erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts import ( from erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts import (
get_statement_dict,
send_emails, send_emails,
) )
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
class TestProcessStatementOfAccounts(unittest.TestCase): class TestProcessStatementOfAccounts(AccountsTestMixin, FrappeTestCase):
def setUp(self): def setUp(self):
self.create_company()
self.create_customer()
self.create_customer(customer_name="Other Customer")
self.clear_old_entries()
self.si = create_sales_invoice() self.si = create_sales_invoice()
self.process_soa = create_process_soa() create_sales_invoice(customer="Other Customer")
def test_process_soa_for_gl(self):
"""Tests the utils for Statement of Accounts(General Ledger)"""
process_soa = create_process_soa(
name="_Test Process SOA for GL",
customers=[{"customer": "_Test Customer"}, {"customer": "Other Customer"}],
)
statement_dict = get_statement_dict(process_soa, get_statement_dict=True)
# Checks if the statements are filtered based on the Customer
self.assertIn("Other Customer", statement_dict)
self.assertIn("_Test Customer", statement_dict)
# Checks if the correct number of receivable entries exist
# 3 rows for opening and closing and 1 row for SI
receivable_entries = statement_dict["_Test Customer"][0]
self.assertEqual(len(receivable_entries), 4)
# Checks the amount for the receivable entry
self.assertEqual(receivable_entries[1].voucher_no, self.si.name)
self.assertEqual(receivable_entries[1].balance, 100)
def test_process_soa_for_ar(self):
"""Tests the utils for Statement of Accounts(Accounts Receivable)"""
process_soa = create_process_soa(name="_Test Process SOA for AR", report="Accounts Receivable")
statement_dict = get_statement_dict(process_soa, get_statement_dict=True)
# Checks if the statements are filtered based on the Customer
self.assertNotIn("Other Customer", statement_dict)
self.assertIn("_Test Customer", statement_dict)
# Checks if the correct number of receivable entries exist
receivable_entries = statement_dict["_Test Customer"][0]
self.assertEqual(len(receivable_entries), 1)
# Checks the amount for the receivable entry
self.assertEqual(receivable_entries[0].voucher_no, self.si.name)
self.assertEqual(receivable_entries[0].total_due, 100)
# Checks the ageing summary for AR
ageing_summary = statement_dict["_Test Customer"][1][0]
expected_summary = frappe._dict(
range1=100,
range2=0,
range3=0,
range4=0,
range5=0,
)
self.check_ageing_summary(ageing_summary, expected_summary)
def test_auto_email_for_process_soa_ar(self): def test_auto_email_for_process_soa_ar(self):
send_emails(self.process_soa.name, from_scheduler=True) process_soa = create_process_soa(
self.process_soa.load_from_db() name="_Test Process SOA", enable_auto_email=1, report="Accounts Receivable"
self.assertEqual(self.process_soa.posting_date, getdate(add_days(today(), 7))) )
send_emails(process_soa.name, from_scheduler=True)
process_soa.load_from_db()
self.assertEqual(process_soa.posting_date, getdate(add_days(today(), 7)))
def check_ageing_summary(self, ageing, expected_ageing):
for age_range in expected_ageing:
self.assertEqual(expected_ageing[age_range], ageing.get(age_range))
def tearDown(self): def tearDown(self):
frappe.delete_doc_if_exists("Process Statement Of Accounts", "Test Process SOA") frappe.db.rollback()
def create_process_soa(): def create_process_soa(**args):
frappe.delete_doc_if_exists("Process Statement Of Accounts", "Test Process SOA") args = frappe._dict(args)
frappe.delete_doc_if_exists("Process Statement Of Accounts", args.name)
process_soa = frappe.new_doc("Process Statement Of Accounts") process_soa = frappe.new_doc("Process Statement Of Accounts")
soa_dict = { soa_dict = frappe._dict(
"name": "Test Process SOA", name=args.name,
"company": "_Test Company", company=args.company or "_Test Company",
} customers=args.customers or [{"customer": "_Test Customer"}],
enable_auto_email=1 if args.enable_auto_email else 0,
frequency=args.frequency or "Weekly",
report=args.report or "General Ledger",
from_date=args.from_date or getdate(today()),
to_date=args.to_date or getdate(today()),
posting_date=args.posting_date or getdate(today()),
include_ageing=1,
)
process_soa.update(soa_dict) process_soa.update(soa_dict)
process_soa.set("customers", [{"customer": "_Test Customer"}])
process_soa.enable_auto_email = 1
process_soa.frequency = "Weekly"
process_soa.report = "Accounts Receivable"
process_soa.save() process_soa.save()
return process_soa return process_soa

View File

@@ -59,6 +59,25 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
this.show_stock_ledger(); this.show_stock_ledger();
} }
if (this.frm.doc.repost_required && this.frm.doc.docstatus===1) {
this.frm.set_intro(__("Accounting entries for this invoice need to be reposted. Please click on 'Repost' button to update."));
this.frm.add_custom_button(__('Repost Accounting Entries'),
() => {
this.frm.call({
doc: this.frm.doc,
method: 'repost_accounting_entries',
freeze: true,
freeze_message: __('Reposting...'),
callback: (r) => {
if (!r.exc) {
frappe.msgprint(__('Accounting Entries are reposted.'));
me.frm.refresh();
}
}
});
}).removeClass('btn-default').addClass('btn-warning');
}
if(!doc.is_return && doc.docstatus == 1 && doc.outstanding_amount != 0){ if(!doc.is_return && doc.docstatus == 1 && doc.outstanding_amount != 0){
if(doc.on_hold) { if(doc.on_hold) {
this.frm.add_custom_button( this.frm.add_custom_button(

View File

@@ -166,6 +166,7 @@
"against_expense_account", "against_expense_account",
"column_break_63", "column_break_63",
"unrealized_profit_loss_account", "unrealized_profit_loss_account",
"repost_required",
"subscription_section", "subscription_section",
"auto_repeat", "auto_repeat",
"update_auto_repeat_reference", "update_auto_repeat_reference",
@@ -190,8 +191,7 @@
"inter_company_invoice_reference", "inter_company_invoice_reference",
"is_old_subcontracting_flow", "is_old_subcontracting_flow",
"remarks", "remarks",
"connections_tab", "connections_tab"
"column_break_38"
], ],
"fields": [ "fields": [
{ {
@@ -987,6 +987,7 @@
"print_hide": 1 "print_hide": 1
}, },
{ {
"allow_on_submit": 1,
"fieldname": "cash_bank_account", "fieldname": "cash_bank_account",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Cash/Bank Account", "label": "Cash/Bank Account",
@@ -1050,6 +1051,7 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"allow_on_submit": 1,
"depends_on": "eval:flt(doc.write_off_amount)!=0", "depends_on": "eval:flt(doc.write_off_amount)!=0",
"fieldname": "write_off_account", "fieldname": "write_off_account",
"fieldtype": "Link", "fieldtype": "Link",
@@ -1213,6 +1215,7 @@
"read_only": 1 "read_only": 1
}, },
{ {
"allow_on_submit": 1,
"default": "No", "default": "No",
"fieldname": "is_opening", "fieldname": "is_opening",
"fieldtype": "Select", "fieldtype": "Select",
@@ -1345,6 +1348,7 @@
"options": "Project" "options": "Project"
}, },
{ {
"allow_on_submit": 1,
"depends_on": "eval:doc.is_internal_supplier", "depends_on": "eval:doc.is_internal_supplier",
"description": "Unrealized Profit/Loss account for intra-company transfers", "description": "Unrealized Profit/Loss account for intra-company transfers",
"fieldname": "unrealized_profit_loss_account", "fieldname": "unrealized_profit_loss_account",
@@ -1495,10 +1499,6 @@
"fieldname": "column_break_6", "fieldname": "column_break_6",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "column_break_38",
"fieldtype": "Column Break"
},
{ {
"fieldname": "column_break_50", "fieldname": "column_break_50",
"fieldtype": "Column Break" "fieldtype": "Column Break"
@@ -1569,6 +1569,15 @@
"fieldname": "use_company_roundoff_cost_center", "fieldname": "use_company_roundoff_cost_center",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Use Company Default Round Off Cost Center" "label": "Use Company Default Round Off Cost Center"
},
{
"default": "0",
"fieldname": "repost_required",
"fieldtype": "Check",
"hidden": 1,
"label": "Repost Required",
"options": "Account",
"read_only": 1
} }
], ],
"icon": "fa fa-file-text", "icon": "fa fa-file-text",

View File

@@ -11,6 +11,9 @@ from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate,
import erpnext import erpnext
from erpnext.accounts.deferred_revenue import validate_service_stop_date from erpnext.accounts.deferred_revenue import validate_service_stop_date
from erpnext.accounts.doctype.gl_entry.gl_entry import update_outstanding_amt from erpnext.accounts.doctype.gl_entry.gl_entry import update_outstanding_amt
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
validate_docs_for_deferred_accounting,
)
from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
check_if_return_invoice_linked_with_payment_entry, check_if_return_invoice_linked_with_payment_entry,
get_total_in_party_account_currency, get_total_in_party_account_currency,
@@ -487,6 +490,11 @@ class PurchaseInvoice(BuyingController):
_("Stock cannot be updated against Purchase Receipt {0}").format(item.purchase_receipt) _("Stock cannot be updated against Purchase Receipt {0}").format(item.purchase_receipt)
) )
def validate_for_repost(self):
self.validate_write_off_account()
self.validate_expense_account()
validate_docs_for_deferred_accounting([], [self.name])
def on_submit(self): def on_submit(self):
super(PurchaseInvoice, self).on_submit() super(PurchaseInvoice, self).on_submit()
@@ -529,6 +537,18 @@ class PurchaseInvoice(BuyingController):
self.process_common_party_accounting() self.process_common_party_accounting()
def on_update_after_submit(self):
if hasattr(self, "repost_required"):
fields_to_check = [
"cash_bank_account",
"write_off_account",
"unrealized_profit_loss_account",
]
child_tables = {"items": ("expense_account",), "taxes": ("account_head",)}
self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables)
self.validate_for_repost()
self.db_set("repost_required", self.needs_repost)
def make_gl_entries(self, gl_entries=None, from_repost=False): def make_gl_entries(self, gl_entries=None, from_repost=False):
if not gl_entries: if not gl_entries:
gl_entries = self.get_gl_entries() gl_entries = self.get_gl_entries()

View File

@@ -1796,7 +1796,6 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
pi = make_purchase_invoice( pi = make_purchase_invoice(
company="_Test Company", company="_Test Company",
customer="_Test Supplier",
do_not_save=True, do_not_save=True,
do_not_submit=True, do_not_submit=True,
rate=1000, rate=1000,
@@ -1826,6 +1825,32 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
clear_dimension_defaults("Branch") clear_dimension_defaults("Branch")
disable_dimension() disable_dimension()
def test_repost_accounting_entries(self):
pi = make_purchase_invoice(
rate=1000,
price_list_rate=1000,
qty=1,
)
expected_gle = [
["_Test Account Cost for Goods Sold - _TC", 1000, 0.0, nowdate()],
["Creditors - _TC", 0.0, 1000, nowdate()],
]
check_gl_entries(self, pi.name, expected_gle, nowdate())
pi.items[0].expense_account = "Service - _TC"
pi.save()
pi.load_from_db()
self.assertTrue(pi.repost_required)
pi.repost_accounting_entries()
expected_gle = [
["Creditors - _TC", 0.0, 1000, nowdate()],
["Service - _TC", 1000, 0.0, nowdate()],
]
check_gl_entries(self, pi.name, expected_gle, nowdate())
pi.load_from_db()
self.assertFalse(pi.repost_required)
def check_gl_entries( def check_gl_entries(
doc, doc,

View File

@@ -468,6 +468,7 @@
"label": "Accounting" "label": "Accounting"
}, },
{ {
"allow_on_submit": 1,
"fieldname": "expense_account", "fieldname": "expense_account",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Expense Head", "label": "Expense Head",

View File

@@ -86,6 +86,7 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"allow_on_submit": 1,
"columns": 2, "columns": 2,
"fieldname": "account_head", "fieldname": "account_head",
"fieldtype": "Link", "fieldtype": "Link",
@@ -97,6 +98,7 @@
"reqd": 1 "reqd": 1
}, },
{ {
"allow_on_submit": 1,
"default": ":Company", "default": ":Company",
"fieldname": "cost_center", "fieldname": "cost_center",
"fieldtype": "Link", "fieldtype": "Link",

View File

@@ -55,7 +55,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-07-27 15:47:58.975034", "modified": "2023-09-26 14:21:27.362567",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Repost Accounting Ledger", "name": "Repost Accounting Ledger",
@@ -77,5 +77,6 @@
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": [],
"track_changes": 1
} }

View File

@@ -21,29 +21,8 @@ class RepostAccountingLedger(Document):
def validate_for_deferred_accounting(self): def validate_for_deferred_accounting(self):
sales_docs = [x.voucher_no for x in self.vouchers if x.voucher_type == "Sales Invoice"] sales_docs = [x.voucher_no for x in self.vouchers if x.voucher_type == "Sales Invoice"]
docs_with_deferred_revenue = frappe.db.get_all(
"Sales Invoice Item",
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
fields=["parent"],
as_list=1,
)
purchase_docs = [x.voucher_no for x in self.vouchers if x.voucher_type == "Purchase Invoice"] purchase_docs = [x.voucher_no for x in self.vouchers if x.voucher_type == "Purchase Invoice"]
docs_with_deferred_expense = frappe.db.get_all( validate_docs_for_deferred_accounting(sales_docs, purchase_docs)
"Purchase Invoice Item",
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
fields=["parent"],
as_list=1,
)
if docs_with_deferred_revenue or docs_with_deferred_expense:
frappe.throw(
_("Documents: {0} have deferred revenue/expense enabled for them. Cannot repost.").format(
frappe.bold(
comma_and([x[0] for x in docs_with_deferred_expense + docs_with_deferred_revenue])
)
)
)
def validate_for_closed_fiscal_year(self): def validate_for_closed_fiscal_year(self):
if self.vouchers: if self.vouchers:
@@ -139,14 +118,17 @@ class RepostAccountingLedger(Document):
return rendered_page return rendered_page
def on_submit(self): def on_submit(self):
job_name = "repost_accounting_ledger_" + self.name if len(self.vouchers) > 1:
frappe.enqueue( job_name = "repost_accounting_ledger_" + self.name
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost", frappe.enqueue(
account_repost_doc=self.name, method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost",
is_async=True, account_repost_doc=self.name,
job_name=job_name, is_async=True,
) job_name=job_name,
frappe.msgprint(_("Repost has started in the background")) )
frappe.msgprint(_("Repost has started in the background"))
else:
start_repost(self.name)
@frappe.whitelist() @frappe.whitelist()
@@ -181,3 +163,26 @@ def start_repost(account_repost_doc=str) -> None:
doc.make_gl_entries() doc.make_gl_entries()
frappe.db.commit() frappe.db.commit()
def validate_docs_for_deferred_accounting(sales_docs, purchase_docs):
docs_with_deferred_revenue = frappe.db.get_all(
"Sales Invoice Item",
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
fields=["parent"],
as_list=1,
)
docs_with_deferred_expense = frappe.db.get_all(
"Purchase Invoice Item",
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
fields=["parent"],
as_list=1,
)
if docs_with_deferred_revenue or docs_with_deferred_expense:
frappe.throw(
_("Documents: {0} have deferred revenue/expense enabled for them. Cannot repost.").format(
frappe.bold(comma_and([x[0] for x in docs_with_deferred_expense + docs_with_deferred_revenue]))
)
)

View File

@@ -99,7 +99,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2022-11-08 07:38:40.079038", "modified": "2023-09-26 14:21:35.719727",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Repost Payment Ledger", "name": "Repost Payment Ledger",
@@ -155,5 +155,6 @@
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": [],
"track_changes": 1
} }

View File

@@ -34,7 +34,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
super.onload(); super.onload();
this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log',
'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger"]; 'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger", "Unreconcile Payments", "Unreconcile Payment Entries"];
if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) { if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {
// show debit_to in print format // show debit_to in print format
@@ -177,8 +177,11 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
}, __('Create')); }, __('Create'));
} }
} }
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(me.frm);
} }
make_maintenance_schedule() { make_maintenance_schedule() {
frappe.model.open_mapped_doc({ frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule", method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule",

View File

@@ -11,13 +11,13 @@ from frappe.utils import add_days, cint, cstr, flt, formatdate, get_link_to_form
import erpnext import erpnext
from erpnext.accounts.deferred_revenue import validate_service_stop_date from erpnext.accounts.deferred_revenue import validate_service_stop_date
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.doctype.loyalty_program.loyalty_program import ( from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
get_loyalty_program_details_with_points, get_loyalty_program_details_with_points,
validate_loyalty_points, validate_loyalty_points,
) )
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
validate_docs_for_deferred_accounting,
)
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import (
get_party_tax_withholding_details, get_party_tax_withholding_details,
) )
@@ -176,6 +176,12 @@ class SalesInvoice(SellingController):
self.validate_account_for_change_amount() self.validate_account_for_change_amount()
self.validate_income_account() self.validate_income_account()
def validate_for_repost(self):
self.validate_write_off_account()
self.validate_account_for_change_amount()
self.validate_income_account()
validate_docs_for_deferred_accounting([self.name], [])
def validate_fixed_asset(self): def validate_fixed_asset(self):
for d in self.get("items"): for d in self.get("items"):
if d.is_fixed_asset and d.meta.get_field("asset") and d.asset: if d.is_fixed_asset and d.meta.get_field("asset") and d.asset:
@@ -401,6 +407,8 @@ class SalesInvoice(SellingController):
"Repost Payment Ledger Items", "Repost Payment Ledger Items",
"Repost Accounting Ledger", "Repost Accounting Ledger",
"Repost Accounting Ledger Items", "Repost Accounting Ledger Items",
"Unreconcile Payments",
"Unreconcile Payment Entries",
"Payment Ledger Entry", "Payment Ledger Entry",
) )
@@ -527,89 +535,21 @@ class SalesInvoice(SellingController):
def on_update_after_submit(self): def on_update_after_submit(self):
if hasattr(self, "repost_required"): if hasattr(self, "repost_required"):
needs_repost = 0 fields_to_check = [
"additional_discount_account",
# Check if any field affecting accounting entry is altered "cash_bank_account",
doc_before_update = self.get_doc_before_save() "account_for_change_amount",
accounting_dimensions = get_accounting_dimensions() + ["cost_center", "project"] "write_off_account",
"loyalty_redemption_account",
# Check if opening entry check updated "unrealized_profit_loss_account",
if doc_before_update.get("is_opening") != self.is_opening: ]
needs_repost = 1 child_tables = {
"items": ("income_account", "expense_account", "discount_account"),
if not needs_repost: "taxes": ("account_head",),
# Parent Level Accounts excluding party account }
for field in ( self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables)
"additional_discount_account", self.validate_for_repost()
"cash_bank_account", self.db_set("repost_required", self.needs_repost)
"account_for_change_amount",
"write_off_account",
"loyalty_redemption_account",
"unrealized_profit_loss_account",
):
if doc_before_update.get(field) != self.get(field):
needs_repost = 1
break
# Check for parent accounting dimensions
for dimension in accounting_dimensions:
if doc_before_update.get(dimension) != self.get(dimension):
needs_repost = 1
break
# Check for child tables
if self.check_if_child_table_updated(
"items",
doc_before_update,
("income_account", "expense_account", "discount_account"),
accounting_dimensions,
):
needs_repost = 1
if self.check_if_child_table_updated(
"taxes", doc_before_update, ("account_head",), accounting_dimensions
):
needs_repost = 1
self.validate_accounts()
# validate if deferred revenue is enabled for any item
# Don't allow to update the invoice if deferred revenue is enabled
for item in self.get("items"):
if item.enable_deferred_revenue:
frappe.throw(
_(
"Deferred Revenue is enabled for item {0}. You cannot update the invoice after submission."
).format(item.item_code)
)
self.db_set("repost_required", needs_repost)
def check_if_child_table_updated(
self, child_table, doc_before_update, fields_to_check, accounting_dimensions
):
# Check if any field affecting accounting entry is altered
for index, item in enumerate(self.get(child_table)):
for field in fields_to_check:
if doc_before_update.get(child_table)[index].get(field) != item.get(field):
return True
for dimension in accounting_dimensions:
if doc_before_update.get(child_table)[index].get(dimension) != item.get(dimension):
return True
return False
@frappe.whitelist()
def repost_accounting_entries(self):
if self.repost_required:
self.docstatus = 2
self.make_gl_entries_on_cancel()
self.docstatus = 1
self.make_gl_entries()
self.db_set("repost_required", 0)
else:
frappe.throw(_("No updates pending for reposting"))
def set_paid_amount(self): def set_paid_amount(self):
paid_amount = 0.0 paid_amount = 0.0

View File

@@ -0,0 +1,83 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2023-08-22 10:28:10.196712",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"account",
"party_type",
"party",
"reference_doctype",
"reference_name",
"allocated_amount",
"account_currency",
"unlinked"
],
"fields": [
{
"fieldname": "reference_name",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Reference Name",
"options": "reference_doctype"
},
{
"fieldname": "allocated_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Allocated Amount",
"options": "account_currency"
},
{
"default": "0",
"fieldname": "unlinked",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Unlinked",
"read_only": 1
},
{
"fieldname": "reference_doctype",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Reference Type",
"options": "DocType"
},
{
"fieldname": "account",
"fieldtype": "Data",
"label": "Account"
},
{
"fieldname": "party_type",
"fieldtype": "Data",
"label": "Party Type"
},
{
"fieldname": "party",
"fieldtype": "Data",
"label": "Party"
},
{
"fieldname": "account_currency",
"fieldtype": "Link",
"label": "Account Currency",
"options": "Currency",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-09-05 09:33:28.620149",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Unreconcile Payment Entries",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class UnreconcilePaymentEntries(Document):
pass

View File

@@ -0,0 +1,316 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import today
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
class TestUnreconcilePayments(AccountsTestMixin, FrappeTestCase):
def setUp(self):
self.create_company()
self.create_customer()
self.create_usd_receivable_account()
self.create_item()
self.clear_old_entries()
def tearDown(self):
frappe.db.rollback()
def create_sales_invoice(self, do_not_submit=False):
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_submit=do_not_submit,
)
return si
def create_payment_entry(self):
pe = create_payment_entry(
company=self.company,
payment_type="Receive",
party_type="Customer",
party=self.customer,
paid_from=self.debit_to,
paid_to=self.cash,
paid_amount=200,
save=True,
)
return pe
def test_01_unreconcile_invoice(self):
si1 = self.create_sales_invoice()
si2 = self.create_sales_invoice()
pe = self.create_payment_entry()
pe.append(
"references",
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 100},
)
pe.append(
"references",
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 100},
)
# Allocation payment against both invoices
pe.save().submit()
# Assert outstanding
[doc.reload() for doc in [si1, si2, pe]]
self.assertEqual(si1.outstanding_amount, 0)
self.assertEqual(si2.outstanding_amount, 0)
self.assertEqual(pe.unallocated_amount, 0)
unreconcile = frappe.get_doc(
{
"doctype": "Unreconcile Payments",
"company": self.company,
"voucher_type": pe.doctype,
"voucher_no": pe.name,
}
)
unreconcile.add_references()
self.assertEqual(len(unreconcile.allocations), 2)
allocations = [x.reference_name for x in unreconcile.allocations]
self.assertEquals([si1.name, si2.name], allocations)
# unreconcile si1
for x in unreconcile.allocations:
if x.reference_name != si1.name:
unreconcile.remove(x)
unreconcile.save().submit()
# Assert outstanding
[doc.reload() for doc in [si1, si2, pe]]
self.assertEqual(si1.outstanding_amount, 100)
self.assertEqual(si2.outstanding_amount, 0)
self.assertEqual(len(pe.references), 1)
self.assertEqual(pe.unallocated_amount, 100)
def test_02_unreconcile_one_payment_from_multi_payments(self):
"""
Scenario: 2 payments, both split against 2 different invoices
Unreconcile only one payment from one invoice
"""
si1 = self.create_sales_invoice()
si2 = self.create_sales_invoice()
pe1 = self.create_payment_entry()
pe1.paid_amount = 100
# Allocate payment against both invoices
pe1.append(
"references",
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50},
)
pe1.append(
"references",
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50},
)
pe1.save().submit()
pe2 = self.create_payment_entry()
pe2.paid_amount = 100
# Allocate payment against both invoices
pe2.append(
"references",
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50},
)
pe2.append(
"references",
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50},
)
pe2.save().submit()
# Assert outstanding and unallocated
[doc.reload() for doc in [si1, si2, pe1, pe2]]
self.assertEqual(si1.outstanding_amount, 0.0)
self.assertEqual(si2.outstanding_amount, 0.0)
self.assertEqual(pe1.unallocated_amount, 0.0)
self.assertEqual(pe2.unallocated_amount, 0.0)
unreconcile = frappe.get_doc(
{
"doctype": "Unreconcile Payments",
"company": self.company,
"voucher_type": pe2.doctype,
"voucher_no": pe2.name,
}
)
unreconcile.add_references()
self.assertEqual(len(unreconcile.allocations), 2)
allocations = [x.reference_name for x in unreconcile.allocations]
self.assertEquals([si1.name, si2.name], allocations)
# unreconcile si1 from pe2
for x in unreconcile.allocations:
if x.reference_name != si1.name:
unreconcile.remove(x)
unreconcile.save().submit()
# Assert outstanding and unallocated
[doc.reload() for doc in [si1, si2, pe1, pe2]]
self.assertEqual(si1.outstanding_amount, 50)
self.assertEqual(si2.outstanding_amount, 0)
self.assertEqual(len(pe1.references), 2)
self.assertEqual(len(pe2.references), 1)
self.assertEqual(pe1.unallocated_amount, 0)
self.assertEqual(pe2.unallocated_amount, 50)
def test_03_unreconciliation_on_multi_currency_invoice(self):
self.create_customer("_Test MC Customer USD", "USD")
si1 = self.create_sales_invoice(do_not_submit=True)
si1.currency = "USD"
si1.debit_to = self.debtors_usd
si1.conversion_rate = 80
si1.save().submit()
si2 = self.create_sales_invoice(do_not_submit=True)
si2.currency = "USD"
si2.debit_to = self.debtors_usd
si2.conversion_rate = 80
si2.save().submit()
pe = self.create_payment_entry()
pe.paid_from = self.debtors_usd
pe.paid_from_account_currency = "USD"
pe.source_exchange_rate = 75
pe.received_amount = 75 * 200
pe.save()
# Allocate payment against both invoices
pe.append(
"references",
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 100},
)
pe.append(
"references",
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 100},
)
pe.save().submit()
unreconcile = frappe.get_doc(
{
"doctype": "Unreconcile Payments",
"company": self.company,
"voucher_type": pe.doctype,
"voucher_no": pe.name,
}
)
unreconcile.add_references()
self.assertEqual(len(unreconcile.allocations), 2)
allocations = [x.reference_name for x in unreconcile.allocations]
self.assertEquals([si1.name, si2.name], allocations)
# unreconcile si1 from pe
for x in unreconcile.allocations:
if x.reference_name != si1.name:
unreconcile.remove(x)
unreconcile.save().submit()
# Assert outstanding and unallocated
[doc.reload() for doc in [si1, si2, pe]]
self.assertEqual(si1.outstanding_amount, 100)
self.assertEqual(si2.outstanding_amount, 0)
self.assertEqual(len(pe.references), 1)
self.assertEqual(pe.unallocated_amount, 100)
# Exc gain/loss JE should've been cancelled as well
self.assertEqual(
frappe.db.count(
"Journal Entry Account",
filters={"reference_type": si1.doctype, "reference_name": si1.name, "docstatus": 1},
),
0,
)
def test_04_unreconciliation_on_multi_currency_invoice(self):
"""
2 payments split against 2 foreign currency invoices
"""
self.create_customer("_Test MC Customer USD", "USD")
si1 = self.create_sales_invoice(do_not_submit=True)
si1.currency = "USD"
si1.debit_to = self.debtors_usd
si1.conversion_rate = 80
si1.save().submit()
si2 = self.create_sales_invoice(do_not_submit=True)
si2.currency = "USD"
si2.debit_to = self.debtors_usd
si2.conversion_rate = 80
si2.save().submit()
pe1 = self.create_payment_entry()
pe1.paid_from = self.debtors_usd
pe1.paid_from_account_currency = "USD"
pe1.source_exchange_rate = 75
pe1.received_amount = 75 * 100
pe1.save()
# Allocate payment against both invoices
pe1.append(
"references",
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50},
)
pe1.append(
"references",
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50},
)
pe1.save().submit()
pe2 = self.create_payment_entry()
pe2.paid_from = self.debtors_usd
pe2.paid_from_account_currency = "USD"
pe2.source_exchange_rate = 75
pe2.received_amount = 75 * 100
pe2.save()
# Allocate payment against both invoices
pe2.append(
"references",
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50},
)
pe2.append(
"references",
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50},
)
pe2.save().submit()
unreconcile = frappe.get_doc(
{
"doctype": "Unreconcile Payments",
"company": self.company,
"voucher_type": pe2.doctype,
"voucher_no": pe2.name,
}
)
unreconcile.add_references()
self.assertEqual(len(unreconcile.allocations), 2)
allocations = [x.reference_name for x in unreconcile.allocations]
self.assertEquals([si1.name, si2.name], allocations)
# unreconcile si1 from pe2
for x in unreconcile.allocations:
if x.reference_name != si1.name:
unreconcile.remove(x)
unreconcile.save().submit()
# Assert outstanding and unallocated
[doc.reload() for doc in [si1, si2, pe1, pe2]]
self.assertEqual(si1.outstanding_amount, 50)
self.assertEqual(si2.outstanding_amount, 0)
self.assertEqual(len(pe1.references), 2)
self.assertEqual(len(pe2.references), 1)
self.assertEqual(pe1.unallocated_amount, 0)
self.assertEqual(pe2.unallocated_amount, 50)
# Exc gain/loss JE from PE1 should be available
self.assertEqual(
frappe.db.count(
"Journal Entry Account",
filters={"reference_type": si1.doctype, "reference_name": si1.name, "docstatus": 1},
),
1,
)

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("Unreconcile Payments", {
refresh(frm) {
frm.set_query("voucher_type", function() {
return {
filters: {
name: ["in", ["Payment Entry", "Journal Entry"]]
}
}
});
frm.set_query("voucher_no", function(doc) {
return {
filters: {
company: doc.company,
docstatus: 1
}
}
});
},
get_allocations: function(frm) {
frm.clear_table("allocations");
frappe.call({
method: "get_allocations_from_payment",
doc: frm.doc,
callback: function(r) {
if (r.message) {
r.message.forEach(x => {
frm.add_child("allocations", x)
})
frm.refresh_fields();
}
}
})
}
});

View File

@@ -0,0 +1,93 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "format:UNREC-{#####}",
"creation": "2023-08-22 10:26:34.421423",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"company",
"voucher_type",
"voucher_no",
"get_allocations",
"allocations",
"amended_from"
],
"fields": [
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Unreconcile Payments",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company"
},
{
"fieldname": "voucher_type",
"fieldtype": "Link",
"label": "Voucher Type",
"options": "DocType"
},
{
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
"label": "Voucher No",
"options": "voucher_type"
},
{
"fieldname": "get_allocations",
"fieldtype": "Button",
"label": "Get Allocations"
},
{
"fieldname": "allocations",
"fieldtype": "Table",
"label": "Allocations",
"options": "Unreconcile Payment Entries"
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2023-08-28 17:42:50.261377",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Unreconcile Payments",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"read": 1,
"role": "Accounts Manager",
"select": 1,
"share": 1,
"submit": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"read": 1,
"role": "Accounts User",
"select": 1,
"share": 1,
"submit": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -0,0 +1,158 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _, qb
from frappe.model.document import Document
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Abs, Sum
from frappe.utils.data import comma_and
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
unlink_ref_doc_from_payment_entries,
update_voucher_outstanding,
)
class UnreconcilePayments(Document):
def validate(self):
self.supported_types = ["Payment Entry", "Journal Entry"]
if not self.voucher_type in self.supported_types:
frappe.throw(_("Only {0} are supported").format(comma_and(self.supported_types)))
@frappe.whitelist()
def get_allocations_from_payment(self):
allocated_references = []
ple = qb.DocType("Payment Ledger Entry")
allocated_references = (
qb.from_(ple)
.select(
ple.account,
ple.party_type,
ple.party,
ple.against_voucher_type.as_("reference_doctype"),
ple.against_voucher_no.as_("reference_name"),
Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"),
ple.account_currency,
)
.where(
(ple.docstatus == 1)
& (ple.voucher_type == self.voucher_type)
& (ple.voucher_no == self.voucher_no)
& (ple.voucher_no != ple.against_voucher_no)
)
.groupby(ple.against_voucher_type, ple.against_voucher_no)
.run(as_dict=True)
)
return allocated_references
def add_references(self):
allocations = self.get_allocations_from_payment()
for alloc in allocations:
self.append("allocations", alloc)
def on_submit(self):
# todo: more granular unreconciliation
for alloc in self.allocations:
doc = frappe.get_doc(alloc.reference_doctype, alloc.reference_name)
unlink_ref_doc_from_payment_entries(doc, self.voucher_no)
cancel_exchange_gain_loss_journal(doc, self.voucher_type, self.voucher_no)
update_voucher_outstanding(
alloc.reference_doctype, alloc.reference_name, alloc.account, alloc.party_type, alloc.party
)
frappe.db.set_value("Unreconcile Payment Entries", alloc.name, "unlinked", True)
@frappe.whitelist()
def doc_has_references(doctype: str = None, docname: str = None):
if doctype in ["Sales Invoice", "Purchase Invoice"]:
return frappe.db.count(
"Payment Ledger Entry",
filters={"delinked": 0, "against_voucher_no": docname, "amount": ["<", 0]},
)
else:
return frappe.db.count(
"Payment Ledger Entry",
filters={"delinked": 0, "voucher_no": docname, "against_voucher_no": ["!=", docname]},
)
@frappe.whitelist()
def get_linked_payments_for_doc(
company: str = None, doctype: str = None, docname: str = None
) -> list:
if company and doctype and docname:
_dt = doctype
_dn = docname
ple = qb.DocType("Payment Ledger Entry")
if _dt in ["Sales Invoice", "Purchase Invoice"]:
criteria = [
(ple.company == company),
(ple.delinked == 0),
(ple.against_voucher_no == _dn),
(ple.amount < 0),
]
res = (
qb.from_(ple)
.select(
ple.company,
ple.voucher_type,
ple.voucher_no,
Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"),
ple.account_currency,
)
.where(Criterion.all(criteria))
.groupby(ple.voucher_no, ple.against_voucher_no)
.having(qb.Field("allocated_amount") > 0)
.run(as_dict=True)
)
return res
else:
criteria = [
(ple.company == company),
(ple.delinked == 0),
(ple.voucher_no == _dn),
(ple.against_voucher_no != _dn),
]
query = (
qb.from_(ple)
.select(
ple.company,
ple.against_voucher_type.as_("voucher_type"),
ple.against_voucher_no.as_("voucher_no"),
Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"),
ple.account_currency,
)
.where(Criterion.all(criteria))
.groupby(ple.against_voucher_no)
)
res = query.run(as_dict=True)
return res
return []
@frappe.whitelist()
def create_unreconcile_doc_for_selection(selections=None):
if selections:
selections = frappe.json.loads(selections)
# assuming each row is a unique voucher
for row in selections:
unrecon = frappe.new_doc("Unreconcile Payments")
unrecon.company = row.get("company")
unrecon.voucher_type = row.get("voucher_type")
unrecon.voucher_no = row.get("voucher_no")
unrecon.add_references()
# remove unselected references
unrecon.allocations = [
x
for x in unrecon.allocations
if x.reference_doctype == row.get("against_voucher_type")
and x.reference_name == row.get("against_voucher_no")
]
unrecon.save().submit()

View File

@@ -46,6 +46,7 @@ def get_data(filters):
.select( .select(
gle.voucher_type, gle.voucher_no, Sum(gle.debit).as_("debit"), Sum(gle.credit).as_("credit") gle.voucher_type, gle.voucher_no, Sum(gle.debit).as_("debit"), Sum(gle.credit).as_("credit")
) )
.where(gle.is_cancelled == 0)
.groupby(gle.voucher_no) .groupby(gle.voucher_no)
) )
query = apply_filters(query, filters, gle) query = apply_filters(query, filters, gle)

View File

@@ -663,7 +663,9 @@ def update_reference_in_payment_entry(
payment_entry.save(ignore_permissions=True) payment_entry.save(ignore_permissions=True)
def cancel_exchange_gain_loss_journal(parent_doc: dict | object) -> None: def cancel_exchange_gain_loss_journal(
parent_doc: dict | object, referenced_dt: str = None, referenced_dn: str = None
) -> None:
""" """
Cancel Exchange Gain/Loss for Sales/Purchase Invoice, if they have any. Cancel Exchange Gain/Loss for Sales/Purchase Invoice, if they have any.
""" """
@@ -690,76 +692,147 @@ def cancel_exchange_gain_loss_journal(parent_doc: dict | object) -> None:
as_list=1, as_list=1,
) )
for doc in gain_loss_journals: for doc in gain_loss_journals:
frappe.get_doc("Journal Entry", doc[0]).cancel() gain_loss_je = frappe.get_doc("Journal Entry", doc[0])
if referenced_dt and referenced_dn:
references = [(x.reference_type, x.reference_name) for x in gain_loss_je.accounts]
if (
len(references) == 2
and (referenced_dt, referenced_dn) in references
and (parent_doc.doctype, parent_doc.name) in references
):
# only cancel JE generated against parent_doc and referenced_dn
gain_loss_je.cancel()
else:
gain_loss_je.cancel()
def unlink_ref_doc_from_payment_entries(ref_doc): def update_accounting_ledgers_after_reference_removal(
remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name) ref_type: str = None, ref_no: str = None, payment_name: str = None
remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name) ):
# General Ledger
frappe.db.sql( gle = qb.DocType("GL Entry")
"""update `tabGL Entry` gle_update_query = (
set against_voucher_type=null, against_voucher=null, qb.update(gle)
modified=%s, modified_by=%s .set(gle.against_voucher_type, None)
where against_voucher_type=%s and against_voucher=%s .set(gle.against_voucher, None)
and voucher_no != ifnull(against_voucher, '')""", .set(gle.modified, now())
(now(), frappe.session.user, ref_doc.doctype, ref_doc.name), .set(gle.modified_by, frappe.session.user)
.where((gle.against_voucher_type == ref_type) & (gle.against_voucher == ref_no))
) )
if payment_name:
gle_update_query = gle_update_query.where(gle.voucher_no == payment_name)
gle_update_query.run()
# Payment Ledger
ple = qb.DocType("Payment Ledger Entry") ple = qb.DocType("Payment Ledger Entry")
ple_update_query = (
qb.update(ple)
.set(ple.against_voucher_type, ple.voucher_type)
.set(ple.against_voucher_no, ple.voucher_no)
.set(ple.modified, now())
.set(ple.modified_by, frappe.session.user)
.where(
(ple.against_voucher_type == ref_type)
& (ple.against_voucher_no == ref_no)
& (ple.delinked == 0)
)
)
qb.update(ple).set(ple.against_voucher_type, ple.voucher_type).set( if payment_name:
ple.against_voucher_no, ple.voucher_no ple_update_query = ple_update_query.where(ple.voucher_no == payment_name)
).set(ple.modified, now()).set(ple.modified_by, frappe.session.user).where( ple_update_query.run()
(ple.against_voucher_type == ref_doc.doctype)
& (ple.against_voucher_no == ref_doc.name)
& (ple.delinked == 0)
).run()
def remove_ref_from_advance_section(ref_doc: object = None):
# TODO: this might need some testing
if ref_doc.doctype in ("Sales Invoice", "Purchase Invoice"): if ref_doc.doctype in ("Sales Invoice", "Purchase Invoice"):
ref_doc.set("advances", []) ref_doc.set("advances", [])
adv_type = qb.DocType(f"{ref_doc.doctype} Advance")
frappe.db.sql( qb.from_(adv_type).delete().where(adv_type.parent == ref_doc.name).run()
"""delete from `tab{0} Advance` where parent = %s""".format(ref_doc.doctype), ref_doc.name
)
def remove_ref_doc_link_from_jv(ref_type, ref_no): def unlink_ref_doc_from_payment_entries(ref_doc: object = None, payment_name: str = None):
linked_jv = frappe.db.sql_list( remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name, payment_name)
"""select parent from `tabJournal Entry Account` remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name, payment_name)
where reference_type=%s and reference_name=%s and docstatus < 2""", update_accounting_ledgers_after_reference_removal(ref_doc.doctype, ref_doc.name, payment_name)
(ref_type, ref_no), remove_ref_from_advance_section(ref_doc)
def remove_ref_doc_link_from_jv(
ref_type: str = None, ref_no: str = None, payment_name: str = None
):
jea = qb.DocType("Journal Entry Account")
linked_jv = (
qb.from_(jea)
.select(jea.parent)
.where((jea.reference_type == ref_type) & (jea.reference_name == ref_no) & (jea.docstatus.lt(2)))
.run(as_list=1)
) )
linked_jv = convert_to_list(linked_jv)
# remove reference only from specified payment
linked_jv = [x for x in linked_jv if x == payment_name] if payment_name else linked_jv
if linked_jv: if linked_jv:
frappe.db.sql( update_query = (
"""update `tabJournal Entry Account` qb.update(jea)
set reference_type=null, reference_name = null, .set(jea.reference_type, None)
modified=%s, modified_by=%s .set(jea.reference_name, None)
where reference_type=%s and reference_name=%s .set(jea.modified, now())
and docstatus < 2""", .set(jea.modified_by, frappe.session.user)
(now(), frappe.session.user, ref_type, ref_no), .where((jea.reference_type == ref_type) & (jea.reference_name == ref_no))
) )
if payment_name:
update_query = update_query.where(jea.parent == payment_name)
update_query.run()
frappe.msgprint(_("Journal Entries {0} are un-linked").format("\n".join(linked_jv))) frappe.msgprint(_("Journal Entries {0} are un-linked").format("\n".join(linked_jv)))
def remove_ref_doc_link_from_pe(ref_type, ref_no): def convert_to_list(result):
linked_pe = frappe.db.sql_list( """
"""select parent from `tabPayment Entry Reference` Convert tuple to list
where reference_doctype=%s and reference_name=%s and docstatus < 2""", """
(ref_type, ref_no), return [x[0] for x in result]
def remove_ref_doc_link_from_pe(
ref_type: str = None, ref_no: str = None, payment_name: str = None
):
per = qb.DocType("Payment Entry Reference")
pay = qb.DocType("Payment Entry")
linked_pe = (
qb.from_(per)
.select(per.parent)
.where(
(per.reference_doctype == ref_type) & (per.reference_name == ref_no) & (per.docstatus.lt(2))
)
.run(as_list=1)
) )
linked_pe = convert_to_list(linked_pe)
# remove reference only from specified payment
linked_pe = [x for x in linked_pe if x == payment_name] if payment_name else linked_pe
if linked_pe: if linked_pe:
frappe.db.sql( update_query = (
"""update `tabPayment Entry Reference` qb.update(per)
set allocated_amount=0, modified=%s, modified_by=%s .set(per.allocated_amount, 0)
where reference_doctype=%s and reference_name=%s .set(per.modified, now())
and docstatus < 2""", .set(per.modified_by, frappe.session.user)
(now(), frappe.session.user, ref_type, ref_no), .where(
(per.docstatus.lt(2) & (per.reference_doctype == ref_type) & (per.reference_name == ref_no))
)
) )
if payment_name:
update_query = update_query.where(per.parent == payment_name)
update_query.run()
for pe in linked_pe: for pe in linked_pe:
try: try:
pe_doc = frappe.get_doc("Payment Entry", pe) pe_doc = frappe.get_doc("Payment Entry", pe)
@@ -772,19 +845,13 @@ def remove_ref_doc_link_from_pe(ref_type, ref_no):
msg += _("Please cancel payment entry manually first") msg += _("Please cancel payment entry manually first")
frappe.throw(msg, exc=PaymentEntryUnlinkError, title=_("Payment Unlink Error")) frappe.throw(msg, exc=PaymentEntryUnlinkError, title=_("Payment Unlink Error"))
frappe.db.sql( qb.update(pay).set(pay.total_allocated_amount, pe_doc.total_allocated_amount).set(
"""update `tabPayment Entry` set total_allocated_amount=%s, pay.base_total_allocated_amount, pe_doc.base_total_allocated_amount
base_total_allocated_amount=%s, unallocated_amount=%s, modified=%s, modified_by=%s ).set(pay.unallocated_amount, pe_doc.unallocated_amount).set(pay.modified, now()).set(
where name=%s""", pay.modified_by, frappe.session.user
( ).where(
pe_doc.total_allocated_amount, pay.name == pe
pe_doc.base_total_allocated_amount, ).run()
pe_doc.unallocated_amount,
now(),
frappe.session.user,
pe,
),
)
frappe.msgprint(_("Payment Entries {0} are un-linked").format("\n".join(linked_pe))) frappe.msgprint(_("Payment Entries {0} are un-linked").format("\n".join(linked_pe)))

View File

@@ -226,7 +226,7 @@ class TestAssetCapitalization(unittest.TestCase):
asset_capitalization = create_asset_capitalization( asset_capitalization = create_asset_capitalization(
entry_type="Capitalization", entry_type="Capitalization",
capitalization_method="Choose a WIP composite asset", capitalization_method="Choose a WIP composite asset",
target_asset=wip_composite_asset, target_asset=wip_composite_asset.name,
target_asset_location="Test Location", target_asset_location="Test Location",
stock_qty=stock_qty, stock_qty=stock_qty,
stock_rate=stock_rate, stock_rate=stock_rate,

View File

@@ -211,12 +211,70 @@ class AccountsController(TransactionBase):
def before_cancel(self): def before_cancel(self):
validate_einvoice_fields(self) validate_einvoice_fields(self)
def _remove_references_in_unreconcile(self):
upe = frappe.qb.DocType("Unreconcile Payment Entries")
rows = (
frappe.qb.from_(upe)
.select(upe.name, upe.parent)
.where((upe.reference_doctype == self.doctype) & (upe.reference_name == self.name))
.run(as_dict=True)
)
if rows:
references_map = frappe._dict()
for x in rows:
references_map.setdefault(x.parent, []).append(x.name)
for doc, rows in references_map.items():
unreconcile_doc = frappe.get_doc("Unreconcile Payments", doc)
for row in rows:
unreconcile_doc.remove(unreconcile_doc.get("allocations", {"name": row})[0])
unreconcile_doc.flags.ignore_validate_update_after_submit = True
unreconcile_doc.flags.ignore_links = True
unreconcile_doc.save(ignore_permissions=True)
# delete docs upon parent doc deletion
unreconcile_docs = frappe.db.get_all("Unreconcile Payments", filters={"voucher_no": self.name})
for x in unreconcile_docs:
_doc = frappe.get_doc("Unreconcile Payments", x.name)
if _doc.docstatus == 1:
_doc.cancel()
_doc.delete()
def _remove_references_in_repost_doctypes(self):
repost_doctypes = ["Repost Payment Ledger Items", "Repost Accounting Ledger Items"]
for _doctype in repost_doctypes:
dt = frappe.qb.DocType(_doctype)
rows = (
frappe.qb.from_(dt)
.select(dt.name, dt.parent, dt.parenttype)
.where((dt.voucher_type == self.doctype) & (dt.voucher_no == self.name))
.run(as_dict=True)
)
if rows:
references_map = frappe._dict()
for x in rows:
references_map.setdefault((x.parenttype, x.parent), []).append(x.name)
for doc, rows in references_map.items():
repost_doc = frappe.get_doc(doc[0], doc[1])
for row in rows:
if _doctype == "Repost Payment Ledger Items":
repost_doc.remove(repost_doc.get("repost_vouchers", {"name": row})[0])
else:
repost_doc.remove(repost_doc.get("vouchers", {"name": row})[0])
repost_doc.flags.ignore_validate_update_after_submit = True
repost_doc.flags.ignore_links = True
repost_doc.save(ignore_permissions=True)
def on_trash(self): def on_trash(self):
# delete references in 'Repost Payment Ledger' self._remove_references_in_repost_doctypes()
rpi = frappe.qb.DocType("Repost Payment Ledger Items") self._remove_references_in_unreconcile()
frappe.qb.from_(rpi).delete().where(
(rpi.voucher_type == self.doctype) & (rpi.voucher_no == self.name)
).run()
# delete sl and gl entries on deletion of transaction # delete sl and gl entries on deletion of transaction
if frappe.db.get_single_value("Accounts Settings", "delete_linked_ledger_entries"): if frappe.db.get_single_value("Accounts Settings", "delete_linked_ledger_entries"):
@@ -909,7 +967,7 @@ class AccountsController(TransactionBase):
party_type, party, party_account, amount_field, order_doctype, order_list, include_unallocated party_type, party, party_account, amount_field, order_doctype, order_list, include_unallocated
) )
payment_entries = get_advance_payment_entries( payment_entries = get_advance_payment_entries_for_regional(
party_type, party, party_account, order_doctype, order_list, include_unallocated party_type, party, party_account, order_doctype, order_list, include_unallocated
) )
@@ -2126,6 +2184,45 @@ class AccountsController(TransactionBase):
_("Select finance book for the item {0} at row {1}").format(item.item_code, item.idx) _("Select finance book for the item {0} at row {1}").format(item.item_code, item.idx)
) )
def check_if_fields_updated(self, fields_to_check, child_tables):
# Check if any field affecting accounting entry is altered
doc_before_update = self.get_doc_before_save()
accounting_dimensions = get_accounting_dimensions() + ["cost_center", "project"]
# Check if opening entry check updated
needs_repost = doc_before_update.get("is_opening") != self.is_opening
if not needs_repost:
# Parent Level Accounts excluding party account
fields_to_check += accounting_dimensions
for field in fields_to_check:
if doc_before_update.get(field) != self.get(field):
needs_repost = 1
break
if not needs_repost:
# Check for child tables
for table in child_tables:
needs_repost = check_if_child_table_updated(
doc_before_update.get(table), self.get(table), child_tables[table]
)
if needs_repost:
break
return needs_repost
@frappe.whitelist()
def repost_accounting_entries(self):
if self.repost_required:
repost_ledger = frappe.new_doc("Repost Accounting Ledger")
repost_ledger.company = self.company
repost_ledger.append("vouchers", {"voucher_type": self.doctype, "voucher_no": self.name})
repost_ledger.insert()
repost_ledger.submit()
self.db_set("repost_required", 0)
else:
frappe.throw(_("No updates pending for reposting"))
@frappe.whitelist() @frappe.whitelist()
def get_tax_rate(account_head): def get_tax_rate(account_head):
@@ -2349,6 +2446,11 @@ def get_advance_journal_entries(
return list(journal_entries) return list(journal_entries)
@erpnext.allow_regional
def get_advance_payment_entries_for_regional(*args, **kwargs):
return get_advance_payment_entries(*args, **kwargs)
def get_advance_payment_entries( def get_advance_payment_entries(
party_type, party_type,
party, party,
@@ -3006,6 +3108,23 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
parent.set_status() parent.set_status()
def check_if_child_table_updated(
child_table_before_update, child_table_after_update, fields_to_check
):
accounting_dimensions = get_accounting_dimensions() + ["cost_center", "project"]
# Check if any field affecting accounting entry is altered
for index, item in enumerate(child_table_after_update):
for field in fields_to_check:
if child_table_before_update[index].get(field) != item.get(field):
return True
for dimension in accounting_dimensions:
if child_table_before_update[index].get(dimension) != item.get(dimension):
return True
return False
@erpnext.allow_regional @erpnext.allow_regional
def validate_regional(doc): def validate_regional(doc):
pass pass

View File

@@ -14,7 +14,8 @@ from erpnext.buying.utils import update_last_purchase_rate, validate_for_items
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
from erpnext.controllers.subcontracting_controller import SubcontractingController from erpnext.controllers.subcontracting_controller import SubcontractingController
from erpnext.stock.get_item_details import get_conversion_factor from erpnext.stock.get_item_details import get_conversion_factor
from erpnext.stock.utils import get_incoming_rate from erpnext.stock.stock_ledger import get_previous_sle
from erpnext.stock.utils import get_incoming_rate, get_valuation_method
class QtyMismatchError(ValidationError): class QtyMismatchError(ValidationError):
@@ -514,9 +515,20 @@ class BuyingController(SubcontractingController):
) )
if self.is_return: if self.is_return:
outgoing_rate = get_rate_for_return( if get_valuation_method(d.item_code) == "Moving Average":
self.doctype, self.name, d.item_code, self.return_against, item_row=d previous_sle = get_previous_sle(
) {
"item_code": d.item_code,
"warehouse": d.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
}
)
outgoing_rate = flt(previous_sle.get("valuation_rate"))
else:
outgoing_rate = get_rate_for_return(
self.doctype, self.name, d.item_code, self.return_against, item_row=d
)
sle.update({"outgoing_rate": outgoing_rate, "recalculate_rate": 1}) sle.update({"outgoing_rate": outgoing_rate, "recalculate_rate": 1})
if d.from_warehouse: if d.from_warehouse:

View File

@@ -190,7 +190,9 @@ class calculate_taxes_and_totals(object):
item.net_rate = item.rate item.net_rate = item.rate
if not item.qty and self.doc.get("is_return"): if (
not item.qty and self.doc.get("is_return") and self.doc.get("doctype") != "Purchase Receipt"
):
item.amount = flt(-1 * item.rate, item.precision("amount")) item.amount = flt(-1 * item.rate, item.precision("amount"))
elif not item.qty and self.doc.get("is_debit_note"): elif not item.qty and self.doc.get("is_debit_note"):
item.amount = flt(item.rate, item.precision("amount")) item.amount = flt(item.rate, item.precision("amount"))

View File

@@ -4,6 +4,7 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.query_builder.functions import Concat_ws, Date
def execute(filters=None): def execute(filters=None):
@@ -69,53 +70,41 @@ def get_columns():
def get_data(filters): def get_data(filters):
return frappe.db.sql( lead = frappe.qb.DocType("Lead")
""" address = frappe.qb.DocType("Address")
SELECT dynamic_link = frappe.qb.DocType("Dynamic Link")
`tabLead`.name,
`tabLead`.lead_name, query = (
`tabLead`.status, frappe.qb.from_(lead)
`tabLead`.lead_owner, .left_join(dynamic_link)
`tabLead`.territory, .on((lead.name == dynamic_link.link_name) & (dynamic_link.parenttype == "Address"))
`tabLead`.source, .left_join(address)
`tabLead`.email_id, .on(address.name == dynamic_link.parent)
`tabLead`.mobile_no, .select(
`tabLead`.phone, lead.name,
`tabLead`.owner, lead.lead_name,
`tabLead`.company, lead.status,
concat_ws(', ', lead.lead_owner,
trim(',' from `tabAddress`.address_line1), lead.territory,
trim(',' from tabAddress.address_line2) lead.source,
) AS address, lead.email_id,
`tabAddress`.state, lead.mobile_no,
`tabAddress`.pincode, lead.phone,
`tabAddress`.country lead.owner,
FROM lead.company,
`tabLead` left join `tabDynamic Link` on ( (Concat_ws(", ", address.address_line1, address.address_line2)).as_("address"),
`tabLead`.name = `tabDynamic Link`.link_name and address.state,
`tabDynamic Link`.parenttype = 'Address') address.pincode,
left join `tabAddress` on ( address.country,
`tabAddress`.name=`tabDynamic Link`.parent) )
WHERE .where(lead.company == filters.company)
company = %(company)s .where(Date(lead.creation).between(filters.from_date, filters.to_date))
AND DATE(`tabLead`.creation) BETWEEN %(from_date)s AND %(to_date)s
{conditions}
ORDER BY
`tabLead`.creation asc """.format(
conditions=get_conditions(filters)
),
filters,
as_dict=1,
) )
def get_conditions(filters):
conditions = []
if filters.get("territory"): if filters.get("territory"):
conditions.append(" and `tabLead`.territory=%(territory)s") query = query.where(lead.territory == filters.get("territory"))
if filters.get("status"): if filters.get("status"):
conditions.append(" and `tabLead`.status=%(status)s") query = query.where(lead.status == filters.get("status"))
return " ".join(conditions) if conditions else "" return query.run(as_dict=1)

View File

@@ -17,7 +17,6 @@ from erpnext.e_commerce.shopping_cart.cart import (
request_for_quotation, request_for_quotation,
update_cart, update_cart,
) )
from erpnext.tests.utils import create_test_contact_and_address
class TestShoppingCart(unittest.TestCase): class TestShoppingCart(unittest.TestCase):
@@ -28,7 +27,6 @@ class TestShoppingCart(unittest.TestCase):
def setUp(self): def setUp(self):
frappe.set_user("Administrator") frappe.set_user("Administrator")
create_test_contact_and_address()
self.enable_shopping_cart() self.enable_shopping_cart()
if not frappe.db.exists("Website Item", {"item_code": "_Test Item"}): if not frappe.db.exists("Website Item", {"item_code": "_Test Item"}):
make_website_item(frappe.get_cached_doc("Item", "_Test Item")) make_website_item(frappe.get_cached_doc("Item", "_Test Item"))
@@ -46,48 +44,57 @@ class TestShoppingCart(unittest.TestCase):
frappe.db.sql("delete from `tabTax Rule`") frappe.db.sql("delete from `tabTax Rule`")
def test_get_cart_new_user(self): def test_get_cart_new_user(self):
self.login_as_new_user() self.login_as_customer(
"test_contact_two_customer@example.com", "_Test Contact 2 For _Test Customer"
)
create_address_and_contact(
address_title="_Test Address for Customer 2",
first_name="_Test Contact for Customer 2",
email="test_contact_two_customer@example.com",
customer="_Test Customer 2",
)
# test if lead is created and quotation with new lead is fetched # test if lead is created and quotation with new lead is fetched
quotation = _get_cart_quotation() customer = frappe.get_doc("Customer", "_Test Customer 2")
quotation = _get_cart_quotation(party=customer)
self.assertEqual(quotation.quotation_to, "Customer") self.assertEqual(quotation.quotation_to, "Customer")
self.assertEqual( self.assertEqual(
quotation.contact_person, quotation.contact_person,
frappe.db.get_value("Contact", dict(email_id="test_cart_user@example.com")), frappe.db.get_value("Contact", dict(email_id="test_contact_two_customer@example.com")),
) )
self.assertEqual(quotation.contact_email, frappe.session.user) self.assertEqual(quotation.contact_email, frappe.session.user)
return quotation return quotation
def test_get_cart_customer(self): def test_get_cart_customer(self, customer="_Test Customer 2"):
def validate_quotation(): def validate_quotation(customer_name):
# test if quotation with customer is fetched # test if quotation with customer is fetched
quotation = _get_cart_quotation() party = frappe.get_doc("Customer", customer_name)
quotation = _get_cart_quotation(party=party)
self.assertEqual(quotation.quotation_to, "Customer") self.assertEqual(quotation.quotation_to, "Customer")
self.assertEqual(quotation.party_name, "_Test Customer") self.assertEqual(quotation.party_name, customer_name)
self.assertEqual(quotation.contact_email, frappe.session.user) self.assertEqual(quotation.contact_email, frappe.session.user)
return quotation return quotation
self.login_as_customer( quotation = validate_quotation(customer)
"test_contact_two_customer@example.com", "_Test Contact 2 For _Test Customer"
)
validate_quotation()
self.login_as_customer()
quotation = validate_quotation()
return quotation return quotation
def test_add_to_cart(self): def test_add_to_cart(self):
self.login_as_customer() self.login_as_customer(
"test_contact_two_customer@example.com", "_Test Contact 2 For _Test Customer"
)
create_address_and_contact(
address_title="_Test Address for Customer 2",
first_name="_Test Contact for Customer 2",
email="test_contact_two_customer@example.com",
customer="_Test Customer 2",
)
# clear existing quotations # clear existing quotations
self.clear_existing_quotations() self.clear_existing_quotations()
# add first item # add first item
update_cart("_Test Item", 1) update_cart("_Test Item", 1)
quotation = self.test_get_cart_customer() quotation = self.test_get_cart_customer("_Test Customer 2")
self.assertEqual(quotation.get("items")[0].item_code, "_Test Item") self.assertEqual(quotation.get("items")[0].item_code, "_Test Item")
self.assertEqual(quotation.get("items")[0].qty, 1) self.assertEqual(quotation.get("items")[0].qty, 1)
@@ -95,7 +102,7 @@ class TestShoppingCart(unittest.TestCase):
# add second item # add second item
update_cart("_Test Item 2", 1) update_cart("_Test Item 2", 1)
quotation = self.test_get_cart_customer() quotation = self.test_get_cart_customer("_Test Customer 2")
self.assertEqual(quotation.get("items")[1].item_code, "_Test Item 2") self.assertEqual(quotation.get("items")[1].item_code, "_Test Item 2")
self.assertEqual(quotation.get("items")[1].qty, 1) self.assertEqual(quotation.get("items")[1].qty, 1)
self.assertEqual(quotation.get("items")[1].amount, 20) self.assertEqual(quotation.get("items")[1].amount, 20)
@@ -108,7 +115,7 @@ class TestShoppingCart(unittest.TestCase):
# update first item # update first item
update_cart("_Test Item", 5) update_cart("_Test Item", 5)
quotation = self.test_get_cart_customer() quotation = self.test_get_cart_customer("_Test Customer 2")
self.assertEqual(quotation.get("items")[0].item_code, "_Test Item") self.assertEqual(quotation.get("items")[0].item_code, "_Test Item")
self.assertEqual(quotation.get("items")[0].qty, 5) self.assertEqual(quotation.get("items")[0].qty, 5)
self.assertEqual(quotation.get("items")[0].amount, 50) self.assertEqual(quotation.get("items")[0].amount, 50)
@@ -121,7 +128,7 @@ class TestShoppingCart(unittest.TestCase):
# remove first item # remove first item
update_cart("_Test Item", 0) update_cart("_Test Item", 0)
quotation = self.test_get_cart_customer() quotation = self.test_get_cart_customer("_Test Customer 2")
self.assertEqual(quotation.get("items")[0].item_code, "_Test Item 2") self.assertEqual(quotation.get("items")[0].item_code, "_Test Item 2")
self.assertEqual(quotation.get("items")[0].qty, 1) self.assertEqual(quotation.get("items")[0].qty, 1)
@@ -132,7 +139,17 @@ class TestShoppingCart(unittest.TestCase):
@unittest.skip("Flaky in CI") @unittest.skip("Flaky in CI")
def test_tax_rule(self): def test_tax_rule(self):
self.create_tax_rule() self.create_tax_rule()
self.login_as_customer()
self.login_as_customer(
"test_contact_two_customer@example.com", "_Test Contact 2 For _Test Customer"
)
create_address_and_contact(
address_title="_Test Address for Customer 2",
first_name="_Test Contact for Customer 2",
email="test_contact_two_customer@example.com",
customer="_Test Customer 2",
)
quotation = self.create_quotation() quotation = self.create_quotation()
from erpnext.accounts.party import set_taxes from erpnext.accounts.party import set_taxes
@@ -320,7 +337,7 @@ class TestShoppingCart(unittest.TestCase):
if frappe.db.exists("User", email): if frappe.db.exists("User", email):
return return
frappe.get_doc( user = frappe.get_doc(
{ {
"doctype": "User", "doctype": "User",
"user_type": "Website User", "user_type": "Website User",
@@ -330,6 +347,40 @@ class TestShoppingCart(unittest.TestCase):
} }
).insert(ignore_permissions=True) ).insert(ignore_permissions=True)
user.add_roles("Customer")
def create_address_and_contact(**kwargs):
if not frappe.db.get_value("Address", {"address_title": kwargs.get("address_title")}):
frappe.get_doc(
{
"doctype": "Address",
"address_title": kwargs.get("address_title"),
"address_type": kwargs.get("address_type") or "Office",
"address_line1": kwargs.get("address_line1") or "Station Road",
"city": kwargs.get("city") or "_Test City",
"state": kwargs.get("state") or "Test State",
"country": kwargs.get("country") or "India",
"links": [
{"link_doctype": "Customer", "link_name": kwargs.get("customer") or "_Test Customer"}
],
}
).insert()
if not frappe.db.get_value("Contact", {"first_name": kwargs.get("first_name")}):
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": kwargs.get("first_name"),
"links": [
{"link_doctype": "Customer", "link_name": kwargs.get("customer") or "_Test Customer"}
],
}
)
contact.add_email(kwargs.get("email") or "test_contact_customer@example.com", is_primary=True)
contact.add_phone(kwargs.get("phone") or "+91 0000000000", is_primary_phone=True)
contact.insert()
test_dependencies = [ test_dependencies = [
"Sales Taxes and Charges Template", "Sales Taxes and Charges Template",

View File

@@ -8,7 +8,6 @@ import json
import frappe import frappe
from frappe import _, msgprint from frappe import _, msgprint
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder import Case
from frappe.query_builder.functions import IfNull, Sum from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import ( from frappe.utils import (
add_days, add_days,
@@ -1617,21 +1616,13 @@ def get_reserved_qty_for_production_plan(item_code, warehouse):
table = frappe.qb.DocType("Production Plan") table = frappe.qb.DocType("Production Plan")
child = frappe.qb.DocType("Material Request Plan Item") child = frappe.qb.DocType("Material Request Plan Item")
completed_production_plans = get_completed_production_plans() non_completed_production_plans = get_non_completed_production_plans()
case = Case()
query = ( query = (
frappe.qb.from_(table) frappe.qb.from_(table)
.inner_join(child) .inner_join(child)
.on(table.name == child.parent) .on(table.name == child.parent)
.select( .select(Sum(child.required_bom_qty))
Sum(
child.quantity
* IfNull(
case.when(child.material_request_type == "Purchase", child.conversion_factor).else_(1.0), 1.0
)
)
)
.where( .where(
(table.docstatus == 1) (table.docstatus == 1)
& (child.item_code == item_code) & (child.item_code == item_code)
@@ -1640,8 +1631,8 @@ def get_reserved_qty_for_production_plan(item_code, warehouse):
) )
) )
if completed_production_plans: if non_completed_production_plans:
query = query.where(table.name.notin(completed_production_plans)) query = query.where(table.name.isin(non_completed_production_plans))
query = query.run() query = query.run()
@@ -1652,7 +1643,7 @@ def get_reserved_qty_for_production_plan(item_code, warehouse):
reserved_qty_for_production = flt( reserved_qty_for_production = flt(
get_reserved_qty_for_production( get_reserved_qty_for_production(
item_code, warehouse, completed_production_plans, check_production_plan=True item_code, warehouse, non_completed_production_plans, check_production_plan=True
) )
) )
@@ -1662,7 +1653,7 @@ def get_reserved_qty_for_production_plan(item_code, warehouse):
return reserved_qty_for_production_plan - reserved_qty_for_production return reserved_qty_for_production_plan - reserved_qty_for_production
def get_completed_production_plans(): def get_non_completed_production_plans():
table = frappe.qb.DocType("Production Plan") table = frappe.qb.DocType("Production Plan")
child = frappe.qb.DocType("Production Plan Item") child = frappe.qb.DocType("Production Plan Item")
@@ -1674,7 +1665,7 @@ def get_completed_production_plans():
.where( .where(
(table.docstatus == 1) (table.docstatus == 1)
& (table.status.notin(["Completed", "Closed"])) & (table.status.notin(["Completed", "Closed"]))
& (child.ordered_qty >= child.planned_qty) & (child.planned_qty > child.ordered_qty)
) )
).run(as_dict=True) ).run(as_dict=True)

View File

@@ -6,8 +6,8 @@ from frappe.utils import add_to_date, flt, getdate, now_datetime, nowdate
from erpnext.controllers.item_variant import create_variant from erpnext.controllers.item_variant import create_variant
from erpnext.manufacturing.doctype.production_plan.production_plan import ( from erpnext.manufacturing.doctype.production_plan.production_plan import (
get_completed_production_plans,
get_items_for_material_requests, get_items_for_material_requests,
get_non_completed_production_plans,
get_sales_orders, get_sales_orders,
get_warehouse_list, get_warehouse_list,
) )
@@ -1132,9 +1132,9 @@ class TestProductionPlan(FrappeTestCase):
self.assertEqual(after_qty, before_qty) self.assertEqual(after_qty, before_qty)
completed_plans = get_completed_production_plans() completed_plans = get_non_completed_production_plans()
for plan in plans: for plan in plans:
self.assertTrue(plan in completed_plans) self.assertFalse(plan in completed_plans)
def test_resered_qty_for_production_plan_for_material_requests_with_multi_UOM(self): def test_resered_qty_for_production_plan_for_material_requests_with_multi_UOM(self):
from erpnext.stock.utils import get_or_make_bin from erpnext.stock.utils import get_or_make_bin

View File

@@ -1493,7 +1493,7 @@ def create_pick_list(source_name, target_doc=None, for_qty=None):
def get_reserved_qty_for_production( def get_reserved_qty_for_production(
item_code: str, item_code: str,
warehouse: str, warehouse: str,
completed_production_plans: list = None, non_completed_production_plans: list = None,
check_production_plan: bool = False, check_production_plan: bool = False,
) -> float: ) -> float:
"""Get total reserved quantity for any item in specified warehouse""" """Get total reserved quantity for any item in specified warehouse"""
@@ -1516,19 +1516,22 @@ def get_reserved_qty_for_production(
& (wo_item.parent == wo.name) & (wo_item.parent == wo.name)
& (wo.docstatus == 1) & (wo.docstatus == 1)
& (wo_item.source_warehouse == warehouse) & (wo_item.source_warehouse == warehouse)
& (wo.status.notin(["Stopped", "Completed", "Closed"]))
& (
(wo_item.required_qty > wo_item.transferred_qty)
| (wo_item.required_qty > wo_item.consumed_qty)
)
) )
) )
if check_production_plan: if check_production_plan:
query = query.where(wo.production_plan.isnotnull()) query = query.where(wo.production_plan.isnotnull())
else:
query = query.where(
(wo.status.notin(["Stopped", "Completed", "Closed"]))
& (
(wo_item.required_qty > wo_item.transferred_qty)
| (wo_item.required_qty > wo_item.consumed_qty)
)
)
if completed_production_plans: if non_completed_production_plans:
query = query.where(wo.production_plan.notin(completed_production_plans)) query = query.where(wo.production_plan.isin(non_completed_production_plans))
return query.run()[0][0] or 0.0 return query.run()[0][0] or 0.0

View File

@@ -1,156 +1,52 @@
{ {
"allow_copy": 0, "actions": [],
"allow_import": 0, "creation": "2015-04-29 04:52:48.868079",
"allow_rename": 0, "doctype": "DocType",
"beta": 0, "editable_grid": 1,
"creation": "2015-04-29 04:52:48.868079", "engine": "InnoDB",
"custom": 0, "field_order": [
"docstatus": 0, "task",
"doctype": "DocType", "column_break_2",
"document_type": "", "subject",
"editable_grid": 1, "project"
],
"fields": [ "fields": [
{ {
"allow_on_submit": 0, "fieldname": "task",
"bold": 0, "fieldtype": "Link",
"collapsible": 0, "in_list_view": 1,
"columns": 0, "label": "Task",
"fieldname": "task", "options": "Task"
"fieldtype": "Link", },
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Task",
"length": 0,
"no_copy": 0,
"options": "Task",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_on_submit": 0, "fieldname": "column_break_2",
"bold": 0, "fieldtype": "Column Break"
"collapsible": 0, },
"columns": 0,
"fieldname": "column_break_2",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_on_submit": 0, "fetch_from": "task.subject",
"bold": 0, "fieldname": "subject",
"collapsible": 0, "fieldtype": "Text",
"columns": 0, "in_list_view": 1,
"fieldname": "subject", "label": "Subject",
"fieldtype": "Text", "read_only": 1
"hidden": 0, },
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Subject",
"length": 0,
"no_copy": 0,
"options": "",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_on_submit": 0, "fetch_from": "task.project",
"bold": 0, "fieldname": "project",
"collapsible": 0, "fieldtype": "Text",
"columns": 0, "label": "Project",
"fieldname": "project", "read_only": 1
"fieldtype": "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": "Project",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
} }
], ],
"hide_heading": 0, "istable": 1,
"hide_toolbar": 0, "links": [],
"idx": 0, "modified": "2023-10-09 11:34:14.335853",
"image_view": 0, "modified_by": "Administrator",
"in_create": 0, "module": "Projects",
"name": "Task Depends On",
"is_submittable": 0, "owner": "Administrator",
"issingle": 0, "permissions": [],
"istable": 1, "sort_field": "modified",
"max_attachments": 0, "sort_order": "DESC",
"modified": "2017-02-24 04:56:04.862502", "states": []
"modified_by": "Administrator",
"module": "Projects",
"name": "Task Depends On",
"name_case": "",
"owner": "Administrator",
"permissions": [],
"quick_entry": 0,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 0,
"track_seen": 0
} }

View File

@@ -135,7 +135,15 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
} }
else { else {
// allow for '0' qty on Credit/Debit notes // allow for '0' qty on Credit/Debit notes
let qty = item.qty || (me.frm.doc.is_debit_note ? 1 : -1); let qty = flt(item.qty);
if (!qty) {
qty = (me.frm.doc.is_debit_note ? 1 : -1);
if (me.frm.doc.doctype !== "Purchase Receipt" && me.frm.doc.is_return === 1) {
// In case of Purchase Receipt, qty can be 0 if all items are rejected
qty = flt(item.qty);
}
}
item.net_amount = item.amount = flt(item.rate * qty, precision("amount", item)); item.net_amount = item.amount = flt(item.rate * qty, precision("amount", item));
} }

View File

@@ -18,6 +18,7 @@ import "./utils/customer_quick_entry";
import "./utils/supplier_quick_entry"; import "./utils/supplier_quick_entry";
import "./call_popup/call_popup"; import "./call_popup/call_popup";
import "./utils/dimension_tree_filter"; import "./utils/dimension_tree_filter";
import "./utils/unreconcile.js";
import "./utils/barcode_scanner"; import "./utils/barcode_scanner";
import "./telephony"; import "./telephony";
import "./templates/call_link.html"; import "./templates/call_link.html";

View File

@@ -666,6 +666,9 @@ erpnext.utils.update_child_items = function(opts) {
}).show(); }).show();
} }
erpnext.utils.map_current_doc = function(opts) { erpnext.utils.map_current_doc = function(opts) {
function _map() { function _map() {
if($.isArray(cur_frm.doc.items) && cur_frm.doc.items.length > 0) { if($.isArray(cur_frm.doc.items) && cur_frm.doc.items.length > 0) {

View File

@@ -0,0 +1,127 @@
frappe.provide('erpnext.accounts');
erpnext.accounts.unreconcile_payments = {
add_unreconcile_btn(frm) {
if (frm.doc.docstatus == 1) {
if(((frm.doc.doctype == "Journal Entry") && (frm.doc.voucher_type != "Journal Entry"))
|| !["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"].includes(frm.doc.doctype)
) {
return;
}
frappe.call({
"method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.doc_has_references",
"args": {
"doctype": frm.doc.doctype,
"docname": frm.doc.name
},
callback: function(r) {
if (r.message) {
frm.add_custom_button(__("Un-Reconcile"), function() {
erpnext.accounts.unreconcile_payments.build_unreconcile_dialog(frm);
});
}
}
});
}
},
build_selection_map(frm, selections) {
// assuming each row is an individual voucher
// pass this to server side method that creates unreconcile doc for each row
let selection_map = [];
if (['Sales Invoice', 'Purchase Invoice'].includes(frm.doc.doctype)) {
selection_map = selections.map(function(elem) {
return {
company: elem.company,
voucher_type: elem.voucher_type,
voucher_no: elem.voucher_no,
against_voucher_type: frm.doc.doctype,
against_voucher_no: frm.doc.name
};
});
} else if (['Payment Entry', 'Journal Entry'].includes(frm.doc.doctype)) {
selection_map = selections.map(function(elem) {
return {
company: elem.company,
voucher_type: frm.doc.doctype,
voucher_no: frm.doc.name,
against_voucher_type: elem.voucher_type,
against_voucher_no: elem.voucher_no,
};
});
}
return selection_map;
},
build_unreconcile_dialog(frm) {
if (['Sales Invoice', 'Purchase Invoice', 'Payment Entry', 'Journal Entry'].includes(frm.doc.doctype)) {
let child_table_fields = [
{ label: __("Voucher Type"), fieldname: "voucher_type", fieldtype: "Dynamic Link", options: "DocType", in_list_view: 1, read_only: 1},
{ label: __("Voucher No"), fieldname: "voucher_no", fieldtype: "Link", options: "voucher_type", in_list_view: 1, read_only: 1 },
{ label: __("Allocated Amount"), fieldname: "allocated_amount", fieldtype: "Currency", in_list_view: 1, read_only: 1 , options: "account_currency"},
{ label: __("Currency"), fieldname: "account_currency", fieldtype: "Currency", read_only: 1},
]
let unreconcile_dialog_fields = [
{
label: __('Allocations'),
fieldname: 'allocations',
fieldtype: 'Table',
read_only: 1,
fields: child_table_fields,
},
];
// get linked payments
frappe.call({
"method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.get_linked_payments_for_doc",
"args": {
"company": frm.doc.company,
"doctype": frm.doc.doctype,
"docname": frm.doc.name
},
callback: function(r) {
if (r.message) {
// populate child table with allocations
unreconcile_dialog_fields[0].data = r.message;
unreconcile_dialog_fields[0].get_data = function(){ return r.message};
let d = new frappe.ui.Dialog({
title: 'Un-Reconcile Allocations',
fields: unreconcile_dialog_fields,
size: 'large',
cannot_add_rows: true,
primary_action_label: 'Un-Reconcile',
primary_action(values) {
let selected_allocations = values.allocations.filter(x=>x.__checked);
if (selected_allocations.length > 0) {
let selection_map = erpnext.accounts.unreconcile_payments.build_selection_map(frm, selected_allocations);
erpnext.accounts.unreconcile_payments.create_unreconcile_docs(selection_map);
d.hide();
} else {
frappe.msgprint("No Selection");
}
}
});
d.show();
}
}
});
}
},
create_unreconcile_docs(selection_map) {
frappe.call({
"method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.create_unreconcile_doc_for_selection",
"args": {
"selections": selection_map
},
});
}
}

View File

@@ -121,6 +121,7 @@ class TestCurrencyExchange(unittest.TestCase):
# Update Currency Exchange Rate # Update Currency Exchange Rate
settings = frappe.get_single("Currency Exchange Settings") settings = frappe.get_single("Currency Exchange Settings")
settings.service_provider = "exchangerate.host" settings.service_provider = "exchangerate.host"
settings.access_key = "12345667890"
settings.save() settings.save()
# Update exchange # Update exchange

View File

@@ -616,6 +616,7 @@
"fieldname": "relieving_date", "fieldname": "relieving_date",
"fieldtype": "Date", "fieldtype": "Date",
"label": "Relieving Date", "label": "Relieving Date",
"no_copy": 1,
"mandatory_depends_on": "eval:doc.status == \"Left\"", "mandatory_depends_on": "eval:doc.status == \"Left\"",
"oldfieldname": "relieving_date", "oldfieldname": "relieving_date",
"oldfieldtype": "Date" "oldfieldtype": "Date"
@@ -822,7 +823,7 @@
"idx": 24, "idx": 24,
"image_field": "image", "image_field": "image",
"links": [], "links": [],
"modified": "2023-03-30 15:57:05.174592", "modified": "2023-10-04 10:57:05.174592",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Setup", "module": "Setup",
"name": "Employee", "name": "Employee",
@@ -870,4 +871,4 @@
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"title_field": "employee_name" "title_field": "employee_name"
} }

View File

@@ -81,6 +81,11 @@ def get_exchange_rate(from_currency, to_currency, transaction_date=None, args=No
if entries: if entries:
return flt(entries[0].exchange_rate) return flt(entries[0].exchange_rate)
if frappe.get_cached_value(
"Currency Exchange Settings", "Currency Exchange Settings", "disabled"
):
return 0.00
try: try:
cache = frappe.cache() cache = frappe.cache()
key = "currency_exchange_rate_{0}:{1}:{2}".format(transaction_date, from_currency, to_currency) key = "currency_exchange_rate_{0}:{1}:{2}".format(transaction_date, from_currency, to_currency)

View File

@@ -37,7 +37,7 @@ frappe.ui.form.on('Inventory Dimension', {
if (frm.doc.__onload && frm.doc.__onload.has_stock_ledger if (frm.doc.__onload && frm.doc.__onload.has_stock_ledger
&& frm.doc.__onload.has_stock_ledger.length) { && frm.doc.__onload.has_stock_ledger.length) {
let allow_to_edit_fields = ['disabled', 'fetch_from_parent', let allow_to_edit_fields = ['disabled', 'fetch_from_parent',
'type_of_transaction', 'condition', 'mandatory_depends_on']; 'type_of_transaction', 'condition', 'mandatory_depends_on', 'validate_negative_stock'];
frm.fields.forEach((field) => { frm.fields.forEach((field) => {
if (!in_list(allow_to_edit_fields, field.df.fieldname)) { if (!in_list(allow_to_edit_fields, field.df.fieldname)) {

View File

@@ -17,6 +17,8 @@
"target_fieldname", "target_fieldname",
"applicable_for_documents_tab", "applicable_for_documents_tab",
"apply_to_all_doctypes", "apply_to_all_doctypes",
"column_break_niy2u",
"validate_negative_stock",
"column_break_13", "column_break_13",
"document_type", "document_type",
"type_of_transaction", "type_of_transaction",
@@ -173,11 +175,21 @@
"fieldname": "reqd", "fieldname": "reqd",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Mandatory" "label": "Mandatory"
},
{
"fieldname": "column_break_niy2u",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "validate_negative_stock",
"fieldtype": "Check",
"label": "Validate Negative Stock"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2023-01-31 13:44:38.507698", "modified": "2023-10-05 12:52:18.705431",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Inventory Dimension", "name": "Inventory Dimension",

View File

@@ -60,6 +60,7 @@ class InventoryDimension(Document):
"fetch_from_parent", "fetch_from_parent",
"type_of_transaction", "type_of_transaction",
"condition", "condition",
"validate_negative_stock",
] ]
for field in frappe.get_meta("Inventory Dimension").fields: for field in frappe.get_meta("Inventory Dimension").fields:
@@ -160,6 +161,7 @@ class InventoryDimension(Document):
insert_after="inventory_dimension", insert_after="inventory_dimension",
options=self.reference_document, options=self.reference_document,
label=label, label=label,
search_index=1,
reqd=self.reqd, reqd=self.reqd,
mandatory_depends_on=self.mandatory_depends_on, mandatory_depends_on=self.mandatory_depends_on,
), ),
@@ -255,7 +257,7 @@ def field_exists(doctype, fieldname) -> str or None:
def get_inventory_documents( def get_inventory_documents(
doctype=None, txt=None, searchfield=None, start=None, page_len=None, filters=None doctype=None, txt=None, searchfield=None, start=None, page_len=None, filters=None
): ):
and_filters = [["DocField", "parent", "not in", ["Batch", "Serial No"]]] and_filters = [["DocField", "parent", "not in", ["Batch", "Serial No", "Item Price"]]]
or_filters = [ or_filters = [
["DocField", "options", "in", ["Batch", "Serial No"]], ["DocField", "options", "in", ["Batch", "Serial No"]],
["DocField", "parent", "in", ["Putaway Rule"]], ["DocField", "parent", "in", ["Putaway Rule"]],
@@ -340,6 +342,7 @@ def get_inventory_dimensions():
fields=[ fields=[
"distinct target_fieldname as fieldname", "distinct target_fieldname as fieldname",
"reference_document as doctype", "reference_document as doctype",
"validate_negative_stock",
], ],
filters={"disabled": 0}, filters={"disabled": 0},
) )

View File

@@ -414,6 +414,53 @@ class TestInventoryDimension(FrappeTestCase):
else: else:
self.assertEqual(d.store, "Inter Transfer Store 2") self.assertEqual(d.store, "Inter Transfer Store 2")
def test_validate_negative_stock_for_inventory_dimension(self):
frappe.local.inventory_dimensions = {}
item_code = "Test Negative Inventory Dimension Item"
frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 1)
create_item(item_code)
inv_dimension = create_inventory_dimension(
apply_to_all_doctypes=1,
dimension_name="Inv Site",
reference_document="Inv Site",
document_type="Inv Site",
validate_negative_stock=1,
)
warehouse = create_warehouse("Negative Stock Warehouse")
doc = make_stock_entry(item_code=item_code, target=warehouse, qty=10, do_not_submit=True)
doc.items[0].to_inv_site = "Site 1"
doc.submit()
site_name = frappe.get_all(
"Stock Ledger Entry", filters={"voucher_no": doc.name, "is_cancelled": 0}, fields=["inv_site"]
)[0].inv_site
self.assertEqual(site_name, "Site 1")
doc = make_stock_entry(item_code=item_code, source=warehouse, qty=100, do_not_submit=True)
doc.items[0].inv_site = "Site 1"
self.assertRaises(frappe.ValidationError, doc.submit)
inv_dimension.reload()
inv_dimension.db_set("validate_negative_stock", 0)
frappe.local.inventory_dimensions = {}
doc = make_stock_entry(item_code=item_code, source=warehouse, qty=100, do_not_submit=True)
doc.items[0].inv_site = "Site 1"
doc.submit()
self.assertEqual(doc.docstatus, 1)
site_name = frappe.get_all(
"Stock Ledger Entry", filters={"voucher_no": doc.name, "is_cancelled": 0}, fields=["inv_site"]
)[0].inv_site
self.assertEqual(site_name, "Site 1")
def get_voucher_sl_entries(voucher_no, fields): def get_voucher_sl_entries(voucher_no, fields):
return frappe.get_all( return frappe.get_all(
@@ -504,6 +551,26 @@ def prepare_test_data():
} }
).insert(ignore_permissions=True) ).insert(ignore_permissions=True)
if not frappe.db.exists("DocType", "Inv Site"):
frappe.get_doc(
{
"doctype": "DocType",
"name": "Inv Site",
"module": "Stock",
"custom": 1,
"naming_rule": "By fieldname",
"autoname": "field:site_name",
"fields": [{"label": "Site Name", "fieldname": "site_name", "fieldtype": "Data"}],
"permissions": [
{"role": "System Manager", "permlevel": 0, "read": 1, "write": 1, "create": 1, "delete": 1}
],
}
).insert(ignore_permissions=True)
for site in ["Site 1", "Site 2"]:
if not frappe.db.exists("Inv Site", site):
frappe.get_doc({"doctype": "Inv Site", "site_name": site}).insert(ignore_permissions=True)
def create_inventory_dimension(**args): def create_inventory_dimension(**args):
args = frappe._dict(args) args = frappe._dict(args)

View File

@@ -102,6 +102,12 @@ frappe.ui.form.on('Material Request', {
if (frm.doc.docstatus == 1 && frm.doc.status != 'Stopped') { if (frm.doc.docstatus == 1 && frm.doc.status != 'Stopped') {
let precision = frappe.defaults.get_default("float_precision"); let precision = frappe.defaults.get_default("float_precision");
if (flt(frm.doc.per_received, precision) < 100) {
frm.add_custom_button(__('Stop'),
() => frm.events.update_status(frm, 'Stopped'));
}
if (flt(frm.doc.per_ordered, precision) < 100) { if (flt(frm.doc.per_ordered, precision) < 100) {
let add_create_pick_list_button = () => { let add_create_pick_list_button = () => {
frm.add_custom_button(__('Pick List'), frm.add_custom_button(__('Pick List'),
@@ -148,11 +154,6 @@ frappe.ui.form.on('Material Request', {
} }
frm.page.set_inner_btn_group_as_primary(__('Create')); frm.page.set_inner_btn_group_as_primary(__('Create'));
// stop
frm.add_custom_button(__('Stop'),
() => frm.events.update_status(frm, 'Stopped'));
} }
} }

View File

@@ -958,6 +958,10 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
total_amount += total_billable_amount total_amount += total_billable_amount
total_billed_amount += flt(item.billed_amt) total_billed_amount += flt(item.billed_amt)
if pr_doc.get("is_return") and not total_amount and total_billed_amount:
total_amount = total_billed_amount
if adjust_incoming_rate: if adjust_incoming_rate:
adjusted_amt = 0.0 adjusted_amt = 0.0
if item.billed_amt and item.amount: if item.billed_amt and item.amount:

View File

@@ -2067,6 +2067,86 @@ class TestPurchaseReceipt(FrappeTestCase):
company.enable_provisional_accounting_for_non_stock_items = 0 company.enable_provisional_accounting_for_non_stock_items = 0
company.save() company.save()
def test_purchase_return_status_with_debit_note(self):
pr = make_purchase_receipt(rejected_qty=10, received_qty=10, rate=100, do_not_save=1)
pr.items[0].qty = 0
pr.items[0].stock_qty = 0
pr.submit()
return_pr = make_purchase_receipt(
is_return=1,
return_against=pr.name,
qty=0,
rejected_qty=10 * -1,
received_qty=10 * -1,
do_not_save=1,
)
return_pr.items[0].qty = 0.0
return_pr.items[0].stock_qty = 0.0
return_pr.submit()
self.assertEqual(return_pr.status, "To Bill")
pi = make_purchase_invoice(return_pr.name)
pi.submit()
return_pr.reload()
self.assertEqual(return_pr.status, "Completed")
def test_valuation_rate_in_return_purchase_receipt_for_moving_average(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.stock.stock_ledger import get_previous_sle
# Step - 1: Create an Item (Valuation Method = Moving Average)
item_code = make_item(properties={"is_stock_item": 1, "valuation_method": "Moving Average"}).name
# Step - 2: Create a Purchase Receipt (Qty = 10, Rate = 100)
pr = make_purchase_receipt(qty=10, rate=100, item_code=item_code)
# Step - 3: Create a Material Receipt Stock Entry (Qty = 100, Basic Rate = 10)
warehouse = "_Test Warehouse - _TC"
make_stock_entry(
purpose="Material Receipt",
item_code=item_code,
to_warehouse=warehouse,
qty=100,
rate=10,
)
# Step - 4: Create a Material Issue Stock Entry (Qty = 100, Basic Rate = 18.18 [Auto Fetched])
make_stock_entry(
purpose="Material Issue", item_code=item_code, from_warehouse=warehouse, qty=100
)
# Step - 5: Create a Return Purchase Return (Qty = -8, Rate = 100 [Auto fetched])
return_pr = make_purchase_receipt(
is_return=1,
return_against=pr.name,
item_code=item_code,
qty=-8,
)
sle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": return_pr.name, "voucher_detail_no": return_pr.items[0].name},
["posting_date", "posting_time", "outgoing_rate", "valuation_rate"],
as_dict=1,
)
previous_sle_valuation_rate = get_previous_sle(
{
"item_code": item_code,
"warehouse": warehouse,
"posting_date": sle.posting_date,
"posting_time": sle.posting_time,
}
).get("valuation_rate")
# Test - 1: Valuation Rate should be equal to Outgoing Rate
self.assertEqual(flt(sle.outgoing_rate, 2), flt(sle.valuation_rate, 2))
# Test - 2: Valuation Rate should be equal to Previous SLE Valuation Rate
self.assertEqual(flt(sle.valuation_rate, 2), flt(previous_sle_valuation_rate, 2))
def prepare_data_for_internal_transfer(): def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -5,13 +5,15 @@
from datetime import date from datetime import date
import frappe import frappe
from frappe import _ from frappe import _, bold
from frappe.core.doctype.role.role import get_users from frappe.core.doctype.role.role import get_users
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import add_days, cint, formatdate, get_datetime, getdate from frappe.utils import add_days, cint, flt, formatdate, get_datetime, getdate
from erpnext.accounts.utils import get_fiscal_year from erpnext.accounts.utils import get_fiscal_year
from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
from erpnext.stock.stock_ledger import get_previous_sle
class StockFreezeError(frappe.ValidationError): class StockFreezeError(frappe.ValidationError):
@@ -48,6 +50,69 @@ class StockLedgerEntry(Document):
self.validate_and_set_fiscal_year() self.validate_and_set_fiscal_year()
self.block_transactions_against_group_warehouse() self.block_transactions_against_group_warehouse()
self.validate_with_last_transaction_posting_time() self.validate_with_last_transaction_posting_time()
self.validate_inventory_dimension_negative_stock()
def validate_inventory_dimension_negative_stock(self):
extra_cond = ""
kwargs = {}
dimensions = self._get_inventory_dimensions()
if not dimensions:
return
for dimension, values in dimensions.items():
kwargs[dimension] = values.get("value")
extra_cond += f" and {dimension} = %({dimension})s"
kwargs.update(
{
"item_code": self.item_code,
"warehouse": self.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"company": self.company,
}
)
sle = get_previous_sle(kwargs, extra_cond=extra_cond)
if sle:
flt_precision = cint(frappe.db.get_default("float_precision")) or 2
diff = sle.qty_after_transaction + flt(self.actual_qty)
diff = flt(diff, flt_precision)
if diff < 0 and abs(diff) > 0.0001:
self.throw_validation_error(diff, dimensions)
def throw_validation_error(self, diff, dimensions):
dimension_msg = _(", with the inventory {0}: {1}").format(
"dimensions" if len(dimensions) > 1 else "dimension",
", ".join(f"{bold(d.doctype)} ({d.value})" for k, d in dimensions.items()),
)
msg = _(
"{0} units of {1} are required in {2}{3}, on {4} {5} for {6} to complete the transaction."
).format(
abs(diff),
frappe.get_desk_link("Item", self.item_code),
frappe.get_desk_link("Warehouse", self.warehouse),
dimension_msg,
self.posting_date,
self.posting_time,
frappe.get_desk_link(self.voucher_type, self.voucher_no),
)
frappe.throw(msg, title=_("Inventory Dimension Negative Stock"))
def _get_inventory_dimensions(self):
inv_dimensions = get_inventory_dimensions()
inv_dimension_dict = {}
for dimension in inv_dimensions:
if not dimension.get("validate_negative_stock") or not self.get(dimension.fieldname):
continue
dimension["value"] = self.get(dimension.fieldname)
inv_dimension_dict.setdefault(dimension.fieldname, dimension)
return inv_dimension_dict
def on_submit(self): def on_submit(self):
self.check_stock_frozen_date() self.check_stock_frozen_date()

View File

@@ -12,6 +12,7 @@ import erpnext
from erpnext.accounts.utils import get_company_default from erpnext.accounts.utils import get_company_default
from erpnext.controllers.stock_controller import StockController from erpnext.controllers.stock_controller import StockController
from erpnext.stock.doctype.batch.batch import get_batch_qty from erpnext.stock.doctype.batch.batch import get_batch_qty
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.utils import get_stock_balance from erpnext.stock.utils import get_stock_balance
@@ -45,10 +46,22 @@ class StockReconciliation(StockController):
self.clean_serial_nos() self.clean_serial_nos()
self.set_total_qty_and_amount() self.set_total_qty_and_amount()
self.validate_putaway_capacity() self.validate_putaway_capacity()
self.validate_inventory_dimension()
if self._action == "submit": if self._action == "submit":
self.make_batches("warehouse") self.make_batches("warehouse")
def validate_inventory_dimension(self):
dimensions = get_inventory_dimensions()
for dimension in dimensions:
for row in self.items:
if not row.batch_no and row.current_qty and row.get(dimension.get("fieldname")):
frappe.throw(
_(
"Row #{0}: You cannot use the inventory dimension '{1}' in Stock Reconciliation to modify the quantity or valuation rate. Stock reconciliation with inventory dimensions is intended solely for performing opening entries."
).format(row.idx, bold(dimension.get("doctype")))
)
def on_submit(self): def on_submit(self):
self.update_stock_ledger() self.update_stock_ledger()
self.make_gl_entries() self.make_gl_entries()
@@ -70,8 +83,19 @@ class StockReconciliation(StockController):
self.difference_amount = 0.0 self.difference_amount = 0.0
def _changed(item): def _changed(item):
inventory_dimensions_dict = {}
if not item.batch_no and not item.serial_no:
for dimension in get_inventory_dimensions():
if item.get(dimension.get("fieldname")):
inventory_dimensions_dict[dimension.get("fieldname")] = item.get(dimension.get("fieldname"))
item_dict = get_stock_balance_for( item_dict = get_stock_balance_for(
item.item_code, item.warehouse, self.posting_date, self.posting_time, batch_no=item.batch_no item.item_code,
item.warehouse,
self.posting_date,
self.posting_time,
batch_no=item.batch_no,
inventory_dimensions_dict=inventory_dimensions_dict,
) )
if ( if (
@@ -167,6 +191,14 @@ class StockReconciliation(StockController):
if flt(row.valuation_rate) < 0: if flt(row.valuation_rate) < 0:
self.validation_messages.append(_get_msg(row_num, _("Negative Valuation Rate is not allowed"))) self.validation_messages.append(_get_msg(row_num, _("Negative Valuation Rate is not allowed")))
if row.batch_no and frappe.get_cached_value("Batch", row.batch_no, "item") != row.item_code:
self.validation_messages.append(
_get_msg(
row_num,
_("Batch {0} does not belong to item {1}").format(bold(row.batch_no), bold(row.item_code)),
)
)
if row.qty and row.valuation_rate in ["", None]: if row.qty and row.valuation_rate in ["", None]:
row.valuation_rate = get_stock_balance( row.valuation_rate = get_stock_balance(
row.item_code, row.warehouse, self.posting_date, self.posting_time, with_valuation_rate=True row.item_code, row.warehouse, self.posting_date, self.posting_time, with_valuation_rate=True
@@ -415,6 +447,12 @@ class StockReconciliation(StockController):
if not row.batch_no: if not row.batch_no:
data.qty_after_transaction = flt(row.qty, row.precision("qty")) data.qty_after_transaction = flt(row.qty, row.precision("qty"))
dimensions = get_inventory_dimensions()
has_dimensions = False
for dimension in dimensions:
if row.get(dimension.get("fieldname")):
has_dimensions = True
if self.docstatus == 2 and not row.batch_no: if self.docstatus == 2 and not row.batch_no:
if row.current_qty: if row.current_qty:
data.actual_qty = -1 * row.current_qty data.actual_qty = -1 * row.current_qty
@@ -429,6 +467,11 @@ class StockReconciliation(StockController):
data.valuation_rate = flt(row.valuation_rate) data.valuation_rate = flt(row.valuation_rate)
data.stock_value_difference = -1 * flt(row.amount_difference) data.stock_value_difference = -1 * flt(row.amount_difference)
elif self.docstatus == 1 and has_dimensions and not row.batch_no:
data.actual_qty = row.qty
data.qty_after_transaction = 0.0
data.incoming_rate = flt(row.valuation_rate)
self.update_inventory_dimensions(row, data) self.update_inventory_dimensions(row, data)
return data return data
@@ -817,6 +860,7 @@ def get_stock_balance_for(
posting_time, posting_time,
batch_no: Optional[str] = None, batch_no: Optional[str] = None,
with_valuation_rate: bool = True, with_valuation_rate: bool = True,
inventory_dimensions_dict=None,
): ):
frappe.has_permission("Stock Reconciliation", "write", throw=True) frappe.has_permission("Stock Reconciliation", "write", throw=True)
@@ -845,6 +889,7 @@ def get_stock_balance_for(
posting_time, posting_time,
with_valuation_rate=with_valuation_rate, with_valuation_rate=with_valuation_rate,
with_serial_no=has_serial_no, with_serial_no=has_serial_no,
inventory_dimensions_dict=inventory_dimensions_dict,
) )
if has_serial_no: if has_serial_no:

View File

@@ -604,9 +604,9 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
create_batch_item_with_batch("Testing Batch Item 1", "001") create_batch_item_with_batch("Testing Batch Item 1", "001")
create_batch_item_with_batch("Testing Batch Item 2", "002") create_batch_item_with_batch("Testing Batch Item 2", "002")
sr = create_stock_reconciliation( sr = create_stock_reconciliation(
item_code="Testing Batch Item 1", qty=1, rate=100, batch_no="002", do_not_submit=True item_code="Testing Batch Item 1", qty=1, rate=100, batch_no="002", do_not_save=True
) )
self.assertRaises(frappe.ValidationError, sr.submit) self.assertRaises(frappe.ValidationError, sr.save)
def test_serial_no_cancellation(self): def test_serial_no_cancellation(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
@@ -916,6 +916,46 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
# Check if Negative Stock is blocked # Check if Negative Stock is blocked
self.assertRaises(frappe.ValidationError, sr.submit) self.assertRaises(frappe.ValidationError, sr.submit)
def test_batch_item_validation(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self.make_item(
"Test Batch Item Original",
{
"is_stock_item": 1,
"has_batch_no": 1,
"batch_number_series": "BNS9.####",
"create_new_batch": 1,
},
).name
sr = make_stock_entry(
item_code=item_code,
target="_Test Warehouse - _TC",
qty=100,
basic_rate=100,
posting_date=nowdate(),
)
new_item_code = self.make_item(
"Test Batch Item New 1",
{
"is_stock_item": 1,
"has_batch_no": 1,
},
).name
sr = create_stock_reconciliation(
item_code=new_item_code,
warehouse="_Test Warehouse - _TC",
qty=10,
rate=100,
batch_no=sr.items[0].batch_no,
do_not_save=True,
)
self.assertRaises(frappe.ValidationError, sr.save)
def create_batch_item_with_batch(item_name, batch_id): def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1) batch_item_doc = create_item(item_name, is_stock_item=1)

View File

@@ -13,6 +13,7 @@ from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdat
import erpnext import erpnext
from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
from erpnext.stock.utils import ( from erpnext.stock.utils import (
get_incoming_outgoing_rate_for_cancel, get_incoming_outgoing_rate_for_cancel,
get_or_make_bin, get_or_make_bin,
@@ -582,6 +583,13 @@ class update_entries_after(object):
): ):
sle.outgoing_rate = get_incoming_rate_for_inter_company_transfer(sle) sle.outgoing_rate = get_incoming_rate_for_inter_company_transfer(sle)
dimensions = get_inventory_dimensions()
has_dimensions = False
if dimensions:
for dimension in dimensions:
if sle.get(dimension.get("fieldname")):
has_dimensions = True
if get_serial_nos(sle.serial_no): if get_serial_nos(sle.serial_no):
self.get_serialized_values(sle) self.get_serialized_values(sle)
self.wh_data.qty_after_transaction += flt(sle.actual_qty) self.wh_data.qty_after_transaction += flt(sle.actual_qty)
@@ -596,7 +604,7 @@ class update_entries_after(object):
): ):
self.update_batched_values(sle) self.update_batched_values(sle)
else: else:
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no: if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no and not has_dimensions:
# assert # assert
self.wh_data.valuation_rate = sle.valuation_rate self.wh_data.valuation_rate = sle.valuation_rate
self.wh_data.qty_after_transaction = sle.qty_after_transaction self.wh_data.qty_after_transaction = sle.qty_after_transaction
@@ -690,14 +698,16 @@ class update_entries_after(object):
get_rate_for_return, # don't move this import to top get_rate_for_return, # don't move this import to top
) )
rate = get_rate_for_return( if self.valuation_method == "Moving Average":
sle.voucher_type, rate = self.data[self.args.warehouse].previous_sle.valuation_rate
sle.voucher_no, else:
sle.item_code, rate = get_rate_for_return(
voucher_detail_no=sle.voucher_detail_no, sle.voucher_type,
sle=sle, sle.voucher_no,
) sle.item_code,
voucher_detail_no=sle.voucher_detail_no,
sle=sle,
)
elif ( elif (
sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"] sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"]
and sle.voucher_detail_no and sle.voucher_detail_no
@@ -1186,7 +1196,7 @@ def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_vouc
return sle[0] if sle else frappe._dict() return sle[0] if sle else frappe._dict()
def get_previous_sle(args, for_update=False): def get_previous_sle(args, for_update=False, extra_cond=None):
""" """
get the last sle on or before the current time-bucket, get the last sle on or before the current time-bucket,
to get actual qty before transaction, this function to get actual qty before transaction, this function
@@ -1201,7 +1211,9 @@ def get_previous_sle(args, for_update=False):
} }
""" """
args["name"] = args.get("sle", None) or "" args["name"] = args.get("sle", None) or ""
sle = get_stock_ledger_entries(args, "<=", "desc", "limit 1", for_update=for_update) sle = get_stock_ledger_entries(
args, "<=", "desc", "limit 1", for_update=for_update, extra_cond=extra_cond
)
return sle and sle[0] or {} return sle and sle[0] or {}
@@ -1213,6 +1225,7 @@ def get_stock_ledger_entries(
for_update=False, for_update=False,
debug=False, debug=False,
check_serial_no=True, check_serial_no=True,
extra_cond=None,
): ):
"""get stock ledger entries filtered by specific posting datetime conditions""" """get stock ledger entries filtered by specific posting datetime conditions"""
conditions = " and timestamp(posting_date, posting_time) {0} timestamp(%(posting_date)s, %(posting_time)s)".format( conditions = " and timestamp(posting_date, posting_time) {0} timestamp(%(posting_date)s, %(posting_time)s)".format(
@@ -1250,6 +1263,9 @@ def get_stock_ledger_entries(
if operator in (">", "<=") and previous_sle.get("name"): if operator in (">", "<=") and previous_sle.get("name"):
conditions += " and name!=%(name)s" conditions += " and name!=%(name)s"
if extra_cond:
conditions += f"{extra_cond}"
return frappe.db.sql( return frappe.db.sql(
""" """
select *, timestamp(posting_date, posting_time) as "timestamp" select *, timestamp(posting_date, posting_time) as "timestamp"

View File

@@ -94,6 +94,7 @@ def get_stock_balance(
posting_time=None, posting_time=None,
with_valuation_rate=False, with_valuation_rate=False,
with_serial_no=False, with_serial_no=False,
inventory_dimensions_dict=None,
): ):
"""Returns stock balance quantity at given warehouse on given posting date or current date. """Returns stock balance quantity at given warehouse on given posting date or current date.
@@ -113,7 +114,13 @@ def get_stock_balance(
"posting_time": posting_time, "posting_time": posting_time,
} }
last_entry = get_previous_sle(args) extra_cond = ""
if inventory_dimensions_dict:
for field, value in inventory_dimensions_dict.items():
args[field] = value
extra_cond += f" and {field} = %({field})s"
last_entry = get_previous_sle(args, extra_cond=extra_cond)
if with_valuation_rate: if with_valuation_rate:
if with_serial_no: if with_serial_no: