Compare commits

...

54 Commits

Author SHA1 Message Date
Mohd Haris
a3f262b415 fix: restrict Party Type/Party to Receivable/Payable accounts in Journal Entry
Journal Entry validation only checked party details when an account was
of type Receivable/Payable, but never restricted setting a Party Type or
Party against accounts of other types. This regressed v14 behavior where
party info could only be captured for Receivable/Payable accounts.

Add a branch to validate_party() that throws when a Party Type or Party
is set on an account that is not Receivable/Payable, with a clear message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:21:51 +05:30
ruthra kumar
7e602d5389 Merge pull request #53152 from aerele/fix_payment_entry
fix: prevent exchange rate flow from transaction to payment
2026-06-16 14:02:55 +05:30
rohitwaghchaure
529f8dc7cd Merge pull request #55826 from rohitwaghchaure/moved-files-to-services
refactor: moved files from stock_entry_handler to services
2026-06-16 13:57:08 +05:30
Shllokkk
52b406f5f1 fix(budget): add root_type filter on account field (#55934) 2026-06-16 11:43:52 +05:30
MochaMind
3dda2005d8 fix: sync translations from crowdin (#55900) 2026-06-16 11:13:36 +05:30
Jatin3128
322d4dff25 fix: clear stale payment rows on non-POS returns so they don't surface in bank reconciliation (#55903) 2026-06-16 10:42:36 +05:30
ruthra kumar
01a10fb5b0 Merge pull request #55949 from ruthra-kumar/speed_up_item_wise_inventory_account_tests
refactor(test): speed up item wise inventory test
2026-06-16 08:47:00 +05:30
ruthra kumar
4c084f7eff Merge pull request #55948 from ruthra-kumar/transaction_deletion_record_test_speed_up
refactor(test): faster transaction deletion record tests
2026-06-16 08:34:03 +05:30
ruthra kumar
627f2058b5 refactor(test): speed up item wise inventory test 2026-06-16 08:23:45 +05:30
ruthra kumar
8db4d2705a refactor(test): dont create master data in setUp 2026-06-16 08:03:47 +05:30
ruthra kumar
4ca7bc8ccf Merge pull request #55942 from ruthra-kumar/speed_up_delivery_note_tests
refactor(test): dont create company in setUp of Deliv Note
2026-06-15 20:16:16 +05:30
rohitwaghchaure
ca5cc4afdc Merge pull request #55928 from aerele/fix/support-#70854
fix(stock): update stock value calculation in stock balance report
2026-06-15 18:10:23 +05:30
Jatin3128
380b005659 fix: fiscal year check on validation (#55930) 2026-06-15 18:08:05 +05:30
ruthra kumar
df0ad93262 refactor(test): dont create company in setUp of Deliv Note 2026-06-15 17:51:02 +05:30
ruthra kumar
f503614cc0 Merge pull request #55929 from ruthra-kumar/parallelize_and_optimize_install_helper
ci: optimize install helper setup
2026-06-15 17:39:24 +05:30
Rohit Waghchaure
6d9beea56b refactor: moved files from stock_entry_handler to services 2026-06-15 17:28:50 +05:30
rohitwaghchaure
560d8bb674 Merge pull request #55926 from rohitwaghchaure/fixed-recalculate-rate-for-purchase-doc
fix: recalculate incoming rate in SLE for purchase documents during repost
2026-06-15 17:03:56 +05:30
Mihir Kandoi
a3e3e1b32c ci: optimize install helper setup
Cc: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:50:50 +05:30
Sudharsanan11
2492dfa558 fix(stock): update stock value calculation in stock balance report 2026-06-15 16:27:18 +05:30
ervishnucs
3b5a203d61 test: resolve failed testcases for exchage rate 2026-06-15 16:20:04 +05:30
ervishnucs
934abe5c6d fix: prevent exchange rate flow from transaction to payment 2026-06-15 16:20:04 +05:30
Rohit Waghchaure
867ee484b9 fix: recalculate incoming rate in SLE for purchase documents during repost 2026-06-15 16:09:03 +05:30
Diptanil Saha
2652082475 Merge pull request #55755 from diptanilsaha/feat/ces_frankfurter_v2
feat(currency exchange settings): frankfurter v2 support
2026-06-15 14:22:49 +05:30
diptanilsaha
abb579e2db fix(get_exchange_rate): using get_single_value to fetch disabled value from currency_exchange_settings 2026-06-15 13:52:34 +05:30
diptanilsaha
0c2d5488a6 fix: restricting currency_exchange_settings write permission only to system manager 2026-06-15 13:52:34 +05:30
diptanilsaha
138f683a68 test: fixed currency exchange test for frankfurter v2 api 2026-06-15 13:52:27 +05:30
diptanilsaha
479f9f63c9 fix: use frankfurter v2 by default for new install 2026-06-15 13:52:06 +05:30
diptanilsaha
56bfe6b6a6 feat(currency exchange settings): frankfurter v2 support 2026-06-15 13:52:06 +05:30
rohitwaghchaure
acae34c8e1 Merge pull request #55901 from rohitwaghchaure/fixed-regression-security-fixes
fix: regression issues related to security fixes
2026-06-15 12:20:38 +05:30
Mihir Kandoi
dcbe4a6d55 Merge pull request #55906 from raghavisruia/develop
fix: show company name in delete transactions confirmation dialog
2026-06-15 09:49:59 +05:30
Raghav Ruia
87d26a2d67 fix: show company name in delete transactions confirmation dialog
Display the actual company name in bold within the confirmation dialog
label so users immediately know which company they must type to confirm,
reducing the risk of accidental data loss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 09:45:55 +05:30
Rohit Waghchaure
e1d8d06966 refactor: consolidate duplicate get_party_bank_account into bank_account.py 2026-06-14 23:53:35 +05:30
Rohit Waghchaure
8c88cecc1f fix: regression issues related to security fixes 2026-06-14 23:42:48 +05:30
MochaMind
9aeafb8140 fix: sync translations from crowdin (#55784) 2026-06-14 17:37:37 +00:00
MochaMind
c24e9796ae chore: update POT file (#55894) 2026-06-14 13:11:21 +02:00
rohitwaghchaure
c7d42e161b Merge pull request #55877 from rohitwaghchaure/feat-allow_to_edit_stock_uom_qty_for_stock_entry
feat: Allow to edit stock UOM qty for Stock Entry
2026-06-14 09:54:24 +05:30
Raffael Meyer
701896692a ci: set disabledLabels and context for greptile (#55883) 2026-06-13 18:46:03 +00:00
Raffael Meyer
93d6be2ed7 fix(Lead): stop storing Gravatar image URLs for Leads (#55880) 2026-06-13 19:03:29 +02:00
Rohit Waghchaure
b0e9ad198f feat: Allow to edit stock UOM qty for Stock Entry 2026-06-13 21:41:05 +05:30
Dipen Gala
a9029f83c7 feat(invoices): add tooltip description to Update Stock checkbox (#55868)
* feat(invoices): add tooltip description to Update Stock checkbox

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: remove duplicate description in purchase_invoice update_stock field

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

* revert: restore custom tooltip in purchase_invoice.js

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

* revert: remove all changes from purchase_invoice.js

Keep purchase_invoice.js identical to upstream develop.

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

---------

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

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

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

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

Fixes: https://github.com/frappe/erpnext/issues/...
2026-06-12 12:57:58 +05:30
Mihir Kandoi
64175bdb3e fix: skip unchanged employee user status sync 2026-06-11 21:34:43 +05:30
Mihir Kandoi
4fed04c6c7 fix: sync employee user status after save 2026-06-11 20:58:35 +05:30
105 changed files with 100769 additions and 30234 deletions

View File

@@ -4,24 +4,46 @@ 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
@@ -37,6 +59,11 @@ 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'"
@@ -51,9 +78,11 @@ fi
install_whktml() {
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
# 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
sudo apt install /tmp/wkhtmltox.deb
}
install_whktml &
wkpid=$!

View File

@@ -59,6 +59,10 @@ 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
@@ -122,6 +126,12 @@ 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:
@@ -131,7 +141,14 @@ jobs:
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
- name: Run Tests
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'
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
env:
TYPE: server
@@ -141,6 +158,7 @@ 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 }}
@@ -149,6 +167,7 @@ jobs:
coverage:
name: Coverage Wrap Up
needs: test
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Clone

10
.greptile/config.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,22 +11,28 @@ frappe.ui.form.on("Currency Exchange Settings", {
},
callback: function (r) {
if (r && r.message) {
let result = [],
params = {};
if (frm.doc.service_provider == "exchangerate.host") {
let result = ["result"];
let params = {
result = ["result"];
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)) {
let result = ["rates", "{to_currency}"];
let params = {
result = ["rates", "{to_currency}"];
params = {
base: "{from_currency}",
symbols: "{to_currency}",
};
add_param(frm, r.message, params, result);
} else if (frm.doc.service_provider == "frankfurter.dev - v2") {
result = ["rate"];
params = {
date: "{transaction_date}",
};
}
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\nCustom",
"options": "frankfurter.dev\nexchangerate.host\nfrankfurter.dev - v2\nCustom",
"reqd": 1
},
{
@@ -101,11 +101,10 @@
"label": "Use HTTP Protocol"
}
],
"hide_toolbar": 0,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-03-16 13:28:21.075743",
"modified": "2026-06-15 11:25:55.873110",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Currency Exchange Settings",
@@ -122,24 +121,11 @@
"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,
"write": 1
"share": 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", "Custom"]
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "frankfurter.dev - v2", "Custom"]
url: DF.Data | None
use_http: DF.Check
# end: auto-generated types
@@ -70,6 +70,14 @@ 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:
@@ -105,13 +113,20 @@ 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"]:
if service_provider and service_provider in [
"exchangerate.host",
"frankfurter.dev",
"frankfurter.app",
"frankfurter.dev - v2",
]:
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

@@ -484,6 +484,12 @@ 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(

View File

@@ -662,6 +662,13 @@ 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

View File

@@ -2780,7 +2780,7 @@ def get_payment_entry(
pe, doc, discount_amount, base_total_discount_loss, party_account_currency
)
pe.set_exchange_rate(ref_doc=doc)
pe.set_exchange_rate()
pe.set_amounts()
# If PE is created from PR directly, then no need to find open PRs for the references

View File

@@ -532,6 +532,8 @@ 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()
@@ -607,6 +609,8 @@ 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,11 +11,12 @@ 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, get_party_bank_account
from erpnext.accounts.party import get_party_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,7 +332,12 @@ class TestPaymentRequest(ERPNextTestSuite):
return_doc=1,
)
pe = pr.set_as_paid()
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()
expected_gle = dict(
(d[0], d)
@@ -418,7 +423,12 @@ 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()
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()
self.assertEqual(pe.base_paid_amount, 800)
self.assertEqual(pe.paid_amount, 800)
self.assertEqual(pe.base_received_amount, 800)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,10 +114,17 @@ class POSService:
return pos
def set_paid_amount(self) -> None:
def update_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,11 +509,6 @@ 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

@@ -80,6 +80,8 @@ 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 (
update_subcontracting_order_status as update_sco_status,
set_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 validate_fiscal_year
from erpnext.accounts.utils import get_fiscal_year, validate_fiscal_year
from erpnext.controllers.print_settings import (
set_print_templates_for_item_table,
set_print_templates_for_taxes,
@@ -640,21 +640,29 @@ class AccountsController(TransactionBase):
self.calculate_contribution()
def validate_date_with_fiscal_year(self):
if self.meta.get_field("fiscal_year"):
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"
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,
)
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),
)
def validate_due_date(self):
if self.get("is_pos") or self.doctype not in ["Sales Invoice", "Purchase Invoice"]:

View File

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

View File

@@ -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 (
update_subcontracting_inward_order_status,
set_subcontracting_inward_order_status,
)
update_subcontracting_inward_order_status(self.subcontracting_inward_order)
set_subcontracting_inward_order_status(self.subcontracting_inward_order)
@frappe.whitelist()

View File

@@ -16,12 +16,13 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestItemWiseInventoryAccount(ERPNextTestSuite):
def setUp(self):
self.company = make_company()
self.company_abbr = frappe.db.get_value("Company", self.company, "abbr")
self.company = "_Test Company with perpetual inventory"
self.company_abbr = "TCP1"
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 = {
@@ -577,23 +578,3 @@ 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,6 +14,7 @@
"opportunity_section",
"close_opportunity_after_days",
"column_break_9",
"enable_opportunity_creation_from_contact_us",
"quotation_section",
"default_valid_till",
"section_break_13",
@@ -98,15 +99,20 @@
"fieldname": "update_timestamp_on_new_communication",
"fieldtype": "Check",
"label": "Update timestamp on new communication"
},
{
"default": "0",
"fieldname": "enable_opportunity_creation_from_contact_us",
"fieldtype": "Check",
"label": "Enable Opportunity Creation from Contact Us"
}
],
"grid_page_length": 50,
"hide_toolbar": 0,
"icon": "fa fa-cog",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-03-16 13:28:19.573964",
"modified": "2026-06-11 23:09:49.750381",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

62782
erpnext/locale/hi.po Normal file

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

@@ -388,8 +388,6 @@ class BOMCreator(Document):
@frappe.whitelist()
def edit_bom_creator(self, docname: str, data: str | dict):
frappe.has_permission("BOM Creator", "write", doc=self, throw=True)
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))
@@ -426,8 +424,6 @@ class BOMCreator(Document):
@frappe.whitelist()
def add_item(self, **kwargs):
frappe.has_permission("BOM Creator", "write", doc=self, throw=True)
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
@@ -458,8 +454,6 @@ class BOMCreator(Document):
@frappe.whitelist()
def add_sub_assembly(self, **kwargs):
frappe.has_permission("BOM Creator", "write", doc=self, throw=True)
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)
@@ -499,7 +493,7 @@ class BOMCreator(Document):
else:
if sbool(kwargs.phantom):
parent_row = next(item for item in self.items if item.name == kwargs.fg_reference_id)
parent_row.db_set("is_phantom_item", 1)
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"):
@@ -528,8 +522,6 @@ class BOMCreator(Document):
@frappe.whitelist()
def delete_node(self, **kwargs):
frappe.has_permission("BOM Creator", "write", doc=self, throw=True)
if isinstance(kwargs, str):
kwargs = frappe.parse_json(kwargs)

View File

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

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.stock_entry_handler.manufacturing import (
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
ManufactureStockEntry,
)

View File

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

View File

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

View File

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

View File

@@ -227,7 +227,9 @@ frappe.ui.form.on("Company", {
{
fieldtype: "Data",
fieldname: "company_name",
label: __("Please enter the company name to confirm"),
label: __('Please enter the company name <b>"{0}"</b> to confirm', [
frappe.utils.escape_html(frm.doc.name),
]),
reqd: 1,
description: __(
"Please make sure you really want to delete all the transactions for this company. Your master data will remain as it is. This action cannot be undone."

View File

@@ -65,13 +65,16 @@ def patched_requests_get(*args, **kwargs):
if kwargs["params"].get("date") and kwargs["params"].get("from") and kwargs["params"].get("to"):
if test_exchange_values.get(kwargs["params"]["date"]):
return PatchResponse({"result": test_exchange_values[kwargs["params"]["date"]]}, 200)
elif args[0].startswith("https://api.frankfurter.dev") and kwargs.get("params"):
elif args[0].startswith("https://api.frankfurter.dev/v1") and kwargs.get("params"):
if kwargs["params"].get("base") and kwargs["params"].get("symbols"):
date = args[0].replace("https://api.frankfurter.dev/v1/", "")
if test_exchange_values.get(date):
return PatchResponse(
{"rates": {kwargs["params"].get("symbols"): test_exchange_values.get(date)}}, 200
)
elif args[0].startswith("https://api.frankfurter.dev/v2") and kwargs.get("params"):
if kwargs["params"].get("date") and test_exchange_values.get(kwargs["params"]["date"]):
return PatchResponse({"rate": test_exchange_values.get(kwargs["params"]["date"])}, 200)
return PatchResponse({"rates": None}, 404)

View File

@@ -150,6 +150,9 @@ class Employee(NestedSet):
)
def validate_user_details(self):
if not self.user_id:
return
self.validate_for_enabled_user_id()
self.validate_duplicate_user_id()
@@ -172,6 +175,7 @@ class Employee(NestedSet):
if self.user_id:
self.update_user()
self.update_user_permissions()
self.update_user_status()
self.reset_employee_emails_cache()
def before_insert(self):
@@ -293,10 +297,18 @@ class Employee(NestedSet):
if not frappe.db.exists("User", self.user_id):
frappe.throw(_("User {0} does not exist").format(self.user_id))
def update_user_status(self):
if not self.user_id:
return
if not self.has_value_changed("status") and not self.has_value_changed("user_id"):
return
user = frappe.get_doc("User", self.user_id)
enabled = user.enabled
if self.status != "Active" and enabled or self.status == "Active" and enabled == 0:
user.enabled = not enabled
# Keep linked User status in sync from the Employee lifecycle and record the audit log.
user.save(ignore_permissions=True)
def validate_duplicate_user_id(self):

View File

@@ -11,7 +11,6 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
def setUp(self):
# Clear all deletion cache flags from previous tests
self._clear_all_deletion_cache_flags()
create_company("Dunder Mifflin Paper Co")
def _clear_all_deletion_cache_flags(self):
"""Clear all deletion_running_doctype:* cache keys"""
@@ -31,7 +30,7 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
def test_doctypes_contain_company_field(self):
"""Test that all DocTypes in To Delete list have a valid company link field"""
tdr = create_and_submit_transaction_deletion_doc("Dunder Mifflin Paper Co")
tdr = create_and_submit_transaction_deletion_doc("_Test Company 7")
for doctype_row in tdr.doctypes_to_delete:
# If company_field is specified, verify it's a valid Company link field
if doctype_row.company_field:
@@ -53,8 +52,8 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
def test_no_of_docs_is_correct(self):
"""Test that document counts are calculated correctly in To Delete list"""
for _ in range(5):
create_task("Dunder Mifflin Paper Co")
tdr = create_and_submit_transaction_deletion_doc("Dunder Mifflin Paper Co")
create_task("_Test Company 7")
tdr = create_and_submit_transaction_deletion_doc("_Test Company 7")
tdr.reload()
# Check To Delete list has correct count
@@ -68,9 +67,9 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
def test_deletion_is_successful(self):
"""Test that deletion actually removes documents"""
create_task("Dunder Mifflin Paper Co")
create_and_submit_transaction_deletion_doc("Dunder Mifflin Paper Co")
tasks_containing_company = frappe.get_all("Task", filters={"company": "Dunder Mifflin Paper Co"})
create_task("_Test Company 7")
create_and_submit_transaction_deletion_doc("_Test Company 7")
tasks_containing_company = frappe.get_all("Task", filters={"company": "_Test Company 7"})
self.assertEqual(tasks_containing_company, [])
def test_company_transaction_deletion_request(self):
@@ -78,15 +77,14 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
from erpnext.setup.doctype.company.company import create_transaction_deletion_request
# don't reuse below company for other test cases
company = "Deep Space Exploration"
create_company(company)
company = "_Test Company 7"
# below call should not raise any exceptions or throw errors
create_transaction_deletion_request(company)
def test_generate_to_delete_list(self):
"""Test automatic generation of To Delete list"""
company = "Dunder Mifflin Paper Co"
company = "_Test Company 7"
create_task(company)
tdr = frappe.new_doc("Transaction Deletion Record")
@@ -104,7 +102,7 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
def test_validation_prevents_child_tables(self):
"""Test that child tables cannot be added to To Delete list"""
company = "Dunder Mifflin Paper Co"
company = "_Test Company 7"
tdr = frappe.new_doc("Transaction Deletion Record")
tdr.company = company
@@ -116,7 +114,7 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
def test_validation_prevents_protected_doctypes(self):
"""Test that protected DocTypes cannot be added to To Delete list"""
company = "Dunder Mifflin Paper Co"
company = "_Test Company 7"
tdr = frappe.new_doc("Transaction Deletion Record")
tdr.company = company
@@ -128,7 +126,7 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
def test_csv_export_import(self):
"""Test CSV export and import functionality with company_field column"""
company = "Dunder Mifflin Paper Co"
company = "_Test Company 7"
create_task(company)
# Create and generate To Delete list
@@ -167,7 +165,7 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
def test_progress_tracking(self):
"""Test that deleted checkbox is marked when DocType deletion completes"""
company = "Dunder Mifflin Paper Co"
company = "_Test Company 7"
create_task(company)
tdr = create_and_submit_transaction_deletion_doc(company)
@@ -186,7 +184,7 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
def test_composite_key_validation(self):
"""Test that duplicate (doctype_name + company_field) combinations are prevented"""
company = "Dunder Mifflin Paper Co"
company = "_Test Company 7"
tdr = frappe.new_doc("Transaction Deletion Record")
tdr.company = company
@@ -199,7 +197,7 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
def test_same_doctype_different_company_field_allowed(self):
"""Test that same DocType can be added with different company_field values"""
company = "Dunder Mifflin Paper Co"
company = "_Test Company 7"
tdr = frappe.new_doc("Transaction Deletion Record")
tdr.company = company
@@ -220,7 +218,7 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
def test_company_field_validation(self):
"""Test that invalid company_field values are rejected"""
company = "Dunder Mifflin Paper Co"
company = "_Test Company 7"
tdr = frappe.new_doc("Transaction Deletion Record")
tdr.company = company
@@ -274,7 +272,7 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
def test_cache_flag_management(self):
"""Test that cache flags can be set and cleared correctly"""
company = "Dunder Mifflin Paper Co"
company = "_Test Company 7"
create_task(company)
tdr = frappe.new_doc("Transaction Deletion Record")
@@ -303,7 +301,7 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
check_for_running_deletion_job,
)
company = "Dunder Mifflin Paper Co"
company = "_Test Company 7"
# Manually set cache flag to simulate running deletion
frappe.cache.set_value("deletion_running_doctype:Task", "TDR-00001", expires_in_sec=60)
@@ -327,7 +325,7 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
def test_check_for_running_deletion_allows_save_when_no_flag(self):
"""Test that documents can be saved when no deletion is running"""
company = "Dunder Mifflin Paper Co"
company = "_Test Company 7"
# Ensure no cache flag exists
frappe.cache.delete_value("deletion_running_doctype:Task")
@@ -347,10 +345,8 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
def test_only_one_deletion_allowed_globally(self):
"""Test that only one deletion can be submitted at a time (global enforcement)"""
company1 = "Dunder Mifflin Paper Co"
company2 = "Sabre Corporation"
create_company(company2)
company1 = "_Test Company 6"
company2 = "_Test Company 7"
# Create and submit first deletion (but don't start it)
tdr1 = frappe.new_doc("Transaction Deletion Record")
@@ -379,13 +375,6 @@ class TestTransactionDeletionRecord(ERPNextTestSuite):
tdr1.cancel()
def create_company(company_name):
company = frappe.get_doc(
{"doctype": "Company", "company_name": company_name, "default_currency": "INR", "country": "India"}
)
company.insert(ignore_if_duplicate=True)
def create_and_submit_transaction_deletion_doc(company):
"""Create and execute a transaction deletion record"""
tdr = frappe.get_doc({"doctype": "Transaction Deletion Record", "company": company})

View File

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

View File

@@ -86,14 +86,7 @@ def setup_repost_defaults():
def setup_currency_exchange():
ces = frappe.get_single("Currency Exchange Settings")
try:
ces.set("result_key", [])
ces.set("req_params", [])
ces.api_endpoint = "https://api.frankfurter.dev/v1/{transaction_date}"
ces.append("result_key", {"key": "rates"})
ces.append("result_key", {"key": "{to_currency}"})
ces.append("req_params", {"key": "base", "value": "{from_currency}"})
ces.append("req_params", {"key": "symbols", "value": "{to_currency}"})
ces.service_provider = "frankfurter.dev - v2"
ces.save()
except frappe.ValidationError:
pass

View File

@@ -100,7 +100,7 @@ def get_exchange_rate(
if entries:
return flt(entries[0].exchange_rate)
if frappe.get_cached_value("Currency Exchange Settings", "Currency Exchange Settings", "disabled"):
if frappe.get_single_value("Currency Exchange Settings", "disabled"):
return 0.00
pegged_currencies = {}

View File

@@ -46,35 +46,18 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestDeliveryNote(ERPNextTestSuite):
SDBNB_COMPANY_NAME = "_Test SDBNB Company"
SDBNB_COMPANY_ABBR = "_TSDBNB"
def setUp(self):
self.load_test_records("Stock Entry")
self.setup_sdbnb_company()
def setup_sdbnb_company(self):
if frappe.db.exists("Company", self.SDBNB_COMPANY_NAME):
company = frappe.get_doc("Company", self.SDBNB_COMPANY_NAME)
else:
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": self.SDBNB_COMPANY_NAME,
"abbr": self.SDBNB_COMPANY_ABBR,
"country": "India",
"default_currency": "INR",
"enable_perpetual_inventory": 1,
}
).insert()
self.sdbnb_company = company.name
self.sdbnb_account = company.stock_delivered_but_not_billed
self.sdbnb_cost_center = company.cost_center
self.sdbnb_warehouse = f"Stores - {self.SDBNB_COMPANY_ABBR}"
self.sdbnb_expense_account = f"Cost of Goods Sold - {self.SDBNB_COMPANY_ABBR}"
self.sdbnb_income_account = f"Sales - {self.SDBNB_COMPANY_ABBR}"
self.sdbnb_debit_to = f"Debtors - {self.SDBNB_COMPANY_ABBR}"
def get_perpetual_defaults(self):
company = frappe.get_doc("Company", "_Test Company with perpetual inventory")
self.perpetual_company = company.name
self.perpetual_account = company.stock_delivered_but_not_billed
self.perpetual_cost_center = company.cost_center
self.perpetual_warehouse = f"Stores - {company.abbr}"
self.perpetual_expense_account = f"Cost of Goods Sold - {company.abbr}"
self.perpetual_income_account = f"Sales - {company.abbr}"
self.perpetual_debit_to = f"Debtors - {company.abbr}"
def test_delivery_note_qty(self):
dn = create_delivery_note(qty=0, do_not_save=True)
@@ -2895,36 +2878,37 @@ class TestDeliveryNote(ERPNextTestSuite):
def test_sdbnb_gl_entry_on_delivery_note(self):
"""Test that DN GL entries use SDBNB account when configured on the company."""
self.get_perpetual_defaults()
item_code = make_item("SDBNB Test Item", properties={"is_stock_item": 1}).name
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
target=self.perpetual_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
company=self.perpetual_company,
)
dn = create_delivery_note(
item_code=item_code,
qty=5,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
company=self.perpetual_company,
warehouse=self.perpetual_warehouse,
cost_center=self.perpetual_cost_center,
expense_account=self.perpetual_expense_account,
)
# DN expense_account should be overridden to SDBNB
dn.reload()
self.assertEqual(dn.items[0].expense_account, self.sdbnb_account)
self.assertEqual(dn.items[0].expense_account, self.perpetual_account)
# Verify DN GL entries use SDBNB account (not COGS)
gl_entries = get_gl_entries("Delivery Note", dn.name)
self.assertTrue(gl_entries)
stock_in_hand_account = get_inventory_account(self.sdbnb_company)
stock_in_hand_account = get_inventory_account(self.perpetual_company)
expected_values = {
self.sdbnb_account: {"debit": True},
self.perpetual_account: {"debit": True},
stock_in_hand_account: {"credit": True},
}
for gle in gl_entries:
@@ -2936,23 +2920,24 @@ class TestDeliveryNote(ERPNextTestSuite):
def test_sdbnb_reversal_on_sales_invoice(self):
"""Test that SI created from DN reverses SDBNB entries (credits SDBNB, debits COGS)."""
self.get_perpetual_defaults()
item_code = make_item("SDBNB Reversal Test Item", properties={"is_stock_item": 1}).name
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
target=self.perpetual_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
company=self.perpetual_company,
)
dn = create_delivery_note(
item_code=item_code,
qty=5,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
company=self.perpetual_company,
warehouse=self.perpetual_warehouse,
cost_center=self.perpetual_cost_center,
expense_account=self.perpetual_expense_account,
)
si = make_sales_invoice(dn.name)
@@ -2978,33 +2963,34 @@ class TestDeliveryNote(ERPNextTestSuite):
si_gl_entries = get_gl_entries("Sales Invoice", si.name)
self.assertTrue(si_gl_entries)
self.assertGreater(
sum(gle.debit for gle in si_gl_entries if gle.account == self.sdbnb_expense_account), 0
sum(gle.debit for gle in si_gl_entries if gle.account == self.perpetual_expense_account), 0
)
sdbnb_credit = sum(gle.credit for gle in si_gl_entries if gle.account == self.sdbnb_account)
cogs_debit = sum(gle.debit for gle in si_gl_entries if gle.account == self.sdbnb_expense_account)
sdbnb_credit = sum(gle.credit for gle in si_gl_entries if gle.account == self.perpetual_account)
cogs_debit = sum(gle.debit for gle in si_gl_entries if gle.account == self.perpetual_expense_account)
self.assertEqual(flt(sdbnb_credit, 2), flt(expected_amount, 2))
self.assertEqual(flt(cogs_debit, 2), flt(expected_amount, 2))
def test_sdbnb_partial_billing(self):
"""Test SDBNB reversal for partial invoicing - only billed qty should be reversed."""
self.get_perpetual_defaults()
item_code = make_item("SDBNB Partial Bill Item", properties={"is_stock_item": 1}).name
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
target=self.perpetual_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
company=self.perpetual_company,
)
dn = create_delivery_note(
item_code=item_code,
qty=10,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
company=self.perpetual_company,
warehouse=self.perpetual_warehouse,
cost_center=self.perpetual_cost_center,
expense_account=self.perpetual_expense_account,
)
# Create SI from DN and reduce qty to 4 (partial billing)
@@ -3030,122 +3016,125 @@ class TestDeliveryNote(ERPNextTestSuite):
expected_amount = flt(valuation_rate * 4) # Only 4 out of 10
si_gl_entries = get_gl_entries("Sales Invoice", si.name)
sdbnb_credit = sum(gle.credit for gle in si_gl_entries if gle.account == self.sdbnb_account)
sdbnb_credit = sum(gle.credit for gle in si_gl_entries if gle.account == self.perpetual_account)
self.assertEqual(flt(sdbnb_credit, 2), flt(expected_amount, 2))
def test_sdbnb_disabled_for_sales_return(self):
"""Test that sales return DN uses default expense account when disable_sdbnb_in_sr is enabled."""
frappe.db.set_value("Company", self.sdbnb_company, "disable_sdbnb_in_sr", 1)
self.get_perpetual_defaults()
frappe.db.set_value("Company", self.perpetual_company, "disable_sdbnb_in_sr", 1)
try:
item_code = make_item("SDBNB Return Disable Item", properties={"is_stock_item": 1}).name
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
target=self.perpetual_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
company=self.perpetual_company,
)
dn = create_delivery_note(
item_code=item_code,
qty=5,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
company=self.perpetual_company,
warehouse=self.perpetual_warehouse,
cost_center=self.perpetual_cost_center,
expense_account=self.perpetual_expense_account,
)
# Original DN should use SDBNB
dn.reload()
self.assertEqual(dn.items[0].expense_account, self.sdbnb_account)
self.assertEqual(dn.items[0].expense_account, self.perpetual_account)
return_dn = create_delivery_note(
item_code=item_code,
qty=-3,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
company=self.perpetual_company,
warehouse=self.perpetual_warehouse,
cost_center=self.perpetual_cost_center,
expense_account=self.perpetual_expense_account,
is_return=1,
return_against=dn.name,
)
# Return DN should not use SDBNB (disable_sdbnb_in_sr is on)
return_dn.reload()
self.assertNotEqual(return_dn.items[0].expense_account, self.sdbnb_account)
self.assertNotEqual(return_dn.items[0].expense_account, self.perpetual_account)
finally:
frappe.db.set_value("Company", self.sdbnb_company, "disable_sdbnb_in_sr", 0)
frappe.db.set_value("Company", self.perpetual_company, "disable_sdbnb_in_sr", 0)
def test_sdbnb_enabled_for_sales_return(self):
"""Test that sales return DN uses SDBNB account when disable_sdbnb_in_sr is off."""
self.get_perpetual_defaults()
item_code = make_item("SDBNB Return Enable Item", properties={"is_stock_item": 1}).name
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
target=self.perpetual_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
company=self.perpetual_company,
)
dn = create_delivery_note(
item_code=item_code,
qty=5,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
company=self.perpetual_company,
warehouse=self.perpetual_warehouse,
cost_center=self.perpetual_cost_center,
expense_account=self.perpetual_expense_account,
)
return_dn = create_delivery_note(
item_code=item_code,
qty=-3,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
company=self.perpetual_company,
warehouse=self.perpetual_warehouse,
cost_center=self.perpetual_cost_center,
expense_account=self.perpetual_expense_account,
is_return=1,
return_against=dn.name,
)
# Return DN should also use SDBNB since disable flag is off by default
return_dn.reload()
self.assertEqual(return_dn.items[0].expense_account, self.sdbnb_account)
self.assertEqual(return_dn.items[0].expense_account, self.perpetual_account)
def test_sdbnb_no_reversal_with_update_stock(self):
"""Test that SI with update_stock=1 (standalone, no DN link) does NOT create SDBNB GL entries."""
self.get_perpetual_defaults()
item_code = make_item("SDBNB Update Stock Item", properties={"is_stock_item": 1}).name
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
target=self.perpetual_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
company=self.perpetual_company,
)
# Create standalone SI with update_stock=1 (no DN link)
si = create_sales_invoice(
company=self.sdbnb_company,
company=self.perpetual_company,
currency="INR",
debit_to=self.sdbnb_debit_to,
income_account=self.sdbnb_income_account,
debit_to=self.perpetual_debit_to,
income_account=self.perpetual_income_account,
update_stock=1,
item_code=item_code,
qty=5,
rate=150,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
warehouse=self.perpetual_warehouse,
cost_center=self.perpetual_cost_center,
expense_account=self.perpetual_expense_account,
)
# SI GL entries should not have SDBNB account
si_gl_entries = get_gl_entries("Sales Invoice", si.name)
sdbnb_entries = [gle for gle in si_gl_entries if gle.account == self.sdbnb_account]
sdbnb_entries = [gle for gle in si_gl_entries if gle.account == self.perpetual_account]
self.assertEqual(len(sdbnb_entries), 0)
def test_sdbnb_skip_for_dn_against_sales_invoice(self):
@@ -3154,62 +3143,66 @@ class TestDeliveryNote(ERPNextTestSuite):
make_delivery_note as make_dn_from_si,
)
self.get_perpetual_defaults()
item_code = make_item("SDBNB Against SI Item", properties={"is_stock_item": 1}).name
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
target=self.perpetual_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
company=self.perpetual_company,
)
si = create_sales_invoice(
company=self.sdbnb_company,
company=self.perpetual_company,
currency="INR",
debit_to=self.sdbnb_debit_to,
income_account=self.sdbnb_income_account,
debit_to=self.perpetual_debit_to,
income_account=self.perpetual_income_account,
update_stock=0,
item_code=item_code,
qty=5,
rate=150,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
warehouse=self.perpetual_warehouse,
cost_center=self.perpetual_cost_center,
expense_account=self.perpetual_expense_account,
)
dn = make_dn_from_si(si.name)
self.assertEqual(dn.items[0].expense_account, self.sdbnb_expense_account)
self.assertEqual(dn.items[0].expense_account, self.perpetual_expense_account)
dn.submit()
# DN items created from SI have against_sales_invoice set,
# so SDBNB should be skipped
dn.reload()
self.assertEqual(dn.items[0].expense_account, self.sdbnb_expense_account)
self.assertEqual(dn.items[0].expense_account, self.perpetual_expense_account)
def test_sdbnb_non_stock_item_skipped(self):
"""Test that non-stock items are not assigned SDBNB account."""
self.get_perpetual_defaults()
non_stock_item = make_item(
"SDBNB Non Stock Item",
properties={"is_stock_item": 0},
).name
dn = create_delivery_note(
company=self.sdbnb_company,
company=self.perpetual_company,
item_code=non_stock_item,
warehouse=self.sdbnb_warehouse,
warehouse=self.perpetual_warehouse,
qty=5,
rate=150,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
cost_center=self.perpetual_cost_center,
expense_account=self.perpetual_expense_account,
do_not_submit=True,
)
# Non-stock item should retain original expense_account, not SDBNB
self.assertNotEqual(dn.items[0].expense_account, self.sdbnb_account)
self.assertEqual(dn.items[0].expense_account, self.sdbnb_expense_account)
self.assertNotEqual(dn.items[0].expense_account, self.perpetual_account)
self.assertEqual(dn.items[0].expense_account, self.perpetual_expense_account)
def test_sdbnb_reposting_with_fifo(self):
"""Test that backdated inward entry triggers reposting and updates SDBNB GL entries (FIFO)."""
self.get_perpetual_defaults()
item_code = make_item(
"SDBNB Repost FIFO Item", properties={"is_stock_item": 1, "valuation_method": "FIFO"}
).name
@@ -3219,10 +3212,10 @@ class TestDeliveryNote(ERPNextTestSuite):
# Inward 10 qty @ 100
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
target=self.perpetual_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
company=self.perpetual_company,
posting_date=posting_date,
)
@@ -3231,16 +3224,16 @@ class TestDeliveryNote(ERPNextTestSuite):
item_code=item_code,
qty=5,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
company=self.perpetual_company,
warehouse=self.perpetual_warehouse,
cost_center=self.perpetual_cost_center,
expense_account=self.perpetual_expense_account,
posting_date=posting_date,
)
# Verify initial DN GL: SDBNB Dr 500, Stock In Hand Cr 500
dn_gl = get_gl_entries("Delivery Note", dn.name)
sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.sdbnb_account)
sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.perpetual_account)
self.assertEqual(flt(sdbnb_debit, 2), 500.0)
# SI from DN
@@ -3251,8 +3244,8 @@ class TestDeliveryNote(ERPNextTestSuite):
# Verify initial SI GL: SDBNB Cr 500, COGS Dr 500
si_gl = get_gl_entries("Sales Invoice", si.name)
sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.sdbnb_account)
cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.sdbnb_expense_account)
sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.perpetual_account)
cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.perpetual_expense_account)
self.assertEqual(flt(sdbnb_credit, 2), 500.0)
self.assertEqual(flt(cogs_debit, 2), 500.0)
@@ -3260,27 +3253,28 @@ class TestDeliveryNote(ERPNextTestSuite):
# DN now consumes 5@50 from front → stock_value_diff = -250
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
target=self.perpetual_warehouse,
qty=5,
basic_rate=50,
company=self.sdbnb_company,
company=self.perpetual_company,
posting_date=add_days(posting_date, -1),
)
# After repost: DN GL should reflect new valuation (250 instead of 500)
dn_gl = get_gl_entries("Delivery Note", dn.name)
sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.sdbnb_account)
sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.perpetual_account)
self.assertEqual(flt(sdbnb_debit, 2), 250.0)
# After repost: SI GL should also reflect new valuation
si_gl = get_gl_entries("Sales Invoice", si.name)
sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.sdbnb_account)
cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.sdbnb_expense_account)
sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.perpetual_account)
cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.perpetual_expense_account)
self.assertEqual(flt(sdbnb_credit, 2), 250.0)
self.assertEqual(flt(cogs_debit, 2), 250.0)
def test_sdbnb_reposting_with_moving_average(self):
"""Test that backdated inward entry triggers reposting and updates SDBNB GL entries (Moving Average)."""
self.get_perpetual_defaults()
item_code = make_item(
"SDBNB Repost MA Item", properties={"is_stock_item": 1, "valuation_method": "Moving Average"}
).name
@@ -3290,10 +3284,10 @@ class TestDeliveryNote(ERPNextTestSuite):
# Inward 10 qty @ 100 → avg = 100
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
target=self.perpetual_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
company=self.perpetual_company,
posting_date=posting_date,
)
@@ -3302,16 +3296,16 @@ class TestDeliveryNote(ERPNextTestSuite):
item_code=item_code,
qty=5,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
company=self.perpetual_company,
warehouse=self.perpetual_warehouse,
cost_center=self.perpetual_cost_center,
expense_account=self.perpetual_expense_account,
posting_date=posting_date,
)
# Verify initial DN GL: SDBNB Dr 500, Stock In Hand Cr 500
dn_gl = get_gl_entries("Delivery Note", dn.name)
sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.sdbnb_account)
sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.perpetual_account)
self.assertEqual(flt(sdbnb_debit, 2), 500.0)
# SI from DN
@@ -3322,8 +3316,8 @@ class TestDeliveryNote(ERPNextTestSuite):
# Verify initial SI GL: SDBNB Cr 500, COGS Dr 500
si_gl = get_gl_entries("Sales Invoice", si.name)
sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.sdbnb_account)
cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.sdbnb_expense_account)
sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.perpetual_account)
cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.perpetual_expense_account)
self.assertEqual(flt(sdbnb_credit, 2), 500.0)
self.assertEqual(flt(cogs_debit, 2), 500.0)
@@ -3332,10 +3326,10 @@ class TestDeliveryNote(ERPNextTestSuite):
# DN 5 qty → reposted stock_value_diff ≈ -416.67
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
target=self.perpetual_warehouse,
qty=5,
basic_rate=50,
company=self.sdbnb_company,
company=self.perpetual_company,
posting_date=add_days(posting_date, -1),
)
@@ -3354,14 +3348,14 @@ class TestDeliveryNote(ERPNextTestSuite):
# DN GL should reflect new moving average valuation
dn_gl = get_gl_entries("Delivery Note", dn.name)
sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.sdbnb_account)
sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.perpetual_account)
self.assertEqual(flt(sdbnb_debit, 2), expected_amount)
self.assertLess(expected_amount, 500.0)
# SI GL should also reflect new valuation
si_gl = get_gl_entries("Sales Invoice", si.name)
sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.sdbnb_account)
cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.sdbnb_expense_account)
sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.perpetual_account)
cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.perpetual_expense_account)
self.assertEqual(flt(sdbnb_credit, 2), expected_amount)
self.assertEqual(flt(cogs_debit, 2), expected_amount)

