Compare commits

...

60 Commits

Author SHA1 Message Date
rohitwaghchaure
c7d42e161b Merge pull request #55877 from rohitwaghchaure/feat-allow_to_edit_stock_uom_qty_for_stock_entry
feat: Allow to edit stock UOM qty for Stock Entry
2026-06-14 09:54:24 +05:30
Raffael Meyer
701896692a ci: set disabledLabels and context for greptile (#55883) 2026-06-13 18:46:03 +00:00
Raffael Meyer
93d6be2ed7 fix(Lead): stop storing Gravatar image URLs for Leads (#55880) 2026-06-13 19:03:29 +02:00
Rohit Waghchaure
b0e9ad198f feat: Allow to edit stock UOM qty for Stock Entry 2026-06-13 21:41:05 +05:30
Dipen Gala
a9029f83c7 feat(invoices): add tooltip description to Update Stock checkbox (#55868)
* feat(invoices): add tooltip description to Update Stock checkbox

Adds a description below the Update Stock checkbox on both Sales Invoice
and Purchase Invoice so users understand when to use the field without
consulting documentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(invoices): replace Update Stock description with hover info tooltip

Removes the inline description text and adds an ℹ icon next to the
Update Stock checkbox label on both Sales Invoice and Purchase Invoice.
Hovering the icon shows the contextual tooltip via Bootstrap tooltip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(invoices): use Frappe native tooltip-content class for Update Stock icon

Replace Bootstrap .tooltip() (pure black bg) with Frappe's own
.tooltip-content CSS class so the hover tooltip matches the rest of
the ERPNext UI — uses var(--bg-dark-gray) and var(--text-dark).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(invoices): use frappe.ui.SidebarCard for Update Stock info tooltip

Replace custom CSS tooltip with the same SidebarCard + Popper approach
Frappe's InfoCard uses for field description tooltips — gives the native
ERPNext card appearance (white card, border, shadow) on hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(invoices): use built-in field description for Update Stock tooltip

Replace custom SidebarCard JS tooltip with Frappe's native
description + show_description_on_click field property on the
update_stock field in Sales Invoice and Purchase Invoice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove duplicate description in purchase_invoice update_stock field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert: restore custom tooltip in purchase_invoice.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert: remove all changes from purchase_invoice.js

Keep purchase_invoice.js identical to upstream develop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:48:03 +05:30
rohitwaghchaure
31e4da562d Merge pull request #55874 from rohitwaghchaure/fixed-permission-for-bom-comparison-tool
fix: permission in bom compare tool
2026-06-13 19:10:38 +05:30
Rohit Waghchaure
e6fdb3702a fix: permission in bom compare tool 2026-06-13 19:09:04 +05:30
rohitwaghchaure
bd60a9be90 Merge pull request #55849 from rohitwaghchaure/fixed-permissions-for-whitelist-functions
fix: permission for whitelist functions
2026-06-13 18:36:46 +05:30
Rohit Waghchaure
a64466561f fix: permission for whitelist functions 2026-06-13 17:45:37 +05:30
Mihir Kandoi
f7ff25d9a8 Merge pull request #55835 from mihir-kandoi/codex/develop-user-disable-audit-fix
fix: sync employee user status after save
2026-06-13 14:49:34 +05:30
Diptanil Saha
c933e34914 fix: opportunity creation from contact us page (#55841) 2026-06-13 04:47:45 +00:00
Mihir Kandoi
87092961e7 Merge pull request #55853 from SandraFrappe/fix/cost-center
fix: pass source cost center to target cost center
2026-06-12 20:57:04 +05:30
rohitwaghchaure
3f436985ed Merge pull request #55844 from rohitwaghchaure/fixed-job-card-permissions
fix: permissions in workstation file
2026-06-12 16:05:24 +05:30
Rohit Waghchaure
cf127e8900 fix: permissions in workstation file 2026-06-12 15:37:46 +05:30
SandraFrappe
9ea766fc10 fix: pass source cost center to target cost center 2026-06-12 14:44:22 +05:30
rohitwaghchaure
53180fde93 Merge pull request #55845 from frappe/fix-update-stock-expense-head-warning
fix: remove unnecessary expense head warning for purchase invoices with update stock
2026-06-12 13:29:34 +05:30
Dipen Gala
224dff32df fix: remove unnecessary expense head warning for purchase invoices with update stock
When a Purchase Invoice is created with `update_stock = 1`, the system
automatically replaces the item's expense account with the correct
inventory account for perpetual inventory. This is expected behaviour,
but a `frappe.msgprint` warning was being shown to the user:

  "Expense Head changed to Stock In Hand because account Cost of Goods
   Sold is not linked to warehouse Stores or it is not the default
   inventory account."

The message is purely informational, provides no actionable guidance,
and confuses users who deliberately enable Update Stock. The underlying
account substitution logic is unchanged; only the popup is suppressed.

The two other `msgprint` calls (for the Purchase-Receipt-linked and
no-Purchase-Receipt flows) are intentionally preserved — those surface
a genuine change in behaviour that users may not expect.

Fixes: https://github.com/frappe/erpnext/issues/...
2026-06-12 12:57:58 +05:30
rohitwaghchaure
292bfa2a34 Merge pull request #55832 from rohitwaghchaure/fixed-regression-55827
fix: bom creator issue
2026-06-12 00:00:01 +05:30
Mihir Kandoi
e90896ced7 Merge pull request #55838 from aerele/fix/uom-mandatory
fix(stock): make uom mandatory in item uom table
2026-06-11 23:39:00 +05:30
Rohit Waghchaure
c360487cd1 fix: converted whitelist non class methods to class methods 2026-06-11 23:33:47 +05:30
pandiyan
a0177fdbe8 fix(stock): make uom mandatory in item uom table 2026-06-11 23:03:27 +05:30
Mihir Kandoi
64175bdb3e fix: skip unchanged employee user status sync 2026-06-11 21:34:43 +05:30
Mihir Kandoi
4fed04c6c7 fix: sync employee user status after save 2026-06-11 20:58:35 +05:30
Rohit Waghchaure
35fe9c60c7 fix: bom creator issue 2026-06-11 20:27:35 +05:30
Mihir Kandoi
878c22fa3f Merge pull request #55820 from aerele/fix/support-70979
fix: show user disable audit log
2026-06-11 20:27:01 +05:30
rohitwaghchaure
12ada21639 Merge pull request #55827 from rohitwaghchaure/fixed-bom-creator-security-issues
fix: multiple issues related to BOM Creator
2026-06-11 19:45:12 +05:30
Rohit Waghchaure
daf3f2e142 fix: multiple issues related to BOM Creator 2026-06-11 19:15:14 +05:30
rohitwaghchaure
ea3ec325e2 Merge pull request #55806 from rohitwaghchaure/refactor-stock-reservation
refactor: stock reservation feature
2026-06-11 13:53:23 +05:30
pandiyan
73d1852773 fix: show user disable audit log 2026-06-11 13:48:41 +05:30
Rohit Waghchaure
9c5f9218b5 refactor: stock reservation feature 2026-06-11 13:27:13 +05:30
ruthra kumar
a8a78a2163 Merge pull request #55695 from kaulith/fix/ar-report-respect-user-permissions
fix: apply user permissions to receivable/payable reports
2026-06-11 12:27:27 +05:30
Diptanil Saha
0b6121422d fix: added doctype filter validation for sales person wise transaction summary report (#55812) 2026-06-11 06:50:21 +00:00
Mohammad Umair Sayed
9249fa89aa fix(bom): fetch routing operations when Routing is selected (#55813)
fix(bom): fetch routing operations when routing is selected

frm.doc.operations is always an array in Frappe, so !frm.doc.operations
was always false (empty array [] is truthy in JS), causing get_routing()
to never fire when a Routing is selected on a BOM with no existing
operations.

Changed the guard to !frm.doc.operations.length so the fetch triggers
correctly when the operations table is empty.

Also wired the same fetch into the with_operations handler so that
enabling the checkbox after a Routing is already set will populate
operations without requiring the user to re-select the Routing.

Co-authored-by: Umair Sayed <umairsayed@Umairs-MacBook-Air-2.local>
2026-06-11 06:20:00 +00:00
Mihir Kandoi
5a816d19cb Merge pull request #55793 from mihir-kandoi/fix-bundle-dialog-lookup
fix(buying): resolve Get Items from Product Bundle by document name
2026-06-10 22:31:14 +05:30
Mihir Kandoi
a7d41f24a3 fix(stock): don't KeyError when neither bundle nor item_code is passed
row is a plain dict subclass, so row["item_code"] raised an unhandled
KeyError (500) when the payload had neither key. get_active_product_bundle
already returns None for falsy input, yielding an empty item list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:02:37 +05:30
Mihir Kandoi
81a1c2c8ce fix(stock): document-level permission check on the legacy bundle path
The legacy item_code path now resolves the active bundle's name via
get_active_product_bundle (same filters as the old joined query) so
frappe.has_permission can validate the specific document on both
branches. The orphaned get_product_bundle_items helper is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:51:49 +05:30
Mihir Kandoi
0c6f7fed55 fix(stock): permission check and test cleanup for bundle item fetch
The whitelisted get_items_from_product_bundle endpoint now verifies read
permission on Product Bundle (doc-level when a name is passed, doctype-
level for the legacy item_code path) so authenticated users can't
enumerate bundle components. The disabled-bundle test also restores the
disabled flag via addCleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:42:55 +05:30
Mihir Kandoi
bfee9df9aa fix: linter error 2026-06-10 18:53:32 +05:30
Mihir Kandoi
bddd1d0ebc fix(buying): resolve Get Items from Product Bundle by document name
Since Product Bundles became versioned, their names are PB-prefixed and
no longer double as the parent item code. The buying dialog kept passing
the picked bundle name as `item_code`, so the component lookup (which
filters `new_item_code`) matched nothing and the dialog silently added
no items.

The dialog now sends the selection as `product_bundle` and the endpoint
fetches that version's components by document name (rejecting
unsubmitted versions); passing `item_code` still resolves the parent
item's active version, preserving the legacy contract of the
whitelisted endpoint. The picker is also restricted to submitted
bundles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:40:12 +05:30
Nabin Hait
aa9f225c41 Merge pull request #55780 from nabinhait/refactor-je-services-internals
refactor(journal_entry): tidy the JE services and mapper internals
2026-06-10 11:54:04 +05:30
Mihir Kandoi
9c799f31ff Merge pull request #55791 from mihir-kandoi/product-bundle-disabled
feat(selling): allow disabling a Product Bundle
2026-06-10 11:42:25 +05:30
Mihir Kandoi
a60afaf91a Merge pull request #55789 from mihir-kandoi/fix-stock-ageing-unbuffered-cursor
fix: prefetch batchwise valuations before streaming SLEs in stock ageing
2026-06-10 11:37:23 +05:30
Nabin Hait
a4cff805f1 test(journal_entry): pin transaction-currency conversion in GL entries
Mutation testing on gl_composer surfaced that the foreign-row
debit/credit_in_transaction_currency conversion (amount / exchange_rate) was
unverified -- a / vs * bug survived. Assert those fields in test_multi_currency
and add a foreign-debit case so both conversion directions are now caught.
2026-06-10 11:23:19 +05:30
Nabin Hait
4f55071eda test(journal_entry): cover the untested mapper builders
Add characterization tests for get_payment_entry_against_order (the Sales/
Purchase Order advance path, previously untested) and make_inter_company_journal_entry
(previously fully uncovered).
2026-06-10 11:23:19 +05:30
Nabin Hait
43bb6c5a42 refactor(journal_entry): break up unlink_asset_reference and type/document asset service
Split AssetService.unlink_asset_reference into _is_depreciation_asset_row /
_reverse_asset_depreciation / _restore_scheduled_depreciation /
_restore_finance_book_value / _block_scrap_journal_cancel, and add return type
hints and docstrings across the service. Behaviour preserved (netted by the
asset suite).
2026-06-10 11:22:15 +05:30
Nabin Hait
34955380ee refactor(journal_entry): break up get_payment_entry and add types/docstrings to mapper
Split get_payment_entry into _reference_exchange_rate / _append_party_row /
_append_bank_row, and add return type hints and docstrings to all mapper
document builders. Behaviour preserved.
2026-06-10 11:22:15 +05:30
Nabin Hait
1714e13b39 refactor(journal_entry): tidy reference-validator and GL-composer services
Add return type hints and option-A docstrings to JournalEntryReferenceValidator,
and split JournalEntryGLComposer.compose into _set_transaction_currency and
_gl_row helpers. Behaviour preserved.
2026-06-10 11:22:15 +05:30
Nabin Hait
263c3e9dd4 Merge pull request #55779 from nabinhait/refactor-je-functions
refactor(journal_entry): smaller functions, Query Builder, type hints and docstrings
2026-06-10 11:20:05 +05:30
Mihir Kandoi
c97c2d1e02 test(selling): cover disabled Product Bundle behaviour
Resolution skips disabled bundles, transactions referencing a disabled
version are blocked, rows without an explicit version stop packing, and
the Item Where Used report surfaces the disabled flag on bundle rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:57:48 +05:30
Mihir Kandoi
cf37478870 feat(selling): allow disabling a Product Bundle
Un-deprecate the `disabled` checkbox: it is now editable (also after
submit) and parks a bundle version without ceding its active slot, so
re-enabling restores it without re-activation.

- `get_active_product_bundle` (the single resolution entry point) skips
  disabled bundles, so every consumer stops treating the item as a bundle
  while it is disabled
- the version pickers on transaction item rows and the buying "Get Items
  from Product Bundle" dialog filter out disabled bundles
- an explicitly selected disabled version blocks the transaction with a
  validation error instead of silently re-packing another version
- Product Bundle Balance report excludes disabled bundles
- list view indicator: Disabled (grey) / Active (green), falling back to
  docstatus for drafts, cancelled and inactive submitted versions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:57:48 +05:30
Mihir Kandoi
060a5c4eeb fix: prefetch batchwise valuations before streaming SLEs in stock ageing
Stock Ageing iterates stock ledger entries through an unbuffered
(streaming) cursor. _get_batchwise_valuation() lazily queried
Batch.use_batchwise_valuation from inside that loop whenever a row
carried the legacy batch_no field, and the nested query invalidated
the active streaming result set — crashing the report (or silently
dropping the remaining rows, depending on the driver version).

Resolve the valuation flags in a single query before entering the
unbuffered cursor block; the lazy lookup now only serves callers that
pass stock ledger entries in directly, where no streaming is active.

Fixes https://github.com/frappe/erpnext/issues/55786

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:49:27 +05:30
Nabin Hait
f099dbad35 refactor(journal_entry): give get_outstanding an explicit parameter list
Replace the single opaque `args` parameter of the whitelisted get_outstanding
with explicit named parameters (the supported interface), splitting the body
into _get_journal_entry_outstanding / _get_invoice_outstanding. The legacy
`args` payload is still accepted via kwargs for backward compatibility with
custom apps. Resolves the overusing-args semgrep finding.
2026-06-09 23:28:12 +05:30
Nabin Hait
cc8ce03232 test(journal_entry): cover write-off, balance and advance-unlink flows; drop dead code
Add characterization tests for the previously untested get_balance (difference
on a blank row), get_outstanding_invoices (write-off rows) and
unlink_advance_entry_reference (reference cleared on cancel). Remove the unused
get_average_exchange_rate, which has no callers in erpnext.
2026-06-09 23:07:03 +05:30
Nabin Hait
bcc1e73962 docs(journal_entry): add class and public-method docstrings
Add a class docstring plus docstrings for the lifecycle hooks and the public
API helpers (get_outstanding, get_against_jv, get_exchange_rate, etc.).
Self-evident one-line methods are intentionally left undocumented.
2026-06-09 22:44:19 +05:30
Nabin Hait
32d7250946 refactor(journal_entry): break up reporting, exchange-rate and balance methods
Decompose update_invoice_discounting, set_print_format_fields,
get_balance_for_periodic_accounting, set_exchange_rate, get_balance and
get_outstanding_invoices into focused per-row / row-building helpers (verb
prefixed, with docstrings). The nested closure in update_invoice_discounting
that ignored its row id is dropped. Behaviour preserved.
2026-06-09 22:40:35 +05:30
Nabin Hait
4c1cabb53e refactor(journal_entry): break up create_remarks and validate_against_jv
Split create_remarks into _cheque_remark / _reference_remark / _bill_remark
helpers, and validate_against_jv into _validate_jv_reference,
_validate_jv_reference_direction and _against_jv_entries. Add docstrings.
Behaviour preserved.
2026-06-09 22:30:51 +05:30
Nabin Hait
1105cb8ddf refactor(journal_entry): add missing type hints
Add return annotations to the module-level helpers and to make_gl_entries,
get_balance and set_total_amount, plus parameter types for set_total_amount
and make_gl_entries.
2026-06-09 22:24:44 +05:30
Nabin Hait
8bb4ffc6b1 refactor(journal_entry): replace raw SQL with Query Builder
Convert the five raw frappe.db.sql calls to Query Builder / ORM: the
against-JV lookup, the write-off invoice listing (get_values, now a single
query), the JV outstanding aggregate (get_outstanding), and the bill-no
lookup (get_value). Behaviour preserved.
2026-06-09 22:17:49 +05:30
Kaushal Shriwas
58582cfa09 test: cover user permission scoping in receivable report 2026-06-07 22:20:46 +05:30
Kaushal Shriwas
1ef4978a86 fix: apply user permissions to receivable/payable reports 2026-06-07 21:43:49 +05:30
74 changed files with 2228 additions and 931 deletions

10
.greptile/config.json Normal file
View File

@@ -0,0 +1,10 @@
{
"disabledLabels": [
"conflicts"
],
"context": {
"repos": [
"frappe/frappe"
]
}
}

View File

@@ -94,6 +94,7 @@ class BankClearance(Document):
invalid_document = []
invalid_cheque_date = []
entries_to_update = []
self.check_permission("write")
def validate_entry(d):
is_valid = True

View File

@@ -518,6 +518,7 @@ def create_internal_transfer(
"""
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
bank_transaction.check_permission("write")
bank_account = frappe.get_cached_value("Bank Account", bank_transaction.bank_account, "account")
company = frappe.get_cached_value("Account", bank_account, "company")
@@ -778,7 +779,6 @@ def create_bulk_payment_entry_and_reconcile(
"""
Create a payment entry and reconcile it with the bank transaction
"""
output = []
for bank_transaction_name in bank_transaction_names:

View File

@@ -374,6 +374,7 @@ def unreconcile_transaction(transaction_name: str | int):
Else, cancel the individual entries
"""
transaction = frappe.get_doc("Bank Transaction", transaction_name)
transaction.check_permission("write")
vouchers_to_cancel = []
@@ -401,6 +402,7 @@ def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type:
"""
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_id)
bank_transaction.check_permission("write")
# Find the voucher in the bank transaction and depending on the action, either remove it or cancel the voucher
for entry in bank_transaction.payment_entries:

View File

@@ -409,18 +409,16 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
}
get_outstanding(doctype, docname, company, child) {
var args = {
doctype: doctype,
docname: docname,
party: child.party,
account: child.account,
account_currency: child.account_currency,
company: company,
};
return frappe.call({
method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_outstanding",
args: { args: args },
args: {
doctype: doctype,
docname: docname,
company: company,
account: child.account,
party: child.party,
account_currency: child.account_currency,
},
callback: function (r) {
if (r.message) {
$.each(r.message, function (field, value) {

View File

@@ -8,6 +8,7 @@ import frappe
from frappe import _, msgprint, scrub
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate
import erpnext
@@ -43,6 +44,14 @@ class StockAccountInvalidTransaction(frappe.ValidationError):
class JournalEntry(AccountsController):
"""Double-entry accounting voucher for manual and system-generated postings.
Besides plain journal entries it also backs depreciation, asset disposal,
exchange gain/loss, deferred revenue/expense, inter-company and periodic
accounting entries: it validates the account rows (party, references,
currency) and posts the corresponding GL entries on submit.
"""
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -128,6 +137,7 @@ class JournalEntry(AccountsController):
super().__init__(*args, **kwargs)
def validate(self):
"""Validate the account rows (party, references, currency, stock) and build derived fields."""
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
from erpnext.accounts.doctype.journal_entry.services.reference_validator import (
JournalEntryReferenceValidator,
@@ -188,28 +198,33 @@ class JournalEntry(AccountsController):
validate_docs_for_deferred_accounting([self.name], [])
def submit(self):
"""Submit inline, or queue submission in the background for large entries."""
if len(self.accounts) > 100 and not self.meta.queue_in_background:
queue_submission(self, "_submit")
else:
return self._submit()
def before_cancel(self):
"""Block cancellation when a submitted Asset Value Adjustment is linked to this entry."""
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
AssetService(self).has_asset_adjustment_entry()
def cancel(self):
"""Cancel inline, or queue cancellation in the background for large entries."""
if len(self.accounts) > 100:
queue_submission(self, "_cancel")
else:
return self._cancel()
def before_submit(self):
"""Ensure total debit equals total credit before submission (skipped on data import)."""
# Do not validate while importing via data import
if not frappe.flags.in_import:
self.validate_total_debit_and_credit()
def on_submit(self):
"""Post GL entries and propagate the submission to assets, inter-company JE and invoice discounting."""
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
self.validate_cheque_info()
@@ -221,18 +236,16 @@ class JournalEntry(AccountsController):
JournalTaxWithholding(self).on_submit()
@frappe.whitelist()
def get_balance_for_periodic_accounting(self):
def get_balance_for_periodic_accounting(self) -> None:
"""Rebuild the entry rows from the stock-vs-ledger difference of each stock account."""
self.validate_company_for_periodic_accounting()
stock_accounts = self.get_stock_accounts_for_periodic_accounting()
self.set("accounts", [])
for account in stock_accounts:
account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(
for account in self.get_stock_accounts_for_periodic_accounting():
account_bal, stock_bal, _warehouse_list = get_stock_and_account_balance(
account, self.posting_date, self.company
)
difference_value = flt(stock_bal - account_bal, self.precision("difference"))
if difference_value == 0:
frappe.msgprint(
_("No difference found for stock account {0}").format(frappe.bold(account)),
@@ -240,23 +253,26 @@ class JournalEntry(AccountsController):
)
continue
self.append(
"accounts",
{
"account": account,
"debit_in_account_currency": difference_value if difference_value > 0 else 0,
"credit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
self._append_periodic_difference_rows(account, difference_value)
self.append(
"accounts",
{
"account": self.periodic_entry_difference_account,
"credit_in_account_currency": difference_value if difference_value > 0 else 0,
"debit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
def _append_periodic_difference_rows(self, account: str, difference_value: float) -> None:
"""Append the stock account row and its offsetting difference-account row."""
self.append(
"accounts",
{
"account": account,
"debit_in_account_currency": difference_value if difference_value > 0 else 0,
"credit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
self.append(
"accounts",
{
"account": self.periodic_entry_difference_account,
"credit_in_account_currency": difference_value if difference_value > 0 else 0,
"debit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
def validate_company_for_periodic_accounting(self):
if erpnext.is_perpetual_inventory_enabled(self.company):
@@ -302,6 +318,7 @@ class JournalEntry(AccountsController):
self.repost_accounting_entries()
def on_cancel(self):
"""Reverse GL entries and unlink asset, inter-company and advance references on cancel."""
# Cancel tax withholding entries
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
@@ -385,49 +402,44 @@ class JournalEntry(AccountsController):
self.name,
)
def update_invoice_discounting(self):
def _validate_invoice_discounting_status(inv_disc, id_status, expected_status, row_id):
id_link = get_link_to_form("Invoice Discounting", inv_disc)
if id_status != expected_status:
frappe.throw(
_("Row #{0}: Status must be {1} for Invoice Discounting {2}").format(
d.idx, expected_status, id_link
)
)
def update_invoice_discounting(self) -> None:
"""Advance each linked Invoice Discounting to its next status on submit/cancel."""
discounting_names = {
row.reference_name for row in self.accounts if row.reference_type == "Invoice Discounting"
}
for name in discounting_names:
inv_disc = frappe.get_doc("Invoice Discounting", name)
if status := self._get_next_invoice_discounting_status(inv_disc):
inv_disc.set_status(status=status)
invoice_discounting_list = list(
set([d.reference_name for d in self.accounts if d.reference_type == "Invoice Discounting"])
)
for inv_disc in invoice_discounting_list:
inv_disc_doc = frappe.get_doc("Invoice Discounting", inv_disc)
status = None
for d in self.accounts:
if d.account == inv_disc_doc.short_term_loan and d.reference_name == inv_disc:
if self.docstatus == 1:
if d.credit > 0:
_validate_invoice_discounting_status(
inv_disc, inv_disc_doc.status, "Sanctioned", d.idx
)
status = "Disbursed"
elif d.debit > 0:
_validate_invoice_discounting_status(
inv_disc, inv_disc_doc.status, "Disbursed", d.idx
)
status = "Settled"
else:
if d.credit > 0:
_validate_invoice_discounting_status(
inv_disc, inv_disc_doc.status, "Disbursed", d.idx
)
status = "Sanctioned"
elif d.debit > 0:
_validate_invoice_discounting_status(
inv_disc, inv_disc_doc.status, "Settled", d.idx
)
status = "Disbursed"
break
if status:
inv_disc_doc.set_status(status=status)
def _get_next_invoice_discounting_status(self, inv_disc) -> str | None:
"""Validate the current status and return the next one from the loan account row."""
for row in self.accounts:
if row.account != inv_disc.short_term_loan or row.reference_name != inv_disc.name:
continue
submitting = self.docstatus == 1
if row.credit > 0:
expected, next_status = (
("Sanctioned", "Disbursed") if submitting else ("Disbursed", "Sanctioned")
)
elif row.debit > 0:
expected, next_status = ("Disbursed", "Settled") if submitting else ("Settled", "Disbursed")
else:
return None
self._validate_invoice_discounting_status(inv_disc, expected, row.idx)
return next_status
return None
def _validate_invoice_discounting_status(self, inv_disc, expected_status: str, row_idx: int) -> None:
"""Throw unless the Invoice Discounting is in the status expected for this transition."""
if inv_disc.status != expected_status:
frappe.throw(
_("Row #{0}: Status must be {1} for Invoice Discounting {2}").format(
row_idx, expected_status, get_link_to_form("Invoice Discounting", inv_disc.name)
)
)
def unlink_advance_entry_reference(self):
for d in self.get("accounts"):
@@ -543,62 +555,76 @@ class JournalEntry(AccountsController):
self.voucher_type == "Exchange Gain Or Loss" and self.multi_currency and self.is_system_generated
)
def validate_against_jv(self):
for d in self.get("accounts"):
if d.reference_type == "Journal Entry":
account_root_type = frappe.get_cached_value("Account", d.account, "root_type")
if (
account_root_type == "Asset"
and flt(d.debit) > 0
and not self.system_generated_gain_loss()
):
frappe.throw(
_(
"Row #{0}: For {1}, you can select reference document only if account gets credited"
).format(d.idx, d.account)
)
elif (
account_root_type == "Liability"
and flt(d.credit) > 0
and not self.system_generated_gain_loss()
):
frappe.throw(
_(
"Row #{0}: For {1}, you can select reference document only if account gets debited"
).format(d.idx, d.account)
)
def validate_against_jv(self) -> None:
"""Validate every account row that references another Journal Entry."""
for row in self.get("accounts"):
if row.reference_type == "Journal Entry":
self._validate_jv_reference(row)
if d.reference_name == self.name:
frappe.throw(_("You can not enter current voucher in 'Against Journal Entry' column"))
def _validate_jv_reference(self, row) -> None:
"""Validate a single 'Against Journal Entry' row: direction, no self-reference,
and the presence of an unmatched entry on the referenced Journal Entry."""
self._validate_jv_reference_direction(row)
against_entries = frappe.db.sql(
"""select * from `tabJournal Entry Account`
where account = %s and docstatus = 1 and parent = %s
and (reference_type is null or reference_type in ('', 'Sales Order', 'Purchase Order'))
""",
(d.account, d.reference_name),
as_dict=True,
if row.reference_name == self.name:
frappe.throw(_("You can not enter current voucher in 'Against Journal Entry' column"))
against_entries = self._get_against_jv_entries(row)
if not against_entries:
if self.voucher_type != "Exchange Gain Or Loss":
frappe.throw(
_(
"Journal Entry {0} does not have account {1} or already matched against other voucher"
).format(row.reference_name, row.account)
)
return
if not against_entries:
if self.voucher_type != "Exchange Gain Or Loss":
frappe.throw(
_(
"Journal Entry {0} does not have account {1} or already matched against other voucher"
).format(d.reference_name, d.account)
)
else:
dr_or_cr = "debit" if flt(d.credit) > 0 else "credit"
valid = False
for jvd in against_entries:
if flt(jvd[dr_or_cr]) > 0:
valid = True
if not valid and not self.system_generated_gain_loss():
frappe.throw(
_("Against Journal Entry {0} does not have any unmatched {1} entry").format(
d.reference_name, dr_or_cr
)
)
dr_or_cr = "debit" if flt(row.credit) > 0 else "credit"
has_unmatched_entry = any(flt(entry[dr_or_cr]) > 0 for entry in against_entries)
if not has_unmatched_entry and not self.system_generated_gain_loss():
frappe.throw(
_("Against Journal Entry {0} does not have any unmatched {1} entry").format(
row.reference_name, dr_or_cr
)
)
def _validate_jv_reference_direction(self, row) -> None:
"""An asset account can reference a JE only when credited, a liability only when debited."""
if self.system_generated_gain_loss():
return
account_root_type = frappe.get_cached_value("Account", row.account, "root_type")
if account_root_type == "Asset" and flt(row.debit) > 0:
frappe.throw(
_(
"Row #{0}: For {1}, you can select reference document only if account gets credited"
).format(row.idx, row.account)
)
if account_root_type == "Liability" and flt(row.credit) > 0:
frappe.throw(
_("Row #{0}: For {1}, you can select reference document only if account gets debited").format(
row.idx, row.account
)
)
def _get_against_jv_entries(self, row) -> list[dict]:
"""Submitted Journal Entry Account rows on the referenced JE for the same account
that are not themselves linked to an order."""
jea = frappe.qb.DocType("Journal Entry Account")
return (
frappe.qb.from_(jea)
.select(jea.star)
.where(
(jea.account == row.account)
& (jea.docstatus == 1)
& (jea.parent == row.reference_name)
& (
jea.reference_type.isnull()
| jea.reference_type.isin(["", "Sales Order", "Purchase Order"])
)
)
.run(as_dict=True)
)
def set_against_account(self):
accounts_debited, accounts_credited = [], []
@@ -686,131 +712,142 @@ class JournalEntry(AccountsController):
d.debit = flt(d.debit_in_account_currency * flt(d.exchange_rate), d.precision("debit"))
d.credit = flt(d.credit_in_account_currency * flt(d.exchange_rate), d.precision("credit"))
def set_exchange_rate(self):
for d in self.get("accounts"):
if d.account_currency == self.company_currency:
d.exchange_rate = 1
elif (
not d.exchange_rate
or d.exchange_rate == 1
or (
d.reference_type in ("Sales Invoice", "Purchase Invoice")
and d.reference_name
and self.posting_date
)
):
ignore_exchange_rate = False
if self.get("flags") and self.flags.get("ignore_exchange_rate"):
ignore_exchange_rate = True
def set_exchange_rate(self) -> None:
"""Resolve a mandatory exchange rate for every account row."""
for row in self.get("accounts"):
self._set_row_exchange_rate(row)
if not row.exchange_rate:
frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(row.idx))
if not ignore_exchange_rate:
# Modified to include the posting date for which to retreive the exchange rate
d.exchange_rate = get_exchange_rate(
self.posting_date,
d.account,
d.account_currency,
self.company,
d.reference_type,
d.reference_name,
d.debit,
d.credit,
d.exchange_rate,
)
if not d.exchange_rate:
frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(d.idx))
def create_remarks(self):
r = []
if self.flags.skip_remarks_creation:
def _set_row_exchange_rate(self, row) -> None:
"""Set a row's exchange rate: 1 for company currency, otherwise fetched when stale."""
if row.account_currency == self.company_currency:
row.exchange_rate = 1
return
if self.get("custom_remark"):
return
if self.cheque_no:
if self.cheque_date:
r.append(_("Reference #{0} dated {1}").format(self.cheque_no, formatdate(self.cheque_date)))
else:
msgprint(_("Please enter Reference date"), raise_exception=frappe.MandatoryError)
for d in self.get("accounts"):
if d.reference_type == "Sales Invoice" and d.credit:
r.append(
_("{0} against Sales Invoice {1}").format(
fmt_money(flt(d.credit), currency=self.company_currency), d.reference_name
)
)
if d.reference_type == "Sales Order" and d.credit:
r.append(
_("{0} against Sales Order {1}").format(
fmt_money(flt(d.credit), currency=self.company_currency), d.reference_name
)
)
if d.reference_type == "Purchase Invoice" and d.debit:
bill_no = frappe.db.sql(
"""select bill_no, bill_date
from `tabPurchase Invoice` where name=%s""",
d.reference_name,
)
if (
bill_no
and bill_no[0][0]
and bill_no[0][0].lower().strip() not in ["na", "not applicable", "none"]
):
r.append(
_("{0} against Bill {1} dated {2}").format(
fmt_money(flt(d.debit), currency=self.company_currency),
bill_no[0][0],
bill_no[0][1] and formatdate(bill_no[0][1].strftime("%Y-%m-%d")),
)
)
if d.reference_type == "Purchase Order" and d.debit:
r.append(
_("{0} against Purchase Order {1}").format(
fmt_money(flt(d.credit), currency=self.company_currency), d.reference_name
)
)
if r:
self.remark = ("\n").join(r) # User Remarks is not mandatory
def set_print_format_fields(self):
bank_amount = party_amount = total_amount = 0.0
currency = bank_account_currency = party_account_currency = pay_to_recd_from = None
party_type = None
for d in self.get("accounts"):
if d.party_type in ["Customer", "Supplier"] and d.party:
party_type = d.party_type
if not pay_to_recd_from:
pay_to_recd_from = d.party
if pay_to_recd_from and pay_to_recd_from == d.party:
party_amount += flt(d.debit_in_account_currency) or flt(d.credit_in_account_currency)
party_account_currency = d.account_currency
elif frappe.get_cached_value("Account", d.account, "account_type") in ["Bank", "Cash"]:
bank_amount += flt(d.debit_in_account_currency) or flt(d.credit_in_account_currency)
bank_account_currency = d.account_currency
if party_type and pay_to_recd_from:
self.pay_to_recd_from = frappe.db.get_value(
party_type, pay_to_recd_from, "customer_name" if party_type == "Customer" else "supplier_name"
needs_refresh = (
not row.exchange_rate
or row.exchange_rate == 1
or (
row.reference_type in ("Sales Invoice", "Purchase Invoice")
and row.reference_name
and self.posting_date
)
if bank_amount:
total_amount = bank_amount
currency = bank_account_currency
)
if not needs_refresh or self.flags.get("ignore_exchange_rate"):
return
# Includes the posting date for which to retrieve the exchange rate
row.exchange_rate = get_exchange_rate(
self.posting_date,
row.account,
row.account_currency,
self.company,
row.reference_type,
row.reference_name,
row.debit,
row.credit,
row.exchange_rate,
)
def create_remarks(self) -> None:
"""Build the auto remark from the cheque reference and each account row's linked
document, unless remark creation is skipped or a custom remark is set."""
if self.flags.skip_remarks_creation or self.get("custom_remark"):
return
remarks = []
if cheque_remark := self._get_cheque_remark():
remarks.append(cheque_remark)
for row in self.get("accounts"):
if reference_remark := self._get_reference_remark(row):
remarks.append(reference_remark)
if remarks:
self.remark = "\n".join(remarks) # User Remarks is not mandatory
def _get_cheque_remark(self) -> str | None:
"""Remark line for the cheque reference; raises if the cheque date is missing."""
if not self.cheque_no:
return None
if not self.cheque_date:
msgprint(_("Please enter Reference date"), raise_exception=frappe.MandatoryError)
return _("Reference #{0} dated {1}").format(self.cheque_no, formatdate(self.cheque_date))
def _get_reference_remark(self, row) -> str | None:
"""Remark line for a single account row's linked Invoice/Order, or None."""
if row.reference_type == "Sales Invoice" and row.credit:
return _("{0} against Sales Invoice {1}").format(
fmt_money(flt(row.credit), currency=self.company_currency), row.reference_name
)
if row.reference_type == "Sales Order" and row.credit:
return _("{0} against Sales Order {1}").format(
fmt_money(flt(row.credit), currency=self.company_currency), row.reference_name
)
if row.reference_type == "Purchase Invoice" and row.debit:
return self._get_bill_remark(row)
if row.reference_type == "Purchase Order" and row.debit:
return _("{0} against Purchase Order {1}").format(
fmt_money(flt(row.credit), currency=self.company_currency), row.reference_name
)
return None
def _get_bill_remark(self, row) -> str | None:
"""Remark line referencing the supplier bill number/date of a Purchase Invoice row."""
bill_no, bill_date = frappe.db.get_value(
"Purchase Invoice", row.reference_name, ["bill_no", "bill_date"]
) or (None, None)
if not bill_no or bill_no.lower().strip() in ["na", "not applicable", "none"]:
return None
return _("{0} against Bill {1} dated {2}").format(
fmt_money(flt(row.debit), currency=self.company_currency),
bill_no,
bill_date and formatdate(bill_date.strftime("%Y-%m-%d")),
)
def set_print_format_fields(self) -> None:
"""Populate pay_to_recd_from and the total amount/currency shown on the print format."""
amounts = self._get_party_and_bank_amounts()
total_amount, currency = 0.0, None
if amounts.party_type and amounts.pay_to_recd_from:
self.pay_to_recd_from = frappe.db.get_value(
amounts.party_type,
amounts.pay_to_recd_from,
"customer_name" if amounts.party_type == "Customer" else "supplier_name",
)
if amounts.bank_amount:
total_amount, currency = amounts.bank_amount, amounts.bank_account_currency
else:
total_amount = party_amount
currency = party_account_currency
total_amount, currency = amounts.party_amount, amounts.party_account_currency
self.set_total_amount(total_amount, currency)
def set_total_amount(self, amt, currency):
def _get_party_and_bank_amounts(self) -> frappe._dict:
"""Sum the party and bank/cash amounts, with their currencies, across the account rows."""
totals = frappe._dict(
bank_amount=0.0,
party_amount=0.0,
bank_account_currency=None,
party_account_currency=None,
pay_to_recd_from=None,
party_type=None,
)
for row in self.get("accounts"):
amount = flt(row.debit_in_account_currency) or flt(row.credit_in_account_currency)
if row.party_type in ["Customer", "Supplier"] and row.party:
totals.party_type = row.party_type
totals.pay_to_recd_from = totals.pay_to_recd_from or row.party
if totals.pay_to_recd_from == row.party:
totals.party_amount += amount
totals.party_account_currency = row.account_currency
elif frappe.get_cached_value("Account", row.account, "account_type") in ["Bank", "Cash"]:
totals.bank_amount += amount
totals.bank_account_currency = row.account_currency
return totals
def set_total_amount(self, amt: float, currency: str) -> None:
self.total_amount = amt
self.total_amount_currency = currency
from frappe.utils import money_in_words
@@ -822,7 +859,7 @@ class JournalEntry(AccountsController):
return JournalEntryGLComposer(self).compose()
def make_gl_entries(self, cancel=0, adv_adj=0):
def make_gl_entries(self, cancel: int = 0, adv_adj: int = 0) -> None:
from erpnext.accounts.general_ledger import make_gl_entries
merge_entries = frappe.get_single_value("Accounts Settings", "merge_similar_account_heads")
@@ -846,94 +883,109 @@ class JournalEntry(AccountsController):
cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
@frappe.whitelist()
def get_balance(self, difference_account: str | None = None):
def get_balance(self, difference_account: str | None = None) -> None:
"""Balance the entry by placing any difference on a blank (or newly added) row."""
if not self.get("accounts"):
msgprint(_("'Entries' cannot be empty"), raise_exception=True)
else:
self.total_debit, self.total_credit = 0, 0
diff = flt(self.difference, self.precision("difference"))
return
# If any row without amount, set the diff on that row
if diff:
blank_row = None
for d in self.get("accounts"):
if not d.credit_in_account_currency and not d.debit_in_account_currency and diff != 0:
blank_row = d
self.total_debit, self.total_credit = 0, 0
diff = flt(self.difference, self.precision("difference"))
if diff:
self._apply_difference_to_blank_row(diff, difference_account)
if not blank_row:
blank_row = self.append(
"accounts",
{
"account": difference_account,
"cost_center": erpnext.get_default_cost_center(self.company),
},
)
self.set_total_debit_credit()
self.validate_total_debit_and_credit()
blank_row.exchange_rate = 1
if diff > 0:
blank_row.credit_in_account_currency = diff
blank_row.credit = diff
elif diff < 0:
blank_row.debit_in_account_currency = abs(diff)
blank_row.debit = abs(diff)
def _apply_difference_to_blank_row(self, diff: float, difference_account: str | None) -> None:
"""Set the balancing difference on the last amountless row, adding one if none exists."""
blank_row = None
for row in self.get("accounts"):
if not row.credit_in_account_currency and not row.debit_in_account_currency:
blank_row = row
self.set_total_debit_credit()
self.validate_total_debit_and_credit()
if not blank_row:
blank_row = self.append(
"accounts",
{
"account": difference_account,
"cost_center": erpnext.get_default_cost_center(self.company),
},
)
blank_row.exchange_rate = 1
if diff > 0:
blank_row.credit_in_account_currency = diff
blank_row.credit = diff
elif diff < 0:
blank_row.debit_in_account_currency = abs(diff)
blank_row.debit = abs(diff)
@frappe.whitelist()
def get_outstanding_invoices(self):
def get_outstanding_invoices(self) -> None:
"""Populate the entry with a write-off row per outstanding invoice plus a balancing row."""
self.set("accounts", [])
total = 0
for d in self.get_values():
total += flt(d.outstanding_amount, self.precision("credit", "accounts"))
jd1 = self.append("accounts", {})
jd1.account = d.account
jd1.party = d.party
for invoice in self.get_values():
total += flt(invoice.outstanding_amount, self.precision("credit", "accounts"))
self._append_outstanding_invoice_row(invoice)
if self.write_off_based_on == "Accounts Receivable":
jd1.party_type = "Customer"
jd1.credit_in_account_currency = flt(
d.outstanding_amount, self.precision("credit", "accounts")
)
jd1.reference_type = "Sales Invoice"
jd1.reference_name = cstr(d.name)
elif self.write_off_based_on == "Accounts Payable":
jd1.party_type = "Supplier"
jd1.debit_in_account_currency = flt(d.outstanding_amount, self.precision("debit", "accounts"))
jd1.reference_type = "Purchase Invoice"
jd1.reference_name = cstr(d.name)
jd2 = self.append("accounts", {})
balancing_row = self.append("accounts", {})
if self.write_off_based_on == "Accounts Receivable":
jd2.debit_in_account_currency = total
balancing_row.debit_in_account_currency = total
elif self.write_off_based_on == "Accounts Payable":
jd2.credit_in_account_currency = total
balancing_row.credit_in_account_currency = total
self.validate_total_debit_and_credit()
def get_values(self):
cond = (
f" and outstanding_amount <= {flt(self.write_off_amount)}"
if flt(self.write_off_amount) > 0
else ""
)
def _append_outstanding_invoice_row(self, invoice) -> None:
"""Append a party row for a single outstanding invoice per the write-off basis."""
row = self.append("accounts", {})
row.account = invoice.account
row.party = invoice.party
if self.write_off_based_on == "Accounts Receivable":
return frappe.db.sql(
"""select name, debit_to as account, customer as party, outstanding_amount
from `tabSales Invoice` where docstatus = 1 and company = {}
and outstanding_amount > 0 {}""".format("%s", cond),
self.company,
as_dict=True,
row.party_type = "Customer"
row.credit_in_account_currency = flt(
invoice.outstanding_amount, self.precision("credit", "accounts")
)
row.reference_type = "Sales Invoice"
row.reference_name = cstr(invoice.name)
elif self.write_off_based_on == "Accounts Payable":
return frappe.db.sql(
"""select name, credit_to as account, supplier as party, outstanding_amount
from `tabPurchase Invoice` where docstatus = 1 and company = {}
and outstanding_amount > 0 {}""".format("%s", cond),
self.company,
as_dict=True,
row.party_type = "Supplier"
row.debit_in_account_currency = flt(
invoice.outstanding_amount, self.precision("debit", "accounts")
)
row.reference_type = "Purchase Invoice"
row.reference_name = cstr(invoice.name)
def get_values(self):
if self.write_off_based_on == "Accounts Receivable":
doctype, account_field, party_field = "Sales Invoice", "debit_to", "customer"
elif self.write_off_based_on == "Accounts Payable":
doctype, account_field, party_field = "Purchase Invoice", "credit_to", "supplier"
else:
return
invoice = frappe.qb.DocType(doctype)
query = (
frappe.qb.from_(invoice)
.select(
invoice.name,
invoice[account_field].as_("account"),
invoice[party_field].as_("party"),
invoice.outstanding_amount,
)
.where(
(invoice.docstatus == 1)
& (invoice.company == self.company)
& (invoice.outstanding_amount > 0)
)
)
if flt(self.write_off_amount) > 0:
query = query.where(invoice.outstanding_amount <= flt(self.write_off_amount))
return query.run(as_dict=True)
def validate_credit_debit_note(self):
if self.stock_entry:
@@ -962,7 +1014,7 @@ def get_default_bank_cash_account(
account: str | None = None,
*,
fetch_balance: bool = True,
):
) -> dict:
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
if mode_of_payment:
@@ -1017,7 +1069,8 @@ def get_against_jv(
start: int,
page_len: int,
filters: dict,
):
) -> list:
"""Link-field search for submitted Journal Entries having an unreferenced row on an account."""
if not frappe.db.has_column("Journal Entry", searchfield):
return []
@@ -1048,67 +1101,97 @@ def get_against_jv(
@frappe.whitelist()
def get_outstanding(args: str | dict):
def get_outstanding(
doctype: str | None = None,
docname: str | None = None,
company: str | None = None,
account: str | None = None,
party: str | None = None,
account_currency: str | None = None,
**kwargs,
) -> dict | None:
"""Return the outstanding amount and side to set when referencing a JV / Invoice.
The named parameters are the supported interface. The legacy `args` payload dict
(captured via kwargs) is still accepted for backward compatibility with callers,
including custom apps, and is unpacked into the named parameters below.
"""
if not frappe.has_permission("Account"):
frappe.msgprint(_("No Permission"), raise_exception=1)
if isinstance(args, str):
args = json.loads(args)
if legacy_payload := kwargs.get("args"):
if isinstance(legacy_payload, str):
legacy_payload = json.loads(legacy_payload)
doctype = legacy_payload.get("doctype")
docname = legacy_payload.get("docname")
company = legacy_payload.get("company")
account = legacy_payload.get("account")
party = legacy_payload.get("party")
account_currency = legacy_payload.get("account_currency")
company_currency = erpnext.get_company_currency(args.get("company"))
due_date = None
if doctype == "Journal Entry":
return _get_journal_entry_outstanding(docname, account, party)
if args.get("doctype") == "Journal Entry":
condition = " and party=%(party)s" if args.get("party") else ""
if doctype in ("Sales Invoice", "Purchase Invoice"):
return _get_invoice_outstanding(doctype, docname, company, account_currency)
against_jv_amount = frappe.db.sql(
f"""
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
from `tabJournal Entry Account` where parent=%(docname)s and account=%(account)s {condition}
and (reference_type is null or reference_type = '')""",
args,
def _get_journal_entry_outstanding(docname: str, account: str | None, party: str | None) -> dict:
"""Unreferenced debit-minus-credit balance for an account on a Journal Entry."""
jea = frappe.qb.DocType("Journal Entry Account")
query = (
frappe.qb.from_(jea)
.select(Sum(jea.debit_in_account_currency) - Sum(jea.credit_in_account_currency))
.where(
(jea.parent == docname)
& (jea.account == account)
& (jea.reference_type.isnull() | (jea.reference_type == ""))
)
)
if party:
query = query.where(jea.party == party)
result = query.run()
balance = flt(result[0][0]) if result else 0
amount_field = "credit_in_account_currency" if balance > 0 else "debit_in_account_currency"
return {amount_field: abs(balance)}
def _get_invoice_outstanding(doctype: str, docname: str, company: str, account_currency: str | None) -> dict:
"""Outstanding amount, side, party and exchange rate for a Sales/Purchase Invoice."""
party_type = "Customer" if doctype == "Sales Invoice" else "Supplier"
invoice = frappe.db.get_value(
doctype,
docname,
["outstanding_amount", "conversion_rate", scrub(party_type), "due_date"],
as_dict=1,
)
company_currency = erpnext.get_company_currency(company)
exchange_rate = invoice.conversion_rate if account_currency != company_currency else 1
outstanding_is_positive = flt(invoice.outstanding_amount) > 0
if doctype == "Sales Invoice":
amount_field = (
"credit_in_account_currency" if outstanding_is_positive else "debit_in_account_currency"
)
else:
amount_field = (
"debit_in_account_currency" if outstanding_is_positive else "credit_in_account_currency"
)
against_jv_amount = flt(against_jv_amount[0][0]) if against_jv_amount else 0
amount_field = "credit_in_account_currency" if against_jv_amount > 0 else "debit_in_account_currency"
return {amount_field: abs(against_jv_amount)}
elif args.get("doctype") in ("Sales Invoice", "Purchase Invoice"):
party_type = "Customer" if args.get("doctype") == "Sales Invoice" else "Supplier"
invoice = frappe.db.get_value(
args["doctype"],
args["docname"],
["outstanding_amount", "conversion_rate", scrub(party_type), "due_date"],
as_dict=1,
)
due_date = invoice.get("due_date")
exchange_rate = invoice.conversion_rate if (args.get("account_currency") != company_currency) else 1
if args["doctype"] == "Sales Invoice":
amount_field = (
"credit_in_account_currency"
if flt(invoice.outstanding_amount) > 0
else "debit_in_account_currency"
)
else:
amount_field = (
"debit_in_account_currency"
if flt(invoice.outstanding_amount) > 0
else "credit_in_account_currency"
)
return {
amount_field: abs(flt(invoice.outstanding_amount)),
"exchange_rate": exchange_rate,
"party_type": party_type,
"party": invoice.get(scrub(party_type)),
"reference_due_date": due_date,
}
return {
amount_field: abs(flt(invoice.outstanding_amount)),
"exchange_rate": exchange_rate,
"party_type": party_type,
"party": invoice.get(scrub(party_type)),
"reference_due_date": invoice.get("due_date"),
}
@frappe.whitelist()
def get_party_account_and_currency(company: str, party_type: str, party: str):
def get_party_account_and_currency(company: str, party_type: str, party: str) -> dict:
"""Return the receivable/payable account for a party and its account currency."""
if not frappe.has_permission("Account"):
frappe.msgprint(_("No Permission"), raise_exception=1)
@@ -1128,7 +1211,7 @@ def get_account_details_and_party_type(
debit: float | str | None = None,
credit: float | str | None = None,
exchange_rate: float | str | None = None,
):
) -> dict:
"""Returns dict of account details and party type to be set in Journal Entry on selection of account."""
if not frappe.has_permission("Account"):
frappe.msgprint(_("No Permission"), raise_exception=1)
@@ -1186,7 +1269,8 @@ def get_exchange_rate(
debit: float | str | None = None,
credit: float | str | None = None,
exchange_rate: str | float | None = None,
):
) -> float:
"""Resolve the exchange rate for an account row, by reference, balance or settings."""
# Ensure exchange_rate is always numeric to avoid calculation errors
if isinstance(exchange_rate, str):
exchange_rate = flt(exchange_rate) or 1
@@ -1219,14 +1303,3 @@ def get_exchange_rate(
# don't return None or 0 as it is multipled with a value and that value could be lost
return exchange_rate or 1
@frappe.whitelist()
def get_average_exchange_rate(account: str):
exchange_rate = 0
bank_balance_in_account_currency = get_balance_on(account)
if bank_balance_in_account_currency:
bank_balance_in_company_currency = get_balance_on(account, in_account_currency=False)
exchange_rate = bank_balance_in_company_currency / bank_balance_in_account_currency
return exchange_rate

View File

@@ -24,7 +24,8 @@ def get_payment_entry_against_order(
debit_in_account_currency: str | float | None = None,
journal_entry: bool = False,
bank_account: str | None = None,
):
) -> dict | Document:
"""Build an advance-payment Journal Entry against an unbilled Sales/Purchase Order."""
ref_doc = frappe.get_doc(dt, dn)
if flt(ref_doc.per_billed, 2) > 0:
@@ -74,7 +75,8 @@ def get_payment_entry_against_invoice(
debit_in_account_currency: str | None = None,
journal_entry: bool = False,
bank_account: str | None = None,
):
) -> dict | Document:
"""Build a payment Journal Entry against a Sales/Purchase Invoice's outstanding amount."""
ref_doc = frappe.get_doc(dt, dn)
if dt == "Sales Invoice":
party_type = "Customer"
@@ -110,32 +112,54 @@ def get_payment_entry_against_invoice(
)
def get_payment_entry(ref_doc, args):
from erpnext.accounts.doctype.journal_entry.journal_entry import (
get_default_bank_cash_account,
get_exchange_rate,
)
def get_payment_entry(ref_doc, args: dict) -> dict | Document:
"""Build a Bank Entry Journal Entry paying `ref_doc`, with a party row and a bank row.
Returns the Journal Entry document when `args["journal_entry"]` is truthy, otherwise its
dict (for client calls).
"""
je = frappe.new_doc("Journal Entry")
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
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,
)
exchange_rate = _reference_exchange_rate(ref_doc, args)
je = frappe.new_doc("Journal Entry")
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
party_row = _append_party_row(je, ref_doc, args, cost_center, exchange_rate)
bank_row = _append_bank_row(je, ref_doc, args, cost_center, exchange_rate)
party_row = je.append(
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()
def _reference_exchange_rate(ref_doc, args: dict) -> float:
"""Exchange rate of the party account on the reference document's posting date."""
if not args.get("party_account"):
return 1
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
return 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,
)
def _append_party_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
"""Append the party (debtor/creditor) row that records the advance/payment."""
return je.append(
"accounts",
{
"account": args.get("party_account"),
@@ -153,14 +177,19 @@ def get_payment_entry(ref_doc, args):
},
)
bank_row = je.append("accounts")
# Make it bank_details
def _append_bank_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
"""Append the bank/cash row, defaulting the account and converting the amount to it."""
from erpnext.accounts.doctype.journal_entry.journal_entry import (
get_default_bank_cash_account,
get_exchange_rate,
)
bank_row = je.append("accounts")
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
# posting date assumed to be the reference document's posting/transaction date
bank_row.exchange_rate = get_exchange_rate(
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
bank_account["account"],
@@ -171,26 +200,17 @@ def get_payment_entry(ref_doc, args):
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()
return bank_row
@frappe.whitelist()
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str):
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str) -> dict:
"""Build the counterpart Journal Entry in another company, linked back to `name`."""
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = voucher_type
journal_entry.company = company
@@ -200,7 +220,8 @@ def make_inter_company_journal_entry(name: str, voucher_type: str, company: str)
@frappe.whitelist()
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None):
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None) -> Document:
"""Map a submitted Journal Entry to a reversing one (debits and credits swapped)."""
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
if existing_reverse:
frappe.throw(
@@ -211,7 +232,7 @@ def make_reverse_journal_entry(source_name: str, target_doc: str | Document | No
from frappe.model.mapper import get_mapped_doc
def post_process(source, target):
def post_process(source, target) -> None:
target.reversal_of = source.name
doclist = get_mapped_doc(

View File

@@ -20,10 +20,11 @@ class AssetService:
Journal Entries tied to asset scrapping or value adjustments.
"""
def __init__(self, doc):
def __init__(self, doc) -> None:
self.doc = doc
def validate_depr_account_and_depr_entry_voucher_type(self):
def validate_depr_account_and_depr_entry_voucher_type(self) -> None:
"""A depreciation account requires voucher type Depreciation Entry and an Expense account."""
for d in self.doc.get("accounts"):
if d.account_type == "Depreciation":
if self.doc.voucher_type != "Depreciation Entry":
@@ -34,7 +35,8 @@ class AssetService:
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):
def has_asset_adjustment_entry(self) -> None:
"""Block cancellation while a submitted Asset Value Adjustment links to this entry."""
if self.doc.flags.get("via_asset_value_adjustment"):
return
@@ -48,11 +50,13 @@ class AssetService:
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
)
def update_asset_value(self):
def update_asset_value(self) -> None:
"""Apply the entry's effect to its linked assets on submit (depreciation or disposal)."""
self.update_asset_on_depreciation()
self.update_asset_on_disposal()
def update_asset_on_depreciation(self):
def update_asset_on_depreciation(self) -> None:
"""Reduce each depreciated asset's value and link the depreciation schedule row."""
if self.doc.voucher_type != "Depreciation Entry":
return
@@ -73,7 +77,8 @@ class AssetService:
asset.set_status()
asset.set_total_booked_depreciations()
def update_value_after_depreciation(self, asset, depr_amount):
def update_value_after_depreciation(self, asset, depr_amount: float) -> None:
"""Subtract the depreciation amount from the asset's relevant finance book."""
fb_idx = 1
if self.doc.finance_book:
for fb_row in asset.get("finance_books"):
@@ -86,7 +91,8 @@ class AssetService:
"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):
def update_journal_entry_link_on_depr_schedule(self, asset, je_row) -> None:
"""Stamp this entry onto the matching (date + amount) depreciation schedule row."""
depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book)
for d in depr_schedule or []:
if (
@@ -96,7 +102,8 @@ class AssetService:
):
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)
def update_asset_on_disposal(self):
def update_asset_on_disposal(self) -> None:
"""Mark each referenced asset disposed (date + scrap entry) on an Asset Disposal."""
if self.doc.voucher_type == "Asset Disposal":
disposed_assets = []
for d in self.doc.get("accounts"):
@@ -117,62 +124,74 @@ class AssetService:
asset_doc.set_status()
disposed_assets.append(d.reference_name)
def unlink_asset_reference(self):
def unlink_asset_reference(self) -> None:
"""On cancel, reverse depreciation links and block cancelling an asset-scrap entry."""
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()
if self._is_depreciation_asset_row(d):
self._reverse_asset_depreciation(d)
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"
)
self._block_scrap_journal_cancel(d)
if journal_entry_for_scrap == self.doc.name:
frappe.throw(
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
)
def _is_depreciation_asset_row(self, d) -> bool:
return bool(
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
)
def unlink_asset_adjustment_entry(self):
def _reverse_asset_depreciation(self, d) -> None:
"""Add the depreciation amount back to the asset and unlink its schedule row."""
asset = frappe.get_doc("Asset", d.reference_name)
if asset.calculate_depreciation and not self._restore_scheduled_depreciation(asset, d.debit):
self._restore_finance_book_value(asset, d.debit)
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
asset.set_status()
asset.set_total_booked_depreciations()
def _restore_scheduled_depreciation(self, asset, debit: float) -> bool:
"""Unlink this entry from the depreciation schedule and credit back its finance book.
Returns True if a matching scheduled depreciation was found.
"""
for fb_row in asset.get("finance_books"):
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 += debit
fb_row.db_update()
return True
return False
def _restore_finance_book_value(self, asset, debit: float) -> None:
"""Credit the depreciation amount back to the relevant finance book when no schedule matched."""
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 += debit
fb_row.db_update()
def _block_scrap_journal_cancel(self, d) -> None:
"""Prevent cancelling a plain Journal Entry that is an asset's scrap voucher."""
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) -> None:
"""Detach this entry from any Asset Value Adjustment that referenced it."""
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
(
frappe.qb.update(AssetValueAdjustment)

View File

@@ -18,86 +18,88 @@ class JournalEntryGLComposer(BaseGLComposer):
from the first foreign-currency row (mirroring the former build_gl_map).
"""
def compose(self):
doc = self.doc
gl_map = []
company_currency = erpnext.get_company_currency(doc.company)
doc.transaction_currency = company_currency
doc.transaction_exchange_rate = 1
if doc.multi_currency:
for row in doc.get("accounts"):
if row.account_currency != company_currency:
# Journal assumes the first foreign currency as transaction currency
doc.transaction_currency = row.account_currency
doc.transaction_exchange_rate = row.exchange_rate
break
def compose(self) -> list:
"""Project the Journal Entry's non-zero account rows into GL dicts."""
self._set_transaction_currency()
advance_doctypes = get_advance_payment_doctypes()
for d in doc.get("accounts"):
if d.debit or d.credit or (doc.voucher_type == "Exchange Gain Or Loss"):
r = [d.user_remark, doc.remark]
r = [x for x in r if x]
remarks = "\n".join(r)
row = {
"account": d.account,
"party_type": d.party_type,
"due_date": doc.due_date,
"party": d.party,
"against": d.against_account,
"debit": flt(d.debit, d.precision("debit")),
"credit": flt(d.credit, d.precision("credit")),
"account_currency": d.account_currency,
"debit_in_account_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
),
"credit_in_account_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
),
"transaction_currency": doc.transaction_currency,
"transaction_exchange_rate": doc.transaction_exchange_rate,
"debit_in_transaction_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
"credit_in_transaction_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
"against_voucher_type": d.reference_type,
"against_voucher": d.reference_name,
"remarks": remarks,
"voucher_detail_no": d.reference_detail_no,
"cost_center": d.cost_center,
"project": d.project,
"finance_book": doc.finance_book,
"advance_voucher_type": d.advance_voucher_type,
"advance_voucher_no": d.advance_voucher_no,
}
if d.reference_type in advance_doctypes:
row.update(
{
"against_voucher_type": doc.doctype,
"against_voucher": doc.name,
"advance_voucher_type": d.reference_type,
"advance_voucher_no": d.reference_name,
}
)
# set flag to skip party validation
account_type = frappe.get_cached_value("Account", d.account, "account_type")
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
frappe.flags.party_not_required = True
gl_map.append(
self.get_gl_dict(
row,
item=d,
)
)
gl_map = []
for d in self.doc.get("accounts"):
if d.debit or d.credit or self.doc.voucher_type == "Exchange Gain Or Loss":
gl_map.append(self.get_gl_dict(self._gl_row(d, advance_doctypes), item=d))
return gl_map
def _set_transaction_currency(self) -> None:
"""Company currency, or the first foreign-currency row, becomes the transaction currency."""
doc = self.doc
doc.transaction_currency = erpnext.get_company_currency(doc.company)
doc.transaction_exchange_rate = 1
if not doc.multi_currency:
return
for row in doc.get("accounts"):
if row.account_currency != doc.transaction_currency:
# Journal assumes the first foreign currency as transaction currency
doc.transaction_currency = row.account_currency
doc.transaction_exchange_rate = row.exchange_rate
break
def _gl_row(self, d, advance_doctypes: list) -> dict:
"""Build the GL dict for a single account row."""
doc = self.doc
remarks = "\n".join(x for x in [d.user_remark, doc.remark] if x)
row = {
"account": d.account,
"party_type": d.party_type,
"due_date": doc.due_date,
"party": d.party,
"against": d.against_account,
"debit": flt(d.debit, d.precision("debit")),
"credit": flt(d.credit, d.precision("credit")),
"account_currency": d.account_currency,
"debit_in_account_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
),
"credit_in_account_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
),
"transaction_currency": doc.transaction_currency,
"transaction_exchange_rate": doc.transaction_exchange_rate,
"debit_in_transaction_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
"credit_in_transaction_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
"against_voucher_type": d.reference_type,
"against_voucher": d.reference_name,
"remarks": remarks,
"voucher_detail_no": d.reference_detail_no,
"cost_center": d.cost_center,
"project": d.project,
"finance_book": doc.finance_book,
"advance_voucher_type": d.advance_voucher_type,
"advance_voucher_no": d.advance_voucher_no,
}
if d.reference_type in advance_doctypes:
row.update(
{
"against_voucher_type": doc.doctype,
"against_voucher": doc.name,
"advance_voucher_type": d.reference_type,
"advance_voucher_no": d.reference_name,
}
)
# set flag to skip party validation
account_type = frappe.get_cached_value("Account", d.account, "account_type")
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
frappe.flags.party_not_required = True
return row

