diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR03_gnucash.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR03_gnucash.json index daf2e21d78c..84b16059e99 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR03_gnucash.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR03_gnucash.json @@ -34,6 +34,13 @@ "account_number": "0430", "account_type": "Fixed Asset" }, + "Anlagen im Bau": { + "is_group": 1, + "Andere Anlagen, Betriebs- und Geschäftsausstattung im Bau": { + "account_number": "0498", + "account_type": "Capital Work in Progress" + } + }, "Accumulated Depreciation": { "account_type": "Accumulated Depreciation" } @@ -317,13 +324,21 @@ "account_number": "3800", "account_type": "Expenses Included In Asset Valuation" }, + "Bestandsveränderungen Roh-, Hilfs- und Betriebsstoffe sowie bezogene Waren": { + "account_number": "3960", + "account_type": "Stock Adjustment" + }, "Herstellungskosten": { "account_number": "4996", "account_type": "Cost of Goods Sold" }, + "Anlagenabgänge Sachanlagen (Restbuchwert bei Buchverlust)": { + "account_number": "2310", + "account_type": "Expense Account" + }, "Verluste aus dem Abgang von Gegenständen des Anlagevermögens": { "account_number": "2320", - "account_type": "Stock Adjustment" + "account_type": "Expense Account" }, "Verwaltungskosten": { "account_number": "4997", @@ -340,7 +355,7 @@ "is_group": 1, "Abschreibungen auf Sachanlagen (ohne AfA auf Kfz und Gebäude)": { "account_number": "4830", - "account_type": "Accumulated Depreciation" + "account_type": "Depreciation" }, "Abschreibungen auf Gebäude": { "account_number": "4831", diff --git a/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json b/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json index fa40222fc66..7e487cccf19 100644 --- a/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json +++ b/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json @@ -1,108 +1,41 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2018-11-22 23:47:02.804568", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "creation": "2018-11-22 23:47:02.804568", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "tax_type", + "tax_rate" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "tax_type", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Tax", - "length": 0, - "no_copy": 0, - "options": "Account", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "tax_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Tax", + "options": "Account", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "tax_rate", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Tax Rate", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "tax_rate", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Tax Rate" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2018-12-21 23:51:39.445198", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Item Tax Template Detail", - "name_case": "", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + ], + "istable": 1, + "links": [], + "modified": "2026-04-30 23:49:27.020639", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Item Tax Template Detail", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 2723ef81d2f..098a90acbbf 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2328,16 +2328,19 @@ def get_outstanding_reference_documents(args, validate=False): } for fieldname, date_fields in date_fields_dict.items(): + from_date = frappe.db.escape(str(args.get(date_fields[0]))) if args.get(date_fields[0]) else None + to_date = frappe.db.escape(str(args.get(date_fields[1]))) if args.get(date_fields[1]) else None + if args.get(date_fields[0]) and args.get(date_fields[1]): - condition += f" and {fieldname} between {frappe.db.escape(args.get(date_fields[0]))} and {frappe.db.escape(args.get(date_fields[1]))}" + condition += f" and {fieldname} between {from_date} and {to_date}" posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])]) elif args.get(date_fields[0]): # if only from date is supplied - condition += f" and {fieldname} >= {frappe.db.escape(args.get(date_fields[0]))}" + condition += f" and {fieldname} >= {from_date}" posting_and_due_date.append(ple[fieldname].gte(args.get(date_fields[0]))) elif args.get(date_fields[1]): # if only to date is supplied - condition += f" and {fieldname} <= {frappe.db.escape(args.get(date_fields[1]))}" + condition += f" and {fieldname} <= {to_date}" posting_and_due_date.append(ple[fieldname].lte(args.get(date_fields[1]))) if args.get("company"): diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 52f68180267..a25af18e5fc 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -115,7 +115,12 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. } } - if (doc.docstatus == 1 && doc.outstanding_amount != 0 && !doc.on_hold) { + if ( + doc.docstatus == 1 && + doc.outstanding_amount != 0 && + !doc.on_hold && + frappe.model.can_create("Payment Entry") + ) { this.frm.add_custom_button(__("Payment"), () => this.make_payment_entry(), __("Create")); cur_frm.page.set_inner_btn_group_as_primary(__("Create")); } @@ -126,7 +131,13 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. } } - if (doc.docstatus == 1 && doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) { + if ( + doc.docstatus == 1 && + doc.outstanding_amount > 0 && + !cint(doc.is_return) && + !doc.on_hold && + frappe.boot.user.in_create.includes("Payment Request") + ) { this.frm.add_custom_button( __("Payment Request"), function () { @@ -460,13 +471,14 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. } items_add(doc, cdt, cdn) { - var row = frappe.get_doc(cdt, cdn); - this.frm.script_manager.copy_from_first_row("items", row, [ - "expense_account", - "discount_account", - "cost_center", - "project", - ]); + const row = frappe.get_doc(cdt, cdn); + const field_copy = ["expense_account", "discount_account", "cost_center"]; + if (doc.project) { + frappe.model.set_value(cdt, cdn, "project", doc.project); + } else { + field_copy.push("project"); + } + this.frm.script_manager.copy_from_first_row("items", row, field_copy); } on_submit() { @@ -575,12 +587,6 @@ cur_frm.fields_dict["items"].grid.get_field("cost_center").get_query = function }; }; -cur_frm.fields_dict["items"].grid.get_field("project").get_query = function (doc, cdt, cdn) { - return { - filters: [["Project", "status", "not in", "Completed, Cancelled"]], - }; -}; - frappe.ui.form.on("Purchase Invoice", { setup: function (frm) { frm.custom_make_buttons = { diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index b4b49828a58..b6a939a1b8b 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -94,7 +94,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( erpnext.accounts.ledger_preview.show_stock_ledger_preview(this.frm); } - if (doc.docstatus == 1 && doc.outstanding_amount != 0) { + if (doc.docstatus == 1 && doc.outstanding_amount != 0 && frappe.model.can_create("Payment Entry")) { this.frm.add_custom_button(__("Payment"), () => this.make_payment_entry(), __("Create")); this.frm.page.set_inner_btn_group_as_primary(__("Create")); } @@ -136,13 +136,15 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( } if (doc.outstanding_amount > 0) { - cur_frm.add_custom_button( - __("Payment Request"), - function () { - me.make_payment_request(); - }, - __("Create") - ); + if (frappe.boot.user.in_create.includes("Payment Request")) { + this.frm.add_custom_button( + __("Payment Request"), + function () { + me.make_payment_request_with_schedule(); + }, + __("Create") + ); + } this.frm.add_custom_button( __("Invoice Discounting"), this.make_invoice_discounting.bind(this), @@ -540,12 +542,14 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( } items_add(doc, cdt, cdn) { - var row = frappe.get_doc(cdt, cdn); - this.frm.script_manager.copy_from_first_row("items", row, [ - "income_account", - "discount_account", - "cost_center", - ]); + const row = frappe.get_doc(cdt, cdn); + const field_copy = ["income_account", "discount_account", "cost_center"]; + if (doc.project) { + frappe.model.set_value(cdt, cdn, "project", doc.project); + } else { + field_copy.push("project"); + } + this.frm.script_manager.copy_from_first_row("items", row, field_copy); } set_dynamic_labels() { diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index e55ebb52b58..2ad2edaf119 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -440,7 +440,11 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( __("Create") ); - if (flt(doc.per_billed) < 100 && doc.status != "Delivered") { + if ( + frappe.model.can_create("Payment Entry") && + flt(doc.per_billed) < 100 && + doc.status != "Delivered" + ) { this.frm.add_custom_button( __("Payment"), () => this.make_payment_entry(), @@ -448,7 +452,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( ); } - if (flt(doc.per_billed) < 100) { + if (flt(doc.per_billed) < 100 && frappe.boot.user.in_create.includes("Payment Request")) { this.frm.add_custom_button( __("Payment Request"), function () { @@ -705,12 +709,20 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( } items_add(doc, cdt, cdn) { - var row = frappe.get_doc(cdt, cdn); - if (doc.schedule_date) { - row.schedule_date = doc.schedule_date; - refresh_field("schedule_date", cdn, "items"); + const row = frappe.get_doc(cdt, cdn); + const field_copy = []; + if (doc.project) { + frappe.model.set_value(cdt, cdn, "project", doc.project); } else { - this.frm.script_manager.copy_from_first_row("items", row, ["schedule_date"]); + field_copy.push("project"); + } + if (doc.schedule_date) { + frappe.model.set_value(cdt, cdn, "schedule_date", doc.schedule_date); + } else { + field_copy.push("schedule_date"); + } + if (field_copy.length) { + this.frm.script_manager.copy_from_first_row("items", row, field_copy); } } @@ -785,12 +797,6 @@ cur_frm.cscript.update_status = function (label, status) { }); }; -cur_frm.fields_dict["items"].grid.get_field("project").get_query = function (doc, cdt, cdn) { - return { - filters: [["Project", "status", "not in", "Completed, Cancelled"]], - }; -}; - if (cur_frm.doc.is_old_subcontracting_flow) { cur_frm.fields_dict["items"].grid.get_field("bom").get_query = function (doc, cdt, cdn) { var d = locals[cdt][cdn]; diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js index e81f9f9c988..c0fa8a54041 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js @@ -115,9 +115,3 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e // for backward compatibility: combine new and previous states extend_cscript(cur_frm.cscript, new erpnext.buying.SupplierQuotationController({ frm: cur_frm })); - -cur_frm.fields_dict["items"].grid.get_field("project").get_query = function (doc, cdt, cdn) { - return { - filters: [["Project", "status", "not in", "Completed, Cancelled"]], - }; -}; diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index bc4e4d869c0..f382118042a 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -364,13 +364,18 @@ class Project(Document): ) for user in self.users: + # process only users who haven't received the welcome email yet if user.welcome_email_sent == 0: - frappe.sendmail( - user.user, - subject=_("Project Collaboration Invitation"), - content=content, - ) - user.welcome_email_sent = 1 + # fetch canonical User data (enabled status + latest email) + user_info = frappe.db.get_value("User", user.user, ["enabled", "email"], as_dict=True) + # send email only if user is enabled and has a valid email + if user_info and user_info.enabled and user_info.email: + frappe.sendmail( + recipients=[user_info.email], + subject=_("Project Collaboration Invitation"), + content=content, + ) + user.welcome_email_sent = 1 def get_timeline_data(doctype: str, name: str) -> dict[int, int]: diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index 37c83483c18..df2355255f4 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -25,13 +25,15 @@ erpnext.buying = { }; }); - this.frm.set_query("project", function (doc) { - return { - filters: { - company: doc.company, - }, - }; - }); + const project_filters = { + query: "erpnext.controllers.queries.get_project_name", + filters: { + company: doc.company, + }, + }; + + this.frm.set_query("project", (_) => project_filters); + this.frm.set_query("project", "items", (_, __, ___) => project_filters); if (this.frm.doc.__islocal && frappe.meta.has_field(this.frm.doc.doctype, "disable_rounded_total")) { diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 2f70260a68a..d1a61ad34a3 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -560,7 +560,9 @@ def _make_customer(source_name, ignore_permissions=False): if quotation.quotation_to == "Customer": return frappe.get_doc("Customer", quotation.party_name) elif quotation.quotation_to == "CRM Deal": - return frappe.get_doc("Customer", {"crm_deal": quotation.party_name}) + customer_name = frappe.get_value("Customer", {"crm_deal": quotation.party_name}) + if customer_name: + return frappe.get_doc("Customer", customer_name) # Check if a Customer already exists for the Lead or Prospect. existing_customer = None diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 46eefff1671..d9b0b322056 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -175,6 +175,61 @@ class TestQuotation(FrappeTestCase): self.assertTrue(quotation.payment_schedule) + def test_terms_attachments_are_copied_to_quotation(self): + terms = make_terms_and_conditions(copy_attachments_to_transaction=True) + first_attachment = make_file_attachment( + "Terms and Conditions", + terms.name, + content="First terms attachment", + ) + + quotation = make_quotation(do_not_save=1) + quotation.tc_name = terms.name + quotation.insert() + + self.assertEqual(get_attachment_urls("Quotation", quotation.name), {first_attachment.file_url}) + + second_attachment = make_file_attachment( + "Terms and Conditions", + terms.name, + content="Second terms attachment", + ) + quotation.valid_till = add_days(getdate(quotation.valid_till), 1) + quotation.save() + + quotation_attachments = get_attachment_urls("Quotation", quotation.name) + self.assertEqual(quotation_attachments, {first_attachment.file_url}) + self.assertNotIn(second_attachment.file_url, quotation_attachments) + + new_terms = make_terms_and_conditions(copy_attachments_to_transaction=True) + new_terms_attachment = make_file_attachment( + "Terms and Conditions", + new_terms.name, + content="Attachment from updated terms", + ) + quotation.tc_name = new_terms.name + quotation.valid_till = add_days(getdate(quotation.valid_till), 1) + quotation.save() + + self.assertEqual( + get_attachment_urls("Quotation", quotation.name), + {first_attachment.file_url, new_terms_attachment.file_url}, + ) + + def test_terms_attachments_are_not_copied_when_disabled(self): + terms = make_terms_and_conditions(copy_attachments_to_transaction=False) + make_file_attachment( + "Terms and Conditions", + terms.name, + content="Terms attachment should stay on the template", + ) + + quotation = make_quotation(do_not_save=1) + quotation.tc_name = terms.name + quotation.insert() + + self.assertFalse(get_attachment_urls("Quotation", quotation.name)) + @change_settings( "Accounts Settings", {"automatically_fetch_payment_terms": 1}, @@ -1142,6 +1197,42 @@ def get_quotation_dict(party_name=None, item_code=None): } +def make_terms_and_conditions(copy_attachments_to_transaction=False): + return frappe.get_doc( + { + "doctype": "Terms and Conditions", + "title": f"_Test Terms and Conditions {frappe.generate_hash(length=8)}", + "selling": 1, + "terms": "Test terms", + "copy_attachments_to_transaction": 1 if copy_attachments_to_transaction else 0, + } + ).insert() + + +def make_file_attachment(doctype, docname, content): + return frappe.get_doc( + { + "doctype": "File", + "file_name": f"terms-attachment-{frappe.generate_hash(length=8)}.txt", + "attached_to_doctype": doctype, + "attached_to_name": docname, + "content": content, + } + ).insert() + + +def get_attachment_urls(doctype, docname): + return { + file.file_url + for file in frappe.get_all( + "File", + filters={"attached_to_doctype": doctype, "attached_to_name": docname}, + fields=["file_url"], + ) + if file.file_url + } + + def make_quotation(**args): qo = frappe.new_doc("Quotation") args = frappe._dict(args) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 1316252005f..1452a1c1e49 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -773,11 +773,13 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } // payment request if (flt(doc.per_billed) < 100 + frappe.boot.sysdefaults.over_billing_allowance) { - this.frm.add_custom_button( - __("Payment Request"), - () => this.make_payment_request(), - __("Create") - ); + if (frappe.boot.user.in_create.includes("Payment Request")) { + this.frm.add_custom_button( + __("Payment Request"), + () => this.make_payment_request_with_schedule(), + __("Create") + ); + } if (frappe.model.can_create("Payment Entry")) { this.frm.add_custom_button( @@ -833,6 +835,24 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex this.order_type(doc); } + items_add(doc, cdt, cdn) { + const row = frappe.get_doc(cdt, cdn); + const field_copy = []; + if (doc.project) { + frappe.model.set_value(cdt, cdn, "project", doc.project); + } else { + field_copy.push("project"); + } + if (doc.delivery_date) { + frappe.model.set_value(cdt, cdn, "delivery_date", doc.delivery_date); + } else { + field_copy.push("delivery_date"); + } + if (field_copy.length) { + this.frm.script_manager.copy_from_first_row("items", row, field_copy); + } + } + create_pick_list() { frappe.model.open_mapped_doc({ method: "erpnext.selling.doctype.sales_order.sales_order.create_pick_list", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 85f9e246b04..a6babe5516c 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -533,6 +533,7 @@ class SalesOrder(SellingController): self.update_reserved_qty() self.notify_update() clear_doctype_notifications(self) + self.update_blanket_order() def update_reserved_qty(self, so_item_rows=None): """update requested qty (before ordered_qty is updated)""" diff --git a/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py b/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py index 5e07eb5d8a8..5b98c4bf386 100644 --- a/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py +++ b/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py @@ -1,122 +1,176 @@ # Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - import frappe -from frappe import _, msgprint +from frappe import _ +from frappe.query_builder import DocType, Field, Order +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.utils import QueryBuilder +from frappe.utils.data import comma_or + +SALES_TRANSACTION_DOCTYPES = ["Sales Order", "Sales Invoice", "Delivery Note", "POS Invoice"] def execute(filters=None): if not filters: filters = {} - columns = get_columns(filters) - data = get_entries(filters) - - return columns, data + return SalesPartnerCommissionSummaryReport(filters).run() -def get_columns(filters): - if not filters.get("doctype"): - msgprint(_("Please select the document type first"), raise_exception=1) +class SalesPartnerSummaryReport: + """ + Base class to generate Sales Partner Summary related Reports. + """ - columns = [ - { - "label": _(filters["doctype"]), - "options": filters["doctype"], - "fieldname": "name", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Customer"), - "options": "Customer", - "fieldname": "customer", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Currency"), - "fieldname": "currency", - "fieldtype": "Data", - "width": 80, - }, - { - "label": _("Territory"), - "options": "Territory", - "fieldname": "territory", - "fieldtype": "Link", - "width": 100, - }, - {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100}, - { - "label": _("Amount"), - "fieldname": "amount", - "fieldtype": "Currency", - "options": "currency", - "width": 120, - }, - { - "label": _("Sales Partner"), - "options": "Sales Partner", - "fieldname": "sales_partner", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Commission Rate %"), - "fieldname": "commission_rate", - "fieldtype": "Data", - "width": 100, - }, - { - "label": _("Total Commission"), - "fieldname": "total_commission", - "fieldtype": "Currency", - "options": "currency", - "width": 120, - }, - ] + dt: DocType + date_field: str + date_label: str + columns: list + data: list + query: QueryBuilder + filters: dict - return columns + def __init__(self, filters: dict): + self.filters = filters + self.columns = [] + def run(self): + self.validate_filters() + self.prepare_columns() + self.get_data() -def get_entries(filters): - date_field = "transaction_date" if filters.get("doctype") == "Sales Order" else "posting_date" - company_currency = frappe.db.get_value("Company", filters.get("company"), "default_currency") - conditions = get_conditions(filters, date_field) - entries = frappe.db.sql( + return self.columns, self.data + + def validate_filters(self): + if not self.filters.get("doctype"): + frappe.throw(_("Please select the document type first.")) + + if self.filters.get("doctype") not in SALES_TRANSACTION_DOCTYPES: + frappe.throw(_("DocType can be one of them {0}").format(comma_or(SALES_TRANSACTION_DOCTYPES))) + + if not self.filters.get("company"): + frappe.throw(_("Please select a company.")) + + if ( + self.filters.get("from_date") + and self.filters.get("to_date") + and self.filters.get("from_date") > self.filters.get("to_date") + ): + frappe.throw(_("From Date cannot be greater than To Date.")) + + self._set_date_field_and_label() + + def _set_date_field_and_label(self): + self.date_field = ( + "transaction_date" if self.filters.get("doctype") == "Sales Order" else "posting_date" + ) + self.date_label = _("Order Date") if self.date_field == "transaction_date" else _("Posting Date") + + def prepare_columns(self): """ - SELECT - name, customer, territory, {} as posting_date, base_net_total as amount, - sales_partner, commission_rate, total_commission, '{}' as currency - FROM - `tab{}` - WHERE - {} and docstatus = 1 and sales_partner is not null - and sales_partner != '' order by name desc, sales_partner - """.format(date_field, company_currency, filters.get("doctype"), conditions), - filters, - as_dict=1, - ) + Extend this method to add columns on the report. Use `make_column` to add more columns. + """ + raise NotImplementedError - return entries + def get_data(self): + self.build_report_query() + + self.data = self.query.run(as_dict=1) + + def build_report_query(self): + self._build_report_base_query() + self.extend_report_query() + self._apply_common_filters() + self.apply_filters() + + def _build_report_base_query(self): + self.dt = DocType(self.filters.get("doctype")) + + company_currency = frappe.get_cached_value("Company", self.filters.get("company"), "default_currency") + + self.query = ( + frappe.qb.from_(self.dt) + .select( + self.dt.name, + self.dt.customer, + self.dt.territory, + Field(self.date_field, "posting_date", table=self.dt), + self.dt.sales_partner, + self.dt.commission_rate, + ConstantColumn(company_currency).as_("currency"), + ) + .where( + (self.dt.docstatus == 1) & (self.dt.sales_partner.notnull()) & (self.dt.sales_partner != "") + ) + .orderby(self.dt.name, order=Order.desc) + .orderby(self.dt.sales_partner) + ) + + def extend_report_query(self): + """ + Extend this method to select more columns on the query. + """ + pass + + def _apply_common_filters(self): + for field in ["company", "customer", "territory", "sales_partner"]: + if self.filters.get(field): + self.query = self.query.where(Field(field, table=self.dt) == self.filters.get(field)) + + if self.filters.get("from_date"): + self.query = self.query.where( + Field(self.date_field, table=self.dt) >= self.filters.get("from_date") + ) + + if self.filters.get("to_date"): + self.query = self.query.where( + Field(self.date_field, table=self.dt) <= self.filters.get("to_date") + ) + + def apply_filters(self): + """ + Extend this method to add more conditions on the query. + """ + pass + + def make_column( + self, label: str, fieldname: str, fieldtype: str, width: int = 140, options: str = "", hidden: int = 0 + ): + self.columns.append( + dict( + label=label, + fieldname=fieldname, + fieldtype=fieldtype, + options=options, + width=width, + hidden=hidden, + ) + ) -def get_conditions(filters, date_field): - conditions = "1=1" +class SalesPartnerCommissionSummaryReport(SalesPartnerSummaryReport): + def prepare_columns(self): + self.make_column(_(self.filters.get("doctype")), "name", "Link", options=self.filters.get("doctype")) - for field in ["company", "customer", "territory"]: - if filters.get(field): - conditions += f" and {field} = %({field})s" + self.make_column(_("Customer"), "customer", "Link", options="Customer") - if filters.get("sales_partner"): - conditions += " and sales_partner = %(sales_partner)s" + self.make_column(_("Currency"), "currency", "Data", 80, hidden=1) - if filters.get("from_date"): - conditions += f" and {date_field} >= %(from_date)s" + self.make_column(_("Territory"), "territory", "Link", 100, "Territory") - if filters.get("to_date"): - conditions += f" and {date_field} <= %(to_date)s" + self.make_column(self.date_label, "posting_date", "Date") - return conditions + self.make_column(_("Amount"), "amount", "Currency", 120, "currency") + + self.make_column(_("Sales Partner"), "sales_partner", "Link", options="Sales Partner") + + self.make_column(_("Commission Rate %"), "commission_rate", "Data", 100) + + self.make_column(_("Total Commission"), "total_commission", "Currency", 120, "currency") + + def extend_report_query(self): + self.query = self.query.select( + self.dt.base_net_total.as_("amount"), + self.dt.total_commission, + ) diff --git a/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.js b/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.js index f6f7c3f3cf3..e4e2199606a 100644 --- a/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.js +++ b/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.js @@ -3,6 +3,14 @@ frappe.query_reports["Sales Partner Transaction Summary"] = { filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1, + }, { fieldname: "sales_partner", label: __("Sales Partner"), @@ -28,14 +36,6 @@ frappe.query_reports["Sales Partner Transaction Summary"] = { fieldtype: "Date", default: frappe.datetime.get_today(), }, - { - fieldname: "company", - label: __("Company"), - fieldtype: "Link", - options: "Company", - default: frappe.defaults.get_user_default("Company"), - reqd: 1, - }, { fieldname: "item_group", label: __("Item Group"), diff --git a/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.py b/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.py index 216adde18fd..f322b89f897 100644 --- a/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.py +++ b/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.py @@ -3,144 +3,84 @@ import frappe -from frappe import _, msgprint +from frappe import _ +from frappe.query_builder import Case + +from erpnext.selling.report.sales_partner_commission_summary.sales_partner_commission_summary import ( + SalesPartnerSummaryReport, +) def execute(filters=None): if not filters: filters = {} - columns = get_columns(filters) - data = get_entries(filters) - - return columns, data + return SalesPartnerTransactionSummaryReport(filters=filters).run() -def get_columns(filters): - if not filters.get("doctype"): - msgprint(_("Please select the document type first"), raise_exception=1) +class SalesPartnerTransactionSummaryReport(SalesPartnerSummaryReport): + def prepare_columns(self): + self.make_column(_(self.filters.get("doctype")), "name", "Link", options=self.filters.get("doctype")) - columns = [ - { - "label": _(filters["doctype"]), - "options": filters["doctype"], - "fieldname": "name", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Customer"), - "options": "Customer", - "fieldname": "customer", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Territory"), - "options": "Territory", - "fieldname": "territory", - "fieldtype": "Link", - "width": 100, - }, - {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100}, - { - "label": _("Item Code"), - "fieldname": "item_code", - "fieldtype": "Link", - "options": "Item", - "width": 100, - }, - { - "label": _("Item Group"), - "fieldname": "item_group", - "fieldtype": "Link", - "options": "Item Group", - "width": 100, - }, - { - "label": _("Brand"), - "fieldname": "brand", - "fieldtype": "Link", - "options": "Brand", - "width": 100, - }, - {"label": _("Quantity"), "fieldname": "qty", "fieldtype": "Float", "width": 120}, - {"label": _("Rate"), "fieldname": "rate", "fieldtype": "Currency", "width": 120}, - {"label": _("Amount"), "fieldname": "amount", "fieldtype": "Currency", "width": 120}, - { - "label": _("Sales Partner"), - "options": "Sales Partner", - "fieldname": "sales_partner", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Commission Rate %"), - "fieldname": "commission_rate", - "fieldtype": "Data", - "width": 100, - }, - {"label": _("Commission"), "fieldname": "commission", "fieldtype": "Currency", "width": 120}, - { - "label": _("Currency"), - "fieldname": "currency", - "fieldtype": "Link", - "options": "Currency", - "width": 120, - }, - ] + self.make_column(_("Customer"), "customer", "Link", options="Customer") - return columns + self.make_column(_("Currency"), "currency", "Data", 80, hidden=1) + self.make_column(_("Territory"), "territory", "Link", 100, "Territory") -def get_entries(filters): - date_field = "transaction_date" if filters.get("doctype") == "Sales Order" else "posting_date" + self.make_column(self.date_label, "posting_date", "Date") - conditions = get_conditions(filters, date_field) - entries = frappe.db.sql( - """ - SELECT - dt.name, dt.customer, dt.territory, dt.{date_field} as posting_date, dt.currency, - dt_item.base_net_rate as rate, dt_item.qty, dt_item.base_net_amount as amount, - ((dt_item.base_net_amount * dt.commission_rate) / 100) as commission, - dt_item.brand, dt.sales_partner, dt.commission_rate, dt_item.item_group, dt_item.item_code - FROM - `tab{doctype}` dt, `tab{doctype} Item` dt_item - WHERE - {cond} and dt.name = dt_item.parent and dt.docstatus = 1 - and dt.sales_partner is not null and dt.sales_partner != '' - order by dt.name desc, dt.sales_partner - """.format(date_field=date_field, doctype=filters.get("doctype"), cond=conditions), - filters, - as_dict=1, - ) + self.make_column(_("Item Code"), "item_code", "Link", 100, "Item") - return entries + self.make_column(_("Item Group"), "item_group", "Link", 100, "Item Group") + self.make_column(_("Brand"), "brand", "Link", 100, "Brand") -def get_conditions(filters, date_field): - conditions = "1=1" + self.make_column(_("Quantity"), "qty", "Float", 120) - for field in ["company", "customer", "territory", "sales_partner"]: - if filters.get(field): - conditions += f" and dt.{field} = %({field})s" + self.make_column(_("Rate"), "rate", "Currency", 120, "currency") - if filters.get("from_date"): - conditions += f" and dt.{date_field} >= %(from_date)s" + self.make_column(_("Amount"), "amount", "Currency", 120, "currency") - if filters.get("to_date"): - conditions += f" and dt.{date_field} <= %(to_date)s" + self.make_column(_("Sales Partner"), "sales_partner", "Link", options="Sales Partner") - if not filters.get("show_return_entries"): - conditions += " and dt_item.qty > 0.0" + self.make_column(_("Commission Rate %"), "commission_rate", "Data", 100) - if filters.get("brand"): - conditions += " and dt_item.brand = %(brand)s" + self.make_column(_("Commission"), "commission", "Currency", 120, "currency") - if filters.get("item_group"): - lft, rgt = frappe.get_cached_value("Item Group", filters.get("item_group"), ["lft", "rgt"]) + def extend_report_query(self): + self.dt_item = frappe.qb.DocType(f"{self.filters['doctype']} Item") - conditions += f""" and dt_item.item_group in (select name from - `tabItem Group` where lft >= {lft} and rgt <= {rgt})""" + self.query = ( + self.query.join(self.dt_item) + .on(self.dt.name == self.dt_item.parent) + .select( + self.dt_item.base_net_rate.as_("rate"), + self.dt_item.qty, + self.dt_item.base_net_amount.as_("amount"), + Case() + .when( + self.dt_item.grant_commission.eq(1), + ((self.dt_item.base_net_amount * self.dt.commission_rate) / 100), + ) + .else_(0) + .as_("commission"), + self.dt_item.brand, + self.dt_item.item_group, + self.dt_item.item_code, + ) + ) - return conditions + def apply_filters(self): + if not self.filters.get("show_return_entries"): + self.query = self.query.where(self.dt_item.qty > 0.0) + + if self.filters.get("brand"): + self.query = self.query.where(self.dt_item.brand == self.filters.get("brand")) + + if self.filters.get("item_group"): + lft, rgt = frappe.get_cached_value("Item Group", self.filters.get("item_group"), ["lft", "rgt"]) + if item_groups := frappe.get_all( + "Item Group", filters=[["lft", ">=", lft], ["rgt", "<=", rgt]], pluck="name" + ): + self.query = self.query.where(self.dt_item.item_group.isin(item_groups)) diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json index 76e52aefeba..30598c9143b 100644 --- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json +++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json @@ -11,6 +11,8 @@ "field_order": [ "title", "disabled", + "column_break_ofhb", + "copy_attachments_to_transaction", "applicable_modules_section", "selling", "buying", @@ -72,12 +74,22 @@ { "fieldname": "section_break_7", "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_ofhb", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "copy_attachments_to_transaction", + "fieldtype": "Check", + "label": "Copy Attachments to Transaction" } ], "icon": "icon-legal", "idx": 1, "links": [], - "modified": "2024-01-30 12:47:52.325531", + "modified": "2026-04-29 22:51:49.285298", "modified_by": "Administrator", "module": "Setup", "name": "Terms and Conditions", diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py index 40c905b161c..127517a1e3f 100644 --- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py +++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py @@ -21,6 +21,7 @@ class TermsandConditions(Document): from frappe.types import DF buying: DF.Check + copy_attachments_to_transaction: DF.Check disabled: DF.Check selling: DF.Check terms: DF.TextEditor | None diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index cdb219180ad..1b7f147f30c 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -372,6 +372,15 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends ( }); } + items_add(doc, cdt, cdn) { + const row = frappe.get_doc(cdt, cdn); + if (doc.project) { + frappe.model.set_value(cdt, cdn, "project", doc.project); + } else { + this.frm.script_manager.copy_from_first_row("items", row, ["project"]); + } + } + make_sales_invoice() { frappe.model.open_mapped_doc({ method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice", diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index 60981047d9e..78567b3b0d8 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -368,11 +368,13 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend items_add(doc, cdt, cdn) { const row = frappe.get_doc(cdt, cdn); - this.frm.script_manager.copy_from_first_row("items", row, [ - "expense_account", - "cost_center", - "project", - ]); + const field_copy = ["expense_account", "cost_center"]; + if (doc.project) { + frappe.model.set_value(cdt, cdn, "project", doc.project); + } else { + field_copy.push("project"); + } + this.frm.script_manager.copy_from_first_row("items", row, field_copy); } }; diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 16f3eb38cac..b805f9cde3d 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -673,6 +673,9 @@ class PurchaseReceipt(BuyingController): or stock_asset_rbnb ) + if self.is_return and item.expense_account: + loss_account = item.expense_account + cost_center = item.cost_center or frappe.get_cached_value( "Company", self.company, "cost_center" ) diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index 8fda1c44702..1a581652d19 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -1042,7 +1042,7 @@ "search_index": 1 }, { - "depends_on": "eval:doc.use_serial_batch_fields === 0", + "depends_on": "eval:doc.use_serial_batch_fields === 0 && parent.docstatus == 0", "fieldname": "add_serial_batch_for_rejected_qty", "fieldtype": "Button", "label": "Add Serial / Batch No (Rejected Qty)" @@ -1057,7 +1057,7 @@ "fieldtype": "Column Break" }, { - "depends_on": "eval:doc.use_serial_batch_fields === 0", + "depends_on": "eval:doc.use_serial_batch_fields === 0 && parent.docstatus == 0", "fieldname": "add_serial_batch_bundle", "fieldtype": "Button", "label": "Add Serial / Batch No" @@ -1148,7 +1148,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-04-07 15:41:47.032889", + "modified": "2026-04-29 16:01:34.154697", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 3448a8ff8de..a7733b0bf8b 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -392,10 +392,10 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( f""" - SELECT distinct item_code, item_name + SELECT distinct `tab{from_doctype}`.item_code, `tab{from_doctype}`.item_name FROM `tab{from_doctype}` JOIN `tab{parent_doctype}` ON `tab{parent_doctype}`.name = `tab{from_doctype}`.parent - WHERE parent=%(parent)s and `tab{parent_doctype}`.docstatus < 2 and item_code like %(txt)s + WHERE `tab{from_doctype}`.parent=%(parent)s and `tab{parent_doctype}`.docstatus < 2 and `tab{from_doctype}`.item_code like %(txt)s {qi_condition} {cond} {mcond} ORDER BY item_code limit {cint(page_len)} offset {cint(start)} """, diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index bfc857ed80b..1fc0f19a4d5 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -356,8 +356,15 @@ def repost(doc): message = message.get("message") status = "Failed" - # If failed because of timeout, set status to In Progress - if traceback and ("timeout" in traceback.lower() or "Deadlock found" in traceback): + # If failed because of a recoverable error (timeout, deadlock), set status to In Progress + # so the scheduler automatically retries instead of leaving it permanently failed. + # NOTE: isinstance check comes first because the traceback string matching is unreliable + # when SIGALRM kills the process mid-C-extension (JobTimeoutException may not appear + # in the traceback if the exception handler itself was interrupted). + traceback_lower = traceback.lower() if traceback else "" + if isinstance(e, RecoverableErrors) or ( + traceback_lower and ("timeout" in traceback_lower or "deadlock found" in traceback_lower) + ): status = "In Progress" if traceback: diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 65d04cfd80c..4e13bb246f2 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -1031,6 +1031,7 @@ def insert_item_price(args): currency=args.currency, uom=args.stock_uom, price_list=args.price_list, + valid_from=transaction_date, ) item_price.insert() frappe.msgprint( @@ -1055,6 +1056,7 @@ def insert_item_price(args): "currency": args.currency, "price_list_rate": price_list_rate, "uom": args.stock_uom, + "valid_from": transaction_date, } ) item_price.insert() diff --git a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py index c927f3d6d9e..b73f8dde3b2 100644 --- a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py +++ b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py @@ -7,6 +7,7 @@ import frappe from frappe import _ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos as get_serial_nos_from_sle +from erpnext.stock.serial_batch_bundle import get_serial_no_status from erpnext.stock.stock_ledger import get_stock_ledger_entries BUYING_VOUCHER_TYPES = ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"] @@ -111,7 +112,7 @@ def get_data(filters): "posting_time": row.posting_time, "voucher_type": row.voucher_type, "voucher_no": row.voucher_no, - "status": "Active" if row.actual_qty > 0 else "Delivered", + "status": get_serial_no_status(row), "company": row.company, "warehouse": row.warehouse, "qty": 1 if row.actual_qty > 0 else -1, diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index 45af9474d1f..3f59dc6be3b 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -73,6 +73,7 @@ def execute(filters=None): inv_dimension_wise_dict, filters, inv_dimension_key=inv_dimension_key, opening_row=opening_row ) + item_wh_wise_prev_sle = {} for sle in sl_entries: item_detail = item_details[sle.item_code] @@ -114,6 +115,21 @@ def execute(filters=None): elif sle.voucher_type == "Stock Reconciliation": sle["in_out_rate"] = sle.valuation_rate + if ( + sle.voucher_type == "Stock Reconciliation" + and not sle.in_qty + and not sle.out_qty + and not sle.actual_qty + ): + if prev_sle := item_wh_wise_prev_sle.get((sle.item_code, sle.warehouse)): + bal_qty = prev_sle.get("qty_after_transaction", 0) + qty = sle.qty_after_transaction - bal_qty + if qty > 0: + sle.in_qty = qty + elif qty < 0: + sle.out_qty = qty + + item_wh_wise_prev_sle[(sle.item_code, sle.warehouse)] = sle data.append(sle) if include_uom: diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 84535372b4b..b2eba994538 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -15,6 +15,45 @@ from erpnext.stock.deprecated_serial_batch import ( ) from erpnext.stock.valuation import round_off_if_near_zero +CONSUMED_SERIAL_NO_STOCK_ENTRY_PURPOSES = ( + "Manufacture", + "Material Issue", + "Repack", + "Material Consumption for Manufacture", +) +INACTIVE_SERIAL_NO_STOCK_ENTRY_PURPOSES = ("Disassemble", "Material Receipt") + + +def get_serial_no_status(sle): + warehouse = sle.warehouse if sle.actual_qty > 0 else None + if warehouse: + return "Active" + + status = get_status_for_serial_nos(sle) + if sle.voucher_type == "Stock Entry" and sle.actual_qty < 0: + purpose = frappe.get_cached_value("Stock Entry", sle.voucher_no, "purpose") + if purpose in INACTIVE_SERIAL_NO_STOCK_ENTRY_PURPOSES: + status = "Inactive" + + return status + + +def get_status_for_serial_nos(sle): + status = "Inactive" + if sle.actual_qty < 0: + status = "Delivered" + if sle.voucher_type == "Stock Entry": + purpose = frappe.get_cached_value("Stock Entry", sle.voucher_no, "purpose") + if purpose in CONSUMED_SERIAL_NO_STOCK_ENTRY_PURPOSES: + status = "Consumed" + + if sle.is_cancelled == 1 and ( + sle.voucher_type in ["Purchase Invoice", "Purchase Receipt"] or status == "Consumed" + ): + status = "Inactive" + + return status + class SerialBatchBundle: def __init__(self, **kwargs): @@ -410,25 +449,7 @@ class SerialBatchBundle: self.update_serial_no_status_warehouse(self.sle, serial_nos) def get_status_for_serial_nos(self, sle): - status = "Inactive" - if sle.actual_qty < 0: - status = "Delivered" - if sle.voucher_type == "Stock Entry": - purpose = frappe.get_cached_value("Stock Entry", sle.voucher_no, "purpose") - if purpose in [ - "Manufacture", - "Material Issue", - "Repack", - "Material Consumption for Manufacture", - ]: - status = "Consumed" - - if sle.is_cancelled == 1 and ( - sle.voucher_type in ["Purchase Invoice", "Purchase Receipt"] or status == "Consumed" - ): - status = "Inactive" - - return status + return get_status_for_serial_nos(sle) def update_serial_no_status_warehouse(self, sle, serial_nos): warehouse = sle.warehouse if sle.actual_qty > 0 else None @@ -436,19 +457,12 @@ class SerialBatchBundle: if isinstance(serial_nos, str): serial_nos = [serial_nos] - status = "Active" - if not warehouse: - status = self.get_status_for_serial_nos(sle) + status = get_serial_no_status(sle) customer = None if sle.voucher_type in ["Sales Invoice", "Delivery Note"] and sle.actual_qty < 0: customer = frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "customer") - if sle.voucher_type in ["Stock Entry"] and sle.actual_qty < 0: - purpose = frappe.get_cached_value("Stock Entry", sle.voucher_no, "purpose") - if purpose in ["Disassemble", "Material Receipt"]: - status = "Inactive" - sn_table = frappe.qb.DocType("Serial No") query = ( diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index 7e40fd681eb..0fa68f52c16 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -15,6 +15,14 @@ class UOMMustBeIntegerError(frappe.ValidationError): class TransactionBase(StatusUpdater): + def on_change(self): + # `on_change` also fires for `db_set()`, so only run during an actual insert/save. + is_real_save = self.flags.in_insert or (self.doctype, self.name) in frappe.flags.currently_saving + if not is_real_save: + return + + self.copy_terms_and_conditions_attachments() + def validate_posting_time(self): # set Edit Posting Date and Time to 1 while data import if frappe.flags.in_import and self.posting_date: @@ -33,6 +41,56 @@ class TransactionBase(StatusUpdater): def validate_uom_is_integer(self, uom_field, qty_fields, child_dt=None): validate_uom_is_integer(self, uom_field, qty_fields, child_dt) + def copy_terms_and_conditions_attachments(self): + if ( + not self.name + or not self.meta.has_field("tc_name") + or not self.tc_name + or not self.has_value_changed("tc_name") + ): + return + + copy_attachments_to_transaction = frappe.db.get_value( + "Terms and Conditions", self.tc_name, "copy_attachments_to_transaction" + ) + if not cint(copy_attachments_to_transaction): + return + + source_attachments = frappe.get_all( + "File", + filters={ + "attached_to_doctype": "Terms and Conditions", + "attached_to_name": self.tc_name, + }, + fields=["name", "file_url"], + ) + if not source_attachments: + return + + existing_file_urls = { + attachment.file_url + for attachment in frappe.get_all( + "File", + filters={ + "attached_to_doctype": self.doctype, + "attached_to_name": self.name, + }, + fields=["file_url"], + ) + if attachment.file_url + } + + for source_attachment in source_attachments: + if not source_attachment.file_url or source_attachment.file_url in existing_file_urls: + continue + + # Reuse the existing file metadata so the same on-disk blob is shared. + new_attachment = frappe.get_doc("File", source_attachment.name).create_attachment_copy( + attached_to_doctype=self.doctype, + attached_to_name=self.name, + ) + existing_file_urls.add(new_attachment.file_url) + def validate_with_previous_doc(self, ref): self.exclude_fields = ["conversion_factor", "uom"] if self.get("is_return") else []