Merge branch 'develop' of https://github.com/frappe/erpnext into multi_pr_pi

This commit is contained in:
Deepesh Garg
2024-04-15 19:58:48 +05:30
96 changed files with 113212 additions and 21972 deletions

View File

@@ -54,6 +54,7 @@ class BankAccount(Document):
self.validate_company()
self.validate_iban()
self.validate_account()
self.update_default_bank_account()
def validate_account(self):
if self.account:
@@ -100,23 +101,51 @@ class BankAccount(Document):
if to_check % 97 != 1:
frappe.throw(_("IBAN is not valid"))
def update_default_bank_account(self):
if self.is_default and not self.disabled:
frappe.db.set_value(
"Bank Account",
{
"party_type": self.party_type,
"party": self.party,
"is_company_account": self.is_company_account,
"is_default": 1,
"disabled": 0,
},
"is_default",
0,
)
@frappe.whitelist()
def make_bank_account(doctype, docname):
doc = frappe.new_doc("Bank Account")
doc.party_type = doctype
doc.party = docname
doc.is_default = 1
return doc
def get_party_bank_account(party_type, party):
return frappe.db.get_value(party_type, party, "default_bank_account")
return frappe.db.get_value(
"Bank Account",
{"party_type": party_type, "party": party, "is_default": 1, "disabled": 0},
"name",
)
def get_default_company_bank_account(company):
return frappe.db.get_value("Bank Account", {"company": company, "is_company_account": 1, "is_default": 1})
def get_default_company_bank_account(company, party_type, party):
default_company_bank_account = frappe.db.get_value(party_type, party, "default_bank_account")
if default_company_bank_account:
if company != frappe.get_cached_value("Bank Account", default_company_bank_account, "company"):
default_company_bank_account = None
if not default_company_bank_account:
default_company_bank_account = frappe.db.get_value(
"Bank Account", {"company": company, "is_company_account": 1, "is_default": 1}
)
return default_company_bank_account
@frappe.whitelist()

View File

