mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-26 22:18:18 +00:00
Compare commits
30 Commits
v16.29.0
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
708d889036 | ||
|
|
20e2728037 | ||
|
|
2359e1610a | ||
|
|
2e5fc38179 | ||
|
|
6d856fa632 | ||
|
|
1802c75ed6 | ||
|
|
c182b4085b | ||
|
|
a0af717234 | ||
|
|
d3c5e866f0 | ||
|
|
2b8b6a09dd | ||
|
|
c79f2e45a8 | ||
|
|
ab4b5abb91 | ||
|
|
3f6501b4ff | ||
|
|
2206e9fe2a | ||
|
|
bbe7580c9d | ||
|
|
ef21a9ba9b | ||
|
|
88f9039d1a | ||
|
|
2e6b4b5838 | ||
|
|
85d793b690 | ||
|
|
8a50572786 | ||
|
|
bbd942c600 | ||
|
|
84813d7f46 | ||
|
|
1132eb1a0f | ||
|
|
2323f2c978 | ||
|
|
e277647f7b | ||
|
|
c587f4934a | ||
|
|
e0020b478a | ||
|
|
95de2374ed | ||
|
|
d70e005412 | ||
|
|
32baf6a47c |
@@ -17,7 +17,7 @@ class ERPNextAddress(Address):
|
||||
|
||||
def link_address(self):
|
||||
"""Link address based on owner"""
|
||||
if self.is_your_company_address:
|
||||
if self.get("is_your_company_address"):
|
||||
return
|
||||
|
||||
return super().link_address()
|
||||
@@ -28,7 +28,9 @@ class ERPNextAddress(Address):
|
||||
self.is_your_company_address = 1
|
||||
|
||||
def validate_reference(self):
|
||||
if self.is_your_company_address and not [row for row in self.links if row.link_doctype == "Company"]:
|
||||
if self.get("is_your_company_address") and not [
|
||||
row for row in self.links if row.link_doctype == "Company"
|
||||
]:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Address needs to be linked to a Company. Please add a row for Company in the Links table."
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"over_billing_allowance",
|
||||
"credit_controller",
|
||||
"role_allowed_to_over_bill",
|
||||
"enable_overdue_billing_threshold",
|
||||
"role_allowed_to_bypass_overdue_billing",
|
||||
"column_break_11",
|
||||
"assets_tab",
|
||||
"asset_settings_section",
|
||||
@@ -271,6 +273,21 @@
|
||||
"label": "Role Allowed to over bill ",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.",
|
||||
"fieldname": "enable_overdue_billing_threshold",
|
||||
"fieldtype": "Check",
|
||||
"label": "Restrict Customer Over Billing"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enable_overdue_billing_threshold",
|
||||
"description": "Users with this role can still submit invoices for customers who have crossed their Overdue Limit.",
|
||||
"fieldname": "role_allowed_to_bypass_overdue_billing",
|
||||
"fieldtype": "Link",
|
||||
"label": "Role Allowed to Bypass Over Billing Restriction",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
"fieldname": "period_closing_settings_section",
|
||||
"fieldtype": "Section Break"
|
||||
|
||||
@@ -77,6 +77,7 @@ class AccountsSettings(Document):
|
||||
enable_fuzzy_matching: DF.Check
|
||||
enable_immutable_ledger: DF.Check
|
||||
enable_loyalty_point_program: DF.Check
|
||||
enable_overdue_billing_threshold: DF.Check
|
||||
enable_party_matching: DF.Check
|
||||
enable_subscription: DF.Check
|
||||
exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"]
|
||||
@@ -95,6 +96,7 @@ class AccountsSettings(Document):
|
||||
receivable_payable_remarks_length: DF.Int
|
||||
reconciliation_queue_size: DF.Int
|
||||
repost_allowed_types: DF.Table[RepostAllowedTypes]
|
||||
role_allowed_to_bypass_overdue_billing: DF.Link | None
|
||||
role_allowed_to_over_bill: DF.Link | None
|
||||
role_to_notify_on_depreciation_failure: DF.Link | None
|
||||
role_to_override_stop_action: DF.Link | None
|
||||
@@ -150,6 +152,10 @@ class AccountsSettings(Document):
|
||||
toggle_subscription_sections(not self.enable_subscription)
|
||||
clear_cache = True
|
||||
|
||||
if old_doc.enable_overdue_billing_threshold != self.enable_overdue_billing_threshold:
|
||||
toggle_overdue_billing_threshold_field(not self.enable_overdue_billing_threshold)
|
||||
clear_cache = True
|
||||
|
||||
if clear_cache:
|
||||
frappe.clear_cache()
|
||||
|
||||
@@ -241,6 +247,10 @@ def toggle_subscription_sections(hide):
|
||||
create_property_setter_for_hiding_field(doctype, "subscription_section", hide)
|
||||
|
||||
|
||||
def toggle_overdue_billing_threshold_field(hide):
|
||||
create_property_setter_for_hiding_field("Customer Credit Limit", "overdue_billing_threshold", hide)
|
||||
|
||||
|
||||
def create_property_setter_for_hiding_field(doctype, field_name, hide):
|
||||
make_property_setter(
|
||||
doctype,
|
||||
|
||||
@@ -517,6 +517,7 @@ class SalesInvoice(SellingController):
|
||||
self.update_billing_status_for_zero_amount_refdoc("Delivery Note")
|
||||
self.update_billing_status_for_zero_amount_refdoc("Sales Order")
|
||||
self.check_credit_limit()
|
||||
self.check_overdue_billing_threshold()
|
||||
|
||||
if cint(self.is_pos) != 1 and not self.is_return:
|
||||
self.update_against_document_in_jv()
|
||||
@@ -778,6 +779,11 @@ class SalesInvoice(SellingController):
|
||||
pos_invoice_doc = frappe.get_doc("POS Invoice", pos_invoice)
|
||||
pos_invoice_doc.cancel()
|
||||
|
||||
def check_overdue_billing_threshold(self):
|
||||
from erpnext.selling.doctype.customer.customer import check_overdue_billing_threshold
|
||||
|
||||
check_overdue_billing_threshold(self.customer, self.company)
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_missing_values(self, for_validate=False):
|
||||
pos = self.set_pos_fields(for_validate)
|
||||
|
||||
@@ -82,8 +82,7 @@
|
||||
"fieldname": "cost_center",
|
||||
"fieldtype": "Link",
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center",
|
||||
"reqd": 1
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "shipping_amount_section",
|
||||
@@ -141,19 +140,20 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-truck",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:41.653314",
|
||||
"modified": "2026-07-22 14:53:27.315435",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Shipping Rule",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -197,7 +197,8 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,18 +36,17 @@ class ShippingRule(Document):
|
||||
from erpnext.accounts.doctype.shipping_rule_condition.shipping_rule_condition import (
|
||||
ShippingRuleCondition,
|
||||
)
|
||||
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import (
|
||||
ShippingRuleCountry,
|
||||
)
|
||||
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ShippingRuleCountry
|
||||
|
||||
account: DF.Link
|
||||
calculate_based_on: DF.Literal["Fixed", "Net Total", "Net Weight"]
|
||||
company: DF.Link
|
||||
conditions: DF.Table[ShippingRuleCondition]
|
||||
cost_center: DF.Link
|
||||
cost_center: DF.Link | None
|
||||
countries: DF.Table[ShippingRuleCountry]
|
||||
disabled: DF.Check
|
||||
label: DF.Data
|
||||
project: DF.Link | None
|
||||
shipping_amount: DF.Currency
|
||||
shipping_rule_type: DF.Literal["Selling", "Buying"]
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -858,7 +858,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
|
||||
|
||||
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
|
||||
|
||||
companies = frappe.get_all(
|
||||
companies = frappe.get_list(
|
||||
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -69,10 +69,10 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -98,10 +98,10 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -55,8 +55,7 @@ class ReceivablePayableReport:
|
||||
self.filters.report_date = getdate(self.filters.report_date or nowdate())
|
||||
self.age_as_on = (
|
||||
getdate(nowdate())
|
||||
if "calculate_ageing_with" not in self.filters
|
||||
or self.filters.calculate_ageing_with == "Today Date"
|
||||
if "age_as_on" not in self.filters or self.filters.age_as_on == "Today"
|
||||
else self.filters.report_date
|
||||
)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1371,8 +1371,9 @@ class StockController(AccountsController):
|
||||
if outstanding > 0:
|
||||
reservations[key].append(row)
|
||||
|
||||
precision = frappe.get_precision("Serial and Batch Entry", "qty")
|
||||
for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
|
||||
if flt(reserved_qty, 6) <= 0:
|
||||
if flt(reserved_qty, precision) <= 0:
|
||||
continue
|
||||
|
||||
batch_qty = get_batch_qty(
|
||||
@@ -1383,7 +1384,7 @@ class StockController(AccountsController):
|
||||
consider_negative_batches=True,
|
||||
)
|
||||
|
||||
if flt(batch_qty, 6) >= flt(reserved_qty, 6):
|
||||
if flt(batch_qty, precision) >= flt(reserved_qty, precision):
|
||||
continue
|
||||
|
||||
vouchers = ", ".join(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -79,6 +79,11 @@ frappe.ui.form.on("BOM Operation", {
|
||||
const d = locals[cdt][cdn];
|
||||
frm.events.calculate_operating_cost(frm, d);
|
||||
},
|
||||
|
||||
hour_rate: function (frm, cdt, cdn) {
|
||||
const d = locals[cdt][cdn];
|
||||
frm.events.calculate_operating_cost(frm, d);
|
||||
},
|
||||
});
|
||||
|
||||
frappe.tour["Routing"] = [
|
||||
|
||||
@@ -2554,7 +2554,13 @@ def get_item_details(item, project=None, skip_bom_info=False, throw=True):
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_work_order(
|
||||
bom_no, item, qty=0, company=None, project=None, variant_items=None, use_multi_level_bom=None
|
||||
bom_no: str,
|
||||
item: str,
|
||||
qty: float = 0,
|
||||
company: str | None = None,
|
||||
project: str | None = None,
|
||||
variant_items: str | list | None = None,
|
||||
use_multi_level_bom: bool | None = None,
|
||||
):
|
||||
from erpnext import get_default_company
|
||||
|
||||
@@ -2563,7 +2569,8 @@ def make_work_order(
|
||||
|
||||
item_details = get_item_details(item, project)
|
||||
|
||||
if frappe.db.get_value("Item", item, "variant_of"):
|
||||
# selected BOM already belongs to this variant — keep it
|
||||
if frappe.db.get_value("Item", item, "variant_of") and frappe.db.get_value("BOM", bom_no, "item") != item:
|
||||
if variant_bom := frappe.db.get_value(
|
||||
"BOM",
|
||||
{"item": item, "is_default": 1, "docstatus": 1},
|
||||
|
||||
@@ -491,3 +491,4 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
|
||||
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
|
||||
erpnext.patches.v16_0.backfill_pick_list_transferred_qty
|
||||
erpnext.patches.v16_0.access_control_for_project_users
|
||||
erpnext.patches.v16_0.rename_ar_ap_ageing_filter
|
||||
|
||||
45
erpnext/patches/v16_0/rename_ar_ap_ageing_filter.py
Normal file
45
erpnext/patches/v16_0/rename_ar_ap_ageing_filter.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import frappe
|
||||
|
||||
REPORTS = (
|
||||
"Accounts Receivable",
|
||||
"Accounts Payable",
|
||||
"Accounts Receivable Summary",
|
||||
"Accounts Payable Summary",
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
# filter `calculate_ageing_with` -> `age_as_on`, option "Today Date" -> "Today"
|
||||
_migrate("Auto Email Report", "filters", "report")
|
||||
_migrate("Dashboard Chart", "filters_json", "report_name", type_field="chart_type")
|
||||
_migrate("Number Card", "filters_json", "report_name", type_field="type")
|
||||
|
||||
|
||||
def _migrate(doctype, filter_field, report_field, type_field=None):
|
||||
conditions = {report_field: ("in", REPORTS)}
|
||||
if type_field:
|
||||
conditions[type_field] = "Report"
|
||||
|
||||
for row in frappe.get_all(doctype, filters=conditions, fields=["name", filter_field]):
|
||||
updated = _rewrite(row.get(filter_field))
|
||||
if updated is not None:
|
||||
frappe.db.set_value(doctype, row.name, filter_field, updated, update_modified=False)
|
||||
|
||||
|
||||
def _rewrite(raw):
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
try:
|
||||
filters = frappe.parse_json(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if not isinstance(filters, dict) or "calculate_ageing_with" not in filters:
|
||||
return None
|
||||
|
||||
filters["age_as_on"] = filters.pop("calculate_ageing_with")
|
||||
if filters["age_as_on"] == "Today Date":
|
||||
filters["age_as_on"] = "Today"
|
||||
|
||||
return frappe.as_json(filters, indent=None)
|
||||
@@ -11,6 +11,36 @@ erpnext.stock.StockController = class StockController extends frappe.ui.form.Con
|
||||
}
|
||||
}
|
||||
|
||||
onload_post_render() {
|
||||
this.set_route_options_for_new_doc();
|
||||
}
|
||||
|
||||
set_route_options_for_new_doc() {
|
||||
// While creating a Batch or Serial and Batch Bundle from the link
|
||||
// field, copy details from the line item to the new form
|
||||
if (!this.frm.fields_dict.items) return;
|
||||
|
||||
let batch_no_field = this.frm.get_docfield("items", "batch_no");
|
||||
if (batch_no_field) {
|
||||
batch_no_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item: row.doc.item_code,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle");
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item_code: row.doc.item_code,
|
||||
warehouse: row.doc.warehouse || row.doc.s_warehouse || row.doc.t_warehouse,
|
||||
voucher_type: this.frm.doc.doctype,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
barcode(doc, cdt, cdn) {
|
||||
let row = locals[cdt][cdn];
|
||||
if (row.barcode) {
|
||||
|
||||
@@ -649,34 +649,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
erpnext.toggle_serial_batch_fields(this.frm);
|
||||
}
|
||||
|
||||
set_route_options_for_new_doc() {
|
||||
// While creating the batch from the link field, copy item from line item to batch form
|
||||
|
||||
if (this.frm.fields_dict["items"].grid.get_field("batch_no")) {
|
||||
let batch_no_field = this.frm.get_docfield("items", "batch_no");
|
||||
if (batch_no_field) {
|
||||
batch_no_field.get_route_options_for_new_doc = function (row) {
|
||||
return {
|
||||
item: row.doc.item_code,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// While creating the SABB from the link field, copy item, doctype from line item to SABB form
|
||||
if (this.frm.fields_dict["items"].grid.get_field("serial_and_batch_bundle")) {
|
||||
let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle");
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item_code: row.doc.item_code,
|
||||
voucher_type: this.frm.doc.doctype,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scan_barcode() {
|
||||
frappe.flags.dialog_set = false;
|
||||
this.barcode_scanner.process_scan();
|
||||
|
||||
@@ -475,10 +475,10 @@
|
||||
"report_hide": 1
|
||||
},
|
||||
{
|
||||
"description": "Transactions are blocked or warned when outstanding balance exceeds this amount.",
|
||||
"description": "Transactions are blocked when the outstanding balance exceeds the credit limit. When Restrict Customer Over Billing is enabled, new invoices are also blocked when the customer's overdue amount exceeds the Overdue Limit.",
|
||||
"fieldname": "credit_limits",
|
||||
"fieldtype": "Table",
|
||||
"label": "Credit Limit",
|
||||
"label": "Credit & Overdue Limits",
|
||||
"options": "Customer Credit Limit",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
|
||||
from frappe.model.utils.rename_doc import update_linked_doctypes
|
||||
from frappe.query_builder import Field, functions
|
||||
from frappe.utils import cint, cstr, flt, get_formatted_email, today
|
||||
from frappe.utils import cint, cstr, flt, fmt_money, get_formatted_email, getdate, today
|
||||
from frappe.utils.user import get_users_with_role
|
||||
|
||||
from erpnext.accounts.party import (
|
||||
@@ -204,17 +204,21 @@ class Customer(TransactionBase):
|
||||
self.credit_limits = []
|
||||
self.payment_terms = self.default_price_list = ""
|
||||
|
||||
tables = [["accounts", "account"], ["credit_limits", "credit_limit"]]
|
||||
tables = [
|
||||
["accounts", ["account"]],
|
||||
["credit_limits", ["credit_limit", "overdue_billing_threshold"]],
|
||||
]
|
||||
fields = ["payment_terms", "default_price_list"]
|
||||
|
||||
for row in tables:
|
||||
table, field = row[0], row[1]
|
||||
table, table_fields = row[0], row[1]
|
||||
if not doc.get(table):
|
||||
continue
|
||||
|
||||
for entry in doc.get(table):
|
||||
child = self.append(table)
|
||||
child.update({"company": entry.company, field: entry.get(field)})
|
||||
child.update({"company": entry.company})
|
||||
child.update({field: entry.get(field) for field in table_fields})
|
||||
|
||||
for field in fields:
|
||||
if not doc.get(field):
|
||||
@@ -403,6 +407,9 @@ class Customer(TransactionBase):
|
||||
else:
|
||||
company_record.append(limit.company)
|
||||
|
||||
if not flt(limit.credit_limit):
|
||||
continue
|
||||
|
||||
outstanding_amt = get_customer_outstanding(
|
||||
self.name, limit.company, ignore_outstanding_sales_order=limit.bypass_credit_limit_check
|
||||
)
|
||||
@@ -674,6 +681,124 @@ def send_emails(customer, customer_outstanding, credit_limit, credit_controller_
|
||||
frappe.sendmail(recipients=credit_controller_users_list, subject=subject, message=message)
|
||||
|
||||
|
||||
def check_overdue_billing_threshold(customer: str, company: str) -> None:
|
||||
if not frappe.get_single_value("Accounts Settings", "enable_overdue_billing_threshold"):
|
||||
return
|
||||
|
||||
threshold = get_overdue_billing_threshold(customer, company)
|
||||
if not threshold:
|
||||
return
|
||||
|
||||
overdue_amount = get_customer_overdue_amount(customer, company)
|
||||
if overdue_amount <= threshold:
|
||||
return
|
||||
|
||||
bypass_role = frappe.get_single_value("Accounts Settings", "role_allowed_to_bypass_overdue_billing")
|
||||
if bypass_role and bypass_role in frappe.get_roles():
|
||||
return
|
||||
|
||||
company_currency = frappe.get_cached_value("Company", company, "default_currency")
|
||||
frappe.throw(
|
||||
_("Overdue Limit crossed for customer {0}. Overdue amount {1} exceeds the allowed limit {2}.").format(
|
||||
customer,
|
||||
fmt_money(overdue_amount, currency=company_currency),
|
||||
fmt_money(threshold, currency=company_currency),
|
||||
),
|
||||
title=_("Overdue Limit Crossed"),
|
||||
)
|
||||
|
||||
|
||||
def get_overdue_billing_threshold(customer: str, company: str) -> float:
|
||||
"""Overdue limit set on the customer, falling back to its customer group."""
|
||||
threshold = frappe.db.get_value(
|
||||
"Customer Credit Limit",
|
||||
{"parent": customer, "parenttype": "Customer", "company": company},
|
||||
"overdue_billing_threshold",
|
||||
)
|
||||
|
||||
if not threshold:
|
||||
customer_group = frappe.get_cached_value("Customer", customer, "customer_group")
|
||||
threshold = frappe.db.get_value(
|
||||
"Customer Credit Limit",
|
||||
{"parent": customer_group, "parenttype": "Customer Group", "company": company},
|
||||
"overdue_billing_threshold",
|
||||
)
|
||||
|
||||
return flt(threshold)
|
||||
|
||||
|
||||
def get_customer_overdue_amount(customer: str, company: str) -> float:
|
||||
"""Amount the customer owes past its due date, in company currency.
|
||||
|
||||
Follows the same rule as the Overdue invoice status, so a customer is only
|
||||
blocked for what the invoice list already shows as overdue.
|
||||
"""
|
||||
invoices = get_outstanding_invoices_for_customer(customer, company)
|
||||
if not invoices:
|
||||
return 0.0
|
||||
|
||||
payable_amounts = get_past_due_payable_amounts([d.name for d in invoices])
|
||||
return flt(sum(get_overdue_portion(d, payable_amounts.get(d.name)) for d in invoices))
|
||||
|
||||
|
||||
def get_outstanding_invoices_for_customer(customer: str, company: str) -> list[frappe._dict]:
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
gl_entry = frappe.qb.DocType("GL Entry")
|
||||
sales_invoice = frappe.qb.DocType("Sales Invoice")
|
||||
|
||||
# debit - credit is always booked in company currency, so this is comparable to the overdue limit
|
||||
outstanding = Sum(gl_entry.debit) - Sum(gl_entry.credit)
|
||||
|
||||
return (
|
||||
frappe.qb.from_(gl_entry)
|
||||
.inner_join(sales_invoice)
|
||||
.on(sales_invoice.name == gl_entry.against_voucher)
|
||||
.select(
|
||||
sales_invoice.name,
|
||||
sales_invoice.due_date,
|
||||
sales_invoice.base_grand_total,
|
||||
outstanding.as_("outstanding"),
|
||||
)
|
||||
.where(gl_entry.party_type == "Customer")
|
||||
.where(gl_entry.party == customer)
|
||||
.where(gl_entry.company == company)
|
||||
.where(gl_entry.is_cancelled == 0)
|
||||
.where(gl_entry.against_voucher_type == "Sales Invoice")
|
||||
.groupby(sales_invoice.name, sales_invoice.due_date, sales_invoice.base_grand_total)
|
||||
.having(outstanding > 0)
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
def get_past_due_payable_amounts(invoices: list[str]) -> dict[str, float]:
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
payment_schedule = frappe.qb.DocType("Payment Schedule")
|
||||
|
||||
rows = (
|
||||
frappe.qb.from_(payment_schedule)
|
||||
.select(payment_schedule.parent, Sum(payment_schedule.base_payment_amount).as_("payable"))
|
||||
.where(payment_schedule.parenttype == "Sales Invoice")
|
||||
.where(payment_schedule.parent.isin(invoices))
|
||||
.where(payment_schedule.due_date < getdate())
|
||||
.groupby(payment_schedule.parent)
|
||||
).run(as_dict=True)
|
||||
|
||||
return {d.parent: flt(d.payable) for d in rows}
|
||||
|
||||
|
||||
def get_overdue_portion(invoice: frappe._dict, payable_amount: float | None) -> float:
|
||||
outstanding = flt(invoice.outstanding)
|
||||
|
||||
# No payable amount means either a schedule-less invoice (POS, opening) or one whose terms are
|
||||
# all still in the future. Both are answered by the invoice due date, which is the last term.
|
||||
if payable_amount is None:
|
||||
return outstanding if invoice.due_date and getdate(invoice.due_date) < getdate() else 0.0
|
||||
|
||||
paid = flt(invoice.base_grand_total) - outstanding
|
||||
return min(max(payable_amount - paid, 0.0), outstanding)
|
||||
|
||||
|
||||
def get_customer_outstanding(customer, company, ignore_outstanding_sales_order=False, cost_center=None):
|
||||
# Outstanding based on GL Entries
|
||||
cond = ""
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt, nowdate
|
||||
from frappe.utils import add_days, flt, getdate, nowdate
|
||||
|
||||
from erpnext.accounts.party import get_due_date
|
||||
from erpnext.exceptions import PartyDisabled, PartyFrozen
|
||||
from erpnext.selling.doctype.customer.customer import (
|
||||
get_credit_limit,
|
||||
get_customer_outstanding,
|
||||
get_customer_overdue_amount,
|
||||
get_overdue_billing_threshold,
|
||||
make_quotation,
|
||||
parse_full_name,
|
||||
)
|
||||
@@ -77,7 +79,11 @@ class TestCustomer(ERPNextTestSuite):
|
||||
"company": "_Test Company",
|
||||
"account": "Creditors - _TC",
|
||||
}
|
||||
test_credit_limits = {"company": "_Test Company", "credit_limit": 350000}
|
||||
test_credit_limits = {
|
||||
"company": "_Test Company",
|
||||
"credit_limit": 350000,
|
||||
"overdue_billing_threshold": 5000,
|
||||
}
|
||||
doc.append("accounts", test_account_details)
|
||||
doc.append("credit_limits", test_credit_limits)
|
||||
doc.insert()
|
||||
@@ -97,6 +103,7 @@ class TestCustomer(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(c_doc.credit_limits[0].company, "_Test Company")
|
||||
self.assertEqual(c_doc.credit_limits[0].credit_limit, 350000)
|
||||
self.assertEqual(c_doc.credit_limits[0].overdue_billing_threshold, 5000)
|
||||
c_doc.delete()
|
||||
doc.delete()
|
||||
|
||||
@@ -352,6 +359,139 @@ class TestCustomer(ERPNextTestSuite):
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, customer.save)
|
||||
|
||||
def test_get_customer_overdue_amount(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
baseline = get_customer_overdue_amount("_Test Customer", "_Test Company")
|
||||
|
||||
# a past-due, unpaid invoice adds its outstanding to the overdue amount
|
||||
create_sales_invoice(qty=1, rate=500, posting_date=add_days(nowdate(), -30))
|
||||
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 500)
|
||||
|
||||
# an invoice due today (not yet past due) does not
|
||||
create_sales_invoice(qty=1, rate=700, posting_date=nowdate())
|
||||
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 500)
|
||||
|
||||
def test_get_customer_overdue_amount_is_in_company_currency(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
baseline = get_customer_overdue_amount("_Test Customer USD", "_Test Company")
|
||||
|
||||
# 100 USD at a conversion rate of 50 must be counted as 5000 in company currency
|
||||
create_sales_invoice(
|
||||
customer="_Test Customer USD",
|
||||
debit_to="_Test Receivable USD - _TC",
|
||||
currency="USD",
|
||||
conversion_rate=50,
|
||||
qty=1,
|
||||
rate=100,
|
||||
posting_date=add_days(nowdate(), -30),
|
||||
)
|
||||
|
||||
self.assertEqual(get_customer_overdue_amount("_Test Customer USD", "_Test Company"), baseline + 5000)
|
||||
|
||||
def test_get_customer_overdue_amount_follows_payment_terms(self):
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
def make_invoice_with_terms():
|
||||
si = create_sales_invoice(
|
||||
qty=1, rate=1200, posting_date=add_days(nowdate(), -60), do_not_save=True
|
||||
)
|
||||
si.append("payment_schedule", {"due_date": add_days(nowdate(), -60), "invoice_portion": 50})
|
||||
si.append("payment_schedule", {"due_date": add_days(nowdate(), 30), "invoice_portion": 50})
|
||||
si.insert()
|
||||
si.submit()
|
||||
return si
|
||||
|
||||
baseline = get_customer_overdue_amount("_Test Customer", "_Test Company")
|
||||
|
||||
# only the term that has fallen due counts, not the whole 1200 balance. The invoice due_date
|
||||
# is the last term (in 30 days), so this is only caught by reading the payment schedule.
|
||||
si = make_invoice_with_terms()
|
||||
self.assertEqual(getdate(si.due_date), getdate(add_days(nowdate(), 30)))
|
||||
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 600)
|
||||
|
||||
# paying off the past-due term clears the overdue amount
|
||||
pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC")
|
||||
pe.reference_no = "_Test Overdue Payment"
|
||||
pe.reference_date = nowdate()
|
||||
pe.paid_amount = pe.received_amount = 600
|
||||
pe.references[0].allocated_amount = 600
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline)
|
||||
|
||||
def test_overdue_billing_threshold_on_submit(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
create_sales_invoice(qty=1, rate=1000, posting_date=add_days(nowdate(), -30))
|
||||
overdue = get_customer_overdue_amount("_Test Customer", "_Test Company")
|
||||
|
||||
settings = frappe.get_single("Accounts Settings")
|
||||
original_enable = settings.enable_overdue_billing_threshold
|
||||
original_bypass_role = settings.role_allowed_to_bypass_overdue_billing
|
||||
try:
|
||||
settings.enable_overdue_billing_threshold = 1
|
||||
settings.role_allowed_to_bypass_overdue_billing = None
|
||||
settings.save()
|
||||
set_overdue_billing_threshold("_Test Customer", "_Test Company", overdue - 100)
|
||||
|
||||
# overdue is over the threshold and the user has no bypass role -> blocked
|
||||
si = create_sales_invoice(do_not_submit=True)
|
||||
self.assertRaises(frappe.ValidationError, si.submit)
|
||||
|
||||
# a user holding the bypass role can still submit
|
||||
settings.role_allowed_to_bypass_overdue_billing = "Accounts Manager"
|
||||
settings.save()
|
||||
si = create_sales_invoice(do_not_submit=True)
|
||||
si.submit()
|
||||
self.assertEqual(si.docstatus, 1)
|
||||
|
||||
# threshold still crossed, but the feature is off -> never blocked
|
||||
settings.enable_overdue_billing_threshold = 0
|
||||
settings.role_allowed_to_bypass_overdue_billing = None
|
||||
settings.save()
|
||||
si = create_sales_invoice(do_not_submit=True)
|
||||
si.submit()
|
||||
self.assertEqual(si.docstatus, 1)
|
||||
finally:
|
||||
settings.enable_overdue_billing_threshold = original_enable
|
||||
settings.role_allowed_to_bypass_overdue_billing = original_bypass_role
|
||||
settings.save()
|
||||
|
||||
def test_overdue_billing_threshold_falls_back_to_customer_group(self):
|
||||
customer_group = frappe.get_cached_value("Customer", "_Test Customer", "customer_group")
|
||||
group = frappe.get_doc("Customer Group", customer_group)
|
||||
group.credit_limits = []
|
||||
group.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 5000})
|
||||
group.save()
|
||||
|
||||
# the customer has no threshold of its own, so the group's applies
|
||||
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000)
|
||||
|
||||
# a threshold on the customer wins over the group
|
||||
set_overdue_billing_threshold("_Test Customer", "_Test Company", 2000)
|
||||
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 2000)
|
||||
|
||||
# a 0 on the customer inherits the group's limit
|
||||
set_overdue_billing_threshold("_Test Customer", "_Test Company", 0)
|
||||
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000)
|
||||
|
||||
def test_overdue_threshold_row_without_credit_limit(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
# outstanding must be > 0 so a 0 credit_limit would previously trip the check
|
||||
create_sales_invoice(qty=1, rate=500)
|
||||
|
||||
customer = frappe.get_doc("Customer", "_Test Customer")
|
||||
customer.credit_limits = []
|
||||
customer.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 1000})
|
||||
customer.save()
|
||||
|
||||
self.assertEqual(customer.credit_limits[0].overdue_billing_threshold, 1000)
|
||||
self.assertEqual(flt(customer.credit_limits[0].credit_limit), 0.0)
|
||||
|
||||
def test_customer_payment_terms(self):
|
||||
frappe.db.set_value(
|
||||
"Customer", "_Test Customer With Template", "payment_terms", "_Test Payment Term Template 3"
|
||||
@@ -451,6 +591,18 @@ def set_credit_limit(customer, company, credit_limit):
|
||||
customer.credit_limits[-1].db_insert()
|
||||
|
||||
|
||||
def set_overdue_billing_threshold(customer, company, threshold):
|
||||
customer = frappe.get_doc("Customer", customer)
|
||||
for d in customer.credit_limits:
|
||||
if d.company == company:
|
||||
d.overdue_billing_threshold = threshold
|
||||
d.db_update()
|
||||
return
|
||||
|
||||
customer.append("credit_limits", {"company": company, "overdue_billing_threshold": threshold})
|
||||
customer.credit_limits[-1].db_insert()
|
||||
|
||||
|
||||
def create_internal_customer(customer_name=None, represents_company=None, allowed_to_interact_with=None):
|
||||
if not customer_name:
|
||||
customer_name = represents_company
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"company",
|
||||
"column_break_2",
|
||||
"credit_limit",
|
||||
"overdue_billing_threshold",
|
||||
"bypass_credit_limit_check"
|
||||
],
|
||||
"fields": [
|
||||
@@ -18,6 +19,15 @@
|
||||
"in_list_view": 1,
|
||||
"label": "Credit Limit"
|
||||
},
|
||||
{
|
||||
"columns": 3,
|
||||
"description": "New Sales Invoices are blocked when the customer's overdue amount exceeds this. Requires 'Restrict Customer Over Billing' in Accounts Settings.",
|
||||
"fieldname": "overdue_billing_threshold",
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Overdue Limit"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_2",
|
||||
"fieldtype": "Column Break"
|
||||
|
||||
@@ -18,6 +18,7 @@ class CustomerCreditLimit(Document):
|
||||
bypass_credit_limit_check: DF.Check
|
||||
company: DF.Link | None
|
||||
credit_limit: DF.Currency
|
||||
overdue_billing_threshold: DF.Currency
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
{
|
||||
"fieldname": "credit_limits",
|
||||
"fieldtype": "Table",
|
||||
"label": "Credit Limit",
|
||||
"label": "Credit & Overdue Limits",
|
||||
"options": "Customer Credit Limit"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -296,7 +296,7 @@ def get_batches_by_oldest(item_code, warehouse):
|
||||
"""Returns the oldest batch and qty for the given item_code and warehouse"""
|
||||
batches = get_batch_qty(item_code=item_code, warehouse=warehouse)
|
||||
batches_dates = [[batch, frappe.get_value("Batch", batch.batch_no, "expiry_date")] for batch in batches]
|
||||
batches_dates.sort(key=lambda tup: tup[1])
|
||||
batches_dates.sort(key=lambda tup: (tup[1] is None, tup[1]))
|
||||
return batches_dates
|
||||
|
||||
|
||||
|
||||
@@ -1381,6 +1381,9 @@ def create_delivery_wo_so(pick_list, target, target_doc=None):
|
||||
|
||||
target_doc.company = pick_list.company
|
||||
|
||||
if not target_doc.customer:
|
||||
target_doc.customer = pick_list.customer
|
||||
|
||||
item_table_mapper_without_so = {
|
||||
"doctype": f"{target} Item",
|
||||
"field_map": {
|
||||
|
||||
@@ -242,7 +242,7 @@ class RepostItemValuation(Document):
|
||||
def clear_attachment(self):
|
||||
if attachments := get_attachments(self.doctype, self.name):
|
||||
attachment = attachments[0]
|
||||
frappe.delete_doc("File", attachment.name, ignore_permissions=True)
|
||||
frappe.delete_doc("File", attachment.name, ignore_permissions=True, force=True)
|
||||
|
||||
if self.reposting_data_file:
|
||||
self.db_set("reposting_data_file", None)
|
||||
|
||||
@@ -515,6 +515,34 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin):
|
||||
)
|
||||
)
|
||||
|
||||
def test_clear_attachment_skips_referenced_data_file(self):
|
||||
riv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Repost Item Valuation",
|
||||
"based_on": "Item and Warehouse",
|
||||
"company": "_Test Company",
|
||||
"item_code": "_Test Item",
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"posting_date": today(),
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
attached = frappe.get_doc(
|
||||
{
|
||||
"doctype": "File",
|
||||
"file_name": "repost_data.json.gz",
|
||||
"content": "test",
|
||||
"attached_to_doctype": riv.doctype,
|
||||
"attached_to_name": riv.name,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
riv.db_set("reposting_data_file", attached.file_url)
|
||||
|
||||
riv.clear_attachment()
|
||||
|
||||
self.assertFalse(frappe.db.exists("File", attached.name))
|
||||
self.assertIsNone(frappe.db.get_value("Repost Item Valuation", riv.name, "reposting_data_file"))
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Reposting Settings",
|
||||
{"item_based_reposting": 1, "enable_parallel_reposting": 1, "no_of_parallel_reposting": 2},
|
||||
|
||||
@@ -569,8 +569,6 @@ frappe.ui.form.on("Stock Entry", {
|
||||
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
|
||||
}
|
||||
|
||||
frm.events.set_route_options_for_new_doc(frm);
|
||||
|
||||
frm.set_df_property(
|
||||
"items",
|
||||
"cannot_add_rows",
|
||||
@@ -583,28 +581,6 @@ frappe.ui.form.on("Stock Entry", {
|
||||
);
|
||||
},
|
||||
|
||||
set_route_options_for_new_doc(frm) {
|
||||
let batch_no_field = frm.get_docfield("items", "batch_no");
|
||||
if (batch_no_field) {
|
||||
batch_no_field.get_route_options_for_new_doc = function (row) {
|
||||
return {
|
||||
item: row.doc.item_code,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle");
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item_code: row.doc.item_code,
|
||||
voucher_type: frm.doc.doctype,
|
||||
warehouse: row.doc.s_warehouse || row.doc.t_warehouse,
|
||||
};
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
get_items_from_transit_entry: function (frm) {
|
||||
if (frm.doc.docstatus === 0 && !frm.doc.subcontracting_inward_order) {
|
||||
frm.add_custom_button(
|
||||
@@ -1312,6 +1288,7 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
|
||||
}
|
||||
|
||||
onload_post_render() {
|
||||
super.onload_post_render();
|
||||
var me = this;
|
||||
if (me.frm.doc.__islocal && me.frm.doc.company && !me.frm.doc.amended_from) {
|
||||
me.company();
|
||||
|
||||
@@ -1344,6 +1344,47 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin):
|
||||
# receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error).
|
||||
self.assertEqual(qty_after(receipt2), 10)
|
||||
|
||||
def test_cancel_shifts_same_timestamp_delivery_notes(self):
|
||||
item = make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
posting_date = today()
|
||||
posting_time = "10:00:00"
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item,
|
||||
to_warehouse=warehouse,
|
||||
qty=100,
|
||||
rate=10,
|
||||
posting_date=posting_date,
|
||||
posting_time="09:00:00",
|
||||
)
|
||||
|
||||
dns = []
|
||||
for i in range(5):
|
||||
dns.append(
|
||||
create_delivery_note(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=20,
|
||||
rate=10 * i,
|
||||
posting_date=posting_date,
|
||||
posting_time=posting_time,
|
||||
)
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
dn = dns[2]
|
||||
dn.cancel()
|
||||
|
||||
expected_qty_after_transaction_of_dns3 = 40
|
||||
qty_after_transaction_of_dns3 = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": dns[3].name, "is_cancelled": 0},
|
||||
"qty_after_transaction",
|
||||
)
|
||||
|
||||
self.assertEqual(expected_qty_after_transaction_of_dns3, qty_after_transaction_of_dns3)
|
||||
|
||||
def test_get_next_stock_reco_respects_creation_order(self):
|
||||
# A stock reco sharing the exact posting timestamp of the current entry must only count as the
|
||||
# "next" reco when it was created after that entry. A reco created before it actually precedes
|
||||
|
||||
@@ -46,17 +46,6 @@ frappe.ui.form.on("Stock Reconciliation", {
|
||||
};
|
||||
});
|
||||
|
||||
let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle");
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item_code: row.doc.item_code,
|
||||
warehouse: row.doc.warehouse,
|
||||
voucher_type: frm.doc.doctype,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
if (frm.doc.company) {
|
||||
erpnext.queries.setup_queries(frm, "Warehouse", function () {
|
||||
return erpnext.queries.warehouse(frm.doc);
|
||||
|
||||
@@ -325,6 +325,7 @@ class FIFOSlots:
|
||||
del stock_ledger_entries
|
||||
|
||||
self._recompute_moving_average_slots()
|
||||
self._rebalance_negative_batch_slots()
|
||||
|
||||
if not self.filters.get("show_warehouse_wise_stock"):
|
||||
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
|
||||
@@ -346,6 +347,33 @@ class FIFOSlots:
|
||||
if is_qty_slot(slot):
|
||||
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate)
|
||||
|
||||
def _rebalance_negative_batch_slots(self) -> None:
|
||||
for item_dict in self.item_details.values():
|
||||
if item_dict.get("has_batch_no"):
|
||||
self._rebalance_negative_batch_slot_values(item_dict["fifo_queue"])
|
||||
|
||||
def _rebalance_negative_batch_slot_values(self, fifo_queue: list) -> None:
|
||||
"""A batch is one valuation pool, so a slot driven negative by consumption
|
||||
at the pooled rate is stale detail: spread the pool value over its slots."""
|
||||
groups = {}
|
||||
for slot in fifo_queue:
|
||||
if is_batch_slot(slot):
|
||||
key = slot[BATCH_SLOT_BATCH_INDEX] if slot[BATCH_SLOT_VALUATION_INDEX] else None
|
||||
groups.setdefault(key, []).append(slot)
|
||||
|
||||
for slots in groups.values():
|
||||
has_negative_slot = any(
|
||||
flt(slot[BATCH_SLOT_VALUE_INDEX]) < 0 and flt(slot[BATCH_SLOT_QTY_INDEX]) > 0
|
||||
for slot in slots
|
||||
)
|
||||
total_qty = sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots)
|
||||
if not has_negative_slot or total_qty <= 0:
|
||||
continue
|
||||
|
||||
rate = sum(flt(slot[BATCH_SLOT_VALUE_INDEX]) for slot in slots) / total_qty
|
||||
for slot in slots:
|
||||
slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate)
|
||||
|
||||
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
|
||||
if stock_ledger_entries is not None:
|
||||
return frappe._dict({}), frappe._dict({})
|
||||
|
||||
@@ -616,6 +616,58 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
],
|
||||
)
|
||||
|
||||
def test_batch_issue_at_pooled_rate_keeps_slot_values_positive(self):
|
||||
"""Ledger (same wh, batch B): [+10 @ 0, +10 @ 10, -4 @ pooled 5]
|
||||
Consuming the zero-valued head slot at the pooled rate drives it
|
||||
negative; slot values are then rebalanced to the batch pool rate."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batch Pool Rebalance",
|
||||
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
|
||||
).name
|
||||
|
||||
batch_no = "SA-POOL-REBALANCE-BATCH"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
|
||||
return frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=actual_qty,
|
||||
qty_after_transaction=qty_after,
|
||||
stock_value_difference=stock_value_difference,
|
||||
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
|
||||
warehouse="WH 1",
|
||||
posting_date=posting_date,
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no=voucher_no,
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
sle = [
|
||||
make_sle("2021-12-01", "001", 10, 10, 0),
|
||||
make_sle("2021-12-02", "002", 10, 20, 100),
|
||||
make_sle("2021-12-03", "003", -4, 16, -20),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
queue = slots[item_code]["fifo_queue"]
|
||||
|
||||
self.assertEqual(
|
||||
queue,
|
||||
[
|
||||
[batch_no, 1, 6.0, "2021-12-01", 30.0],
|
||||
[batch_no, 1, 10.0, "2021-12-01", 50.0],
|
||||
],
|
||||
)
|
||||
|
||||
def test_sequential_stock_reco_same_warehouse(self):
|
||||
"""
|
||||
Test back to back stock recos (same warehouse).
|
||||
|
||||
@@ -136,7 +136,7 @@ frappe.query_reports["Stock Balance"] = {
|
||||
fieldname: "include_zero_stock_items",
|
||||
label: __("Include Zero Stock Items"),
|
||||
fieldtype: "Check",
|
||||
default: 0,
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "show_dimension_wise_stock",
|
||||
|
||||
@@ -497,7 +497,9 @@ class update_entries_after:
|
||||
|
||||
self.data = frappe._dict()
|
||||
|
||||
if not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle"):
|
||||
if (not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle")) and not self.args.get(
|
||||
"cancelled"
|
||||
):
|
||||
self.initialize_previous_data(self.args)
|
||||
|
||||
self.build()
|
||||
@@ -805,10 +807,23 @@ class update_entries_after:
|
||||
|
||||
def process_sle_against_current_timestamp(self):
|
||||
sl_entries = get_sle_against_current_voucher(self.args)
|
||||
if self.args.get("cancelled") and sl_entries:
|
||||
self.seed_previous_sle_for_cancellation(sl_entries[0])
|
||||
for sle in sl_entries:
|
||||
sle["timestamp"] = sle.posting_datetime
|
||||
self.process_sle(sle)
|
||||
|
||||
def seed_previous_sle_for_cancellation(self, anchor_sle):
|
||||
key = (anchor_sle.item_code, anchor_sle.warehouse)
|
||||
if key in self.prev_sle_dict:
|
||||
return
|
||||
|
||||
args = frappe._dict(anchor_sle)
|
||||
args["sle_id"] = args.name
|
||||
prev_sle = get_previous_sle_of_current_voucher(args)
|
||||
if prev_sle:
|
||||
self.prev_sle_dict[key] = prev_sle
|
||||
|
||||
def get_future_entries_to_fix(self):
|
||||
# includes current entry!
|
||||
args = self.data[self.args.warehouse].previous_sle or frappe._dict(
|
||||
|
||||
Reference in New Issue
Block a user