Compare commits

..

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
b10113eeeb fix: use query builder for payment entry semgrep paths 2026-06-10 05:38:51 +00:00
copilot-swe-agent[bot]
ecbf0bf0b8 fix: suppress safe semgrep sql false positives 2026-06-10 05:36:26 +00:00
copilot-swe-agent[bot]
c922cae520 Initial plan 2026-06-10 05:28:34 +00:00
143 changed files with 31195 additions and 102895 deletions

View File

@@ -4,46 +4,24 @@ set -e
cd ~ || exit
sudo apt update
sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev
pip install frappe-bench
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
frappeuser=${FRAPPE_USER:-"frappe"}
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
# ---------------------------------------------------------------------------
# Phase 1 — parallelise the three slow, independent setup steps:
# a) system packages b) frappe-bench pip install c) frappe git fetch
# ---------------------------------------------------------------------------
sudo apt update
# apt remove/install must run sequentially but can overlap with pip and git.
sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev &
apt_pid=$!
pip install frappe-bench &
pip_pid=$!
mkdir frappe
(
cd frappe
git init
git remote add origin "https://github.com/${frappeuser}/frappe"
git fetch origin "${frappecommitish}" --depth 1
) &
clone_pid=$!
wait $apt_pid
wait $pip_pid
wait $clone_pid
pushd frappe
git init
git remote add origin "https://github.com/${frappeuser}/frappe"
git fetch origin "${frappecommitish}" --depth 1
git checkout FETCH_HEAD
popd
# ---------------------------------------------------------------------------
# Phase 2 — bench init and site setup
# ---------------------------------------------------------------------------
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
mkdir ~/frappe-bench/sites/test_site
@@ -59,11 +37,6 @@ if [ "$DB" == "mariadb" ];then
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
# Belt-and-suspenders: also set performance variables at runtime in case
# MARIADB_EXTRA_FLAGS was not honoured by the container image.
mariadb --host 127.0.0.1 --port 3306 -u root -proot \
-e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
@@ -78,11 +51,9 @@ fi
install_whktml() {
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
if [ ! -f /tmp/wkhtmltox.deb ]; then
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
fi
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
sudo apt install /tmp/wkhtmltox.deb
}
install_whktml &
wkpid=$!

View File

@@ -59,10 +59,6 @@ jobs:
env:
TZ: 'Asia/Kolkata'
MARIADB_ROOT_PASSWORD: 'root'
# Disable durability guarantees that are unnecessary in a throwaway CI container.
# innodb_flush_log_at_trx_commit=0 avoids an fsync on every commit (biggest win).
# sync_binlog=0 skips binary-log syncs; innodb_doublewrite=0 skips the doublewrite buffer.
MARIADB_EXTRA_FLAGS: --innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --innodb-doublewrite=0
ports:
- 3306:3306
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
@@ -126,12 +122,6 @@ jobs:
restore-keys: |
${{ runner.os }}-yarn-
- name: Cache wkhtmltopdf
uses: actions/cache@v4
with:
path: /tmp/wkhtmltox.deb
key: wkhtmltox-0.12.6.1-2-jammy-amd64
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
@@ -141,14 +131,7 @@ jobs:
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
- name: Run Tests
run: |
cd ~/frappe-bench/
coverage_flag=""
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
bench --site test_site run-parallel-tests --lightmode --app erpnext \
--total-builds ${{ strategy.job-total }} \
--build-number ${{ matrix.container }} \
$coverage_flag
run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --lightmode --app erpnext --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} --with-coverage'
env:
TYPE: server
@@ -158,7 +141,6 @@ jobs:
run: cat ~/frappe-bench/bench_start.log || true
- name: Upload coverage data
if: ${{ env.WITH_COVERAGE == 'true' }}
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.container }}
@@ -167,7 +149,6 @@ jobs:
coverage:
name: Coverage Wrap Up
needs: test
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Clone

View File

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

View File

@@ -94,7 +94,6 @@ 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,7 +518,6 @@ 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")
@@ -779,6 +778,7 @@ 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,7 +374,6 @@ 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 = []
@@ -402,7 +401,6 @@ 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

@@ -17,7 +17,6 @@ frappe.ui.form.on("Budget", {
filters: {
is_group: 0,
company: frm.doc.company,
root_type: ["in", ["Income", "Expense"]],
},
};
});

View File

@@ -11,28 +11,22 @@ frappe.ui.form.on("Currency Exchange Settings", {
},
callback: function (r) {
if (r && r.message) {
let result = [],
params = {};
if (frm.doc.service_provider == "exchangerate.host") {
result = ["result"];
params = {
let result = ["result"];
let params = {
date: "{transaction_date}",
from: "{from_currency}",
to: "{to_currency}",
};
add_param(frm, r.message, params, result);
} else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) {
result = ["rates", "{to_currency}"];
params = {
let result = ["rates", "{to_currency}"];
let params = {
base: "{from_currency}",
symbols: "{to_currency}",
};
} else if (frm.doc.service_provider == "frankfurter.dev - v2") {
result = ["rate"];
params = {
date: "{transaction_date}",
};
add_param(frm, r.message, params, result);
}
add_param(frm, r.message, params, result);
}
},
});

View File

@@ -78,7 +78,7 @@
"fieldname": "service_provider",
"fieldtype": "Select",
"label": "Service Provider",
"options": "frankfurter.dev\nexchangerate.host\nfrankfurter.dev - v2\nCustom",
"options": "frankfurter.dev\nexchangerate.host\nCustom",
"reqd": 1
},
{
@@ -101,10 +101,11 @@
"label": "Use HTTP Protocol"
}
],
"hide_toolbar": 0,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-15 11:25:55.873110",
"modified": "2026-03-16 13:28:21.075743",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Currency Exchange Settings",
@@ -121,11 +122,24 @@
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Accounts User",
"share": 1
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",

View File