@@ -146,6 +146,10 @@ class Dunning(AccountsController):
)
row.dunning_level = len(past_dunnings) + 1
def on_cancel(self):
super().on_cancel()
self.ignore_linked_doctypes = ["GL Entry"]
def resolve_dunning(doc, state):
"""

View File

@@ -182,6 +182,7 @@ class GLEntry(Document):
and self.company == dimension.company
and dimension.mandatory_for_pl
and not dimension.disabled
and not self.is_cancelled
):
if not self.get(dimension.fieldname):
frappe.throw(
@@ -195,6 +196,7 @@ class GLEntry(Document):
and self.company == dimension.company
and dimension.mandatory_for_bs
and not dimension.disabled
and not self.is_cancelled
):
if not self.get(dimension.fieldname):
frappe.throw(

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Ledger Health", {
// refresh(frm) {
// },
// });

View File

@@ -0,0 +1,70 @@
{
"actions": [],
"autoname": "autoincrement",
"creation": "2024-03-26 17:01:47.443986",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"voucher_type",
"voucher_no",
"checked_on",
"debit_credit_mismatch",
"general_and_payment_ledger_mismatch"
],
"fields": [
{
"fieldname": "voucher_type",
"fieldtype": "Data",
"label": "Voucher Type"
},
{
"fieldname": "voucher_no",
"fieldtype": "Data",
"label": "Voucher No"
},
{
"default": "0",
"fieldname": "debit_credit_mismatch",
"fieldtype": "Check",
"label": "Debit-Credit mismatch"
},
{
"fieldname": "checked_on",
"fieldtype": "Datetime",
"label": "Checked On"
},
{
"default": "0",
"fieldname": "general_and_payment_ledger_mismatch",
"fieldtype": "Check",
"label": "General and Payment Ledger mismatch"
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-04-09 11:16:07.044484",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Ledger Health",
"naming_rule": "Autoincrement",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"read_only": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,25 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LedgerHealth(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
checked_on: DF.Datetime | None
debit_credit_mismatch: DF.Check
general_and_payment_ledger_mismatch: DF.Check
name: DF.Int | None
voucher_no: DF.Data | None
voucher_type: DF.Data | None
# end: auto-generated types
pass

View File

@@ -0,0 +1,109 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe import qb
from frappe.tests.utils import FrappeTestCase
from frappe.utils import nowdate
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.accounts.utils import run_ledger_health_checks
class TestLedgerHealth(AccountsTestMixin, FrappeTestCase):
def setUp(self):
self.create_company()
self.create_customer()
self.configure_monitoring_tool()
self.clear_old_entries()
def tearDown(self):
frappe.db.rollback()
def configure_monitoring_tool(self):
monitor_settings = frappe.get_doc("Ledger Health Monitor")
monitor_settings.enable_health_monitor = True
monitor_settings.enable_for_last_x_days = 60
monitor_settings.debit_credit_mismatch = True
monitor_settings.general_and_payment_ledger_mismatch = True
exists = [x for x in monitor_settings.companies if x.company == self.company]
if not exists:
monitor_settings.append("companies", {"company": self.company})
monitor_settings.save()
def clear_old_entries(self):
super().clear_old_entries()
lh = qb.DocType("Ledger Health")
qb.from_(lh).delete().run()
def create_journal(self):
je = frappe.new_doc("Journal Entry")
je.company = self.company
je.voucher_type = "Journal Entry"
je.posting_date = nowdate()
je.append(
"accounts",
{
"account": self.debit_to,
"party_type": "Customer",
"party": self.customer,
"debit_in_account_currency": 10000,
},
)
je.append("accounts", {"account": self.income_account, "credit_in_account_currency": 10000})
je.save().submit()
self.je = je
def test_debit_credit_mismatch(self):
self.create_journal()
# manually cause debit-credit mismatch
gle = frappe.db.get_all(
"GL Entry", filters={"voucher_no": self.je.name, "account": self.income_account}
)[0]
frappe.db.set_value("GL Entry", gle.name, "credit", 8000)
run_ledger_health_checks()
expected = {
"voucher_type": self.je.doctype,
"voucher_no": self.je.name,
"debit_credit_mismatch": True,
"general_and_payment_ledger_mismatch": False,
}
actual = frappe.db.get_all(
"Ledger Health",
fields=[
"voucher_type",
"voucher_no",
"debit_credit_mismatch",
"general_and_payment_ledger_mismatch",
],
)
self.assertEqual(len(actual), 1)
self.assertEqual(expected, actual[0])
def test_gl_and_pl_mismatch(self):
self.create_journal()
# manually cause GL and PL discrepancy
ple = frappe.db.get_all("Payment Ledger Entry", filters={"voucher_no": self.je.name})[0]
frappe.db.set_value("Payment Ledger Entry", ple.name, "amount", 11000)
run_ledger_health_checks()
expected = {
"voucher_type": self.je.doctype,
"voucher_no": self.je.name,
"debit_credit_mismatch": False,
"general_and_payment_ledger_mismatch": True,
}
actual = frappe.db.get_all(
"Ledger Health",
fields=[
"voucher_type",
"voucher_no",
"debit_credit_mismatch",
"general_and_payment_ledger_mismatch",
],
)
self.assertEqual(len(actual), 1)
self.assertEqual(expected, actual[0])

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Ledger Health Monitor", {
// refresh(frm) {
// },
// });

View File

@@ -0,0 +1,104 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2024-03-27 09:38:07.427997",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"enable_health_monitor",
"monitor_section",
"monitor_for_last_x_days",
"debit_credit_mismatch",
"general_and_payment_ledger_mismatch",
"section_break_xdsp",
"companies"
],
"fields": [
{
"default": "0",
"fieldname": "enable_health_monitor",
"fieldtype": "Check",
"label": "Enable Health Monitor"
},
{
"fieldname": "monitor_section",
"fieldtype": "Section Break",
"label": "Configuration"
},
{
"default": "0",
"fieldname": "debit_credit_mismatch",
"fieldtype": "Check",
"label": "Debit-Credit Mismatch"
},
{
"default": "0",
"fieldname": "general_and_payment_ledger_mismatch",
"fieldtype": "Check",
"label": "Discrepancy between General and Payment Ledger"
},
{
"default": "60",
"fieldname": "monitor_for_last_x_days",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Monitor for Last 'X' days",
"reqd": 1
},
{
"fieldname": "section_break_xdsp",
"fieldtype": "Section Break",
"label": "Companies"
},
{
"fieldname": "companies",
"fieldtype": "Table",
"options": "Ledger Health Monitor Company"
}
],
"hide_toolbar": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2024-03-27 10:14:16.511681",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Ledger Health Monitor",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Accounts User",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -0,0 +1,28 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LedgerHealthMonitor(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
from erpnext.accounts.doctype.ledger_health_monitor_company.ledger_health_monitor_company import (
LedgerHealthMonitorCompany,
)
companies: DF.Table[LedgerHealthMonitorCompany]
debit_credit_mismatch: DF.Check
enable_health_monitor: DF.Check
general_and_payment_ledger_mismatch: DF.Check
monitor_for_last_x_days: DF.Int
# end: auto-generated types
pass

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestLedgerHealthMonitor(FrappeTestCase):
pass

View File

@@ -0,0 +1,32 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2024-03-27 10:04:45.727054",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"company"
],
"fields": [
{
"fieldname": "company",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Company",
"options": "Company"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 10:06:22.806155",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Ledger Health Monitor Company",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,23 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LedgerHealthMonitorCompany(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
company: DF.Link | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
# end: auto-generated types
pass

View File

@@ -1665,6 +1665,8 @@ frappe.ui.form.on("Payment Entry Reference", {
frm.doc.payment_type == "Receive"
? frm.doc.paid_from_account_currency
: frm.doc.paid_to_account_currency,
party_type: frm.doc.party_type,
party: frm.doc.party,
},
callback: function (r, rt) {
if (r.message) {

View File

@@ -188,6 +188,7 @@ class PaymentEntry(AccountsController):
self.update_outstanding_amounts()
self.update_advance_paid()
self.update_payment_schedule()
self.set_payment_req_status()
self.set_status()
def set_liability_account(self):
@@ -484,7 +485,7 @@ class PaymentEntry(AccountsController):
self,
force: bool = False,
update_ref_details_only_for: list | None = None,
ref_exchange_rate: float | None = None,
reference_exchange_details: dict | None = None,
) -> None:
for d in self.get("references"):
if d.allocated_amount:
@@ -495,12 +496,20 @@ class PaymentEntry(AccountsController):
continue
ref_details = get_reference_details(
d.reference_doctype, d.reference_name, self.party_account_currency
d.reference_doctype,
d.reference_name,
self.party_account_currency,
self.party_type,
self.party,
)
# Only update exchange rate when the reference is Journal Entry
if ref_exchange_rate and d.reference_doctype == "Journal Entry":
ref_details.update({"exchange_rate": ref_exchange_rate})
if (
reference_exchange_details
and d.reference_doctype == reference_exchange_details.reference_doctype
and d.reference_name == reference_exchange_details.reference_name
):
ref_details.update({"exchange_rate": reference_exchange_details.exchange_rate})
for field, value in ref_details.items():
if d.exchange_gain_loss:
@@ -1120,9 +1129,11 @@ class PaymentEntry(AccountsController):
else:
remarks = [
_("Amount {0} {1} {2} {3}").format(
_(self.party_account_currency),
_(self.paid_to_account_currency)
if self.payment_type == "Receive"
else _(self.paid_from_account_currency),
self.paid_amount if self.payment_type == "Receive" else self.received_amount,
_("received from") if self.payment_type == "Receive" else _("to"),
_("received from") if self.payment_type == "Receive" else _("paid to"),
self.party,
)
]
@@ -1311,6 +1322,18 @@ class PaymentEntry(AccountsController):
):
self.add_advance_gl_for_reference(gl_entries, ref)
def get_dr_and_account_for_advances(self, reference):
if reference.reference_doctype == "Sales Invoice":
return "credit", reference.account
if reference.reference_doctype == "Payment Entry":
if reference.account_type == "Receivable" and reference.payment_type == "Pay":
return "credit", self.party_account
else:
return "debit", self.party_account
return "debit", reference.account
def add_advance_gl_for_reference(self, gl_entries, invoice):
args_dict = {
"party_type": self.party_type,
@@ -1330,8 +1353,8 @@ class PaymentEntry(AccountsController):
if getdate(posting_date) < getdate(self.posting_date):
posting_date = self.posting_date
dr_or_cr = "credit" if invoice.reference_doctype in ["Sales Invoice", "Payment Entry"] else "debit"
args_dict["account"] = invoice.account
dr_or_cr, account = self.get_dr_and_account_for_advances(invoice)
args_dict["account"] = account
args_dict[dr_or_cr] = invoice.allocated_amount
args_dict[dr_or_cr + "_in_account_currency"] = invoice.allocated_amount
args_dict.update(
@@ -2115,8 +2138,7 @@ def get_party_details(company, party_type, party, date, cost_center=None):
if party_type in ["Customer", "Supplier"]:
party_bank_account = get_party_bank_account(party_type, party)
bank_account = get_default_company_bank_account(company)
bank_account = get_default_company_bank_account(company, party_type, party)
return {
"party_account": party_account,
"party_name": party_name,
@@ -2157,49 +2179,75 @@ def get_company_defaults(company):
return frappe.get_cached_value("Company", company, fields, as_dict=1)
def get_outstanding_on_journal_entry(name):
gl = frappe.qb.DocType("GL Entry")
res = (
frappe.qb.from_(gl)
.select(
Case()
.when(
gl.party_type == "Customer",
Coalesce(Sum(gl.debit_in_account_currency - gl.credit_in_account_currency), 0),
)
.else_(Coalesce(Sum(gl.credit_in_account_currency - gl.debit_in_account_currency), 0))
.as_("outstanding_amount")
)
def get_outstanding_on_journal_entry(voucher_no, party_type, party):
ple = frappe.qb.DocType("Payment Ledger Entry")
outstanding = (
frappe.qb.from_(ple)
.select(Sum(ple.amount_in_account_currency))
.where(
(Coalesce(gl.party_type, "") != "")
& (gl.is_cancelled == 0)
& ((gl.voucher_no == name) | (gl.against_voucher == name))
(ple.against_voucher_no == voucher_no)
& (ple.party_type == party_type)
& (ple.party == party)
& (ple.delinked == 0)
)
).run(as_dict=True)
).run()
outstanding_amount = res[0].get("outstanding_amount", 0) if res else 0
outstanding_amount = outstanding[0][0] if outstanding else 0
return outstanding_amount
total = (
frappe.qb.from_(ple)
.select(Sum(ple.amount_in_account_currency))
.where(
(ple.voucher_no == voucher_no)
& (ple.party_type == party_type)
& (ple.party == party)
& (ple.delinked == 0)
)
).run()
total_amount = total[0][0] if total else 0
return outstanding_amount, total_amount
@frappe.whitelist()
def get_reference_details(reference_doctype, reference_name, party_account_currency):
def get_reference_details(
reference_doctype, reference_name, party_account_currency, party_type=None, party=None
):
total_amount = outstanding_amount = exchange_rate = account = None
ref_doc = frappe.get_doc(reference_doctype, reference_name)
company_currency = ref_doc.get("company_currency") or erpnext.get_company_currency(ref_doc.company)
# Only applies for Reverse Payment Entries
account_type = None
payment_type = None
if reference_doctype == "Dunning":
total_amount = outstanding_amount = ref_doc.get("dunning_amount")
exchange_rate = 1
elif reference_doctype == "Journal Entry" and ref_doc.docstatus == 1:
total_amount = ref_doc.get("total_amount")
if ref_doc.multi_currency:
exchange_rate = get_exchange_rate(party_account_currency, company_currency, ref_doc.posting_date)
else:
exchange_rate = 1
outstanding_amount = get_outstanding_on_journal_entry(reference_name)
outstanding_amount, total_amount = get_outstanding_on_journal_entry(
reference_name, party_type, party
)
elif reference_doctype == "Payment Entry":
if reverse_payment_details := frappe.db.get_all(
"Payment Entry",
filters={"name": reference_name},
fields=["payment_type", "party_type"],
)[0]:
payment_type = reverse_payment_details.payment_type
account_type = frappe.db.get_value(
"Party Type", reverse_payment_details.party_type, "account_type"
)
exchange_rate = 1
elif reference_doctype != "Journal Entry":
if not total_amount:
@@ -2245,6 +2293,8 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre
"outstanding_amount": flt(outstanding_amount),
"exchange_rate": flt(exchange_rate),
"bill_no": ref_doc.get("bill_no"),
"account_type": account_type,
"payment_type": payment_type,
}
)
if account:

View File

@@ -1074,9 +1074,13 @@ class TestPaymentEntry(FrappeTestCase):
pe.source_exchange_rate = 50
pe.save()
ref_details = get_reference_details(so.doctype, so.name, pe.paid_from_account_currency)
ref_details = get_reference_details(
so.doctype, so.name, pe.paid_from_account_currency, "Customer", so.customer
)
expected_response = {
"account": get_party_account("Customer", so.customer, so.company),
"account_type": None, # only applies for Reverse Payment Entry
"payment_type": None, # only applies for Reverse Payment Entry
"total_amount": 5000.0,
"outstanding_amount": 5000.0,
"exchange_rate": 1.0,
@@ -1543,7 +1547,7 @@ class TestPaymentEntry(FrappeTestCase):
company = "_Test Company"
customer = create_customer(frappe.generate_hash(length=10), "INR")
advance_account = create_account(
parent_account="Current Assets - _TC",
parent_account="Current Liabilities - _TC",
account_name="Advances Received",
company=company,
account_type="Receivable",
@@ -1599,9 +1603,9 @@ class TestPaymentEntry(FrappeTestCase):
# assert General and Payment Ledger entries post partial reconciliation
self.expected_gle = [
{"account": "Debtors - _TC", "debit": 0.0, "credit": 400.0},
{"account": advance_account, "debit": 400.0, "credit": 0.0},
{"account": advance_account, "debit": 0.0, "credit": 1000.0},
{"account": advance_account, "debit": 0.0, "credit": 400.0},
{"account": "_Test Cash - _TC", "debit": 1000.0, "credit": 0.0},
]
self.expected_ple = [
@@ -1612,7 +1616,7 @@ class TestPaymentEntry(FrappeTestCase):
"amount": -1000.0,
},
{
"account": "Debtors - _TC",
"account": advance_account,
"voucher_no": pe.name,
"against_voucher_no": reverse_pe.name,
"amount": -400.0,

View File

@@ -10,6 +10,8 @@
"due_date",
"bill_no",
"payment_term",
"account_type",
"payment_type",
"column_break_4",
"total_amount",
"outstanding_amount",
@@ -108,12 +110,22 @@
"fieldtype": "Link",
"label": "Account",
"options": "Account"
},
{
"fieldname": "account_type",
"fieldtype": "Data",
"label": "Account Type"
},
{
"fieldname": "payment_type",
"fieldtype": "Data",
"label": "Payment Type"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:09.578312",
"modified": "2024-04-05 09:44:08.310593",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Reference",

View File

@@ -15,6 +15,7 @@ class PaymentEntryReference(Document):
from frappe.types import DF
account: DF.Link | None
account_type: DF.Data | None
allocated_amount: DF.Float
bill_no: DF.Data | None
due_date: DF.Date | None
@@ -25,6 +26,7 @@ class PaymentEntryReference(Document):
parentfield: DF.Data
parenttype: DF.Data
payment_term: DF.Link | None
payment_type: DF.Data | None
reference_doctype: DF.Link
reference_name: DF.DynamicLink
total_amount: DF.Float

View File

@@ -5,6 +5,8 @@ import unittest
# test_records = frappe.get_test_records('Payment Gateway Account')
test_ignore = ["Payment Gateway"]
class TestPaymentGatewayAccount(unittest.TestCase):
pass

View File

@@ -101,6 +101,14 @@ class TestPaymentReconciliation(FrappeTestCase):
"account_currency": "USD",
"account_type": "Payable",
},
# 'Payable' account for capturing advance paid, under 'Assets' group
{
"attribute": "advance_payable_account",
"account_name": "Advance Paid",
"parent_account": "Current Assets - _PR",
"account_currency": "INR",
"account_type": "Payable",
},
]
for x in accounts:
@@ -1335,6 +1343,188 @@ class TestPaymentReconciliation(FrappeTestCase):
# Should not raise frappe.exceptions.ValidationError: Payment Entry has been modified after you pulled it. Please pull it again.
pr.reconcile()
def test_reverse_payment_against_payment_for_supplier(self):
"""
Reconcile a payment against a reverse payment, for a supplier.
"""
self.supplier = "_Test Supplier"
amount = 4000
pe = self.create_payment_entry(amount=amount)
pe.party_type = "Supplier"
pe.party = self.supplier
pe.payment_type = "Pay"
pe.paid_from = self.cash
pe.paid_to = self.creditors
pe.save().submit()
reverse_pe = self.create_payment_entry(amount=amount)
reverse_pe.party_type = "Supplier"
reverse_pe.party = self.supplier
reverse_pe.payment_type = "Receive"
reverse_pe.paid_from = self.creditors
reverse_pe.paid_to = self.cash
reverse_pe.save().submit()
pr = self.create_payment_reconciliation(party_is_customer=False)
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
self.assertEqual(pr.invoices[0].invoice_number, reverse_pe.name)
self.assertEqual(pr.payments[0].reference_name, pe.name)
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.reconcile()
pe.reload()
self.assertEqual(len(pe.references), 1)
self.assertEqual(pe.references[0].exchange_rate, 1)
# There should not be any Exc Gain/Loss
self.assertEqual(pe.references[0].exchange_gain_loss, 0)
self.assertEqual(pe.references[0].reference_name, reverse_pe.name)
journals = frappe.db.get_all(
"Journal Entry",
filters={
"voucher_type": "Exchange Gain Or Loss",
"reference_type": "Payment Entry",
"reference_name": ("in", [pe.name, reverse_pe.name]),
},
)
# There should be no Exchange Gain/Loss created
self.assertEqual(journals, [])
def test_advance_reverse_payment_against_payment_for_supplier(self):
"""
Reconcile an Advance payment against reverse payment, for a supplier.
"""
frappe.db.set_value(
"Company",
self.company,
{
"book_advance_payments_in_separate_party_account": 1,
"default_advance_paid_account": self.advance_payable_account,
},
)
self.supplier = "_Test Supplier"
amount = 4000
pe = self.create_payment_entry(amount=amount)
pe.party_type = "Supplier"
pe.party = self.supplier
pe.payment_type = "Pay"
pe.paid_from = self.cash
pe.paid_to = self.advance_payable_account
pe.save().submit()
reverse_pe = self.create_payment_entry(amount=amount)
reverse_pe.party_type = "Supplier"
reverse_pe.party = self.supplier
reverse_pe.payment_type = "Receive"
reverse_pe.paid_from = self.advance_payable_account
reverse_pe.paid_to = self.cash
reverse_pe.save().submit()
pr = self.create_payment_reconciliation(party_is_customer=False)
pr.default_advance_account = self.advance_payable_account
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
self.assertEqual(pr.invoices[0].invoice_number, reverse_pe.name)
self.assertEqual(pr.payments[0].reference_name, pe.name)
invoices = [invoice.as_dict() for invoice in pr.invoices]
payments = [payment.as_dict() for payment in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.reconcile()
pe.reload()
self.assertEqual(len(pe.references), 1)
self.assertEqual(pe.references[0].exchange_rate, 1)
# There should not be any Exc Gain/Loss
self.assertEqual(pe.references[0].exchange_gain_loss, 0)
self.assertEqual(pe.references[0].reference_name, reverse_pe.name)
journals = frappe.db.get_all(
"Journal Entry",
filters={
"voucher_type": "Exchange Gain Or Loss",
"reference_type": "Payment Entry",
"reference_name": ("in", [pe.name, reverse_pe.name]),
},
)
# There should be no Exchange Gain/Loss created
self.assertEqual(journals, [])
# Assert Ledger Entries
gl_entries = frappe.db.get_all(
"GL Entry",
filters={"voucher_no": pe.name},
fields=["account", "voucher_no", "against_voucher", "debit", "credit"],
order_by="account, against_voucher, debit",
)
expected_gle = [
{
"account": self.advance_payable_account,
"voucher_no": pe.name,
"against_voucher": pe.name,
"debit": 0.0,
"credit": amount,
},
{
"account": self.advance_payable_account,
"voucher_no": pe.name,
"against_voucher": pe.name,
"debit": amount,
"credit": 0.0,
},
{
"account": self.advance_payable_account,
"voucher_no": pe.name,
"against_voucher": reverse_pe.name,
"debit": amount,
"credit": 0.0,
},
{
"account": "Cash - _PR",
"voucher_no": pe.name,
"against_voucher": None,
"debit": 0.0,
"credit": amount,
},
]
self.assertEqual(gl_entries, expected_gle)
pl_entries = frappe.db.get_all(
"Payment Ledger Entry",
filters={"voucher_no": pe.name},
fields=["account", "voucher_no", "against_voucher_no", "amount"],
order_by="account, against_voucher_no, amount",
)
expected_ple = [
{
"account": self.advance_payable_account,
"voucher_no": pe.name,
"against_voucher_no": pe.name,
"amount": -amount,
},
{
"account": self.advance_payable_account,
"voucher_no": pe.name,
"against_voucher_no": pe.name,
"amount": amount,
},
{
"account": self.advance_payable_account,
"voucher_no": pe.name,
"against_voucher_no": reverse_pe.name,
"amount": -amount,
},
]
self.assertEqual(pl_entries, expected_ple)
def make_customer(customer_name, currency=None):
if not frappe.db.exists("Customer", customer_name):

View File

@@ -149,35 +149,37 @@ class PaymentRequest(Document):
).format(self.grand_total, amount)
)
def on_submit(self):
if self.payment_request_type == "Outward":
self.db_set("status", "Initiated")
return
elif self.payment_request_type == "Inward":
self.db_set("status", "Requested")
send_mail = self.payment_gateway_validation() if self.payment_gateway else None
def on_change(self):
ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
if (
hasattr(ref_doc, "order_type") and ref_doc.order_type == "Shopping Cart"
) or self.flags.mute_email:
send_mail = False
if send_mail and self.payment_channel != "Phone":
self.set_payment_request_url()
self.send_email()
self.make_communication_entry()
elif self.payment_channel == "Phone":
self.request_phone_payment()
advance_payment_doctypes = frappe.get_hooks("advance_payment_receivable_doctypes") + frappe.get_hooks(
"advance_payment_payable_doctypes"
)
if self.reference_doctype in advance_payment_doctypes:
# set advance payment status
ref_doc.set_total_advance_paid()
ref_doc.set_advance_payment_status()
def on_submit(self):
if self.payment_request_type == "Outward":
self.db_set("status", "Initiated")
elif self.payment_request_type == "Inward":
self.db_set("status", "Requested")
if self.payment_request_type == "Inward":
send_mail = self.payment_gateway_validation() if self.payment_gateway else None
ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
if (
hasattr(ref_doc, "order_type") and ref_doc.order_type == "Shopping Cart"
) or self.flags.mute_email:
send_mail = False
if send_mail and self.payment_channel != "Phone":
self.set_payment_request_url()
self.send_email()
self.make_communication_entry()
elif self.payment_channel == "Phone":
self.request_phone_payment()
def request_phone_payment(self):
controller = _get_payment_gateway_controller(self.payment_gateway)
@@ -217,14 +219,6 @@ class PaymentRequest(Document):
self.check_if_payment_entry_exists()
self.set_as_cancelled()
ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
advance_payment_doctypes = frappe.get_hooks("advance_payment_receivable_doctypes") + frappe.get_hooks(
"advance_payment_payable_doctypes"
)
if self.reference_doctype in advance_payment_doctypes:
# set advance payment status
ref_doc.set_total_advance_paid()
def make_invoice(self):
ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
if hasattr(ref_doc, "order_type") and ref_doc.order_type == "Shopping Cart":
@@ -641,7 +635,11 @@ def update_payment_req_status(doc, method):
if payment_request_name:
ref_details = get_reference_details(
ref.reference_doctype, ref.reference_name, doc.party_account_currency
ref.reference_doctype,
ref.reference_name,
doc.party_account_currency,
doc.party_type,
doc.party,
)
pay_req_doc = frappe.get_doc("Payment Request", payment_request_name)
status = pay_req_doc.status

View File

@@ -35,10 +35,11 @@
}
],
"links": [],
"modified": "2024-03-27 13:10:11.676098",
"modified": "2024-04-07 11:26:42.021585",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Terms Template",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -76,6 +77,15 @@
"role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"report": 1,
"role": "All",
"select": 1,
"share": 1
}
],
"sort_field": "creation",

View File

@@ -441,6 +441,7 @@ class POSInvoice(SalesInvoice):
if self.is_return:
invoice_total = self.rounded_total or self.grand_total
total_amount_in_payments = flt(total_amount_in_payments, self.precision("grand_total"))
if total_amount_in_payments and total_amount_in_payments < invoice_total:
frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total))

View File

@@ -136,7 +136,11 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
if (!doc.is_return && doc.docstatus == 1) {
if (doc.outstanding_amount >= 0 || Math.abs(flt(doc.outstanding_amount)) < flt(doc.grand_total)) {
this.frm.add_custom_button(__("Return / Debit Note"), this.make_debit_note, __("Create"));
this.frm.add_custom_button(
__("Return / Debit Note"),
this.make_debit_note.bind(this),
__("Create")
);
}
}

View File

@@ -16,6 +16,7 @@
"col_break1",
"account_head",
"description",
"is_tax_withholding_account",
"section_break_10",
"rate",
"accounting_dimensions_section",
@@ -225,15 +226,23 @@
"label": "Account Currency",
"options": "Currency",
"read_only": 1
},
{
"default": "0",
"fieldname": "is_tax_withholding_account",
"fieldtype": "Check",
"label": "Is Tax Withholding Account",
"read_only": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:26.775139",
"modified": "2024-04-08 19:51:36.678551",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Taxes and Charges",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"sort_field": "creation",

View File

@@ -33,6 +33,7 @@ class PurchaseTaxesandCharges(Document):
description: DF.SmallText
included_in_paid_amount: DF.Check
included_in_print_rate: DF.Check
is_tax_withholding_account: DF.Check
item_wise_tax_detail: DF.Code | None
parent: DF.Data
parentfield: DF.Data

View File

@@ -118,7 +118,11 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
});
if (doc.outstanding_amount >= 0 || Math.abs(flt(doc.outstanding_amount)) < flt(doc.grand_total)) {
this.frm.add_custom_button(__("Return / Credit Note"), this.make_sales_return, __("Create"));
this.frm.add_custom_button(
__("Return / Credit Note"),
this.make_sales_return.bind(this),
__("Create")
);
this.frm.page.set_inner_btn_group_as_primary(__("Create"));
}

View File

@@ -3632,6 +3632,105 @@ class TestSalesInvoice(FrappeTestCase):
self.assertEqual(1, len(advances))
self.assertEqual(advances[0].reference_name, pe.name)
def test_taxes_merging_from_delivery_note(self):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
dn1 = create_delivery_note(do_not_submit=1)
dn1.items[0].qty = 10
dn1.items[0].rate = 100
dn1.append(
"taxes",
{
"charge_type": "Actual",
"account_head": "Freight and Forwarding Charges - _TC",
"description": "movement charges",
"tax_amount": 100,
},
)
dn1.append(
"taxes",
{
"charge_type": "Actual",
"account_head": "Marketing Expenses - _TC",
"description": "marketing",
"tax_amount": 150,
},
)
dn1.save().submit()
dn2 = create_delivery_note(do_not_submit=1)
dn2.items[0].qty = 5
dn2.items[0].rate = 100
dn2.append(
"taxes",
{
"charge_type": "Actual",
"account_head": "Freight and Forwarding Charges - _TC",
"description": "movement charges",
"tax_amount": 20,
},
)
dn2.append(
"taxes",
{
"charge_type": "Actual",
"account_head": "Miscellaneous Expenses - _TC",
"description": "marketing",
"tax_amount": 60,
},
)
dn2.save().submit()
# si = make_sales_invoice(dn1.name)
si = create_sales_invoice(do_not_submit=True)
si.customer = dn1.customer
si.items.clear()
from frappe.model.mapper import map_docs
map_docs(
method="erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
source_names=frappe.json.dumps([dn1.name, dn2.name]),
target_doc=si,
args=frappe.json.dumps({"customer": dn1.customer, "merge_taxes": 1, "filtered_children": []}),
)
si.save().submit()
expected = [
{
"charge_type": "Actual",
"account_head": "Freight and Forwarding Charges - _TC",
"tax_amount": 120.0,
"total": 1520.0,
"base_total": 1520.0,
},
{
"charge_type": "Actual",
"account_head": "Marketing Expenses - _TC",
"tax_amount": 150.0,
"total": 1670.0,
"base_total": 1670.0,
},
{
"charge_type": "Actual",
"account_head": "Miscellaneous Expenses - _TC",
"tax_amount": 60.0,
"total": 1610.0,
"base_total": 1610.0,
},
]
actual = [
dict(
charge_type=x.charge_type,
account_head=x.account_head,
tax_amount=x.tax_amount,
total=x.total,
base_total=x.base_total,
)
for x in si.taxes
]
self.assertEqual(expected, actual)
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(

View File

@@ -146,7 +146,12 @@ def get_party_tax_withholding_details(inv, tax_withholding_category=None):
tax_row = get_tax_row_for_tcs(inv, tax_details, tax_amount, tax_deducted)
cost_center = get_cost_center(inv)
tax_row.update({"cost_center": cost_center})
tax_row.update(
{
"cost_center": cost_center,
"is_tax_withholding_account": 1,
}
)
if inv.doctype == "Purchase Invoice":
return tax_row, tax_deducted_on_advances, voucher_wise_amount

View File

@@ -55,10 +55,10 @@
</span>
</td>
<td style="text-align: right">
{%= format_currency(data[i].debit, filters.presentation_currency) %}
{%= format_currency(data[i].debit, filters.presentation_currency || data[i].account_currency) %}
</td>
<td style="text-align: right">
{%= format_currency(data[i].credit, filters.presentation_currency) %}
{%= format_currency(data[i].credit, filters.presentation_currency || data[i].account_currency) %}
</td>
{% } else { %}
<td></td>

View File

@@ -347,7 +347,7 @@ def get_data_with_opening_closing(filters, account_details, accounting_dimension
# acc
if acc_dict.entries:
# opening
data.append({})
data.append({"debit_in_transaction_currency": None, "credit_in_transaction_currency": None})
if filters.get("group_by") != "Group by Voucher":
data.append(acc_dict.totals.opening)
@@ -359,7 +359,8 @@ def get_data_with_opening_closing(filters, account_details, accounting_dimension
# closing
if filters.get("group_by") != "Group by Voucher":
data.append(acc_dict.totals.closing)
data.append({})
data.append({"debit_in_transaction_currency": None, "credit_in_transaction_currency": None})
else:
data += entries
@@ -380,6 +381,8 @@ def get_totals_dict():
credit=0.0,
debit_in_account_currency=0.0,
credit_in_account_currency=0.0,
debit_in_transaction_currency=None,
credit_in_transaction_currency=None,
)
return _dict(
@@ -424,6 +427,10 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map):
data[key].debit_in_account_currency += gle.debit_in_account_currency
data[key].credit_in_account_currency += gle.credit_in_account_currency
if filters.get("add_values_in_transaction_currency") and key not in ["opening", "closing", "total"]:
data[key].debit_in_transaction_currency += gle.debit_in_transaction_currency
data[key].credit_in_transaction_currency += gle.credit_in_transaction_currency
if filters.get("show_net_values_in_party_account") and account_type_map.get(data[key].account) in (
"Receivable",
"Payable",

View File

@@ -61,10 +61,11 @@ def get_pos_entries(filters, group_by_field):
order_by += f", p.{group_by_field}"
select_mop_field = ", p.base_paid_amount - p.change_amount as paid_amount "
# nosemgrep
return frappe.db.sql(
f"""
SELECT
p.posting_date, p.name as pos_invoice, p.pos_profile,
p.posting_date, p.name as pos_invoice, p.pos_profile, p.company,
p.owner, p.customer, p.is_return, p.base_grand_total as grand_total {select_mop_field}
FROM
`tabPOS Invoice` p {from_sales_invoice_payment}
@@ -201,14 +202,14 @@ def get_columns(filters):
"label": _("Grand Total"),
"fieldname": "grand_total",
"fieldtype": "Currency",
"options": "company:currency",
"options": "Company:company:default_currency",
"width": 120,
},
{
"label": _("Paid Amount"),
"fieldname": "paid_amount",
"fieldtype": "Currency",
"options": "company:currency",
"options": "Company:company:default_currency",
"width": 120,
},
{
@@ -218,6 +219,13 @@ def get_columns(filters):
"width": 150,
},
{"label": _("Is Return"), "fieldname": "is_return", "fieldtype": "Data", "width": 80},
{
"label": _("Company"),
"fieldname": "company",
"fieldtype": "Link",
"options": "Company",
"width": 120,
},
]
return columns

