Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-57322

This commit is contained in:
Jatin3128
2026-07-24 15:37:35 +05:30
committed by GitHub
27 changed files with 560 additions and 94 deletions

View File

@@ -17,7 +17,7 @@ class ERPNextAddress(Address):
def link_address(self):
"""Link address based on owner"""
if self.is_your_company_address:
if self.get("is_your_company_address"):
return
return super().link_address()
@@ -28,7 +28,9 @@ class ERPNextAddress(Address):
self.is_your_company_address = 1
def validate_reference(self):
if self.is_your_company_address and not [row for row in self.links if row.link_doctype == "Company"]:
if self.get("is_your_company_address") and not [
row for row in self.links if row.link_doctype == "Company"
]:
frappe.throw(
_(
"Address needs to be linked to a Company. Please add a row for Company in the Links table."

View File

@@ -75,6 +75,8 @@
"over_billing_allowance",
"credit_controller",
"role_allowed_to_over_bill",
"enable_overdue_billing_threshold",
"role_allowed_to_bypass_overdue_billing",
"column_break_11",
"assets_tab",
"asset_settings_section",
@@ -271,6 +273,21 @@
"label": "Role Allowed to over bill ",
"options": "Role"
},
{
"default": "0",
"description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.",
"fieldname": "enable_overdue_billing_threshold",
"fieldtype": "Check",
"label": "Restrict Customer Over Billing"
},
{
"depends_on": "eval:doc.enable_overdue_billing_threshold",
"description": "Users with this role can still submit invoices for customers who have crossed their Overdue Limit.",
"fieldname": "role_allowed_to_bypass_overdue_billing",
"fieldtype": "Link",
"label": "Role Allowed to Bypass Over Billing Restriction",
"options": "Role"
},
{
"fieldname": "period_closing_settings_section",
"fieldtype": "Section Break"

View File

@@ -77,6 +77,7 @@ class AccountsSettings(Document):
enable_fuzzy_matching: DF.Check
enable_immutable_ledger: DF.Check
enable_loyalty_point_program: DF.Check
enable_overdue_billing_threshold: DF.Check
enable_party_matching: DF.Check
enable_subscription: DF.Check
exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"]
@@ -95,6 +96,7 @@ class AccountsSettings(Document):
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int
repost_allowed_types: DF.Table[RepostAllowedTypes]
role_allowed_to_bypass_overdue_billing: DF.Link | None
role_allowed_to_over_bill: DF.Link | None
role_to_notify_on_depreciation_failure: DF.Link | None
role_to_override_stop_action: DF.Link | None
@@ -150,6 +152,10 @@ class AccountsSettings(Document):
toggle_subscription_sections(not self.enable_subscription)
clear_cache = True
if old_doc.enable_overdue_billing_threshold != self.enable_overdue_billing_threshold:
toggle_overdue_billing_threshold_field(not self.enable_overdue_billing_threshold)
clear_cache = True
if clear_cache:
frappe.clear_cache()
@@ -241,6 +247,10 @@ def toggle_subscription_sections(hide):
create_property_setter_for_hiding_field(doctype, "subscription_section", hide)
def toggle_overdue_billing_threshold_field(hide):
create_property_setter_for_hiding_field("Customer Credit Limit", "overdue_billing_threshold", hide)
def create_property_setter_for_hiding_field(doctype, field_name, hide):
make_property_setter(
doctype,

View File

@@ -517,6 +517,7 @@ class SalesInvoice(SellingController):
self.update_billing_status_for_zero_amount_refdoc("Delivery Note")
self.update_billing_status_for_zero_amount_refdoc("Sales Order")
self.check_credit_limit()
self.check_overdue_billing_threshold()
if cint(self.is_pos) != 1 and not self.is_return:
self.update_against_document_in_jv()
@@ -778,6 +779,11 @@ class SalesInvoice(SellingController):
pos_invoice_doc = frappe.get_doc("POS Invoice", pos_invoice)
pos_invoice_doc.cancel()
def check_overdue_billing_threshold(self):
from erpnext.selling.doctype.customer.customer import check_overdue_billing_threshold
check_overdue_billing_threshold(self.customer, self.company)
@frappe.whitelist()
def set_missing_values(self, for_validate=False):
pos = self.set_pos_fields(for_validate)

View File

@@ -82,8 +82,7 @@
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center",
"reqd": 1
"options": "Cost Center"
},
{
"fieldname": "shipping_amount_section",
@@ -141,19 +140,20 @@
"fieldtype": "Column Break"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
}
],
"icon": "fa fa-truck",
"idx": 1,
"links": [],
"modified": "2024-03-27 13:10:41.653314",
"modified": "2026-07-22 14:53:27.315435",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Shipping Rule",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -197,7 +197,8 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": []
}
}

