mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-10 00:19:00 +00:00
Compare commits
57 Commits
auth_did
...
l10n_devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f71680459 | ||
|
|
dfc824ded6 | ||
|
|
dfd7cd0bae | ||
|
|
e083aa4c86 | ||
|
|
c4fbc745db | ||
|
|
2b6234f7af | ||
|
|
88b9911136 | ||
|
|
360f52e636 | ||
|
|
6201fefdfb | ||
|
|
08129ff71c | ||
|
|
5357634b70 | ||
|
|
20ba97aa7d | ||
|
|
d90d4c29e1 | ||
|
|
ddbd61b2a2 | ||
|
|
6a7c9f616e | ||
|
|
a3194720b4 | ||
|
|
7825ddf989 | ||
|
|
e9b67ff682 | ||
|
|
4c3aa9b4f3 | ||
|
|
ca77145522 | ||
|
|
5753c23ccf | ||
|
|
a397e82278 | ||
|
|
9c23229cbf | ||
|
|
08f6af867a | ||
|
|
6988781f81 | ||
|
|
49093b326e | ||
|
|
9503dd0c7f | ||
|
|
bd0acf4413 | ||
|
|
969cdf1b26 | ||
|
|
8db1eb0d27 | ||
|
|
d146dc5435 | ||
|
|
0ca38517f3 | ||
|
|
5d1af7fc93 | ||
|
|
1fab935434 | ||
|
|
d6ba0f0eca | ||
|
|
49164f41b1 | ||
|
|
e36426e235 | ||
|
|
ba936eefab | ||
|
|
5eb9461cfd | ||
|
|
e1e588e416 | ||
|
|
00880eb657 | ||
|
|
ae6aef91bd | ||
|
|
faf92b1368 | ||
|
|
a52c8fdaea | ||
|
|
030e1a77e6 | ||
|
|
d2306b1b29 | ||
|
|
601f39dda7 | ||
|
|
047e4faa90 | ||
|
|
8d7edafc99 | ||
|
|
8f15dd4d5d | ||
|
|
bf769a52c0 | ||
|
|
37d2adc74b | ||
|
|
5dbf3fdde0 | ||
|
|
4b0b7adeee | ||
|
|
8de259a669 | ||
|
|
2ecf8b0466 | ||
|
|
e460e83516 |
@@ -6,12 +6,14 @@ frappe.provide("erpnext.cheque_print");
|
||||
frappe.ui.form.on("Cheque Print Template", {
|
||||
refresh: function (frm) {
|
||||
if (!frm.doc.__islocal) {
|
||||
frm.add_custom_button(
|
||||
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
|
||||
function () {
|
||||
erpnext.cheque_print.view_cheque_print(frm);
|
||||
}
|
||||
).addClass("btn-primary");
|
||||
if (frappe.user.has_role("System Manager")) {
|
||||
frm.add_custom_button(
|
||||
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
|
||||
function () {
|
||||
erpnext.cheque_print.view_cheque_print(frm);
|
||||
}
|
||||
).addClass("btn-primary");
|
||||
}
|
||||
|
||||
$(frm.fields_dict.cheque_print_preview.wrapper).empty();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "field:bank_name",
|
||||
"creation": "2016-05-04 14:35:00.402544",
|
||||
"doctype": "DocType",
|
||||
@@ -294,7 +295,7 @@
|
||||
],
|
||||
"links": [],
|
||||
"max_attachments": 1,
|
||||
"modified": "2024-03-27 13:06:44.654989",
|
||||
"modified": "2026-06-08 12:10:35.829531",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Cheque Print Template",
|
||||
@@ -325,19 +326,17 @@
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ class ChequePrintTemplate(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_or_update_cheque_print_format(template_name: str):
|
||||
frappe.only_for("System Manager")
|
||||
|
||||
if not frappe.db.exists("Print Format", template_name):
|
||||
cheque_print = frappe.new_doc("Print Format")
|
||||
cheque_print.update(
|
||||
|
||||
@@ -5,7 +5,7 @@ import frappe
|
||||
from frappe.utils import add_days, flt, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_payment_entry_against_invoice
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -178,7 +178,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
voucher_type: frm.doc.voucher_type,
|
||||
company: args.company,
|
||||
},
|
||||
method: "erpnext.accounts.doctype.journal_entry.journal_entry.make_inter_company_journal_entry",
|
||||
method: "erpnext.accounts.doctype.journal_entry.mapper.make_inter_company_journal_entry",
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
var doc = frappe.model.sync(r.message)[0];
|
||||
@@ -731,7 +731,7 @@ $.extend(erpnext.journal_entry, {
|
||||
|
||||
reverse_journal_entry: function (frm) {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.journal_entry.journal_entry.make_reverse_journal_entry",
|
||||
method: "erpnext.accounts.doctype.journal_entry.mapper.make_reverse_journal_entry",
|
||||
frm: frm,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -11,10 +11,16 @@ from frappe.model.document import Document
|
||||
from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts
|
||||
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
|
||||
get_party_account_based_on_invoice_discounting,
|
||||
)
|
||||
|
||||
# Re-exported so existing call paths (including custom apps) referencing
|
||||
# erpnext.accounts.doctype.journal_entry.journal_entry.<fn> keep working.
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import (
|
||||
get_payment_entry_against_invoice,
|
||||
get_payment_entry_against_order,
|
||||
)
|
||||
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
|
||||
validate_docs_for_deferred_accounting,
|
||||
validate_docs_for_voucher_types,
|
||||
@@ -28,9 +34,6 @@ from erpnext.accounts.utils import (
|
||||
get_stock_accounts,
|
||||
get_stock_and_account_balance,
|
||||
)
|
||||
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
|
||||
get_depr_schedule,
|
||||
)
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
from erpnext.setup.utils import get_exchange_rate as _get_exchange_rate
|
||||
|
||||
@@ -125,6 +128,11 @@ class JournalEntry(AccountsController):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def validate(self):
|
||||
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
|
||||
from erpnext.accounts.doctype.journal_entry.services.reference_validator import (
|
||||
JournalEntryReferenceValidator,
|
||||
)
|
||||
|
||||
if self.voucher_type == "Opening Entry":
|
||||
self.is_opening = "Yes"
|
||||
|
||||
@@ -144,7 +152,7 @@ class JournalEntry(AccountsController):
|
||||
self.validate_against_jv()
|
||||
self.validate_stock_accounts()
|
||||
|
||||
self.validate_reference_doc()
|
||||
JournalEntryReferenceValidator(self).validate()
|
||||
if self.docstatus == 0:
|
||||
self.set_against_account()
|
||||
self.create_remarks()
|
||||
@@ -152,7 +160,7 @@ class JournalEntry(AccountsController):
|
||||
self.validate_credit_debit_note()
|
||||
self.validate_empty_accounts_table()
|
||||
self.validate_inter_company_accounts()
|
||||
self.validate_depr_account_and_depr_entry_voucher_type()
|
||||
AssetService(self).validate_depr_account_and_depr_entry_voucher_type()
|
||||
self.validate_company_in_accounting_dimension()
|
||||
self.validate_advance_accounts()
|
||||
|
||||
@@ -186,7 +194,9 @@ class JournalEntry(AccountsController):
|
||||
return self._submit()
|
||||
|
||||
def before_cancel(self):
|
||||
self.has_asset_adjustment_entry()
|
||||
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
|
||||
|
||||
AssetService(self).has_asset_adjustment_entry()
|
||||
|
||||
def cancel(self):
|
||||
if len(self.accounts) > 100:
|
||||
@@ -200,10 +210,12 @@ class JournalEntry(AccountsController):
|
||||
self.validate_total_debit_and_credit()
|
||||
|
||||
def on_submit(self):
|
||||
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
|
||||
|
||||
self.validate_cheque_info()
|
||||
self.make_gl_entries()
|
||||
self.check_credit_limit()
|
||||
self.update_asset_value()
|
||||
AssetService(self).update_asset_value()
|
||||
self.update_inter_company_jv()
|
||||
self.update_invoice_discounting()
|
||||
JournalTaxWithholding(self).on_submit()
|
||||
@@ -292,6 +304,8 @@ class JournalEntry(AccountsController):
|
||||
def on_cancel(self):
|
||||
# Cancel tax withholding entries
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
|
||||
|
||||
# References for this Journal are removed on the `on_cancel` event in accounts_controller
|
||||
super().on_cancel()
|
||||
|
||||
@@ -316,9 +330,9 @@ class JournalEntry(AccountsController):
|
||||
self.make_gl_entries(1)
|
||||
JournalTaxWithholding(self).on_cancel()
|
||||
self.unlink_advance_entry_reference()
|
||||
self.unlink_asset_reference()
|
||||
AssetService(self).unlink_asset_reference()
|
||||
self.unlink_inter_company_jv()
|
||||
self.unlink_asset_adjustment_entry()
|
||||
AssetService(self).unlink_asset_adjustment_entry()
|
||||
self.update_invoice_discounting()
|
||||
|
||||
def get_title(self):
|
||||
@@ -342,17 +356,6 @@ class JournalEntry(AccountsController):
|
||||
):
|
||||
frappe.throw(_("Total Credit/ Debit Amount should be same as linked Journal Entry"))
|
||||
|
||||
def validate_depr_account_and_depr_entry_voucher_type(self):
|
||||
for d in self.get("accounts"):
|
||||
if d.account_type == "Depreciation":
|
||||
if self.voucher_type != "Depreciation Entry":
|
||||
frappe.throw(
|
||||
_("Journal Entry type should be set as Depreciation Entry for asset depreciation")
|
||||
)
|
||||
|
||||
if frappe.get_cached_value("Account", d.account, "root_type") != "Expense":
|
||||
frappe.throw(_("Account {0} should be of type Expense").format(d.account))
|
||||
|
||||
def validate_stock_accounts(self):
|
||||
if (
|
||||
not erpnext.is_perpetual_inventory_enabled(self.company)
|
||||
@@ -373,75 +376,6 @@ class JournalEntry(AccountsController):
|
||||
StockAccountInvalidTransaction,
|
||||
)
|
||||
|
||||
def update_asset_value(self):
|
||||
self.update_asset_on_depreciation()
|
||||
self.update_asset_on_disposal()
|
||||
|
||||
def update_asset_on_depreciation(self):
|
||||
if self.voucher_type != "Depreciation Entry":
|
||||
return
|
||||
|
||||
for d in self.get("accounts"):
|
||||
if (
|
||||
d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||
and d.debit
|
||||
):
|
||||
asset = frappe.get_cached_doc("Asset", d.reference_name)
|
||||
|
||||
if asset.calculate_depreciation:
|
||||
self.update_journal_entry_link_on_depr_schedule(asset, d)
|
||||
self.update_value_after_depreciation(asset, d.debit)
|
||||
|
||||
asset.db_set("value_after_depreciation", asset.value_after_depreciation - d.debit)
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
|
||||
def update_value_after_depreciation(self, asset, depr_amount):
|
||||
fb_idx = 1
|
||||
if self.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if fb_row.finance_book == self.finance_book:
|
||||
fb_idx = fb_row.idx
|
||||
break
|
||||
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||
fb_row.value_after_depreciation -= depr_amount
|
||||
frappe.db.set_value(
|
||||
"Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation
|
||||
)
|
||||
|
||||
def update_journal_entry_link_on_depr_schedule(self, asset, je_row):
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", self.finance_book)
|
||||
for d in depr_schedule or []:
|
||||
if (
|
||||
d.schedule_date == self.posting_date
|
||||
and not d.journal_entry
|
||||
and d.depreciation_amount == flt(je_row.debit)
|
||||
):
|
||||
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.name)
|
||||
|
||||
def update_asset_on_disposal(self):
|
||||
if self.voucher_type == "Asset Disposal":
|
||||
disposed_assets = []
|
||||
for d in self.get("accounts"):
|
||||
if (
|
||||
d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and d.reference_name not in disposed_assets
|
||||
):
|
||||
frappe.db.set_value(
|
||||
"Asset",
|
||||
d.reference_name,
|
||||
{
|
||||
"disposal_date": self.posting_date,
|
||||
"journal_entry_for_scrap": self.name,
|
||||
},
|
||||
)
|
||||
asset_doc = frappe.get_doc("Asset", d.reference_name)
|
||||
asset_doc.set_status()
|
||||
disposed_assets.append(d.reference_name)
|
||||
|
||||
def update_inter_company_jv(self):
|
||||
if self.voucher_type == "Inter Company Journal Entry" and self.inter_company_journal_entry_reference:
|
||||
frappe.db.set_value(
|
||||
@@ -504,59 +438,6 @@ class JournalEntry(AccountsController):
|
||||
d.reference_name = ""
|
||||
d.db_update()
|
||||
|
||||
def unlink_asset_reference(self):
|
||||
for d in self.get("accounts"):
|
||||
if (
|
||||
self.voucher_type == "Depreciation Entry"
|
||||
and d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||
and d.debit
|
||||
):
|
||||
asset = frappe.get_doc("Asset", d.reference_name)
|
||||
|
||||
if asset.calculate_depreciation:
|
||||
je_found = False
|
||||
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if je_found:
|
||||
break
|
||||
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
|
||||
|
||||
for s in depr_schedule or []:
|
||||
if s.journal_entry == self.name:
|
||||
s.db_set("journal_entry", None)
|
||||
|
||||
fb_row.value_after_depreciation += d.debit
|
||||
fb_row.db_update()
|
||||
|
||||
je_found = True
|
||||
break
|
||||
if not je_found:
|
||||
fb_idx = 1
|
||||
if self.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if fb_row.finance_book == self.finance_book:
|
||||
fb_idx = fb_row.idx
|
||||
break
|
||||
|
||||
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||
fb_row.value_after_depreciation += d.debit
|
||||
fb_row.db_update()
|
||||
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
elif self.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name:
|
||||
journal_entry_for_scrap = frappe.db.get_value(
|
||||
"Asset", d.reference_name, "journal_entry_for_scrap"
|
||||
)
|
||||
|
||||
if journal_entry_for_scrap == self.name:
|
||||
frappe.throw(
|
||||
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
|
||||
)
|
||||
|
||||
def unlink_inter_company_jv(self):
|
||||
if self.voucher_type == "Inter Company Journal Entry" and self.inter_company_journal_entry_reference:
|
||||
frappe.db.set_value(
|
||||
@@ -567,28 +448,6 @@ class JournalEntry(AccountsController):
|
||||
)
|
||||
frappe.db.set_value("Journal Entry", self.name, "inter_company_journal_entry_reference", "")
|
||||
|
||||
def has_asset_adjustment_entry(self):
|
||||
if self.flags.get("via_asset_value_adjustment"):
|
||||
return
|
||||
|
||||
asset_value_adjustment = frappe.db.get_value(
|
||||
"Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.name}, "name"
|
||||
)
|
||||
if asset_value_adjustment:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot cancel this document as it is linked with the submitted Asset Value Adjustment <b>{0}</b>. Please cancel the Asset Value Adjustment to continue."
|
||||
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
|
||||
)
|
||||
|
||||
def unlink_asset_adjustment_entry(self):
|
||||
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
|
||||
(
|
||||
frappe.qb.update(AssetValueAdjustment)
|
||||
.set(AssetValueAdjustment.journal_entry, None)
|
||||
.where(AssetValueAdjustment.journal_entry == self.name)
|
||||
).run()
|
||||
|
||||
def validate_party(self):
|
||||
for d in self.get("accounts"):
|
||||
account_type = frappe.get_cached_value("Account", d.account, "account_type")
|
||||
@@ -741,166 +600,6 @@ class JournalEntry(AccountsController):
|
||||
)
|
||||
)
|
||||
|
||||
def validate_reference_doc(self):
|
||||
"""Validates reference document"""
|
||||
field_dict = {
|
||||
"Sales Invoice": ["Customer", "Debit To"],
|
||||
"Purchase Invoice": ["Supplier", "Credit To"],
|
||||
"Sales Order": ["Customer"],
|
||||
"Purchase Order": ["Supplier"],
|
||||
}
|
||||
|
||||
self.reference_totals = {}
|
||||
self.reference_types = {}
|
||||
self.reference_accounts = {}
|
||||
|
||||
for d in self.get("accounts"):
|
||||
if not d.reference_type:
|
||||
d.reference_name = None
|
||||
if not d.reference_name:
|
||||
d.reference_type = None
|
||||
if d.reference_type and d.reference_name and (d.reference_type in list(field_dict)):
|
||||
dr_or_cr = (
|
||||
"credit_in_account_currency"
|
||||
if d.reference_type in ("Sales Order", "Sales Invoice")
|
||||
else "debit_in_account_currency"
|
||||
)
|
||||
|
||||
# check debit or credit type Sales / Purchase Order
|
||||
if d.reference_type == "Sales Order" and flt(d.debit) > 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Debit entry can not be linked with a {1}").format(d.idx, d.reference_type)
|
||||
)
|
||||
|
||||
if d.reference_type == "Purchase Order" and flt(d.credit) > 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Credit entry can not be linked with a {1}").format(
|
||||
d.idx, d.reference_type
|
||||
)
|
||||
)
|
||||
|
||||
# set totals
|
||||
if d.reference_name not in self.reference_totals:
|
||||
self.reference_totals[d.reference_name] = 0.0
|
||||
|
||||
if self.voucher_type not in ("Deferred Revenue", "Deferred Expense"):
|
||||
self.reference_totals[d.reference_name] += flt(d.get(dr_or_cr))
|
||||
|
||||
self.reference_types[d.reference_name] = d.reference_type
|
||||
self.reference_accounts[d.reference_name] = d.account
|
||||
|
||||
against_voucher = frappe.db.get_value(
|
||||
d.reference_type, d.reference_name, [scrub(dt) for dt in field_dict.get(d.reference_type)]
|
||||
)
|
||||
|
||||
if not against_voucher:
|
||||
frappe.throw(_("Row {0}: Invalid reference {1}").format(d.idx, d.reference_name))
|
||||
|
||||
# check if party and account match
|
||||
if d.reference_type in ("Sales Invoice", "Purchase Invoice"):
|
||||
if (
|
||||
self.voucher_type in ("Deferred Revenue", "Deferred Expense")
|
||||
and d.reference_detail_no
|
||||
):
|
||||
debit_or_credit = "Debit" if d.debit else "Credit"
|
||||
party_account = get_deferred_booking_accounts(
|
||||
d.reference_type, d.reference_detail_no, debit_or_credit
|
||||
)
|
||||
against_voucher = ["", against_voucher[1]]
|
||||
else:
|
||||
if d.reference_type == "Sales Invoice":
|
||||
party_account = (
|
||||
get_party_account_based_on_invoice_discounting(d.reference_name)
|
||||
or against_voucher[1]
|
||||
)
|
||||
else:
|
||||
party_account = against_voucher[1]
|
||||
|
||||
if (
|
||||
against_voucher[0] != cstr(d.party) or party_account != d.account
|
||||
) and self.voucher_type != "Exchange Gain Or Loss":
|
||||
frappe.throw(
|
||||
_("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format(
|
||||
d.idx,
|
||||
field_dict.get(d.reference_type)[0],
|
||||
field_dict.get(d.reference_type)[1],
|
||||
d.reference_type,
|
||||
d.reference_name,
|
||||
)
|
||||
)
|
||||
|
||||
# check if party matches for Sales / Purchase Order
|
||||
if d.reference_type in ("Sales Order", "Purchase Order"):
|
||||
# set totals
|
||||
if against_voucher != d.party:
|
||||
frappe.throw(
|
||||
_("Row {0}: {1} {2} does not match with {3}").format(
|
||||
d.idx, d.party_type, d.party, d.reference_type
|
||||
)
|
||||
)
|
||||
|
||||
self.validate_orders()
|
||||
self.validate_invoices()
|
||||
|
||||
def validate_orders(self):
|
||||
"""Validate totals, closed and docstatus for orders"""
|
||||
for reference_name, total in self.reference_totals.items():
|
||||
reference_type = self.reference_types[reference_name]
|
||||
account = self.reference_accounts[reference_name]
|
||||
|
||||
if reference_type in ("Sales Order", "Purchase Order"):
|
||||
order = frappe.get_doc(reference_type, reference_name)
|
||||
|
||||
if order.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
|
||||
if flt(order.per_billed) >= 100:
|
||||
frappe.throw(_("{0} {1} is fully billed").format(reference_type, reference_name))
|
||||
|
||||
if cstr(order.status) == "Closed":
|
||||
frappe.throw(_("{0} {1} is closed").format(reference_type, reference_name))
|
||||
|
||||
account_currency = get_account_currency(account)
|
||||
if account_currency == self.company_currency:
|
||||
voucher_total = order.base_grand_total
|
||||
formatted_voucher_total = fmt_money(
|
||||
voucher_total, order.precision("base_grand_total"), currency=account_currency
|
||||
)
|
||||
else:
|
||||
voucher_total = order.grand_total
|
||||
formatted_voucher_total = fmt_money(
|
||||
voucher_total, order.precision("grand_total"), currency=account_currency
|
||||
)
|
||||
|
||||
if flt(voucher_total) < (flt(order.advance_paid) + total):
|
||||
frappe.throw(
|
||||
_("Advance paid against {0} {1} cannot be greater than Grand Total {2}").format(
|
||||
reference_type, reference_name, formatted_voucher_total
|
||||
)
|
||||
)
|
||||
|
||||
def validate_invoices(self):
|
||||
"""Validate totals and docstatus for invoices"""
|
||||
for reference_name, total in self.reference_totals.items():
|
||||
reference_type = self.reference_types[reference_name]
|
||||
|
||||
if reference_type in ("Sales Invoice", "Purchase Invoice") and self.voucher_type not in [
|
||||
"Debit Note",
|
||||
"Credit Note",
|
||||
]:
|
||||
invoice = frappe.get_doc(reference_type, reference_name)
|
||||
|
||||
if invoice.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
|
||||
precision = invoice.precision("outstanding_amount")
|
||||
if total and flt(invoice.outstanding_amount, precision) < flt(total, precision):
|
||||
frappe.throw(
|
||||
_("Payment against {0} {1} cannot be greater than Outstanding Amount {2}").format(
|
||||
reference_type, reference_name, invoice.outstanding_amount
|
||||
)
|
||||
)
|
||||
|
||||
def set_against_account(self):
|
||||
accounts_debited, accounts_credited = [], []
|
||||
if self.voucher_type in ("Deferred Revenue", "Deferred Expense"):
|
||||
@@ -1309,174 +1008,6 @@ def get_default_bank_cash_account(
|
||||
return frappe._dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_entry_against_order(
|
||||
dt: str,
|
||||
dn: str,
|
||||
amount: float | None = None,
|
||||
debit_in_account_currency: str | float | None = None,
|
||||
journal_entry: bool = False,
|
||||
bank_account: str | None = None,
|
||||
):
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
|
||||
if flt(ref_doc.per_billed, 2) > 0:
|
||||
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
|
||||
|
||||
if dt == "Sales Order":
|
||||
party_type = "Customer"
|
||||
amount_field_party = "credit_in_account_currency"
|
||||
amount_field_bank = "debit_in_account_currency"
|
||||
else:
|
||||
party_type = "Supplier"
|
||||
amount_field_party = "debit_in_account_currency"
|
||||
amount_field_bank = "credit_in_account_currency"
|
||||
|
||||
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
|
||||
party_account_currency = get_account_currency(party_account)
|
||||
|
||||
if not amount:
|
||||
if party_account_currency == ref_doc.company_currency:
|
||||
amount = flt(ref_doc.base_grand_total) - flt(ref_doc.advance_paid)
|
||||
else:
|
||||
amount = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid)
|
||||
|
||||
return get_payment_entry(
|
||||
ref_doc,
|
||||
{
|
||||
"party_type": party_type,
|
||||
"party_account": party_account,
|
||||
"party_account_currency": party_account_currency,
|
||||
"amount_field_party": amount_field_party,
|
||||
"amount_field_bank": amount_field_bank,
|
||||
"amount": amount,
|
||||
"debit_in_account_currency": debit_in_account_currency,
|
||||
"remarks": f"Advance Payment received against {dt} {dn}",
|
||||
"is_advance": "Yes",
|
||||
"bank_account": bank_account,
|
||||
"journal_entry": journal_entry,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_entry_against_invoice(
|
||||
dt: str,
|
||||
dn: str,
|
||||
amount: float | None = None,
|
||||
debit_in_account_currency: str | None = None,
|
||||
journal_entry: bool = False,
|
||||
bank_account: str | None = None,
|
||||
):
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
if dt == "Sales Invoice":
|
||||
party_type = "Customer"
|
||||
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
|
||||
else:
|
||||
party_type = "Supplier"
|
||||
party_account = ref_doc.credit_to
|
||||
|
||||
if (dt == "Sales Invoice" and ref_doc.outstanding_amount > 0) or (
|
||||
dt == "Purchase Invoice" and ref_doc.outstanding_amount < 0
|
||||
):
|
||||
amount_field_party = "credit_in_account_currency"
|
||||
amount_field_bank = "debit_in_account_currency"
|
||||
else:
|
||||
amount_field_party = "debit_in_account_currency"
|
||||
amount_field_bank = "credit_in_account_currency"
|
||||
|
||||
return get_payment_entry(
|
||||
ref_doc,
|
||||
{
|
||||
"party_type": party_type,
|
||||
"party_account": party_account,
|
||||
"party_account_currency": ref_doc.party_account_currency,
|
||||
"amount_field_party": amount_field_party,
|
||||
"amount_field_bank": amount_field_bank,
|
||||
"amount": amount if amount else abs(ref_doc.outstanding_amount),
|
||||
"debit_in_account_currency": debit_in_account_currency,
|
||||
"remarks": f"Payment received against {dt} {dn}. {ref_doc.remarks}",
|
||||
"is_advance": "No",
|
||||
"bank_account": bank_account,
|
||||
"journal_entry": journal_entry,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_payment_entry(ref_doc, args):
|
||||
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
|
||||
"Company", ref_doc.company, "cost_center"
|
||||
)
|
||||
exchange_rate = 1
|
||||
if args.get("party_account"):
|
||||
# Modified to include the posting date for which the exchange rate is required.
|
||||
# Assumed to be the posting date in the reference document
|
||||
exchange_rate = get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
args.get("party_account"),
|
||||
args.get("party_account_currency"),
|
||||
ref_doc.company,
|
||||
ref_doc.doctype,
|
||||
ref_doc.name,
|
||||
)
|
||||
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
|
||||
|
||||
party_row = je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": args.get("party_account"),
|
||||
"party_type": args.get("party_type"),
|
||||
"party": ref_doc.get(args.get("party_type").lower()),
|
||||
"cost_center": cost_center,
|
||||
"account_type": frappe.get_cached_value("Account", args.get("party_account"), "account_type"),
|
||||
"account_currency": args.get("party_account_currency")
|
||||
or get_account_currency(args.get("party_account")),
|
||||
"exchange_rate": exchange_rate,
|
||||
args.get("amount_field_party"): args.get("amount"),
|
||||
"is_advance": args.get("is_advance"),
|
||||
"reference_type": ref_doc.doctype,
|
||||
"reference_name": ref_doc.name,
|
||||
},
|
||||
)
|
||||
|
||||
bank_row = je.append("accounts")
|
||||
|
||||
# Make it bank_details
|
||||
bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account"))
|
||||
if bank_account:
|
||||
bank_row.update(bank_account)
|
||||
# Modified to include the posting date for which the exchange rate is required.
|
||||
# Assumed to be the posting date of the reference date
|
||||
bank_row.exchange_rate = get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
bank_account["account"],
|
||||
bank_account["account_currency"],
|
||||
ref_doc.company,
|
||||
)
|
||||
|
||||
bank_row.cost_center = cost_center
|
||||
|
||||
amount = args.get("debit_in_account_currency") or args.get("amount")
|
||||
|
||||
if bank_row.account_currency == args.get("party_account_currency"):
|
||||
bank_row.set(args.get("amount_field_bank"), amount)
|
||||
else:
|
||||
bank_row.set(args.get("amount_field_bank"), amount * exchange_rate)
|
||||
|
||||
# Multi currency check again
|
||||
if party_row.account_currency != ref_doc.company_currency or (
|
||||
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
|
||||
):
|
||||
je.multi_currency = 1
|
||||
|
||||
je.set_amounts_in_company_currency()
|
||||
je.set_total_debit_credit()
|
||||
|
||||
return je if args.get("journal_entry") else je.as_dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_against_jv(
|
||||
@@ -1699,54 +1230,3 @@ def get_average_exchange_rate(account: str):
|
||||
exchange_rate = bank_balance_in_company_currency / bank_balance_in_account_currency
|
||||
|
||||
return exchange_rate
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str):
|
||||
journal_entry = frappe.new_doc("Journal Entry")
|
||||
journal_entry.voucher_type = voucher_type
|
||||
journal_entry.company = company
|
||||
journal_entry.posting_date = nowdate()
|
||||
journal_entry.inter_company_journal_entry_reference = name
|
||||
return journal_entry.as_dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None):
|
||||
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
|
||||
if existing_reverse:
|
||||
frappe.throw(
|
||||
_("A Reverse Journal Entry {0} already exists for this Journal Entry.").format(
|
||||
get_link_to_form("Journal Entry", existing_reverse)
|
||||
)
|
||||
)
|
||||
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
|
||||
def post_process(source, target):
|
||||
target.reversal_of = source.name
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Journal Entry",
|
||||
source_name,
|
||||
{
|
||||
"Journal Entry": {"doctype": "Journal Entry", "validation": {"docstatus": ["=", 1]}},
|
||||
"Journal Entry Account": {
|
||||
"doctype": "Journal Entry Account",
|
||||
"field_map": {
|
||||
"account_currency": "account_currency",
|
||||
"exchange_rate": "exchange_rate",
|
||||
"debit_in_account_currency": "credit_in_account_currency",
|
||||
"debit": "credit",
|
||||
"credit_in_account_currency": "debit_in_account_currency",
|
||||
"credit": "debit",
|
||||
"reference_type": "reference_type",
|
||||
"reference_name": "reference_name",
|
||||
},
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
post_process,
|
||||
)
|
||||
|
||||
return doclist
|
||||
|
||||
240
erpnext/accounts/doctype/journal_entry/mapper.py
Normal file
240
erpnext/accounts/doctype/journal_entry/mapper.py
Normal file
@@ -0,0 +1,240 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Document builders that map a source document to a Journal Entry or to a
|
||||
Payment Entry raised against it."""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import flt, get_link_to_form, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
|
||||
get_party_account_based_on_invoice_discounting,
|
||||
)
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.utils import get_account_currency
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_entry_against_order(
|
||||
dt: str,
|
||||
dn: str,
|
||||
amount: float | None = None,
|
||||
debit_in_account_currency: str | float | None = None,
|
||||
journal_entry: bool = False,
|
||||
bank_account: str | None = None,
|
||||
):
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
|
||||
if flt(ref_doc.per_billed, 2) > 0:
|
||||
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
|
||||
|
||||
if dt == "Sales Order":
|
||||
party_type = "Customer"
|
||||
amount_field_party = "credit_in_account_currency"
|
||||
amount_field_bank = "debit_in_account_currency"
|
||||
else:
|
||||
party_type = "Supplier"
|
||||
amount_field_party = "debit_in_account_currency"
|
||||
amount_field_bank = "credit_in_account_currency"
|
||||
|
||||
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
|
||||
party_account_currency = get_account_currency(party_account)
|
||||
|
||||
if not amount:
|
||||
if party_account_currency == ref_doc.company_currency:
|
||||
amount = flt(ref_doc.base_grand_total) - flt(ref_doc.advance_paid)
|
||||
else:
|
||||
amount = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid)
|
||||
|
||||
return get_payment_entry(
|
||||
ref_doc,
|
||||
{
|
||||
"party_type": party_type,
|
||||
"party_account": party_account,
|
||||
"party_account_currency": party_account_currency,
|
||||
"amount_field_party": amount_field_party,
|
||||
"amount_field_bank": amount_field_bank,
|
||||
"amount": amount,
|
||||
"debit_in_account_currency": debit_in_account_currency,
|
||||
"remarks": f"Advance Payment received against {dt} {dn}",
|
||||
"is_advance": "Yes",
|
||||
"bank_account": bank_account,
|
||||
"journal_entry": journal_entry,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_entry_against_invoice(
|
||||
dt: str,
|
||||
dn: str,
|
||||
amount: float | None = None,
|
||||
debit_in_account_currency: str | None = None,
|
||||
journal_entry: bool = False,
|
||||
bank_account: str | None = None,
|
||||
):
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
if dt == "Sales Invoice":
|
||||
party_type = "Customer"
|
||||
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
|
||||
else:
|
||||
party_type = "Supplier"
|
||||
party_account = ref_doc.credit_to
|
||||
|
||||
if (dt == "Sales Invoice" and ref_doc.outstanding_amount > 0) or (
|
||||
dt == "Purchase Invoice" and ref_doc.outstanding_amount < 0
|
||||
):
|
||||
amount_field_party = "credit_in_account_currency"
|
||||
amount_field_bank = "debit_in_account_currency"
|
||||
else:
|
||||
amount_field_party = "debit_in_account_currency"
|
||||
amount_field_bank = "credit_in_account_currency"
|
||||
|
||||
return get_payment_entry(
|
||||
ref_doc,
|
||||
{
|
||||
"party_type": party_type,
|
||||
"party_account": party_account,
|
||||
"party_account_currency": ref_doc.party_account_currency,
|
||||
"amount_field_party": amount_field_party,
|
||||
"amount_field_bank": amount_field_bank,
|
||||
"amount": amount if amount else abs(ref_doc.outstanding_amount),
|
||||
"debit_in_account_currency": debit_in_account_currency,
|
||||
"remarks": f"Payment received against {dt} {dn}. {ref_doc.remarks}",
|
||||
"is_advance": "No",
|
||||
"bank_account": bank_account,
|
||||
"journal_entry": journal_entry,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_payment_entry(ref_doc, args):
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import (
|
||||
get_default_bank_cash_account,
|
||||
get_exchange_rate,
|
||||
)
|
||||
|
||||
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
|
||||
"Company", ref_doc.company, "cost_center"
|
||||
)
|
||||
exchange_rate = 1
|
||||
if args.get("party_account"):
|
||||
# Modified to include the posting date for which the exchange rate is required.
|
||||
# Assumed to be the posting date in the reference document
|
||||
exchange_rate = get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
args.get("party_account"),
|
||||
args.get("party_account_currency"),
|
||||
ref_doc.company,
|
||||
ref_doc.doctype,
|
||||
ref_doc.name,
|
||||
)
|
||||
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
|
||||
|
||||
party_row = je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": args.get("party_account"),
|
||||
"party_type": args.get("party_type"),
|
||||
"party": ref_doc.get(args.get("party_type").lower()),
|
||||
"cost_center": cost_center,
|
||||
"account_type": frappe.get_cached_value("Account", args.get("party_account"), "account_type"),
|
||||
"account_currency": args.get("party_account_currency")
|
||||
or get_account_currency(args.get("party_account")),
|
||||
"exchange_rate": exchange_rate,
|
||||
args.get("amount_field_party"): args.get("amount"),
|
||||
"is_advance": args.get("is_advance"),
|
||||
"reference_type": ref_doc.doctype,
|
||||
"reference_name": ref_doc.name,
|
||||
},
|
||||
)
|
||||
|
||||
bank_row = je.append("accounts")
|
||||
|
||||
# Make it bank_details
|
||||
bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account"))
|
||||
if bank_account:
|
||||
bank_row.update(bank_account)
|
||||
# Modified to include the posting date for which the exchange rate is required.
|
||||
# Assumed to be the posting date of the reference date
|
||||
bank_row.exchange_rate = get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
bank_account["account"],
|
||||
bank_account["account_currency"],
|
||||
ref_doc.company,
|
||||
)
|
||||
|
||||
bank_row.cost_center = cost_center
|
||||
|
||||
amount = args.get("debit_in_account_currency") or args.get("amount")
|
||||
|
||||
if bank_row.account_currency == args.get("party_account_currency"):
|
||||
bank_row.set(args.get("amount_field_bank"), amount)
|
||||
else:
|
||||
bank_row.set(args.get("amount_field_bank"), amount * exchange_rate)
|
||||
|
||||
# Multi currency check again
|
||||
if party_row.account_currency != ref_doc.company_currency or (
|
||||
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
|
||||
):
|
||||
je.multi_currency = 1
|
||||
|
||||
je.set_amounts_in_company_currency()
|
||||
je.set_total_debit_credit()
|
||||
|
||||
return je if args.get("journal_entry") else je.as_dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str):
|
||||
journal_entry = frappe.new_doc("Journal Entry")
|
||||
journal_entry.voucher_type = voucher_type
|
||||
journal_entry.company = company
|
||||
journal_entry.posting_date = nowdate()
|
||||
journal_entry.inter_company_journal_entry_reference = name
|
||||
return journal_entry.as_dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None):
|
||||
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
|
||||
if existing_reverse:
|
||||
frappe.throw(
|
||||
_("A Reverse Journal Entry {0} already exists for this Journal Entry.").format(
|
||||
get_link_to_form("Journal Entry", existing_reverse)
|
||||
)
|
||||
)
|
||||
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
|
||||
def post_process(source, target):
|
||||
target.reversal_of = source.name
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Journal Entry",
|
||||
source_name,
|
||||
{
|
||||
"Journal Entry": {"doctype": "Journal Entry", "validation": {"docstatus": ["=", 1]}},
|
||||
"Journal Entry Account": {
|
||||
"doctype": "Journal Entry Account",
|
||||
"field_map": {
|
||||
"account_currency": "account_currency",
|
||||
"exchange_rate": "exchange_rate",
|
||||
"debit_in_account_currency": "credit_in_account_currency",
|
||||
"debit": "credit",
|
||||
"credit_in_account_currency": "debit_in_account_currency",
|
||||
"credit": "debit",
|
||||
"reference_type": "reference_type",
|
||||
"reference_name": "reference_name",
|
||||
},
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
post_process,
|
||||
)
|
||||
|
||||
return doclist
|
||||
181
erpnext/accounts/doctype/journal_entry/services/asset_service.py
Normal file
181
erpnext/accounts/doctype/journal_entry/services/asset_service.py
Normal file
@@ -0,0 +1,181 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
|
||||
get_depr_schedule,
|
||||
)
|
||||
|
||||
|
||||
class AssetService:
|
||||
"""Keeps Assets in sync with the Journal Entries that depreciate, dispose or
|
||||
adjust them.
|
||||
|
||||
On submit of a Depreciation Entry it reduces the asset value and links the
|
||||
depreciation schedule; on submit of an Asset Disposal it marks the asset
|
||||
disposed. On cancel it reverses those links. It also guards cancellation of
|
||||
Journal Entries tied to asset scrapping or value adjustments.
|
||||
"""
|
||||
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def validate_depr_account_and_depr_entry_voucher_type(self):
|
||||
for d in self.doc.get("accounts"):
|
||||
if d.account_type == "Depreciation":
|
||||
if self.doc.voucher_type != "Depreciation Entry":
|
||||
frappe.throw(
|
||||
_("Journal Entry type should be set as Depreciation Entry for asset depreciation")
|
||||
)
|
||||
|
||||
if frappe.get_cached_value("Account", d.account, "root_type") != "Expense":
|
||||
frappe.throw(_("Account {0} should be of type Expense").format(d.account))
|
||||
|
||||
def has_asset_adjustment_entry(self):
|
||||
if self.doc.flags.get("via_asset_value_adjustment"):
|
||||
return
|
||||
|
||||
asset_value_adjustment = frappe.db.get_value(
|
||||
"Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.doc.name}, "name"
|
||||
)
|
||||
if asset_value_adjustment:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot cancel this document as it is linked with the submitted Asset Value Adjustment <b>{0}</b>. Please cancel the Asset Value Adjustment to continue."
|
||||
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
|
||||
)
|
||||
|
||||
def update_asset_value(self):
|
||||
self.update_asset_on_depreciation()
|
||||
self.update_asset_on_disposal()
|
||||
|
||||
def update_asset_on_depreciation(self):
|
||||
if self.doc.voucher_type != "Depreciation Entry":
|
||||
return
|
||||
|
||||
for d in self.doc.get("accounts"):
|
||||
if (
|
||||
d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||
and d.debit
|
||||
):
|
||||
asset = frappe.get_cached_doc("Asset", d.reference_name)
|
||||
|
||||
if asset.calculate_depreciation:
|
||||
self.update_journal_entry_link_on_depr_schedule(asset, d)
|
||||
self.update_value_after_depreciation(asset, d.debit)
|
||||
|
||||
asset.db_set("value_after_depreciation", asset.value_after_depreciation - d.debit)
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
|
||||
def update_value_after_depreciation(self, asset, depr_amount):
|
||||
fb_idx = 1
|
||||
if self.doc.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if fb_row.finance_book == self.doc.finance_book:
|
||||
fb_idx = fb_row.idx
|
||||
break
|
||||
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||
fb_row.value_after_depreciation -= depr_amount
|
||||
frappe.db.set_value(
|
||||
"Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation
|
||||
)
|
||||
|
||||
def update_journal_entry_link_on_depr_schedule(self, asset, je_row):
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book)
|
||||
for d in depr_schedule or []:
|
||||
if (
|
||||
d.schedule_date == self.doc.posting_date
|
||||
and not d.journal_entry
|
||||
and d.depreciation_amount == flt(je_row.debit)
|
||||
):
|
||||
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)
|
||||
|
||||
def update_asset_on_disposal(self):
|
||||
if self.doc.voucher_type == "Asset Disposal":
|
||||
disposed_assets = []
|
||||
for d in self.doc.get("accounts"):
|
||||
if (
|
||||
d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and d.reference_name not in disposed_assets
|
||||
):
|
||||
frappe.db.set_value(
|
||||
"Asset",
|
||||
d.reference_name,
|
||||
{
|
||||
"disposal_date": self.doc.posting_date,
|
||||
"journal_entry_for_scrap": self.doc.name,
|
||||
},
|
||||
)
|
||||
asset_doc = frappe.get_doc("Asset", d.reference_name)
|
||||
asset_doc.set_status()
|
||||
disposed_assets.append(d.reference_name)
|
||||
|
||||
def unlink_asset_reference(self):
|
||||
for d in self.doc.get("accounts"):
|
||||
if (
|
||||
self.doc.voucher_type == "Depreciation Entry"
|
||||
and d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||
and d.debit
|
||||
):
|
||||
asset = frappe.get_doc("Asset", d.reference_name)
|
||||
|
||||
if asset.calculate_depreciation:
|
||||
je_found = False
|
||||
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if je_found:
|
||||
break
|
||||
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
|
||||
|
||||
for s in depr_schedule or []:
|
||||
if s.journal_entry == self.doc.name:
|
||||
s.db_set("journal_entry", None)
|
||||
|
||||
fb_row.value_after_depreciation += d.debit
|
||||
fb_row.db_update()
|
||||
|
||||
je_found = True
|
||||
break
|
||||
if not je_found:
|
||||
fb_idx = 1
|
||||
if self.doc.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if fb_row.finance_book == self.doc.finance_book:
|
||||
fb_idx = fb_row.idx
|
||||
break
|
||||
|
||||
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||
fb_row.value_after_depreciation += d.debit
|
||||
fb_row.db_update()
|
||||
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
elif (
|
||||
self.doc.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name
|
||||
):
|
||||
journal_entry_for_scrap = frappe.db.get_value(
|
||||
"Asset", d.reference_name, "journal_entry_for_scrap"
|
||||
)
|
||||
|
||||
if journal_entry_for_scrap == self.doc.name:
|
||||
frappe.throw(
|
||||
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
|
||||
)
|
||||
|
||||
def unlink_asset_adjustment_entry(self):
|
||||
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
|
||||
(
|
||||
frappe.qb.update(AssetValueAdjustment)
|
||||
.set(AssetValueAdjustment.journal_entry, None)
|
||||
.where(AssetValueAdjustment.journal_entry == self.doc.name)
|
||||
).run()
|
||||
@@ -0,0 +1,191 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _, scrub
|
||||
from frappe.utils import cstr, flt, fmt_money
|
||||
|
||||
from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts
|
||||
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
|
||||
get_party_account_based_on_invoice_discounting,
|
||||
)
|
||||
from erpnext.accounts.utils import get_account_currency
|
||||
|
||||
REFERENCE_PARTY_ACCOUNT_FIELDS = {
|
||||
"Sales Invoice": ["Customer", "Debit To"],
|
||||
"Purchase Invoice": ["Supplier", "Credit To"],
|
||||
"Sales Order": ["Customer"],
|
||||
"Purchase Order": ["Supplier"],
|
||||
}
|
||||
|
||||
|
||||
class JournalEntryReferenceValidator:
|
||||
"""Validates Journal Entry account rows against their referenced documents.
|
||||
|
||||
For each row that links a Sales/Purchase Invoice or Order, this checks the
|
||||
debit/credit direction, party and account match, and aggregates per-reference
|
||||
totals (held on the document as ``reference_totals``/``reference_types``/
|
||||
``reference_accounts``) which are then validated against the referenced
|
||||
orders and invoices.
|
||||
"""
|
||||
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def validate(self):
|
||||
self.doc.reference_totals = {}
|
||||
self.doc.reference_types = {}
|
||||
self.doc.reference_accounts = {}
|
||||
for row in self.doc.get("accounts"):
|
||||
self._normalize_reference_fields(row)
|
||||
if not self._has_party_reference(row):
|
||||
continue
|
||||
self._validate_order_direction(row)
|
||||
self._register_reference(row)
|
||||
self._validate_reference_party_and_account(row)
|
||||
|
||||
self._validate_orders()
|
||||
self._validate_invoices()
|
||||
|
||||
def _normalize_reference_fields(self, row):
|
||||
if not row.reference_type:
|
||||
row.reference_name = None
|
||||
if not row.reference_name:
|
||||
row.reference_type = None
|
||||
|
||||
def _has_party_reference(self, row):
|
||||
return bool(
|
||||
row.reference_type and row.reference_name and row.reference_type in REFERENCE_PARTY_ACCOUNT_FIELDS
|
||||
)
|
||||
|
||||
def _reference_amount_field(self, row):
|
||||
if row.reference_type in ("Sales Order", "Sales Invoice"):
|
||||
return "credit_in_account_currency"
|
||||
return "debit_in_account_currency"
|
||||
|
||||
def _validate_order_direction(self, row):
|
||||
if row.reference_type == "Sales Order" and flt(row.debit) > 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Debit entry can not be linked with a {1}").format(row.idx, row.reference_type)
|
||||
)
|
||||
if row.reference_type == "Purchase Order" and flt(row.credit) > 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Credit entry can not be linked with a {1}").format(row.idx, row.reference_type)
|
||||
)
|
||||
|
||||
def _register_reference(self, row):
|
||||
if row.reference_name not in self.doc.reference_totals:
|
||||
self.doc.reference_totals[row.reference_name] = 0.0
|
||||
if self.doc.voucher_type not in ("Deferred Revenue", "Deferred Expense"):
|
||||
self.doc.reference_totals[row.reference_name] += flt(row.get(self._reference_amount_field(row)))
|
||||
self.doc.reference_types[row.reference_name] = row.reference_type
|
||||
self.doc.reference_accounts[row.reference_name] = row.account
|
||||
|
||||
def _validate_reference_party_and_account(self, row):
|
||||
party_fields = REFERENCE_PARTY_ACCOUNT_FIELDS[row.reference_type]
|
||||
against_voucher = frappe.db.get_value(
|
||||
row.reference_type, row.reference_name, [scrub(f) for f in party_fields]
|
||||
)
|
||||
if not against_voucher:
|
||||
frappe.throw(_("Row {0}: Invalid reference {1}").format(row.idx, row.reference_name))
|
||||
|
||||
if row.reference_type in ("Sales Invoice", "Purchase Invoice"):
|
||||
self._validate_invoice_party_and_account(row, against_voucher, party_fields)
|
||||
elif row.reference_type in ("Sales Order", "Purchase Order"):
|
||||
self._validate_order_party(row, against_voucher)
|
||||
|
||||
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields):
|
||||
party_account, against_party = self._resolve_invoice_party_account(row, against_voucher)
|
||||
if self.doc.voucher_type == "Exchange Gain Or Loss":
|
||||
return
|
||||
if against_party != cstr(row.party) or party_account != row.account:
|
||||
frappe.throw(
|
||||
_("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format(
|
||||
row.idx, party_fields[0], party_fields[1], row.reference_type, row.reference_name
|
||||
)
|
||||
)
|
||||
|
||||
def _resolve_invoice_party_account(self, row, against_voucher):
|
||||
if self.doc.voucher_type in ("Deferred Revenue", "Deferred Expense") and row.reference_detail_no:
|
||||
debit_or_credit = "Debit" if row.debit else "Credit"
|
||||
party_account = get_deferred_booking_accounts(
|
||||
row.reference_type, row.reference_detail_no, debit_or_credit
|
||||
)
|
||||
return party_account, ""
|
||||
if row.reference_type == "Sales Invoice":
|
||||
party_account = (
|
||||
get_party_account_based_on_invoice_discounting(row.reference_name) or against_voucher[1]
|
||||
)
|
||||
else:
|
||||
party_account = against_voucher[1]
|
||||
return party_account, against_voucher[0]
|
||||
|
||||
def _validate_order_party(self, row, against_voucher):
|
||||
if against_voucher != row.party:
|
||||
frappe.throw(
|
||||
_("Row {0}: {1} {2} does not match with {3}").format(
|
||||
row.idx, row.party_type, row.party, row.reference_type
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_orders(self):
|
||||
"""Validate totals, closed and docstatus for orders"""
|
||||
for reference_name, total in self.doc.reference_totals.items():
|
||||
reference_type = self.doc.reference_types[reference_name]
|
||||
account = self.doc.reference_accounts[reference_name]
|
||||
if reference_type not in ("Sales Order", "Purchase Order"):
|
||||
continue
|
||||
|
||||
order = frappe.get_doc(reference_type, reference_name)
|
||||
self._validate_order_status(order, reference_type, reference_name)
|
||||
self._validate_order_advance_total(order, account, total, reference_type, reference_name)
|
||||
|
||||
def _validate_order_status(self, order, reference_type, reference_name):
|
||||
if order.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
if flt(order.per_billed) >= 100:
|
||||
frappe.throw(_("{0} {1} is fully billed").format(reference_type, reference_name))
|
||||
if cstr(order.status) == "Closed":
|
||||
frappe.throw(_("{0} {1} is closed").format(reference_type, reference_name))
|
||||
|
||||
def _validate_order_advance_total(self, order, account, total, reference_type, reference_name):
|
||||
account_currency = get_account_currency(account)
|
||||
if account_currency == self.doc.company_currency:
|
||||
voucher_total = order.base_grand_total
|
||||
field = "base_grand_total"
|
||||
else:
|
||||
voucher_total = order.grand_total
|
||||
field = "grand_total"
|
||||
|
||||
if flt(voucher_total) < (flt(order.advance_paid) + total):
|
||||
formatted_voucher_total = fmt_money(
|
||||
voucher_total, order.precision(field), currency=account_currency
|
||||
)
|
||||
frappe.throw(
|
||||
_("Advance paid against {0} {1} cannot be greater than Grand Total {2}").format(
|
||||
reference_type, reference_name, formatted_voucher_total
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_invoices(self):
|
||||
"""Validate totals and docstatus for invoices"""
|
||||
if self.doc.voucher_type in ("Debit Note", "Credit Note"):
|
||||
return
|
||||
for reference_name, total in self.doc.reference_totals.items():
|
||||
reference_type = self.doc.reference_types[reference_name]
|
||||
if reference_type not in ("Sales Invoice", "Purchase Invoice"):
|
||||
continue
|
||||
invoice = frappe.get_doc(reference_type, reference_name)
|
||||
self._validate_invoice_outstanding(invoice, total, reference_type, reference_name)
|
||||
|
||||
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name):
|
||||
if invoice.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
|
||||
precision = invoice.precision("outstanding_amount")
|
||||
if total and flt(invoice.outstanding_amount, precision) < flt(total, precision):
|
||||
frappe.throw(
|
||||
_("Payment against {0} {1} cannot be greater than Outstanding Amount {2}").format(
|
||||
reference_type, reference_name, invoice.outstanding_amount
|
||||
)
|
||||
)
|
||||
@@ -204,7 +204,7 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
self.assertFalse(gle)
|
||||
|
||||
def test_reverse_journal_entry(self):
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
|
||||
|
||||
jv = make_journal_entry("_Test Bank USD - _TC", "Sales - _TC", 100, exchange_rate=50, save=False)
|
||||
|
||||
@@ -609,6 +609,85 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
jv.save()
|
||||
self.assertRaises(frappe.ValidationError, jv.submit)
|
||||
|
||||
def test_validate_reference_doc_debit_against_sales_order_throws(self):
|
||||
"""Characterize: a debit entry linked to a Sales Order is rejected."""
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
sales_order = make_sales_order()
|
||||
jv = make_journal_entry("Debtors - _TC", "_Test Cash - _TC", 100, save=False)
|
||||
jv.accounts[0].party_type = "Customer"
|
||||
jv.accounts[0].party = "_Test Customer"
|
||||
jv.accounts[0].reference_type = "Sales Order"
|
||||
jv.accounts[0].reference_name = sales_order.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Debit entry can not be linked", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_credit_against_purchase_order_throws(self):
|
||||
"""Characterize: a credit entry linked to a Purchase Order is rejected."""
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
|
||||
purchase_order = create_purchase_order()
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Creditors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Supplier"
|
||||
jv.accounts[1].party = "_Test Supplier"
|
||||
jv.accounts[1].reference_type = "Purchase Order"
|
||||
jv.accounts[1].reference_name = purchase_order.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Credit entry can not be linked", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_nonexistent_reference_rejected(self):
|
||||
"""Characterize: a JE referencing a non-existent invoice is rejected by link validation.
|
||||
|
||||
Note: the controller's own "Invalid reference" branch is unreachable in normal flow
|
||||
because Frappe link validation rejects the missing reference before validate_reference_doc.
|
||||
"""
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = "_Test Customer"
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = "NON-EXISTENT-SI"
|
||||
self.assertRaises(frappe.LinkValidationError, jv.insert)
|
||||
|
||||
def test_validate_reference_doc_invoice_party_mismatch_throws(self):
|
||||
"""Characterize: an invoice reference whose party differs from the row party is rejected."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=500)
|
||||
other_customer = make_customer("_Test JE Mismatch Customer")
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = other_customer
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = invoice.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Party / Account does not match", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_order_party_mismatch_throws(self):
|
||||
"""Characterize: a Sales Order reference whose party differs from the row party is rejected."""
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
sales_order = make_sales_order()
|
||||
other_customer = make_customer("_Test JE Mismatch Customer")
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = other_customer
|
||||
jv.accounts[1].is_advance = "Yes"
|
||||
jv.accounts[1].reference_type = "Sales Order"
|
||||
jv.accounts[1].reference_name = sales_order.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "does not match", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_populates_reference_side_effects(self):
|
||||
"""Characterize: a valid invoice reference populates reference_totals/types/accounts."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=500)
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = "_Test Customer"
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = invoice.name
|
||||
jv.insert()
|
||||
self.assertEqual(jv.reference_totals[invoice.name], 100.0)
|
||||
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
|
||||
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
|
||||
|
||||
|
||||
def make_journal_entry(
|
||||
account1,
|
||||
|
||||
@@ -2036,6 +2036,9 @@ def get_outstanding_reference_documents(args: str | dict, validate: bool = False
|
||||
if args.get("party_type") == "Member":
|
||||
return
|
||||
|
||||
if args.get("party_type") and args.get("party"):
|
||||
frappe.has_permission(args["party_type"], "read", args["party"], throw=True)
|
||||
|
||||
if not args.get("get_outstanding_invoices") and not args.get("get_orders_to_be_billed"):
|
||||
args["get_outstanding_invoices"] = True
|
||||
|
||||
@@ -2531,6 +2534,7 @@ def get_reference_details(
|
||||
):
|
||||
total_amount = outstanding_amount = exchange_rate = account = None
|
||||
|
||||
frappe.has_permission(reference_doctype, "read", reference_name, throw=True)
|
||||
ref_doc = frappe.get_lazy_doc(reference_doctype, reference_name)
|
||||
company_currency = ref_doc.get("company_currency") or erpnext.get_company_currency(ref_doc.company)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from erpnext.accounts.doctype.sales_invoice.services.loyalty import LoyaltyServi
|
||||
from erpnext.accounts.party import get_due_date, get_party_account
|
||||
from erpnext.controllers.queries import item_query as _item_query
|
||||
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.stock_ledger import is_negative_stock_allowed
|
||||
|
||||
@@ -403,7 +404,7 @@ class POSInvoice(SalesInvoice):
|
||||
|
||||
for d in self.get("items"):
|
||||
if not d.serial_and_batch_bundle:
|
||||
if frappe.db.exists("Product Bundle", d.item_code):
|
||||
if get_active_product_bundle(d.item_code):
|
||||
(
|
||||
availability,
|
||||
is_stock_item,
|
||||
@@ -916,7 +917,7 @@ def get_stock_availability(item_code: str | None, warehouse: str):
|
||||
return bin_qty - pos_sales_qty, is_stock_item, is_negative_stock_allowed(item_code=item_code)
|
||||
else:
|
||||
is_stock_item = True
|
||||
if frappe.db.exists("Product Bundle", {"name": item_code, "disabled": 0}):
|
||||
if get_active_product_bundle(item_code):
|
||||
return get_bundle_availability(item_code, warehouse), is_stock_item, False
|
||||
else:
|
||||
is_stock_item = False
|
||||
@@ -926,7 +927,7 @@ def get_stock_availability(item_code: str | None, warehouse: str):
|
||||
|
||||
def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
|
||||
is_stock_item = True
|
||||
bundle = frappe.get_doc("Product Bundle", item_code)
|
||||
bundle = frappe.get_doc("Product Bundle", get_active_product_bundle(item_code))
|
||||
availabilities = []
|
||||
for bundle_item in bundle.items:
|
||||
if frappe.get_value("Item", bundle_item.item_code, "is_stock_item"):
|
||||
@@ -945,7 +946,7 @@ def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
|
||||
|
||||
|
||||
def get_bundle_availability(bundle_item_code, warehouse):
|
||||
product_bundle = frappe.get_doc("Product Bundle", bundle_item_code)
|
||||
product_bundle = frappe.get_doc("Product Bundle", get_active_product_bundle(bundle_item_code))
|
||||
|
||||
bundle_bin_qty = 1000000
|
||||
for item in product_bundle.items:
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"barcode",
|
||||
"has_item_scanned",
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"col_break1",
|
||||
"item_name",
|
||||
"customer_item_code",
|
||||
@@ -125,6 +127,23 @@
|
||||
"options": "Item",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only_depends_on": "eval:doc.so_detail"
|
||||
},
|
||||
{
|
||||
"fieldname": "col_break1",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -858,7 +877,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-20 16:16:12.322024",
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice Item",
|
||||
|
||||
@@ -101,6 +101,7 @@ class ProcessStatementOfAccounts(Document):
|
||||
|
||||
validate_template(self.subject)
|
||||
validate_template(self.body)
|
||||
validate_template(self.pdf_name)
|
||||
|
||||
if not self.customers:
|
||||
frappe.throw(_("Customers not selected."))
|
||||
@@ -578,6 +579,7 @@ def send_emails(document_name: str, from_scheduler: bool = False, posting_date:
|
||||
|
||||
@frappe.whitelist()
|
||||
def send_auto_email():
|
||||
frappe.has_permission("Process Statement Of Accounts", throw=True)
|
||||
selected = frappe.get_list(
|
||||
"Process Statement Of Accounts",
|
||||
filters={"enable_auto_email": 1},
|
||||
|
||||
@@ -653,6 +653,9 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
self.process_common_party_accounting()
|
||||
|
||||
if self.is_return:
|
||||
self.refresh_subscription_status()
|
||||
|
||||
def on_update_after_submit(self):
|
||||
fields_to_check = [
|
||||
"cash_bank_account",
|
||||
@@ -772,6 +775,8 @@ class PurchaseInvoice(BuyingController):
|
||||
"Tax Withholding Entry",
|
||||
)
|
||||
|
||||
self.refresh_subscription_status()
|
||||
|
||||
def update_project(self):
|
||||
projects = frappe._dict()
|
||||
for d in self.items:
|
||||
@@ -934,9 +939,9 @@ def make_regional_gl_entries(gl_entries, doc):
|
||||
|
||||
@frappe.whitelist()
|
||||
def change_release_date(name: str, release_date: str | None = None):
|
||||
if frappe.db.exists("Purchase Invoice", name):
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.db_set("release_date", release_date)
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.check_permission()
|
||||
pi.db_set("release_date", release_date)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -886,8 +886,10 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Product Bundle version this row was packed from",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only": 1
|
||||
@@ -1008,7 +1010,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-06 08:08:40.782395",
|
||||
"modified": "2026-06-08 21:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice Item",
|
||||
|
||||
@@ -327,7 +327,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
"rate": "rate",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: doc.qty > 0,
|
||||
"condition": lambda doc: doc.qty - received_items.get(doc.name, 0.0) > 0,
|
||||
}
|
||||
|
||||
if doctype in ["Sales Invoice", "Sales Order"]:
|
||||
@@ -367,11 +367,19 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
target_doc,
|
||||
set_missing_values,
|
||||
)
|
||||
if not doclist.get("items"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot create Intercompany {0}. All items in the source {1} have already been fully invoiced. "
|
||||
"Please check the existing linked {2}s."
|
||||
).format(target_doctype, doctype, target_doctype)
|
||||
)
|
||||
|
||||
return doclist
|
||||
|
||||
|
||||
def get_received_items(reference_name, doctype, reference_fieldname):
|
||||
@frappe.whitelist()
|
||||
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
|
||||
reference_field = "inter_company_invoice_reference"
|
||||
if doctype == "Purchase Order":
|
||||
reference_field = "inter_company_order_reference"
|
||||
@@ -384,20 +392,19 @@ def get_received_items(reference_name, doctype, reference_fieldname):
|
||||
target_doctypes = frappe.get_all(
|
||||
doctype,
|
||||
filters=filters,
|
||||
as_list=True,
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
received_items_map = {}
|
||||
if target_doctypes:
|
||||
target_doctypes = list(target_doctypes[0])
|
||||
|
||||
received_items_map = frappe._dict(
|
||||
frappe.get_all(
|
||||
received_items_data = frappe.get_all(
|
||||
doctype + " Item",
|
||||
filters={"parent": ("in", target_doctypes)},
|
||||
fields=[reference_fieldname, "qty"],
|
||||
as_list=1,
|
||||
)
|
||||
)
|
||||
for item in received_items_data:
|
||||
key = item.get(reference_fieldname)
|
||||
if key:
|
||||
received_items_map[key] = received_items_map.get(key, 0.0) + flt(item.qty)
|
||||
|
||||
return received_items_map
|
||||
|
||||
|
||||
@@ -179,12 +179,31 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
: "Inter Company Purchase Invoice";
|
||||
|
||||
me.frm.add_custom_button(
|
||||
button_label,
|
||||
__(button_label),
|
||||
function () {
|
||||
me.make_inter_company_invoice();
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.mapper.get_received_items",
|
||||
args: {
|
||||
reference_name: me.frm.doc.name,
|
||||
doctype: "Purchase Invoice",
|
||||
reference_fieldname: "sales_invoice_item",
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.exc) return;
|
||||
const received_items = r.message || {};
|
||||
const has_pending_qty = me.frm.doc.items.some(
|
||||
(item) => flt(item.qty) - flt(received_items[item.name] || 0) > 0
|
||||
);
|
||||
if (!has_pending_qty) {
|
||||
me.frm.remove_custom_button(__(button_label), __("Create"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -497,6 +497,9 @@ class SalesInvoice(SellingController):
|
||||
self.process_common_party_accounting()
|
||||
self.update_billed_qty_in_scio()
|
||||
|
||||
if self.is_return:
|
||||
self.refresh_subscription_status()
|
||||
|
||||
def before_cancel(self):
|
||||
POSService(self).check_if_created_using_pos_and_pos_closing_entry_generated()
|
||||
POSService(self).check_if_consolidated_invoice()
|
||||
@@ -584,6 +587,7 @@ class SalesInvoice(SellingController):
|
||||
POSService(self).cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode()
|
||||
|
||||
self.update_billed_qty_in_scio()
|
||||
self.refresh_subscription_status()
|
||||
|
||||
def update_status_updater_args(self):
|
||||
if not cint(self.update_stock):
|
||||
|
||||
@@ -2918,6 +2918,67 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(target_doc.company, "_Test Company 1")
|
||||
self.assertEqual(target_doc.supplier, "_Test Internal Supplier")
|
||||
|
||||
def test_restrict_inter_company_pi_when_sales_invoice_qty_fully_consumed(self):
|
||||
item_code_1 = "_Test IC Item 1"
|
||||
item_code_2 = "_Test IC Item 2"
|
||||
|
||||
create_item(item_code_1, is_stock_item=1)
|
||||
create_item(item_code_2, is_stock_item=1)
|
||||
|
||||
si = create_sales_invoice(
|
||||
company="Wind Power LLC",
|
||||
customer="_Test Internal Customer",
|
||||
item_code=item_code_1,
|
||||
debit_to="Debtors - WP",
|
||||
warehouse="Stores - WP",
|
||||
income_account="Sales - WP",
|
||||
expense_account="Cost of Goods Sold - WP",
|
||||
cost_center="Main - WP",
|
||||
currency="USD",
|
||||
qty=3,
|
||||
do_not_save=1,
|
||||
)
|
||||
si.selling_price_list = "_Test Price List Rest of the World"
|
||||
si.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_code_2,
|
||||
"item_name": item_code_2,
|
||||
"description": item_code_2,
|
||||
"warehouse": "Stores - WP",
|
||||
"qty": 2,
|
||||
"uom": "Nos",
|
||||
"stock_uom": "Nos",
|
||||
"rate": 100,
|
||||
"price_list_rate": 100,
|
||||
"income_account": "Sales - WP",
|
||||
"expense_account": "Cost of Goods Sold - WP",
|
||||
"cost_center": "Main - WP",
|
||||
"conversion_factor": 1,
|
||||
},
|
||||
)
|
||||
|
||||
si.submit()
|
||||
|
||||
target_doc = make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
for item in target_doc.items:
|
||||
item.update(
|
||||
{
|
||||
"expense_account": "Cost of Goods Sold - _TC1",
|
||||
"cost_center": "Main - _TC1",
|
||||
}
|
||||
)
|
||||
|
||||
target_doc.submit()
|
||||
self.assertEqual(len(target_doc.items), 2)
|
||||
self.assertEqual([item.qty for item in target_doc.items], [3, 2])
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"already been fully invoiced",
|
||||
):
|
||||
make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
def test_inter_company_transaction_does_not_inherit_party_fields(self):
|
||||
"""
|
||||
Party-derived fields on SI (from Customer) must not leak into the mapped PI.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"barcode",
|
||||
"has_item_scanned",
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"col_break1",
|
||||
"item_name",
|
||||
"customer_item_code",
|
||||
@@ -144,6 +146,23 @@
|
||||
"options": "Item",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only_depends_on": "eval:doc.so_detail || doc.dn_detail"
|
||||
},
|
||||
{
|
||||
"fieldname": "col_break1",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -1036,7 +1055,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-03 13:17:36.145788",
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -29,7 +29,13 @@ frappe.ui.form.on("Subscription", {
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.is_new()) return;
|
||||
if (frm.is_new()) {
|
||||
// The field wrapper is reused across docs; clear any stale heatmap.
|
||||
frm.get_field("billing_heatmap").$wrapper.empty();
|
||||
return;
|
||||
}
|
||||
|
||||
frm.trigger("render_billing_heatmap");
|
||||
|
||||
if (frm.doc.status !== "Cancelled") {
|
||||
frm.add_custom_button(
|
||||
@@ -95,4 +101,88 @@ frappe.ui.form.on("Subscription", {
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
render_billing_heatmap: function (frm) {
|
||||
frm.call("get_billing_heatmap").then((r) => {
|
||||
if (!r.message || !r.message.length) return;
|
||||
render_heatmap(frm.get_field("billing_heatmap").$wrapper, r.message, frm.doc);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Status -> colour and label for the calendar heatmap. Keys are Title-case to
|
||||
// match the value frappe-charts shows in its hover tooltip.
|
||||
const HEATMAP_COLORS = {
|
||||
Paid: "#39d353",
|
||||
Unpaid: "#388bfd",
|
||||
Overdue: "#f0883e",
|
||||
Cancelled: "#f85149",
|
||||
Refunded: "#a371f7",
|
||||
Planned: "#87ceeb",
|
||||
};
|
||||
|
||||
// Days inside the window but outside the subscription's active span stay faded.
|
||||
const EMPTY_COLOR = "#ebedf0";
|
||||
|
||||
function title_case(status) {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
function render_heatmap($wrapper, days, doc) {
|
||||
const data_points = {};
|
||||
days.forEach((day) => {
|
||||
data_points[day.date] = title_case(day.status);
|
||||
});
|
||||
|
||||
$wrapper.empty();
|
||||
const chart_el = $('<div class="subscription-billing-heatmap"></div>').appendTo($wrapper)[0];
|
||||
|
||||
new frappe.Chart(chart_el, {
|
||||
type: "heatmap",
|
||||
data: {
|
||||
dataPoints: data_points,
|
||||
start: new Date(days[0].date),
|
||||
end: new Date(days[days.length - 1].date),
|
||||
},
|
||||
discreteDomains: 1,
|
||||
showLegend: 0,
|
||||
// frappe-charts only does an intensity scale; we recolour each square by
|
||||
// its own status below, so the scale colours are placeholders.
|
||||
colors: ["#ebedf0", "#ebedf0", "#ebedf0", "#ebedf0", "#ebedf0"],
|
||||
});
|
||||
|
||||
// Paint every day square with its status colour (data-value holds the status).
|
||||
// The chart re-renders once for its entry animation, so repaint on each redraw.
|
||||
const within_subscription = (date) =>
|
||||
(!doc.start_date || date >= doc.start_date) && (!doc.end_date || date <= doc.end_date);
|
||||
|
||||
const paint = () =>
|
||||
chart_el.querySelectorAll("[data-date]").forEach((square) => {
|
||||
const status = square.getAttribute("data-value");
|
||||
if (status === "Planned" && !within_subscription(square.getAttribute("data-date"))) {
|
||||
// Outside the subscription's span: render blank and drop the status so the
|
||||
// hover tooltip shows only the date, not "Planned".
|
||||
square.setAttribute("fill", EMPTY_COLOR);
|
||||
square.setAttribute("data-value", "");
|
||||
return;
|
||||
}
|
||||
square.setAttribute("fill", HEATMAP_COLORS[status] || EMPTY_COLOR);
|
||||
});
|
||||
|
||||
paint();
|
||||
new MutationObserver(paint).observe(chart_el, { childList: true, subtree: true });
|
||||
|
||||
const legend = Object.keys(HEATMAP_COLORS)
|
||||
.map(
|
||||
(status) =>
|
||||
`<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;">
|
||||
<span style="width:11px;height:11px;border-radius:2px;background:${HEATMAP_COLORS[status]};"></span>
|
||||
${__(status)}
|
||||
</span>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
$(`<div style="margin-top:8px;font-size:11px;color:var(--text-muted);">${legend}</div>`).appendTo(
|
||||
$wrapper
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"billing_history_section",
|
||||
"billing_heatmap",
|
||||
"section_break_jznv",
|
||||
"party_type",
|
||||
"party",
|
||||
"cb_1",
|
||||
@@ -21,12 +24,16 @@
|
||||
"generate_new_invoices_past_due_date",
|
||||
"submit_invoice",
|
||||
"column_break_11",
|
||||
"current_invoice_start",
|
||||
"current_invoice_end",
|
||||
"days_until_due",
|
||||
"generate_invoice_at",
|
||||
"number_of_days",
|
||||
"cancel_at_period_end",
|
||||
"billing_period_section",
|
||||
"current_invoice_start",
|
||||
"current_invoice_end",
|
||||
"billing_period_cb",
|
||||
"next_billing_period_start",
|
||||
"next_billing_period_end",
|
||||
"sb_4",
|
||||
"plans",
|
||||
"sb_1",
|
||||
@@ -51,7 +58,7 @@
|
||||
"fieldtype": "Select",
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "\nTrialing\nActive\nGrace Period\nCancelled\nUnpaid\nCompleted",
|
||||
"options": "\nTrialing\nActive\nGrace Period\nCancelled\nUnpaid\nCompleted\nRefunded",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -83,17 +90,40 @@
|
||||
"fieldname": "column_break_11",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "billing_period_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Billing Period"
|
||||
},
|
||||
{
|
||||
"fieldname": "current_invoice_start",
|
||||
"fieldtype": "Date",
|
||||
"label": "Current Invoice Start Date",
|
||||
"label": "Current Invoice Start",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "current_invoice_end",
|
||||
"fieldtype": "Date",
|
||||
"label": "Current Invoice End Date",
|
||||
"label": "Current Invoice End",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "billing_period_cb",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "next_billing_period_start",
|
||||
"fieldtype": "Date",
|
||||
"label": "Next Billing Period Start",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "next_billing_period_end",
|
||||
"fieldtype": "Date",
|
||||
"label": "Next Billing Period End",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -108,7 +138,18 @@
|
||||
"default": "0",
|
||||
"fieldname": "cancel_at_period_end",
|
||||
"fieldtype": "Check",
|
||||
"label": "Cancel At End Of Period"
|
||||
"label": "Cancel When Period Ends"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "billing_history_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Billing History"
|
||||
},
|
||||
{
|
||||
"fieldname": "billing_heatmap",
|
||||
"fieldtype": "HTML",
|
||||
"label": "Billing Heatmap"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
@@ -206,7 +247,7 @@
|
||||
"description": "New invoices will be generated as per schedule even if current invoices are unpaid or past due date",
|
||||
"fieldname": "generate_new_invoices_past_due_date",
|
||||
"fieldtype": "Check",
|
||||
"label": "Generate New Invoices Past Due Date"
|
||||
"label": "Bill Even If Previous Invoice Unpaid"
|
||||
},
|
||||
{
|
||||
"fieldname": "end_date",
|
||||
@@ -239,19 +280,23 @@
|
||||
"label": "Submit Generated Invoices"
|
||||
},
|
||||
{
|
||||
"default": "End of the current subscription period",
|
||||
"default": "Postpaid (bill at period end)",
|
||||
"fieldname": "generate_invoice_at",
|
||||
"fieldtype": "Select",
|
||||
"label": "Generate Invoice At",
|
||||
"options": "End of the current subscription period\nBeginning of the current subscription period\nDays before the current subscription period",
|
||||
"options": "Postpaid (bill at period end)\nPrepaid (bill at period start)\nBill N days before period start",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\"",
|
||||
"depends_on": "eval:doc.generate_invoice_at === \"Bill N days before period start\"",
|
||||
"fieldname": "number_of_days",
|
||||
"fieldtype": "Int",
|
||||
"label": "Number of Days",
|
||||
"mandatory_depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\""
|
||||
"mandatory_depends_on": "eval:doc.generate_invoice_at === \"Bill N days before period start\""
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jznv",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
@@ -267,11 +312,11 @@
|
||||
"link_fieldname": "subscription"
|
||||
}
|
||||
],
|
||||
"modified": "2025-12-23 19:42:52.036034",
|
||||
"modified": "2026-06-04 07:21:15.938170",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Subscription",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ from frappe.utils.data import (
|
||||
cint,
|
||||
date_diff,
|
||||
flt,
|
||||
get_first_day,
|
||||
get_last_day,
|
||||
get_link_to_form,
|
||||
getdate,
|
||||
@@ -35,6 +36,24 @@ class InvoiceNotCancelled(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
GENERATE_AT_END = "Postpaid (bill at period end)"
|
||||
GENERATE_AT_BEGINNING = "Prepaid (bill at period start)"
|
||||
GENERATE_AT_DAYS_BEFORE = "Bill N days before period start"
|
||||
|
||||
STATUS_TRIALING = "Trialing"
|
||||
STATUS_ACTIVE = "Active"
|
||||
STATUS_GRACE_PERIOD = "Grace Period"
|
||||
STATUS_CANCELLED = "Cancelled"
|
||||
STATUS_UNPAID = "Unpaid"
|
||||
STATUS_COMPLETED = "Completed"
|
||||
STATUS_REFUNDED = "Refunded"
|
||||
|
||||
PARTY_CUSTOMER = "Customer"
|
||||
PARTY_SUPPLIER = "Supplier"
|
||||
|
||||
INVOICE_PAID = "Paid"
|
||||
|
||||
|
||||
DateTimeLikeObject = str | date
|
||||
|
||||
|
||||
@@ -64,11 +83,13 @@ class Subscription(Document):
|
||||
end_date: DF.Date | None
|
||||
follow_calendar_months: DF.Check
|
||||
generate_invoice_at: DF.Literal[
|
||||
"End of the current subscription period",
|
||||
"Beginning of the current subscription period",
|
||||
"Days before the current subscription period",
|
||||
"Postpaid (bill at period end)",
|
||||
"Prepaid (bill at period start)",
|
||||
"Bill N days before period start",
|
||||
]
|
||||
generate_new_invoices_past_due_date: DF.Check
|
||||
next_billing_period_end: DF.Date | None
|
||||
next_billing_period_start: DF.Date | None
|
||||
number_of_days: DF.Int
|
||||
party: DF.DynamicLink
|
||||
party_type: DF.Link
|
||||
@@ -76,7 +97,9 @@ class Subscription(Document):
|
||||
purchase_tax_template: DF.Link | None
|
||||
sales_tax_template: DF.Link | None
|
||||
start_date: DF.Date | None
|
||||
status: DF.Literal["", "Trialing", "Active", "Grace Period", "Cancelled", "Unpaid", "Completed"]
|
||||
status: DF.Literal[
|
||||
"", "Trialing", "Active", "Grace Period", "Cancelled", "Unpaid", "Completed", "Refunded"
|
||||
]
|
||||
submit_invoice: DF.Check
|
||||
trial_period_end: DF.Date | None
|
||||
trial_period_start: DF.Date | None
|
||||
@@ -103,38 +126,39 @@ class Subscription(Document):
|
||||
or an outstanding invoice blocks billing (per `generate_new_invoices_past_due_date`).
|
||||
"""
|
||||
while getdate(self._next_invoice_trigger_date()) <= getdate(nowdate()):
|
||||
period_start = self.current_invoice_start
|
||||
period_start = self.next_billing_period_start
|
||||
self.process(posting_date=self._next_invoice_trigger_date())
|
||||
|
||||
if self.status == "Cancelled" or getdate(self.current_invoice_start) == getdate(period_start):
|
||||
if self.status == STATUS_CANCELLED or getdate(self.next_billing_period_start) == getdate(
|
||||
period_start
|
||||
):
|
||||
break
|
||||
|
||||
if not self.generate_new_invoices_past_due_date:
|
||||
break
|
||||
|
||||
def _next_invoice_trigger_date(self) -> DateTimeLikeObject:
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
||||
return self.current_invoice_start
|
||||
if self.generate_invoice_at == "Days before the current subscription period":
|
||||
return add_days(self.current_invoice_start, -self.number_of_days)
|
||||
return self.current_invoice_end
|
||||
return self._invoice_date_for_period(self.next_billing_period_start, self.next_billing_period_end)
|
||||
|
||||
def _invoice_date_for_period(
|
||||
self, period_start: DateTimeLikeObject, period_end: DateTimeLikeObject
|
||||
) -> DateTimeLikeObject:
|
||||
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||
return period_start
|
||||
if self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||
return add_days(period_start, -self.number_of_days)
|
||||
return period_end
|
||||
|
||||
def update_subscription_period(self, date: DateTimeLikeObject | None = None):
|
||||
"""
|
||||
Subscription period is the period to be billed. This method updates the
|
||||
beginning of the billing period and end of the billing period.
|
||||
The beginning of the billing period is represented in the doctype as
|
||||
`current_invoice_start` and the end of the billing period is represented
|
||||
as `current_invoice_end`.
|
||||
`next_billing_period_start` and the end of the billing period is represented
|
||||
as `next_billing_period_end`.
|
||||
"""
|
||||
self.current_invoice_start = self.get_current_invoice_start(date)
|
||||
self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start)
|
||||
|
||||
def _get_subscription_period(self, date: DateTimeLikeObject | None = None):
|
||||
_current_invoice_start = self.get_current_invoice_start(date)
|
||||
_current_invoice_end = self.get_current_invoice_end(_current_invoice_start)
|
||||
|
||||
return _current_invoice_start, _current_invoice_end
|
||||
self.next_billing_period_start = self.get_current_invoice_start(date)
|
||||
self.next_billing_period_end = self.get_current_invoice_end(self.next_billing_period_start)
|
||||
|
||||
def get_current_invoice_start(self, date: DateTimeLikeObject | None = None) -> DateTimeLikeObject:
|
||||
"""
|
||||
@@ -175,7 +199,7 @@ class Subscription(Document):
|
||||
_current_invoice_end = add_to_date(self.start_date, **billing_cycle_info)
|
||||
|
||||
# For cases where trial period is for an entire billing interval
|
||||
if getdate(self.current_invoice_end) < getdate(date):
|
||||
if getdate(self.next_billing_period_end) < getdate(date):
|
||||
_current_invoice_end = add_to_date(date, **billing_cycle_info)
|
||||
else:
|
||||
_current_invoice_end = add_to_date(date, **billing_cycle_info)
|
||||
@@ -253,21 +277,35 @@ class Subscription(Document):
|
||||
"""
|
||||
Sets the status of the `Subscription`
|
||||
"""
|
||||
self._set_current_invoice_dates()
|
||||
if self.is_trialling():
|
||||
self.status = "Trialing"
|
||||
self.status = STATUS_TRIALING
|
||||
elif self.is_fully_refunded() and self.has_outstanding_invoice():
|
||||
self.status = STATUS_REFUNDED
|
||||
elif (
|
||||
not self.has_outstanding_invoice()
|
||||
and self.end_date
|
||||
and getdate(posting_date) > getdate(self.end_date)
|
||||
):
|
||||
self.status = "Completed"
|
||||
self.status = STATUS_COMPLETED
|
||||
elif self.is_past_grace_period():
|
||||
self.status = self.get_status_for_past_grace_period()
|
||||
self.cancelation_date = getdate(posting_date) if self.status == "Cancelled" else None
|
||||
self.cancelation_date = getdate(posting_date) if self.status == STATUS_CANCELLED else None
|
||||
elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
|
||||
self.status = "Grace Period"
|
||||
self.status = STATUS_GRACE_PERIOD
|
||||
elif not self.has_outstanding_invoice():
|
||||
self.status = "Active"
|
||||
self.status = STATUS_ACTIVE
|
||||
|
||||
def _set_current_invoice_dates(self) -> None:
|
||||
invoice = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={"subscription": self.name, "docstatus": ("<", 2), "is_return": 0},
|
||||
fields=["from_date", "to_date"],
|
||||
order_by="to_date desc",
|
||||
limit=1,
|
||||
)
|
||||
self.current_invoice_start = invoice[0].from_date if invoice else None
|
||||
self.current_invoice_end = invoice[0].to_date if invoice else None
|
||||
|
||||
def is_trialling(self) -> bool:
|
||||
"""
|
||||
@@ -282,7 +320,6 @@ class Subscription(Document):
|
||||
"""
|
||||
Returns true if the given `end_date` has passed
|
||||
"""
|
||||
# todo: test for illegal time
|
||||
if not end_date:
|
||||
return True
|
||||
|
||||
@@ -290,10 +327,10 @@ class Subscription(Document):
|
||||
|
||||
def get_status_for_past_grace_period(self) -> str:
|
||||
cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace"))
|
||||
status = "Unpaid"
|
||||
status = STATUS_UNPAID
|
||||
|
||||
if cancel_after_grace:
|
||||
status = "Cancelled"
|
||||
status = STATUS_CANCELLED
|
||||
|
||||
return status
|
||||
|
||||
@@ -321,7 +358,7 @@ class Subscription(Document):
|
||||
|
||||
@property
|
||||
def invoice_document_type(self) -> str:
|
||||
return "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
|
||||
return "Sales Invoice" if self.party_type == PARTY_CUSTOMER else "Purchase Invoice"
|
||||
|
||||
def validate(self) -> None:
|
||||
self.validate_trial_period()
|
||||
@@ -413,11 +450,7 @@ class Subscription(Document):
|
||||
to_date: DateTimeLikeObject | None = None,
|
||||
posting_date: DateTimeLikeObject | None = None,
|
||||
) -> Document:
|
||||
"""
|
||||
Creates a `Invoice` for the `Subscription`, updates `self.invoices` and
|
||||
saves the `Subscription`.
|
||||
Backwards compatibility
|
||||
"""
|
||||
"""Public alias for `create_invoice`; kept for external integrations."""
|
||||
return self.create_invoice(from_date=from_date, to_date=to_date, posting_date=posting_date)
|
||||
|
||||
def create_invoice(
|
||||
@@ -429,8 +462,19 @@ class Subscription(Document):
|
||||
"""
|
||||
Creates a `Invoice`, submits it and returns it
|
||||
"""
|
||||
# For backward compatibility
|
||||
# Earlier subscription didn't had any company field
|
||||
company = self._resolve_company()
|
||||
invoice = self._init_invoice_doc(company, posting_date)
|
||||
self._set_invoice_party(invoice)
|
||||
self._set_invoice_currency(invoice)
|
||||
self._apply_accounting_dimensions(invoice)
|
||||
self._append_invoice_items(invoice)
|
||||
self._apply_taxes(invoice)
|
||||
self._apply_payment_schedule(invoice)
|
||||
self._apply_discounts(invoice)
|
||||
return self._finalize_invoice(invoice, from_date, to_date)
|
||||
|
||||
def _resolve_company(self) -> str:
|
||||
# Earlier subscriptions didn't have a company field
|
||||
company = self.get("company") or get_default_company()
|
||||
if not company:
|
||||
frappe.throw(
|
||||
@@ -438,48 +482,49 @@ class Subscription(Document):
|
||||
"Company is mandatory for generating an invoice. Please set a default company in Global Defaults."
|
||||
)
|
||||
)
|
||||
return company
|
||||
|
||||
def _init_invoice_doc(self, company: str, posting_date: DateTimeLikeObject | None = None) -> Document:
|
||||
invoice = frappe.new_doc(self.invoice_document_type)
|
||||
invoice.company = company
|
||||
invoice.set_posting_time = 1
|
||||
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
||||
invoice.posting_date = self.current_invoice_start
|
||||
elif self.generate_invoice_at == "Days before the current subscription period":
|
||||
invoice.posting_date = posting_date or self.current_invoice_start
|
||||
else:
|
||||
invoice.posting_date = self.current_invoice_end
|
||||
|
||||
invoice.posting_date = self._invoice_posting_date(posting_date)
|
||||
invoice.cost_center = self.cost_center
|
||||
return invoice
|
||||
|
||||
def _invoice_posting_date(self, posting_date: DateTimeLikeObject | None = None) -> DateTimeLikeObject:
|
||||
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||
return self.next_billing_period_start
|
||||
if self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||
return posting_date or self.next_billing_period_start
|
||||
return self.next_billing_period_end
|
||||
|
||||
def _set_invoice_party(self, invoice: Document) -> None:
|
||||
if self.invoice_document_type == "Sales Invoice":
|
||||
invoice.customer = self.party
|
||||
else:
|
||||
invoice.supplier = self.party
|
||||
tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
|
||||
"Supplier", self.party, ["tax_withholding_category", "tax_withholding_group"]
|
||||
)
|
||||
if tax_withholding_category or tax_withholding_group:
|
||||
invoice.apply_tds = 1
|
||||
return
|
||||
|
||||
# Add currency to invoice
|
||||
invoice.supplier = self.party
|
||||
tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
|
||||
"Supplier", self.party, ["tax_withholding_category", "tax_withholding_group"]
|
||||
)
|
||||
if tax_withholding_category or tax_withholding_group:
|
||||
invoice.apply_tds = 1
|
||||
|
||||
def _set_invoice_currency(self, invoice: Document) -> None:
|
||||
invoice.currency = frappe.db.get_value("Subscription Plan", {"name": self.plans[0].plan}, "currency")
|
||||
|
||||
# Add dimensions in invoice for subscription:
|
||||
accounting_dimensions = get_accounting_dimensions()
|
||||
|
||||
for dimension in accounting_dimensions:
|
||||
def _apply_accounting_dimensions(self, invoice: Document) -> None:
|
||||
for dimension in get_accounting_dimensions():
|
||||
if self.get(dimension):
|
||||
invoice.update({dimension: self.get(dimension)})
|
||||
|
||||
# Subscription is better suited for service items. I won't update `update_stock`
|
||||
# for that reason
|
||||
items_list = self.get_items_from_plans(self.plans, is_prorate())
|
||||
|
||||
for item in items_list:
|
||||
def _append_invoice_items(self, invoice: Document) -> None:
|
||||
# Subscription is better suited for service items, so `update_stock` is left untouched
|
||||
for item in self.get_items_from_plans(self.plans, is_prorate()):
|
||||
invoice.append("items", item)
|
||||
|
||||
# Taxes
|
||||
def _apply_taxes(self, invoice: Document) -> None:
|
||||
tax_template = ""
|
||||
|
||||
if self.invoice_document_type == "Sales Invoice" and self.sales_tax_template:
|
||||
@@ -493,37 +538,43 @@ class Subscription(Document):
|
||||
invoice.taxes_and_charges = tax_template
|
||||
TaxService(invoice).set_taxes()
|
||||
|
||||
# Due date
|
||||
if self.days_until_due:
|
||||
invoice.append(
|
||||
"payment_schedule",
|
||||
{
|
||||
"due_date": add_days(invoice.posting_date, cint(self.days_until_due)),
|
||||
"invoice_portion": 100,
|
||||
},
|
||||
)
|
||||
def _apply_payment_schedule(self, invoice: Document) -> None:
|
||||
if not self.days_until_due:
|
||||
return
|
||||
|
||||
# Discounts
|
||||
invoice.append(
|
||||
"payment_schedule",
|
||||
{
|
||||
"due_date": add_days(invoice.posting_date, cint(self.days_until_due)),
|
||||
"invoice_portion": 100,
|
||||
},
|
||||
)
|
||||
|
||||
def _apply_discounts(self, invoice: Document) -> None:
|
||||
if self.is_trialling():
|
||||
invoice.additional_discount_percentage = 100
|
||||
else:
|
||||
if self.additional_discount_percentage:
|
||||
invoice.additional_discount_percentage = self.additional_discount_percentage
|
||||
return
|
||||
|
||||
if self.additional_discount_amount:
|
||||
invoice.discount_amount = self.additional_discount_amount
|
||||
if self.additional_discount_percentage:
|
||||
invoice.additional_discount_percentage = self.additional_discount_percentage
|
||||
|
||||
if self.additional_discount_percentage or self.additional_discount_amount:
|
||||
discount_on = self.apply_additional_discount
|
||||
invoice.apply_discount_on = discount_on if discount_on else "Grand Total"
|
||||
if self.additional_discount_amount:
|
||||
invoice.discount_amount = self.additional_discount_amount
|
||||
|
||||
# Subscription period
|
||||
if self.additional_discount_percentage or self.additional_discount_amount:
|
||||
invoice.apply_discount_on = self.apply_additional_discount or "Grand Total"
|
||||
|
||||
def _finalize_invoice(
|
||||
self,
|
||||
invoice: Document,
|
||||
from_date: DateTimeLikeObject | None = None,
|
||||
to_date: DateTimeLikeObject | None = None,
|
||||
) -> Document:
|
||||
invoice.subscription = self.name
|
||||
invoice.from_date = from_date or self.current_invoice_start
|
||||
invoice.to_date = to_date or self.current_invoice_end
|
||||
invoice.from_date = from_date or self.next_billing_period_start
|
||||
invoice.to_date = to_date or self.next_billing_period_end
|
||||
|
||||
invoice.flags.ignore_mandatory = True
|
||||
|
||||
invoice.set_missing_values()
|
||||
invoice.save()
|
||||
|
||||
@@ -540,15 +591,9 @@ class Subscription(Document):
|
||||
prorate_factor = 1
|
||||
if prorate:
|
||||
prorate_factor = get_prorata_factor(
|
||||
self.current_invoice_end,
|
||||
self.current_invoice_start,
|
||||
cint(
|
||||
self.generate_invoice_at
|
||||
in [
|
||||
"Beginning of the current subscription period",
|
||||
"Days before the current subscription period",
|
||||
]
|
||||
),
|
||||
self.next_billing_period_end,
|
||||
self.next_billing_period_start,
|
||||
cint(self.generate_invoice_at in [GENERATE_AT_BEGINNING, GENERATE_AT_DAYS_BEFORE]),
|
||||
)
|
||||
|
||||
items = []
|
||||
@@ -558,7 +603,7 @@ class Subscription(Document):
|
||||
|
||||
item_code = plan_doc.item
|
||||
|
||||
if self.party_type == "Customer":
|
||||
if self.party_type == PARTY_CUSTOMER:
|
||||
deferred_field = "enable_deferred_revenue"
|
||||
else:
|
||||
deferred_field = "enable_deferred_expense"
|
||||
@@ -572,8 +617,8 @@ class Subscription(Document):
|
||||
plan.plan,
|
||||
plan.qty,
|
||||
party,
|
||||
self.current_invoice_start,
|
||||
self.current_invoice_end,
|
||||
self.next_billing_period_start,
|
||||
self.next_billing_period_end,
|
||||
prorate_factor,
|
||||
),
|
||||
"cost_center": plan_doc.cost_center,
|
||||
@@ -583,8 +628,8 @@ class Subscription(Document):
|
||||
item.update(
|
||||
{
|
||||
deferred_field: deferred,
|
||||
"service_start_date": self.current_invoice_start,
|
||||
"service_end_date": self.current_invoice_end,
|
||||
"service_start_date": self.next_billing_period_start,
|
||||
"service_end_date": self.next_billing_period_end,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -607,11 +652,11 @@ class Subscription(Document):
|
||||
2. `process_for_past_due`
|
||||
"""
|
||||
if not self.is_current_invoice_generated(
|
||||
self.current_invoice_start, self.current_invoice_end
|
||||
self.next_billing_period_start, self.next_billing_period_end
|
||||
) and self.can_generate_new_invoice(posting_date):
|
||||
self.generate_invoice(posting_date=posting_date)
|
||||
if self.end_date:
|
||||
next_start = add_days(self.current_invoice_end, 1)
|
||||
next_start = add_days(self.next_billing_period_end, 1)
|
||||
|
||||
if getdate(next_start) > getdate(self.end_date):
|
||||
if self.cancel_at_period_end:
|
||||
@@ -621,12 +666,12 @@ class Subscription(Document):
|
||||
|
||||
self.save()
|
||||
return
|
||||
self.update_subscription_period(add_days(self.current_invoice_end, 1))
|
||||
elif posting_date and getdate(posting_date) > getdate(self.current_invoice_end):
|
||||
self.update_subscription_period(add_days(self.next_billing_period_end, 1))
|
||||
elif posting_date and getdate(posting_date) > getdate(self.next_billing_period_end):
|
||||
self.update_subscription_period()
|
||||
|
||||
if self.cancel_at_period_end and (
|
||||
getdate(posting_date) >= getdate(self.current_invoice_end)
|
||||
getdate(posting_date) >= getdate(self.next_billing_period_end)
|
||||
or getdate(posting_date) >= getdate(self.end_date)
|
||||
):
|
||||
self.cancel_subscription()
|
||||
@@ -652,9 +697,9 @@ class Subscription(Document):
|
||||
# multi-year gap doesn't retroactively bill cycle after cycle in one call.
|
||||
billing_cycle_info = self.get_billing_cycle_data()
|
||||
if billing_cycle_info:
|
||||
upper = getdate(add_to_date(self.current_invoice_end, **billing_cycle_info))
|
||||
upper = getdate(add_to_date(self.next_billing_period_end, **billing_cycle_info))
|
||||
else:
|
||||
upper = getdate(self.current_invoice_end)
|
||||
upper = getdate(self.next_billing_period_end)
|
||||
|
||||
return posting <= upper
|
||||
|
||||
@@ -664,9 +709,8 @@ class Subscription(Document):
|
||||
_current_end_date: DateTimeLikeObject | None = None,
|
||||
) -> bool:
|
||||
if not (_current_start_date and _current_end_date):
|
||||
_current_start_date, _current_end_date = self._get_subscription_period(
|
||||
date=add_days(self.current_invoice_end, 1)
|
||||
)
|
||||
_current_start_date = self.get_current_invoice_start(add_days(self.next_billing_period_end, 1))
|
||||
_current_end_date = self.get_current_invoice_end(_current_start_date)
|
||||
|
||||
if self.current_invoice and getdate(_current_start_date) <= getdate(
|
||||
self.current_invoice.posting_date
|
||||
@@ -688,7 +732,7 @@ class Subscription(Document):
|
||||
"""
|
||||
invoice = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
{"subscription": self.name, "docstatus": ("<", 2)},
|
||||
{"subscription": self.name, "docstatus": ("<", 2), "is_return": 0},
|
||||
limit=1,
|
||||
order_by="to_date desc",
|
||||
pluck="name",
|
||||
@@ -710,41 +754,70 @@ class Subscription(Document):
|
||||
"""
|
||||
Return `True` if the given invoice is paid
|
||||
"""
|
||||
return invoice.status == "Paid"
|
||||
return invoice.status == INVOICE_PAID
|
||||
|
||||
def has_outstanding_invoice(self) -> int:
|
||||
"""
|
||||
Returns `True` if the most recent invoice for the `Subscription` is not paid
|
||||
Returns the count of submitted, non-return invoices that are not yet paid.
|
||||
"""
|
||||
return frappe.db.count(
|
||||
self.invoice_document_type,
|
||||
{
|
||||
"subscription": self.name,
|
||||
"docstatus": 1,
|
||||
"status": ["!=", "Paid"],
|
||||
"is_return": 0,
|
||||
"status": ["!=", INVOICE_PAID],
|
||||
},
|
||||
)
|
||||
|
||||
def is_fully_refunded(self) -> bool:
|
||||
"""
|
||||
`True` only when every submitted, not-`Paid` invoice on the subscription has
|
||||
credit notes whose absolute total covers its outstanding amount.
|
||||
"""
|
||||
unpaid_invoices = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={
|
||||
"subscription": self.name,
|
||||
"docstatus": 1,
|
||||
"is_return": 0,
|
||||
"status": ["!=", INVOICE_PAID],
|
||||
},
|
||||
fields=["name", "outstanding_amount"],
|
||||
)
|
||||
if not unpaid_invoices:
|
||||
return False
|
||||
|
||||
return all(self._is_invoice_fully_credited(invoice) for invoice in unpaid_invoices)
|
||||
|
||||
def _is_invoice_fully_credited(self, invoice: dict) -> bool:
|
||||
credit_notes = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={"return_against": invoice.name, "docstatus": 1},
|
||||
pluck="grand_total",
|
||||
)
|
||||
credited = sum(flt(amount) for amount in credit_notes)
|
||||
return abs(credited) >= flt(invoice.outstanding_amount)
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_subscription(self) -> None:
|
||||
"""
|
||||
This sets the subscription as cancelled. It will stop invoices from being generated
|
||||
but it will not affect already created invoices.
|
||||
"""
|
||||
if self.status == "Cancelled":
|
||||
if self.status == STATUS_CANCELLED:
|
||||
frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
|
||||
|
||||
to_generate_invoice = (
|
||||
True
|
||||
if self.status == "Active"
|
||||
and self.generate_invoice_at != "Beginning of the current subscription period"
|
||||
if self.status == STATUS_ACTIVE and self.generate_invoice_at != GENERATE_AT_BEGINNING
|
||||
else False
|
||||
)
|
||||
self.status = "Cancelled"
|
||||
self.status = STATUS_CANCELLED
|
||||
self.cancelation_date = nowdate()
|
||||
|
||||
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.current_invoice_start):
|
||||
self.generate_invoice(self.current_invoice_start, self.cancelation_date)
|
||||
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.next_billing_period_start):
|
||||
self.generate_invoice(self.next_billing_period_start, self.cancelation_date)
|
||||
|
||||
self.save()
|
||||
|
||||
@@ -755,10 +828,10 @@ class Subscription(Document):
|
||||
subscription and the `Subscription` will lose all the history of generated invoices
|
||||
it has.
|
||||
"""
|
||||
if self.status != "Cancelled":
|
||||
if self.status != STATUS_CANCELLED:
|
||||
frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled)
|
||||
|
||||
self.status = "Active"
|
||||
self.status = STATUS_ACTIVE
|
||||
self.cancelation_date = None
|
||||
self.update_subscription_period(posting_date or nowdate())
|
||||
self.save()
|
||||
@@ -766,25 +839,130 @@ class Subscription(Document):
|
||||
@frappe.whitelist()
|
||||
def force_fetch_subscription_updates(self):
|
||||
"""
|
||||
Process Subscription and create Invoices even if current date doesn't lie between current_invoice_start and currenct_invoice_end
|
||||
Process Subscription and create Invoices even if current date doesn't lie between next_billing_period_start and next_billing_period_end
|
||||
It makes use of 'Proces Subscription' to force processing in a specific 'posting_date'
|
||||
"""
|
||||
|
||||
# Don't process future subscriptions
|
||||
if getdate(nowdate()) < getdate(self.current_invoice_start):
|
||||
if getdate(nowdate()) < getdate(self.next_billing_period_start):
|
||||
frappe.msgprint(_("Subscription for Future dates cannot be processed."))
|
||||
return
|
||||
|
||||
processing_date = None
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
||||
processing_date = self.current_invoice_start
|
||||
elif self.generate_invoice_at == "End of the current subscription period":
|
||||
processing_date = self.current_invoice_end
|
||||
elif self.generate_invoice_at == "Days before the current subscription period":
|
||||
processing_date = add_days(self.current_invoice_start, -self.number_of_days)
|
||||
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||
processing_date = self.next_billing_period_start
|
||||
elif self.generate_invoice_at == GENERATE_AT_END:
|
||||
processing_date = self.next_billing_period_end
|
||||
elif self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||
processing_date = add_days(self.next_billing_period_start, -self.number_of_days)
|
||||
|
||||
self.process(posting_date=processing_date)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_billing_heatmap(self) -> list[dict]:
|
||||
"""
|
||||
One cell per calendar day for a fixed 12-month window starting at the first day of
|
||||
the subscription's first month. Each day is coloured by the status of the billing
|
||||
period it falls into; days with no invoice yet are `planned`.
|
||||
"""
|
||||
periods = self._billing_periods()
|
||||
window_start = get_first_day(self.start_date) if self.start_date else get_first_day(nowdate())
|
||||
window_end = get_last_day(add_months(window_start, 11))
|
||||
|
||||
cells = []
|
||||
day = window_start
|
||||
while day <= window_end:
|
||||
cells.append(self._heatmap_cell(day, periods))
|
||||
day = add_days(day, 1)
|
||||
|
||||
return cells
|
||||
|
||||
def _billing_periods(self) -> list[dict]:
|
||||
invoices = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={"subscription": self.name},
|
||||
fields=[
|
||||
"name",
|
||||
"from_date",
|
||||
"to_date",
|
||||
"status",
|
||||
"due_date",
|
||||
"grand_total",
|
||||
"docstatus",
|
||||
"is_return",
|
||||
"return_against",
|
||||
],
|
||||
order_by="from_date asc",
|
||||
)
|
||||
|
||||
credited = {
|
||||
invoice.return_against
|
||||
for invoice in invoices
|
||||
if invoice.is_return and invoice.docstatus == 1 and invoice.return_against
|
||||
}
|
||||
|
||||
periods = [
|
||||
{
|
||||
"period_start": str(invoice.from_date),
|
||||
"period_end": str(invoice.to_date),
|
||||
"invoice": invoice.name,
|
||||
"amount": flt(invoice.grand_total),
|
||||
"status": self._heatmap_status(invoice, invoice.name in credited),
|
||||
}
|
||||
for invoice in invoices
|
||||
if not invoice.is_return and invoice.from_date and invoice.to_date
|
||||
]
|
||||
|
||||
return [*periods, *self._planned_periods(periods)]
|
||||
|
||||
def _heatmap_status(self, invoice: dict, is_credited: bool) -> str:
|
||||
if invoice.docstatus == 2:
|
||||
return "cancelled"
|
||||
if is_credited:
|
||||
return "refunded"
|
||||
if invoice.status == INVOICE_PAID:
|
||||
return "paid"
|
||||
if invoice.due_date and getdate(invoice.due_date) < getdate(nowdate()):
|
||||
return "overdue"
|
||||
return "unpaid"
|
||||
|
||||
def _planned_periods(self, invoiced_periods: list[dict]) -> list[dict]:
|
||||
invoiced = {(period["period_start"], period["period_end"]) for period in invoiced_periods}
|
||||
planned = []
|
||||
for start, end in self._upcoming_periods():
|
||||
if start and end and (str(start), str(end)) not in invoiced:
|
||||
planned.append(
|
||||
{
|
||||
"period_start": str(start),
|
||||
"period_end": str(end),
|
||||
"invoice": None,
|
||||
"amount": 0.0,
|
||||
"status": "planned",
|
||||
}
|
||||
)
|
||||
return planned
|
||||
|
||||
def _upcoming_periods(self) -> list[tuple]:
|
||||
"""The open billing period and the one immediately after it."""
|
||||
open_period = (self.next_billing_period_start, self.next_billing_period_end)
|
||||
after_start = add_days(self.next_billing_period_end, 1) if self.next_billing_period_end else None
|
||||
after_end = self.get_current_invoice_end(after_start) if after_start else None
|
||||
return [open_period, (after_start, after_end)]
|
||||
|
||||
def _heatmap_cell(self, day: date, periods: list[dict]) -> dict:
|
||||
for period in periods:
|
||||
if getdate(period["period_start"]) <= day <= getdate(period["period_end"]):
|
||||
return {"date": str(day), **period}
|
||||
|
||||
return {
|
||||
"date": str(day),
|
||||
"status": "planned",
|
||||
"invoice": None,
|
||||
"amount": 0.0,
|
||||
"period_start": None,
|
||||
"period_end": None,
|
||||
}
|
||||
|
||||
|
||||
def is_prorate() -> int:
|
||||
return cint(frappe.db.get_single_value("Subscription Settings", "prorate"))
|
||||
|
||||
@@ -11,6 +11,8 @@ from frappe.utils.data import (
|
||||
date_diff,
|
||||
flt,
|
||||
get_date_str,
|
||||
get_first_day,
|
||||
get_last_day,
|
||||
getdate,
|
||||
nowdate,
|
||||
)
|
||||
@@ -35,11 +37,11 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertEqual(subscription.trial_period_start, nowdate())
|
||||
self.assertEqual(subscription.trial_period_end, add_months(nowdate(), 1))
|
||||
self.assertEqual(
|
||||
add_days(subscription.trial_period_end, 1), get_date_str(subscription.current_invoice_start)
|
||||
add_days(subscription.trial_period_end, 1), get_date_str(subscription.next_billing_period_start)
|
||||
)
|
||||
self.assertEqual(
|
||||
add_to_date(subscription.current_invoice_start, months=1, days=-1),
|
||||
get_date_str(subscription.current_invoice_end),
|
||||
add_to_date(subscription.next_billing_period_start, months=1, days=-1),
|
||||
get_date_str(subscription.next_billing_period_end),
|
||||
)
|
||||
self.assertEqual(subscription.invoices, [])
|
||||
self.assertEqual(subscription.status, "Trialing")
|
||||
@@ -48,8 +50,8 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription = create_subscription()
|
||||
self.assertEqual(subscription.trial_period_start, None)
|
||||
self.assertEqual(subscription.trial_period_end, None)
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
# No invoice is created
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
@@ -66,12 +68,12 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
self.assertEqual(getdate(subscription.current_invoice_start), getdate("2018-02-01"))
|
||||
self.assertEqual(getdate(subscription.current_invoice_end), getdate("2018-02-28"))
|
||||
self.assertEqual(getdate(subscription.next_billing_period_start), getdate("2018-02-01"))
|
||||
self.assertEqual(getdate(subscription.next_billing_period_end), getdate("2018-02-28"))
|
||||
|
||||
def test_status_goes_back_to_active_after_invoice_is_paid(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
|
||||
start_date="2018-01-01", generate_invoice_at="Prepaid (bill at period start)"
|
||||
)
|
||||
subscription.process(posting_date="2018-01-01") # generate first invoice
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
@@ -89,7 +91,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.process()
|
||||
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, add_months(subscription.start_date, 1))
|
||||
self.assertEqual(subscription.next_billing_period_start, add_months(subscription.start_date, 1))
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_subscription_cancel_after_grace_period(self):
|
||||
@@ -122,7 +124,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
_date = add_months(nowdate(), -1)
|
||||
subscription = create_subscription(start_date=_date, days_until_due=10)
|
||||
|
||||
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
|
||||
subscription.process(posting_date=subscription.next_billing_period_end) # generate first invoice
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
@@ -134,7 +136,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
|
||||
subscription = create_subscription(start_date=add_days(nowdate(), -1000))
|
||||
|
||||
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
|
||||
subscription.process(posting_date=subscription.next_billing_period_end) # generate first invoice
|
||||
self.assertEqual(subscription.status, "Grace Period")
|
||||
|
||||
subscription.process()
|
||||
@@ -154,20 +156,20 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription = create_subscription() # no changes expected
|
||||
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
subscription.process() # no changes expected still
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
subscription.process() # no changes expected yet still
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
def test_subscription_cancellation(self):
|
||||
@@ -191,16 +193,18 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1)
|
||||
plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1)
|
||||
diff = flt(date_diff(nowdate(), subscription.next_billing_period_start) + 1)
|
||||
plan_days = flt(
|
||||
date_diff(subscription.next_billing_period_end, subscription.next_billing_period_start) + 1
|
||||
)
|
||||
prorate_factor = flt(diff / plan_days)
|
||||
|
||||
self.assertEqual(
|
||||
flt(
|
||||
get_prorata_factor(
|
||||
subscription.current_invoice_end,
|
||||
subscription.current_invoice_start,
|
||||
cint(subscription.generate_invoice_at == "Beginning of the current subscription period"),
|
||||
subscription.next_billing_period_end,
|
||||
subscription.next_billing_period_start,
|
||||
cint(subscription.generate_invoice_at == "Prepaid (bill at period start)"),
|
||||
),
|
||||
2,
|
||||
),
|
||||
@@ -237,8 +241,10 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.cancel_subscription()
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1)
|
||||
plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1)
|
||||
diff = flt(date_diff(nowdate(), subscription.next_billing_period_start) + 1)
|
||||
plan_days = flt(
|
||||
date_diff(subscription.next_billing_period_end, subscription.next_billing_period_start) + 1
|
||||
)
|
||||
prorate_factor = flt(diff / plan_days)
|
||||
|
||||
self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2))
|
||||
@@ -303,9 +309,9 @@ class TestSubscription(ERPNextTestSuite):
|
||||
settings.save()
|
||||
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
|
||||
start_date="2018-01-01", generate_invoice_at="Prepaid (bill at period start)"
|
||||
)
|
||||
subscription.process(subscription.current_invoice_start) # generate first invoice
|
||||
subscription.process(subscription.next_billing_period_start) # generate first invoice
|
||||
# This should change status to Unpaid since grace period is 0
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
@@ -317,7 +323,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
# A new invoice is generated
|
||||
subscription.process(posting_date=subscription.current_invoice_start)
|
||||
subscription.process(posting_date=subscription.next_billing_period_start)
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
settings.cancel_after_grace = default_grace_period_action
|
||||
@@ -354,7 +360,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
|
||||
# Change the subscription type to prebilled and process it.
|
||||
# Prepaid invoice should be generated
|
||||
subscription.generate_invoice_at = "Beginning of the current subscription period"
|
||||
subscription.generate_invoice_at = "Prepaid (bill at period start)"
|
||||
subscription.save()
|
||||
subscription.process()
|
||||
|
||||
@@ -366,7 +372,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
settings.prorate = 1
|
||||
settings.save()
|
||||
|
||||
subscription = create_subscription(generate_invoice_at="Beginning of the current subscription period")
|
||||
subscription = create_subscription(generate_invoice_at="Prepaid (bill at period start)")
|
||||
subscription.process()
|
||||
subscription.cancel_subscription()
|
||||
|
||||
@@ -387,7 +393,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.company = "_Test Company"
|
||||
subscription.party_type = "Supplier"
|
||||
subscription.party = "_Test Supplier"
|
||||
subscription.generate_invoice_at = "Beginning of the current subscription period"
|
||||
subscription.generate_invoice_at = "Prepaid (bill at period start)"
|
||||
subscription.follow_calendar_months = 1
|
||||
|
||||
# select subscription start date as "2018-01-15"
|
||||
@@ -413,7 +419,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date="2018-12-31",
|
||||
party_type="Supplier",
|
||||
party="_Test Supplier",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_new_invoices_past_due_date=1,
|
||||
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
||||
)
|
||||
@@ -424,7 +430,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_subscription_without_generate_invoice_past_due(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
||||
)
|
||||
|
||||
@@ -442,7 +448,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
frappe.db.set_value("Customer", party, "default_currency", "USD")
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
|
||||
party=party,
|
||||
)
|
||||
@@ -464,7 +470,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
frappe.db.set_value("Customer", party, "default_currency", "USD")
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
|
||||
party=party,
|
||||
)
|
||||
@@ -517,7 +523,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription = create_subscription(
|
||||
start_date="2023-01-01",
|
||||
end_date="2023-02-28",
|
||||
generate_invoice_at="Days before the current subscription period",
|
||||
generate_invoice_at="Bill N days before period start",
|
||||
number_of_days=10,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
)
|
||||
@@ -555,7 +561,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
start_date=start_date,
|
||||
party_type="Supplier",
|
||||
party="_Test Supplier",
|
||||
generate_invoice_at="Days before the current subscription period",
|
||||
generate_invoice_at="Bill N days before period start",
|
||||
generate_new_invoices_past_due_date=1,
|
||||
number_of_days=2,
|
||||
plans=[{"plan": "_Test Plan Name 5", "qty": 1}],
|
||||
@@ -577,7 +583,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date=add_days(start_date, 8),
|
||||
cancel_at_period_end=1,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
||||
)
|
||||
# Catch-up billing on creation generates every elapsed period and cancels at end
|
||||
@@ -598,7 +604,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date=add_days(start_date, 6),
|
||||
cancel_at_period_end=1,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
||||
)
|
||||
|
||||
@@ -684,7 +690,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date=end_date,
|
||||
party_type="Customer",
|
||||
party="_Test Customer",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_new_invoices_past_due_date=1,
|
||||
plans=[{"plan": "_Test Plan 3 Day", "qty": 1}],
|
||||
)
|
||||
@@ -713,7 +719,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_status_updates_immediately_when_invoice_paid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
@@ -729,7 +735,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_invoice_update_hook_refreshes_subscription_status(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
@@ -748,7 +754,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
# Test that payment entry → invoice → subscription status update chain works
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
@@ -771,16 +777,33 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_first_invoice_generated_on_create_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_current_invoice_dates_reflect_latest_invoice(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date="2018-01-01")
|
||||
invoice = subscription.get_current_invoice()
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(getdate(subscription.current_invoice_start), getdate(invoice.from_date))
|
||||
self.assertEqual(getdate(subscription.current_invoice_end), getdate(invoice.to_date))
|
||||
# `next_billing_period_start` tracks the next (unbilled) period.
|
||||
self.assertEqual(
|
||||
getdate(subscription.next_billing_period_start), getdate(add_days(invoice.to_date, 1))
|
||||
)
|
||||
|
||||
def test_first_invoice_not_generated_on_create_during_trial(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
trial_period_start=nowdate(),
|
||||
trial_period_end=add_days(nowdate(), 30),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
self.assertEqual(subscription.status, "Trialing")
|
||||
@@ -790,7 +813,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
try:
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
finally:
|
||||
@@ -799,10 +822,144 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_first_invoice_not_generated_for_future_dated_subscription(self):
|
||||
subscription = create_subscription(
|
||||
start_date=add_days(nowdate(), 10),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
def test_generate_invoice_at_migration_patch(self):
|
||||
from erpnext.patches.v16_0.migrate_subscription_generate_invoice_at import VALUE_MAP, execute
|
||||
|
||||
subscription = create_subscription(start_date=add_days(nowdate(), 10))
|
||||
for old_value, new_value in VALUE_MAP.items():
|
||||
frappe.db.set_value("Subscription", subscription.name, "generate_invoice_at", old_value)
|
||||
execute()
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Subscription", subscription.name, "generate_invoice_at"), new_value
|
||||
)
|
||||
|
||||
def test_next_billing_period_populated_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=add_days(nowdate(), 10),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(getdate(subscription.next_billing_period_start), getdate(add_days(nowdate(), 10)))
|
||||
self.assertGreater(
|
||||
getdate(subscription.next_billing_period_end), getdate(subscription.next_billing_period_start)
|
||||
)
|
||||
|
||||
def test_status_becomes_refunded_when_only_invoice_credited(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
make_full_credit_note(subscription.get_current_invoice().name)
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Refunded")
|
||||
|
||||
def test_status_stays_unpaid_when_one_of_two_invoices_credited(self):
|
||||
subscription = create_subscription(
|
||||
start_date=add_months(nowdate(), -2),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
)
|
||||
invoices = frappe.get_all(
|
||||
"Sales Invoice",
|
||||
filters={"subscription": subscription.name, "docstatus": 1, "is_return": 0},
|
||||
pluck="name",
|
||||
order_by="from_date asc",
|
||||
)
|
||||
self.assertGreaterEqual(len(invoices), 2)
|
||||
|
||||
make_full_credit_note(invoices[0])
|
||||
|
||||
subscription.reload()
|
||||
self.assertNotEqual(subscription.status, "Refunded")
|
||||
|
||||
def test_refunded_reverts_to_active_after_full_settlement(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
make_full_credit_note(invoice.name)
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Refunded")
|
||||
|
||||
invoice.db_set("status", "Paid")
|
||||
invoice.db_set("outstanding_amount", 0)
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_heatmap_spans_twelve_months_from_start_month(self):
|
||||
start_date = getdate("2024-03-14")
|
||||
subscription = create_subscription(start_date=start_date)
|
||||
heatmap = subscription.get_billing_heatmap()
|
||||
self.assertEqual(getdate(heatmap[0]["date"]), get_first_day(start_date))
|
||||
self.assertEqual(
|
||||
getdate(heatmap[-1]["date"]), get_last_day(add_months(get_first_day(start_date), 11))
|
||||
)
|
||||
self.assertIn("status", heatmap[0])
|
||||
|
||||
def test_heatmap_marks_paid_days_green(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
invoice.db_set("status", "Paid")
|
||||
invoice.db_set("outstanding_amount", 0)
|
||||
|
||||
subscription.reload()
|
||||
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
|
||||
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "paid")
|
||||
|
||||
def test_heatmap_marks_future_planned_days(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
today = getdate(nowdate())
|
||||
planned = [
|
||||
cell
|
||||
for cell in subscription.get_billing_heatmap()
|
||||
if cell["status"] == "planned" and getdate(cell["date"]) > today
|
||||
]
|
||||
self.assertTrue(planned)
|
||||
|
||||
def test_heatmap_marks_refunded_days_for_credited_periods(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
make_full_credit_note(invoice.name)
|
||||
|
||||
subscription.reload()
|
||||
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
|
||||
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "refunded")
|
||||
|
||||
|
||||
def make_full_credit_note(invoice_name):
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
|
||||
|
||||
credit_note = make_sales_return(invoice_name)
|
||||
credit_note.insert()
|
||||
credit_note.submit()
|
||||
return credit_note
|
||||
|
||||
|
||||
def make_plans():
|
||||
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import CustomFunction
|
||||
from frappe.utils import cint
|
||||
|
||||
|
||||
@@ -97,19 +98,32 @@ def get_sales_details(filters):
|
||||
if filters["based_on"] not in ("Sales Order", "Sales Invoice"):
|
||||
frappe.throw(_("Invalid value {0} for 'Based On'").format(filters["based_on"]))
|
||||
|
||||
date_field = "s.transaction_date" if filters["based_on"] == "Sales Order" else "s.posting_date"
|
||||
parent = frappe.qb.DocType(filters["based_on"])
|
||||
child_doctype = "Sales Order Item" if filters["based_on"] == "Sales Order" else "Sales Invoice Item"
|
||||
child = frappe.qb.DocType(child_doctype)
|
||||
|
||||
sales_data = frappe.db.sql(
|
||||
"""
|
||||
select s.territory, s.customer, si.item_group, si.item_code, si.qty, {date_field} as last_order_date,
|
||||
DATEDIFF(CURRENT_DATE, {date_field}) as days_since_last_order
|
||||
from `tab{doctype}` s, `tab{doctype} Item` si
|
||||
where s.name = si.parent and s.docstatus = 1
|
||||
order by days_since_last_order """.format( # nosec
|
||||
date_field=date_field, doctype=filters["based_on"]
|
||||
),
|
||||
as_dict=1,
|
||||
)
|
||||
date_diff = CustomFunction("DATEDIFF", ["d1", "d2"])
|
||||
current_date = CustomFunction("CURRENT_DATE", [])
|
||||
|
||||
date_col = parent.transaction_date if filters["based_on"] == "Sales Order" else parent.posting_date
|
||||
days_since_last_order = date_diff(current_date(), date_col)
|
||||
|
||||
sales_data = (
|
||||
frappe.qb.from_(parent)
|
||||
.inner_join(child)
|
||||
.on(parent.name == child.parent)
|
||||
.select(
|
||||
parent.territory,
|
||||
parent.customer,
|
||||
child.item_group,
|
||||
child.item_code,
|
||||
child.qty,
|
||||
date_col.as_("last_order_date"),
|
||||
days_since_last_order.as_("days_since_last_order"),
|
||||
)
|
||||
.where(parent.docstatus == 1)
|
||||
.orderby(days_since_last_order)
|
||||
).run(as_dict=True)
|
||||
|
||||
for d in sales_data:
|
||||
item_details_map.setdefault((d.territory, d.item_code), d)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied
|
||||
from frappe.model.workflow import get_workflow_name
|
||||
from frappe.utils import flt, get_link_to_form, getdate
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions
|
||||
@@ -222,15 +222,12 @@ class ChildItemUpdater:
|
||||
current_state = self.parent.get(workflow_doc.workflow_state_field)
|
||||
roles = frappe.get_roles()
|
||||
|
||||
transitions = [
|
||||
t.as_dict()
|
||||
for t in workflow_doc.transitions
|
||||
if t.next_state == current_state
|
||||
and t.allowed in roles
|
||||
and is_transition_condition_satisfied(t, self.parent)
|
||||
]
|
||||
allowed = any(
|
||||
state.state == current_state and (not state.allow_edit or state.allow_edit in roles)
|
||||
for state in workflow_doc.states
|
||||
)
|
||||
|
||||
if not transitions:
|
||||
if not allowed:
|
||||
frappe.throw(
|
||||
_("You are not allowed to update as per the conditions set in {} Workflow.").format(
|
||||
get_link_to_form("Workflow", workflow)
|
||||
|
||||
@@ -304,6 +304,7 @@ def get_balance_on(
|
||||
)
|
||||
|
||||
if party_type and party:
|
||||
frappe.has_permission(party_type, "read", party, throw=True)
|
||||
cond.append(
|
||||
f"""gle.party_type = {frappe.db.escape(party_type)} and gle.party = {frappe.db.escape(party)} """
|
||||
)
|
||||
@@ -446,15 +447,13 @@ def add_ac(args: frappe._dict | None = None):
|
||||
if not args:
|
||||
args = frappe.local.form_dict
|
||||
|
||||
args.pop("ignore_permissions", None)
|
||||
frappe.has_permission("Account", "create", throw=True)
|
||||
|
||||
args.doctype = "Account"
|
||||
args = make_tree_args(**args)
|
||||
|
||||
ac = frappe.new_doc("Account")
|
||||
|
||||
if args.get("ignore_permissions"):
|
||||
ac.flags.ignore_permissions = True
|
||||
args.pop("ignore_permissions")
|
||||
|
||||
ac.update(args)
|
||||
|
||||
if not ac.parent_account:
|
||||
|
||||
@@ -24,7 +24,7 @@ import erpnext
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_checks_for_pl_and_bs_accounts,
|
||||
)
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
|
||||
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
|
||||
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
|
||||
get_asset_depr_schedule_doc,
|
||||
|
||||
@@ -845,8 +845,10 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Product Bundle version this row was packed from",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only": 1
|
||||
@@ -939,7 +941,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-20 00:50:16.192936",
|
||||
"modified": "2026-06-08 21:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order Item",
|
||||
|
||||
@@ -425,7 +425,7 @@ class SellingController(StockController):
|
||||
row.new_item_code
|
||||
for row in frappe.get_all(
|
||||
"Product Bundle",
|
||||
filters={"new_item_code": ("in", items_to_fetch), "disabled": 0},
|
||||
filters={"new_item_code": ("in", items_to_fetch), "is_active": 1, "docstatus": 1},
|
||||
fields="new_item_code",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class calculate_taxes_and_totals:
|
||||
def __init__(self, doc: Document):
|
||||
self.doc = doc
|
||||
frappe.flags.round_off_applicable_accounts = (
|
||||
get_round_off_applicable_accounts(self.doc.company, []) or []
|
||||
get_round_off_applicable_accounts(self.doc.company, [], self.doc) or []
|
||||
)
|
||||
frappe.flags.round_row_wise_tax = frappe.get_single_value("Accounts Settings", "round_row_wise_tax")
|
||||
|
||||
@@ -1240,14 +1240,16 @@ def get_itemised_tax_breakup_html(doc):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_round_off_applicable_accounts(company: str, account_list: list | str):
|
||||
def get_round_off_applicable_accounts(
|
||||
company: str, account_list: list | str, doc: str | dict | Document | None = None
|
||||
):
|
||||
# required to set correct region
|
||||
with temporary_flag("company", company):
|
||||
return get_regional_round_off_accounts(company, account_list)
|
||||
return get_regional_round_off_accounts(company, account_list, doc)
|
||||
|
||||
|
||||
@erpnext.allow_regional
|
||||
def get_regional_round_off_accounts(company, account_list):
|
||||
def get_regional_round_off_accounts(company, account_list, doc=None):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -988,6 +988,34 @@ class TestAccountsController(ERPNextTestSuite):
|
||||
self.assertEqual(sinv.taxes[0].account_head, "_Test Account Excise Duty - _TC")
|
||||
self.assertEqual(sinv.total_taxes_and_charges, 5)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{"add_taxes_from_item_tax_template": 1, "add_taxes_from_taxes_and_charges_template": 0},
|
||||
)
|
||||
def test_19b_fetch_taxes_from_item_tax_template_purchase_invoice(self):
|
||||
pinv = frappe.new_doc("Purchase Invoice")
|
||||
pinv.supplier = "_Test Supplier"
|
||||
pinv.company = self.company
|
||||
pinv.currency = "INR"
|
||||
item = pinv.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 1,
|
||||
"rate": 50,
|
||||
"item_tax_template": "_Test Account Excise Duty @ 10 - _TC",
|
||||
},
|
||||
)
|
||||
|
||||
item_details = pinv.fetch_item_details(item)
|
||||
pinv.add_taxes_from_item_template(item, item_details)
|
||||
|
||||
self.assertEqual(len(pinv.taxes), 1)
|
||||
tax_row = pinv.taxes[0]
|
||||
self.assertEqual(tax_row.account_head, "_Test Account Excise Duty - _TC")
|
||||
self.assertEqual(tax_row.category, "Total")
|
||||
self.assertEqual(tax_row.add_deduct_tax, "Add")
|
||||
|
||||
def test_20_journal_against_sales_invoice(self):
|
||||
# Invoice in Foreign Currency
|
||||
si = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1)
|
||||
|
||||
@@ -15,7 +15,7 @@ class TestTaxesAndTotals(ERPNextTestSuite):
|
||||
"""
|
||||
test_account = "_Test Round Off Account"
|
||||
|
||||
def mock_regional(company, account_list: list) -> list:
|
||||
def mock_regional(company, account_list: list, doc=None) -> list:
|
||||
# Simulates a regional override
|
||||
account_list.extend([test_account])
|
||||
return account_list
|
||||
|
||||
@@ -31,20 +31,16 @@ def create_custom_fields_for_frappe_crm():
|
||||
@frappe.whitelist()
|
||||
def create_prospect_against_crm_deal():
|
||||
doc = frappe.form_dict
|
||||
prospect = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Prospect",
|
||||
"company_name": doc.organization or doc.lead_name,
|
||||
"no_of_employees": doc.no_of_employees,
|
||||
"prospect_owner": doc.deal_owner,
|
||||
"company": doc.erpnext_company,
|
||||
"crm_deal": doc.crm_deal,
|
||||
"territory": doc.territory,
|
||||
"industry": doc.industry,
|
||||
"website": doc.website,
|
||||
"annual_revenue": doc.annual_revenue,
|
||||
}
|
||||
)
|
||||
prospect = frappe.new_doc("Prospect")
|
||||
prospect.company_name = doc.organization or doc.lead_name
|
||||
prospect.no_of_employees = doc.no_of_employees
|
||||
prospect.prospect_owner = doc.deal_owner
|
||||
prospect.company = doc.erpnext_company
|
||||
prospect.crm_deal = doc.crm_deal
|
||||
prospect.territory = doc.territory
|
||||
prospect.industry = doc.industry
|
||||
prospect.website = doc.website
|
||||
prospect.annual_revenue = doc.annual_revenue
|
||||
|
||||
try:
|
||||
prospect_name = frappe.db.get_value("Prospect", {"company_name": prospect.company_name})
|
||||
@@ -150,6 +146,18 @@ def contact_exists(email, mobile_no):
|
||||
return False
|
||||
|
||||
|
||||
CUSTOMER_ALLOWED_FIELDS = {
|
||||
"customer_name",
|
||||
"customer_group",
|
||||
"customer_type",
|
||||
"territory",
|
||||
"default_currency",
|
||||
"industry",
|
||||
"website",
|
||||
"crm_deal",
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_customer(customer_data: dict | None = None):
|
||||
if not customer_data:
|
||||
@@ -158,9 +166,11 @@ def create_customer(customer_data: dict | None = None):
|
||||
try:
|
||||
customer_name = frappe.db.exists("Customer", {"customer_name": customer_data.get("customer_name")})
|
||||
if not customer_name:
|
||||
customer = frappe.get_doc({"doctype": "Customer", **customer_data}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
customer = frappe.new_doc("Customer")
|
||||
for field in CUSTOMER_ALLOWED_FIELDS:
|
||||
if customer_data.get(field) is not None:
|
||||
customer.set(field, customer_data.get(field))
|
||||
customer.insert(ignore_permissions=True)
|
||||
customer_name = customer.name
|
||||
|
||||
contacts = json.loads(customer_data.get("contacts"))
|
||||
|
||||
5256
erpnext/locale/ar.po
5256
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
5234
erpnext/locale/bs.po
5234
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
5188
erpnext/locale/cs.po
5188
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
5182
erpnext/locale/da.po
5182
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
5274
erpnext/locale/de.po
5274
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
5284
erpnext/locale/eo.po
5284
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
5262
erpnext/locale/es.po
5262
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
5274
erpnext/locale/fa.po
5274
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
5236
erpnext/locale/fr.po
5236
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
5248
erpnext/locale/hr.po
5248
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
5216
erpnext/locale/hu.po
5216
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
5188
erpnext/locale/id.po
5188
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
5194
erpnext/locale/it.po
5194
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
5234
erpnext/locale/ko.po
5234
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
5182
erpnext/locale/my.po
5182
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
5188
erpnext/locale/nb.po
5188
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
5270
erpnext/locale/nl.po
5270
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
5214
erpnext/locale/pl.po
5214
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
5188
erpnext/locale/pt.po
5188
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
5266
erpnext/locale/ru.po
5266
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
5186
erpnext/locale/sl.po
5186
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
5274
erpnext/locale/sr.po
5274
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
5374
erpnext/locale/sv.po
5374
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
5270
erpnext/locale/th.po
5270
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
5254
erpnext/locale/tr.po
5254
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
5272
erpnext/locale/vi.po
5272
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
5264
erpnext/locale/zh.po
5264
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -366,6 +366,13 @@ class BOM(WebsiteGenerator):
|
||||
|
||||
def validate_secondary_items(self):
|
||||
for item in self.secondary_items:
|
||||
if not item.is_legacy and item.item_code == self.item:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Finished Good Item {1} cannot be added in the Secondary Items table."
|
||||
).format(item.idx, get_link_to_form("Item", item.item_code))
|
||||
)
|
||||
|
||||
if not item.qty:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Quantity should be greater than 0 for {1} Item {2}").format(
|
||||
|
||||
@@ -444,6 +444,30 @@ class TestBOM(ERPNextTestSuite):
|
||||
# Items with whole UOMs can't be PL Items
|
||||
self.assertRaises(frappe.ValidationError, bom_doc.submit)
|
||||
|
||||
@timeout
|
||||
def test_fg_item_not_allowed_in_secondary_items(self):
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
|
||||
|
||||
bom_doc = frappe.new_doc("BOM")
|
||||
bom_doc.item = fg_item
|
||||
bom_doc.quantity = 1
|
||||
bom_doc.company = "_Test Company"
|
||||
bom_doc.currency = "INR"
|
||||
bom_doc.append("items", {"item_code": rm_item, "qty": 1, "rate": 100.0})
|
||||
bom_doc.append(
|
||||
"secondary_items",
|
||||
{
|
||||
"item_code": fg_item,
|
||||
"secondary_item_type": "Additional Finished Good",
|
||||
"qty": 1,
|
||||
"cost_allocation_per": 10,
|
||||
},
|
||||
)
|
||||
|
||||
# FG item of the BOM cannot also be a secondary item
|
||||
self.assertRaises(frappe.ValidationError, bom_doc.save)
|
||||
|
||||
@timeout
|
||||
def test_bom_item_query(self):
|
||||
query = partial(
|
||||
|
||||
@@ -577,6 +577,10 @@ frappe.ui.form.on("Job Card", {
|
||||
const wrapper = $(frm.fields_dict["job_card_dashboard"].wrapper);
|
||||
wrapper.empty();
|
||||
|
||||
if (frm.doc.docstatus !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { doc } = frm;
|
||||
const { time_logs, status } = doc;
|
||||
|
||||
|
||||
@@ -905,9 +905,6 @@ class JobCard(Document):
|
||||
)
|
||||
|
||||
def validate_job_card(self):
|
||||
if self.track_semi_finished_goods:
|
||||
return
|
||||
|
||||
if self.work_order and frappe.get_cached_value("Work Order", self.work_order, "status") == "Stopped":
|
||||
frappe.throw(
|
||||
_("Transaction not allowed against stopped Work Order {0}").format(
|
||||
@@ -915,10 +912,23 @@ class JobCard(Document):
|
||||
)
|
||||
)
|
||||
|
||||
self.validate_not_on_hold()
|
||||
self.validate_time_logs_present()
|
||||
self.validate_completed_qty_matches_for_quantity()
|
||||
|
||||
def validate_not_on_hold(self):
|
||||
if self.is_paused:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot submit Job Card {0} while it is On Hold. Please resume and complete the job before submission."
|
||||
).format(get_link_to_form("Job Card", self.name)),
|
||||
title=_("Job Card On Hold"),
|
||||
)
|
||||
|
||||
def validate_time_logs_present(self):
|
||||
if self.track_semi_finished_goods and self.is_subcontracted:
|
||||
return
|
||||
|
||||
if not self.time_logs:
|
||||
frappe.throw(
|
||||
_("Time logs are required for {0} {1}").format(
|
||||
@@ -933,6 +943,9 @@ class JobCard(Document):
|
||||
)
|
||||
|
||||
def validate_completed_qty_matches_for_quantity(self):
|
||||
if self.track_semi_finished_goods and self.is_subcontracted:
|
||||
return
|
||||
|
||||
precision = self.precision("total_completed_qty")
|
||||
total_completed_qty = flt(
|
||||
flt(self.total_completed_qty, precision)
|
||||
@@ -1328,6 +1341,8 @@ class JobCard(Document):
|
||||
def pause_job(self, **kwargs):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
|
||||
self.validate_docstatus()
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
@@ -1338,6 +1353,8 @@ class JobCard(Document):
|
||||
def resume_job(self, **kwargs):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
|
||||
self.validate_docstatus()
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
@@ -1515,6 +1532,8 @@ class JobCard(Document):
|
||||
def start_timer(self, **kwargs):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
|
||||
self.validate_docstatus()
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
@@ -1528,6 +1547,8 @@ class JobCard(Document):
|
||||
def complete_job_card(self, **kwargs):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
|
||||
self.validate_docstatus()
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
@@ -1541,6 +1562,13 @@ class JobCard(Document):
|
||||
if kwargs.auto_submit:
|
||||
self.auto_submit_job_card(kwargs.auto_submit)
|
||||
|
||||
def validate_docstatus(self):
|
||||
if self.docstatus == 2:
|
||||
frappe.throw(_("Cancelled Job Card cannot be processed."))
|
||||
|
||||
if self.docstatus == 1:
|
||||
frappe.throw(_("Submitted Job Card cannot be processed."))
|
||||
|
||||
def validate_complete_job_card_qty(self, kwargs):
|
||||
if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) < 0:
|
||||
frappe.throw(_("Pending quantity cannot be negative."))
|
||||
|
||||
@@ -197,6 +197,28 @@ class TestJobCard(ERPNextTestSuite):
|
||||
)
|
||||
self.assertEqual(completed_qty, job_card.for_quantity)
|
||||
|
||||
def test_job_card_cannot_be_submitted_while_on_hold(self):
|
||||
# Regression for #55756: a paused (On Hold) job card must not be submittable, otherwise
|
||||
# the document gets locked in the On Hold state with Resume/Complete no longer available.
|
||||
job_card = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": self.work_order.name},
|
||||
fields=["name", "for_quantity"],
|
||||
)[0]
|
||||
|
||||
doc = frappe.get_doc("Job Card", job_card.name)
|
||||
doc.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": "2024-01-01 08:00:00",
|
||||
"to_time": "2024-01-01 09:00:00",
|
||||
"time_in_mins": 60,
|
||||
"completed_qty": job_card.for_quantity,
|
||||
},
|
||||
)
|
||||
doc.is_paused = 1
|
||||
self.assertRaises(frappe.ValidationError, doc.submit)
|
||||
|
||||
def test_job_card_overlap(self):
|
||||
wo2 = make_wo_order_test_record(item="_Test FG Item 2", qty=2)
|
||||
|
||||
|
||||
@@ -433,6 +433,98 @@ class StockReservationService:
|
||||
if sre_list:
|
||||
unreserve_stock_for_work_order(self.doc, sre_list)
|
||||
|
||||
def release_reserved_qty_for_subcontract_transfer(self):
|
||||
"""Free this Work Order's own reservation for items sent to a subcontractor.
|
||||
|
||||
A ``Send to Subcontractor`` Stock Entry raised against a Work Order consumes stock that
|
||||
the same Work Order reserved (e.g. the semi-finished item of a subcontracted operation).
|
||||
The sent qty is recorded as ``transferred_qty`` on the matching Stock Reservation Entries
|
||||
so the negative-stock guard stops treating it as reserved for "other transactions". The
|
||||
figure is recomputed from every submitted ``Send to Subcontractor`` entry for the Work
|
||||
Order, so it self-corrects on cancellation / reposting.
|
||||
|
||||
Note: only qty-based reservations are handled here; serial/batch reservations are left to
|
||||
the existing material-transfer machinery.
|
||||
"""
|
||||
sent = self._subcontract_transferred_qty_by_item()
|
||||
|
||||
entries = frappe.get_all(
|
||||
"Stock Reservation Entry",
|
||||
filters={"voucher_no": self.doc.name, "voucher_type": "Work Order", "docstatus": 1},
|
||||
fields=["name", "item_code", "warehouse", "reservation_based_on"],
|
||||
order_by="creation",
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
if entry.reservation_based_on == "Serial and Batch":
|
||||
continue
|
||||
|
||||
key = (entry.item_code, entry.warehouse)
|
||||
sre = frappe.get_doc("Stock Reservation Entry", entry.name)
|
||||
|
||||
# Cap at what is still reservable (qty not already delivered/consumed). Always set the
|
||||
# value -- including back to 0 when nothing (or less) is now sent -- so cancelling a
|
||||
# transfer restores the reservation.
|
||||
available = flt(sre.reserved_qty) - flt(sre.consumed_qty) - flt(sre.delivered_qty)
|
||||
qty_to_set = max(min(flt(sent.get(key, 0.0)), available), 0.0)
|
||||
if key in sent:
|
||||
sent[key] = flt(sent[key]) - qty_to_set
|
||||
|
||||
if flt(sre.transferred_qty) == qty_to_set:
|
||||
continue
|
||||
|
||||
sre.db_set("transferred_qty", qty_to_set, update_modified=False)
|
||||
sre.update_status()
|
||||
sre.update_reserved_stock_in_bin()
|
||||
|
||||
def _subcontract_transferred_qty_by_item(self):
|
||||
"""Qty sent to subcontractors for this Work Order, keyed by (item_code, source warehouse).
|
||||
|
||||
The transfer Stock Entries are linked to the Work Order through its subcontracted Job Cards
|
||||
(Job Card -> Subcontracting Order / Purchase Order -> Send to Subcontractor entry), since the
|
||||
entry itself does not retain ``work_order``. Only submitted (docstatus 1) entries contribute,
|
||||
so a cancelled transfer drops out and the reservation is restored on the next recompute.
|
||||
"""
|
||||
job_cards = frappe.get_all(
|
||||
"Job Card", filters={"work_order": self.doc.name, "is_subcontracted": 1}, pluck="name"
|
||||
)
|
||||
if not job_cards:
|
||||
return {}
|
||||
|
||||
sco_names = frappe.get_all(
|
||||
"Subcontracting Order Item", filters={"job_card": ["in", job_cards]}, pluck="parent"
|
||||
)
|
||||
po_names = frappe.get_all(
|
||||
"Purchase Order Item", filters={"job_card": ["in", job_cards]}, pluck="parent"
|
||||
)
|
||||
if not sco_names and not po_names:
|
||||
return {}
|
||||
|
||||
ste = frappe.qb.DocType("Stock Entry")
|
||||
ste_child = frappe.qb.DocType("Stock Entry Detail")
|
||||
|
||||
link = None
|
||||
if sco_names:
|
||||
link = ste.subcontracting_order.isin(list(set(sco_names)))
|
||||
if po_names:
|
||||
po_link = ste.purchase_order.isin(list(set(po_names)))
|
||||
link = po_link if link is None else (link | po_link)
|
||||
|
||||
rows = (
|
||||
frappe.qb.from_(ste)
|
||||
.inner_join(ste_child)
|
||||
.on(ste_child.parent == ste.name)
|
||||
.select(ste_child.item_code, ste_child.s_warehouse, fn.Sum(ste_child.transfer_qty).as_("qty"))
|
||||
.where(
|
||||
(ste.docstatus == 1)
|
||||
& (ste.purpose == "Send to Subcontractor")
|
||||
& (ste_child.s_warehouse.isnotnull())
|
||||
& link
|
||||
)
|
||||
.groupby(ste_child.item_code, ste_child.s_warehouse)
|
||||
).run(as_dict=1)
|
||||
return {(d.item_code, d.s_warehouse): flt(d.qty) for d in rows}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_reservation_entries(
|
||||
|
||||
@@ -3417,6 +3417,176 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, transfer_entry.submit)
|
||||
|
||||
def test_send_to_subcontractor_can_consume_work_order_reserved_stock(self):
|
||||
from erpnext.buying.doctype.purchase_order.mapper import make_subcontracting_order
|
||||
from erpnext.controllers.subcontracting_controller import make_rm_stock_entry
|
||||
from erpnext.manufacturing.doctype.job_card.mapper import make_subcontracting_po
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
company = "_Test Company"
|
||||
warehouse = "Stores - _TC"
|
||||
supplier_warehouse = create_warehouse("Test S2S Supplier WH", company=company)
|
||||
|
||||
fabric = make_item("Test S2S Fabric", {"is_stock_item": 1}).name
|
||||
stitched = make_item("Test S2S Stitched Shirt", {"is_stock_item": 1}).name
|
||||
tshirt = make_item("Test S2S T-Shirt", {"is_stock_item": 1, "is_sub_contracted_item": 1}).name
|
||||
service_item = make_item("Test S2S Ironing Service", {"is_stock_item": 0}).name
|
||||
|
||||
# Semi-FG BOM: Stitched Shirt from Fabric.
|
||||
sfg_bom = frappe.new_doc("BOM")
|
||||
sfg_bom.company = company
|
||||
sfg_bom.item = stitched
|
||||
sfg_bom.quantity = 1
|
||||
sfg_bom.append("items", {"item_code": fabric, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
# Subcontracting BOM: how to make the final T-Shirt at the supplier (consuming Stitched Shirt).
|
||||
tshirt_from_stitched = frappe.new_doc("BOM")
|
||||
tshirt_from_stitched.company = company
|
||||
tshirt_from_stitched.item = tshirt
|
||||
tshirt_from_stitched.quantity = 1
|
||||
tshirt_from_stitched.append("items", {"item_code": stitched, "qty": 1})
|
||||
tshirt_from_stitched.insert()
|
||||
tshirt_from_stitched.submit()
|
||||
|
||||
if not frappe.db.exists("Subcontracting BOM", {"finished_good": tshirt}):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Subcontracting BOM",
|
||||
"finished_good": tshirt,
|
||||
"finished_good_qty": 1,
|
||||
"service_item": service_item,
|
||||
"service_item_qty": 1,
|
||||
"finished_good_bom": tshirt_from_stitched.name,
|
||||
"is_active": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
if not frappe.db.exists("Workstation", "Test S2S Workstation"):
|
||||
make_workstation(workstation="Test S2S Workstation", production_capacity=1)
|
||||
for op in ("Test S2S Stitching", "Test S2S Ironing"):
|
||||
if not frappe.db.exists("Operation", op):
|
||||
make_operation(operation=op, workstation="Test S2S Workstation")
|
||||
|
||||
# Final BOM for T-Shirt: internal Stitching op (produces Stitched Shirt) + subcontracted Ironing.
|
||||
fg_bom = frappe.new_doc("BOM")
|
||||
fg_bom.company = company
|
||||
fg_bom.item = tshirt
|
||||
fg_bom.quantity = 1
|
||||
fg_bom.with_operations = 1
|
||||
fg_bom.track_semi_finished_goods = 1
|
||||
fg_bom.append("items", {"item_code": fabric, "qty": 1})
|
||||
fg_bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "Test S2S Stitching",
|
||||
"workstation": "Test S2S Workstation",
|
||||
"finished_good": stitched,
|
||||
"finished_good_qty": 1,
|
||||
"bom_no": sfg_bom.name,
|
||||
"time_in_mins": 60,
|
||||
"sequence_id": 1,
|
||||
},
|
||||
)
|
||||
fg_bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "Test S2S Ironing",
|
||||
"workstation": "Test S2S Workstation",
|
||||
"finished_good": tshirt,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"is_subcontracted": 1,
|
||||
"bom_no": tshirt_from_stitched.name,
|
||||
"time_in_mins": 60,
|
||||
"sequence_id": 2,
|
||||
},
|
||||
)
|
||||
fg_bom.append("items", {"item_code": stitched, "qty": 1, "operation_row_id": 2})
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
make_stock_entry_test_record(item_code=fabric, target=warehouse, qty=10, basic_rate=100)
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
production_item=tshirt,
|
||||
qty=10,
|
||||
bom_no=fg_bom.name,
|
||||
reserve_stock=1,
|
||||
skip_transfer=1,
|
||||
source_warehouse=warehouse,
|
||||
wip_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
do_not_save=True,
|
||||
)
|
||||
wo.operations[0].time_in_mins = 60
|
||||
wo.operations[1].time_in_mins = 60
|
||||
wo.save()
|
||||
wo.submit()
|
||||
|
||||
# Complete the internal Stitching job card -> Stitched Shirt is produced into WIP and reserved.
|
||||
stitching_jc = frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value("Job Card", {"work_order": wo.name, "operation": "Test S2S Stitching"}),
|
||||
)
|
||||
stitching_jc.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": "2024-01-01 08:00:00",
|
||||
"to_time": "2024-01-01 09:00:00",
|
||||
"completed_qty": stitching_jc.for_quantity,
|
||||
},
|
||||
)
|
||||
stitching_jc.submit()
|
||||
|
||||
manufacturing_entry = frappe.get_doc(stitching_jc.make_stock_entry_for_semi_fg_item())
|
||||
manufacturing_entry.submit()
|
||||
|
||||
sre_name = frappe.db.get_value(
|
||||
"Stock Reservation Entry",
|
||||
{"voucher_no": wo.name, "item_code": stitched, "warehouse": warehouse, "docstatus": 1},
|
||||
)
|
||||
self.assertTrue(sre_name, "Work Order should have reserved the semi-finished good")
|
||||
|
||||
# Subcontract the Ironing operation: Job Card -> Subcontracting PO -> Subcontracting Order.
|
||||
ironing_jc = frappe.db.get_value("Job Card", {"work_order": wo.name, "operation": "Test S2S Ironing"})
|
||||
po = frappe.get_doc(make_subcontracting_po(ironing_jc))
|
||||
po.supplier = "_Test Supplier"
|
||||
po.supplier_warehouse = supplier_warehouse
|
||||
po.schedule_date = nowdate()
|
||||
for item in po.items:
|
||||
item.schedule_date = nowdate()
|
||||
po.insert()
|
||||
po.submit()
|
||||
|
||||
sco = make_subcontracting_order(po.name)
|
||||
sco.supplier_warehouse = supplier_warehouse
|
||||
for item in sco.supplied_items:
|
||||
item.reserve_warehouse = warehouse
|
||||
sco.insert()
|
||||
sco.submit()
|
||||
|
||||
# Transfer the reserved Stitched Shirt to the subcontractor. This must NOT raise
|
||||
# NegativeStockError ("reserved for other transactions").
|
||||
ste = frappe.new_doc("Stock Entry").update(make_rm_stock_entry(sco.name))
|
||||
ste.insert()
|
||||
ste.submit()
|
||||
|
||||
# The reservation is freed: transferred_qty == sent qty and the SRE is Closed.
|
||||
sre = frappe.get_doc("Stock Reservation Entry", sre_name)
|
||||
self.assertEqual(sre.transferred_qty, 10)
|
||||
self.assertEqual(sre.status, "Closed")
|
||||
|
||||
# Cancelling the transfer restores the reservation.
|
||||
ste.cancel()
|
||||
sre.reload()
|
||||
self.assertEqual(sre.transferred_qty, 0)
|
||||
self.assertEqual(sre.status, "Reserved")
|
||||
|
||||
def test_stock_reservation_for_batched_raw_material(self):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
|
||||
@@ -9,11 +9,23 @@ from erpnext.manufacturing.doctype.workstation.workstation import (
|
||||
NotInWorkingHoursError,
|
||||
WorkstationHolidayError,
|
||||
check_if_within_operating_hours,
|
||||
update_job_card,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestWorkstation(ERPNextTestSuite):
|
||||
def test_update_job_card_rejects_disallowed_method(self):
|
||||
# The whitelisted update_job_card endpoint must only run an allowlisted set of Job Card
|
||||
# methods. An arbitrary method name must be rejected (PermissionError) before the document
|
||||
# is even loaded, so this needs no Job Card to exist.
|
||||
self.assertRaises(
|
||||
frappe.PermissionError,
|
||||
update_job_card,
|
||||
"NON-EXISTENT-JOB-CARD",
|
||||
"delete",
|
||||
)
|
||||
|
||||
def test_validate_timings(self):
|
||||
check_if_within_operating_hours(
|
||||
"_Test Workstation 1", "Operation 1", "2013-02-02 11:00:00", "2013-02-02 19:00:00"
|
||||
|
||||
@@ -517,8 +517,33 @@ def get_color_map():
|
||||
}
|
||||
|
||||
|
||||
ALLOWED_JOB_CARD_METHODS = frozenset(
|
||||
{
|
||||
"start_timer",
|
||||
"pause_job",
|
||||
"resume_job",
|
||||
"complete_job_card",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_job_card(job_card: str, method: str, **kwargs):
|
||||
if method not in ALLOWED_JOB_CARD_METHODS:
|
||||
frappe.throw(
|
||||
_("Method {0} is not allowed to be run on a Job Card.").format(bold(method)),
|
||||
frappe.PermissionError,
|
||||
title=_("Not Allowed"),
|
||||
)
|
||||
|
||||
frappe.has_permission("Job Card", "read", throw=True)
|
||||
|
||||
doc = frappe.get_doc("Job Card", job_card)
|
||||
|
||||
# These methods mutate the Job Card, but frappe.get_doc does not enforce permissions —
|
||||
# require write access before running anything.
|
||||
frappe.has_permission("Job Card", "write", doc=doc, throw=True)
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
@@ -528,7 +553,6 @@ def update_job_card(job_card: str, method: str, **kwargs):
|
||||
if kwargs.qty and isinstance(kwargs.qty, str):
|
||||
kwargs.qty = flt(kwargs.qty)
|
||||
|
||||
doc = frappe.get_doc("Job Card", job_card)
|
||||
doc.run_method(method, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -485,3 +485,6 @@ erpnext.patches.v16_0.set_default_letter_head_for_doctype_and_report
|
||||
erpnext.patches.v16_0.clear_procedures_from_receivable_report
|
||||
erpnext.patches.v16_0.migrate_address_contact_custom_fields
|
||||
erpnext.patches.v16_0.rename_secondary_item_type_field
|
||||
erpnext.patches.v16_0.submit_existing_product_bundles #1
|
||||
erpnext.patches.v16_0.migrate_subscription_generate_invoice_at
|
||||
erpnext.patches.v16_0.rename_subscription_billing_period_fields
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import frappe
|
||||
|
||||
VALUE_MAP = {
|
||||
"End of the current subscription period": "Postpaid (bill at period end)",
|
||||
"Beginning of the current subscription period": "Prepaid (bill at period start)",
|
||||
"Days before the current subscription period": "Bill N days before period start",
|
||||
}
|
||||
|
||||
|
||||
def execute():
|
||||
subscription = frappe.qb.DocType("Subscription")
|
||||
for old_value, new_value in VALUE_MAP.items():
|
||||
(
|
||||
frappe.qb.update(subscription)
|
||||
.set(subscription.generate_invoice_at, new_value)
|
||||
.where(subscription.generate_invoice_at == old_value)
|
||||
).run()
|
||||
@@ -0,0 +1,26 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
"""Move billing-period data to the renamed fields.
|
||||
|
||||
`current_invoice_start/end` used to hold the open (next) billing period and now
|
||||
holds the actual current invoice period, while the open period moved to
|
||||
`next_billing_period_start/end`.
|
||||
"""
|
||||
columns = set(frappe.db.get_table_columns("Subscription"))
|
||||
subscription = frappe.qb.DocType("Subscription")
|
||||
|
||||
if {"next_billing_period_start", "next_billing_period_end"} <= columns:
|
||||
(
|
||||
frappe.qb.update(subscription)
|
||||
.set(subscription.next_billing_period_start, subscription.current_invoice_start)
|
||||
.set(subscription.next_billing_period_end, subscription.current_invoice_end)
|
||||
).run()
|
||||
|
||||
if {"current_invoice_from_date", "current_invoice_to_date"} <= columns:
|
||||
(
|
||||
frappe.qb.update(subscription)
|
||||
.set(subscription.current_invoice_start, subscription.current_invoice_from_date)
|
||||
.set(subscription.current_invoice_end, subscription.current_invoice_to_date)
|
||||
).run()
|
||||
159
erpnext/patches/v16_0/submit_existing_product_bundles.py
Normal file
159
erpnext/patches/v16_0/submit_existing_product_bundles.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Migrate Product Bundles to the submittable, versioned model (issue #29462).
|
||||
|
||||
Pre-existing bundles were editable drafts named after their parent item
|
||||
(``name == new_item_code``). This patch:
|
||||
|
||||
1. renames each legacy bundle to the versioned name ``PB-<parent item>-001``,
|
||||
marks it submitted (``docstatus = 1``) and seeds ``is_active`` from the legacy
|
||||
``disabled`` flag (active = not disabled), and
|
||||
2. stamps the resolved version onto existing transaction rows, so documents keep a
|
||||
reference to the exact bundle version they were packed from.
|
||||
|
||||
Both steps ship together (v16 is unreleased), so they are a single migration. No
|
||||
transaction stores a bundle's *name* (they snapshot components and reference the
|
||||
parent item code), so renaming is reference-safe. The whole patch is idempotent.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import NAME_PREFIX, build_bundle_name
|
||||
|
||||
# doctype -> column holding the bundle parent item code
|
||||
SELLING_ITEM_TABLES = {
|
||||
"Sales Order Item": "item_code",
|
||||
"Delivery Note Item": "item_code",
|
||||
"Sales Invoice Item": "item_code",
|
||||
"POS Invoice Item": "item_code",
|
||||
"Quotation Item": "item_code",
|
||||
"Packed Item": "parent_item",
|
||||
}
|
||||
|
||||
BUYING_ITEM_TABLES = ["Purchase Order Item", "Purchase Invoice Item", "Purchase Receipt Item"]
|
||||
|
||||
|
||||
def execute():
|
||||
submit_existing_bundles()
|
||||
stamp_versions_on_transactions()
|
||||
|
||||
|
||||
def submit_existing_bundles():
|
||||
legacy_bundles = frappe.get_all(
|
||||
"Product Bundle",
|
||||
filters={"docstatus": 0},
|
||||
fields=["name", "new_item_code", "disabled"],
|
||||
order_by="creation asc",
|
||||
)
|
||||
|
||||
for bundle in legacy_bundles:
|
||||
# Submitted bundles are already migrated and excluded by the docstatus filter.
|
||||
# A draft that still carries its legacy name needs renaming; a draft already
|
||||
# named PB-* is the leftover of an interrupted run and only needs submitting.
|
||||
target_name = bundle.name
|
||||
|
||||
if not bundle.name.startswith(f"{NAME_PREFIX}-"):
|
||||
new_name = build_bundle_name(bundle.new_item_code, _next_index(bundle.new_item_code))
|
||||
if not frappe.db.exists("Product Bundle", new_name):
|
||||
frappe.rename_doc(
|
||||
"Product Bundle", bundle.name, new_name, force=True, merge=False, show_alert=False
|
||||
)
|
||||
target_name = new_name
|
||||
|
||||
frappe.db.set_value(
|
||||
"Product Bundle",
|
||||
target_name,
|
||||
{"docstatus": 1, "is_active": 0 if bundle.disabled else 1},
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
_enforce_single_active_version()
|
||||
|
||||
|
||||
def stamp_versions_on_transactions():
|
||||
"""Backfill the ``product_bundle`` version link onto existing transaction rows.
|
||||
|
||||
- Selling / packed rows: a row whose item is a bundle parent is stamped with that
|
||||
bundle's version (the field was newly added, so only blank rows are touched) and
|
||||
flagged via ``is_product_bundle`` so the version field stays visible.
|
||||
- Buying rows: the ``product_bundle`` field previously stored the parent *item code*;
|
||||
convert those legacy values to the bundle version name. Idempotent: once converted,
|
||||
the value is a bundle name and no longer matches a ``new_item_code``.
|
||||
"""
|
||||
# parent item code -> migrated bundle version name (active version preferred)
|
||||
version_by_item = {}
|
||||
for bundle in frappe.get_all(
|
||||
"Product Bundle",
|
||||
filters={"docstatus": 1},
|
||||
fields=["name", "new_item_code"],
|
||||
order_by="is_active desc, creation asc",
|
||||
):
|
||||
version_by_item.setdefault(bundle.new_item_code, bundle.name)
|
||||
|
||||
if not version_by_item:
|
||||
return
|
||||
|
||||
for doctype, item_field in SELLING_ITEM_TABLES.items():
|
||||
if not frappe.db.has_column(doctype, "product_bundle"):
|
||||
continue
|
||||
table = frappe.qb.DocType(doctype)
|
||||
item_column = getattr(table, item_field)
|
||||
flag_bundle_rows = frappe.db.has_column(doctype, "is_product_bundle")
|
||||
for item_code, version in version_by_item.items():
|
||||
(
|
||||
frappe.qb.update(table)
|
||||
.set(table.product_bundle, version)
|
||||
.where(
|
||||
(item_column == item_code)
|
||||
& ((table.product_bundle.isnull()) | (table.product_bundle == ""))
|
||||
)
|
||||
).run()
|
||||
if flag_bundle_rows:
|
||||
# keep the version field visible on bundle rows even if its value is cleared
|
||||
(
|
||||
frappe.qb.update(table).set(table.is_product_bundle, 1).where(item_column == item_code)
|
||||
).run()
|
||||
|
||||
for doctype in BUYING_ITEM_TABLES:
|
||||
if not frappe.db.has_column(doctype, "product_bundle"):
|
||||
continue
|
||||
table = frappe.qb.DocType(doctype)
|
||||
for item_code, version in version_by_item.items():
|
||||
# only legacy rows still holding the item code are matched
|
||||
(
|
||||
frappe.qb.update(table)
|
||||
.set(table.product_bundle, version)
|
||||
.where(table.product_bundle == item_code)
|
||||
).run()
|
||||
|
||||
|
||||
def _next_index(item_code: str) -> int:
|
||||
"""Next free version index for a parent item among already-migrated bundles."""
|
||||
existing = frappe.get_all(
|
||||
"Product Bundle",
|
||||
filters={"new_item_code": item_code, "name": ("like", f"{NAME_PREFIX}-%")},
|
||||
pluck="name",
|
||||
)
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_next_version_index
|
||||
|
||||
return get_next_version_index(existing)
|
||||
|
||||
|
||||
def _enforce_single_active_version():
|
||||
"""Guarantee at most one active version per parent item.
|
||||
|
||||
Under the old unique-name-per-item invariant duplicates can't exist, so this is a
|
||||
safety net; if several are somehow active, keep the most recently created one.
|
||||
"""
|
||||
active = frappe.get_all(
|
||||
"Product Bundle",
|
||||
filters={"is_active": 1, "docstatus": 1},
|
||||
fields=["name", "new_item_code"],
|
||||
order_by="new_item_code asc, creation desc",
|
||||
)
|
||||
|
||||
seen = set()
|
||||
for bundle in active:
|
||||
if bundle.new_item_code in seen:
|
||||
# a newer version for this item was already kept; deactivate the rest
|
||||
frappe.db.set_value("Product Bundle", bundle.name, "is_active", 0, update_modified=False)
|
||||
else:
|
||||
seen.add(bundle.new_item_code)
|
||||
@@ -231,6 +231,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
args: {
|
||||
company: me.frm.doc.company,
|
||||
account_list: frappe.flags.round_off_applicable_accounts,
|
||||
// pass the doc so regional overrides can inspect it
|
||||
doc: me.frm.doc,
|
||||
},
|
||||
callback(r) {
|
||||
if (r.message) {
|
||||
|
||||
@@ -200,6 +200,20 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
});
|
||||
}
|
||||
|
||||
if (this.frm.fields_dict["items"].grid.get_field("product_bundle")) {
|
||||
// restrict the version picker to submitted Product Bundles of the row's item
|
||||
this.frm.set_query("product_bundle", "items", function (doc, cdt, cdn) {
|
||||
let row = locals[cdt][cdn];
|
||||
|
||||
return {
|
||||
filters: {
|
||||
new_item_code: row.item_code,
|
||||
docstatus: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.frm.docstatus < 2 &&
|
||||
this.frm.fields_dict["payment_terms_template"] &&
|
||||
@@ -2989,11 +3003,9 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
let method = "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry";
|
||||
if (this.frm.doc.__onload && this.frm.doc.__onload.make_payment_via_journal_entry) {
|
||||
if (["Sales Invoice", "Purchase Invoice"].includes(this.frm.doc.doctype)) {
|
||||
method =
|
||||
"erpnext.accounts.doctype.journal_entry.journal_entry.get_payment_entry_against_invoice";
|
||||
method = "erpnext.accounts.doctype.journal_entry.mapper.get_payment_entry_against_invoice";
|
||||
} else {
|
||||
method =
|
||||
"erpnext.accounts.doctype.journal_entry.journal_entry.get_payment_entry_against_order";
|
||||
method = "erpnext.accounts.doctype.journal_entry.mapper.get_payment_entry_against_order";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -734,6 +734,7 @@ erpnext.utils.update_child_items = function (opts) {
|
||||
read_only: 0,
|
||||
disabled: 0,
|
||||
label: __("Item Code"),
|
||||
formatter: (value) => value,
|
||||
get_query: function () {
|
||||
let filters;
|
||||
if (frm.doc.doctype == "Sales Order") {
|
||||
|
||||
@@ -9,5 +9,54 @@ frappe.ui.form.on("Product Bundle", {
|
||||
query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code",
|
||||
};
|
||||
});
|
||||
|
||||
// A submitted bundle is immutable. To change it, create a new version
|
||||
// (a fresh draft copied from this one) and submit that instead.
|
||||
if (frm.doc.docstatus === 1) {
|
||||
frm.add_custom_button(__("Create New Version"), () => {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.selling.doctype.product_bundle.product_bundle.make_new_version",
|
||||
frm: frm,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
show_supersede_hint(frm);
|
||||
},
|
||||
|
||||
new_item_code: function (frm) {
|
||||
show_supersede_hint(frm);
|
||||
},
|
||||
});
|
||||
|
||||
function show_supersede_hint(frm) {
|
||||
// Warn (non-blocking) when the chosen Parent Item already has an active bundle:
|
||||
// submitting this draft will create a new version and deactivate that one.
|
||||
frm.set_intro("");
|
||||
if (frm.doc.docstatus !== 0 || !frm.doc.new_item_code) {
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.db
|
||||
.get_value(
|
||||
"Product Bundle",
|
||||
{
|
||||
new_item_code: frm.doc.new_item_code,
|
||||
is_active: 1,
|
||||
docstatus: 1,
|
||||
},
|
||||
"name"
|
||||
)
|
||||
.then((r) => {
|
||||
const active = r.message && r.message.name;
|
||||
if (active && active !== frm.doc.name) {
|
||||
frm.set_intro(
|
||||
__(
|
||||
"Item {0} already has an active Product Bundle ({1}). Submitting this will create a new version and deactivate {1}.",
|
||||
[frm.doc.new_item_code, active]
|
||||
),
|
||||
"orange"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"new_item_code",
|
||||
"description",
|
||||
"column_break_eonk",
|
||||
"is_active",
|
||||
"disabled",
|
||||
"amended_from",
|
||||
"item_section",
|
||||
"items",
|
||||
"section_break_4",
|
||||
@@ -63,11 +65,33 @@
|
||||
"fieldtype": "HTML",
|
||||
"options": "<h3>About Product Bundle</h3>\n\n<p>Aggregate group of <b>Items</b> into another <b>Item</b>. This is useful if you are bundling a certain <b>Items</b> into a package and you maintain stock of the packed <b>Items</b> and not the aggregate <b>Item</b>.</p>\n<p>The package <b>Item</b> will have <code>Is Stock Item</code> as <b>No</b> and <code>Is Sales Item</code> as <b>Yes</b>.</p>\n<h4>Example:</h4>\n<p>If you are selling Laptops and Backpacks separately and have a special price if the customer buys both, then the Laptop + Backpack will be a new Product Bundle Item.</p>"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "1",
|
||||
"description": "Only one version of a Product Bundle can be active at a time for a given Parent Item. Activating a version deactivates the previously active one.",
|
||||
"fieldname": "is_active",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Is Active",
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "disabled",
|
||||
"description": "Deprecated: use Cancel / Is Active instead. Retained for backward compatibility.",
|
||||
"fieldname": "disabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disabled"
|
||||
"label": "Disabled",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "amended_from",
|
||||
"fieldtype": "Link",
|
||||
"label": "Amended From",
|
||||
"no_copy": 1,
|
||||
"options": "Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_eonk",
|
||||
@@ -76,14 +100,17 @@
|
||||
],
|
||||
"icon": "fa fa-sitemap",
|
||||
"idx": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:19.599302",
|
||||
"modified": "2026-06-08 00:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Product Bundle",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"amend": 1,
|
||||
"cancel": 1,
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
@@ -92,6 +119,7 @@
|
||||
"report": 1,
|
||||
"role": "Stock Manager",
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
@@ -102,6 +130,8 @@
|
||||
"role": "Stock User"
|
||||
},
|
||||
{
|
||||
"amend": 1,
|
||||
"cancel": 1,
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
@@ -110,10 +140,11 @@
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
import re
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.utils import get_link_to_form
|
||||
from frappe.utils import cint, get_link_to_form
|
||||
|
||||
NAME_PREFIX = "PB"
|
||||
|
||||
|
||||
class ProductBundle(Document):
|
||||
@@ -20,14 +24,28 @@ class ProductBundle(Document):
|
||||
|
||||
from erpnext.selling.doctype.product_bundle_item.product_bundle_item import ProductBundleItem
|
||||
|
||||
amended_from: DF.Link | None
|
||||
description: DF.Data | None
|
||||
disabled: DF.Check
|
||||
is_active: DF.Check
|
||||
items: DF.Table[ProductBundleItem]
|
||||
new_item_code: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
def autoname(self):
|
||||
self.name = self.new_item_code
|
||||
"""BOM-style versioned name: ``PB-<parent item>-001``.
|
||||
|
||||
Amended copies are excluded while computing the current index so that an
|
||||
amendment naturally becomes the next version of the bundle.
|
||||
"""
|
||||
search_key = f"{NAME_PREFIX}-{self.new_item_code}-%"
|
||||
existing = frappe.get_all(
|
||||
"Product Bundle",
|
||||
filters={"name": ("like", search_key), "amended_from": ["is", "not set"]},
|
||||
pluck="name",
|
||||
)
|
||||
index = get_next_version_index(existing)
|
||||
self.name = build_bundle_name(self.new_item_code, index)
|
||||
|
||||
def validate(self):
|
||||
self.validate_main_item()
|
||||
@@ -37,6 +55,37 @@ class ProductBundle(Document):
|
||||
|
||||
validate_uom_is_integer(self, "uom", "qty")
|
||||
|
||||
def on_submit(self):
|
||||
self.make_active()
|
||||
|
||||
def on_cancel(self):
|
||||
self.db_set("is_active", 0)
|
||||
|
||||
def on_update_after_submit(self):
|
||||
# `is_active` is the only field editable after submit; keep a single active
|
||||
# version per parent item in sync when the user (re)activates a version.
|
||||
if self.is_active:
|
||||
self.make_active()
|
||||
|
||||
def make_active(self):
|
||||
"""Mark this version active and deactivate every other submitted version
|
||||
of the same parent item."""
|
||||
if not self.is_active:
|
||||
self.db_set("is_active", 1)
|
||||
|
||||
others = frappe.get_all(
|
||||
"Product Bundle",
|
||||
filters={
|
||||
"new_item_code": self.new_item_code,
|
||||
"is_active": 1,
|
||||
"docstatus": 1,
|
||||
"name": ("!=", self.name),
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
for name in others:
|
||||
frappe.db.set_value("Product Bundle", name, "is_active", 0)
|
||||
|
||||
def on_trash(self):
|
||||
linked_doctypes = [
|
||||
"Delivery Note",
|
||||
@@ -82,7 +131,7 @@ class ProductBundle(Document):
|
||||
|
||||
def validate_child_items(self):
|
||||
for item in self.items:
|
||||
if frappe.db.exists("Product Bundle", {"name": item.item_code, "disabled": 0}):
|
||||
if get_active_product_bundle(item.item_code):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Child Item should not be a Product Bundle. Please remove Item {1} and Save"
|
||||
@@ -99,11 +148,81 @@ class ProductBundle(Document):
|
||||
)
|
||||
|
||||
|
||||
def build_bundle_name(item_code: str, index: int) -> str:
|
||||
"""Build a ``PB-<item>-NNN`` name, truncating the item part to stay within 140 chars."""
|
||||
suffix = "%.3i" % index
|
||||
name = f"{NAME_PREFIX}-{item_code}-{suffix}"
|
||||
if len(name) <= 140:
|
||||
return name
|
||||
|
||||
truncated_length = 140 - (len(NAME_PREFIX) + len(suffix) + 2)
|
||||
truncated_item = item_code[:truncated_length].rsplit(" ", 1)[0]
|
||||
return f"{NAME_PREFIX}-{truncated_item}-{suffix}"
|
||||
|
||||
|
||||
def get_next_version_index(existing_names: list[str]) -> int:
|
||||
"""Highest trailing version index across ``existing_names`` plus one (1 if none)."""
|
||||
pattern = "|".join(re.escape(delim) for delim in ("/", "-"))
|
||||
parts = [re.split(pattern, name) for name in existing_names]
|
||||
valid = [p for p in parts if len(p) > 1 and p[-1]]
|
||||
if not valid:
|
||||
return 1
|
||||
return max(cint(p[-1]) for p in valid) + 1
|
||||
|
||||
|
||||
def get_active_product_bundle(item_code: str) -> str | None:
|
||||
"""Return the name of the active, submitted Product Bundle for ``item_code``, else None.
|
||||
|
||||
This is the single resolution entry point for every consumer of bundles; it
|
||||
replaces the legacy ``exists("Product Bundle", {name/new_item_code, disabled: 0})``
|
||||
lookups that assumed one mutable bundle per item.
|
||||
"""
|
||||
if not item_code:
|
||||
return None
|
||||
return frappe.db.get_value(
|
||||
"Product Bundle",
|
||||
{"new_item_code": item_code, "is_active": 1, "docstatus": 1},
|
||||
"name",
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_new_version(source_name: str, target_doc: str | None = None):
|
||||
"""Create a fresh draft bundle copied from an existing (typically submitted) one.
|
||||
|
||||
The copy keeps the same parent item and component rows but gets a new version
|
||||
name on submit; it does not carry over docstatus or the active flag.
|
||||
"""
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
|
||||
def post_process(source, target):
|
||||
target.is_active = 1
|
||||
target.disabled = 0
|
||||
|
||||
return get_mapped_doc(
|
||||
"Product Bundle",
|
||||
source_name,
|
||||
{
|
||||
"Product Bundle": {
|
||||
"doctype": "Product Bundle",
|
||||
"field_map": {"new_item_code": "new_item_code"},
|
||||
"field_no_map": ["amended_from", "is_active", "disabled"],
|
||||
},
|
||||
"Product Bundle Item": {
|
||||
"doctype": "Product Bundle Item",
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
post_process,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_new_item_code(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
product_bundles = frappe.db.get_list("Product Bundle", {"disabled": 0}, pluck="name")
|
||||
|
||||
# Items that already have a bundle are intentionally *not* excluded: creating a
|
||||
# bundle for such an item produces a new version that supersedes the active one
|
||||
# on submit (same as the "Create New Version" action).
|
||||
if not searchfield or searchfield == "name":
|
||||
searchfield = frappe.get_meta("Item").get("search_fields")
|
||||
|
||||
@@ -122,7 +241,4 @@ def get_new_item_code(doctype: str, txt: str, searchfield: str, start: int, page
|
||||
if searchfield:
|
||||
query = query.where(Criterion.any([item[fieldname].like(f"%{txt}%") for fieldname in searchfield]))
|
||||
|
||||
if product_bundles:
|
||||
query = query.where(item.name.notin(product_bundles))
|
||||
|
||||
return query.run()
|
||||
|
||||
@@ -3,10 +3,22 @@
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import (
|
||||
get_active_product_bundle,
|
||||
make_new_version,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
def make_product_bundle(parent, items, qty=None):
|
||||
if frappe.db.exists("Product Bundle", parent):
|
||||
return frappe.get_doc("Product Bundle", parent)
|
||||
"""Create (and submit) an active Product Bundle for ``parent``.
|
||||
|
||||
Product Bundle is now submittable & versioned, so the active version is resolved
|
||||
by parent item code rather than by document name.
|
||||
"""
|
||||
if active := get_active_product_bundle(parent):
|
||||
return frappe.get_doc("Product Bundle", active)
|
||||
|
||||
product_bundle = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": parent})
|
||||
|
||||
@@ -14,5 +26,87 @@ def make_product_bundle(parent, items, qty=None):
|
||||
product_bundle.append("items", {"item_code": item, "qty": qty or 1})
|
||||
|
||||
product_bundle.insert()
|
||||
product_bundle.submit()
|
||||
|
||||
return product_bundle
|
||||
|
||||
|
||||
class TestProductBundle(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.parent = make_item("_Test PB Parent", {"is_stock_item": 0, "is_sales_item": 1}).name
|
||||
make_item("_Test PB Child A", {"is_stock_item": 1})
|
||||
make_item("_Test PB Child B", {"is_stock_item": 1})
|
||||
|
||||
def test_submit_makes_bundle_active_and_versioned(self):
|
||||
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||
self.assertEqual(bundle.docstatus, 1)
|
||||
self.assertEqual(bundle.is_active, 1)
|
||||
self.assertTrue(bundle.name.startswith("PB-"))
|
||||
self.assertEqual(get_active_product_bundle(self.parent), bundle.name)
|
||||
|
||||
def test_new_version_deactivates_previous(self):
|
||||
v1 = make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||
|
||||
v2 = make_new_version(v1.name)
|
||||
v2.items[0].qty = 5
|
||||
v2.insert()
|
||||
v2.submit()
|
||||
|
||||
self.assertNotEqual(v1.name, v2.name)
|
||||
self.assertEqual(get_active_product_bundle(self.parent), v2.name)
|
||||
self.assertEqual(frappe.db.get_value("Product Bundle", v1.name, "is_active"), 0)
|
||||
|
||||
def test_reactivating_old_version_deactivates_current(self):
|
||||
v1 = make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||
|
||||
v2 = make_new_version(v1.name)
|
||||
v2.items[0].qty = 5
|
||||
v2.insert()
|
||||
v2.submit()
|
||||
self.assertEqual(get_active_product_bundle(self.parent), v2.name)
|
||||
|
||||
# switch back to v1 by toggling is_active on the submitted doc (allow_on_submit)
|
||||
v1.reload()
|
||||
v1.is_active = 1
|
||||
v1.save()
|
||||
|
||||
self.assertEqual(get_active_product_bundle(self.parent), v1.name)
|
||||
self.assertEqual(frappe.db.get_value("Product Bundle", v2.name, "is_active"), 0)
|
||||
|
||||
def test_new_bundle_from_scratch_supersedes_existing(self):
|
||||
# An item that already has a bundle must remain selectable so a new version
|
||||
# can be created straight from the New Product Bundle form.
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_new_item_code
|
||||
|
||||
v1 = make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||
|
||||
picker = [row[0] for row in get_new_item_code("Item", self.parent, "name", 0, 20, {})]
|
||||
self.assertIn(self.parent, picker)
|
||||
|
||||
v2 = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": self.parent})
|
||||
v2.append("items", {"item_code": "_Test PB Child B", "qty": 1})
|
||||
v2.insert()
|
||||
v2.submit()
|
||||
|
||||
self.assertNotEqual(v1.name, v2.name)
|
||||
self.assertEqual(get_active_product_bundle(self.parent), v2.name)
|
||||
self.assertEqual(frappe.db.get_value("Product Bundle", v1.name, "is_active"), 0)
|
||||
|
||||
def test_cancel_clears_active(self):
|
||||
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||
bundle.cancel()
|
||||
self.assertEqual(frappe.db.get_value("Product Bundle", bundle.name, "is_active"), 0)
|
||||
self.assertIsNone(get_active_product_bundle(self.parent))
|
||||
|
||||
def test_submitted_bundle_is_immutable(self):
|
||||
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||
bundle.items[0].qty = 99
|
||||
self.assertRaises(frappe.exceptions.UpdateAfterSubmitError, bundle.save)
|
||||
|
||||
def test_child_cannot_be_active_bundle(self):
|
||||
make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||
outer = make_item("_Test PB Outer", {"is_stock_item": 0, "is_sales_item": 1}).name
|
||||
|
||||
doc = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": outer})
|
||||
doc.append("items", {"item_code": self.parent, "qty": 1})
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"item_name",
|
||||
"customer_item_code",
|
||||
"col_break1",
|
||||
@@ -100,6 +102,22 @@
|
||||
"search_index": 1,
|
||||
"width": "150px"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle"
|
||||
},
|
||||
{
|
||||
"fieldname": "customer_item_code",
|
||||
"fieldtype": "Data",
|
||||
@@ -711,7 +729,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-30 12:56:08.320190",
|
||||
"modified": "2026-06-08 19:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Quotation Item",
|
||||
|
||||
@@ -17,6 +17,7 @@ from erpnext.manufacturing.doctype.production_plan.production_plan import (
|
||||
get_items_for_material_requests,
|
||||
get_sales_orders,
|
||||
)
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||
from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle, make_packing_list
|
||||
@@ -71,8 +72,15 @@ def make_material_request(source_name: str, target_doc: str | Document | None =
|
||||
"Sales Order Item", {"name": so_item.parent_detail_docname}, ["delivered_qty"]
|
||||
)
|
||||
|
||||
bundle_item_qty = frappe.db.get_value(
|
||||
"Product Bundle Item", {"parent": so_item.parent_item, "item_code": so_item.item_code}, ["qty"]
|
||||
bundle_name = get_active_product_bundle(so_item.parent_item)
|
||||
bundle_item_qty = (
|
||||
frappe.db.get_value(
|
||||
"Product Bundle Item",
|
||||
{"parent": bundle_name, "item_code": so_item.item_code},
|
||||
["qty"],
|
||||
)
|
||||
if bundle_name
|
||||
else None
|
||||
)
|
||||
|
||||
return flt(
|
||||
@@ -133,9 +141,7 @@ def make_material_request(source_name: str, target_doc: str | Document | None =
|
||||
"delivery_date": "schedule_date",
|
||||
"bom_no": "bom_no",
|
||||
},
|
||||
"condition": lambda item: not frappe.db.exists(
|
||||
"Product Bundle", {"name": item.item_code, "disabled": 0}
|
||||
)
|
||||
"condition": lambda item: not is_product_bundle(item.item_code)
|
||||
and get_remaining_qty(item) > 0,
|
||||
"postprocess": update_item,
|
||||
},
|
||||
@@ -769,7 +775,7 @@ def make_purchase_order(
|
||||
["parent", "sales_order"],
|
||||
["uom", "uom"],
|
||||
["conversion_factor", "conversion_factor"],
|
||||
["parent_item", "product_bundle"],
|
||||
["product_bundle", "product_bundle"],
|
||||
["rate", "rate"],
|
||||
],
|
||||
"field_no_map": [
|
||||
@@ -798,17 +804,20 @@ def make_purchase_order(
|
||||
|
||||
|
||||
def set_delivery_date(items: list, sales_order: str) -> None:
|
||||
# `product_bundle` now holds the Product Bundle *version*, so match the Purchase
|
||||
# Order rows to their originating Sales Order rows by that version.
|
||||
delivery_dates = frappe.get_all(
|
||||
"Sales Order Item", filters={"parent": sales_order}, fields=["delivery_date", "item_code"]
|
||||
"Sales Order Item", filters={"parent": sales_order}, fields=["delivery_date", "product_bundle"]
|
||||
)
|
||||
|
||||
delivery_by_item = frappe._dict()
|
||||
delivery_by_bundle = frappe._dict()
|
||||
for date in delivery_dates:
|
||||
delivery_by_item[date.item_code] = date.delivery_date
|
||||
if date.product_bundle:
|
||||
delivery_by_bundle[date.product_bundle] = date.delivery_date
|
||||
|
||||
for item in items:
|
||||
if item.product_bundle:
|
||||
item.schedule_date = delivery_by_item[item.product_bundle]
|
||||
item.schedule_date = delivery_by_bundle.get(item.product_bundle)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -326,9 +326,15 @@ class SalesOrder(SellingController):
|
||||
d.projected_qty = bin_data.get((d.item_code, d.warehouse), 0.0)
|
||||
|
||||
def product_bundle_has_stock_item(self, product_bundle):
|
||||
"""Returns true if product bundle has stock item"""
|
||||
"""Returns true if the active bundle for `product_bundle` (a parent item code) has a stock item"""
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
bundle_name = get_active_product_bundle(product_bundle)
|
||||
if not bundle_name:
|
||||
return False
|
||||
|
||||
bundle_items = frappe.get_all(
|
||||
"Product Bundle Item", filters={"parent": product_bundle}, pluck="item_code"
|
||||
"Product Bundle Item", filters={"parent": bundle_name}, pluck="item_code"
|
||||
)
|
||||
|
||||
if not bundle_items:
|
||||
@@ -807,7 +813,9 @@ def get_work_order_items(sales_order: str, for_raw_material_request: int = 0):
|
||||
product_bundle_parents = [
|
||||
pb.new_item_code
|
||||
for pb in frappe.get_all(
|
||||
"Product Bundle", {"new_item_code": ["in", item_codes], "disabled": 0}, ["new_item_code"]
|
||||
"Product Bundle",
|
||||
{"new_item_code": ["in", item_codes], "is_active": 1, "docstatus": 1},
|
||||
["new_item_code"],
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
product_bundle = make_product_bundle(
|
||||
"_Test Product Bundle Item", ["_Test Item", "_Test Item Home Desktop 100"]
|
||||
)
|
||||
so = make_sales_order(item_code=product_bundle.name, qty=2)
|
||||
so = make_sales_order(item_code=product_bundle.new_item_code, qty=2)
|
||||
mr = make_material_request(so.name)
|
||||
mr.items[0].qty = 4
|
||||
mr.items[1].qty = 2
|
||||
@@ -724,6 +724,41 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
workflow.is_active = 0
|
||||
workflow.save()
|
||||
|
||||
def test_update_child_qty_rate_follows_allow_edit(self):
|
||||
from frappe.model.workflow import apply_workflow
|
||||
|
||||
workflow = make_sales_order_edit_perm_workflow()
|
||||
so = make_sales_order(item_code="_Test Item", qty=1, rate=150, do_not_submit=1)
|
||||
apply_workflow(so, "Approve")
|
||||
|
||||
trans_item = json.dumps(
|
||||
[{"item_code": "_Test Item", "rate": 150, "qty": 2, "docname": so.items[0].name}]
|
||||
)
|
||||
|
||||
# Test Junior Approver performed the transition into Approved, but the state's
|
||||
# Only Allow Edit For is Test Approver — the mover must not be able to edit.
|
||||
mover = "test@example.com"
|
||||
mover_user = frappe.get_doc("User", mover)
|
||||
mover_user.add_roles("Sales User", "Test Junior Approver")
|
||||
with self.set_user(mover):
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, update_child_qty_rate, "Sales Order", trans_item, so.name
|
||||
)
|
||||
|
||||
# The configured allow_edit role can edit even without any transition rights.
|
||||
editor = "test2@example.com"
|
||||
editor_user = frappe.get_doc("User", editor)
|
||||
editor_user.add_roles("Sales User", "Test Approver")
|
||||
with self.set_user(editor):
|
||||
update_child_qty_rate("Sales Order", trans_item, so.name)
|
||||
so.reload()
|
||||
self.assertEqual(so.items[0].qty, 2)
|
||||
|
||||
mover_user.remove_roles("Sales User", "Test Junior Approver", "Test Approver")
|
||||
editor_user.remove_roles("Sales User", "Test Junior Approver", "Test Approver")
|
||||
workflow.is_active = 0
|
||||
workflow.save()
|
||||
|
||||
def test_material_request_for_product_bundle(self):
|
||||
# Create the Material Request from the sales order for the Packing Items
|
||||
# Check whether the material request has the correct packing item or not.
|
||||
@@ -1380,6 +1415,14 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
self.assertEqual(purchase_order.items[0].item_code, "_Test Bundle Item 1")
|
||||
self.assertEqual(purchase_order.items[1].item_code, "_Test Bundle Item 2")
|
||||
|
||||
# each Purchase Order row records the Product Bundle version it was packed from
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
version = get_active_product_bundle("_Test Product Bundle")
|
||||
self.assertTrue(version and version.startswith("PB-"))
|
||||
self.assertEqual(purchase_order.items[0].product_bundle, version)
|
||||
self.assertEqual(purchase_order.items[1].product_bundle, version)
|
||||
|
||||
def test_purchase_order_updates_packed_item_ordered_qty(self):
|
||||
"""
|
||||
Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order
|
||||
@@ -3041,3 +3084,43 @@ def make_sales_order_workflow():
|
||||
workflow.insert(ignore_permissions=True)
|
||||
|
||||
return workflow
|
||||
|
||||
|
||||
def make_sales_order_edit_perm_workflow():
|
||||
if frappe.db.exists("Workflow", "SO Edit Perm Workflow"):
|
||||
doc = frappe.get_doc("Workflow", "SO Edit Perm Workflow")
|
||||
doc.set("is_active", 1)
|
||||
doc.save()
|
||||
return doc
|
||||
|
||||
frappe.get_doc(doctype="Role", role_name="Test Junior Approver").insert(ignore_if_duplicate=True)
|
||||
frappe.get_doc(doctype="Role", role_name="Test Approver").insert(ignore_if_duplicate=True)
|
||||
frappe.cache().hdel("roles", frappe.session.user)
|
||||
|
||||
workflow = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Workflow",
|
||||
"workflow_name": "SO Edit Perm Workflow",
|
||||
"document_type": "Sales Order",
|
||||
"workflow_state_field": "workflow_state",
|
||||
"is_active": 1,
|
||||
"send_email_alert": 0,
|
||||
}
|
||||
)
|
||||
workflow.append("states", dict(state="Pending", allow_edit="All"))
|
||||
# Only Allow Edit For is Test Approver, while the transition into Approved is
|
||||
# performed by Test Junior Approver — the two roles are deliberately different.
|
||||
workflow.append("states", dict(state="Approved", allow_edit="Test Approver", doc_status=1))
|
||||
workflow.append(
|
||||
"transitions",
|
||||
dict(
|
||||
state="Pending",
|
||||
action="Approve",
|
||||
next_state="Approved",
|
||||
allowed="Test Junior Approver",
|
||||
allow_self_approval=1,
|
||||
),
|
||||
)
|
||||
workflow.insert(ignore_permissions=True)
|
||||
|
||||
return workflow
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"fg_item",
|
||||
"fg_item_qty",
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"customer_item_code",
|
||||
"ensure_delivery_based_on_produced_serial_no",
|
||||
"is_stock_item",
|
||||
@@ -136,6 +138,23 @@
|
||||
"reqd": 1,
|
||||
"width": "150px"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only_depends_on": "eval:doc.quotation_item"
|
||||
},
|
||||
{
|
||||
"fieldname": "customer_item_code",
|
||||
"fieldtype": "Data",
|
||||
@@ -1036,7 +1055,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-06 12:03:40.472277",
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order Item",
|
||||
|
||||
@@ -71,7 +71,8 @@ def get_item_warehouse_quantity_map():
|
||||
FROM tabBin AS bi, (SELECT pb.new_item_code as parent, b.item_code, b.qty, w.name
|
||||
FROM `tabProduct Bundle Item` b, `tabWarehouse` w,
|
||||
`tabProduct Bundle` pb
|
||||
where b.parent = pb.name) AS b
|
||||
where b.parent = pb.name
|
||||
and pb.is_active = 1 and pb.docstatus = 1) AS b
|
||||
WHERE bi.item_code = b.item_code
|
||||
AND bi.warehouse = b.name
|
||||
GROUP BY b.parent, b.item_code, bi.warehouse
|
||||
@@ -80,7 +81,8 @@ def get_item_warehouse_quantity_map():
|
||||
FROM (SELECT pb.new_item_code as parent, b.item_code, b.qty, w.name
|
||||
FROM `tabProduct Bundle Item` b, `tabWarehouse` w,
|
||||
`tabProduct Bundle` pb
|
||||
where b.parent = pb.name) AS b
|
||||
where b.parent = pb.name
|
||||
and pb.is_active = 1 and pb.docstatus = 1) AS b
|
||||
WHERE NOT EXISTS(SELECT *
|
||||
FROM `tabBin` AS bi
|
||||
WHERE bi.item_code = b.item_code
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Case, CustomFunction
|
||||
from frappe.query_builder.functions import Count, Max, Sum
|
||||
from frappe.utils import cint
|
||||
|
||||
|
||||
@@ -14,7 +16,7 @@ def execute(filters=None):
|
||||
days_since_last_order = filters.get("days_since_last_order")
|
||||
doctype = filters.get("doctype")
|
||||
|
||||
if doctype not in ("Sales Order", "Sales Invoice"):
|
||||
if doctype not in {"Sales Order", "Sales Invoice"}:
|
||||
frappe.throw(_("Invalid value {0} for 'Doctype'").format(doctype))
|
||||
|
||||
if cint(days_since_last_order) <= 0:
|
||||
@@ -24,50 +26,69 @@ def execute(filters=None):
|
||||
customers = get_sales_details(doctype)
|
||||
|
||||
data = []
|
||||
for cust in customers:
|
||||
if cint(cust[8]) >= cint(days_since_last_order):
|
||||
cust.insert(7, get_last_sales_amt(cust[0], doctype))
|
||||
data.append(cust)
|
||||
for row in customers:
|
||||
if cint(row[8]) >= cint(days_since_last_order):
|
||||
row.insert(7, get_last_sales_amt(row[0], doctype))
|
||||
data.append(row)
|
||||
return columns, data
|
||||
|
||||
|
||||
def get_sales_details(doctype):
|
||||
cond = """sum(so.base_net_total) as 'total_order_considered',
|
||||
max(so.posting_date) as 'last_order_date',
|
||||
DATEDIFF(CURRENT_DATE, max(so.posting_date)) as 'days_since_last_order' """
|
||||
if doctype == "Sales Order":
|
||||
cond = """sum(if(so.status = "Stopped",
|
||||
so.base_net_total * so.per_delivered/100,
|
||||
so.base_net_total)) as 'total_order_considered',
|
||||
max(so.transaction_date) as 'last_order_date',
|
||||
DATEDIFF(CURRENT_DATE, max(so.transaction_date)) as 'days_since_last_order'"""
|
||||
customer = frappe.qb.DocType("Customer")
|
||||
sales_doctype = frappe.qb.DocType(doctype)
|
||||
|
||||
return frappe.db.sql(
|
||||
f"""select
|
||||
cust.name,
|
||||
cust.customer_name,
|
||||
cust.territory,
|
||||
cust.customer_group,
|
||||
count(distinct(so.name)) as 'num_of_order',
|
||||
sum(base_net_total) as 'total_order_value', {cond}
|
||||
from `tabCustomer` cust, `tab{doctype}` so
|
||||
where cust.name = so.customer and so.docstatus = 1
|
||||
group by cust.name
|
||||
order by 'days_since_last_order' desc """,
|
||||
as_list=1,
|
||||
)
|
||||
date_diff = CustomFunction("DATEDIFF", ["d1", "d2"])
|
||||
current_date = CustomFunction("CURRENT_DATE", [])
|
||||
|
||||
if doctype == "Sales Order":
|
||||
total_considered = Sum(
|
||||
Case()
|
||||
.when(
|
||||
sales_doctype.status == "Stopped",
|
||||
sales_doctype.base_net_total * sales_doctype.per_delivered / 100,
|
||||
)
|
||||
.else_(sales_doctype.base_net_total)
|
||||
)
|
||||
date_col = sales_doctype.transaction_date
|
||||
else:
|
||||
total_considered = Sum(sales_doctype.base_net_total)
|
||||
date_col = sales_doctype.posting_date
|
||||
|
||||
last_order_date = Max(date_col)
|
||||
days_since_last_order = date_diff(current_date(), last_order_date)
|
||||
|
||||
return (
|
||||
frappe.qb.from_(customer)
|
||||
.inner_join(sales_doctype)
|
||||
.on(customer.name == sales_doctype.customer)
|
||||
.select(
|
||||
customer.name,
|
||||
customer.customer_name,
|
||||
customer.territory,
|
||||
customer.customer_group,
|
||||
Count(sales_doctype.name).distinct().as_("num_of_order"),
|
||||
Sum(sales_doctype.base_net_total).as_("total_order_value"),
|
||||
total_considered.as_("total_order_considered"),
|
||||
last_order_date.as_("last_order_date"),
|
||||
days_since_last_order.as_("days_since_last_order"),
|
||||
)
|
||||
.where(sales_doctype.docstatus == 1)
|
||||
.groupby(customer.name)
|
||||
.orderby(days_since_last_order, order=frappe.qb.desc)
|
||||
).run(as_list=True)
|
||||
|
||||
|
||||
def get_last_sales_amt(customer, doctype):
|
||||
cond = "posting_date"
|
||||
if doctype == "Sales Order":
|
||||
cond = "transaction_date"
|
||||
res = frappe.db.sql(
|
||||
f"""select base_net_total from `tab{doctype}`
|
||||
where customer = %s and docstatus = 1 order by {cond} desc
|
||||
limit 1""",
|
||||
customer,
|
||||
)
|
||||
sales_doctype = frappe.qb.DocType(doctype)
|
||||
date_col = sales_doctype.transaction_date if doctype == "Sales Order" else sales_doctype.posting_date
|
||||
|
||||
res = (
|
||||
frappe.qb.from_(sales_doctype)
|
||||
.select(sales_doctype.base_net_total)
|
||||
.where((sales_doctype.customer == customer) & (sales_doctype.docstatus == 1))
|
||||
.orderby(date_col, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
).run()
|
||||
|
||||
return res and res[0][0] or 0
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, getdate, today
|
||||
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.inactive_customers.inactive_customers import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestInactiveCustomers(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.customer = frappe.get_doc(doctype="Customer", customer_name="_Test Inactive Customer").insert()
|
||||
self.last_order_date = add_days(today(), -120)
|
||||
so = make_sales_order(
|
||||
customer=self.customer.name,
|
||||
transaction_date=self.last_order_date,
|
||||
qty=5,
|
||||
rate=200,
|
||||
)
|
||||
so.submit()
|
||||
self.sales_order = so
|
||||
|
||||
def test_invalid_doctype_is_rejected(self):
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
execute,
|
||||
{"doctype": "Purchase Order", "days_since_last_order": 30},
|
||||
)
|
||||
|
||||
def test_inactive_customer_is_listed_with_expected_columns(self):
|
||||
columns, data = execute({"doctype": "Sales Order", "days_since_last_order": 30})
|
||||
|
||||
row = self.get_customer_row(data)
|
||||
self.assertIsNotNone(row, "Inactive customer should be present in the report")
|
||||
|
||||
# Column contract: the report relies on positional access.
|
||||
self.assertEqual(row[0], self.customer.name)
|
||||
self.assertEqual(row[7], 1000) # Last Order Amount inserted at index 7 (5 * 200)
|
||||
self.assertEqual(getdate(row[8]), getdate(self.last_order_date)) # Last Order Date
|
||||
self.assertGreaterEqual(row[9], 30) # Days Since Last Order
|
||||
|
||||
def test_recent_customer_is_excluded(self):
|
||||
_columns, data = execute({"doctype": "Sales Order", "days_since_last_order": 200})
|
||||
self.assertIsNone(
|
||||
self.get_customer_row(data),
|
||||
"Customer ordering within the threshold must be excluded",
|
||||
)
|
||||
|
||||
def get_customer_row(self, data):
|
||||
return next((row for row in data if row[0] == self.customer.name), None)
|
||||
@@ -144,7 +144,9 @@ def get_data():
|
||||
|
||||
def get_items_with_product_bundle(item_list):
|
||||
bundled_items = frappe.get_all(
|
||||
"Product Bundle", filters=[("new_item_code", "IN", item_list)], fields=["new_item_code"]
|
||||
"Product Bundle",
|
||||
filters=[("new_item_code", "IN", item_list), ("is_active", "=", 1), ("docstatus", "=", 1)],
|
||||
fields=["new_item_code"],
|
||||
)
|
||||
|
||||
return [d.new_item_code for d in bundled_items]
|
||||
|
||||
@@ -15,6 +15,7 @@ from frappe.utils import flt
|
||||
|
||||
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date
|
||||
from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
|
||||
from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle
|
||||
|
||||
|
||||
def get_invoiced_qty_map(delivery_note: str) -> dict:
|
||||
@@ -287,8 +288,7 @@ def make_packing_slip(source_name: str, target_doc: str | Document | None = None
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda item: (
|
||||
not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code, "disabled": 0})
|
||||
and flt(item.packed_qty) < flt(item.qty)
|
||||
not is_product_bundle(item.item_code) and flt(item.packed_qty) < flt(item.qty)
|
||||
),
|
||||
},
|
||||
"Packed Item": {
|
||||
|
||||
@@ -44,7 +44,7 @@ class PackingService:
|
||||
items_list = [item.item_code for item in self.doc.items]
|
||||
return frappe.db.get_all(
|
||||
"Product Bundle",
|
||||
filters={"new_item_code": ["in", items_list], "disabled": 0},
|
||||
filters={"new_item_code": ["in", items_list], "is_active": 1, "docstatus": 1},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"barcode",
|
||||
"has_item_scanned",
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"item_name",
|
||||
"col_break1",
|
||||
"customer_item_code",
|
||||
@@ -136,6 +138,23 @@
|
||||
"search_index": 1,
|
||||
"width": "150px"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only_depends_on": "eval:doc.so_detail"
|
||||
},
|
||||
{
|
||||
"fieldname": "item_name",
|
||||
"fieldtype": "Data",
|
||||
@@ -952,7 +971,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-07 15:43:20.892151",
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Delivery Note Item",
|
||||
|
||||
@@ -712,8 +712,10 @@ class Item(Document):
|
||||
|
||||
def validate_duplicate_product_bundles_before_merge(self, old_name, new_name):
|
||||
"Block merge if both old and new items have product bundles."
|
||||
old_bundle = frappe.get_value("Product Bundle", filters={"new_item_code": old_name, "disabled": 0})
|
||||
new_bundle = frappe.get_value("Product Bundle", filters={"new_item_code": new_name, "disabled": 0})
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
old_bundle = get_active_product_bundle(old_name)
|
||||
new_bundle = get_active_product_bundle(new_name)
|
||||
|
||||
if old_bundle and new_bundle:
|
||||
bundle_link = get_link_to_form("Product Bundle", old_bundle)
|
||||
@@ -1146,7 +1148,7 @@ class Item(Document):
|
||||
|
||||
if doctype in ("Product Bundle", "BOM"):
|
||||
if doctype == "Product Bundle":
|
||||
filters = {"new_item_code": self.name}
|
||||
filters = {"new_item_code": self.name, "is_active": 1, "docstatus": 1}
|
||||
fieldname = "new_item_code as docname"
|
||||
else:
|
||||
filters = {"item": self.name, "docstatus": 1}
|
||||
|
||||
@@ -542,6 +542,7 @@ class TestItem(ERPNextTestSuite):
|
||||
with self.assertRaises(DataValidationError):
|
||||
frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True)
|
||||
|
||||
bundle1.cancel()
|
||||
bundle1.delete()
|
||||
frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True)
|
||||
|
||||
@@ -862,18 +863,21 @@ class TestItem(ERPNextTestSuite):
|
||||
item.reload()
|
||||
self.assertEqual(item.is_stock_item, 0)
|
||||
|
||||
# Step - 3: Create Product Bundle
|
||||
# Step - 3: Create (and submit) an active Product Bundle for the item
|
||||
component = make_item(properties={"is_stock_item": 1}).name
|
||||
pb = frappe.new_doc("Product Bundle")
|
||||
pb.new_item_code = item.name
|
||||
pb.flags.ignore_mandatory = True
|
||||
pb.save()
|
||||
pb.append("items", {"item_code": component, "qty": 1})
|
||||
pb.insert()
|
||||
pb.submit()
|
||||
|
||||
# Step - 4: Try to enable Maintain Stock, should throw a validation error
|
||||
item.is_stock_item = 1
|
||||
self.assertRaises(frappe.ValidationError, item.save)
|
||||
item.reload()
|
||||
|
||||
# Step - 5: Delete Product Bundle
|
||||
# Step - 5: Cancel & delete Product Bundle
|
||||
pb.cancel()
|
||||
pb.delete()
|
||||
|
||||
# Step - 6: Again try to enable Maintain Stock
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"field_order": [
|
||||
"parent_item",
|
||||
"item_code",
|
||||
"product_bundle",
|
||||
"item_name",
|
||||
"delivered_by_supplier",
|
||||
"reserve_stock",
|
||||
@@ -65,6 +66,16 @@
|
||||
"options": "Item",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.product_bundle",
|
||||
"description": "Product Bundle version this row was packed from",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"no_copy": 1,
|
||||
"options": "Product Bundle",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "item_name",
|
||||
"fieldtype": "Data",
|
||||
@@ -327,7 +338,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-05 16:16:12.856629",
|
||||
"modified": "2026-06-08 15:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Packed Item",
|
||||
|
||||
@@ -84,8 +84,16 @@ def make_packing_list(doc):
|
||||
reset = reset_packing_list(doc)
|
||||
|
||||
for item_row in doc.get("items"):
|
||||
if is_product_bundle(item_row.item_code):
|
||||
for bundle_item in get_product_bundle_items(item_row.item_code):
|
||||
# Pack from the version chosen on the row (default: the item's active version)
|
||||
# and record it so the document keeps a reference to the exact version used.
|
||||
bundle_name = get_bundle_version_for_row(item_row)
|
||||
if item_row.meta.has_field("product_bundle"):
|
||||
item_row.product_bundle = bundle_name
|
||||
if item_row.meta.has_field("is_product_bundle"):
|
||||
item_row.is_product_bundle = 1 if bundle_name else 0
|
||||
|
||||
if bundle_name:
|
||||
for bundle_item in get_product_bundle_items_by_name(bundle_name):
|
||||
pi_row = add_packed_item_row(
|
||||
doc=doc,
|
||||
packing_item=bundle_item,
|
||||
@@ -93,6 +101,7 @@ def make_packing_list(doc):
|
||||
packed_items_table=stale_packed_items_table,
|
||||
reset=reset,
|
||||
)
|
||||
pi_row.product_bundle = bundle_name
|
||||
item_data = get_packed_item_details(bundle_item.item_code, doc.company)
|
||||
update_packed_item_basic_data(item_row, pi_row, bundle_item, item_data)
|
||||
update_packed_item_stock_data(item_row, pi_row, bundle_item, item_data, doc)
|
||||
@@ -111,7 +120,9 @@ def make_packing_list(doc):
|
||||
|
||||
|
||||
def is_product_bundle(item_code: str) -> bool:
|
||||
return bool(frappe.db.exists("Product Bundle", {"new_item_code": item_code, "disabled": 0}))
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
return bool(get_active_product_bundle(item_code))
|
||||
|
||||
|
||||
def get_indexed_packed_items_table(doc):
|
||||
@@ -144,8 +155,13 @@ def reset_packing_list(doc):
|
||||
# 1. items were deleted
|
||||
# 2. if bundle item replaced by another item (same no. of items but different items)
|
||||
# we maintain list to track recurring item rows as well
|
||||
items_before_save = [(item.name, item.item_code) for item in doc_before_save.get("items")]
|
||||
items_after_save = [(item.name, item.item_code) for item in doc.get("items")]
|
||||
# include product_bundle so picking a different version re-packs the components
|
||||
items_before_save = [
|
||||
(item.name, item.item_code, item.get("product_bundle")) for item in doc_before_save.get("items")
|
||||
]
|
||||
items_after_save = [
|
||||
(item.name, item.item_code, item.get("product_bundle")) for item in doc.get("items")
|
||||
]
|
||||
reset_table = items_before_save != items_after_save
|
||||
else:
|
||||
# reset: if via Update Items OR
|
||||
@@ -172,12 +188,50 @@ def get_product_bundle_items(item_code):
|
||||
product_bundle_item.uom,
|
||||
product_bundle_item.description,
|
||||
)
|
||||
.where((product_bundle.new_item_code == item_code) & (product_bundle.disabled == 0))
|
||||
.where(
|
||||
(product_bundle.new_item_code == item_code)
|
||||
& (product_bundle.is_active == 1)
|
||||
& (product_bundle.docstatus == 1)
|
||||
)
|
||||
.orderby(product_bundle_item.idx)
|
||||
)
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_product_bundle_items_by_name(bundle_name):
|
||||
"Component rows of a specific Product Bundle version."
|
||||
product_bundle_item = frappe.qb.DocType("Product Bundle Item")
|
||||
return (
|
||||
frappe.qb.from_(product_bundle_item)
|
||||
.select(
|
||||
product_bundle_item.item_code,
|
||||
product_bundle_item.qty,
|
||||
product_bundle_item.uom,
|
||||
product_bundle_item.description,
|
||||
)
|
||||
.where(product_bundle_item.parent == bundle_name)
|
||||
.orderby(product_bundle_item.idx)
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
def get_bundle_version_for_row(item_row):
|
||||
"""Product Bundle version to pack ``item_row`` from.
|
||||
|
||||
Honours a version explicitly chosen on the row (validated to be a submitted
|
||||
bundle of that item); otherwise falls back to the item's active version. A stale
|
||||
choice (e.g. left over after changing the item) self-heals back to the active one.
|
||||
"""
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
chosen = item_row.get("product_bundle") if item_row.meta.has_field("product_bundle") else None
|
||||
if chosen:
|
||||
bundle = frappe.db.get_value("Product Bundle", chosen, ["new_item_code", "docstatus"], as_dict=True)
|
||||
if bundle and bundle.new_item_code == item_row.item_code and bundle.docstatus == 1:
|
||||
return chosen
|
||||
|
||||
return get_active_product_bundle(item_row.item_code)
|
||||
|
||||
|
||||
def add_packed_item_row(doc, packing_item, main_item_row, packed_items_table, reset):
|
||||
"""Add and return packed item row.
|
||||
doc: Transaction document
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user