View File

@@ -29,10 +29,11 @@ class JournalEntryReferenceValidator:
orders and invoices.
"""
def __init__(self, doc):
def __init__(self, doc) -> None:
self.doc = doc
def validate(self):
def validate(self) -> None:
"""Validate every reference-bearing row, then the referenced orders and invoices."""
self.doc.reference_totals = {}
self.doc.reference_types = {}
self.doc.reference_accounts = {}
@@ -47,23 +48,24 @@ class JournalEntryReferenceValidator:
self._validate_orders()
self._validate_invoices()
def _normalize_reference_fields(self, row):
def _normalize_reference_fields(self, row) -> None:
if not row.reference_type:
row.reference_name = None
if not row.reference_name:
row.reference_type = None
def _has_party_reference(self, row):
def _has_party_reference(self, row) -> bool:
return bool(
row.reference_type and row.reference_name and row.reference_type in REFERENCE_PARTY_ACCOUNT_FIELDS
)
def _reference_amount_field(self, row):
def _reference_amount_field(self, row) -> str:
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):
def _validate_order_direction(self, row) -> None:
"""An order can only be linked on the side that records an advance."""
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)
@@ -73,7 +75,8 @@ class JournalEntryReferenceValidator:
_("Row {0}: Credit entry can not be linked with a {1}").format(row.idx, row.reference_type)
)
def _register_reference(self, row):
def _register_reference(self, row) -> None:
"""Aggregate the row's amount, type and account onto the per-reference lookups."""
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"):
@@ -81,7 +84,8 @@ class JournalEntryReferenceValidator:
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):
def _validate_reference_party_and_account(self, row) -> None:
"""Reject a missing reference, then check party/account against the linked document."""
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]
@@ -94,7 +98,7 @@ class JournalEntryReferenceValidator:
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):
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields) -> None:
party_account, against_party = self._resolve_invoice_party_account(row, against_voucher)
if self.doc.voucher_type == "Exchange Gain Or Loss":
return
@@ -105,7 +109,9 @@ class JournalEntryReferenceValidator:
)
)
def _resolve_invoice_party_account(self, row, against_voucher):
def _resolve_invoice_party_account(self, row, against_voucher) -> tuple:
"""Expected (party_account, party) for an invoice row, honouring deferred booking
and invoice-discounting accounts."""
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(
@@ -120,7 +126,7 @@ class JournalEntryReferenceValidator:
party_account = against_voucher[1]
return party_account, against_voucher[0]
def _validate_order_party(self, row, against_voucher):
def _validate_order_party(self, row, against_voucher) -> None:
if against_voucher != row.party:
frappe.throw(
_("Row {0}: {1} {2} does not match with {3}").format(
@@ -128,8 +134,8 @@ class JournalEntryReferenceValidator:
)
)
def _validate_orders(self):
"""Validate totals, closed and docstatus for orders"""
def _validate_orders(self) -> None:
"""Validate totals, closed and docstatus for referenced 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]
@@ -140,7 +146,7 @@ class JournalEntryReferenceValidator:
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):
def _validate_order_status(self, order, reference_type, reference_name) -> None:
if order.docstatus != 1:
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
if flt(order.per_billed) >= 100:
@@ -148,7 +154,8 @@ class JournalEntryReferenceValidator:
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):
def _validate_order_advance_total(self, order, account, total, reference_type, reference_name) -> None:
"""The advance paid against an order cannot exceed its grand total."""
account_currency = get_account_currency(account)
if account_currency == self.doc.company_currency:
voucher_total = order.base_grand_total
@@ -167,8 +174,8 @@ class JournalEntryReferenceValidator:
)
)
def _validate_invoices(self):
"""Validate totals and docstatus for invoices"""
def _validate_invoices(self) -> None:
"""Validate totals and docstatus for referenced invoices."""
if self.doc.voucher_type in ("Debit Note", "Credit Note"):
return
for reference_name, total in self.doc.reference_totals.items():
@@ -178,7 +185,8 @@ class JournalEntryReferenceValidator:
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):
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name) -> None:
"""Payment booked against an invoice cannot exceed its outstanding amount."""
if invoice.docstatus != 1:
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))

View File

@@ -169,8 +169,11 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency",
"credit",
"credit_in_account_currency",
"debit_in_transaction_currency",
"credit_in_transaction_currency",
]
# Transaction currency is USD (first foreign row); the INR row is converted at 1/50.
self.expected_gle = [
{
"account": "_Test Bank - _TC",
@@ -179,6 +182,8 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency": 0,
"credit": 5000,
"credit_in_account_currency": 5000,
"debit_in_transaction_currency": 0,
"credit_in_transaction_currency": 100,
},
{
"account": "_Test Bank USD - _TC",
@@ -187,6 +192,8 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency": 100,
"credit": 0,
"credit_in_account_currency": 0,
"debit_in_transaction_currency": 100,
"credit_in_transaction_currency": 0,
},
]
@@ -203,6 +210,52 @@ class TestJournalEntry(ERPNextTestSuite):
self.assertFalse(gle)
def test_multi_currency_transaction_currency_on_foreign_debit(self):
"""Pin debit_in_transaction_currency for a foreign-currency debit row.
Transaction currency is USD (the first foreign row); the INR debit row must be
converted at 1/exchange_rate, so 5000 INR -> 100 USD. Guards the / vs * direction.
"""
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.multi_currency = 1
jv.append(
"accounts",
{
"account": "_Test Bank USD - _TC",
"cost_center": "_Test Cost Center - _TC",
"credit_in_account_currency": 100,
"exchange_rate": 50,
},
)
jv.append(
"accounts",
{
"account": "_Test Bank - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit_in_account_currency": 5000,
"exchange_rate": 1,
},
)
jv.submit()
self.voucher_no = jv.name
self.fields = ["account", "debit_in_transaction_currency", "credit_in_transaction_currency"]
self.expected_gle = [
{
"account": "_Test Bank - _TC",
"debit_in_transaction_currency": 100,
"credit_in_transaction_currency": 0,
},
{
"account": "_Test Bank USD - _TC",
"debit_in_transaction_currency": 0,
"credit_in_transaction_currency": 100,
},
]
self.check_gl_entries()
def test_reverse_journal_entry(self):
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
@@ -688,6 +741,95 @@ class TestJournalEntry(ERPNextTestSuite):
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
def test_get_balance_places_difference_on_blank_row(self):
"""Characterize: get_balance puts the unbalanced difference on an amountless row."""
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.append(
"accounts",
{
"account": "_Test Cash - _TC",
"debit_in_account_currency": 100,
"debit": 100,
"exchange_rate": 1,
},
)
jv.append("accounts", {"account": "_Test Bank - _TC", "exchange_rate": 1}) # amountless row
jv.set_total_debit_credit()
self.assertEqual(jv.difference, 100)
jv.get_balance()
blank_row = jv.accounts[1]
self.assertEqual(blank_row.credit_in_account_currency, 100)
self.assertEqual(jv.total_debit, jv.total_credit)
def test_get_outstanding_invoices_builds_write_off_rows(self):
"""Characterize: get_outstanding_invoices adds a party row for each outstanding invoice."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=700)
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.voucher_type = "Write Off Entry"
jv.write_off_based_on = "Accounts Receivable"
jv.write_off_amount = 1000
jv.get_outstanding_invoices()
invoice_rows = [row for row in jv.accounts if row.reference_name == invoice.name]
self.assertTrue(invoice_rows)
self.assertEqual(invoice_rows[0].party_type, "Customer")
self.assertEqual(invoice_rows[0].reference_type, "Sales Invoice")
self.assertEqual(flt(invoice_rows[0].credit_in_account_currency), 700)
def test_unlink_advance_entry_reference_on_cancel(self):
"""Characterize: cancelling an advance JE against an invoice clears the row's reference."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=700)
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
advance_row = jv.accounts[1]
advance_row.party_type = "Customer"
advance_row.party = "_Test Customer"
advance_row.is_advance = "Yes"
advance_row.reference_type = "Sales Invoice"
advance_row.reference_name = invoice.name
jv.submit()
jv.cancel()
jv.reload()
self.assertFalse(jv.accounts[1].reference_type)
self.assertFalse(jv.accounts[1].reference_name)
def test_get_payment_entry_against_order_builds_advance_je(self):
"""Characterize the mapper: an advance Bank Entry JE is built against an unbilled order."""
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_order
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
sales_order = make_sales_order()
je = get_payment_entry_against_order("Sales Order", sales_order.name, journal_entry=True)
self.assertEqual(je.voucher_type, "Bank Entry")
party_rows = [row for row in je.accounts if row.party_type == "Customer"]
self.assertTrue(party_rows)
self.assertEqual(party_rows[0].reference_type, "Sales Order")
self.assertEqual(party_rows[0].reference_name, sales_order.name)
self.assertEqual(party_rows[0].is_advance, "Yes")
def test_make_inter_company_journal_entry_builds_linked_draft(self):
"""Characterize the mapper: the counterpart JE carries the company and back-reference."""
from erpnext.accounts.doctype.journal_entry.mapper import make_inter_company_journal_entry
source = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, submit=True)
result = make_inter_company_journal_entry(
source.name, "Inter Company Journal Entry", "_Test Company 1"
)
self.assertEqual(result.get("voucher_type"), "Inter Company Journal Entry")
self.assertEqual(result.get("company"), "_Test Company 1")
self.assertEqual(result.get("inter_company_journal_entry_reference"), source.name)
def make_journal_entry(
account1,

View File

@@ -614,10 +614,12 @@
{
"default": "0",
"depends_on": "eval:doc.items.every((item) => !item.pr_detail)",
"description": "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Purchase Receipt is created separately.",
"fieldname": "update_stock",
"fieldtype": "Check",
"label": "Update Stock",
"print_hide": 1
"print_hide": 1,
"show_description_on_click": 1
},
{
"fieldname": "scan_barcode",
@@ -1690,7 +1692,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-05-28 12:36:55.215363",
"modified": "2026-06-13 18:36:46.704623",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -51,16 +51,6 @@ class ExpenseAccountService:
if doc.update_stock and item.warehouse and (not item.from_warehouse):
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
if for_validate and item.expense_account and item.expense_account != _inv_dict["account"]:
msg = _(
"Row {0}: Expense Head changed to {1} because account {2} is not linked to warehouse {3} or it is not the default inventory account"
).format(
item.idx,
frappe.bold(_inv_dict["account"]),
frappe.bold(item.expense_account),
frappe.bold(item.warehouse),
)
frappe.msgprint(msg, title=_("Expense Head Changed"))
item.expense_account = _inv_dict["account"]
else:
# check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not

View File

@@ -158,6 +158,7 @@ def start_repost(account_repost_doc: str | None = None) -> None:
frappe.flags.through_repost_accounting_ledger = True
if account_repost_doc:
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
repost_doc.check_permission("write")
if repost_doc.docstatus == 1:
# Prevent repost on invoices with deferred accounting

View File

@@ -715,6 +715,7 @@
{
"default": "0",
"depends_on": "eval:doc.items.every((item) => !item.dn_detail)",
"description": "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Delivery Note is created separately.",
"fieldname": "update_stock",
"fieldtype": "Check",
"hide_days": 1,
@@ -722,7 +723,8 @@
"label": "Update Stock",
"oldfieldname": "update_stock",
"oldfieldtype": "Check",
"print_hide": 1
"print_hide": 1,
"show_description_on_click": 1
},
{
"fieldname": "scan_barcode",

View File

@@ -511,6 +511,7 @@ def get_party_advance_account(party_type, party, company):
@frappe.whitelist()
def get_party_bank_account(party_type: str, party: str):
frappe.has_permission("Bank Account", "read", throw=True)
return frappe.db.get_value("Bank Account", {"party_type": party_type, "party": party, "is_default": 1})

View File

@@ -927,8 +927,28 @@ class ReceivablePayableReport:
if self.filters.project:
self.qb_selection_filter.append(self.ple.project.isin(self.filters.project))
self.add_user_permission_filters()
self.add_accounting_dimensions_filters()
def add_user_permission_filters(self):
# Party is a dynamic link, so match conditions cannot auto-apply Customer/Supplier user permissions
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
from frappe.permissions import get_allowed_docs_for_doctype
user_permissions = get_user_permissions()
if not user_permissions:
return
for party_type in self.party_type:
if party_type not in user_permissions:
continue
allowed_parties = get_allowed_docs_for_doctype(user_permissions[party_type], party_type)
self.qb_selection_filter.append(
(self.ple.party_type != party_type) | self.ple.party.isin(allowed_parties or [""])
)
def get_cost_center_conditions(self):
cost_center_list = get_cost_centers_with_children(self.filters.cost_center)
self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list))

View File

@@ -1243,3 +1243,44 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
self.assertEqual(len(report[1]), 1)
row = report[1][0]
self.assertEqual([si.name, project.name, 60], [row.voucher_no, row.project, row.outstanding])
def test_accounts_receivable_respects_user_permissions(self):
# Party is a dynamic link on Payment Ledger Entry, so user permissions on Customer
# must be applied explicitly. The report should only show permitted customers.
original_customer = self.customer
second_customer = "_Test AR Perm Customer"
# create_customer overrides self.customer, so build the restricted invoice first
self.create_customer(customer_name=second_customer)
self.create_sales_invoice(no_payment_schedule=True)
self.customer = original_customer
allowed_invoice = self.create_sales_invoice(no_payment_schedule=True)
test_user = "test_ar_user_permission@example.com"
if not frappe.db.exists("User", test_user):
user = frappe.new_doc("User")
user.email = test_user
user.first_name = "AR Perm"
user.append("roles", {"role": "Accounts User"})
user.save()
frappe.permissions.add_user_permission("Customer", original_customer, test_user)
filters = {
"company": self.company,
"party_type": "Customer",
"report_date": today(),
"range": "30, 60, 90, 120",
}
frappe.set_user(test_user)
try:
report = execute(filters)
finally:
frappe.set_user("Administrator")
parties = {row.party for row in report[1]}
self.assertIn(original_customer, parties)
self.assertNotIn(second_customer, parties)
self.assertEqual(allowed_invoice.customer, original_customer)

View File

@@ -598,6 +598,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
target_doc.so_detail = source_doc.so_detail
target_doc.expense_account = source_doc.expense_account
target_doc.dn_detail = source_doc.name
target_doc.cost_center = source_doc.cost_center
if default_warehouse_for_sales_return:
target_doc.warehouse = default_warehouse_for_sales_return
elif doctype == "Sales Invoice" or doctype == "POS Invoice":

View File

@@ -425,7 +425,12 @@ class SellingController(StockController):
row.new_item_code
for row in frappe.get_all(
"Product Bundle",
filters={"new_item_code": ("in", items_to_fetch), "is_active": 1, "docstatus": 1},
filters={
"new_item_code": ("in", items_to_fetch),
"is_active": 1,
"docstatus": 1,
"disabled": 0,
},
fields="new_item_code",
)
}
@@ -979,9 +984,14 @@ class SellingController(StockController):
qty_can_be_deliver = 0
if sre_doc.reservation_based_on == "Serial and Batch":
sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
# Delivered serial/batch may live in a Serial and Batch Bundle or directly in the
# row's serial_no/batch_no fields (use_serial_batch_fields). Read from whichever is
# present so this never crashes on a missing bundle.
(
delivered_serial_nos,
delivered_batch_qty,
) = get_delivered_serial_batch_for_reservation(item)
if sre_doc.has_serial_no:
delivered_serial_nos = [d.serial_no for d in sbb.entries]
for entry in sre_doc.sb_entries:
if entry.serial_no in delivered_serial_nos:
entry.delivered_qty = 1 # Qty will always be 0 or 1 for Serial No.
@@ -989,16 +999,16 @@ class SellingController(StockController):
qty_can_be_deliver += 1
delivered_serial_nos.remove(entry.serial_no)
else:
delivered_batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries}
for entry in sre_doc.sb_entries:
if entry.batch_no in delivered_batch_qty:
available_batch_qty = delivered_batch_qty.get(entry.batch_no, 0)
if available_batch_qty > 0:
delivered_qty = min(
(entry.qty - entry.delivered_qty), delivered_batch_qty[entry.batch_no]
(entry.qty - entry.delivered_qty), available_batch_qty
)
entry.delivered_qty += delivered_qty
entry.db_update()
qty_can_be_deliver += delivered_qty
delivered_batch_qty[entry.batch_no] -= delivered_qty
delivered_batch_qty[entry.batch_no] = available_batch_qty - delivered_qty
else:
# `Delivered Qty` should be less than or equal to `Reserved Qty`.
qty_can_be_deliver = min(
@@ -1174,3 +1184,31 @@ def get_serial_and_batch_bundle(child, parent, delivery_note_child=None):
child.db_set("serial_and_batch_bundle", doc.name)
return doc.name
def get_delivered_serial_batch_for_reservation(item):
"""Serial nos and per-batch qty delivered by a stock row.
The detail may be stored in a Serial and Batch Bundle or directly in the row's
``serial_no``/``batch_no`` fields (``use_serial_batch_fields``). Reading from whichever is
present keeps the Stock Reservation Entry delivered-qty update independent of a bundle being
created -- delivering reserved serial/batch stock used to crash when the row had no bundle.
"""
serial_nos, batch_qty = [], {}
if item.get("serial_and_batch_bundle"):
bundle = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
for row in bundle.entries:
if row.serial_no:
serial_nos.append(row.serial_no)
if row.batch_no:
batch_qty[row.batch_no] = batch_qty.get(row.batch_no, 0) + abs(flt(row.qty))
else:
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
if item.get("serial_no"):
serial_nos = get_serial_nos(item.serial_no)
if item.get("batch_no"):
batch_qty[item.batch_no] = abs(flt(item.get("stock_qty") or item.get("qty")))
return serial_nos, batch_qty

View File

@@ -14,6 +14,7 @@
"opportunity_section",
"close_opportunity_after_days",
"column_break_9",
"enable_opportunity_creation_from_contact_us",
"quotation_section",
"default_valid_till",
"section_break_13",
@@ -98,15 +99,20 @@
"fieldname": "update_timestamp_on_new_communication",
"fieldtype": "Check",
"label": "Update timestamp on new communication"
},
{
"default": "0",
"fieldname": "enable_opportunity_creation_from_contact_us",
"fieldtype": "Check",
"label": "Enable Opportunity Creation from Contact Us"
}
],
"grid_page_length": 50,
"hide_toolbar": 0,
"icon": "fa fa-cog",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-03-16 13:28:19.573964",
"modified": "2026-06-11 23:09:49.750381",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",

View File

@@ -2,6 +2,7 @@
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
@@ -20,8 +21,20 @@ class CRMSettings(Document):
carry_forward_communication_and_comments: DF.Check
close_opportunity_after_days: DF.Int
default_valid_till: DF.Data | None
enable_opportunity_creation_from_contact_us: DF.Check
update_timestamp_on_new_communication: DF.Check
# end: auto-generated types
def validate(self):
frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", ""))
self.validate_enable_opportunity_creation_from_contact_us()
def validate_enable_opportunity_creation_from_contact_us(self):
contact_disabled = frappe.get_single_value("Contact Us Settings", "is_disabled")
if self.enable_opportunity_creation_from_contact_us and contact_disabled:
frappe.throw(
_(
"Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled."
)
)

View File

@@ -8,7 +8,7 @@ from frappe.contacts.address_and_contact import (
load_address_and_contact,
)
from frappe.model.document import Document
from frappe.utils import comma_and, get_link_to_form, has_gravatar, validate_email_address
from frappe.utils import comma_and, get_link_to_form, validate_email_address
from frappe.utils.data import DateTimeLikeObject
from erpnext.accounts.party import set_taxes
@@ -173,9 +173,6 @@ class Lead(SellingController, CRMNote):
if self.email_id == self.lead_owner:
frappe.throw(_("Lead Owner cannot be same as the Lead Email Address"))
if self.is_new() or not self.image:
self.image = has_gravatar(self.email_id)
def link_to_contact(self):
# update contact links
if self.contact_doc:

View File

@@ -130,7 +130,6 @@ def make_lead_from_communication(communication: str, ignore_communication_links:
}
)
lead.flags.ignore_mandatory = True
lead.flags.ignore_permissions = True
lead.insert()
lead_name = lead.name

View File

@@ -145,7 +145,7 @@ def make_opportunity_from_communication(
"opportunity_from": opportunity_from,
"party_name": lead,
}
).insert(ignore_permissions=True)
).insert()
link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links)

View File

@@ -5,6 +5,11 @@ from frappe.utils import cstr, now, today
from pypika import functions
def disable_opportunity_creation_on_contact_us_disabled(doc, method):
if doc.is_disabled:
frappe.db.set_single_value("CRM Settings", "enable_opportunity_creation_from_contact_us", 0)
def update_lead_phone_numbers(contact, method):
if contact.phone_nos:
contact_lead = contact.get_link_for("Lead")

View File

@@ -383,6 +383,9 @@ doc_events = {
"Event": {
"after_insert": "erpnext.crm.utils.link_events_with_prospect",
},
"Contact Us Settings": {
"on_update": "erpnext.crm.utils.disable_opportunity_creation_on_contact_us_disabled",
},
"Sales Invoice": {
"on_submit": [
"erpnext.regional.italy.utils.sales_invoice_on_submit",

View File

@@ -175,6 +175,9 @@ frappe.ui.form.on("BOM", {
with_operations: function (frm) {
frm.set_df_property("fg_based_operating_cost", "hidden", frm.doc.with_operations ? 1 : 0);
frm.trigger("toggle_fields_for_semi_finished_goods");
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
frm.trigger("routing");
}
},
fg_based_operating_cost: function (frm) {
@@ -583,7 +586,7 @@ frappe.ui.form.on("BOM", {
},
routing(frm) {
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations) {
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
frappe.call({
doc: frm.doc,
method: "get_routing",

View File

@@ -245,10 +245,14 @@ class BOMCreator(Document):
frappe.throw(_("Please set {0} in BOM Creator {1}").format(_(label), self.name))
def on_submit(self):
self.enqueue_create_boms()
self.enqueue_bom_creation()
@frappe.whitelist()
def enqueue_create_boms(self):
self.check_permission("submit")
self.enqueue_bom_creation()
def enqueue_bom_creation(self):
frappe.enqueue(
self.create_boms,
queue="short",
@@ -382,6 +386,31 @@ class BOMCreator(Document):
production_item_wise_rm[(row.item_code, row.name)].bom_no = bom.name
@frappe.whitelist()
def edit_bom_creator(self, docname: str, data: str | dict):
if not frappe.db.exists("BOM Creator Item", {"parent": self.name, "name": docname}):
frappe.throw(_("BOM Creator Item with name {0} does not exist").format(docname))
if isinstance(data, str):
data = frappe.parse_json(data)
updated = False
for row in self.items:
if row.name == docname:
for key, value in data.items():
if key in BOM_ITEM_FIELDS and row.get(key) != value:
row.set(key, value)
updated = True
break
if updated:
self.set_rate_for_items()
self.save()
frappe.msgprint(_("Updated successfully"), alert=True)
return self
def has_operations(self):
for row in self.items:
if row.operation:
@@ -393,9 +422,144 @@ class BOMCreator(Document):
def get_default_bom(self, item_code: str):
return frappe.get_cached_value("Item", item_code, "default_bom")
@frappe.whitelist()
def add_item(self, **kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
item_info = get_item_details(kwargs.item_code)
parent_row_no = ""
if kwargs.fg_reference_id and self.name != kwargs.fg_reference_id:
parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id)
kwargs.update(
{
"uom": item_info.stock_uom,
"stock_uom": item_info.stock_uom,
"conversion_factor": 1,
}
)
if parent_row_no:
kwargs.update({"parent_row_no": parent_row_no})
self.append("items", kwargs)
self.save()
return self
@frappe.whitelist()
def add_sub_assembly(self, **kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
bom_item = frappe.parse_json(kwargs.bom_item)
name = kwargs.fg_reference_id
parent_row_no = ""
if not kwargs.convert_to_sub_assembly:
item_info = get_item_details(bom_item.item_code)
parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id)
item_row = self.append(
"items",
{
"item_code": bom_item.item_code,
"qty": bom_item.qty,
"uom": item_info.stock_uom,
"fg_item": kwargs.fg_item,
"conversion_factor": 1,
"parent_row_no": parent_row_no,
"fg_reference_id": name,
"stock_qty": bom_item.qty,
"do_not_explode": 1,
"is_expandable": 1,
"stock_uom": item_info.stock_uom,
"operation": bom_item.operation,
"is_phantom_item": sbool(kwargs.phantom),
},
)
parent_row_no = item_row.idx
name = ""
else:
if sbool(kwargs.phantom):
parent_row = next(item for item in self.items if item.name == kwargs.fg_reference_id)
parent_row.is_phantom_item = 1
parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id)
for row in bom_item.get("items"):
row = frappe._dict(row)
item_info = get_item_details(row.item_code)
self.append(
"items",
{
"item_code": row.item_code,
"qty": row.qty,
"operation": row.operation,
"fg_item": bom_item.item_code,
"uom": item_info.stock_uom,
"fg_reference_id": name,
"parent_row_no": parent_row_no,
"conversion_factor": 1,
"do_not_explode": 1,
"stock_qty": row.qty,
"stock_uom": item_info.stock_uom,
},
)
self.save()
return self
@frappe.whitelist()
def delete_node(self, **kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
updated = False
if kwargs.docname:
row = next((row for row in self.items if row.name == kwargs.docname), None)
if not row:
frappe.throw(_("BOM Creator Item with name {0} does not exist").format(kwargs.docname))
row.delete()
updated = True
items = get_children(parent=kwargs.fg_item, parent_id=self.name)
if items:
for item in items:
updated = True
child_row = next((row for row in self.items if row.name == item.name), None)
if child_row:
child_row.delete()
if item.expandable:
self.delete_node(fg_item=item.value)
if updated:
self.set_rate_for_items()
self.save()
return self
return frappe._dict()
@frappe.whitelist()
def get_children(doctype: str | None = None, parent: str | None = None, **kwargs):
frappe.has_permission("BOM Creator", "read", throw=True)
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
@@ -429,108 +593,6 @@ def get_children(doctype: str | None = None, parent: str | None = None, **kwargs
return frappe.get_all("BOM Creator Item", fields=fields, filters=query_filters, order_by="idx")
@frappe.whitelist()
def add_item(**kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
doc = frappe.get_doc("BOM Creator", kwargs.parent)
item_info = get_item_details(kwargs.item_code)
parent_row_no = ""
if kwargs.fg_reference_id and doc.name != kwargs.fg_reference_id:
parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
kwargs.update(
{
"uom": item_info.stock_uom,
"stock_uom": item_info.stock_uom,
"conversion_factor": 1,
}
)
if parent_row_no:
kwargs.update({"parent_row_no": parent_row_no})
doc.append("items", kwargs)
doc.save()
return doc
@frappe.whitelist()
def add_sub_assembly(**kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
doc = frappe.get_doc("BOM Creator", kwargs.parent)
bom_item = frappe.parse_json(kwargs.bom_item)
name = kwargs.fg_reference_id
parent_row_no = ""
if not kwargs.convert_to_sub_assembly:
item_info = get_item_details(bom_item.item_code)
parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
item_row = doc.append(
"items",
{
"item_code": bom_item.item_code,
"qty": bom_item.qty,
"uom": item_info.stock_uom,
"fg_item": kwargs.fg_item,
"conversion_factor": 1,
"parent_row_no": parent_row_no,
"fg_reference_id": name,
"stock_qty": bom_item.qty,
"do_not_explode": 1,
"is_expandable": 1,
"stock_uom": item_info.stock_uom,
"operation": bom_item.operation,
"is_phantom_item": sbool(kwargs.phantom),
},
)
parent_row_no = item_row.idx
name = ""
else:
if sbool(kwargs.phantom):
parent_row = next(item for item in doc.items if item.name == kwargs.fg_reference_id)
parent_row.db_set("is_phantom_item", 1)
parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id)
for row in bom_item.get("items"):
row = frappe._dict(row)
item_info = get_item_details(row.item_code)
doc.append(
"items",
{
"item_code": row.item_code,
"qty": row.qty,
"operation": row.operation,
"fg_item": bom_item.item_code,
"uom": item_info.stock_uom,
"fg_reference_id": name,
"parent_row_no": parent_row_no,
"conversion_factor": 1,
"do_not_explode": 1,
"stock_qty": row.qty,
"stock_uom": item_info.stock_uom,
},
)
doc.save()
return doc
def get_item_details(item_code):
return frappe.get_cached_value(
"Item", item_code, ["item_name", "description", "image", "stock_uom", "default_bom"], as_dict=1
@@ -548,46 +610,3 @@ def get_parent_row_no(doc, name):
frappe.msgprint(_("Parent Row No not found for {0}").format(name), alert=True)
return None
@frappe.whitelist()
def delete_node(**kwargs):
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
items = get_children(parent=kwargs.fg_item, parent_id=kwargs.parent)
if kwargs.docname:
frappe.delete_doc("BOM Creator Item", kwargs.docname)
for item in items:
frappe.delete_doc("BOM Creator Item", item.name)
if item.expandable:
delete_node(fg_item=item.value, parent=item.parent_id)
doc = frappe.get_doc("BOM Creator", kwargs.parent)
doc.set_rate_for_items()
doc.save()
return doc
@frappe.whitelist()
def edit_bom_creator(doctype: str, docname: str, data: str | dict, parent: str):
if not frappe.has_permission(doctype=doctype, ptype="write", parent_doctype="BOM Creator"):
frappe.throw(_("You do not have permission to edit this document"), frappe.PermissionError)
if isinstance(data, str):
data = frappe.parse_json(data)
frappe.db.set_value(doctype, docname, data)
doc = frappe.get_doc("BOM Creator", parent)
doc.set_rate_for_items()
doc.save()
frappe.msgprint(_("Updated successfully"), alert=True)
return doc

View File

@@ -5,10 +5,6 @@ import random
import frappe
from erpnext.manufacturing.doctype.bom_creator.bom_creator import (
add_item,
add_sub_assembly,
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
@@ -38,8 +34,7 @@ class TestBOMCreator(ERPNextTestSuite):
conversion_rate=1,
)
add_sub_assembly(
parent=doc.name,
doc.add_sub_assembly(
fg_item=final_product,
fg_reference_id=doc.name,
bom_item={
@@ -93,8 +88,7 @@ class TestBOMCreator(ERPNextTestSuite):
conversion_rate=1,
)
add_item(
parent=doc.name,
doc.add_item(
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -137,8 +131,7 @@ class TestBOMCreator(ERPNextTestSuite):
conversion_rate=1,
)
add_item(
parent=doc.name,
doc.add_item(
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -148,9 +141,8 @@ class TestBOMCreator(ERPNextTestSuite):
doc.reload()
self.assertEqual(doc.items[0].is_expandable, 0)
add_sub_assembly(
doc.add_sub_assembly(
convert_to_sub_assembly=1,
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.items[0].name,
bom_item={
@@ -205,8 +197,7 @@ class TestBOMCreator(ERPNextTestSuite):
conversion_rate=1,
)
add_item(
parent=doc.name,
doc.add_item(
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -216,9 +207,8 @@ class TestBOMCreator(ERPNextTestSuite):
doc.reload()
self.assertEqual(doc.items[0].is_expandable, 0)
add_sub_assembly(
doc.add_sub_assembly(
convert_to_sub_assembly=1,
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.items[0].name,
bom_item={
@@ -251,6 +241,43 @@ class TestBOMCreator(ERPNextTestSuite):
data = frappe.get_all("BOM", filters={"bom_creator": doc.name, "docstatus": 1})
self.assertEqual(len(data), 2)
def test_edit_and_delete_reject_unknown_item(self):
final_product = "Bicycle"
make_item(
final_product,
{
"item_group": "Raw Material",
"stock_uom": "Nos",
},
)
doc = make_bom_creator(
name="Bicycle BOM Guarded",
company="_Test Company",
item_code=final_product,
qty=1,
rm_cosy_as_per="Valuation Rate",
currency="INR",
plc_conversion_rate=1,
conversion_rate=1,
)
# Editing a row that does not belong to this BOM Creator must be rejected.
self.assertRaises(
frappe.ValidationError,
doc.edit_bom_creator,
docname="non-existent-row",
data={"qty": 5},
)
# Deleting a row that does not belong to this BOM Creator must be rejected.
self.assertRaises(
frappe.ValidationError,
doc.delete_node,
fg_item=final_product,
docname="non-existent-row",
)
def create_items():
raw_materials = [

View File

@@ -116,17 +116,57 @@ def _sub_assembly_reserved_filter(table, child, item_code, warehouse):
)
class ProductionPlanStockReservation:
"""Reservation lifecycle for a Production Plan.
A Production Plan reserves stock for two of its child tables: the sub-assembly
items it will manufacture and the raw materials of its material-request rows
(see ``_RESERVATION_TABLES``). On submit the rows are reserved; on cancel the
reservations are released.
The reserved-qty *query* helpers in this module
(``get_reserved_qty_for_production_plan`` etc.) are read-only and answer "how
much is reserved?" for bins and reports, so they stay module-level functions
rather than methods, mirroring the engine's own query helpers.
"""
def __init__(self, doc):
self.doc = doc
def reserve(self, items: str | list | None = None, table_name: str | None = None, notify: bool = False):
"""Reserve (docstatus 1) or release (docstatus 2) stock for the plan's tables."""
if items and isinstance(items, str):
items = parse_json(items)
for child_table_name, kwargs in _RESERVATION_TABLES.items():
if table_name and table_name != child_table_name:
continue
self._reserve_or_cancel_plan_table(items, kwargs)
self.doc.reload()
def _reserve_or_cancel_plan_table(self, items, kwargs):
sre = StockReservation(self.doc, items=items, kwargs=kwargs)
if self.doc.docstatus == 1:
if sre.make_stock_reservation_entries():
frappe.msgprint(_("Stock Reservation Entries Created"), alert=True)
elif self.doc.docstatus == 2:
sre.cancel_stock_reservation_entries()
def cancel(self, sre_list: str | list | None = None):
"""Cancel specific (or all) Stock Reservation Entries held by the plan."""
StockReservation(self.doc).cancel_stock_reservation_entries(sre_list)
self.doc.reload()
@frappe.whitelist()
def make_stock_reservation_entries(
doc: str | Document, items: str | list | None = None, table_name: str | None = None, notify: bool = False
):
"""Whitelisted entry point: verify Production Plan write access, then reserve stock."""
if isinstance(doc, str):
doc = parse_json(doc)
doc = frappe.get_doc("Production Plan", doc.get("name"))
doc = _load_production_plan(doc)
frappe.has_permission("Production Plan", "write", doc=doc, throw=True)
reserve_stock_for_production_plan(doc, items=items, table_name=table_name, notify=notify)
ProductionPlanStockReservation(doc).reserve(items=items, table_name=table_name, notify=notify)
def reserve_stock_for_production_plan(
@@ -134,35 +174,19 @@ def reserve_stock_for_production_plan(
):
"""Reserve stock for a Production Plan. Internal: no permission check (also called
from the Production Plan submit/cancel lifecycle)."""
if items and isinstance(items, str):
items = parse_json(items)
for child_table_name, kwargs in _RESERVATION_TABLES.items():
if table_name and table_name != child_table_name:
continue
_reserve_or_cancel_plan_table(doc, items, kwargs)
doc.reload()
def _reserve_or_cancel_plan_table(doc, items, kwargs):
sre = StockReservation(doc, items=items, kwargs=kwargs)
if doc.docstatus == 1:
if sre.make_stock_reservation_entries():
frappe.msgprint(_("Stock Reservation Entries Created"), alert=True)
elif doc.docstatus == 2:
sre.cancel_stock_reservation_entries()
ProductionPlanStockReservation(doc).reserve(items=items, table_name=table_name, notify=notify)
@frappe.whitelist()
def cancel_stock_reservation_entries(doc: str | Document, sre_list: str | list):
"""Whitelisted entry point: verify Production Plan write access, then cancel reservations."""
doc = _load_production_plan(doc)
frappe.has_permission("Production Plan", "write", doc=doc, throw=True)
ProductionPlanStockReservation(doc).cancel(sre_list)
def _load_production_plan(doc: str | Document) -> Document:
if isinstance(doc, str):
doc = parse_json(doc)
doc = frappe.get_doc("Production Plan", doc.get("name"))
frappe.has_permission("Production Plan", "write", doc=doc, throw=True)
sre = StockReservation(doc)
sre.cancel_stock_reservation_entries(sre_list)
doc.reload()
return doc

View File

@@ -2322,6 +2322,74 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(len(reserved_entries), 0)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_stock_reservation_restored_on_work_order_cancel(self):
# Spec #5 (cancellation path): when a Work Order created from a Production Plan is cancelled,
# the reservation that was transferred PP -> WO must flow back to the still-open Production
# Plan, not silently vanish.
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
try:
bom_tree = {
"FG For SR Cancel": {"Sub Assembly For SR Cancel 1": {"Raw Material For SR Cancel 1": {}}}
}
parent_bom = create_nested_bom(bom_tree, prefix="")
warehouse = "_Test Warehouse - _TC"
# Plenty of stock so the Production Plan reserves everything directly on submit.
for item_code in ["Sub Assembly For SR Cancel 1", "Raw Material For SR Cancel 1"]:
make_stock_entry(item_code=item_code, target=warehouse, qty=20, basic_rate=100)
plan = create_production_plan(
item_code=parent_bom.item,
planned_qty=10,
skip_available_sub_assembly_item=1,
ignore_existing_ordered_qty=1,
do_not_submit=1,
warehouse=warehouse,
sub_assembly_warehouse=warehouse,
for_warehouse=warehouse,
reserve_stock=1,
)
plan.get_sub_assembly_items()
plan.set("mr_items", [])
for d in get_items_for_material_requests(plan.as_dict()):
plan.append("mr_items", d)
plan.save()
plan.submit()
def pp_reserved():
return sum(
r.reserved_qty
for r in StockReservation(plan).get_reserved_entries("Production Plan", plan.name)
)
reserved_before = pp_reserved()
self.assertGreater(reserved_before, 0, "Production Plan should reserve stock on submit")
plan.make_work_order()
work_orders = frappe.get_all("Work Order", filters={"production_plan": plan.name}, pluck="name")
work_orders = list(set(work_orders))
for wo_name in work_orders:
wo_doc = frappe.get_doc("Work Order", wo_name)
wo_doc.source_warehouse = warehouse
wo_doc.wip_warehouse = warehouse
wo_doc.fg_warehouse = warehouse
wo_doc.submit()
# After all Work Orders are submitted the reservation has fully transferred off the plan.
self.assertEqual(pp_reserved(), 0, "Reservation should transfer PP -> WO on submit")
# Cancelling the Work Orders must return the reservation to the Production Plan.
for wo_name in work_orders:
frappe.get_doc("Work Order", wo_name).cancel()
self.assertEqual(
pp_reserved(), reserved_before, "Cancelling the Work Order must restore the PP reservation"
)
finally:
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_stock_reservation_of_serial_nos_against_production_plan(self):
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom

View File

@@ -14,8 +14,8 @@ from pypika import functions as fn
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
from erpnext.manufacturing.doctype.work_order.mapper import check_if_scrap_warehouse_mandatory
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
from erpnext.manufacturing.doctype.work_order.services.reservation import (
WorkOrderStockReservation,
get_consumed_qty,
get_row_wise_serial_batch,
)
@@ -44,7 +44,7 @@ class RequiredItemsService:
# update in bin
self.update_reserved_qty_for_production()
StockReservationService(self.doc).validate_reserved_qty()
WorkOrderStockReservation(self.doc).validate_reserved_qty()
def update_reserved_qty_for_production(self, items=None):
"""update reserved_qty_for_production in bins"""
@@ -142,7 +142,7 @@ class RequiredItemsService:
transferred_qty = transferred_items.get(row.item_code) or 0.0
row.db_set("transferred_qty", transferred_qty, update_modified=False)
if self.doc.reserve_stock:
StockReservationService(self.doc).update_qty_in_stock_reservation(
WorkOrderStockReservation(self.doc).update_qty_in_stock_reservation(
row, transferred_qty, row_wise_serial_batch
)
@@ -189,7 +189,7 @@ class RequiredItemsService:
continue
warehouse = wip_warehouse or item.source_warehouse
StockReservationService(self.doc).update_consumed_qty_in_stock_reservation(
WorkOrderStockReservation(self.doc).update_consumed_qty_in_stock_reservation(
item, consumed_qty, warehouse
)

View File

@@ -3,7 +3,7 @@
"""Stock reservation logic for Work Order.
Extracted from work_order.py. ``StockReservationService`` wraps a Work Order
Extracted from work_order.py. ``WorkOrderStockReservation`` wraps a Work Order
document (composition) and owns the reservation-related behaviour; the
module-level helpers are reused by the controller and by Production Plan.
work_order.py re-exports them to preserve whitelist dotted-paths and imports.
@@ -51,7 +51,7 @@ _SERIAL_BATCH_FIELDS = [
]
class StockReservationService:
class WorkOrderStockReservation:
def __init__(self, doc):
self.doc = doc
@@ -94,6 +94,11 @@ class StockReservationService:
self.doc.db_set("status", self.doc.get_status())
def update_qty_in_stock_reservation(self, row, transferred_qty, row_wise_serial_batch):
# `transferred_qty` is the absolute qty transferred to WIP recomputed from submitted stock
# entries, so this method must also be able to *lower* it (e.g. when a transfer is cancelled).
# A fully-transferred entry is "Closed"; it must stay eligible here, otherwise cancelling the
# transfer can never reset its transferred_qty and the Store reservation is lost. Only truly
# cancelled entries (docstatus 2) are excluded.
names = frappe.get_all(
"Stock Reservation Entry",
filters={
@@ -101,9 +106,10 @@ class StockReservationService:
"item_code": row.item_code,
"voucher_detail_no": row.name,
"warehouse": row.source_warehouse,
"status": ("not in", ["Closed", "Cancelled", "Completed"]),
"docstatus": 1,
},
pluck="name",
order_by="creation",
)
for name in names:
transferred_qty = self._apply_transferred_qty(name, transferred_qty, row_wise_serial_batch)
@@ -424,14 +430,26 @@ class StockReservationService:
)
def cancel_reserved_qty_for_wip_and_fg(self, ste_doc):
# Reservations created by this stock entry are identified by `from_voucher_no`. They can be
# held against the Work Order *or* against another voucher -- e.g. the finished good of an
# SO-linked Work Order is reserved against the Sales Order. They must be cancelled directly:
# routing through the Work-Order-scoped `cancel_stock_reservation_entries` would silently skip
# any entry whose `voucher_type`/`voucher_no` is not the Work Order, leaving the finished good
# reserved and making the manufacture impossible to cancel (NegativeStockError).
cancelled = False
for row in ste_doc.items:
sre_list = frappe.get_all(
"Stock Reservation Entry",
filters={"from_voucher_no": ste_doc.name, "from_voucher_detail_no": row.name, "docstatus": 1},
pluck="name",
)
if sre_list:
unreserve_stock_for_work_order(self.doc, sre_list)
for name in sre_list:
frappe.get_doc("Stock Reservation Entry", name).cancel()
cancelled = True
if cancelled:
self.doc.reload()
self.doc.db_set("status", self.doc.get_status())
def release_reserved_qty_for_subcontract_transfer(self):
"""Free this Work Order's own reservation for items sent to a subcontractor.

View File

@@ -3417,6 +3417,137 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, transfer_entry.submit)
def test_stock_reservation_moves_from_store_to_wip_on_transfer(self):
# Spec #7: a Material Transfer for Manufacture (Store -> WIP) for a reserve_stock Work Order
# must move the reservation from the Store warehouse to the WIP warehouse; cancelling the
# transfer must move it back.
from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
make_stock_entry as make_stock_entry_test_record,
)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
try:
store = "Stores - _TC"
wip = "Work In Progress - _TC"
fg = make_item("Test SR Move FG", {"is_stock_item": 1}).name
rm = make_item("Test SR Move RM", {"is_stock_item": 1}).name
bom = make_bom(item=fg, raw_materials=[rm], source_warehouse=store, do_not_submit=True)
bom.save()
bom.submit()
make_stock_entry_test_record(item_code=rm, target=store, qty=10, basic_rate=100)
wo = make_wo_order_test_record(
production_item=fg,
qty=10,
bom_no=bom.name,
reserve_stock=1,
source_warehouse=store,
wip_warehouse=wip,
fg_warehouse=wip,
do_not_save=True,
)
wo.save()
wo.submit()
def reserved_in(warehouse):
return sum(
flt(r.reserved_qty) - flt(r.transferred_qty) - flt(r.delivered_qty) - flt(r.consumed_qty)
for r in frappe.get_all(
"Stock Reservation Entry",
filters={
"voucher_no": wo.name,
"item_code": rm,
"warehouse": warehouse,
"docstatus": 1,
},
fields=["reserved_qty", "transferred_qty", "delivered_qty", "consumed_qty"],
)
)
self.assertEqual(reserved_in(store), 10, "RM should be reserved in Store after WO submit")
self.assertEqual(reserved_in(wip), 0)
# Transfer Store -> WIP.
se = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10))
se.submit()
self.assertEqual(reserved_in(store), 0, "Store reservation should be freed after transfer")
self.assertEqual(reserved_in(wip), 10, "Reservation should move to WIP after transfer")
# Cancel the transfer -> reservation returns to Store.
se.cancel()
self.assertEqual(reserved_in(store), 10, "Cancelling transfer must restore Store reservation")
self.assertEqual(reserved_in(wip), 0)
finally:
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_sales_order_linked_work_order_reserves_finished_good(self):
# Spec #8: when a Work Order is linked to a Sales Order, manufacturing the finished good must
# reserve it against that Sales Order; cancelling the manufacture must release the reservation.
from erpnext.stock.doctype.stock_entry.stock_entry_utils import (
make_stock_entry as make_stock_entry_test_record,
)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
try:
warehouse = "_Test Warehouse - _TC"
wip = "_Test Warehouse 1 - _TC"
fg = make_item("Test SR SO-WO FG", {"is_stock_item": 1}).name
rm = make_item("Test SR SO-WO RM", {"is_stock_item": 1}).name
bom = make_bom(item=fg, raw_materials=[rm], source_warehouse=warehouse, do_not_submit=True)
bom.save()
bom.submit()
make_stock_entry_test_record(item_code=rm, target=warehouse, qty=10, basic_rate=100)
# The finished good is reserved in the Sales Order item's warehouse, so the WO must produce
# the FG into that same warehouse.
so = make_sales_order(item_code=fg, warehouse=warehouse, qty=10, rate=500)
wo = make_wo_order_test_record(
production_item=fg,
qty=10,
bom_no=bom.name,
sales_order=so.name,
reserve_stock=1,
source_warehouse=warehouse,
wip_warehouse=wip,
fg_warehouse=warehouse,
do_not_save=True,
)
wo.save()
wo.submit()
# Transfer the raw material to WIP, then manufacture the finished good.
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10))
transfer.submit()
manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10))
manufacture.submit()
def so_fg_reserved():
return sum(
flt(r.reserved_qty)
for r in frappe.get_all(
"Stock Reservation Entry",
filters={
"voucher_type": "Sales Order",
"voucher_no": so.name,
"item_code": fg,
"docstatus": 1,
},
fields=["reserved_qty"],
)
)
self.assertEqual(so_fg_reserved(), 10, "Finished good should be reserved against the Sales Order")
# Cancelling the manufacture releases the finished-good reservation.
manufacture.cancel()
self.assertEqual(so_fg_reserved(), 0, "Cancelling manufacture must release the SO reservation")
finally:
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
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