View File

@@ -36,18 +36,17 @@ class ShippingRule(Document):
from erpnext.accounts.doctype.shipping_rule_condition.shipping_rule_condition import (
ShippingRuleCondition,
)
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import (
ShippingRuleCountry,
)
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ShippingRuleCountry
account: DF.Link
calculate_based_on: DF.Literal["Fixed", "Net Total", "Net Weight"]
company: DF.Link
conditions: DF.Table[ShippingRuleCondition]
cost_center: DF.Link
cost_center: DF.Link | None
countries: DF.Table[ShippingRuleCountry]
disabled: DF.Check
label: DF.Data
project: DF.Link | None
shipping_amount: DF.Currency
shipping_rule_type: DF.Literal["Selling", "Buying"]
# end: auto-generated types

View File

@@ -858,7 +858,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
companies = frappe.get_all(
companies = frappe.get_list(
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
)

View File

@@ -1371,8 +1371,9 @@ class StockController(AccountsController):
if outstanding > 0:
reservations[key].append(row)
precision = frappe.get_precision("Serial and Batch Entry", "qty")
for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
if flt(reserved_qty, 6) <= 0:
if flt(reserved_qty, precision) <= 0:
continue
batch_qty = get_batch_qty(
@@ -1383,7 +1384,7 @@ class StockController(AccountsController):
consider_negative_batches=True,
)
if flt(batch_qty, 6) >= flt(reserved_qty, 6):
if flt(batch_qty, precision) >= flt(reserved_qty, precision):
continue
vouchers = ", ".join(

View File

@@ -2554,7 +2554,13 @@ def get_item_details(item, project=None, skip_bom_info=False, throw=True):
@frappe.whitelist()
def make_work_order(
bom_no, item, qty=0, company=None, project=None, variant_items=None, use_multi_level_bom=None
bom_no: str,
item: str,
qty: float = 0,
company: str | None = None,
project: str | None = None,
variant_items: str | list | None = None,
use_multi_level_bom: bool | None = None,
):
from erpnext import get_default_company
@@ -2563,7 +2569,8 @@ def make_work_order(
item_details = get_item_details(item, project)
if frappe.db.get_value("Item", item, "variant_of"):
# selected BOM already belongs to this variant — keep it
if frappe.db.get_value("Item", item, "variant_of") and frappe.db.get_value("BOM", bom_no, "item") != item:
if variant_bom := frappe.db.get_value(
"BOM",
{"item": item, "is_default": 1, "docstatus": 1},

View File

@@ -11,6 +11,36 @@ erpnext.stock.StockController = class StockController extends frappe.ui.form.Con
}
}
onload_post_render() {
this.set_route_options_for_new_doc();
}
set_route_options_for_new_doc() {
// While creating a Batch or Serial and Batch Bundle from the link
// field, copy details from the line item to the new form
if (!this.frm.fields_dict.items) return;
let batch_no_field = this.frm.get_docfield("items", "batch_no");
if (batch_no_field) {
batch_no_field.get_route_options_for_new_doc = (row) => {
return {
item: row.doc.item_code,
};
};
}
let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
warehouse: row.doc.warehouse || row.doc.s_warehouse || row.doc.t_warehouse,
voucher_type: this.frm.doc.doctype,
};
};
}
}
barcode(doc, cdt, cdn) {
let row = locals[cdt][cdn];
if (row.barcode) {

View File

@@ -649,34 +649,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
erpnext.toggle_serial_batch_fields(this.frm);
}
set_route_options_for_new_doc() {
// While creating the batch from the link field, copy item from line item to batch form
if (this.frm.fields_dict["items"].grid.get_field("batch_no")) {
let batch_no_field = this.frm.get_docfield("items", "batch_no");
if (batch_no_field) {
batch_no_field.get_route_options_for_new_doc = function (row) {
return {
item: row.doc.item_code,
};
};
}
}
// While creating the SABB from the link field, copy item, doctype from line item to SABB form
if (this.frm.fields_dict["items"].grid.get_field("serial_and_batch_bundle")) {
let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
voucher_type: this.frm.doc.doctype,
};
};
}
}
}
scan_barcode() {
frappe.flags.dialog_set = false;
this.barcode_scanner.process_scan();

View File

@@ -475,10 +475,10 @@
"report_hide": 1
},
{
"description": "Transactions are blocked or warned when outstanding balance exceeds this amount.",
"description": "Transactions are blocked when the outstanding balance exceeds the credit limit. When Restrict Customer Over Billing is enabled, new invoices are also blocked when the customer's overdue amount exceeds the Overdue Limit.",
"fieldname": "credit_limits",
"fieldtype": "Table",
"label": "Credit Limit",
"label": "Credit & Overdue Limits",
"options": "Customer Credit Limit",
"show_description_on_click": 1
},

