mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-16 19:49:18 +00:00
Compare commits
327 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0312d58dca | ||
|
|
e8e9fb25fe | ||
|
|
4a7e2742ec | ||
|
|
55d5d6535b | ||
|
|
b8ac04fb54 | ||
|
|
45e4c04830 | ||
|
|
d80c8d14b0 | ||
|
|
66e47f5651 | ||
|
|
a3e8af19a6 | ||
|
|
9b498a8da8 | ||
|
|
25389be340 | ||
|
|
de244e0af7 | ||
|
|
b132e3f22a | ||
|
|
670fd79e38 | ||
|
|
e09406d085 | ||
|
|
3bc348d6f0 | ||
|
|
3f0032d793 | ||
|
|
163b848455 | ||
|
|
8ce51b2f80 | ||
|
|
422b37332e | ||
|
|
4c14e74a12 | ||
|
|
28e8c40bfc | ||
|
|
660fc8f76a | ||
|
|
22456a5857 | ||
|
|
76e0eb00a5 | ||
|
|
9dcaf38142 | ||
|
|
6048add4c0 | ||
|
|
0552b48328 | ||
|
|
193b29d5fc | ||
|
|
220a528d7f | ||
|
|
e087a8b179 | ||
|
|
d7067f6b7a | ||
|
|
e264d8e2d6 | ||
|
|
8b2559ab0c | ||
|
|
f425f89a26 | ||
|
|
e3bf84c572 | ||
|
|
e2b88218ec | ||
|
|
e755a4ad98 | ||
|
|
d2ea428030 | ||
|
|
496956f08f | ||
|
|
a3190dd556 | ||
|
|
95c4b8de06 | ||
|
|
4963261dc8 | ||
|
|
81e65757ee | ||
|
|
78e581154b | ||
|
|
2a3642b55a | ||
|
|
beaa76ca16 | ||
|
|
9997185071 | ||
|
|
681c0b5917 | ||
|
|
4a0e04ee20 | ||
|
|
55129e697d | ||
|
|
f9a8fc1f2d | ||
|
|
091ff81ae5 | ||
|
|
024e7b01ac | ||
|
|
a0156b61b8 | ||
|
|
7e5eab261c | ||
|
|
63782e6355 | ||
|
|
c80b554cd7 | ||
|
|
7db88b210e | ||
|
|
42d873f1d9 | ||
|
|
19c1dcc3dd | ||
|
|
23e027b6be | ||
|
|
d91cfa76e6 | ||
|
|
a29df7be67 | ||
|
|
dbe5846908 | ||
|
|
83fcb5d2d8 | ||
|
|
b8ab55fee8 | ||
|
|
4d03f4ebaa | ||
|
|
a2d302b3fa | ||
|
|
b5321d42a3 | ||
|
|
af167f91fe | ||
|
|
e74389f01c | ||
|
|
e7ace8e620 | ||
|
|
e23e9b5d66 | ||
|
|
136b2cfba5 | ||
|
|
3b3738577d | ||
|
|
1e646bd0ed | ||
|
|
02e6c49130 | ||
|
|
e9fa725030 | ||
|
|
b03494bb67 | ||
|
|
f7b915dfe6 | ||
|
|
0a5aac9ce7 | ||
|
|
e3c62070d1 | ||
|
|
b365444027 | ||
|
|
0d1c30f3f0 | ||
|
|
d0b553dca3 | ||
|
|
e4cdd971c8 | ||
|
|
78c4f01733 | ||
|
|
eac4978278 | ||
|
|
c619be989b | ||
|
|
64921fc1b5 | ||
|
|
6d8d502bbf | ||
|
|
5595602f24 | ||
|
|
7042f2b8fb | ||
|
|
f462639aa0 | ||
|
|
14ba0f1cae | ||
|
|
3944dfde31 | ||
|
|
16cc2b0a25 | ||
|
|
3394e1a126 | ||
|
|
66086010fc | ||
|
|
195a2f3e74 | ||
|
|
7e7b16b23e | ||
|
|
f4beb41df2 | ||
|
|
4c4aa9bbdf | ||
|
|
687a80d74c | ||
|
|
f01e0576b9 | ||
|
|
83a0d957ef | ||
|
|
554aeb94fd | ||
|
|
05e30dc011 | ||
|
|
40a3dabd30 | ||
|
|
0f9bf08685 | ||
|
|
b84fd46841 | ||
|
|
6209d633c2 | ||
|
|
095fe65bef | ||
|
|
b285548a46 | ||
|
|
0b7684eccd | ||
|
|
574460c009 | ||
|
|
27fe754a7d | ||
|
|
531fe59a24 | ||
|
|
0a56647a61 | ||
|
|
c2f666b7a3 | ||
|
|
c1bbe1104e | ||
|
|
2c86327c7e | ||
|
|
31385a1f91 | ||
|
|
4f1695616a | ||
|
|
9ed3801d06 | ||
|
|
61ad67ec29 | ||
|
|
735b9da6b1 | ||
|
|
fe7a797156 | ||
|
|
f951dd180a | ||
|
|
8e871796d4 | ||
|
|
c88ee50c34 | ||
|
|
e49f6f4f09 | ||
|
|
72942e6b8c | ||
|
|
fdcf037f1b | ||
|
|
9e0c606b95 | ||
|
|
6b9f2ddf83 | ||
|
|
c1cc1dbd27 | ||
|
|
de584e2e8d | ||
|
|
75758610dd | ||
|
|
1927adbd2e | ||
|
|
7342b2551b | ||
|
|
6115f8fb9a | ||
|
|
53b73757ed | ||
|
|
dee3357da2 | ||
|
|
b1b1f25bb1 | ||
|
|
46e6096fe3 | ||
|
|
f486071cf6 | ||
|
|
b541fbbd60 | ||
|
|
41e6687b35 | ||
|
|
55ce40de37 | ||
|
|
6f21ab5d9a | ||
|
|
7c81672b36 | ||
|
|
d0faff09cb | ||
|
|
cff09b71cc | ||
|
|
b180e3b78c | ||
|
|
6ba2795043 | ||
|
|
1b9a93f90e | ||
|
|
1f1428f00a | ||
|
|
606ac2a91a | ||
|
|
9a175757ac | ||
|
|
22a8d483e1 | ||
|
|
7abaaed957 | ||
|
|
8946f12677 | ||
|
|
e9f3f0f445 | ||
|
|
ba38bc3eaf | ||
|
|
b4572978f9 | ||
|
|
bf53133f94 | ||
|
|
23c902c317 | ||
|
|
13e4849c43 | ||
|
|
f97b850077 | ||
|
|
b3e12f9acb | ||
|
|
fb3fb8ca5e | ||
|
|
e2232340dc | ||
|
|
881562fc37 | ||
|
|
dcd6279d47 | ||
|
|
041a7c5a57 | ||
|
|
27ffef41a7 | ||
|
|
9038f19fb6 | ||
|
|
264855e5e1 | ||
|
|
7120fbd14b | ||
|
|
b1716bfeef | ||
|
|
37a237dbb7 | ||
|
|
be9112b6fc | ||
|
|
2f240f3553 | ||
|
|
c7c7a55a58 | ||
|
|
30e6b5daac | ||
|
|
0b5cc039b6 | ||
|
|
6fa60d2f1a | ||
|
|
91199ea9c9 | ||
|
|
e23ba0e852 | ||
|
|
7c2bbe0d82 | ||
|
|
0a2234a814 | ||
|
|
504c84e28a | ||
|
|
bb2bada1fd | ||
|
|
ca85ee33f5 | ||
|
|
21c1189e24 | ||
|
|
7e7885b304 | ||
|
|
30238e3063 | ||
|
|
35b3045b72 | ||
|
|
cf130ff865 | ||
|
|
176096bc5b | ||
|
|
e854eafc0b | ||
|
|
72cdddbeda | ||
|
|
c9d7c6cd42 | ||
|
|
8393c3c32d | ||
|
|
5752d2e0a1 | ||
|
|
f25558b4d7 | ||
|
|
fef6df709d | ||
|
|
3ac431bd50 | ||
|
|
5d5d208a49 | ||
|
|
9535f3d583 | ||
|
|
673635e2c3 | ||
|
|
d067e37ab6 | ||
|
|
37e241ba15 | ||
|
|
fb669eb6f4 | ||
|
|
232225d753 | ||
|
|
80cbd851d1 | ||
|
|
5474ac298d | ||
|
|
7a9b10a05e | ||
|
|
621243c1d3 | ||
|
|
efa5173964 | ||
|
|
7b3f74609a | ||
|
|
775f6d07b1 | ||
|
|
e80ed14456 | ||
|
|
fe59ace285 | ||
|
|
2131c7aadb | ||
|
|
b7284c7717 | ||
|
|
6b9107c05c | ||
|
|
1ed8857d31 | ||
|
|
a195690bc8 | ||
|
|
0c546c9e5a | ||
|
|
11d9fd3dee | ||
|
|
3bdaab149b | ||
|
|
ad4ac4e53c | ||
|
|
9cc9fa59be | ||
|
|
ab2aedd9a2 | ||
|
|
686911546f | ||
|
|
4f3078ab1a | ||
|
|
a950adab79 | ||
|
|
ae6b3af013 | ||
|
|
1da8ed202b | ||
|
|
a3d860eabf | ||
|
|
adc9dc82ca | ||
|
|
79e04ea1fe | ||
|
|
0981b894dd | ||
|
|
380564a677 | ||
|
|
75deb180fb | ||
|
|
839315752b | ||
|
|
2b4a547e23 | ||
|
|
3c533d04f5 | ||
|
|
1c214eec98 | ||
|
|
aeb2b60450 | ||
|
|
1b3f5e1c96 | ||
|
|
c830bf6fc7 | ||
|
|
e2b95da24d | ||
|
|
89b44c41a2 | ||
|
|
181141b56a | ||
|
|
5742a5d86a | ||
|
|
68c8dfb24c | ||
|
|
7f54de7926 | ||
|
|
cb2d4550af | ||
|
|
c22d7e16d1 | ||
|
|
9d5a0e56a0 | ||
|
|
fcea7603a8 | ||
|
|
42ebb7446a | ||
|
|
49760e4542 | ||
|
|
6e1f4d84b6 | ||
|
|
ab482caac9 | ||
|
|
d8506fb2c0 | ||
|
|
d3dfed909e | ||
|
|
ac31c5ca19 | ||
|
|
ccab91b9ed | ||
|
|
541a8b135a | ||
|
|
c0a30a5302 | ||
|
|
accce1fe59 | ||
|
|
f04221417e | ||
|
|
c4c2d35565 | ||
|
|
f1e41f4a4f | ||
|
|
d9326d80de | ||
|
|
5c93bf5798 | ||
|
|
f62ad83d6f | ||
|
|
876e2d4e6e | ||
|
|
4977e06c50 | ||
|
|
e64ae9a8a9 | ||
|
|
6ddf4eee15 | ||
|
|
d27a09cb9f | ||
|
|
86d5939d91 | ||
|
|
768c131073 | ||
|
|
8f77223057 | ||
|
|
8ba470160d | ||
|
|
1a6264d831 | ||
|
|
19a90c0980 | ||
|
|
d57fc49896 | ||
|
|
fe0431a6d0 | ||
|
|
bfd6375508 | ||
|
|
6dade11d8f | ||
|
|
ce421bb1d4 | ||
|
|
84a749e3d0 | ||
|
|
65a1c7086b | ||
|
|
a04da71182 | ||
|
|
cbfc13728b | ||
|
|
9b88275312 | ||
|
|
332673f260 | ||
|
|
e49add20b7 | ||
|
|
c3b0633eda | ||
|
|
a660ed061b | ||
|
|
0b1c0c36b5 | ||
|
|
d316ef2306 | ||
|
|
af3a7903b3 | ||
|
|
a66e114a71 | ||
|
|
631b9d3bb0 | ||
|
|
eb03781718 | ||
|
|
eb7cebac91 | ||
|
|
3420e21d45 | ||
|
|
211832104c | ||
|
|
4b85d51257 | ||
|
|
0363b01ab7 | ||
|
|
fc517f7fa2 | ||
|
|
18451b69e6 | ||
|
|
50ce61ae02 | ||
|
|
294fb27dc8 | ||
|
|
c3fdb191b9 | ||
|
|
93db2ebd6f | ||
|
|
2925f9a04e | ||
|
|
c7bf103c0c | ||
|
|
6dead8fd85 |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -60,7 +60,7 @@ body:
|
||||
description: Share exact version number of Frappe and ERPNext you are using.
|
||||
placeholder: |
|
||||
Frappe version -
|
||||
ERPNext Verion -
|
||||
ERPNext version -
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
2
.github/workflows/initiate_release.yml
vendored
2
.github/workflows/initiate_release.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version: ["14", "15"]
|
||||
version: ["14", "15", "16"]
|
||||
|
||||
steps:
|
||||
- uses: octokit/request-action@v2.x
|
||||
|
||||
5
.github/workflows/patch.yml
vendored
5
.github/workflows/patch.yml
vendored
@@ -113,8 +113,8 @@ jobs:
|
||||
jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
|
||||
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
|
||||
|
||||
wget https://erpnext.com/files/v13-erpnext.sql.gz
|
||||
bench --site test_site --force restore ~/frappe-bench/v13-erpnext.sql.gz
|
||||
wget https://frappe.io/files/erpnext-v14.sql.gz
|
||||
bench --site test_site --force restore ~/frappe-bench/erpnext-v14.sql.gz
|
||||
|
||||
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
|
||||
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
|
||||
@@ -142,7 +142,6 @@ jobs:
|
||||
bench --site test_site migrate
|
||||
}
|
||||
|
||||
update_to_version 14 3.11
|
||||
update_to_version 15 3.13
|
||||
|
||||
echo "Updating to latest version"
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -2,7 +2,7 @@ name: Generate Semantic Release
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- version-13
|
||||
- version-16
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
paths:
|
||||
- "**.js"
|
||||
- "**.css"
|
||||
- "**.svg"
|
||||
- "**.md"
|
||||
- "**.html"
|
||||
- 'crowdin.yml'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"branches": ["version-13"],
|
||||
"branches": ["version-16"],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer", {
|
||||
"preset": "angular",
|
||||
@@ -21,4 +21,4 @@
|
||||
],
|
||||
"@semantic-release/github"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
<div align="center">
|
||||
<a href="https://frappe.io/erpnext">
|
||||
<img src="./erpnext/public/images/v16/erpnext.svg" alt="ERPNext Logo" height="80px" width="80xp"/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "16.0.0-dev"
|
||||
__version__ = "16.4.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
50
erpnext/accounts/accounts_dashboard/payments/payments.json
Normal file
50
erpnext/accounts/accounts_dashboard/payments/payments.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"cards": [
|
||||
{
|
||||
"card": "Total Outgoing Bills"
|
||||
},
|
||||
{
|
||||
"card": "Total Incoming Bills"
|
||||
},
|
||||
{
|
||||
"card": "Total Incoming Payment"
|
||||
},
|
||||
{
|
||||
"card": "Total Outgoing Payment"
|
||||
}
|
||||
],
|
||||
"charts": [
|
||||
{
|
||||
"chart": "Incoming Bills (Purchase Invoice)",
|
||||
"width": "Half"
|
||||
},
|
||||
{
|
||||
"chart": "Outgoing Bills (Sales Invoice)",
|
||||
"width": "Half"
|
||||
},
|
||||
{
|
||||
"chart": "Accounts Receivable Ageing",
|
||||
"width": "Half"
|
||||
},
|
||||
{
|
||||
"chart": "Accounts Payable Ageing",
|
||||
"width": "Half"
|
||||
},
|
||||
{
|
||||
"chart": "Bank Balance",
|
||||
"width": "Full"
|
||||
}
|
||||
],
|
||||
"creation": "2026-01-26 21:25:12.793893",
|
||||
"dashboard_name": "Payments",
|
||||
"docstatus": 0,
|
||||
"doctype": "Dashboard",
|
||||
"idx": 0,
|
||||
"is_default": 0,
|
||||
"is_standard": 1,
|
||||
"modified": "2026-01-26 21:25:12.793893",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payments",
|
||||
"owner": "Administrator"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,17 @@
|
||||
},
|
||||
"account_number": "1151.000"
|
||||
},
|
||||
"Pajak Dibayar di Muka": {
|
||||
"PPN Masukan": {
|
||||
"account_number": "1152.001",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"PPh 23 Dibayar di Muka": {
|
||||
"account_number": "1152.002",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"account_number": "1152.000"
|
||||
},
|
||||
"account_number": "1150.000"
|
||||
},
|
||||
"Kas": {
|
||||
@@ -97,17 +108,6 @@
|
||||
},
|
||||
"account_number": "1130.000"
|
||||
},
|
||||
"Pajak Dibayar di Muka": {
|
||||
"PPN Masukan": {
|
||||
"account_number": "1151.001",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"PPh 23 Dibayar di Muka": {
|
||||
"account_number": "1152.001",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"account_number": "1150.000"
|
||||
},
|
||||
"account_number": "1100.000"
|
||||
|
||||
},
|
||||
|
||||
@@ -281,7 +281,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Learn about <a href=\"https://docs.erpnext.com/docs/v13/user/manual/en/accounts/articles/common_party_accounting#:~:text=Common%20Party%20Accounting%20in%20ERPNext,Invoice%20against%20a%20primary%20Supplier.\">Common Party</a>",
|
||||
"description": "Learn about <a href=\"https://docs.frappe.io/erpnext/user/manual/en/common_party_accounting\">Common Party</a>",
|
||||
"fieldname": "enable_common_party_accounting",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Common Party Accounting"
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
frappe.provide("erpnext.integrations");
|
||||
|
||||
frappe.ui.form.on("Bank", {
|
||||
onload: function (frm) {
|
||||
add_fields_to_mapping_table(frm);
|
||||
},
|
||||
refresh: function (frm) {
|
||||
add_fields_to_mapping_table(frm);
|
||||
frm.toggle_display(["address_html", "contact_html"], !frm.doc.__islocal);
|
||||
@@ -37,11 +34,11 @@ let add_fields_to_mapping_table = function (frm) {
|
||||
});
|
||||
});
|
||||
|
||||
frm.fields_dict.bank_transaction_mapping.grid.update_docfield_property(
|
||||
"bank_transaction_field",
|
||||
"options",
|
||||
options
|
||||
);
|
||||
const grid = frm.fields_dict.bank_transaction_mapping?.grid;
|
||||
|
||||
if (grid) {
|
||||
grid.update_docfield_property("bank_transaction_field", "options", options);
|
||||
}
|
||||
};
|
||||
|
||||
erpnext.integrations.refreshPlaidLink = class refreshPlaidLink {
|
||||
@@ -116,7 +113,7 @@ erpnext.integrations.refreshPlaidLink = class refreshPlaidLink {
|
||||
"There was an issue connecting to Plaid's authentication server. Check browser console for more information"
|
||||
)
|
||||
);
|
||||
console.log(error);
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
plaid_success(token, response) {
|
||||
|
||||
@@ -42,8 +42,4 @@ frappe.ui.form.on("Bank Account", {
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
is_company_account: function (frm) {
|
||||
frm.set_df_property("account", "reqd", frm.doc.is_company_account);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Company Account",
|
||||
"mandatory_depends_on": "is_company_account",
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
@@ -98,6 +99,7 @@
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Company",
|
||||
"mandatory_depends_on": "is_company_account",
|
||||
"options": "Company"
|
||||
},
|
||||
{
|
||||
@@ -252,7 +254,7 @@
|
||||
"link_fieldname": "default_bank_account"
|
||||
}
|
||||
],
|
||||
"modified": "2025-08-29 12:32:01.081687",
|
||||
"modified": "2026-01-20 00:46:16.633364",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Account",
|
||||
|
||||
@@ -51,25 +51,29 @@ class BankAccount(Document):
|
||||
delete_contact_and_address("Bank Account", self.name)
|
||||
|
||||
def validate(self):
|
||||
self.validate_company()
|
||||
self.validate_account()
|
||||
self.validate_is_company_account()
|
||||
self.update_default_bank_account()
|
||||
|
||||
def validate_account(self):
|
||||
if self.account:
|
||||
if accounts := frappe.db.get_all(
|
||||
"Bank Account", filters={"account": self.account, "name": ["!=", self.name]}, as_list=1
|
||||
):
|
||||
frappe.throw(
|
||||
_("'{0}' account is already used by {1}. Use another account.").format(
|
||||
frappe.bold(self.account),
|
||||
frappe.bold(comma_and([get_link_to_form(self.doctype, x[0]) for x in accounts])),
|
||||
)
|
||||
)
|
||||
def validate_is_company_account(self):
|
||||
if self.is_company_account:
|
||||
if not self.company:
|
||||
frappe.throw(_("Company is mandatory for company account"))
|
||||
|
||||
def validate_company(self):
|
||||
if self.is_company_account and not self.company:
|
||||
frappe.throw(_("Company is mandatory for company account"))
|
||||
if not self.account:
|
||||
frappe.throw(_("Company Account is mandatory"))
|
||||
|
||||
self.validate_account()
|
||||
|
||||
def validate_account(self):
|
||||
if accounts := frappe.db.get_all(
|
||||
"Bank Account", filters={"account": self.account, "name": ["!=", self.name]}, as_list=1
|
||||
):
|
||||
frappe.throw(
|
||||
_("'{0}' account is already used by {1}. Use another account.").format(
|
||||
frappe.bold(self.account),
|
||||
frappe.bold(comma_and([get_link_to_form(self.doctype, x[0]) for x in accounts])),
|
||||
)
|
||||
)
|
||||
|
||||
def update_default_bank_account(self):
|
||||
if self.is_default and not self.disabled:
|
||||
|
||||
@@ -179,11 +179,14 @@ class JournalEntry(AccountsController):
|
||||
validate_docs_for_deferred_accounting([self.name], [])
|
||||
|
||||
def submit(self):
|
||||
if len(self.accounts) > 100:
|
||||
if len(self.accounts) > 100 and not self.meta.queue_in_background:
|
||||
queue_submission(self, "_submit")
|
||||
else:
|
||||
return self._submit()
|
||||
|
||||
def before_cancel(self):
|
||||
self.has_asset_adjustment_entry()
|
||||
|
||||
def cancel(self):
|
||||
if len(self.accounts) > 100:
|
||||
queue_submission(self, "_cancel")
|
||||
@@ -554,12 +557,27 @@ class JournalEntry(AccountsController):
|
||||
)
|
||||
frappe.db.set_value("Journal Entry", self.name, "inter_company_journal_entry_reference", "")
|
||||
|
||||
def unlink_asset_adjustment_entry(self):
|
||||
frappe.db.sql(
|
||||
""" update `tabAsset Value Adjustment`
|
||||
set journal_entry = null where journal_entry = %s""",
|
||||
self.name,
|
||||
def has_asset_adjustment_entry(self):
|
||||
if self.flags.get("via_asset_value_adjustment"):
|
||||
return
|
||||
|
||||
asset_value_adjustment = frappe.db.get_value(
|
||||
"Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.name}, "name"
|
||||
)
|
||||
if asset_value_adjustment:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot cancel this document as it is linked with the submitted Asset Value Adjustment <b>{0}</b>. Please cancel the Asset Value Adjustment to continue."
|
||||
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
|
||||
)
|
||||
|
||||
def unlink_asset_adjustment_entry(self):
|
||||
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
|
||||
(
|
||||
frappe.qb.update(AssetValueAdjustment)
|
||||
.set(AssetValueAdjustment.journal_entry, None)
|
||||
.where(AssetValueAdjustment.journal_entry == self.name)
|
||||
).run()
|
||||
|
||||
def validate_party(self):
|
||||
for d in self.get("accounts"):
|
||||
@@ -1673,6 +1691,10 @@ def get_exchange_rate(
|
||||
credit=None,
|
||||
exchange_rate=None,
|
||||
):
|
||||
# Ensure exchange_rate is always numeric to avoid calculation errors
|
||||
if isinstance(exchange_rate, str):
|
||||
exchange_rate = flt(exchange_rate) or 1
|
||||
|
||||
account_details = frappe.get_cached_value(
|
||||
"Account", account, ["account_type", "root_type", "account_currency", "company"], as_dict=1
|
||||
)
|
||||
|
||||
@@ -400,6 +400,16 @@ frappe.ui.form.on("Payment Entry", {
|
||||
);
|
||||
|
||||
frm.refresh_fields();
|
||||
|
||||
const party_currency =
|
||||
frm.doc.payment_type === "Receive" ? "paid_from_account_currency" : "paid_to_account_currency";
|
||||
|
||||
var reference_grid = frm.fields_dict["references"].grid;
|
||||
["total_amount", "outstanding_amount", "allocated_amount"].forEach((fieldname) => {
|
||||
reference_grid.update_docfield_property(fieldname, "options", party_currency);
|
||||
});
|
||||
|
||||
reference_grid.refresh();
|
||||
},
|
||||
|
||||
show_general_ledger: function (frm) {
|
||||
@@ -1104,7 +1114,7 @@ frappe.ui.form.on("Payment Entry", {
|
||||
|
||||
allocate_party_amount_against_ref_docs: async function (frm, paid_amount, paid_amount_change) {
|
||||
await frm.call("allocate_amount_to_references", {
|
||||
paid_amount: paid_amount,
|
||||
paid_amount: flt(paid_amount),
|
||||
paid_amount_change: paid_amount_change,
|
||||
allocate_payment_amount: frappe.flags.allocate_payment_amount ?? false,
|
||||
});
|
||||
|
||||
@@ -132,6 +132,12 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
},
|
||||
{
|
||||
"fieldname": "due_date",
|
||||
|
||||
@@ -38,6 +38,7 @@ class PaymentLedgerEntry(Document):
|
||||
amount_in_account_currency: DF.Currency
|
||||
company: DF.Link | None
|
||||
cost_center: DF.Link | None
|
||||
project: DF.Link | None
|
||||
delinked: DF.Check
|
||||
due_date: DF.Date | None
|
||||
finance_book: DF.Link | None
|
||||
|
||||
@@ -746,7 +746,7 @@ class PaymentReconciliation(Document):
|
||||
ple = qb.DocType("Payment Ledger Entry")
|
||||
for x in self.dimensions:
|
||||
dimension = x.fieldname
|
||||
if self.get(dimension):
|
||||
if self.get(dimension) and frappe.db.has_column("Payment Ledger Entry", dimension):
|
||||
self.accounting_dimension_filter_conditions.append(ple[dimension] == self.get(dimension))
|
||||
|
||||
def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):
|
||||
|
||||
@@ -1610,13 +1610,14 @@
|
||||
"hidden": 1,
|
||||
"label": "Item Wise Tax Details",
|
||||
"no_copy": 1,
|
||||
"options": "Item Wise Tax Detail"
|
||||
"options": "Item Wise Tax Detail",
|
||||
"print_hide": 1
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-file-text",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-04 22:22:31.471752",
|
||||
"modified": "2026-01-29 21:20:51.376875",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice",
|
||||
|
||||
@@ -898,6 +898,53 @@ class TestPOSInvoice(IntegrationTestCase):
|
||||
if batch.batch_no == batch_no and batch.warehouse == "_Test Warehouse - _TC":
|
||||
self.assertEqual(batch.qty, 5)
|
||||
|
||||
def test_pos_batch_reservation_with_return_qty(self):
|
||||
"""
|
||||
Test POS Invoice reserved qty for batch without bundle with return invoices.
|
||||
"""
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
get_auto_batch_nos,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_batch_item_with_batch,
|
||||
)
|
||||
|
||||
create_batch_item_with_batch("_Batch Item Reserve Return", "TestBatch-RR 01")
|
||||
se = make_stock_entry(
|
||||
target="_Test Warehouse - _TC",
|
||||
item_code="_Batch Item Reserve Return",
|
||||
qty=30,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
se.reload()
|
||||
|
||||
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
|
||||
|
||||
# POS Invoice for the batch without bundle
|
||||
pos_inv = create_pos_invoice(item="_Batch Item Reserve Return", rate=300, qty=15, do_not_save=1)
|
||||
pos_inv.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "amount": 4500},
|
||||
)
|
||||
pos_inv.items[0].batch_no = batch_no
|
||||
pos_inv.save()
|
||||
pos_inv.submit()
|
||||
|
||||
# POS Invoice return
|
||||
pos_return = make_sales_return(pos_inv.name)
|
||||
|
||||
pos_return.insert()
|
||||
pos_return.submit()
|
||||
|
||||
batches = get_auto_batch_nos(
|
||||
frappe._dict({"item_code": "_Batch Item Reserve Return", "warehouse": "_Test Warehouse - _TC"})
|
||||
)
|
||||
|
||||
for batch in batches:
|
||||
if batch.batch_no == batch_no and batch.warehouse == "_Test Warehouse - _TC":
|
||||
self.assertEqual(batch.qty, 30)
|
||||
|
||||
def test_pos_batch_item_qty_validation(self):
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
BatchNegativeStockError,
|
||||
|
||||
@@ -415,8 +415,9 @@ def reconcile(doc: None | str = None) -> None:
|
||||
for x in allocations:
|
||||
pr.append("allocation", x)
|
||||
|
||||
skip_ref_details_update_for_pe = check_multi_currency(pr)
|
||||
# reconcile
|
||||
pr.reconcile_allocations(skip_ref_details_update_for_pe=True)
|
||||
pr.reconcile_allocations(skip_ref_details_update_for_pe=skip_ref_details_update_for_pe)
|
||||
|
||||
# If Payment Entry, update details only for newly linked references
|
||||
# This is for performance
|
||||
@@ -504,6 +505,37 @@ def reconcile(doc: None | str = None) -> None:
|
||||
frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed")
|
||||
|
||||
|
||||
def check_multi_currency(pr_doc):
|
||||
GL = frappe.qb.DocType("GL Entry")
|
||||
Account = frappe.qb.DocType("Account")
|
||||
|
||||
def get_account_currency(voucher_type, voucher_no):
|
||||
currency = (
|
||||
frappe.qb.from_(GL)
|
||||
.join(Account)
|
||||
.on(GL.account == Account.name)
|
||||
.select(Account.account_currency)
|
||||
.where(
|
||||
(GL.voucher_type == voucher_type)
|
||||
& (GL.voucher_no == voucher_no)
|
||||
& (Account.account_type.isin(["Payable", "Receivable"]))
|
||||
)
|
||||
.limit(1)
|
||||
).run(as_dict=True)
|
||||
|
||||
return currency[0].account_currency if currency else None
|
||||
|
||||
for allocation in pr_doc.allocation:
|
||||
reference_currency = get_account_currency(allocation.reference_type, allocation.reference_name)
|
||||
|
||||
invoice_currency = get_account_currency(allocation.invoice_type, allocation.invoice_number)
|
||||
|
||||
if reference_currency != invoice_currency:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def is_any_doc_running(for_filter: str | dict | None = None) -> str | None:
|
||||
running_doc = None
|
||||
|
||||
@@ -1625,7 +1625,8 @@
|
||||
"hidden": 1,
|
||||
"label": "Item Wise Tax Details",
|
||||
"no_copy": 1,
|
||||
"options": "Item Wise Tax Detail"
|
||||
"options": "Item Wise Tax Detail",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
@@ -1667,7 +1668,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-12-15 06:41:38.237728",
|
||||
"modified": "2026-01-29 21:21:53.051193",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -36,7 +36,7 @@ from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update
|
||||
from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
|
||||
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
|
||||
from erpnext.buying.utils import check_on_hold_or_closed_status
|
||||
from erpnext.controllers.accounts_controller import validate_account_head
|
||||
from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
update_billed_amount_based_on_po,
|
||||
@@ -2005,9 +2005,17 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
args = json.loads(args)
|
||||
|
||||
def post_parent_process(source_parent, target_parent):
|
||||
for row in target_parent.get("items"):
|
||||
if row.get("qty") == 0:
|
||||
target_parent.remove(row)
|
||||
remove_items_with_zero_qty(target_parent)
|
||||
set_missing_values(source_parent, target_parent)
|
||||
|
||||
def remove_items_with_zero_qty(target_parent):
|
||||
target_parent.items = [row for row in target_parent.get("items") if row.get("qty") != 0]
|
||||
|
||||
def set_missing_values(source_parent, target_parent):
|
||||
target_parent.run_method("set_missing_values")
|
||||
if args and args.get("merge_taxes"):
|
||||
merge_taxes(source_parent, target_parent)
|
||||
target_parent.run_method("calculate_taxes_and_totals")
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
from erpnext.controllers.sales_and_purchase_return import get_returned_qty_map_for_row
|
||||
@@ -2059,7 +2067,11 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
|
||||
},
|
||||
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges"},
|
||||
"Purchase Taxes and Charges": {
|
||||
"doctype": "Purchase Taxes and Charges",
|
||||
"reset_value": not (args and args.get("merge_taxes")),
|
||||
"ignore": args.get("merge_taxes") if args else 0,
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
post_parent_process,
|
||||
|
||||
@@ -44,6 +44,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
"Unreconcile Payment Entries",
|
||||
"Serial and Batch Bundle",
|
||||
"Bank Transaction",
|
||||
"Packing Slip",
|
||||
];
|
||||
|
||||
if (!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {
|
||||
@@ -115,18 +116,21 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
}
|
||||
|
||||
if (cint(doc.update_stock) != 1) {
|
||||
// show Make Delivery Note button only if Sales Invoice is not created from Delivery Note
|
||||
var from_delivery_note = false;
|
||||
from_delivery_note = this.frm.doc.items.some(function (item) {
|
||||
return item.delivery_note ? true : false;
|
||||
});
|
||||
|
||||
if (!from_delivery_note && !is_delivered_by_supplier) {
|
||||
this.frm.add_custom_button(
|
||||
__("Delivery"),
|
||||
this.frm.cscript["Make Delivery Note"],
|
||||
__("Create")
|
||||
if (!is_delivered_by_supplier) {
|
||||
const should_create_delivery_note = doc.items.some(
|
||||
(item) =>
|
||||
item.qty - item.delivered_qty > 0 &&
|
||||
!item.scio_detail &&
|
||||
!item.dn_detail &&
|
||||
!item.delivered_by_supplier
|
||||
);
|
||||
if (should_create_delivery_note) {
|
||||
this.frm.add_custom_button(
|
||||
__("Delivery Note"),
|
||||
this.frm.cscript["Make Delivery Note"],
|
||||
__("Create")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2022-01-25 10:29:57.771398",
|
||||
"doctype": "DocType",
|
||||
@@ -778,8 +779,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.total_billing_amount > 0",
|
||||
"depends_on": "eval:!doc.is_return",
|
||||
"collapsible_depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0",
|
||||
"fieldname": "time_sheet_list",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_border": 1,
|
||||
@@ -793,7 +793,6 @@
|
||||
"hide_days": 1,
|
||||
"hide_seconds": 1,
|
||||
"label": "Time Sheets",
|
||||
"no_copy": 1,
|
||||
"options": "Sales Invoice Timesheet",
|
||||
"print_hide": 1
|
||||
},
|
||||
@@ -2092,7 +2091,7 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:(!doc.is_return && doc.total_billing_amount > 0)",
|
||||
"depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0",
|
||||
"fieldname": "section_break_104",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
@@ -2252,7 +2251,8 @@
|
||||
"hidden": 1,
|
||||
"label": "Item Wise Tax Details",
|
||||
"no_copy": 1,
|
||||
"options": "Item Wise Tax Detail"
|
||||
"options": "Item Wise Tax Detail",
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -2306,7 +2306,7 @@
|
||||
"link_fieldname": "consolidated_invoice"
|
||||
}
|
||||
],
|
||||
"modified": "2025-10-09 14:48:59.472826",
|
||||
"modified": "2026-01-30 16:45:59.682473",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice",
|
||||
|
||||
@@ -352,10 +352,22 @@ class SalesInvoice(SellingController):
|
||||
self.is_opening = "No"
|
||||
|
||||
self.set_against_income_account()
|
||||
self.validate_time_sheets_are_submitted()
|
||||
|
||||
if self.is_return and not self.return_against and self.timesheets:
|
||||
frappe.throw(_("Direct return is not allowed for Timesheet."))
|
||||
|
||||
if not self.is_return:
|
||||
self.validate_time_sheets_are_submitted()
|
||||
|
||||
self.validate_multiple_billing("Delivery Note", "dn_detail", "amount")
|
||||
if self.is_return:
|
||||
self.timesheets = []
|
||||
|
||||
if self.is_return and self.return_against:
|
||||
for row in self.timesheets:
|
||||
if row.billing_hours:
|
||||
row.billing_hours = -abs(row.billing_hours)
|
||||
if row.billing_amount:
|
||||
row.billing_amount = -abs(row.billing_amount)
|
||||
|
||||
self.update_packing_list()
|
||||
self.set_billing_hours_and_amount()
|
||||
self.update_timesheet_billing_for_project()
|
||||
@@ -484,7 +496,7 @@ class SalesInvoice(SellingController):
|
||||
if cint(self.is_pos) != 1 and not self.is_return:
|
||||
self.update_against_document_in_jv()
|
||||
|
||||
self.update_time_sheet(self.name)
|
||||
self.update_time_sheet(None if (self.is_return and self.return_against) else self.name)
|
||||
|
||||
if frappe.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction":
|
||||
update_company_current_month_sales(self.company)
|
||||
@@ -564,7 +576,7 @@ class SalesInvoice(SellingController):
|
||||
self.check_if_consolidated_invoice()
|
||||
|
||||
super().before_cancel()
|
||||
self.update_time_sheet(None)
|
||||
self.update_time_sheet(self.return_against if (self.is_return and self.return_against) else None)
|
||||
|
||||
def on_cancel(self):
|
||||
check_if_return_invoice_linked_with_payment_entry(self)
|
||||
@@ -804,8 +816,20 @@ class SalesInvoice(SellingController):
|
||||
for data in timesheet.time_logs:
|
||||
if (
|
||||
(self.project and args.timesheet_detail == data.name)
|
||||
or (not self.project and not data.sales_invoice)
|
||||
or (not sales_invoice and data.sales_invoice == self.name)
|
||||
or (not self.project and not data.sales_invoice and args.timesheet_detail == data.name)
|
||||
or (
|
||||
not sales_invoice
|
||||
and data.sales_invoice == self.name
|
||||
and args.timesheet_detail == data.name
|
||||
)
|
||||
or (
|
||||
self.is_return
|
||||
and self.return_against
|
||||
and data.sales_invoice
|
||||
and data.sales_invoice == self.return_against
|
||||
and not sales_invoice
|
||||
and args.timesheet_detail == data.name
|
||||
)
|
||||
):
|
||||
data.sales_invoice = sales_invoice
|
||||
|
||||
@@ -845,11 +869,26 @@ class SalesInvoice(SellingController):
|
||||
payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account")
|
||||
|
||||
def validate_time_sheets_are_submitted(self):
|
||||
# Note: This validation is skipped for return invoices
|
||||
# to allow returns to reference already-billed timesheet details
|
||||
for data in self.timesheets:
|
||||
# Handle invoice duplication
|
||||
if data.time_sheet and data.timesheet_detail:
|
||||
if sales_invoice := frappe.db.get_value(
|
||||
"Timesheet Detail", data.timesheet_detail, "sales_invoice"
|
||||
):
|
||||
frappe.throw(
|
||||
_("Row {0}: Sales Invoice {1} is already created for {2}").format(
|
||||
data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet)
|
||||
)
|
||||
)
|
||||
|
||||
if data.time_sheet:
|
||||
status = frappe.db.get_value("Timesheet", data.time_sheet, "status")
|
||||
if status not in ["Submitted", "Payslip"]:
|
||||
frappe.throw(_("Timesheet {0} is already completed or cancelled").format(data.time_sheet))
|
||||
if status not in ["Submitted", "Payslip", "Partially Billed"]:
|
||||
frappe.throw(
|
||||
_("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet)
|
||||
)
|
||||
|
||||
def set_pos_fields(self, for_validate=False):
|
||||
"""Set retail related fields from POS Profiles"""
|
||||
@@ -1283,7 +1322,12 @@ class SalesInvoice(SellingController):
|
||||
timesheet.billing_amount = ts_doc.total_billable_amount
|
||||
|
||||
def update_timesheet_billing_for_project(self):
|
||||
if not self.timesheets and self.project and self.is_auto_fetch_timesheet_enabled():
|
||||
if (
|
||||
not self.is_return
|
||||
and not self.timesheets
|
||||
and self.project
|
||||
and self.is_auto_fetch_timesheet_enabled()
|
||||
):
|
||||
self.add_timesheet_data()
|
||||
else:
|
||||
self.calculate_billing_amount_for_timesheet()
|
||||
@@ -2378,7 +2422,10 @@ def make_delivery_note(source_name, target_doc=None):
|
||||
"cost_center": "cost_center",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: doc.delivered_by_supplier != 1 and not doc.scio_detail,
|
||||
"condition": lambda doc: doc.delivered_by_supplier != 1
|
||||
and not doc.scio_detail
|
||||
and not doc.dn_detail
|
||||
and doc.qty - doc.delivered_qty > 0,
|
||||
},
|
||||
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
|
||||
"Sales Team": {
|
||||
|
||||
@@ -2951,6 +2951,60 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(sales_invoice.items[0].item_tax_template, "_Test Account Excise Duty @ 10 - _TC")
|
||||
self.assertEqual(sales_invoice.items[0].item_tax_rate, item_tax_map)
|
||||
|
||||
def test_item_tax_template_change_with_grand_total_discount(self):
|
||||
"""
|
||||
Test that when item tax template changes due to discount on Grand Total,
|
||||
the tax calculations are consistent.
|
||||
"""
|
||||
item = create_item("Test Item With Multiple Tax Templates")
|
||||
|
||||
item.set("taxes", [])
|
||||
item.append(
|
||||
"taxes",
|
||||
{
|
||||
"item_tax_template": "_Test Account Excise Duty @ 10 - _TC",
|
||||
"minimum_net_rate": 0,
|
||||
"maximum_net_rate": 500,
|
||||
},
|
||||
)
|
||||
|
||||
item.append(
|
||||
"taxes",
|
||||
{
|
||||
"item_tax_template": "_Test Account Excise Duty @ 12 - _TC",
|
||||
"minimum_net_rate": 501,
|
||||
"maximum_net_rate": 1000,
|
||||
},
|
||||
)
|
||||
|
||||
item.save()
|
||||
|
||||
si = create_sales_invoice(item=item.name, rate=700, do_not_save=True)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Excise Duty - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Excise Duty",
|
||||
"rate": 0,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
self.assertEqual(si.items[0].item_tax_template, "_Test Account Excise Duty @ 12 - _TC")
|
||||
|
||||
si.apply_discount_on = "Grand Total"
|
||||
si.discount_amount = 300
|
||||
si.save()
|
||||
|
||||
# Verify template changed to 10%
|
||||
self.assertEqual(si.items[0].item_tax_template, "_Test Account Excise Duty @ 10 - _TC")
|
||||
self.assertEqual(si.taxes[0].tax_amount, 70) # 10% of 700
|
||||
self.assertEqual(si.grand_total, 470) # 700 + 70 - 300
|
||||
|
||||
si.submit()
|
||||
|
||||
@IntegrationTestCase.change_settings("Selling Settings", {"enable_discount_accounting": 1})
|
||||
def test_sales_invoice_with_discount_accounting_enabled(self):
|
||||
discount_account = create_account(
|
||||
@@ -4691,6 +4745,66 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
doc.db_set("do_not_use_batchwise_valuation", original_value)
|
||||
|
||||
@change_settings("Selling Settings", {"set_zero_rate_for_expired_batch": True})
|
||||
def test_zero_valuation_for_standalone_credit_note_with_expired_batch(self):
|
||||
item_code = "_Test Item for Expiry Batch Zero Valuation"
|
||||
make_item_for_si(
|
||||
item_code,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"has_expiry_date": 1,
|
||||
"shelf_life_in_days": 2,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TBATCH-EBZV.####",
|
||||
},
|
||||
)
|
||||
|
||||
se = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
target="_Test Warehouse - _TC",
|
||||
rate=100,
|
||||
)
|
||||
|
||||
# fetch batch no from bundle
|
||||
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
|
||||
|
||||
si = create_sales_invoice(
|
||||
posting_date=add_days(nowdate(), 3),
|
||||
item=item_code,
|
||||
qty=-10,
|
||||
rate=100,
|
||||
is_return=1,
|
||||
update_stock=1,
|
||||
use_serial_batch_fields=1,
|
||||
do_not_save=1,
|
||||
do_not_submit=1,
|
||||
)
|
||||
|
||||
si.items[0].batch_no = batch_no
|
||||
si.save()
|
||||
si.submit()
|
||||
|
||||
si.reload()
|
||||
# check zero incoming rate in voucher
|
||||
self.assertEqual(si.items[0].incoming_rate, 0.0)
|
||||
|
||||
# chekc zero incoming rate in stock ledger
|
||||
stock_ledger_entry = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_type": "Sales Invoice",
|
||||
"voucher_no": si.name,
|
||||
"item_code": item_code,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
},
|
||||
["incoming_rate", "valuation_rate"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
self.assertEqual(stock_ledger_entry.incoming_rate, 0.0)
|
||||
|
||||
|
||||
def make_item_for_si(item_code, properties=None):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Timesheet Detail",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -117,15 +116,16 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:36.562795",
|
||||
"modified": "2025-12-23 13:54:17.677187",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Timesheet",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,7 +415,6 @@ class TestTaxWithholdingCategory(IntegrationTestCase):
|
||||
"cost_center": "Main - _TC",
|
||||
"tax_amount": 500,
|
||||
"description": "Test",
|
||||
"add_deduct_tax": "Add",
|
||||
},
|
||||
)
|
||||
pi.save()
|
||||
@@ -506,7 +505,6 @@ class TestTaxWithholdingCategory(IntegrationTestCase):
|
||||
"cost_center": "Main - _TC",
|
||||
"tax_amount": 200,
|
||||
"description": "Test Gross Tax",
|
||||
"add_deduct_tax": "Add",
|
||||
},
|
||||
)
|
||||
si.save()
|
||||
@@ -541,10 +539,10 @@ class TestTaxWithholdingCategory(IntegrationTestCase):
|
||||
"cost_center": "Main - _TC",
|
||||
"tax_amount": 400,
|
||||
"description": "Test Gross Tax",
|
||||
"add_deduct_tax": "Add",
|
||||
},
|
||||
)
|
||||
si.save()
|
||||
si.reload()
|
||||
si.submit()
|
||||
invoices.append(si)
|
||||
# For amount before threshold (first 8000 + VAT): TCS entry with amount zero
|
||||
@@ -594,7 +592,6 @@ class TestTaxWithholdingCategory(IntegrationTestCase):
|
||||
"cost_center": "Main - _TC",
|
||||
"tax_amount": 500,
|
||||
"description": "VAT added to test TDS calculation on gross amount",
|
||||
"add_deduct_tax": "Add",
|
||||
},
|
||||
)
|
||||
si.save()
|
||||
@@ -1024,7 +1021,6 @@ class TestTaxWithholdingCategory(IntegrationTestCase):
|
||||
"cost_center": "Main - _TC",
|
||||
"tax_amount": 1000,
|
||||
"description": "VAT added to test TDS calculation on gross amount",
|
||||
"add_deduct_tax": "Add",
|
||||
},
|
||||
)
|
||||
pi.save()
|
||||
@@ -1162,7 +1158,6 @@ class TestTaxWithholdingCategory(IntegrationTestCase):
|
||||
"cost_center": "Main - _TC",
|
||||
"tax_amount": 8000,
|
||||
"description": "Test",
|
||||
"add_deduct_tax": "Add",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -708,6 +708,10 @@ class TaxWithholdingController:
|
||||
existing_taxes = {row.account_head: row for row in self.doc.taxes if row.is_tax_withholding_account}
|
||||
precision = self.doc.precision("tax_amount", "taxes")
|
||||
conversion_rate = self.get_conversion_rate()
|
||||
add_deduct_tax = "Deduct"
|
||||
|
||||
if self.party_type == "Customer":
|
||||
add_deduct_tax = "Add"
|
||||
|
||||
for account_head, base_amount in account_amount_map.items():
|
||||
tax_amount = flt(base_amount / conversion_rate, precision)
|
||||
@@ -724,6 +728,7 @@ class TaxWithholdingController:
|
||||
tax_row = self._create_tax_row(account_head, tax_amount)
|
||||
for_update = False
|
||||
|
||||
tax_row.add_deduct_tax = add_deduct_tax
|
||||
# Set item-wise tax breakup for this tax row
|
||||
self._set_item_wise_tax_for_tds(
|
||||
tax_row, account_head, category_withholding_map, for_update=for_update
|
||||
@@ -743,7 +748,6 @@ class TaxWithholdingController:
|
||||
"account_head": account_head,
|
||||
"description": account_head,
|
||||
"cost_center": cost_center,
|
||||
"add_deduct_tax": "Deduct",
|
||||
"tax_amount": tax_amount,
|
||||
"dont_recompute_tax": 1,
|
||||
},
|
||||
@@ -807,12 +811,14 @@ class TaxWithholdingController:
|
||||
else:
|
||||
item_tax_amount = 0
|
||||
|
||||
multiplier = -1 if tax_row.add_deduct_tax == "Deduct" else 1
|
||||
|
||||
self.doc._item_wise_tax_details.append(
|
||||
frappe._dict(
|
||||
item=item,
|
||||
tax=tax_row,
|
||||
rate=category.tax_rate,
|
||||
amount=item_tax_amount * -1, # Negative because it's a deduction
|
||||
amount=item_tax_amount * multiplier,
|
||||
taxable_amount=item_base_taxable,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -43,16 +43,18 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:55.008837",
|
||||
"modified": "2025-11-14 16:17:25.584675",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Transaction Deletion Record Details",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
<div class="col-xs-6">
|
||||
<table>
|
||||
<tr><td><strong>Date: </strong></td><td>{{ frappe.utils.format_date(doc.creation) }}</td></tr>
|
||||
<tr><td><strong>Date: </strong></td><td>{{ frappe.utils.format_date(doc.posting_date) }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -877,11 +877,15 @@ class ReceivablePayableReport:
|
||||
else:
|
||||
entry_date = row.posting_date
|
||||
|
||||
row.range0 = 0.0
|
||||
|
||||
self.get_ageing_data(entry_date, row)
|
||||
|
||||
# ageing buckets should not have amounts if due date is not reached
|
||||
if getdate(entry_date) > getdate(self.age_as_on):
|
||||
row.range0 = row.outstanding
|
||||
[setattr(row, f"range{i}", 0.0) for i in self.range_numbers]
|
||||
row.total_due = 0
|
||||
return
|
||||
|
||||
row.total_due = sum(row[f"range{i}"] for i in self.range_numbers)
|
||||
|
||||
@@ -1281,6 +1285,8 @@ class ReceivablePayableReport:
|
||||
ranges = [*self.ranges, _("Above")]
|
||||
|
||||
prev_range_value = 0
|
||||
self.add_column(label=_("<0"), fieldname="range0", fieldtype="Currency")
|
||||
self.ageing_column_labels.append(_("<0"))
|
||||
for idx, curr_range_value in enumerate(ranges):
|
||||
label = f"{prev_range_value}-{curr_range_value}"
|
||||
self.add_column(label=label, fieldname="range" + str(idx + 1))
|
||||
@@ -1296,7 +1302,9 @@ class ReceivablePayableReport:
|
||||
for row in self.data:
|
||||
row = frappe._dict(row)
|
||||
if not cint(row.bold):
|
||||
values = [flt(row.get(f"range{i}", None), precision) for i in self.range_numbers]
|
||||
values = [flt(row.get("range0", 0), precision)] + [
|
||||
flt(row.get(f"range{i}", 0), precision) for i in self.range_numbers
|
||||
]
|
||||
rows.append({"values": values})
|
||||
|
||||
self.chart = {
|
||||
|
||||
@@ -18,6 +18,8 @@ def execute(filters=None):
|
||||
dimensions = filters.get("budget_against_filter")
|
||||
else:
|
||||
dimensions = get_budget_dimensions(filters)
|
||||
if not dimensions:
|
||||
return columns, [], None, None
|
||||
|
||||
budget_records = get_budget_records(filters, dimensions)
|
||||
budget_map = build_budget_map(budget_records, filters)
|
||||
|
||||
@@ -219,13 +219,18 @@ def get_net_profit(
|
||||
|
||||
has_value = False
|
||||
|
||||
gross_income_roots = [row for row in (gross_income or []) if not flt(row.get("indent"))]
|
||||
non_gross_income_roots = [row for row in (non_gross_income or []) if not flt(row.get("indent"))]
|
||||
gross_expense_roots = [row for row in (gross_expense or []) if not flt(row.get("indent"))]
|
||||
non_gross_expense_roots = [row for row in (non_gross_expense or []) if not flt(row.get("indent"))]
|
||||
|
||||
for period in period_list:
|
||||
key = period if consolidated else period.key
|
||||
gross_income_for_period = flt(gross_income[0].get(key, 0)) if gross_income else 0
|
||||
non_gross_income_for_period = flt(non_gross_income[0].get(key, 0)) if non_gross_income else 0
|
||||
|
||||
gross_expense_for_period = flt(gross_expense[0].get(key, 0)) if gross_expense else 0
|
||||
non_gross_expense_for_period = flt(non_gross_expense[0].get(key, 0)) if non_gross_expense else 0
|
||||
gross_income_for_period = sum(flt(row.get(key, 0)) for row in gross_income_roots)
|
||||
non_gross_income_for_period = sum(flt(row.get(key, 0)) for row in non_gross_income_roots)
|
||||
gross_expense_for_period = sum(flt(row.get(key, 0)) for row in gross_expense_roots)
|
||||
non_gross_expense_for_period = sum(flt(row.get(key, 0)) for row in non_gross_expense_roots)
|
||||
|
||||
total_income = gross_income_for_period + non_gross_income_for_period
|
||||
total_expense = gross_expense_for_period + non_gross_expense_for_period
|
||||
|
||||
@@ -105,7 +105,7 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
|
||||
{
|
||||
"total_tax": total_tax,
|
||||
"total_other_charges": total_other_charges,
|
||||
"total": d.base_net_amount + total_tax,
|
||||
"total": d.base_net_amount + total_tax + total_other_charges,
|
||||
"currency": company_currency,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -163,11 +163,11 @@ def get_net_profit_loss(income, expense, period_list, company, currency=None, co
|
||||
|
||||
|
||||
def get_chart_data(filters, columns, income, expense, net_profit_loss, currency):
|
||||
labels = [d.get("label") for d in columns[2:]]
|
||||
labels = [d.get("label") for d in columns[4:]]
|
||||
|
||||
income_data, expense_data, net_profit = [], [], []
|
||||
|
||||
for p in columns[2:]:
|
||||
for p in columns[4:]:
|
||||
if income:
|
||||
income_data.append(income[-2].get(p.get("fieldname")))
|
||||
if expense:
|
||||
|
||||
@@ -11,6 +11,7 @@ import frappe.defaults
|
||||
from frappe import _, qb, throw
|
||||
from frappe.desk.reportview import build_match_conditions
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.model.naming import determine_consecutive_week_number
|
||||
from frappe.query_builder import AliasedQuery, Case, Criterion, Field, Table
|
||||
from frappe.query_builder.functions import Count, IfNull, Max, Round, Sum
|
||||
from frappe.query_builder.utils import DocType
|
||||
@@ -25,6 +26,7 @@ from frappe.utils import (
|
||||
get_number_format_info,
|
||||
getdate,
|
||||
now,
|
||||
now_datetime,
|
||||
nowdate,
|
||||
)
|
||||
from frappe.utils.caching import site_cache
|
||||
@@ -66,6 +68,7 @@ def get_fiscal_year(
|
||||
as_dict=False,
|
||||
boolean=None,
|
||||
raise_on_missing=True,
|
||||
truncate=False,
|
||||
):
|
||||
if isinstance(raise_on_missing, str):
|
||||
raise_on_missing = loads(raise_on_missing)
|
||||
@@ -79,7 +82,14 @@ def get_fiscal_year(
|
||||
fiscal_years = get_fiscal_years(
|
||||
date, fiscal_year, label, verbose, company, as_dict=as_dict, raise_on_missing=raise_on_missing
|
||||
)
|
||||
return False if not fiscal_years else fiscal_years[0]
|
||||
|
||||
if fiscal_years:
|
||||
fiscal_year = fiscal_years[0]
|
||||
if truncate:
|
||||
return ("-".join(y[-2:] for y in fiscal_year[0].split("-")), fiscal_year[1], fiscal_year[2])
|
||||
return fiscal_year
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_fiscal_years(
|
||||
@@ -547,6 +557,7 @@ def reconcile_against_document(
|
||||
doc.make_advance_gl_entries(entry=row)
|
||||
else:
|
||||
_delete_pl_entries(voucher_type, voucher_no)
|
||||
_delete_adv_pl_entries(voucher_type, voucher_no)
|
||||
gl_map = doc.build_gl_map()
|
||||
# Make sure there is no overallocation
|
||||
from erpnext.accounts.general_ledger import process_debit_credit_difference
|
||||
@@ -1500,14 +1511,14 @@ def get_autoname_with_number(number_value, doc_title, company):
|
||||
|
||||
|
||||
def parse_naming_series_variable(doc, variable):
|
||||
if variable == "FY":
|
||||
if variable in ["FY", "TFY"]:
|
||||
if doc:
|
||||
date = doc.get("posting_date") or doc.get("transaction_date") or getdate()
|
||||
company = doc.get("company")
|
||||
else:
|
||||
date = getdate()
|
||||
company = None
|
||||
return get_fiscal_year(date=date, company=company)[0]
|
||||
return get_fiscal_year(date=date, company=company, truncate=variable == "TFY")[0]
|
||||
|
||||
elif variable == "ABBR":
|
||||
if doc:
|
||||
@@ -1517,6 +1528,18 @@ def parse_naming_series_variable(doc, variable):
|
||||
|
||||
return frappe.db.get_value("Company", company, "abbr") if company else ""
|
||||
|
||||
else:
|
||||
data = {"YY": "%y", "YYYY": "%Y", "MM": "%m", "DD": "%d", "JJJ": "%j"}
|
||||
date = (
|
||||
(
|
||||
getdate(doc.get("posting_date") or doc.get("transaction_date") or doc.get("posting_datetime"))
|
||||
or now_datetime()
|
||||
)
|
||||
if frappe.get_single_value("Global Defaults", "use_posting_datetime_for_naming_documents")
|
||||
else now_datetime()
|
||||
)
|
||||
return date.strftime(data[variable]) if variable in data else determine_consecutive_week_number(date)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_coa(doctype, parent, is_root=None, chart=None):
|
||||
@@ -1946,6 +1969,7 @@ def get_payment_ledger_entries(gl_entries, cancel=0):
|
||||
account=gle.account,
|
||||
party_type=gle.party_type,
|
||||
party=gle.party,
|
||||
project=gle.project,
|
||||
cost_center=gle.cost_center,
|
||||
finance_book=gle.finance_book,
|
||||
due_date=gle.due_date,
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "accounting",
|
||||
"idx": 3,
|
||||
"idx": 4,
|
||||
"indicator_color": "",
|
||||
"is_hidden": 0,
|
||||
"label": "Accounting",
|
||||
"label": "Invoicing",
|
||||
"links": [
|
||||
{
|
||||
"hidden": 0,
|
||||
@@ -587,10 +587,10 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2025-12-24 13:20:34.857205",
|
||||
"modified": "2026-01-23 11:05:47.246213",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounting",
|
||||
"name": "Invoicing",
|
||||
"number_cards": [
|
||||
{
|
||||
"label": "Outgoing Bills",
|
||||
@@ -617,6 +617,6 @@
|
||||
"roles": [],
|
||||
"sequence_id": 2.0,
|
||||
"shortcuts": [],
|
||||
"title": "Accounting",
|
||||
"title": "Invoicing",
|
||||
"type": "Workspace"
|
||||
}
|
||||
@@ -116,14 +116,6 @@ frappe.ui.form.on("Asset", {
|
||||
__("Manage")
|
||||
);
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Repair Asset"),
|
||||
function () {
|
||||
frm.trigger("create_asset_repair");
|
||||
},
|
||||
__("Manage")
|
||||
);
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Split Asset"),
|
||||
function () {
|
||||
@@ -155,6 +147,14 @@ frappe.ui.form.on("Asset", {
|
||||
},
|
||||
__("Manage")
|
||||
);
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Repair Asset"),
|
||||
function () {
|
||||
frm.trigger("create_asset_repair");
|
||||
},
|
||||
__("Manage")
|
||||
);
|
||||
}
|
||||
|
||||
if (!frm.doc.calculate_depreciation) {
|
||||
|
||||
@@ -244,6 +244,8 @@ class Asset(AccountsController):
|
||||
|
||||
def before_submit(self):
|
||||
if self.is_composite_asset and not has_active_capitalization(self.name):
|
||||
if self.split_from and has_active_capitalization(self.split_from):
|
||||
return
|
||||
frappe.throw(_("Please capitalize this asset before submitting."))
|
||||
|
||||
def on_submit(self):
|
||||
|
||||
@@ -246,7 +246,9 @@ def _make_journal_entry_for_depreciation(
|
||||
|
||||
def setup_journal_entry_metadata(je, depr_schedule_doc, depr_series, depr_schedule, asset):
|
||||
je.voucher_type = "Depreciation Entry"
|
||||
je.naming_series = depr_series
|
||||
if depr_series:
|
||||
je.naming_series = depr_series
|
||||
|
||||
je.posting_date = depr_schedule.schedule_date
|
||||
je.company = asset.company
|
||||
je.finance_book = depr_schedule_doc.finance_book
|
||||
|
||||
@@ -1691,6 +1691,71 @@ class TestDepreciationBasics(AssetSetup):
|
||||
pr.submit()
|
||||
self.assertTrue(get_gl_entries("Purchase Receipt", pr.name))
|
||||
|
||||
def test_split_asset_created_via_capitalization(self):
|
||||
"""Test that assets created via Asset Capitalization can be split without capitalization error"""
|
||||
from erpnext.assets.doctype.asset_capitalization.test_asset_capitalization import (
|
||||
create_asset_capitalization,
|
||||
create_asset_capitalization_data,
|
||||
)
|
||||
|
||||
# Ensure test data exists
|
||||
create_asset_capitalization_data()
|
||||
|
||||
company = "_Test Company with perpetual inventory"
|
||||
set_depreciation_settings_in_company(company=company)
|
||||
name = frappe.db.get_value(
|
||||
"Asset Category Account",
|
||||
filters={"parent": "Computers", "company_name": company},
|
||||
fieldname=["name"],
|
||||
)
|
||||
frappe.db.set_value("Asset Category Account", name, "capital_work_in_progress_account", "")
|
||||
|
||||
stock_rate = 1000
|
||||
stock_qty = 2
|
||||
total_amount = 2000
|
||||
|
||||
# Create composite asset
|
||||
wip_composite_asset = create_asset(
|
||||
asset_name="Asset Capitalization WIP Composite Asset for Split",
|
||||
is_composite_asset=1,
|
||||
warehouse="Stores - TCP1",
|
||||
company=company,
|
||||
asset_quantity=2, # Set quantity > 1 to allow splitting
|
||||
)
|
||||
|
||||
# Create and submit Asset Capitalization
|
||||
asset_capitalization = create_asset_capitalization(
|
||||
target_asset=wip_composite_asset.name,
|
||||
stock_qty=stock_qty,
|
||||
stock_rate=stock_rate,
|
||||
company=company,
|
||||
submit=1,
|
||||
)
|
||||
|
||||
# Verify asset was capitalized
|
||||
target_asset = frappe.get_doc("Asset", asset_capitalization.target_asset)
|
||||
self.assertEqual(target_asset.net_purchase_amount, total_amount)
|
||||
self.assertEqual(target_asset.status, "Work In Progress")
|
||||
|
||||
# Submit the capitalized asset
|
||||
target_asset.submit()
|
||||
self.assertEqual(target_asset.status, "Submitted")
|
||||
|
||||
# Split the asset - this should work without capitalization error
|
||||
split_qty = 1
|
||||
splitted_asset = split_asset(target_asset.name, split_qty)
|
||||
|
||||
# Verify split asset was created and submitted successfully
|
||||
self.assertIsNotNone(splitted_asset)
|
||||
self.assertEqual(splitted_asset.asset_quantity, split_qty)
|
||||
self.assertEqual(splitted_asset.split_from, target_asset.name)
|
||||
self.assertEqual(splitted_asset.docstatus, 1) # Should be submitted
|
||||
self.assertEqual(splitted_asset.status, "Submitted")
|
||||
|
||||
# Verify original asset was updated
|
||||
target_asset.reload()
|
||||
self.assertEqual(target_asset.asset_quantity, 1) # Remaining quantity
|
||||
|
||||
|
||||
def get_gl_entries(doctype, docname):
|
||||
gl_entry = frappe.qb.DocType("GL Entry")
|
||||
|
||||
@@ -574,13 +574,19 @@ class AssetCapitalization(StockController):
|
||||
if self.docstatus == 2:
|
||||
net_purchase_amount = asset_doc.net_purchase_amount - total_target_asset_value
|
||||
purchase_amount = asset_doc.purchase_amount - total_target_asset_value
|
||||
asset_doc.db_set("total_asset_cost", asset_doc.total_asset_cost - total_target_asset_value)
|
||||
total_asset_cost = asset_doc.total_asset_cost - total_target_asset_value
|
||||
else:
|
||||
net_purchase_amount = asset_doc.net_purchase_amount + total_target_asset_value
|
||||
purchase_amount = asset_doc.purchase_amount + total_target_asset_value
|
||||
total_asset_cost = asset_doc.total_asset_cost + total_target_asset_value
|
||||
|
||||
asset_doc.db_set("net_purchase_amount", net_purchase_amount)
|
||||
asset_doc.db_set("purchase_amount", purchase_amount)
|
||||
asset_doc.db_set(
|
||||
{
|
||||
"net_purchase_amount": net_purchase_amount,
|
||||
"purchase_amount": purchase_amount,
|
||||
"total_asset_cost": total_asset_cost,
|
||||
}
|
||||
)
|
||||
|
||||
frappe.msgprint(
|
||||
_("Asset {0} has been updated. Please set the depreciation details if any and submit it.").format(
|
||||
|
||||
@@ -74,7 +74,7 @@ class AssetValueAdjustment(Document):
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
frappe.get_doc("Journal Entry", self.journal_entry).cancel()
|
||||
self.cancel_asset_revaluation_entry()
|
||||
self.update_asset()
|
||||
add_asset_activity(
|
||||
self.asset,
|
||||
@@ -167,6 +167,17 @@ class AssetValueAdjustment(Document):
|
||||
if dimension.get("mandatory_for_pl"):
|
||||
debit_entry.update({dimension["fieldname"]: dimension_value})
|
||||
|
||||
def cancel_asset_revaluation_entry(self):
|
||||
if not self.journal_entry:
|
||||
return
|
||||
|
||||
revaluation_entry = frappe.get_doc("Journal Entry", self.journal_entry)
|
||||
if revaluation_entry.docstatus == 1:
|
||||
# Ignore permissions to match Journal Entry submission behavior
|
||||
revaluation_entry.flags.ignore_permissions = True
|
||||
revaluation_entry.flags.via_asset_value_adjustment = True
|
||||
revaluation_entry.cancel()
|
||||
|
||||
def update_asset(self):
|
||||
asset = self.update_asset_value_after_depreciation()
|
||||
note = self.get_adjustment_note()
|
||||
|
||||
@@ -803,7 +803,7 @@ frappe.ui.form.on("Purchase Order", "is_subcontracted", function (frm) {
|
||||
|
||||
function prevent_past_schedule_dates(frm) {
|
||||
if (frm.doc.transaction_date) {
|
||||
frm.fields_dict["schedule_date"].datepicker.update({
|
||||
frm.fields_dict["schedule_date"].datepicker?.update({
|
||||
minDate: new Date(frm.doc.transaction_date),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1301,7 +1301,8 @@
|
||||
"hidden": 1,
|
||||
"label": "Item Wise Tax Details",
|
||||
"no_copy": 1,
|
||||
"options": "Item Wise Tax Detail"
|
||||
"options": "Item Wise Tax Detail",
|
||||
"print_hide": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -1309,7 +1310,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-09-28 11:00:56.635116",
|
||||
"modified": "2026-01-29 21:22:54.323838",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order",
|
||||
|
||||
@@ -149,6 +149,7 @@ class PurchaseOrder(BuyingController):
|
||||
supplied_items: DF.Table[PurchaseOrderItemSupplied]
|
||||
supplier: DF.Link
|
||||
supplier_address: DF.Link | None
|
||||
supplier_group: DF.Link | None
|
||||
supplier_name: DF.Data | None
|
||||
supplier_warehouse: DF.Link | None
|
||||
tax_category: DF.Link | None
|
||||
|
||||
@@ -1330,6 +1330,55 @@ class TestPurchaseOrder(IntegrationTestCase):
|
||||
pi = make_pi_from_po(po.name)
|
||||
self.assertEqual(pi.items[0].qty, 50)
|
||||
|
||||
def test_multiple_advances_against_purchase_order_are_allocated_across_partial_purchase_invoices(self):
|
||||
# step - 1: create PO
|
||||
po = create_purchase_order(qty=10, rate=10)
|
||||
|
||||
# step - 2: create first partial advance payment
|
||||
pe1 = get_payment_entry("Purchase Order", po.name, bank_account="_Test Bank - _TC")
|
||||
pe1.reference_no = "1"
|
||||
pe1.reference_date = nowdate()
|
||||
pe1.paid_amount = 50
|
||||
pe1.references[0].allocated_amount = 50
|
||||
pe1.save(ignore_permissions=True).submit()
|
||||
|
||||
# check first advance paid against PO
|
||||
po.reload()
|
||||
self.assertEqual(po.advance_paid, 50)
|
||||
|
||||
# step - 3: create first PI for partial qty and allocate first advance
|
||||
pi_1 = make_pi_from_po(po.name)
|
||||
pi_1.update_stock = 1
|
||||
pi_1.allocate_advances_automatically = 1
|
||||
pi_1.items[0].qty = 5
|
||||
pi_1.save(ignore_permissions=True).submit()
|
||||
|
||||
# step - 4: create second advance payment for remaining
|
||||
pe2 = get_payment_entry("Purchase Order", po.name, bank_account="_Test Bank - _TC")
|
||||
pe2.reference_no = "2"
|
||||
pe2.reference_date = nowdate()
|
||||
pe2.paid_amount = 50
|
||||
pe2.references[0].allocated_amount = 50
|
||||
pe2.save(ignore_permissions=True).submit()
|
||||
|
||||
# check second advance paid against PO
|
||||
po.reload()
|
||||
self.assertEqual(po.advance_paid, 100)
|
||||
|
||||
# step - 5: create second PI for remaining qty and allocate second advance
|
||||
pi_2 = make_pi_from_po(po.name)
|
||||
pi_2.update_stock = 1
|
||||
pi_2.allocate_advances_automatically = 1
|
||||
pi_2.save(ignore_permissions=True).submit()
|
||||
|
||||
# check PO and PI status
|
||||
po.reload()
|
||||
pi_1.reload()
|
||||
pi_2.reload()
|
||||
self.assertEqual(pi_1.status, "Paid")
|
||||
self.assertEqual(pi_2.status, "Paid")
|
||||
self.assertEqual(po.status, "Completed")
|
||||
|
||||
|
||||
def create_po_for_sc_testing():
|
||||
from erpnext.controllers.tests.test_subcontracting_controller import (
|
||||
|
||||
@@ -304,12 +304,17 @@ class RequestforQuotation(BuyingController):
|
||||
else:
|
||||
sender = frappe.session.user not in STANDARD_USERS and frappe.session.user or None
|
||||
|
||||
rendered_message = frappe.render_template(self.message_for_supplier, doc_args)
|
||||
subject_source = (
|
||||
self.subject
|
||||
or frappe.get_value("Email Template", self.email_template, "subject")
|
||||
or _("Request for Quotation")
|
||||
)
|
||||
rendered_subject = frappe.render_template(subject_source, doc_args)
|
||||
if preview:
|
||||
return {
|
||||
"message": self.message_for_supplier,
|
||||
"subject": self.subject
|
||||
or frappe.get_value("Email Template", self.email_template, "subject")
|
||||
or _("Request for Quotation"),
|
||||
"message": rendered_message,
|
||||
"subject": rendered_subject,
|
||||
}
|
||||
|
||||
attachments = []
|
||||
@@ -333,10 +338,8 @@ class RequestforQuotation(BuyingController):
|
||||
self.send_email(
|
||||
data,
|
||||
sender,
|
||||
self.subject
|
||||
or frappe.get_value("Email Template", self.email_template, "subject")
|
||||
or _("Request for Quotation"),
|
||||
self.message_for_supplier,
|
||||
rendered_subject,
|
||||
rendered_message,
|
||||
attachments,
|
||||
)
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fetch_from": "item_code.item_name",
|
||||
"fieldname": "item_name",
|
||||
"fieldtype": "Data",
|
||||
"in_global_search": 1,
|
||||
@@ -262,7 +261,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-04-28 23:30:22.927989",
|
||||
"modified": "2026-01-31 19:46:27.884592",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Request for Quotation Item",
|
||||
|
||||
@@ -383,7 +383,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "primary_address",
|
||||
"fieldtype": "Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Primary Address",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -500,7 +500,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2025-06-29 05:30:50.398653",
|
||||
"modified": "2026-01-16 15:56:31.139206",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
|
||||
@@ -62,7 +62,7 @@ class Supplier(TransactionBase):
|
||||
portal_users: DF.Table[PortalUser]
|
||||
prevent_pos: DF.Check
|
||||
prevent_rfqs: DF.Check
|
||||
primary_address: DF.Text | None
|
||||
primary_address: DF.TextEditor | None
|
||||
release_date: DF.Date | None
|
||||
represents_company: DF.Link | None
|
||||
supplier_details: DF.Text | None
|
||||
|
||||
@@ -16,6 +16,14 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
|
||||
return !doc.qty && me.frm.doc.has_unit_price_items ? "yellow" : "";
|
||||
});
|
||||
|
||||
this.frm.set_query("warehouse", "items", (doc, cdt, cdn) => {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
super.setup();
|
||||
}
|
||||
|
||||
|
||||
@@ -938,7 +938,8 @@
|
||||
"hidden": 1,
|
||||
"label": "Item Wise Tax Details",
|
||||
"no_copy": 1,
|
||||
"options": "Item Wise Tax Detail"
|
||||
"options": "Item Wise Tax Detail",
|
||||
"print_hide": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -947,7 +948,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-23 02:22:43.526822",
|
||||
"modified": "2026-01-29 21:23:13.778468",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation",
|
||||
|
||||
@@ -187,9 +187,8 @@ class AccountsController(TransactionBase):
|
||||
|
||||
msg = ""
|
||||
if self.get("update_outstanding_for_self"):
|
||||
msg = (
|
||||
"We can see {0} is made against {1}. If you want {1}'s outstanding to be updated, "
|
||||
"uncheck '{2}' checkbox. <br><br>Or"
|
||||
msg = _(
|
||||
"We can see {0} is made against {1}. If you want {1}'s outstanding to be updated, uncheck the '{2}' checkbox."
|
||||
).format(
|
||||
frappe.bold(document_type),
|
||||
get_link_to_form(self.doctype, self.get("return_against")),
|
||||
@@ -200,8 +199,8 @@ class AccountsController(TransactionBase):
|
||||
abs(flt(self.rounded_total) or flt(self.grand_total)) > flt(against_voucher_outstanding)
|
||||
):
|
||||
self.update_outstanding_for_self = 1
|
||||
msg = (
|
||||
"The outstanding amount {} in {} is lesser than {}. Updating the outstanding to this invoice. <br><br>And"
|
||||
msg = _(
|
||||
"The outstanding amount {0} in {1} is lesser than {2}. Updating the outstanding to this invoice."
|
||||
).format(
|
||||
against_voucher_outstanding,
|
||||
get_link_to_form(self.doctype, self.get("return_against")),
|
||||
@@ -209,11 +208,11 @@ class AccountsController(TransactionBase):
|
||||
)
|
||||
|
||||
if msg:
|
||||
msg += " you can use {} tool to reconcile against {} later.".format(
|
||||
msg += "<br><br>" + _("You can use {0} to reconcile against {1} later.").format(
|
||||
get_link_to_form("Payment Reconciliation"),
|
||||
get_link_to_form(self.doctype, self.get("return_against")),
|
||||
)
|
||||
frappe.msgprint(_(msg))
|
||||
frappe.msgprint(msg)
|
||||
|
||||
def validate(self):
|
||||
if not self.get("is_return") and not self.get("is_debit_note"):
|
||||
|
||||
@@ -212,7 +212,10 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
party = filters.get("customer") or filters.get("supplier")
|
||||
item_rules_list = frappe.get_all(
|
||||
"Party Specific Item",
|
||||
filters={"party": party},
|
||||
filters={
|
||||
"party": ["!=", party],
|
||||
"party_type": "Customer" if filters.get("customer") else "Supplier",
|
||||
},
|
||||
fields=["restrict_based_on", "based_on_value"],
|
||||
)
|
||||
|
||||
@@ -226,7 +229,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
filters_dict[rule.restrict_based_on].append(rule.based_on_value)
|
||||
|
||||
for filter in filters_dict:
|
||||
filters[scrub(filter)] = ["in", filters_dict[filter]]
|
||||
filters[scrub(filter)] = ["not in", filters_dict[filter]]
|
||||
|
||||
if filters.get("customer"):
|
||||
del filters["customer"]
|
||||
|
||||
@@ -12,7 +12,7 @@ from frappe.utils import cint, flt, format_datetime, get_datetime
|
||||
|
||||
import erpnext
|
||||
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
|
||||
from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method
|
||||
from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method, getdate
|
||||
|
||||
|
||||
class StockOverReturnError(frappe.ValidationError):
|
||||
@@ -759,6 +759,29 @@ def get_rate_for_return(
|
||||
StockLedgerEntry = frappe.qb.DocType("Stock Ledger Entry")
|
||||
select_field = Abs(StockLedgerEntry.stock_value_difference / StockLedgerEntry.actual_qty)
|
||||
|
||||
item_details = frappe.get_cached_value("Item", item_code, ["has_batch_no", "has_expiry_date"], as_dict=1)
|
||||
set_zero_rate_for_expired_batch = frappe.db.get_single_value(
|
||||
"Selling Settings", "set_zero_rate_for_expired_batch"
|
||||
)
|
||||
|
||||
if (
|
||||
set_zero_rate_for_expired_batch
|
||||
and item_details.has_batch_no
|
||||
and item_details.has_expiry_date
|
||||
and not return_against
|
||||
and voucher_type in ["Sales Invoice", "Delivery Note"]
|
||||
):
|
||||
# set incoming_rate zero explicitly for standalone credit note with expired batch
|
||||
batch_no = frappe.db.get_value(f"{voucher_type} Item", voucher_detail_no, "batch_no")
|
||||
if batch_no and is_batch_expired(batch_no, sle.get("posting_date")):
|
||||
frappe.db.set_value(
|
||||
voucher_type + " Item",
|
||||
voucher_detail_no,
|
||||
"incoming_rate",
|
||||
0,
|
||||
)
|
||||
return 0
|
||||
|
||||
rate = flt(frappe.db.get_value("Stock Ledger Entry", filters, select_field))
|
||||
if not (rate and return_against) and voucher_type in ["Sales Invoice", "Delivery Note"]:
|
||||
rate = frappe.db.get_value(f"{voucher_type} Item", voucher_detail_no, "incoming_rate")
|
||||
@@ -823,12 +846,34 @@ def get_filters(
|
||||
if reference_voucher_detail_no:
|
||||
filters["voucher_detail_no"] = reference_voucher_detail_no
|
||||
|
||||
if voucher_type in ["Purchase Receipt", "Purchase Invoice"] and item_row and item_row.get("warehouse"):
|
||||
filters["warehouse"] = item_row.get("warehouse")
|
||||
warehouses = []
|
||||
if voucher_type in ["Purchase Receipt", "Purchase Invoice"] and item_row:
|
||||
if reference_voucher_detail_no:
|
||||
warehouses = get_warehouses_for_return(voucher_type, reference_voucher_detail_no)
|
||||
|
||||
if item_row.get("warehouse") and item_row.get("warehouse") in warehouses:
|
||||
filters["warehouse"] = item_row.get("warehouse")
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def get_warehouses_for_return(voucher_type, name):
|
||||
warehouses = []
|
||||
warehouse_details = frappe.get_all(
|
||||
voucher_type + " Item",
|
||||
filters={"name": name, "docstatus": 1},
|
||||
fields=["warehouse", "rejected_warehouse"],
|
||||
)
|
||||
|
||||
for d in warehouse_details:
|
||||
if d.warehouse:
|
||||
warehouses.append(d.warehouse)
|
||||
if d.rejected_warehouse:
|
||||
warehouses.append(d.rejected_warehouse)
|
||||
|
||||
return warehouses
|
||||
|
||||
|
||||
def get_returned_serial_nos(child_doc, parent_doc, serial_no_field=None, ignore_voucher_detail_no=None):
|
||||
from erpnext.stock.doctype.serial_no.serial_no import (
|
||||
get_serial_nos as get_serial_nos_from_serial_no,
|
||||
@@ -1276,3 +1321,17 @@ def get_sales_invoice_item_from_consolidated_invoice(return_against_pos_invoice,
|
||||
return result[0].name if result else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def is_batch_expired(batch_no, posting_date):
|
||||
"""
|
||||
To check whether the batch is expired or not based on the posting date.
|
||||
"""
|
||||
expiry_date = frappe.db.get_value("Batch", batch_no, "expiry_date")
|
||||
if not expiry_date:
|
||||
return
|
||||
|
||||
if getdate(posting_date) > getdate(expiry_date):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -8,7 +8,7 @@ from frappe.utils import cint, flt, get_link_to_form, nowtime
|
||||
|
||||
from erpnext.accounts.party import render_address
|
||||
from erpnext.controllers.accounts_controller import get_taxes_and_charges
|
||||
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
|
||||
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return, is_batch_expired
|
||||
from erpnext.controllers.stock_controller import StockController
|
||||
from erpnext.stock.doctype.item.item import set_item_default
|
||||
from erpnext.stock.get_item_details import get_bin_details, get_conversion_factor
|
||||
@@ -296,7 +296,7 @@ class SellingController(StockController):
|
||||
_(
|
||||
"""Row #{0}: Selling rate for item {1} is lower than its {2}.
|
||||
Selling {3} should be atleast {4}.<br><br>Alternatively,
|
||||
you can disable selling price validation in {5} to bypass
|
||||
you can disable '{5}' in {6} to bypass
|
||||
this validation."""
|
||||
).format(
|
||||
idx,
|
||||
@@ -304,7 +304,8 @@ class SellingController(StockController):
|
||||
bold(ref_rate_field),
|
||||
bold("net rate"),
|
||||
bold(rate),
|
||||
get_link_to_form("Selling Settings", "Selling Settings"),
|
||||
bold(frappe.get_meta("Selling Settings").get_label("validate_selling_price")),
|
||||
get_link_to_form("Selling Settings"),
|
||||
),
|
||||
title=_("Invalid Selling Price"),
|
||||
)
|
||||
@@ -313,7 +314,6 @@ class SellingController(StockController):
|
||||
return
|
||||
|
||||
is_internal_customer = self.get("is_internal_customer")
|
||||
valuation_rate_map = {}
|
||||
|
||||
for item in self.items:
|
||||
if not item.item_code or item.is_free_item:
|
||||
@@ -323,7 +323,9 @@ class SellingController(StockController):
|
||||
"Item", item.item_code, ("last_purchase_rate", "is_stock_item")
|
||||
)
|
||||
|
||||
last_purchase_rate_in_sales_uom = last_purchase_rate * (item.conversion_factor or 1)
|
||||
last_purchase_rate_in_sales_uom = flt(
|
||||
last_purchase_rate * (item.conversion_factor or 1), item.precision("base_net_rate")
|
||||
)
|
||||
|
||||
if flt(item.base_net_rate) < flt(last_purchase_rate_in_sales_uom):
|
||||
throw_message(item.idx, item.item_name, last_purchase_rate_in_sales_uom, "last purchase rate")
|
||||
@@ -331,50 +333,16 @@ class SellingController(StockController):
|
||||
if is_internal_customer or not is_stock_item:
|
||||
continue
|
||||
|
||||
valuation_rate_map[(item.item_code, item.warehouse)] = None
|
||||
|
||||
if not valuation_rate_map:
|
||||
return
|
||||
|
||||
or_conditions = (
|
||||
f"""(item_code = {frappe.db.escape(valuation_rate[0])}
|
||||
and warehouse = {frappe.db.escape(valuation_rate[1])})"""
|
||||
for valuation_rate in valuation_rate_map
|
||||
)
|
||||
|
||||
valuation_rates = frappe.db.sql(
|
||||
f"""
|
||||
select
|
||||
item_code, warehouse, valuation_rate
|
||||
from
|
||||
`tabBin`
|
||||
where
|
||||
({" or ".join(or_conditions)})
|
||||
and valuation_rate > 0
|
||||
""",
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
for rate in valuation_rates:
|
||||
valuation_rate_map[(rate.item_code, rate.warehouse)] = rate.valuation_rate
|
||||
|
||||
for item in self.items:
|
||||
if not item.item_code or item.is_free_item:
|
||||
continue
|
||||
|
||||
last_valuation_rate = valuation_rate_map.get((item.item_code, item.warehouse))
|
||||
|
||||
if not last_valuation_rate:
|
||||
continue
|
||||
|
||||
last_valuation_rate_in_sales_uom = last_valuation_rate * (item.conversion_factor or 1)
|
||||
|
||||
if flt(item.base_net_rate) < flt(last_valuation_rate_in_sales_uom):
|
||||
if item.get("incoming_rate") and item.base_net_rate < (
|
||||
valuation_rate := flt(
|
||||
item.incoming_rate * (item.conversion_factor or 1), item.precision("base_net_rate")
|
||||
)
|
||||
):
|
||||
throw_message(
|
||||
item.idx,
|
||||
item.item_name,
|
||||
last_valuation_rate_in_sales_uom,
|
||||
"valuation rate (Moving Average)",
|
||||
valuation_rate,
|
||||
"valuation rate",
|
||||
)
|
||||
|
||||
def get_item_list(self):
|
||||
@@ -533,19 +501,37 @@ class SellingController(StockController):
|
||||
if self.doctype not in ("Delivery Note", "Sales Invoice"):
|
||||
return
|
||||
|
||||
from erpnext.stock.serial_batch_bundle import get_batch_nos
|
||||
|
||||
allow_at_arms_length_price = frappe.get_cached_value(
|
||||
"Stock Settings", None, "allow_internal_transfer_at_arms_length_price"
|
||||
)
|
||||
set_zero_rate_for_expired_batch = frappe.db.get_single_value(
|
||||
"Selling Settings", "set_zero_rate_for_expired_batch"
|
||||
)
|
||||
|
||||
old_doc = self.get_doc_before_save()
|
||||
items = self.get("items") + (self.get("packed_items") or [])
|
||||
for d in items:
|
||||
if not frappe.get_cached_value("Item", d.item_code, "is_stock_item"):
|
||||
continue
|
||||
|
||||
item_details = frappe.get_cached_value(
|
||||
"Item", d.item_code, ["has_serial_no", "has_batch_no"], as_dict=1
|
||||
"Item", d.item_code, ["has_serial_no", "has_batch_no", "has_expiry_date"], as_dict=1
|
||||
)
|
||||
|
||||
if not self.get("return_against") or (
|
||||
if (
|
||||
set_zero_rate_for_expired_batch
|
||||
and item_details.has_batch_no
|
||||
and item_details.has_expiry_date
|
||||
and self.get("is_return")
|
||||
and not self.get("return_against")
|
||||
and is_batch_expired(d.batch_no, self.get("posting_date"))
|
||||
):
|
||||
# set incoming rate as zero for stand-lone credit note with expired batch
|
||||
d.incoming_rate = 0
|
||||
|
||||
elif not self.get("return_against") or (
|
||||
get_valuation_method(d.item_code, self.company) == "Moving Average"
|
||||
and self.get("is_return")
|
||||
and not item_details.has_serial_no
|
||||
@@ -554,6 +540,29 @@ class SellingController(StockController):
|
||||
# Get incoming rate based on original item cost based on valuation method
|
||||
qty = flt(d.get("stock_qty") or d.get("actual_qty") or d.get("qty"))
|
||||
|
||||
if old_doc:
|
||||
old_item = next(
|
||||
(
|
||||
item
|
||||
for item in (old_doc.get("items") + (old_doc.get("packed_items") or []))
|
||||
if item.name == d.name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if old_item:
|
||||
old_qty = flt(
|
||||
old_item.get("stock_qty") or old_item.get("actual_qty") or old_item.get("qty")
|
||||
)
|
||||
if (
|
||||
old_item.item_code != d.item_code
|
||||
or old_item.warehouse != d.warehouse
|
||||
or old_qty != qty
|
||||
or old_item.batch_no != d.batch_no
|
||||
or get_batch_nos(old_item.serial_and_batch_bundle)
|
||||
!= get_batch_nos(d.serial_and_batch_bundle)
|
||||
):
|
||||
d.incoming_rate = 0
|
||||
|
||||
if (
|
||||
not d.incoming_rate
|
||||
or self.is_internal_transfer()
|
||||
|
||||
@@ -91,7 +91,8 @@ status_map = {
|
||||
],
|
||||
"Delivery Note": [
|
||||
["Draft", None],
|
||||
["To Bill", "eval:self.per_billed < 100 and self.docstatus == 1"],
|
||||
["To Bill", "eval:self.per_billed == 0 and self.docstatus == 1"],
|
||||
["Partially Billed", "eval:self.per_billed < 100 and self.per_billed > 0 and self.docstatus == 1"],
|
||||
["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"],
|
||||
["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"],
|
||||
["Return", "eval:self.is_return == 1 and self.per_billed == 0 and self.docstatus == 1"],
|
||||
@@ -443,7 +444,7 @@ class StatusUpdater(Document):
|
||||
):
|
||||
return
|
||||
|
||||
if args["source_dt"] != "Pick List Item":
|
||||
if args["source_dt"] != "Pick List Item" and args["target_dt"] != "Quotation Item":
|
||||
if qty_or_amount == "qty":
|
||||
action_msg = _(
|
||||
'To allow over receipt / delivery, update "Over Receipt/Delivery Allowance" in Stock Settings or the Item.'
|
||||
|
||||
@@ -552,7 +552,10 @@ class StockController(AccountsController):
|
||||
if is_rejected:
|
||||
serial_nos = row.get("rejected_serial_no")
|
||||
type_of_transaction = "Inward" if not self.is_return else "Outward"
|
||||
qty = row.get("rejected_qty")
|
||||
qty = flt(
|
||||
row.get("rejected_qty") * row.get("conversion_factor", 1.0),
|
||||
frappe.get_precision("Serial and Batch Entry", "qty"),
|
||||
)
|
||||
warehouse = row.get("rejected_warehouse")
|
||||
|
||||
if (
|
||||
|
||||
@@ -166,29 +166,46 @@ class SubcontractingController(StockController):
|
||||
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
|
||||
)
|
||||
|
||||
if self.doctype != "Subcontracting Receipt" and item.qty > flt(
|
||||
get_pending_subcontracted_quantity(
|
||||
self.doctype,
|
||||
self.purchase_order if self.doctype == "Subcontracting Order" else self.sales_order,
|
||||
).get(
|
||||
item.purchase_order_item
|
||||
if self.doctype == "Subcontracting Order"
|
||||
else item.sales_order_item
|
||||
)
|
||||
/ item.subcontracting_conversion_factor,
|
||||
frappe.get_precision(
|
||||
if self.doctype != "Subcontracting Receipt":
|
||||
order_item_doctype = (
|
||||
"Purchase Order Item"
|
||||
if self.doctype == "Subcontracting Order"
|
||||
else "Sales Order Item",
|
||||
"qty",
|
||||
),
|
||||
):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row {0}: Item {1}'s quantity cannot be higher than the available quantity."
|
||||
).format(item.idx, item.item_name)
|
||||
else "Sales Order Item"
|
||||
)
|
||||
|
||||
order_name = (
|
||||
self.purchase_order if self.doctype == "Subcontracting Order" else self.sales_order
|
||||
)
|
||||
order_item_field = frappe.scrub(order_item_doctype)
|
||||
|
||||
if not item.get(order_item_field):
|
||||
frappe.throw(
|
||||
_("Row {0}: Item {1} must be linked to a {2}.").format(
|
||||
item.idx, item.item_name, order_item_doctype
|
||||
)
|
||||
)
|
||||
|
||||
pending_qty = flt(
|
||||
flt(
|
||||
get_pending_subcontracted_quantity(
|
||||
order_item_doctype,
|
||||
order_name,
|
||||
).get(item.get(order_item_field))
|
||||
)
|
||||
/ item.subcontracting_conversion_factor,
|
||||
frappe.get_precision(
|
||||
order_item_doctype,
|
||||
"qty",
|
||||
),
|
||||
)
|
||||
|
||||
if item.qty > pending_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row {0}: Item {1}'s quantity cannot be higher than the available quantity."
|
||||
).format(item.idx, item.item_name)
|
||||
)
|
||||
|
||||
if self.doctype != "Subcontracting Inward Order":
|
||||
item.amount = item.qty * item.rate
|
||||
|
||||
@@ -296,10 +313,10 @@ class SubcontractingController(StockController):
|
||||
):
|
||||
for row in frappe.get_all(
|
||||
f"{self.subcontract_data.order_doctype} Item",
|
||||
fields=["item_code", {"SUB": ["qty", "received_qty"], "as": "qty"}, "parent", "name"],
|
||||
fields=["item_code", {"SUB": ["qty", "received_qty"], "as": "qty"}, "parent", "bom"],
|
||||
filters={"docstatus": 1, "parent": ("in", self.subcontract_orders)},
|
||||
):
|
||||
self.qty_to_be_received[(row.item_code, row.parent)] += row.qty
|
||||
self.qty_to_be_received[(row.item_code, row.parent, row.bom)] += row.qty
|
||||
|
||||
def __get_transferred_items(self):
|
||||
se = frappe.qb.DocType("Stock Entry")
|
||||
@@ -610,7 +627,9 @@ class SubcontractingController(StockController):
|
||||
and self.doctype != "Subcontracting Inward Order"
|
||||
):
|
||||
row.reserve_warehouse = self.set_reserve_warehouse or item.warehouse
|
||||
elif frappe.get_cached_value("Item", row.rm_item_code, "is_customer_provided_item"):
|
||||
elif frappe.get_cached_value("Item", row.rm_item_code, "is_customer_provided_item") and self.get(
|
||||
"customer_warehouse"
|
||||
):
|
||||
row.warehouse = self.customer_warehouse
|
||||
|
||||
def __set_alternative_item(self, bom_item):
|
||||
@@ -904,13 +923,17 @@ class SubcontractingController(StockController):
|
||||
self.__set_serial_nos(item_row, rm_obj)
|
||||
|
||||
def __get_qty_based_on_material_transfer(self, item_row, transfer_item):
|
||||
key = (item_row.item_code, item_row.get(self.subcontract_data.order_field))
|
||||
key = (
|
||||
item_row.item_code,
|
||||
item_row.get(self.subcontract_data.order_field),
|
||||
item_row.get("bom"),
|
||||
)
|
||||
|
||||
if self.qty_to_be_received == item_row.qty:
|
||||
return transfer_item.qty
|
||||
|
||||
if self.qty_to_be_received:
|
||||
qty = (flt(item_row.qty) * flt(transfer_item.qty)) / flt(self.qty_to_be_received.get(key, 0))
|
||||
if self.qty_to_be_received.get(key):
|
||||
qty = (flt(item_row.qty) * flt(transfer_item.qty)) / flt(self.qty_to_be_received.get(key))
|
||||
transfer_item.item_details.required_qty = transfer_item.qty
|
||||
|
||||
if transfer_item.serial_no or frappe.get_cached_value(
|
||||
@@ -959,7 +982,11 @@ class SubcontractingController(StockController):
|
||||
|
||||
if self.qty_to_be_received:
|
||||
self.qty_to_be_received[
|
||||
(row.item_code, row.get(self.subcontract_data.order_field))
|
||||
(
|
||||
row.item_code,
|
||||
row.get(self.subcontract_data.order_field),
|
||||
row.get("bom"),
|
||||
)
|
||||
] -= row.qty
|
||||
|
||||
def __set_rate_for_serial_and_batch_bundle(self):
|
||||
@@ -1331,9 +1358,7 @@ def get_item_details(items):
|
||||
|
||||
|
||||
def get_pending_subcontracted_quantity(doctype, name):
|
||||
table = frappe.qb.DocType(
|
||||
"Purchase Order Item" if doctype == "Subcontracting Order" else "Sales Order Item"
|
||||
)
|
||||
table = frappe.qb.DocType(doctype)
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(table.name, table.stock_qty, table.subcontracted_qty)
|
||||
|
||||
@@ -720,6 +720,7 @@ class SubcontractingInwardController:
|
||||
item.db_set("scio_detail", scio_rm.name)
|
||||
|
||||
if data:
|
||||
precision = self.precision("customer_provided_item_cost", "items")
|
||||
result = frappe.get_all(
|
||||
"Subcontracting Inward Order Received Item",
|
||||
filters={
|
||||
@@ -734,10 +735,17 @@ class SubcontractingInwardController:
|
||||
table = frappe.qb.DocType("Subcontracting Inward Order Received Item")
|
||||
case_expr_qty, case_expr_rate = Case(), Case()
|
||||
for d in result:
|
||||
d.received_qty += (
|
||||
data[d.name].transfer_qty if self._action == "submit" else -data[d.name].transfer_qty
|
||||
current_qty = flt(data[d.name].transfer_qty) * (1 if self._action == "submit" else -1)
|
||||
current_rate = flt(data[d.name].rate)
|
||||
|
||||
# Calculate weighted average rate
|
||||
old_total = d.rate * d.received_qty
|
||||
current_total = current_rate * current_qty
|
||||
|
||||
d.received_qty = d.received_qty + current_qty
|
||||
d.rate = (
|
||||
flt((old_total + current_total) / d.received_qty, precision) if d.received_qty else 0.0
|
||||
)
|
||||
d.rate += data[d.name].rate if self._action == "submit" else -data[d.name].rate
|
||||
|
||||
if not d.required_qty and not d.received_qty:
|
||||
deleted_docs.append(d.name)
|
||||
|
||||
@@ -39,17 +39,23 @@ class calculate_taxes_and_totals:
|
||||
items = list(filter(lambda item: not item.get("is_alternative"), self.doc.get("items")))
|
||||
return items
|
||||
|
||||
def calculate(self):
|
||||
def calculate(self, ignore_tax_template_validation=False):
|
||||
if not len(self.doc.items):
|
||||
return
|
||||
|
||||
self.discount_amount_applied = False
|
||||
self.need_recomputation = False
|
||||
self.ignore_tax_template_validation = ignore_tax_template_validation
|
||||
|
||||
self._calculate()
|
||||
|
||||
if self.doc.meta.get_field("discount_amount"):
|
||||
self.set_discount_amount()
|
||||
self.apply_discount_amount()
|
||||
|
||||
if not ignore_tax_template_validation and self.need_recomputation:
|
||||
return self.calculate(ignore_tax_template_validation=True)
|
||||
|
||||
# Update grand total as per cash and non trade discount
|
||||
if self.doc.apply_discount_on == "Grand Total" and self.doc.get("is_cash_or_non_trade_discount"):
|
||||
self.doc.grand_total -= self.doc.discount_amount
|
||||
@@ -79,6 +85,9 @@ class calculate_taxes_and_totals:
|
||||
self.calculate_total_net_weight()
|
||||
|
||||
def validate_item_tax_template(self):
|
||||
if self.ignore_tax_template_validation:
|
||||
return
|
||||
|
||||
if self.doc.get("is_return") and self.doc.get("return_against"):
|
||||
return
|
||||
|
||||
@@ -122,6 +131,10 @@ class calculate_taxes_and_totals:
|
||||
)
|
||||
)
|
||||
|
||||
# For correct tax_amount calculation re-computation is required
|
||||
if self.discount_amount_applied and self.doc.apply_discount_on == "Grand Total":
|
||||
self.need_recomputation = True
|
||||
|
||||
def update_item_tax_map(self):
|
||||
for item in self.doc.items:
|
||||
item.item_tax_rate = get_item_tax_map(
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.query_builder.functions import Sum
|
||||
@@ -2480,3 +2478,21 @@ class TestAccountsController(IntegrationTestCase):
|
||||
self.assertRaises(frappe.ValidationError, po.save)
|
||||
po.items[0].delivered_by_supplier = 1
|
||||
po.save()
|
||||
|
||||
@IntegrationTestCase.change_settings("Global Defaults", {"use_posting_datetime_for_naming_documents": 1})
|
||||
def test_document_naming_rule_based_on_posting_date(self):
|
||||
frappe.new_doc(
|
||||
"Document Naming Rule", document_type="Sales Invoice", prefix="SI-.MM.-.YYYY.-"
|
||||
).submit()
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.set_posting_time = 1
|
||||
si.posting_date = "2025-12-31"
|
||||
si.save()
|
||||
self.assertEqual(si.name, "SI-12-2025-00001")
|
||||
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.set_posting_time = 1
|
||||
si.posting_date = "2026-01-01"
|
||||
si.save()
|
||||
self.assertEqual(si.name, "SI-01-2026-00002")
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2025-11-17 20:55:11.854086",
|
||||
"creation": "2026-01-27 17:02:43.440221",
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "accounting",
|
||||
"icon_type": "Link",
|
||||
"icon_type": "Folder",
|
||||
"idx": 1,
|
||||
"label": "Accounting",
|
||||
"link_to": "Accounting",
|
||||
"link_to": "",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-01 20:07:01.203651",
|
||||
"modified": "2026-01-27 17:04:04.351402",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Accounting",
|
||||
"owner": "Administrator",
|
||||
"parent_icon": "Accounts",
|
||||
"parent_icon": "",
|
||||
"restrict_removal": 0,
|
||||
"roles": [],
|
||||
"standard": 1
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2025-11-12 13:07:51.988728",
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon_type": "Folder",
|
||||
"idx": 1,
|
||||
"label": "Accounts",
|
||||
"link_type": "DocType",
|
||||
"logo_url": "",
|
||||
"modified": "2025-11-17 17:39:36.915358",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Accounts",
|
||||
"owner": "Administrator",
|
||||
"roles": [],
|
||||
"standard": 1
|
||||
}
|
||||
21
erpnext/desktop_icon/accounts_setup.json
Normal file
21
erpnext/desktop_icon/accounts_setup.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"bg_color": "blue",
|
||||
"creation": "2026-01-27 17:37:55.824821",
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon_type": "Link",
|
||||
"idx": 3,
|
||||
"label": "Accounts Setup",
|
||||
"link_to": "Accounts Setup",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-27 18:34:57.092350",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Accounts Setup",
|
||||
"owner": "Administrator",
|
||||
"parent_icon": "Accounting",
|
||||
"restrict_removal": 0,
|
||||
"roles": [],
|
||||
"standard": 1
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
"modified_by": "Administrator",
|
||||
"name": "Banking",
|
||||
"owner": "Administrator",
|
||||
"parent_icon": "Accounts",
|
||||
"parent_icon": "Accounting",
|
||||
"restrict_removal": 0,
|
||||
"roles": [],
|
||||
"standard": 1
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2025-11-10 16:54:04.780644",
|
||||
"creation": "2026-01-23 11:00:23.272751",
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "expenses",
|
||||
"icon_type": "Link",
|
||||
"idx": 4,
|
||||
"idx": 6,
|
||||
"label": "Budget",
|
||||
"link_to": "Budget",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-01 20:07:01.449176",
|
||||
"modified": "2026-01-23 14:39:30.839274",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Budget",
|
||||
"owner": "Administrator",
|
||||
"parent_icon": "Accounts",
|
||||
"parent_icon": "Accounting",
|
||||
"restrict_removal": 0,
|
||||
"roles": [],
|
||||
"standard": 1
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2025-11-17 20:55:11.772622",
|
||||
"creation": "2026-01-23 11:00:23.250819",
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "file",
|
||||
"icon_type": "Link",
|
||||
"idx": 0,
|
||||
"idx": 2,
|
||||
"label": "Financial Reports",
|
||||
"link_to": "Financial Reports",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-01 20:07:01.253367",
|
||||
"modified": "2026-01-23 14:38:46.479759",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Financial Reports",
|
||||
"owner": "Administrator",
|
||||
"parent_icon": "Accounts",
|
||||
"parent_icon": "Accounting",
|
||||
"restrict_removal": 0,
|
||||
"roles": [],
|
||||
"sidebar": "",
|
||||
|
||||
21
erpnext/desktop_icon/invoicing.json
Normal file
21
erpnext/desktop_icon/invoicing.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2026-01-23 10:51:05.799725",
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "accounting",
|
||||
"icon_type": "Link",
|
||||
"idx": 0,
|
||||
"label": "Invoicing",
|
||||
"link_to": "Invoicing",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-23 15:17:23.564795",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Invoicing",
|
||||
"owner": "Administrator",
|
||||
"parent_icon": "Accounting",
|
||||
"restrict_removal": 0,
|
||||
"roles": [],
|
||||
"standard": 1
|
||||
}
|
||||
22
erpnext/desktop_icon/payments.json
Normal file
22
erpnext/desktop_icon/payments.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"bg_color": "blue",
|
||||
"creation": "2026-01-27 17:37:55.866525",
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "receipt-text",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Payments",
|
||||
"link_to": "Payments",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-27 18:31:59.617181",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Payments",
|
||||
"owner": "Administrator",
|
||||
"parent_icon": "Accounting",
|
||||
"restrict_removal": 0,
|
||||
"roles": [],
|
||||
"standard": 1
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2026-01-12 12:31:53.444807",
|
||||
"creation": "2026-01-23 11:00:23.303554",
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon_type": "Link",
|
||||
"idx": 8,
|
||||
"idx": 7,
|
||||
"label": "Share Management",
|
||||
"link_to": "Share Management",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-12 12:31:53.444807",
|
||||
"modified": "2026-01-23 14:39:34.128991",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Share Management",
|
||||
"owner": "Administrator",
|
||||
"parent_icon": "Accounts",
|
||||
"parent_icon": "Accounting",
|
||||
"restrict_removal": 0,
|
||||
"roles": [],
|
||||
"standard": 1
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2025-11-10 16:14:25.976756",
|
||||
"creation": "2026-01-23 11:00:23.344237",
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "monitor-check",
|
||||
"icon_type": "Link",
|
||||
"idx": 99,
|
||||
"idx": 8,
|
||||
"label": "Subscription",
|
||||
"link_to": "Subscription",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-01 20:07:01.548581",
|
||||
"modified": "2026-01-23 14:39:37.830722",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Subscription",
|
||||
"owner": "Administrator",
|
||||
"parent_icon": "Accounts",
|
||||
"parent_icon": "Accounting",
|
||||
"restrict_removal": 0,
|
||||
"roles": [],
|
||||
"standard": 1
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"creation": "2025-11-12 15:05:54.474218",
|
||||
"creation": "2026-01-23 11:00:23.262357",
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "book-text",
|
||||
"icon_type": "Link",
|
||||
"idx": 3,
|
||||
"idx": 4,
|
||||
"label": "Taxes",
|
||||
"link_to": "Taxes",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-01 20:07:01.356333",
|
||||
"modified": "2026-01-23 14:39:25.636166",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Taxes",
|
||||
"owner": "Administrator",
|
||||
"parent_icon": "Accounts",
|
||||
"parent_icon": "Accounting",
|
||||
"restrict_removal": 0,
|
||||
"roles": [],
|
||||
"sidebar": "",
|
||||
|
||||
@@ -8,7 +8,7 @@ app_email = "hello@frappe.io"
|
||||
app_license = "GNU General Public License (v3)"
|
||||
source_link = "https://github.com/frappe/erpnext"
|
||||
app_logo_url = "/assets/erpnext/images/erpnext-logo.svg"
|
||||
app_home = "/app/home"
|
||||
app_home = "/desk"
|
||||
|
||||
add_to_apps_screen = [
|
||||
{
|
||||
@@ -402,9 +402,10 @@ doc_events = {
|
||||
}
|
||||
|
||||
# function should expect the variable and doc as arguments
|
||||
naming_series_variables_list = ["FY", "TFY", "ABBR", "MM", "DD", "YY", "YYYY", "JJJ", "WW"]
|
||||
naming_series_variables = {
|
||||
"FY": "erpnext.accounts.utils.parse_naming_series_variable",
|
||||
"ABBR": "erpnext.accounts.utils.parse_naming_series_variable",
|
||||
variable: "erpnext.accounts.utils.parse_naming_series_variable"
|
||||
for variable in naming_series_variables_list
|
||||
}
|
||||
|
||||
# On cancel event Payment Entry will be exempted and all linked submittable doctype will get cancelled.
|
||||
@@ -569,6 +570,7 @@ accounting_dimension_doctypes = [
|
||||
"Payment Request",
|
||||
"Asset Movement Item",
|
||||
"Asset Depreciation Schedule",
|
||||
"Advance Taxes and Charges",
|
||||
]
|
||||
|
||||
get_matching_queries = (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,10 @@ frappe.ui.form.on("BOM", {
|
||||
};
|
||||
});
|
||||
|
||||
frm.events.set_company_filters(frm, "project");
|
||||
frm.events.set_company_filters(frm, "default_source_warehouse");
|
||||
frm.events.set_company_filters(frm, "default_target_warehouse");
|
||||
|
||||
frm.trigger("toggle_fields_for_semi_finished_goods");
|
||||
},
|
||||
|
||||
@@ -104,6 +108,16 @@ frappe.ui.form.on("BOM", {
|
||||
}
|
||||
},
|
||||
|
||||
set_company_filters: function (frm, fieldname) {
|
||||
frm.set_query(fieldname, () => {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
track_semi_finished_goods(frm) {
|
||||
frm.trigger("toggle_fields_for_semi_finished_goods");
|
||||
},
|
||||
@@ -683,8 +697,6 @@ var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) {
|
||||
do_not_explode: d.do_not_explode,
|
||||
},
|
||||
callback: function (r) {
|
||||
d = locals[cdt][cdn];
|
||||
|
||||
$.extend(d, r.message);
|
||||
refresh_field("items");
|
||||
refresh_field("scrap_items");
|
||||
|
||||
@@ -1547,6 +1547,9 @@ def add_operating_cost_component_wise(
|
||||
if job_card and job_card.operation_id != row.name:
|
||||
continue
|
||||
|
||||
if not row.actual_operation_time:
|
||||
continue
|
||||
|
||||
workstation_cost = frappe.get_all(
|
||||
"Workstation Cost",
|
||||
fields=["operating_component", "operating_cost"],
|
||||
@@ -1609,7 +1612,7 @@ def add_operations_cost(stock_entry, work_order=None, expense_account=None, job_
|
||||
job_card=job_card,
|
||||
)
|
||||
|
||||
if not cost_added:
|
||||
if not cost_added and not job_card:
|
||||
stock_entry.append(
|
||||
"additional_costs",
|
||||
{
|
||||
|
||||
@@ -747,19 +747,21 @@ class ProductionPlan(Document):
|
||||
"project": self.project,
|
||||
}
|
||||
|
||||
key = (d.item_code, d.sales_order, d.sales_order_item, d.warehouse)
|
||||
key = (d.item_code, d.sales_order, d.sales_order_item, d.warehouse, d.planned_start_date)
|
||||
if self.combine_items:
|
||||
key = (d.item_code, d.sales_order, d.warehouse)
|
||||
key = (d.item_code, d.sales_order, d.warehouse, d.planned_start_date)
|
||||
|
||||
if not d.sales_order:
|
||||
key = (d.name, d.item_code, d.warehouse)
|
||||
key = (d.name, d.item_code, d.warehouse, d.planned_start_date)
|
||||
|
||||
if not item_details["project"] and d.sales_order:
|
||||
item_details["project"] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
|
||||
|
||||
if self.get_items_from == "Material Request":
|
||||
item_details.update({"qty": d.planned_qty})
|
||||
item_dict[(d.item_code, d.material_request_item, d.warehouse)] = item_details
|
||||
item_dict[
|
||||
(d.item_code, d.material_request_item, d.warehouse, d.planned_start_date)
|
||||
] = item_details
|
||||
else:
|
||||
item_details.update(
|
||||
{
|
||||
|
||||
@@ -999,7 +999,7 @@ class TestProductionPlan(IntegrationTestCase):
|
||||
items_data = pln.get_production_items()
|
||||
|
||||
# Update qty
|
||||
items_data[(pln.po_items[0].name, item, None)]["qty"] = qty
|
||||
items_data[(pln.po_items[0].name, item, None, pln.po_items[0].planned_start_date)]["qty"] = qty
|
||||
|
||||
# Create and Submit Work Order for each item in items_data
|
||||
for _key, item in items_data.items():
|
||||
|
||||
@@ -3725,6 +3725,53 @@ class TestWorkOrder(IntegrationTestCase):
|
||||
wo = make_wo_order_test_record(item="Top Level Parent")
|
||||
self.assertEqual([item.item_code for item in wo.required_items], expected)
|
||||
|
||||
def test_reserved_qty_for_pp_with_extra_material_transfer(self):
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
rm_item_code = make_item(
|
||||
"_Test Reserved Qty PP Item",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
},
|
||||
).name
|
||||
|
||||
fg_item_code = make_item(
|
||||
"_Test Reserved Qty PP FG Item",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
},
|
||||
).name
|
||||
|
||||
make_stock_entry_test_record(
|
||||
item_code=rm_item_code, target="_Test Warehouse - _TC", qty=10, basic_rate=100
|
||||
)
|
||||
|
||||
make_bom(
|
||||
item=fg_item_code,
|
||||
raw_materials=[rm_item_code],
|
||||
)
|
||||
|
||||
wo_order = make_wo_order_test_record(
|
||||
item=fg_item_code,
|
||||
qty=1,
|
||||
source_warehouse="_Test Warehouse - _TC",
|
||||
skip_transfer=0,
|
||||
target_warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
|
||||
bin1_at_completion = get_bin(rm_item_code, "_Test Warehouse - _TC")
|
||||
self.assertEqual(bin1_at_completion.reserved_qty_for_production, 1)
|
||||
|
||||
s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 1))
|
||||
s.items[0].qty += 2 # extra material transfer
|
||||
s.submit()
|
||||
|
||||
bin1_at_completion = get_bin(rm_item_code, "_Test Warehouse - _TC")
|
||||
|
||||
self.assertEqual(bin1_at_completion.reserved_qty_for_production, 0)
|
||||
|
||||
|
||||
def get_reserved_entries(voucher_no, warehouse=None):
|
||||
doctype = frappe.qb.DocType("Stock Reservation Entry")
|
||||
|
||||
@@ -710,7 +710,7 @@ erpnext.work_order = {
|
||||
set_custom_buttons: function (frm) {
|
||||
var doc = frm.doc;
|
||||
|
||||
if (doc.docstatus === 1 && doc.status !== "Closed") {
|
||||
if (doc.docstatus === 1 && !["Closed", "Completed"].includes(doc.status)) {
|
||||
frm.add_custom_button(
|
||||
__("Close"),
|
||||
function () {
|
||||
@@ -720,9 +720,6 @@ erpnext.work_order = {
|
||||
},
|
||||
__("Status")
|
||||
);
|
||||
}
|
||||
|
||||
if (doc.docstatus === 1 && !["Closed", "Completed"].includes(doc.status)) {
|
||||
if (doc.status != "Stopped" && doc.status != "Completed") {
|
||||
frm.add_custom_button(
|
||||
__("Stop"),
|
||||
@@ -829,7 +826,7 @@ erpnext.work_order = {
|
||||
}
|
||||
}
|
||||
if (counter > 0) {
|
||||
var consumption_btn = frm.add_custom_button(
|
||||
frm.add_custom_button(
|
||||
__("Material Consumption"),
|
||||
function () {
|
||||
const backflush_raw_materials_based_on =
|
||||
|
||||
@@ -502,8 +502,8 @@ class WorkOrder(Document):
|
||||
def validate_work_order_against_so(self):
|
||||
# already ordered qty
|
||||
ordered_qty_against_so = frappe.db.sql(
|
||||
"""select sum(qty) from `tabWork Order`
|
||||
where production_item = %s and sales_order = %s and docstatus < 2 and status != 'Closed' and name != %s""",
|
||||
"""select sum(qty - process_loss_qty) from `tabWork Order`
|
||||
where production_item = %s and sales_order = %s and docstatus = 1 and status != 'Closed' and name != %s""",
|
||||
(self.production_item, self.sales_order, self.name),
|
||||
)[0][0]
|
||||
|
||||
@@ -512,13 +512,13 @@ class WorkOrder(Document):
|
||||
# get qty from Sales Order Item table
|
||||
so_item_qty = frappe.db.sql(
|
||||
"""select sum(stock_qty) from `tabSales Order Item`
|
||||
where parent = %s and item_code = %s""",
|
||||
where parent = %s and item_code = %s and docstatus = 1""",
|
||||
(self.sales_order, self.production_item),
|
||||
)[0][0]
|
||||
# get qty from Packing Item table
|
||||
dnpi_qty = frappe.db.sql(
|
||||
"""select sum(qty) from `tabPacked Item`
|
||||
where parent = %s and parenttype = 'Sales Order' and item_code = %s""",
|
||||
where parent = %s and parenttype = 'Sales Order' and item_code = %s and docstatus = 1""",
|
||||
(self.sales_order, self.production_item),
|
||||
)[0][0]
|
||||
# total qty in SO
|
||||
@@ -530,8 +530,10 @@ class WorkOrder(Document):
|
||||
|
||||
if total_qty > so_qty + (allowance_percentage / 100 * so_qty):
|
||||
frappe.throw(
|
||||
_("Cannot produce more Item {0} than Sales Order quantity {1}").format(
|
||||
self.production_item, so_qty
|
||||
_("Cannot produce more Item {0} than Sales Order quantity {1} {2}").format(
|
||||
get_link_to_form("Item", self.production_item),
|
||||
frappe.bold(so_qty),
|
||||
frappe.bold(frappe.get_value("Item", self.production_item, "stock_uom")),
|
||||
),
|
||||
OverProductionError,
|
||||
)
|
||||
@@ -768,6 +770,7 @@ class WorkOrder(Document):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
self.on_close_or_cancel()
|
||||
self.delete_job_card()
|
||||
|
||||
def on_close_or_cancel(self):
|
||||
if self.production_plan and frappe.db.exists(
|
||||
@@ -777,7 +780,6 @@ class WorkOrder(Document):
|
||||
else:
|
||||
self.update_work_order_qty_in_so()
|
||||
|
||||
self.delete_job_card()
|
||||
self.update_completed_qty_in_material_request()
|
||||
self.update_planned_qty()
|
||||
self.update_ordered_qty()
|
||||
@@ -2652,6 +2654,9 @@ def get_reserved_qty_for_production(
|
||||
qty_field = wo_item.required_qty
|
||||
else:
|
||||
qty_field = Case()
|
||||
qty_field = qty_field.when(
|
||||
((wo.skip_transfer == 0) & (wo_item.transferred_qty > wo_item.required_qty)), 0.0
|
||||
)
|
||||
qty_field = qty_field.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty)
|
||||
qty_field = qty_field.else_(wo_item.required_qty - wo_item.consumed_qty)
|
||||
|
||||
|
||||
@@ -454,7 +454,6 @@ class MaterialRequirementsPlanningReport:
|
||||
row[field] = rm_details.get(field)
|
||||
|
||||
self.update_required_qty(row)
|
||||
row.release_date = add_days(row.delivery_date, row.lead_time * -1)
|
||||
if i != 0:
|
||||
data.append(frappe._dict({}))
|
||||
|
||||
@@ -463,7 +462,15 @@ class MaterialRequirementsPlanningReport:
|
||||
if rm_details.raw_materials:
|
||||
row.capacity = get_item_capacity(row.item_code, self.filters.bucket_size)
|
||||
row.type_of_material = "Manufacture"
|
||||
if row.lead_time and row.required_qty:
|
||||
row.lead_time = math.ceil(row.required_qty / row.lead_time)
|
||||
elif not row.required_qty:
|
||||
row.lead_time = 0
|
||||
|
||||
if not row.lead_time and rm_details.raw_materials:
|
||||
row.lead_time = self.get_lead_time_from_raw_materials(rm_details.raw_materials)
|
||||
|
||||
row.release_date = add_days(row.delivery_date, row.lead_time * -1)
|
||||
data.append(row)
|
||||
if rm_details.raw_materials:
|
||||
self.update_rm_details(
|
||||
@@ -472,6 +479,15 @@ class MaterialRequirementsPlanningReport:
|
||||
|
||||
return data
|
||||
|
||||
def get_lead_time_from_raw_materials(self, raw_materials):
|
||||
lead_time = 0
|
||||
for material in raw_materials:
|
||||
lead_time += math.ceil(material.lead_time)
|
||||
if material.raw_materials:
|
||||
lead_time += self.get_lead_time_from_raw_materials(material.raw_materials)
|
||||
|
||||
return lead_time
|
||||
|
||||
def add_non_planned_so(self, row):
|
||||
if so_details := self._so_details.get((row.item_code, row.delivery_date)):
|
||||
row.adhoc_qty = so_details.qty
|
||||
@@ -1199,8 +1215,10 @@ def get_item_lead_time(item_code, type_of_material):
|
||||
if type_of_material == "Manufacture":
|
||||
query = query.select(
|
||||
Case()
|
||||
.when(doctype.manufacturing_time_in_mins.isnull(), 0)
|
||||
.else_(doctype.manufacturing_time_in_mins / 1440 + doctype.buffer_time)
|
||||
.when(
|
||||
(doctype.manufacturing_time_in_mins.isnull() | (doctype.manufacturing_time_in_mins <= 0)), 0
|
||||
)
|
||||
.else_(1440 / doctype.manufacturing_time_in_mins + doctype.buffer_time)
|
||||
.as_("lead_time")
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -458,3 +458,7 @@ erpnext.patches.v16_0.update_corrected_cancelled_status
|
||||
erpnext.patches.v16_0.fix_barcode_typo
|
||||
erpnext.patches.v16_0.set_post_change_gl_entries_on_pos_settings
|
||||
execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing")
|
||||
erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges
|
||||
erpnext.patches.v16_0.set_ordered_qty_in_quotation_item
|
||||
erpnext.patches.v16_0.migrate_transaction_deletion_task_flags_to_status # 2
|
||||
erpnext.patches.v16_0.update_company_custom_field_in_bin
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
create_accounting_dimensions_for_doctype,
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
create_accounting_dimensions_for_doctype(doctype="Advance Taxes and Charges")
|
||||
@@ -0,0 +1,42 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
"""
|
||||
Migrate Transaction Deletion Record boolean task flags to status Select fields.
|
||||
Renames fields from old names to new names with _status suffix.
|
||||
Maps: 0 -> "Pending", 1 -> "Completed"
|
||||
"""
|
||||
if not frappe.db.table_exists("tabTransaction Deletion Record"):
|
||||
return
|
||||
|
||||
# Field mapping: old boolean field name -> new status field name
|
||||
field_mapping = {
|
||||
"delete_bin_data": "delete_bin_data_status",
|
||||
"delete_leads_and_addresses": "delete_leads_and_addresses_status",
|
||||
"reset_company_default_values": "reset_company_default_values_status",
|
||||
"clear_notifications": "clear_notifications_status",
|
||||
"initialize_doctypes_table": "initialize_doctypes_table_status",
|
||||
"delete_transactions": "delete_transactions_status",
|
||||
}
|
||||
|
||||
# Get all Transaction Deletion Records
|
||||
records = frappe.db.get_all("Transaction Deletion Record", pluck="name")
|
||||
|
||||
for name in records or []:
|
||||
updates = {}
|
||||
|
||||
for old_field, new_field in field_mapping.items():
|
||||
# Read from old boolean field
|
||||
current_value = frappe.db.get_value("Transaction Deletion Record", name, old_field)
|
||||
|
||||
# Map to new status and write to new field name
|
||||
if current_value in (1, "1", True):
|
||||
updates[new_field] = "Completed"
|
||||
else:
|
||||
# Handle 0, "0", False, None, empty string
|
||||
updates[new_field] = "Pending"
|
||||
|
||||
# Update all fields at once
|
||||
if updates:
|
||||
frappe.db.set_value("Transaction Deletion Record", name, updates, update_modified=False)
|
||||
16
erpnext/patches/v16_0/set_ordered_qty_in_quotation_item.py
Normal file
16
erpnext/patches/v16_0/set_ordered_qty_in_quotation_item.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
data = frappe.get_all(
|
||||
"Sales Order Item",
|
||||
filters={"quotation_item": ["is", "set"], "docstatus": 1},
|
||||
fields=["quotation_item", {"SUM": "stock_qty", "as": "ordered_qty"}],
|
||||
group_by="quotation_item",
|
||||
)
|
||||
if data:
|
||||
frappe.db.auto_commit_on_many_writes = 1
|
||||
frappe.db.bulk_update(
|
||||
"Quotation Item", {d.quotation_item: {"ordered_qty": d.ordered_qty} for d in data}
|
||||
)
|
||||
frappe.db.auto_commit_on_many_writes = 0
|
||||
14
erpnext/patches/v16_0/update_company_custom_field_in_bin.py
Normal file
14
erpnext/patches/v16_0/update_company_custom_field_in_bin.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
frappe.reload_doc("stock", "doctype", "bin")
|
||||
|
||||
frappe.db.sql(
|
||||
"""
|
||||
UPDATE `tabBin` b
|
||||
INNER JOIN `tabWarehouse` w ON b.warehouse = w.name
|
||||
SET b.company = w.company
|
||||
WHERE b.company IS NULL OR b.company = ''
|
||||
"""
|
||||
)
|
||||
@@ -308,6 +308,8 @@ class Project(Document):
|
||||
self.gross_margin = flt(self.total_billed_amount) - expense_amount
|
||||
if self.total_billed_amount:
|
||||
self.per_gross_margin = (self.gross_margin / flt(self.total_billed_amount)) * 100
|
||||
else:
|
||||
self.per_gross_margin = 0
|
||||
|
||||
def update_purchase_costing(self):
|
||||
total_purchase_cost = calculate_total_purchase_cost(self.name)
|
||||
@@ -603,7 +605,7 @@ def send_project_update_email_to_users(project):
|
||||
"sent": 0,
|
||||
"date": today(),
|
||||
"time": nowtime(),
|
||||
"naming_series": "UPDATE-.project.-.YY.MM.DD.-",
|
||||
"naming_series": "UPDATE-.project.-.YY.MM.DD.-.####",
|
||||
}
|
||||
).insert()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.utils import add_to_date, now_datetime, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.projects.doctype.task.test_task import create_task
|
||||
from erpnext.projects.doctype.timesheet.timesheet import OverlapError, make_sales_invoice
|
||||
@@ -272,6 +273,60 @@ class TestTimesheet(ERPNextTestSuite):
|
||||
ts.calculate_percentage_billed()
|
||||
self.assertEqual(ts.per_billed, 100)
|
||||
|
||||
def test_partial_billing_and_return(self):
|
||||
"""
|
||||
Test Timesheet status transitions during partial billing, full billing,
|
||||
sales return, and return cancellation.
|
||||
|
||||
Scenario:
|
||||
1. Create a Timesheet with two billable time logs.
|
||||
2. Create a Sales Invoice billing only one time log → Timesheet becomes Partially Billed.
|
||||
3. Create another Sales Invoice billing the remaining time log → Timesheet becomes Billed.
|
||||
4. Create a Sales Return against the second invoice → Timesheet reverts to Partially Billed.
|
||||
5. Cancel the Sales Return → Timesheet returns to Billed status.
|
||||
|
||||
This test ensures Timesheet status is recalculated correctly
|
||||
across billing and return lifecycle events.
|
||||
"""
|
||||
emp = make_employee("test_employee_6@salary.com")
|
||||
|
||||
timesheet = make_timesheet(emp, simulate=True, is_billable=1, do_not_submit=True)
|
||||
timesheet_detail = timesheet.append("time_logs", {})
|
||||
timesheet_detail.is_billable = 1
|
||||
timesheet_detail.activity_type = "_Test Activity Type"
|
||||
timesheet_detail.from_time = timesheet.time_logs[0].to_time + datetime.timedelta(minutes=1)
|
||||
timesheet_detail.hours = 2
|
||||
timesheet_detail.to_time = timesheet_detail.from_time + datetime.timedelta(
|
||||
hours=timesheet_detail.hours
|
||||
)
|
||||
timesheet.save().submit()
|
||||
|
||||
sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
|
||||
sales_invoice.due_date = nowdate()
|
||||
sales_invoice.timesheets.pop()
|
||||
sales_invoice.submit()
|
||||
|
||||
timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
|
||||
self.assertEqual(timesheet_status, "Partially Billed")
|
||||
|
||||
sales_invoice2 = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
|
||||
sales_invoice2.due_date = nowdate()
|
||||
sales_invoice2.submit()
|
||||
|
||||
timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
|
||||
self.assertEqual(timesheet_status, "Billed")
|
||||
|
||||
sales_return = make_sales_return(sales_invoice2.name).submit()
|
||||
timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
|
||||
self.assertEqual(timesheet_status, "Partially Billed")
|
||||
|
||||
sales_return.load_from_db()
|
||||
sales_return.cancel()
|
||||
|
||||
timesheet.load_from_db()
|
||||
self.assertEqual(timesheet.time_logs[1].sales_invoice, sales_invoice2.name)
|
||||
self.assertEqual(timesheet.status, "Billed")
|
||||
|
||||
|
||||
def make_timesheet(
|
||||
employee,
|
||||
@@ -283,6 +338,7 @@ def make_timesheet(
|
||||
company=None,
|
||||
currency=None,
|
||||
exchange_rate=None,
|
||||
do_not_submit=False,
|
||||
):
|
||||
update_activity_type(activity_type)
|
||||
timesheet = frappe.new_doc("Timesheet")
|
||||
@@ -311,7 +367,8 @@ def make_timesheet(
|
||||
else:
|
||||
timesheet.save(ignore_permissions=True)
|
||||
|
||||
timesheet.submit()
|
||||
if not do_not_submit:
|
||||
timesheet.submit()
|
||||
|
||||
return timesheet
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
"in_standard_filter": 1,
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "Draft\nSubmitted\nBilled\nPayslip\nCompleted\nCancelled",
|
||||
"options": "Draft\nSubmitted\nPartially Billed\nBilled\nPayslip\nCompleted\nCancelled",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -310,7 +310,7 @@
|
||||
"idx": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:53.551907",
|
||||
"modified": "2025-12-19 13:48:23.453636",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"name": "Timesheet",
|
||||
@@ -386,8 +386,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": [],
|
||||
"title_field": "title"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,9 @@ class Timesheet(Document):
|
||||
per_billed: DF.Percent
|
||||
sales_invoice: DF.Link | None
|
||||
start_date: DF.Date | None
|
||||
status: DF.Literal["Draft", "Submitted", "Billed", "Payslip", "Completed", "Cancelled"]
|
||||
status: DF.Literal[
|
||||
"Draft", "Submitted", "Partially Billed", "Billed", "Payslip", "Completed", "Cancelled"
|
||||
]
|
||||
time_logs: DF.Table[TimesheetDetail]
|
||||
title: DF.Data | None
|
||||
total_billable_amount: DF.Currency
|
||||
@@ -128,6 +130,9 @@ class Timesheet(Document):
|
||||
if flt(self.per_billed, self.precision("per_billed")) >= 100.0:
|
||||
self.status = "Billed"
|
||||
|
||||
if 0.0 < flt(self.per_billed, self.precision("per_billed")) < 100.0:
|
||||
self.status = "Partially Billed"
|
||||
|
||||
if self.sales_invoice:
|
||||
self.status = "Completed"
|
||||
|
||||
@@ -433,7 +438,7 @@ def make_sales_invoice(source_name, item_code=None, customer=None, currency=None
|
||||
target.append("items", {"item_code": item_code, "qty": hours, "rate": billing_rate})
|
||||
|
||||
for time_log in timesheet.time_logs:
|
||||
if time_log.is_billable:
|
||||
if time_log.is_billable and not time_log.sales_invoice:
|
||||
target.append(
|
||||
"timesheets",
|
||||
{
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
frappe.listview_settings["Timesheet"] = {
|
||||
add_fields: ["status", "total_hours", "start_date", "end_date"],
|
||||
get_indicator: function (doc) {
|
||||
if (doc.status == "Partially Billed") {
|
||||
return [__("Partially Billed"), "orange", "status,=," + "Partially Billed"];
|
||||
}
|
||||
|
||||
if (doc.status == "Billed") {
|
||||
return [__("Billed"), "green", "status,=," + "Billed"];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user