View File

@@ -13,11 +13,13 @@ from frappe.query_builder import AliasedQuery, Criterion, Table
from frappe.query_builder.functions import Round, Sum
from frappe.query_builder.utils import DocType
from frappe.utils import (
add_days,
cint,
create_batch,
cstr,
flt,
formatdate,
get_datetime,
get_number_format_info,
getdate,
now,
@@ -720,7 +722,19 @@ def update_reference_in_payment_entry(
payment_entry.setup_party_account_field()
payment_entry.set_missing_values()
if not skip_ref_details_update_for_pe:
payment_entry.set_missing_ref_details(ref_exchange_rate=d.exchange_rate or None)
reference_exchange_details = frappe._dict()
if d.against_voucher_type == "Journal Entry" and d.exchange_rate:
reference_exchange_details.update(
{
"reference_doctype": d.against_voucher_type,
"reference_name": d.against_voucher,
"exchange_rate": d.exchange_rate,
}
)
payment_entry.set_missing_ref_details(
update_ref_details_only_for=[(d.against_voucher_type, d.against_voucher)],
reference_exchange_details=reference_exchange_details,
)
payment_entry.set_amounts()
payment_entry.make_exchange_gain_loss_journal(
@@ -1709,6 +1723,7 @@ def update_voucher_outstanding(voucher_type, voucher_no, account, party_type, pa
)
ref_doc.set_status(update=True)
ref_doc.notify_update()
def delink_original_entry(pl_entry, partial_cancel=False):
@@ -2059,3 +2074,44 @@ def create_gain_loss_journal(
def get_party_types_from_account_type(account_type):
return frappe.db.get_all("Party Type", {"account_type": account_type}, pluck="name")
def run_ledger_health_checks():
health_monitor_settings = frappe.get_doc("Ledger Health Monitor")
if health_monitor_settings.enable_health_monitor:
period_end = getdate()
period_start = add_days(period_end, -abs(health_monitor_settings.monitor_for_last_x_days))
run_date = get_datetime()
# Debit-Credit mismatch report
if health_monitor_settings.debit_credit_mismatch:
for x in health_monitor_settings.companies:
filters = {"company": x.company, "from_date": period_start, "to_date": period_end}
voucher_wise = frappe.get_doc("Report", "Voucher-wise Balance")
res = voucher_wise.execute_script_report(filters=filters)
for x in res[1]:
doc = frappe.new_doc("Ledger Health")
doc.voucher_type = x.voucher_type
doc.voucher_no = x.voucher_no
doc.debit_credit_mismatch = True
doc.checked_on = run_date
doc.save()
# General Ledger and Payment Ledger discrepancy
if health_monitor_settings.general_and_payment_ledger_mismatch:
for x in health_monitor_settings.companies:
filters = {
"company": x.company,
"period_start_date": period_start,
"period_end_date": period_end,
}
gl_pl_comparison = frappe.get_doc("Report", "General and Payment Ledger Comparison")
res = gl_pl_comparison.execute_script_report(filters=filters)
for x in res[1]:
doc = frappe.new_doc("Ledger Health")
doc.voucher_type = x.voucher_type
doc.voucher_no = x.voucher_no
doc.general_and_payment_ledger_mismatch = True
doc.checked_on = run_date
doc.save()

View File

@@ -232,7 +232,11 @@ class TestAsset(AssetSetup):
asset.precision("gross_purchase_amount"),
)
pro_rata_amount, _, _ = _get_pro_rata_amt(
asset.finance_books[0], 9000, get_last_day(add_months(purchase_date, 1)), date
asset.finance_books[0],
9000,
get_last_day(add_months(purchase_date, 1)),
date,
original_schedule_date=get_last_day(nowdate()),
)
pro_rata_amount = flt(pro_rata_amount, asset.precision("gross_purchase_amount"))
self.assertEqual(
@@ -314,7 +318,11 @@ class TestAsset(AssetSetup):
self.assertEqual(first_asset_depr_schedule.status, "Cancelled")
pro_rata_amount, _, _ = _get_pro_rata_amt(
asset.finance_books[0], 9000, get_last_day(add_months(purchase_date, 1)), date
asset.finance_books[0],
9000,
get_last_day(add_months(purchase_date, 1)),
date,
original_schedule_date=get_last_day(nowdate()),
)
pro_rata_amount = flt(pro_rata_amount, asset.precision("gross_purchase_amount"))
@@ -332,7 +340,6 @@ class TestAsset(AssetSetup):
),
("Debtors - _TC", 25000.0, 0.0),
)
gle = get_gl_entries("Sales Invoice", si.name)
self.assertSequenceEqual(gle, expected_gle)
@@ -378,7 +385,7 @@ class TestAsset(AssetSetup):
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold")
expected_values = [["2023-03-31", 12000, 36000], ["2023-05-23", 1742.47, 37742.47]]
expected_values = [["2023-03-31", 12000, 36000], ["2023-05-23", 1737.7, 37737.7]]
second_asset_depr_schedule = get_depr_schedule(asset.name, "Active")
@@ -391,7 +398,7 @@ class TestAsset(AssetSetup):
expected_gle = (
(
"_Test Accumulated Depreciations - _TC",
37742.47,
37737.7,
0.0,
),
(
@@ -402,7 +409,7 @@ class TestAsset(AssetSetup):
(
"_Test Gain/Loss on Asset Disposal - _TC",
0.0,
17742.47,
17737.7,
),
("Debtors - _TC", 40000.0, 0.0),
)
@@ -707,25 +714,24 @@ class TestDepreciationMethods(AssetSetup):
)
expected_schedules = [
["2023-01-31", 1021.98, 1021.98],
["2023-02-28", 923.08, 1945.06],
["2023-03-31", 1021.98, 2967.04],
["2023-04-30", 989.01, 3956.05],
["2023-05-31", 1021.98, 4978.03],
["2023-06-30", 989.01, 5967.04],
["2023-07-31", 1021.98, 6989.02],
["2023-08-31", 1021.98, 8011.0],
["2023-09-30", 989.01, 9000.01],
["2023-10-31", 1021.98, 10021.99],
["2023-11-30", 989.01, 11011.0],
["2023-12-31", 989.0, 12000.0],
["2023-01-31", 1019.18, 1019.18],
["2023-02-28", 920.55, 1939.73],
["2023-03-31", 1019.18, 2958.91],
["2023-04-30", 986.3, 3945.21],
["2023-05-31", 1019.18, 4964.39],
["2023-06-30", 986.3, 5950.69],
["2023-07-31", 1019.18, 6969.87],
["2023-08-31", 1019.18, 7989.05],
["2023-09-30", 986.3, 8975.35],
["2023-10-31", 1019.18, 9994.53],
["2023-11-30", 986.3, 10980.83],
["2023-12-31", 1019.17, 12000.0],
]
schedules = [
[cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount]
for d in get_depr_schedule(asset.name, "Draft")
]
self.assertEqual(schedules, expected_schedules)
def test_schedule_for_straight_line_method_for_existing_asset(self):

View File

@@ -313,7 +313,6 @@ class AssetDepreciationSchedule(Document):
has_wdv_or_dd_non_yearly_pro_rata,
number_of_pending_depreciations,
)
if not has_pro_rata or (
n < (cint(final_number_of_depreciations) - 1) or final_number_of_depreciations == 2
):
@@ -338,6 +337,7 @@ class AssetDepreciationSchedule(Document):
depreciation_amount,
from_date,
date_of_disposal,
original_schedule_date=schedule_date,
)
if depreciation_amount > 0:
@@ -565,23 +565,27 @@ def _get_modified_available_for_use_date(asset_doc, row, wdv_or_dd_non_yearly=Fa
)
def _get_pro_rata_amt(row, depreciation_amount, from_date, to_date, has_wdv_or_dd_non_yearly_pro_rata=False):
def _get_pro_rata_amt(
row,
depreciation_amount,
from_date,
to_date,
has_wdv_or_dd_non_yearly_pro_rata=False,
original_schedule_date=None,
):
days = date_diff(to_date, from_date)
months = month_diff(to_date, from_date)
if has_wdv_or_dd_non_yearly_pro_rata:
total_days = get_total_days(to_date, 12)
total_days = get_total_days(original_schedule_date or to_date, 12)
else:
total_days = get_total_days(to_date, row.frequency_of_depreciation)
total_days = get_total_days(original_schedule_date or to_date, row.frequency_of_depreciation)
return (depreciation_amount * flt(days)) / flt(total_days), days, months
def get_total_days(date, frequency):
period_start_date = add_months(date, cint(frequency) * -1)
if is_last_day_of_the_month(date):
period_start_date = get_last_day(period_start_date)
return date_diff(date, period_start_date)
@@ -632,33 +636,37 @@ def get_straight_line_or_manual_depr_amount(
# if the Depreciation Schedule is being modified after Asset Value Adjustment due to decrease in asset value
elif asset.flags.decrease_in_asset_value_due_to_value_adjustment:
if row.daily_prorata_based:
daily_depr_amount = (
flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life)
) / date_diff(
get_last_day(
add_months(
row.depreciation_start_date,
flt(row.total_number_of_depreciations - asset.number_of_depreciations_booked - 1)
* row.frequency_of_depreciation,
)
),
add_days(
amount = flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life)
total_days = (
date_diff(
get_last_day(
add_months(
row.depreciation_start_date,
flt(
row.total_number_of_depreciations
- asset.number_of_depreciations_booked
- number_of_pending_depreciations
- 1
)
flt(row.total_number_of_depreciations - asset.number_of_depreciations_booked - 1)
* row.frequency_of_depreciation,
)
),
1,
),
add_days(
get_last_day(
add_months(
row.depreciation_start_date,
flt(
row.total_number_of_depreciations
- asset.number_of_depreciations_booked
- number_of_pending_depreciations
- 1
)
* row.frequency_of_depreciation,
)
),
1,
),
)
+ 1
)
daily_depr_amount = amount / total_days
to_date = get_last_day(
add_months(row.depreciation_start_date, schedule_idx * row.frequency_of_depreciation)
)
@@ -679,24 +687,33 @@ def get_straight_line_or_manual_depr_amount(
# if the Depreciation Schedule is being prepared for the first time
else:
if row.daily_prorata_based:
daily_depr_amount = (
amount = (
flt(asset.gross_purchase_amount)
- flt(asset.opening_accumulated_depreciation)
- flt(row.expected_value_after_useful_life)
) / date_diff(
get_last_day(
add_months(
row.depreciation_start_date,
flt(row.total_number_of_depreciations - asset.number_of_depreciations_booked - 1)
* row.frequency_of_depreciation,
)
),
add_days(
get_last_day(add_months(row.depreciation_start_date, -1 * row.frequency_of_depreciation)),
1,
),
)
total_days = (
date_diff(
get_last_day(
add_months(
row.depreciation_start_date,
flt(row.total_number_of_depreciations - asset.number_of_depreciations_booked - 1)
* row.frequency_of_depreciation,
)
),
add_days(
get_last_day(
add_months(row.depreciation_start_date, -1 * row.frequency_of_depreciation)
),
1,
),
)
+ 1
)
daily_depr_amount = amount / total_days
to_date = get_last_day(
add_months(row.depreciation_start_date, schedule_idx * row.frequency_of_depreciation)
)
@@ -708,7 +725,6 @@ def get_straight_line_or_manual_depr_amount(
),
1,
)
return daily_depr_amount * (date_diff(to_date, from_date) + 1)
else:
return (
@@ -980,32 +996,35 @@ def get_depr_schedule(asset_name, status, finance_book=None):
@frappe.whitelist()
def get_asset_depr_schedule_doc(asset_name, status, finance_book=None):
asset_depr_schedule_name = get_asset_depr_schedule_name(asset_name, status, finance_book)
asset_depr_schedule = get_asset_depr_schedule_name(asset_name, status, finance_book)
if not asset_depr_schedule_name:
if not asset_depr_schedule:
return
asset_depr_schedule_doc = frappe.get_doc("Asset Depreciation Schedule", asset_depr_schedule_name)
asset_depr_schedule_doc = frappe.get_doc("Asset Depreciation Schedule", asset_depr_schedule[0].name)
return asset_depr_schedule_doc
def get_asset_depr_schedule_name(asset_name, status, finance_book=None):
if finance_book is None:
finance_book_filter = ["finance_book", "is", "not set"]
else:
finance_book_filter = ["finance_book", "=", finance_book]
if isinstance(status, str):
status = [status]
return frappe.db.get_value(
filters = [
["asset", "=", asset_name],
["status", "in", status],
["docstatus", "<", 2],
]
if finance_book:
filters.append(["finance_book", "=", finance_book])
else:
filters.append(["finance_book", "is", "not set"])
return frappe.get_all(
doctype="Asset Depreciation Schedule",
filters=[
["asset", "=", asset_name],
finance_book_filter,
["status", "in", status],
],
filters=filters,
limit=1,
)

View File

@@ -145,6 +145,7 @@ def get_data(filters):
"asset_category": asset.asset_category,
"purchase_date": asset.purchase_date,
"asset_value": asset_value,
"company": asset.company,
}
data.append(row)
@@ -369,30 +370,37 @@ def get_columns(filters):
"label": _("Gross Purchase Amount"),
"fieldname": "gross_purchase_amount",
"fieldtype": "Currency",
"options": "company:currency",
"options": "Company:company:default_currency",
"width": 250,
},
{
"label": _("Opening Accumulated Depreciation"),
"fieldname": "opening_accumulated_depreciation",
"fieldtype": "Currency",
"options": "company:currency",
"options": "Company:company:default_currency",
"width": 250,
},
{
"label": _("Depreciated Amount"),
"fieldname": "depreciated_amount",
"fieldtype": "Currency",
"options": "company:currency",
"options": "Company:company:default_currency",
"width": 250,
},
{
"label": _("Asset Value"),
"fieldname": "asset_value",
"fieldtype": "Currency",
"options": "company:currency",
"options": "Company:company:default_currency",
"width": 250,
},
{
"label": _("Company"),
"fieldname": "company",
"fieldtype": "Link",
"options": "Company",
"width": 120,
},
]
return [
@@ -423,28 +431,28 @@ def get_columns(filters):
"label": _("Gross Purchase Amount"),
"fieldname": "gross_purchase_amount",
"fieldtype": "Currency",
"options": "company:currency",
"options": "Company:company:default_currency",
"width": 100,
},
{
"label": _("Asset Value"),
"fieldname": "asset_value",
"fieldtype": "Currency",
"options": "company:currency",
"options": "Company:company:default_currency",
"width": 100,
},
{
"label": _("Opening Accumulated Depreciation"),
"fieldname": "opening_accumulated_depreciation",
"fieldtype": "Currency",
"options": "company:currency",
"options": "Company:company:default_currency",
"width": 90,
},
{
"label": _("Depreciated Amount"),
"fieldname": "depreciated_amount",
"fieldtype": "Currency",
"options": "company:currency",
"options": "Company:company:default_currency",
"width": 100,
},
{
@@ -469,4 +477,11 @@ def get_columns(filters):
"options": "Location",
"width": 100,
},
{
"label": _("Company"),
"fieldname": "company",
"fieldtype": "Link",
"options": "Company",
"width": 120,
},
]

