mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 23:39:28 +00:00
Compare commits
57 Commits
codex/back
...
v15.121.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df8b7f9648 | ||
|
|
0deed27cfe | ||
|
|
fdc48277a8 | ||
|
|
8635652c90 | ||
|
|
c7d865ef3b | ||
|
|
2c2a3e9f10 | ||
|
|
96357b4fdc | ||
|
|
13367a5c54 | ||
|
|
28918c8338 | ||
|
|
341089ce35 | ||
|
|
a8c6cec5a3 | ||
|
|
89b29eee94 | ||
|
|
b409ded2f6 | ||
|
|
69f38a04c8 | ||
|
|
05a1127587 | ||
|
|
940fda3327 | ||
|
|
95b203079a | ||
|
|
c752bb34a8 | ||
|
|
24cac30aa9 | ||
|
|
a64b78d283 | ||
|
|
4a3a2cbdc4 | ||
|
|
2ee5f6b1c4 | ||
|
|
1a0bf0bf6c | ||
|
|
57266ab474 | ||
|
|
945e825bee | ||
|
|
5d60bd9f54 | ||
|
|
0526834071 | ||
|
|
88f32f1d1b | ||
|
|
d707cb1e0e | ||
|
|
ac5b4d185b | ||
|
|
be176617cc | ||
|
|
942a1ddb2c | ||
|
|
866688931b | ||
|
|
f0d1887e6e | ||
|
|
7098602dcc | ||
|
|
32b56ac505 | ||
|
|
9f1bdba9a7 | ||
|
|
c630226846 | ||
|
|
d5b49cd66e | ||
|
|
b9c9b76f5b | ||
|
|
da0e3b5882 | ||
|
|
3d4198494b | ||
|
|
e6b4799b1a | ||
|
|
49eb2366cd | ||
|
|
fb2a4e5f98 | ||
|
|
0ce7917648 | ||
|
|
41038979ec | ||
|
|
88b6779062 | ||
|
|
b5f784612d | ||
|
|
457424f7a4 | ||
|
|
25ee3695f0 | ||
|
|
ff205da810 | ||
|
|
2980171007 | ||
|
|
c6c4815e8d | ||
|
|
82a85818c2 | ||
|
|
57a2be6b56 | ||
|
|
47f54a4725 |
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.112.0"
|
||||
__version__ = "15.121.2"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -223,7 +223,8 @@
|
||||
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
|
||||
"fieldname": "over_billing_allowance",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Over Billing Allowance (%)"
|
||||
"label": "Over Billing Allowance (%)",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
@@ -679,7 +680,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-14 13:12:47.895908",
|
||||
"modified": "2026-09-04 10:08:30.115003",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -421,8 +421,8 @@ cur_frm.cscript.update_totals = function (doc) {
|
||||
tc += flt(accounts[i].credit, precision("credit", accounts[i]));
|
||||
}
|
||||
doc = locals[doc.doctype][doc.name];
|
||||
doc.total_debit = td;
|
||||
doc.total_credit = tc;
|
||||
doc.total_debit = flt(td, precision("total_debit"));
|
||||
doc.total_credit = flt(tc, precision("total_credit"));
|
||||
doc.difference = flt(td - tc, precision("difference"));
|
||||
refresh_many(["total_debit", "total_credit", "difference"]);
|
||||
};
|
||||
|
||||
@@ -861,12 +861,14 @@ class JournalEntry(AccountsController):
|
||||
if d.debit and d.credit:
|
||||
frappe.throw(_("You cannot credit and debit same account at the same time"))
|
||||
|
||||
self.total_debit = flt(self.total_debit) + flt(d.debit, d.precision("debit"))
|
||||
self.total_credit = flt(self.total_credit) + flt(d.credit, d.precision("credit"))
|
||||
self.total_debit = flt(
|
||||
self.total_debit + flt(d.debit, d.precision("debit")), self.precision("total_debit")
|
||||
)
|
||||
self.total_credit = flt(
|
||||
self.total_credit + flt(d.credit, d.precision("credit")), self.precision("total_credit")
|
||||
)
|
||||
|
||||
self.difference = flt(self.total_debit, self.precision("total_debit")) - flt(
|
||||
self.total_credit, self.precision("total_credit")
|
||||
)
|
||||
self.difference = flt(self.total_debit - self.total_credit, self.precision("difference"))
|
||||
|
||||
def validate_multi_currency(self):
|
||||
alternate_currency = []
|
||||
|
||||
@@ -410,6 +410,59 @@ class TestJournalEntry(unittest.TestCase):
|
||||
|
||||
self.check_gl_entries()
|
||||
|
||||
def make_jv_with_fractional_totals(self):
|
||||
"""0.10 + 0.20 sums to 0.30000000000000004, the residue this guards against."""
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.posting_date = nowdate()
|
||||
jv.company = "_Test Company"
|
||||
jv.voucher_type = "Journal Entry"
|
||||
jv.remark = "test"
|
||||
for amount in (0.10, 0.20):
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Cash - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"debit_in_account_currency": amount,
|
||||
},
|
||||
)
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Bank - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"credit_in_account_currency": 0.30,
|
||||
},
|
||||
)
|
||||
jv.insert()
|
||||
return jv
|
||||
|
||||
def test_totals_are_rounded_to_precision(self):
|
||||
jv = self.make_jv_with_fractional_totals()
|
||||
jv.submit()
|
||||
|
||||
stored = frappe.db.get_value(
|
||||
"Journal Entry", jv.name, ["total_debit", "total_credit", "difference"], as_dict=True
|
||||
)
|
||||
self.assertEqual(jv.total_debit, flt(jv.total_debit, jv.precision("total_debit")))
|
||||
self.assertEqual(jv.total_credit, flt(jv.total_credit, jv.precision("total_credit")))
|
||||
self.assertEqual(jv.total_debit, stored.total_debit)
|
||||
self.assertEqual(jv.total_credit, stored.total_credit)
|
||||
self.assertEqual(jv.difference, stored.difference)
|
||||
|
||||
def test_update_after_submit_with_fractional_totals(self):
|
||||
"""An unrounded total is stored rounded, so updating a submitted entry used to throw."""
|
||||
jv = self.make_jv_with_fractional_totals()
|
||||
jv.submit()
|
||||
|
||||
jv.pay_to_recd_from = "_Test Supplier"
|
||||
jv.save()
|
||||
|
||||
self.assertEqual(jv.docstatus, 1)
|
||||
self.assertEqual(
|
||||
jv.pay_to_recd_from, frappe.db.get_value("Journal Entry", jv.name, "pay_to_recd_from")
|
||||
)
|
||||
|
||||
def test_jv_account_and_party_balance_with_cost_centre(self):
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
from erpnext.accounts.utils import get_balance_on
|
||||
|
||||
@@ -45,23 +45,27 @@ frappe.ui.form.on("Payment Entry", {
|
||||
},
|
||||
|
||||
setup: function (frm) {
|
||||
frm.set_query("paid_from", function () {
|
||||
frm.set_query("paid_from", function (doc) {
|
||||
frm.events.validate_company(frm);
|
||||
|
||||
var account_types = ["Pay", "Internal Transfer"].includes(frm.doc.payment_type)
|
||||
? ["Bank", "Cash"]
|
||||
: [frappe.boot.party_account_types[frm.doc.party_type]];
|
||||
let filters = {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
};
|
||||
|
||||
if (frm.doc.party_type == "Shareholder") {
|
||||
account_types.push("Equity");
|
||||
}
|
||||
if (doc.payment_type == "Internal Transfer" && doc.paid_to) {
|
||||
filters.name = ["!=", doc.paid_to];
|
||||
}
|
||||
|
||||
return {
|
||||
filters: {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
},
|
||||
filters,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -105,21 +109,25 @@ frappe.ui.form.on("Payment Entry", {
|
||||
}
|
||||
});
|
||||
|
||||
frm.set_query("paid_to", function () {
|
||||
frm.set_query("paid_to", function (doc) {
|
||||
frm.events.validate_company(frm);
|
||||
|
||||
var account_types = ["Receive", "Internal Transfer"].includes(frm.doc.payment_type)
|
||||
? ["Bank", "Cash"]
|
||||
: [frappe.boot.party_account_types[frm.doc.party_type]];
|
||||
let filters = {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
};
|
||||
if (frm.doc.party_type == "Shareholder") {
|
||||
account_types.push("Equity");
|
||||
}
|
||||
if (doc.payment_type == "Internal Transfer" && doc.paid_from) {
|
||||
filters.name = ["!=", doc.paid_from];
|
||||
}
|
||||
return {
|
||||
filters: {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
},
|
||||
filters,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ class PaymentEntry(AccountsController):
|
||||
self.set_liability_account()
|
||||
self.set_missing_ref_details(force=True)
|
||||
self.validate_payment_type()
|
||||
self.validate_internal_transfer_accounts()
|
||||
self.validate_party_details()
|
||||
self.set_exchange_rate()
|
||||
self.validate_mandatory()
|
||||
@@ -531,6 +532,10 @@ class PaymentEntry(AccountsController):
|
||||
if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
|
||||
frappe.throw(_("Payment Type must be one of Receive, Pay and Internal Transfer"))
|
||||
|
||||
def validate_internal_transfer_accounts(self):
|
||||
if self.payment_type == "Internal Transfer" and self.paid_from and self.paid_from == self.paid_to:
|
||||
frappe.throw(_("Paid From and Paid To accounts must be different for an Internal Transfer."))
|
||||
|
||||
def validate_party_details(self):
|
||||
if self.party and not frappe.db.exists(self.party_type, self.party):
|
||||
frappe.throw(_("{0} {1} does not exist").format(_(self.party_type), self.party))
|
||||
|
||||
@@ -733,6 +733,23 @@ class TestPaymentEntry(FrappeTestCase):
|
||||
|
||||
self.validate_gl_entries(pe.name, expected_gle)
|
||||
|
||||
def test_internal_transfer_rejects_same_account(self):
|
||||
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 Bank - _TC"
|
||||
pe.paid_amount = 100
|
||||
pe.received_amount = 100
|
||||
pe.reference_no = "same-account-transfer"
|
||||
pe.reference_date = nowdate()
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Paid From and Paid To accounts must be different",
|
||||
pe.insert,
|
||||
)
|
||||
|
||||
def test_payment_against_negative_sales_invoice(self):
|
||||
si1 = create_sales_invoice()
|
||||
|
||||
|
||||
@@ -375,9 +375,10 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
get_query_filters: {
|
||||
docstatus: 1,
|
||||
status: ["not in", ["Closed", "On Hold"]],
|
||||
per_billed: ["<", 99.99],
|
||||
company: me.frm.doc.company,
|
||||
},
|
||||
get_query_method:
|
||||
"erpnext.selling.doctype.sales_order.sales_order.get_potentially_billable_sales_orders",
|
||||
allow_child_item_selection: true,
|
||||
child_fieldname: "items",
|
||||
child_columns: ["item_code", "item_name", "qty", "amount", "billed_amt"],
|
||||
|
||||
@@ -1637,7 +1637,7 @@ class SalesInvoice(SellingController):
|
||||
|
||||
for payment_mode in self.payments:
|
||||
if skip_change_gl_entries and payment_mode.account == self.account_for_change_amount:
|
||||
payment_mode.base_amount -= flt(self.change_amount)
|
||||
payment_mode.base_amount -= flt(self.base_change_amount)
|
||||
|
||||
against_voucher = self.name
|
||||
if self.is_return and self.return_against and not self.update_outstanding_for_self:
|
||||
|
||||
@@ -1235,6 +1235,33 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
|
||||
frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 1)
|
||||
|
||||
def test_pos_change_amount_multi_currency_gl_entry(self):
|
||||
frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 0)
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.is_pos = 1
|
||||
si.currency = "USD"
|
||||
si.conversion_rate = 50
|
||||
si.party_account_currency = "USD"
|
||||
si.account_for_change_amount = "Cash - _TC"
|
||||
si.change_amount = 50
|
||||
si.base_change_amount = 2500
|
||||
si.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 150, "base_amount": 7500},
|
||||
)
|
||||
|
||||
gl_entries = []
|
||||
si.make_pos_gl_entries(gl_entries)
|
||||
|
||||
debtors_entry = next(entry for entry in gl_entries if entry["account"] == si.debit_to)
|
||||
cash_entry = next(entry for entry in gl_entries if entry["account"] == "Cash - _TC")
|
||||
|
||||
self.assertEqual(flt(debtors_entry["credit"]), 5000.0)
|
||||
self.assertEqual(flt(cash_entry["debit"]), 5000.0)
|
||||
|
||||
frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 1)
|
||||
|
||||
def validate_pos_gl_entry(self, si, pos, cash_amount, validate_without_change_gle=False):
|
||||
if validate_without_change_gle:
|
||||
cash_amount -= pos.change_amount
|
||||
|
||||
@@ -167,6 +167,7 @@ class ReceivablePayableReport:
|
||||
party_account=ple.account,
|
||||
posting_date=ple.posting_date,
|
||||
account_currency=ple.account_currency,
|
||||
cost_center=ple.cost_center,
|
||||
remarks=ple.remarks,
|
||||
invoiced=0.0,
|
||||
paid=0.0,
|
||||
|
||||
@@ -1175,6 +1175,28 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
|
||||
row = report[1][0]
|
||||
self.assertEqual(expected_data_after_payment, [row.voucher_no, row.cost_center, row.outstanding])
|
||||
|
||||
def test_cost_center_on_payment_before_invoice(self):
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Customer",
|
||||
"party": [self.customer],
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
|
||||
si.posting_date = add_days(today(), 1)
|
||||
si.due_date = si.posting_date
|
||||
si.payment_schedule[0].due_date = si.posting_date
|
||||
si.save().submit()
|
||||
|
||||
pe = self.create_payment_entry(si.name, do_not_submit=True)
|
||||
pe.cost_center = self.cost_center
|
||||
pe.save().submit()
|
||||
|
||||
row = next(row for row in execute(filters)[1] if row.voucher_no == pe.name)
|
||||
self.assertEqual(row.cost_center, pe.cost_center)
|
||||
|
||||
def test_payment_terms_template_filters(self):
|
||||
from erpnext.controllers.accounts_controller import get_payment_terms
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ def execute(filters=None):
|
||||
filters={
|
||||
"account_type": row["account_type"],
|
||||
"is_group": 0,
|
||||
"company": filters.company,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
@@ -180,13 +180,15 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
||||
columns[0]["fieldname"] = "sales_invoice"
|
||||
columns[0]["options"] = "Item"
|
||||
columns[0]["width"] = 300
|
||||
# removing Item Code and Item Name columns
|
||||
# removing the duplicate Item Code column and moving Item Name before Customer
|
||||
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
|
||||
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
|
||||
if supplier_master_name == "Supplier Name" and customer_master_name == "Customer Name":
|
||||
del columns[4:6]
|
||||
del columns[4]
|
||||
columns.insert(1, columns.pop(4))
|
||||
else:
|
||||
del columns[5:7]
|
||||
del columns[5]
|
||||
columns.insert(1, columns.pop(5))
|
||||
|
||||
total_base_amount = 0
|
||||
total_buying_amount = 0
|
||||
|
||||
@@ -231,6 +231,7 @@ class PurchaseOrder(BuyingController):
|
||||
self.doctype, self.supplier, self.company, self.inter_company_order_reference
|
||||
)
|
||||
self.reset_default_field_value("set_warehouse", "items", "warehouse")
|
||||
self.set_missing_terms()
|
||||
|
||||
def set_has_unit_price_items(self):
|
||||
"""
|
||||
|
||||
@@ -889,7 +889,8 @@ class ProductionPlan(Document):
|
||||
material_request_map = {}
|
||||
|
||||
for item in self.mr_items:
|
||||
if not item.quantity:
|
||||
qty_to_request = flt(item.quantity, item.precision("quantity"))
|
||||
if qty_to_request <= 0:
|
||||
continue
|
||||
|
||||
item_doc = frappe.get_cached_doc("Item", item.item_code)
|
||||
@@ -925,7 +926,7 @@ class ProductionPlan(Document):
|
||||
"from_warehouse": item.from_warehouse
|
||||
if material_request_type == "Material Transfer"
|
||||
else None,
|
||||
"qty": item.quantity,
|
||||
"qty": qty_to_request,
|
||||
"schedule_date": schedule_date,
|
||||
"warehouse": item.warehouse,
|
||||
"sales_order": item.sales_order,
|
||||
@@ -1339,18 +1340,44 @@ def get_material_request_items(
|
||||
bin_dict,
|
||||
consumed_qty,
|
||||
):
|
||||
required_qty = _required_qty_for_mr(
|
||||
row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty, include_safety_stock
|
||||
)
|
||||
required_qty = 0
|
||||
item_code = row.get("item_code")
|
||||
|
||||
if ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0:
|
||||
required_qty = flt(row.get("qty"))
|
||||
else:
|
||||
key = (item_code, warehouse)
|
||||
available_qty = flt(bin_dict.get("projected_qty", 0)) - consumed_qty[key]
|
||||
if available_qty > 0:
|
||||
required_qty = max(0, flt(row.get("qty")) - available_qty)
|
||||
consumed_qty[key] += min(flt(row.get("qty")), available_qty)
|
||||
else:
|
||||
required_qty = flt(row.get("qty"))
|
||||
|
||||
if doc.get("consider_minimum_order_qty") and required_qty > 0 and required_qty < row["min_order_qty"]:
|
||||
required_qty = row["min_order_qty"]
|
||||
|
||||
item_group_defaults = get_item_group_defaults(row.item_code, company)
|
||||
|
||||
if not row["purchase_uom"]:
|
||||
row["purchase_uom"] = row["stock_uom"]
|
||||
|
||||
if row["purchase_uom"] != row["stock_uom"]:
|
||||
if not (row["conversion_factor"] or frappe.flags.show_qty_in_stock_uom):
|
||||
frappe.throw(
|
||||
_("UOM Conversion factor ({0} -> {1}) not found for item: {2}").format(
|
||||
row["purchase_uom"], row["stock_uom"], row.item_code
|
||||
)
|
||||
)
|
||||
|
||||
required_qty = required_qty / row["conversion_factor"]
|
||||
|
||||
if frappe.db.get_value("UOM", row["purchase_uom"], "must_be_whole_number"):
|
||||
required_qty = ceil(required_qty)
|
||||
|
||||
if include_safety_stock:
|
||||
required_qty += flt(row["safety_stock"])
|
||||
|
||||
item_details = frappe.get_cached_value("Item", row.item_code, ["purchase_uom", "stock_uom"], as_dict=1)
|
||||
|
||||
conversion_factor = 1.0
|
||||
@@ -1458,38 +1485,6 @@ def get_sales_orders(self):
|
||||
return open_so
|
||||
|
||||
|
||||
def _required_qty_for_mr(
|
||||
row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty, include_safety_stock
|
||||
):
|
||||
safety_stock = flt(row["safety_stock"]) if include_safety_stock else 0
|
||||
qty = flt(row.get("qty"))
|
||||
projected_qty = max(0, flt(bin_dict.get("projected_qty"))) if not ignore_existing_ordered_qty else 0
|
||||
|
||||
key = (row.get("item_code"), warehouse)
|
||||
available_qty = projected_qty - consumed_qty[key]
|
||||
required_qty = max(0, qty - (available_qty - safety_stock))
|
||||
required_qty = _adjust_required_qty_for_uom(row, required_qty)
|
||||
consumed_qty[key] += qty - required_qty
|
||||
return required_qty
|
||||
|
||||
|
||||
def _adjust_required_qty_for_uom(row, required_qty):
|
||||
if not row["purchase_uom"]:
|
||||
row["purchase_uom"] = row["stock_uom"]
|
||||
|
||||
if row["purchase_uom"] != row["stock_uom"]:
|
||||
if not (row["conversion_factor"] or frappe.flags.show_qty_in_stock_uom):
|
||||
frappe.throw(
|
||||
_("UOM Conversion factor ({0} -> {1}) not found for item: {2}").format(
|
||||
row["purchase_uom"], row["stock_uom"], row.item_code
|
||||
)
|
||||
)
|
||||
|
||||
if frappe.db.get_value("UOM", row["purchase_uom"], "must_be_whole_number"):
|
||||
required_qty = ceil(required_qty)
|
||||
return required_qty
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_bin_details(row, company, for_warehouse=None, all_warehouse=False):
|
||||
if isinstance(row, str):
|
||||
|
||||
@@ -120,141 +120,23 @@ class TestProductionPlan(FrappeTestCase):
|
||||
self.assertEqual(len(quantities), len(pln.mr_items) - 1)
|
||||
self.assertNotIn(0, quantities)
|
||||
|
||||
def _plan_for_safety_stock(self, rm_item, qty_per_order):
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
|
||||
def test_production_plan_material_request_skips_zero_qty_items(self):
|
||||
pln = create_production_plan(item_code="Test Production Item 1")
|
||||
zero_qty_item, requested_item = pln.mr_items
|
||||
zero_qty_item.quantity = "0"
|
||||
|
||||
pln = create_production_plan(
|
||||
item_code=fg_item,
|
||||
ignore_existing_ordered_qty=0,
|
||||
do_not_save=1,
|
||||
skip_getting_mr_items=1,
|
||||
pln.make_material_request()
|
||||
|
||||
material_request_items = frappe.get_all(
|
||||
"Material Request Item",
|
||||
filters={"production_plan": pln.name},
|
||||
fields=["item_code", "qty"],
|
||||
)
|
||||
pln.get_items_from = "Sales Order"
|
||||
for _ in range(2):
|
||||
so = make_sales_order(item_code=fg_item, qty=qty_per_order)
|
||||
pln.append(
|
||||
"sales_orders",
|
||||
{
|
||||
"sales_order": so.name,
|
||||
"sales_order_date": so.transaction_date,
|
||||
"customer": so.customer,
|
||||
"grand_total": so.grand_total,
|
||||
},
|
||||
)
|
||||
pln.get_items()
|
||||
return pln
|
||||
|
||||
def test_safety_stock_added_once_for_repeated_raw_material(self):
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "safety_stock": 10, "valuation_rate": 100}).name
|
||||
make_stock_entry(item_code=rm_item, qty=100, rate=100, target="_Test Warehouse - _TC")
|
||||
|
||||
pln = self._plan_for_safety_stock(rm_item, qty_per_order=50)
|
||||
pln.include_safety_stock = 1
|
||||
|
||||
items = get_items_for_material_requests(pln.as_dict())
|
||||
quantities = sorted(flt(d.get("quantity")) for d in items if d.get("item_code") == rm_item)
|
||||
self.assertEqual(quantities, [0, 10])
|
||||
|
||||
def test_safety_stock_added_once_with_negative_or_ignored_projected_qty(self):
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "safety_stock": 100}).name
|
||||
bin_name = get_or_make_bin(rm_item, "_Test Warehouse - _TC")
|
||||
pln = self._plan_for_safety_stock(rm_item, qty_per_order=250)
|
||||
pln.po_items[1].planned_qty = 1000
|
||||
pln.include_safety_stock = 1
|
||||
|
||||
for projected_qty in (-5, 0, 200, 1500):
|
||||
frappe.db.set_value("Bin", bin_name, "projected_qty", projected_qty)
|
||||
for consider_projected_qty in (0, 1):
|
||||
with self.subTest(projected_qty=projected_qty, consider_projected_qty=consider_projected_qty):
|
||||
pln.ignore_existing_ordered_qty = not consider_projected_qty
|
||||
items = get_items_for_material_requests(pln.as_dict())
|
||||
expected_qty = [350, 1000]
|
||||
if consider_projected_qty and projected_qty > 0:
|
||||
expected_qty = [150, 1000] if projected_qty == 200 else [0, 0]
|
||||
self.assertEqual([row["quantity"] for row in items], expected_qty)
|
||||
self.assertEqual([row["required_bom_qty"] for row in items], [250, 1000])
|
||||
self.assertEqual([row["safety_stock"] for row in items], [100, 100])
|
||||
self.assertEqual(
|
||||
[row["sales_order"] for row in items], [row.sales_order for row in pln.po_items]
|
||||
)
|
||||
|
||||
def test_safety_stock_disabled_with_negative_projected_qty(self):
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "safety_stock": 100}).name
|
||||
bin_name = get_or_make_bin(rm_item, "_Test Warehouse - _TC")
|
||||
frappe.db.set_value("Bin", bin_name, "projected_qty", -5)
|
||||
pln = self._plan_for_safety_stock(rm_item, qty_per_order=250)
|
||||
pln.po_items[1].planned_qty = 1000
|
||||
|
||||
items = get_items_for_material_requests(pln.as_dict())
|
||||
self.assertEqual([row["quantity"] for row in items], [250, 1000])
|
||||
|
||||
def test_safety_stock_is_separate_for_each_item_and_warehouse(self):
|
||||
from collections import defaultdict
|
||||
|
||||
from erpnext.manufacturing.doctype.production_plan.production_plan import (
|
||||
_required_qty_for_mr,
|
||||
self.assertEqual(
|
||||
material_request_items,
|
||||
[{"item_code": requested_item.item_code, "qty": requested_item.quantity}],
|
||||
)
|
||||
|
||||
row = frappe._dict(qty=250, safety_stock=100, purchase_uom="Nos", stock_uom="Nos")
|
||||
items_and_warehouses = [
|
||||
("Raw Material Item 1", "_Test Warehouse - _TC"),
|
||||
("Raw Material Item 1", "_Test Warehouse 1 - _TC"),
|
||||
("Raw Material Item 2", "_Test Warehouse - _TC"),
|
||||
]
|
||||
for consider_projected_qty in (0, 1):
|
||||
with self.subTest(consider_projected_qty=consider_projected_qty):
|
||||
consumed_qty = defaultdict(float)
|
||||
quantities = []
|
||||
for item_code, warehouse in items_and_warehouses * 2:
|
||||
row.item_code = item_code
|
||||
quantities.append(
|
||||
_required_qty_for_mr(
|
||||
row,
|
||||
not consider_projected_qty,
|
||||
warehouse,
|
||||
{"projected_qty": -5},
|
||||
consumed_qty,
|
||||
True,
|
||||
)
|
||||
)
|
||||
self.assertEqual(quantities, [350, 350, 350, 250, 250, 250])
|
||||
|
||||
def test_safety_stock_added_once_before_transferring_materials(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "safety_stock": 100, "min_order_qty": 1234}).name
|
||||
source_warehouse = create_warehouse("Safety Stock Source Warehouse", company="_Test Company")
|
||||
make_stock_entry(item_code=rm_item, qty=2000, rate=100, target=source_warehouse)
|
||||
bin_name = get_or_make_bin(rm_item, "_Test Warehouse - _TC")
|
||||
frappe.db.set_value("Bin", bin_name, "projected_qty", -5)
|
||||
pln = self._plan_for_safety_stock(rm_item, qty_per_order=250)
|
||||
pln.po_items[1].planned_qty = 1000
|
||||
pln.for_warehouse = "_Test Warehouse - _TC"
|
||||
pln.include_safety_stock = 1
|
||||
|
||||
items = get_items_for_material_requests(pln.as_dict(), warehouses=[{"warehouse": source_warehouse}])
|
||||
self.assertEqual([row["material_request_type"] for row in items], ["Material Transfer"] * 2)
|
||||
self.assertEqual([row["quantity"] for row in items], [350, 1000])
|
||||
|
||||
def test_safety_stock_with_fractional_minimum_uses_whole_purchase_uom(self):
|
||||
rm_item = make_item(
|
||||
properties={"is_stock_item": 1, "stock_uom": "Nos", "safety_stock": 0.5, "min_order_qty": 2.5}
|
||||
).name
|
||||
pln = self._plan_for_safety_stock(rm_item, qty_per_order=1)
|
||||
pln.set("po_items", [pln.po_items[0]])
|
||||
pln.include_safety_stock = 1
|
||||
pln.consider_minimum_order_qty = 1
|
||||
|
||||
items = get_items_for_material_requests(pln.as_dict())
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0]["quantity"], 3)
|
||||
|
||||
def test_production_plan_start_date(self):
|
||||
"Test if Work Order has same Planned Start Date as Prod Plan."
|
||||
planned_date = add_to_date(date=None, days=3)
|
||||
|
||||
@@ -602,6 +602,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
},
|
||||
callback: (r) => {
|
||||
const doc = frappe.model.sync(r.message);
|
||||
track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
},
|
||||
});
|
||||
@@ -622,9 +623,77 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
},
|
||||
callback: (r) => {
|
||||
var doc = frappe.model.sync(r.message);
|
||||
track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pending_reconciliations = new Map();
|
||||
|
||||
const voucher_key = (doctype, docname) => `${doctype}:${docname}`;
|
||||
|
||||
const track_voucher = (doctype, docname, bank_transaction_name) => {
|
||||
pending_reconciliations.set(voucher_key(doctype, docname), bank_transaction_name);
|
||||
};
|
||||
|
||||
for (const voucher_doctype of ["Payment Entry", "Journal Entry"]) {
|
||||
frappe.ui.form.on(voucher_doctype, {
|
||||
before_save(frm) {
|
||||
frm.__pending_reconciliation_key = voucher_key(frm.doctype, frm.doc.name);
|
||||
},
|
||||
|
||||
after_save(frm) {
|
||||
const old_key = frm.__pending_reconciliation_key;
|
||||
delete frm.__pending_reconciliation_key;
|
||||
|
||||
const new_key = voucher_key(frm.doctype, frm.doc.name);
|
||||
if (!old_key || old_key === new_key || !pending_reconciliations.has(old_key)) return;
|
||||
|
||||
// Follow the rename so the voucher stays identifiable on submit
|
||||
pending_reconciliations.set(new_key, pending_reconciliations.get(old_key));
|
||||
pending_reconciliations.delete(old_key);
|
||||
},
|
||||
|
||||
on_submit(frm) {
|
||||
const key = voucher_key(frm.doctype, frm.doc.name);
|
||||
const bank_transaction_name = pending_reconciliations.get(key);
|
||||
if (!bank_transaction_name) return;
|
||||
|
||||
pending_reconciliations.delete(key);
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.reconcile_vouchers",
|
||||
args: {
|
||||
bank_transaction_name: bank_transaction_name,
|
||||
vouchers: [
|
||||
{
|
||||
payment_doctype: frm.doctype,
|
||||
payment_name: frm.doc.name,
|
||||
},
|
||||
],
|
||||
is_new_voucher: true,
|
||||
},
|
||||
callback: (r) => {
|
||||
if (r.exc) return;
|
||||
frappe.show_alert({
|
||||
message: __("Bank Transaction {0} Matched", [bank_transaction_name]),
|
||||
indicator: "green",
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
frappe.msgprint({
|
||||
title: __("Reconciliation Failed"),
|
||||
indicator: "red",
|
||||
message: __(
|
||||
"{0} {1} was submitted but could not be reconciled against Bank Transaction {2}. Match it manually from the Bank Reconciliation Tool.",
|
||||
[__(frm.doctype), frm.doc.name, bank_transaction_name]
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -695,7 +695,10 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
}
|
||||
|
||||
// sales invoice
|
||||
if (flt(doc.per_billed) < 100 && frappe.model.can_create("Sales Invoice")) {
|
||||
if (
|
||||
doc.__onload?.has_potentially_billable_items &&
|
||||
frappe.model.can_create("Sales Invoice")
|
||||
) {
|
||||
this.frm.add_custom_button(
|
||||
__("Sales Invoice"),
|
||||
() => me.make_sales_invoice(),
|
||||
|
||||
@@ -13,8 +13,10 @@ from frappe.desk.notifications import clear_doctype_notifications
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.model.utils import get_fetch_values
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.query_builder import Case, Criterion
|
||||
from frappe.query_builder.functions import Abs, Sum
|
||||
from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, strip_html
|
||||
from pypika import Order
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
||||
unlink_inter_company_doc,
|
||||
@@ -22,6 +24,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
||||
validate_inter_company_party,
|
||||
)
|
||||
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account
|
||||
from erpnext.accounts.utils import build_qb_match_conditions
|
||||
from erpnext.controllers.mapper import get_qty_already_mapped
|
||||
from erpnext.controllers.selling_controller import SellingController
|
||||
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
|
||||
@@ -42,6 +45,18 @@ from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty
|
||||
|
||||
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
||||
|
||||
LINK_SEARCH_FIELDTYPES = {
|
||||
"Autocomplete",
|
||||
"Data",
|
||||
"Link",
|
||||
"Long Text",
|
||||
"Read Only",
|
||||
"Select",
|
||||
"Small Text",
|
||||
"Text",
|
||||
"Text Editor",
|
||||
}
|
||||
|
||||
|
||||
class WarehouseRequired(frappe.ValidationError):
|
||||
pass
|
||||
@@ -207,6 +222,12 @@ class SalesOrder(SellingController):
|
||||
if has_reserved_stock(self.doctype, self.name):
|
||||
self.set_onload("has_reserved_stock", True)
|
||||
|
||||
if self.docstatus == 1 and self.status not in {"Closed", "On Hold"}:
|
||||
self.set_onload(
|
||||
"has_potentially_billable_items",
|
||||
has_potentially_billable_items(self.name),
|
||||
)
|
||||
|
||||
def before_validate(self):
|
||||
self.set_has_unit_price_items()
|
||||
self.flags.allow_zero_qty = self.has_unit_price_items
|
||||
@@ -1142,11 +1163,23 @@ def make_sales_invoice(
|
||||
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 = {}
|
||||
amount_allowance_by_item = {}
|
||||
mapped_qty_by_item = get_qty_already_mapped(target_doc, "so_detail")
|
||||
|
||||
def is_unit_price_row(source):
|
||||
return has_unit_price_items and source.qty == 0
|
||||
|
||||
def is_amount_billable(source):
|
||||
from erpnext.controllers.status_updater import get_allowance_for
|
||||
|
||||
if source.item_code not in amount_allowance_by_item:
|
||||
amount_allowance_by_item[source.item_code] = flt(
|
||||
get_allowance_for(source.item_code, qty_or_amount="amount")[0]
|
||||
)
|
||||
|
||||
allowance = amount_allowance_by_item[source.item_code]
|
||||
return abs(flt(source.billed_amt)) < abs(flt(source.amount)) * (1 + allowance / 100)
|
||||
|
||||
def get_billed_qty_by_item():
|
||||
nonlocal billed_qty_by_item
|
||||
|
||||
@@ -1168,9 +1201,7 @@ def make_sales_invoice(
|
||||
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)
|
||||
|
||||
billable_qty -= get_billed_qty_by_item().get(source.name, 0)
|
||||
billable_qty -= mapped_qty_by_item.get(source.name, 0)
|
||||
pending_qty_by_item[source.name] = max(flt(billable_qty, source.precision("qty")), 0)
|
||||
|
||||
@@ -1255,7 +1286,7 @@ def make_sales_invoice(
|
||||
if is_unit_price_row(doc)
|
||||
else (
|
||||
doc.qty
|
||||
and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))
|
||||
and (doc.base_amount == 0 or is_amount_billable(doc))
|
||||
and get_pending_qty(doc) > 0
|
||||
)
|
||||
),
|
||||
@@ -1940,3 +1971,87 @@ def get_work_order_items(sales_order, for_raw_material_request=0):
|
||||
@frappe.whitelist()
|
||||
def get_stock_reservation_status():
|
||||
return frappe.db.get_single_value("Stock Settings", "enable_stock_reservation")
|
||||
|
||||
|
||||
def get_potentially_billable_item_criterion(sales_order, sales_order_item, item):
|
||||
"""Return the amount check for UI candidates. The mapper checks pending quantity."""
|
||||
global_allowance = flt(frappe.get_cached_value("Accounts Settings", None, "over_billing_allowance"))
|
||||
allowance = (
|
||||
Case().when(item.over_billing_allowance != 0, item.over_billing_allowance).else_(global_allowance)
|
||||
)
|
||||
|
||||
has_amount_headroom = (sales_order_item.base_amount == 0) | (
|
||||
Abs(sales_order_item.billed_amt) < Abs(sales_order_item.amount) * (1 + allowance / 100)
|
||||
)
|
||||
is_unit_price_row = (sales_order.has_unit_price_items == 1) & (sales_order_item.qty == 0)
|
||||
|
||||
return is_unit_price_row | ((sales_order_item.qty != 0) & has_amount_headroom)
|
||||
|
||||
|
||||
def has_potentially_billable_items(sales_order: str) -> bool:
|
||||
"""Return whether a Sales Order has an item with billing amount headroom."""
|
||||
so = qb.DocType("Sales Order")
|
||||
so_item = qb.DocType("Sales Order Item")
|
||||
item = qb.DocType("Item")
|
||||
|
||||
return bool(
|
||||
qb.from_(so_item)
|
||||
.inner_join(so)
|
||||
.on(so.name == so_item.parent)
|
||||
.left_join(item)
|
||||
.on(item.name == so_item.item_code)
|
||||
.select(so_item.name)
|
||||
.where((so_item.parent == sales_order) & get_potentially_billable_item_criterion(so, so_item, item))
|
||||
.limit(1)
|
||||
.run()
|
||||
)
|
||||
|
||||
|
||||
def get_text_search_criterion(sales_order, txt: str):
|
||||
"""Match the search text the way the Sales Order link search does."""
|
||||
meta = frappe.get_meta("Sales Order")
|
||||
conditions = []
|
||||
|
||||
for fieldname in dict.fromkeys(["name", meta.title_field, *meta.get_search_fields()]):
|
||||
if not fieldname:
|
||||
continue
|
||||
|
||||
field = meta.get_field(fieldname)
|
||||
if fieldname == "name" or (field and field.fieldtype in LINK_SEARCH_FIELDTYPES):
|
||||
conditions.append(sales_order[fieldname].like(f"%{txt}%"))
|
||||
|
||||
return Criterion.any(conditions)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["GET"])
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_potentially_billable_sales_orders(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict
|
||||
):
|
||||
"""Return Sales Orders that have an item with billing amount headroom."""
|
||||
permission_type = "select" if frappe.only_has_select_perm("Sales Order") else "read"
|
||||
frappe.has_permission("Sales Order", permission_type, throw=True)
|
||||
|
||||
so = qb.DocType("Sales Order")
|
||||
so_item = qb.DocType("Sales Order Item")
|
||||
item = qb.DocType("Item")
|
||||
|
||||
query = frappe.qb.get_query(
|
||||
"Sales Order", fields=["name", "customer", "transaction_date"], filters=filters
|
||||
)
|
||||
if txt:
|
||||
query = query.where(get_text_search_criterion(so, txt))
|
||||
|
||||
return (
|
||||
query.inner_join(so_item)
|
||||
.on(so_item.parent == so.name)
|
||||
.left_join(item)
|
||||
.on(item.name == so_item.item_code)
|
||||
.where(get_potentially_billable_item_criterion(so, so_item, item))
|
||||
.where(Criterion.all(build_qb_match_conditions("Sales Order")))
|
||||
.distinct()
|
||||
.orderby(so.transaction_date, order=Order.desc)
|
||||
.limit(cint(page_len))
|
||||
.offset(cint(start))
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
@@ -22,6 +22,8 @@ from erpnext.selling.doctype.product_bundle.test_product_bundle import make_prod
|
||||
from erpnext.selling.doctype.sales_order.sales_order import (
|
||||
WarehouseRequired,
|
||||
create_pick_list,
|
||||
get_potentially_billable_sales_orders,
|
||||
has_potentially_billable_items,
|
||||
make_delivery_note,
|
||||
make_material_request,
|
||||
make_purchase_order,
|
||||
@@ -226,6 +228,68 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
||||
si1 = make_sales_invoice(so.name)
|
||||
self.assertEqual(len(si1.get("items")), 0)
|
||||
|
||||
def test_make_sales_invoice_for_pending_qty_with_item_billing_allowance(self):
|
||||
item = make_item(
|
||||
"_Test Over Billed Pending Qty Item",
|
||||
{"is_stock_item": 1, "over_billing_allowance": 0},
|
||||
).name
|
||||
so = make_sales_order(item_code=item, qty=390, rate=100)
|
||||
|
||||
for _ in range(2):
|
||||
si = make_sales_invoice(so.name)
|
||||
si.get("items")[0].qty = 120
|
||||
si.get("items")[0].rate = 162.50
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
so.load_from_db()
|
||||
self.assertEqual(flt(so.per_billed), 100)
|
||||
self.assertEqual(so.get("items")[0].billed_amt, so.get("items")[0].amount)
|
||||
|
||||
filters = {"docstatus": 1, "company": so.company, "customer": so.customer}
|
||||
|
||||
def is_offered(txt=""):
|
||||
rows = get_potentially_billable_sales_orders("Sales Order", txt, "name", 0, 50, filters)
|
||||
return so.name in [row.name for row in rows]
|
||||
|
||||
def forget_cached_allowances():
|
||||
frappe.local.request_cache.clear()
|
||||
|
||||
with change_settings("Accounts Settings", {"over_billing_allowance": 100}):
|
||||
forget_cached_allowances()
|
||||
self.assertTrue(has_potentially_billable_items(so.name))
|
||||
self.assertTrue(is_offered())
|
||||
self.assertEqual(make_sales_invoice(so.name).get("items")[0].qty, 150)
|
||||
|
||||
with change_settings("Accounts Settings", {"over_billing_allowance": 0}):
|
||||
forget_cached_allowances()
|
||||
self.assertFalse(has_potentially_billable_items(so.name))
|
||||
self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0)
|
||||
|
||||
frappe.db.set_value("Item", item, "over_billing_allowance", 100)
|
||||
forget_cached_allowances()
|
||||
|
||||
so.run_method("onload")
|
||||
self.assertTrue(so.get_onload("has_potentially_billable_items"))
|
||||
self.assertTrue(is_offered(so.customer))
|
||||
|
||||
si = make_sales_invoice(so.name)
|
||||
self.assertEqual(len(si.get("items")), 1)
|
||||
self.assertEqual(si.get("items")[0].qty, 150)
|
||||
|
||||
def test_make_sales_invoice_skips_fully_invoiced_free_item(self):
|
||||
free_item = make_item("_Test Free Item", {"is_stock_item": 1}).name
|
||||
so = make_sales_order(qty=10, rate=100, do_not_submit=True)
|
||||
so.append("items", {"item_code": free_item, "qty": 5, "rate": 0, "warehouse": so.items[0].warehouse})
|
||||
so.submit()
|
||||
|
||||
si = make_sales_invoice(so.name)
|
||||
self.assertEqual([row.qty for row in si.items], [10, 5])
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
self.assertEqual(len(make_sales_invoice(so.name).items), 0)
|
||||
|
||||
def test_make_sales_invoice_after_return_and_redelivery(self):
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||
|
||||
|
||||
@@ -740,6 +740,9 @@ class DeliveryNote(SellingController):
|
||||
|
||||
def update_billing_status(self, update_modified=True):
|
||||
updated_delivery_notes = [self.name]
|
||||
if self.is_return and self.return_against:
|
||||
updated_delivery_notes.append(self.return_against)
|
||||
|
||||
for d in self.get("items"):
|
||||
if d.si_detail and not d.so_detail:
|
||||
d.db_set("billed_amt", d.amount, update_modified=update_modified)
|
||||
@@ -748,7 +751,12 @@ class DeliveryNote(SellingController):
|
||||
|
||||
for dn in set(updated_delivery_notes):
|
||||
dn_doc = self if (dn == self.name) else frappe.get_doc("Delivery Note", dn)
|
||||
dn_doc.update_billing_percentage(update_modified=update_modified)
|
||||
update_dn_modified = update_modified and dn != self.return_against
|
||||
dn_doc.update_billing_percentage(update_modified=update_dn_modified)
|
||||
if dn == self.return_against:
|
||||
dn_doc.load_from_db()
|
||||
dn_doc.set_status(update=True, update_modified=False)
|
||||
dn_doc.notify_update()
|
||||
|
||||
self.load_from_db()
|
||||
|
||||
|
||||
@@ -1049,6 +1049,56 @@ class TestDeliveryNote(FrappeTestCase):
|
||||
self.assertEqual(dn.per_billed, 100)
|
||||
self.assertEqual(dn.status, "Completed")
|
||||
|
||||
def test_dn_is_completed_when_unbilled_item_is_returned(self):
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||
|
||||
make_stock_entry(target="_Test Warehouse - _TC", qty=1, basic_rate=100)
|
||||
make_stock_entry(item_code="_Test Item 2", target="_Test Warehouse - _TC", qty=1, basic_rate=100)
|
||||
|
||||
dn = create_delivery_note(do_not_submit=True)
|
||||
dn.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item 2",
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"conversion_factor": 1,
|
||||
"allow_zero_valuation_rate": 1,
|
||||
"expense_account": "Cost of Goods Sold - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
dn.submit()
|
||||
|
||||
si = make_sales_invoice(dn.name)
|
||||
si.set("items", [item for item in si.items if item.item_code == "_Test Item"])
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
dn.reload()
|
||||
self.assertEqual(dn.per_billed, 50)
|
||||
self.assertEqual(dn.status, "Partially Billed")
|
||||
|
||||
return_dn = make_sales_return(dn.name)
|
||||
return_dn.set("items", [item for item in return_dn.items if item.item_code == "_Test Item 2"])
|
||||
return_dn.insert()
|
||||
# Mimic the submit request, which reconstructs the document from client data.
|
||||
return_dn = frappe.get_doc(return_dn.as_dict())
|
||||
return_dn.submit()
|
||||
|
||||
dn.reload()
|
||||
self.assertEqual(dn.items[1].returned_qty, 1)
|
||||
self.assertEqual(dn.per_billed, 100)
|
||||
self.assertEqual(dn.status, "Completed")
|
||||
|
||||
return_dn.cancel()
|
||||
|
||||
dn.reload()
|
||||
self.assertEqual(dn.items[1].returned_qty, 0)
|
||||
self.assertEqual(dn.per_billed, 50)
|
||||
self.assertEqual(dn.status, "Partially Billed")
|
||||
|
||||
def test_dn_billing_status_case2(self):
|
||||
# SO -> SI and SO -> DN1, DN2
|
||||
from erpnext.selling.doctype.sales_order.sales_order import (
|
||||
|
||||
@@ -796,6 +796,7 @@
|
||||
"fieldname": "over_delivery_receipt_allowance",
|
||||
"fieldtype": "Float",
|
||||
"label": "Over Delivery/Receipt Allowance (%)",
|
||||
"non_negative": 1,
|
||||
"oldfieldname": "tolerance",
|
||||
"oldfieldtype": "Currency"
|
||||
},
|
||||
@@ -803,7 +804,8 @@
|
||||
"depends_on": "eval:!doc.__islocal && !doc.is_fixed_asset",
|
||||
"fieldname": "over_billing_allowance",
|
||||
"fieldtype": "Float",
|
||||
"label": "Over Billing Allowance (%)"
|
||||
"label": "Over Billing Allowance (%)",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -898,7 +900,7 @@
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2026-07-05 23:24:45.734144",
|
||||
"modified": "2026-09-04 10:08:30.115003",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item",
|
||||
|
||||
@@ -1734,7 +1734,7 @@ class TestPickList(FrappeTestCase):
|
||||
stock_entry.cancel()
|
||||
|
||||
def test_packed_item_in_pick_list(self):
|
||||
warehouse_1 = "RJ Warehouse - _TC"
|
||||
warehouse_1 = "_Test Warehouse - _TC"
|
||||
warehouse_2 = "_Test Warehouse 2 - _TC"
|
||||
item_1 = make_item(properties={"is_stock_item": 0}).name
|
||||
item_2 = make_item().name
|
||||
@@ -1765,7 +1765,7 @@ class TestPickList(FrappeTestCase):
|
||||
|
||||
def test_packed_item_multiple_times_in_so(self):
|
||||
frappe.db.delete("Item Price")
|
||||
warehouse_1 = "RJ Warehouse - _TC"
|
||||
warehouse_1 = "_Test Warehouse - _TC"
|
||||
warehouse_2 = "_Test Warehouse 2 - _TC"
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
item_1 = make_item(properties={"is_stock_item": 0}).name
|
||||
|
||||
@@ -192,7 +192,11 @@ def get_rate_locked_source_row(args, doc):
|
||||
if not source_fields or not doc or args.get("is_return") or not maintain_same_rate_enabled(args):
|
||||
return None
|
||||
|
||||
row = next((d for d in doc.get("items") or [] if d.get("name") == args.child_docname), None)
|
||||
row = (
|
||||
next((d for d in doc.get("items") or [] if d.get("name") == args.child_docname), None)
|
||||
if args.child_docname
|
||||
else args
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
|
||||
@@ -422,3 +422,65 @@ class TestGetItemDetail(FrappeTestCase):
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original)
|
||||
frappe.clear_cache(doctype="Buying Settings")
|
||||
|
||||
def test_rate_lock_keeps_each_rows_rate_for_batch_items(self):
|
||||
"""Batch rows mapped PR->PI must each keep their own rate, not collapse onto the first."""
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
get_batch_from_bundle,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
|
||||
original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate")
|
||||
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
|
||||
frappe.clear_cache(doctype="Buying Settings")
|
||||
self.addCleanup(frappe.clear_cache, doctype="Buying Settings")
|
||||
self.addCleanup(frappe.db.set_single_value, "Buying Settings", "maintain_same_rate", original)
|
||||
|
||||
def batched_item():
|
||||
code = make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "FBQ-.#####",
|
||||
}
|
||||
).name
|
||||
se = make_stock_entry(item_code=code, target="_Test Warehouse - _TC", qty=5, basic_rate=100)
|
||||
return code, get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
|
||||
|
||||
item_a, batch_a = batched_item()
|
||||
item_b, batch_b = batched_item()
|
||||
|
||||
# one PO with both items at different rates
|
||||
po = create_purchase_order(item_code=item_a, qty=5, rate=28, do_not_save=True)
|
||||
po.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_b,
|
||||
"qty": 5,
|
||||
"rate": 275,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"schedule_date": frappe.utils.nowdate(),
|
||||
},
|
||||
)
|
||||
po.set_missing_values()
|
||||
po.insert()
|
||||
po.submit()
|
||||
|
||||
# receive both against their own batches
|
||||
pr = make_purchase_receipt(po.name)
|
||||
for row in pr.items:
|
||||
row.use_serial_batch_fields = 1
|
||||
pr.items[0].batch_no = batch_a
|
||||
pr.items[1].batch_no = batch_b
|
||||
pr.insert()
|
||||
pr.submit()
|
||||
|
||||
# the batch_no branch force-writes the fetched rate during mapping
|
||||
pi = make_purchase_invoice(pr.name)
|
||||
self.assertEqual(pi.items[0].rate, 28)
|
||||
self.assertEqual(pi.items[1].rate, 275) # used to collapse onto the first row (28)
|
||||
|
||||
Reference in New Issue
Block a user