View File

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

View File

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

View File

@@ -9,13 +9,13 @@ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
from erpnext.stock.utils import get_combine_datetime
from .base import BaseStockEntry
from .manufacturing import (
ceil_qty_if_uom_has_whole_number,
get_bom_items,
get_production_item_details,
get_secondary_items,
)
from .stock_entry_base import BaseStockEntry
def _qty_tolerance(precision: int) -> float:

View File

@@ -16,8 +16,8 @@ from erpnext.stock.serial_batch_bundle import (
get_serial_nos_from_bundle,
)
from .base import BaseStockEntry
from .serial_batch import create_serial_and_batch_bundle
from .stock_entry_base import BaseStockEntry
class BaseManufactureStockEntry(BaseStockEntry):
@@ -388,7 +388,12 @@ class ManufactureStockEntry(BaseManufactureStockEntry):
self.add_secondary_items_from_job_card()
def add_raw_materials(self):
if not frappe.db.get_single_value("Manufacturing Settings", "material_consumption"):
material_consumption = frappe.db.get_single_value("Manufacturing Settings", "material_consumption")
if material_consumption and self.raw_materials_already_consumed():
return
if not material_consumption:
if self.backflush_based_on == "BOM" or self.wo_doc.skip_transfer:
self.add_raw_materials_based_on_work_order()
else:
@@ -398,6 +403,21 @@ class ManufactureStockEntry(BaseManufactureStockEntry):
else:
self.add_raw_materials_based_on_transfer()
def raw_materials_already_consumed(self) -> bool:
if not self.doc.work_order:
return False
return bool(
frappe.db.exists(
"Stock Entry",
{
"work_order": self.doc.work_order,
"purpose": "Material Consumption for Manufacture",
"docstatus": 1,
},
)
)
def add_unconsumed_raw_materials(self):
wo = self.wo_doc
if not wo:

View File

@@ -2,8 +2,8 @@ import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from .base import BaseStockEntry
from .manufacturing import get_bom_items
from .stock_entry_base import BaseStockEntry
class MaterialReceiptStockEntry(BaseStockEntry):

View File

@@ -3,8 +3,8 @@ from frappe import _
from frappe.query_builder.functions import Sum
from frappe.utils import cstr, flt
from .base import BaseStockEntry
from .manufacturing import _check_bom_component_qty, get_bom_items
from .stock_entry_base import BaseStockEntry
class BaseMaterialTransferStockEntry(BaseStockEntry):

View File

@@ -8,7 +8,7 @@ from erpnext.manufacturing.doctype.bom.bom import get_backflush_based_on
from erpnext.stock.serial_batch_bundle import SerialBatchCreation, get_serial_or_batch_items
from erpnext.stock.utils import get_combine_datetime
from .base import BaseStockEntry
from .stock_entry_base import BaseStockEntry
class StockEntrySABB(BaseStockEntry):

View File

@@ -8,7 +8,7 @@ from frappe.utils import flt
from erpnext.stock.utils import get_bin
from .base import BaseStockEntry
from .stock_entry_base import BaseStockEntry
class SendToSubcontractorStockEntry(BaseStockEntry):

View File

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

View File