@@ -29,7 +29,7 @@ class CurrencyExchangeSettings(Document):
disabled: DF.Check
req_params: DF.Table[CurrencyExchangeSettingsDetails]
result_key: DF.Table[CurrencyExchangeSettingsResult]
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "frankfurter.dev - v2", "Custom"]
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "Custom"]
url: DF.Data | None
use_http: DF.Check
# end: auto-generated types
@@ -70,14 +70,6 @@ class CurrencyExchangeSettings(Document):
self.append("req_params", {"key": "base", "value": "{from_currency}"})
self.append("req_params", {"key": "symbols", "value": "{to_currency}"})
elif self.service_provider == "frankfurter.dev - v2":
self.set("result_key", [])
self.set("req_params", [])
self.api_endpoint = get_api_endpoint(self.service_provider, self.use_http)
self.append("result_key", {"key": "rate"})
self.append("req_params", {"key": "date", "value": "{transaction_date}"})
def validate_parameters(self):
params = {}
for row in self.req_params:
@@ -113,20 +105,13 @@ class CurrencyExchangeSettings(Document):
@frappe.whitelist()
def get_api_endpoint(service_provider: str | None = None, use_http: bool = False):
if service_provider and service_provider in [
"exchangerate.host",
"frankfurter.dev",
"frankfurter.app",
"frankfurter.dev - v2",
]:
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev", "frankfurter.app"]:
if service_provider == "exchangerate.host":
api = "api.exchangerate.host/convert"
elif service_provider == "frankfurter.app":
api = "api.frankfurter.app/{transaction_date}"
elif service_provider == "frankfurter.dev":
api = "api.frankfurter.dev/v1/{transaction_date}"
elif service_provider == "frankfurter.dev - v2":
api = "api.frankfurter.dev/v2/rate/{from_currency}/{to_currency}"
protocol = "https://"
if use_http:

View File

@@ -409,16 +409,18 @@ 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: {
doctype: doctype,
docname: docname,
company: company,
account: child.account,
party: child.party,
account_currency: child.account_currency,
},
args: { args: args },
callback: function (r) {
if (r.message) {
$.each(r.message, function (field, value) {

View File

@@ -8,7 +8,6 @@ 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
@@ -44,14 +43,6 @@ 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.
@@ -137,7 +128,6 @@ 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,
@@ -198,33 +188,28 @@ 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()
@@ -236,16 +221,18 @@ class JournalEntry(AccountsController):
JournalTaxWithholding(self).on_submit()
@frappe.whitelist()
def get_balance_for_periodic_accounting(self) -> None:
"""Rebuild the entry rows from the stock-vs-ledger difference of each stock account."""
def get_balance_for_periodic_accounting(self):
self.validate_company_for_periodic_accounting()
stock_accounts = self.get_stock_accounts_for_periodic_accounting()
self.set("accounts", [])
for account in self.get_stock_accounts_for_periodic_accounting():
account_bal, stock_bal, _warehouse_list = get_stock_and_account_balance(
for account in stock_accounts:
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)),
@@ -253,26 +240,23 @@ class JournalEntry(AccountsController):
)
continue
self._append_periodic_difference_rows(account, difference_value)
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,
},
)
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,
},
)
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):
@@ -318,7 +302,6 @@ 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
@@ -402,44 +385,49 @@ class JournalEntry(AccountsController):
self.name,
)
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)
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")
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
)
)
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)
)
)
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 unlink_advance_entry_reference(self):
for d in self.get("accounts"):
@@ -484,12 +472,6 @@ class JournalEntry(AccountsController):
d.idx, d.account, d.party_type
)
)
elif d.party_type or d.party:
frappe.throw(
_(
"Row {0}: Party Type or Party can only be set for Receivable / Payable account, but account {1} is of type {2}"
).format(d.idx, d.account, account_type or _("None"))
)
def check_credit_limit(self):
customers = list(
@@ -561,76 +543,62 @@ class JournalEntry(AccountsController):
self.voucher_type == "Exchange Gain Or Loss" and self.multi_currency and self.is_system_generated
)
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)
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_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)
if d.reference_name == self.name:
frappe.throw(_("You can not enter current voucher in 'Against Journal Entry' column"))
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)
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,
)
return
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)
)
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
)
)
def set_against_account(self):
accounts_debited, accounts_credited = [], []
@@ -718,142 +686,131 @@ 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) -> 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))
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_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
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:
return
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 not needs_refresh or self.flags.get("ignore_exchange_rate"):
if self.get("custom_remark"):
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
if self.cheque_no:
if self.cheque_date:
r.append(_("Reference #{0} dated {1}").format(self.cheque_no, formatdate(self.cheque_date)))
else:
total_amount, currency = amounts.party_amount, amounts.party_account_currency
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"
)
if bank_amount:
total_amount = bank_amount
currency = bank_account_currency
else:
total_amount = party_amount
currency = party_account_currency
self.set_total_amount(total_amount, 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:
def set_total_amount(self, amt, currency):
self.total_amount = amt
self.total_amount_currency = currency
from frappe.utils import money_in_words
@@ -865,7 +822,7 @@ class JournalEntry(AccountsController):
return JournalEntryGLComposer(self).compose()
def make_gl_entries(self, cancel: int = 0, adv_adj: int = 0) -> None:
def make_gl_entries(self, cancel=0, adv_adj=0):
from erpnext.accounts.general_ledger import make_gl_entries
merge_entries = frappe.get_single_value("Accounts Settings", "merge_similar_account_heads")
@@ -889,109 +846,94 @@ 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) -> None:
"""Balance the entry by placing any difference on a blank (or newly added) row."""
def get_balance(self, difference_account: str | None = None):
if not self.get("accounts"):
msgprint(_("'Entries' cannot be empty"), raise_exception=True)
return
else:
self.total_debit, self.total_credit = 0, 0
diff = flt(self.difference, self.precision("difference"))
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 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.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),
},
)
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
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)
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)
self.set_total_debit_credit()
self.validate_total_debit_and_credit()
@frappe.whitelist()
def get_outstanding_invoices(self) -> None:
"""Populate the entry with a write-off row per outstanding invoice plus a balancing row."""
def get_outstanding_invoices(self):
self.set("accounts", [])
total = 0
for invoice in self.get_values():
total += flt(invoice.outstanding_amount, self.precision("credit", "accounts"))
self._append_outstanding_invoice_row(invoice)
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
balancing_row = self.append("accounts", {})
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", {})
if self.write_off_based_on == "Accounts Receivable":
balancing_row.debit_in_account_currency = total
jd2.debit_in_account_currency = total
elif self.write_off_based_on == "Accounts Payable":
balancing_row.credit_in_account_currency = total
jd2.credit_in_account_currency = total
self.validate_total_debit_and_credit()
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":
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":
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)
)
cond = (
f" and outstanding_amount <= {flt(self.write_off_amount)}"
if flt(self.write_off_amount) > 0
else ""
)
if flt(self.write_off_amount) > 0:
query = query.where(invoice.outstanding_amount <= flt(self.write_off_amount))
return query.run(as_dict=True)
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,
)
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,
)
def validate_credit_debit_note(self):
if self.stock_entry:
@@ -1020,7 +962,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:
@@ -1075,8 +1017,7 @@ 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 []
@@ -1107,97 +1048,67 @@ def get_against_jv(
@frappe.whitelist()
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.
"""
def get_outstanding(args: str | dict):
if not frappe.has_permission("Account"):
frappe.msgprint(_("No Permission"), raise_exception=1)
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")
if isinstance(args, str):
args = json.loads(args)
if doctype == "Journal Entry":
return _get_journal_entry_outstanding(docname, account, party)
company_currency = erpnext.get_company_currency(args.get("company"))
due_date = None
if doctype in ("Sales Invoice", "Purchase Invoice"):
return _get_invoice_outstanding(doctype, docname, company, account_currency)
if args.get("doctype") == "Journal Entry":
condition = " and party=%(party)s" if args.get("party") else ""
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 = 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,
)
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"),
}
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,
}
@frappe.whitelist()
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."""
def get_party_account_and_currency(company: str, party_type: str, party: str):
if not frappe.has_permission("Account"):
frappe.msgprint(_("No Permission"), raise_exception=1)
@@ -1217,7 +1128,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)
@@ -1275,8 +1186,7 @@ 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
@@ -1309,3 +1219,14 @@ 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,8 +24,7 @@ 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:
@@ -75,8 +74,7 @@ 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"
@@ -112,54 +110,32 @@ def get_payment_entry_against_invoice(
)
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")})
def get_payment_entry(ref_doc, args):
from erpnext.accounts.doctype.journal_entry.journal_entry import (
get_default_bank_cash_account,
get_exchange_rate,
)
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
"Company", ref_doc.company, "cost_center"
)
exchange_rate = _reference_exchange_rate(ref_doc, args)
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,
)
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)
je = frappe.new_doc("Journal Entry")
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
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(
party_row = je.append(
"accounts",
{
"account": args.get("party_account"),
@@ -177,19 +153,14 @@ def _append_party_row(je, ref_doc, args: dict, cost_center, exchange_rate: float
},
)
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")
# Make it bank_details
bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account"))
if bank_account:
bank_row.update(bank_account)
# posting date assumed to be the reference document's posting/transaction date
# Modified to include the posting date for which the exchange rate is required.
# Assumed to be the posting date of the reference date
bank_row.exchange_rate = get_exchange_rate(
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
bank_account["account"],
@@ -200,17 +171,26 @@ def _append_bank_row(je, ref_doc, args: dict, cost_center, exchange_rate: float)
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)
return bank_row
# Multi currency check again
if party_row.account_currency != ref_doc.company_currency or (
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
):
je.multi_currency = 1
je.set_amounts_in_company_currency()
je.set_total_debit_credit()
return je if args.get("journal_entry") else je.as_dict()
@frappe.whitelist()
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str) -> dict:
"""Build the counterpart Journal Entry in another company, linked back to `name`."""
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str):
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = voucher_type
journal_entry.company = company
@@ -220,8 +200,7 @@ 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) -> Document:
"""Map a submitted Journal Entry to a reversing one (debits and credits swapped)."""
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None):
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
if existing_reverse:
frappe.throw(
@@ -232,7 +211,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) -> None:
def post_process(source, target):
target.reversal_of = source.name
doclist = get_mapped_doc(

View File

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

View File

@@ -18,88 +18,86 @@ class JournalEntryGLComposer(BaseGLComposer):
from the first foreign-currency row (mirroring the former build_gl_map).
"""
def compose(self) -> list:
"""Project the Journal Entry's non-zero account rows into GL dicts."""
self._set_transaction_currency()
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
advance_doctypes = get_advance_payment_doctypes()
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
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)
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,
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,
}
)
# 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
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,
}
)
return row
# 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,
)
)
return gl_map