View File

@@ -46,11 +46,8 @@ from erpnext.manufacturing.doctype.work_order.services.operations import (
from erpnext.manufacturing.doctype.work_order.services.required_items import (
RequiredItemsService,
)
from erpnext.manufacturing.doctype.work_order.services.status import (
StatusService,
)
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
from erpnext.manufacturing.doctype.work_order.services.reservation import (
WorkOrderStockReservation,
cancel_stock_reservation_entries,
get_consumed_qty,
get_reserved_qty_for_production,
@@ -58,6 +55,9 @@ from erpnext.manufacturing.doctype.work_order.services.stock_reservation import
get_sre_details,
make_stock_reservation_entries,
)
from erpnext.manufacturing.doctype.work_order.services.status import (
StatusService,
)
from erpnext.stock.doctype.batch.batch import make_batch
from erpnext.stock.doctype.item.item import validate_end_of_life
from erpnext.stock.doctype.serial_no.serial_no import get_available_serial_nos
@@ -297,8 +297,8 @@ class WorkOrder(Document):
self.status = self.get_status()
self.validate_workstation_type()
self.reset_use_multi_level_bom()
StockReservationService(self).set_reserve_stock()
StockReservationService(self).validate_fg_warehouse_for_reservation()
WorkOrderStockReservation(self).set_reserve_stock()
WorkOrderStockReservation(self).validate_fg_warehouse_for_reservation()
self.validate_dates()
if self.source_warehouse:
@@ -311,7 +311,7 @@ class WorkOrder(Document):
):
self.set_required_items(reset_only_qty=len(self.get("required_items")))
StockReservationService(self).enable_auto_reserve_stock()
WorkOrderStockReservation(self).enable_auto_reserve_stock()
self.validate_operations_sequence()
self.validate_subcontracting_inward_order()
@@ -625,7 +625,7 @@ class WorkOrder(Document):
self.create_job_card_from_wo()
if self.reserve_stock:
StockReservationService(self).update_stock_reservation()
WorkOrderStockReservation(self).update_stock_reservation()
self.update_subcontracting_inward_order_received_items()
@@ -649,7 +649,7 @@ class WorkOrder(Document):
self.update_reserved_qty_for_production()
if self.reserve_stock:
StockReservationService(self).update_stock_reservation()
WorkOrderStockReservation(self).update_stock_reservation()
self.update_subcontracting_inward_order_received_items()