View File

@@ -16,7 +16,7 @@ from frappe.model.mapper import get_mapped_doc
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
from frappe.model.utils.rename_doc import update_linked_doctypes
from frappe.query_builder import Field, functions
from frappe.utils import cint, cstr, flt, get_formatted_email, today
from frappe.utils import cint, cstr, flt, fmt_money, get_formatted_email, getdate, today
from frappe.utils.user import get_users_with_role
from erpnext.accounts.party import (
@@ -204,17 +204,21 @@ class Customer(TransactionBase):
self.credit_limits = []
self.payment_terms = self.default_price_list = ""
tables = [["accounts", "account"], ["credit_limits", "credit_limit"]]
tables = [
["accounts", ["account"]],
["credit_limits", ["credit_limit", "overdue_billing_threshold"]],
]
fields = ["payment_terms", "default_price_list"]
for row in tables:
table, field = row[0], row[1]
table, table_fields = row[0], row[1]
if not doc.get(table):
continue
for entry in doc.get(table):
child = self.append(table)
child.update({"company": entry.company, field: entry.get(field)})
child.update({"company": entry.company})
child.update({field: entry.get(field) for field in table_fields})
for field in fields:
if not doc.get(field):
@@ -403,6 +407,9 @@ class Customer(TransactionBase):
else:
company_record.append(limit.company)
if not flt(limit.credit_limit):
continue
outstanding_amt = get_customer_outstanding(
self.name, limit.company, ignore_outstanding_sales_order=limit.bypass_credit_limit_check
)
@@ -674,6 +681,124 @@ def send_emails(customer, customer_outstanding, credit_limit, credit_controller_
frappe.sendmail(recipients=credit_controller_users_list, subject=subject, message=message)
def check_overdue_billing_threshold(customer: str, company: str) -> None:
if not frappe.get_single_value("Accounts Settings", "enable_overdue_billing_threshold"):
return
threshold = get_overdue_billing_threshold(customer, company)
if not threshold:
return
overdue_amount = get_customer_overdue_amount(customer, company)
if overdue_amount <= threshold:
return
bypass_role = frappe.get_single_value("Accounts Settings", "role_allowed_to_bypass_overdue_billing")
if bypass_role and bypass_role in frappe.get_roles():
return
company_currency = frappe.get_cached_value("Company", company, "default_currency")
frappe.throw(
_("Overdue Limit crossed for customer {0}. Overdue amount {1} exceeds the allowed limit {2}.").format(
customer,
fmt_money(overdue_amount, currency=company_currency),
fmt_money(threshold, currency=company_currency),
),
title=_("Overdue Limit Crossed"),
)
def get_overdue_billing_threshold(customer: str, company: str) -> float:
"""Overdue limit set on the customer, falling back to its customer group."""
threshold = frappe.db.get_value(
"Customer Credit Limit",
{"parent": customer, "parenttype": "Customer", "company": company},
"overdue_billing_threshold",
)
if not threshold:
customer_group = frappe.get_cached_value("Customer", customer, "customer_group")
threshold = frappe.db.get_value(
"Customer Credit Limit",
{"parent": customer_group, "parenttype": "Customer Group", "company": company},
"overdue_billing_threshold",
)
return flt(threshold)
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.
"""
invoices = get_outstanding_invoices_for_customer(customer, company)
if not invoices:
return 0.0
payable_amounts = get_past_due_payable_amounts([d.name for d in invoices])
return flt(sum(get_overdue_portion(d, payable_amounts.get(d.name)) for d in invoices))
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")
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)
return (
frappe.qb.from_(gl_entry)
.inner_join(sales_invoice)
.on(sales_invoice.name == gl_entry.against_voucher)
.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")
.groupby(sales_invoice.name, sales_invoice.due_date, sales_invoice.base_grand_total)
.having(outstanding > 0)
).run(as_dict=True)
def get_past_due_payable_amounts(invoices: list[str]) -> dict[str, float]:
from frappe.query_builder.functions import Sum
payment_schedule = frappe.qb.DocType("Payment Schedule")
rows = (
frappe.qb.from_(payment_schedule)
.select(payment_schedule.parent, Sum(payment_schedule.base_payment_amount).as_("payable"))
.where(payment_schedule.parenttype == "Sales Invoice")
.where(payment_schedule.parent.isin(invoices))
.where(payment_schedule.due_date < getdate())
.groupby(payment_schedule.parent)
).run(as_dict=True)
return {d.parent: flt(d.payable) for d in rows}
def get_overdue_portion(invoice: frappe._dict, payable_amount: float | None) -> float:
outstanding = flt(invoice.outstanding)
# No payable amount means either a schedule-less invoice (POS, opening) or one whose terms are
# all still in the future. Both are answered by the invoice due date, which is the last term.
if payable_amount is None:
return outstanding if invoice.due_date and getdate(invoice.due_date) < getdate() else 0.0
paid = flt(invoice.base_grand_total) - outstanding
return min(max(payable_amount - paid, 0.0), outstanding)
def get_customer_outstanding(customer, company, ignore_outstanding_sales_order=False, cost_center=None):
# Outstanding based on GL Entries
cond = ""

View File

@@ -5,13 +5,15 @@
import json
import frappe
from frappe.utils import flt, nowdate
from frappe.utils import add_days, flt, getdate, nowdate
from erpnext.accounts.party import get_due_date
from erpnext.exceptions import PartyDisabled, PartyFrozen
from erpnext.selling.doctype.customer.customer import (
get_credit_limit,
get_customer_outstanding,
get_customer_overdue_amount,
get_overdue_billing_threshold,
make_quotation,
parse_full_name,
)
@@ -77,7 +79,11 @@ class TestCustomer(ERPNextTestSuite):
"company": "_Test Company",
"account": "Creditors - _TC",
}
test_credit_limits = {"company": "_Test Company", "credit_limit": 350000}
test_credit_limits = {
"company": "_Test Company",
"credit_limit": 350000,
"overdue_billing_threshold": 5000,
}
doc.append("accounts", test_account_details)
doc.append("credit_limits", test_credit_limits)
doc.insert()
@@ -97,6 +103,7 @@ class TestCustomer(ERPNextTestSuite):
self.assertEqual(c_doc.credit_limits[0].company, "_Test Company")
self.assertEqual(c_doc.credit_limits[0].credit_limit, 350000)
self.assertEqual(c_doc.credit_limits[0].overdue_billing_threshold, 5000)
c_doc.delete()
doc.delete()
@@ -352,6 +359,139 @@ class TestCustomer(ERPNextTestSuite):
)
self.assertRaises(frappe.ValidationError, customer.save)
def test_get_customer_overdue_amount(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
baseline = get_customer_overdue_amount("_Test Customer", "_Test Company")
# a past-due, unpaid invoice adds its outstanding to the overdue amount
create_sales_invoice(qty=1, rate=500, posting_date=add_days(nowdate(), -30))
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 500)
# an invoice due today (not yet past due) does not
create_sales_invoice(qty=1, rate=700, posting_date=nowdate())
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 500)
def test_get_customer_overdue_amount_is_in_company_currency(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
baseline = get_customer_overdue_amount("_Test Customer USD", "_Test Company")
# 100 USD at a conversion rate of 50 must be counted as 5000 in company currency
create_sales_invoice(
customer="_Test Customer USD",
debit_to="_Test Receivable USD - _TC",
currency="USD",
conversion_rate=50,
qty=1,
rate=100,
posting_date=add_days(nowdate(), -30),
)
self.assertEqual(get_customer_overdue_amount("_Test Customer USD", "_Test Company"), baseline + 5000)
def test_get_customer_overdue_amount_follows_payment_terms(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
def make_invoice_with_terms():
si = create_sales_invoice(
qty=1, rate=1200, posting_date=add_days(nowdate(), -60), do_not_save=True
)
si.append("payment_schedule", {"due_date": add_days(nowdate(), -60), "invoice_portion": 50})
si.append("payment_schedule", {"due_date": add_days(nowdate(), 30), "invoice_portion": 50})
si.insert()
si.submit()
return si
baseline = get_customer_overdue_amount("_Test Customer", "_Test Company")
# only the term that has fallen due counts, not the whole 1200 balance. The invoice due_date
# is the last term (in 30 days), so this is only caught by reading the payment schedule.
si = make_invoice_with_terms()
self.assertEqual(getdate(si.due_date), getdate(add_days(nowdate(), 30)))
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 600)
# paying off the past-due term clears the overdue amount
pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC")
pe.reference_no = "_Test Overdue Payment"
pe.reference_date = nowdate()
pe.paid_amount = pe.received_amount = 600
pe.references[0].allocated_amount = 600
pe.insert()
pe.submit()
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
create_sales_invoice(qty=1, rate=1000, posting_date=add_days(nowdate(), -30))
overdue = get_customer_overdue_amount("_Test Customer", "_Test Company")
settings = frappe.get_single("Accounts Settings")
original_enable = settings.enable_overdue_billing_threshold
original_bypass_role = settings.role_allowed_to_bypass_overdue_billing
try:
settings.enable_overdue_billing_threshold = 1
settings.role_allowed_to_bypass_overdue_billing = None
settings.save()
set_overdue_billing_threshold("_Test Customer", "_Test Company", overdue - 100)
# overdue is over the threshold and the user has no bypass role -> blocked
si = create_sales_invoice(do_not_submit=True)
self.assertRaises(frappe.ValidationError, si.submit)
# a user holding the bypass role can still submit
settings.role_allowed_to_bypass_overdue_billing = "Accounts Manager"
settings.save()
si = create_sales_invoice(do_not_submit=True)
si.submit()
self.assertEqual(si.docstatus, 1)
# threshold still crossed, but the feature is off -> never blocked
settings.enable_overdue_billing_threshold = 0
settings.role_allowed_to_bypass_overdue_billing = None
settings.save()
si = create_sales_invoice(do_not_submit=True)
si.submit()
self.assertEqual(si.docstatus, 1)
finally:
settings.enable_overdue_billing_threshold = original_enable
settings.role_allowed_to_bypass_overdue_billing = original_bypass_role
settings.save()
def test_overdue_billing_threshold_falls_back_to_customer_group(self):
customer_group = frappe.get_cached_value("Customer", "_Test Customer", "customer_group")
group = frappe.get_doc("Customer Group", customer_group)
group.credit_limits = []
group.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 5000})
group.save()
# the customer has no threshold of its own, so the group's applies
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000)
# a threshold on the customer wins over the group
set_overdue_billing_threshold("_Test Customer", "_Test Company", 2000)
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 2000)
# a 0 on the customer inherits the group's limit
set_overdue_billing_threshold("_Test Customer", "_Test Company", 0)
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000)
def test_overdue_threshold_row_without_credit_limit(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
# outstanding must be > 0 so a 0 credit_limit would previously trip the check
create_sales_invoice(qty=1, rate=500)
customer = frappe.get_doc("Customer", "_Test Customer")
customer.credit_limits = []
customer.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 1000})
customer.save()
self.assertEqual(customer.credit_limits[0].overdue_billing_threshold, 1000)
self.assertEqual(flt(customer.credit_limits[0].credit_limit), 0.0)
def test_customer_payment_terms(self):
frappe.db.set_value(
"Customer", "_Test Customer With Template", "payment_terms", "_Test Payment Term Template 3"
@@ -451,6 +591,18 @@ def set_credit_limit(customer, company, credit_limit):
customer.credit_limits[-1].db_insert()
def set_overdue_billing_threshold(customer, company, threshold):
customer = frappe.get_doc("Customer", customer)
for d in customer.credit_limits:
if d.company == company:
d.overdue_billing_threshold = threshold
d.db_update()
return
customer.append("credit_limits", {"company": company, "overdue_billing_threshold": threshold})
customer.credit_limits[-1].db_insert()
def create_internal_customer(customer_name=None, represents_company=None, allowed_to_interact_with=None):
if not customer_name:
customer_name = represents_company

View File

@@ -8,6 +8,7 @@
"company",
"column_break_2",
"credit_limit",
"overdue_billing_threshold",
"bypass_credit_limit_check"
],
"fields": [
@@ -18,6 +19,15 @@
"in_list_view": 1,
"label": "Credit Limit"
},
{
"columns": 3,
"description": "New Sales Invoices are blocked when the customer's overdue amount exceeds this. Requires 'Restrict Customer Over Billing' in Accounts Settings.",
"fieldname": "overdue_billing_threshold",
"fieldtype": "Currency",
"hidden": 1,
"in_list_view": 1,
"label": "Overdue Limit"
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"

View File

@@ -18,6 +18,7 @@ class CustomerCreditLimit(Document):
bypass_credit_limit_check: DF.Check
company: DF.Link | None
credit_limit: DF.Currency
overdue_billing_threshold: DF.Currency
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View File

@@ -132,7 +132,7 @@
{
"fieldname": "credit_limits",
"fieldtype": "Table",
"label": "Credit Limit",
"label": "Credit & Overdue Limits",
"options": "Customer Credit Limit"
}
],

View File

@@ -296,7 +296,7 @@ def get_batches_by_oldest(item_code, warehouse):
"""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]
batches_dates.sort(key=lambda tup: tup[1])
batches_dates.sort(key=lambda tup: (tup[1] is None, tup[1]))
return batches_dates

View File

@@ -1381,6 +1381,9 @@ def create_delivery_wo_so(pick_list, target, target_doc=None):
target_doc.company = pick_list.company
if not target_doc.customer:
target_doc.customer = pick_list.customer
item_table_mapper_without_so = {
"doctype": f"{target} Item",
"field_map": {

View File

@@ -242,7 +242,7 @@ class RepostItemValuation(Document):
def clear_attachment(self):
if attachments := get_attachments(self.doctype, self.name):
attachment = attachments[0]
frappe.delete_doc("File", attachment.name, ignore_permissions=True)
frappe.delete_doc("File", attachment.name, ignore_permissions=True, force=True)
if self.reposting_data_file:
self.db_set("reposting_data_file", None)

View File

@@ -515,6 +515,34 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin):
)
)
def test_clear_attachment_skips_referenced_data_file(self):
riv = frappe.get_doc(
{
"doctype": "Repost Item Valuation",
"based_on": "Item and Warehouse",
"company": "_Test Company",
"item_code": "_Test Item",
"warehouse": "_Test Warehouse - _TC",
"posting_date": today(),
}
).insert(ignore_permissions=True)
attached = frappe.get_doc(
{
"doctype": "File",
"file_name": "repost_data.json.gz",
"content": "test",
"attached_to_doctype": riv.doctype,
"attached_to_name": riv.name,
}
).insert(ignore_permissions=True)
riv.db_set("reposting_data_file", attached.file_url)
riv.clear_attachment()
self.assertFalse(frappe.db.exists("File", attached.name))
self.assertIsNone(frappe.db.get_value("Repost Item Valuation", riv.name, "reposting_data_file"))
@ERPNextTestSuite.change_settings(
"Stock Reposting Settings",
{"item_based_reposting": 1, "enable_parallel_reposting": 1, "no_of_parallel_reposting": 2},

View File

@@ -569,8 +569,6 @@ frappe.ui.form.on("Stock Entry", {
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
}
frm.events.set_route_options_for_new_doc(frm);
frm.set_df_property(
"items",
"cannot_add_rows",
@@ -583,28 +581,6 @@ frappe.ui.form.on("Stock Entry", {
);
},
set_route_options_for_new_doc(frm) {
let batch_no_field = frm.get_docfield("items", "batch_no");
if (batch_no_field) {
batch_no_field.get_route_options_for_new_doc = function (row) {
return {
item: row.doc.item_code,
};
};
}
let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
voucher_type: frm.doc.doctype,
warehouse: row.doc.s_warehouse || row.doc.t_warehouse,
};
};
}
},
get_items_from_transit_entry: function (frm) {
if (frm.doc.docstatus === 0 && !frm.doc.subcontracting_inward_order) {
frm.add_custom_button(
@@ -1312,6 +1288,7 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
}
onload_post_render() {
super.onload_post_render();
var me = this;
if (me.frm.doc.__islocal && me.frm.doc.company && !me.frm.doc.amended_from) {
me.company();

View File

@@ -1344,6 +1344,47 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin):
# receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error).
self.assertEqual(qty_after(receipt2), 10)
def test_cancel_shifts_same_timestamp_delivery_notes(self):
item = make_item().name
warehouse = "_Test Warehouse - _TC"
posting_date = today()
posting_time = "10:00:00"
make_stock_entry(
item_code=item,
to_warehouse=warehouse,
qty=100,
rate=10,
posting_date=posting_date,
posting_time="09:00:00",
)
dns = []
for i in range(5):
dns.append(
create_delivery_note(
item_code=item,
warehouse=warehouse,
qty=20,
rate=10 * i,
posting_date=posting_date,
posting_time=posting_time,
)
)
time.sleep(1)
dn = dns[2]
dn.cancel()
expected_qty_after_transaction_of_dns3 = 40
qty_after_transaction_of_dns3 = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": dns[3].name, "is_cancelled": 0},
"qty_after_transaction",
)
self.assertEqual(expected_qty_after_transaction_of_dns3, qty_after_transaction_of_dns3)
def test_get_next_stock_reco_respects_creation_order(self):
# A stock reco sharing the exact posting timestamp of the current entry must only count as the
# "next" reco when it was created after that entry. A reco created before it actually precedes

View File

@@ -46,17 +46,6 @@ frappe.ui.form.on("Stock Reconciliation", {
};
});
let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {
return {
item_code: row.doc.item_code,
warehouse: row.doc.warehouse,
voucher_type: frm.doc.doctype,
};
};
}
if (frm.doc.company) {
erpnext.queries.setup_queries(frm, "Warehouse", function () {
return erpnext.queries.warehouse(frm.doc);

View File

@@ -325,6 +325,7 @@ class FIFOSlots:
del stock_ledger_entries
self._recompute_moving_average_slots()
self._rebalance_negative_batch_slots()
if not self.filters.get("show_warehouse_wise_stock"):
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
@@ -346,6 +347,33 @@ class FIFOSlots:
if is_qty_slot(slot):
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate)
def _rebalance_negative_batch_slots(self) -> None:
for item_dict in self.item_details.values():
if item_dict.get("has_batch_no"):
self._rebalance_negative_batch_slot_values(item_dict["fifo_queue"])
def _rebalance_negative_batch_slot_values(self, fifo_queue: list) -> None:
"""A batch is one valuation pool, so a slot driven negative by consumption
at the pooled rate is stale detail: spread the pool value over its slots."""
groups = {}
for slot in fifo_queue:
if is_batch_slot(slot):
key = slot[BATCH_SLOT_BATCH_INDEX] if slot[BATCH_SLOT_VALUATION_INDEX] else None
groups.setdefault(key, []).append(slot)
for slots in groups.values():
has_negative_slot = any(
flt(slot[BATCH_SLOT_VALUE_INDEX]) < 0 and flt(slot[BATCH_SLOT_QTY_INDEX]) > 0
for slot in slots
)
total_qty = sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots)
if not has_negative_slot or total_qty <= 0:
continue
rate = sum(flt(slot[BATCH_SLOT_VALUE_INDEX]) for slot in slots) / total_qty
for slot in slots:
slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate)
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
if stock_ledger_entries is not None:
return frappe._dict({}), frappe._dict({})