View File

@@ -1127,10 +1127,17 @@ class TestPurchaseOrder(FrappeTestCase):
po = create_purchase_order()
self.assertEqual(frappe.db.get_value(po.doctype, po.name, "advance_payment_status"), "Not Initiated")
pr = make_payment_request(dt=po.doctype, dn=po.name, submit_doc=True, return_doc=True)
pr = make_payment_request(
dt=po.doctype, dn=po.name, submit_doc=True, return_doc=True, payment_request_type="Outward"
)
po.reload()
self.assertEqual(frappe.db.get_value(po.doctype, po.name, "advance_payment_status"), "Initiated")
pe = get_payment_entry(po.doctype, po.name).save().submit()
pr.reload()
self.assertEqual(pr.status, "Paid")
self.assertEqual(frappe.db.get_value(po.doctype, po.name, "advance_payment_status"), "Fully Paid")
pe.reload()

View File

@@ -22,9 +22,13 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
this.frm.set_value("valid_till", frappe.datetime.add_months(this.frm.doc.transaction_date, 1));
}
if (this.frm.doc.docstatus === 1) {
this.frm.add_custom_button(__("Purchase Order"), this.make_purchase_order, __("Create"));
this.frm.add_custom_button(
__("Purchase Order"),
this.make_purchase_order.bind(this),
__("Create")
);
this.frm.page.set_inner_btn_group_as_primary(__("Create"));
this.frm.add_custom_button(__("Quotation"), this.make_quotation, __("Create"));
this.frm.add_custom_button(__("Quotation"), this.make_quotation.bind(this), __("Create"));
} else if (this.frm.doc.docstatus === 0) {
this.frm.add_custom_button(
__("Material Request"),

View File

@@ -3,6 +3,7 @@
import json
from collections import defaultdict
import frappe
from frappe import _, bold, qb, throw
@@ -1031,10 +1032,10 @@ class AccountsController(TransactionBase):
"transaction_currency": self.get("currency") or self.company_currency,
"transaction_exchange_rate": self.get("conversion_rate", 1),
"debit_in_transaction_currency": self.get_value_in_transaction_currency(
account_currency, args, "debit"
account_currency, gl_dict, "debit"
),
"credit_in_transaction_currency": self.get_value_in_transaction_currency(
account_currency, args, "credit"
account_currency, gl_dict, "credit"
),
}
)
@@ -1066,11 +1067,11 @@ class AccountsController(TransactionBase):
return "Debit Note"
return self.doctype
def get_value_in_transaction_currency(self, account_currency, args, field):
def get_value_in_transaction_currency(self, account_currency, gl_dict, field):
if account_currency == self.get("currency"):
return args.get(field + "_in_account_currency")
return gl_dict.get(field + "_in_account_currency")
else:
return flt(args.get(field, 0) / self.get("conversion_rate", 1))
return flt(gl_dict.get(field, 0) / self.get("conversion_rate", 1))
def validate_zero_qty_for_return_invoices_with_stock(self):
rows = []
@@ -1924,32 +1925,43 @@ class AccountsController(TransactionBase):
self.db_set("advance_paid", advance_paid)
self.set_advance_payment_status(advance_paid, order_total)
self.set_advance_payment_status()
def set_advance_payment_status(self, advance_paid: float | None = None, order_total: float | None = None):
def set_advance_payment_status(self):
new_status = None
# if money is paid set the paid states
if advance_paid:
new_status = "Partially Paid" if advance_paid < order_total else "Fully Paid"
if not new_status:
prs = frappe.db.count(
"Payment Request",
{
"reference_doctype": self.doctype,
"reference_name": self.name,
"docstatus": 1,
},
)
if self.doctype in frappe.get_hooks("advance_payment_receivable_doctypes"):
new_status = "Requested" if prs else "Not Requested"
if self.doctype in frappe.get_hooks("advance_payment_payable_doctypes"):
new_status = "Initiated" if prs else "Not Initiated"
stati = frappe.get_list(
"Payment Request",
{
"reference_doctype": self.doctype,
"reference_name": self.name,
"docstatus": 1,
},
pluck="status",
)
if self.doctype in frappe.get_hooks("advance_payment_receivable_doctypes"):
if not stati:
new_status = "Not Requested"
elif "Requested" in stati or "Failed" in stati:
new_status = "Requested"
elif "Partially Paid" in stati:
new_status = "Partially Paid"
elif "Paid" in stati:
new_status = "Fully Paid"
if self.doctype in frappe.get_hooks("advance_payment_payable_doctypes"):
if not stati:
new_status = "Not Initiated"
elif "Initiated" in stati or "Failed" in stati or "Payment Ordered" in stati:
new_status = "Initiated"
elif "Partially Paid" in stati:
new_status = "Partially Paid"
elif "Paid" in stati:
new_status = "Fully Paid"
if new_status == self.advance_payment_status:
return
self.db_set("advance_payment_status", new_status)
self.db_set("advance_payment_status", new_status, update_modified=False)
self.set_status(update=True)
self.notify_update()
@@ -2078,21 +2090,26 @@ class AccountsController(TransactionBase):
)
def group_similar_items(self):
group_item_qty = {}
group_item_amount = {}
grouped_items = {}
# to update serial number in print
count = 0
fields_to_group = frappe.get_hooks("fields_for_group_similar_items")
fields_to_group = set(fields_to_group)
for item in self.items:
group_item_qty[item.item_code] = group_item_qty.get(item.item_code, 0) + item.qty
group_item_amount[item.item_code] = group_item_amount.get(item.item_code, 0) + item.amount
item_values = grouped_items.setdefault(item.item_code, defaultdict(int))
for field in fields_to_group:
item_values[field] += item.get(field, 0)
duplicate_list = []
for item in self.items:
if item.item_code in group_item_qty:
if item.item_code in grouped_items:
count += 1
item.qty = group_item_qty[item.item_code]
item.amount = group_item_amount[item.item_code]
for field in fields_to_group:
item.set(field, grouped_items[item.item_code][field])
if item.qty:
item.rate = flt(flt(item.amount) / flt(item.qty), item.precision("rate"))
@@ -2100,7 +2117,7 @@ class AccountsController(TransactionBase):
item.rate = 0
item.idx = count
del group_item_qty[item.item_code]
del grouped_items[item.item_code]
else:
duplicate_list.append(item)
for item in duplicate_list:
@@ -3510,6 +3527,37 @@ def check_if_child_table_updated(child_table_before_update, child_table_after_up
return False
def merge_taxes(source_taxes, target_doc):
from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import (
update_item_wise_tax_detail,
)
existing_taxes = target_doc.get("taxes") or []
idx = 1
for tax in source_taxes:
found = False
for t in existing_taxes:
if t.account_head == tax.account_head and t.cost_center == tax.cost_center:
t.tax_amount = flt(t.tax_amount) + flt(tax.tax_amount_after_discount_amount)
t.base_tax_amount = flt(t.base_tax_amount) + flt(tax.base_tax_amount_after_discount_amount)
update_item_wise_tax_detail(t, tax)
found = True
if not found:
tax.charge_type = "Actual"
tax.idx = idx
idx += 1
tax.included_in_print_rate = 0
tax.dont_recompute_tax = 1
tax.row_id = ""
tax.tax_amount = tax.tax_amount_after_discount_amount
tax.base_tax_amount = tax.base_tax_amount_after_discount_amount
tax.item_wise_tax_detail = tax.item_wise_tax_detail
existing_taxes.append(tax)
target_doc.set("taxes", existing_taxes)
@erpnext.allow_regional
def validate_regional(doc):
pass

View File

@@ -441,7 +441,7 @@ class SellingController(StockController):
get_valuation_method(d.item_code) == "Moving Average" and self.get("is_return")
):
# Get incoming rate based on original item cost based on valuation method
qty = flt(d.get("stock_qty") or d.get("actual_qty"))
qty = flt(d.get("stock_qty") or d.get("actual_qty") or d.get("qty"))
if (
not d.incoming_rate

View File

@@ -166,7 +166,7 @@ class StockController(AccountsController):
# remove extra whitespace and store one serial no on each line
row.serial_no = clean_serial_no_string(row.serial_no)
def make_bundle_using_old_serial_batch_fields(self, table_name=None):
def make_bundle_using_old_serial_batch_fields(self, table_name=None, via_landed_cost_voucher=False):
if self.get("_action") == "update_after_submit":
return
@@ -205,7 +205,7 @@ class StockController(AccountsController):
"company": self.company,
"is_rejected": 1 if row.get("rejected_warehouse") else 0,
"use_serial_batch_fields": row.use_serial_batch_fields,
"do_not_submit": True,
"do_not_submit": True if not via_landed_cost_voucher else False,
}
if row.get("qty") or row.get("consumed_qty"):
@@ -1119,7 +1119,7 @@ class StockController(AccountsController):
message += _("Please adjust the qty or edit {0} to proceed.").format(rule_link)
return message
def repost_future_sle_and_gle(self, force=False):
def repost_future_sle_and_gle(self, force=False, via_landed_cost_voucher=False):
args = frappe._dict(
{
"posting_date": self.posting_date,
@@ -1127,6 +1127,7 @@ class StockController(AccountsController):
"voucher_type": self.doctype,
"voucher_no": self.name,
"company": self.company,
"via_landed_cost_voucher": via_landed_cost_voucher,
}
)
@@ -1138,7 +1139,11 @@ class StockController(AccountsController):
frappe.db.get_single_value("Stock Reposting Settings", "item_based_reposting")
)
if item_based_reposting:
create_item_wise_repost_entries(voucher_type=self.doctype, voucher_no=self.name)
create_item_wise_repost_entries(
voucher_type=self.doctype,
voucher_no=self.name,
via_landed_cost_voucher=via_landed_cost_voucher,
)
else:
create_repost_item_valuation_entry(args)
@@ -1510,11 +1515,14 @@ def create_repost_item_valuation_entry(args):
repost_entry.allow_zero_rate = args.allow_zero_rate
repost_entry.flags.ignore_links = True
repost_entry.flags.ignore_permissions = True
repost_entry.via_landed_cost_voucher = args.via_landed_cost_voucher
repost_entry.save()
repost_entry.submit()
def create_item_wise_repost_entries(voucher_type, voucher_no, allow_zero_rate=False):
def create_item_wise_repost_entries(
voucher_type, voucher_no, allow_zero_rate=False, via_landed_cost_voucher=False
):
"""Using a voucher create repost item valuation records for all item-warehouse pairs."""
stock_ledger_entries = get_items_to_be_repost(voucher_type, voucher_no)
@@ -1538,6 +1546,7 @@ def create_item_wise_repost_entries(voucher_type, voucher_no, allow_zero_rate=Fa
repost_entry.allow_zero_rate = allow_zero_rate
repost_entry.flags.ignore_links = True
repost_entry.flags.ignore_permissions = True
repost_entry.via_landed_cost_voucher = via_landed_cost_voucher
repost_entry.submit()
repost_entries.append(repost_entry)

View File

@@ -467,7 +467,16 @@ class calculate_taxes_and_totals:
if tax.charge_type == "Actual":
# distribute the tax amount proportionally to each item row
actual = flt(tax.tax_amount, tax.precision("tax_amount"))
current_tax_amount = item.net_amount * actual / self.doc.net_total if self.doc.net_total else 0.0
if tax.get("is_tax_withholding_account") and item.meta.get_field("apply_tds"):
if not item.get("apply_tds") or not self.doc.tax_withholding_net_total:
current_tax_amount = 0.0
else:
current_tax_amount = item.net_amount * actual / self.doc.tax_withholding_net_total
else:
current_tax_amount = (
item.net_amount * actual / self.doc.net_total if self.doc.net_total else 0.0
)
elif tax.charge_type == "On Net Total":
current_tax_amount = (tax_rate / 100.0) * item.net_amount
@@ -1091,6 +1100,11 @@ def get_rounded_tax_amount(itemised_tax, precision):
row["tax_amount"] = flt(row["tax_amount"], precision)
@frappe.whitelist()
def get_rounding_tax_settings():
return frappe.db.get_single_value("Accounts Settings", "round_row_wise_tax")
class init_landed_taxes_and_totals:
def __init__(self, doc):
self.doc = doc

View File

@@ -53,7 +53,8 @@ class TestAccountsController(FrappeTestCase):
20 series - Sales Invoice against Journals
30 series - Sales Invoice against Credit Notes
40 series - Company default Cost center is unset
50 series = Journals against Journals
50 series - Journals against Journals
60 series - Journals against Payment Entries
90 series - Dimension inheritence
"""
@@ -1538,3 +1539,70 @@ class TestAccountsController(FrappeTestCase):
exc_je_for_je = self.get_journals_for(journal_as_payment.doctype, journal_as_payment.name)
self.assertEqual(exc_je_for_si, [])
self.assertEqual(exc_je_for_je, [])
def test_60_payment_entry_against_journal(self):
# Invoices
exc_rate1 = 75
exc_rate2 = 77
amount = 1
je1 = self.create_journal_entry(
acc1=self.debit_usd,
acc1_exc_rate=exc_rate1,
acc2=self.cash,
acc1_amount=amount,
acc2_amount=(amount * 75),
acc2_exc_rate=1,
)
je1.accounts[0].party_type = "Customer"
je1.accounts[0].party = self.customer
je1 = je1.save().submit()
je2 = self.create_journal_entry(
acc1=self.debit_usd,
acc1_exc_rate=exc_rate2,
acc2=self.cash,
acc1_amount=amount,
acc2_amount=(amount * exc_rate2),
acc2_exc_rate=1,
)
je2.accounts[0].party_type = "Customer"
je2.accounts[0].party = self.customer
je2 = je2.save().submit()
# Payment
pe = self.create_payment_entry(amount=2, source_exc_rate=exc_rate1).save().submit()
pr = self.create_payment_reconciliation()
pr.receivable_payable_account = self.debit_usd
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 2)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.reconcile()
self.assertEqual(len(pr.invoices), 0)
self.assertEqual(len(pr.payments), 0)
# There should be no outstanding in both currencies
self.assert_ledger_outstanding(je1.doctype, je1.name, 0.0, 0.0)
self.assert_ledger_outstanding(je2.doctype, je2.name, 0.0, 0.0)
# Exchange Gain/Loss Journal should've been created only for JE2
exc_je_for_je1 = self.get_journals_for(je1.doctype, je1.name)
exc_je_for_je2 = self.get_journals_for(je2.doctype, je2.name)
self.assertEqual(exc_je_for_je1, [])
self.assertEqual(len(exc_je_for_je2), 1)
# Cancel Payment
pe.reload()
pe.cancel()
self.assert_ledger_outstanding(je1.doctype, je1.name, (amount * exc_rate1), amount)
self.assert_ledger_outstanding(je2.doctype, je2.name, (amount * exc_rate2), amount)
# Exchange Gain/Loss Journal should've been cancelled
exc_je_for_je1 = self.get_journals_for(je1.doctype, je1.name)
exc_je_for_je2 = self.get_journals_for(je2.doctype, je2.name)
self.assertEqual(exc_je_for_je1, [])
self.assertEqual(exc_je_for_je2, [])

View File

@@ -349,7 +349,6 @@ doc_events = {
"Payment Entry": {
"on_submit": [
"erpnext.regional.create_transaction_log",
"erpnext.accounts.doctype.payment_request.payment_request.update_payment_req_status",
"erpnext.accounts.doctype.dunning.dunning.resolve_dunning",
],
"on_cancel": ["erpnext.accounts.doctype.dunning.dunning.resolve_dunning"],
@@ -435,6 +434,7 @@ scheduler_events = {
"erpnext.buying.doctype.supplier_quotation.supplier_quotation.set_expired_status",
"erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts.send_auto_email",
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_daily",
"erpnext.accounts.utils.run_ledger_health_checks",
],
"weekly": [
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly",
@@ -534,6 +534,7 @@ accounting_dimension_doctypes = [
"Supplier Quotation Item",
"Payment Reconciliation",
"Payment Reconciliation Allocation",
"Payment Request",
]
get_matching_queries = (
@@ -635,3 +636,5 @@ default_log_clearing_doctypes = {
}
export_python_type_annotations = True
fields_for_group_similar_items = ["qty", "amount"]

File diff suppressed because it is too large Load Diff

83599
erpnext/locale/bs.po Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -238,7 +238,7 @@
"fieldname": "rm_cost_as_per",
"fieldtype": "Select",
"label": "Rate Of Materials Based On",
"options": "Valuation Rate\nLast Purchase Rate\nPrice List\nManual"
"options": "Valuation Rate\nLast Purchase Rate\nPrice List"
},
{
"allow_on_submit": 1,
@@ -637,7 +637,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2024-03-27 13:06:40.214929",
"modified": "2024-04-02 16:22:47.518411",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM",

View File

@@ -147,7 +147,7 @@ class BOM(WebsiteGenerator):
quality_inspection_template: DF.Link | None
quantity: DF.Float
raw_material_cost: DF.Currency
rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List", "Manual"]
rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List"]
route: DF.SmallText | None
routing: DF.Link | None
scrap_items: DF.Table[BOMScrapItem]
@@ -737,6 +737,7 @@ class BOM(WebsiteGenerator):
def calculate_rm_cost(self, save=False):
"""Fetch RM rate as per today's valuation rate and calculate totals"""
total_rm_cost = 0
base_total_rm_cost = 0
@@ -745,7 +746,7 @@ class BOM(WebsiteGenerator):
continue
old_rate = d.rate
if self.rm_cost_as_per != "Manual":
if not self.bom_creator:
d.rate = self.get_rm_rate(
{
"company": self.company,
@@ -1017,8 +1018,6 @@ def get_bom_item_rate(args, bom_doc):
item_doc = frappe.get_cached_doc("Item", args.get("item_code"))
price_list_data = get_price_list_rate(bom_args, item_doc)
rate = price_list_data.price_list_rate
elif bom_doc.rm_cost_as_per == "Manual":
return
return flt(rate)

View File

@@ -66,7 +66,7 @@
"fieldname": "rm_cost_as_per",
"fieldtype": "Select",
"label": "Rate Of Materials Based On",
"options": "Valuation Rate\nLast Purchase Rate\nPrice List\nManual",
"options": "Valuation Rate\nLast Purchase Rate\nPrice List",
"reqd": 1
},
{
@@ -288,7 +288,7 @@
"link_fieldname": "bom_creator"
}
],
"modified": "2024-03-27 13:06:40.535884",
"modified": "2024-04-02 16:30:59.779190",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Creator",

View File

@@ -59,7 +59,7 @@ class BOMCreator(Document):
qty: DF.Float
raw_material_cost: DF.Currency
remarks: DF.TextEditor | None
rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List", "Manual"]
rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List"]
set_rate_based_on_warehouse: DF.Check
status: DF.Literal["Draft", "Submitted", "In Progress", "Completed", "Failed", "Cancelled"]
uom: DF.Link | None
@@ -141,9 +141,6 @@ class BOMCreator(Document):
self.submit()
def set_rate_for_items(self):
if self.rm_cost_as_per == "Manual":
return
amount = self.get_raw_material_cost()
self.raw_material_cost = amount
@@ -239,6 +236,9 @@ class BOMCreator(Document):
frappe._dict({"items": [], "bom_no": "", "fg_item_data": row}),
)
if not row.fg_reference_id and production_item_wise_rm.get((row.fg_item, row.fg_reference_id)):
frappe.throw(_("Please set Parent Row No for item {0}").format(row.fg_item))
production_item_wise_rm[(row.fg_item, row.fg_reference_id)]["items"].append(row)
reverse_tree = OrderedDict(reversed(list(production_item_wise_rm.items())))
@@ -282,7 +282,6 @@ class BOMCreator(Document):
"allow_alternative_item": 1,
"bom_creator": self.name,
"bom_creator_item": bom_creator_item,
"rm_cost_as_per": "Manual",
}
)