View File

@@ -84,7 +84,7 @@ class Workstation(Document):
def before_save(self):
if self.has_value_changed("workstation_type"):
self.set_data_based_on_workstation_type()
self._set_data_based_on_workstation_type()
self.set_hour_rate()
self.set_total_working_hours()
@@ -115,6 +115,10 @@ class Workstation(Document):
@frappe.whitelist()
def set_data_based_on_workstation_type(self):
self.check_permission("write")
self._set_data_based_on_workstation_type()
def _set_data_based_on_workstation_type(self):
if self.workstation_type:
data = frappe.get_all(
"Workstation Cost",
@@ -212,21 +216,25 @@ class Workstation(Document):
@frappe.whitelist()
def start_job(self, job_card: str, from_time: DateTimeLikeObject, employee: str):
doc = frappe.get_doc("Job Card", job_card)
doc.check_permission("write")
doc.append("time_logs", {"from_time": from_time, "employee": employee})
doc.save(ignore_permissions=True)
doc.save()
return doc
@frappe.whitelist()
def complete_job(self, job_card: str, qty: float, to_time: DateTimeLikeObject):
doc = frappe.get_doc("Job Card", job_card)
doc.check_permission("submit")
for row in doc.time_logs:
if not row.to_time:
row.to_time = to_time
row.time_in_mins = time_diff_in_hours(row.to_time, row.from_time) / 60
row.completed_qty = qty
doc.save(ignore_permissions=True)
doc.save()
doc.submit()
return doc
@@ -318,6 +326,8 @@ def get_status_color(status):
@frappe.whitelist()
def get_raw_materials(job_card: str):
frappe.has_permission("Job Card", "read", doc=job_card, throw=True)
raw_materials = frappe.get_all(
"Job Card",
fields=[
@@ -461,6 +471,8 @@ def check_workstation_for_holiday(workstation, from_datetime, to_datetime):
@frappe.whitelist()
def get_workstations(**kwargs):
frappe.has_permission("Workstation", "read", throw=True)
kwargs = frappe._dict(kwargs)
_workstation = frappe.qb.DocType("Workstation")
@@ -536,13 +548,8 @@ def update_job_card(job_card: str, method: str, **kwargs):
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)
doc.check_permission("write")
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
@@ -558,6 +565,8 @@ def update_job_card(job_card: str, method: str, **kwargs):
@frappe.whitelist()
def validate_job_card(job_card: str, status: str):
frappe.has_permission("Job Card", "read", doc=job_card, throw=True)
job_card_details = frappe.db.get_value("Job Card", job_card, ["status", "for_quantity"], as_dict=1)
current_status = job_card_details.status

View File

@@ -96,8 +96,8 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
return `
<tr>
<td>${frappe.meta.get_label(doctype, fieldname)}</td>
<td>${value1}</td>
<td>${value2}</td>
<td>${frappe.utils.escape_html(cstr(value1))}</td>
<td>${frappe.utils.escape_html(cstr(value2))}</td>
</tr>
`;
})
@@ -138,13 +138,17 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
.map((change, i) => {
let [fieldname, value1, value2] = change;
let th =
i === 0 ? `<th rowspan="${values_changed.length}">${item_code}</th>` : "";
i === 0
? `<th rowspan="${values_changed.length}">${frappe.utils.escape_html(
cstr(item_code)
)}</th>`
: "";
return `
<tr>
${th}
<td>${frappe.meta.get_label(child_doctype, fieldname)}</td>
<td>${value1}</td>
<td>${value2}</td>
<td>${frappe.utils.escape_html(cstr(value1))}</td>
<td>${frappe.utils.escape_html(cstr(value2))}</td>
</tr>
`;
})
@@ -177,7 +181,9 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
let html = rows
.map((row) => {
let [, doc] = row;
let cells = fields.map((df) => `<td>${doc[df.fieldname]}</td>`).join("");
let cells = fields
.map((df) => `<td>${frappe.utils.escape_html(cstr(doc[df.fieldname]))}</td>`)
.join("");
return `<tr>${cells}</tr>`;
})
.join("");