View File

@@ -616,6 +616,58 @@ class TestStockAgeing(ERPNextTestSuite):
],
)
def test_batch_issue_at_pooled_rate_keeps_slot_values_positive(self):
"""Ledger (same wh, batch B): [+10 @ 0, +10 @ 10, -4 @ pooled 5]
Consuming the zero-valued head slot at the pooled rate drives it
negative; slot values are then rebalanced to the batch pool rate."""
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batch Pool Rebalance",
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
).name
batch_no = "SA-POOL-REBALANCE-BATCH"
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
ignore_permissions=True
)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after,
stock_value_difference=stock_value_difference,
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
warehouse="WH 1",
posting_date=posting_date,
voucher_type="Stock Entry",
voucher_no=voucher_no,
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
)
sle = [
make_sle("2021-12-01", "001", 10, 10, 0),
make_sle("2021-12-02", "002", 10, 20, 100),
make_sle("2021-12-03", "003", -4, 16, -20),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots[item_code]["fifo_queue"]
self.assertEqual(
queue,
[
[batch_no, 1, 6.0, "2021-12-01", 30.0],
[batch_no, 1, 10.0, "2021-12-01", 50.0],
],
)
def test_sequential_stock_reco_same_warehouse(self):
"""
Test back to back stock recos (same warehouse).

View File

@@ -497,7 +497,9 @@ class update_entries_after:
self.data = frappe._dict()
if not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle"):
if (not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle")) and not self.args.get(
"cancelled"
):
self.initialize_previous_data(self.args)
self.build()
@@ -805,10 +807,23 @@ class update_entries_after:
def process_sle_against_current_timestamp(self):
sl_entries = get_sle_against_current_voucher(self.args)
if self.args.get("cancelled") and sl_entries:
self.seed_previous_sle_for_cancellation(sl_entries[0])
for sle in sl_entries:
sle["timestamp"] = sle.posting_datetime
self.process_sle(sle)
def seed_previous_sle_for_cancellation(self, anchor_sle):
key = (anchor_sle.item_code, anchor_sle.warehouse)
if key in self.prev_sle_dict:
return
args = frappe._dict(anchor_sle)
args["sle_id"] = args.name
prev_sle = get_previous_sle_of_current_voucher(args)
if prev_sle:
self.prev_sle_dict[key] = prev_sle
def get_future_entries_to_fix(self):
# includes current entry!
args = self.data[self.args.warehouse].previous_sle or frappe._dict(