mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-18 12:46:29 +00:00
Compare commits
18 Commits
l10n_devel
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71ccc8885e | ||
|
|
5b36f12596 | ||
|
|
ca5bec2b77 | ||
|
|
dfc2a411e1 | ||
|
|
1aee0df79a | ||
|
|
e0896c656c | ||
|
|
6a69237130 | ||
|
|
334346e0f8 | ||
|
|
3a63f61832 | ||
|
|
1887825ce5 | ||
|
|
1ef3cd1d3f | ||
|
|
a31119353c | ||
|
|
6dcc0cab3a | ||
|
|
18b15f2ca9 | ||
|
|
a33da337ec | ||
|
|
7248961568 | ||
|
|
79e5ccd370 | ||
|
|
51a9fc0316 |
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user