View File

@@ -182,6 +182,7 @@ class WorkstationDashboard {
me.job_cards = [r.message];
me.prepare_timer();
me.update_job_card_details();
me.frm.reload_doc();
}
},
});
@@ -229,6 +230,7 @@ class WorkstationDashboard {
me.job_cards = [r.message];
me.prepare_timer();
me.update_job_card_details();
me.frm.reload_doc();
}
},
});

View File

@@ -357,6 +357,7 @@ erpnext.patches.v15_0.create_advance_payment_status
erpnext.patches.v15_0.allow_on_submit_dimensions_for_repostable_doctypes
erpnext.patches.v14_0.create_accounting_dimensions_in_reconciliation_tool
erpnext.patches.v14_0.update_flag_for_return_invoices #2024-03-22
erpnext.patches.v15_0.create_accounting_dimensions_in_payment_request
# below migration patch should always run last
erpnext.patches.v14_0.migrate_gl_to_payment_ledger
erpnext.stock.doctype.delivery_note.patches.drop_unused_return_against_index # 2023-12-20

View File

@@ -0,0 +1,7 @@
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
create_accounting_dimensions_for_doctype,
)
def execute():
create_accounting_dimensions_for_doctype(doctype="Payment Request")

View File

@@ -210,10 +210,12 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
});
}
frappe.db.get_single_value("Accounts Settings", "round_row_wise_tax")
.then((round_row_wise_tax) => {
frappe.flags.round_row_wise_tax = round_row_wise_tax;
})
frappe.call({
method: "erpnext.controllers.taxes_and_totals.get_rounding_tax_settings",
callback: function(r) {
frappe.flags.round_off_settings = r.message;
}
});
}
determine_exclusive_rate() {

View File

@@ -252,9 +252,14 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
toggle_enable_for_stock_uom(field) {
frappe.db.get_single_value('Stock Settings', field)
.then(value => {
this.frm.fields_dict["items"].grid.toggle_enable("stock_qty", value);
frappe.call({
method: 'erpnext.stock.doctype.stock_settings.stock_settings.get_enable_stock_uom_editing',
callback: (r) => {
if (r.message) {
var value = r.message[field];
this.frm.fields_dict["items"].grid.toggle_enable("stock_qty", value);
}
}
});
}

View File

@@ -452,6 +452,9 @@ $.extend(erpnext.utils, {
},
get_fiscal_year: function (date, with_dates = false, boolean = false) {
if (!frappe.boot.setup_complete) {
return;
}
if (!date) {
date = frappe.datetime.get_today();
}
@@ -934,7 +937,7 @@ erpnext.utils.map_current_doc = function (opts) {
if (opts.source_doctype) {
let data_fields = [];
if (opts.source_doctype == "Purchase Receipt") {
if (["Purchase Receipt", "Delivery Note"].includes(opts.source_doctype)) {
data_fields.push({
fieldname: "merge_taxes",
fieldtype: "Check",
@@ -960,7 +963,10 @@ erpnext.utils.map_current_doc = function (opts) {
return;
}
opts.source_name = values;
if (opts.allow_child_item_selection || opts.source_doctype == "Purchase Receipt") {
if (
opts.allow_child_item_selection ||
["Purchase Receipt", "Delivery Note"].includes(opts.source_doctype)
) {
// args contains filtered child docnames
opts.args = args;
}

View File

@@ -384,7 +384,6 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
)
target.flags.ignore_permissions = ignore_permissions
target.delivery_date = nowdate()
target.run_method("set_missing_values")
target.run_method("calculate_taxes_and_totals")

View File

@@ -134,6 +134,7 @@ class TestQuotation(FrappeTestCase):
sales_order.naming_series = "_T-Quotation-"
sales_order.transaction_date = nowdate()
sales_order.delivery_date = nowdate()
sales_order.insert()
def test_make_sales_order_with_terms(self):
@@ -164,6 +165,7 @@ class TestQuotation(FrappeTestCase):
sales_order.naming_series = "_T-Quotation-"
sales_order.transaction_date = nowdate()
sales_order.delivery_date = nowdate()
sales_order.insert()
# Remove any unknown taxes if applied

View File

@@ -111,16 +111,26 @@ frappe.ui.form.on("Sales Order", {
}
if (frm.doc.docstatus === 0) {
frappe.db.get_single_value("Stock Settings", "enable_stock_reservation").then((value) => {
if (!value) {
// If `Stock Reservation` is disabled in Stock Settings, set Reserve Stock to 0 and make the field read-only and hidden.
frm.set_value("reserve_stock", 0);
frm.set_df_property("reserve_stock", "read_only", 1);
frm.set_df_property("reserve_stock", "hidden", 1);
frm.fields_dict.items.grid.update_docfield_property("reserve_stock", "hidden", 1);
frm.fields_dict.items.grid.update_docfield_property("reserve_stock", "default", 0);
frm.fields_dict.items.grid.update_docfield_property("reserve_stock", "read_only", 1);
}
frappe.call({
method: "erpnext.selling.doctype.sales_order.sales_order.get_stock_reservation_status",
callback: function (r) {
if (!r.message) {
frm.set_value("reserve_stock", 0);
frm.set_df_property("reserve_stock", "read_only", 1);
frm.set_df_property("reserve_stock", "hidden", 1);
frm.fields_dict.items.grid.update_docfield_property("reserve_stock", "hidden", 1);
frm.fields_dict.items.grid.update_docfield_property(
"reserve_stock",
"default",
0
);
frm.fields_dict.items.grid.update_docfield_property(
"reserve_stock",
"read_only",
1
);
}
},
});
}
}
@@ -278,6 +288,7 @@ frappe.ui.form.on("Sales Order", {
label: __("Items to Reserve"),
allow_bulk_edit: false,
cannot_add_rows: true,
cannot_delete_rows: true,
data: [],
fields: [
{
@@ -346,7 +357,7 @@ frappe.ui.form.on("Sales Order", {
],
primary_action_label: __("Reserve Stock"),
primary_action: () => {
var data = { items: dialog.fields_dict.items.grid.data };
var data = { items: dialog.fields_dict.items.grid.get_selected_children() };
if (data.items && data.items.length > 0) {
frappe.call({
@@ -363,9 +374,11 @@ frappe.ui.form.on("Sales Order", {
frm.reload_doc();
},
});
}
dialog.hide();
dialog.hide();
} else {
frappe.msgprint(__("Please select items to reserve."));
}
},
});
@@ -380,6 +393,7 @@ frappe.ui.form.on("Sales Order", {
if (unreserved_qty > 0) {
dialog.fields_dict.items.df.data.push({
__checked: 1,
sales_order_item: item.name,
item_code: item.item_code,
warehouse: item.warehouse,
@@ -404,6 +418,7 @@ frappe.ui.form.on("Sales Order", {
label: __("Reserved Stock"),
allow_bulk_edit: false,
cannot_add_rows: true,
cannot_delete_rows: true,
in_place_edit: true,
data: [],
fields: [
@@ -447,7 +462,7 @@ frappe.ui.form.on("Sales Order", {
],
primary_action_label: __("Unreserve Stock"),
primary_action: () => {
var data = { sr_entries: dialog.fields_dict.sr_entries.grid.data };
var data = { sr_entries: dialog.fields_dict.sr_entries.grid.get_selected_children() };
if (data.sr_entries && data.sr_entries.length > 0) {
frappe.call({
@@ -463,9 +478,11 @@ frappe.ui.form.on("Sales Order", {
frm.reload_doc();
},
});
}
dialog.hide();
dialog.hide();
} else {
frappe.msgprint(__("Please select items to unreserve."));
}
},
});

View File

@@ -1724,3 +1724,8 @@ def get_work_order_items(sales_order, for_raw_material_request=0):
)
return items
@frappe.whitelist()
def get_stock_reservation_status():
return frappe.db.get_single_value("Stock Settings", "enable_stock_reservation")

View File

@@ -2,6 +2,7 @@
# License: GNU General Public License v3. See license.txt
import json
from unittest.mock import patch
import frappe
import frappe.permissions
@@ -1956,10 +1957,48 @@ class TestSalesOrder(FrappeTestCase):
self.assertEqual(so.items[0].rate, scenario.get("expected_rate"))
self.assertEqual(so.packed_items[0].rate, scenario.get("expected_rate"))
def test_sales_order_advance_payment_status(self):
@patch(
# this also shadows one (1) call to _get_payment_gateway_controller
"erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.get_payment_url",
return_value=None,
)
def test_sales_order_advance_payment_status(self, mocked_get_payment_url):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
# Flow progressing to SI with payment entries "moved" from SO to SI
so = make_sales_order(qty=1, rate=100, do_not_submit=True)
# no-op; for optical consistency with how a webshop SO would look like
so.order_type = "Shopping Cart"
so.submit()
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Not Requested")
pr = make_payment_request(
dt=so.doctype, dn=so.name, order_type="Shopping Cart", submit_doc=True, return_doc=True
)
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Requested")
pe = pr.set_as_paid()
pr.reload() # status updated
pe.reload() # references moved to Sales Invoice
self.assertEqual(pr.status, "Paid")
self.assertEqual(pe.references[0].reference_doctype, "Sales Invoice")
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Fully Paid")
pe.cancel()
pr.reload()
self.assertEqual(pr.status, "Paid") # TODO: this might be a bug
so.reload() # reload
# regardless, since the references have already "handed-over" to SI,
# the SO keeps its historical state at the time of hand over
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Fully Paid")
pr.cancel()
self.assertEqual(
frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Not Requested"
) # TODO: this might be a bug; handover has happened
# Flow NOT progressing to SI with payment entries NOT "moved"
so = make_sales_order(qty=1, rate=100)
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Not Requested")
@@ -1971,11 +2010,15 @@ class TestSalesOrder(FrappeTestCase):
pe.reload()
pe.cancel()
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Requested")
self.assertEqual(
frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Requested"
) # here: reset
pr.reload()
pr.cancel()
self.assertEqual(frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Not Requested")
self.assertEqual(
frappe.db.get_value(so.doctype, so.name, "advance_payment_status"), "Not Requested"
) # here: reset
def test_pick_list_without_rejected_materials(self):
serial_and_batch_item = make_item(

View File

@@ -547,6 +547,8 @@ erpnext.PointOfSale.Controller = class {
async on_cart_update(args) {
frappe.dom.freeze();
if (this.frm.doc.set_warehouse != this.settings.warehouse)
this.frm.doc.set_warehouse = this.settings.warehouse;
let item_row = undefined;
try {
let { field, value, item } = args;

View File

@@ -142,6 +142,11 @@ def add_company_to_session_defaults():
def add_standard_navbar_items():
navbar_settings = frappe.get_single("Navbar Settings")
# Translatable strings for below navbar items
__ = _("Documentation")
__ = _("User Forum")
__ = _("Report an Issue")
erpnext_navbar_items = [
{
"item_label": "Documentation",

View File

@@ -65,7 +65,7 @@ class ClosingStockBalance(Document):
& (
(table.from_date.between(self.from_date, self.to_date))
| (table.to_date.between(self.from_date, self.to_date))
| (table.from_date >= self.from_date and table.to_date >= self.to_date)
| ((table.from_date >= self.from_date) & (table.to_date >= self.to_date))
)
)
)

View File

@@ -10,7 +10,7 @@ from frappe.model.mapper import get_mapped_doc
from frappe.model.utils import get_fetch_values
from frappe.utils import cint, flt
from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
from erpnext.controllers.selling_controller import SellingController
from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no
@@ -960,7 +960,7 @@ def get_returned_qty_map(delivery_note):
@frappe.whitelist()
def make_sales_invoice(source_name, target_doc=None):
def make_sales_invoice(source_name, target_doc=None, args=None):
doc = frappe.get_doc("Delivery Note", source_name)
to_make_invoice_qty_map = {}
@@ -974,6 +974,9 @@ def make_sales_invoice(source_name, target_doc=None):
if len(target.get("items")) == 0:
frappe.throw(_("All these items have already been Invoiced/Returned"))
if args and args.get("merge_taxes"):
merge_taxes(source.get("taxes") or [], target)
target.run_method("calculate_taxes_and_totals")
# set company address
@@ -1038,7 +1041,11 @@ def make_sales_invoice(source_name, target_doc=None):
if not doc.get("is_return")
else get_pending_qty(d) > 0,
},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
"Sales Taxes and Charges": {
"doctype": "Sales Taxes and Charges",
"add_if_empty": True,
"ignore": args.get("merge_taxes") if args else 0,
},
"Sales Team": {
"doctype": "Sales Team",
"field_map": {"incentives": "incentives"},

View File

@@ -32,7 +32,7 @@ test_ignore = ["BOM"]
test_dependencies = ["Warehouse", "Item Group", "Item Tax Template", "Brand", "Item Attribute"]
def make_item(item_code=None, properties=None, uoms=None):
def make_item(item_code=None, properties=None, uoms=None, barcode=None):
if not item_code:
item_code = frappe.generate_hash(length=16)
@@ -61,6 +61,14 @@ def make_item(item_code=None, properties=None, uoms=None):
for uom in uoms:
item.append("uoms", uom)
if barcode:
item.append(
"barcodes",
{
"barcode": barcode,
},
)
item.insert()
return item

View File

@@ -250,9 +250,10 @@ class LandedCostVoucher(Document):
# update stock & gl entries for submit state of PR
doc.docstatus = 1
doc.make_bundle_using_old_serial_batch_fields(via_landed_cost_voucher=True)
doc.update_stock_ledger(allow_negative_stock=True, via_landed_cost_voucher=True)
doc.make_gl_entries()
doc.repost_future_sle_and_gle()
doc.repost_future_sle_and_gle(via_landed_cost_voucher=True)
def validate_asset_qty_and_status(self, receipt_document_type, receipt_document):
for item in self.get("items"):

View File

@@ -596,6 +596,356 @@ class TestLandedCostVoucher(FrappeTestCase):
lcv.cancel()
pr.cancel()
def test_landed_cost_voucher_with_serial_batch_for_legacy_pr(self):
from erpnext.stock.doctype.item.test_item import make_item
frappe.flags.ignore_serial_batch_bundle_validation = True
frappe.flags.use_serial_and_batch_fields = True
sn_item = "Test Landed Cost Voucher Serial NO for Legacy PR"
batch_item = "Test Landed Cost Voucher Batch NO for Legacy PR"
sn_item_doc = make_item(
sn_item,
{
"has_serial_no": 1,
"serial_no_series": "SN-TLCVSNO-.####",
"is_stock_item": 1,
},
)
batch_item_doc = make_item(
batch_item,
{
"has_batch_no": 1,
"batch_number_series": "BATCH-TLCVSNO-.####",
"create_new_batch": 1,
"is_stock_item": 1,
},
)
serial_nos = [
"SN-TLCVSNO-0001",
"SN-TLCVSNO-0002",
"SN-TLCVSNO-0003",
"SN-TLCVSNO-0004",
"SN-TLCVSNO-0005",
]
for sn in serial_nos:
if not frappe.db.exists("Serial No", sn):
sn_doc = frappe.get_doc(
{
"doctype": "Serial No",
"item_code": sn_item,
"serial_no": sn,
}
)
sn_doc.insert()
if not frappe.db.exists("Batch", "BATCH-TLCVSNO-0001"):
batch_doc = frappe.get_doc(
{
"doctype": "Batch",
"item": batch_item,
"batch_id": "BATCH-TLCVSNO-0001",
}
)
batch_doc.insert()
warehouse = "_Test Warehouse - _TC"
company = frappe.db.get_value("Warehouse", warehouse, "company")
pr = make_purchase_receipt(
company=company,
warehouse=warehouse,
item_code=sn_item,
qty=5,
rate=100,
uom=sn_item_doc.stock_uom,
stock_uom=sn_item_doc.stock_uom,
do_not_submit=True,
)
pr.append(
"items",
{
"item_code": batch_item,
"item_name": batch_item,
"description": "Test Batch Item",
"uom": batch_item_doc.stock_uom,
"stock_uom": batch_item_doc.stock_uom,
"qty": 5,
"rate": 100,
"warehouse": warehouse,
},
)
pr.submit()
pr.reload()
for row in pr.items:
self.assertEqual(row.valuation_rate, 100)
self.assertFalse(row.serial_no)
self.assertFalse(row.batch_no)
self.assertFalse(row.serial_and_batch_bundle)
if row.item_code == sn_item:
row.db_set("serial_no", ", ".join(serial_nos))
else:
row.db_set("batch_no", "BATCH-TLCVSNO-0001")
for sn in serial_nos:
sn_doc = frappe.get_doc("Serial No", sn)
sn_doc.db_set(
{
"warehouse": warehouse,
"status": "Active",
}
)
batch_doc.db_set(
{
"batch_qty": 5,
}
)
frappe.flags.ignore_serial_batch_bundle_validation = False
frappe.flags.use_serial_and_batch_fields = False
lcv = make_landed_cost_voucher(
company=pr.company,
receipt_document_type="Purchase Receipt",
receipt_document=pr.name,
charges=20,
distribute_charges_based_on="Qty",
do_not_save=True,
)
lcv.get_items_from_purchase_receipts()
lcv.save()
lcv.submit()
pr.reload()
for row in pr.items:
self.assertEqual(row.valuation_rate, 102)
self.assertTrue(row.serial_and_batch_bundle)
self.assertEqual(
row.valuation_rate,
frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "avg_rate"),
)
lcv.cancel()
pr.reload()
for row in pr.items:
self.assertEqual(row.valuation_rate, 100)
self.assertTrue(row.serial_and_batch_bundle)
self.assertEqual(
row.valuation_rate,
frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "avg_rate"),
)
def test_do_not_validate_landed_cost_voucher_with_serial_batch_for_legacy_pr(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import get_auto_batch_nos
frappe.flags.ignore_serial_batch_bundle_validation = True
frappe.flags.use_serial_and_batch_fields = True
sn_item = "Test Don't Validate Landed Cost Voucher Serial NO for Legacy PR"
batch_item = "Test Don't Validate Landed Cost Voucher Batch NO for Legacy PR"
sn_item_doc = make_item(
sn_item,
{
"has_serial_no": 1,
"serial_no_series": "SN-TDVLCVSNO-.####",
"is_stock_item": 1,
},
)
batch_item_doc = make_item(
batch_item,
{
"has_batch_no": 1,
"batch_number_series": "BATCH-TDVLCVSNO-.####",
"create_new_batch": 1,
"is_stock_item": 1,
},
)
serial_nos = [
"SN-TDVLCVSNO-0001",
"SN-TDVLCVSNO-0002",
"SN-TDVLCVSNO-0003",
"SN-TDVLCVSNO-0004",
"SN-TDVLCVSNO-0005",
]
for sn in serial_nos:
if not frappe.db.exists("Serial No", sn):
sn_doc = frappe.get_doc(
{
"doctype": "Serial No",
"item_code": sn_item,
"serial_no": sn,
}
)
sn_doc.insert()
if not frappe.db.exists("Batch", "BATCH-TDVLCVSNO-0001"):
batch_doc = frappe.get_doc(
{
"doctype": "Batch",
"item": batch_item,
"batch_id": "BATCH-TDVLCVSNO-0001",
}
)
batch_doc.insert()
warehouse = "_Test Warehouse - _TC"
company = frappe.db.get_value("Warehouse", warehouse, "company")
pr = make_purchase_receipt(
company=company,
warehouse=warehouse,
item_code=sn_item,
qty=5,
rate=100,
uom=sn_item_doc.stock_uom,
stock_uom=sn_item_doc.stock_uom,
do_not_submit=True,
)
pr.append(
"items",
{
"item_code": batch_item,
"item_name": batch_item,
"description": "Test Batch Item",
"uom": batch_item_doc.stock_uom,
"stock_uom": batch_item_doc.stock_uom,
"qty": 5,
"rate": 100,
"warehouse": warehouse,
},
)
pr.submit()
pr.reload()
for sn in serial_nos:
sn_doc = frappe.get_doc("Serial No", sn)
sn_doc.db_set(
{
"warehouse": warehouse,
"status": "Active",
}
)
batch_doc.db_set(
{
"batch_qty": 5,
}
)
for row in pr.items:
if row.item_code == sn_item:
row.db_set("serial_no", ", ".join(serial_nos))
else:
row.db_set("batch_no", "BATCH-TDVLCVSNO-0001")
stock_ledger_entries = frappe.get_all("Stock Ledger Entry", filters={"voucher_no": pr.name})
for sle in stock_ledger_entries:
doc = frappe.get_doc("Stock Ledger Entry", sle.name)
if doc.item_code == sn_item:
doc.db_set("serial_no", ", ".join(serial_nos))
else:
doc.db_set("batch_no", "BATCH-TDVLCVSNO-0001")
dn = create_delivery_note(
company=company,
warehouse=warehouse,
item_code=sn_item,
qty=5,
rate=100,
uom=sn_item_doc.stock_uom,
stock_uom=sn_item_doc.stock_uom,
do_not_submit=True,
)
dn.append(
"items",
{
"item_code": batch_item,
"item_name": batch_item,
"description": "Test Batch Item",
"uom": batch_item_doc.stock_uom,
"stock_uom": batch_item_doc.stock_uom,
"qty": 5,
"rate": 100,
"warehouse": warehouse,
},
)
dn.submit()
stock_ledger_entries = frappe.get_all("Stock Ledger Entry", filters={"voucher_no": dn.name})
for sle in stock_ledger_entries:
doc = frappe.get_doc("Stock Ledger Entry", sle.name)
if doc.item_code == sn_item:
doc.db_set("serial_no", ", ".join(serial_nos))
else:
doc.db_set("batch_no", "BATCH-TDVLCVSNO-0001")
available_batches = get_auto_batch_nos(
frappe._dict(
{
"item_code": batch_item,
"warehouse": warehouse,
"batch_no": ["BATCH-TDVLCVSNO-0001"],
"consider_negative_batches": True,
}
)
)[0]
self.assertFalse(available_batches.get("qty"))
frappe.flags.ignore_serial_batch_bundle_validation = False
frappe.flags.use_serial_and_batch_fields = False
lcv = make_landed_cost_voucher(
company=pr.company,
receipt_document_type="Purchase Receipt",
receipt_document=pr.name,
charges=20,
distribute_charges_based_on="Qty",
do_not_save=True,
)
lcv.get_items_from_purchase_receipts()
lcv.save()
lcv.submit()
pr.reload()
for row in pr.items:
self.assertEqual(row.valuation_rate, 102)
self.assertTrue(row.serial_and_batch_bundle)
self.assertEqual(
row.valuation_rate,
frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "avg_rate"),
)
lcv.cancel()
pr.reload()
for row in pr.items:
self.assertEqual(row.valuation_rate, 100)
self.assertTrue(row.serial_and_batch_bundle)
self.assertEqual(
row.valuation_rate,
frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "avg_rate"),
)
def make_landed_cost_voucher(**args):
args = frappe._dict(args)

View File

@@ -18,6 +18,7 @@
"parent_warehouse",
"consider_rejected_warehouses",
"get_item_locations",
"pick_manually",
"section_break_6",
"scan_barcode",
"column_break_13",
@@ -192,11 +193,18 @@
"fieldname": "consider_rejected_warehouses",
"fieldtype": "Check",
"label": "Consider Rejected Warehouses"
},
{
"default": "0",
"description": "If enabled then system won't override the picked qty / batches / serial numbers.",
"fieldname": "pick_manually",
"fieldtype": "Check",
"label": "Pick Manually"
}
],
"is_submittable": 1,
"links": [],
"modified": "2024-03-27 13:10:13.177072",
"modified": "2024-03-27 22:49:16.954637",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List",
@@ -264,7 +272,7 @@
"write": 1
}
],
"sort_field": "creation",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1

View File

@@ -12,7 +12,7 @@ from frappe.model.mapper import map_child_doc
from frappe.query_builder import Case
from frappe.query_builder.custom import GROUP_CONCAT
from frappe.query_builder.functions import Coalesce, Locate, Replace, Sum
from frappe.utils import ceil, cint, floor, flt
from frappe.utils import ceil, cint, floor, flt, get_link_to_form
from frappe.utils.nestedset import get_descendants_of
from erpnext.selling.doctype.sales_order.sales_order import (
@@ -23,7 +23,11 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor
get_picked_serial_nos,
)
from erpnext.stock.get_item_details import get_conversion_factor
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
from erpnext.stock.serial_batch_bundle import (
SerialBatchCreation,
get_batches_from_bundle,
get_serial_nos_from_bundle,
)
# TODO: Prioritize SO or WO group warehouse
@@ -41,6 +45,7 @@ class PickList(Document):
amended_from: DF.Link | None
company: DF.Link
consider_rejected_warehouses: DF.Check
customer: DF.Link | None
customer_name: DF.Data | None
for_qty: DF.Float
@@ -49,6 +54,7 @@ class PickList(Document):
material_request: DF.Link | None
naming_series: DF.Literal["STO-PICK-.YYYY.-"]
parent_warehouse: DF.Link | None
pick_manually: DF.Check
prompt_qty: DF.Check
purpose: DF.Literal["Material Transfer for Manufacture", "Material Transfer", "Delivery"]
scan_barcode: DF.Data | None
@@ -70,7 +76,8 @@ class PickList(Document):
def before_save(self):
self.update_status()
self.set_item_locations()
if not self.pick_manually:
self.set_item_locations()
if self.get("locations"):
self.validate_sales_order_percentage()
@@ -198,10 +205,11 @@ class PickList(Document):
row.db_set("serial_and_batch_bundle", None)
def on_update(self):
self.linked_serial_and_batch_bundle()
if self.get("locations"):
self.linked_serial_and_batch_bundle()
def linked_serial_and_batch_bundle(self):
for row in self.locations:
for row in self.get("locations"):
if row.serial_and_batch_bundle:
frappe.get_doc(
"Serial and Batch Bundle", row.serial_and_batch_bundle
@@ -510,55 +518,82 @@ class PickList(Document):
def get_picked_items_details(self, items):
picked_items = frappe._dict()
if items:
pi = frappe.qb.DocType("Pick List")
pi_item = frappe.qb.DocType("Pick List Item")
query = (
frappe.qb.from_(pi)
.inner_join(pi_item)
.on(pi.name == pi_item.parent)
.select(
pi_item.item_code,
pi_item.warehouse,
pi_item.batch_no,
pi_item.serial_and_batch_bundle,
Sum(Case().when(pi_item.picked_qty > 0, pi_item.picked_qty).else_(pi_item.stock_qty)).as_(
"picked_qty"
),
Replace(GROUP_CONCAT(pi_item.serial_no), ",", "\n").as_("serial_no"),
)
.where(
(pi_item.item_code.isin([x.item_code for x in items]))
& ((pi_item.picked_qty > 0) | (pi_item.stock_qty > 0))
& (pi.status != "Completed")
& (pi.status != "Cancelled")
& (pi_item.docstatus != 2)
)
.groupby(
pi_item.item_code,
pi_item.warehouse,
pi_item.batch_no,
)
)
if not items:
return picked_items
if self.name:
query = query.where(pi_item.parent != self.name)
items_data = self._get_pick_list_items(items)
items_data = query.run(as_dict=True)
for item_data in items_data:
key = (item_data.warehouse, item_data.batch_no) if item_data.batch_no else item_data.warehouse
serial_no = [x for x in item_data.serial_no.split("\n") if x] if item_data.serial_no else None
for item_data in items_data:
key = (item_data.warehouse, item_data.batch_no) if item_data.batch_no else item_data.warehouse
serial_no = [x for x in item_data.serial_no.split("\n") if x] if item_data.serial_no else None
data = {"picked_qty": item_data.picked_qty}
if serial_no:
data["serial_no"] = serial_no
if item_data.item_code not in picked_items:
picked_items[item_data.item_code] = {key: data}
else:
picked_items[item_data.item_code][key] = data
if item_data.serial_and_batch_bundle:
if not serial_no:
serial_no = get_serial_nos_from_bundle(item_data.serial_and_batch_bundle)
if not item_data.batch_no and not serial_no:
bundle_batches = get_batches_from_bundle(item_data.serial_and_batch_bundle)
for batch_no, batch_qty in bundle_batches.items():
batch_qty = abs(batch_qty)
key = (item_data.warehouse, batch_no)
if item_data.item_code not in picked_items:
picked_items[item_data.item_code] = {key: {"picked_qty": batch_qty}}
else:
picked_items[item_data.item_code][key]["picked_qty"] += batch_qty
continue
if item_data.item_code not in picked_items:
picked_items[item_data.item_code] = {}
if key not in picked_items[item_data.item_code]:
picked_items[item_data.item_code][key] = frappe._dict(
{
"picked_qty": 0,
"serial_no": [],
"batch_no": item_data.batch_no or "",
"warehouse": item_data.warehouse,
}
)
picked_items[item_data.item_code][key]["picked_qty"] += item_data.picked_qty
if serial_no:
picked_items[item_data.item_code][key]["serial_no"].extend(serial_no)
return picked_items
def _get_pick_list_items(self, items):
pi = frappe.qb.DocType("Pick List")
pi_item = frappe.qb.DocType("Pick List Item")
query = (
frappe.qb.from_(pi)
.inner_join(pi_item)
.on(pi.name == pi_item.parent)
.select(
pi_item.item_code,
pi_item.warehouse,
pi_item.batch_no,
pi_item.serial_and_batch_bundle,
pi_item.serial_no,
(Case().when(pi_item.picked_qty > 0, pi_item.picked_qty).else_(pi_item.stock_qty)).as_(
"picked_qty"
),
)
.where(
(pi_item.item_code.isin([x.item_code for x in items]))
& ((pi_item.picked_qty > 0) | (pi_item.stock_qty > 0))
& (pi.status != "Completed")
& (pi.status != "Cancelled")
& (pi_item.docstatus != 2)
)
)
if self.name:
query = query.where(pi_item.parent != self.name)
return query.run(as_dict=True)
def _get_product_bundles(self) -> dict[str, str]:
# Dict[so_item_row: item_code]
product_bundles = {}
@@ -715,9 +750,7 @@ def get_available_item_locations(
consider_rejected_warehouses=False,
):
locations = []
total_picked_qty = (
sum([v.get("picked_qty") for k, v in picked_item_details.items()]) if picked_item_details else 0
)
has_serial_no = frappe.get_cached_value("Item", item_code, "has_serial_no")
has_batch_no = frappe.get_cached_value("Item", item_code, "has_batch_no")
@@ -727,63 +760,90 @@ def get_available_item_locations(
from_warehouses,
required_qty,
company,
total_picked_qty,
consider_rejected_warehouses=consider_rejected_warehouses,
)
elif has_serial_no:
locations = get_available_item_locations_for_serialized_item(
item_code,
from_warehouses,
required_qty,
company,
total_picked_qty,
consider_rejected_warehouses=consider_rejected_warehouses,
)
elif has_batch_no:
locations = get_available_item_locations_for_batched_item(
item_code,
from_warehouses,
required_qty,
company,
total_picked_qty,
consider_rejected_warehouses=consider_rejected_warehouses,
)
else:
locations = get_available_item_locations_for_other_item(
item_code,
from_warehouses,
required_qty,
company,
total_picked_qty,
consider_rejected_warehouses=consider_rejected_warehouses,
)
if picked_item_details:
locations = filter_locations_by_picked_materials(locations, picked_item_details)
if locations:
locations = get_locations_based_on_required_qty(locations, required_qty)
if not ignore_validation:
validate_picked_materials(item_code, required_qty, locations)
return locations
def get_locations_based_on_required_qty(locations, required_qty):
filtered_locations = []
for location in locations:
if location.qty >= required_qty:
location.qty = required_qty
filtered_locations.append(location)
break
required_qty -= location.qty
filtered_locations.append(location)
return filtered_locations
def validate_picked_materials(item_code, required_qty, locations):
for location in list(locations):
if location["qty"] < 0:
locations.remove(location)
total_qty_available = sum(location.get("qty") for location in locations)
remaining_qty = required_qty - total_qty_available
if remaining_qty > 0 and not ignore_validation:
if remaining_qty > 0:
frappe.msgprint(
_("{0} units of Item {1} is not available.").format(
remaining_qty, frappe.get_desk_link("Item", item_code)
_("{0} units of Item {1} is picked in another Pick List.").format(
remaining_qty, get_link_to_form("Item", item_code)
),
title=_("Insufficient Stock"),
title=_("Already Picked"),
)
if picked_item_details:
for location in list(locations):
if location["qty"] < 0:
locations.remove(location)
total_qty_available = sum(location.get("qty") for location in locations)
remaining_qty = required_qty - total_qty_available
def filter_locations_by_picked_materials(locations, picked_item_details) -> list[dict]:
for row in locations:
key = row.warehouse
if row.batch_no:
key = (row.warehouse, row.batch_no)
if remaining_qty > 0 and not ignore_validation:
frappe.msgprint(
_("{0} units of Item {1} is picked in another Pick List.").format(
remaining_qty, frappe.get_desk_link("Item", item_code)
),
title=_("Already Picked"),
)
picked_qty = picked_item_details.get(key, {}).get("picked_qty", 0)
if not picked_qty:
continue
if picked_qty > row.qty:
row.qty = 0
picked_item_details[key]["picked_qty"] -= row.qty
else:
row.qty -= picked_qty
picked_item_details[key]["picked_qty"] = 0.0
if row.serial_nos:
row.serial_nos = list(set(row.serial_nos) - set(picked_item_details[key].get("serial_no")))
return locations
@@ -793,15 +853,12 @@ def get_available_item_locations_for_serial_and_batched_item(
from_warehouses,
required_qty,
company,
total_picked_qty=0,
consider_rejected_warehouses=False,
):
# Get batch nos by FIFO
locations = get_available_item_locations_for_batched_item(
item_code,
from_warehouses,
required_qty,
company,
consider_rejected_warehouses=consider_rejected_warehouses,
)
@@ -821,7 +878,6 @@ def get_available_item_locations_for_serial_and_batched_item(
(conditions) & (sn.batch_no == location.batch_no) & (sn.warehouse == location.warehouse)
)
.orderby(sn.creation)
.limit(ceil(location.qty + total_picked_qty))
).run(as_dict=True)
serial_nos = [sn.name for sn in serial_nos]
@@ -834,18 +890,14 @@ def get_available_item_locations_for_serial_and_batched_item(
def get_available_item_locations_for_serialized_item(
item_code,
from_warehouses,
required_qty,
company,
total_picked_qty=0,
consider_rejected_warehouses=False,
):
picked_serial_nos = get_picked_serial_nos(item_code, from_warehouses)
sn = frappe.qb.DocType("Serial No")
query = (
frappe.qb.from_(sn)
.select(sn.name, sn.warehouse)
.where((sn.item_code == item_code) & (sn.company == company))
.where(sn.item_code == item_code)
.orderby(sn.creation)
)
@@ -853,6 +905,7 @@ def get_available_item_locations_for_serialized_item(
query = query.where(sn.warehouse.isin(from_warehouses))
else:
query = query.where(Coalesce(sn.warehouse, "") != "")
query = query.where(sn.company == company)
if not consider_rejected_warehouses:
if rejected_warehouses := get_rejected_warehouses():
@@ -861,16 +914,8 @@ def get_available_item_locations_for_serialized_item(
serial_nos = query.run(as_list=True)
warehouse_serial_nos_map = frappe._dict()
picked_qty = required_qty
for serial_no, warehouse in serial_nos:
if serial_no in picked_serial_nos:
continue
if picked_qty <= 0:
break
warehouse_serial_nos_map.setdefault(warehouse, []).append(serial_no)
picked_qty -= 1
locations = []
@@ -878,12 +923,14 @@ def get_available_item_locations_for_serialized_item(
qty = len(serial_nos)
locations.append(
{
"qty": qty,
"warehouse": warehouse,
"item_code": item_code,
"serial_nos": serial_nos,
}
frappe._dict(
{
"qty": qty,
"warehouse": warehouse,
"item_code": item_code,
"serial_nos": serial_nos,
}
)
)
return locations
@@ -892,9 +939,6 @@ def get_available_item_locations_for_serialized_item(
def get_available_item_locations_for_batched_item(
item_code,
from_warehouses,
required_qty,
company,
total_picked_qty=0,
consider_rejected_warehouses=False,
):
locations = []
@@ -903,8 +947,6 @@ def get_available_item_locations_for_batched_item(
{
"item_code": item_code,
"warehouse": from_warehouses,
"qty": required_qty,
"is_pick_list": True,
}
)
)
@@ -940,9 +982,7 @@ def get_available_item_locations_for_batched_item(
def get_available_item_locations_for_other_item(
item_code,
from_warehouses,
required_qty,
company,
total_picked_qty=0,
consider_rejected_warehouses=False,
):
bin = frappe.qb.DocType("Bin")
@@ -951,7 +991,6 @@ def get_available_item_locations_for_other_item(
.select(bin.warehouse, bin.actual_qty.as_("qty"))
.where((bin.item_code == item_code) & (bin.actual_qty > 0))
.orderby(bin.creation)
.limit(cint(required_qty + total_picked_qty))
)
if from_warehouses:

View File

@@ -815,7 +815,7 @@ class TestPickList(FrappeTestCase):
def test_pick_list_status(self):
warehouse = "_Test Warehouse - _TC"
item = make_item(properties={"maintain_stock": 1}).name
item = make_item(properties={"is_stock_item": 1}).name
make_stock_entry(item=item, to_warehouse=warehouse, qty=10)
so = make_sales_order(item_code=item, qty=10, rate=100)
@@ -845,3 +845,135 @@ class TestPickList(FrappeTestCase):
pl.cancel()
pl.reload()
self.assertEqual(pl.status, "Cancelled")
def test_pick_list_validation(self):
warehouse = "_Test Warehouse - _TC"
item = make_item("Test Non Serialized Pick List Item", properties={"is_stock_item": 1}).name
make_stock_entry(item=item, to_warehouse=warehouse, qty=10)
so = make_sales_order(item_code=item, qty=5, rate=100)
pl = create_pick_list(so.name)
pl.save()
pl.submit()
self.assertEqual(pl.locations[0].qty, 5.0)
self.assertTrue(hasattr(pl, "locations"))
so = make_sales_order(item_code=item, qty=5, rate=100)
pl = create_pick_list(so.name)
pl.save()
self.assertEqual(pl.locations[0].qty, 5.0)
self.assertTrue(hasattr(pl, "locations"))
so = make_sales_order(item_code=item, qty=4, rate=100)
pl = create_pick_list(so.name)
self.assertFalse(hasattr(pl, "locations"))
def test_pick_list_validation_for_serial_no(self):
warehouse = "_Test Warehouse - _TC"
item = make_item(
"Test Serialized Pick List Item",
properties={"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "SN-SPLI-.####"},
).name
make_stock_entry(item=item, to_warehouse=warehouse, qty=10)
so = make_sales_order(item_code=item, qty=5, rate=100)
pl = create_pick_list(so.name)
pl.locations[0].qty = 5
pl.save()
pl.submit()
self.assertTrue(pl.locations[0].serial_no)
self.assertEqual(pl.locations[0].qty, 5.0)
self.assertTrue(hasattr(pl, "locations"))
so = make_sales_order(item_code=item, qty=5, rate=100)
pl = create_pick_list(so.name)
pl.save()
self.assertTrue(pl.locations[0].serial_no)
self.assertEqual(pl.locations[0].qty, 5.0)
self.assertTrue(hasattr(pl, "locations"))
so = make_sales_order(item_code=item, qty=4, rate=100)
pl = create_pick_list(so.name)
self.assertFalse(hasattr(pl, "locations"))
def test_pick_list_validation_for_batch_no(self):
warehouse = "_Test Warehouse - _TC"
item = make_item(
"Test Batch Pick List Item",
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"batch_number_series": "BATCH-SPLI-.####",
"create_new_batch": 1,
},
).name
make_stock_entry(item=item, to_warehouse=warehouse, qty=10)
so = make_sales_order(item_code=item, qty=5, rate=100)
pl = create_pick_list(so.name)
pl.locations[0].qty = 5
pl.save()
pl.submit()
self.assertTrue(pl.locations[0].batch_no)
self.assertEqual(pl.locations[0].qty, 5.0)
self.assertTrue(hasattr(pl, "locations"))
so = make_sales_order(item_code=item, qty=5, rate=100)
pl = create_pick_list(so.name)
pl.save()
self.assertTrue(pl.locations[0].batch_no)
self.assertEqual(pl.locations[0].qty, 5.0)
self.assertTrue(hasattr(pl, "locations"))
so = make_sales_order(item_code=item, qty=4, rate=100)
pl = create_pick_list(so.name)
self.assertFalse(hasattr(pl, "locations"))
def test_pick_list_validation_for_batch_no_and_serial_item(self):
warehouse = "_Test Warehouse - _TC"
item = make_item(
"Test Serialized Batch Pick List Item",
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"batch_number_series": "SN-BT-BATCH-SPLI-.####",
"create_new_batch": 1,
"has_serial_no": 1,
"serial_no_series": "SN-BT-SPLI-.####",
},
).name
make_stock_entry(item=item, to_warehouse=warehouse, qty=10)
so = make_sales_order(item_code=item, qty=5, rate=100)
pl = create_pick_list(so.name)
pl.locations[0].qty = 5
pl.save()
pl.submit()
self.assertTrue(pl.locations[0].batch_no)
self.assertTrue(pl.locations[0].serial_no)
self.assertEqual(pl.locations[0].qty, 5.0)
self.assertTrue(hasattr(pl, "locations"))
so = make_sales_order(item_code=item, qty=5, rate=100)
pl = create_pick_list(so.name)
pl.save()
self.assertTrue(pl.locations[0].batch_no)
self.assertTrue(pl.locations[0].serial_no)
self.assertEqual(pl.locations[0].qty, 5.0)
self.assertTrue(hasattr(pl, "locations"))
so = make_sales_order(item_code=item, qty=4, rate=100)
pl = create_pick_list(so.name)
self.assertFalse(hasattr(pl, "locations"))

View File

@@ -58,6 +58,8 @@
"column_break_27",
"total",
"net_total",
"tax_withholding_net_total",
"base_tax_withholding_net_total",
"taxes_charges_section",
"tax_category",
"taxes_and_charges",
@@ -1246,13 +1248,31 @@
"label": "Subcontracting Receipt",
"options": "Subcontracting Receipt",
"search_index": 1
},
{
"fieldname": "tax_withholding_net_total",
"fieldtype": "Currency",
"hidden": 1,
"label": "Tax Withholding Net Total",
"no_copy": 1,
"options": "currency",
"read_only": 1
},
{
"fieldname": "base_tax_withholding_net_total",
"fieldtype": "Currency",
"hidden": 1,
"label": "Base Tax Withholding Net Total",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
}
],
"icon": "fa fa-truck",
"idx": 261,
"is_submittable": 1,
"links": [],
"modified": "2024-03-27 13:10:25.441066",
"modified": "2024-04-08 20:23:03.699201",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt",

View File

@@ -15,6 +15,7 @@ from erpnext.accounts.utils import get_account_currency
from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.accounts_controller import merge_taxes
from erpnext.controllers.buying_controller import BuyingController
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_transaction
@@ -51,6 +52,7 @@ class PurchaseReceipt(BuyingController):
base_net_total: DF.Currency
base_rounded_total: DF.Currency
base_rounding_adjustment: DF.Currency
base_tax_withholding_net_total: DF.Currency
base_taxes_and_charges_added: DF.Currency
base_taxes_and_charges_deducted: DF.Currency
base_total: DF.Currency
@@ -120,6 +122,7 @@ class PurchaseReceipt(BuyingController):
supplier_name: DF.Data | None
supplier_warehouse: DF.Link | None
tax_category: DF.Link | None
tax_withholding_net_total: DF.Currency
taxes: DF.Table[PurchaseTaxesandCharges]
taxes_and_charges: DF.Link | None
taxes_and_charges_added: DF.Currency
@@ -1123,37 +1126,6 @@ def get_item_wise_returned_qty(pr_doc):
)
def merge_taxes(source_taxes, target_doc):
from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import (
update_item_wise_tax_detail,
)
existing_taxes = target_doc.get("taxes") or []
idx = 1
for tax in source_taxes:
found = False
for t in existing_taxes:
if t.account_head == tax.account_head and t.cost_center == tax.cost_center:
t.tax_amount = flt(t.tax_amount) + flt(tax.tax_amount_after_discount_amount)
t.base_tax_amount = flt(t.base_tax_amount) + flt(tax.base_tax_amount_after_discount_amount)
update_item_wise_tax_detail(t, tax)
found = True
if not found:
tax.charge_type = "Actual"
tax.idx = idx
idx += 1
tax.included_in_print_rate = 0
tax.dont_recompute_tax = 1
tax.row_id = ""
tax.tax_amount = tax.tax_amount_after_discount_amount
tax.base_tax_amount = tax.base_tax_amount_after_discount_amount
tax.item_wise_tax_detail = tax.item_wise_tax_detail
existing_taxes.append(tax)
target_doc.set("taxes", existing_taxes)
@frappe.whitelist()
def make_purchase_invoice(source_name, target_doc=None, args=None):
from erpnext.accounts.party import get_payment_terms_template

View File

@@ -58,6 +58,7 @@
"pricing_rules",
"stock_uom_rate",
"is_free_item",
"apply_tds",
"section_break_29",
"net_rate",
"net_amount",
@@ -1107,12 +1108,20 @@
"fieldname": "use_serial_batch_fields",
"fieldtype": "Check",
"label": "Use Serial No / Batch Fields"
},
{
"default": "1",
"fieldname": "apply_tds",
"fieldtype": "Check",
"hidden": 1,
"label": "Apply TDS",
"read_only": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:25.896543",
"modified": "2024-04-08 20:00:16.277292",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt Item",

View File

@@ -16,6 +16,7 @@ class PurchaseReceiptItem(Document):
allow_zero_valuation_rate: DF.Check
amount: DF.Currency
apply_tds: DF.Check
asset_category: DF.Link | None
asset_location: DF.Link | None
barcode: DF.Data | None

View File

@@ -860,6 +860,12 @@ class SerialandBatchBundle(Document):
self.validate_batch_inventory()
def validate_batch_inventory(self):
if (
self.voucher_type in ["Purchase Invoice", "Purchase Receipt"]
and frappe.db.get_value(self.voucher_type, self.voucher_no, "docstatus") == 1
):
return
if not self.has_batch_no:
return

View File

@@ -9,7 +9,17 @@ import frappe
from frappe import _
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder.functions import Sum
from frappe.utils import cint, comma_or, cstr, flt, format_time, formatdate, getdate, nowdate
from frappe.utils import (
cint,
comma_or,
cstr,
flt,
format_time,
formatdate,
get_link_to_form,
getdate,
nowdate,
)
import erpnext
from erpnext.accounts.general_ledger import process_gl_map
@@ -30,6 +40,7 @@ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
OpeningEntryAccountError,
)
from erpnext.stock.get_item_details import (
get_barcode_data,
get_bin_details,
get_conversion_factor,
get_default_cost_center,
@@ -428,7 +439,14 @@ class StockEntry(StockController):
for field in reset_fields:
item.set(field, item_details.get(field))
update_fields = ("uom", "description", "expense_account", "cost_center", "conversion_factor")
update_fields = (
"uom",
"description",
"expense_account",
"cost_center",
"conversion_factor",
"barcode",
)
for field in update_fields:
if not item.get(field):
@@ -637,8 +655,8 @@ class StockEntry(StockController):
)
)
work_order_link = frappe.utils.get_link_to_form("Work Order", self.work_order)
job_card_link = frappe.utils.get_link_to_form("Job Card", job_card)
work_order_link = get_link_to_form("Work Order", self.work_order)
job_card_link = get_link_to_form("Job Card", job_card)
frappe.throw(
_(
"Row #{0}: Operation {1} is not completed for {2} qty of finished goods in Work Order {3}. Please update operation status via Job Card {4}."
@@ -1347,9 +1365,24 @@ class StockEntry(StockController):
return finished_item_row
def validate_serial_batch_bundle_type(self, serial_and_batch_bundle):
if (
frappe.db.get_value("Serial and Batch Bundle", serial_and_batch_bundle, "type_of_transaction")
!= "Outward"
):
frappe.throw(
_(
"The Serial and Batch Bundle {0} is not valid for this transaction. The 'Type of Transaction' should be 'Outward' instead of 'Inward' in Serial and Batch Bundle {0}"
).format(get_link_to_form("Serial and Batch Bundle", serial_and_batch_bundle)),
title=_("Invalid Serial and Batch Bundle"),
)
def get_sle_for_source_warehouse(self, sl_entries, finished_item_row):
for d in self.get("items"):
if cstr(d.s_warehouse):
if d.serial_and_batch_bundle and self.docstatus == 1:
self.validate_serial_batch_bundle_type(d.serial_and_batch_bundle)
sle = self.get_sl_entries(
d,
{
@@ -1366,6 +1399,21 @@ class StockEntry(StockController):
):
sle.dependant_sle_voucher_detail_no = finished_item_row.name
if sle.serial_and_batch_bundle and self.docstatus == 2:
bundle_id = frappe.get_cached_value(
"Serial and Batch Bundle",
{
"voucher_detail_no": d.name,
"voucher_no": self.name,
"is_cancelled": 0,
"type_of_transaction": "Outward",
},
"name",
)
if bundle_id:
sle.serial_and_batch_bundle = bundle_id
sl_entries.append(sle)
def make_serial_and_batch_bundle_for_transfer(self):
@@ -1634,6 +1682,10 @@ class StockEntry(StockController):
if subcontract_items and len(subcontract_items) == 1:
ret["subcontracted_item"] = subcontract_items[0].main_item_code
barcode_data = get_barcode_data(item_code=item.name)
if barcode_data and len(barcode_data.get(item.name)) == 1:
ret["barcode"] = barcode_data.get(item.name)[0]
return ret
@frappe.whitelist()

View File

@@ -110,6 +110,12 @@ class TestStockEntry(FrappeTestCase):
self._test_auto_material_request("_Test Item")
self._test_auto_material_request("_Test Item", material_request_type="Transfer")
def test_barcode_item_stock_entry(self):
item_code = make_item("_Test Item Stock Entry For Barcode", barcode="BDD-1234567890")
se = make_stock_entry(item_code=item_code, target="_Test Warehouse - _TC", qty=1, basic_rate=100)
self.assertEqual(se.items[0].barcode, "BDD-1234567890")
def test_auto_material_request_for_variant(self):
fields = [{"field_name": "reorder_levels"}]
set_item_variant_settings(fields)
@@ -1748,6 +1754,41 @@ class TestStockEntry(FrappeTestCase):
self.assertTrue(frappe.db.exists("Serial No", serial_no))
self.assertEqual(frappe.db.get_value("Serial No", serial_no, "status"), "Delivered")
def test_serial_batch_bundle_type_of_transaction(self):
item = make_item(
"Test Use Serial and Batch Item SN Item",
{
"has_batch_no": 1,
"is_stock_item": 1,
"create_new_batch": 1,
"batch_naming_series": "Test-SBBTYT-NNS.#####",
},
).name
se = make_stock_entry(
item_code=item,
qty=2,
target="_Test Warehouse - _TC",
use_serial_batch_fields=1,
)
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
se = make_stock_entry(
item_code=item,
qty=2,
source="_Test Warehouse - _TC",
target="Stores - _TC",
use_serial_batch_fields=0,
batch_no=batch_no,
do_not_submit=True,
)
se.reload()
sbb = se.items[0].serial_and_batch_bundle
frappe.db.set_value("Serial and Batch Bundle", sbb, "type_of_transaction", "Inward")
self.assertRaises(frappe.ValidationError, se.submit)
def make_serialized_item(**args):
args = frappe._dict(args)

View File

@@ -81,6 +81,18 @@ frappe.ui.form.on("Stock Reconciliation", {
if (frm.doc.company) {
frm.trigger("toggle_display_account_head");
}
frm.events.set_fields_onload_for_line_item(frm);
},
set_fields_onload_for_line_item(frm) {
if (frm.is_new() && frm.doc?.items && cint(frappe.user_defaults?.use_serial_batch_fields) === 1) {
frm.doc.items.forEach((item) => {
if (!item.serial_and_batch_bundle) {
frappe.model.set_value(item.doctype, item.name, "use_serial_batch_fields", 1);
}
});
}
},
scan_barcode: function (frm) {
@@ -155,6 +167,9 @@ frappe.ui.form.on("Stock Reconciliation", {
item.qty = item.qty || 0;
item.valuation_rate = item.valuation_rate || 0;
item.use_serial_batch_fields = cint(
frappe.user_defaults?.use_serial_batch_fields
);
});
frm.refresh_field("items");
},
@@ -298,6 +313,10 @@ frappe.ui.form.on("Stock Reconciliation Item", {
if (!item.warehouse && frm.doc.set_warehouse) {
frappe.model.set_value(cdt, cdn, "warehouse", frm.doc.set_warehouse);
}
if (item.docstatus === 0 && cint(frappe.user_defaults?.use_serial_batch_fields) === 1) {
frappe.model.set_value(item.doctype, item.name, "use_serial_batch_fields", 1);
}
},
add_serial_batch_bundle(frm, cdt, cdn) {

View File

@@ -1021,7 +1021,9 @@ def get_batch_qty_for_stock_reco(item_code, warehouse, batch_no, posting_date, p
@frappe.whitelist()
def get_items(warehouse, posting_date, posting_time, company, item_code=None, ignore_empty_stock=False):
ignore_empty_stock = cint(ignore_empty_stock)
items = [frappe._dict({"item_code": item_code, "warehouse": warehouse})]
items = []
if item_code and warehouse:
items = get_item_and_warehouses(item_code, warehouse)
if not item_code:
items = get_items_for_stock_reco(warehouse, company)
@@ -1066,6 +1068,20 @@ def get_items(warehouse, posting_date, posting_time, company, item_code=None, ig
return res
def get_item_and_warehouses(item_code, warehouse):
from frappe.utils.nestedset import get_descendants_of
items = []
if frappe.get_cached_value("Warehouse", warehouse, "is_group"):
childrens = get_descendants_of("Warehouse", warehouse, ignore_permissions=True, order_by="lft")
for ch_warehouse in childrens:
items.append(frappe._dict({"item_code": item_code, "warehouse": ch_warehouse}))
else:
items = [frappe._dict({"item_code": item_code, "warehouse": warehouse})]
return items
def get_items_for_stock_reco(warehouse, company):
lft, rgt = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"])
items = frappe.db.sql(
@@ -1080,7 +1096,7 @@ def get_items_for_stock_reco(warehouse, company):
and i.is_stock_item = 1
and i.has_variants = 0
and exists(
select name from `tabWarehouse` where lft >= {lft} and rgt <= {rgt} and name = bin.warehouse
select name from `tabWarehouse` where lft >= {lft} and rgt <= {rgt} and name = bin.warehouse and is_group = 0
)
""",
as_dict=1,
@@ -1095,7 +1111,7 @@ def get_items_for_stock_reco(warehouse, company):
where
i.name = id.parent
and exists(
select name from `tabWarehouse` where lft >= %s and rgt <= %s and name=id.default_warehouse
select name from `tabWarehouse` where lft >= %s and rgt <= %s and name=id.default_warehouse and is_group = 0
)
and i.is_stock_item = 1
and i.has_variants = 0
@@ -1157,7 +1173,7 @@ def get_itemwise_batch(warehouse, posting_date, company, item_code=None):
frappe._dict(
{
"item_code": row[0],
"warehouse": warehouse,
"warehouse": row[3],
"qty": row[8],
"item_name": row[1],
"batch_no": row[4],

View File

@@ -308,3 +308,13 @@ def clean_all_descriptions():
clean_description = clean_html(item.description)
if item.description != clean_description:
frappe.db.set_value("Item", item.name, "description", clean_description)
@frappe.whitelist()
def get_enable_stock_uom_editing():
return frappe.get_cached_value(
"Stock Settings",
None,
["allow_to_edit_stock_uom_qty_for_sales", "allow_to_edit_stock_uom_qty_for_purchase"],
as_dict=1,
)

View File

@@ -490,12 +490,21 @@ def update_barcode_value(out):
out["barcode"] = barcode_data.get(out.item_code)[0]
def get_barcode_data(items_list):
def get_barcode_data(items_list=None, item_code=None):
# get item-wise batch no data
# example: {'LED-GRE': [Batch001, Batch002]}
# where LED-GRE is item code, SN0001 is serial no and Pune is warehouse
itemwise_barcode = {}
if not items_list and item_code:
_dict_item_code = frappe._dict(
{
"item_code": item_code,
}
)
items_list = [frappe._dict(_dict_item_code)]
for item in items_list:
barcodes = frappe.db.get_all("Item Barcode", filters={"parent": item.item_code}, fields="barcode")

View File

@@ -9,9 +9,6 @@ frappe.query_reports["Item Prices"] = {
fieldtype: "Select",
options: "Enabled Items only\nDisabled Items only\nAll Items",
default: "Enabled Items only",
on_change: function (query_report) {
query_report.trigger_refresh();
},
},
],
};

View File

@@ -498,8 +498,6 @@ class SubcontractingReceipt(SubcontractingController):
return process_gl_map(gl_entries)
def make_item_gl_entries(self, gl_entries, warehouse_account=None):
stock_rbnb = self.get_company_default("stock_received_but_not_billed")
warehouse_with_no_account = []
for item in self.items:
@@ -517,31 +515,41 @@ class SubcontractingReceipt(SubcontractingController):
"stock_value_difference",
)
warehouse_account_name = warehouse_account[item.warehouse]["account"]
warehouse_account_currency = warehouse_account[item.warehouse]["account_currency"]
accepted_warehouse_account = warehouse_account[item.warehouse]["account"]
supplier_warehouse_account = warehouse_account.get(self.supplier_warehouse, {}).get(
"account"
)
supplier_warehouse_account_currency = warehouse_account.get(
self.supplier_warehouse, {}
).get("account_currency")
remarks = self.get("remarks") or _("Accounting Entry for Stock")
# FG Warehouse Account (Debit)
# Accepted Warehouse Account (Debit)
self.add_gl_entry(
gl_entries=gl_entries,
account=warehouse_account_name,
account=accepted_warehouse_account,
cost_center=item.cost_center,
debit=stock_value_diff,
credit=0.0,
remarks=remarks,
against_account=stock_rbnb,
account_currency=warehouse_account_currency,
against_account=item.expense_account,
account_currency=get_account_currency(accepted_warehouse_account),
project=item.project,
item=item,
)
# Expense Account (Credit)
self.add_gl_entry(
gl_entries=gl_entries,
account=item.expense_account,
cost_center=item.cost_center,
debit=0.0,
credit=stock_value_diff,
remarks=remarks,
against_account=accepted_warehouse_account,
account_currency=get_account_currency(item.expense_account),
project=item.project,
item=item,
)
# Supplier Warehouse Account (Credit)
if flt(item.rm_supp_cost) and warehouse_account.get(self.supplier_warehouse):
if flt(item.rm_supp_cost) and supplier_warehouse_account:
# Supplier Warehouse Account (Credit)
self.add_gl_entry(
gl_entries=gl_entries,
account=supplier_warehouse_account,
@@ -549,40 +557,66 @@ class SubcontractingReceipt(SubcontractingController):
debit=0.0,
credit=flt(item.rm_supp_cost),
remarks=remarks,
against_account=warehouse_account_name,
account_currency=supplier_warehouse_account_currency,
against_account=item.expense_account,
account_currency=get_account_currency(supplier_warehouse_account),
project=item.project,
item=item,
)
# Expense Account (Credit)
if flt(item.service_cost_per_qty):
# Expense Account (Debit)
self.add_gl_entry(
gl_entries=gl_entries,
account=item.expense_account,
cost_center=item.cost_center,
debit=0.0,
credit=flt(item.service_cost_per_qty) * flt(item.qty),
debit=flt(item.rm_supp_cost),
credit=0.0,
remarks=remarks,
against_account=warehouse_account_name,
against_account=supplier_warehouse_account,
account_currency=get_account_currency(item.expense_account),
project=item.project,
item=item,
)
# Loss Account (Credit)
divisional_loss = flt(item.amount - stock_value_diff, item.precision("amount"))
# Expense Account (Debit)
if item.additional_cost_per_qty:
self.add_gl_entry(
gl_entries=gl_entries,
account=item.expense_account,
cost_center=self.cost_center or self.get_company_default("cost_center"),
debit=item.qty * item.additional_cost_per_qty,
credit=0.0,
remarks=remarks,
against_account=None,
account_currency=get_account_currency(item.expense_account),
)
if divisional_loss:
loss_account = item.expense_account
if divisional_loss := flt(item.amount - stock_value_diff, item.precision("amount")):
loss_account = self.get_company_default(
"stock_adjustment_account", ignore_validation=True
)
# Loss Account (Credit)
self.add_gl_entry(
gl_entries=gl_entries,
account=loss_account,
cost_center=item.cost_center,
debit=0.0,
credit=divisional_loss,
remarks=remarks,
against_account=item.expense_account,
account_currency=get_account_currency(loss_account),
project=item.project,
item=item,
)
# Expense Account (Debit)
self.add_gl_entry(
gl_entries=gl_entries,
account=item.expense_account,
cost_center=item.cost_center,
debit=divisional_loss,
credit=0.0,
remarks=remarks,
against_account=warehouse_account_name,
account_currency=get_account_currency(loss_account),
against_account=loss_account,
account_currency=get_account_currency(item.expense_account),
project=item.project,
item=item,
)
@@ -592,7 +626,6 @@ class SubcontractingReceipt(SubcontractingController):
):
warehouse_with_no_account.append(item.warehouse)
# Additional Costs Expense Accounts (Credit)
for row in self.additional_costs:
credit_amount = (
flt(row.base_amount)
@@ -600,6 +633,7 @@ class SubcontractingReceipt(SubcontractingController):
else flt(row.amount)
)
# Additional Cost Expense Account (Credit)
self.add_gl_entry(
gl_entries=gl_entries,
account=row.expense_account,
@@ -608,6 +642,7 @@ class SubcontractingReceipt(SubcontractingController):
credit=credit_amount,
remarks=remarks,
against_account=None,
account_currency=get_account_currency(row.expense_account),
)
if warehouse_with_no_account:

View File

@@ -10,6 +10,7 @@ from frappe.utils import add_days, cint, flt, nowtime, today
import erpnext
from erpnext.accounts.doctype.account.test_account import get_inventory_account
from erpnext.accounts.utils import get_company_default
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.controllers.tests.test_subcontracting_controller import (
get_rm_items,
@@ -351,26 +352,15 @@ class TestSubcontractingReceipt(FrappeTestCase):
self.assertEqual(cint(erpnext.is_perpetual_inventory_enabled(scr.company)), 1)
gl_entries = get_gl_entries("Subcontracting Receipt", scr.name)
self.assertTrue(gl_entries)
fg_warehouse_ac = get_inventory_account(scr.company, scr.items[0].warehouse)
supplier_warehouse_ac = get_inventory_account(scr.company, scr.supplier_warehouse)
expense_account = scr.items[0].expense_account
if fg_warehouse_ac == supplier_warehouse_ac:
expected_values = {
fg_warehouse_ac: [2100.0, 1000.0], # FG Amount (D), RM Cost (C)
expense_account: [0.0, 1000.0], # Service Cost (C)
additional_costs_expense_account: [0.0, 100.0], # Additional Cost (C)
}
else:
expected_values = {
fg_warehouse_ac: [2100.0, 0.0], # FG Amount (D)
supplier_warehouse_ac: [0.0, 1000.0], # RM Cost (C)
expense_account: [0.0, 1000.0], # Service Cost (C)
additional_costs_expense_account: [0.0, 100.0], # Additional Cost (C)
}
expected_values = {
fg_warehouse_ac: [2100.0, 1000],
expense_account: [1100, 2100],
additional_costs_expense_account: [0.0, 100.0],
}
for gle in gl_entries:
self.assertEqual(expected_values[gle.account][0], gle.debit)
@@ -381,6 +371,53 @@ class TestSubcontractingReceipt(FrappeTestCase):
self.assertTrue(get_gl_entries("Subcontracting Receipt", scr.name))
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1)
@change_settings("Stock Settings", {"use_serial_batch_fields": 0})
def test_subcontracting_receipt_with_zero_service_cost(self):
warehouse = "Stores - TCP1"
service_items = [
{
"warehouse": warehouse,
"item_code": "Subcontracted Service Item 7",
"qty": 10,
"rate": 0,
"fg_item": "Subcontracted Item SA7",
"fg_item_qty": 10,
},
]
sco = get_subcontracting_order(
company="_Test Company with perpetual inventory",
warehouse=warehouse,
supplier_warehouse="Work In Progress - TCP1",
service_items=service_items,
)
rm_items = get_rm_items(sco.supplied_items)
itemwise_details = make_stock_in_entry(rm_items=rm_items)
make_stock_transfer_entry(
sco_no=sco.name,
rm_items=rm_items,
itemwise_details=copy.deepcopy(itemwise_details),
)
scr = make_subcontracting_receipt(sco.name)
scr.save()
scr.submit()
gl_entries = get_gl_entries("Subcontracting Receipt", scr.name)
self.assertTrue(gl_entries)
fg_warehouse_ac = get_inventory_account(scr.company, scr.items[0].warehouse)
expense_account = scr.items[0].expense_account
expected_values = {
fg_warehouse_ac: [1000, 1000],
expense_account: [1000, 1000],
}
for gle in gl_entries:
self.assertEqual(expected_values[gle.account][0], gle.debit)
self.assertEqual(expected_values[gle.account][1], gle.credit)
scr.reload()
scr.cancel()
def test_supplied_items_consumed_qty(self):
# Set Backflush Based On as "Material Transferred for Subcontracting" to transfer RM's more than the required qty
set_backflush_based_on("Material Transferred for Subcontract")

View File

@@ -1 +1 @@
<a href="https://erpnext.com?source=website_footer" target="_blank" class="text-muted">Powered by ERPNext</a>
{{ _("Powered by {0}").format('<a href="https://erpnext.com?source=website_footer" target="_blank" class="text-muted">ERPNext</a>') }}