mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-06 19:23:06 +00:00
Compare commits
54 Commits
feat-purch
...
fix-je-par
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3f262b415 | ||
|
|
7e602d5389 | ||
|
|
529f8dc7cd | ||
|
|
52b406f5f1 | ||
|
|
3dda2005d8 | ||
|
|
322d4dff25 | ||
|
|
01a10fb5b0 | ||
|
|
4c084f7eff | ||
|
|
627f2058b5 | ||
|
|
8db4d2705a | ||
|
|
4ca7bc8ccf | ||
|
|
ca5cc4afdc | ||
|
|
380b005659 | ||
|
|
df0ad93262 | ||
|
|
f503614cc0 | ||
|
|
6d9beea56b | ||
|
|
560d8bb674 | ||
|
|
a3e3e1b32c | ||
|
|
2492dfa558 | ||
|
|
3b5a203d61 | ||
|
|
934abe5c6d | ||
|
|
867ee484b9 | ||
|
|
2652082475 | ||
|
|
abb579e2db | ||
|
|
0c2d5488a6 | ||
|
|
138f683a68 | ||
|
|
479f9f63c9 | ||
|
|
56bfe6b6a6 | ||
|
|
acae34c8e1 | ||
|
|
dcbe4a6d55 | ||
|
|
87d26a2d67 | ||
|
|
e1d8d06966 | ||
|
|
8c88cecc1f | ||
|
|
9aeafb8140 | ||
|
|
c24e9796ae | ||
|
|
c7d42e161b | ||
|
|
701896692a | ||
|
|
93d6be2ed7 | ||
|
|
b0e9ad198f | ||
|
|
a9029f83c7 | ||
|
|
31e4da562d | ||
|
|
e6fdb3702a | ||
|
|
bd60a9be90 | ||
|
|
a64466561f | ||
|
|
f7ff25d9a8 | ||
|
|
c933e34914 | ||
|
|
87092961e7 | ||
|
|
3f436985ed | ||
|
|
cf127e8900 | ||
|
|
9ea766fc10 | ||
|
|
53180fde93 | ||
|
|
224dff32df | ||
|
|
64175bdb3e | ||
|
|
4fed04c6c7 |
51
.github/helper/install.sh
vendored
51
.github/helper/install.sh
vendored
@@ -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=$!
|
||||
|
||||
21
.github/workflows/server-tests-mariadb.yml
vendored
21
.github/workflows/server-tests-mariadb.yml
vendored
@@ -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
10
.greptile/config.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"disabledLabels": [
|
||||
"conflicts"
|
||||
],
|
||||
"context": {
|
||||
"repos": [
|
||||
"frappe/frappe"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -17,6 +17,7 @@ frappe.ui.form.on("Budget", {
|
||||
filters: {
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
root_type: ["in", ["Income", "Expense"]],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
2239
erpnext/locale/ar.po
2239
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/bs.po
2239
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
2235
erpnext/locale/cs.po
2235
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
2235
erpnext/locale/da.po
2235
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/de.po
2239
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/eo.po
2239
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/es.po
2239
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/fa.po
2239
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/fr.po
2239
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
62782
erpnext/locale/hi.po
Normal file
62782
erpnext/locale/hi.po
Normal file
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/hr.po
2239
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
2237
erpnext/locale/hu.po
2237
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
2235
erpnext/locale/id.po
2235
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
2235
erpnext/locale/it.po
2235
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/ko.po
2239
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2235
erpnext/locale/my.po
2235
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
2235
erpnext/locale/nb.po
2235
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/nl.po
2239
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/pl.po
2239
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/pt.po
2239
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/ru.po
2239
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
2235
erpnext/locale/sl.po
2235
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/sr.po
2239
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2347
erpnext/locale/sv.po
2347
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/th.po
2239
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/tr.po
2239
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/vi.po
2239
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
2239
erpnext/locale/zh.po
2239
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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):
|
||||
@@ -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):
|
||||
@@ -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):
|
||||
@@ -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):
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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, []
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user