mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-16 07:58:38 +00:00
Merge branch 'develop' into fix/available-batch-report-company-filter
This commit is contained in:
@@ -91,3 +91,5 @@ pull_request_rules:
|
||||
commit_message_format:
|
||||
title: pr-title
|
||||
body: pr-body
|
||||
merge_queue:
|
||||
queue_controls_comment: false
|
||||
|
||||
@@ -727,6 +727,7 @@ def get_company_default_account_fields():
|
||||
"stock_delivered_but_not_billed": "Stock Delivered But Not Billed Account",
|
||||
"stock_adjustment_account": "Stock Adjustment Account",
|
||||
"write_off_account": "Write Off Account",
|
||||
"bank_charges_account": "Bank Charges Account",
|
||||
"default_discount_account": "Default Payment Discount Account",
|
||||
"unrealized_profit_loss_account": "Unrealized Profit / Loss Account",
|
||||
"exchange_gain_loss_account": "Exchange Gain / Loss Account",
|
||||
|
||||
@@ -110,18 +110,6 @@ frappe.ui.form.on("Chart of Accounts Importer", {
|
||||
args: {
|
||||
company: frm.doc.company,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message === false) {
|
||||
frm.set_value("company", "");
|
||||
frappe.throw(
|
||||
__(
|
||||
"Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions."
|
||||
)
|
||||
);
|
||||
} else {
|
||||
frm.trigger("refresh");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -70,7 +70,13 @@ def validate_company(company: str):
|
||||
frappe.throw(msg, title=_("Wrong Company"))
|
||||
|
||||
if frappe.db.get_all("GL Entry", {"company": company}, "name", limit=1):
|
||||
return False
|
||||
frappe.throw(
|
||||
_(
|
||||
"Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions."
|
||||
)
|
||||
)
|
||||
|
||||
validate_user_perms(company)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -79,16 +85,22 @@ def import_coa(file_name: str, company: str):
|
||||
|
||||
# delete existing data for accounts
|
||||
frappe.has_permission("Company", "write", company, throw=True)
|
||||
unset_existing_data(company)
|
||||
|
||||
# create accounts
|
||||
file_doc, extension = get_file(file_name)
|
||||
validate_accounts(file_doc, extension)
|
||||
|
||||
if extension == "csv":
|
||||
data = generate_data_from_csv(file_doc)
|
||||
else:
|
||||
data = generate_data_from_excel(file_doc, extension)
|
||||
|
||||
validate_columns(data)
|
||||
|
||||
validate_company(company)
|
||||
|
||||
unset_existing_data(company)
|
||||
|
||||
frappe.local.flags.ignore_root_company_validation = True
|
||||
forest = build_forest(data)
|
||||
create_charts(company, custom_chart=forest, from_coa_importer=True)
|
||||
@@ -453,7 +465,6 @@ def get_mandatory_account_types():
|
||||
|
||||
def unset_existing_data(company):
|
||||
# remove accounts data from company
|
||||
|
||||
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
|
||||
linked = [{"fieldname": name} for name in fieldnames]
|
||||
update_values = {d.get("fieldname"): "" for d in linked}
|
||||
@@ -463,13 +474,30 @@ def unset_existing_data(company):
|
||||
# remove accounts data from various doctypes
|
||||
for doctype in [
|
||||
"Account",
|
||||
"Sales Taxes and Charges Template",
|
||||
"Purchase Taxes and Charges Template",
|
||||
"Party Account",
|
||||
"Mode of Payment Account",
|
||||
"Tax Withholding Account",
|
||||
"Sales Taxes and Charges Template",
|
||||
"Purchase Taxes and Charges Template",
|
||||
]:
|
||||
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
|
||||
frappe.get_query(doctype, delete=True, filters={"company": company}).run()
|
||||
|
||||
|
||||
def validate_user_perms(company):
|
||||
# User Permission Check for Account Deletion
|
||||
company_accounts_count = frappe.get_query(
|
||||
"Account", fields=[{"COUNT": "name"}], filters={"company": company}
|
||||
).run()[0][0]
|
||||
company_accounts_user_has_access_to = frappe.get_query(
|
||||
"Account", fields=[{"COUNT": "name"}], filters={"company": company}, ignore_permissions=False
|
||||
).run()[0][0]
|
||||
|
||||
if company_accounts_count != company_accounts_user_has_access_to:
|
||||
frappe.throw(
|
||||
_("Accounts cannot be removed, as user doesn't have access to all the accounts of {0}").format(
|
||||
frappe.bold(company)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def set_default_accounts(company):
|
||||
|
||||
@@ -1292,7 +1292,10 @@ frappe.ui.form.on("Payment Entry", {
|
||||
|
||||
if (!row) {
|
||||
const company_defaults = frappe.get_doc(":Company", frm.doc.company);
|
||||
const is_single_currency =
|
||||
frm.doc.paid_from_account_currency === frm.doc.paid_to_account_currency;
|
||||
const account =
|
||||
(is_single_currency && company_defaults?.bank_charges_account) ||
|
||||
company_defaults?.[account_fieldname] ||
|
||||
(await prompt_for_missing_account(frm, account_fieldname));
|
||||
|
||||
@@ -1847,7 +1850,7 @@ frappe.ui.form.on("Payment Entry Deduction", {
|
||||
before_deductions_remove: function (doc, cdt, cdn) {
|
||||
const row = frappe.get_doc(cdt, cdn);
|
||||
if (row.is_exchange_gain_loss && row.amount) {
|
||||
frappe.throw(__("Cannot delete Exchange Gain/Loss row"));
|
||||
frappe.throw(__("Cannot delete a system-generated deduction row"));
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1135,10 +1135,18 @@ class PaymentEntry(AccountsController):
|
||||
|
||||
if not exchange_gain_loss_row:
|
||||
values = frappe.get_cached_value(
|
||||
"Company", self.company, ("exchange_gain_loss_account", "cost_center"), as_dict=True
|
||||
"Company",
|
||||
self.company,
|
||||
("bank_charges_account", "exchange_gain_loss_account", "cost_center"),
|
||||
as_dict=True,
|
||||
)
|
||||
is_single_currency = self.paid_from_account_currency == self.paid_to_account_currency
|
||||
account = (
|
||||
is_single_currency and values.bank_charges_account
|
||||
) or values.exchange_gain_loss_account
|
||||
|
||||
for fieldname, value in values.items():
|
||||
missing_fields = {"exchange_gain_loss_account": account, "cost_center": values.cost_center}
|
||||
for fieldname, value in missing_fields.items():
|
||||
if value:
|
||||
continue
|
||||
|
||||
@@ -1155,7 +1163,7 @@ class PaymentEntry(AccountsController):
|
||||
exchange_gain_loss_row = self.append(
|
||||
"deductions",
|
||||
{
|
||||
"account": values.exchange_gain_loss_account,
|
||||
"account": account,
|
||||
"cost_center": values.cost_center,
|
||||
"is_exchange_gain_loss": 1,
|
||||
},
|
||||
|
||||
@@ -782,6 +782,94 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
|
||||
self.validate_gl_entries(pe.name, expected_gle)
|
||||
|
||||
def test_bank_charges_deduction(self):
|
||||
bank_charges_account = create_account(
|
||||
parent_account="Indirect Expenses - _TC",
|
||||
account_name="_Test Bank Charges",
|
||||
company="_Test Company",
|
||||
)
|
||||
frappe.db.set_value("Company", "_Test Company", "bank_charges_account", bank_charges_account)
|
||||
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "bank_charges_account", "")
|
||||
|
||||
pe = frappe.new_doc("Payment Entry")
|
||||
pe.payment_type = "Internal Transfer"
|
||||
pe.company = "_Test Company"
|
||||
pe.paid_from = "_Test Bank - _TC"
|
||||
pe.paid_to = "_Test Cash - _TC"
|
||||
pe.paid_amount = 1000
|
||||
pe.received_amount = 990
|
||||
pe.reference_no = "4"
|
||||
pe.reference_date = nowdate()
|
||||
|
||||
pe.setup_party_account_field()
|
||||
pe.set_missing_values()
|
||||
pe.set_exchange_rate()
|
||||
pe.set_amounts()
|
||||
|
||||
self.assertEqual(pe.deductions[0].account, bank_charges_account)
|
||||
self.assertEqual(pe.deductions[0].amount, 10)
|
||||
pe.deductions[0].cost_center = "_Test Cost Center - _TC"
|
||||
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
expected_gle = dict(
|
||||
(d[0], d)
|
||||
for d in [
|
||||
["_Test Bank - _TC", 0, 1000, None],
|
||||
["_Test Cash - _TC", 990, 0, None],
|
||||
[bank_charges_account, 10, 0, None],
|
||||
]
|
||||
)
|
||||
|
||||
self.validate_gl_entries(pe.name, expected_gle)
|
||||
|
||||
def test_cross_currency_transfer_ignores_bank_charges_account(self):
|
||||
exchange_gain_loss_account = frappe.db.get_value(
|
||||
"Company", "_Test Company", "exchange_gain_loss_account"
|
||||
)
|
||||
bank_charges_account = create_account(
|
||||
parent_account="Indirect Expenses - _TC",
|
||||
account_name="_Test Bank Charges",
|
||||
company="_Test Company",
|
||||
)
|
||||
frappe.db.set_value("Company", "_Test Company", "bank_charges_account", bank_charges_account)
|
||||
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "bank_charges_account", "")
|
||||
|
||||
pe = frappe.new_doc("Payment Entry")
|
||||
pe.payment_type = "Internal Transfer"
|
||||
pe.company = "_Test Company"
|
||||
pe.paid_from = "_Test Bank USD - _TC"
|
||||
pe.paid_to = "_Test Bank - _TC"
|
||||
pe.paid_amount = 100
|
||||
pe.source_exchange_rate = 50
|
||||
pe.received_amount = 4500
|
||||
pe.reference_no = "5"
|
||||
pe.reference_date = nowdate()
|
||||
|
||||
pe.setup_party_account_field()
|
||||
pe.set_missing_values()
|
||||
pe.set_exchange_rate()
|
||||
pe.set_amounts()
|
||||
|
||||
self.assertEqual(pe.deductions[0].account, exchange_gain_loss_account)
|
||||
self.assertEqual(pe.deductions[0].amount, 500)
|
||||
pe.deductions[0].cost_center = "_Test Cost Center - _TC"
|
||||
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
expected_gle = dict(
|
||||
(d[0], d)
|
||||
for d in [
|
||||
["_Test Bank USD - _TC", 0, 5000, None],
|
||||
["_Test Bank - _TC", 4500, 0, None],
|
||||
[exchange_gain_loss_account, 500.0, 0, None],
|
||||
]
|
||||
)
|
||||
|
||||
self.validate_gl_entries(pe.name, expected_gle)
|
||||
|
||||
def test_payment_against_negative_sales_invoice(self):
|
||||
si1 = create_sales_invoice()
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"depends_on": "eval:doc.is_exchange_gain_loss",
|
||||
"fieldname": "is_exchange_gain_loss",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Exchange Gain / Loss?",
|
||||
"label": "System Generated",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
|
||||
@@ -297,7 +297,6 @@
|
||||
"hide_days": 1,
|
||||
"hide_seconds": 1,
|
||||
"label": "Tax Id",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -1940,6 +1939,7 @@
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "additional_discount_account",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Discount Account",
|
||||
"options": "Account"
|
||||
},
|
||||
@@ -2360,7 +2360,7 @@
|
||||
"link_fieldname": "consolidated_invoice"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-21 12:46:13.250145",
|
||||
"modified": "2026-08-11 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice",
|
||||
|
||||
@@ -29,6 +29,7 @@ from erpnext.setup.doctype.company.company import update_company_current_month_s
|
||||
from erpnext.stock.doctype.delivery_note.services.billing_status import (
|
||||
update_billed_amount_based_on_so,
|
||||
)
|
||||
from erpnext.stock.utils import get_bin_qty_map
|
||||
|
||||
from .services.fixed_assets import FixedAssetService
|
||||
from .services.inter_company import (
|
||||
@@ -991,11 +992,17 @@ class SalesInvoice(SellingController):
|
||||
)
|
||||
|
||||
def update_current_stock(self):
|
||||
bin_qty_map = get_bin_qty_map(self.items + self.packed_items)
|
||||
|
||||
for item in self.items:
|
||||
item.set_actual_qty()
|
||||
if item.item_code and item.warehouse:
|
||||
bin_data = bin_qty_map.get((item.item_code, item.warehouse))
|
||||
item.actual_qty = bin_data.actual_qty if bin_data else 0
|
||||
|
||||
for packed_item in self.packed_items:
|
||||
packed_item.set_actual_and_projected_qty()
|
||||
bin_data = bin_qty_map.get((packed_item.item_code, packed_item.warehouse))
|
||||
packed_item.actual_qty = bin_data.actual_qty if bin_data else 0
|
||||
packed_item.projected_qty = bin_data.projected_qty if bin_data else 0
|
||||
|
||||
def update_packing_list(self):
|
||||
if cint(self.update_stock) == 1:
|
||||
|
||||
@@ -887,6 +887,7 @@
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "discount_account",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Discount Account",
|
||||
"options": "Account"
|
||||
},
|
||||
@@ -1067,7 +1068,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified": "2026-08-11 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -114,15 +114,6 @@ class SalesInvoiceItem(Document):
|
||||
)
|
||||
)
|
||||
|
||||
def set_actual_qty(self):
|
||||
if self.item_code and self.warehouse:
|
||||
self.actual_qty = (
|
||||
frappe.db.get_value(
|
||||
"Bin", {"item_code": self.item_code, "warehouse": self.warehouse}, "actual_qty"
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
def set_income_account_for_fixed_asset(self, company: str):
|
||||
"""Set income account for fixed asset item based on company's disposal account and cost center."""
|
||||
if not self.is_fixed_asset:
|
||||
|
||||
@@ -323,9 +323,13 @@ def add_total_row_account(
|
||||
consolidated=False,
|
||||
add_blank_row=True,
|
||||
):
|
||||
name_key = "account" if consolidated else "section"
|
||||
parent_key = "parent_account" if consolidated else "parent_section"
|
||||
label_str = "'" + str(label) + "'"
|
||||
|
||||
total_row = {
|
||||
"section_name": "'" + _("{0}").format(label) + "'",
|
||||
"section": "'" + _("{0}").format(label) + "'",
|
||||
f"{name_key}_name": label_str,
|
||||
name_key: label_str,
|
||||
"currency": currency,
|
||||
}
|
||||
|
||||
@@ -336,15 +340,15 @@ def add_total_row_account(
|
||||
period_list = get_filtered_list_for_consolidated_report(filters, period_list)
|
||||
|
||||
for row in data:
|
||||
if row.get("parent_section"):
|
||||
if row.get(parent_key):
|
||||
for period in period_list:
|
||||
key = period if consolidated else period["key"]
|
||||
total_row.setdefault(key, 0.0)
|
||||
total_row[key] += row.get(key, 0.0)
|
||||
summary_data[label] += row.get(key)
|
||||
summary_data[label] += row.get(key) or 0.0
|
||||
|
||||
total_row.setdefault("total", 0.0)
|
||||
total_row["total"] += row["total"]
|
||||
total_row["total"] += row.get("total", 0.0)
|
||||
|
||||
out.append(total_row)
|
||||
|
||||
@@ -484,7 +488,6 @@ def get_opening_range_using_fiscal_year(company, period_list):
|
||||
|
||||
def get_report_summary(summary_data, currency):
|
||||
report_summary = []
|
||||
|
||||
for label, value in summary_data.items():
|
||||
report_summary.append({"value": value, "label": label, "datatype": "Currency", "currency": currency})
|
||||
|
||||
|
||||
@@ -53,6 +53,15 @@ class BuyingSettings(Document):
|
||||
for key in ["supplier_group", "supp_master_name", "maintain_same_rate", "buying_price_list"]:
|
||||
frappe.db.set_default(key, self.get(key, ""))
|
||||
|
||||
self.update_supplier_naming_settings()
|
||||
|
||||
if not self.bill_for_rejected_quantity_in_purchase_invoice:
|
||||
self.set_valuation_rate_for_rejected_materials = 0
|
||||
|
||||
def update_supplier_naming_settings(self):
|
||||
if not self.has_value_changed("supp_master_name"):
|
||||
return
|
||||
|
||||
from erpnext.utilities.naming import set_by_naming_series
|
||||
|
||||
set_by_naming_series(
|
||||
@@ -62,9 +71,6 @@ class BuyingSettings(Document):
|
||||
hide_name_field=False,
|
||||
)
|
||||
|
||||
if not self.bill_for_rejected_quantity_in_purchase_invoice:
|
||||
self.set_valuation_rate_for_rejected_materials = 0
|
||||
|
||||
def before_save(self):
|
||||
self.check_maintain_same_rate()
|
||||
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
# import frappe
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestBuyingSettings(ERPNextTestSuite):
|
||||
pass
|
||||
def test_unrelated_change_does_not_update_supplier_metadata(self):
|
||||
settings = frappe.get_single("Buying Settings")
|
||||
settings.allow_multiple_items = not settings.allow_multiple_items
|
||||
|
||||
with patch("erpnext.utilities.naming.set_by_naming_series") as set_by_naming_series:
|
||||
settings.save()
|
||||
|
||||
set_by_naming_series.assert_not_called()
|
||||
|
||||
def test_supplier_metadata_updates_when_related_settings_change(self):
|
||||
settings = frappe.get_single("Buying Settings")
|
||||
settings.supp_master_name = (
|
||||
"Supplier Name" if settings.supp_master_name == "Naming Series" else "Naming Series"
|
||||
)
|
||||
|
||||
with patch("erpnext.utilities.naming.set_by_naming_series") as set_by_naming_series:
|
||||
settings.save()
|
||||
|
||||
set_by_naming_series.assert_called_once()
|
||||
|
||||
@@ -51,9 +51,9 @@ class SubcontractingService:
|
||||
if not doc.is_subcontracted:
|
||||
return
|
||||
|
||||
finished_goods_without_service_item = {
|
||||
d.fg_item for d in doc.items if (not d.item_code and d.fg_item)
|
||||
}
|
||||
finished_goods_without_service_item = list(
|
||||
{d.fg_item for d in doc.items if (not d.item_code and d.fg_item)}
|
||||
)
|
||||
|
||||
if subcontracting_boms := get_subcontracting_boms_for_finished_goods(
|
||||
finished_goods_without_service_item
|
||||
|
||||
@@ -13,6 +13,7 @@ from frappe.utils import get_url
|
||||
from frappe.utils.print_format import download_pdf
|
||||
from frappe.utils.user import get_user_fullname
|
||||
|
||||
from erpnext.accounts.party import validate_party_frozen_disabled
|
||||
from erpnext.buying.utils import validate_for_items
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
|
||||
@@ -122,6 +123,8 @@ class RequestforQuotation(BuyingController):
|
||||
|
||||
def validate_supplier_list(self):
|
||||
for d in self.suppliers:
|
||||
validate_party_frozen_disabled(self.company, "Supplier", d.supplier)
|
||||
|
||||
prevent_rfqs = frappe.db.get_value("Supplier", d.supplier, "prevent_rfqs")
|
||||
if prevent_rfqs:
|
||||
standing = frappe.db.get_value("Supplier Scorecard", d.supplier, "status")
|
||||
|
||||
@@ -18,6 +18,7 @@ from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.crm.doctype.opportunity.mapper import make_request_for_quotation as make_rfq
|
||||
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
|
||||
from erpnext.exceptions import PartyDisabled
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
|
||||
from erpnext.templates.pages.rfq import check_supplier_has_docname_access
|
||||
@@ -89,6 +90,17 @@ class TestRequestforQuotation(ERPNextTestSuite):
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, rfq.save)
|
||||
|
||||
def test_rfq_blocked_for_disabled_supplier(self):
|
||||
frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 1)
|
||||
rfq = make_request_for_quotation(
|
||||
supplier_data=[{"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"}],
|
||||
do_not_save=True,
|
||||
)
|
||||
self.assertRaises(PartyDisabled, rfq.save)
|
||||
|
||||
frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 0)
|
||||
rfq.save()
|
||||
|
||||
def test_rfq_status_lifecycle(self):
|
||||
rfq = make_request_for_quotation()
|
||||
self.assertEqual(rfq.status, "Submitted")
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Supplier",
|
||||
"link_filters": "[[\"Supplier\",\"disabled\",\"=\",0]]",
|
||||
"options": "Supplier",
|
||||
"reqd": 1
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@ from erpnext.exceptions import (
|
||||
)
|
||||
from erpnext.setup.doctype.brand.brand import get_brand_defaults
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock import get_warehouse_account_map
|
||||
from erpnext.stock import get_warehouse_account, get_warehouse_account_map
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||
from erpnext.stock.services.internal_transfer import StockInternalTransferService
|
||||
from erpnext.stock.stock_ledger import get_items_to_be_repost
|
||||
@@ -135,7 +135,9 @@ class StockController(AccountsController):
|
||||
def use_item_inventory_account(self):
|
||||
return frappe.get_cached_value("Company", self.company, "enable_item_wise_inventory_account")
|
||||
|
||||
def get_inventory_account_dict(self, row, inventory_account_map, warehouse_field=None):
|
||||
def get_inventory_account_dict(
|
||||
self, row, inventory_account_map, warehouse_field=None, *, raise_error=True
|
||||
):
|
||||
account_dict = frappe._dict()
|
||||
|
||||
if isinstance(row, dict):
|
||||
@@ -164,8 +166,15 @@ class StockController(AccountsController):
|
||||
if not warehouse:
|
||||
warehouse = self.get(warehouse_field)
|
||||
|
||||
if warehouse and warehouse in inventory_account_map:
|
||||
account_dict = inventory_account_map[warehouse]
|
||||
if warehouse:
|
||||
account_dict = inventory_account_map.get(warehouse)
|
||||
if not account_dict and raise_error:
|
||||
account = get_warehouse_account(frappe.get_cached_doc("Warehouse", warehouse))
|
||||
account_dict = frappe._dict(
|
||||
account=account,
|
||||
account_currency=frappe.get_cached_value("Account", account, "account_currency"),
|
||||
)
|
||||
inventory_account_map[warehouse] = account_dict
|
||||
|
||||
return account_dict
|
||||
|
||||
|
||||
@@ -938,8 +938,9 @@ class calculate_taxes_and_totals:
|
||||
item.net_amount = flt(
|
||||
item.net_amount + rounding_difference, item.precision("net_amount")
|
||||
)
|
||||
# net_amount went up by rounding_difference, so its discount share goes down
|
||||
item.distributed_discount_amount = flt(
|
||||
distributed_amount + rounding_difference,
|
||||
distributed_amount - rounding_difference,
|
||||
item.precision("distributed_discount_amount"),
|
||||
)
|
||||
net_total += rounding_difference
|
||||
|
||||
@@ -60,6 +60,30 @@ class TestTaxesAndTotals(ERPNextTestSuite):
|
||||
self.assertAlmostEqual(so.net_total, 1272.73, places=2)
|
||||
self.assertEqual(so.grand_total, 1400)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
|
||||
def test_distributed_discount_amount_with_rounding_adjustment(self):
|
||||
so = make_sales_order(do_not_save=1)
|
||||
so.apply_discount_on = "Net Total"
|
||||
so.discount_amount = 10
|
||||
so.items[0].qty = 1
|
||||
so.items[0].rate = 100
|
||||
so.append("items", so.items[0].as_dict())
|
||||
so.append("items", so.items[0].as_dict())
|
||||
so.save()
|
||||
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
# the rounding adjustment lands on the second line
|
||||
self.assertAlmostEqual(so.items[1].net_amount, 96.66, places=2)
|
||||
self.assertAlmostEqual(so.items[1].distributed_discount_amount, 3.34, places=2)
|
||||
|
||||
for item in so.items:
|
||||
self.assertAlmostEqual(item.amount - item.distributed_discount_amount, item.net_amount, places=2)
|
||||
self.assertAlmostEqual(
|
||||
sum(i.distributed_discount_amount for i in so.items), so.discount_amount, places=2
|
||||
)
|
||||
self.assertEqual(so.net_total, 290)
|
||||
|
||||
def test_100_percent_discount_with_inclusive_tax(self):
|
||||
"""Test that 100% discount with inclusive taxes results in zero net_total"""
|
||||
so = make_sales_order(do_not_save=1)
|
||||
|
||||
@@ -46,3 +46,61 @@ class TestReactivity(ERPNextTestSuite):
|
||||
with self.subTest(field=field):
|
||||
self.assertIsNotNone(itm.get(field[0]))
|
||||
si.save().submit()
|
||||
|
||||
def test_item_change_clears_stale_item_details(self):
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
old_item = make_item(properties={"is_stock_item": 0, "stock_uom": "Nos"})
|
||||
new_item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 0,
|
||||
"stock_uom": "Kg",
|
||||
"weight_per_unit": 2,
|
||||
"weight_uom": "Kg",
|
||||
}
|
||||
)
|
||||
sales_order = make_sales_order(item_code=old_item.name, do_not_submit=True)
|
||||
|
||||
item = sales_order.items[0]
|
||||
self.assertEqual(item.uom, "Nos")
|
||||
row_state = (item.qty, item.warehouse, item.delivery_date)
|
||||
|
||||
sales_order.ignore_pricing_rule = 1
|
||||
item.weight_per_unit = 10
|
||||
item.weight_uom = "Nos"
|
||||
item.barcode = "OLD-BARCODE"
|
||||
item.pricing_rules = "OLD-PRICING-RULE"
|
||||
item.item_code = new_item.name
|
||||
sales_order.process_item_selection(item.idx, reset_item_details=True)
|
||||
|
||||
self.assertEqual(item.uom, "Kg")
|
||||
self.assertEqual(item.stock_uom, "Kg")
|
||||
self.assertEqual(item.conversion_factor, 1)
|
||||
self.assertEqual(item.weight_per_unit, 2)
|
||||
self.assertEqual(item.weight_uom, "Kg")
|
||||
self.assertIsNone(item.barcode)
|
||||
self.assertFalse(item.pricing_rules)
|
||||
self.assertEqual((item.qty, item.warehouse, item.delivery_date), row_state)
|
||||
|
||||
def test_programmatic_item_selection_preserves_explicit_uom(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 0,
|
||||
"stock_uom": "Kg",
|
||||
"sales_uom": "Nos",
|
||||
"weight_per_unit": 2,
|
||||
"weight_uom": "Kg",
|
||||
},
|
||||
uoms=[{"uom": "Nos", "conversion_factor": 10}],
|
||||
)
|
||||
sales_invoice = create_sales_invoice(item_code=item.name, uom="Kg", do_not_save=True)
|
||||
|
||||
sales_invoice.process_item_selection(sales_invoice.items[0].idx)
|
||||
|
||||
self.assertEqual(sales_invoice.items[0].uom, "Kg")
|
||||
self.assertEqual(sales_invoice.items[0].conversion_factor, 1)
|
||||
self.assertEqual(sales_invoice.items[0].stock_qty, sales_invoice.items[0].qty)
|
||||
|
||||
@@ -12,6 +12,7 @@ from frappe.query_builder import DocType, Interval
|
||||
from frappe.query_builder.functions import Now
|
||||
from frappe.utils import flt, get_fullname
|
||||
|
||||
from erpnext.accounts.party import validate_party_frozen_disabled
|
||||
from erpnext.crm.utils import (
|
||||
CRMNote,
|
||||
copy_comments,
|
||||
@@ -132,6 +133,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
self.validate_item_details()
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_cust_name()
|
||||
self.validate_party()
|
||||
self.map_fields()
|
||||
self.validate_qty()
|
||||
self.set_exchange_rate()
|
||||
@@ -355,6 +357,10 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
return False
|
||||
return True
|
||||
|
||||
def validate_party(self) -> None:
|
||||
if self.opportunity_from == "Customer":
|
||||
validate_party_frozen_disabled(self.company, "Customer", self.party_name)
|
||||
|
||||
def validate_cust_name(self):
|
||||
if self.party_name:
|
||||
if self.opportunity_from == "Customer":
|
||||
|
||||
@@ -9,6 +9,7 @@ from erpnext.crm.doctype.lead.test_lead import make_lead
|
||||
from erpnext.crm.doctype.opportunity.mapper import make_quotation
|
||||
from erpnext.crm.doctype.opportunity.opportunity import auto_close_opportunity, get_item_details
|
||||
from erpnext.crm.utils import get_linked_communication_list
|
||||
from erpnext.exceptions import PartyDisabled
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -71,6 +72,23 @@ class TestOpportunity(ERPNextTestSuite):
|
||||
opportunity_doc = make_opportunity(with_items=1, rate=1100, qty=2)
|
||||
self.assertEqual(opportunity_doc.total, 2200)
|
||||
|
||||
def test_disabled_customer_not_allowed(self):
|
||||
frappe.db.set_value("Customer", "_Test Customer", "disabled", 1)
|
||||
|
||||
self.assertRaises(PartyDisabled, make_opportunity, with_items=0)
|
||||
|
||||
frappe.db.set_value("Customer", "_Test Customer", "disabled", 0)
|
||||
make_opportunity(with_items=0)
|
||||
|
||||
def test_disabled_lead_not_blocked(self):
|
||||
# Lead.disabled isn't enforced anywhere else (e.g. the Lead picker query only
|
||||
# excludes Converted leads), so it shouldn't block Opportunity creation either.
|
||||
lead_doc = make_lead()
|
||||
frappe.db.set_value("Lead", lead_doc.name, "disabled", 1)
|
||||
|
||||
opp_doc = make_opportunity(opportunity_from="Lead", lead=lead_doc.name)
|
||||
self.assertEqual(opp_doc.party_name, lead_doc.name)
|
||||
|
||||
def test_carry_forward_of_email_and_comments(self):
|
||||
frappe.db.set_single_value("CRM Settings", "carry_forward_communication_and_comments", 1)
|
||||
lead_doc = make_lead()
|
||||
|
||||
1970
erpnext/locale/ar.po
1970
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
1966
erpnext/locale/bg.po
1966
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
1980
erpnext/locale/bs.po
1980
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
1966
erpnext/locale/cs.po
1966
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
1970
erpnext/locale/da.po
1970
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
1970
erpnext/locale/de.po
1970
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
1972
erpnext/locale/eo.po
1972
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
1968
erpnext/locale/es.po
1968
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
2010
erpnext/locale/fa.po
2010
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
1966
erpnext/locale/fr.po
1966
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
1966
erpnext/locale/hi.po
1966
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
1974
erpnext/locale/hr.po
1974
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
1966
erpnext/locale/hu.po
1966
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
1966
erpnext/locale/id.po
1966
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
1966
erpnext/locale/it.po
1966
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
1972
erpnext/locale/ko.po
1972
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
1966
erpnext/locale/my.po
1966
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
1966
erpnext/locale/nb.po
1966
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
1970
erpnext/locale/nl.po
1970
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
1966
erpnext/locale/pl.po
1966
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
1966
erpnext/locale/pt.po
1966
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1966
erpnext/locale/ro.po
1966
erpnext/locale/ro.po
File diff suppressed because it is too large
Load Diff
1970
erpnext/locale/ru.po
1970
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
1966
erpnext/locale/sl.po
1966
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
1970
erpnext/locale/sr.po
1970
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1974
erpnext/locale/sv.po
1974
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
1970
erpnext/locale/th.po
1970
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
1970
erpnext/locale/tr.po
1970
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
1970
erpnext/locale/uz.po
1970
erpnext/locale/uz.po
File diff suppressed because it is too large
Load Diff
1970
erpnext/locale/vi.po
1970
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
18307
erpnext/locale/zh.po
18307
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
105112
erpnext/locale/zh_TW.po
105112
erpnext/locale/zh_TW.po
File diff suppressed because it is too large
Load Diff
@@ -158,9 +158,9 @@ def _item_query_filters(filters):
|
||||
|
||||
def _item_query_or_filters(txt, searchfields, query_filters):
|
||||
if not txt:
|
||||
return {}
|
||||
return []
|
||||
|
||||
or_filters = {s_field: ("like", f"%{txt}%") for s_field in searchfields}
|
||||
or_filters = [[s_field, "like", f"%{txt}%"] for s_field in searchfields]
|
||||
barcodes = frappe.get_all(
|
||||
"Item Barcode",
|
||||
fields=["parent as item_code"],
|
||||
@@ -169,7 +169,7 @@ def _item_query_or_filters(txt, searchfields, query_filters):
|
||||
)
|
||||
barcode_codes = [d.item_code for d in barcodes]
|
||||
if barcode_codes:
|
||||
or_filters["name"] = ("in", barcode_codes)
|
||||
or_filters.append(["name", "in", barcode_codes])
|
||||
return or_filters
|
||||
|
||||
|
||||
|
||||
@@ -594,6 +594,29 @@ class TestBOM(ERPNextTestSuite):
|
||||
self.assertNotEqual(len(test_items), len(filtered), msg="Item filtering showing excessive results")
|
||||
self.assertTrue(0 < len(filtered) <= 3, msg="Item filtering showing excessive results")
|
||||
|
||||
@timeout
|
||||
def test_bom_item_query_matches_item_code_colliding_with_another_barcode(self):
|
||||
item = make_item(
|
||||
"_Test BOM Query 2.5MM",
|
||||
{"is_stock_item": 1, "item_name": "_Test BOM Query Sheet", "description": "sheet"},
|
||||
)
|
||||
make_item(
|
||||
"_Test BOM Query Barcode Holder",
|
||||
{"is_stock_item": 1},
|
||||
barcode=f"90{item.name}90",
|
||||
)
|
||||
|
||||
results = item_query(
|
||||
doctype="Item",
|
||||
txt=item.name,
|
||||
searchfield="name",
|
||||
start=0,
|
||||
page_len=20,
|
||||
filters={"is_stock_item": 1},
|
||||
)
|
||||
|
||||
self.assertIn(item.name, [d[0] for d in results])
|
||||
|
||||
@timeout
|
||||
def test_exclude_exploded_items_from_bom(self):
|
||||
bom_no = get_default_bom()
|
||||
|
||||
@@ -208,13 +208,6 @@ frappe.ui.form.on("BOM Creator", {
|
||||
});
|
||||
|
||||
frappe.ui.form.on("BOM Creator Item", {
|
||||
item_code(frm, cdt, cdn) {
|
||||
let item = frappe.get_doc(cdt, cdn);
|
||||
if (item.item_code && item.is_root) {
|
||||
frappe.model.set_value(cdt, cdn, "fg_item", item.item_code);
|
||||
}
|
||||
},
|
||||
|
||||
do_not_explode(frm, cdt, cdn) {
|
||||
let item = frappe.get_doc(cdt, cdn);
|
||||
if (!item.do_not_explode) {
|
||||
@@ -237,6 +230,20 @@ frappe.ui.form.on("BOM Creator Item", {
|
||||
});
|
||||
|
||||
erpnext.bom.BomConfigurator = class BomConfigurator extends erpnext.TransactionController {
|
||||
item_code(doc, cdt, cdn) {
|
||||
if (cdt !== "BOM Creator Item") {
|
||||
return;
|
||||
}
|
||||
|
||||
let item = frappe.get_doc(cdt, cdn);
|
||||
if (item.item_code && item.is_root) {
|
||||
frappe.model.set_value(cdt, cdn, "fg_item", item.item_code);
|
||||
}
|
||||
|
||||
// BOM Creator does not support TransactionController's server-side item selection.
|
||||
return this.process_item_selection(doc, cdt, cdn);
|
||||
}
|
||||
|
||||
conversion_rate(doc) {
|
||||
if (this.frm.doc.currency === this.get_company_currency()) {
|
||||
this.frm.set_value("conversion_rate", 1.0);
|
||||
|
||||
@@ -597,8 +597,7 @@ frappe.ui.form.on("Job Card", {
|
||||
const has_remaining_qty = doc.for_quantity + doc.process_loss_qty > doc.total_completed_qty;
|
||||
const pending_transfer =
|
||||
has_items && doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty));
|
||||
const materials_ready =
|
||||
doc.skip_material_transfer || !pending_transfer || !doc.finished_good || !has_items;
|
||||
const materials_ready = doc.skip_material_transfer || doc.is_corrective_job_card || !pending_transfer;
|
||||
|
||||
let last_row = {};
|
||||
const has_sub_ops_or_pending_qty = doc.sub_operations?.length || doc.pending_qty > 0;
|
||||
|
||||
@@ -1692,6 +1692,7 @@ class JobCard(Document):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
|
||||
self.validate_docstatus()
|
||||
self.validate_transfer_qty()
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
@@ -1707,6 +1708,7 @@ class JobCard(Document):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
|
||||
self.validate_docstatus()
|
||||
self.validate_transfer_qty()
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
@@ -383,6 +383,31 @@ class TestJobCard(ERPNextTestSuite):
|
||||
# JC is Completed with excess transfer
|
||||
self.assertEqual(job_card.status, "Completed")
|
||||
|
||||
def test_job_card_actions_blocked_until_material_transfer(self):
|
||||
"Start and Complete must wait for the transfer when RMs move against Job Card."
|
||||
self.transfer_material_against = "Job Card"
|
||||
self.source_warehouse = "Stores - _TC"
|
||||
|
||||
self.generate_required_stock(self.work_order)
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
|
||||
|
||||
self.assertRaises(frappe.ValidationError, job_card.start_timer, start_time=now())
|
||||
self.assertRaises(frappe.ValidationError, job_card.complete_job_card, qty=2, for_quantity=2)
|
||||
|
||||
transfer_entry = make_stock_entry_from_jc(job_card.name)
|
||||
transfer_entry.insert()
|
||||
transfer_entry.submit()
|
||||
|
||||
job_card.reload()
|
||||
job_card.append("time_logs", {"from_time": "2024-03-01 08:00:00"})
|
||||
job_card.save()
|
||||
job_card.complete_job_card(
|
||||
qty=2, for_quantity=2, pending_qty=0, process_loss_qty=0, end_time="2024-03-01 09:00:00"
|
||||
)
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.total_completed_qty), 2)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 0})
|
||||
def test_job_card_excess_material_transfer_block(self):
|
||||
self.transfer_material_against = "Job Card"
|
||||
|
||||
@@ -312,6 +312,7 @@ def get_operation_details(name, work_order, parent_bom):
|
||||
for row in work_order.operations:
|
||||
if row.name == name:
|
||||
return {
|
||||
"idx": row.idx,
|
||||
"workstation": row.workstation,
|
||||
"workstation_type": row.workstation_type,
|
||||
"source_warehouse": row.source_warehouse,
|
||||
|
||||
@@ -34,6 +34,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle
|
||||
)
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.doctype.stock_entry import test_stock_entry
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import OperationsNotCompleteError
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.utils import get_bin
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -509,6 +510,18 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
for stock_entry in stock_entries:
|
||||
stock_entry.cancel()
|
||||
|
||||
@timeout(seconds=60)
|
||||
def test_manufacture_blocked_until_operations_completed(self):
|
||||
bom = frappe.get_doc(
|
||||
"BOM", {"docstatus": 1, "with_operations": 1, "company": "_Test Company", "has_variants": 0}
|
||||
)
|
||||
work_order = make_wo_order_test_record(
|
||||
item=bom.item, qty=1, bom_no=bom.name, source_warehouse="_Test Warehouse - _TC", skip_transfer=1
|
||||
)
|
||||
|
||||
stock_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1))
|
||||
self.assertRaises(OperationsNotCompleteError, stock_entry.insert)
|
||||
|
||||
def test_work_order_material_transferred_qty_with_process_loss(self):
|
||||
stock_entries = []
|
||||
item_code = make_item("_Test Item For Process Loss", {"is_stock_item": 1}).name
|
||||
|
||||
@@ -242,7 +242,6 @@ class Workstation(Document):
|
||||
for row in doc.time_logs:
|
||||
if not row.to_time:
|
||||
row.to_time = to_time
|
||||
row.time_in_mins = time_diff_in_hours(row.to_time, row.from_time) / 60
|
||||
row.completed_qty = qty
|
||||
|
||||
doc.save()
|
||||
|
||||
@@ -25,10 +25,12 @@ def fetch_exploded_bom_items(root_bom):
|
||||
recursive CTE -- replaces a query-per-node walk with a single query. UNION keeps it cycle-safe
|
||||
and fetches each sub-BOM's items only once even when it is reused across the tree."""
|
||||
bom_item = frappe.qb.DocType("BOM Item")
|
||||
child_bom = frappe.qb.DocType("BOM").as_("child_bom")
|
||||
tree = frappe.qb.Table("exploded_bom")
|
||||
fields = [
|
||||
bom_item.parent,
|
||||
bom_item.qty,
|
||||
bom_item.stock_qty,
|
||||
bom_item.bom_no,
|
||||
bom_item.item_code,
|
||||
bom_item.item_name,
|
||||
@@ -46,7 +48,11 @@ def fetch_exploded_bom_items(root_bom):
|
||||
.where(tree.bom_no != "")
|
||||
)
|
||||
rows = (
|
||||
frappe.qb.with_(seed + recursion, "exploded_bom", recursive=True).from_(tree).select(tree.star)
|
||||
frappe.qb.with_(seed + recursion, "exploded_bom", recursive=True)
|
||||
.from_(tree)
|
||||
.left_join(child_bom)
|
||||
.on(tree.bom_no == child_bom.name)
|
||||
.select(tree.star, child_bom.quantity.as_("child_bom_qty"))
|
||||
).run(as_dict=True)
|
||||
|
||||
children_map = defaultdict(list)
|
||||
@@ -71,7 +77,13 @@ def build_exploded_rows(bom, children_map, data, indent=0, qty=1):
|
||||
}
|
||||
)
|
||||
if item.bom_no:
|
||||
build_exploded_rows(item.bom_no, children_map, data, indent + 1, item.qty)
|
||||
build_exploded_rows(
|
||||
item.bom_no,
|
||||
children_map,
|
||||
data,
|
||||
indent + 1,
|
||||
qty * item.stock_qty / item.child_bom_qty,
|
||||
)
|
||||
|
||||
|
||||
def get_columns():
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
from erpnext.manufacturing.report.bom_explorer.bom_explorer import execute
|
||||
from erpnext.manufacturing.report.bom_explorer.bom_explorer import build_exploded_rows, execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -78,3 +78,55 @@ class TestBOMExplorer(ERPNextTestSuite):
|
||||
# The leaf belongs to the sub-assembly, so it is exploded one level deeper.
|
||||
self.assertEqual(rows_by_item[leaf_item]["indent"], 1)
|
||||
self.assertEqual(rows_by_item[leaf_item]["bom_level"], 1)
|
||||
|
||||
def test_nested_bom_uses_stock_qty_for_output_normalization(self):
|
||||
parent_bom = create_nested_bom(
|
||||
{"parent": {"sub": {"leaf": {}}}},
|
||||
prefix="_Test explorer converted quantity ",
|
||||
)
|
||||
sub_bom = frappe.get_doc("BOM", parent_bom.items[0].bom_no)
|
||||
|
||||
# The parent needs two boxes (20 units). The child BOM produces five units per batch.
|
||||
frappe.db.set_value("BOM", sub_bom.name, "quantity", 5)
|
||||
frappe.db.set_value("BOM Item", sub_bom.items[0].name, {"qty": 3, "stock_qty": 3})
|
||||
frappe.db.set_value(
|
||||
"BOM Item",
|
||||
parent_bom.items[0].name,
|
||||
{"qty": 2, "uom": "Box", "conversion_factor": 10, "stock_qty": 20},
|
||||
)
|
||||
|
||||
data = self.run_report(parent_bom.name)
|
||||
rows_by_item = {row["item_code"]: row for row in data}
|
||||
|
||||
self.assertEqual(rows_by_item["_Test explorer converted quantity sub"]["qty"], 2)
|
||||
self.assertEqual(rows_by_item["_Test explorer converted quantity leaf"]["qty"], 12)
|
||||
|
||||
def test_nested_bom_multiplies_qty_at_every_level(self):
|
||||
children_map = {
|
||||
"root": [
|
||||
frappe._dict(
|
||||
item_code="parent",
|
||||
idx=1,
|
||||
bom_no="parent-bom",
|
||||
child_bom_qty=1,
|
||||
qty=8,
|
||||
stock_qty=8,
|
||||
)
|
||||
],
|
||||
"parent-bom": [
|
||||
frappe._dict(
|
||||
item_code="child",
|
||||
idx=1,
|
||||
bom_no="child-bom",
|
||||
child_bom_qty=1,
|
||||
qty=4,
|
||||
stock_qty=4,
|
||||
)
|
||||
],
|
||||
"child-bom": [frappe._dict(item_code="raw-material", idx=1, bom_no="", qty=2, stock_qty=2)],
|
||||
}
|
||||
data = []
|
||||
|
||||
build_exploded_rows("root", children_map, data)
|
||||
|
||||
self.assertEqual([row["qty"] for row in data], [8, 32, 64])
|
||||
|
||||
@@ -1326,13 +1326,7 @@ def make_order(selected_rows: str | list, company: str, warehouse: str | None =
|
||||
|
||||
def make_purchase_orders(purchase_orders, company, warehouse=None, mps=None):
|
||||
for (supplier, release_date), items in purchase_orders.items():
|
||||
po = frappe.new_doc("Purchase Order")
|
||||
po.supplier = supplier
|
||||
po.company = company
|
||||
po.mps = mps
|
||||
po.transaction_date = release_date
|
||||
po.set("items", [])
|
||||
|
||||
po_items = []
|
||||
for item in items:
|
||||
uom = item.purchase_uom or item.uom
|
||||
if not uom:
|
||||
@@ -1345,23 +1339,33 @@ def make_purchase_orders(purchase_orders, company, warehouse=None, mps=None):
|
||||
if flt(item.required_qty) < flt(item.min_order_qty):
|
||||
item.required_qty = item.min_order_qty
|
||||
|
||||
po.append(
|
||||
"items",
|
||||
po_items.append(
|
||||
{
|
||||
"item_code": item.item_code,
|
||||
"qty": item.required_qty,
|
||||
"uom": uom,
|
||||
"schedule_date": item.delivery_date if item.delivery_date else today(),
|
||||
"warehouse": warehouse or item.default_warehouse,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if len(po.items) > 0:
|
||||
po.insert()
|
||||
frappe.msgprint(
|
||||
_("Purchase Order {0} created").format(frappe.bold(po.name)),
|
||||
alert=True,
|
||||
)
|
||||
if not po_items:
|
||||
continue
|
||||
|
||||
po = frappe.new_doc("Purchase Order")
|
||||
po.supplier = supplier
|
||||
po.company = company
|
||||
po.mps = mps
|
||||
po.transaction_date = release_date
|
||||
po.set("items", po_items)
|
||||
|
||||
po.run_method("set_missing_values")
|
||||
po.insert()
|
||||
|
||||
frappe.msgprint(
|
||||
_("Purchase Order {0} created").format(frappe.bold(po.name)),
|
||||
alert=True,
|
||||
)
|
||||
|
||||
|
||||
def make_work_orders(work_orders, company, warehouse=None, mps=None):
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, flt, today
|
||||
|
||||
from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.manufacturing.report.material_requirements_planning_report.material_requirements_planning_report import (
|
||||
execute,
|
||||
get_item_lead_time,
|
||||
make_order,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
WAREHOUSE = "_Test Warehouse - _TC"
|
||||
SUPPLIER = "_Test Supplier"
|
||||
TAX_TEMPLATE = "_Test Purchase Taxes and Charges Template - _TC"
|
||||
|
||||
|
||||
class TestMaterialRequirementsPlanningReport(ERPNextTestSuite):
|
||||
def test_manufacture_lead_time_is_not_int_truncated(self):
|
||||
@@ -28,3 +38,126 @@ class TestMaterialRequirementsPlanningReport(ERPNextTestSuite):
|
||||
lead_time = get_item_lead_time(item, "Manufacture")
|
||||
# 1440 / 7 + 2 = 207.714...; a truncating integer division on Postgres would give 207.
|
||||
self.assertAlmostEqual(float(lead_time), 1440 / 7 + 2, places=2)
|
||||
|
||||
def test_make_order_creates_draft_purchase_and_work_orders(self):
|
||||
plan = make_mrp_plan(self)
|
||||
|
||||
make_order(plan.rows, COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
purchase_order = get_created_order(plan.mps, "Purchase Order")
|
||||
self.assertEqual(purchase_order.docstatus, 0)
|
||||
self.assertEqual(purchase_order.supplier, SUPPLIER)
|
||||
self.assertEqual([d.item_code for d in purchase_order.items], [plan.rm_item])
|
||||
self.assertEqual(purchase_order.items[0].qty, plan.planned_qty * plan.rm_qty)
|
||||
|
||||
work_order = get_created_order(plan.mps, "Work Order")
|
||||
self.assertEqual(work_order.docstatus, 0)
|
||||
self.assertEqual(work_order.production_item, plan.fg_item)
|
||||
self.assertEqual(work_order.bom_no, plan.bom)
|
||||
self.assertEqual(work_order.qty, plan.planned_qty)
|
||||
|
||||
def test_purchase_order_gets_defaults_from_set_missing_values(self):
|
||||
plan = make_mrp_plan(self)
|
||||
make_tax_rule(tax_type="Purchase", purchase_tax_template=TAX_TEMPLATE, priority=1, save=1)
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Price",
|
||||
"item_code": plan.rm_item,
|
||||
"price_list": "Standard Buying",
|
||||
"price_list_rate": 100,
|
||||
}
|
||||
).insert()
|
||||
|
||||
make_order(plan.rows, COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
purchase_order = get_created_order(plan.mps, "Purchase Order")
|
||||
self.assertEqual(purchase_order.buying_price_list, "Standard Buying")
|
||||
self.assertEqual(purchase_order.items[0].rate, 100)
|
||||
template = frappe.get_doc("Purchase Taxes and Charges Template", TAX_TEMPLATE)
|
||||
self.assertEqual(purchase_order.taxes_and_charges, TAX_TEMPLATE)
|
||||
self.assertEqual([d.rate for d in purchase_order.taxes], [d.rate for d in template.taxes])
|
||||
|
||||
net_total = flt(purchase_order.net_total)
|
||||
self.assertEqual(
|
||||
purchase_order.grand_total, net_total + net_total * flt(template.taxes[0].rate) / 100
|
||||
)
|
||||
|
||||
|
||||
def make_mrp_plan(test_case, planned_qty=10, rm_qty=2):
|
||||
"""Build a finished good with a submitted BOM and an MPS demanding it, then return the
|
||||
report's own output rows -- the same payload the report's client sends to `make_order`."""
|
||||
rm_item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"is_purchase_item": 1,
|
||||
"item_defaults": [
|
||||
{"company": COMPANY, "default_warehouse": WAREHOUSE, "default_supplier": SUPPLIER}
|
||||
],
|
||||
}
|
||||
).name
|
||||
fg_item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"item_defaults": [{"company": COMPANY, "default_warehouse": WAREHOUSE}],
|
||||
}
|
||||
).name
|
||||
|
||||
# on_submit sets Item.default_bom, which is how the report finds the raw materials
|
||||
bom = make_bom(item=fg_item, raw_materials=[rm_item], rm_qty=rm_qty, rate=100).name
|
||||
|
||||
mps = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Master Production Schedule",
|
||||
"company": COMPANY,
|
||||
"posting_date": today(),
|
||||
"from_date": today(),
|
||||
"parent_warehouse": WAREHOUSE,
|
||||
"items": [
|
||||
{
|
||||
"item_code": fg_item,
|
||||
"warehouse": WAREHOUSE,
|
||||
"delivery_date": add_days(today(), 30),
|
||||
"planned_qty": planned_qty,
|
||||
"uom": frappe.get_cached_value("Item", fg_item, "stock_uom"),
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
# left in draft: on_submit enqueues MRP Log creation in a background job
|
||||
mps.insert()
|
||||
|
||||
_, data, _, _ = execute(
|
||||
frappe._dict(
|
||||
{
|
||||
"company": COMPANY,
|
||||
"from_date": today(),
|
||||
"to_date": add_days(today(), 90),
|
||||
"warehouse": WAREHOUSE,
|
||||
"mps": mps.name,
|
||||
"type_of_material": "All",
|
||||
"add_safety_stock": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# the report separates each finished good with a blank row
|
||||
rows = [row for row in data if row.get("item_code")]
|
||||
test_case.assertTrue(rows, msg="the report returned no rows to create orders from")
|
||||
|
||||
return frappe._dict(
|
||||
rm_item=rm_item,
|
||||
fg_item=fg_item,
|
||||
bom=bom,
|
||||
mps=mps.name,
|
||||
planned_qty=planned_qty,
|
||||
rm_qty=rm_qty,
|
||||
rows=rows,
|
||||
)
|
||||
|
||||
|
||||
def get_created_order(mps, doctype):
|
||||
names = frappe.get_all(doctype, filters={"mps": mps}, pluck="name")
|
||||
if len(names) != 1:
|
||||
frappe.throw(f"Expected exactly one {doctype} for {mps}, got {names}")
|
||||
|
||||
return frappe.get_doc(doctype, names[0])
|
||||
|
||||
@@ -510,3 +510,5 @@ erpnext.patches.v16_0.merge_seeded_item_group_root
|
||||
erpnext.patches.v16_0.set_stock_uom_in_job_card
|
||||
erpnext.patches.v16_0.set_work_order_requested_and_picked_qty
|
||||
erpnext.patches.v16_0.rename_italy_customer_name_fields
|
||||
erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status
|
||||
erpnext.patches.v16_0.recalculate_mixed_purchase_receipt_billing_status
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status import (
|
||||
exclude_purchase_order_items_with_invoice_created_receipts,
|
||||
get_candidate_purchase_order_items,
|
||||
)
|
||||
from erpnext.stock.doctype.purchase_receipt.services.billing_status import (
|
||||
update_billed_amount_based_on_po,
|
||||
update_billing_percentage,
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
purchase_order_items = get_candidate_purchase_order_items()
|
||||
if purchase_order_items:
|
||||
purchase_order_items = exclude_purchase_order_items_with_invoice_created_receipts(
|
||||
purchase_order_items
|
||||
)
|
||||
|
||||
if not purchase_order_items:
|
||||
return
|
||||
|
||||
updated_purchase_receipts = update_billed_amount_based_on_po(purchase_order_items)
|
||||
for purchase_receipt in set(updated_purchase_receipts):
|
||||
update_billing_percentage(frappe.get_doc("Purchase Receipt", purchase_receipt))
|
||||
@@ -0,0 +1,110 @@
|
||||
from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Count
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.stock.doctype.purchase_receipt.services.billing_status import (
|
||||
get_billed_amount_against_po,
|
||||
get_billed_amount_against_pr,
|
||||
get_purchase_receipts_against_po_details,
|
||||
update_billed_amount_based_on_po,
|
||||
update_billing_percentage,
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
purchase_order_items = get_affected_purchase_order_items()
|
||||
if not purchase_order_items:
|
||||
return
|
||||
|
||||
updated_purchase_receipts = update_billed_amount_based_on_po(purchase_order_items)
|
||||
for purchase_receipt in set(updated_purchase_receipts):
|
||||
update_billing_percentage(frappe.get_doc("Purchase Receipt", purchase_receipt))
|
||||
|
||||
|
||||
def get_affected_purchase_order_items() -> list[str]:
|
||||
purchase_order_items = get_candidate_purchase_order_items()
|
||||
if purchase_order_items:
|
||||
purchase_order_items = exclude_purchase_order_items_with_invoice_created_receipts(
|
||||
purchase_order_items
|
||||
)
|
||||
|
||||
if not purchase_order_items:
|
||||
return []
|
||||
|
||||
purchase_receipt_items = get_purchase_receipts_against_po_details(purchase_order_items)
|
||||
direct_billed_amounts = get_billed_amount_against_pr([item.name for item in purchase_receipt_items])
|
||||
po_billed_amounts = get_billed_amount_against_po(purchase_order_items)
|
||||
|
||||
current_billed_amounts = defaultdict(float)
|
||||
available_billed_amounts = defaultdict(float)
|
||||
for purchase_order_item, billed_details in po_billed_amounts.items():
|
||||
available_billed_amounts[purchase_order_item] = flt(billed_details["billed_amt"])
|
||||
|
||||
for item in purchase_receipt_items:
|
||||
current_billed_amounts[item.purchase_order_item] += flt(item.billed_amt)
|
||||
available_billed_amounts[item.purchase_order_item] += flt(direct_billed_amounts.get(item.name))
|
||||
|
||||
precision = frappe.get_precision("Purchase Receipt Item", "billed_amt") or 2
|
||||
return [
|
||||
purchase_order_item
|
||||
for purchase_order_item in purchase_order_items
|
||||
if flt(po_billed_amounts.get(purchase_order_item, {}).get("billed_amt")) > 0
|
||||
and flt(po_billed_amounts.get(purchase_order_item, {}).get("billed_qty")) > 0
|
||||
and flt(
|
||||
current_billed_amounts[purchase_order_item] - available_billed_amounts[purchase_order_item],
|
||||
precision,
|
||||
)
|
||||
> 0
|
||||
]
|
||||
|
||||
|
||||
def exclude_purchase_order_items_with_invoice_created_receipts(purchase_order_items: list[str]) -> list[str]:
|
||||
invoice_created_receipt_items = set(
|
||||
frappe.get_all(
|
||||
"Purchase Receipt Item",
|
||||
filters={
|
||||
"purchase_order_item": ("in", purchase_order_items),
|
||||
"purchase_invoice_item": ("is", "set"),
|
||||
"docstatus": 1,
|
||||
},
|
||||
pluck="purchase_order_item",
|
||||
)
|
||||
)
|
||||
return [item for item in purchase_order_items if item not in invoice_created_receipt_items]
|
||||
|
||||
|
||||
def get_candidate_purchase_order_items() -> list[str]:
|
||||
purchase_receipt = frappe.qb.DocType("Purchase Receipt")
|
||||
purchase_receipt_item = frappe.qb.DocType("Purchase Receipt Item")
|
||||
purchase_invoice = frappe.qb.DocType("Purchase Invoice")
|
||||
purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
|
||||
|
||||
purchase_order_items_with_multiple_receipts = (
|
||||
frappe.qb.from_(purchase_receipt_item)
|
||||
.inner_join(purchase_receipt)
|
||||
.on(purchase_receipt_item.parent == purchase_receipt.name)
|
||||
.select(purchase_receipt_item.purchase_order_item)
|
||||
.where(
|
||||
(purchase_receipt.docstatus == 1)
|
||||
& (purchase_receipt.is_return == 0)
|
||||
& purchase_receipt_item.purchase_order_item.isnotnull()
|
||||
)
|
||||
.groupby(purchase_receipt_item.purchase_order_item)
|
||||
.having(Count(purchase_receipt_item.name) > 1)
|
||||
)
|
||||
|
||||
return (
|
||||
frappe.qb.from_(purchase_invoice_item)
|
||||
.inner_join(purchase_invoice)
|
||||
.on(purchase_invoice_item.parent == purchase_invoice.name)
|
||||
.select(purchase_invoice_item.po_detail)
|
||||
.distinct()
|
||||
.where(
|
||||
(purchase_invoice.docstatus == 1)
|
||||
& (purchase_invoice.update_stock == 0)
|
||||
& purchase_invoice_item.pr_detail.isnull()
|
||||
& purchase_invoice_item.po_detail.isin(purchase_order_items_with_multiple_receipts)
|
||||
)
|
||||
).run(pluck=True)
|
||||
@@ -783,6 +783,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
method: "process_item_selection",
|
||||
args: {
|
||||
item_idx: item.idx,
|
||||
reset_item_details: true,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (!r.exc) {
|
||||
|
||||
@@ -12,6 +12,10 @@ $.extend(erpnext.queries, {
|
||||
return { query: "erpnext.controllers.queries.lead_query" };
|
||||
},
|
||||
|
||||
customer: function () {
|
||||
return { filters: { disabled: 0 } };
|
||||
},
|
||||
|
||||
item: function (filters) {
|
||||
var args = { query: "erpnext.controllers.queries.item_query" };
|
||||
if (filters) args["filters"] = filters;
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
flex-direction: column;
|
||||
padding: var(--padding-lg);
|
||||
padding-top: var(--padding-md);
|
||||
overflow-y: auto;
|
||||
|
||||
> .item-details-header {
|
||||
display: flex;
|
||||
|
||||
@@ -632,8 +632,8 @@ def get_overdue_billing_threshold(customer: str, company: str) -> float:
|
||||
def get_customer_overdue_amount(customer: str, company: str) -> float:
|
||||
"""Amount the customer owes past its due date, in company currency.
|
||||
|
||||
Follows the same rule as the Overdue invoice status, so a customer is only
|
||||
blocked for what the invoice list already shows as overdue.
|
||||
Reads the Payment Ledger, the same source as `outstanding_amount`, so this agrees
|
||||
with the Overdue status the invoice list already shows.
|
||||
"""
|
||||
invoices = get_outstanding_invoices_for_customer(customer, company)
|
||||
if not invoices:
|
||||
@@ -646,27 +646,28 @@ def get_customer_overdue_amount(customer: str, company: str) -> float:
|
||||
def get_outstanding_invoices_for_customer(customer: str, company: str) -> list[frappe._dict]:
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
gl_entry = frappe.qb.DocType("GL Entry")
|
||||
ple = frappe.qb.DocType("Payment Ledger Entry")
|
||||
sales_invoice = frappe.qb.DocType("Sales Invoice")
|
||||
|
||||
# debit - credit is always booked in company currency, so this is comparable to the overdue limit
|
||||
outstanding = Sum(gl_entry.debit) - Sum(gl_entry.credit)
|
||||
# the Payment Ledger, not the GL, carries allocations made after submit (reconciled advances).
|
||||
# `amount` is booked in company currency, so this is comparable to the overdue limit.
|
||||
outstanding = Sum(ple.amount)
|
||||
|
||||
return (
|
||||
frappe.qb.from_(gl_entry)
|
||||
frappe.qb.from_(ple)
|
||||
.inner_join(sales_invoice)
|
||||
.on(sales_invoice.name == gl_entry.against_voucher)
|
||||
.on(sales_invoice.name == ple.against_voucher_no)
|
||||
.select(
|
||||
sales_invoice.name,
|
||||
sales_invoice.due_date,
|
||||
sales_invoice.base_grand_total,
|
||||
outstanding.as_("outstanding"),
|
||||
)
|
||||
.where(gl_entry.party_type == "Customer")
|
||||
.where(gl_entry.party == customer)
|
||||
.where(gl_entry.company == company)
|
||||
.where(gl_entry.is_cancelled == 0)
|
||||
.where(gl_entry.against_voucher_type == "Sales Invoice")
|
||||
.where(ple.party_type == "Customer")
|
||||
.where(ple.party == customer)
|
||||
.where(ple.company == company)
|
||||
.where(ple.delinked == 0)
|
||||
.where(ple.against_voucher_type == "Sales Invoice")
|
||||
.groupby(sales_invoice.name, sales_invoice.due_date, sales_invoice.base_grand_total)
|
||||
.having(outstanding > 0)
|
||||
).run(as_dict=True)
|
||||
|
||||
@@ -438,6 +438,37 @@ class TestCustomer(ERPNextTestSuite):
|
||||
pe.submit()
|
||||
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline)
|
||||
|
||||
def test_get_customer_overdue_amount_ignores_advance_reconciled_after_submit(self):
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
baseline = get_customer_overdue_amount("_Test Customer", "_Test Company")
|
||||
|
||||
# advance received before the invoice exists, so it carries no reference row
|
||||
pe = create_payment_entry(
|
||||
company="_Test Company",
|
||||
party_type="Customer",
|
||||
party="_Test Customer",
|
||||
payment_type="Receive",
|
||||
paid_from="Debtors - _TC",
|
||||
paid_to="Cash - _TC",
|
||||
paid_amount=800,
|
||||
)
|
||||
pe.posting_date = add_days(nowdate(), -60)
|
||||
pe.submit()
|
||||
|
||||
si = create_sales_invoice(qty=1, rate=800, posting_date=add_days(nowdate(), -30))
|
||||
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 800)
|
||||
|
||||
reconcile_payment_against_invoice(pe, si)
|
||||
|
||||
# reconciliation settles the invoice without re-tagging the payment's GL entries, so an
|
||||
# overdue amount read off the GL would still count the full 800 here
|
||||
si.reload()
|
||||
self.assertEqual(si.outstanding_amount, 0)
|
||||
self.assertEqual(si.status, "Paid")
|
||||
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline)
|
||||
|
||||
def test_overdue_billing_threshold_on_submit(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
@@ -632,6 +663,27 @@ def set_credit_limit(customer, company, credit_limit):
|
||||
customer.credit_limits[-1].db_insert()
|
||||
|
||||
|
||||
def reconcile_payment_against_invoice(payment_entry, sales_invoice):
|
||||
"""Allocate an unlinked payment against an invoice through the reconciliation tool."""
|
||||
pr = frappe.get_doc(
|
||||
doctype="Payment Reconciliation",
|
||||
company=sales_invoice.company,
|
||||
party_type="Customer",
|
||||
party=sales_invoice.customer,
|
||||
receivable_payable_account=sales_invoice.debit_to,
|
||||
)
|
||||
pr.get_unreconciled_entries()
|
||||
pr.allocate_entries(
|
||||
frappe._dict(
|
||||
{
|
||||
"invoices": [d.as_dict() for d in pr.invoices if d.invoice_number == sales_invoice.name],
|
||||
"payments": [d.as_dict() for d in pr.payments if d.reference_name == payment_entry.name],
|
||||
}
|
||||
)
|
||||
)
|
||||
pr.reconcile()
|
||||
|
||||
|
||||
def set_overdue_billing_threshold(customer, company, threshold):
|
||||
customer = frappe.get_doc("Customer", customer)
|
||||
for d in customer.credit_limits:
|
||||
|
||||
@@ -421,6 +421,13 @@ def make_delivery_note(
|
||||
return target_doc
|
||||
|
||||
|
||||
def get_qty_net_of_returns(so_item) -> float:
|
||||
"""Return the ordered quantity billable after returns and re-deliveries."""
|
||||
qty = flt(so_item.qty)
|
||||
|
||||
return min(qty, max(qty - flt(so_item.returned_qty), flt(so_item.delivered_qty)))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_sales_invoice(
|
||||
source_name: str,
|
||||
@@ -434,10 +441,40 @@ def make_sales_invoice(
|
||||
|
||||
# 0 qty is accepted, as the qty is uncertain for some items
|
||||
has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
|
||||
billed_qty_by_item = None
|
||||
pending_qty_by_item = {}
|
||||
|
||||
def is_unit_price_row(source):
|
||||
return has_unit_price_items and source.qty == 0
|
||||
|
||||
def get_billed_qty_by_item():
|
||||
nonlocal billed_qty_by_item
|
||||
|
||||
if billed_qty_by_item is None:
|
||||
invoice_item = frappe.qb.DocType("Sales Invoice Item")
|
||||
sales_order_item = frappe.qb.DocType("Sales Order Item")
|
||||
rows = (
|
||||
frappe.qb.from_(invoice_item)
|
||||
.inner_join(sales_order_item)
|
||||
.on(invoice_item.so_detail == sales_order_item.name)
|
||||
.select(invoice_item.so_detail, Sum(invoice_item.qty).as_("qty"))
|
||||
.where((invoice_item.docstatus == 1) & (sales_order_item.parent == source_name))
|
||||
.groupby(invoice_item.so_detail)
|
||||
).run(as_dict=True)
|
||||
billed_qty_by_item = {row.so_detail: flt(row.qty) for row in rows}
|
||||
|
||||
return billed_qty_by_item
|
||||
|
||||
def get_pending_qty(source):
|
||||
if source.name not in pending_qty_by_item:
|
||||
billable_qty = get_qty_net_of_returns(source)
|
||||
if source.qty and source.billed_amt:
|
||||
billable_qty -= get_billed_qty_by_item().get(source.name, 0)
|
||||
|
||||
pending_qty_by_item[source.name] = max(flt(billable_qty, source.precision("qty")), 0)
|
||||
|
||||
return pending_qty_by_item[source.name]
|
||||
|
||||
def postprocess(source, target):
|
||||
set_missing_values(source, target)
|
||||
# Get the advance paid Journal Entries in Sales Invoice Advance
|
||||
@@ -476,15 +513,6 @@ def make_sales_invoice(
|
||||
target.debit_to = get_party_account("Customer", source.customer, source.company)
|
||||
|
||||
def update_item(source, target, source_parent):
|
||||
def get_billed_qty(so_item_name):
|
||||
table = frappe.qb.DocType("Sales Invoice Item")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.qty).as_("qty"))
|
||||
.where((table.docstatus == 1) & (table.so_detail == so_item_name))
|
||||
)
|
||||
return query.run(pluck="qty")[0] or 0
|
||||
|
||||
if source_parent.has_unit_price_items:
|
||||
# 0 Amount rows (as seen in Unit Price Items) should be mapped as it is
|
||||
pending_amount = flt(source.amount) - flt(source.billed_amt)
|
||||
@@ -493,11 +521,7 @@ def make_sales_invoice(
|
||||
target.amount = flt(source.amount) - flt(source.billed_amt)
|
||||
|
||||
target.base_amount = target.amount * flt(source_parent.conversion_rate)
|
||||
target.qty = (
|
||||
source.qty - get_billed_qty(source.name)
|
||||
if (source.qty and source.billed_amt)
|
||||
else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty)
|
||||
)
|
||||
target.qty = source.qty if is_unit_price_row(source) else get_pending_qty(source)
|
||||
|
||||
if source_parent.project:
|
||||
target.cost_center = frappe.db.get_value("Project", source_parent.project, "cost_center")
|
||||
@@ -575,13 +599,17 @@ def make_sales_invoice(
|
||||
"parent": "sales_order",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: (
|
||||
"condition": lambda doc: not args.get("skip_item_mapping")
|
||||
and select_item(doc)
|
||||
and (
|
||||
True
|
||||
if is_unit_price_row(doc)
|
||||
else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)))
|
||||
)
|
||||
and select_item(doc)
|
||||
and not args.get("skip_item_mapping"),
|
||||
else (
|
||||
doc.qty
|
||||
and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))
|
||||
and get_pending_qty(doc) > 0
|
||||
)
|
||||
),
|
||||
},
|
||||
"Sales Taxes and Charges": {
|
||||
"doctype": "Sales Taxes and Charges",
|
||||
|
||||
@@ -238,6 +238,9 @@ class SalesOrder(SellingController):
|
||||
|
||||
validate_coupon_code(self.coupon_code)
|
||||
|
||||
if not self.get("is_subcontracted"):
|
||||
SalesOrderStockReservation(self).enable_auto_reserve_stock()
|
||||
|
||||
make_packing_list(self)
|
||||
|
||||
self.validate_with_previous_doc()
|
||||
@@ -247,8 +250,6 @@ class SalesOrder(SellingController):
|
||||
StatusService(self).set_default_statuses()
|
||||
|
||||
self.reset_default_field_value("set_warehouse", "items", "warehouse")
|
||||
if not self.get("is_subcontracted"):
|
||||
SalesOrderStockReservation(self).enable_auto_reserve_stock()
|
||||
|
||||
def set_has_unit_price_items(self):
|
||||
"""
|
||||
|
||||
@@ -288,6 +288,95 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
si1 = make_sales_invoice(so.name)
|
||||
self.assertEqual(len(si1.get("items")), 0)
|
||||
|
||||
def test_make_sales_invoice_after_return_and_redelivery(self):
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
|
||||
|
||||
so = make_sales_order(qty=10, rate=100)
|
||||
dn = create_dn_against_so(so.name, 10)
|
||||
|
||||
dn_return = frappe.get_doc(make_sales_return(dn.name).as_dict())
|
||||
dn_return.insert()
|
||||
dn_return.submit()
|
||||
|
||||
self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0)
|
||||
|
||||
create_dn_against_so(so.name, 10)
|
||||
|
||||
so.load_from_db()
|
||||
item = so.get("items")[0]
|
||||
self.assertEqual(item.delivered_qty, 10)
|
||||
self.assertEqual(item.returned_qty, 10)
|
||||
|
||||
si = make_sales_invoice(so.name)
|
||||
self.assertEqual(si.get("items")[0].qty, 10)
|
||||
|
||||
def test_make_sales_invoice_bills_ordered_qty_for_partial_delivery(self):
|
||||
so = make_sales_order(qty=10, rate=100)
|
||||
create_dn_against_so(so.name, 4)
|
||||
|
||||
si = make_sales_invoice(so.name)
|
||||
self.assertEqual(si.get("items")[0].qty, 10)
|
||||
|
||||
def test_make_sales_invoice_after_partial_billing_return_and_redelivery(self):
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
|
||||
|
||||
so = make_sales_order(qty=10, rate=100)
|
||||
dn = create_dn_against_so(so.name, 10)
|
||||
|
||||
si = make_sales_invoice(so.name)
|
||||
si.get("items")[0].qty = 4
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
dn_return = frappe.get_doc(make_sales_return(dn.name).as_dict())
|
||||
dn_return.insert()
|
||||
dn_return.submit()
|
||||
create_dn_against_so(so.name, 5)
|
||||
|
||||
so.load_from_db()
|
||||
item = so.get("items")[0]
|
||||
self.assertEqual(item.delivered_qty, 5)
|
||||
self.assertEqual(item.returned_qty, 10)
|
||||
self.assertEqual(item.billed_amt, 400)
|
||||
|
||||
pending_invoice = make_sales_invoice(so.name)
|
||||
self.assertEqual(pending_invoice.get("items")[0].qty, 1)
|
||||
pending_invoice.insert()
|
||||
pending_invoice.submit()
|
||||
|
||||
so.load_from_db()
|
||||
self.assertEqual(so.get("items")[0].billed_amt, 500)
|
||||
|
||||
def test_make_sales_invoice_after_partial_billing_multiple_items(self):
|
||||
so = make_sales_order(
|
||||
item_list=[
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"qty": 10,
|
||||
"rate": 100,
|
||||
},
|
||||
{
|
||||
"item_code": "_Test FG Item",
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"qty": 10,
|
||||
"rate": 100,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
si = make_sales_invoice(so.name)
|
||||
si.get("items")[0].qty = 4
|
||||
si.get("items")[1].qty = 6
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
pending_invoice = make_sales_invoice(so.name)
|
||||
self.assertEqual(
|
||||
{item.so_detail: item.qty for item in pending_invoice.get("items")},
|
||||
{so.get("items")[0].name: 6, so.get("items")[1].name: 4},
|
||||
)
|
||||
|
||||
def test_so_billed_amount_against_return_entry(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
|
||||
|
||||
|
||||
@@ -84,14 +84,7 @@ class SellingSettings(Document):
|
||||
]:
|
||||
frappe.db.set_default(key, self.get(key, ""))
|
||||
|
||||
from erpnext.utilities.naming import set_by_naming_series
|
||||
|
||||
set_by_naming_series(
|
||||
"Customer",
|
||||
"customer_name",
|
||||
self.get("cust_master_name") == "Naming Series",
|
||||
hide_name_field=False,
|
||||
)
|
||||
self.update_customer_naming_settings()
|
||||
|
||||
self.validate_fallback_to_default_price_list()
|
||||
|
||||
@@ -101,6 +94,19 @@ class SellingSettings(Document):
|
||||
if old_doc and old_doc.enable_utm != self.enable_utm:
|
||||
toggle_utm_analytics_section(not self.enable_utm)
|
||||
|
||||
def update_customer_naming_settings(self):
|
||||
if not self.has_value_changed("cust_master_name"):
|
||||
return
|
||||
|
||||
from erpnext.utilities.naming import set_by_naming_series
|
||||
|
||||
set_by_naming_series(
|
||||
"Customer",
|
||||
"customer_name",
|
||||
self.get("cust_master_name") == "Naming Series",
|
||||
hide_name_field=False,
|
||||
)
|
||||
|
||||
def validate_fallback_to_default_price_list(self):
|
||||
if (
|
||||
self.fallback_to_default_price_list
|
||||
@@ -119,6 +125,9 @@ class SellingSettings(Document):
|
||||
)
|
||||
|
||||
def toggle_hide_tax_id(self):
|
||||
if not self.has_value_changed("hide_tax_id"):
|
||||
return
|
||||
|
||||
_hide_tax_id = cint(self.hide_tax_id)
|
||||
|
||||
# Make property setters to hide tax_id fields
|
||||
@@ -131,6 +140,9 @@ class SellingSettings(Document):
|
||||
)
|
||||
|
||||
def toggle_editable_rate_for_bundle_items(self):
|
||||
if not self.has_value_changed("editable_bundle_item_rates"):
|
||||
return
|
||||
|
||||
editable_bundle_item_rates = cint(self.editable_bundle_item_rates)
|
||||
|
||||
make_property_setter(
|
||||
@@ -143,6 +155,9 @@ class SellingSettings(Document):
|
||||
)
|
||||
|
||||
def toggle_discount_accounting_fields(self):
|
||||
if not self.has_value_changed("enable_discount_accounting"):
|
||||
return
|
||||
|
||||
enable_discount_accounting = cint(self.enable_discount_accounting)
|
||||
|
||||
make_property_setter(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -12,3 +14,47 @@ class TestSellingSettings(ERPNextTestSuite):
|
||||
# if setup was completed correctly
|
||||
default = frappe.db.get_single_value("Selling Settings", "maintain_same_rate_action")
|
||||
self.assertEqual("Stop", default)
|
||||
|
||||
def test_unrelated_change_does_not_update_customer_metadata(self):
|
||||
settings = frappe.get_single("Selling Settings")
|
||||
settings.allow_multiple_items = not settings.allow_multiple_items
|
||||
|
||||
with patch("erpnext.utilities.naming.set_by_naming_series") as set_by_naming_series:
|
||||
settings.save()
|
||||
|
||||
set_by_naming_series.assert_not_called()
|
||||
|
||||
def test_customer_metadata_updates_when_related_settings_change(self):
|
||||
settings = frappe.get_single("Selling Settings")
|
||||
settings.cust_master_name = (
|
||||
"Customer Name" if settings.cust_master_name == "Naming Series" else "Naming Series"
|
||||
)
|
||||
|
||||
with patch("erpnext.utilities.naming.set_by_naming_series") as set_by_naming_series:
|
||||
settings.save()
|
||||
|
||||
set_by_naming_series.assert_called_once()
|
||||
|
||||
def test_unrelated_change_does_not_rewrite_toggle_setters(self):
|
||||
settings = frappe.get_single("Selling Settings")
|
||||
settings.allow_multiple_items = not settings.allow_multiple_items
|
||||
|
||||
with patch(
|
||||
"erpnext.selling.doctype.selling_settings.selling_settings.make_property_setter"
|
||||
) as make_property_setter:
|
||||
settings.save()
|
||||
|
||||
make_property_setter.assert_not_called()
|
||||
|
||||
def test_toggle_setters_rewritten_when_related_settings_change(self):
|
||||
settings = frappe.get_single("Selling Settings")
|
||||
settings.hide_tax_id = not settings.hide_tax_id
|
||||
settings.editable_bundle_item_rates = not settings.editable_bundle_item_rates
|
||||
settings.enable_discount_accounting = not settings.enable_discount_accounting
|
||||
|
||||
with patch(
|
||||
"erpnext.selling.doctype.selling_settings.selling_settings.make_property_setter"
|
||||
) as make_property_setter:
|
||||
settings.save()
|
||||
|
||||
self.assertEqual(make_property_setter.call_count, 11)
|
||||
|
||||
@@ -84,6 +84,17 @@ erpnext.PointOfSale.ItemDetails = class {
|
||||
this.item_row = item;
|
||||
this.currency = this.events.get_frm().doc.currency;
|
||||
|
||||
if (item.has_serial_no == null || item.has_batch_no == null) {
|
||||
const r = await frappe.db.get_value("Item", item.item_code, [
|
||||
"has_serial_no",
|
||||
"has_batch_no",
|
||||
]);
|
||||
if (r && r.message) {
|
||||
item.has_serial_no = r.message.has_serial_no;
|
||||
item.has_batch_no = r.message.has_batch_no;
|
||||
}
|
||||
}
|
||||
|
||||
this.current_item = item;
|
||||
|
||||
this.render_dom(item);
|
||||
|
||||
@@ -9,6 +9,9 @@ from frappe.utils.data import comma_or
|
||||
from erpnext.selling.report.sales_partner_commission_summary.sales_partner_commission_summary import (
|
||||
SALES_TRANSACTION_DOCTYPES,
|
||||
)
|
||||
from erpnext.selling.report.sales_partner_transaction_summary.test_utils import (
|
||||
SalesPartnerTransactionSummaryAssertions,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -63,14 +66,15 @@ class SalesPartnerSummaryReportTestMixin(ERPNextTestSuite):
|
||||
|
||||
self.make_transaction_func = make_transaction_funcs[doctype]
|
||||
|
||||
make_stock_entry(
|
||||
item_code="_Test Item 2",
|
||||
qty=10,
|
||||
company="_Test Company",
|
||||
to_warehouse="_Test Warehouse - _TC",
|
||||
purpose="Material Receipt",
|
||||
posting_date="2026-01-01",
|
||||
)
|
||||
if doctype in {"Delivery Note", "POS Invoice"}:
|
||||
make_stock_entry(
|
||||
item_code="_Test Item 2",
|
||||
qty=10,
|
||||
company="_Test Company",
|
||||
to_warehouse="_Test Warehouse - _TC",
|
||||
purpose="Material Receipt",
|
||||
posting_date="2026-01-01",
|
||||
)
|
||||
|
||||
if doctype == "POS Invoice":
|
||||
POSInvoiceTestMixin.setUp(self)
|
||||
@@ -246,7 +250,9 @@ class SalesPartnerSummaryReportTestMixin(ERPNextTestSuite):
|
||||
self.returned_doc.submit()
|
||||
|
||||
|
||||
class TestSalesPartnerCommissionSummary(SalesPartnerSummaryReportTestMixin):
|
||||
class TestSalesPartnerSummaryReports(
|
||||
SalesPartnerSummaryReportTestMixin, SalesPartnerTransactionSummaryAssertions
|
||||
):
|
||||
def setUp(self):
|
||||
self.filters = {
|
||||
"company": "_Test Company",
|
||||
@@ -262,29 +268,33 @@ class TestSalesPartnerCommissionSummary(SalesPartnerSummaryReportTestMixin):
|
||||
def test_posting_date_column_label(self):
|
||||
self.assert_posting_date_label()
|
||||
|
||||
def test_sales_order_sp_commission_summary(self):
|
||||
def test_sales_order_sp_summaries(self):
|
||||
self.filters["doctype"] = "Sales Order"
|
||||
self.create_transactions(self.filters["doctype"])
|
||||
|
||||
self.assert_sales_partner_commission_summary_report()
|
||||
self.assert_sales_partner_transaction_summary_report()
|
||||
|
||||
def test_sales_invoice_sp_commission_summary(self):
|
||||
def test_sales_invoice_sp_summaries(self):
|
||||
self.filters["doctype"] = "Sales Invoice"
|
||||
self.create_transactions(self.filters["doctype"])
|
||||
|
||||
self.assert_sales_partner_commission_summary_report()
|
||||
self.assert_sales_partner_transaction_summary_report()
|
||||
|
||||
def test_delivery_note_sp_commission_summary(self):
|
||||
def test_delivery_note_sp_summaries(self):
|
||||
self.filters["doctype"] = "Delivery Note"
|
||||
self.create_transactions(self.filters["doctype"])
|
||||
|
||||
self.assert_sales_partner_commission_summary_report()
|
||||
self.assert_sales_partner_transaction_summary_report()
|
||||
|
||||
def test_pos_invoice_sp_commission_summary(self):
|
||||
def test_pos_invoice_sp_summaries(self):
|
||||
self.filters["doctype"] = "POS Invoice"
|
||||
self.create_transactions(self.filters["doctype"])
|
||||
|
||||
self.assert_sales_partner_commission_summary_report()
|
||||
self.assert_sales_partner_transaction_summary_report()
|
||||
|
||||
def assert_sales_partner_commission_summary_report(self):
|
||||
report_data = run(self.report_name, self.filters)
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
from frappe.desk.query_report import run
|
||||
|
||||
from erpnext.selling.report.sales_partner_commission_summary.test_sales_partner_commission_summary import (
|
||||
SalesPartnerSummaryReportTestMixin,
|
||||
)
|
||||
from erpnext.selling.report.sales_partner_transaction_summary.test_utils import (
|
||||
SalesPartnerTransactionSummaryAssertions,
|
||||
)
|
||||
|
||||
|
||||
class TestSalesPartnerTransactionSummary(SalesPartnerSummaryReportTestMixin):
|
||||
class TestSalesPartnerTransactionSummary(
|
||||
SalesPartnerSummaryReportTestMixin, SalesPartnerTransactionSummaryAssertions
|
||||
):
|
||||
def setUp(self):
|
||||
self.filters = {
|
||||
"company": "_Test Company",
|
||||
@@ -25,159 +28,8 @@ class TestSalesPartnerTransactionSummary(SalesPartnerSummaryReportTestMixin):
|
||||
def test_posting_date_column_label(self):
|
||||
self.assert_posting_date_label()
|
||||
|
||||
def test_sales_order_sp_transaction_summary(self):
|
||||
self.filters["doctype"] = "Sales Order"
|
||||
self.create_transactions(self.filters["doctype"])
|
||||
|
||||
self.assert_sales_partner_transaction_summary_report()
|
||||
|
||||
def test_sales_invoice_sp_transaction_summary(self):
|
||||
self.filters["doctype"] = "Sales Invoice"
|
||||
self.create_transactions(self.filters["doctype"])
|
||||
|
||||
self.assert_sales_partner_transaction_summary_report()
|
||||
|
||||
def test_delivery_note_sp_transaction_summary(self):
|
||||
self.filters["doctype"] = "Delivery Note"
|
||||
self.create_transactions(self.filters["doctype"])
|
||||
|
||||
self.assert_sales_partner_transaction_summary_report()
|
||||
|
||||
def test_pos_invoice_sp_transaction_summary(self):
|
||||
self.filters["doctype"] = "POS Invoice"
|
||||
self.create_transactions(self.filters["doctype"])
|
||||
|
||||
self.assert_sales_partner_transaction_summary_report()
|
||||
|
||||
def assert_sales_partner_transaction_summary_report(self):
|
||||
report_data = run(self.report_name, self.filters)
|
||||
|
||||
self.report_result = report_data.get("result")
|
||||
self.report_result_without_total_row = self.report_result[:-1]
|
||||
|
||||
self.assertIsNotNone(self.report_result_without_total_row)
|
||||
|
||||
self.assert_7pc_commission()
|
||||
self.assert_5pc_commission_with_multiple_items()
|
||||
self.assert_doc_with_no_sp()
|
||||
self.assert_doc_with_posting_date_out_of_range()
|
||||
self.assert_doc_with_revoked_commission()
|
||||
self.assert_doc_not_submitted()
|
||||
self.assert_doc_cancelled()
|
||||
self.assert_commission()
|
||||
|
||||
if self.filters["doctype"] != "Sales Order":
|
||||
self.assert_returned_doc()
|
||||
|
||||
def assert_7pc_commission(self):
|
||||
doc_name = self.seven_pc_doc.name
|
||||
|
||||
row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None)
|
||||
|
||||
self.assertIsNotNone(row)
|
||||
|
||||
self.assertEqual(row["customer"], "_Test Customer")
|
||||
self.assertEqual(row["item_code"], "_Test Item")
|
||||
self.assertEqual(row["item_group"], "_Test Item Group")
|
||||
self.assertEqual(row["amount"], 1000)
|
||||
self.assertEqual(row["commission_rate"], 7)
|
||||
self.assertEqual(row["commission"], 70)
|
||||
|
||||
def assert_5pc_commission_with_multiple_items(self):
|
||||
doc_name = self.five_pc_doc.name
|
||||
|
||||
row1 = next(
|
||||
(
|
||||
row
|
||||
for row in self.report_result_without_total_row
|
||||
if row.get("name") == doc_name and row.get("item_code") == "_Test Item"
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(row1)
|
||||
|
||||
row2 = next(
|
||||
(
|
||||
row
|
||||
for row in self.report_result_without_total_row
|
||||
if row.get("name") == doc_name and row.get("item_code") == "_Test Item 2"
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(row2)
|
||||
|
||||
self.assertEqual(row1["amount"], 120)
|
||||
self.assertEqual(row1["commission_rate"], 5)
|
||||
self.assertEqual(row1["commission"], 6)
|
||||
|
||||
self.assertEqual(row2["amount"], 120)
|
||||
self.assertEqual(row2["commission_rate"], 5)
|
||||
self.assertEqual(row2["commission"], 6)
|
||||
|
||||
def assert_doc_with_no_sp(self):
|
||||
doc_name = self.no_sp_doc.name
|
||||
|
||||
row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None)
|
||||
|
||||
self.assertIsNone(row)
|
||||
|
||||
def assert_doc_with_posting_date_out_of_range(self):
|
||||
doc_name = self.date_out_of_range_doc.name
|
||||
|
||||
row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None)
|
||||
|
||||
self.assertIsNone(row)
|
||||
|
||||
def assert_doc_with_revoked_commission(self):
|
||||
doc_name = self.revoked_comm_doc.name
|
||||
|
||||
row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None)
|
||||
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row["amount"], 800)
|
||||
self.assertEqual(row["commission_rate"], 7)
|
||||
self.assertEqual(row["commission"], 0)
|
||||
|
||||
def assert_doc_not_submitted(self):
|
||||
doc_name = self.doc_not_submitted.name
|
||||
|
||||
row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None)
|
||||
|
||||
self.assertIsNone(row)
|
||||
|
||||
def assert_doc_cancelled(self):
|
||||
doc_name = self.cancelled_doc.name
|
||||
|
||||
row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None)
|
||||
|
||||
self.assertIsNone(row)
|
||||
|
||||
def assert_commission(self):
|
||||
total_row = self.report_result[-1]
|
||||
|
||||
# Total Amount
|
||||
self.assertEqual(total_row[-4], 2040)
|
||||
|
||||
# Total Commission
|
||||
self.assertEqual(total_row[-1], 82)
|
||||
|
||||
def assert_returned_doc(self):
|
||||
doc_name = self.to_be_returned_doc.name
|
||||
returned_doc_name = self.returned_doc.name
|
||||
|
||||
outward_row = next(
|
||||
(row for row in self.report_result_without_total_row if row.get("name") == doc_name), None
|
||||
)
|
||||
inward_row = next(
|
||||
(row for row in self.report_result_without_total_row if row.get("name") == returned_doc_name),
|
||||
None,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(outward_row)
|
||||
self.assertIsNotNone(inward_row)
|
||||
|
||||
self.assertEqual(outward_row["amount"], 900)
|
||||
self.assertEqual(outward_row["commission"], 45)
|
||||
|
||||
self.assertEqual(inward_row["amount"], -900)
|
||||
self.assertEqual(inward_row["commission"], -45)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
from frappe.desk.query_report import run
|
||||
|
||||
|
||||
class SalesPartnerTransactionSummaryAssertions:
|
||||
def assert_sales_partner_transaction_summary_report(self):
|
||||
filters = self.filters.copy()
|
||||
filters["show_return_entries"] = 1
|
||||
report_data = run("Sales Partner Transaction Summary", filters)
|
||||
|
||||
self.transaction_report_result = report_data.get("result")
|
||||
self.transaction_report_result_without_total_row = self.transaction_report_result[:-1]
|
||||
|
||||
self.assertIsNotNone(self.transaction_report_result_without_total_row)
|
||||
|
||||
self.assert_transaction_7pc_commission()
|
||||
self.assert_transaction_5pc_commission_with_multiple_items()
|
||||
self.assert_transaction_doc_with_no_sp()
|
||||
self.assert_transaction_doc_with_posting_date_out_of_range()
|
||||
self.assert_transaction_doc_with_revoked_commission()
|
||||
self.assert_transaction_doc_not_submitted()
|
||||
self.assert_transaction_doc_cancelled()
|
||||
self.assert_transaction_commission()
|
||||
|
||||
if self.filters["doctype"] != "Sales Order":
|
||||
self.assert_transaction_returned_doc()
|
||||
|
||||
def assert_transaction_7pc_commission(self):
|
||||
row = self._get_transaction_report_row(self.seven_pc_doc.name)
|
||||
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row["customer"], "_Test Customer")
|
||||
self.assertEqual(row["item_code"], "_Test Item")
|
||||
self.assertEqual(row["item_group"], "_Test Item Group")
|
||||
self.assertEqual(row["amount"], 1000)
|
||||
self.assertEqual(row["commission_rate"], 7)
|
||||
self.assertEqual(row["commission"], 70)
|
||||
|
||||
def assert_transaction_5pc_commission_with_multiple_items(self):
|
||||
row1 = self._get_transaction_report_row(self.five_pc_doc.name, "_Test Item")
|
||||
self.assertIsNotNone(row1)
|
||||
|
||||
row2 = self._get_transaction_report_row(self.five_pc_doc.name, "_Test Item 2")
|
||||
self.assertIsNotNone(row2)
|
||||
|
||||
self.assertEqual(row1["amount"], 120)
|
||||
self.assertEqual(row1["commission_rate"], 5)
|
||||
self.assertEqual(row1["commission"], 6)
|
||||
|
||||
self.assertEqual(row2["amount"], 120)
|
||||
self.assertEqual(row2["commission_rate"], 5)
|
||||
self.assertEqual(row2["commission"], 6)
|
||||
|
||||
def assert_transaction_doc_with_no_sp(self):
|
||||
row = self._get_transaction_report_row(self.no_sp_doc.name)
|
||||
self.assertIsNone(row)
|
||||
|
||||
def assert_transaction_doc_with_posting_date_out_of_range(self):
|
||||
row = self._get_transaction_report_row(self.date_out_of_range_doc.name)
|
||||
self.assertIsNone(row)
|
||||
|
||||
def assert_transaction_doc_with_revoked_commission(self):
|
||||
row = self._get_transaction_report_row(self.revoked_comm_doc.name)
|
||||
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row["amount"], 800)
|
||||
self.assertEqual(row["commission_rate"], 7)
|
||||
self.assertEqual(row["commission"], 0)
|
||||
|
||||
def assert_transaction_doc_not_submitted(self):
|
||||
row = self._get_transaction_report_row(self.doc_not_submitted.name)
|
||||
self.assertIsNone(row)
|
||||
|
||||
def assert_transaction_doc_cancelled(self):
|
||||
row = self._get_transaction_report_row(self.cancelled_doc.name)
|
||||
self.assertIsNone(row)
|
||||
|
||||
def assert_transaction_commission(self):
|
||||
total_row = self.transaction_report_result[-1]
|
||||
|
||||
self.assertEqual(total_row[-4], 2040)
|
||||
self.assertEqual(total_row[-1], 82)
|
||||
|
||||
def assert_transaction_returned_doc(self):
|
||||
outward_row = self._get_transaction_report_row(self.to_be_returned_doc.name)
|
||||
inward_row = self._get_transaction_report_row(self.returned_doc.name)
|
||||
|
||||
self.assertIsNotNone(outward_row)
|
||||
self.assertIsNotNone(inward_row)
|
||||
self.assertEqual(outward_row["amount"], 900)
|
||||
self.assertEqual(outward_row["commission"], 45)
|
||||
self.assertEqual(inward_row["amount"], -900)
|
||||
self.assertEqual(inward_row["commission"], -45)
|
||||
|
||||
def _get_transaction_report_row(self, doc_name, item_code=None):
|
||||
return next(
|
||||
(
|
||||
row
|
||||
for row in self.transaction_report_result_without_total_row
|
||||
if row.get("name") == doc_name and (not item_code or row.get("item_code") == item_code)
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -303,6 +303,7 @@ erpnext.company.setup_queries = function (frm) {
|
||||
["round_off_account", { root_type: ["in", ["Expense", "Income"]] }],
|
||||
["round_off_for_opening", { root_type: "Liability", account_type: "Round Off for Opening" }],
|
||||
["write_off_account", { root_type: "Expense" }],
|
||||
["bank_charges_account", { root_type: "Expense" }],
|
||||
["default_deferred_expense_account", {}],
|
||||
["default_deferred_revenue_account", {}],
|
||||
["default_discount_account", {}],
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"default_receivable_account",
|
||||
"default_payable_account",
|
||||
"write_off_account",
|
||||
"bank_charges_account",
|
||||
"unrealized_profit_loss_account",
|
||||
"column_break0",
|
||||
"allow_account_creation_against_child_company",
|
||||
@@ -390,6 +391,15 @@
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "bank_charges_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Bank Charges Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "exchange_gain_loss_account",
|
||||
|
||||
@@ -49,6 +49,7 @@ class Company(NestedSet):
|
||||
asset_received_but_not_billed: DF.Link | None
|
||||
auto_err_frequency: DF.Literal["Daily", "Weekly", "Monthly"]
|
||||
auto_exchange_rate_revaluation: DF.Check
|
||||
bank_charges_account: DF.Link | None
|
||||
book_advance_payments_in_separate_party_account: DF.Check
|
||||
capital_work_in_progress_account: DF.Link | None
|
||||
chart_of_accounts: DF.Literal[None]
|
||||
@@ -368,6 +369,7 @@ class Company(NestedSet):
|
||||
["Stock Delivered But Not Billed Account", "stock_delivered_but_not_billed"],
|
||||
["Stock Adjustment Account", "stock_adjustment_account"],
|
||||
["Write Off Account", "write_off_account"],
|
||||
["Bank Charges Account", "bank_charges_account"],
|
||||
["Default Payment Discount Account", "default_discount_account"],
|
||||
["Unrealized Profit / Loss Account", "unrealized_profit_loss_account"],
|
||||
["Exchange Gain / Loss Account", "exchange_gain_loss_account"],
|
||||
@@ -520,6 +522,7 @@ class Company(NestedSet):
|
||||
)
|
||||
warehouse.flags.ignore_permissions = True
|
||||
warehouse.flags.ignore_mandatory = True
|
||||
warehouse.flags.ignore_inventory_account_validation = True
|
||||
warehouse.insert()
|
||||
|
||||
if wh_detail["is_group"]:
|
||||
@@ -789,6 +792,13 @@ class Company(NestedSet):
|
||||
|
||||
self.db_set("write_off_account", write_off_acct)
|
||||
|
||||
if not self.bank_charges_account:
|
||||
bank_charges_acct = frappe.db.get_value(
|
||||
"Account", {"account_name": _("Bank Charges"), "company": self.name, "is_group": 0}
|
||||
)
|
||||
|
||||
self.db_set("bank_charges_account", bank_charges_acct)
|
||||
|
||||
if not self.exchange_gain_loss_account:
|
||||
exchange_gain_loss_acct = frappe.db.get_value(
|
||||
"Account", {"account_name": _("Exchange Gain/Loss"), "company": self.name, "is_group": 0}
|
||||
|
||||
@@ -23,15 +23,20 @@ frappe.ui.form.on("Driver", {
|
||||
},
|
||||
|
||||
transporter: function (frm, cdt, cdn) {
|
||||
// this assumes that supplier's address has same title as supplier's name
|
||||
if (!frm.doc.transporter) return;
|
||||
frappe.db
|
||||
.get_doc("Address", null, { address_title: frm.doc.transporter })
|
||||
.then((r) => {
|
||||
frappe.model.set_value(cdt, cdn, "address", r.name);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
|
||||
const transporter = frm.doc.transporter;
|
||||
frappe.call({
|
||||
method: "frappe.contacts.doctype.address.address.get_default_address",
|
||||
args: {
|
||||
doctype: "Supplier",
|
||||
name: transporter,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (frm.doc.transporter === transporter) {
|
||||
frappe.model.set_value(cdt, cdn, "address", r.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ def boot_session(bootinfo):
|
||||
"enable_perpetual_inventory",
|
||||
"country",
|
||||
"exchange_gain_loss_account",
|
||||
"bank_charges_account",
|
||||
],
|
||||
limit_page_length=0, # intentionally unbounded: all companies are needed for boot
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ def get_warehouse_account_map(company=None):
|
||||
order_by="lft, rgt",
|
||||
):
|
||||
if not d.account:
|
||||
d.account = get_warehouse_account(d, warehouse_account)
|
||||
d.account = get_warehouse_account(d, warehouse_account, raise_error=False)
|
||||
|
||||
if d.account:
|
||||
d.account_currency = frappe.db.get_value("Account", d.account, "account_currency", cache=True)
|
||||
@@ -47,10 +47,13 @@ def get_warehouse_account_map(company=None):
|
||||
else:
|
||||
frappe.flags.warehouse_account_map = warehouse_account
|
||||
|
||||
return frappe.flags.warehouse_account_map.get(company) or frappe.flags.warehouse_account_map
|
||||
if company:
|
||||
return frappe.flags.warehouse_account_map.get(company, frappe._dict())
|
||||
|
||||
return frappe.flags.warehouse_account_map
|
||||
|
||||
|
||||
def get_warehouse_account(warehouse, warehouse_account=None):
|
||||
def get_warehouse_account(warehouse, warehouse_account=None, *, raise_error=True):
|
||||
account = warehouse.account
|
||||
if not account and warehouse.parent_warehouse:
|
||||
if warehouse_account:
|
||||
@@ -87,7 +90,7 @@ def get_warehouse_account(warehouse, warehouse_account=None):
|
||||
if len(inventory_accounts) == 1:
|
||||
account = inventory_accounts[0]
|
||||
|
||||
if not account and warehouse.company and not warehouse.is_group:
|
||||
if raise_error and not account and warehouse.company and not warehouse.is_group:
|
||||
frappe.throw(
|
||||
_("Please set Account in Warehouse {0} or Default Inventory Account in Company {1}").format(
|
||||
warehouse.name, warehouse.company
|
||||
|
||||
@@ -296,7 +296,18 @@ def get_batch_qty(
|
||||
def get_batches_by_oldest(item_code: str, warehouse: str):
|
||||
"""Returns the oldest batch and qty for the given item_code and warehouse"""
|
||||
batches = get_batch_qty(item_code=item_code, warehouse=warehouse)
|
||||
batches_dates = [[batch, frappe.get_value("Batch", batch.batch_no, "expiry_date")] for batch in batches]
|
||||
if not batches:
|
||||
return []
|
||||
|
||||
expiry_dates = dict(
|
||||
frappe.get_all(
|
||||
"Batch",
|
||||
filters={"name": ["in", {batch.batch_no for batch in batches}]},
|
||||
fields=["name", "expiry_date"],
|
||||
as_list=True,
|
||||
)
|
||||
)
|
||||
batches_dates = [[batch, expiry_dates.get(batch.batch_no)] for batch in batches]
|
||||
batches_dates.sort(key=lambda tup: (tup[1] is None, tup[1]))
|
||||
return batches_dates
|
||||
|
||||
|
||||
@@ -258,7 +258,13 @@ def get_bin_details(bin_name):
|
||||
)
|
||||
|
||||
|
||||
def update_qty(bin_name, args):
|
||||
def update_qty_from_sle(bin_name, args):
|
||||
"""Refresh the Bin's quantity fields after an SLE has been processed.
|
||||
|
||||
Distinct from ``stock_balance.update_bin_qty``, which writes caller-supplied
|
||||
absolute values; this recomputes every quantity from the ledger and open
|
||||
documents.
|
||||
"""
|
||||
from erpnext.controllers.stock_controller import future_sle_exists
|
||||
|
||||
bin_details = get_bin_details(bin_name)
|
||||
|
||||
@@ -10,6 +10,7 @@ from erpnext.controllers.selling_controller import SellingController
|
||||
from erpnext.stock.doctype.delivery_note.services.billing_status import BillingStatusService
|
||||
from erpnext.stock.doctype.delivery_note.services.packing import PackingService
|
||||
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
|
||||
from erpnext.stock.utils import get_bin_qty_map
|
||||
|
||||
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
||||
|
||||
@@ -258,14 +259,6 @@ class DeliveryNote(SellingController):
|
||||
|
||||
super().before_print(settings)
|
||||
|
||||
def set_actual_qty(self):
|
||||
for d in self.get("items"):
|
||||
if d.item_code and d.warehouse:
|
||||
actual_qty = frappe.db.get_value(
|
||||
"Bin", {"item_code": d.item_code, "warehouse": d.warehouse}, "actual_qty"
|
||||
)
|
||||
d.actual_qty = flt(actual_qty) or 0
|
||||
|
||||
def so_required(self):
|
||||
"""check in manage account if sales order required or not"""
|
||||
if frappe.get_single_value("Selling Settings", "so_required") == "Yes":
|
||||
@@ -404,28 +397,14 @@ class DeliveryNote(SellingController):
|
||||
if not (self.get("_action") and self._action != "update_after_submit"):
|
||||
return
|
||||
|
||||
warehouse_item_codes = {}
|
||||
for d in self.get("items") + self.get("packed_items"):
|
||||
warehouse_item_codes.setdefault(d.warehouse, set()).add(d.item_code)
|
||||
|
||||
if not warehouse_item_codes:
|
||||
return
|
||||
|
||||
bin_map = {}
|
||||
for warehouse, item_codes in warehouse_item_codes.items():
|
||||
for b in frappe.get_all(
|
||||
"Bin",
|
||||
filters={"item_code": ["in", item_codes], "warehouse": warehouse},
|
||||
fields=["item_code", "actual_qty", "projected_qty"],
|
||||
):
|
||||
bin_map[(b.item_code, warehouse)] = b
|
||||
bin_qty_map = get_bin_qty_map(self.get("items") + self.get("packed_items"))
|
||||
|
||||
for d in self.get("items"):
|
||||
bin_data = bin_map.get((d.item_code, d.warehouse))
|
||||
bin_data = bin_qty_map.get((d.item_code, d.warehouse))
|
||||
d.actual_qty = bin_data.actual_qty if bin_data else None
|
||||
|
||||
for d in self.get("packed_items"):
|
||||
bin_data = bin_map.get((d.item_code, d.warehouse))
|
||||
bin_data = bin_qty_map.get((d.item_code, d.warehouse))
|
||||
if bin_data:
|
||||
d.actual_qty = flt(bin_data.actual_qty)
|
||||
d.projected_qty = flt(bin_data.projected_qty)
|
||||
|
||||
@@ -56,16 +56,7 @@ class PackedItem(Document):
|
||||
warehouse: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
def set_actual_and_projected_qty(self):
|
||||
"Set actual and projected qty based on warehouse and item_code"
|
||||
_bin = frappe.db.get_value(
|
||||
"Bin",
|
||||
{"item_code": self.item_code, "warehouse": self.warehouse},
|
||||
["actual_qty", "projected_qty"],
|
||||
as_dict=True,
|
||||
)
|
||||
self.actual_qty = _bin.actual_qty if _bin else 0
|
||||
self.projected_qty = _bin.projected_qty if _bin else 0
|
||||
pass
|
||||
|
||||
|
||||
def make_packing_list(doc):
|
||||
|
||||
@@ -61,16 +61,26 @@ def update_billed_amount_based_on_po(po_details: list, update_modified: bool = T
|
||||
billed_amt_against_pr = flt(flt(billed_amt_against_po) * flt(pr_item.qty)) / flt(
|
||||
billed_qty_against_po
|
||||
)
|
||||
|
||||
# Deduct the amount and qty consumed by this PR so that the next PR
|
||||
# against the same PO Item does not get billed for the same amount again.
|
||||
po_billed_amt_details[pr_item.purchase_order_item]["billed_amt"] = (
|
||||
billed_amt_against_po - billed_amt_against_pr
|
||||
)
|
||||
po_billed_amt_details[pr_item.purchase_order_item]["billed_qty"] = (
|
||||
billed_qty_against_po - pr_item.qty
|
||||
)
|
||||
else:
|
||||
pending_to_bill = flt(pr_item.amount) - billed_amt_against_pr
|
||||
if pending_to_bill <= billed_amt_against_po:
|
||||
billed_amt_against_pr += pending_to_bill
|
||||
billed_amt_against_po -= pending_to_bill
|
||||
else:
|
||||
billed_amt_against_pr += billed_amt_against_po
|
||||
billed_amt_against_po = 0
|
||||
consumed_amt_against_po = min(pending_to_bill, billed_amt_against_po)
|
||||
billed_amt_against_pr += consumed_amt_against_po
|
||||
|
||||
po_billed_amt_details[pr_item.purchase_order_item]["billed_amt"] = billed_amt_against_po
|
||||
po_billed_amt_details[pr_item.purchase_order_item]["billed_amt"] = (
|
||||
billed_amt_against_po - consumed_amt_against_po
|
||||
)
|
||||
po_billed_amt_details[pr_item.purchase_order_item]["billed_qty"] = billed_qty_against_po * (
|
||||
1 - consumed_amt_against_po / billed_amt_against_po
|
||||
)
|
||||
|
||||
if pr_item.billed_amt != billed_amt_against_pr:
|
||||
# update existing doc if possible
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user