@@ -37,20 +37,20 @@ from erpnext.stock.get_item_details import (
from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate
from erpnext.stock.utils import get_incoming_rate
from .stock_entry_handler.disassemble import DisassembleStockEntry
from .stock_entry_handler.manufacturing import (
from .services.disassemble import DisassembleStockEntry
from .services.manufacturing import (
ManufactureStockEntry,
MaterialConsumptionForManufactureStockEntry,
RepackStockEntry,
)
from .stock_entry_handler.material_receipt_issue import MaterialIssueStockEntry, MaterialReceiptStockEntry
from .stock_entry_handler.material_transfer import (
from .services.material_receipt_issue import MaterialIssueStockEntry, MaterialReceiptStockEntry
from .services.material_transfer import (
MaterialRequestStockEntry,
MaterialTransferForManufactureStockEntry,
MaterialTransferStockEntry,
)
from .stock_entry_handler.serial_batch import StockEntrySABB
from .stock_entry_handler.subcontracting import SendToSubcontractorStockEntry
from .services.serial_batch import StockEntrySABB
from .services.subcontracting import SendToSubcontractorStockEntry
class FinishedGoodError(frappe.ValidationError):
@@ -1465,10 +1465,12 @@ class StockEntry(StockController, SubcontractingInwardController):
def update_subcontracting_order_status(self):
if self.subcontracting_order and self.purpose in ["Send to Subcontractor", "Material Transfer"]:
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
update_subcontracting_order_status,
set_subcontracting_order_status,
)
update_subcontracting_order_status(self.subcontracting_order)
# Trusted submit/cancel flow — a Stock operation must not require Subcontracting Order
# write permission, so use the no-check internal helper (not the whitelisted boundary).
set_subcontracting_order_status(self.subcontracting_order)
def update_pick_list_status(self):
from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status

View File

@@ -2388,7 +2388,7 @@ class TestStockEntry(ERPNextTestSuite):
"Stock Settings", {"sample_retention_warehouse": "_Test Warehouse 1 - _TC"}
)
def test_sample_retention_stock_entry(self):
from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
move_sample_to_retention_warehouse,
)
@@ -2558,7 +2558,7 @@ class TestStockEntryCoverage(ERPNextTestSuite):
# ── ceil_qty_if_uom_has_whole_number ──────────────────────────────────────
def test_ceil_qty_rounds_up_for_whole_number_uom(self):
from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
ceil_qty_if_uom_has_whole_number,
)
@@ -2567,7 +2567,7 @@ class TestStockEntryCoverage(ERPNextTestSuite):
frappe.set_value("UOM", "Nos", "must_be_whole_number", 0)
def test_ceil_qty_no_rounding_for_decimal_uom(self):
from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
ceil_qty_if_uom_has_whole_number,
)
@@ -2644,7 +2644,7 @@ class TestStockEntryCoverage(ERPNextTestSuite):
# ── get_production_item_details ────────────────────────────────────────────
def test_get_production_item_details_from_bom(self):
from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
get_production_item_details,
)
@@ -2654,7 +2654,7 @@ class TestStockEntryCoverage(ERPNextTestSuite):
self.assertIsNotNone(result.stock_uom)
def test_get_production_item_details_from_work_order(self):
from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
get_production_item_details,
)
@@ -2680,7 +2680,7 @@ class TestStockEntryCoverage(ERPNextTestSuite):
# ── get_bom_items ──────────────────────────────────────────────────────────
def test_get_bom_items_returns_raw_materials_with_structure(self):
from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import get_bom_items
from erpnext.stock.doctype.stock_entry.services.manufacturing import get_bom_items
bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "is_default": 1, "docstatus": 1})
items = get_bom_items(bom_no)
@@ -2690,7 +2690,7 @@ class TestStockEntryCoverage(ERPNextTestSuite):
self.assertIn("qty", item)
def test_get_bom_items_scales_qty_proportionally(self):
from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import get_bom_items
from erpnext.stock.doctype.stock_entry.services.manufacturing import get_bom_items
bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "is_default": 1, "docstatus": 1})
items_1 = {i["item_code"]: i["qty"] for i in get_bom_items(bom_no, qty=1)}
@@ -2704,7 +2704,7 @@ class TestStockEntryCoverage(ERPNextTestSuite):
"Stock Settings", {"sample_retention_warehouse": "_Test Warehouse 1 - _TC"}
)
def test_validate_sample_quantity_raises_when_sample_exceeds_received_qty(self):
from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
validate_sample_quantity,
)
@@ -2718,7 +2718,7 @@ class TestStockEntryCoverage(ERPNextTestSuite):
def test_get_expired_batches_includes_expired_batch(self):
from erpnext.stock.doctype.batch.test_batch import make_new_batch
from erpnext.stock.doctype.stock_entry.stock_entry_handler.serial_batch import (
from erpnext.stock.doctype.stock_entry.services.serial_batch import (
get_expired_batches,
)
@@ -2735,7 +2735,7 @@ class TestStockEntryCoverage(ERPNextTestSuite):
def test_get_expired_batches_excludes_future_batch(self):
from erpnext.stock.doctype.batch.test_batch import make_new_batch
from erpnext.stock.doctype.stock_entry.stock_entry_handler.serial_batch import (
from erpnext.stock.doctype.stock_entry.services.serial_batch import (
get_expired_batches,
)
@@ -2848,7 +2848,7 @@ class TestStockEntryCoverage(ERPNextTestSuite):
from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as _make_stock_entry,
)
from erpnext.stock.doctype.stock_entry.stock_entry_handler.disassemble import (
from erpnext.stock.doctype.stock_entry.services.disassemble import (
get_available_materials,
)
@@ -2893,7 +2893,7 @@ class TestStockEntryCoverage(ERPNextTestSuite):
from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as _make_stock_entry,
)
from erpnext.stock.doctype.stock_entry.stock_entry_handler.disassemble import (
from erpnext.stock.doctype.stock_entry.services.disassemble import (
get_available_materials,
)

View File

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

View File

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

View File

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

View File

@@ -351,12 +351,13 @@ class StockBalanceReport:
qty_dict.opening_qty -= self.stock_reco_voucher_wise_count.get(entry.voucher_detail_no, 0)
qty_dict.bal_qty = 0.0
qty_diff = flt(entry.actual_qty)
value_diff = flt(entry.stock_value_difference)
else:
qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty)
value_diff = flt(entry.stock_value) - flt(qty_dict.bal_val)
else:
qty_diff = flt(entry.actual_qty)
value_diff = flt(entry.stock_value_difference)
value_diff = flt(entry.stock_value_difference)
if entry.posting_date < self.from_date or entry.voucher_no in self.opening_vouchers.get(
entry.voucher_type, []

View File

@@ -1194,6 +1194,7 @@ class update_entries_after:
sle.recalculate_rate
or self.has_landed_cost_based_on_pi(sle)
or (sle.voucher_type == "Stock Entry" and sle.actual_qty > 0 and is_repack_entry(sle.voucher_no))
or (sle.voucher_type in ("Purchase Receipt", "Purchase Invoice"))
):
rate = self.get_incoming_outgoing_rate_from_transaction(sle)

View File

@@ -557,9 +557,18 @@ class SubcontractingInwardOrder(SubcontractingController):
return stock_entry.as_dict()
@frappe.whitelist()
def update_subcontracting_inward_order_status(scio: str | Document, status: str | None = None):
def set_subcontracting_inward_order_status(scio: str | Document, status: str | None = None):
if isinstance(scio, str):
scio = frappe.get_doc("Subcontracting Inward Order", scio)
scio.update_status(status)
@frappe.whitelist()
def update_subcontracting_inward_order_status(scio: str | Document, status: str | None = None):
"""Whitelisted boundary for direct API/UI calls — enforces write permission, then delegates."""
if isinstance(scio, str):
scio = frappe.get_doc("Subcontracting Inward Order", scio)
scio.check_permission("write")
set_subcontracting_inward_order_status(scio, status)

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