View File

@@ -240,9 +240,9 @@ class BOMConfigurator {
}
frappe.call({
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_item",
method: "add_item",
doc: this.frm.doc,
args: {
parent: node.data.parent_id,
fg_item: node.data.value,
item_code: data.item_code,
fg_reference_id: node.data.name || this.frm.doc.name,
@@ -295,9 +295,9 @@ class BOMConfigurator {
}
frappe.call({
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_sub_assembly",
method: "add_sub_assembly",
doc: this.frm.doc,
args: {
parent: node.data.parent_id,
fg_item: node.data.value,
fg_reference_id: node.data.name || this.frm.doc.name,
bom_item: bom_item,
@@ -442,9 +442,9 @@ class BOMConfigurator {
}
frappe.call({
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_sub_assembly",
method: "add_sub_assembly",
doc: this.frm.doc,
args: {
parent: node.data.parent_id,
fg_item: node.data.value,
bom_item: bom_item,
fg_reference_id: node.data.name || this.frm.doc.name,
@@ -479,9 +479,9 @@ class BOMConfigurator {
delete_node(node, view) {
frappe.confirm(__("Are you sure you want to delete this Item?"), () => {
frappe.call({
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.delete_node",
method: "delete_node",
doc: this.frm.doc,
args: {
parent: node.data.parent_id,
fg_item: node.data.value,
doctype: node.data.doctype,
docname: node.data.name,
@@ -501,16 +501,14 @@ class BOMConfigurator {
this.frm.edit_bom_dialog = frappe.prompt(
fields,
(data) => {
let doctype = node.data.doctype || this.frm.doc.doctype;
let docname = node.data.name || this.frm.doc.name;
frappe.call({
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.edit_bom_creator",
method: "edit_bom_creator",
doc: me.frm.doc,
args: {
doctype: doctype,
docname: docname,
data: data,
parent: node.data.parent_id || this.frm.doc.name,
},
callback: (r) => {
for (let key in data) {
@@ -540,6 +538,13 @@ class BOMConfigurator {
}
load_tree(response, node) {
// delete_node returns an empty response when nothing was removed; just
// refresh the node and bail out so we don't read undefined fields below.
if (!response?.message?.items) {
frappe.views.trees["BOM Configurator"].tree.load_children(node);
return;
}
let item_row = "";
let parent_dom = "";
let total_amount = response.message.raw_material_cost;

View File

@@ -607,6 +607,9 @@ erpnext.buying.get_items_from_product_bundle = function (frm) {
fieldname: "product_bundle",
options: "Product Bundle",
reqd: 1,
get_query: () => {
return { filters: { docstatus: 1, disabled: 0 } };
},
},
{
fieldtype: "Currency",
@@ -625,7 +628,7 @@ erpnext.buying.get_items_from_product_bundle = function (frm) {
method: "erpnext.stock.doctype.packed_item.packed_item.get_items_from_product_bundle",
args: {
row: {
item_code: args.product_bundle,
product_bundle: args.product_bundle,
quantity: args.quantity,
parenttype: frm.doc.doctype,
parent: frm.doc.name,

View File

@@ -201,7 +201,7 @@ 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
// restrict the version picker to enabled, submitted Product Bundles of the row's item
this.frm.set_query("product_bundle", "items", function (doc, cdt, cdn) {
let row = locals[cdt][cdn];
@@ -209,6 +209,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
filters: {
new_item_code: row.item_code,
docstatus: 1,
disabled: 0,
},
};
});

View File

@@ -76,13 +76,14 @@
"no_copy": 1
},
{
"allow_on_submit": 1,
"default": "0",
"depends_on": "disabled",
"description": "Deprecated: use Cancel / Is Active instead. Retained for backward compatibility.",
"description": "A disabled Product Bundle cannot be selected in transactions.",
"fieldname": "disabled",
"fieldtype": "Check",
"in_standard_filter": 1,
"label": "Disabled",
"read_only": 1
"no_copy": 1
},
{
"fieldname": "amended_from",
@@ -102,7 +103,7 @@
"idx": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-06-08 00:00:00.000000",
"modified": "2026-06-10 12:00:00.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Product Bundle",

View File

@@ -62,8 +62,10 @@ class ProductBundle(Document):
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.
# `is_active` and `disabled` are the only fields editable after submit; keep a
# single active version per parent item in sync when the user (re)activates a
# version. `disabled` is orthogonal: it parks a version without ceding the
# active slot, so re-enabling restores it without re-activation.
if self.is_active:
self.make_active()
@@ -171,17 +173,19 @@ def get_next_version_index(existing_names: list[str]) -> int:
def get_active_product_bundle(item_code: str) -> str | None:
"""Return the name of the active, submitted Product Bundle for ``item_code``, else None.
"""Return the name of the active, enabled, 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.
lookups that assumed one mutable bundle per item. A disabled bundle resolves to
None even if it still holds the active slot for its parent item.
"""
if not item_code:
return None
return frappe.db.get_value(
"Product Bundle",
{"new_item_code": item_code, "is_active": 1, "docstatus": 1},
{"new_item_code": item_code, "is_active": 1, "docstatus": 1, "disabled": 0},
"name",
)

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.listview_settings["Product Bundle"] = {
add_fields: ["is_active", "disabled"],
get_indicator(doc) {
// Draft and Cancelled fall through to the standard docstatus indicators;
// this only refines submitted bundles.
if (doc.disabled) {
return [__("Disabled"), "grey", "disabled,=,1"];
}
if (doc.docstatus === 1 && doc.is_active) {
return [__("Active"), "green", "is_active,=,1|disabled,=,0|docstatus,=,1"];
}
// inactive submitted versions keep the default "Submitted" indicator
},
};

View File

@@ -103,6 +103,38 @@ class TestProductBundle(ERPNextTestSuite):
bundle.items[0].qty = 99
self.assertRaises(frappe.exceptions.UpdateAfterSubmitError, bundle.save)
def test_disabled_bundle_is_not_resolved(self):
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
bundle.disabled = 1
bundle.save()
self.assertIsNone(get_active_product_bundle(self.parent))
# disabling parks the version without ceding the active slot, so re-enabling
# restores resolution without re-activation
self.assertEqual(frappe.db.get_value("Product Bundle", bundle.name, "is_active"), 1)
bundle.disabled = 0
bundle.save()
self.assertEqual(get_active_product_bundle(self.parent), bundle.name)
def test_item_where_used_report_shows_disabled_flag(self):
from erpnext.stock.report.item_where_used.item_where_used import execute
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
bundle.disabled = 1
bundle.save()
_, component_rows = execute({"item": "_Test PB Child A", "section": "Where Used"})
rows = [r for r in component_rows if r.document_name == bundle.name]
self.assertTrue(rows)
self.assertEqual(rows[0].disabled, 1)
self.assertEqual(rows[0].is_active, 1)
_, parent_rows = execute({"item": self.parent, "section": "References"})
rows = [r for r in parent_rows if r.document_name == bundle.name]
self.assertTrue(rows)
self.assertEqual(rows[0].disabled, 1)
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

View File

@@ -376,12 +376,27 @@ def make_delivery_note(
dn_item.qty = flt(sre.reserved_qty) / flt(dn_item.get("conversion_factor", 1))
dn_item.warehouse = sre.warehouse
if (
not use_serial_batch_fields
and sre.reservation_based_on == "Serial and Batch"
and (sre.has_serial_no or sre.has_batch_no)
):
dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher([sre]).name
if sre.reservation_based_on == "Serial and Batch" and (sre.has_serial_no or sre.has_batch_no):
if use_serial_batch_fields:
# Carry the reserved serial/batch in the row fields. A single field can't hold
# multiple batches, so fall back to a bundle in that case.
dn_item.use_serial_batch_fields = 1
sb_entries = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": sre.name},
fields=["serial_no", "batch_no"],
)
serial_nos = [d.serial_no for d in sb_entries if d.serial_no]
batch_nos = list({d.batch_no for d in sb_entries if d.batch_no})
if serial_nos:
dn_item.serial_no = "\n".join(serial_nos)
if len(batch_nos) == 1:
dn_item.batch_no = batch_nos[0]
elif len(batch_nos) > 1:
dn_item.use_serial_batch_fields = 0
dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher([sre]).name
else:
dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher([sre]).name
target_doc.append("items", dn_item)
# Correct rows index.

View File

@@ -23,8 +23,8 @@ from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
)
from erpnext.selling.doctype.customer.customer import check_credit_limit
from erpnext.selling.doctype.sales_order.services.delivery_schedule import DeliveryScheduleService
from erpnext.selling.doctype.sales_order.services.reservation import SalesOrderStockReservation
from erpnext.selling.doctype.sales_order.services.status import StatusService
from erpnext.selling.doctype.sales_order.services.stock_reservation import StockReservationService
from erpnext.selling.doctype.sales_order.services.subcontracting import SubcontractingService
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import has_reserved_stock
@@ -226,7 +226,7 @@ class SalesOrder(SellingController):
self.validate_for_items()
self.validate_warehouse()
self.validate_drop_ship()
StockReservationService(self).validate_reserved_stock()
SalesOrderStockReservation(self).validate_reserved_stock()
self.validate_serial_no_based_delivery()
validate_against_blanket_order(self)
validate_inter_company_party(
@@ -248,7 +248,7 @@ class SalesOrder(SellingController):
self.reset_default_field_value("set_warehouse", "items", "warehouse")
if not self.get("is_subcontracted"):
StockReservationService(self).enable_auto_reserve_stock()
SalesOrderStockReservation(self).enable_auto_reserve_stock()
def set_has_unit_price_items(self):
"""
@@ -542,7 +542,7 @@ class SalesOrder(SellingController):
StatusService(self).update_status(status)
def update_reserved_qty(self, so_item_rows=None):
StockReservationService(self).update_reserved_qty(so_item_rows)
SalesOrderStockReservation(self).update_reserved_qty(so_item_rows)
def on_update_after_submit(self):
self.calculate_commission()
@@ -652,7 +652,7 @@ class SalesOrder(SellingController):
@frappe.whitelist()
def has_unreserved_stock(self, table_name: str = "items") -> dict:
"""Returns unreserved qty per item if there is any unreserved item in the Sales Order."""
return StockReservationService(self).has_unreserved_stock(table_name)
return SalesOrderStockReservation(self).has_unreserved_stock(table_name)
@frappe.whitelist()
def create_stock_reservation_entries(
@@ -662,14 +662,14 @@ class SalesOrder(SellingController):
notify: bool = True,
) -> None:
"""Creates Stock Reservation Entries for Sales Order Items."""
StockReservationService(self).create_stock_reservation_entries(
SalesOrderStockReservation(self).create_stock_reservation_entries(
items_details, from_voucher_type, notify
)
@frappe.whitelist()
def cancel_stock_reservation_entries(self, sre_list: list | None = None, notify: bool = True) -> None:
"""Cancel Stock Reservation Entries for Sales Order Items."""
StockReservationService(self).cancel_stock_reservation_entries(sre_list, notify)
SalesOrderStockReservation(self).cancel_stock_reservation_entries(sre_list, notify)
def set_missing_values(self, for_validate=False):
super().set_missing_values(for_validate)

View File

@@ -15,7 +15,7 @@ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry impor
from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty
class StockReservationService:
class SalesOrderStockReservation:
def __init__(self, doc):
self.doc = doc

View File

@@ -13,6 +13,8 @@ def execute(filters=None):
if not filters:
filters = {}
validate_filters(filters)
columns = get_columns(filters)
entries = get_entries(filters)
item_details = get_item_details()
@@ -49,10 +51,17 @@ def execute(filters=None):
return columns, data
def get_columns(filters):
def validate_filters(filters):
ALLOWED_DOCTYPES = ["Sales Order", "Sales Invoice", "Delivery Note"]
if not filters.get("doc_type"):
msgprint(_("Please select the document type first"), raise_exception=1)
if filters.get("doc_type") not in ALLOWED_DOCTYPES:
frappe.throw(_("{0}, {1} or {2} are the only allowed options.").format(*ALLOWED_DOCTYPES))
def get_columns(filters):
columns = [
{
"label": _(filters["doc_type"]),

View File

@@ -150,15 +150,11 @@ class Employee(NestedSet):
)
def validate_user_details(self):
if self.user_id:
data = frappe.db.get_value("User", self.user_id, ["enabled"], as_dict=1)
if not self.user_id:
return
if not data:
self.user_id = None
return
self.validate_for_enabled_user_id(data.get("enabled", 0))
self.validate_duplicate_user_id()
self.validate_for_enabled_user_id()
self.validate_duplicate_user_id()
def validate_auto_user_creation(self):
if self.create_user_automatically and not (
@@ -179,6 +175,7 @@ class Employee(NestedSet):
if self.user_id:
self.update_user()
self.update_user_permissions()
self.update_user_status()
self.reset_employee_emails_cache()
def before_insert(self):
@@ -296,12 +293,23 @@ class Employee(NestedSet):
if not self.relieving_date:
throw(_("Please enter relieving date."))
def validate_for_enabled_user_id(self, enabled):
if enabled is None:
def validate_for_enabled_user_id(self):
if not frappe.db.exists("User", self.user_id):
frappe.throw(_("User {0} does not exist").format(self.user_id))
def update_user_status(self):
if not self.user_id:
return
if not self.has_value_changed("status") and not self.has_value_changed("user_id"):
return
user = frappe.get_doc("User", self.user_id)
enabled = user.enabled
if self.status != "Active" and enabled or self.status == "Active" and enabled == 0:
frappe.db.set_value("User", self.user_id, "enabled", not enabled)
user.enabled = not enabled
# Keep linked User status in sync from the Employee lifecycle and record the audit log.
user.save(ignore_permissions=True)
def validate_duplicate_user_id(self):
Employee = frappe.qb.DocType("Employee")

View File

@@ -642,6 +642,8 @@ class TransactionDeletionRecord(Document):
@frappe.whitelist()
def start_deletion_tasks(self):
self.check_permission("write")
# This method is the entry point for the chain of events that follow
self.db_set("status", "Running")
self._set_deletion_cache()

View File

@@ -368,6 +368,8 @@ def get_default_address(out, name):
@frappe.whitelist()
def get_contact_display(contact: str):
frappe.has_permission("Contact", "read", doc=contact, throw=True)
contact_info = frappe.db.get_value(
"Contact", contact, ["first_name", "last_name", "phone", "mobile_no"], as_dict=1
)
@@ -470,6 +472,8 @@ def get_attachments(delivery_stop):
@frappe.whitelist()
def get_driver_email(driver: str):
frappe.has_permission("Driver", "read", doc=driver, throw=True)
employee = frappe.db.get_value("Driver", driver, "employee")
email = frappe.db.get_value("Employee", employee, "prefered_email")
return {"email": email}

View File

@@ -8,6 +8,7 @@ import json
import frappe
import frappe.defaults
from frappe import _
from frappe.model.document import Document
from frappe.utils import flt
@@ -174,30 +175,6 @@ def reset_packing_list(doc):
return reset_table
def get_product_bundle_items(item_code):
product_bundle = frappe.qb.DocType("Product Bundle")
product_bundle_item = frappe.qb.DocType("Product Bundle Item")
query = (
frappe.qb.from_(product_bundle_item)
.join(product_bundle)
.on(product_bundle_item.parent == product_bundle.name)
.select(
product_bundle_item.item_code,
product_bundle_item.qty,
product_bundle_item.uom,
product_bundle_item.description,
)
.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")
@@ -219,14 +196,25 @@ def get_bundle_version_for_row(item_row):
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.
choice (e.g. left over after changing the item) self-heals back to the active
one, but a disabled choice blocks the transaction instead of silently switching
versions behind the user's back.
"""
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)
bundle = frappe.db.get_value(
"Product Bundle", chosen, ["new_item_code", "docstatus", "disabled"], as_dict=True
)
if bundle and bundle.new_item_code == item_row.item_code and bundle.docstatus == 1:
if bundle.disabled:
frappe.throw(
_("Row #{0}: Product Bundle {1} is disabled and cannot be used in transactions.").format(
item_row.idx, frappe.bold(chosen)
),
title=_("Disabled Product Bundle"),
)
return chosen
return get_active_product_bundle(item_row.item_code)
@@ -445,9 +433,31 @@ def on_doctype_update():
@frappe.whitelist()
def get_items_from_product_bundle(row: str):
"""Item details for each component of a Product Bundle.
``row.product_bundle`` selects a specific version by document name (the buying
dialog passes this); ``row.item_code`` is the legacy contract, resolving the
parent item's active version.
"""
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
row, items = ItemDetailsCtx(json.loads(row)), []
bundled_items = get_product_bundle_items(row["item_code"])
if bundle_name := row.get("product_bundle"):
frappe.has_permission("Product Bundle", "read", bundle_name, throw=True)
bundle = frappe.db.get_value("Product Bundle", bundle_name, ["docstatus", "disabled"], as_dict=True)
if not bundle or bundle.docstatus != 1:
frappe.throw(_("Product Bundle {0} is not submitted").format(frappe.bold(bundle_name)))
if bundle.disabled:
frappe.throw(
_("Product Bundle {0} is disabled and cannot be used in transactions.").format(
frappe.bold(bundle_name)
)
)
elif bundle_name := get_active_product_bundle(row.get("item_code")):
frappe.has_permission("Product Bundle", "read", bundle_name, throw=True)
bundled_items = get_product_bundle_items_by_name(bundle_name) if bundle_name else []
for item in bundled_items:
row.update(
{

View File

@@ -165,6 +165,80 @@ class TestPackedItem(ERPNextTestSuite):
self.assertEqual(so.items[0].product_bundle, v1)
self.assertEqual(sorted(pi.item_code for pi in so.packed_items), sorted(self.bundle_items))
def test_disabled_bundle_blocks_transaction(self):
"A row that explicitly references a disabled version cannot be saved."
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
version = get_active_product_bundle(self.bundle)
so = make_sales_order(item_code=self.bundle, qty=1, warehouse=self.warehouse, do_not_submit=True)
self.assertEqual(so.items[0].product_bundle, version)
frappe.db.set_value("Product Bundle", version, "disabled", 1)
self.assertRaises(frappe.ValidationError, so.save)
def test_disabled_bundle_is_not_packed(self):
"Without an explicit version, a disabled bundle is not treated as a bundle at all."
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
version = get_active_product_bundle(self.bundle2)
frappe.db.set_value("Product Bundle", version, "disabled", 1)
so = make_sales_order(item_code=self.bundle2, qty=1, warehouse=self.warehouse, do_not_submit=True)
self.assertEqual(so.items[0].is_product_bundle, 0)
self.assertFalse(so.items[0].product_bundle)
self.assertFalse(so.get("packed_items"))
def test_get_items_from_product_bundle_endpoint(self):
"The buying dialog passes the chosen version by document name (legacy: parent item code)."
import json
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
from erpnext.stock.doctype.packed_item.packed_item import get_items_from_product_bundle
ctx = {
"quantity": 2,
"doctype": "Purchase Order",
"parenttype": "Purchase Order",
"company": "_Test Company",
"currency": "INR",
"conversion_rate": 1,
"transaction_date": nowdate(),
}
# by document name, as the buying dialog sends it (bundle names are PB-prefixed
# since versioning, so they no longer double as the parent item code)
version = get_active_product_bundle(self.bundle)
items = get_items_from_product_bundle(json.dumps({"product_bundle": version, **ctx}))
self.assertEqual(sorted(i.item_code for i in items), sorted(self.bundle_items))
self.assertEqual([i.qty for i in items], [4, 4])
# legacy contract: the parent item code resolves to its active version
items = get_items_from_product_bundle(json.dumps({"item_code": self.bundle, **ctx}))
self.assertEqual(sorted(i.item_code for i in items), sorted(self.bundle_items))
# an unsubmitted version is rejected
draft = frappe.get_doc(
{
"doctype": "Product Bundle",
"new_item_code": make_item(properties={"is_stock_item": 0}).name,
"items": [{"item_code": self.bundle_items[0], "qty": 1}],
}
).insert()
self.assertRaises(
frappe.ValidationError,
get_items_from_product_bundle,
json.dumps({"product_bundle": draft.name, **ctx}),
)
# a disabled version is rejected
frappe.db.set_value("Product Bundle", version, "disabled", 1)
self.addCleanup(frappe.db.set_value, "Product Bundle", version, "disabled", 0)
self.assertRaises(
frappe.ValidationError,
get_items_from_product_bundle,
json.dumps({"product_bundle": version, **ctx}),
)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_recurring_bundle_item(self):
"Test impact on packed items if same bundle item is added and removed."

View File

@@ -15,7 +15,9 @@ from erpnext.stock.doctype.purchase_receipt.services.billing_status import Billi
from erpnext.stock.doctype.purchase_receipt.services.provisional_accounting import (
ProvisionalAccountingService,
)
from erpnext.stock.doctype.purchase_receipt.services.stock_reservation import StockReservationService
from erpnext.stock.doctype.purchase_receipt.services.reservation import (
PurchaseReceiptStockReservation,
)
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -374,7 +376,7 @@ class PurchaseReceipt(BuyingController):
self.make_gl_entries()
self.repost_future_sle_and_gle()
self.set_consumed_qty_in_subcontract_order()
StockReservationService(self).reserve_stock()
PurchaseReceiptStockReservation(self).reserve_stock()
self.update_received_qty_if_from_pp()
def update_received_qty_if_from_pp(self):

View File

@@ -10,7 +10,7 @@ from frappe.utils import get_datetime
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation
class StockReservationService:
class PurchaseReceiptStockReservation:
def __init__(self, doc):
self.doc = doc

View File

@@ -127,6 +127,8 @@ def get_contact_name(ref_doctype: str, docname: str):
@frappe.whitelist()
def get_company_contact(user: str):
frappe.has_permission("User", "read", throw=True)
contact = frappe.db.get_value(
"User",
user,

View File

@@ -9,6 +9,8 @@ frappe.ui.form.on("Stock Entry", {
setup: function (frm) {
frm.ignore_doctypes_on_cancel_all = ["Serial and Batch Bundle"];
frm.trigger("toggle_enable_for_stock_uom_qty");
frm.set_indicator_formatter("item_code", function (doc) {
if (!doc.s_warehouse) {
return "blue";
@@ -276,6 +278,20 @@ frappe.ui.form.on("Stock Entry", {
});
},
toggle_enable_for_stock_uom_qty: function (frm) {
frappe.call({
method: "erpnext.stock.doctype.stock_settings.stock_settings.get_enable_stock_uom_editing",
callback: (r) => {
if (r.message) {
frm.fields_dict["items"].grid.toggle_enable(
"transfer_qty",
r.message.allow_to_edit_stock_uom_qty_for_stock_entry
);
}
},
});
},
refresh: function (frm) {
frm.trigger("get_items_from_transit_entry");
frm.trigger("toggle_warehouse_fields");
@@ -1016,6 +1032,21 @@ frappe.ui.form.on("Stock Entry Detail", {
frm.events.set_basic_rate(frm, cdt, cdn);
},
transfer_qty(frm, cdt, cdn) {
let item = locals[cdt][cdn];
let old_conversion_factor = item.conversion_factor;
let conversion_factor = 1.0;
if (flt(item.qty) && flt(item.transfer_qty)) {
conversion_factor = flt(item.transfer_qty) / flt(item.qty);
}
if (old_conversion_factor !== conversion_factor) {
item.conversion_factor = conversion_factor;
refresh_field("conversion_factor", item.name, item.parentfield);
frm.events.set_basic_rate(frm, cdt, cdn);
}
},
s_warehouse(frm, cdt, cdn) {
frm.events.get_warehouse_details(frm, cdt, cdn);

View File

@@ -1061,8 +1061,8 @@ class StockEntry(StockController, SubcontractingInwardController):
return getattr(self, "_wo_doc", None)
def make_stock_reserve_for_wip_and_fg(self):
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
from erpnext.manufacturing.doctype.work_order.services.reservation import (
WorkOrderStockReservation,
)
if self.is_stock_reserve_for_work_order():
@@ -1076,7 +1076,7 @@ class StockEntry(StockController, SubcontractingInwardController):
):
return
StockReservationService(pro_doc).set_reserved_qty_for_wip_and_fg(self)
WorkOrderStockReservation(pro_doc).set_reserved_qty_for_wip_and_fg(self)
def reserve_stock_for_subcontracting(self):
if self.purpose == "Send to Subcontractor" and frappe.get_value(
@@ -1103,8 +1103,8 @@ class StockEntry(StockController, SubcontractingInwardController):
)
def cancel_stock_reserve_for_wip_and_fg(self):
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
from erpnext.manufacturing.doctype.work_order.services.reservation import (
WorkOrderStockReservation,
)
if self.is_stock_reserve_for_work_order():
@@ -1116,7 +1116,7 @@ class StockEntry(StockController, SubcontractingInwardController):
):
return
StockReservationService(pro_doc).cancel_reserved_qty_for_wip_and_fg(self)
WorkOrderStockReservation(pro_doc).cancel_reserved_qty_for_wip_and_fg(self)
def is_stock_reserve_for_work_order(self):
if (
@@ -1133,8 +1133,8 @@ class StockEntry(StockController, SubcontractingInwardController):
# purpose), so the owning Work Order is derived from the Subcontracting Order / Purchase Order
# that raised the transfer. Each such Work Order that reserves stock gets its reservation for
# the sent items released, so the negative-stock guard stops blocking the consumption.
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
from erpnext.manufacturing.doctype.work_order.services.reservation import (
WorkOrderStockReservation,
)
if self.purpose != "Send to Subcontractor":
@@ -1142,7 +1142,7 @@ class StockEntry(StockController, SubcontractingInwardController):
for wo_name in self.get_reserved_work_orders_for_subcontracting():
pro_doc = frappe.get_doc("Work Order", wo_name)
StockReservationService(pro_doc).release_reserved_qty_for_subcontract_transfer()
WorkOrderStockReservation(pro_doc).release_reserved_qty_for_subcontract_transfer()
def get_reserved_work_orders_for_subcontracting(self):
job_cards = set()

View File

@@ -1587,7 +1587,7 @@ def create_stock_reservation_entries_for_so_items(
):
"""Creates Stock Reservation Entries for Sales Order Items."""
from erpnext.selling.doctype.sales_order.services.stock_reservation import get_unreserved_qty
from erpnext.selling.doctype.sales_order.services.reservation import get_unreserved_qty
if not from_voucher_type and (
sales_order.get("_action") == "submit"

View File

@@ -4,7 +4,7 @@
from random import randint
import frappe
from frappe.utils import today
from frappe.utils import flt, today
from erpnext.selling.doctype.sales_order.mapper import create_pick_list, make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
@@ -313,6 +313,231 @@ class TestStockReservationEntry(ERPNextTestSuite):
for sre_detail in sre_details:
self.assertEqual(sre_detail.reserved_qty, sre_detail.delivered_qty)
@ERPNextTestSuite.change_settings(
"Stock Settings", {"enable_stock_reservation": 1, "allow_partial_reservation": 1}
)
def test_reservation_restored_on_delivery_note_cancel(self) -> None:
# Cancellation path (spec #1): delivering reserved stock via a Delivery Note marks the SRE
# delivered; cancelling that Delivery Note must restore the reservation (delivered_qty -> 0),
# otherwise the reserved stock is silently lost.
so = make_sales_order(
item_code=self.sr_item.name,
warehouse=self.warehouse,
qty=10,
rate=100,
do_not_submit=True,
)
so.reserve_stock = 1
so.items[0].reserve_stock = 1
so.save()
so.submit()
so.create_stock_reservation_entries()
def sre():
return frappe.get_all(
"Stock Reservation Entry",
filters={"voucher_no": so.name, "item_code": self.sr_item.name, "docstatus": 1},
fields=["reserved_qty", "delivered_qty", "status"],
)[0]
self.assertEqual(sre().reserved_qty, 10)
self.assertEqual(sre().delivered_qty, 0)
dn = make_delivery_note(so.name)
dn.submit()
after = sre()
self.assertEqual(after.delivered_qty, 10, "Delivery Note should mark the reservation delivered")
self.assertEqual(after.status, "Delivered")
dn.cancel()
restored = sre()
self.assertEqual(
restored.delivered_qty, 0, "Cancelling the Delivery Note must restore the reservation"
)
self.assertEqual(restored.status, "Reserved")
@ERPNextTestSuite.change_settings(
"Stock Settings", {"enable_stock_reservation": 1, "allow_negative_stock": 0}
)
def test_reserved_stock_cannot_be_delivered_against_a_different_sales_order(self) -> None:
# Spec #2: stock reserved for one Sales Order must not be deliverable through a Delivery Note
# raised for a different Sales Order. Pin allow_negative_stock off so the guard is enforced
# regardless of any global Stock Settings left enabled by other tests in the suite.
from erpnext.stock.stock_ledger import NegativeStockError
item_doc = make_item(properties={"is_stock_item": 1, "valuation_rate": 100})
item = item_doc.name
warehouse = self.warehouse
create_material_receipt(items={item: item_doc}, warehouse=warehouse, qty=10)
# SO-A reserves the entire available stock.
so_a = make_sales_order(item_code=item, warehouse=warehouse, qty=10, rate=100, do_not_submit=True)
so_a.reserve_stock = 1
so_a.items[0].reserve_stock = 1
so_a.save()
so_a.submit()
so_a.create_stock_reservation_entries()
self.assertTrue(has_reserved_stock("Sales Order", so_a.name))
# SO-B (a different order) for the same item must not be able to deliver the reserved stock.
so_b = make_sales_order(item_code=item, warehouse=warehouse, qty=10, rate=100, do_not_submit=True)
so_b.save()
so_b.submit()
dn = make_delivery_note(so_b.name)
dn.save()
self.assertRaises(NegativeStockError, dn.submit)
@ERPNextTestSuite.change_settings("Stock Settings", {"enable_stock_reservation": 1})
def test_stock_can_be_unreserved_and_reserved_against_another_sales_order(self) -> None:
# Spec #3: a user can manually unreserve stock from one Sales Order and reserve the same stock
# against another Sales Order.
item_doc = make_item(properties={"is_stock_item": 1, "valuation_rate": 100})
item = item_doc.name
warehouse = self.warehouse
create_material_receipt(items={item: item_doc}, warehouse=warehouse, qty=10)
so_a = make_sales_order(item_code=item, warehouse=warehouse, qty=10, rate=100, do_not_submit=True)
so_a.reserve_stock = 1
so_a.items[0].reserve_stock = 1
so_a.save()
so_a.submit()
so_a.create_stock_reservation_entries()
self.assertTrue(has_reserved_stock("Sales Order", so_a.name))
so_b = make_sales_order(item_code=item, warehouse=warehouse, qty=10, rate=100, do_not_submit=True)
so_b.reserve_stock = 1
so_b.items[0].reserve_stock = 1
so_b.save()
so_b.submit()
# With all stock reserved by SO-A, SO-B cannot reserve anything yet.
so_b.create_stock_reservation_entries()
self.assertFalse(has_reserved_stock("Sales Order", so_b.name))
# Manually unreserve SO-A, then SO-B can reserve the freed stock.
cancel_stock_reservation_entries("Sales Order", so_a.name)
self.assertFalse(has_reserved_stock("Sales Order", so_a.name))
so_b.create_stock_reservation_entries()
self.assertTrue(has_reserved_stock("Sales Order", so_b.name))
@ERPNextTestSuite.change_settings(
"Stock Settings",
{
"enable_stock_reservation": 1,
"auto_reserve_serial_and_batch": 1,
"pick_serial_and_batch_based_on": "FIFO",
},
)
def test_serial_and_batch_reservation_can_be_unreserved(self) -> None:
# Spec #9 (cancellation): an auto-reserved serial/batch Sales Order reservation pins specific
# serial/batch entries on the SRE (`sb_entries`). Manually unreserving it must cancel the SRE
# (and its pinned entries) and free the stock for another order.
items_details = create_items()
create_material_receipt(items_details, self.warehouse, qty=10)
serial_item = next(
name for name, p in items_details.items() if p.get("has_serial_no") and not p.get("has_batch_no")
)
batch_item = next(
name for name, p in items_details.items() if p.get("has_batch_no") and not p.get("has_serial_no")
)
item_list = [
{"item_code": serial_item, "warehouse": self.warehouse, "qty": 10, "rate": 100},
{"item_code": batch_item, "warehouse": self.warehouse, "qty": 10, "rate": 100},
]
so = make_sales_order(item_list=item_list, warehouse=self.warehouse)
so.create_stock_reservation_entries()
so.load_from_db()
def sre_row(so_item):
return frappe.get_all(
"Stock Reservation Entry",
filters={"voucher_no": so.name, "voucher_detail_no": so_item, "docstatus": 1},
fields=["name", "reserved_qty", "reservation_based_on"],
)
# Each item is reserved on a Serial-and-Batch basis with the specific entries pinned.
self.assertTrue(has_reserved_stock("Sales Order", so.name))
for item in so.items:
rows = sre_row(item.name)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].reserved_qty, 10)
self.assertEqual(rows[0].reservation_based_on, "Serial and Batch")
pinned = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": rows[0].name, "parentfield": "sb_entries"},
)
self.assertGreaterEqual(len(pinned), 1, "serial/batch entries should be pinned on the SRE")
# Manually unreserve: the SRE (and its pinned entries) must be cancelled and the stock freed.
cancel_stock_reservation_entries("Sales Order", so.name)
self.assertFalse(has_reserved_stock("Sales Order", so.name))
for item in so.items:
self.assertEqual(sre_row(item.name), [])
# The freed serial/batch stock can be reserved by another Sales Order.
so_b = make_sales_order(item_list=item_list, warehouse=self.warehouse)
so_b.create_stock_reservation_entries()
self.assertTrue(has_reserved_stock("Sales Order", so_b.name))
@ERPNextTestSuite.change_settings(
"Stock Settings",
{
"enable_stock_reservation": 1,
"auto_reserve_serial_and_batch": 1,
"pick_serial_and_batch_based_on": "FIFO",
"use_serial_batch_fields": 1,
},
)
def test_serial_and_batch_reserved_stock_delivery_and_cancel(self) -> None:
# Regression: delivering a serial/batch reserved Sales Order used to crash with
# "Serial and Batch Bundle None not found" when the delivered serial/batch was carried in the
# row's serial_no/batch_no fields (use_serial_batch_fields) rather than a bundle. Delivery
# must mark the reservation delivered, and cancelling the Delivery Note must restore it.
items_details = create_items()
create_material_receipt(items_details, self.warehouse, qty=10)
serial_item = next(
name for name, p in items_details.items() if p.get("has_serial_no") and not p.get("has_batch_no")
)
batch_item = next(
name for name, p in items_details.items() if p.get("has_batch_no") and not p.get("has_serial_no")
)
item_list = [
{"item_code": serial_item, "warehouse": self.warehouse, "qty": 10, "rate": 100},
{"item_code": batch_item, "warehouse": self.warehouse, "qty": 10, "rate": 100},
]
so = make_sales_order(item_list=item_list, warehouse=self.warehouse)
so.create_stock_reservation_entries()
def sre_row(so_item):
return frappe.get_all(
"Stock Reservation Entry",
filters={"voucher_no": so.name, "voucher_detail_no": so_item, "docstatus": 1},
fields=["reserved_qty", "delivered_qty", "status"],
)[0]
# Deliver the reserved stock (serial/batch carried in row fields, no bundle).
dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": True})
dn.save()
dn.submit()
for item in so.items:
row = sre_row(item.name)
self.assertEqual(
row.delivered_qty, 10, "delivery should mark the serial/batch reservation delivered"
)
self.assertEqual(row.status, "Delivered")
# Cancelling the Delivery Note restores the reservation.
dn.cancel()
for item in so.items:
row = sre_row(item.name)
self.assertEqual(row.delivered_qty, 0, "DN cancel must restore the serial/batch reservation")
self.assertEqual(row.status, "Reserved")
@ERPNextTestSuite.change_settings(
"Stock Settings",
{

View File

@@ -21,6 +21,7 @@
"stock_uom",
"allow_to_edit_stock_uom_qty_for_sales",
"allow_to_edit_stock_uom_qty_for_purchase",
"allow_to_edit_stock_uom_qty_for_stock_entry",
"allow_uom_with_conversion_rate_defined_in_item",
"warehouse_defaults_section",
"default_warehouse",
@@ -404,6 +405,13 @@
"fieldtype": "Check",
"label": "Allow to edit stock UOM qty for Purchase documents"
},
{
"default": "0",
"documentation_url": "https://docs.frappe.io/erpnext/stock-settings#why-to-edit-stock-qty-qty-as-per-stock-uom",
"fieldname": "allow_to_edit_stock_uom_qty_for_stock_entry",
"fieldtype": "Check",
"label": "Allow to edit stock UOM qty for Stock Entry"
},
{
"default": "0",
"depends_on": "eval: doc.enable_stock_reservation",
@@ -594,7 +602,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-03 12:38:02.202183",
"modified": "2026-06-13 12:38:02.202183",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",

View File

@@ -31,6 +31,7 @@ class StockSettings(Document):
allow_partial_reservation: DF.Check
allow_to_edit_stock_uom_qty_for_purchase: DF.Check
allow_to_edit_stock_uom_qty_for_sales: DF.Check
allow_to_edit_stock_uom_qty_for_stock_entry: DF.Check
allow_to_make_quality_inspection_after_purchase_or_delivery: DF.Check
allow_uom_with_conversion_rate_defined_in_item: DF.Check
auto_create_serial_and_batch_bundle_for_outward: DF.Check
@@ -111,6 +112,7 @@ class StockSettings(Document):
self.validate_auto_insert_price_list_rate_if_missing()
self.change_precision_for_for_sales()
self.change_precision_for_purchase()
self.change_precision_for_stock_entry()
self.validate_do_not_use_batchwise_valuation()
def validate_do_not_use_batchwise_valuation(self):
@@ -289,6 +291,18 @@ class StockSettings(Document):
]
self.make_property_setter_for_precision(doctypes)
def change_precision_for_stock_entry(self):
doc_before_save = self.get_doc_before_save()
if doc_before_save and (
doc_before_save.allow_to_edit_stock_uom_qty_for_stock_entry
== self.allow_to_edit_stock_uom_qty_for_stock_entry
):
return
if self.allow_to_edit_stock_uom_qty_for_stock_entry:
doctypes = ["Stock Entry Detail"]
self.make_property_setter_for_precision(doctypes)
@staticmethod
def make_property_setter_for_precision(doctypes):
for doctype in doctypes:
@@ -321,6 +335,10 @@ def clean_all_descriptions():
def get_enable_stock_uom_editing():
return frappe.get_single_value(
"Stock Settings",
["allow_to_edit_stock_uom_qty_for_sales", "allow_to_edit_stock_uom_qty_for_purchase"],
[
"allow_to_edit_stock_uom_qty_for_sales",
"allow_to_edit_stock_uom_qty_for_purchase",
"allow_to_edit_stock_uom_qty_for_stock_entry",
],
as_dict=1,
)

View File

@@ -18,7 +18,8 @@
"label": "UOM",
"oldfieldname": "uom",
"oldfieldtype": "Link",
"options": "UOM"
"options": "UOM",
"reqd": 1
},
{
"fieldname": "conversion_factor",
@@ -37,7 +38,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-04-27 02:22:52.652036",
"modified": "2026-06-11 23:02:54.800673",
"modified_by": "Administrator",
"module": "Stock",
"name": "UOM Conversion Detail",

View File

@@ -18,7 +18,7 @@ class UOMConversionDetail(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
uom: DF.Link | None
uom: DF.Link
# end: auto-generated types
pass

View File

@@ -135,11 +135,14 @@ def get_linked_cancelled_sabb(filters):
@frappe.whitelist()
def fix_sabb_entries(selected_rows: str | list):
frappe.has_permission("Serial and Batch Bundle", "write", throw=True)
if isinstance(selected_rows, str):
selected_rows = frappe.parse_json(selected_rows)
for row in selected_rows:
doc = frappe.get_doc("Serial and Batch Bundle", row.get("name"))
doc.check_permission("write")
if doc.is_cancelled == 0 and not frappe.db.get_value(
"Stock Ledger Entry",
{"serial_and_batch_bundle": doc.name, "is_cancelled": 0},

View File

@@ -139,7 +139,9 @@ def get_items(filters):
item.brand,
item.stock_uom,
)
.where((IfNull(item.disabled, 0) == 0) & (pb.is_active == 1) & (pb.docstatus == 1))
.where(
(IfNull(item.disabled, 0) == 0) & (pb.is_active == 1) & (pb.docstatus == 1) & (pb.disabled == 0)
)
)
if item_code := filters.get("item_code"):
@@ -181,7 +183,12 @@ def get_items(filters):
pbi.uom,
pbi.qty,
)
.where(pb.new_item_code.isin(parent_items) & (pb.is_active == 1) & (pb.docstatus == 1))
.where(
pb.new_item_code.isin(parent_items)
& (pb.is_active == 1)
& (pb.docstatus == 1)
& (pb.disabled == 0)
)
).run(as_dict=1)
child_items = set()

View File

@@ -308,6 +308,11 @@ class FIFOSlots:
# prepare single sle voucher detail lookup
self.prepare_stock_reco_voucher_wise_count()
if stock_ledger_entries is None:
# nested queries invalidate the streaming cursor below,
# so batchwise valuation flags must be resolved beforehand
self._prefetch_batchwise_valuations()
with frappe.db.unbuffered_cursor():
if stock_ledger_entries is None:
stock_ledger_entries = self._get_stock_ledger_entries()
@@ -425,12 +430,38 @@ class FIFOSlots:
def _get_batchwise_valuation(self, batch_no: str):
if batch_no not in self.batchwise_valuation_by_batch:
# only reachable when stock ledger entries are passed in directly;
# the streaming path prefetches all flags before iteration
self.batchwise_valuation_by_batch[batch_no] = frappe.db.get_value(
"Batch", batch_no, "use_batchwise_valuation"
)
return self.batchwise_valuation_by_batch[batch_no]
def _prefetch_batchwise_valuations(self) -> None:
sle = frappe.qb.DocType("Stock Ledger Entry")
batch = frappe.qb.DocType("Batch")
to_date = get_datetime(self.filters.get("to_date") + " 23:59:59")
query = (
frappe.qb.from_(sle)
.left_join(batch)
.on(sle.batch_no == batch.name)
.select(sle.batch_no, batch.use_batchwise_valuation)
.distinct()
.where(
(sle.batch_no.isnotnull())
& (sle.company == self.filters.get("company"))
& (sle.posting_datetime <= to_date)
& (sle.is_cancelled != 1)
)
)
query = self._apply_filter(query, sle, "item_code")
for batch_no, use_batchwise_valuation in query.run():
self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation
def _init_key_stores(self, row: dict) -> tuple:
"Initialise keys and FIFO Queue."

View File

@@ -1434,6 +1434,80 @@ class TestStockAgeing(ERPNextTestSuite):
item_result["fifo_queue"], [[batch_no.upper(), 1, 5.0, getdate(add_days(base_date, -2)), 50.0]]
)
def test_legacy_batch_no_sle_with_streaming_cursor(self):
"""SLEs carrying the legacy batch_no field must not trigger nested
queries while entries stream through an unbuffered cursor."""
from unittest.mock import patch
from frappe.utils import add_days, nowdate
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
)
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
suffix = frappe.generate_hash(length=8).upper()
item_code = make_item(
f"Test Stock Ageing Legacy Batch {suffix}",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": f"SA-LEG-{suffix}-.###",
"valuation_method": "FIFO",
},
).name
warehouse = "_Test Warehouse - _TC"
base_date = nowdate()
reco = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=10,
rate=10,
posting_date=add_days(base_date, -2),
posting_time="10:00:00",
)
batch_no = get_batch_from_bundle(reco.items[0].serial_and_batch_bundle)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=5,
rate=10,
batch_no=batch_no,
posting_date=add_days(base_date, -1),
posting_time="10:00:00",
)
# mimic pre-bundle data where SLEs carry batch_no directly
frappe.db.set_value(
"Stock Ledger Entry",
{"item_code": item_code},
"batch_no",
batch_no,
)
filters = frappe._dict(
company="_Test Company",
to_date=base_date,
ranges=["30", "60", "90"],
item_code=item_code,
)
fifo_slots = FIFOSlots(filters)
# fetch row by row so the streaming result set is still active
# while each stock ledger entry is processed
with patch("frappe.database.database.SQL_ITERATOR_BATCH_SIZE", 1):
slots = fifo_slots.generate()
self.assertEqual(fifo_slots.batchwise_valuation_by_batch.get(batch_no), 1)
self.assertEqual(slots[item_code]["total_qty"], 5.0)
def generate_item_and_item_wh_wise_slots(filters, sle):
"Return results with and without 'show_warehouse_wise_stock'"

View File

@@ -562,4 +562,5 @@ def update_subcontracting_inward_order_status(scio: str | Document, status: str
if isinstance(scio, str):
scio = frappe.get_doc("Subcontracting Inward Order", scio)
scio.check_permission("write")
scio.update_status(status)

View File

@@ -484,4 +484,5 @@ def update_subcontracting_order_status(sco: str | Document, status: str | None =
if isinstance(sco, str):
sco = frappe.get_doc("Subcontracting Order", sco)
sco.check_permission("write")
sco.update_status(status)

View File

@@ -121,6 +121,8 @@ class Issue(Document):
def split_issue(self, subject: str, communication_id: str):
from copy import deepcopy
self.check_permission("write")
replicated_issue = deepcopy(self)
replicated_issue.subject = subject
replicated_issue.issue_split_from = self.name
@@ -285,7 +287,7 @@ def make_issue_from_communication(communication: str, ignore_communication_links
"raised_by": doc.sender or "",
"raised_by_phone": doc.phone_no or "",
}
).insert(ignore_permissions=True)
).insert()
link_communication_to_document(doc, "Issue", issue.name, ignore_communication_links)

View File

@@ -79,10 +79,6 @@
padding: 8px;
}
.gravatar-top{
margin-top:8px;
}
.progress-hg{
margin-bottom: 30!important;
height:2px;

View File

@@ -3,10 +3,12 @@
import frappe
from frappe.rate_limiter import rate_limit
from frappe.utils import escape_html
@frappe.whitelist(allow_guest=True)
@frappe.whitelist(allow_guest=True, methods=["POST"])
@rate_limit(limit=10, seconds=3 * 60)
def send_message(sender: str, message: str, subject: str = "Website Query"):
from frappe.www.contact import send_message as website_send_message
@@ -14,6 +16,14 @@ def send_message(sender: str, message: str, subject: str = "Website Query"):
message = escape_html(message)
oppotunity_creation = frappe.get_single_value(
"CRM Settings", "enable_opportunity_creation_from_contact_us"
)
if not oppotunity_creation:
# Meant to silently fail instead of throwing error.
return
lead = customer = None
customer = frappe.db.sql(
"""select distinct dl.link_name from `tabDynamic Link` dl