Compare commits

...

18 Commits

Author SHA1 Message Date
Mihir Kandoi
71ccc8885e Merge pull request #57253 from aerele/fix-pick-list-work-order-transferred-qty-leak
fix: exclude transferred_qty from work order item to pick list item m…
2026-07-18 14:05:55 +05:30
pandiyan
5b36f12596 fix: exclude transferred_qty from work order item to pick list item mapping
get_mapped_doc copies same-named fields by default. work order item's
transferred_qty (cumulative across the whole work order) was leaking into
the new pick list item's transferred_qty (meant to track how much of
that pick list row has been converted into a stock entry, starting at 0).

the leaked value then got subtracted again in
get_pending_transfer_stock_qty(), so every pick list after the first
under-transferred raw materials by whatever was already recorded on the
work order, driving material_transferred_for_manufacturing towards zero
across repeated partial pick-list/finish cycles.

fixes #57236, related to #56596
2026-07-18 13:36:48 +05:30
Mihir Kandoi
ca5bec2b77 Merge pull request #57249 from mihir-kandoi/ppmr
fix: add fetch from in production plan material request child table
2026-07-17 22:19:58 +05:30
Mihir Kandoi
dfc2a411e1 fix: add fetch from in production plan material request child table 2026-07-17 22:08:15 +05:30
kaulith
1aee0df79a fix: force-delete repost data file during cleanup (#57245)
* fix(stock): force-delete repost data file during cleanup

* test(stock): cover repost data file cleanup with attach guard
2026-07-17 22:07:20 +05:30
Mihir Kandoi
e0896c656c Merge pull request #57244 from mihir-kandoi/fix-clear-old-logs-orphan-references
fix: clear linked comments, versions and attachments with old logs
2026-07-17 22:06:23 +05:30
Mihir Kandoi
6a69237130 Update erpnext/utilities/__init__.py
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-07-17 21:53:16 +05:30
Mihir Kandoi
334346e0f8 Merge pull request #57241 from mihir-kandoi/fix-material-request-buying-price-list
fix: validate buying price list on material request and update item rates on change
2026-07-17 21:38:10 +05:30
Mihir Kandoi
3a63f61832 chore: remove unneccessary flt 2026-07-17 21:24:40 +05:30
Mihir Kandoi
1887825ce5 fix: clear linked comments, versions and attachments with old logs
Repost Item Valuation and BOM Update Log cleared old logs with a raw
delete on the parent table, orphaning timeline comments, versions,
attachments and other reference records.

Fixes #57237
2026-07-17 21:24:08 +05:30
Mihir Kandoi
1ef3cd1d3f fix: dont overwrite rate with 0 if not found 2026-07-17 21:23:49 +05:30
Mihir Kandoi
a31119353c Merge pull request #57223 from aerele/project_validation
fix(projects): include on hold status in project filters and reports
2026-07-17 20:46:11 +05:30
Mihir Kandoi
6dcc0cab3a fix: pass ctx keys get_price_list_rate_for reads, skip rate update on insert
update_item_rates passed price_not_uom_dependent, a key
get_price_list_rate_for never reads, and omitted conversion_factor, so a
stock-UOM price was never converted to the row UOM. The function's
(historically misnamed) price_list_uom_dependant ctx key carries the
Price List's price_not_uom_dependent value: truthy returns the found
rate as-is, falsy multiplies by conversion_factor.

Also guard on_update with is_new(): has_value_changed returns True when
there is no doc_before_save, so every first save re-wrote item rates.
2026-07-17 20:44:07 +05:30
Mihir Kandoi
18b15f2ca9 fix: validate buying price list on material request and update item rates on change 2026-07-17 20:44:07 +05:30
Jatin3128
a33da337ec feat: block sales invoice submit when customer overdue exceeds threshold (#57230)
* feat: block sales invoice submit when customer overdue exceeds threshold

Adds an opt-in, per-customer Overdue Billing Threshold. When enabled in
Accounts Settings, submitting a Sales Invoice is blocked if the customer's
overdue amount exceeds their threshold, unless the current user holds a
configured bypass role. Modeled on the existing credit limit feature.

- Accounts Settings (Credit Limits tab): enable toggle + bypass role.
- Per-customer threshold on the Customer Credit Limit table, shown only
  when the feature is enabled via a property setter (same mechanism as
  subscription / accounting dimension sections). Table relabeled to
  "Credit & Overdue Limits".
- Overdue is read live from the ledger via get_outstanding_invoices
  (payments already netted), summing Sales Invoices past their due date.
- Enforced in Sales Invoice on_submit, after the credit-limit check;
  returns are exempt.
- validate_credit_limit_on_change no longer trips when a row sets only
  the overdue threshold (credit_limit = 0).

Fixes #52960

* fix: compute overdue amount in company currency and format with fmt_money

get_customer_overdue_amount now sums GL Entry debit - credit grouped per
invoice, which is always booked in company currency, instead of using
get_outstanding_invoices which returns the receivable-account currency.
The threshold is in company currency, so the previous comparison could mix
currencies for customers with a foreign-currency receivable account. This
mirrors how get_customer_outstanding computes the figure for the existing
credit-limit check.

The blocking message now formats both amounts with fmt_money using the
company currency.

Adds a test asserting a 100 USD invoice at a conversion rate of 50 is
counted as 5000 in company currency.

* refactor: drop redundant threshold coercion and dead test cleanup

- Coerce the overdue threshold with flt() once when reading it, instead of
  calling flt() on it at each of the three use sites.
- Remove a no-op set_overdue_billing_threshold() call in the feature-disabled
  block (the threshold was already set to that value) and the trailing reset,
  which is dead since each test is rolled back.

No behaviour change.

* fix: compute overdue amount from payment terms, matching the Overdue status

The overdue amount keyed on Sales Invoice.due_date, which set_due_date() sets
to the LAST payment term. An invoice whose first term was past due and unpaid
was therefore counted as zero, even though ERPNext already shows it as Overdue
in the invoice list. The gate and the UI could disagree.

get_customer_overdue_amount now follows the same rule as is_overdue(): per
invoice, the amount that has fallen due (sum of payment schedule terms past
their due date) minus what has been paid, clamped to the outstanding balance.
Invoices without a schedule (POS, opening) still fall back to the invoice due
date, mirroring is_overdue()'s own guard.

The ledger stays the source of truth for what is unpaid: the outstanding per
invoice is still SUM(debit) - SUM(credit) from GL Entry. base_payment_amount is
always stored in company currency, so no currency conversion is needed and the
comparison against the threshold stays consistent.

Adds a test covering a two-term invoice: only the past-due term counts, and
paying it off clears the overdue amount.

* feat: honour the overdue billing threshold set on the customer group

The threshold lives on Customer Credit Limit, which is also rendered on
Customer Group. A threshold set there was stored but never evaluated, so the
configuration was a silent no-op.

get_overdue_billing_threshold now reads the customer's row and falls back to
its customer group, mirroring get_credit_limit. The group's
bypass_credit_limit_check is deliberately not consulted: it is labelled for the
credit limit check at sales order and is unrelated to overdue billing.

get_customer_group_details also dropped the threshold when copying group rows
onto a customer, because it copied a single hardcoded field per table. It now
copies a list of fields per table, so credit_limit and overdue_billing_threshold
both carry over.
2026-07-17 18:58:10 +05:30
Poovetha
7248961568 fix(projects): add project filter 2026-07-17 15:03:25 +05:30
Poovetha
79e5ccd370 test(projects): add test to ensure on hold project retains status 2026-07-17 15:03:25 +05:30
Poovetha
51a9fc0316 fix(projects): include on hold status in project filters and reports 2026-07-17 15:03:25 +05:30
22 changed files with 501 additions and 37 deletions

View File

@@ -78,6 +78,8 @@
"over_billing_allowance", "over_billing_allowance",
"credit_controller", "credit_controller",
"role_allowed_to_over_bill", "role_allowed_to_over_bill",
"enable_overdue_billing_threshold",
"role_allowed_to_bypass_overdue_billing",
"column_break_11", "column_break_11",
"assets_tab", "assets_tab",
"asset_settings_section", "asset_settings_section",
@@ -274,6 +276,21 @@
"label": "Role Allowed to over bill ", "label": "Role Allowed to over bill ",
"options": "Role" "options": "Role"
}, },
{
"default": "0",
"description": "Block submitting a new Sales Invoice when the customer's overdue amount exceeds the Overdue Billing Threshold set on the customer.",
"fieldname": "enable_overdue_billing_threshold",
"fieldtype": "Check",
"label": "Enable Overdue Billing Threshold"
},
{
"depends_on": "eval:doc.enable_overdue_billing_threshold",
"description": "Users with this role can still submit invoices for customers over their overdue billing threshold.",
"fieldname": "role_allowed_to_bypass_overdue_billing",
"fieldtype": "Link",
"label": "Role allowed to bypass overdue billing limit",
"options": "Role"
},
{ {
"fieldname": "period_closing_settings_section", "fieldname": "period_closing_settings_section",
"fieldtype": "Section Break" "fieldtype": "Section Break"

View File

@@ -78,6 +78,7 @@ class AccountsSettings(Document):
enable_fuzzy_matching: DF.Check enable_fuzzy_matching: DF.Check
enable_immutable_ledger: DF.Check enable_immutable_ledger: DF.Check
enable_loyalty_point_program: DF.Check enable_loyalty_point_program: DF.Check
enable_overdue_billing_threshold: DF.Check
enable_party_matching: DF.Check enable_party_matching: DF.Check
enable_subscription: DF.Check enable_subscription: DF.Check
exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"] exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"]
@@ -97,6 +98,7 @@ class AccountsSettings(Document):
receivable_payable_remarks_length: DF.Int receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int reconciliation_queue_size: DF.Int
repost_allowed_types: DF.Table[RepostAllowedTypes] repost_allowed_types: DF.Table[RepostAllowedTypes]
role_allowed_to_bypass_overdue_billing: DF.Link | None
role_allowed_to_over_bill: DF.Link | None role_allowed_to_over_bill: DF.Link | None
role_to_notify_on_depreciation_failure: DF.Link | None role_to_notify_on_depreciation_failure: DF.Link | None
role_to_override_stop_action: DF.Link | None role_to_override_stop_action: DF.Link | None
@@ -152,6 +154,10 @@ class AccountsSettings(Document):
toggle_subscription_sections(not self.enable_subscription) toggle_subscription_sections(not self.enable_subscription)
clear_cache = True 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: if clear_cache:
frappe.clear_cache() frappe.clear_cache()
@@ -243,6 +249,10 @@ def toggle_subscription_sections(hide):
create_property_setter_for_hiding_field(doctype, "subscription_section", 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): def create_property_setter_for_hiding_field(doctype, field_name, hide):
make_property_setter( make_property_setter(
doctype, doctype,

View File

@@ -465,6 +465,7 @@ class SalesInvoice(SellingController):
self.update_billing_status_for_zero_amount_refdoc("Delivery Note") self.update_billing_status_for_zero_amount_refdoc("Delivery Note")
self.update_billing_status_for_zero_amount_refdoc("Sales Order") self.update_billing_status_for_zero_amount_refdoc("Sales Order")
self.check_credit_limit() self.check_credit_limit()
self.check_overdue_billing_threshold()
if cint(self.is_pos) != 1 and not self.is_return: if cint(self.is_pos) != 1 and not self.is_return:
self.update_against_document_in_jv() self.update_against_document_in_jv()
@@ -669,6 +670,11 @@ class SalesInvoice(SellingController):
if validate_against_credit_limit: if validate_against_credit_limit:
check_credit_limit(self.customer, self.company, bypass_credit_limit_check_at_sales_order) check_credit_limit(self.customer, self.company, bypass_credit_limit_check_at_sales_order)
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() @frappe.whitelist()
def set_missing_values(self, for_validate: bool = False): def set_missing_values(self, for_validate: bool = False):
pos = POSService(self).set_pos_fields(for_validate) pos = POSService(self).set_pos_fields(for_validate)

View File

@@ -411,7 +411,7 @@ def get_project_name(
if filters.get("company"): if filters.get("company"):
qb_filter_and_conditions.append(proj.company == filters.get("company")) qb_filter_and_conditions.append(proj.company == filters.get("company"))
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled"])) qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled", "On hold"]))
q = qb.from_(proj) q = qb.from_(proj)

View File

@@ -6,9 +6,7 @@ from typing import Any
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder import DocType, Interval from frappe.utils import add_days, cint, cstr, date_diff, now, today
from frappe.query_builder.functions import Now
from frappe.utils import cint, cstr, date_diff, today
from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import ( from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import (
get_leaf_boms, get_leaf_boms,
@@ -17,6 +15,7 @@ from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import (
replace_bom, replace_bom,
set_values_in_log, set_values_in_log,
) )
from erpnext.utilities import clear_logs_with_references
class BOMMissingError(frappe.ValidationError): class BOMMissingError(frappe.ValidationError):
@@ -48,10 +47,12 @@ class BOMUpdateLog(Document):
@staticmethod @staticmethod
def clear_old_logs(days=None): def clear_old_logs(days=None):
days = days or 90 days = days or 90
table = DocType("BOM Update Log") clear_logs_with_references(
frappe.db.delete( "BOM Update Log",
table, {
filters=((table.creation < (Now() - Interval(days=days))) & (table.update_type == "Update Cost")), "creation": ("<", add_days(now(), -days)),
"update_type": "Update Cost",
},
) )
def validate(self): def validate(self):

View File

@@ -28,6 +28,7 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"fetch_from": "material_request.transaction_date",
"fieldname": "material_request_date", "fieldname": "material_request_date",
"fieldtype": "Date", "fieldtype": "Date",
"in_list_view": 1, "in_list_view": 1,
@@ -41,12 +42,14 @@
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-03-27 13:10:20.526011", "modified": "2026-07-17 22:06:35.428875",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Production Plan Material Request", "name": "Production Plan Material Request",
"naming_rule": "Random",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "ASC", "sort_order": "ASC",
"states": [] "states": []

View File

@@ -488,6 +488,7 @@ def _pick_list_mapping(postprocess):
"Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}}, "Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}},
"Work Order Item": { "Work Order Item": {
"doctype": "Pick List Item", "doctype": "Pick List Item",
"field_no_map": ["transferred_qty"],
"postprocess": postprocess, "postprocess": postprocess,
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty), "condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
}, },

