From 498cd2b371115d78f59edd8f9471002f509a1f68 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sun, 31 May 2026 12:52:26 +0530 Subject: [PATCH] refactor(sales_invoice): extract non-GL services (Phase 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the sales_invoice.py monolith into focused service modules under sales_invoice/services/: - fixed_assets.py — FixedAssetService (depreciation, disposal, split) - inter_company.py — validate/link/unlink inter-company docs - loyalty.py — LoyaltyService (earn, redeem, delete points) - pos.py — POSService + POS free functions - status.py — StatusService + is_overdue / get_discounting_status - timesheet_billing.py — TimesheetBillingService Lifecycle hooks (validate/on_submit/on_cancel) call services directly; no thin shims. The 7 methods POS Invoice calls via self.* are kept on the class with an explicit comment. @frappe.whitelist() doc-methods and framework hooks (set_status, set_indicator) stay on the class. sales_invoice.py: 2156 → 1205 lines. All 29 snapshot + 121 SI tests green. Co-Authored-By: Claude Sonnet 4.6 --- .../doctype/sales_invoice/sales_invoice.py | 1203 ++--------------- .../sales_invoice/services/fixed_assets.py | 173 +++ .../sales_invoice/services/inter_company.py | 68 + .../doctype/sales_invoice/services/loyalty.py | 162 +++ .../doctype/sales_invoice/services/pos.py | 396 ++++++ .../doctype/sales_invoice/services/status.py | 130 ++ .../services/timesheet_billing.py | 121 ++ 7 files changed, 1179 insertions(+), 1074 deletions(-) create mode 100644 erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py create mode 100644 erpnext/accounts/doctype/sales_invoice/services/inter_company.py create mode 100644 erpnext/accounts/doctype/sales_invoice/services/loyalty.py create mode 100644 erpnext/accounts/doctype/sales_invoice/services/pos.py create mode 100644 erpnext/accounts/doctype/sales_invoice/services/status.py create mode 100644 erpnext/accounts/doctype/sales_invoice/services/timesheet_billing.py diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 89851d8c16e..eac209cacad 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -5,17 +5,13 @@ import frappe import frappe.utils from frappe import _, msgprint, throw -from frappe.model.document import Document from frappe.query_builder import Case -from frappe.utils import add_days, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate +from frappe.utils import cint, flt, formatdate, get_link_to_form from frappe.utils.data import comma_and import erpnext from erpnext.accounts.deferred_revenue import validate_service_stop_date -from erpnext.accounts.doctype.loyalty_program.loyalty_program import ( - get_loyalty_program_details_with_points, - validate_loyalty_points, -) +from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points from erpnext.accounts.doctype.pricing_rule.utils import ( update_coupon_code_count, validate_coupon_code, @@ -25,24 +21,10 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger validate_docs_for_voucher_types, ) from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import SalesTaxWithholding -from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.party import get_due_date, get_party_account -from erpnext.accounts.utils import ( - get_account_currency, - update_voucher_outstanding, -) -from erpnext.assets.doctype.asset.asset import split_asset -from erpnext.assets.doctype.asset.depreciation import ( - depreciate_asset, - get_gl_entries_on_asset_disposal, - get_gl_entries_on_asset_regain, - reset_depreciation_schedule, - reverse_depreciation_entry_made_on_disposal, -) -from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity +from erpnext.accounts.utils import update_voucher_outstanding from erpnext.controllers.accounts_controller import validate_account_head 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 @@ -60,14 +42,35 @@ from .mapper import ( update_taxes, validate_inter_company_transaction, ) +from .services.fixed_assets import FixedAssetService +from .services.inter_company import ( + unlink_inter_company_doc, + update_linked_doc, + validate_inter_company_party, +) +from .services.loyalty import LoyaltyService +from .services.pos import ( + PartialPaymentValidationError, + POSService, + get_all_mode_of_payments, + get_mode_of_payment_info, + get_mode_of_payments_info, + update_multi_mode_option, +) +from .services.pos import ( + get_bank_cash_account as _get_bank_cash_account, +) +from .services.status import ( + StatusService, + get_discounting_status, + get_total_in_party_account_currency, + is_overdue, +) +from .services.timesheet_billing import TimesheetBillingService form_grid_templates = {"items": "templates/form_grid/item_grid.html"} -class PartialPaymentValidationError(frappe.ValidationError): - pass - - class SalesInvoice(SellingController): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -285,21 +288,7 @@ class SalesInvoice(SellingController): def set_indicator(self): """Set indicator for portal""" - if self.outstanding_amount < 0: - self.indicator_title = _("Credit Note Issued") - self.indicator_color = "gray" - elif self.outstanding_amount > 0 and getdate(self.due_date) >= getdate(nowdate()): - self.indicator_color = "orange" - self.indicator_title = _("Unpaid") - elif self.outstanding_amount > 0 and getdate(self.due_date) < getdate(nowdate()): - self.indicator_color = "red" - self.indicator_title = _("Overdue") - elif cint(self.is_return) == 1: - self.indicator_title = _("Return") - self.indicator_color = "gray" - else: - self.indicator_color = "green" - self.indicator_title = _("Paid") + StatusService(self).set_indicator() def onload(self): super().onload() @@ -321,15 +310,15 @@ class SalesInvoice(SellingController): SalesTaxWithholding(self).on_validate() self.validate_proj_cust() - self.validate_pos_return() + POSService(self).validate_pos_return() self.validate_with_previous_doc() self.validate_uom_is_integer("stock_uom", "stock_qty") self.validate_uom_is_integer("uom", "qty") self.check_sales_order_on_hold_or_close("sales_order") self.validate_debit_to_acc() self.clear_unallocated_advances("Sales Invoice Advance", "advances") - self.validate_fixed_asset() - self.set_income_account_for_fixed_assets() + FixedAssetService(self).validate_fixed_asset() + FixedAssetService(self).set_income_account_for_fixed_assets() self.validate_item_cost_centers() self.check_conversion_rate() self.validate_accounts() @@ -338,7 +327,6 @@ class SalesInvoice(SellingController): self.doctype, self.customer, self.company, self.inter_company_invoice_reference ) - # Validating coupon code if self.coupon_code: validate_coupon_code(self.coupon_code) @@ -346,8 +334,8 @@ class SalesInvoice(SellingController): self.validate_pos() if cint(self.is_created_using_pos): - self.validate_created_using_pos() - self.validate_full_payment() + POSService(self).validate_created_using_pos() + POSService(self).validate_full_payment() self.validate_dropship_item() @@ -357,10 +345,7 @@ class SalesInvoice(SellingController): self.validate_delivery_note() - is_deferred_invoice = any(d.get("enable_deferred_revenue") for d in self.get("items")) - - # validate service stop date to lie in between start and end date - if is_deferred_invoice: + if any(d.get("enable_deferred_revenue") for d in self.get("items")): validate_service_stop_date(self) if not self.is_opening: @@ -372,7 +357,7 @@ class SalesInvoice(SellingController): frappe.throw(_("Direct return is not allowed for Timesheet.")) if not self.is_return: - self.validate_time_sheets_are_submitted() + TimesheetBillingService(self).validate_time_sheets_are_submitted() from erpnext.accounts.services.billing_validation import BillingValidationService @@ -386,20 +371,19 @@ class SalesInvoice(SellingController): row.billing_amount = -abs(row.billing_amount) self.update_packing_list() - self.set_billing_hours_and_amount() - self.update_timesheet_billing_for_project() + TimesheetBillingService(self).set_billing_hours_and_amount() + TimesheetBillingService(self).update_timesheet_billing_for_project() self.set_status() if self.is_pos and not self.is_return: - self.verify_payment_amount_is_positive() + POSService(self).verify_payment_amount_is_positive() - # validate amount in mode of payments for returned invoices for pos must be negative if self.is_pos and self.is_return: - self.verify_payment_amount_is_negative() + POSService(self).verify_payment_amount_is_negative() if self.redeem_loyalty_points and self.loyalty_points and not self.is_consolidated: validate_loyalty_points(self, self.loyalty_points) - self.allow_write_off_only_on_pos() + POSService(self).allow_write_off_only_on_pos() self.reset_default_field_value("set_warehouse", "items", "warehouse") self.validate_subcontracted_sales_order() self.validate_scio_self_rm_qty() @@ -416,36 +400,6 @@ class SalesInvoice(SellingController): validate_docs_for_voucher_types(["Sales Invoice"]) validate_docs_for_deferred_accounting([self.name], []) - def validate_fixed_asset(self): - if self.doctype != "Sales Invoice": - return - - for d in self.get("items"): - if d.is_fixed_asset: - if d.asset: - if not self.is_return: - asset_status = frappe.db.get_value("Asset", d.asset, "status") - if self.update_stock: - frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale")) - - elif asset_status in ("Scrapped", "Cancelled", "Capitalized"): - frappe.throw( - _("Row #{0}: Asset {1} cannot be sold, it is already {2}").format( - d.idx, d.asset, asset_status - ) - ) - elif asset_status == "Sold" and not self.is_return: - frappe.throw(_("Row #{0}: Asset {1} is already sold").format(d.idx, d.asset)) - elif not self.return_against: - frappe.throw( - _("Row #{0}: Return Against is required for returning asset").format(d.idx) - ) - else: - frappe.throw( - _("Row #{0}: You must select an Asset for Item {1}.").format(d.idx, d.item_code), - title=_("Missing Asset"), - ) - def validate_item_cost_centers(self): for item in self.items: item.validate_cost_center(self.company) @@ -455,14 +409,14 @@ class SalesInvoice(SellingController): validate_account_head(item.idx, item.income_account, self.company, _("Income")) def before_save(self): - self.set_account_for_mode_of_payment() - self.set_paid_amount() + POSService(self).set_account_for_mode_of_payment() + POSService(self).set_paid_amount() def before_submit(self): self.add_remarks() def on_submit(self): - self.validate_pos_paid_amount() + POSService(self).validate_pos_paid_amount() if not self.auto_repeat: frappe.get_cached_doc("Authorization Control").validate_approving_authority( @@ -483,8 +437,6 @@ class SalesInvoice(SellingController): self.update_billing_status_in_dn() self.clear_unallocated_mode_of_payments() - # Updating stock ledger should always be called after updating prevdoc status, - # because updating reserved qty in bin depends upon updated delivered qty in SO if self.update_stock == 1: for table_name in ["items", "packed_items"]: if not self.get(table_name): @@ -497,11 +449,9 @@ class SalesInvoice(SellingController): self.update_stock_reservation_entries() self.update_stock_ledger() - self.split_asset_based_on_sale_qty() + FixedAssetService(self).split_asset_based_on_sale_qty() + FixedAssetService(self).process_asset_depreciation() - self.process_asset_depreciation() - - # this sequence because outstanding may get -ve self.make_gl_entries() if self.update_stock == 1: @@ -515,7 +465,9 @@ class SalesInvoice(SellingController): if cint(self.is_pos) != 1 and not self.is_return: self.update_against_document_in_jv() - self.update_time_sheet(None if (self.is_return and self.return_against) else self.name) + TimesheetBillingService(self).update_time_sheet( + None if (self.is_return and self.return_against) else self.name + ) if frappe.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction": update_company_current_month_sales(self.company) @@ -525,7 +477,6 @@ class SalesInvoice(SellingController): if self.coupon_code: update_coupon_code_count(self.coupon_code, "used") - # create the loyalty point ledger entry if the customer is enrolled in any loyalty program if ( not self.is_return and not self.is_consolidated @@ -535,67 +486,22 @@ class SalesInvoice(SellingController): self.make_loyalty_point_entry() elif self.is_return and self.return_against and not self.is_consolidated and self.loyalty_program: against_si_doc = frappe.get_doc("Sales Invoice", self.return_against) - against_si_doc.delete_loyalty_point_entry() - against_si_doc.make_loyalty_point_entry() + LoyaltyService(against_si_doc).delete_loyalty_point_entry() + LoyaltyService(against_si_doc).make_loyalty_point_entry() if self.redeem_loyalty_points and not self.is_consolidated and self.loyalty_points: self.apply_loyalty_points() self.process_common_party_accounting() self.update_billed_qty_in_scio() - def validate_pos_return(self): - if self.is_consolidated: - # pos return is already validated in pos invoice - return - - if self.is_pos and self.is_return: - total_amount_in_payments = 0 - for payment in self.payments: - total_amount_in_payments += payment.amount - invoice_total = self.rounded_total or self.grand_total - if total_amount_in_payments < invoice_total: - frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total)) - - def validate_pos_paid_amount(self): - if len(self.payments) == 0 and self.is_pos and flt(self.grand_total) > 0: - frappe.throw(_("At least one mode of payment is required for POS invoice.")) - - def check_if_consolidated_invoice(self): - # since POS Invoice extends Sales Invoice, we explicitly check if doctype is Sales Invoice - if self.doctype == "Sales Invoice" and self.is_consolidated: - invoice_or_credit_note = "consolidated_credit_note" if self.is_return else "consolidated_invoice" - pos_closing_entry = frappe.get_all( - "POS Invoice Merge Log", - filters={invoice_or_credit_note: self.name}, - pluck="pos_closing_entry", - ) - if pos_closing_entry and pos_closing_entry[0]: - msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format( - frappe.bold(_("Consolidated Sales Invoice")), - get_link_to_form("POS Closing Entry", pos_closing_entry[0]), - ) - frappe.throw(msg, title=_("Not Allowed")) - - def check_if_created_using_pos_and_pos_closing_entry_generated(self): - if self.doctype == "Sales Invoice" and self.is_created_using_pos and self.pos_closing_entry: - pos_closing_entry_docstatus = frappe.db.get_value( - "POS Closing Entry", self.pos_closing_entry, "docstatus" - ) - if pos_closing_entry_docstatus == 1: - frappe.throw( - msg=_("To cancel this Sales Invoice you need to cancel the POS Closing Entry {}.").format( - get_link_to_form("POS Closing Entry", self.pos_closing_entry) - ), - title=_("Not Allowed"), - ) - def before_cancel(self): - # check if generated via POS and already included in POS Closing Entry - self.check_if_created_using_pos_and_pos_closing_entry_generated() - self.check_if_consolidated_invoice() + POSService(self).check_if_created_using_pos_and_pos_closing_entry_generated() + POSService(self).check_if_consolidated_invoice() super().before_cancel() - self.update_time_sheet(self.return_against if (self.is_return and self.return_against) else None) + TimesheetBillingService(self).update_time_sheet( + self.return_against if (self.is_return and self.return_against) else None + ) def on_cancel(self): check_if_return_invoice_linked_with_payment_entry(self) @@ -616,13 +522,11 @@ class SalesInvoice(SellingController): self.update_billing_status_for_zero_amount_refdoc("Delivery Note") self.update_billing_status_for_zero_amount_refdoc("Sales Order") - # Updating stock ledger should always be called after updating prevdoc status, - # because updating reserved qty in bin depends upon updated delivered qty in SO SalesTaxWithholding(self).on_cancel() if self.update_stock == 1: self.update_stock_ledger() - self.process_asset_depreciation() + FixedAssetService(self).process_asset_depreciation() self.make_gl_entries_on_cancel() @@ -638,16 +542,17 @@ class SalesInvoice(SellingController): if frappe.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction": update_company_current_month_sales(self.company) self.update_project() + if not self.is_return and not self.is_consolidated and self.loyalty_program: self.delete_loyalty_point_entry() elif self.is_return and self.return_against and not self.is_consolidated and self.loyalty_program: against_si_doc = frappe.get_doc("Sales Invoice", self.return_against) - against_si_doc.delete_loyalty_point_entry() - against_si_doc.make_loyalty_point_entry() + LoyaltyService(against_si_doc).delete_loyalty_point_entry() + LoyaltyService(against_si_doc).make_loyalty_point_entry() unlink_inter_company_doc(self.doctype, self.name, self.inter_company_invoice_reference) - self.unlink_sales_invoice_from_timesheets() + TimesheetBillingService(self).unlink_sales_invoice_from_timesheets() self.ignore_linked_doctypes = ( "GL Entry", "Stock Ledger Entry", @@ -672,7 +577,7 @@ class SalesInvoice(SellingController): and self.is_created_using_pos and not self.pos_closing_entry ): - self.cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode() + POSService(self).cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode() self.update_billed_qty_in_scio() @@ -740,25 +645,9 @@ class SalesInvoice(SellingController): if validate_against_credit_limit: check_credit_limit(self.customer, self.company, bypass_credit_limit_check_at_sales_order) - def unlink_sales_invoice_from_timesheets(self): - for row in self.timesheets: - timesheet = frappe.get_doc("Timesheet", row.time_sheet) - timesheet.unlink_sales_invoice(self.name) - timesheet.flags.ignore_validate_update_after_submit = True - timesheet.db_update_all() - - def cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode(self): - pos_invoices = frappe.get_all( - "POS Invoice", filters={"consolidated_invoice": self.name}, pluck="name" - ) - if pos_invoices: - for pos_invoice in pos_invoices: - pos_invoice_doc = frappe.get_doc("POS Invoice", pos_invoice) - pos_invoice_doc.cancel() - @frappe.whitelist() def set_missing_values(self, for_validate: bool = False): - pos = self.set_pos_fields(for_validate) + pos = POSService(self).set_pos_fields(for_validate) if not self.debit_to: self.debit_to = get_party_account("Customer", self.customer, self.company) @@ -792,221 +681,29 @@ class SalesInvoice(SellingController): "set_default_payment": pos.get("set_grand_total_to_default_mop", 1), } + # Called by POS Invoice + def set_pos_fields(self, for_validate=False): + return POSService(self).set_pos_fields(for_validate) + @frappe.whitelist() def reset_mode_of_payments(self): - if self.pos_profile: - pos_profile = frappe.get_cached_doc("POS Profile", self.pos_profile) - update_multi_mode_option(self, pos_profile) - self.paid_amount = 0 - - def update_time_sheet(self, sales_invoice): - for d in self.timesheets: - if d.time_sheet: - timesheet = frappe.get_doc("Timesheet", d.time_sheet) - self.update_time_sheet_detail(timesheet, d, sales_invoice) - timesheet.calculate_total_amounts() - timesheet.calculate_percentage_billed() - timesheet.flags.ignore_validate_update_after_submit = True - timesheet.set_status() - timesheet.db_update_all() - - def update_billed_qty_in_scio(self): - if self.is_return: - return - - table = frappe.qb.DocType("Subcontracting Inward Order Received Item") - data = frappe._dict( - { - item.scio_detail: item.stock_qty if self._action == "submit" else -item.stock_qty - for item in self.items - if item.scio_detail - } - ) - - if data: - case_expr = Case() - for name, qty in data.items(): - case_expr = case_expr.when(table.name == name, table.billed_qty + qty) - frappe.qb.update(table).set(table.billed_qty, case_expr).where( - (table.name.isin(list(data.keys()))) & (table.docstatus == 1) - ).run() - - def update_time_sheet_detail(self, timesheet, args, sales_invoice): - for data in timesheet.time_logs: - if ( - (self.project and args.timesheet_detail == data.name) - or (not self.project and not data.sales_invoice and args.timesheet_detail == data.name) - or ( - not sales_invoice - and data.sales_invoice == self.name - and args.timesheet_detail == data.name - ) - or ( - self.is_return - and self.return_against - and data.sales_invoice - and data.sales_invoice == self.return_against - and not sales_invoice - and args.timesheet_detail == data.name - ) - ): - data.sales_invoice = sales_invoice - - def on_update_after_submit(self): - fields_to_check = [ - "additional_discount_account", - "cash_bank_account", - "account_for_change_amount", - "write_off_account", - "loyalty_redemption_account", - "unrealized_profit_loss_account", - "is_opening", - ] - child_tables = { - "items": ("income_account", "expense_account", "discount_account"), - "taxes": ("account_head",), - } - self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables) - if self.needs_repost: - self.validate_for_repost() - self.repost_accounting_entries() - - def set_paid_amount(self): - paid_amount = 0.0 - base_paid_amount = 0.0 - for data in self.payments: - data.base_amount = flt(data.amount * self.conversion_rate, self.precision("base_paid_amount")) - paid_amount += data.amount - base_paid_amount += data.base_amount - - self.paid_amount = paid_amount - self.base_paid_amount = base_paid_amount + POSService(self).reset_mode_of_payments() @frappe.whitelist() def set_account_for_mode_of_payment(self): - for payment in self.payments: - payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account") + POSService(self).set_account_for_mode_of_payment() - def validate_time_sheets_are_submitted(self): - # Note: This validation is skipped for return invoices - # to allow returns to reference already-billed timesheet details - for data in self.timesheets: - # Handle invoice duplication - if data.time_sheet and data.timesheet_detail: - if sales_invoice := frappe.db.get_value( - "Timesheet Detail", data.timesheet_detail, "sales_invoice" - ): - frappe.throw( - _("Row {0}: Sales Invoice {1} is already created for {2}").format( - data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet) - ) - ) + # Called by POS Invoice + def validate_pos(self): + POSService(self).validate_pos() - if data.time_sheet: - status = frappe.db.get_value("Timesheet", data.time_sheet, "status") - if status not in ["Submitted", "Payslip", "Partially Billed"]: - frappe.throw( - _("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet) - ) + # Called by POS Invoice + def validate_pos_opening_entry(self): + POSService(self).validate_pos_opening_entry() - def set_pos_fields(self, for_validate=False): - """Set retail related fields from POS Profiles""" - if cint(self.is_pos) != 1: - return - - if not self.account_for_change_amount: - self.account_for_change_amount = frappe.get_cached_value( - "Company", self.company, "default_cash_account" - ) - - from erpnext.stock.get_item_details import ( - ItemDetailsCtx, - get_pos_profile, - get_pos_profile_item_details_, - ) - - if not self.pos_profile and not self.flags.ignore_pos_profile: - pos_profile = get_pos_profile(self.company) or {} - if not pos_profile: - return - self.pos_profile = pos_profile.get("name") - - pos = {} - if self.pos_profile: - pos = frappe.get_doc("POS Profile", self.pos_profile) - - if pos: - if not for_validate: - update_multi_mode_option(self, pos) - self.tax_category = pos.get("tax_category") - - if not for_validate and not self.customer: - self.customer = pos.customer - - if not for_validate: - self.ignore_pricing_rule = pos.ignore_pricing_rule - - if pos.get("account_for_change_amount"): - self.account_for_change_amount = pos.get("account_for_change_amount") - - for fieldname in ( - "currency", - "letter_head", - "tc_name", - "company", - "select_print_heading", - "write_off_account", - "taxes_and_charges", - "write_off_cost_center", - "apply_discount_on", - "cost_center", - ): - if (not for_validate) or (for_validate and not self.get(fieldname)): - self.set(fieldname, pos.get(fieldname)) - - if pos.get("company_address"): - self.company_address = pos.get("company_address") - - if self.customer: - customer_price_list, customer_group = frappe.get_value( - "Customer", self.customer, ["default_price_list", "customer_group"] - ) - customer_group_price_list = frappe.get_value( - "Customer Group", customer_group, "default_price_list" - ) - selling_price_list = ( - customer_price_list or customer_group_price_list or pos.get("selling_price_list") - ) - else: - selling_price_list = pos.get("selling_price_list") - - if selling_price_list: - self.set("selling_price_list", selling_price_list) - - if not for_validate: - self.update_stock = cint(pos.get("update_stock")) - - # set pos values in items - for item in self.get("items"): - if item.get("item_code"): - profile_details = get_pos_profile_item_details_( - ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True - ) - for fname, val in profile_details.items(): - if (not for_validate) or (for_validate and not item.get(fname)): - item.set(fname, val) - - # fetch terms - if self.tc_name and not self.terms: - self.terms = frappe.db.get_value("Terms and Conditions", self.tc_name, "terms") - - # fetch charges - if self.taxes_and_charges and not len(self.get("taxes")): - from erpnext.accounts.services.taxes import TaxService - - TaxService(self).set_taxes() - - return pos + # Called by POS Invoice + def clear_unallocated_mode_of_payments(self): + POSService(self).clear_unallocated_mode_of_payments() def get_company_abbr(self): return frappe.db.sql("select abbr from tabCompany where name=%s", self.company)[0][0] @@ -1046,15 +743,6 @@ class SalesInvoice(SellingController): self.party_account_currency = account.account_currency - def clear_unallocated_mode_of_payments(self): - self.set("payments", self.get("payments", {"amount": ["not in", [0, None, ""]]})) - - frappe.db.sql( - """delete from `tabSales Invoice Payment` where parent = %s - and amount = 0""", - self.name, - ) - def validate_with_previous_doc(self): super().validate_with_previous_doc( { @@ -1120,7 +808,6 @@ class SalesInvoice(SellingController): self.remarks += " " + _("dated {0}").format(formatdate(self.po_date)) def validate_auto_set_posting_time(self): - # Don't auto set the posting date and time if invoice is amended if self.is_new() and self.amended_from: self.set_posting_time = 1 @@ -1157,68 +844,6 @@ class SalesInvoice(SellingController): if not res: throw(_("Customer {0} does not belong to project {1}").format(self.customer, self.project)) - def validate_pos(self): - if self.is_return: - invoice_total = self.rounded_total or self.grand_total - if abs(flt(self.paid_amount)) + abs(flt(self.write_off_amount)) - abs( - flt(invoice_total) - ) > 1.0 / (10.0 ** (self.precision("grand_total") + 1.0)): - frappe.throw(_("Paid amount + Write Off Amount can not be greater than Grand Total")) - - def validate_created_using_pos(self): - if self.is_created_using_pos and not self.pos_profile: - frappe.throw(_("POS Profile is mandatory to mark this invoice as POS Transaction.")) - - self.invoice_type_in_pos = frappe.db.get_single_value("POS Settings", "invoice_type") - if self.invoice_type_in_pos == "POS Invoice" and not self.is_return: - frappe.throw(_("Transactions using Sales Invoice in POS are disabled.")) - - self.validate_pos_opening_entry() - - def validate_full_payment(self): - allow_partial_payment = frappe.db.get_value("POS Profile", self.pos_profile, "allow_partial_payment") - invoice_total = flt(self.rounded_total) or flt(self.grand_total) - - if ( - self.docstatus == 1 - and not self.is_return - and not allow_partial_payment - and self.paid_amount < invoice_total - ): - frappe.throw( - msg=_("Partial Payment in POS Transactions are not allowed."), - exc=PartialPaymentValidationError, - ) - - def validate_pos_opening_entry(self): - opening_entries = frappe.get_all( - "POS Opening Entry", - fields=["name", "period_start_date"], - filters={"pos_profile": self.pos_profile, "status": "Open"}, - order_by="period_start_date desc", - ) - if not opening_entries: - frappe.throw( - title=_("POS Opening Entry Missing"), - msg=_("No open POS Opening Entry found for POS Profile {0}.").format( - frappe.bold(self.pos_profile) - ), - ) - if len(opening_entries) > 1: - frappe.throw( - title=_("Multiple POS Opening Entry"), - msg=_( - "POS Profile - {0} has multiple open POS Opening Entries. Please close or cancel the existing entries before proceeding." - ).format(self.pos_profile), - ) - if frappe.utils.get_date_str(opening_entries[0].get("period_start_date")) != frappe.utils.today(): - frappe.throw( - title=_("Outdated POS Opening Entry"), - msg=_( - "POS Opening Entry - {0} is outdated. Please close the POS and create a new POS Opening Entry." - ).format(opening_entries[0].get("name")), - ) - def validate_warehouse(self): super().validate_warehouse() @@ -1243,10 +868,6 @@ class SalesInvoice(SellingController): ), ) - def allow_write_off_only_on_pos(self): - if not self.is_pos and self.write_off_account: - self.write_off_account = None - def validate_subcontracted_sales_order(self): if self.has_subcontracted: if [item for item in self.items if not item.sales_order and not item.scio_detail]: @@ -1327,82 +948,13 @@ class SalesInvoice(SellingController): else: self.set("packed_items", []) - def set_billing_hours_and_amount(self): - if not self.project: - for timesheet in self.timesheets: - ts_doc = frappe.get_doc("Timesheet", timesheet.time_sheet) - if not timesheet.billing_hours and ts_doc.total_billable_hours: - timesheet.billing_hours = ts_doc.total_billable_hours - - if not timesheet.billing_amount and ts_doc.total_billable_amount: - timesheet.billing_amount = ts_doc.total_billable_amount - - def update_timesheet_billing_for_project(self): - if ( - not self.is_return - and not self.timesheets - and self.project - and self.is_auto_fetch_timesheet_enabled() - ): - self.add_timesheet_data() - else: - self.calculate_billing_amount_for_timesheet() - @frappe.whitelist() def is_auto_fetch_timesheet_enabled(self): return frappe.db.get_single_value("Projects Settings", "fetch_timesheet_in_sales_invoice") @frappe.whitelist() def add_timesheet_data(self): - self.set("timesheets", []) - if self.project: - for data in get_projectwise_timesheet_data(self.project): - self.append( - "timesheets", - { - "time_sheet": data.time_sheet, - "billing_hours": data.billing_hours, - "billing_amount": data.billing_amount, - "timesheet_detail": data.name, - "activity_type": data.activity_type, - "description": data.description, - }, - ) - - self.calculate_billing_amount_for_timesheet() - - def calculate_billing_amount_for_timesheet(self): - def timesheet_sum(field): - return sum((ts.get(field) or 0.0) for ts in self.timesheets) - - self.total_billing_amount = timesheet_sum("billing_amount") - self.total_billing_hours = timesheet_sum("billing_hours") - - def get_warehouse(self): - user_pos_profile = frappe.db.sql( - """select name, warehouse from `tabPOS Profile` - where ifnull(user,'') = %s and company = %s""", - (frappe.session["user"], self.company), - ) - warehouse = user_pos_profile[0][1] if user_pos_profile else None - - if not warehouse: - global_pos_profile = frappe.db.sql( - """select name, warehouse from `tabPOS Profile` - where (user is null or user = '') and company = %s""", - self.company, - ) - - if global_pos_profile: - warehouse = global_pos_profile[0][1] - elif not user_pos_profile: - msgprint(_("POS Profile required to make POS Entry"), raise_exception=True) - - return warehouse - - def set_income_account_for_fixed_assets(self): - for item in self.items: - item.set_income_account_for_fixed_asset(self.company) + TimesheetBillingService(self).add_timesheet_data() def check_prev_docstatus(self): for d in self.get("items"): @@ -1418,138 +970,6 @@ class SalesInvoice(SellingController): ): throw(_("Delivery Note {0} is not submitted").format(d.delivery_note)) - def split_asset_based_on_sale_qty(self): - asset_qty_map = self.get_asset_qty() - for asset, qty in asset_qty_map.items(): - if qty["actual_qty"] < qty["sale_qty"]: - frappe.throw( - _( - "Sell quantity cannot exceed the asset quantity. Asset {0} has only {1} item(s)." - ).format(asset, qty["actual_qty"]) - ) - - remaining_qty = qty["actual_qty"] - qty["sale_qty"] - if remaining_qty > 0: - split_asset(asset, remaining_qty) - - def get_asset_qty(self): - asset_qty_map = {} - - assets = {row.asset for row in self.items if row.is_fixed_asset and row.asset} - if not assets or self.is_return: - return asset_qty_map - - asset_actual_qty = dict( - frappe.db.get_all( - "Asset", - {"name": ["in", list(assets)]}, - ["name", "asset_quantity"], - as_list=True, - ) - ) - for row in self.items: - if row.is_fixed_asset and row.asset: - actual_qty = asset_actual_qty.get(row.asset) - if row.asset in asset_qty_map.keys(): - asset_qty_map[row.asset]["sale_qty"] += flt(row.qty) - else: - asset_qty_map.setdefault( - row.asset, - { - "sale_qty": flt(row.qty), - "actual_qty": flt(actual_qty), - }, - ) - - return asset_qty_map - - def process_asset_depreciation(self): - if self.is_internal_transfer(): - return - - if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1): - self.depreciate_asset_on_sale() - else: - self.restore_asset() - - self.update_asset() - - def depreciate_asset_on_sale(self): - """ - Depreciate asset on sale or cancellation of return sales invoice - """ - disposal_date = self.get_disposal_date() - for d in self.get("items"): - if d.asset: - asset = frappe.get_doc("Asset", d.asset) - if asset.calculate_depreciation and asset.status != "Fully Depreciated": - depreciate_asset(asset, disposal_date, self.get_note_for_asset_sale(asset)) - - def get_note_for_asset_sale(self, asset): - return _("This schedule was created when Asset {0} was {1} through Sales Invoice {2}.").format( - get_link_to_form(asset.doctype, asset.name), - _("returned") if self.is_return else _("sold"), - get_link_to_form(self.doctype, self.get("name")), - ) - - def restore_asset(self): - """ - Restore asset on return or cancellation of original sales invoice - """ - - for d in self.get("items"): - if d.asset: - asset = frappe.get_cached_doc("Asset", d.asset) - if asset.calculate_depreciation: - reverse_depreciation_entry_made_on_disposal(asset) - - note = self.get_note_for_asset_return(asset) - reset_depreciation_schedule(asset, note) - - def get_note_for_asset_return(self, asset): - asset_link = get_link_to_form(asset.doctype, asset.name) - invoice_link = get_link_to_form(self.doctype, self.get("name")) - if self.is_return: - return _( - "This schedule was created when Asset {0} was returned through Sales Invoice {1}." - ).format(asset_link, invoice_link) - else: - return _( - "This schedule was created when Asset {0} was restored due to Sales Invoice {1} cancellation." - ).format(asset_link, invoice_link) - - def update_asset(self): - """ - Update asset status, disposal date and asset activity on sale or return sales invoice - """ - - def _update_asset(asset, disposal_date, note, asset_status=None): - frappe.db.set_value("Asset", d.asset, "disposal_date", disposal_date) - add_asset_activity(asset.name, note) - asset.set_status(asset_status) - - disposal_date = self.get_disposal_date() - for d in self.get("items"): - if d.asset: - asset = frappe.get_cached_doc("Asset", d.asset) - - if (self.is_return and self.docstatus == 1) or (not self.is_return and self.docstatus == 2): - note = _("Asset returned") if self.is_return else _("Asset sold") - asset_status, disposal_date = None, None - else: - note = _("Asset sold") if not self.is_return else _("Return invoice of asset cancelled") - asset_status = "Sold" - - _update_asset(asset, disposal_date, note, asset_status) - - def get_disposal_date(self): - if self.is_return: - disposal_date = frappe.db.get_value("Sales Invoice", self.return_against, "posting_date") - else: - disposal_date = self.posting_date - - return disposal_date - def make_gl_entries(self, gl_entries=None, from_repost=False): from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries @@ -1558,7 +978,6 @@ class SalesInvoice(SellingController): gl_entries = self.get_gl_entries() if gl_entries: - # if POS and amount is written off, updating outstanding amt after posting all gl entries update_outstanding = ( "No" if (cint(self.is_pos) or self.write_off_account or cint(self.redeem_loyalty_points)) @@ -1646,194 +1065,60 @@ class SalesInvoice(SellingController): project.calculate_gross_margin() project.db_update() - def verify_payment_amount_is_positive(self): - for entry in self.payments: - if entry.amount < 0: - frappe.throw(_("Row #{0} (Payment Table): Amount must be positive").format(entry.idx)) - - def verify_payment_amount_is_negative(self): - for entry in self.payments: - if entry.amount > 0: - frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx)) - - # collection of the loyalty points, create the ledger entry for that. - def make_loyalty_point_entry(self): - returned_amount = self.get_returned_amount() - current_amount = flt(self.grand_total) - cint(self.loyalty_amount) - eligible_amount = current_amount - returned_amount - lp_details = get_loyalty_program_details_with_points( - self.customer, - company=self.company, - current_transaction_amount=current_amount, - loyalty_program=self.loyalty_program, - expiry_date=self.posting_date, - include_expired_entry=True, - ) - if ( - lp_details - and getdate(lp_details.from_date) <= getdate(self.posting_date) - and (not lp_details.to_date or getdate(lp_details.to_date) >= getdate(self.posting_date)) - ): - collection_factor = lp_details.collection_factor if lp_details.collection_factor else 1.0 - points_earned = cint(eligible_amount / collection_factor) - - doc = frappe.get_doc( - { - "doctype": "Loyalty Point Entry", - "company": self.company, - "loyalty_program": lp_details.loyalty_program, - "loyalty_program_tier": lp_details.tier_name, - "customer": self.customer, - "invoice_type": self.doctype, - "invoice": self.name, - "loyalty_points": points_earned, - "purchase_amount": eligible_amount, - "expiry_date": add_days(self.posting_date, lp_details.expiry_duration), - "posting_date": self.posting_date, - } - ) - doc.flags.ignore_permissions = 1 - doc.save() - self.set_loyalty_program_tier() - - # valdite the redemption and then delete the loyalty points earned on cancel of the invoice - def delete_loyalty_point_entry(self): - lp_entry = frappe.db.sql( - "select name from `tabLoyalty Point Entry` where invoice=%s", (self.name), as_dict=1 - ) - - if not lp_entry: + def update_billed_qty_in_scio(self): + if self.is_return: return - against_lp_entry = frappe.db.sql( - """select name, invoice from `tabLoyalty Point Entry` - where redeem_against=%s""", - (lp_entry[0].name), - as_dict=1, + + table = frappe.qb.DocType("Subcontracting Inward Order Received Item") + data = frappe._dict( + { + item.scio_detail: item.stock_qty if self._action == "submit" else -item.stock_qty + for item in self.items + if item.scio_detail + } ) - if against_lp_entry: - invoice_list = ", ".join([d.invoice for d in against_lp_entry]) - frappe.throw( - _( - """{} can't be cancelled since the Loyalty Points earned has been redeemed. First cancel the {} No {}""" - ).format(self.doctype, self.doctype, invoice_list) - ) - else: - frappe.db.sql("""delete from `tabLoyalty Point Entry` where invoice=%s""", (self.name)) - # Set loyalty program - self.set_loyalty_program_tier() - def set_loyalty_program_tier(self): - lp_details = get_loyalty_program_details_with_points( - self.customer, - company=self.company, - loyalty_program=self.loyalty_program, - include_expired_entry=True, - ) - customer = frappe.get_doc("Customer", self.customer) - customer.db_set("loyalty_program_tier", lp_details.tier_name) + if data: + case_expr = Case() + for name, qty in data.items(): + case_expr = case_expr.when(table.name == name, table.billed_qty + qty) + frappe.qb.update(table).set(table.billed_qty, case_expr).where( + (table.name.isin(list(data.keys()))) & (table.docstatus == 1) + ).run() - def get_returned_amount(self): - from frappe.query_builder.functions import Sum + def on_update_after_submit(self): + fields_to_check = [ + "additional_discount_account", + "cash_bank_account", + "account_for_change_amount", + "write_off_account", + "loyalty_redemption_account", + "unrealized_profit_loss_account", + "is_opening", + ] + child_tables = { + "items": ("income_account", "expense_account", "discount_account"), + "taxes": ("account_head",), + } + self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables) + if self.needs_repost: + self.validate_for_repost() + self.repost_accounting_entries() - doc = frappe.qb.DocType(self.doctype) - returned_amount = ( - frappe.qb.from_(doc) - .select(Sum(doc.grand_total)) - .where((doc.docstatus == 1) & (doc.is_return == 1) & (doc.return_against == self.name)) - ).run() + # Called by POS Invoice + def make_loyalty_point_entry(self): + LoyaltyService(self).make_loyalty_point_entry() - return abs(returned_amount[0][0]) if returned_amount[0][0] else 0 + # Called by POS Invoice + def delete_loyalty_point_entry(self): + LoyaltyService(self).delete_loyalty_point_entry() - # redeem the loyalty points. + # Called by POS Invoice def apply_loyalty_points(self): - from erpnext.accounts.doctype.loyalty_point_entry.loyalty_point_entry import ( - get_loyalty_point_entries, - get_redemption_details, - ) - - loyalty_point_entries = get_loyalty_point_entries( - self.customer, self.loyalty_program, self.company, self.posting_date - ) - redemption_details = get_redemption_details(self.customer, self.loyalty_program, self.company) - - points_to_redeem = self.loyalty_points - for lp_entry in loyalty_point_entries: - if lp_entry.invoice_type != self.doctype or lp_entry.invoice == self.name: - # redeemption should be done against same doctype - # also it shouldn't be against itself - continue - available_points = lp_entry.loyalty_points - flt(redemption_details.get(lp_entry.name)) - if available_points > points_to_redeem: - redeemed_points = points_to_redeem - else: - redeemed_points = available_points - doc = frappe.get_doc( - { - "doctype": "Loyalty Point Entry", - "company": self.company, - "loyalty_program": self.loyalty_program, - "loyalty_program_tier": lp_entry.loyalty_program_tier, - "customer": self.customer, - "invoice_type": self.doctype, - "invoice": self.name, - "redeem_against": lp_entry.name, - "loyalty_points": -1 * redeemed_points, - "purchase_amount": self.grand_total, - "expiry_date": lp_entry.expiry_date, - "posting_date": self.posting_date, - } - ) - doc.flags.ignore_permissions = 1 - doc.save() - points_to_redeem -= redeemed_points - if points_to_redeem < 1: # since points_to_redeem is integer - break + LoyaltyService(self).apply_loyalty_points() def set_status(self, update=False, status=None, update_modified=True): - if self.is_new(): - if self.get("amended_from"): - self.status = "Draft" - return - - outstanding_amount = flt(self.outstanding_amount, self.precision("outstanding_amount")) - total = get_total_in_party_account_currency(self) - - if not status: - if self.docstatus == 2: - status = "Cancelled" - elif self.docstatus == 1: - if self.is_internal_transfer(): - self.status = "Internal Transfer" - elif is_overdue(self, total): - self.status = "Overdue" - elif 0 < outstanding_amount < total: - self.status = "Partly Paid" - elif outstanding_amount > 0 and getdate(self.due_date) >= getdate(): - self.status = "Unpaid" - # Check if outstanding amount is 0 due to credit note issued against invoice - elif self.is_return == 0 and frappe.db.get_value( - "Sales Invoice", {"is_return": 1, "return_against": self.name, "docstatus": 1} - ): - self.status = "Credit Note Issued" - elif self.is_return == 1: - self.status = "Return" - elif outstanding_amount <= 0: - self.status = "Paid" - else: - self.status = "Submitted" - - if ( - self.status in ("Unpaid", "Partly Paid", "Overdue") - and self.is_discounted - and get_discounting_status(self.name) == "Disbursed" - ): - self.status += " and Discounted" - - else: - self.status = "Draft" - - if update: - self.db_set("status", self.status, update_modified=update_modified) + StatusService(self).set_status(update, status, update_modified) @frappe.whitelist() def is_subcontracted(self): @@ -1853,129 +1138,6 @@ class SalesInvoice(SellingController): return self.has_subcontracted -def get_total_in_party_account_currency(doc): - total_fieldname = "grand_total" if doc.disable_rounded_total else "rounded_total" - if doc.party_account_currency != doc.currency: - total_fieldname = "base_" + total_fieldname - - return flt(doc.get(total_fieldname), doc.precision(total_fieldname)) - - -def is_overdue(doc, total): - outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount")) - if outstanding_amount <= 0: - return - - today = getdate() - if doc.get("is_pos") or not doc.get("payment_schedule"): - return getdate(doc.due_date) < today - - # calculate payable amount till date - payment_amount_field = ( - "base_payment_amount" if doc.party_account_currency != doc.currency else "payment_amount" - ) - - payable_amount = flt( - sum( - payment.get(payment_amount_field) - for payment in doc.payment_schedule - if getdate(payment.due_date) < today - ), - doc.precision("outstanding_amount"), - ) - - return flt(total - outstanding_amount, doc.precision("outstanding_amount")) < payable_amount - - -def get_discounting_status(sales_invoice): - status = None - - invoice_discounting_list = frappe.db.sql( - """ - select status - from `tabInvoice Discounting` id, `tabDiscounted Invoice` d - where - id.name = d.parent - and d.sales_invoice=%s - and id.docstatus=1 - and status in ('Disbursed', 'Settled') - """, - sales_invoice, - ) - - for d in invoice_discounting_list: - status = d[0] - if status == "Disbursed": - break - - return status - - -def validate_inter_company_party(doctype, party, company, inter_company_reference): - if not party: - return - - if doctype in ["Sales Invoice", "Sales Order"]: - partytype, ref_partytype, internal = "Customer", "Supplier", "is_internal_customer" - - if doctype == "Sales Invoice": - ref_doc = "Purchase Invoice" - else: - ref_doc = "Purchase Order" - else: - partytype, ref_partytype, internal = "Supplier", "Customer", "is_internal_supplier" - - if doctype == "Purchase Invoice": - ref_doc = "Sales Invoice" - else: - ref_doc = "Sales Order" - - if inter_company_reference: - doc = frappe.get_doc(ref_doc, inter_company_reference) - ref_party = doc.supplier if doctype in ["Sales Invoice", "Sales Order"] else doc.customer - if frappe.db.get_value(partytype, {"represents_company": doc.company}, "name") != party: - frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(partytype))) - if frappe.get_cached_value(ref_partytype, ref_party, "represents_company") != company: - frappe.throw(_("Invalid Company for Inter Company Transaction.")) - - elif frappe.db.get_value(partytype, {"name": party, internal: 1}, "name") == party: - companies = frappe.get_all( - "Allowed To Transact With", - fields=["company"], - filters={"parenttype": partytype, "parent": party}, - ) - companies = [d.company for d in companies] - if company not in companies: - frappe.throw( - _( - "{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record." - ).format(_(partytype), company) - ) - - -def update_linked_doc(doctype, name, inter_company_reference): - if doctype in ["Sales Invoice", "Purchase Invoice"]: - ref_field = "inter_company_invoice_reference" - else: - ref_field = "inter_company_order_reference" - - if inter_company_reference: - frappe.db.set_value(doctype, inter_company_reference, ref_field, name) - - -def unlink_inter_company_doc(doctype, name, inter_company_reference): - if doctype in ["Sales Invoice", "Purchase Invoice"]: - ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Sales Invoice" - ref_field = "inter_company_invoice_reference" - else: - ref_doc = "Purchase Order" if doctype == "Sales Order" else "Sales Order" - ref_field = "inter_company_order_reference" - - if inter_company_reference: - frappe.db.set_value(doctype, name, ref_field, "") - frappe.db.set_value(ref_doc, inter_company_reference, ref_field, "") - - def get_list_context(context=None): from erpnext.controllers.website_list_for_contact import get_list_context @@ -1992,134 +1154,27 @@ def get_list_context(context=None): return list_context -@frappe.whitelist() -def get_bank_cash_account(mode_of_payment: str, company: str): - account = frappe.db.get_value( - "Mode of Payment Account", {"parent": mode_of_payment, "company": company}, "default_account" - ) - if not account: - frappe.throw( - _("Please set default Cash or Bank account in Mode of Payment {0}").format( - get_link_to_form("Mode of Payment", mode_of_payment) - ), - title=_("Missing Account"), - ) - return {"account": account} - - @erpnext.allow_regional def make_regional_gl_entries(gl_entries, doc): return gl_entries @frappe.whitelist() -def get_loyalty_programs(customer: str): - """sets applicable loyalty program to the customer or returns a list of applicable programs""" - from erpnext.selling.doctype.customer.customer import get_loyalty_programs - - customer = frappe.get_doc("Customer", customer) - if customer.loyalty_program: - return [customer.loyalty_program] - - lp_details = get_loyalty_programs(customer) - - if len(lp_details) == 1: - customer.db_set("loyalty_program", lp_details[0]) - return lp_details - else: - return lp_details +def get_bank_cash_account(mode_of_payment: str, company: str) -> dict: + return _get_bank_cash_account(mode_of_payment, company) -def update_multi_mode_option(doc, pos_profile): - def append_payment(payment_mode): - payment = doc.append("payments", {}) - payment.default = payment_mode.default - payment.mode_of_payment = payment_mode.mop - payment.account = payment_mode.default_account - payment.type = payment_mode.type +@frappe.whitelist() +def get_loyalty_programs(customer: str) -> list: + from .services.loyalty import get_loyalty_programs as _get - mop_refetched = bool(doc.payments) and not doc.is_created_using_pos - - doc.set("payments", []) - invalid_modes = [] - mode_of_payments = [d.mode_of_payment for d in pos_profile.get("payments")] - mode_of_payments_info = get_mode_of_payments_info(mode_of_payments, doc.company) - - for row in pos_profile.get("payments"): - payment_mode = mode_of_payments_info.get(row.mode_of_payment) - if not payment_mode: - invalid_modes.append(get_link_to_form("Mode of Payment", row.mode_of_payment)) - continue - - payment_mode.default = row.default - append_payment(payment_mode) - - if invalid_modes: - if invalid_modes == 1: - msg = _("Please set default Cash or Bank account in Mode of Payment {}") - else: - msg = _("Please set default Cash or Bank account in Mode of Payments {}") - frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account")) - - if mop_refetched: - frappe.toast( - _("Payment methods refreshed. Please review before proceeding."), - indicator="orange", - ) - - -def get_all_mode_of_payments(doc): - return frappe.db.sql( - """ - select mpa.default_account, mpa.parent, mp.type as type - from `tabMode of Payment Account` mpa,`tabMode of Payment` mp - where mpa.parent = mp.name and mpa.company = %(company)s and mp.enabled = 1""", - {"company": doc.company}, - as_dict=1, - ) - - -def get_mode_of_payments_info(mode_of_payments, company): - data = frappe.db.sql( - """ - select - mpa.default_account, mpa.parent as mop, mp.type as type - from - `tabMode of Payment Account` mpa,`tabMode of Payment` mp - where - mpa.parent = mp.name and - mpa.company = %s and - mp.enabled = 1 and - mp.name in %s - group by - mp.name - """, - (company, mode_of_payments), - as_dict=1, - ) - - return {row.get("mop"): row for row in data} - - -def get_mode_of_payment_info(mode_of_payment, company): - return frappe.db.sql( - """ - select mpa.default_account, mpa.parent, mp.type as type - from `tabMode of Payment Account` mpa,`tabMode of Payment` mp - where mpa.parent = mp.name and mpa.company = %s and mp.enabled = 1 and mp.name = %s""", - (company, mode_of_payment), - as_dict=1, - ) + return _get(customer) def check_if_return_invoice_linked_with_payment_entry(self): - # If a Return invoice is linked with payment entry along with other invoices, - # the cancellation of the Return causes allocated amount to be greater than paid - if not frappe.get_single_value("Accounts Settings", "unlink_payment_on_cancellation_of_invoice"): return - payment_entries = [] if self.is_return and self.return_against: invoice = self.return_against else: diff --git a/erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py b/erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py new file mode 100644 index 00000000000..3b793085304 --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py @@ -0,0 +1,173 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Fixed asset lifecycle helpers for Sales Invoice.""" + +import frappe +from frappe import _ +from frappe.utils import flt, get_link_to_form + +from erpnext.assets.doctype.asset.asset import split_asset +from erpnext.assets.doctype.asset.depreciation import ( + depreciate_asset, + reset_depreciation_schedule, + reverse_depreciation_entry_made_on_disposal, +) +from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity + + +class FixedAssetService: + def __init__(self, doc): + self.doc = doc + + def validate_fixed_asset(self) -> None: + doc = self.doc + if doc.doctype != "Sales Invoice": + return + + for d in doc.get("items"): + if not d.is_fixed_asset: + continue + + if d.asset: + if not doc.is_return: + asset_status = frappe.db.get_value("Asset", d.asset, "status") + if doc.update_stock: + frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale")) + elif asset_status in ("Scrapped", "Cancelled", "Capitalized"): + frappe.throw( + _("Row #{0}: Asset {1} cannot be sold, it is already {2}").format( + d.idx, d.asset, asset_status + ) + ) + elif asset_status == "Sold" and not doc.is_return: + frappe.throw(_("Row #{0}: Asset {1} is already sold").format(d.idx, d.asset)) + elif not doc.return_against: + frappe.throw(_("Row #{0}: Return Against is required for returning asset").format(d.idx)) + else: + frappe.throw( + _("Row #{0}: You must select an Asset for Item {1}.").format(d.idx, d.item_code), + title=_("Missing Asset"), + ) + + def set_income_account_for_fixed_assets(self) -> None: + for item in self.doc.items: + item.set_income_account_for_fixed_asset(self.doc.company) + + def process_asset_depreciation(self) -> None: + doc = self.doc + if doc.is_internal_transfer(): + return + + if (doc.is_return and doc.docstatus == 2) or (not doc.is_return and doc.docstatus == 1): + self._depreciate_asset_on_sale() + else: + self._restore_asset() + + self._update_asset() + + def split_asset_based_on_sale_qty(self) -> None: + asset_qty_map = self._get_asset_qty() + for asset, qty in asset_qty_map.items(): + if qty["actual_qty"] < qty["sale_qty"]: + frappe.throw( + _( + "Sell quantity cannot exceed the asset quantity. Asset {0} has only {1} item(s)." + ).format(asset, qty["actual_qty"]) + ) + + remaining_qty = qty["actual_qty"] - qty["sale_qty"] + if remaining_qty > 0: + split_asset(asset, remaining_qty) + + def get_disposal_date(self) -> str: + doc = self.doc + if doc.is_return: + return frappe.db.get_value("Sales Invoice", doc.return_against, "posting_date") + return doc.posting_date + + def _depreciate_asset_on_sale(self) -> None: + disposal_date = self.get_disposal_date() + for d in self.doc.get("items"): + if d.asset: + asset = frappe.get_doc("Asset", d.asset) + if asset.calculate_depreciation and asset.status != "Fully Depreciated": + depreciate_asset(asset, disposal_date, self._get_note_for_asset_sale(asset)) + + def _restore_asset(self) -> None: + for d in self.doc.get("items"): + if d.asset: + asset = frappe.get_cached_doc("Asset", d.asset) + if asset.calculate_depreciation: + reverse_depreciation_entry_made_on_disposal(asset) + reset_depreciation_schedule(asset, self._get_note_for_asset_return(asset)) + + def _update_asset(self) -> None: + doc = self.doc + disposal_date = self.get_disposal_date() + + for d in doc.get("items"): + if not d.asset: + continue + + asset = frappe.get_cached_doc("Asset", d.asset) + + if (doc.is_return and doc.docstatus == 1) or (not doc.is_return and doc.docstatus == 2): + note = _("Asset returned") if doc.is_return else _("Asset sold") + asset_status, disposal_date = None, None + else: + note = _("Asset sold") if not doc.is_return else _("Return invoice of asset cancelled") + asset_status = "Sold" + + frappe.db.set_value("Asset", d.asset, "disposal_date", disposal_date) + add_asset_activity(asset.name, note) + asset.set_status(asset_status) + + def _get_asset_qty(self) -> dict: + doc = self.doc + asset_qty_map = {} + + assets = {row.asset for row in doc.items if row.is_fixed_asset and row.asset} + if not assets or doc.is_return: + return asset_qty_map + + asset_actual_qty = dict( + frappe.db.get_all( + "Asset", + {"name": ["in", list(assets)]}, + ["name", "asset_quantity"], + as_list=True, + ) + ) + for row in doc.items: + if row.is_fixed_asset and row.asset: + actual_qty = asset_actual_qty.get(row.asset) + if row.asset in asset_qty_map: + asset_qty_map[row.asset]["sale_qty"] += flt(row.qty) + else: + asset_qty_map[row.asset] = { + "sale_qty": flt(row.qty), + "actual_qty": flt(actual_qty), + } + + return asset_qty_map + + def _get_note_for_asset_sale(self, asset) -> str: + doc = self.doc + return _("This schedule was created when Asset {0} was {1} through Sales Invoice {2}.").format( + get_link_to_form(asset.doctype, asset.name), + _("returned") if doc.is_return else _("sold"), + get_link_to_form(doc.doctype, doc.get("name")), + ) + + def _get_note_for_asset_return(self, asset) -> str: + doc = self.doc + asset_link = get_link_to_form(asset.doctype, asset.name) + invoice_link = get_link_to_form(doc.doctype, doc.get("name")) + if doc.is_return: + return _( + "This schedule was created when Asset {0} was returned through Sales Invoice {1}." + ).format(asset_link, invoice_link) + return _( + "This schedule was created when Asset {0} was restored due to Sales Invoice {1} cancellation." + ).format(asset_link, invoice_link) diff --git a/erpnext/accounts/doctype/sales_invoice/services/inter_company.py b/erpnext/accounts/doctype/sales_invoice/services/inter_company.py new file mode 100644 index 00000000000..c6e3abaa24b --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice/services/inter_company.py @@ -0,0 +1,68 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Inter-company transaction helpers for Sales Invoice.""" + +import frappe +from frappe import _ + + +def validate_inter_company_party( + doctype: str, party: str, company: str, inter_company_reference: str | None +) -> None: + if not party: + return + + if doctype in ["Sales Invoice", "Sales Order"]: + partytype, ref_partytype, internal = "Customer", "Supplier", "is_internal_customer" + ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order" + else: + partytype, ref_partytype, internal = "Supplier", "Customer", "is_internal_supplier" + ref_doc = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order" + + if inter_company_reference: + doc = frappe.get_doc(ref_doc, inter_company_reference) + ref_party = doc.supplier if doctype in ["Sales Invoice", "Sales Order"] else doc.customer + if frappe.db.get_value(partytype, {"represents_company": doc.company}, "name") != party: + frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(partytype))) + if frappe.get_cached_value(ref_partytype, ref_party, "represents_company") != company: + frappe.throw(_("Invalid Company for Inter Company Transaction.")) + + elif frappe.db.get_value(partytype, {"name": party, internal: 1}, "name") == party: + companies = [ + d.company + for d in frappe.get_all( + "Allowed To Transact With", + fields=["company"], + filters={"parenttype": partytype, "parent": party}, + ) + ] + if company not in companies: + frappe.throw( + _( + "{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record." + ).format(_(partytype), company) + ) + + +def update_linked_doc(doctype: str, name: str, inter_company_reference: str | None) -> None: + ref_field = ( + "inter_company_invoice_reference" + if doctype in ["Sales Invoice", "Purchase Invoice"] + else "inter_company_order_reference" + ) + if inter_company_reference: + frappe.db.set_value(doctype, inter_company_reference, ref_field, name) + + +def unlink_inter_company_doc(doctype: str, name: str, inter_company_reference: str | None) -> None: + if doctype in ["Sales Invoice", "Purchase Invoice"]: + ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Sales Invoice" + ref_field = "inter_company_invoice_reference" + else: + ref_doc = "Purchase Order" if doctype == "Sales Order" else "Sales Order" + ref_field = "inter_company_order_reference" + + if inter_company_reference: + frappe.db.set_value(doctype, name, ref_field, "") + frappe.db.set_value(ref_doc, inter_company_reference, ref_field, "") diff --git a/erpnext/accounts/doctype/sales_invoice/services/loyalty.py b/erpnext/accounts/doctype/sales_invoice/services/loyalty.py new file mode 100644 index 00000000000..706894b33b6 --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice/services/loyalty.py @@ -0,0 +1,162 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Loyalty program helpers for Sales Invoice.""" + +import frappe +from frappe import _ +from frappe.utils import add_days, cint, flt, getdate + +from erpnext.accounts.doctype.loyalty_program.loyalty_program import ( + get_loyalty_program_details_with_points, +) + + +class LoyaltyService: + def __init__(self, doc): + self.doc = doc + + def make_loyalty_point_entry(self) -> None: + doc = self.doc + returned_amount = self._get_returned_amount() + current_amount = flt(doc.grand_total) - cint(doc.loyalty_amount) + eligible_amount = current_amount - returned_amount + lp_details = get_loyalty_program_details_with_points( + doc.customer, + company=doc.company, + current_transaction_amount=current_amount, + loyalty_program=doc.loyalty_program, + expiry_date=doc.posting_date, + include_expired_entry=True, + ) + if ( + lp_details + and getdate(lp_details.from_date) <= getdate(doc.posting_date) + and (not lp_details.to_date or getdate(lp_details.to_date) >= getdate(doc.posting_date)) + ): + collection_factor = lp_details.collection_factor if lp_details.collection_factor else 1.0 + points_earned = cint(eligible_amount / collection_factor) + + entry = frappe.get_doc( + { + "doctype": "Loyalty Point Entry", + "company": doc.company, + "loyalty_program": lp_details.loyalty_program, + "loyalty_program_tier": lp_details.tier_name, + "customer": doc.customer, + "invoice_type": doc.doctype, + "invoice": doc.name, + "loyalty_points": points_earned, + "purchase_amount": eligible_amount, + "expiry_date": add_days(doc.posting_date, lp_details.expiry_duration), + "posting_date": doc.posting_date, + } + ) + entry.flags.ignore_permissions = 1 + entry.save() + self._set_loyalty_program_tier() + + def delete_loyalty_point_entry(self) -> None: + doc = self.doc + lp_entry = frappe.db.sql( + "select name from `tabLoyalty Point Entry` where invoice=%s", (doc.name), as_dict=1 + ) + + if not lp_entry: + return + + against_lp_entry = frappe.db.sql( + """select name, invoice from `tabLoyalty Point Entry` + where redeem_against=%s""", + (lp_entry[0].name), + as_dict=1, + ) + if against_lp_entry: + invoice_list = ", ".join([d.invoice for d in against_lp_entry]) + frappe.throw( + _( + """{} can't be cancelled since the Loyalty Points earned has been redeemed. First cancel the {} No {}""" + ).format(doc.doctype, doc.doctype, invoice_list) + ) + else: + frappe.db.sql("""delete from `tabLoyalty Point Entry` where invoice=%s""", (doc.name)) + self._set_loyalty_program_tier() + + def apply_loyalty_points(self) -> None: + from erpnext.accounts.doctype.loyalty_point_entry.loyalty_point_entry import ( + get_loyalty_point_entries, + get_redemption_details, + ) + + doc = self.doc + loyalty_point_entries = get_loyalty_point_entries( + doc.customer, doc.loyalty_program, doc.company, doc.posting_date + ) + redemption_details = get_redemption_details(doc.customer, doc.loyalty_program, doc.company) + + points_to_redeem = doc.loyalty_points + for lp_entry in loyalty_point_entries: + if lp_entry.invoice_type != doc.doctype or lp_entry.invoice == doc.name: + continue + available_points = lp_entry.loyalty_points - flt(redemption_details.get(lp_entry.name)) + redeemed_points = min(available_points, points_to_redeem) + entry = frappe.get_doc( + { + "doctype": "Loyalty Point Entry", + "company": doc.company, + "loyalty_program": doc.loyalty_program, + "loyalty_program_tier": lp_entry.loyalty_program_tier, + "customer": doc.customer, + "invoice_type": doc.doctype, + "invoice": doc.name, + "redeem_against": lp_entry.name, + "loyalty_points": -1 * redeemed_points, + "purchase_amount": doc.grand_total, + "expiry_date": lp_entry.expiry_date, + "posting_date": doc.posting_date, + } + ) + entry.flags.ignore_permissions = 1 + entry.save() + points_to_redeem -= redeemed_points + if points_to_redeem < 1: + break + + def _set_loyalty_program_tier(self) -> None: + doc = self.doc + lp_details = get_loyalty_program_details_with_points( + doc.customer, + company=doc.company, + loyalty_program=doc.loyalty_program, + include_expired_entry=True, + ) + customer = frappe.get_doc("Customer", doc.customer) + customer.db_set("loyalty_program_tier", lp_details.tier_name) + + def _get_returned_amount(self) -> float: + from frappe.query_builder.functions import Sum + + doc = frappe.qb.DocType(self.doc.doctype) + returned_amount = ( + frappe.qb.from_(doc) + .select(Sum(doc.grand_total)) + .where((doc.docstatus == 1) & (doc.is_return == 1) & (doc.return_against == self.doc.name)) + ).run() + + return abs(returned_amount[0][0]) if returned_amount[0][0] else 0 + + +def get_loyalty_programs(customer: str) -> list: + """Return applicable loyalty programs for the customer.""" + from erpnext.selling.doctype.customer.customer import get_loyalty_programs as _get + + customer_doc = frappe.get_doc("Customer", customer) + if customer_doc.loyalty_program: + return [customer_doc.loyalty_program] + + lp_details = _get(customer_doc) + + if len(lp_details) == 1: + customer_doc.db_set("loyalty_program", lp_details[0]) + + return lp_details diff --git a/erpnext/accounts/doctype/sales_invoice/services/pos.py b/erpnext/accounts/doctype/sales_invoice/services/pos.py new file mode 100644 index 00000000000..2a40eee9292 --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice/services/pos.py @@ -0,0 +1,396 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""POS helpers for Sales Invoice.""" + +import frappe +from frappe import _, msgprint +from frappe.utils import cint, flt, get_link_to_form + + +class PartialPaymentValidationError(frappe.ValidationError): + pass + + +class POSService: + def __init__(self, doc): + self.doc = doc + + def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | None: + """Populate POS-profile fields on the invoice; return the profile or None.""" + doc = self.doc + if cint(doc.is_pos) != 1: + return None + + if not doc.account_for_change_amount: + doc.account_for_change_amount = frappe.get_cached_value( + "Company", doc.company, "default_cash_account" + ) + + from erpnext.stock.get_item_details import ( + ItemDetailsCtx, + get_pos_profile, + get_pos_profile_item_details_, + ) + + if not doc.pos_profile and not doc.flags.ignore_pos_profile: + pos_profile = get_pos_profile(doc.company) or {} + if not pos_profile: + return None + doc.pos_profile = pos_profile.get("name") + + pos = {} + if doc.pos_profile: + pos = frappe.get_doc("POS Profile", doc.pos_profile) + + if pos: + if not for_validate: + update_multi_mode_option(doc, pos) + doc.tax_category = pos.get("tax_category") + + if not for_validate and not doc.customer: + doc.customer = pos.customer + + if not for_validate: + doc.ignore_pricing_rule = pos.ignore_pricing_rule + + if pos.get("account_for_change_amount"): + doc.account_for_change_amount = pos.get("account_for_change_amount") + + for fieldname in ( + "currency", + "letter_head", + "tc_name", + "company", + "select_print_heading", + "write_off_account", + "taxes_and_charges", + "write_off_cost_center", + "apply_discount_on", + "cost_center", + ): + if (not for_validate) or (for_validate and not doc.get(fieldname)): + doc.set(fieldname, pos.get(fieldname)) + + if pos.get("company_address"): + doc.company_address = pos.get("company_address") + + if doc.customer: + customer_price_list, customer_group = frappe.get_value( + "Customer", doc.customer, ["default_price_list", "customer_group"] + ) + customer_group_price_list = frappe.get_value( + "Customer Group", customer_group, "default_price_list" + ) + selling_price_list = ( + customer_price_list or customer_group_price_list or pos.get("selling_price_list") + ) + else: + selling_price_list = pos.get("selling_price_list") + + if selling_price_list: + doc.set("selling_price_list", selling_price_list) + + if not for_validate: + doc.update_stock = cint(pos.get("update_stock")) + + for item in doc.get("items"): + if item.get("item_code"): + profile_details = get_pos_profile_item_details_( + ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True + ) + for fname, val in profile_details.items(): + if (not for_validate) or (for_validate and not item.get(fname)): + item.set(fname, val) + + if doc.tc_name and not doc.terms: + doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms") + + if doc.taxes_and_charges and not len(doc.get("taxes")): + from erpnext.accounts.services.taxes import TaxService + + TaxService(doc).set_taxes() + + return pos + + def set_paid_amount(self) -> None: + doc = self.doc + paid_amount = 0.0 + base_paid_amount = 0.0 + for data in doc.payments: + data.base_amount = flt(data.amount * doc.conversion_rate, doc.precision("base_paid_amount")) + paid_amount += data.amount + base_paid_amount += data.base_amount + doc.paid_amount = paid_amount + doc.base_paid_amount = base_paid_amount + + def set_account_for_mode_of_payment(self) -> None: + for payment in self.doc.payments: + payment.account = get_bank_cash_account(payment.mode_of_payment, self.doc.company).get("account") + + def reset_mode_of_payments(self) -> None: + doc = self.doc + if doc.pos_profile: + pos_profile = frappe.get_cached_doc("POS Profile", doc.pos_profile) + update_multi_mode_option(doc, pos_profile) + doc.paid_amount = 0 + + def validate_pos_return(self) -> None: + doc = self.doc + if doc.is_consolidated: + return + + if doc.is_pos and doc.is_return: + total_amount_in_payments = sum(payment.amount for payment in doc.payments) + invoice_total = doc.rounded_total or doc.grand_total + if total_amount_in_payments < invoice_total: + frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total)) + + def validate_pos_paid_amount(self) -> None: + doc = self.doc + if len(doc.payments) == 0 and doc.is_pos and flt(doc.grand_total) > 0: + frappe.throw(_("At least one mode of payment is required for POS invoice.")) + + def validate_pos(self) -> None: + doc = self.doc + if doc.is_return: + invoice_total = doc.rounded_total or doc.grand_total + if abs(flt(doc.paid_amount)) + abs(flt(doc.write_off_amount)) - abs(flt(invoice_total)) > 1.0 / ( + 10.0 ** (doc.precision("grand_total") + 1.0) + ): + frappe.throw(_("Paid amount + Write Off Amount can not be greater than Grand Total")) + + def validate_created_using_pos(self) -> None: + doc = self.doc + if doc.is_created_using_pos and not doc.pos_profile: + frappe.throw(_("POS Profile is mandatory to mark this invoice as POS Transaction.")) + + doc.invoice_type_in_pos = frappe.db.get_single_value("POS Settings", "invoice_type") + if doc.invoice_type_in_pos == "POS Invoice" and not doc.is_return: + frappe.throw(_("Transactions using Sales Invoice in POS are disabled.")) + + self.validate_pos_opening_entry() + + def validate_full_payment(self) -> None: + doc = self.doc + allow_partial_payment = frappe.db.get_value("POS Profile", doc.pos_profile, "allow_partial_payment") + invoice_total = flt(doc.rounded_total) or flt(doc.grand_total) + + if ( + doc.docstatus == 1 + and not doc.is_return + and not allow_partial_payment + and doc.paid_amount < invoice_total + ): + frappe.throw( + msg=_("Partial Payment in POS Transactions are not allowed."), + exc=PartialPaymentValidationError, + ) + + def validate_pos_opening_entry(self) -> None: + doc = self.doc + opening_entries = frappe.get_all( + "POS Opening Entry", + fields=["name", "period_start_date"], + filters={"pos_profile": doc.pos_profile, "status": "Open"}, + order_by="period_start_date desc", + ) + if not opening_entries: + frappe.throw( + title=_("POS Opening Entry Missing"), + msg=_("No open POS Opening Entry found for POS Profile {0}.").format( + frappe.bold(doc.pos_profile) + ), + ) + if len(opening_entries) > 1: + frappe.throw( + title=_("Multiple POS Opening Entry"), + msg=_( + "POS Profile - {0} has multiple open POS Opening Entries. Please close or cancel the existing entries before proceeding." + ).format(doc.pos_profile), + ) + if frappe.utils.get_date_str(opening_entries[0].get("period_start_date")) != frappe.utils.today(): + frappe.throw( + title=_("Outdated POS Opening Entry"), + msg=_( + "POS Opening Entry - {0} is outdated. Please close the POS and create a new POS Opening Entry." + ).format(opening_entries[0].get("name")), + ) + + def check_if_consolidated_invoice(self) -> None: + doc = self.doc + if doc.doctype == "Sales Invoice" and doc.is_consolidated: + invoice_or_credit_note = "consolidated_credit_note" if doc.is_return else "consolidated_invoice" + pos_closing_entry = frappe.get_all( + "POS Invoice Merge Log", + filters={invoice_or_credit_note: doc.name}, + pluck="pos_closing_entry", + ) + if pos_closing_entry and pos_closing_entry[0]: + msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format( + frappe.bold(_("Consolidated Sales Invoice")), + get_link_to_form("POS Closing Entry", pos_closing_entry[0]), + ) + frappe.throw(msg, title=_("Not Allowed")) + + def check_if_created_using_pos_and_pos_closing_entry_generated(self) -> None: + doc = self.doc + if doc.doctype == "Sales Invoice" and doc.is_created_using_pos and doc.pos_closing_entry: + pos_closing_entry_docstatus = frappe.db.get_value( + "POS Closing Entry", doc.pos_closing_entry, "docstatus" + ) + if pos_closing_entry_docstatus == 1: + frappe.throw( + msg=_( + "To cancel this Sales Invoice you need to cancel the POS Closing Entry {0}." + ).format(get_link_to_form("POS Closing Entry", doc.pos_closing_entry)), + title=_("Not Allowed"), + ) + + def cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode(self) -> None: + pos_invoices = frappe.get_all( + "POS Invoice", filters={"consolidated_invoice": self.doc.name}, pluck="name" + ) + for pos_invoice in pos_invoices: + frappe.get_doc("POS Invoice", pos_invoice).cancel() + + def clear_unallocated_mode_of_payments(self) -> None: + doc = self.doc + doc.set("payments", doc.get("payments", {"amount": ["not in", [0, None, ""]]})) + frappe.db.sql( + """delete from `tabSales Invoice Payment` where parent = %s and amount = 0""", + doc.name, + ) + + def allow_write_off_only_on_pos(self) -> None: + if not self.doc.is_pos and self.doc.write_off_account: + self.doc.write_off_account = None + + def verify_payment_amount_is_positive(self) -> None: + for entry in self.doc.payments: + if entry.amount < 0: + frappe.throw(_("Row #{0} (Payment Table): Amount must be positive").format(entry.idx)) + + def verify_payment_amount_is_negative(self) -> None: + for entry in self.doc.payments: + if entry.amount > 0: + frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx)) + + def get_warehouse(self) -> str | None: + doc = self.doc + user_pos_profile = frappe.db.sql( + """select name, warehouse from `tabPOS Profile` + where ifnull(user,'') = %s and company = %s""", + (frappe.session["user"], doc.company), + ) + warehouse = user_pos_profile[0][1] if user_pos_profile else None + + if not warehouse: + global_pos_profile = frappe.db.sql( + """select name, warehouse from `tabPOS Profile` + where (user is null or user = '') and company = %s""", + doc.company, + ) + if global_pos_profile: + warehouse = global_pos_profile[0][1] + elif not user_pos_profile: + msgprint(_("POS Profile required to make POS Entry"), raise_exception=True) + + return warehouse + + +def get_bank_cash_account(mode_of_payment: str, company: str) -> dict: + account = frappe.db.get_value( + "Mode of Payment Account", + {"parent": mode_of_payment, "company": company}, + "default_account", + ) + if not account: + frappe.throw( + _("Please set default Cash or Bank account in Mode of Payment {0}").format( + get_link_to_form("Mode of Payment", mode_of_payment) + ), + title=_("Missing Account"), + ) + return {"account": account} + + +def update_multi_mode_option(doc, pos_profile) -> None: + def append_payment(payment_mode): + payment = doc.append("payments", {}) + payment.default = payment_mode.default + payment.mode_of_payment = payment_mode.mop + payment.account = payment_mode.default_account + payment.type = payment_mode.type + + mop_refetched = bool(doc.payments) and not doc.is_created_using_pos + + doc.set("payments", []) + invalid_modes = [] + mode_of_payments = [d.mode_of_payment for d in pos_profile.get("payments")] + mode_of_payments_info = get_mode_of_payments_info(mode_of_payments, doc.company) + + for row in pos_profile.get("payments"): + payment_mode = mode_of_payments_info.get(row.mode_of_payment) + if not payment_mode: + invalid_modes.append(get_link_to_form("Mode of Payment", row.mode_of_payment)) + continue + + payment_mode.default = row.default + append_payment(payment_mode) + + if invalid_modes: + if invalid_modes == 1: + msg = _("Please set default Cash or Bank account in Mode of Payment {}") + else: + msg = _("Please set default Cash or Bank account in Mode of Payments {}") + frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account")) + + if mop_refetched: + frappe.toast( + _("Payment methods refreshed. Please review before proceeding."), + indicator="orange", + ) + + +def get_all_mode_of_payments(doc) -> list: + return frappe.db.sql( + """ + select mpa.default_account, mpa.parent, mp.type as type + from `tabMode of Payment Account` mpa,`tabMode of Payment` mp + where mpa.parent = mp.name and mpa.company = %(company)s and mp.enabled = 1""", + {"company": doc.company}, + as_dict=1, + ) + + +def get_mode_of_payments_info(mode_of_payments: list, company: str) -> dict: + data = frappe.db.sql( + """ + select + mpa.default_account, mpa.parent as mop, mp.type as type + from + `tabMode of Payment Account` mpa,`tabMode of Payment` mp + where + mpa.parent = mp.name and + mpa.company = %s and + mp.enabled = 1 and + mp.name in %s + group by + mp.name + """, + (company, mode_of_payments), + as_dict=1, + ) + return {row.get("mop"): row for row in data} + + +def get_mode_of_payment_info(mode_of_payment: str, company: str) -> list: + return frappe.db.sql( + """ + select mpa.default_account, mpa.parent, mp.type as type + from `tabMode of Payment Account` mpa,`tabMode of Payment` mp + where mpa.parent = mp.name and mpa.company = %s and mp.enabled = 1 and mp.name = %s""", + (company, mode_of_payment), + as_dict=1, + ) diff --git a/erpnext/accounts/doctype/sales_invoice/services/status.py b/erpnext/accounts/doctype/sales_invoice/services/status.py new file mode 100644 index 00000000000..8ec179d9853 --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice/services/status.py @@ -0,0 +1,130 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Status computation and display helpers for Sales Invoice.""" + +import frappe +from frappe import _ +from frappe.utils import cint, flt, getdate, nowdate + + +class StatusService: + def __init__(self, doc): + self.doc = doc + + def set_status( + self, update: bool = False, status: str | None = None, update_modified: bool = True + ) -> None: + doc = self.doc + if doc.is_new(): + if doc.get("amended_from"): + doc.status = "Draft" + return + + outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount")) + total = get_total_in_party_account_currency(doc) + + if not status: + if doc.docstatus == 2: + status = "Cancelled" + elif doc.docstatus == 1: + if doc.is_internal_transfer(): + doc.status = "Internal Transfer" + elif is_overdue(doc, total): + doc.status = "Overdue" + elif 0 < outstanding_amount < total: + doc.status = "Partly Paid" + elif outstanding_amount > 0 and getdate(doc.due_date) >= getdate(): + doc.status = "Unpaid" + elif doc.is_return == 0 and frappe.db.get_value( + "Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1} + ): + doc.status = "Credit Note Issued" + elif doc.is_return == 1: + doc.status = "Return" + elif outstanding_amount <= 0: + doc.status = "Paid" + else: + doc.status = "Submitted" + + if ( + doc.status in ("Unpaid", "Partly Paid", "Overdue") + and doc.is_discounted + and get_discounting_status(doc.name) == "Disbursed" + ): + doc.status += " and Discounted" + + else: + doc.status = "Draft" + + if update: + doc.db_set("status", doc.status, update_modified=update_modified) + + def set_indicator(self) -> None: + doc = self.doc + if doc.outstanding_amount < 0: + doc.indicator_title = _("Credit Note Issued") + doc.indicator_color = "gray" + elif doc.outstanding_amount > 0 and getdate(doc.due_date) >= getdate(nowdate()): + doc.indicator_color = "orange" + doc.indicator_title = _("Unpaid") + elif doc.outstanding_amount > 0 and getdate(doc.due_date) < getdate(nowdate()): + doc.indicator_color = "red" + doc.indicator_title = _("Overdue") + elif cint(doc.is_return) == 1: + doc.indicator_title = _("Return") + doc.indicator_color = "gray" + else: + doc.indicator_color = "green" + doc.indicator_title = _("Paid") + + +def get_total_in_party_account_currency(doc) -> float: + total_fieldname = "grand_total" if doc.disable_rounded_total else "rounded_total" + if doc.party_account_currency != doc.currency: + total_fieldname = "base_" + total_fieldname + return flt(doc.get(total_fieldname), doc.precision(total_fieldname)) + + +def is_overdue(doc, total: float) -> bool | None: + outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount")) + if outstanding_amount <= 0: + return + + today = getdate() + if doc.get("is_pos") or not doc.get("payment_schedule"): + return getdate(doc.due_date) < today + + payment_amount_field = ( + "base_payment_amount" if doc.party_account_currency != doc.currency else "payment_amount" + ) + payable_amount = flt( + sum( + payment.get(payment_amount_field) + for payment in doc.payment_schedule + if getdate(payment.due_date) < today + ), + doc.precision("outstanding_amount"), + ) + return flt(total - outstanding_amount, doc.precision("outstanding_amount")) < payable_amount + + +def get_discounting_status(sales_invoice: str) -> str | None: + status = None + invoice_discounting_list = frappe.db.sql( + """ + select status + from `tabInvoice Discounting` id, `tabDiscounted Invoice` d + where + id.name = d.parent + and d.sales_invoice=%s + and id.docstatus=1 + and status in ('Disbursed', 'Settled') + """, + sales_invoice, + ) + for d in invoice_discounting_list: + status = d[0] + if status == "Disbursed": + break + return status diff --git a/erpnext/accounts/doctype/sales_invoice/services/timesheet_billing.py b/erpnext/accounts/doctype/sales_invoice/services/timesheet_billing.py new file mode 100644 index 00000000000..f688363dfc7 --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice/services/timesheet_billing.py @@ -0,0 +1,121 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Timesheet billing helpers for Sales Invoice.""" + +import frappe +from frappe import _ +from frappe.utils import flt + +from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data + + +class TimesheetBillingService: + def __init__(self, doc): + self.doc = doc + + def validate_time_sheets_are_submitted(self) -> None: + for data in self.doc.timesheets: + if data.time_sheet and data.timesheet_detail: + if sales_invoice := frappe.db.get_value( + "Timesheet Detail", data.timesheet_detail, "sales_invoice" + ): + frappe.throw( + _("Row {0}: Sales Invoice {1} is already created for {2}").format( + data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet) + ) + ) + + if data.time_sheet: + status = frappe.db.get_value("Timesheet", data.time_sheet, "status") + if status not in ["Submitted", "Payslip", "Partially Billed"]: + frappe.throw( + _("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet) + ) + + def update_time_sheet(self, sales_invoice: str | None) -> None: + for d in self.doc.timesheets: + if d.time_sheet: + timesheet = frappe.get_doc("Timesheet", d.time_sheet) + self._update_time_sheet_detail(timesheet, d, sales_invoice) + timesheet.calculate_total_amounts() + timesheet.calculate_percentage_billed() + timesheet.flags.ignore_validate_update_after_submit = True + timesheet.set_status() + timesheet.db_update_all() + + def unlink_sales_invoice_from_timesheets(self) -> None: + for row in self.doc.timesheets: + timesheet = frappe.get_doc("Timesheet", row.time_sheet) + timesheet.unlink_sales_invoice(self.doc.name) + timesheet.flags.ignore_validate_update_after_submit = True + timesheet.db_update_all() + + def set_billing_hours_and_amount(self) -> None: + doc = self.doc + if doc.project: + return + + for timesheet in doc.timesheets: + ts_doc = frappe.get_doc("Timesheet", timesheet.time_sheet) + if not timesheet.billing_hours and ts_doc.total_billable_hours: + timesheet.billing_hours = ts_doc.total_billable_hours + if not timesheet.billing_amount and ts_doc.total_billable_amount: + timesheet.billing_amount = ts_doc.total_billable_amount + + def update_timesheet_billing_for_project(self) -> None: + doc = self.doc + if ( + not doc.is_return + and not doc.timesheets + and doc.project + and frappe.db.get_single_value("Projects Settings", "fetch_timesheet_in_sales_invoice") + ): + self.add_timesheet_data() + else: + self.calculate_billing_amount_for_timesheet() + + def add_timesheet_data(self) -> None: + doc = self.doc + doc.set("timesheets", []) + if doc.project: + for data in get_projectwise_timesheet_data(doc.project): + doc.append( + "timesheets", + { + "time_sheet": data.time_sheet, + "billing_hours": data.billing_hours, + "billing_amount": data.billing_amount, + "timesheet_detail": data.name, + "activity_type": data.activity_type, + "description": data.description, + }, + ) + self.calculate_billing_amount_for_timesheet() + + def calculate_billing_amount_for_timesheet(self) -> None: + doc = self.doc + doc.total_billing_amount = sum(flt(ts.billing_amount) for ts in doc.timesheets) + doc.total_billing_hours = sum(flt(ts.billing_hours) for ts in doc.timesheets) + + def _update_time_sheet_detail(self, timesheet, args, sales_invoice: str | None) -> None: + doc = self.doc + for data in timesheet.time_logs: + if ( + (doc.project and args.timesheet_detail == data.name) + or (not doc.project and not data.sales_invoice and args.timesheet_detail == data.name) + or ( + not sales_invoice + and data.sales_invoice == doc.name + and args.timesheet_detail == data.name + ) + or ( + doc.is_return + and doc.return_against + and data.sales_invoice + and data.sales_invoice == doc.return_against + and not sales_invoice + and args.timesheet_detail == data.name + ) + ): + data.sales_invoice = sales_invoice