View File

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

View File

@@ -169,11 +169,8 @@ 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",
@@ -182,8 +179,6 @@ 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",
@@ -192,8 +187,6 @@ 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,
},
]
@@ -210,52 +203,6 @@ 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
@@ -662,13 +609,6 @@ class TestJournalEntry(ERPNextTestSuite):
jv.save()
self.assertRaises(frappe.ValidationError, jv.submit)
def test_party_not_allowed_for_non_receivable_payable_account(self):
customer = make_customer("_Test New Customer")
jv = make_journal_entry(account1="_Test Cash - _TC", account2="_Test Bank - _TC", amount=100, save=False)
jv.accounts[0].party_type = "Customer"
jv.accounts[0].party = customer
self.assertRaises(frappe.ValidationError, jv.save)
def test_validate_reference_doc_debit_against_sales_order_throws(self):
"""Characterize: a debit entry linked to a Sales Order is rejected."""
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
@@ -748,95 +688,6 @@ 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

@@ -10,7 +10,8 @@ from frappe import ValidationError, _, qb, scrub, throw
from frappe.model.document import Document
from frappe.model.meta import get_field_precision
from frappe.query_builder import Tuple
from frappe.query_builder.functions import Count
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Abs, Count, NullIf
from frappe.utils import cint, comma_or, flt, getdate, nowdate
from frappe.utils.data import comma_and, fmt_money, get_link_to_form
from pypika.functions import Coalesce, Sum
@@ -2064,22 +2065,18 @@ def get_outstanding_reference_documents(args: str | dict, validate: bool = False
company_currency = frappe.get_cached_value("Company", args.get("company"), "default_currency")
# Get positive outstanding sales /purchase invoices
condition = ""
if args.get("voucher_type") and args.get("voucher_no"):
condition = f" and voucher_type={frappe.db.escape(args['voucher_type'])} and voucher_no={frappe.db.escape(args['voucher_no'])}"
common_filter.append(ple.voucher_type == args["voucher_type"])
common_filter.append(ple.voucher_no == args["voucher_no"])
# Add cost center condition
if args.get("cost_center"):
condition += f" and cost_center={frappe.db.escape(args.get('cost_center'))}"
accounting_dimensions_filter.append(ple.cost_center == args.get("cost_center"))
# dynamic dimension filters
active_dimensions = get_dimensions()[0]
for dim in active_dimensions:
if args.get(dim.fieldname):
condition += f" and {dim.fieldname}={frappe.db.escape(args.get(dim.fieldname))}"
accounting_dimensions_filter.append(ple[dim.fieldname] == args.get(dim.fieldname))
date_fields_dict = {
@@ -2088,23 +2085,16 @@ def get_outstanding_reference_documents(args: str | dict, validate: bool = False
}
for fieldname, date_fields in date_fields_dict.items():
from_date = frappe.db.escape(str(args.get(date_fields[0]))) if args.get(date_fields[0]) else None
to_date = frappe.db.escape(str(args.get(date_fields[1]))) if args.get(date_fields[1]) else None
if args.get(date_fields[0]) and args.get(date_fields[1]):
condition += f" and {fieldname} between {from_date} and {to_date}"
posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])])
elif args.get(date_fields[0]):
# if only from date is supplied
condition += f" and {fieldname} >= {from_date}"
posting_and_due_date.append(ple[fieldname].gte(args.get(date_fields[0])))
elif args.get(date_fields[1]):
# if only to date is supplied
condition += f" and {fieldname} <= {to_date}"
posting_and_due_date.append(ple[fieldname].lte(args.get(date_fields[1])))
if args.get("company"):
condition += " and company = {}".format(frappe.db.escape(args.get("company")))
common_filter.append(ple.company == args.get("company"))
outstanding_invoices = []
@@ -2157,7 +2147,7 @@ def get_outstanding_reference_documents(args: str | dict, validate: bool = False
args.get("party_account"),
party_account_currency,
company_currency,
condition=condition,
filters=args,
)
# Get all SO / PO which are not fully billed or against which full advance not paid
@@ -2315,13 +2305,6 @@ def get_orders_to_be_billed(
if not voucher_type:
return []
# dynamic dimension filters
condition = ""
active_dimensions = get_dimensions(True)[0]
for dim in active_dimensions:
if filters.get(dim.fieldname):
condition += f" and {dim.fieldname}={frappe.db.escape(filters.get(dim.fieldname))}"
if party_account_currency == company_currency:
grand_total_field = "base_grand_total"
rounded_total_field = "base_rounded_total"
@@ -2329,38 +2312,35 @@ def get_orders_to_be_billed(
grand_total_field = "grand_total"
rounded_total_field = "rounded_total"
orders = frappe.db.sql(
"""
select
name as voucher_no,
if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) as invoice_amount,
(if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) - advance_paid) as outstanding_amount,
transaction_date as posting_date
from
`tab{voucher_type}`
where
{party_type} = %s
and docstatus = 1
and company = %s
and status != "Closed"
and if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) > advance_paid
and abs(100 - per_billed) > 0.01
{condition}
order by
transaction_date, name
""".format(
**{
"rounded_total_field": rounded_total_field,
"grand_total_field": grand_total_field,
"voucher_type": voucher_type,
"party_type": scrub(party_type),
"condition": condition,
}
),
(party, company),
as_dict=True,
filters = filters or {}
order = qb.DocType(voucher_type)
invoice_amount = Coalesce(NullIf(order[rounded_total_field], 0), order[grand_total_field])
orders_query = (
qb.from_(order)
.select(
order.name.as_("voucher_no"),
invoice_amount.as_("invoice_amount"),
(invoice_amount - order.advance_paid).as_("outstanding_amount"),
order.transaction_date.as_("posting_date"),
)
.where(order[scrub(party_type)] == party)
.where(order.docstatus == 1)
.where(order.company == company)
.where(order.status != "Closed")
.where(invoice_amount > order.advance_paid)
.where(Abs(100 - order.per_billed) > 0.01)
.orderby(order.transaction_date)
.orderby(order.name)
)
active_dimensions = get_dimensions(True)[0]
for dim in active_dimensions:
if filters.get(dim.fieldname):
orders_query = orders_query.where(order[dim.fieldname] == filters.get(dim.fieldname))
orders = orders_query.run(as_dict=True)
order_list = []
for d in orders:
if (
@@ -2390,15 +2370,12 @@ def get_negative_outstanding_invoices(
party_account_currency,
company_currency,
cost_center=None,
condition=None,
filters=None,
):
if party_type not in ["Customer", "Supplier"]:
return []
voucher_type = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
account = "debit_to" if voucher_type == "Sales Invoice" else "credit_to"
supplier_condition = ""
if voucher_type == "Purchase Invoice":
supplier_condition = "and (release_date is null or release_date <= CURRENT_DATE)"
if party_account_currency == company_currency:
grand_total_field = "base_grand_total"
rounded_total_field = "base_rounded_total"
@@ -2406,39 +2383,64 @@ def get_negative_outstanding_invoices(
grand_total_field = "grand_total"
rounded_total_field = "rounded_total"
return frappe.db.sql(
"""
select
"{voucher_type}" as voucher_type, name as voucher_no, {account} as account,
if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) as invoice_amount,
outstanding_amount, posting_date,
due_date, conversion_rate as exchange_rate
from
`tab{voucher_type}`
where
{party_type} = %s and {party_account} = %s and docstatus = 1 and
outstanding_amount < 0
{supplier_condition}
{condition}
order by
posting_date, name
""".format(
**{
"supplier_condition": supplier_condition,
"condition": condition,
"rounded_total_field": rounded_total_field,
"grand_total_field": grand_total_field,
"voucher_type": voucher_type,
"party_type": scrub(party_type),
"party_account": "debit_to" if party_type == "Customer" else "credit_to",
"cost_center": cost_center,
"account": account,
}
),
(party, party_account),
as_dict=True,
filters = filters or {}
invoice = qb.DocType(voucher_type)
invoice_amount = Coalesce(NullIf(invoice[rounded_total_field], 0), invoice[grand_total_field])
query = (
qb.from_(invoice)
.select(
ConstantColumn(voucher_type).as_("voucher_type"),
invoice.name.as_("voucher_no"),
invoice[account].as_("account"),
invoice_amount.as_("invoice_amount"),
invoice.outstanding_amount,
invoice.posting_date,
invoice.due_date,
invoice.conversion_rate.as_("exchange_rate"),
)
.where(invoice[scrub(party_type)] == party)
.where(invoice[account] == party_account)
.where(invoice.docstatus == 1)
.where(invoice.outstanding_amount < 0)
.orderby(invoice.posting_date)
.orderby(invoice.name)
)
if voucher_type == "Purchase Invoice":
query = query.where(invoice.release_date.isnull() | (invoice.release_date <= date.today()))
if filters.get("voucher_type") and filters.get("voucher_no"):
if filters["voucher_type"] != voucher_type:
return []
query = query.where(invoice.name == filters["voucher_no"])
if filters.get("cost_center"):
query = query.where(invoice.cost_center == filters.get("cost_center"))
active_dimensions = get_dimensions()[0]
for dim in active_dimensions:
if filters.get(dim.fieldname):
query = query.where(invoice[dim.fieldname] == filters.get(dim.fieldname))
date_fields_dict = {
"posting_date": ["from_posting_date", "to_posting_date"],
"due_date": ["from_due_date", "to_due_date"],
}
for fieldname, date_fields in date_fields_dict.items():
if filters.get(date_fields[0]) and filters.get(date_fields[1]):
query = query.where(invoice[fieldname][filters.get(date_fields[0]) : filters.get(date_fields[1])])
elif filters.get(date_fields[0]):
query = query.where(invoice[fieldname] >= filters.get(date_fields[0]))
elif filters.get(date_fields[1]):
query = query.where(invoice[fieldname] <= filters.get(date_fields[1]))
if filters.get("company"):
query = query.where(invoice.company == filters.get("company"))
return query.run(as_dict=True)
@frappe.whitelist()
def get_party_details(company: str, party_type: str, party: str, date: str, cost_center: str | None = None):
@@ -2780,7 +2782,7 @@ def get_payment_entry(
pe, doc, discount_amount, base_total_discount_loss, party_account_currency
)
pe.set_exchange_rate()
pe.set_exchange_rate(ref_doc=doc)
pe.set_amounts()
# If PE is created from PR directly, then no need to find open PRs for the references

View File

@@ -532,8 +532,6 @@ class TestPaymentEntry(ERPNextTestSuite):
si.submit()
pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC", bank_amount=4700)
pe.source_exchange_rate = 50
pe.set_amounts()
pe.reference_no = si.name
pe.reference_date = nowdate()
@@ -609,8 +607,6 @@ class TestPaymentEntry(ERPNextTestSuite):
pe = get_payment_entry(
"Sales Invoice", si.name, party_amount=20, bank_account="_Test Bank - _TC", bank_amount=900
)
pe.source_exchange_rate = 50
pe.set_amounts()
pe.reference_no = "1"
pe.reference_date = "2016-01-01"

View File

@@ -11,12 +11,11 @@ from erpnext import get_company_currency
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.doctype.bank_account.bank_account import get_party_bank_account
from erpnext.accounts.doctype.payment_entry.payment_entry import (
get_payment_entry,
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.accounts.party import get_party_account
from erpnext.accounts.party import get_party_account, get_party_bank_account
from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes, get_currency_precision
from erpnext.utilities import payment_app_import_guard

View File

@@ -332,12 +332,7 @@ class TestPaymentRequest(ERPNextTestSuite):
return_doc=1,
)
pe = pr.create_payment_entry(submit=False)
pe.source_exchange_rate = 50
pe.target_exchange_rate = 50
pe.set_amounts()
pe.insert(ignore_permissions=True)
pe.submit()
pe = pr.set_as_paid()
expected_gle = dict(
(d[0], d)
@@ -423,12 +418,7 @@ class TestPaymentRequest(ERPNextTestSuite):
pr = make_payment_request(dt=po_doc.doctype, dn=po_doc.name, recipient_id="nabin@erpnext.com")
pr = frappe.get_doc(pr).save().submit()
pe = pr.create_payment_entry(submit=False)
pe.target_exchange_rate = 80
pe.paid_amount = 800
pe.set_amounts()
pe.insert(ignore_permissions=True)
pe.submit()
pe = pr.create_payment_entry()
self.assertEqual(pe.base_paid_amount, 800)
self.assertEqual(pe.paid_amount, 800)
self.assertEqual(pe.base_received_amount, 800)

View File

@@ -614,12 +614,10 @@
{
"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,
"show_description_on_click": 1
"print_hide": 1
},
{
"fieldname": "scan_barcode",
@@ -1692,7 +1690,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-06-13 18:36:46.704623",
"modified": "2026-05-28 12:36:55.215363",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -51,6 +51,16 @@ 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,7 +158,6 @@ 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,7 +715,6 @@
{
"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,
@@ -723,8 +722,7 @@
"label": "Update Stock",
"oldfieldname": "update_stock",
"oldfieldtype": "Check",
"print_hide": 1,
"show_description_on_click": 1
"print_hide": 1
},
{
"fieldname": "scan_barcode",

View File

@@ -412,8 +412,8 @@ class SalesInvoice(SellingController):
validate_account_head(item.idx, item.income_account, self.company, _("Income"))
def before_save(self):
POSService(self).update_paid_amount()
POSService(self).set_account_for_mode_of_payment()
POSService(self).set_paid_amount()
def before_submit(self):
self.add_remarks()

View File

@@ -114,17 +114,10 @@ class POSService:
return pos
def update_paid_amount(self) -> None:
def set_paid_amount(self) -> None:
doc = self.doc
paid_amount = 0.0
base_paid_amount = 0.0
if not cint(doc.is_pos) and doc.is_return:
doc.set("payments", [])
doc.paid_amount = paid_amount
doc.base_paid_amount = base_paid_amount
return
for data in doc.payments:
data.base_amount = flt(data.amount * doc.conversion_rate, doc.precision("base_paid_amount"))
paid_amount += data.amount

View File

@@ -509,6 +509,11 @@ def get_party_advance_account(party_type, party, company):
return account
@frappe.whitelist()
def get_party_bank_account(party_type: str, party: str):
return frappe.db.get_value("Bank Account", {"party_type": party_type, "party": party, "is_default": 1})
def get_party_account_currency(party_type, party, company):
def generator():
party_account = get_party_account(party_type, party, company)

View File

@@ -927,28 +927,8 @@ 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,44 +1243,3 @@ 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

@@ -80,8 +80,6 @@ class TestUtils(ERPNextTestSuite):
purchase_invoice.submit()
payment_entry = get_payment_entry(purchase_invoice.doctype, purchase_invoice.name)
payment_entry.target_exchange_rate = 82.32
payment_entry.set_amounts()
payment_entry.paid_amount = 15725
payment_entry.deductions = []
payment_entry.save()

View File

@@ -86,7 +86,7 @@ class SubcontractingService:
def update_subcontracting_order_status(self) -> None:
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
set_subcontracting_order_status as update_sco_status,
update_subcontracting_order_status as update_sco_status,
)
doc = self.doc

View File

@@ -38,7 +38,7 @@ from erpnext.accounts.party import (
from erpnext.accounts.utils import (
get_advance_payment_doctypes as _get_advance_payment_doctypes,
)
from erpnext.accounts.utils import get_fiscal_year, validate_fiscal_year
from erpnext.accounts.utils import validate_fiscal_year
from erpnext.controllers.print_settings import (
set_print_templates_for_item_table,
set_print_templates_for_taxes,
@@ -640,29 +640,21 @@ class AccountsController(TransactionBase):
self.calculate_contribution()
def validate_date_with_fiscal_year(self):
date_field = None
if self.meta.get_field("posting_date"):
date_field = "posting_date"
elif self.meta.get_field("transaction_date"):
date_field = "transaction_date"
if not date_field or not self.get(date_field):
return
if self.meta.get_field("fiscal_year"):
validate_fiscal_year(
self.get(date_field),
self.fiscal_year,
self.company,
self.meta.get_label(date_field),
self,
)
else:
get_fiscal_year(
self.get(date_field),
company=self.company,
label=self.meta.get_label(date_field),
)
date_field = None
if self.meta.get_field("posting_date"):
date_field = "posting_date"
elif self.meta.get_field("transaction_date"):
date_field = "transaction_date"
if date_field and self.get(date_field):
validate_fiscal_year(
self.get(date_field),
self.fiscal_year,
self.company,
self.meta.get_label(date_field),
self,
)
def validate_due_date(self):
if self.get("is_pos") or self.doctype not in ["Sales Invoice", "Purchase Invoice"]:

View File

@@ -598,7 +598,6 @@ 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,12 +425,7 @@ class SellingController(StockController):
row.new_item_code
for row in frappe.get_all(
"Product Bundle",
filters={
"new_item_code": ("in", items_to_fetch),
"is_active": 1,
"docstatus": 1,
"disabled": 0,
},
filters={"new_item_code": ("in", items_to_fetch), "is_active": 1, "docstatus": 1},
fields="new_item_code",
)
}
@@ -984,14 +979,9 @@ class SellingController(StockController):
qty_can_be_deliver = 0
if sre_doc.reservation_based_on == "Serial and Batch":
# 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)
sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
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.
@@ -999,16 +989,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:
available_batch_qty = delivered_batch_qty.get(entry.batch_no, 0)
if available_batch_qty > 0:
if entry.batch_no in delivered_batch_qty:
delivered_qty = min(
(entry.qty - entry.delivered_qty), available_batch_qty
(entry.qty - entry.delivered_qty), delivered_batch_qty[entry.batch_no]
)
entry.delivered_qty += delivered_qty
entry.db_update()
qty_can_be_deliver += delivered_qty
delivered_batch_qty[entry.batch_no] = available_batch_qty - delivered_qty
delivered_batch_qty[entry.batch_no] -= delivered_qty
else:
# `Delivered Qty` should be less than or equal to `Reserved Qty`.
qty_can_be_deliver = min(
@@ -1184,31 +1174,3 @@ 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

@@ -1124,10 +1124,10 @@ class SubcontractingInwardController:
def update_inward_order_status(self):
if self.subcontracting_inward_order:
from erpnext.subcontracting.doctype.subcontracting_inward_order.subcontracting_inward_order import (
set_subcontracting_inward_order_status,
update_subcontracting_inward_order_status,
)
set_subcontracting_inward_order_status(self.subcontracting_inward_order)
update_subcontracting_inward_order_status(self.subcontracting_inward_order)
@frappe.whitelist()

View File

@@ -16,13 +16,12 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestItemWiseInventoryAccount(ERPNextTestSuite):
def setUp(self):
self.company = "_Test Company with perpetual inventory"
self.company_abbr = "TCP1"
self.company = make_company()
self.company_abbr = frappe.db.get_value("Company", self.company, "abbr")
self.default_warehouse = frappe.db.get_value(
"Warehouse",
{"company": self.company, "is_group": 0, "warehouse_name": ("like", "%Stores%")},
)
frappe.db.set_value("Company", self.company, "enable_item_wise_inventory_account", 1)
def test_item_account_for_purchase_receipt_entry(self):
items = {
@@ -578,3 +577,23 @@ class TestItemWiseInventoryAccount(ERPNextTestSuite):
gl_value = gl_value * -1
self.assertEqual(sle_value, gl_value, f"GL Entry not created for {item_code} correctly")
def make_company():
company = "_Test Company for Item Wise Inventory Account"
if frappe.db.exists("Company", company):
return company
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": "_Test Company for Item Wise Inventory Account",
"abbr": "_TCIWIA",
"default_currency": "INR",
"country": "India",
"enable_perpetual_inventory": 1,
"enable_item_wise_inventory_account": 1,
}
).insert()
return company.name

View File

@@ -14,7 +14,6 @@
"opportunity_section",
"close_opportunity_after_days",
"column_break_9",
"enable_opportunity_creation_from_contact_us",
"quotation_section",
"default_valid_till",
"section_break_13",
@@ -99,20 +98,15 @@
"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-06-11 23:09:49.750381",
"modified": "2026-03-16 13:28:19.573964",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",

View File

@@ -2,7 +2,6 @@
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
@@ -21,20 +20,8 @@ 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, validate_email_address
from frappe.utils import comma_and, get_link_to_form, has_gravatar, validate_email_address
from frappe.utils.data import DateTimeLikeObject
from erpnext.accounts.party import set_taxes
@@ -173,6 +173,9 @@ 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,6 +130,7 @@ 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()
).insert(ignore_permissions=True)
link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links)

View File

@@ -5,11 +5,6 @@ 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,9 +383,6 @@ 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",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -175,9 +175,6 @@ 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) {
@@ -586,7 +583,7 @@ frappe.ui.form.on("BOM", {
},
routing(frm) {
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations) {
frappe.call({
doc: frm.doc,
method: "get_routing",

View File

@@ -245,14 +245,10 @@ class BOMCreator(Document):
frappe.throw(_("Please set {0} in BOM Creator {1}").format(_(label), self.name))
def on_submit(self):
self.enqueue_bom_creation()
self.enqueue_create_boms()
@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",
@@ -386,31 +382,6 @@ 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:
@@ -422,144 +393,9 @@ 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)
@@ -593,6 +429,108 @@ 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
@@ -610,3 +548,46 @@ 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,6 +5,10 @@ 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
@@ -34,7 +38,8 @@ class TestBOMCreator(ERPNextTestSuite):
conversion_rate=1,
)
doc.add_sub_assembly(
add_sub_assembly(
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.name,
bom_item={
@@ -88,7 +93,8 @@ class TestBOMCreator(ERPNextTestSuite):
conversion_rate=1,
)
doc.add_item(
add_item(
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -131,7 +137,8 @@ class TestBOMCreator(ERPNextTestSuite):
conversion_rate=1,
)
doc.add_item(
add_item(
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -141,8 +148,9 @@ class TestBOMCreator(ERPNextTestSuite):
doc.reload()
self.assertEqual(doc.items[0].is_expandable, 0)
doc.add_sub_assembly(
add_sub_assembly(
convert_to_sub_assembly=1,
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.items[0].name,
bom_item={
@@ -197,7 +205,8 @@ class TestBOMCreator(ERPNextTestSuite):
conversion_rate=1,
)
doc.add_item(
add_item(
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.name,
item_code="Pedal Assembly",
@@ -207,8 +216,9 @@ class TestBOMCreator(ERPNextTestSuite):
doc.reload()
self.assertEqual(doc.items[0].is_expandable, 0)
doc.add_sub_assembly(
add_sub_assembly(
convert_to_sub_assembly=1,
parent=doc.name,
fg_item=final_product,
fg_reference_id=doc.items[0].name,
bom_item={
@@ -241,43 +251,6 @@ 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

@@ -1653,7 +1653,7 @@ class JobCard(Document):
)
def populate_manufacture_stock_entry(self, ste):
from erpnext.stock.doctype.stock_entry.services.manufacturing import ManufactureStockEntry
from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ManufactureStockEntry
ste.make_stock_entry()
ste.stock_entry.flags.ignore_mandatory = True

View File

@@ -116,57 +116,17 @@ 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."""
doc = _load_production_plan(doc)
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)
ProductionPlanStockReservation(doc).reserve(items=items, table_name=table_name, notify=notify)
reserve_stock_for_production_plan(doc, items=items, table_name=table_name, notify=notify)
def reserve_stock_for_production_plan(
@@ -174,19 +134,35 @@ 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)."""
ProductionPlanStockReservation(doc).reserve(items=items, table_name=table_name, notify=notify)
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()
@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"))
return doc
frappe.has_permission("Production Plan", "write", doc=doc, throw=True)
sre = StockReservation(doc)
sre.cancel_stock_reservation_entries(sre_list)
doc.reload()

View File

@@ -2322,74 +2322,6 @@ 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

@@ -518,7 +518,7 @@ def _set_pick_list_item_qty(source, target, source_parent, for_qty, max_finished
@frappe.whitelist()
def make_stock_return_entry(work_order: str):
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
ManufactureStockEntry,
)

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.reservation import (
WorkOrderStockReservation,
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
get_consumed_qty,
get_row_wise_serial_batch,
)
@@ -44,7 +44,7 @@ class RequiredItemsService:
# update in bin
self.update_reserved_qty_for_production()
WorkOrderStockReservation(self.doc).validate_reserved_qty()
StockReservationService(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:
WorkOrderStockReservation(self.doc).update_qty_in_stock_reservation(
StockReservationService(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
WorkOrderStockReservation(self.doc).update_consumed_qty_in_stock_reservation(
StockReservationService(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. ``WorkOrderStockReservation`` wraps a Work Order
Extracted from work_order.py. ``StockReservationService`` 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 WorkOrderStockReservation:
class StockReservationService:
def __init__(self, doc):
self.doc = doc
@@ -94,11 +94,6 @@ class WorkOrderStockReservation:
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={
@@ -106,10 +101,9 @@ class WorkOrderStockReservation:
"item_code": row.item_code,
"voucher_detail_no": row.name,
"warehouse": row.source_warehouse,
"docstatus": 1,
"status": ("not in", ["Closed", "Cancelled", "Completed"]),
},
pluck="name",
order_by="creation",
)
for name in names:
transferred_qty = self._apply_transferred_qty(name, transferred_qty, row_wise_serial_batch)
@@ -430,26 +424,14 @@ class WorkOrderStockReservation:
)
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",
)
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())
if sre_list:
unreserve_stock_for_work_order(self.doc, sre_list)
def release_reserved_qty_for_subcontract_transfer(self):
"""Free this Work Order's own reservation for items sent to a subcontractor.

View File

@@ -3417,137 +3417,6 @@ 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,8 +46,11 @@ 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.reservation import (
WorkOrderStockReservation,
from erpnext.manufacturing.doctype.work_order.services.status import (
StatusService,
)
from erpnext.manufacturing.doctype.work_order.services.stock_reservation import (
StockReservationService,
cancel_stock_reservation_entries,
get_consumed_qty,
get_reserved_qty_for_production,
@@ -55,9 +58,6 @@ from erpnext.manufacturing.doctype.work_order.services.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()
WorkOrderStockReservation(self).set_reserve_stock()
WorkOrderStockReservation(self).validate_fg_warehouse_for_reservation()
StockReservationService(self).set_reserve_stock()
StockReservationService(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")))
WorkOrderStockReservation(self).enable_auto_reserve_stock()
StockReservationService(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:
WorkOrderStockReservation(self).update_stock_reservation()
StockReservationService(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:
WorkOrderStockReservation(self).update_stock_reservation()
StockReservationService(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,10 +115,6 @@ 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",
@@ -216,25 +212,21 @@ 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()
doc.save(ignore_permissions=True)
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()
doc.save(ignore_permissions=True)
doc.submit()
return doc
@@ -326,8 +318,6 @@ 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=[
@@ -471,8 +461,6 @@ 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")
@@ -548,8 +536,13 @@ 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)
doc.check_permission("write")
# These methods mutate the Job Card, but frappe.get_doc does not enforce permissions —
# require write access before running anything.
frappe.has_permission("Job Card", "write", doc=doc, throw=True)
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
@@ -565,8 +558,6 @@ 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>${frappe.utils.escape_html(cstr(value1))}</td>
<td>${frappe.utils.escape_html(cstr(value2))}</td>
<td>${value1}</td>
<td>${value2}</td>
</tr>
`;
})
@@ -138,17 +138,13 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
.map((change, i) => {
let [fieldname, value1, value2] = change;
let th =
i === 0
? `<th rowspan="${values_changed.length}">${frappe.utils.escape_html(
cstr(item_code)
)}</th>`
: "";
i === 0 ? `<th rowspan="${values_changed.length}">${item_code}</th>` : "";
return `
<tr>
${th}
<td>${frappe.meta.get_label(child_doctype, fieldname)}</td>
<td>${frappe.utils.escape_html(cstr(value1))}</td>
<td>${frappe.utils.escape_html(cstr(value2))}</td>
<td>${value1}</td>
<td>${value2}</td>
</tr>
`;
})
@@ -181,9 +177,7 @@ erpnext.BOMComparisonTool = class BOMComparisonTool {
let html = rows
.map((row) => {
let [, doc] = row;
let cells = fields
.map((df) => `<td>${frappe.utils.escape_html(cstr(doc[df.fieldname]))}</td>`)
.join("");
let cells = fields.map((df) => `<td>${doc[df.fieldname]}</td>`).join("");
return `<tr>${cells}</tr>`;
})
.join("");

View File

@@ -240,9 +240,9 @@ class BOMConfigurator {
}
frappe.call({
method: "add_item",
doc: this.frm.doc,
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_item",
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: "add_sub_assembly",
doc: this.frm.doc,
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_sub_assembly",
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: "add_sub_assembly",
doc: this.frm.doc,
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_sub_assembly",
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: "delete_node",
doc: this.frm.doc,
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.delete_node",
args: {
parent: node.data.parent_id,
fg_item: node.data.value,
doctype: node.data.doctype,
docname: node.data.name,
@@ -501,14 +501,16 @@ 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: "edit_bom_creator",
doc: me.frm.doc,
method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.edit_bom_creator",
args: {
doctype: doctype,
docname: docname,
data: data,
parent: node.data.parent_id || this.frm.doc.name,
},
callback: (r) => {
for (let key in data) {
@@ -538,13 +540,6 @@ 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,9 +607,6 @@ 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",
@@ -628,7 +625,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: {
product_bundle: args.product_bundle,
item_code: 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 enabled, submitted Product Bundles of the row's item
// restrict the version picker to submitted Product Bundles of the row's item
this.frm.set_query("product_bundle", "items", function (doc, cdt, cdn) {
let row = locals[cdt][cdn];
@@ -209,7 +209,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
filters: {
new_item_code: row.item_code,
docstatus: 1,
disabled: 0,
},
};
});

View File

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

View File

@@ -62,10 +62,8 @@ class ProductBundle(Document):
self.db_set("is_active", 0)
def on_update_after_submit(self):
# `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.
# `is_active` is the only field editable after submit; keep a single active
# version per parent item in sync when the user (re)activates a version.
if self.is_active:
self.make_active()
@@ -173,19 +171,17 @@ 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, enabled, submitted Product Bundle for
``item_code``, else None.
"""Return the name of the active, submitted Product Bundle for ``item_code``, else None.
This is the single resolution entry point for every consumer of bundles; it
replaces the legacy ``exists("Product Bundle", {name/new_item_code, disabled: 0})``
lookups that assumed one mutable bundle per item. A disabled bundle resolves to
None even if it still holds the active slot for its parent item.
lookups that assumed one mutable bundle per item.
"""
if not item_code:
return None
return frappe.db.get_value(
"Product Bundle",
{"new_item_code": item_code, "is_active": 1, "docstatus": 1, "disabled": 0},
{"new_item_code": item_code, "is_active": 1, "docstatus": 1},
"name",
)

View File

@@ -1,17 +0,0 @@
// 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,38 +103,6 @@ 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,27 +376,12 @@ def make_delivery_note(
dn_item.qty = flt(sre.reserved_qty) / flt(dn_item.get("conversion_factor", 1))
dn_item.warehouse = sre.warehouse
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
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
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()
SalesOrderStockReservation(self).validate_reserved_stock()
StockReservationService(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"):
SalesOrderStockReservation(self).enable_auto_reserve_stock()
StockReservationService(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):
SalesOrderStockReservation(self).update_reserved_qty(so_item_rows)
StockReservationService(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 SalesOrderStockReservation(self).has_unreserved_stock(table_name)
return StockReservationService(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."""
SalesOrderStockReservation(self).create_stock_reservation_entries(
StockReservationService(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."""
SalesOrderStockReservation(self).cancel_stock_reservation_entries(sre_list, notify)
StockReservationService(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 SalesOrderStockReservation:
class StockReservationService:
def __init__(self, doc):
self.doc = doc

View File

@@ -56,7 +56,7 @@ class SubcontractingService:
def update_subcontracting_order_status(self) -> None:
from erpnext.subcontracting.doctype.subcontracting_inward_order.subcontracting_inward_order import (
set_subcontracting_inward_order_status as update_scio_status,
update_subcontracting_inward_order_status as update_scio_status,
)
doc = self.doc

View File

@@ -13,8 +13,6 @@ def execute(filters=None):
if not filters:
filters = {}
validate_filters(filters)
columns = get_columns(filters)
entries = get_entries(filters)
item_details = get_item_details()
@@ -51,17 +49,10 @@ def execute(filters=None):
return columns, data
def validate_filters(filters):
ALLOWED_DOCTYPES = ["Sales Order", "Sales Invoice", "Delivery Note"]
def get_columns(filters):
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"]),

Some files were not shown because too many files have changed in this diff Show More