View File

@@ -332,6 +332,23 @@ class TestProject(ERPNextTestSuite):
self.assertEqual(project.percent_complete, 100) self.assertEqual(project.percent_complete, 100)
self.assertEqual(project.status, "Cancelled") self.assertEqual(project.status, "Cancelled")
def test_on_hold_project_keeps_status(self):
project, tasks = self._project_with_tasks("Task Completion", 4)
# an On hold project is not auto-flipped to Completed even at 100%
project.status = "On hold"
for task in tasks:
frappe.db.set_value("Task", task, "status", "Completed")
project.update_percent_complete()
self.assertEqual(project.percent_complete, 100)
self.assertEqual(project.status, "On hold")
# nor auto-flipped back to Open when below 100%
frappe.db.set_value("Task", tasks[0], "status", "Open")
project.update_percent_complete()
self.assertEqual(project.percent_complete, 75)
self.assertEqual(project.status, "On hold")
def test_percent_complete_by_task_progress(self): def test_percent_complete_by_task_progress(self):
project, tasks = self._project_with_tasks("Task Progress", 2) project, tasks = self._project_with_tasks("Task Progress", 2)

View File

@@ -14,6 +14,12 @@ frappe.ui.form.on("Task", {
}; };
}, },
onload: function (frm) { onload: function (frm) {
frm.set_query("project", function () {
return {
query: "erpnext.controllers.queries.get_project_name",
};
});
frm.set_query("task", "depends_on", function () { frm.set_query("task", "depends_on", function () {
let filters = { let filters = {
name: ["!=", frm.doc.name], name: ["!=", frm.doc.name],

View File

@@ -30,6 +30,7 @@ frappe.ui.form.on("Timesheet", {
return { return {
filters: { filters: {
company: frm.doc.company, company: frm.doc.company,
status: "Open",
}, },
}; };
}; };
@@ -122,6 +123,7 @@ frappe.ui.form.on("Timesheet", {
return { return {
filters: { filters: {
customer: doc.customer, customer: doc.customer,
status: "Open",
}, },
}; };
}); });

View File

@@ -22,7 +22,7 @@ frappe.query_reports["Project Summary"] = {
fieldname: "status", fieldname: "status",
label: __("Status"), label: __("Status"),
fieldtype: "Select", fieldtype: "Select",
options: "\nOpen\nCompleted\nCancelled", options: "\nOpen\nOn hold\nCompleted\nCancelled",
default: "Open", default: "Open",
}, },
{ {

View File

@@ -470,10 +470,10 @@
"report_hide": 1 "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 the overdue billing setting is enabled, new invoices are also blocked when the customer's overdue amount exceeds the overdue billing threshold.",
"fieldname": "credit_limits", "fieldname": "credit_limits",
"fieldtype": "Table", "fieldtype": "Table",
"label": "Credit Limit", "label": "Credit & Overdue Limits",
"options": "Customer Credit Limit", "options": "Customer Credit Limit",
"show_description_on_click": 1 "show_description_on_click": 1
}, },

View File

@@ -16,7 +16,7 @@ from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_
from frappe.model.utils.rename_doc import update_linked_doctypes from frappe.model.utils.rename_doc import update_linked_doctypes
from frappe.query_builder import CustomFunction, Field, functions from frappe.query_builder import CustomFunction, Field, functions
from frappe.query_builder.functions import Cast, Coalesce, Max from frappe.query_builder.functions import Cast, Coalesce, Max
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 frappe.utils.user import get_users_with_role
from erpnext.accounts.party import ( from erpnext.accounts.party import (
@@ -210,17 +210,21 @@ class Customer(TransactionBase):
self.credit_limits = [] self.credit_limits = []
self.payment_terms = self.default_price_list = "" 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"] fields = ["payment_terms", "default_price_list"]
for row in tables: for row in tables:
table, field = row[0], row[1] table, table_fields = row[0], row[1]
if not doc.get(table): if not doc.get(table):
continue continue
for entry in doc.get(table): for entry in doc.get(table):
child = self.append(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: for field in fields:
if not doc.get(field): if not doc.get(field):
@@ -409,6 +413,9 @@ class Customer(TransactionBase):
else: else:
company_record.append(limit.company) company_record.append(limit.company)
if not flt(limit.credit_limit):
continue
outstanding_amt = get_customer_outstanding( outstanding_amt = get_customer_outstanding(
self.name, limit.company, ignore_outstanding_sales_order=limit.bypass_credit_limit_check self.name, limit.company, ignore_outstanding_sales_order=limit.bypass_credit_limit_check
) )
@@ -576,6 +583,126 @@ def send_emails(
frappe.sendmail(recipients=credit_controller_users_list, subject=subject, message=message) 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(
_(
"Customer {0} has an overdue billing limit. Overdue amount {1} exceeds the allowed threshold {2}."
).format(
customer,
fmt_money(overdue_amount, currency=company_currency),
fmt_money(threshold, currency=company_currency),
),
title=_("Overdue Billing Limit Crossed"),
)
def get_overdue_billing_threshold(customer: str, company: str) -> float:
"""Threshold 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 threshold
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): def get_customer_outstanding(customer, company, ignore_outstanding_sales_order=False, cost_center=None):
from frappe.query_builder import Criterion from frappe.query_builder import Criterion
from frappe.query_builder.functions import Coalesce, IfNull, Sum from frappe.query_builder.functions import Coalesce, IfNull, Sum

View File

@@ -5,13 +5,15 @@
import json import json
import frappe 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.accounts.party import get_due_date
from erpnext.exceptions import PartyDisabled, PartyFrozen from erpnext.exceptions import PartyDisabled, PartyFrozen
from erpnext.selling.doctype.customer.customer import ( from erpnext.selling.doctype.customer.customer import (
get_credit_limit, get_credit_limit,
get_customer_outstanding, get_customer_outstanding,
get_customer_overdue_amount,
get_overdue_billing_threshold,
) )
from erpnext.selling.doctype.customer.mapper import ( from erpnext.selling.doctype.customer.mapper import (
make_quotation, make_quotation,
@@ -93,7 +95,11 @@ class TestCustomer(ERPNextTestSuite):
"company": "_Test Company", "company": "_Test Company",
"account": "Creditors - _TC", "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("accounts", test_account_details)
doc.append("credit_limits", test_credit_limits) doc.append("credit_limits", test_credit_limits)
doc.insert() doc.insert()
@@ -113,6 +119,7 @@ class TestCustomer(ERPNextTestSuite):
self.assertEqual(c_doc.credit_limits[0].company, "_Test Company") 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].credit_limit, 350000)
self.assertEqual(c_doc.credit_limits[0].overdue_billing_threshold, 5000)
c_doc.delete() c_doc.delete()
doc.delete() doc.delete()
@@ -368,6 +375,128 @@ class TestCustomer(ERPNextTestSuite):
) )
self.assertRaises(frappe.ValidationError, customer.save) 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")
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)
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)
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): def test_customer_payment_terms(self):
frappe.db.set_value( frappe.db.set_value(
"Customer", "_Test Customer With Template", "payment_terms", "_Test Payment Term Template 3" "Customer", "_Test Customer With Template", "payment_terms", "_Test Payment Term Template 3"
@@ -476,6 +605,18 @@ def set_credit_limit(customer, company, credit_limit):
customer.credit_limits[-1].db_insert() 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): def create_internal_customer(customer_name=None, represents_company=None, allowed_to_interact_with=None):
if not customer_name: if not customer_name:
customer_name = represents_company customer_name = represents_company

View File

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

View File

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

View File

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

View File

@@ -100,7 +100,10 @@ frappe.ui.form.on("Material Request", {
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
if (!frm.doc.buying_price_list) { if (!frm.doc.buying_price_list) {
frm.doc.buying_price_list = frappe.defaults.get_default("buying_price_list"); const buying_price_list = frappe.defaults.get_default("buying_price_list");
if (frappe.has_permission("Price List", "read", buying_price_list)) {
frm.set_value("buying_price_list", buying_price_list);
}
} }
}, },
@@ -287,9 +290,7 @@ frappe.ui.form.on("Material Request", {
from_warehouse: item.from_warehouse, from_warehouse: item.from_warehouse,
warehouse: item.warehouse, warehouse: item.warehouse,
doctype: frm.doc.doctype, doctype: frm.doc.doctype,
buying_price_list: frm.doc.buying_price_list buying_price_list: frm.doc.buying_price_list,
? frm.doc.buying_price_list
: frappe.defaults.get_default("buying_price_list"),
currency: frappe.defaults.get_default("Currency"), currency: frappe.defaults.get_default("Currency"),
name: frm.doc.name, name: frm.doc.name,
qty: item.qty || 1, qty: item.qty || 1,

View File

@@ -18,6 +18,7 @@ from frappe.utils import cint, flt, get_datetime, get_link_to_form, getdate, new
from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items
from erpnext.controllers.buying_controller import BuyingController from erpnext.controllers.buying_controller import BuyingController
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
from erpnext.stock.get_item_details import get_price_list_rate_for
from erpnext.stock.stock_balance import get_indented_qty, update_bin_qty from erpnext.stock.stock_balance import get_indented_qty, update_bin_qty
from .mapper import ( from .mapper import (
@@ -192,8 +193,46 @@ class MaterialRequest(BuyingController):
self.validate_pp_qty() self.validate_pp_qty()
if self.buying_price_list and not frappe.get_value("Price List", self.buying_price_list, "buying"):
self.buying_price_list = None
if not self.buying_price_list: if not self.buying_price_list:
self.buying_price_list = frappe.defaults.get_defaults().buying_price_list buying_price_list = frappe.defaults.get_defaults().buying_price_list
if frappe.has_permission("Price List", "read", buying_price_list):
self.buying_price_list = buying_price_list
def on_update(self):
if not self.is_new() and self.buying_price_list and self.has_value_changed("buying_price_list"):
self.update_item_rates()
def update_item_rates(self):
price_not_uom_dependent = frappe.get_value(
"Price List", self.buying_price_list, "price_not_uom_dependent"
)
for item in self.items:
rate = get_price_list_rate_for(
frappe._dict(
{
"price_list": self.buying_price_list,
"uom": item.uom,
"transaction_date": self.transaction_date,
"qty": item.qty,
"stock_uom": item.stock_uom,
"conversion_factor": item.conversion_factor,
"price_list_uom_dependant": price_not_uom_dependent,
}
),
item.item_code,
)
if rate is not None:
item.db_set({"rate": rate, "amount": flt(rate * item.qty, item.precision("amount"))})
frappe.msgprint(
_("Item rates have been updated based on the selected Buying Price List {0}").format(
self.buying_price_list
),
alert=True,
)
def validate_pp_qty(self): def validate_pp_qty(self):
items_from_pp = [item for item in self.items if item.material_request_plan_item] items_from_pp = [item for item in self.items if item.material_request_plan_item]

View File

@@ -6,9 +6,18 @@ from frappe import _
from frappe.desk.form.load import get_attachments from frappe.desk.form.load import get_attachments
from frappe.exceptions import QueryDeadlockError, QueryTimeoutError from frappe.exceptions import QueryDeadlockError, QueryTimeoutError
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder import DocType, Interval from frappe.query_builder import DocType
from frappe.query_builder.functions import CombineDatetime, Max, Now from frappe.query_builder.functions import CombineDatetime, Max
from frappe.utils import cint, get_datetime, get_link_to_form, get_weekday, getdate, now, nowtime from frappe.utils import (
add_days,
cint,
get_datetime,
get_link_to_form,
get_weekday,
getdate,
now,
nowtime,
)
from frappe.utils.user import get_users_with_role from frappe.utils.user import get_users_with_role
from rq.timeouts import JobTimeoutException from rq.timeouts import JobTimeoutException
@@ -22,6 +31,7 @@ from erpnext.stock.stock_ledger import (
repost_future_sle, repost_future_sle,
) )
from erpnext.stock.utils import get_combine_datetime from erpnext.stock.utils import get_combine_datetime
from erpnext.utilities import clear_logs_with_references
RecoverableErrors = (JobTimeoutException, QueryDeadlockError, QueryTimeoutError) RecoverableErrors = (JobTimeoutException, QueryDeadlockError, QueryTimeoutError)
@@ -65,13 +75,12 @@ class RepostItemValuation(Document):
@staticmethod @staticmethod
def clear_old_logs(days=None): def clear_old_logs(days=None):
days = days or 90 days = days or 90
table = DocType("Repost Item Valuation") clear_logs_with_references(
frappe.db.delete( "Repost Item Valuation",
table, {
filters=( "creation": ("<", add_days(now(), -days)),
(table.creation < (Now() - Interval(days=days))) "status": ("in", ["Completed", "Skipped"]),
& (table.status.isin(["Completed", "Skipped"])) },
),
) )
def on_discard(self): def on_discard(self):
@@ -242,7 +251,7 @@ class RepostItemValuation(Document):
def clear_attachment(self): def clear_attachment(self):
if attachments := get_attachments(self.doctype, self.name): if attachments := get_attachments(self.doctype, self.name):
attachment = attachments[0] 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: if self.reposting_data_file:
self.db_set("reposting_data_file", None) self.db_set("reposting_data_file", None)

View File

@@ -104,6 +104,15 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin):
repost_doc.creation = add_days(now(), days=-i * 10) repost_doc.creation = add_days(now(), days=-i * 10)
repost_doc.db_update_all() repost_doc.db_update_all()
repost_doc.add_comment("Comment", "test comment")
frappe.new_doc(
"File",
file_name="test_clear_old_logs.txt",
content="test",
attached_to_doctype=repost_doc.doctype,
attached_to_name=repost_doc.name,
).insert(ignore_permissions=True)
logs = frappe.get_all("Repost Item Valuation", filters={"status": "Skipped"}) logs = frappe.get_all("Repost Item Valuation", filters={"status": "Skipped"})
self.assertGreater(len(logs), 10) self.assertGreater(len(logs), 10)
@@ -114,6 +123,15 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin):
logs = frappe.get_all("Repost Item Valuation", filters={"status": "Skipped"}) logs = frappe.get_all("Repost Item Valuation", filters={"status": "Skipped"})
self.assertEqual(len(logs), 0) self.assertEqual(len(logs), 0)
orphan_reference = {"reference_doctype": repost_doc.doctype, "reference_name": repost_doc.name}
self.assertFalse(frappe.get_all("Comment", filters=orphan_reference))
self.assertFalse(
frappe.get_all(
"File",
filters={"attached_to_doctype": repost_doc.doctype, "attached_to_name": repost_doc.name},
)
)
def test_create_item_wise_repost_item_valuation_entries(self): def test_create_item_wise_repost_item_valuation_entries(self):
pr = make_purchase_receipt( pr = make_purchase_receipt(
company="_Test Company with perpetual inventory", company="_Test Company with perpetual inventory",
@@ -670,6 +688,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( @ERPNextTestSuite.change_settings(
"Stock Reposting Settings", "Stock Reposting Settings",
{"item_based_reposting": 1, "enable_parallel_reposting": 1, "no_of_parallel_reposting": 2}, {"item_based_reposting": 1, "enable_parallel_reposting": 1, "no_of_parallel_reposting": 2},

View File

@@ -4,10 +4,37 @@ from contextlib import contextmanager
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import cstr from frappe.utils import create_batch, cstr
from erpnext.utilities.activation import get_level from erpnext.utilities.activation import get_level
LOG_REFERENCE_FIELDS = {
"Comment": ("reference_doctype", "reference_name"),
"Version": ("ref_doctype", "docname"),
"ToDo": ("reference_type", "reference_name"),
"DocShare": ("share_doctype", "share_name"),
"View Log": ("reference_doctype", "reference_name"),
"Document Follow": ("ref_doctype", "ref_docname"),
"Notification Log": ("document_type", "document_name"),
}
def clear_logs_with_references(doctype, filters):
names = frappe.get_all(doctype, filters=filters, pluck="name")
for batch in create_batch(names, 1000):
attached_files = frappe.get_all(
"File",
filters={"attached_to_doctype": doctype, "attached_to_name": ("in", batch)},
pluck="name",
)
if attached_files:
frappe.delete_doc("File", attached_files, ignore_permissions=True, delete_permanently=True)
for reference_doctype, (doctype_field, name_field) in LOG_REFERENCE_FIELDS.items():
frappe.db.delete(reference_doctype, {doctype_field: doctype, name_field: ("in", batch)})
frappe.db.delete(doctype, {"name": ("in", batch)})
def update_doctypes(): def update_doctypes():
df = frappe.qb.DocType("DocField") df = frappe.qb.DocType("DocField")