diff --git a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.py b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.py index c24db1d6a03..e058f8225ab 100644 --- a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.py +++ b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.py @@ -3,10 +3,10 @@ # import frappe -from frappe.model.document import Document +from erpnext.accounts.doctype.sales_invoice_item.sales_invoice_item import SalesInvoiceItem -class POSInvoiceItem(Document): +class POSInvoiceItem(SalesInvoiceItem): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 3352e0d90ad..e2cbf5eff0a 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -8,6 +8,7 @@ from frappe.contacts.doctype.address.address import get_address_display from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values from frappe.utils import add_days, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate +from frappe.utils.data import comma_and import erpnext from erpnext.accounts.deferred_revenue import validate_service_stop_date @@ -27,7 +28,6 @@ from erpnext.accounts.party import get_due_date, get_party_account, get_party_de from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_account_currency from erpnext.assets.doctype.asset.depreciation import ( depreciate_asset, - get_disposal_account_and_cost_center, get_gl_entries_on_asset_disposal, get_gl_entries_on_asset_regain, reset_depreciation_schedule, @@ -39,7 +39,6 @@ from erpnext.controllers.selling_controller import SellingController from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data from erpnext.setup.doctype.company.company import update_company_current_month_sales from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so -from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no, get_serial_nos form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -297,11 +296,13 @@ class SalesInvoice(SellingController): if cint(self.is_pos): self.validate_pos() + self.validate_dropship_item() + if cint(self.update_stock): - self.validate_dropship_item() self.validate_warehouse() self.update_current_stock() - self.validate_delivery_note() + + self.validate_delivery_note() # validate service stop date to lie in between start and end date validate_service_stop_date(self) @@ -379,13 +380,7 @@ class SalesInvoice(SellingController): def validate_item_cost_centers(self): for item in self.items: - cost_center_company = frappe.get_cached_value("Cost Center", item.cost_center, "company") - if cost_center_company != self.company: - frappe.throw( - _("Row #{0}: Cost Center {1} does not belong to company {2}").format( - frappe.bold(item.idx), frappe.bold(item.cost_center), frappe.bold(self.company) - ) - ) + item.validate_cost_center(self.company) def validate_income_account(self): for item in self.get("items"): @@ -601,43 +596,48 @@ class SalesInvoice(SellingController): self.delete_auto_created_batches() def update_status_updater_args(self): - if cint(self.update_stock): - self.status_updater.append( - { - "source_dt": "Sales Invoice Item", - "target_dt": "Sales Order Item", - "target_parent_dt": "Sales Order", - "target_parent_field": "per_delivered", - "target_field": "delivered_qty", - "target_ref_field": "qty", - "source_field": "qty", - "join_field": "so_detail", - "percent_join_field": "sales_order", - "status_field": "delivery_status", - "keyword": "Delivered", - "second_source_dt": "Delivery Note Item", - "second_source_field": "qty", - "second_join_field": "so_detail", - "overflow_type": "delivery", - "extra_cond": """ and exists(select name from `tabSales Invoice` - where name=`tabSales Invoice Item`.parent and update_stock = 1)""", - } - ) - if cint(self.is_return): - self.status_updater.append( - { - "source_dt": "Sales Invoice Item", - "target_dt": "Sales Order Item", - "join_field": "so_detail", - "target_field": "returned_qty", - "target_parent_dt": "Sales Order", - "source_field": "-1 * qty", - "second_source_dt": "Delivery Note Item", - "second_source_field": "-1 * qty", - "second_join_field": "so_detail", - "extra_cond": """ and exists (select name from `tabSales Invoice` where name=`tabSales Invoice Item`.parent and update_stock=1 and is_return=1)""", - } - ) + if not cint(self.update_stock): + return + + self.status_updater.append( + { + "source_dt": "Sales Invoice Item", + "target_dt": "Sales Order Item", + "target_parent_dt": "Sales Order", + "target_parent_field": "per_delivered", + "target_field": "delivered_qty", + "target_ref_field": "qty", + "source_field": "qty", + "join_field": "so_detail", + "percent_join_field": "sales_order", + "status_field": "delivery_status", + "keyword": "Delivered", + "second_source_dt": "Delivery Note Item", + "second_source_field": "qty", + "second_join_field": "so_detail", + "overflow_type": "delivery", + "extra_cond": """ and exists(select name from `tabSales Invoice` + where name=`tabSales Invoice Item`.parent and update_stock = 1)""", + } + ) + + if not cint(self.is_return): + return + + self.status_updater.append( + { + "source_dt": "Sales Invoice Item", + "target_dt": "Sales Order Item", + "join_field": "so_detail", + "target_field": "returned_qty", + "target_parent_dt": "Sales Order", + "source_field": "-1 * qty", + "second_source_dt": "Delivery Note Item", + "second_source_field": "-1 * qty", + "second_join_field": "so_detail", + "extra_cond": """ and exists (select name from `tabSales Invoice` where name=`tabSales Invoice Item`.parent and update_stock=1 and is_return=1)""", + } + ) def check_credit_limit(self): from erpnext.selling.doctype.customer.customer import check_credit_limit @@ -662,13 +662,8 @@ class SalesInvoice(SellingController): def unlink_sales_invoice_from_timesheets(self): for row in self.timesheets: timesheet = frappe.get_doc("Timesheet", row.time_sheet) - for time_log in timesheet.time_logs: - if time_log.sales_invoice == self.name: - time_log.sales_invoice = None - timesheet.calculate_total_amounts() - timesheet.calculate_percentage_billed() + timesheet.unlink_sales_invoice(self.name) timesheet.flags.ignore_validate_update_after_submit = True - timesheet.set_status() timesheet.db_update_all() @frappe.whitelist() @@ -1011,12 +1006,17 @@ class SalesInvoice(SellingController): frappe.throw(_("Warehouse required for stock Item {0}").format(d.item_code)) def validate_delivery_note(self): - for d in self.get("items"): - if d.delivery_note: - msgprint( - _("Stock cannot be updated against Delivery Note {0}").format(d.delivery_note), - raise_exception=1, - ) + """If items are linked with a delivery note, stock cannot be updated again.""" + if not cint(self.update_stock): + return + + notes = [item.delivery_note for item in self.items if item.delivery_note] + if notes: + frappe.throw( + _("Stock cannot be updated against the following Delivery Notes: {0}").format( + comma_and(notes) + ), + ) def validate_write_off_account(self): if flt(self.write_off_amount) and not self.write_off_account: @@ -1030,29 +1030,23 @@ class SalesInvoice(SellingController): msgprint(_("Please enter Account for Change Amount"), raise_exception=1) def validate_dropship_item(self): - for item in self.items: - if item.sales_order: - if frappe.db.get_value("Sales Order Item", item.so_detail, "delivered_by_supplier"): - frappe.throw(_("Could not update stock, invoice contains drop shipping item.")) + """If items are drop shipped, stock cannot be updated.""" + if not cint(self.update_stock): + return + + if any(item.delivered_by_supplier for item in self.items): + frappe.throw( + _( + "Stock cannot be updated because the invoice contains a drop shipping item. Please disable 'Update Stock' or remove the drop shipping item." + ), + ) def update_current_stock(self): - for d in self.get("items"): - if d.item_code and d.warehouse: - bin = frappe.db.sql( - "select actual_qty from `tabBin` where item_code = %s and warehouse = %s", - (d.item_code, d.warehouse), - as_dict=1, - ) - d.actual_qty = bin and flt(bin[0]["actual_qty"]) or 0 + for item in self.items: + item.set_actual_qty() - for d in self.get("packed_items"): - bin = frappe.db.sql( - "select actual_qty, projected_qty from `tabBin` where item_code = %s and warehouse = %s", - (d.item_code, d.warehouse), - as_dict=1, - ) - d.actual_qty = bin and flt(bin[0]["actual_qty"]) or 0 - d.projected_qty = bin and flt(bin[0]["projected_qty"]) or 0 + for packed_item in self.packed_items: + packed_item.set_actual_and_projected_qty() def update_packing_list(self): if cint(self.update_stock) == 1: @@ -1127,17 +1121,8 @@ class SalesInvoice(SellingController): return warehouse def set_income_account_for_fixed_assets(self): - disposal_account = depreciation_cost_center = None - for d in self.get("items"): - if d.is_fixed_asset: - if not disposal_account: - disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center( - self.company - ) - - d.income_account = disposal_account - if not d.cost_center: - d.cost_center = depreciation_cost_center + for item in self.items: + item.set_income_account_for_fixed_asset(self.company) def check_prev_docstatus(self): for d in self.get("items"): @@ -1510,47 +1495,46 @@ class SalesInvoice(SellingController): ) if not skip_change_gl_entries: - self.make_gle_for_change_amount(gl_entries) + gl_entries.extend(self.get_gle_for_change_amount()) - def make_gle_for_change_amount(self, gl_entries): - if self.change_amount: - if self.account_for_change_amount: - gl_entries.append( - self.get_gl_dict( - { - "account": self.debit_to, - "party_type": "Customer", - "party": self.customer, - "against": self.account_for_change_amount, - "debit": flt(self.base_change_amount), - "debit_in_account_currency": flt(self.base_change_amount) - if self.party_account_currency == self.company_currency - else flt(self.change_amount), - "against_voucher": self.return_against - if cint(self.is_return) and self.return_against - else self.name, - "against_voucher_type": self.doctype, - "cost_center": self.cost_center, - "project": self.project, - }, - self.party_account_currency, - item=self, - ) - ) + def get_gle_for_change_amount(self) -> list[dict]: + if not self.change_amount: + return [] - gl_entries.append( - self.get_gl_dict( - { - "account": self.account_for_change_amount, - "against": self.customer, - "credit": self.base_change_amount, - "cost_center": self.cost_center, - }, - item=self, - ) - ) - else: - frappe.throw(_("Select change amount account"), title=_("Mandatory Field")) + if not self.account_for_change_amount: + frappe.throw(_("Please set Account for Change Amount"), title=_("Mandatory Field")) + + return [ + self.get_gl_dict( + { + "account": self.debit_to, + "party_type": "Customer", + "party": self.customer, + "against": self.account_for_change_amount, + "debit": flt(self.base_change_amount), + "debit_in_account_currency": flt(self.base_change_amount) + if self.party_account_currency == self.company_currency + else flt(self.change_amount), + "against_voucher": self.return_against + if cint(self.is_return) and self.return_against + else self.name, + "against_voucher_type": self.doctype, + "cost_center": self.cost_center, + "project": self.project, + }, + self.party_account_currency, + item=self, + ), + self.get_gl_dict( + { + "account": self.account_for_change_amount, + "against": self.customer, + "credit": self.base_change_amount, + "cost_center": self.cost_center, + }, + item=self, + ), + ] def make_write_off_gl_entry(self, gl_entries): # write off entries, applicable if only pos @@ -1659,48 +1643,9 @@ class SalesInvoice(SellingController): """ validate serial number agains Delivery Note and Sales Invoice """ - self.set_serial_no_against_delivery_note() - self.validate_serial_against_delivery_note() - - def set_serial_no_against_delivery_note(self): for item in self.items: - if item.serial_no and item.delivery_note and item.qty != len(get_serial_nos(item.serial_no)): - item.serial_no = get_delivery_note_serial_no(item.item_code, item.qty, item.delivery_note) - - def validate_serial_against_delivery_note(self): - """ - validate if the serial numbers in Sales Invoice Items are same as in - Delivery Note Item - """ - - for item in self.items: - if not item.delivery_note or not item.dn_detail: - continue - - serial_nos = frappe.db.get_value("Delivery Note Item", item.dn_detail, "serial_no") or "" - dn_serial_nos = set(get_serial_nos(serial_nos)) - - serial_nos = item.serial_no or "" - si_serial_nos = set(get_serial_nos(serial_nos)) - serial_no_diff = si_serial_nos - dn_serial_nos - - if serial_no_diff: - dn_link = frappe.utils.get_link_to_form("Delivery Note", item.delivery_note) - serial_no_msg = ", ".join(frappe.bold(d) for d in serial_no_diff) - - msg = _("Row #{0}: The following Serial Nos are not present in Delivery Note {1}:").format( - item.idx, dn_link - ) - msg += " " + serial_no_msg - - frappe.throw(msg=msg, title=_("Serial Nos Mismatch")) - - if item.serial_no and cint(item.qty) != len(si_serial_nos): - frappe.throw( - _("Row #{0}: {1} Serial numbers required for Item {2}. You have provided {3}.").format( - item.idx, item.qty, item.item_code, len(si_serial_nos) - ) - ) + item.set_serial_no_against_delivery_note() + item.validate_serial_against_delivery_note() def update_project(self): if self.project: diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py index 9be1b42aab3..f92a7a8222b 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py @@ -2,7 +2,13 @@ # License: GNU General Public License v3. See license.txt +import frappe +from frappe import _ from frappe.model.document import Document +from frappe.utils.data import cint + +from erpnext.assets.doctype.asset.depreciation import get_disposal_account_and_cost_center +from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no, get_serial_nos class SalesInvoiceItem(Document): @@ -92,4 +98,67 @@ class SalesInvoiceItem(Document): weight_uom: DF.Link | None # end: auto-generated types - pass + def validate_cost_center(self, company: str): + cost_center_company = frappe.get_cached_value("Cost Center", self.cost_center, "company") + if cost_center_company != company: + frappe.throw( + _("Row #{0}: Cost Center {1} does not belong to company {2}").format( + frappe.bold(self.idx), frappe.bold(self.cost_center), frappe.bold(company) + ) + ) + + def set_actual_qty(self): + if self.item_code and self.warehouse: + self.actual_qty = ( + frappe.db.get_value( + "Bin", {"item_code": self.item_code, "warehouse": self.warehouse}, "actual_qty" + ) + or 0 + ) + + def set_income_account_for_fixed_asset(self, company: str): + """Set income account for fixed asset item based on company's disposal account and cost center.""" + if not self.is_fixed_asset: + return + + disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(company) + + self.income_account = disposal_account + if not self.cost_center: + self.cost_center = depreciation_cost_center + + def set_serial_no_against_delivery_note(self): + """Set serial no based on delivery note.""" + if self.serial_no and self.delivery_note and self.qty != len(get_serial_nos(self.serial_no)): + self.serial_no = get_delivery_note_serial_no(self.item_code, self.qty, self.delivery_note) + + def validate_serial_against_delivery_note(self): + """Ensure the serial numbers in this Sales Invoice Item are same as in the linked Delivery Note.""" + if not self.delivery_note or not self.dn_detail: + return + + serial_nos = frappe.db.get_value("Delivery Note Item", self.dn_detail, "serial_no") or "" + dn_serial_nos = set(get_serial_nos(serial_nos)) + + serial_nos = self.serial_no or "" + si_serial_nos = set(get_serial_nos(serial_nos)) + serial_no_diff = si_serial_nos - dn_serial_nos + + if serial_no_diff: + dn_link = frappe.utils.get_link_to_form("Delivery Note", self.delivery_note) + msg = ( + _("Row #{0}: The following serial numbers are not present in Delivery Note {1}:").format( + self.idx, dn_link + ) + + " " + + ", ".join(frappe.bold(d) for d in serial_no_diff) + ) + + frappe.throw(msg=msg, title=_("Serial Nos Mismatch")) + + if self.serial_no and cint(self.qty) != len(si_serial_nos): + frappe.throw( + _( + "Row #{0}: {1} serial numbers are required for Item {2}. You have provided {3} serial numbers." + ).format(self.idx, self.qty, self.item_code, len(si_serial_nos)) + ) diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index e26d04aa9af..d7014965faf 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -256,6 +256,16 @@ class Timesheet(Document): if not ts_detail.is_billable: ts_detail.billing_rate = 0.0 + def unlink_sales_invoice(self, sales_invoice: str): + """Remove link to Sales Invoice from all time logs.""" + for time_log in self.time_logs: + if time_log.sales_invoice == sales_invoice: + time_log.sales_invoice = None + + self.calculate_total_amounts() + self.calculate_percentage_billed() + self.set_status() + @frappe.whitelist() def get_projectwise_timesheet_data(project=None, parent=None, from_time=None, to_time=None): diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index c5fed0dcd89..d81ceafd85d 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -51,7 +51,16 @@ class PackedItem(Document): warehouse: DF.Link | None # end: auto-generated types - pass + def set_actual_and_projected_qty(self): + "Set actual and projected qty based on warehouse and item_code" + _bin = frappe.db.get_value( + "Bin", + {"item_code": self.item_code, "warehouse": self.warehouse}, + ["actual_qty", "projected_qty"], + as_dict=True, + ) + self.actual_qty = _bin.actual_qty if _bin else 0 + self.projected_qty = _bin.projected_qty if _bin else 0 def make_packing_list(doc):