mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-24 15:39:20 +00:00
Merge pull request #37430 from frappe/version-14-hotfix
chore: release v14
This commit is contained in:
@@ -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
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}"})
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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]))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
127
erpnext/public/js/utils/unreconcile.js
Normal file
127
erpnext/public/js/utils/unreconcile.js
Normal 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
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'));
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user