Compare commits

..

57 Commits

Author SHA1 Message Date
MochaMind
8f71680459 fix: Swedish translations 2026-06-10 01:35:57 +05:30
Diptanil Saha
dfc824ded6 fix(process statement of accounts): validate pdf_name and validate permission before triggering send_auto_email (#55781) 2026-06-09 18:52:28 +00:00
Nabin Hait
dfd7cd0bae Merge pull request #55767 from nabinhait/refactor-je-extract-services
refactor(journal_entry): extract reference, asset and document-builder services
2026-06-09 22:08:55 +05:30
rohitwaghchaure
e083aa4c86 Merge pull request #55778 from rohitwaghchaure/fixed-github-55621-develop
fix: Stock Reservation blocks Subcontracting operation within the same work order
2026-06-09 21:06:10 +05:30
Rohit Waghchaure
c4fbc745db fix: Stock Reservation blocks Subcontracting operation within the same Work Order 2026-06-09 20:40:00 +05:30
Mihir Kandoi
2b6234f7af fix: handle multi-select stock ageing filters (#55774) 2026-06-09 13:58:17 +00:00
MochaMind
88b9911136 fix: sync translations from crowdin (#55638) 2026-06-09 18:05:38 +05:30
Lakshit Jain
360f52e636 fix(taxes): add category and add_deduct_tax fields to tax entries (#55753) 2026-06-09 18:02:33 +05:30
Mihir Kandoi
6201fefdfb fix: show inactive product bundles in item where used (#55769) 2026-06-09 12:27:54 +00:00
Lakshit Jain
08129ff71c fix: update round off account functions to accept document context for regional overrides (#55758) 2026-06-09 17:52:19 +05:30
rohitwaghchaure
5357634b70 Merge pull request #55765 from rohitwaghchaure/fixed-github-55621
fix: Stock Reservation blocks Subcontracting operation within the same work order
2026-06-09 17:43:47 +05:30
Rohit Waghchaure
20ba97aa7d fix: Stock Reservation blocks Subcontracting operation within the same Work Order 2026-06-09 17:15:56 +05:30
Nabin Hait
d90d4c29e1 refactor(journal_entry): move mapper re-export to the top import block 2026-06-09 16:59:27 +05:30
Nabin Hait
ddbd61b2a2 refactor(journal_entry): point erpnext imports at mapper, trim re-exports
Update erpnext's own importers (asset depreciation, invoice discounting and the
JE tests) to import the builders from mapper.py directly. Drop
make_inter_company_journal_entry and make_reverse_journal_entry from the
backward-compat re-export in journal_entry.py -- they are not part of the
custom-app call surface; only the payment-entry builders remain re-exported.
2026-06-09 16:59:27 +05:30
Nabin Hait
6a7c9f616e refactor(journal_entry): extract document builders into mapper.py
Move the Payment Entry / Journal Entry builders (get_payment_entry and its
against-order/against-invoice helpers, make_inter_company_journal_entry,
make_reverse_journal_entry) into mapper.py. The whitelisted builders are
re-exported from journal_entry.py so existing call paths -- including custom
apps -- keep working, and the erpnext client calls now point at the mapper
path. get_payment_entry imports the exchange-rate/bank-account helpers lazily
to avoid a circular import with the re-export.
2026-06-09 16:59:27 +05:30
Nabin Hait
a3194720b4 refactor(journal_entry): rename asset service to AssetService
Rename JournalEntryAssetLinkage -> AssetService and the file asset_linkage.py
-> asset_service.py.
2026-06-09 16:59:27 +05:30
Nabin Hait
7825ddf989 refactor(journal_entry): extract asset linkage into a service
Move the nine asset/depreciation coupling methods (depreciation-account
validation, asset value updates on depreciation and disposal, and the
unlink-on-cancel logic) out of the controller into a JournalEntryAssetLinkage
service under services/. Pure behaviour-preserving move, netted by the asset
suite (asset, asset_value_adjustment) plus the JE module.
2026-06-09 16:59:27 +05:30
Nabin Hait
e9b67ff682 refactor(journal_entry): extract reference validation into a service
Move validate_reference_doc and its helpers, plus validate_orders and
validate_invoices, out of the controller into a JournalEntryReferenceValidator
service under services/. Behaviour preserved; the per-reference totals stay on
the document. The order/invoice validators are split into <=15-line helpers.
2026-06-09 16:59:27 +05:30
Jatin3128
4c3aa9b4f3 feat(subscription): add refunded status, billing heatmap and billing UX (#55617)
* fix(subscription): bill on creation and keep status in sync with invoices

* feat(subscription): add refunded status, billing heatmap and billing UX
2026-06-09 16:43:24 +05:30
Nabin Hait
ca77145522 Merge pull request #55749 from nabinhait/refactor-je-validate-reference-doc
refactor(journal_entry): split validate_reference_doc into per-row methods
2026-06-09 16:13:45 +05:30
Nabin Hait
5753c23ccf refactor(journal_entry): clarify reference helper names
Rename three private helpers for intent and to drop an abbreviation:
_is_validatable_reference -> _has_party_reference,
_accumulate_reference -> _register_reference,
_reference_dr_or_cr -> _reference_amount_field.
2026-06-09 15:28:12 +05:30
rohitwaghchaure
a397e82278 Merge pull request #55760 from rohitwaghchaure/fixed-github-55756
fix: don't allow to submit job card with hold status
2026-06-09 14:29:53 +05:30
Rohit Waghchaure
9c23229cbf fix: don't allow to submit job card with hold status 2026-06-09 14:03:27 +05:30
Mihir Kandoi
08f6af867a feat: record and select Product Bundle version on transactions (#55738)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:15:20 +05:30
rohitwaghchaure
6988781f81 Merge pull request #55748 from rohitwaghchaure/fixed-suppoort-70455
fix: sql injection
2026-06-09 09:25:24 +05:30
Nabin Hait
49093b326e refactor(journal_entry): split validate_reference_doc into per-row methods
Extract the 100-line, CC-27 validate_reference_doc into a thin orchestrator
loop plus focused per-row private methods, and lift the inline reference
field map to a module constant. Behaviour preserved; complexity drops from
27 to 3 and no extracted function exceeds 15 lines.
2026-06-08 23:18:52 +05:30
Nabin Hait
9503dd0c7f test(journal_entry): characterize validate_reference_doc branches
Pin every branch of validate_reference_doc before refactoring: Sales Order
debit / Purchase Order credit rejection, non-existent reference handling,
Sales/Purchase Invoice and Order party mismatches, and population of the
reference_totals/types/accounts side effects.
2026-06-08 23:18:22 +05:30
Rohit Waghchaure
bd0acf4413 fix: sql injection 2026-06-08 23:10:33 +05:30
rohitwaghchaure
969cdf1b26 Merge pull request #55737 from rohitwaghchaure/fixed-security-issue-job-card
fix: allow specific methods to run
2026-06-08 19:46:59 +05:30
Rohit Waghchaure
8db1eb0d27 fix: allow specific methods to run 2026-06-08 16:06:16 +05:30
rohitwaghchaure
d146dc5435 Merge pull request #55724 from rohitwaghchaure/fixed-support-67770-3
fix: validate fg and materials qty in the disassemble entry
2026-06-08 16:04:29 +05:30
rohitwaghchaure
0ca38517f3 Merge pull request #55716 from rohitwaghchaure/fixed-support-67770-2
fix: do not allow to make changes in SABB after submit
2026-06-08 15:25:26 +05:30
ruthra kumar
5d1af7fc93 Merge pull request #55487 from Shllokkk/accounts-perm-fix
fix: add validations in accounts whitelisted methods
2026-06-08 15:15:37 +05:30
Ankush Menat
1fab935434 fix: only require read for hold
Support weird workflows.
2026-06-08 15:12:24 +05:30
ruthra kumar
d6ba0f0eca Merge pull request #55486 from Shllokkk/crm-create-customer-fix
Validations in CRM-api endpoints
2026-06-08 15:10:26 +05:30
Rohit Waghchaure
49164f41b1 fix: validate fg and materials qty in the disassemble entry 2026-06-08 15:06:43 +05:30
Rohit Waghchaure
e36426e235 fix: do not allow to make changes in SABB after submit 2026-06-08 14:59:07 +05:30
Ankush Menat
ba936eefab fix: Add authorization checks on internal functions (#55709) 2026-06-08 14:49:32 +05:30
Mihir Kandoi
5eb9461cfd fix: remove item name from update items dialog item code column (#55718)
Co-authored-by: Abdullah <frappe@LAPTOP-4E788RM4.localdomain>
2026-06-08 13:54:42 +05:30
Nabin Hait
e1e588e416 Merge pull request #55627 from Shllokkk/inact-cust-report
fix(inactive_customers): add allowlist for doctype filter and migrate…
2026-06-08 13:20:09 +05:30
Mihir Kandoi
00880eb657 fix: disallow BOM finished good item in secondary items table (#55710)
The FG item produced by a BOM should not also appear as a secondary
item (Co-Product/By-Product/Scrap/Additional Finished Good). When an
Additional Finished Good shared the main FG's item code, the resulting
Stock Entry ended up with two rows of the same item carrying different
valuation rates. Validate against it instead, exempting legacy rows so
migrated BOMs can still be re-saved.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 07:47:32 +00:00
Mihir Kandoi
ae6aef91bd feat: add item where used report (#55660) 2026-06-08 07:42:37 +00:00
Diptanil Saha
faf92b1368 fix(cheque_print_template): print format creation from cheque print template requires system manager (#55708) 2026-06-08 07:23:26 +00:00
Mihir Kandoi
a52c8fdaea feat: make Product Bundle submittable and versioned (#55702)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:19:42 +05:30
rohitwaghchaure
030e1a77e6 Merge pull request #55645 from aerele/fix/support-70407
fix: bypass project permission check when updating consumed material …
2026-06-08 12:06:20 +05:30
Pandiyan P
d2306b1b29 fix: restrict already invoiced qty in intercompany purchase invoice (#55639) 2026-06-08 11:59:11 +05:30
Nabin Hait
601f39dda7 test(inactive_customers): remove non-positive days test case 2026-06-08 11:55:32 +05:30
kaulith
047e4faa90 fix: update items respect workflow "Only Allow Edit For" role (#55662) 2026-06-08 11:53:12 +05:30
Nabin Hait
8d7edafc99 refactor(inactive_customers): rename sales alias to sales_doctype 2026-06-08 11:52:56 +05:30
Nabin Hait
8f15dd4d5d refactor(inactive_customers): use descriptive aliases and add tests
Rename single-letter query-builder aliases (C, DT) to readable names
(customer, sales) and add report tests covering the column contract,
validation guards, and the days-since-last-order threshold.
2026-06-08 11:45:34 +05:30
ruthra kumar
bf769a52c0 Merge pull request #55665 from Shllokkk/add-ac-ignore-permissions-fix
fix: drop ignore_permissions handling from add_ac
2026-06-08 11:44:23 +05:30
Shllokkk
37d2adc74b fix: drop ignore_permissions handling from add_ac 2026-06-05 20:49:17 +05:30
Shllokkk
5dbf3fdde0 fix: add permission checks in accounts whitelisted methods 2026-06-05 13:52:57 +05:30
pandiyan
4b0b7adeee fix: bypass project permission check when updating consumed material cost 2026-06-05 13:35:40 +05:30
Shllokkk
8de259a669 Merge branch 'develop' into inact-cust-report 2026-06-04 14:57:58 +05:30
Shllokkk
2ecf8b0466 fix(inactive_customers): add allowlist for doctype filter and migrate to qb 2026-06-04 14:55:49 +05:30
Shllokkk
e460e83516 fix: use new_doc with field allowlist in CRM integration endpoints 2026-05-31 18:42:26 +05:30
124 changed files with 85618 additions and 71499 deletions

View File

@@ -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();

View File

@@ -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": []
}
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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,
});
},

View File

@@ -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

View 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

View 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()

View File

@@ -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
)
)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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:

View File

@@ -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",

View File

@@ -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},

View File

@@ -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()

View File

@@ -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",

View File

@@ -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

View File

@@ -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"));
}
},
});
}
}

View File

@@ -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):

View File

@@ -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.

View File

@@ -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",

View File

@@ -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
);
}

View File

@@ -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": [
{

View File

@@ -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"))

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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,

View File

@@ -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",

View File

@@ -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",
)
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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"))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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(

View File

@@ -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(

View File

@@ -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;

View File

@@ -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."))

View File

@@ -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)

View File

@@ -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(

View File

@@ -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,

View File

@@ -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"

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View 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)

View File

@@ -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) {

View File

@@ -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";
}
}

View File

@@ -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") {

View File

@@ -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"
);
}
});
}

View File

@@ -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": []
}
}

View File

@@ -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()

View File

@@ -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)

View File

@@ -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",

View File

@@ -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()

View File

@@ -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"],
)
]

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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]

View File

@@ -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": {

View File

@@ -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",
)

View File

@@ -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",

View File

@@ -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}

View File

@@ -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

View File

@@ -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",

View File

@@ -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