mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-28 21:38:41 +00:00
Compare commits
276 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2626ed55f | ||
|
|
0cc77274cb | ||
|
|
5b7e6eb831 | ||
|
|
1fb9c5244c | ||
|
|
e68eece3da | ||
|
|
b8063a07fc | ||
|
|
8b42fcf274 | ||
|
|
0063201818 | ||
|
|
2f9643d44d | ||
|
|
0505684d22 | ||
|
|
0b958136be | ||
|
|
652bd396d4 | ||
|
|
904ac62830 | ||
|
|
2fee39017c | ||
|
|
0b0dccd294 | ||
|
|
99df61a0d8 | ||
|
|
7767659b87 | ||
|
|
84d5b52483 | ||
|
|
eee6d7e566 | ||
|
|
d690a0c6bd | ||
|
|
8f01d12b5e | ||
|
|
44d40795df | ||
|
|
841b507502 | ||
|
|
20f81516cf | ||
|
|
229dc23f97 | ||
|
|
f9b1df3572 | ||
|
|
1063a56251 | ||
|
|
75eb5ad584 | ||
|
|
43c507570b | ||
|
|
df049cd277 | ||
|
|
b8ddc2f2b9 | ||
|
|
b87b445802 | ||
|
|
e9ce0a41e6 | ||
|
|
583c7b9819 | ||
|
|
ef15c0581d | ||
|
|
835ae27b38 | ||
|
|
849b2e6ebf | ||
|
|
44f2e9480d | ||
|
|
55ee1dcd04 | ||
|
|
c81c1ea869 | ||
|
|
831ddcd5af | ||
|
|
bcf59e7171 | ||
|
|
ee812687e6 | ||
|
|
14085de332 | ||
|
|
22652f30db | ||
|
|
7794f3033e | ||
|
|
21805bde1f | ||
|
|
93bfd62725 | ||
|
|
def62cf3fe | ||
|
|
ea3fcc214b | ||
|
|
1146c9550a | ||
|
|
c5edeae97e | ||
|
|
f855cc89c9 | ||
|
|
1ffbc399e1 | ||
|
|
13eab9f993 | ||
|
|
97684d3dae | ||
|
|
7a227e048e | ||
|
|
af0116cdc5 | ||
|
|
a71d32e668 | ||
|
|
e33abeef7f | ||
|
|
cb0a548a95 | ||
|
|
b57db06100 | ||
|
|
05d6cf5c9a | ||
|
|
67183ad90c | ||
|
|
7f72189665 | ||
|
|
3c327d5225 | ||
|
|
62d58702a0 | ||
|
|
1d36cb55cd | ||
|
|
3fbfad1b9b | ||
|
|
2597eaad51 | ||
|
|
39aaefc202 | ||
|
|
75344e9e82 | ||
|
|
22774fdf87 | ||
|
|
e159c79766 | ||
|
|
94fe32f189 | ||
|
|
d39072a689 | ||
|
|
efdb004f0b | ||
|
|
f4a1f04566 | ||
|
|
01b8ae3e11 | ||
|
|
b80e10e15e | ||
|
|
25852879f6 | ||
|
|
d16061f1bc | ||
|
|
b35a6c2e73 | ||
|
|
5884b71b0a | ||
|
|
bcd56abb62 | ||
|
|
db70d2e4df | ||
|
|
098cbcde10 | ||
|
|
1eda22c2bd | ||
|
|
fffd3a785c | ||
|
|
88c16c8378 | ||
|
|
40c2b3c0f6 | ||
|
|
46f751e403 | ||
|
|
a21b82b238 | ||
|
|
f7536f645b | ||
|
|
ddf6eab013 | ||
|
|
3e4c331962 | ||
|
|
4b1c1d33b0 | ||
|
|
14088ee7ac | ||
|
|
6151a496e7 | ||
|
|
237915dc03 | ||
|
|
d09207ab82 | ||
|
|
d9cd09b24a | ||
|
|
0a28fb3ae1 | ||
|
|
d50c727f89 | ||
|
|
40c8201302 | ||
|
|
979c594e98 | ||
|
|
7b9f2626f8 | ||
|
|
64956ab59c | ||
|
|
78635ebe99 | ||
|
|
d6afb9b10a | ||
|
|
468ca2bde1 | ||
|
|
1d14ba1639 | ||
|
|
a270c02bb4 | ||
|
|
28aa21bf83 | ||
|
|
119195c6fa | ||
|
|
32c0532dec | ||
|
|
46e784d094 | ||
|
|
c0ce34e12c | ||
|
|
eaf5494502 | ||
|
|
04d74ad6eb | ||
|
|
2de04b8a46 | ||
|
|
974755b224 | ||
|
|
5e767ea595 | ||
|
|
f5bd85b4dc | ||
|
|
3fcf6cfef7 | ||
|
|
461bc1733f | ||
|
|
812ca37055 | ||
|
|
540a8540d6 | ||
|
|
d96590c4d9 | ||
|
|
90e4f9026d | ||
|
|
defa1d4a76 | ||
|
|
ff11429941 | ||
|
|
b3f0e2a00d | ||
|
|
7707a79d44 | ||
|
|
94900cb8b8 | ||
|
|
c1be262357 | ||
|
|
65d8a176a6 | ||
|
|
f8ab56ecc9 | ||
|
|
488ea7f994 | ||
|
|
6ea3d56972 | ||
|
|
e2c8dc5386 | ||
|
|
9c243e8dd0 | ||
|
|
4f39dfd642 | ||
|
|
af86fd3cb4 | ||
|
|
5a3bc27e2c | ||
|
|
562f93e75c | ||
|
|
e0f1e757f3 | ||
|
|
572d8530b6 | ||
|
|
0fa8cc76f5 | ||
|
|
9e10dec903 | ||
|
|
7b64f88734 | ||
|
|
7acd435835 | ||
|
|
16fe458b92 | ||
|
|
4c2dba98da | ||
|
|
e17b5dfe61 | ||
|
|
c09c5999dc | ||
|
|
a7bf55b4bf | ||
|
|
c912df95cb | ||
|
|
915315ef1b | ||
|
|
526dc68c72 | ||
|
|
9771ed4c57 | ||
|
|
57815a07ac | ||
|
|
b9c8e8d478 | ||
|
|
526ffc1176 | ||
|
|
239728e4d9 | ||
|
|
48d211f8a0 | ||
|
|
761caba8e8 | ||
|
|
3bc9190795 | ||
|
|
3720a8d5c9 | ||
|
|
03f09222cf | ||
|
|
f232024fa4 | ||
|
|
fd336e8d4b | ||
|
|
c384564314 | ||
|
|
ca6872c768 | ||
|
|
a5d1afe304 | ||
|
|
a85aeb2f9b | ||
|
|
158e290580 | ||
|
|
0e770c0bbd | ||
|
|
d262a65b00 | ||
|
|
ab9d960aa8 | ||
|
|
eec8cf8a71 | ||
|
|
284ccd1def | ||
|
|
3aafed0659 | ||
|
|
30fe711c44 | ||
|
|
ac6c06daf9 | ||
|
|
b63b5320f2 | ||
|
|
433dec8a6c | ||
|
|
81244a84e7 | ||
|
|
7a7c4a03f0 | ||
|
|
a6cf31edad | ||
|
|
d6693c9b79 | ||
|
|
56ffd52335 | ||
|
|
53e3bfbf22 | ||
|
|
db9dc86694 | ||
|
|
1c444ef822 | ||
|
|
f702a71126 | ||
|
|
908e185cfe | ||
|
|
2a70203cab | ||
|
|
85c4cc3e1b | ||
|
|
62280c285f | ||
|
|
5737d2afa3 | ||
|
|
0e00ab8865 | ||
|
|
05d614eb04 | ||
|
|
6d476604df | ||
|
|
0d527ac8ea | ||
|
|
0612f1c941 | ||
|
|
dbed426725 | ||
|
|
8497d1f8cf | ||
|
|
034d460ae1 | ||
|
|
fd94cd0e7c | ||
|
|
db251c6e11 | ||
|
|
b037dae529 | ||
|
|
b73d9700d0 | ||
|
|
73d347f456 | ||
|
|
3e7d2c6f11 | ||
|
|
4ebc23752e | ||
|
|
ef6fd7dcb5 | ||
|
|
e3e9d7b19e | ||
|
|
a4aaf67b2b | ||
|
|
d425e90ef7 | ||
|
|
79b04826d9 | ||
|
|
8c7100df04 | ||
|
|
1ffd814f92 | ||
|
|
c6e7cf13b5 | ||
|
|
55a0603356 | ||
|
|
abe433cfa7 | ||
|
|
49648b5c6e | ||
|
|
b59dc173b8 | ||
|
|
7454db2b3e | ||
|
|
fcfadf9dea | ||
|
|
098f6fd0d2 | ||
|
|
fd8fac7d40 | ||
|
|
b7fd9aea6a | ||
|
|
c5796fed4a | ||
|
|
2b25059315 | ||
|
|
dfbb3e97a8 | ||
|
|
ee22347d64 | ||
|
|
4d418d40db | ||
|
|
a6dd07802a | ||
|
|
94972da845 | ||
|
|
c2e67599f5 | ||
|
|
bcc542b1f9 | ||
|
|
ed428ceb1c | ||
|
|
93ebec90ef | ||
|
|
0b1746a4c8 | ||
|
|
c71557f432 | ||
|
|
c142a2be9c | ||
|
|
3c77653508 | ||
|
|
c572a019b4 | ||
|
|
260d87a80c | ||
|
|
e0f5ae2d4c | ||
|
|
37e750e877 | ||
|
|
3148816451 | ||
|
|
5764f5ec80 | ||
|
|
e9ae156323 | ||
|
|
54fdce648e | ||
|
|
180e232eb0 | ||
|
|
ba4a99b22c | ||
|
|
9100428f1b | ||
|
|
1950e82d1e | ||
|
|
6898d70382 | ||
|
|
624d1d4759 | ||
|
|
bf6b5b7b7f | ||
|
|
24d9e2c5a9 | ||
|
|
7b2e4832aa | ||
|
|
a7e8f31f56 | ||
|
|
d5a250a254 | ||
|
|
be598108b6 | ||
|
|
68bac20198 | ||
|
|
072ab8d5f3 | ||
|
|
717c5b25eb | ||
|
|
e5282a48ae | ||
|
|
516ad9021b | ||
|
|
86c628521e | ||
|
|
6ad84d66cc | ||
|
|
e5eb5406da |
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.100.2"
|
||||
__version__ = "15.104.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
"description": "Payment Terms from orders will be fetched into the invoices as is",
|
||||
"fieldname": "automatically_fetch_payment_terms",
|
||||
"fieldtype": "Check",
|
||||
"label": "Automatically Fetch Payment Terms from Order"
|
||||
"label": "Automatically Fetch Payment Terms from Order/Quotation"
|
||||
},
|
||||
{
|
||||
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
|
||||
@@ -307,7 +307,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Learn about <a href=\"https://docs.frappe.io/erpnext/user/manual/en/common_party_accounting\">Common Party</a>",
|
||||
"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>",
|
||||
"fieldname": "enable_common_party_accounting",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Common Party Accounting"
|
||||
@@ -671,7 +671,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-12-26 19:46:55.093717",
|
||||
"modified": "2026-03-06 14:49:11.467716",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
@@ -701,4 +701,4 @@
|
||||
"sort_order": "ASC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,7 @@ def get_default_company_bank_account(company, party_type, party):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_bank_account_details(bank_account):
|
||||
frappe.has_permission("Bank Account", doc=bank_account, ptype="read", throw=True)
|
||||
return frappe.get_cached_value(
|
||||
"Bank Account", bank_account, ["account", "bank", "bank_account_no"], as_dict=1
|
||||
)
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
from frappe.utils import flt, fmt_money, get_link_to_form, getdate
|
||||
from pypika import Order
|
||||
|
||||
@@ -136,65 +138,162 @@ def get_payment_entries_for_bank_clearance(
|
||||
):
|
||||
entries = []
|
||||
|
||||
condition = ""
|
||||
pe_condition = ""
|
||||
journal_entry = frappe.qb.DocType("Journal Entry")
|
||||
journal_entry_account = frappe.qb.DocType("Journal Entry Account")
|
||||
|
||||
journal_entry_query = (
|
||||
frappe.qb.from_(journal_entry_account)
|
||||
.inner_join(journal_entry)
|
||||
.on(journal_entry_account.parent == journal_entry.name)
|
||||
.select(
|
||||
ConstantColumn("Journal Entry").as_("payment_document"),
|
||||
journal_entry.name.as_("payment_entry"),
|
||||
journal_entry.cheque_no.as_("cheque_number"),
|
||||
journal_entry.cheque_date,
|
||||
Sum(journal_entry_account.debit_in_account_currency).as_("debit"),
|
||||
Sum(journal_entry_account.credit_in_account_currency).as_("credit"),
|
||||
journal_entry.posting_date,
|
||||
journal_entry_account.against_account,
|
||||
journal_entry.clearance_date,
|
||||
journal_entry_account.account_currency,
|
||||
)
|
||||
.where(
|
||||
(journal_entry_account.account == account)
|
||||
& (journal_entry.docstatus == 1)
|
||||
& (journal_entry.posting_date >= from_date)
|
||||
& (journal_entry.posting_date <= to_date)
|
||||
& (journal_entry.is_opening == "No")
|
||||
)
|
||||
)
|
||||
|
||||
if not include_reconciled_entries:
|
||||
condition = "and (clearance_date IS NULL or clearance_date='0000-00-00')"
|
||||
pe_condition = "and (pe.clearance_date IS NULL or pe.clearance_date='0000-00-00')"
|
||||
journal_entry_query = journal_entry_query.where(
|
||||
(journal_entry.clearance_date.isnull()) | (journal_entry.clearance_date == "0000-00-00")
|
||||
)
|
||||
|
||||
journal_entries = frappe.db.sql(
|
||||
f"""
|
||||
select
|
||||
"Journal Entry" as payment_document, t1.name as payment_entry,
|
||||
t1.cheque_no as cheque_number, t1.cheque_date,
|
||||
sum(t2.debit_in_account_currency) as debit, sum(t2.credit_in_account_currency) as credit,
|
||||
t1.posting_date, t2.against_account, t1.clearance_date, t2.account_currency
|
||||
from
|
||||
`tabJournal Entry` t1, `tabJournal Entry Account` t2
|
||||
where
|
||||
t2.parent = t1.name and t2.account = %(account)s and t1.docstatus=1
|
||||
and t1.posting_date >= %(from)s and t1.posting_date <= %(to)s
|
||||
and ifnull(t1.is_opening, 'No') = 'No' {condition}
|
||||
group by t2.account, t1.name
|
||||
order by t1.posting_date ASC, t1.name DESC
|
||||
""",
|
||||
{"account": account, "from": from_date, "to": to_date},
|
||||
as_dict=1,
|
||||
journal_entries = (
|
||||
journal_entry_query.groupby(journal_entry_account.account, journal_entry.name)
|
||||
.orderby(journal_entry.posting_date)
|
||||
.orderby(journal_entry.name, order=Order.desc)
|
||||
).run(as_dict=True)
|
||||
|
||||
pe = frappe.qb.DocType("Payment Entry")
|
||||
company = frappe.qb.DocType("Company")
|
||||
payment_entry_query = (
|
||||
frappe.qb.from_(pe)
|
||||
.join(company)
|
||||
.on(pe.company == company.name)
|
||||
.select(
|
||||
ConstantColumn("Payment Entry").as_("payment_document"),
|
||||
pe.name.as_("payment_entry"),
|
||||
pe.reference_no.as_("cheque_number"),
|
||||
pe.reference_date.as_("cheque_date"),
|
||||
(
|
||||
Case()
|
||||
.when(
|
||||
pe.paid_from == account,
|
||||
(
|
||||
pe.paid_amount
|
||||
+ (
|
||||
Case()
|
||||
.when(
|
||||
(pe.payment_type == "Pay")
|
||||
& (company.default_currency == pe.paid_from_account_currency),
|
||||
pe.base_total_taxes_and_charges,
|
||||
)
|
||||
.else_(pe.total_taxes_and_charges)
|
||||
)
|
||||
),
|
||||
)
|
||||
.else_(0)
|
||||
).as_("credit"),
|
||||
(
|
||||
Case()
|
||||
.when(pe.paid_from == account, 0)
|
||||
.else_(
|
||||
pe.received_amount
|
||||
+ (
|
||||
Case()
|
||||
.when(
|
||||
company.default_currency == pe.paid_to_account_currency,
|
||||
pe.base_total_taxes_and_charges,
|
||||
)
|
||||
.else_(pe.total_taxes_and_charges)
|
||||
)
|
||||
)
|
||||
).as_("debit"),
|
||||
pe.posting_date,
|
||||
Coalesce(pe.party, Case().when(pe.paid_from == account, pe.paid_to).else_(pe.paid_from)).as_(
|
||||
"against_account"
|
||||
),
|
||||
pe.clearance_date,
|
||||
(
|
||||
Case()
|
||||
.when(pe.paid_to == account, pe.paid_to_account_currency)
|
||||
.else_(pe.paid_from_account_currency)
|
||||
).as_("account_currency"),
|
||||
)
|
||||
.where(
|
||||
((pe.paid_from == account) | (pe.paid_to == account))
|
||||
& (pe.docstatus == 1)
|
||||
& (pe.posting_date >= from_date)
|
||||
& (pe.posting_date <= to_date)
|
||||
)
|
||||
)
|
||||
|
||||
payment_entries = frappe.db.sql(
|
||||
f"""
|
||||
select
|
||||
"Payment Entry" as payment_document, pe.name as payment_entry,
|
||||
pe.reference_no as cheque_number, pe.reference_date as cheque_date,
|
||||
if(pe.paid_from=%(account)s, pe.paid_amount + if(pe.payment_type = 'Pay' and c.default_currency = pe.paid_from_account_currency, pe.base_total_taxes_and_charges, pe.total_taxes_and_charges) , 0) as credit,
|
||||
if(pe.paid_from=%(account)s, 0, pe.received_amount + pe.total_taxes_and_charges) as debit,
|
||||
pe.posting_date, ifnull(pe.party,if(pe.paid_from=%(account)s,pe.paid_to,pe.paid_from)) as against_account, pe.clearance_date,
|
||||
if(pe.paid_to=%(account)s, pe.paid_to_account_currency, pe.paid_from_account_currency) as account_currency
|
||||
from `tabPayment Entry` as pe
|
||||
join `tabCompany` c on c.name = pe.company
|
||||
where
|
||||
(pe.paid_from=%(account)s or pe.paid_to=%(account)s) and pe.docstatus=1
|
||||
and pe.posting_date >= %(from)s and pe.posting_date <= %(to)s
|
||||
{pe_condition}
|
||||
order by
|
||||
pe.posting_date ASC, pe.name DESC
|
||||
""",
|
||||
{
|
||||
"account": account,
|
||||
"from": from_date,
|
||||
"to": to_date,
|
||||
},
|
||||
as_dict=1,
|
||||
if not include_reconciled_entries:
|
||||
payment_entry_query = payment_entry_query.where(
|
||||
(pe.clearance_date.isnull()) | (pe.clearance_date == "0000-00-00")
|
||||
)
|
||||
|
||||
payment_entries = (payment_entry_query.orderby(pe.posting_date).orderby(pe.name, order=Order.desc)).run(
|
||||
as_dict=True
|
||||
)
|
||||
|
||||
pos_sales_invoices, pos_purchase_invoices = [], []
|
||||
acc = frappe.qb.DocType("Account")
|
||||
|
||||
pi = frappe.qb.DocType("Purchase Invoice")
|
||||
|
||||
paid_purchase_invoices_query = (
|
||||
frappe.qb.from_(pi)
|
||||
.inner_join(acc)
|
||||
.on(pi.cash_bank_account == acc.name)
|
||||
.select(
|
||||
ConstantColumn("Purchase Invoice").as_("payment_document"),
|
||||
pi.name.as_("payment_entry"),
|
||||
pi.paid_amount.as_("credit"),
|
||||
pi.posting_date,
|
||||
pi.supplier.as_("against_account"),
|
||||
pi.bill_no.as_("cheque_number"),
|
||||
pi.clearance_date,
|
||||
acc.account_currency,
|
||||
ConstantColumn(0).as_("debit"),
|
||||
)
|
||||
.where(
|
||||
(pi.docstatus == 1)
|
||||
& (pi.is_paid == 1)
|
||||
& (pi.cash_bank_account == account)
|
||||
& (pi.posting_date >= from_date)
|
||||
& (pi.posting_date <= to_date)
|
||||
)
|
||||
)
|
||||
|
||||
if not include_reconciled_entries:
|
||||
paid_purchase_invoices_query = paid_purchase_invoices_query.where(
|
||||
(pi.clearance_date.isnull()) | (pi.clearance_date == "0000-00-00")
|
||||
)
|
||||
|
||||
paid_purchase_invoices = (
|
||||
paid_purchase_invoices_query.orderby(pi.posting_date).orderby(pi.name, order=Order.desc)
|
||||
).run(as_dict=True)
|
||||
|
||||
pos_sales_invoices = []
|
||||
|
||||
if include_pos_transactions:
|
||||
si_payment = frappe.qb.DocType("Sales Invoice Payment")
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
acc = frappe.qb.DocType("Account")
|
||||
|
||||
pos_sales_invoices = (
|
||||
pos_sales_invoices_query = (
|
||||
frappe.qb.from_(si_payment)
|
||||
.inner_join(si)
|
||||
.on(si_payment.parent == si.name)
|
||||
@@ -217,38 +316,22 @@ def get_payment_entries_for_bank_clearance(
|
||||
& (si.posting_date >= from_date)
|
||||
& (si.posting_date <= to_date)
|
||||
)
|
||||
.orderby(si.posting_date)
|
||||
.orderby(si.name, order=Order.desc)
|
||||
).run(as_dict=True)
|
||||
)
|
||||
|
||||
pi = frappe.qb.DocType("Purchase Invoice")
|
||||
if not include_reconciled_entries:
|
||||
pos_sales_invoices_query = pos_sales_invoices_query.where(
|
||||
(si_payment.clearance_date.isnull()) | (si_payment.clearance_date == "0000-00-00")
|
||||
)
|
||||
|
||||
pos_purchase_invoices = (
|
||||
frappe.qb.from_(pi)
|
||||
.inner_join(acc)
|
||||
.on(pi.cash_bank_account == acc.name)
|
||||
.select(
|
||||
ConstantColumn("Purchase Invoice").as_("payment_document"),
|
||||
pi.name.as_("payment_entry"),
|
||||
pi.paid_amount.as_("credit"),
|
||||
pi.posting_date,
|
||||
pi.supplier.as_("against_account"),
|
||||
pi.clearance_date,
|
||||
acc.account_currency,
|
||||
ConstantColumn(0).as_("debit"),
|
||||
)
|
||||
.where(
|
||||
(pi.docstatus == 1)
|
||||
& (pi.cash_bank_account == account)
|
||||
& (pi.posting_date >= from_date)
|
||||
& (pi.posting_date <= to_date)
|
||||
)
|
||||
.orderby(pi.posting_date)
|
||||
.orderby(pi.name, order=Order.desc)
|
||||
pos_sales_invoices = (
|
||||
pos_sales_invoices_query.orderby(si.posting_date).orderby(si.name, order=Order.desc)
|
||||
).run(as_dict=True)
|
||||
|
||||
entries = (
|
||||
list(payment_entries) + list(journal_entries) + list(pos_sales_invoices) + list(pos_purchase_invoices)
|
||||
list(payment_entries)
|
||||
+ list(journal_entries)
|
||||
+ list(pos_sales_invoices)
|
||||
+ list(paid_purchase_invoices)
|
||||
)
|
||||
|
||||
return entries
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("Bank Statement Import", {
|
||||
onload(frm) {
|
||||
frm.set_query("bank_account", function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
setup(frm) {
|
||||
frappe.realtime.on("data_import_refresh", ({ data_import }) => {
|
||||
frm.import_in_progress = false;
|
||||
|
||||
@@ -398,7 +398,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_group": "All Customer Groups",
|
||||
"customer_group": "Individual",
|
||||
"customer_type": "Company",
|
||||
"customer_name": "Poore Simon's",
|
||||
}
|
||||
@@ -429,7 +429,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_group": "All Customer Groups",
|
||||
"customer_group": "Individual",
|
||||
"customer_type": "Company",
|
||||
"customer_name": "Fayva",
|
||||
}
|
||||
|
||||
@@ -214,6 +214,8 @@ class JournalEntry(AccountsController):
|
||||
def on_cancel(self):
|
||||
# References for this Journal are removed on the `on_cancel` event in accounts_controller
|
||||
super().on_cancel()
|
||||
|
||||
from_doc_events = getattr(self, "ignore_linked_doctypes", ())
|
||||
self.ignore_linked_doctypes = (
|
||||
"GL Entry",
|
||||
"Stock Ledger Entry",
|
||||
@@ -226,6 +228,10 @@ class JournalEntry(AccountsController):
|
||||
"Unreconcile Payment Entries",
|
||||
"Advance Payment Ledger Entry",
|
||||
)
|
||||
|
||||
if from_doc_events and from_doc_events != self.ignore_linked_doctypes:
|
||||
self.ignore_linked_doctypes = self.ignore_linked_doctypes + from_doc_events
|
||||
|
||||
self.make_gl_entries(1)
|
||||
self.unlink_advance_entry_reference()
|
||||
self.unlink_asset_reference()
|
||||
@@ -263,6 +269,9 @@ class JournalEntry(AccountsController):
|
||||
frappe.throw(_("Journal Entry type should be set as Depreciation Entry for asset depreciation"))
|
||||
|
||||
def validate_stock_accounts(self):
|
||||
if not erpnext.is_perpetual_inventory_enabled(self.company):
|
||||
return
|
||||
|
||||
stock_accounts = get_stock_accounts(self.company, accounts=self.accounts)
|
||||
for account in stock_accounts:
|
||||
account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import frappe
|
||||
from frappe import _, scrub
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import flt, nowdate
|
||||
from frappe.utils import escape_html, flt, nowdate
|
||||
from frappe.utils.background_jobs import enqueue, is_job_enqueued
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
@@ -84,6 +84,11 @@ class OpeningInvoiceCreationTool(Document):
|
||||
)
|
||||
prepare_invoice_summary(doctype, invoices)
|
||||
|
||||
invoices_summary_companies = list(invoices_summary.keys())
|
||||
|
||||
for company in invoices_summary_companies:
|
||||
invoices_summary[escape_html(company)] = invoices_summary.pop(company)
|
||||
|
||||
return invoices_summary, max_count
|
||||
|
||||
def validate_company(self):
|
||||
|
||||
@@ -209,7 +209,7 @@ def make_customer(customer=None):
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_name": customer_name,
|
||||
"customer_group": "All Customer Groups",
|
||||
"customer_group": "Individual",
|
||||
"customer_type": "Company",
|
||||
"territory": "All Territories",
|
||||
}
|
||||
|
||||
@@ -839,7 +839,7 @@ frappe.ui.form.on("Payment Entry", {
|
||||
paid_amount: function (frm) {
|
||||
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||
if (!frm.doc.received_amount) {
|
||||
if (frm.doc.paid_amount) {
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("received_amount", frm.doc.paid_amount);
|
||||
} else if (company_currency == frm.doc.paid_to_account_currency) {
|
||||
@@ -860,7 +860,7 @@ frappe.ui.form.on("Payment Entry", {
|
||||
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
|
||||
);
|
||||
|
||||
if (!frm.doc.paid_amount) {
|
||||
if (frm.doc.received_amount) {
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("paid_amount", frm.doc.received_amount);
|
||||
if (frm.doc.target_exchange_rate) {
|
||||
|
||||
@@ -2556,14 +2556,9 @@ def get_orders_to_be_billed(
|
||||
if not voucher_type:
|
||||
return []
|
||||
|
||||
# Add cost center condition
|
||||
doc = frappe.get_doc({"doctype": voucher_type})
|
||||
condition = ""
|
||||
if doc and hasattr(doc, "cost_center") and doc.cost_center:
|
||||
condition = " and cost_center='%s'" % cost_center
|
||||
|
||||
# dynamic dimension filters
|
||||
active_dimensions = get_dimensions()[0]
|
||||
condition = ""
|
||||
active_dimensions = get_dimensions(True)[0]
|
||||
for dim in active_dimensions:
|
||||
if filters.get(dim.fieldname):
|
||||
condition += f" and {dim.fieldname}='{filters.get(dim.fieldname)}'"
|
||||
|
||||
@@ -2043,6 +2043,7 @@ def create_customer(name="_Test Customer 2 USD", currency="USD"):
|
||||
customer.customer_name = name
|
||||
customer.default_currency = currency
|
||||
customer.type = "Individual"
|
||||
customer.customer_group = "Individual"
|
||||
customer.save()
|
||||
customer = customer.name
|
||||
return customer
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "cost_center",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
@@ -59,7 +60,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-11-05 16:07:47.307971",
|
||||
"modified": "2026-03-11 14:26:11.312950",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Entry Deduction",
|
||||
|
||||
@@ -80,6 +80,7 @@ class TestPaymentLedgerEntry(FrappeTestCase):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = name
|
||||
customer.type = "Individual"
|
||||
customer.customer_group = "Individual"
|
||||
customer.save()
|
||||
self.customer = customer.name
|
||||
|
||||
|
||||
@@ -2546,6 +2546,7 @@ def make_customer(customer_name, currency=None):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = customer_name
|
||||
customer.type = "Individual"
|
||||
customer.customer_group = "Individual"
|
||||
|
||||
if currency:
|
||||
customer.default_currency = currency
|
||||
|
||||
@@ -243,8 +243,10 @@ def get_other_conditions(conditions, values, args):
|
||||
if group_condition:
|
||||
conditions += " and " + group_condition
|
||||
|
||||
date = args.get("transaction_date") or frappe.get_value(
|
||||
args.get("doctype"), args.get("name"), "posting_date", ignore=True
|
||||
date = (
|
||||
args.get("transaction_date")
|
||||
or args.get("posting_date")
|
||||
or frappe.get_value(args.get("doctype"), args.get("name"), "posting_date", ignore=True)
|
||||
)
|
||||
if date:
|
||||
conditions += """ and %(transaction_date)s between ifnull(`tabPricing Rule`.valid_from, '2000-01-01')
|
||||
|
||||
@@ -21,10 +21,12 @@ frappe.ui.form.on("Promotional Scheme", {
|
||||
|
||||
selling: function (frm) {
|
||||
frm.trigger("set_options_for_applicable_for");
|
||||
frm.toggle_enable("buying", !frm.doc.selling);
|
||||
},
|
||||
|
||||
buying: function (frm) {
|
||||
frm.trigger("set_options_for_applicable_for");
|
||||
frm.toggle_enable("selling", !frm.doc.buying);
|
||||
},
|
||||
|
||||
set_options_for_applicable_for: function (frm) {
|
||||
|
||||
@@ -312,7 +312,7 @@
|
||||
"fieldname": "posting_date",
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
"label": "Date",
|
||||
"label": "Posting Date",
|
||||
"oldfieldname": "posting_date",
|
||||
"oldfieldtype": "Date",
|
||||
"print_hide": 1,
|
||||
@@ -382,7 +382,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "bill_no",
|
||||
"collapsible_depends_on": "posting_date",
|
||||
"fieldname": "supplier_invoice_details",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Supplier Invoice"
|
||||
@@ -1660,7 +1660,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-05 20:45:16.964500",
|
||||
"modified": "2026-03-17 20:44:00.221219",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -612,12 +612,13 @@ class PurchaseInvoice(BuyingController):
|
||||
frappe.db.set_value(self.doctype, self.name, "against_expense_account", self.against_expense_account)
|
||||
|
||||
def po_required(self):
|
||||
if frappe.db.get_value("Buying Settings", None, "po_required") == "Yes":
|
||||
if frappe.get_value(
|
||||
if (
|
||||
frappe.db.get_single_value("Buying Settings", "po_required") == "Yes"
|
||||
and not self.is_internal_transfer()
|
||||
and not frappe.db.get_value(
|
||||
"Supplier", self.supplier, "allow_purchase_invoice_creation_without_purchase_order"
|
||||
):
|
||||
return
|
||||
|
||||
)
|
||||
):
|
||||
for d in self.get("items"):
|
||||
if not d.purchase_order:
|
||||
msg = _("Purchase Order Required for item {}").format(frappe.bold(d.item_code))
|
||||
@@ -728,9 +729,10 @@ class PurchaseInvoice(BuyingController):
|
||||
for item in self.get("items"):
|
||||
if item.purchase_receipt:
|
||||
frappe.throw(
|
||||
_("Stock cannot be updated against Purchase Receipt {0}").format(
|
||||
item.purchase_receipt
|
||||
)
|
||||
_(
|
||||
"Stock cannot be updated for Purchase Invoice {0} because a Purchase Receipt {1} has already been created for this transaction. Please disable the 'Update Stock' checkbox in the Purchase Invoice and save the invoice."
|
||||
).format(self.name, item.purchase_receipt),
|
||||
title=_("Stock Update Not Allowed"),
|
||||
)
|
||||
|
||||
def validate_for_repost(self):
|
||||
@@ -976,6 +978,10 @@ class PurchaseInvoice(BuyingController):
|
||||
if provisional_accounting_for_non_stock_items:
|
||||
self.get_provisional_accounts()
|
||||
|
||||
adjust_incoming_rate = frappe.db.get_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
|
||||
)
|
||||
|
||||
for item in self.get("items"):
|
||||
if flt(item.base_net_amount) or (self.get("update_stock") and item.valuation_rate):
|
||||
if item.item_code:
|
||||
@@ -1144,7 +1150,11 @@ class PurchaseInvoice(BuyingController):
|
||||
)
|
||||
|
||||
# check if the exchange rate has changed
|
||||
if item.get("purchase_receipt") and self.auto_accounting_for_stock:
|
||||
if (
|
||||
not adjust_incoming_rate
|
||||
and item.get("purchase_receipt")
|
||||
and self.auto_accounting_for_stock
|
||||
):
|
||||
if (
|
||||
exchange_rate_map[item.purchase_receipt]
|
||||
and self.conversion_rate != exchange_rate_map[item.purchase_receipt]
|
||||
@@ -1181,6 +1191,7 @@ class PurchaseInvoice(BuyingController):
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
self.auto_accounting_for_stock
|
||||
and self.is_opening == "No"
|
||||
|
||||
@@ -356,6 +356,12 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
make_purchase_invoice as create_purchase_invoice,
|
||||
)
|
||||
|
||||
original_value = frappe.db.get_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
|
||||
)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
company="_Test Company with perpetual inventory",
|
||||
warehouse="Stores - TCP1",
|
||||
@@ -376,12 +382,17 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
amount = frappe.db.get_value(
|
||||
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
|
||||
)
|
||||
|
||||
discrepancy_caused_by_exchange_rate_diff = abs(
|
||||
pi.items[0].base_net_amount - pr.items[0].base_net_amount
|
||||
)
|
||||
|
||||
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
|
||||
|
||||
frappe.db.set_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
|
||||
)
|
||||
|
||||
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
make_purchase_invoice as create_purchase_invoice,
|
||||
|
||||
@@ -373,7 +373,7 @@
|
||||
"fieldtype": "Date",
|
||||
"hide_days": 1,
|
||||
"hide_seconds": 1,
|
||||
"label": "Date",
|
||||
"label": "Posting Date",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "posting_date",
|
||||
"oldfieldtype": "Date",
|
||||
@@ -777,8 +777,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,
|
||||
@@ -792,7 +791,6 @@
|
||||
"hide_days": 1,
|
||||
"hide_seconds": 1,
|
||||
"label": "Time Sheets",
|
||||
"no_copy": 1,
|
||||
"options": "Sales Invoice Timesheet",
|
||||
"print_hide": 1
|
||||
},
|
||||
@@ -2112,7 +2110,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"
|
||||
},
|
||||
@@ -2200,7 +2198,7 @@
|
||||
"link_fieldname": "consolidated_invoice"
|
||||
}
|
||||
],
|
||||
"modified": "2026-02-05 20:43:44.732805",
|
||||
"modified": "2026-04-06 22:30:28.513139",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice",
|
||||
|
||||
@@ -323,10 +323,22 @@ class SalesInvoice(SellingController):
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -494,7 +506,7 @@ class SalesInvoice(SellingController):
|
||||
if not 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.db.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction":
|
||||
update_company_current_month_sales(self.company)
|
||||
@@ -550,7 +562,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)
|
||||
@@ -735,8 +747,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
|
||||
|
||||
@@ -776,11 +800,25 @@ 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"""
|
||||
@@ -804,11 +842,9 @@ class SalesInvoice(SellingController):
|
||||
if self.pos_profile:
|
||||
pos = frappe.get_doc("POS Profile", self.pos_profile)
|
||||
|
||||
if not self.get("payments") and not for_validate:
|
||||
update_multi_mode_option(self, pos)
|
||||
|
||||
if pos:
|
||||
if not for_validate:
|
||||
update_multi_mode_option(self, pos)
|
||||
self.tax_category = pos.get("tax_category")
|
||||
|
||||
if not for_validate and not self.customer:
|
||||
@@ -1114,7 +1150,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()
|
||||
@@ -1199,6 +1240,9 @@ class SalesInvoice(SellingController):
|
||||
throw(_("Delivery Note {0} is not submitted").format(d.delivery_note))
|
||||
|
||||
def process_asset_depreciation(self):
|
||||
if self.is_internal_transfer():
|
||||
return
|
||||
|
||||
if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1):
|
||||
self.depreciate_asset_on_sale()
|
||||
else:
|
||||
@@ -2515,7 +2559,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
"doctype": target_doctype,
|
||||
"postprocess": update_details,
|
||||
"set_target_warehouse": "set_from_warehouse",
|
||||
"field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address"],
|
||||
"field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address", "cost_center"],
|
||||
},
|
||||
doctype + " Item": item_field_map,
|
||||
},
|
||||
@@ -2744,6 +2788,8 @@ def update_multi_mode_option(doc, pos_profile):
|
||||
payment.account = payment_mode.default_account
|
||||
payment.type = payment_mode.type
|
||||
|
||||
mop_refetched = bool(doc.payments)
|
||||
|
||||
doc.set("payments", [])
|
||||
invalid_modes = []
|
||||
mode_of_payments = [d.mode_of_payment for d in pos_profile.get("payments")]
|
||||
@@ -2765,6 +2811,11 @@ def update_multi_mode_option(doc, pos_profile):
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payments {}")
|
||||
frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account"))
|
||||
|
||||
if mop_refetched:
|
||||
frappe.msgprint(
|
||||
_("Payment methods refreshed. Please review before proceeding."), indicator="orange", alert=True
|
||||
)
|
||||
|
||||
|
||||
def get_all_mode_of_payments(doc):
|
||||
return frappe.db.sql(
|
||||
|
||||
@@ -3230,7 +3230,7 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
calculate_depreciation=1,
|
||||
submit=1,
|
||||
)
|
||||
post_depreciation_entries()
|
||||
post_depreciation_entries(date="2025-04-01")
|
||||
|
||||
si = create_sales_invoice(
|
||||
item_code="Macbook Pro", asset=asset.name, qty=1, rate=10000, posting_date=getdate("2025-05-01")
|
||||
@@ -4835,6 +4835,33 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
|
||||
self.assertEqual(stock_ledger_entry.incoming_rate, 0.0)
|
||||
|
||||
def test_inter_company_transaction_cost_center(self):
|
||||
si = create_sales_invoice(
|
||||
company="Wind Power LLC",
|
||||
customer="_Test Internal Customer",
|
||||
debit_to="Debtors - WP",
|
||||
warehouse="Stores - WP",
|
||||
income_account="Sales - WP",
|
||||
expense_account="Cost of Goods Sold - WP",
|
||||
parent_cost_center="Main - WP",
|
||||
cost_center="Main - WP",
|
||||
currency="USD",
|
||||
do_not_save=1,
|
||||
)
|
||||
|
||||
si.selling_price_list = "_Test Price List Rest of the World"
|
||||
si.submit()
|
||||
|
||||
cost_center = frappe.db.get_value("Company", "_Test Company 1", "cost_center")
|
||||
frappe.db.set_value("Company", "_Test Company 1", "cost_center", None)
|
||||
|
||||
target_doc = make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
self.assertEqual(target_doc.cost_center, None)
|
||||
self.assertEqual(target_doc.items[0].cost_center, None)
|
||||
|
||||
frappe.db.set_value("Company", "_Test Company 1", "cost_center", cost_center)
|
||||
|
||||
|
||||
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,7 +116,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-10-02 03:48:44.979777",
|
||||
"modified": "2026-04-06 22:30:28.513139",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Timesheet",
|
||||
|
||||
@@ -152,7 +152,9 @@ class ShippingRule(Document):
|
||||
frappe.throw(_("Shipping rule only applicable for Buying"))
|
||||
|
||||
shipping_charge["doctype"] = "Purchase Taxes and Charges"
|
||||
shipping_charge["category"] = "Valuation and Total"
|
||||
shipping_charge["category"] = (
|
||||
"Valuation and Total" if doc.get_stock_items() or doc.get_asset_items() else "Total"
|
||||
)
|
||||
shipping_charge["add_deduct_tax"] = "Add"
|
||||
|
||||
existing_shipping_charge = doc.get("taxes", filters=shipping_charge)
|
||||
|
||||
@@ -629,18 +629,21 @@ def create_parties():
|
||||
customer.customer_name = "_Test Subscription Customer"
|
||||
customer.default_currency = "USD"
|
||||
customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"})
|
||||
customer.customer_group = "Individual"
|
||||
customer.insert()
|
||||
|
||||
if not frappe.db.exists("Customer", "_Test Subscription Customer Multi Currency"):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = "Test Subscription Customer Multi Currency"
|
||||
customer.default_currency = "USD"
|
||||
customer.customer_group = "Individual"
|
||||
customer.insert()
|
||||
|
||||
if not frappe.db.exists("Customer", "_Test Subscription Customer John Doe"):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = "_Test Subscription Customer John Doe"
|
||||
customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable - _TC"})
|
||||
customer.customer_group = "Individual"
|
||||
customer.insert()
|
||||
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"align_labels_right": 0,
|
||||
"creation": "2016-05-05 17:16:18.564460",
|
||||
"custom_format": 1,
|
||||
"disabled": 0,
|
||||
"doc_type": "Sales Invoice",
|
||||
"docstatus": 0,
|
||||
"doctype": "Print Format",
|
||||
"font": "Default",
|
||||
"html": "<style>\n\t.print-format table, .print-format tr, \n\t.print-format td, .print-format div, .print-format p {\n\t\tfont-family: Monospace;\n\t\tline-height: 200%;\n\t\tvertical-align: middle;\n\t}\n\t@media screen {\n\t\t.print-format {\n\t\t\twidth: 4in;\n\t\t\tpadding: 0.25in;\n\t\t\tmin-height: 8in;\n\t\t}\n\t}\n</style>\n\n<p class=\"text-center\">\n\t{{ company }}<br>\n\t{{ __(\"POS No : \") }} {{ offline_pos_name }}<br>\n</p>\n<p>\n\t<b>{{ __(\"Customer\") }}:</b> {{ customer }}<br>\n</p>\n\n<p>\n\t<b>{{ __(\"Date\") }}:</b> {{ dateutil.global_date_format(posting_date) }}<br>\n</p>\n\n<hr>\n<table class=\"table table-condensed cart no-border\">\n\t<thead>\n\t\t<tr>\n\t\t\t<th width=\"50%\">{{ __(\"Item\") }}</b></th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ __(\"Qty\") }}</th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ __(\"Amount\") }}</th>\n\t\t</tr>\n\t</thead>\n\t<tbody>\n\t\t{% for item in items %}\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t{{ item.item_name }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">{{ format_number(item.qty, null,precision(\"difference\")) }}<br>@ {{ format_currency(item.rate, currency) }}</td>\n\t\t\t<td class=\"text-right\">{{ format_currency(item.amount, currency) }}</td>\n\t\t</tr>\n\t\t{% endfor %}\n\t</tbody>\n</table>\n\n<table class=\"table table-condensed no-border\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t{{ __(\"Net Total\") }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ format_currency(total, currency) }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{% for row in taxes %}\n\t\t{% if not row.included_in_print_rate %}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t{{ row.description }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ format_currency(row.tax_amount, currency) }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{% endif %}\n\t\t{% endfor %}\n\t\t{% if discount_amount %}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t{{ __(\"Discount\") }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ format_currency(discount_amount, currency) }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{% endif %}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ __(\"Grand Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ format_currency(grand_total, currency) }}\n\t\t\t</td>\n\t\t</tr>\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ __(\"Paid Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ format_currency(paid_amount, currency) }}\n\t\t\t</td>\n\t\t</tr>\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ __(\"Qty Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ qty_total }}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n\n\n<hr>\n<p>{{ terms }}</p>\n<p class=\"text-center\">{{ __(\"Thank you, please visit again.\") }}</p>",
|
||||
"idx": 0,
|
||||
"line_breaks": 0,
|
||||
"modified": "2019-09-05 17:20:30.726659",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Point of Sale",
|
||||
"owner": "Administrator",
|
||||
"print_format_builder": 0,
|
||||
"print_format_type": "JS",
|
||||
"raw_printing": 0,
|
||||
"show_section_headings": 0,
|
||||
"standard": "Yes"
|
||||
}
|
||||
@@ -779,6 +779,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
|
||||
"customer_name": "Jane Doe",
|
||||
"type": "Individual",
|
||||
"default_currency": "USD",
|
||||
"customer_group": "Individual",
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
@@ -1002,6 +1003,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
|
||||
"customer_name": "Jane Doe",
|
||||
"type": "Individual",
|
||||
"default_currency": "USD",
|
||||
"customer_group": "Individual",
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import getdate, nowdate
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.utils import getdate
|
||||
from pypika import Order
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -48,17 +51,6 @@ def get_columns():
|
||||
return columns
|
||||
|
||||
|
||||
def get_conditions(filters):
|
||||
conditions = ""
|
||||
|
||||
if filters.get("from_date"):
|
||||
conditions += " and posting_date>=%(from_date)s"
|
||||
if filters.get("to_date"):
|
||||
conditions += " and posting_date<=%(to_date)s"
|
||||
|
||||
return conditions
|
||||
|
||||
|
||||
def get_entries(filters):
|
||||
entries = []
|
||||
|
||||
@@ -73,41 +65,90 @@ def get_entries(filters):
|
||||
|
||||
return sorted(
|
||||
entries,
|
||||
key=lambda k: k[2].strftime("%H%M%S") or getdate(nowdate()),
|
||||
key=lambda k: getdate(k[2]),
|
||||
)
|
||||
|
||||
|
||||
def get_entries_for_bank_clearance_summary(filters):
|
||||
entries = []
|
||||
|
||||
conditions = get_conditions(filters)
|
||||
je = frappe.qb.DocType("Journal Entry")
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
|
||||
journal_entries = frappe.db.sql(
|
||||
f"""SELECT
|
||||
"Journal Entry", jv.name, jv.posting_date, jv.cheque_no,
|
||||
jv.clearance_date, jvd.against_account, jvd.debit - jvd.credit
|
||||
FROM
|
||||
`tabJournal Entry Account` jvd, `tabJournal Entry` jv
|
||||
WHERE
|
||||
jvd.parent = jv.name and jv.docstatus=1 and jvd.account = %(account)s {conditions}
|
||||
order by posting_date DESC, jv.name DESC""",
|
||||
filters,
|
||||
as_list=1,
|
||||
)
|
||||
journal_entries = (
|
||||
frappe.qb.from_(jea)
|
||||
.inner_join(je)
|
||||
.on(jea.parent == je.name)
|
||||
.select(
|
||||
ConstantColumn("Journal Entry").as_("payment_document"),
|
||||
je.name.as_("payment_entry"),
|
||||
je.posting_date,
|
||||
je.cheque_no,
|
||||
je.clearance_date,
|
||||
jea.against_account,
|
||||
jea.debit_in_account_currency - jea.credit_in_account_currency,
|
||||
)
|
||||
.where(
|
||||
(jea.account == filters.account)
|
||||
& (je.docstatus == 1)
|
||||
& (je.posting_date >= filters.from_date)
|
||||
& (je.posting_date <= filters.to_date)
|
||||
& ((je.is_opening == "No") | (je.is_opening.isnull()))
|
||||
)
|
||||
.orderby(je.posting_date, order=Order.desc)
|
||||
.orderby(je.name, order=Order.desc)
|
||||
).run(as_list=True)
|
||||
|
||||
payment_entries = frappe.db.sql(
|
||||
f"""SELECT
|
||||
"Payment Entry", name, posting_date, reference_no, clearance_date, party,
|
||||
if(paid_from=%(account)s, ((paid_amount * -1) - total_taxes_and_charges) , received_amount)
|
||||
FROM
|
||||
`tabPayment Entry`
|
||||
WHERE
|
||||
docstatus=1 and (paid_from = %(account)s or paid_to = %(account)s) {conditions}
|
||||
order by posting_date DESC, name DESC""",
|
||||
filters,
|
||||
as_list=1,
|
||||
)
|
||||
pe = frappe.qb.DocType("Payment Entry")
|
||||
payment_entries = (
|
||||
frappe.qb.from_(pe)
|
||||
.select(
|
||||
ConstantColumn("Payment Entry").as_("payment_document"),
|
||||
pe.name.as_("payment_entry"),
|
||||
pe.posting_date,
|
||||
pe.reference_no.as_("cheque_no"),
|
||||
pe.clearance_date,
|
||||
pe.party.as_("against_account"),
|
||||
Case()
|
||||
.when(
|
||||
(pe.paid_from == filters.account),
|
||||
((pe.paid_amount * -1) - pe.total_taxes_and_charges),
|
||||
)
|
||||
.else_(pe.received_amount),
|
||||
)
|
||||
.where((pe.paid_from == filters.account) | (pe.paid_to == filters.account))
|
||||
.where(
|
||||
(pe.docstatus == 1)
|
||||
& (pe.posting_date >= filters.from_date)
|
||||
& (pe.posting_date <= filters.to_date)
|
||||
)
|
||||
.orderby(pe.posting_date, order=Order.desc)
|
||||
.orderby(pe.name, order=Order.desc)
|
||||
).run(as_list=True)
|
||||
|
||||
entries = journal_entries + payment_entries
|
||||
pi = frappe.qb.DocType("Purchase Invoice")
|
||||
purchase_invoices = (
|
||||
frappe.qb.from_(pi)
|
||||
.select(
|
||||
ConstantColumn("Purchase Invoice").as_("payment_document"),
|
||||
pi.name.as_("payment_entry"),
|
||||
pi.posting_date,
|
||||
pi.bill_no.as_("cheque_no"),
|
||||
pi.clearance_date,
|
||||
pi.supplier.as_("against_account"),
|
||||
(pi.paid_amount * -1).as_("amount"),
|
||||
)
|
||||
.where(
|
||||
(pi.docstatus == 1)
|
||||
& (pi.is_paid == 1)
|
||||
& (pi.cash_bank_account == filters.account)
|
||||
& (pi.posting_date >= filters.from_date)
|
||||
& (pi.posting_date <= filters.to_date)
|
||||
)
|
||||
.orderby(pi.posting_date, order=Order.desc)
|
||||
.orderby(pi.name, order=Order.desc)
|
||||
).run(as_list=True)
|
||||
|
||||
entries = journal_entries + payment_entries + purchase_invoices
|
||||
|
||||
return entries
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
from frappe.utils import flt, getdate
|
||||
from pypika import Order
|
||||
|
||||
from erpnext.accounts.utils import get_balance_on
|
||||
|
||||
@@ -123,73 +127,143 @@ def get_entries_for_bank_reconciliation_statement(filters):
|
||||
|
||||
payment_entries = get_payment_entries(filters)
|
||||
|
||||
purchase_invoices = get_purchase_invoices(filters)
|
||||
|
||||
pos_entries = []
|
||||
if filters.include_pos_transactions:
|
||||
pos_entries = get_pos_entries(filters)
|
||||
|
||||
return list(journal_entries) + list(payment_entries) + list(pos_entries)
|
||||
return list(journal_entries) + list(payment_entries) + list(pos_entries) + list(purchase_invoices)
|
||||
|
||||
|
||||
def get_journal_entries(filters):
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select "Journal Entry" as payment_document, jv.posting_date,
|
||||
jv.name as payment_entry, jvd.debit_in_account_currency as debit,
|
||||
jvd.credit_in_account_currency as credit, jvd.against_account,
|
||||
jv.cheque_no as reference_no, jv.cheque_date as ref_date, jv.clearance_date, jvd.account_currency
|
||||
from
|
||||
`tabJournal Entry Account` jvd, `tabJournal Entry` jv
|
||||
where jvd.parent = jv.name and jv.docstatus=1
|
||||
and jvd.account = %(account)s and jv.posting_date <= %(report_date)s
|
||||
and ifnull(jv.clearance_date, '4000-01-01') > %(report_date)s
|
||||
and ifnull(jv.is_opening, 'No') = 'No'
|
||||
and jv.company = %(company)s """,
|
||||
filters,
|
||||
as_dict=1,
|
||||
)
|
||||
je = frappe.qb.DocType("Journal Entry")
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
return (
|
||||
frappe.qb.from_(jea)
|
||||
.join(je)
|
||||
.on(jea.parent == je.name)
|
||||
.select(
|
||||
ConstantColumn("Journal Entry").as_("payment_document"),
|
||||
je.name.as_("payment_entry"),
|
||||
je.posting_date,
|
||||
jea.debit_in_account_currency.as_("debit"),
|
||||
jea.credit_in_account_currency.as_("credit"),
|
||||
jea.against_account,
|
||||
je.cheque_no.as_("reference_no"),
|
||||
je.cheque_date.as_("ref_date"),
|
||||
je.clearance_date,
|
||||
jea.account_currency,
|
||||
)
|
||||
.where(
|
||||
(je.docstatus == 1)
|
||||
& (jea.account == filters.account)
|
||||
& (je.posting_date <= filters.report_date)
|
||||
& (je.clearance_date.isnull() | (je.clearance_date > filters.report_date))
|
||||
& (je.company == filters.company)
|
||||
& ((je.is_opening.isnull()) | (je.is_opening == "No"))
|
||||
)
|
||||
.orderby(je.posting_date)
|
||||
.orderby(je.name, order=Order.desc)
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
def get_payment_entries(filters):
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
"Payment Entry" as payment_document, name as payment_entry,
|
||||
reference_no, reference_date as ref_date,
|
||||
if(paid_to=%(account)s, received_amount_after_tax, 0) as debit,
|
||||
if(paid_from=%(account)s, paid_amount_after_tax, 0) as credit,
|
||||
posting_date, ifnull(party,if(paid_from=%(account)s,paid_to,paid_from)) as against_account, clearance_date,
|
||||
if(paid_to=%(account)s, paid_to_account_currency, paid_from_account_currency) as account_currency
|
||||
from `tabPayment Entry`
|
||||
where
|
||||
(paid_from=%(account)s or paid_to=%(account)s) and docstatus=1
|
||||
and posting_date <= %(report_date)s
|
||||
and ifnull(clearance_date, '4000-01-01') > %(report_date)s
|
||||
and company = %(company)s
|
||||
""",
|
||||
filters,
|
||||
as_dict=1,
|
||||
)
|
||||
pe = frappe.qb.DocType("Payment Entry")
|
||||
return (
|
||||
frappe.qb.from_(pe)
|
||||
.select(
|
||||
ConstantColumn("Payment Entry").as_("payment_document"),
|
||||
pe.name.as_("payment_entry"),
|
||||
pe.reference_no.as_("reference_no"),
|
||||
pe.reference_date.as_("ref_date"),
|
||||
Case().when(pe.paid_to == filters.account, pe.received_amount_after_tax).else_(0).as_("debit"),
|
||||
Case().when(pe.paid_from == filters.account, pe.paid_amount_after_tax).else_(0).as_("credit"),
|
||||
pe.posting_date,
|
||||
Coalesce(
|
||||
pe.party, Case().when(pe.paid_from == filters.account, pe.paid_to).else_(pe.paid_from)
|
||||
).as_("against_account"),
|
||||
pe.clearance_date,
|
||||
(
|
||||
Case()
|
||||
.when(pe.paid_to == filters.account, pe.paid_to_account_currency)
|
||||
.else_(pe.paid_from_account_currency)
|
||||
).as_("account_currency"),
|
||||
)
|
||||
.where(
|
||||
(pe.docstatus == 1)
|
||||
& ((pe.paid_from == filters.account) | (pe.paid_to == filters.account))
|
||||
& (pe.posting_date <= filters.report_date)
|
||||
& (pe.clearance_date.isnull() | (pe.clearance_date > filters.report_date))
|
||||
& (pe.company == filters.company)
|
||||
)
|
||||
.orderby(pe.posting_date)
|
||||
.orderby(pe.name, order=Order.desc)
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
def get_purchase_invoices(filters):
|
||||
pi = frappe.qb.DocType("Purchase Invoice")
|
||||
acc = frappe.qb.DocType("Account")
|
||||
return (
|
||||
frappe.qb.from_(pi)
|
||||
.inner_join(acc)
|
||||
.on(pi.cash_bank_account == acc.name)
|
||||
.select(
|
||||
ConstantColumn("Purchase Invoice").as_("payment_document"),
|
||||
pi.name.as_("payment_entry"),
|
||||
pi.bill_no.as_("reference_no"),
|
||||
pi.posting_date.as_("ref_date"),
|
||||
Case().when(pi.paid_amount < 0, pi.paid_amount * -1).else_(0).as_("debit"),
|
||||
Case().when(pi.paid_amount > 0, pi.paid_amount).else_(0).as_("credit"),
|
||||
pi.posting_date,
|
||||
pi.supplier.as_("against_account"),
|
||||
pi.clearance_date,
|
||||
acc.account_currency,
|
||||
)
|
||||
.where(
|
||||
(pi.docstatus == 1)
|
||||
& (pi.is_paid == 1)
|
||||
& (pi.cash_bank_account == filters.account)
|
||||
& (pi.posting_date <= filters.report_date)
|
||||
& (pi.clearance_date.isnull() | (pi.clearance_date > filters.report_date))
|
||||
& (pi.company == filters.company)
|
||||
)
|
||||
.orderby(pi.posting_date)
|
||||
.orderby(pi.name, order=Order.desc)
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
def get_pos_entries(filters):
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
"Sales Invoice Payment" as payment_document, sip.name as payment_entry, sip.amount as debit,
|
||||
si.posting_date, si.debit_to as against_account, sip.clearance_date,
|
||||
account.account_currency, 0 as credit
|
||||
from `tabSales Invoice Payment` sip, `tabSales Invoice` si, `tabAccount` account
|
||||
where
|
||||
sip.account=%(account)s and si.docstatus=1 and sip.parent = si.name
|
||||
and account.name = sip.account and si.posting_date <= %(report_date)s and
|
||||
ifnull(sip.clearance_date, '4000-01-01') > %(report_date)s
|
||||
and si.company = %(company)s
|
||||
order by
|
||||
si.posting_date ASC, si.name DESC
|
||||
""",
|
||||
filters,
|
||||
as_dict=1,
|
||||
)
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
si_payment = frappe.qb.DocType("Sales Invoice Payment")
|
||||
acc = frappe.qb.DocType("Account")
|
||||
return (
|
||||
frappe.qb.from_(si_payment)
|
||||
.join(si)
|
||||
.on(si_payment.parent == si.name)
|
||||
.join(acc)
|
||||
.on(si_payment.account == acc.name)
|
||||
.select(
|
||||
ConstantColumn("Sales Invoice").as_("payment_document"),
|
||||
si.name.as_("payment_entry"),
|
||||
si_payment.amount.as_("debit"),
|
||||
si.posting_date,
|
||||
si.debit_to.as_("against_account"),
|
||||
si_payment.clearance_date,
|
||||
acc.account_currency,
|
||||
ConstantColumn(0).as_("credit"),
|
||||
)
|
||||
.where(
|
||||
(si_payment.account == filters.account)
|
||||
& (si.docstatus == 1)
|
||||
& (si.posting_date <= filters.report_date)
|
||||
& (si_payment.clearance_date.isnull() | (si_payment.clearance_date > filters.report_date))
|
||||
& (si.company == filters.company)
|
||||
)
|
||||
.orderby(si.posting_date)
|
||||
.orderby(si_payment.name, order=Order.desc)
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
def get_amounts_not_reflected_in_system(filters):
|
||||
@@ -205,30 +279,66 @@ def get_amounts_not_reflected_in_system(filters):
|
||||
|
||||
|
||||
def get_amounts_not_reflected_in_system_for_bank_reconciliation_statement(filters):
|
||||
je_amount = frappe.db.sql(
|
||||
"""
|
||||
select sum(jvd.debit_in_account_currency - jvd.credit_in_account_currency)
|
||||
from `tabJournal Entry Account` jvd, `tabJournal Entry` jv
|
||||
where jvd.parent = jv.name and jv.docstatus=1 and jvd.account=%(account)s
|
||||
and jv.posting_date > %(report_date)s and jv.clearance_date <= %(report_date)s
|
||||
and ifnull(jv.is_opening, 'No') = 'No' """,
|
||||
filters,
|
||||
je = frappe.qb.DocType("Journal Entry")
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
|
||||
je_amount = (
|
||||
frappe.qb.from_(jea)
|
||||
.inner_join(je)
|
||||
.on(jea.parent == je.name)
|
||||
.select(
|
||||
Sum(jea.debit_in_account_currency - jea.credit_in_account_currency).as_("amount"),
|
||||
)
|
||||
.where(
|
||||
(je.docstatus == 1)
|
||||
& (jea.account == filters.account)
|
||||
& (je.posting_date > filters.report_date)
|
||||
& (je.clearance_date <= filters.report_date)
|
||||
& (je.company == filters.company)
|
||||
& ((je.is_opening.isnull()) | (je.is_opening == "No"))
|
||||
)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
je_amount = flt(je_amount[0].amount) if je_amount else 0.0
|
||||
|
||||
je_amount = flt(je_amount[0][0]) if je_amount else 0.0
|
||||
|
||||
pe_amount = frappe.db.sql(
|
||||
"""
|
||||
select sum(if(paid_from=%(account)s, paid_amount, received_amount))
|
||||
from `tabPayment Entry`
|
||||
where (paid_from=%(account)s or paid_to=%(account)s) and docstatus=1
|
||||
and posting_date > %(report_date)s and clearance_date <= %(report_date)s""",
|
||||
filters,
|
||||
pe = frappe.qb.DocType("Payment Entry")
|
||||
pe_amount = (
|
||||
frappe.qb.from_(pe)
|
||||
.select(
|
||||
Sum(Case().when(pe.paid_from == filters.account, pe.paid_amount).else_(pe.received_amount)).as_(
|
||||
"amount"
|
||||
),
|
||||
)
|
||||
.where(
|
||||
((pe.paid_from == filters.account) | (pe.paid_to == filters.account))
|
||||
& (pe.docstatus == 1)
|
||||
& (pe.posting_date > filters.report_date)
|
||||
& (pe.clearance_date <= filters.report_date)
|
||||
& (pe.company == filters.company)
|
||||
)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
pe_amount = flt(pe_amount[0].amount) if pe_amount else 0.0
|
||||
|
||||
pe_amount = flt(pe_amount[0][0]) if pe_amount else 0.0
|
||||
pi = frappe.qb.DocType("Purchase Invoice")
|
||||
pi_amount = (
|
||||
frappe.qb.from_(pi)
|
||||
.select(
|
||||
Sum(pi.paid_amount).as_("amount"),
|
||||
)
|
||||
.where(
|
||||
(pi.docstatus == 1)
|
||||
& (pi.is_paid == 1)
|
||||
& (pi.cash_bank_account == filters.account)
|
||||
& (pi.posting_date > filters.report_date)
|
||||
& (pi.clearance_date <= filters.report_date)
|
||||
& (pi.company == filters.company)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
return je_amount + pe_amount
|
||||
pi_amount = flt(pi_amount[0].amount) if pi_amount else 0.0
|
||||
|
||||
return je_amount + pe_amount + pi_amount
|
||||
|
||||
|
||||
def get_balance_row(label, amount, account_currency):
|
||||
|
||||
@@ -8,6 +8,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt, formatdate
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
|
||||
from erpnext.controllers.trends import get_period_date_ranges, get_period_month_ranges
|
||||
|
||||
|
||||
@@ -15,6 +16,8 @@ def execute(filters=None):
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
validate_filters(filters)
|
||||
|
||||
columns = get_columns(filters)
|
||||
if filters.get("budget_against_filter"):
|
||||
dimensions = filters.get("budget_against_filter")
|
||||
@@ -35,6 +38,21 @@ def execute(filters=None):
|
||||
return columns, data, None, chart
|
||||
|
||||
|
||||
def validate_filters(filters):
|
||||
validate_budget_dimensions(filters)
|
||||
|
||||
|
||||
def validate_budget_dimensions(filters):
|
||||
dimensions = [d.get("document_type") for d in get_dimensions(with_cost_center_and_project=True)[0]]
|
||||
if filters.get("budget_against") and filters.get("budget_against") not in dimensions:
|
||||
frappe.throw(
|
||||
title=_("Invalid Accounting Dimension"),
|
||||
msg=_("{0} is not a valid Accounting Dimension.").format(
|
||||
frappe.bold(filters.get("budget_against"))
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_final_data(dimension, dimension_items, filters, period_month_ranges, data, DCC_allocation):
|
||||
for account, monthwise_data in dimension_items.items():
|
||||
row = [dimension, account]
|
||||
|
||||
@@ -48,6 +48,9 @@ class Deferred_Item:
|
||||
Generate report data for output
|
||||
"""
|
||||
ret_data = frappe._dict({"name": self.item_name})
|
||||
ret_data.service_start_date = self.service_start_date
|
||||
ret_data.service_end_date = self.service_end_date
|
||||
ret_data.amount = self.base_net_amount
|
||||
for period in self.period_total:
|
||||
ret_data[period.key] = period.total
|
||||
ret_data.indent = 1
|
||||
@@ -205,6 +208,9 @@ class Deferred_Invoice:
|
||||
for item in self.uniq_items:
|
||||
self.items.append(Deferred_Item(item, self, [x for x in items if x.item == item]))
|
||||
|
||||
# roll-up amount from all deferred items
|
||||
self.amount_total = sum(item.base_net_amount for item in self.items)
|
||||
|
||||
def calculate_invoice_revenue_expense_for_period(self):
|
||||
"""
|
||||
calculate deferred revenue/expense for all items in invoice
|
||||
@@ -232,7 +238,7 @@ class Deferred_Invoice:
|
||||
generate report data for invoice, includes invoice total
|
||||
"""
|
||||
ret_data = []
|
||||
inv_total = frappe._dict({"name": self.name})
|
||||
inv_total = frappe._dict({"name": self.name, "amount": self.amount_total})
|
||||
for x in self.period_total:
|
||||
inv_total[x.key] = x.total
|
||||
inv_total.indent = 0
|
||||
@@ -386,6 +392,24 @@ class Deferred_Revenue_and_Expense_Report:
|
||||
def get_columns(self):
|
||||
columns = []
|
||||
columns.append({"label": _("Name"), "fieldname": "name", "fieldtype": "Data", "read_only": 1})
|
||||
columns.append(
|
||||
{
|
||||
"label": _("Service Start Date"),
|
||||
"fieldname": "service_start_date",
|
||||
"fieldtype": "Date",
|
||||
"read_only": 1,
|
||||
}
|
||||
)
|
||||
columns.append(
|
||||
{
|
||||
"label": _("Service End Date"),
|
||||
"fieldname": "service_end_date",
|
||||
"fieldtype": "Date",
|
||||
"read_only": 1,
|
||||
}
|
||||
)
|
||||
columns.append({"label": _("Amount"), "fieldname": "amount", "fieldtype": "Currency", "read_only": 1})
|
||||
|
||||
for period in self.period_list:
|
||||
columns.append(
|
||||
{
|
||||
@@ -415,6 +439,8 @@ class Deferred_Revenue_and_Expense_Report:
|
||||
elif self.filters.type == "Expense":
|
||||
total_row = frappe._dict({"name": "Total Deferred Expense"})
|
||||
|
||||
total_row["amount"] = sum(inv.amount_total for inv in self.deferred_invoices)
|
||||
|
||||
for idx, period in enumerate(self.period_list, 0):
|
||||
total_row[period.key] = self.period_total[idx].total
|
||||
ret.append(total_row)
|
||||
|
||||
@@ -37,6 +37,20 @@ function get_filters() {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "party_type",
|
||||
label: __("Party Type"),
|
||||
fieldtype: "Link",
|
||||
options: "Party Type",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
fieldname: "party",
|
||||
label: __("Party"),
|
||||
fieldtype: "Dynamic Link",
|
||||
options: "party_type",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
fieldname: "voucher_no",
|
||||
label: __("Voucher No"),
|
||||
|
||||
@@ -68,6 +68,12 @@ class General_Payment_Ledger_Comparison:
|
||||
if self.filters.period_end_date:
|
||||
filter_criterion.append(gle.posting_date.lte(self.filters.period_end_date))
|
||||
|
||||
if self.filters.party_type:
|
||||
filter_criterion.append(gle.party_type.eq(self.filters.party_type))
|
||||
|
||||
if self.filters.party:
|
||||
filter_criterion.append(gle.party.eq(self.filters.party))
|
||||
|
||||
if acc_type == "receivable":
|
||||
outstanding = (Sum(gle.debit) - Sum(gle.credit)).as_("outstanding")
|
||||
else:
|
||||
@@ -111,6 +117,12 @@ class General_Payment_Ledger_Comparison:
|
||||
if self.filters.period_end_date:
|
||||
filter_criterion.append(ple.posting_date.lte(self.filters.period_end_date))
|
||||
|
||||
if self.filters.party_type:
|
||||
filter_criterion.append(ple.party_type.eq(self.filters.party_type))
|
||||
|
||||
if self.filters.party:
|
||||
filter_criterion.append(ple.party.eq(self.filters.party))
|
||||
|
||||
self.account_types[acc_type].ple = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
|
||||
@@ -649,7 +649,7 @@ class GrossProfitGenerator:
|
||||
new_row = row
|
||||
self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion)
|
||||
else:
|
||||
new_row.qty += flt(row.qty)
|
||||
new_row.qty = flt((new_row.qty + row.qty), self.float_precision)
|
||||
self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion, True)
|
||||
|
||||
new_row = self.set_average_rate(new_row)
|
||||
@@ -659,11 +659,17 @@ class GrossProfitGenerator:
|
||||
if i == 0:
|
||||
new_row = row
|
||||
else:
|
||||
new_row.qty += flt(row.qty)
|
||||
new_row.buying_amount += flt(row.buying_amount, self.currency_precision)
|
||||
new_row.base_amount += flt(row.base_amount, self.currency_precision)
|
||||
new_row.qty = flt((new_row.qty + row.qty), self.float_precision)
|
||||
new_row.buying_amount = flt(
|
||||
(new_row.buying_amount + row.buying_amount), self.currency_precision
|
||||
)
|
||||
new_row.base_amount = flt(
|
||||
(new_row.base_amount + row.base_amount), self.currency_precision
|
||||
)
|
||||
if self.filters.get("group_by") == "Sales Person":
|
||||
new_row.allocated_amount += flt(row.allocated_amount, self.currency_precision)
|
||||
new_row.allocated_amount = flt(
|
||||
(new_row.allocated_amount + row.allocated_amount), self.currency_precision
|
||||
)
|
||||
new_row = self.set_average_rate(new_row)
|
||||
self.grouped_data.append(new_row)
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ class TestGrossProfit(FrappeTestCase):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = name
|
||||
customer.type = "Individual"
|
||||
customer.customer_group = "Individual"
|
||||
customer.save()
|
||||
self.customer = customer.name
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ def _execute(filters=None, additional_table_columns=None):
|
||||
|
||||
item_list = get_items(filters, additional_table_columns)
|
||||
aii_account_map = get_aii_accounts()
|
||||
default_taxes = {}
|
||||
if item_list:
|
||||
itemised_tax, tax_columns = get_tax_accounts(
|
||||
item_list,
|
||||
@@ -39,6 +40,9 @@ def _execute(filters=None, additional_table_columns=None):
|
||||
doctype="Purchase Invoice",
|
||||
tax_doctype="Purchase Taxes and Charges",
|
||||
)
|
||||
for tax in tax_columns:
|
||||
default_taxes[f"{tax}_rate"] = 0
|
||||
default_taxes[f"{tax}_amount"] = 0
|
||||
|
||||
po_pr_map = get_purchase_receipts_against_purchase_order(item_list)
|
||||
|
||||
@@ -85,6 +89,8 @@ def _execute(filters=None, additional_table_columns=None):
|
||||
}
|
||||
|
||||
total_tax = 0
|
||||
row.update(default_taxes.copy())
|
||||
|
||||
for tax in tax_columns:
|
||||
item_tax = itemised_tax.get(d.name, {}).get(tax, {})
|
||||
row.update(
|
||||
|
||||
@@ -29,8 +29,12 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
|
||||
company_currency = frappe.get_cached_value("Company", filters.get("company"), "default_currency")
|
||||
|
||||
item_list = get_items(filters, additional_table_columns, additional_conditions)
|
||||
default_taxes = {}
|
||||
if item_list:
|
||||
itemised_tax, tax_columns = get_tax_accounts(item_list, columns, company_currency)
|
||||
for tax in tax_columns:
|
||||
default_taxes[f"{tax}_rate"] = 0
|
||||
default_taxes[f"{tax}_amount"] = 0
|
||||
|
||||
mode_of_payments = get_mode_of_payments(set(d.parent for d in item_list))
|
||||
so_dn_map = get_delivery_notes_against_sales_order(item_list)
|
||||
@@ -88,6 +92,8 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
|
||||
|
||||
total_tax = 0
|
||||
total_other_charges = 0
|
||||
row.update(default_taxes.copy())
|
||||
|
||||
for tax in tax_columns:
|
||||
item_tax = itemised_tax.get(d.name, {}).get(tax, {})
|
||||
row.update(
|
||||
|
||||
@@ -17,9 +17,11 @@ class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase):
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def create_sales_invoice(self, do_not_submit=False):
|
||||
def create_sales_invoice(self, item=None, taxes=None, do_not_submit=False):
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
item=item or self.item,
|
||||
item_name=item or self.item,
|
||||
description=item or self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debit_to,
|
||||
@@ -30,6 +32,19 @@ class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase):
|
||||
price_list_rate=100,
|
||||
do_not_save=1,
|
||||
)
|
||||
|
||||
for tax in taxes or []:
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": tax["account_head"],
|
||||
"cost_center": self.cost_center,
|
||||
"description": tax["description"],
|
||||
"rate": tax["rate"],
|
||||
},
|
||||
)
|
||||
|
||||
si = si.save()
|
||||
if not do_not_submit:
|
||||
si = si.submit()
|
||||
@@ -63,3 +78,50 @@ class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase):
|
||||
|
||||
report_output = {k: v for k, v in report[1][0].items() if k in expected_result}
|
||||
self.assertDictEqual(report_output, expected_result)
|
||||
|
||||
def test_grouped_report_handles_different_tax_descriptions(self):
|
||||
self.create_item(item_name="_Test Item Tax Description A")
|
||||
first_item = self.item
|
||||
self.create_item(item_name="_Test Item Tax Description B")
|
||||
second_item = self.item
|
||||
|
||||
first_tax_description = "Tax Description A"
|
||||
second_tax_description = "Tax Description B"
|
||||
first_tax_amount_field = f"{frappe.scrub(first_tax_description)}_amount"
|
||||
second_tax_amount_field = f"{frappe.scrub(second_tax_description)}_amount"
|
||||
|
||||
self.create_sales_invoice(
|
||||
item=first_item,
|
||||
taxes=[
|
||||
{
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"description": first_tax_description,
|
||||
"rate": 5,
|
||||
}
|
||||
],
|
||||
)
|
||||
self.create_sales_invoice(
|
||||
item=second_item,
|
||||
taxes=[
|
||||
{
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"description": second_tax_description,
|
||||
"rate": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"from_date": today(),
|
||||
"to_date": today(),
|
||||
"company": self.company,
|
||||
"group_by": "Customer",
|
||||
}
|
||||
)
|
||||
_, data, _, _, _, _ = execute(filters)
|
||||
|
||||
grand_total_row = next(row for row in data if row.get("bold") and row.get("item_code") == "Total")
|
||||
|
||||
self.assertEqual(grand_total_row[first_tax_amount_field], 5.0)
|
||||
self.assertEqual(grand_total_row[second_tax_amount_field], 2.0)
|
||||
|
||||
@@ -22,7 +22,7 @@ frappe.query_reports["Profit and Loss Statement"]["filters"].push(
|
||||
fieldname: "accumulated_values",
|
||||
label: __("Accumulated Values"),
|
||||
fieldtype: "Check",
|
||||
default: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
fieldname: "include_default_book_entries",
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 3,
|
||||
"idx": 4,
|
||||
"is_standard": "Yes",
|
||||
"letterhead": null,
|
||||
"modified": "2025-11-05 11:55:49.950442",
|
||||
"letter_head": null,
|
||||
"modified": "2026-03-13 17:35:39.703838",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice Trends",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"filters": [],
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"letterhead": null,
|
||||
"modified": "2025-11-05 11:55:50.070651",
|
||||
"letter_head": null,
|
||||
"modified": "2026-03-13 17:36:13.725601",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Trends",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt, getdate
|
||||
from pypika import Tuple
|
||||
|
||||
from erpnext.accounts.utils import get_currency_precision
|
||||
|
||||
@@ -19,18 +20,14 @@ def execute(filters=None):
|
||||
|
||||
validate_filters(filters)
|
||||
(
|
||||
tds_docs,
|
||||
tds_accounts,
|
||||
tax_category_map,
|
||||
journal_entry_party_map,
|
||||
net_total_map,
|
||||
) = get_tds_docs(filters)
|
||||
|
||||
columns = get_columns(filters)
|
||||
|
||||
res = get_result(
|
||||
filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, net_total_map
|
||||
)
|
||||
res = get_result(filters, tds_accounts, tax_category_map, net_total_map)
|
||||
return columns, res
|
||||
|
||||
|
||||
@@ -41,27 +38,23 @@ def validate_filters(filters):
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
|
||||
|
||||
def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, net_total_map):
|
||||
party_map = get_party_pan_map(filters.get("party_type"))
|
||||
def get_result(filters, tds_accounts, tax_category_map, net_total_map):
|
||||
party_names = {v.party for v in net_total_map.values() if v.party}
|
||||
party_map = get_party_pan_map(filters.get("party_type"), party_names)
|
||||
tax_rate_map = get_tax_rate_map(filters)
|
||||
gle_map = get_gle_map(tds_docs)
|
||||
gle_map = get_gle_map(net_total_map)
|
||||
precision = get_currency_precision()
|
||||
|
||||
out = []
|
||||
entries = {}
|
||||
for name, details in gle_map.items():
|
||||
for (voucher_type, name), details in gle_map.items():
|
||||
for entry in details:
|
||||
tax_amount, total_amount, grand_total, base_total, base_tax_withholding_net_total = 0, 0, 0, 0, 0
|
||||
tax_withholding_category, rate = None, None
|
||||
bill_no, bill_date = "", ""
|
||||
party = entry.party or entry.against
|
||||
posting_date = entry.posting_date
|
||||
voucher_type = entry.voucher_type
|
||||
|
||||
if voucher_type == "Journal Entry":
|
||||
party_list = journal_entry_party_map.get(name)
|
||||
if party_list:
|
||||
party = party_list[0]
|
||||
values = net_total_map.get((voucher_type, name))
|
||||
party = values.party if values else (entry.party or entry.against)
|
||||
|
||||
if entry.account in tds_accounts.keys():
|
||||
tax_amount += entry.credit - entry.debit
|
||||
@@ -76,12 +69,13 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_
|
||||
|
||||
rate = get_tax_withholding_rates(tax_rate_map.get(tax_withholding_category, []), posting_date)
|
||||
|
||||
values = net_total_map.get((voucher_type, name))
|
||||
|
||||
if values:
|
||||
if voucher_type == "Journal Entry" and tax_amount and rate:
|
||||
# back calculate total amount from rate and tax_amount
|
||||
base_total = min(flt(tax_amount / (rate / 100), precision=precision), values[0])
|
||||
base_total = min(
|
||||
flt(tax_amount / (rate / 100), precision=precision),
|
||||
values.base_tax_withholding_net_total,
|
||||
)
|
||||
total_amount = grand_total = base_total
|
||||
base_tax_withholding_net_total = total_amount
|
||||
|
||||
@@ -90,16 +84,16 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_
|
||||
# back calculate total amount from rate and tax_amount
|
||||
total_amount = flt((tax_amount * 100) / rate, precision=precision)
|
||||
else:
|
||||
total_amount = values[0]
|
||||
total_amount = values.base_tax_withholding_net_total
|
||||
|
||||
grand_total = values[1]
|
||||
base_total = values[2]
|
||||
grand_total = values.grand_total
|
||||
base_total = values.base_total
|
||||
base_tax_withholding_net_total = total_amount
|
||||
|
||||
if voucher_type == "Purchase Invoice":
|
||||
base_tax_withholding_net_total = values[0]
|
||||
bill_no = values[3]
|
||||
bill_date = values[4]
|
||||
base_tax_withholding_net_total = values.base_tax_withholding_net_total
|
||||
bill_no = values.bill_no
|
||||
bill_date = values.bill_date
|
||||
|
||||
else:
|
||||
total_amount += entry.credit
|
||||
@@ -147,14 +141,17 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_
|
||||
else:
|
||||
entries[key] = row
|
||||
out = list(entries.values())
|
||||
out.sort(key=lambda x: (x["section_code"], x["transaction_date"]))
|
||||
out.sort(key=lambda x: (x["section_code"], x["transaction_date"], x["ref_no"]))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def get_party_pan_map(party_type):
|
||||
def get_party_pan_map(party_type, party_names):
|
||||
party_map = frappe._dict()
|
||||
|
||||
if not party_names:
|
||||
return party_map
|
||||
|
||||
fields = ["name", "tax_withholding_category"]
|
||||
if party_type == "Supplier":
|
||||
fields += ["supplier_type", "supplier_name"]
|
||||
@@ -164,7 +161,7 @@ def get_party_pan_map(party_type):
|
||||
if frappe.db.has_column(party_type, "pan"):
|
||||
fields.append("pan")
|
||||
|
||||
party_details = frappe.db.get_all(party_type, fields=fields)
|
||||
party_details = frappe.db.get_all(party_type, filters={"name": ("in", list(party_names))}, fields=fields)
|
||||
|
||||
for party in party_details:
|
||||
party.party_type = party_type
|
||||
@@ -173,22 +170,33 @@ def get_party_pan_map(party_type):
|
||||
return party_map
|
||||
|
||||
|
||||
def get_gle_map(documents):
|
||||
# create gle_map of the form
|
||||
# {"purchase_invoice": list of dict of all gle created for this invoice}
|
||||
def get_gle_map(net_total_map):
|
||||
if not net_total_map:
|
||||
return {}
|
||||
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
voucher_pairs = list(net_total_map.keys())
|
||||
|
||||
rows = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(
|
||||
gle.credit,
|
||||
gle.debit,
|
||||
gle.account,
|
||||
gle.voucher_no,
|
||||
gle.posting_date,
|
||||
gle.voucher_type,
|
||||
gle.against,
|
||||
gle.party,
|
||||
gle.party_type,
|
||||
)
|
||||
.where(gle.is_cancelled == 0)
|
||||
.where(Tuple(gle.voucher_type, gle.voucher_no).isin(voucher_pairs))
|
||||
).run(as_dict=True)
|
||||
|
||||
gle_map = {}
|
||||
|
||||
gle = frappe.db.get_all(
|
||||
"GL Entry",
|
||||
{"voucher_no": ["in", documents], "is_cancelled": 0},
|
||||
["credit", "debit", "account", "voucher_no", "posting_date", "voucher_type", "against", "party"],
|
||||
)
|
||||
|
||||
for d in gle:
|
||||
if d.voucher_no not in gle_map:
|
||||
gle_map[d.voucher_no] = [d]
|
||||
else:
|
||||
gle_map[d.voucher_no].append(d)
|
||||
for d in rows:
|
||||
gle_map.setdefault((d.voucher_type, d.voucher_no), []).append(d)
|
||||
|
||||
return gle_map
|
||||
|
||||
@@ -308,14 +316,9 @@ def get_columns(filters):
|
||||
|
||||
|
||||
def get_tds_docs(filters):
|
||||
tds_documents = []
|
||||
purchase_invoices = []
|
||||
sales_invoices = []
|
||||
payment_entries = []
|
||||
journal_entries = []
|
||||
vouchers = frappe._dict()
|
||||
tax_category_map = frappe._dict()
|
||||
net_total_map = frappe._dict()
|
||||
journal_entry_party_map = frappe._dict()
|
||||
bank_accounts = frappe.get_all("Account", {"is_group": 0, "account_type": "Bank"}, pluck="name")
|
||||
|
||||
_tds_accounts = frappe.get_all(
|
||||
@@ -334,35 +337,14 @@ def get_tds_docs(filters):
|
||||
tds_docs = get_tds_docs_query(filters, bank_accounts, list(tds_accounts.keys())).run(as_dict=True)
|
||||
|
||||
for d in tds_docs:
|
||||
if d.voucher_type == "Purchase Invoice":
|
||||
purchase_invoices.append(d.voucher_no)
|
||||
if d.voucher_type == "Sales Invoice":
|
||||
sales_invoices.append(d.voucher_no)
|
||||
elif d.voucher_type == "Payment Entry":
|
||||
payment_entries.append(d.voucher_no)
|
||||
elif d.voucher_type == "Journal Entry":
|
||||
journal_entries.append(d.voucher_no)
|
||||
vouchers.setdefault(d.voucher_type, set()).add(d.voucher_no)
|
||||
|
||||
tds_documents.append(d.voucher_no)
|
||||
|
||||
if purchase_invoices:
|
||||
get_doc_info(purchase_invoices, "Purchase Invoice", tax_category_map, net_total_map)
|
||||
|
||||
if sales_invoices:
|
||||
get_doc_info(sales_invoices, "Sales Invoice", tax_category_map, net_total_map)
|
||||
|
||||
if payment_entries:
|
||||
get_doc_info(payment_entries, "Payment Entry", tax_category_map, net_total_map)
|
||||
|
||||
if journal_entries:
|
||||
journal_entry_party_map = get_journal_entry_party_map(journal_entries)
|
||||
get_doc_info(journal_entries, "Journal Entry", tax_category_map, net_total_map)
|
||||
for voucher_type, docs in vouchers.items():
|
||||
get_doc_info(docs, voucher_type, tax_category_map, net_total_map, filters)
|
||||
|
||||
return (
|
||||
tds_documents,
|
||||
tds_accounts,
|
||||
tax_category_map,
|
||||
journal_entry_party_map,
|
||||
net_total_map,
|
||||
)
|
||||
|
||||
@@ -373,11 +355,16 @@ def get_tds_docs_query(filters, bank_accounts, tds_accounts):
|
||||
_("No {0} Accounts found for this company.").format(frappe.bold(_("Tax Withholding"))),
|
||||
title=_("Accounts Missing Error"),
|
||||
)
|
||||
|
||||
invoice_voucher = "Purchase Invoice" if filters.get("party_type") == "Supplier" else "Sales Invoice"
|
||||
voucher_types = {"Payment Entry", "Journal Entry", invoice_voucher}
|
||||
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
query = (
|
||||
frappe.qb.from_(gle)
|
||||
.select("voucher_no", "voucher_type", "against", "party")
|
||||
.where(gle.is_cancelled == 0)
|
||||
.where(gle.voucher_type.isin(voucher_types))
|
||||
)
|
||||
|
||||
if filters.get("from_date"):
|
||||
@@ -403,25 +390,27 @@ def get_tds_docs_query(filters, bank_accounts, tds_accounts):
|
||||
return query
|
||||
|
||||
|
||||
def get_journal_entry_party_map(journal_entries):
|
||||
def get_journal_entry_party_map(journal_entries, party_type):
|
||||
journal_entry_party_map = {}
|
||||
for d in frappe.db.get_all(
|
||||
"Journal Entry Account",
|
||||
{
|
||||
"parent": ("in", journal_entries),
|
||||
"party_type": ("in", ("Supplier", "Customer")),
|
||||
"party_type": party_type,
|
||||
"party": ("is", "set"),
|
||||
},
|
||||
["parent", "party"],
|
||||
):
|
||||
if d.parent not in journal_entry_party_map:
|
||||
journal_entry_party_map[d.parent] = []
|
||||
journal_entry_party_map[d.parent].append(d.party)
|
||||
journal_entry_party_map.setdefault(d.parent, []).append(d.party)
|
||||
|
||||
return journal_entry_party_map
|
||||
|
||||
|
||||
def get_doc_info(vouchers, doctype, tax_category_map, net_total_map=None):
|
||||
def get_doc_info(vouchers, doctype, tax_category_map, net_total_map=None, filters=None):
|
||||
journal_entry_party_map = {}
|
||||
party_type = filters.get("party_type") if filters else None
|
||||
party = filters.get("party") if filters else None
|
||||
|
||||
common_fields = ["name"]
|
||||
fields_dict = {
|
||||
"Purchase Invoice": [
|
||||
@@ -431,37 +420,81 @@ def get_doc_info(vouchers, doctype, tax_category_map, net_total_map=None):
|
||||
"base_total",
|
||||
"bill_no",
|
||||
"bill_date",
|
||||
"supplier",
|
||||
],
|
||||
"Sales Invoice": ["base_net_total", "grand_total", "base_total"],
|
||||
"Sales Invoice": ["base_net_total", "grand_total", "base_total", "customer"],
|
||||
"Payment Entry": [
|
||||
"tax_withholding_category",
|
||||
"paid_amount",
|
||||
"paid_amount_after_tax",
|
||||
"base_paid_amount",
|
||||
"party",
|
||||
"party_type",
|
||||
],
|
||||
"Journal Entry": ["tax_withholding_category", "total_debit"],
|
||||
}
|
||||
party_field = {
|
||||
"Purchase Invoice": "supplier",
|
||||
"Sales Invoice": "customer",
|
||||
"Payment Entry": "party",
|
||||
}
|
||||
|
||||
entries = frappe.get_all(
|
||||
doctype, filters={"name": ("in", vouchers)}, fields=common_fields + fields_dict[doctype]
|
||||
)
|
||||
doc_filters = {"name": ("in", vouchers)}
|
||||
|
||||
if party and party_field.get(doctype):
|
||||
doc_filters[party_field[doctype]] = party
|
||||
|
||||
if doctype == "Payment Entry":
|
||||
doc_filters["party_type"] = party_type
|
||||
|
||||
entries = frappe.get_all(doctype, filters=doc_filters, fields=common_fields + fields_dict[doctype])
|
||||
|
||||
if doctype == "Journal Entry":
|
||||
journal_entry_party_map = get_journal_entry_party_map(vouchers, party_type=party_type)
|
||||
|
||||
for entry in entries:
|
||||
tax_category_map[(doctype, entry.name)] = entry.tax_withholding_category
|
||||
|
||||
value = frappe._dict(
|
||||
party=None,
|
||||
party_type=party_type,
|
||||
base_tax_withholding_net_total=0,
|
||||
grand_total=0,
|
||||
base_total=0,
|
||||
bill_no="",
|
||||
bill_date="",
|
||||
)
|
||||
|
||||
if doctype == "Purchase Invoice":
|
||||
value = [
|
||||
entry.base_tax_withholding_net_total,
|
||||
entry.grand_total,
|
||||
entry.base_total,
|
||||
entry.bill_no,
|
||||
entry.bill_date,
|
||||
]
|
||||
value.party = entry.supplier
|
||||
value.party_type = "Supplier"
|
||||
value.base_tax_withholding_net_total = entry.base_tax_withholding_net_total
|
||||
value.grand_total = entry.grand_total
|
||||
value.base_total = entry.base_total
|
||||
value.bill_no = entry.bill_no
|
||||
value.bill_date = entry.bill_date
|
||||
elif doctype == "Sales Invoice":
|
||||
value = [entry.base_net_total, entry.grand_total, entry.base_total]
|
||||
value.party = entry.customer
|
||||
value.party_type = "Customer"
|
||||
value.base_tax_withholding_net_total = entry.base_net_total
|
||||
value.grand_total = entry.grand_total
|
||||
value.base_total = entry.base_total
|
||||
elif doctype == "Payment Entry":
|
||||
value = [entry.paid_amount, entry.paid_amount_after_tax, entry.base_paid_amount]
|
||||
value.party = entry.party
|
||||
value.party_type = entry.party_type
|
||||
value.base_tax_withholding_net_total = entry.paid_amount
|
||||
value.grand_total = entry.paid_amount_after_tax
|
||||
value.base_total = entry.base_paid_amount
|
||||
else:
|
||||
value = [entry.total_debit] * 3
|
||||
party_list = journal_entry_party_map.get(entry.name, [])
|
||||
if party and party in party_list:
|
||||
value.party = party
|
||||
elif party_list:
|
||||
value.party = sorted(party_list)[0]
|
||||
value.party_type = party_type
|
||||
value.base_tax_withholding_net_total = entry.total_debit
|
||||
value.grand_total = entry.total_debit
|
||||
value.base_total = entry.total_debit
|
||||
|
||||
net_total_map[(doctype, entry.name)] = value
|
||||
|
||||
|
||||
@@ -20,16 +20,12 @@ def execute(filters=None):
|
||||
|
||||
columns = get_columns(filters)
|
||||
(
|
||||
tds_docs,
|
||||
tds_accounts,
|
||||
tax_category_map,
|
||||
journal_entry_party_map,
|
||||
invoice_total_map,
|
||||
net_total_map,
|
||||
) = get_tds_docs(filters)
|
||||
|
||||
res = get_result(
|
||||
filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, invoice_total_map
|
||||
)
|
||||
res = get_result(filters, tds_accounts, tax_category_map, net_total_map)
|
||||
final_result = group_by_party_and_category(res, filters)
|
||||
|
||||
return columns, final_result
|
||||
|
||||
@@ -12,6 +12,7 @@ class AccountsTestMixin:
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = customer_name
|
||||
customer.type = "Individual"
|
||||
customer.customer_group = "Individual"
|
||||
|
||||
if currency:
|
||||
customer.default_currency = currency
|
||||
@@ -36,6 +37,7 @@ class AccountsTestMixin:
|
||||
"account": default_account,
|
||||
},
|
||||
)
|
||||
customer.customer_group = "Individual"
|
||||
customer.save()
|
||||
self.customer = customer_name
|
||||
|
||||
|
||||
@@ -7,12 +7,8 @@ from erpnext.accounts.party import get_default_price_list
|
||||
class PartyTestCase(FrappeTestCase):
|
||||
def test_get_default_price_list_should_return_none_for_invalid_group(self):
|
||||
customer = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_name": "test customer",
|
||||
}
|
||||
{"doctype": "Customer", "customer_name": "test customer", "customer_group": "Individual"}
|
||||
).insert(ignore_permissions=True, ignore_mandatory=True)
|
||||
customer.customer_group = None
|
||||
customer.save()
|
||||
price_list = get_default_price_list(customer)
|
||||
assert price_list is None
|
||||
|
||||
@@ -36,6 +36,7 @@ frappe.ui.form.on("Asset", {
|
||||
},
|
||||
|
||||
company: function (frm) {
|
||||
frm.trigger("set_dynamic_labels");
|
||||
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
|
||||
},
|
||||
|
||||
@@ -87,6 +88,7 @@ frappe.ui.form.on("Asset", {
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
frm.trigger("set_dynamic_labels");
|
||||
frappe.ui.form.trigger("Asset", "is_existing_asset");
|
||||
frm.toggle_display("next_depreciation_date", frm.doc.docstatus < 1);
|
||||
|
||||
@@ -221,6 +223,10 @@ frappe.ui.form.on("Asset", {
|
||||
}
|
||||
},
|
||||
|
||||
set_dynamic_labels: function (frm) {
|
||||
frm.set_currency_labels(["gross_purchase_amount"], erpnext.get_currency(frm.doc.company));
|
||||
},
|
||||
|
||||
set_depr_posting_failure_alert: function (frm) {
|
||||
const alert = `
|
||||
<div class="row">
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Purchase Amount",
|
||||
"mandatory_depends_on": "eval:(!doc.is_composite_asset || doc.docstatus==1)",
|
||||
"options": "Company:company:default_currency",
|
||||
"options": "currency",
|
||||
"read_only_depends_on": "eval: doc.is_composite_asset"
|
||||
},
|
||||
{
|
||||
@@ -597,7 +597,7 @@
|
||||
"link_fieldname": "target_asset"
|
||||
}
|
||||
],
|
||||
"modified": "2025-12-23 16:01:10.195932",
|
||||
"modified": "2026-03-13 12:15:25.734623",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset",
|
||||
|
||||
@@ -111,16 +111,12 @@ frappe.ui.form.on("Asset Repair", {
|
||||
purchase_invoice: function (frm) {
|
||||
if (frm.doc.purchase_invoice) {
|
||||
frappe.call({
|
||||
method: "frappe.client.get_value",
|
||||
method: "erpnext.assets.doctype.asset_repair.asset_repair.get_repair_cost_for_purchase_invoice",
|
||||
args: {
|
||||
doctype: "Purchase Invoice",
|
||||
fieldname: "base_net_total",
|
||||
filters: { name: frm.doc.purchase_invoice },
|
||||
purchase_invoice: frm.doc.purchase_invoice,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frm.set_value("repair_cost", r.message.base_net_total);
|
||||
}
|
||||
frm.set_value("repair_cost", r.message || 0);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -135,7 +131,7 @@ frappe.ui.form.on("Asset Repair", {
|
||||
function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.posting_date,
|
||||
from_date: moment(frm.doc.completion_date).format("YYYY-MM-DD"),
|
||||
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import add_months, cint, flt, get_link_to_form, getdate, time_diff_in_hours
|
||||
|
||||
import erpnext
|
||||
@@ -308,9 +309,14 @@ class AssetRepair(AccountsController):
|
||||
if flt(self.repair_cost) <= 0:
|
||||
return
|
||||
|
||||
pi_expense_account = (
|
||||
frappe.get_doc("Purchase Invoice", self.purchase_invoice).items[0].expense_account
|
||||
)
|
||||
expense_accounts = _get_expense_accounts_for_purchase_invoice(self.purchase_invoice)
|
||||
|
||||
if not expense_accounts:
|
||||
frappe.throw(
|
||||
_("No expense accounts found for Purchase Invoice {0}").format(self.purchase_invoice)
|
||||
)
|
||||
|
||||
pi_expense_account = expense_accounts[0]
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
@@ -473,3 +479,84 @@ class AssetRepair(AccountsController):
|
||||
def get_downtime(failure_date, completion_date):
|
||||
downtime = time_diff_in_hours(completion_date, failure_date)
|
||||
return round(downtime, 2)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_repair_cost_for_purchase_invoice(purchase_invoice: str) -> float:
|
||||
"""
|
||||
Get the total repair cost from GL entries for a purchase invoice.
|
||||
Only considers expense accounts for non-stock, non-fixed-asset items.
|
||||
"""
|
||||
if not purchase_invoice:
|
||||
return 0.0
|
||||
|
||||
frappe.has_permission("Purchase Invoice", "read", purchase_invoice, throw=True)
|
||||
|
||||
expense_accounts = _get_expense_accounts_for_purchase_invoice(purchase_invoice)
|
||||
|
||||
if not expense_accounts:
|
||||
return 0.0
|
||||
|
||||
return _get_total_expense_amount(purchase_invoice, expense_accounts)
|
||||
|
||||
|
||||
def _get_expense_accounts_for_purchase_invoice(purchase_invoice: str) -> list[str]:
|
||||
"""
|
||||
Get expense accounts for non-stock items from the purchase invoice.
|
||||
"""
|
||||
pi_items = frappe.get_all(
|
||||
"Purchase Invoice Item",
|
||||
filters={"parent": purchase_invoice},
|
||||
fields=["item_code", "expense_account", "is_fixed_asset"],
|
||||
)
|
||||
|
||||
if not pi_items:
|
||||
return []
|
||||
|
||||
# Get list of stock item codes from the invoice
|
||||
item_codes = {item.item_code for item in pi_items if item.item_code}
|
||||
stock_items = set()
|
||||
if item_codes:
|
||||
stock_items = set(
|
||||
frappe.db.get_all(
|
||||
"Item", filters={"name": ["in", list(item_codes)], "is_stock_item": 1}, pluck="name"
|
||||
)
|
||||
)
|
||||
|
||||
expense_accounts = set()
|
||||
|
||||
for item in pi_items:
|
||||
# Skip stock items - they use warehouse accounts
|
||||
if item.item_code and item.item_code in stock_items:
|
||||
continue
|
||||
|
||||
# Skip fixed assets - they use asset accounts
|
||||
if item.is_fixed_asset:
|
||||
continue
|
||||
|
||||
# Use expense account from Purchase Invoice Item
|
||||
if item.expense_account:
|
||||
expense_accounts.add(item.expense_account)
|
||||
|
||||
return list(expense_accounts)
|
||||
|
||||
|
||||
def _get_total_expense_amount(purchase_invoice: str, expense_accounts: list[str]) -> float:
|
||||
"""Get the total expense amount from GL entries for a purchase invoice and accounts."""
|
||||
if not expense_accounts:
|
||||
return 0.0
|
||||
|
||||
gl_entry = frappe.qb.DocType("GL Entry")
|
||||
|
||||
result = (
|
||||
frappe.qb.from_(gl_entry)
|
||||
.select((Sum(gl_entry.debit) - Sum(gl_entry.credit)).as_("total"))
|
||||
.where(
|
||||
(gl_entry.voucher_type == "Purchase Invoice")
|
||||
& (gl_entry.voucher_no == purchase_invoice)
|
||||
& (gl_entry.account.isin(expense_accounts))
|
||||
& (gl_entry.is_cancelled == 0)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
return flt(result[0].total) if result else 0.0
|
||||
|
||||
@@ -8,6 +8,7 @@ from frappe import qb
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import add_days, add_months, flt, get_first_day, nowdate, nowtime, today
|
||||
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.assets.doctype.asset.asset import (
|
||||
get_asset_account,
|
||||
get_asset_value_after_depreciation,
|
||||
@@ -21,6 +22,7 @@ from erpnext.assets.doctype.asset.test_asset import (
|
||||
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
|
||||
get_asset_depr_schedule_doc,
|
||||
)
|
||||
from erpnext.assets.doctype.asset_repair.asset_repair import get_repair_cost_for_purchase_invoice
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
get_serial_nos_from_bundle,
|
||||
@@ -321,6 +323,59 @@ class TestAssetRepair(unittest.TestCase):
|
||||
self.assertEqual(asset.additional_asset_cost, asset_repair.repair_cost)
|
||||
self.assertEqual(booked_value, asset_repair.repair_cost)
|
||||
|
||||
def test_repair_cost_fetches_only_service_item_amount(self):
|
||||
"""Test that repair cost only includes service (non-stock) item amounts from purchase invoice."""
|
||||
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
|
||||
service_item = create_item(
|
||||
"_Test Service Item for Repair",
|
||||
is_stock_item=0,
|
||||
warehouse=warehouse,
|
||||
company=company,
|
||||
)
|
||||
|
||||
stock_item = create_item(
|
||||
"_Test Stock Item for Repair",
|
||||
is_stock_item=1,
|
||||
warehouse=warehouse,
|
||||
company=company,
|
||||
)
|
||||
|
||||
service_expense_account = "Miscellaneous Expenses - TCP1"
|
||||
cost_center = frappe.db.get_value("Company", company, "cost_center")
|
||||
|
||||
pi = make_purchase_invoice(
|
||||
item_code=service_item.name,
|
||||
qty=1,
|
||||
rate=500,
|
||||
expense_account=service_expense_account,
|
||||
cost_center=cost_center,
|
||||
warehouse=warehouse,
|
||||
update_stock=0,
|
||||
do_not_submit=1,
|
||||
company=company,
|
||||
)
|
||||
|
||||
pi.update_stock = 1
|
||||
pi.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": stock_item.name,
|
||||
"qty": 2,
|
||||
"rate": 300,
|
||||
"warehouse": warehouse,
|
||||
"cost_center": cost_center,
|
||||
},
|
||||
)
|
||||
pi.save()
|
||||
pi.submit()
|
||||
|
||||
repair_cost = get_repair_cost_for_purchase_invoice(pi.name)
|
||||
|
||||
self.assertEqual(repair_cost, 500)
|
||||
|
||||
|
||||
def num_of_depreciations(asset):
|
||||
return asset.finance_books[0].total_number_of_depreciations
|
||||
@@ -411,6 +466,7 @@ def create_asset_repair(**args):
|
||||
if asset.calculate_depreciation:
|
||||
asset_repair.increase_in_asset_life = 12
|
||||
pi = make_purchase_invoice(
|
||||
item=args.item or "_Test Non Stock Item",
|
||||
company=asset.company,
|
||||
expense_account=frappe.db.get_value("Company", asset.company, "default_expense_account"),
|
||||
cost_center=asset_repair.cost_center,
|
||||
|
||||
@@ -769,10 +769,6 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
items_on_form_rendered() {
|
||||
set_schedule_date(this.frm);
|
||||
}
|
||||
|
||||
schedule_date() {
|
||||
set_schedule_date(this.frm);
|
||||
}
|
||||
};
|
||||
|
||||
// for backward compatibility: combine new and previous states
|
||||
|
||||
@@ -826,18 +826,18 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
|
||||
target.set_payment_schedule()
|
||||
target.credit_to = get_party_account("Supplier", source.supplier, source.company)
|
||||
|
||||
def get_billed_qty(po_item_name):
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
table = frappe.qb.DocType("Purchase Invoice Item")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.qty).as_("qty"))
|
||||
.where((table.docstatus == 1) & (table.po_detail == po_item_name))
|
||||
)
|
||||
return query.run(pluck="qty")[0] or 0
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
def get_billed_qty(po_item_name):
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
table = frappe.qb.DocType("Purchase Invoice Item")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.qty).as_("qty"))
|
||||
.where((table.docstatus == 1) & (table.po_detail == po_item_name))
|
||||
)
|
||||
return query.run(pluck="qty")[0] or 0
|
||||
|
||||
billed_qty = flt(get_billed_qty(obj.name))
|
||||
target.qty = flt(obj.qty) - billed_qty
|
||||
|
||||
@@ -877,7 +877,11 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
|
||||
"wip_composite_asset": "wip_composite_asset",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))
|
||||
"condition": lambda doc: (
|
||||
doc.base_amount == 0
|
||||
or abs(doc.billed_amt) < abs(doc.amount)
|
||||
or doc.qty > flt(get_billed_qty(doc.name))
|
||||
)
|
||||
and select_item(doc),
|
||||
},
|
||||
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},
|
||||
@@ -912,6 +916,8 @@ def get_list_context(context=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_status(status, name):
|
||||
frappe.has_permission("Purchase Order", "submit", name, throw=True)
|
||||
|
||||
po = frappe.get_doc("Purchase Order", name)
|
||||
po.update_status(status)
|
||||
po.update_delivered_qty_in_sales_order()
|
||||
|
||||
@@ -289,6 +289,30 @@ class TestPurchaseOrder(FrappeTestCase):
|
||||
# ordered qty should decrease (back to initial) on row deletion
|
||||
self.assertEqual(get_ordered_qty(), existing_ordered_qty)
|
||||
|
||||
def test_discount_amount_partial_purchase_receipt(self):
|
||||
po = create_purchase_order(qty=4, rate=100, do_not_save=1)
|
||||
po.apply_discount_on = "Grand Total"
|
||||
po.discount_amount = 120
|
||||
po.save()
|
||||
po.submit()
|
||||
|
||||
self.assertEqual(po.grand_total, 280)
|
||||
|
||||
pr1 = make_purchase_receipt(po.name)
|
||||
pr1.items[0].qty = 3
|
||||
pr1.save()
|
||||
pr1.submit()
|
||||
|
||||
self.assertEqual(pr1.discount_amount, 120)
|
||||
self.assertEqual(pr1.grand_total, 180)
|
||||
|
||||
pr2 = make_purchase_receipt(po.name)
|
||||
pr2.save()
|
||||
pr2.submit()
|
||||
|
||||
self.assertEqual(pr2.discount_amount, 0)
|
||||
self.assertEqual(pr2.grand_total, 100)
|
||||
|
||||
def test_update_child_perm(self):
|
||||
po = create_purchase_order(item_code="_Test Item", qty=4)
|
||||
|
||||
@@ -1346,6 +1370,35 @@ class TestPurchaseOrder(FrappeTestCase):
|
||||
self.assertEqual(pi_2.status, "Paid")
|
||||
self.assertEqual(po.status, "Completed")
|
||||
|
||||
@change_settings("Buying Settings", {"maintain_same_rate": 0})
|
||||
def test_purchase_order_over_billing_missing_item(self):
|
||||
item1 = make_item(
|
||||
"_Test Item for Overbilling",
|
||||
).name
|
||||
|
||||
item2 = make_item(
|
||||
"_Test Item for Overbilling 2",
|
||||
).name
|
||||
|
||||
po = create_purchase_order(qty=10, rate=1000, item_code=item1, do_not_save=1)
|
||||
po.append("items", {"item_code": item2, "qty": 5, "rate": 20, "warehouse": "_Test Warehouse - _TC"})
|
||||
po.taxes = []
|
||||
po.insert()
|
||||
po.submit()
|
||||
|
||||
pi1 = make_pi_from_po(po.name)
|
||||
pi1.items[0].qty = 8
|
||||
pi1.items[0].rate = 1250
|
||||
pi1.remove(pi1.items[1])
|
||||
pi1.insert()
|
||||
pi1.submit()
|
||||
|
||||
self.assertEqual(pi1.grand_total, 10000.0)
|
||||
self.assertTrue(len(pi1.items) == 1)
|
||||
|
||||
pi2 = make_pi_from_po(po.name)
|
||||
self.assertEqual(len(pi2.items), 2)
|
||||
|
||||
|
||||
def create_po_for_sc_testing():
|
||||
from erpnext.controllers.tests.test_subcontracting_controller import (
|
||||
|
||||
@@ -165,14 +165,10 @@ frappe.ui.form.on("Request for Quotation", {
|
||||
},
|
||||
|
||||
show_supplier_quotation_comparison(frm) {
|
||||
const today = new Date();
|
||||
const oneMonthAgo = new Date(today);
|
||||
oneMonthAgo.setMonth(today.getMonth() - 1);
|
||||
|
||||
frappe.route_options = {
|
||||
company: frm.doc.company,
|
||||
from_date: moment(oneMonthAgo).format("YYYY-MM-DD"),
|
||||
to_date: moment(today).format("YYYY-MM-DD"),
|
||||
from_date: moment(frm.doc.transaction_date).format("YYYY-MM-DD"),
|
||||
to_date: moment(new Date()).format("YYYY-MM-DD"),
|
||||
request_for_quotation: frm.doc.name,
|
||||
};
|
||||
frappe.set_route("query-report", "Supplier Quotation Comparison");
|
||||
|
||||
@@ -175,6 +175,15 @@ def create_supplier(**args):
|
||||
if not args.without_supplier_group:
|
||||
doc.supplier_group = args.supplier_group or "Services"
|
||||
|
||||
if args.get("party_account"):
|
||||
doc.append(
|
||||
"accounts",
|
||||
{
|
||||
"company": frappe.db.get_value("Account", args.get("party_account"), "company"),
|
||||
"account": args.get("party_account"),
|
||||
},
|
||||
)
|
||||
|
||||
doc.insert()
|
||||
|
||||
return doc
|
||||
|
||||
@@ -37,7 +37,7 @@ class TestPurchaseOrder(FrappeTestCase):
|
||||
self.assertEqual(sq.get("items")[0].qty, 5)
|
||||
self.assertEqual(sq.get("items")[1].rate, 300)
|
||||
|
||||
def test_update_supplier_quotation_child_rate_disallow(self):
|
||||
def test_update_supplier_quotation_child_rate(self):
|
||||
sq = frappe.copy_doc(test_records[0])
|
||||
sq.submit()
|
||||
trans_item = json.dumps(
|
||||
@@ -50,6 +50,22 @@ class TestPurchaseOrder(FrappeTestCase):
|
||||
},
|
||||
]
|
||||
)
|
||||
update_child_qty_rate("Supplier Quotation", trans_item, sq.name)
|
||||
sq.reload()
|
||||
self.assertEqual(sq.get("items")[0].rate, 300)
|
||||
po = make_purchase_order(sq.name)
|
||||
po.schedule_date = add_days(today(), 1)
|
||||
po.submit()
|
||||
trans_item = json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": sq.items[0].item_code,
|
||||
"rate": 20,
|
||||
"qty": sq.items[0].qty,
|
||||
"docname": sq.items[0].name,
|
||||
},
|
||||
]
|
||||
)
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name
|
||||
)
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"filters": [],
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"letterhead": null,
|
||||
"modified": "2025-11-05 11:55:50.058154",
|
||||
"letter_head": null,
|
||||
"modified": "2026-03-13 17:36:05.561765",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order Trends",
|
||||
|
||||
@@ -41,6 +41,7 @@ def get_columns(filters):
|
||||
"fieldname": "transferred_qty",
|
||||
"width": 200,
|
||||
},
|
||||
{"label": _("Returned Quantity"), "fieldtype": "Float", "fieldname": "returned_qty", "width": 150},
|
||||
{"label": _("Pending Quantity"), "fieldtype": "Float", "fieldname": "p_qty", "width": 150},
|
||||
]
|
||||
|
||||
@@ -50,7 +51,7 @@ def get_data(filters):
|
||||
|
||||
data = []
|
||||
for row in order_rm_item_details:
|
||||
transferred_qty = row.get("transferred_qty") or 0
|
||||
transferred_qty = (row.get("transferred_qty") or 0) - (row.get("returned_qty") or 0)
|
||||
if transferred_qty < row.get("reqd_qty", 0):
|
||||
pending_qty = frappe.utils.flt(row.get("reqd_qty", 0) - transferred_qty)
|
||||
row.p_qty = pending_qty if pending_qty > 0 else 0
|
||||
@@ -86,6 +87,7 @@ def get_order_items_to_supply(filters):
|
||||
f"`tab{supplied_items_table}`.rm_item_code as rm_item_code",
|
||||
f"`tab{supplied_items_table}`.required_qty as reqd_qty",
|
||||
f"`tab{supplied_items_table}`.supplied_qty as transferred_qty",
|
||||
f"`tab{supplied_items_table}`.returned_qty as returned_qty",
|
||||
],
|
||||
filters=record_filters,
|
||||
)
|
||||
|
||||
@@ -2297,6 +2297,16 @@ class AccountsController(TransactionBase):
|
||||
|
||||
return stock_items
|
||||
|
||||
def get_asset_items(self):
|
||||
asset_items = []
|
||||
item_codes = list(set(item.item_code for item in self.get("items")))
|
||||
if item_codes:
|
||||
asset_items = frappe.db.get_values(
|
||||
"Item", {"name": ["in", item_codes], "is_fixed_asset": 1}, pluck="name", cache=True
|
||||
)
|
||||
|
||||
return asset_items
|
||||
|
||||
def calculate_total_advance_from_ledger(self):
|
||||
adv = frappe.qb.DocType("Advance Payment Ledger Entry")
|
||||
return (
|
||||
@@ -2502,13 +2512,14 @@ class AccountsController(TransactionBase):
|
||||
grand_total = self.get("rounded_total") or self.grand_total
|
||||
automatically_fetch_payment_terms = 0
|
||||
|
||||
if self.doctype in ("Sales Invoice", "Purchase Invoice"):
|
||||
base_grand_total = base_grand_total - flt(self.base_write_off_amount)
|
||||
grand_total = grand_total - flt(self.write_off_amount)
|
||||
if self.doctype in ("Sales Invoice", "Purchase Invoice", "Sales Order"):
|
||||
po_or_so, doctype, fieldname = self.get_order_details()
|
||||
automatically_fetch_payment_terms = cint(
|
||||
frappe.db.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
|
||||
)
|
||||
if self.doctype != "Sales Order":
|
||||
base_grand_total = base_grand_total - flt(self.base_write_off_amount)
|
||||
grand_total = grand_total - flt(self.write_off_amount)
|
||||
|
||||
if self.get("total_advance"):
|
||||
if party_account_currency == self.company_currency:
|
||||
@@ -2524,7 +2535,7 @@ class AccountsController(TransactionBase):
|
||||
|
||||
if not self.get("payment_schedule"):
|
||||
if (
|
||||
self.doctype in ["Sales Invoice", "Purchase Invoice"]
|
||||
self.doctype in ["Sales Invoice", "Purchase Invoice", "Sales Order"]
|
||||
and automatically_fetch_payment_terms
|
||||
and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype)
|
||||
):
|
||||
@@ -2579,17 +2590,23 @@ class AccountsController(TransactionBase):
|
||||
self.ignore_default_payment_terms_template = 1
|
||||
|
||||
def get_order_details(self):
|
||||
if not self.get("items"):
|
||||
return None, None, None
|
||||
if self.doctype == "Sales Invoice":
|
||||
po_or_so = self.get("items") and self.get("items")[0].get("sales_order")
|
||||
po_or_so_doctype = "Sales Order"
|
||||
po_or_so_doctype_name = "sales_order"
|
||||
|
||||
prev_doc = self.get("items")[0].get("sales_order")
|
||||
prev_doctype = "Sales Order"
|
||||
prev_doctype_name = "sales_order"
|
||||
elif self.doctype == "Purchase Invoice":
|
||||
prev_doc = self.get("items")[0].get("purchase_order")
|
||||
prev_doctype = "Purchase Order"
|
||||
prev_doctype_name = "purchase_order"
|
||||
elif self.doctype == "Sales Order":
|
||||
prev_doc = self.get("items")[0].get("prevdoc_docname")
|
||||
prev_doctype = "Quotation"
|
||||
prev_doctype_name = "prevdoc_docname"
|
||||
else:
|
||||
po_or_so = self.get("items") and self.get("items")[0].get("purchase_order")
|
||||
po_or_so_doctype = "Purchase Order"
|
||||
po_or_so_doctype_name = "purchase_order"
|
||||
|
||||
return po_or_so, po_or_so_doctype, po_or_so_doctype_name
|
||||
return None, None, None
|
||||
return prev_doc, prev_doctype, prev_doctype_name
|
||||
|
||||
def linked_order_has_payment_terms(self, po_or_so, fieldname, doctype):
|
||||
if po_or_so and self.all_items_have_same_po_or_so(po_or_so, fieldname):
|
||||
@@ -2685,7 +2702,9 @@ class AccountsController(TransactionBase):
|
||||
|
||||
for d in self.get("payment_schedule"):
|
||||
d.validate_from_to_dates("discount_date", "due_date")
|
||||
if self.doctype == "Sales Order" and getdate(d.due_date) < getdate(self.transaction_date):
|
||||
if self.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate(
|
||||
self.transaction_date
|
||||
):
|
||||
frappe.throw(
|
||||
_("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format(
|
||||
d.idx
|
||||
@@ -3838,20 +3857,28 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False
|
||||
return False
|
||||
|
||||
def validate_quantity(child_item, new_data):
|
||||
def validate_quantity_and_rate(child_item, new_data):
|
||||
if not flt(new_data.get("qty")) and not is_allowed_zero_qty():
|
||||
frappe.throw(
|
||||
_("Row #{0}: Quantity for Item {1} cannot be zero.").format(
|
||||
_("Row #{0}:Quantity for Item {1} cannot be zero.").format(
|
||||
new_data.get("idx"), frappe.bold(new_data.get("item_code"))
|
||||
),
|
||||
title=_("Invalid Qty"),
|
||||
)
|
||||
|
||||
if parent_doctype == "Sales Order" and flt(new_data.get("qty")) < flt(child_item.delivered_qty):
|
||||
frappe.throw(_("Cannot set quantity less than delivered quantity"))
|
||||
qty_limits = {
|
||||
"Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity")),
|
||||
"Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity")),
|
||||
}
|
||||
|
||||
if parent_doctype == "Purchase Order" and flt(new_data.get("qty")) < flt(child_item.received_qty):
|
||||
frappe.throw(_("Cannot set quantity less than received quantity"))
|
||||
if parent_doctype in qty_limits:
|
||||
qty_field, error_message = qty_limits[parent_doctype]
|
||||
if flt(new_data.get("qty")) < flt(child_item.get(qty_field)):
|
||||
frappe.throw(
|
||||
_("Row #{0}:").format(new_data.get("idx"))
|
||||
+ error_message.format(frappe.bold(new_data.get("item_code"))),
|
||||
title=_("Invalid Qty"),
|
||||
)
|
||||
|
||||
if parent_doctype in ["Quotation", "Supplier Quotation"]:
|
||||
if (parent_doctype == "Quotation" and not ordered_items) or (
|
||||
@@ -3864,7 +3891,15 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
if parent_doctype == "Quotation"
|
||||
else purchased_items.get(child_item.name)
|
||||
)
|
||||
|
||||
if qty_to_check:
|
||||
if not rate_unchanged:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot update rate as item {0} is already ordered or purchased against this quotation"
|
||||
).format(frappe.bold(new_data.get("item_code")))
|
||||
)
|
||||
|
||||
if flt(new_data.get("qty")) < qty_to_check:
|
||||
frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity"))
|
||||
|
||||
@@ -3980,10 +4015,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
):
|
||||
continue
|
||||
|
||||
validate_quantity(child_item, d)
|
||||
if parent_doctype in ["Quotation", "Supplier Quotation"]:
|
||||
if not rate_unchanged:
|
||||
frappe.throw(_("Rates cannot be modified for quoted items"))
|
||||
validate_quantity_and_rate(child_item, d)
|
||||
|
||||
if flt(child_item.get("qty")) != flt(d.get("qty")):
|
||||
any_qty_changed = True
|
||||
|
||||
@@ -327,7 +327,7 @@ class BuyingController(SubcontractingController):
|
||||
last_item_idx = d.idx
|
||||
|
||||
total_valuation_amount = sum(
|
||||
flt(d.base_tax_amount_after_discount_amount)
|
||||
flt(d.base_tax_amount_after_discount_amount) * (-1 if d.get("add_deduct_tax") == "Deduct" else 1)
|
||||
for d in self.get("taxes")
|
||||
if d.category in ["Valuation", "Valuation and Total"]
|
||||
)
|
||||
@@ -364,7 +364,7 @@ class BuyingController(SubcontractingController):
|
||||
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0
|
||||
)
|
||||
|
||||
net_rate = item.base_net_amount
|
||||
net_rate = item.qty * item.base_net_rate
|
||||
if item.sales_incoming_rate: # for internal transfer
|
||||
net_rate = item.qty * item.sales_incoming_rate
|
||||
|
||||
|
||||
@@ -360,13 +360,13 @@ def copy_attributes_to_variant(item, variant):
|
||||
else:
|
||||
if item.variant_based_on == "Item Attribute":
|
||||
if variant.attributes:
|
||||
attributes_description = item.description + " "
|
||||
attributes_description = item.description or ""
|
||||
for d in variant.attributes:
|
||||
attributes_description += (
|
||||
"<div>" + d.attribute + ": " + cstr(d.attribute_value) + "</div>"
|
||||
)
|
||||
|
||||
if attributes_description not in variant.description:
|
||||
if attributes_description not in (variant.description or ""):
|
||||
variant.description = attributes_description
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from frappe.utils import cint, nowdate, today, unique
|
||||
from pypika import Order
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.utils import build_qb_match_conditions
|
||||
from erpnext.stock.get_item_details import _get_item_tax_template
|
||||
|
||||
|
||||
@@ -608,34 +609,37 @@ def get_blanket_orders(doctype, txt, searchfield, start, page_len, filters):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_income_account(doctype, txt, searchfield, start, page_len, filters):
|
||||
from erpnext.controllers.queries import get_match_cond
|
||||
|
||||
# income account can be any Credit account,
|
||||
# but can also be a Asset account with account_type='Income Account' in special circumstances.
|
||||
# Hence the first condition is an "OR"
|
||||
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
doctype = "Account"
|
||||
condition = ""
|
||||
dt = "Account"
|
||||
|
||||
acc = qb.DocType(dt)
|
||||
condition = [
|
||||
(acc.report_type.eq("Profit and Loss") | acc.account_type.isin(["Income Account", "Temporary"])),
|
||||
acc.is_group.eq(0),
|
||||
acc.disabled.eq(0),
|
||||
]
|
||||
if txt:
|
||||
condition.append(acc.name.like(f"%{txt}%"))
|
||||
|
||||
if filters.get("company"):
|
||||
condition += "and tabAccount.company = %(company)s"
|
||||
condition.append(acc.company.eq(filters.get("company")))
|
||||
|
||||
condition += " and tabAccount.disabled = %(disabled)s"
|
||||
user_perms = build_qb_match_conditions(dt)
|
||||
condition.extend(user_perms)
|
||||
|
||||
return frappe.db.sql(
|
||||
f"""select tabAccount.name from `tabAccount`
|
||||
where (tabAccount.report_type = "Profit and Loss"
|
||||
or tabAccount.account_type in ("Income Account", "Temporary"))
|
||||
and tabAccount.is_group=0
|
||||
and tabAccount.`{searchfield}` LIKE %(txt)s
|
||||
{condition} {get_match_cond(doctype)}
|
||||
order by idx desc, name""",
|
||||
{
|
||||
"txt": "%" + txt + "%",
|
||||
"company": filters.get("company", ""),
|
||||
"disabled": cint(filters.get("disabled", 0)),
|
||||
},
|
||||
return (
|
||||
qb.from_(acc)
|
||||
.select(acc.name)
|
||||
.where(Criterion.all(condition))
|
||||
.orderby(acc.idx, order=Order.desc)
|
||||
.orderby(acc.name)
|
||||
.run()
|
||||
)
|
||||
|
||||
|
||||
@@ -696,26 +700,38 @@ def get_filtered_dimensions(doctype, txt, searchfield, start, page_len, filters,
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_expense_account(doctype, txt, searchfield, start, page_len, filters):
|
||||
from erpnext.controllers.queries import get_match_cond
|
||||
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
doctype = "Account"
|
||||
condition = ""
|
||||
if filters.get("company"):
|
||||
condition += "and tabAccount.company = %(company)s"
|
||||
dt = "Account"
|
||||
|
||||
return frappe.db.sql(
|
||||
f"""select tabAccount.name from `tabAccount`
|
||||
where (tabAccount.report_type = "Profit and Loss"
|
||||
or tabAccount.account_type in ("Expense Account", "Fixed Asset", "Temporary", "Asset Received But Not Billed", "Capital Work in Progress"))
|
||||
and tabAccount.is_group=0
|
||||
and tabAccount.disabled = 0
|
||||
and tabAccount.{searchfield} LIKE %(txt)s
|
||||
{condition} {get_match_cond(doctype)}""",
|
||||
{"company": filters.get("company", ""), "txt": "%" + txt + "%"},
|
||||
)
|
||||
acc = qb.DocType(dt)
|
||||
condition = [
|
||||
(
|
||||
acc.report_type.eq("Profit and Loss")
|
||||
| acc.account_type.isin(
|
||||
[
|
||||
"Expense Account",
|
||||
"Fixed Asset",
|
||||
"Temporary",
|
||||
"Asset Received But Not Billed",
|
||||
"Capital Work in Progress",
|
||||
]
|
||||
)
|
||||
),
|
||||
acc.is_group.eq(0),
|
||||
acc.disabled.eq(0),
|
||||
]
|
||||
if txt:
|
||||
condition.append(acc.name.like(f"%{txt}%"))
|
||||
|
||||
if filters.get("company"):
|
||||
condition.append(acc.company.eq(filters.get("company")))
|
||||
|
||||
user_perms = build_qb_match_conditions(dt)
|
||||
condition.extend(user_perms)
|
||||
|
||||
return qb.from_(acc).select(acc.name).where(Criterion.all(condition)).run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -976,3 +992,26 @@ def get_item_uom_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
limit_page_length=page_len,
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_warehouse_address(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
table = frappe.qb.DocType(doctype)
|
||||
child_table = frappe.qb.DocType("Dynamic Link")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.inner_join(child_table)
|
||||
.on((table.name == child_table.parent) & (child_table.parenttype == doctype))
|
||||
.select(table.name)
|
||||
.where(
|
||||
(child_table.link_name == filters.get("warehouse"))
|
||||
& (table.disabled == 0)
|
||||
& (child_table.link_doctype == "Warehouse")
|
||||
& (table.name.like(f"%{txt}%"))
|
||||
)
|
||||
.offset(start)
|
||||
.limit(page_len)
|
||||
)
|
||||
return query.run(as_list=1)
|
||||
|
||||
@@ -531,7 +531,6 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
|
||||
target_doc.against_sales_order = source_doc.against_sales_order
|
||||
target_doc.against_sales_invoice = source_doc.against_sales_invoice
|
||||
target_doc.so_detail = source_doc.so_detail
|
||||
target_doc.si_detail = source_doc.si_detail
|
||||
target_doc.expense_account = source_doc.expense_account
|
||||
target_doc.dn_detail = source_doc.name
|
||||
if default_warehouse_for_sales_return:
|
||||
|
||||
@@ -511,6 +511,9 @@ class SellingController(StockController):
|
||||
if self.doctype not in ("Delivery Note", "Sales Invoice"):
|
||||
return
|
||||
|
||||
if self.doctype == "Sales Invoice" and not self.update_stock and not self.is_internal_transfer():
|
||||
return
|
||||
|
||||
from erpnext.stock.serial_batch_bundle import get_batch_nos, get_serial_nos
|
||||
|
||||
allow_at_arms_length_price = frappe.get_cached_value(
|
||||
|
||||
@@ -234,8 +234,8 @@ class StatusUpdater(Document):
|
||||
self.global_amount_allowance = None
|
||||
|
||||
for args in self.status_updater:
|
||||
if "target_ref_field" not in args:
|
||||
# if target_ref_field is not specified, the programmer does not want to validate qty / amount
|
||||
if "target_ref_field" not in args or args.get("validate_qty") is False:
|
||||
# if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation
|
||||
continue
|
||||
|
||||
# get unique transactions to update
|
||||
|
||||
@@ -1135,6 +1135,16 @@ class StockController(AccountsController):
|
||||
continue
|
||||
|
||||
if qi_required: # validate row only if inspection is required on item level
|
||||
if self.doctype in [
|
||||
"Purchase Receipt",
|
||||
"Purchase Invoice",
|
||||
"Sales Invoice",
|
||||
"Delivery Note",
|
||||
] and frappe.get_single_value(
|
||||
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
|
||||
):
|
||||
return
|
||||
|
||||
self.validate_qi_presence(row)
|
||||
if self.docstatus == 1:
|
||||
self.validate_qi_submission(row)
|
||||
@@ -1142,16 +1152,6 @@ class StockController(AccountsController):
|
||||
|
||||
def validate_qi_presence(self, row):
|
||||
"""Check if QI is present on row level. Warn on save and stop on submit if missing."""
|
||||
if self.doctype in [
|
||||
"Purchase Receipt",
|
||||
"Purchase Invoice",
|
||||
"Sales Invoice",
|
||||
"Delivery Note",
|
||||
] and frappe.db.get_single_value(
|
||||
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
|
||||
):
|
||||
return
|
||||
|
||||
if not row.quality_inspection:
|
||||
msg = _("Row #{0}: Quality Inspection is required for Item {1}").format(
|
||||
row.idx, frappe.bold(row.item_code)
|
||||
|
||||
@@ -989,6 +989,12 @@ class SubcontractingController(StockController):
|
||||
if self.doctype not in ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"]:
|
||||
return
|
||||
|
||||
if (
|
||||
frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on")
|
||||
== "BOM"
|
||||
):
|
||||
return
|
||||
|
||||
for row in self.get(self.raw_material_table):
|
||||
key = (row.rm_item_code, row.main_item_code, row.get(self.subcontract_data.order_field))
|
||||
if not self.__transferred_items or not self.__transferred_items.get(key):
|
||||
@@ -1297,6 +1303,55 @@ def make_rm_stock_entry(
|
||||
if target_doc and target_doc.get("items"):
|
||||
target_doc.items = []
|
||||
|
||||
def post_process(source_doc, target_doc):
|
||||
target_doc.purpose = "Send to Subcontractor"
|
||||
|
||||
if order_doctype == "Purchase Order":
|
||||
target_doc.purchase_order = source_doc.name
|
||||
else:
|
||||
target_doc.subcontracting_order = source_doc.name
|
||||
|
||||
target_doc.set_stock_entry_type()
|
||||
|
||||
for fg_item_code in fg_item_code_list:
|
||||
for rm_item in rm_items:
|
||||
if (
|
||||
rm_item.get("main_item_code") == fg_item_code
|
||||
or rm_item.get("item_code") == fg_item_code
|
||||
):
|
||||
rm_item_code = rm_item.get("rm_item_code")
|
||||
|
||||
items_dict = {
|
||||
rm_item_code: {
|
||||
rm_detail_field: rm_item.get("name"),
|
||||
"item_name": rm_item.get("item_name")
|
||||
or item_wh.get(rm_item_code, {}).get("item_name", ""),
|
||||
"description": item_wh.get(rm_item_code, {}).get("description", ""),
|
||||
"qty": rm_item.get("qty")
|
||||
or max(
|
||||
rm_item.get("required_qty") - rm_item.get("total_supplied_qty"), 0
|
||||
),
|
||||
"from_warehouse": rm_item.get("warehouse")
|
||||
or rm_item.get("reserve_warehouse"),
|
||||
"to_warehouse": source_doc.supplier_warehouse,
|
||||
"stock_uom": rm_item.get("stock_uom"),
|
||||
"serial_and_batch_bundle": rm_item.get("serial_and_batch_bundle"),
|
||||
"main_item_code": fg_item_code,
|
||||
"allow_alternative_item": item_wh.get(rm_item_code, {}).get(
|
||||
"allow_alternative_item"
|
||||
),
|
||||
"use_serial_batch_fields": rm_item.get("use_serial_batch_fields"),
|
||||
"serial_no": rm_item.get("serial_no")
|
||||
if rm_item.get("use_serial_batch_fields")
|
||||
else None,
|
||||
"batch_no": rm_item.get("batch_no")
|
||||
if rm_item.get("use_serial_batch_fields")
|
||||
else None,
|
||||
}
|
||||
}
|
||||
|
||||
target_doc.add_to_stock_entry_detail(items_dict)
|
||||
|
||||
stock_entry = get_mapped_doc(
|
||||
order_doctype,
|
||||
subcontract_order.name,
|
||||
@@ -1317,53 +1372,9 @@ def make_rm_stock_entry(
|
||||
},
|
||||
target_doc,
|
||||
ignore_child_tables=True,
|
||||
postprocess=post_process,
|
||||
)
|
||||
|
||||
stock_entry.purpose = "Send to Subcontractor"
|
||||
|
||||
if order_doctype == "Purchase Order":
|
||||
stock_entry.purchase_order = subcontract_order.name
|
||||
else:
|
||||
stock_entry.subcontracting_order = subcontract_order.name
|
||||
|
||||
stock_entry.set_stock_entry_type()
|
||||
|
||||
for fg_item_code in fg_item_code_list:
|
||||
for rm_item in rm_items:
|
||||
if (
|
||||
rm_item.get("main_item_code") == fg_item_code
|
||||
or rm_item.get("item_code") == fg_item_code
|
||||
):
|
||||
rm_item_code = rm_item.get("rm_item_code")
|
||||
items_dict = {
|
||||
rm_item_code: {
|
||||
rm_detail_field: rm_item.get("name"),
|
||||
"item_name": rm_item.get("item_name")
|
||||
or item_wh.get(rm_item_code, {}).get("item_name", ""),
|
||||
"description": item_wh.get(rm_item_code, {}).get("description", ""),
|
||||
"qty": rm_item.get("qty")
|
||||
or max(rm_item.get("required_qty") - rm_item.get("total_supplied_qty"), 0),
|
||||
"from_warehouse": rm_item.get("warehouse")
|
||||
or rm_item.get("reserve_warehouse"),
|
||||
"to_warehouse": subcontract_order.supplier_warehouse,
|
||||
"stock_uom": rm_item.get("stock_uom"),
|
||||
"serial_and_batch_bundle": rm_item.get("serial_and_batch_bundle"),
|
||||
"main_item_code": fg_item_code,
|
||||
"allow_alternative_item": item_wh.get(rm_item_code, {}).get(
|
||||
"allow_alternative_item"
|
||||
),
|
||||
"use_serial_batch_fields": rm_item.get("use_serial_batch_fields"),
|
||||
"serial_no": rm_item.get("serial_no")
|
||||
if rm_item.get("use_serial_batch_fields")
|
||||
else None,
|
||||
"batch_no": rm_item.get("batch_no")
|
||||
if rm_item.get("use_serial_batch_fields")
|
||||
else None,
|
||||
}
|
||||
}
|
||||
|
||||
stock_entry.add_to_stock_entry_detail(items_dict)
|
||||
|
||||
if target_doc:
|
||||
return stock_entry
|
||||
else:
|
||||
@@ -1395,6 +1406,8 @@ def add_items_in_ste(ste_doc, row, qty, rm_details, rm_detail_field="sco_rm_deta
|
||||
def make_return_stock_entry_for_subcontract(
|
||||
available_materials, order_doc, rm_details, order_doctype="Subcontracting Order"
|
||||
):
|
||||
rm_detail_field = "po_detail" if order_doctype == "Purchase Order" else "sco_rm_detail"
|
||||
|
||||
def post_process(source_doc, target_doc):
|
||||
target_doc.purpose = "Material Transfer"
|
||||
|
||||
@@ -1405,6 +1418,21 @@ def make_return_stock_entry_for_subcontract(
|
||||
|
||||
target_doc.company = source_doc.company
|
||||
target_doc.is_return = 1
|
||||
for _key, value in available_materials.items():
|
||||
if not value.qty:
|
||||
continue
|
||||
|
||||
if item_details := value.get("item_details"):
|
||||
item_details["serial_and_batch_bundle"] = None
|
||||
|
||||
if value.batch_no:
|
||||
for batch_no, qty in value.batch_no.items():
|
||||
if qty > 0:
|
||||
add_items_in_ste(target_doc, value, qty, rm_details, rm_detail_field, batch_no)
|
||||
else:
|
||||
add_items_in_ste(target_doc, value, value.qty, rm_details, rm_detail_field)
|
||||
|
||||
target_doc.set_stock_entry_type()
|
||||
|
||||
ste_doc = get_mapped_doc(
|
||||
order_doctype,
|
||||
@@ -1419,27 +1447,6 @@ def make_return_stock_entry_for_subcontract(
|
||||
postprocess=post_process,
|
||||
)
|
||||
|
||||
if order_doctype == "Purchase Order":
|
||||
rm_detail_field = "po_detail"
|
||||
else:
|
||||
rm_detail_field = "sco_rm_detail"
|
||||
|
||||
for _key, value in available_materials.items():
|
||||
if not value.qty:
|
||||
continue
|
||||
|
||||
if item_details := value.get("item_details"):
|
||||
item_details["serial_and_batch_bundle"] = None
|
||||
|
||||
if value.batch_no:
|
||||
for batch_no, qty in value.batch_no.items():
|
||||
if qty > 0:
|
||||
add_items_in_ste(ste_doc, value, qty, rm_details, rm_detail_field, batch_no)
|
||||
else:
|
||||
add_items_in_ste(ste_doc, value, value.qty, rm_details, rm_detail_field)
|
||||
|
||||
ste_doc.set_stock_entry_type()
|
||||
|
||||
return ste_doc
|
||||
|
||||
|
||||
|
||||
@@ -183,6 +183,9 @@ class calculate_taxes_and_totals:
|
||||
return
|
||||
|
||||
if not self.discount_amount_applied:
|
||||
bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
|
||||
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
|
||||
)
|
||||
for item in self.doc.items:
|
||||
self.doc.round_floats_in(item)
|
||||
|
||||
@@ -238,7 +241,13 @@ class calculate_taxes_and_totals:
|
||||
elif not item.qty and self.doc.get("is_debit_note"):
|
||||
item.amount = flt(item.rate, item.precision("amount"))
|
||||
else:
|
||||
item.amount = flt(item.rate * item.qty, item.precision("amount"))
|
||||
qty = (
|
||||
(item.qty + item.rejected_qty)
|
||||
if bill_for_rejected_quantity_in_purchase_invoice
|
||||
and self.doc.doctype == "Purchase Receipt"
|
||||
else item.qty
|
||||
)
|
||||
item.amount = flt(item.rate * qty, item.precision("amount"))
|
||||
|
||||
item.net_amount = item.amount
|
||||
|
||||
@@ -370,9 +379,16 @@ class calculate_taxes_and_totals:
|
||||
self.doc.total
|
||||
) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0
|
||||
|
||||
bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
|
||||
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
|
||||
)
|
||||
for item in self._items:
|
||||
self.doc.total += item.amount
|
||||
self.doc.total_qty += item.qty
|
||||
self.doc.total_qty += (
|
||||
(item.qty + item.rejected_qty)
|
||||
if bill_for_rejected_quantity_in_purchase_invoice and self.doc.doctype == "Purchase Receipt"
|
||||
else item.qty
|
||||
)
|
||||
self.doc.base_total += item.base_amount
|
||||
self.doc.net_total += item.net_amount
|
||||
self.doc.base_net_total += item.base_net_amount
|
||||
@@ -724,7 +740,8 @@ class calculate_taxes_and_totals:
|
||||
discount_amount += total_return_discount
|
||||
|
||||
# validate that discount amount cannot exceed the total before discount
|
||||
if (
|
||||
# only during save (i.e. when `_action` is set)
|
||||
if self.doc.get("_action") and (
|
||||
(grand_total >= 0 and discount_amount > grand_total)
|
||||
or (grand_total < 0 and discount_amount < grand_total) # returns
|
||||
):
|
||||
|
||||
@@ -29,6 +29,7 @@ def make_customer(customer_name, currency=None):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = customer_name
|
||||
customer.customer_type = "Individual"
|
||||
customer.customer_group = "Individual"
|
||||
|
||||
if currency:
|
||||
customer.default_currency = currency
|
||||
|
||||
@@ -66,7 +66,7 @@ class TestTaxes(unittest.TestCase):
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_name": uuid4(),
|
||||
"customer_group": "All Customer Groups",
|
||||
"customer_group": "Individual",
|
||||
}
|
||||
).insert()
|
||||
self.supplier = frappe.get_doc(
|
||||
|
||||
@@ -55,6 +55,14 @@ def validate_filters(filters):
|
||||
if filters.get("based_on") == filters.get("group_by"):
|
||||
frappe.throw(_("'Based On' and 'Group By' can not be same"))
|
||||
|
||||
if filters.get("period_based_on") and filters.period_based_on not in ["bill_date", "posting_date"]:
|
||||
frappe.throw(
|
||||
msg=_("{0} can be either {1} or {2}.").format(
|
||||
frappe.bold("Period based On"), frappe.bold("Posting Date"), frappe.bold("Billing Date")
|
||||
),
|
||||
title=_("Invalid Filter"),
|
||||
)
|
||||
|
||||
|
||||
def get_data(filters, conditions):
|
||||
data = []
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2020-12-07 10:44:22.587047",
|
||||
"modified": "2026-03-25 19:27:19.162421",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Contract Template",
|
||||
@@ -75,43 +75,34 @@
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
"share": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Purchase Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
"share": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "HR Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,8 +204,22 @@ def send_mail(entry, email_campaign):
|
||||
|
||||
# called from hooks on doc_event Email Unsubscribe
|
||||
def unsubscribe_recipient(unsubscribe, method):
|
||||
if unsubscribe.reference_doctype == "Email Campaign":
|
||||
frappe.db.set_value("Email Campaign", unsubscribe.reference_name, "status", "Unsubscribed")
|
||||
if unsubscribe.reference_doctype != "Email Campaign":
|
||||
return
|
||||
|
||||
email_campaign = frappe.get_doc("Email Campaign", unsubscribe.reference_name)
|
||||
|
||||
if email_campaign.email_campaign_for == "Email Group":
|
||||
if unsubscribe.email:
|
||||
frappe.db.set_value(
|
||||
"Email Group Member",
|
||||
{"email_group": email_campaign.recipient, "email": unsubscribe.email},
|
||||
"unsubscribed",
|
||||
1,
|
||||
)
|
||||
else:
|
||||
# For Lead or Contact
|
||||
frappe.db.set_value("Email Campaign", email_campaign.name, "status", "Unsubscribed")
|
||||
|
||||
|
||||
# called through hooks to update email campaign status daily
|
||||
|
||||
@@ -35,7 +35,9 @@ class TestOpportunity(unittest.TestCase):
|
||||
self.assertEqual(frappe.db.get_value("Lead", opp_doc.party_name, "email_id"), opp_doc.contact_email)
|
||||
|
||||
# create new customer and create new contact against 'new.opportunity@example.com'
|
||||
customer = make_customer(opp_doc.party_name).insert(ignore_permissions=True)
|
||||
customer = make_customer(opp_doc.party_name)
|
||||
customer.customer_group = "Individual"
|
||||
customer.insert(ignore_permissions=True)
|
||||
contact = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Contact",
|
||||
|
||||
@@ -196,6 +196,7 @@ def create_customer():
|
||||
if not doc:
|
||||
doc = frappe.new_doc("Customer")
|
||||
doc.customer_name = "_Test NC"
|
||||
doc.customer_group = "Individual"
|
||||
doc.insert()
|
||||
|
||||
|
||||
|
||||
@@ -192,6 +192,7 @@ frappe.ui.form.on("BOM", {
|
||||
bom_no: frm.doc.name,
|
||||
item: item,
|
||||
qty: data.qty || 0.0,
|
||||
company: frm.doc.company,
|
||||
project: frm.doc.project,
|
||||
variant_items: variant_items,
|
||||
use_multi_level_bom: use_multi_level_bom,
|
||||
@@ -263,6 +264,7 @@ frappe.ui.form.on("BOM", {
|
||||
reqd: 1,
|
||||
default: 1,
|
||||
onchange: () => {
|
||||
if (!cur_dialog) return;
|
||||
const { quantity, items: rm } = frm.doc;
|
||||
const variant_items_map = rm.reduce((acc, item) => {
|
||||
acc[item.item_code] = item.qty;
|
||||
|
||||
@@ -767,12 +767,14 @@ class BOM(WebsiteGenerator):
|
||||
hour_rate / flt(self.conversion_rate) if self.conversion_rate and hour_rate else hour_rate
|
||||
)
|
||||
|
||||
if row.hour_rate and row.time_in_mins:
|
||||
if row.hour_rate:
|
||||
row.base_hour_rate = flt(row.hour_rate) * flt(self.conversion_rate)
|
||||
row.operating_cost = flt(row.hour_rate) * flt(row.time_in_mins) / 60.0
|
||||
row.base_operating_cost = flt(row.operating_cost) * flt(self.conversion_rate)
|
||||
row.cost_per_unit = row.operating_cost / (row.batch_size or 1.0)
|
||||
row.base_cost_per_unit = row.base_operating_cost / (row.batch_size or 1.0)
|
||||
|
||||
if row.time_in_mins:
|
||||
row.operating_cost = flt(row.hour_rate) * flt(row.time_in_mins) / 60.0
|
||||
row.base_operating_cost = flt(row.operating_cost) * flt(self.conversion_rate)
|
||||
row.cost_per_unit = row.operating_cost / (row.batch_size or 1.0)
|
||||
row.base_cost_per_unit = row.base_operating_cost / (row.batch_size or 1.0)
|
||||
|
||||
if update_hour_rate:
|
||||
row.db_update()
|
||||
@@ -1019,6 +1021,12 @@ class BOM(WebsiteGenerator):
|
||||
"Row {0}: Workstation or Workstation Type is mandatory for an operation {1}"
|
||||
).format(d.idx, d.operation)
|
||||
)
|
||||
if not d.time_in_mins or d.time_in_mins <= 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Operation time should be greater than 0 for operation {1}").format(
|
||||
d.idx, d.operation
|
||||
)
|
||||
)
|
||||
|
||||
def get_tree_representation(self) -> BOMTree:
|
||||
"""Get a complete tree representation preserving order of child items."""
|
||||
@@ -1329,9 +1337,10 @@ def add_non_stock_items_cost(stock_entry, work_order, expense_account):
|
||||
bom = frappe.get_doc("BOM", work_order.bom_no)
|
||||
table = "exploded_items" if work_order.get("use_multi_level_bom") else "items"
|
||||
|
||||
items = {}
|
||||
items = frappe._dict()
|
||||
for d in bom.get(table):
|
||||
items.setdefault(d.item_code, d.amount)
|
||||
items.setdefault(d.item_code, 0)
|
||||
items[d.item_code] += flt(d.amount)
|
||||
|
||||
non_stock_items = frappe.get_all(
|
||||
"Item",
|
||||
|
||||
@@ -132,6 +132,15 @@ class TestBOM(FrappeTestCase):
|
||||
self.assertAlmostEqual(bom.base_raw_material_cost, base_raw_material_cost)
|
||||
self.assertAlmostEqual(bom.base_total_cost, base_raw_material_cost + base_op_cost)
|
||||
|
||||
@timeout
|
||||
def test_bom_no_operation_time_validation(self):
|
||||
bom = frappe.copy_doc(test_records[2])
|
||||
bom.docstatus = 0
|
||||
for op_row in bom.operations:
|
||||
op_row.time_in_mins = 0
|
||||
|
||||
self.assertRaises(frappe.ValidationError, bom.save)
|
||||
|
||||
@timeout
|
||||
def test_bom_cost_with_batch_size(self):
|
||||
bom = frappe.copy_doc(test_records[2])
|
||||
|
||||
@@ -47,6 +47,14 @@ frappe.ui.form.on("Job Card", {
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("work_order", function () {
|
||||
return {
|
||||
filters: {
|
||||
status: ["not in", ["Cancelled", "Closed", "Stopped"]],
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
|
||||
@@ -281,9 +281,8 @@ class JobCard(Document):
|
||||
# if key number reaches/crosses to production_capacity means capacity is full and overlap error generated
|
||||
# this will store last to_time of sequential job cards
|
||||
alloted_capacity = {1: time_logs[0]["to_time"]}
|
||||
# flag for sequential Job card found
|
||||
sequential_job_card_found = False
|
||||
for i in range(1, len(time_logs)):
|
||||
sequential_job_card_found = False
|
||||
# scanning for all Existing keys
|
||||
for key in alloted_capacity.keys():
|
||||
# if current Job Card from time is greater than last to_time in that key means these job card are sequential
|
||||
@@ -1076,9 +1075,9 @@ class JobCard(Document):
|
||||
|
||||
def is_work_order_closed(self):
|
||||
if self.work_order:
|
||||
status = frappe.get_value("Work Order", self.work_order)
|
||||
status = frappe.get_value("Work Order", self.work_order, "status")
|
||||
|
||||
if status == "Closed":
|
||||
if status in ["Closed", "Stopped"]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"bom_section",
|
||||
"update_bom_costs_automatically",
|
||||
"column_break_lhyt",
|
||||
"allow_editing_of_items_and_quantities_in_work_order",
|
||||
"section_break_6",
|
||||
"default_wip_warehouse",
|
||||
"default_fg_warehouse",
|
||||
@@ -243,13 +244,20 @@
|
||||
"fieldname": "enforce_time_logs",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enforce Time Logs"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If enabled, the system will allow users to edit the raw materials and their quantities in the Work Order. The system will not reset the quantities as per the BOM, if the user has changed them.",
|
||||
"fieldname": "allow_editing_of_items_and_quantities_in_work_order",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Editing of Items and Quantities in Work Order"
|
||||
}
|
||||
],
|
||||
"icon": "icon-wrench",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-05-16 11:23:16.916512",
|
||||
"modified": "2025-11-07 14:52:56.241459",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Manufacturing Settings",
|
||||
|
||||
@@ -18,6 +18,7 @@ class ManufacturingSettings(Document):
|
||||
from frappe.types import DF
|
||||
|
||||
add_corrective_operation_cost_in_finished_good_valuation: DF.Check
|
||||
allow_editing_of_items_and_quantities_in_work_order: DF.Check
|
||||
allow_overtime: DF.Check
|
||||
allow_production_on_holidays: DF.Check
|
||||
backflush_raw_materials_based_on: DF.Literal["BOM", "Material Transferred for Manufacture"]
|
||||
|
||||
@@ -1664,8 +1664,10 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
)
|
||||
|
||||
sales_order = data.get("sales_order")
|
||||
qty_precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
|
||||
for item_code, details in item_details.items():
|
||||
details.qty = flt(details.qty, qty_precision)
|
||||
so_item_details.setdefault(sales_order, frappe._dict())
|
||||
if item_code in so_item_details.get(sales_order, {}):
|
||||
so_item_details[sales_order][item_code]["qty"] = so_item_details[sales_order][item_code].get(
|
||||
@@ -1784,7 +1786,7 @@ def get_item_data(item_code):
|
||||
return {
|
||||
"bom_no": item_details.get("bom_no"),
|
||||
"stock_uom": item_details.get("stock_uom"),
|
||||
# "description": item_details.get("description")
|
||||
"description": item_details.get("description"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1800,6 +1802,7 @@ def get_sub_assembly_items(
|
||||
skip_available_sub_assembly_item=False,
|
||||
):
|
||||
data = get_bom_children(parent=bom_no)
|
||||
precision = frappe.get_precision("Production Plan Sub Assembly Item", "qty")
|
||||
for d in data:
|
||||
if d.expandable:
|
||||
parent_item_code = frappe.get_cached_value("BOM", bom_no, "item")
|
||||
@@ -1837,7 +1840,7 @@ def get_sub_assembly_items(
|
||||
"is_sub_contracted_item": d.is_sub_contracted_item,
|
||||
"bom_level": indent,
|
||||
"indent": indent,
|
||||
"stock_qty": stock_qty,
|
||||
"stock_qty": flt(stock_qty, precision),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings, timeout
|
||||
from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, today
|
||||
from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, nowdate, nowtime, today
|
||||
|
||||
from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError
|
||||
from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry as make_stock_entry_from_jc
|
||||
@@ -2395,7 +2395,7 @@ class TestWorkOrder(FrappeTestCase):
|
||||
|
||||
stock_entry.submit()
|
||||
|
||||
def test_disassembly_order_with_qty_behavior(self):
|
||||
def test_disassembly_order_with_qty_from_wo_behavior(self):
|
||||
# Create raw material and FG item
|
||||
raw_item = make_item("Test Raw for Disassembly", {"is_stock_item": 1}).name
|
||||
fg_item = make_item("Test FG for Disassembly", {"is_stock_item": 1}).name
|
||||
@@ -2435,27 +2435,9 @@ class TestWorkOrder(FrappeTestCase):
|
||||
se_for_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
|
||||
se_for_manufacture.submit()
|
||||
|
||||
# Simulate a disassembly stock entry
|
||||
# Disassembly via WO required_items path (no source_stock_entry)
|
||||
disassemble_qty = 4
|
||||
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
|
||||
stock_entry.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": fg_item,
|
||||
"qty": disassemble_qty,
|
||||
"s_warehouse": wo.fg_warehouse,
|
||||
},
|
||||
)
|
||||
|
||||
for bom_item in bom.items:
|
||||
stock_entry.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": bom_item.item_code,
|
||||
"qty": (bom_item.qty / bom.quantity) * disassemble_qty,
|
||||
"t_warehouse": wo.source_warehouse,
|
||||
},
|
||||
)
|
||||
|
||||
wo.reload()
|
||||
stock_entry.save()
|
||||
@@ -2470,7 +2452,7 @@ class TestWorkOrder(FrappeTestCase):
|
||||
f"Expected FG qty {disassemble_qty}, found {finished_good_entry.qty}",
|
||||
)
|
||||
|
||||
# Assert raw materials
|
||||
# Assert raw materials - qty scaled from WO required_items
|
||||
for item in stock_entry.items:
|
||||
if item.item_code == fg_item:
|
||||
continue
|
||||
@@ -2494,10 +2476,35 @@ class TestWorkOrder(FrappeTestCase):
|
||||
f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}",
|
||||
)
|
||||
|
||||
# Second disassembly: explicitly linked to manufacture SE — verifies SE-linked path
|
||||
# (first disassembly auto-set source_stock_entry since there's only one manufacture entry)
|
||||
disassemble_qty_2 = 2
|
||||
stock_entry_2 = frappe.get_doc(
|
||||
make_stock_entry(
|
||||
wo.name, "Disassemble", disassemble_qty_2, source_stock_entry=se_for_manufacture.name
|
||||
)
|
||||
)
|
||||
stock_entry_2.save()
|
||||
stock_entry_2.submit()
|
||||
|
||||
# All rows must trace back to se_for_manufacture
|
||||
for item in stock_entry_2.items:
|
||||
self.assertEqual(item.against_stock_entry, se_for_manufacture.name)
|
||||
self.assertTrue(item.ste_detail)
|
||||
|
||||
# RM qty scaled from the manufacture SE rows
|
||||
rm_row = next((i for i in stock_entry_2.items if i.item_code == raw_item), None)
|
||||
expected_rm_qty = (bom.items[0].qty / bom.quantity) * disassemble_qty_2
|
||||
self.assertAlmostEqual(rm_row.qty, expected_rm_qty, places=3)
|
||||
|
||||
wo.reload()
|
||||
self.assertEqual(wo.disassembled_qty, disassemble_qty + disassemble_qty_2)
|
||||
|
||||
def test_disassembly_with_multiple_manufacture_entries(self):
|
||||
"""
|
||||
Test that disassembly does not create duplicate items when manufacturing
|
||||
is done in multiple batches (multiple manufacture stock entries).
|
||||
is done in multiple batches (multiple manufacture stock entries), including
|
||||
secondary/scrap items.
|
||||
|
||||
Scenario:
|
||||
1. Create Work Order for 10 units
|
||||
@@ -2506,11 +2513,17 @@ class TestWorkOrder(FrappeTestCase):
|
||||
4. Create Disassembly for 4 units
|
||||
5. Verify no duplicate items in the disassembly stock entry
|
||||
"""
|
||||
# Create RM and FG item
|
||||
# Create RM, scrap and FG item
|
||||
raw_item1 = make_item("Test Raw for Multi Batch Disassembly 1", {"is_stock_item": 1}).name
|
||||
raw_item2 = make_item("Test Raw for Multi Batch Disassembly 2", {"is_stock_item": 1}).name
|
||||
scrap_item = make_item("Test Scrap for Multi Batch Disassembly", {"is_stock_item": 1}).name
|
||||
fg_item = make_item("Test FG for Multi Batch Disassembly", {"is_stock_item": 1}).name
|
||||
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2)
|
||||
bom = make_bom(
|
||||
item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2, do_not_submit=True
|
||||
)
|
||||
# add scrap item
|
||||
bom.append("scrap_items", {"item_code": scrap_item, "stock_qty": 10})
|
||||
bom.submit()
|
||||
|
||||
# Create WO
|
||||
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
|
||||
@@ -2585,7 +2598,7 @@ class TestWorkOrder(FrappeTestCase):
|
||||
f"Found duplicate items in disassembly stock entry: {duplicates}",
|
||||
)
|
||||
|
||||
expected_items = 3 # FG item + 2 raw materials
|
||||
expected_items = 4 # FG item + 2 raw materials + 1 scrap item
|
||||
self.assertEqual(
|
||||
len(stock_entry.items),
|
||||
expected_items,
|
||||
@@ -2596,6 +2609,16 @@ class TestWorkOrder(FrappeTestCase):
|
||||
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
|
||||
self.assertEqual(fg_item_row.qty, disassemble_qty)
|
||||
|
||||
# Scrap item: should be taken from scrap warehouse in disassembly
|
||||
scrap_row = next((i for i in stock_entry.items if i.item_code == scrap_item), None)
|
||||
self.assertIsNotNone(scrap_row)
|
||||
self.assertEqual(scrap_row.is_scrap_item, 1)
|
||||
self.assertTrue(scrap_row.s_warehouse)
|
||||
self.assertFalse(scrap_row.t_warehouse)
|
||||
self.assertEqual(scrap_row.s_warehouse, wo.scrap_warehouse)
|
||||
# BOM has scrap_qty=10/FG, total produced = 10*10 = 100, disassemble 4/10 → 40
|
||||
self.assertEqual(scrap_row.qty, 40)
|
||||
|
||||
# RM quantities
|
||||
for bom_item in bom.items:
|
||||
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
|
||||
@@ -2607,19 +2630,57 @@ class TestWorkOrder(FrappeTestCase):
|
||||
msg=f"Raw material {bom_item.item_code} qty mismatch",
|
||||
)
|
||||
|
||||
# -- BOM-path disassembly (no source_stock_entry, no work_order) --
|
||||
|
||||
make_stock_entry_test_record(
|
||||
item_code=scrap_item,
|
||||
purpose="Material Receipt",
|
||||
target=wo.fg_warehouse,
|
||||
qty=50,
|
||||
basic_rate=10,
|
||||
)
|
||||
|
||||
bom_disassemble_qty = 2
|
||||
bom_se = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Entry",
|
||||
"stock_entry_type": "Disassemble",
|
||||
"purpose": "Disassemble",
|
||||
"from_bom": 1,
|
||||
"bom_no": bom.name,
|
||||
"fg_completed_qty": bom_disassemble_qty,
|
||||
"from_warehouse": wo.fg_warehouse,
|
||||
"to_warehouse": wo.wip_warehouse,
|
||||
"company": wo.company,
|
||||
"posting_date": nowdate(),
|
||||
"posting_time": nowtime(),
|
||||
}
|
||||
)
|
||||
bom_se.get_items()
|
||||
bom_se.save()
|
||||
bom_se.submit()
|
||||
|
||||
bom_scrap_row = next((i for i in bom_se.items if i.item_code == scrap_item), None)
|
||||
self.assertIsNotNone(bom_scrap_row, "Scrap item must appear in BOM-path disassembly")
|
||||
# v15: BOM scrap_qty=10/FG, no process_loss_per field → qty = 10 * 2 = 20
|
||||
self.assertEqual(
|
||||
bom_scrap_row.qty,
|
||||
20,
|
||||
f"BOM-path disassembly scrap qty mismatch; expected 20, got {bom_scrap_row.qty}",
|
||||
)
|
||||
|
||||
def test_disassembly_with_additional_rm_not_in_bom(self):
|
||||
"""
|
||||
Test that disassembly correctly handles additional raw materials that were
|
||||
manually added during manufacturing (not part of the BOM).
|
||||
Test that SE-linked disassembly includes additional raw materials
|
||||
that were manually added during manufacturing (not part of the BOM).
|
||||
|
||||
Scenario:
|
||||
1. Create Work Order for 10 units with 2 raw materials in BOM
|
||||
2. Transfer raw materials for manufacture
|
||||
3. Manufacture in 2 parts (3 units, then 7 units)
|
||||
4. In each manufacture entry, manually add an extra consumable item
|
||||
(not in BOM) in proportion to the manufactured qty
|
||||
5. Create Disassembly for 4 units
|
||||
6. Verify that the additional RM is included in disassembly with proportional qty
|
||||
5. Disassemble 3 units linked to first manufacture entry
|
||||
6. Verify additional RM is included with correct proportional qty from SE1
|
||||
"""
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
@@ -2655,9 +2716,8 @@ class TestWorkOrder(FrappeTestCase):
|
||||
se_for_material_transfer.save()
|
||||
se_for_material_transfer.submit()
|
||||
|
||||
# First Manufacture Entry - 3 units
|
||||
# First Manufacture Entry - 3 units with additional RM
|
||||
se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
|
||||
# Additional RM
|
||||
se_manufacture1.append(
|
||||
"items",
|
||||
{
|
||||
@@ -2670,9 +2730,8 @@ class TestWorkOrder(FrappeTestCase):
|
||||
se_manufacture1.save()
|
||||
se_manufacture1.submit()
|
||||
|
||||
# Second Manufacture Entry - 7 units
|
||||
# Second Manufacture Entry - 7 units with additional RM
|
||||
se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7))
|
||||
# AAdditional RM
|
||||
se_manufacture2.append(
|
||||
"items",
|
||||
{
|
||||
@@ -2688,13 +2747,15 @@ class TestWorkOrder(FrappeTestCase):
|
||||
wo.reload()
|
||||
self.assertEqual(wo.produced_qty, 10)
|
||||
|
||||
# Disassembly for 4 units
|
||||
disassemble_qty = 4
|
||||
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
|
||||
# Disassemble 3 units linked to first manufacture entry
|
||||
disassemble_qty = 3
|
||||
stock_entry = frappe.get_doc(
|
||||
make_stock_entry(wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture1.name)
|
||||
)
|
||||
stock_entry.save()
|
||||
stock_entry.submit()
|
||||
|
||||
# No duplicate
|
||||
# No duplicates
|
||||
item_counts = {}
|
||||
for item in stock_entry.items:
|
||||
item_code = item.item_code
|
||||
@@ -2707,16 +2768,15 @@ class TestWorkOrder(FrappeTestCase):
|
||||
f"Found duplicate items in disassembly stock entry: {duplicates}",
|
||||
)
|
||||
|
||||
# Additional RM qty
|
||||
# Additional RM should be included — qty proportional to SE1 (3 units -> 3 additional RM)
|
||||
additional_rm_row = next((i for i in stock_entry.items if i.item_code == additional_rm), None)
|
||||
self.assertIsNotNone(
|
||||
additional_rm_row,
|
||||
f"Additional raw material {additional_rm} not found in disassembly",
|
||||
)
|
||||
|
||||
# intentional full reversal as not part of BOM
|
||||
# eg: dies or consumables used during manufacturing
|
||||
expected_additional_rm_qty = 3 + 7
|
||||
# SE1 had 3 additional RM for 3 manufactured units, disassembling all 3
|
||||
expected_additional_rm_qty = 3
|
||||
self.assertAlmostEqual(
|
||||
additional_rm_row.qty,
|
||||
expected_additional_rm_qty,
|
||||
@@ -2724,7 +2784,7 @@ class TestWorkOrder(FrappeTestCase):
|
||||
msg=f"Additional RM qty mismatch: expected {expected_additional_rm_qty}, got {additional_rm_row.qty}",
|
||||
)
|
||||
|
||||
# RM qty
|
||||
# BOM RM qty — scaled from SE1's rows
|
||||
for bom_item in bom.items:
|
||||
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
|
||||
rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None)
|
||||
@@ -2740,6 +2800,7 @@ class TestWorkOrder(FrappeTestCase):
|
||||
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
|
||||
self.assertEqual(fg_item_row.qty, disassemble_qty)
|
||||
|
||||
# FG + 2 BOM RM + 1 additional RM = 4 items
|
||||
expected_items = 4
|
||||
self.assertEqual(
|
||||
len(stock_entry.items),
|
||||
@@ -2747,6 +2808,282 @@ class TestWorkOrder(FrappeTestCase):
|
||||
f"Expected {expected_items} items, found {len(stock_entry.items)}",
|
||||
)
|
||||
|
||||
# Verify traceability
|
||||
for item in stock_entry.items:
|
||||
self.assertEqual(item.against_stock_entry, se_manufacture1.name)
|
||||
self.assertTrue(item.ste_detail)
|
||||
|
||||
def test_disassembly_auto_sets_source_stock_entry(self):
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
raw_item = make_item("Test Raw Auto Set Disassembly", {"is_stock_item": 1}).name
|
||||
fg_item = make_item("Test FG Auto Set Disassembly", {"is_stock_item": 1}).name
|
||||
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item], rm_qty=2)
|
||||
|
||||
wo = make_wo_order_test_record(production_item=fg_item, qty=5, bom_no=bom.name, status="Not Started")
|
||||
|
||||
make_stock_entry_test_record(
|
||||
item_code=raw_item, purpose="Material Receipt", target=wo.wip_warehouse, qty=50, basic_rate=100
|
||||
)
|
||||
|
||||
se_transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty))
|
||||
for item in se_transfer.items:
|
||||
item.s_warehouse = wo.wip_warehouse
|
||||
se_transfer.save()
|
||||
se_transfer.submit()
|
||||
|
||||
se_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
|
||||
se_manufacture.submit()
|
||||
|
||||
# Disassemble without specifying source_stock_entry
|
||||
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", 3))
|
||||
stock_entry.save()
|
||||
|
||||
# source_stock_entry should be auto-set since only one manufacture entry
|
||||
self.assertEqual(stock_entry.source_stock_entry, se_manufacture.name)
|
||||
|
||||
# All items should have against_stock_entry linked
|
||||
for item in stock_entry.items:
|
||||
self.assertEqual(item.against_stock_entry, se_manufacture.name)
|
||||
self.assertTrue(item.ste_detail)
|
||||
|
||||
stock_entry.submit()
|
||||
|
||||
def test_disassembly_batch_tracked_items(self):
|
||||
from erpnext.stock.doctype.batch.batch import make_batch
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
wip_wh = "_Test Warehouse - _TC"
|
||||
|
||||
rm_item = make_item(
|
||||
"Test Batch RM for Disassembly SB",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TBRD-RM-.###",
|
||||
},
|
||||
).name
|
||||
fg_item = make_item(
|
||||
"Test Batch FG for Disassembly SB",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TBRD-FG-.###",
|
||||
},
|
||||
).name
|
||||
|
||||
bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2)
|
||||
wo = make_wo_order_test_record(
|
||||
production_item=fg_item,
|
||||
qty=6,
|
||||
bom_no=bom.name,
|
||||
skip_transfer=1,
|
||||
source_warehouse=wip_wh,
|
||||
status="Not Started",
|
||||
)
|
||||
|
||||
# Two separate RM receipts → two distinct batches (batch_1, batch_2)
|
||||
rm_receipt_1 = make_stock_entry_test_record(
|
||||
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
|
||||
)
|
||||
rm_batch_1 = get_batch_from_bundle(
|
||||
frappe.db.get_value(
|
||||
"Stock Entry Detail",
|
||||
{"parent": rm_receipt_1.name, "item_code": rm_item},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
)
|
||||
|
||||
rm_receipt_2 = make_stock_entry_test_record(
|
||||
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
|
||||
)
|
||||
rm_batch_2 = get_batch_from_bundle(
|
||||
frappe.db.get_value(
|
||||
"Stock Entry Detail",
|
||||
{"parent": rm_receipt_2.name, "item_code": rm_item},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertNotEqual(rm_batch_1, rm_batch_2, "Two receipts must create two distinct RM batches")
|
||||
|
||||
fg_batch_1 = make_batch(frappe._dict(item=fg_item))
|
||||
fg_batch_2 = make_batch(frappe._dict(item=fg_item))
|
||||
|
||||
# Manufacture entry 1 — 3 FG using batch_1 RM/FG
|
||||
se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
|
||||
for row in se_manufacture_1.items:
|
||||
if row.item_code == rm_item:
|
||||
row.batch_no = rm_batch_1
|
||||
row.use_serial_batch_fields = 1
|
||||
elif row.item_code == fg_item:
|
||||
row.batch_no = fg_batch_1
|
||||
row.use_serial_batch_fields = 1
|
||||
se_manufacture_1.save()
|
||||
se_manufacture_1.submit()
|
||||
|
||||
# Manufacture entry 2 — 3 FG using batch_2 RM/FG
|
||||
se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
|
||||
for row in se_manufacture_2.items:
|
||||
if row.item_code == rm_item:
|
||||
row.batch_no = rm_batch_2
|
||||
row.use_serial_batch_fields = 1
|
||||
elif row.item_code == fg_item:
|
||||
row.batch_no = fg_batch_2
|
||||
row.use_serial_batch_fields = 1
|
||||
se_manufacture_2.save()
|
||||
se_manufacture_2.submit()
|
||||
|
||||
# Disassemble 2 units from SE_1 only — must use SE_1's batches, not SE_2's
|
||||
disassemble_qty = 2
|
||||
stock_entry = frappe.get_doc(
|
||||
make_stock_entry(
|
||||
wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name
|
||||
)
|
||||
)
|
||||
stock_entry.save()
|
||||
stock_entry.submit()
|
||||
|
||||
# FG row: must use fg_batch_1 exclusively (fg_batch_2 must not appear)
|
||||
fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
|
||||
self.assertIsNotNone(fg_row)
|
||||
self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle")
|
||||
self.assertEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_1)
|
||||
self.assertNotEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_2)
|
||||
|
||||
# RM row: must use rm_batch_1 exclusively (rm_batch_2 must not appear)
|
||||
rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None)
|
||||
self.assertIsNotNone(rm_row)
|
||||
self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle")
|
||||
self.assertEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_1)
|
||||
self.assertNotEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_2)
|
||||
|
||||
# RM qty: 2 FG disassembled x 2 RM per FG = 4
|
||||
self.assertAlmostEqual(rm_row.qty, 4.0, places=3)
|
||||
|
||||
def test_disassembly_serial_tracked_items(self):
|
||||
from frappe.model.naming import make_autoname
|
||||
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
wip_wh = "_Test Warehouse - _TC"
|
||||
|
||||
rm_item = make_item(
|
||||
"Test Serial RM for Disassembly SB",
|
||||
{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-RM-.####"},
|
||||
).name
|
||||
fg_item = make_item(
|
||||
"Test Serial FG for Disassembly SB",
|
||||
{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-FG-.####"},
|
||||
).name
|
||||
|
||||
bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2)
|
||||
wo = make_wo_order_test_record(
|
||||
production_item=fg_item,
|
||||
qty=6,
|
||||
bom_no=bom.name,
|
||||
skip_transfer=1,
|
||||
source_warehouse=wip_wh,
|
||||
status="Not Started",
|
||||
)
|
||||
|
||||
# Two separate RM receipts → two disjoint sets of serial numbers
|
||||
rm_receipt_1 = make_stock_entry_test_record(
|
||||
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
|
||||
)
|
||||
rm_serials_1 = get_serial_nos_from_bundle(
|
||||
frappe.db.get_value(
|
||||
"Stock Entry Detail",
|
||||
{"parent": rm_receipt_1.name, "item_code": rm_item},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
)
|
||||
self.assertEqual(len(rm_serials_1), 6)
|
||||
|
||||
rm_receipt_2 = make_stock_entry_test_record(
|
||||
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
|
||||
)
|
||||
rm_serials_2 = get_serial_nos_from_bundle(
|
||||
frappe.db.get_value(
|
||||
"Stock Entry Detail",
|
||||
{"parent": rm_receipt_2.name, "item_code": rm_item},
|
||||
"serial_and_batch_bundle",
|
||||
)
|
||||
)
|
||||
self.assertEqual(len(rm_serials_2), 6)
|
||||
self.assertFalse(
|
||||
set(rm_serials_1) & set(rm_serials_2), "Two receipts must produce disjoint RM serial sets"
|
||||
)
|
||||
|
||||
# Pre-generate two sets of FG serial numbers
|
||||
series = frappe.db.get_value("Item", fg_item, "serial_no_series")
|
||||
fg_serials_1 = [make_autoname(series) for _ in range(3)]
|
||||
fg_serials_2 = [make_autoname(series) for _ in range(3)]
|
||||
|
||||
# Manufacture entry 1 — consumes rm_serials_1, produces fg_serials_1
|
||||
se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
|
||||
for row in se_manufacture_1.items:
|
||||
if row.item_code == rm_item:
|
||||
row.serial_no = "\n".join(rm_serials_1)
|
||||
row.use_serial_batch_fields = 1
|
||||
elif row.item_code == fg_item:
|
||||
row.serial_no = "\n".join(fg_serials_1)
|
||||
row.use_serial_batch_fields = 1
|
||||
se_manufacture_1.save()
|
||||
se_manufacture_1.submit()
|
||||
|
||||
# Manufacture entry 2 — consumes rm_serials_2, produces fg_serials_2
|
||||
se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
|
||||
for row in se_manufacture_2.items:
|
||||
if row.item_code == rm_item:
|
||||
row.serial_no = "\n".join(rm_serials_2)
|
||||
row.use_serial_batch_fields = 1
|
||||
elif row.item_code == fg_item:
|
||||
row.serial_no = "\n".join(fg_serials_2)
|
||||
row.use_serial_batch_fields = 1
|
||||
se_manufacture_2.save()
|
||||
se_manufacture_2.submit()
|
||||
|
||||
# Disassemble 2 units from SE_1 only — must use SE_1's serials, not SE_2's
|
||||
disassemble_qty = 2
|
||||
stock_entry = frappe.get_doc(
|
||||
make_stock_entry(
|
||||
wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name
|
||||
)
|
||||
)
|
||||
stock_entry.save()
|
||||
stock_entry.submit()
|
||||
|
||||
# FG row: 2 serials consumed — must be subset of fg_serials_1, disjoint from fg_serials_2
|
||||
fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
|
||||
self.assertIsNotNone(fg_row)
|
||||
self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle")
|
||||
fg_dasm_serials = get_serial_nos_from_bundle(fg_row.serial_and_batch_bundle)
|
||||
self.assertEqual(len(fg_dasm_serials), disassemble_qty)
|
||||
self.assertTrue(set(fg_dasm_serials).issubset(set(fg_serials_1)))
|
||||
self.assertFalse(
|
||||
set(fg_dasm_serials) & set(fg_serials_2), "Disassembly must not use SE_2's FG serials"
|
||||
)
|
||||
|
||||
# RM row: 4 serials returned (2 FG x 2 RM each) — subset of rm_serials_1, disjoint from rm_serials_2
|
||||
rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None)
|
||||
self.assertIsNotNone(rm_row)
|
||||
self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle")
|
||||
rm_dasm_serials = get_serial_nos_from_bundle(rm_row.serial_and_batch_bundle)
|
||||
self.assertEqual(len(rm_dasm_serials), disassemble_qty * 2)
|
||||
self.assertTrue(set(rm_dasm_serials).issubset(set(rm_serials_1)))
|
||||
self.assertFalse(
|
||||
set(rm_dasm_serials) & set(rm_serials_2), "Disassembly must not use SE_2's RM serials"
|
||||
)
|
||||
|
||||
def test_components_alternate_item_for_bom_based_manufacture_entry(self):
|
||||
frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")
|
||||
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1)
|
||||
|
||||
@@ -243,6 +243,20 @@ frappe.ui.form.on("Work Order", {
|
||||
|
||||
frm.trigger("add_custom_button_to_return_components");
|
||||
frm.trigger("allow_alternative_item");
|
||||
frm.trigger("toggle_items_editable");
|
||||
},
|
||||
|
||||
toggle_items_editable(frm) {
|
||||
let allow_edit = true;
|
||||
if (!frm.doc.__onload?.allow_editing_items) allow_edit = false;
|
||||
|
||||
frm.set_df_property("required_items", "cannot_delete_rows", !allow_edit);
|
||||
frm.set_df_property("required_items", "cannot_add_rows", !allow_edit);
|
||||
|
||||
const grid = frm.fields_dict["required_items"].grid;
|
||||
grid.update_docfield_property("item_code", "read_only", !allow_edit);
|
||||
grid.update_docfield_property("required_qty", "read_only", !allow_edit);
|
||||
grid.refresh();
|
||||
},
|
||||
|
||||
add_custom_button_to_return_components: function (frm) {
|
||||
@@ -401,7 +415,7 @@ frappe.ui.form.on("Work Order", {
|
||||
|
||||
make_disassembly_order(frm) {
|
||||
erpnext.work_order
|
||||
.show_prompt_for_qty_input(frm, "Disassemble")
|
||||
.show_disassembly_prompt(frm)
|
||||
.then((data) => {
|
||||
if (flt(data.qty) <= 0) {
|
||||
frappe.msgprint(__("Disassemble Qty cannot be less than or equal to <b>0</b>."));
|
||||
@@ -411,11 +425,14 @@ frappe.ui.form.on("Work Order", {
|
||||
work_order_id: frm.doc.name,
|
||||
purpose: "Disassemble",
|
||||
qty: data.qty,
|
||||
source_stock_entry: data.source_stock_entry,
|
||||
});
|
||||
})
|
||||
.then((stock_entry) => {
|
||||
frappe.model.sync(stock_entry);
|
||||
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
|
||||
if (stock_entry) {
|
||||
frappe.model.sync(stock_entry);
|
||||
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -425,10 +442,11 @@ frappe.ui.form.on("Work Order", {
|
||||
var added_min = false;
|
||||
|
||||
// produced qty
|
||||
var title = __("{0} items produced", [frm.doc.produced_qty]);
|
||||
let produced_qty = frm.doc.produced_qty - frm.doc.disassembled_qty;
|
||||
var title = __("{0} items produced", [produced_qty]);
|
||||
bars.push({
|
||||
title: title,
|
||||
width: (frm.doc.produced_qty / frm.doc.qty) * 100 + "%",
|
||||
width: (flt(produced_qty) / frm.doc.qty) * 100 + "%",
|
||||
progress_class: "progress-bar-success",
|
||||
});
|
||||
if (bars[0].width == "0%") {
|
||||
@@ -445,14 +463,27 @@ frappe.ui.form.on("Work Order", {
|
||||
if (pending_complete > 0) {
|
||||
var width = (pending_complete / frm.doc.qty) * 100 - added_min;
|
||||
title = __("{0} items in progress", [pending_complete]);
|
||||
let progress_class = "progress-bar-warning";
|
||||
if (frm.doc.status == "Closed") {
|
||||
if (frm.doc.required_items.find((d) => d.returned_qty > 0)) {
|
||||
title = __("{0} items returned", [pending_complete]);
|
||||
progress_class = "progress-bar-warning";
|
||||
} else {
|
||||
title = __("{0} items to return", [pending_complete]);
|
||||
progress_class = "progress-bar-info";
|
||||
}
|
||||
}
|
||||
|
||||
bars.push({
|
||||
title: title,
|
||||
width: (width > 100 ? "99.5" : width) + "%",
|
||||
progress_class: "progress-bar-warning",
|
||||
progress_class: progress_class,
|
||||
});
|
||||
message = message + ". " + title;
|
||||
}
|
||||
}
|
||||
|
||||
//process loss qty
|
||||
if (frm.doc.process_loss_qty) {
|
||||
var process_loss_width = (frm.doc.process_loss_qty / frm.doc.qty) * 100;
|
||||
title = __("{0} items lost during process.", [frm.doc.process_loss_qty]);
|
||||
@@ -463,6 +494,19 @@ frappe.ui.form.on("Work Order", {
|
||||
});
|
||||
message = message + ". " + title;
|
||||
}
|
||||
|
||||
// disassembled qty
|
||||
if (frm.doc.disassembled_qty) {
|
||||
var disassembled_width = (frm.doc.disassembled_qty / frm.doc.qty) * 100;
|
||||
title = __("{0} items disassembled", [frm.doc.disassembled_qty]);
|
||||
bars.push({
|
||||
title: title,
|
||||
width: disassembled_width + "%",
|
||||
progress_class: "progress-bar-secondary",
|
||||
});
|
||||
message = message + ". " + title;
|
||||
}
|
||||
|
||||
frm.dashboard.add_progress(__("Status"), bars, message);
|
||||
},
|
||||
|
||||
@@ -838,6 +882,60 @@ erpnext.work_order = {
|
||||
return flt(max, precision("qty"));
|
||||
},
|
||||
|
||||
show_disassembly_prompt: function (frm) {
|
||||
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
|
||||
|
||||
let fields = [
|
||||
{
|
||||
fieldtype: "Link",
|
||||
label: __("Source Manufacture Entry"),
|
||||
fieldname: "source_stock_entry",
|
||||
options: "Stock Entry",
|
||||
description: __("Optional. Select a specific manufacture entry to reverse."),
|
||||
get_query: () => {
|
||||
return {
|
||||
filters: {
|
||||
work_order: frm.doc.name,
|
||||
purpose: "Manufacture",
|
||||
docstatus: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
onchange: async function () {
|
||||
if (!frm.disassembly_prompt) return;
|
||||
|
||||
let se_name = this.value;
|
||||
let qty = max_qty;
|
||||
if (se_name) {
|
||||
qty = await frappe.xcall(
|
||||
"erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty",
|
||||
{ stock_entry_name: se_name }
|
||||
);
|
||||
}
|
||||
|
||||
frm.disassembly_prompt.set_value("qty", qty);
|
||||
frm.disassembly_prompt.fields_dict.qty.set_description(__("Max: {0}", [qty]));
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldtype: "Float",
|
||||
label: __("Qty for {0}", [__("Disassemble")]),
|
||||
fieldname: "qty",
|
||||
description: __("Max: {0}", [max_qty]),
|
||||
default: max_qty,
|
||||
},
|
||||
];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
frm.disassembly_prompt = frappe.prompt(
|
||||
fields,
|
||||
(data) => resolve(data),
|
||||
__("Disassemble"),
|
||||
__("Create")
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
show_prompt_for_qty_input: function (frm, purpose) {
|
||||
let max = this.get_max_transferable_qty(frm, purpose);
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ class WorkOrder(Document):
|
||||
|
||||
def onload(self):
|
||||
ms = frappe.get_doc("Manufacturing Settings")
|
||||
self.set_onload("allow_editing_items", ms.allow_editing_of_items_and_quantities_in_work_order)
|
||||
self.set_onload("material_consumption", ms.material_consumption)
|
||||
self.set_onload("backflush_raw_materials_based_on", ms.backflush_raw_materials_based_on)
|
||||
self.set_onload("overproduction_percentage", ms.overproduction_percentage_for_work_order)
|
||||
@@ -167,7 +168,11 @@ class WorkOrder(Document):
|
||||
|
||||
validate_uom_is_integer(self, "stock_uom", ["required_qty"])
|
||||
|
||||
self.set_required_items(reset_only_qty=len(self.get("required_items")))
|
||||
if not len(self.get("required_items")) or not frappe.db.get_single_value(
|
||||
"Manufacturing Settings", "allow_editing_of_items_and_quantities_in_work_order"
|
||||
):
|
||||
self.set_required_items(reset_only_qty=len(self.get("required_items")))
|
||||
|
||||
self.validate_operations_sequence()
|
||||
|
||||
def validate_operations_sequence(self):
|
||||
@@ -220,39 +225,52 @@ class WorkOrder(Document):
|
||||
)
|
||||
|
||||
def validate_sales_order(self):
|
||||
if self.production_plan_sub_assembly_item:
|
||||
return
|
||||
|
||||
if self.sales_order:
|
||||
self.check_sales_order_on_hold_or_close()
|
||||
so = frappe.db.sql(
|
||||
"""
|
||||
select so.name, so_item.delivery_date, so.project
|
||||
from `tabSales Order` so
|
||||
inner join `tabSales Order Item` so_item on so_item.parent = so.name
|
||||
left join `tabProduct Bundle Item` pk_item on so_item.item_code = pk_item.parent
|
||||
where so.name=%s and so.docstatus = 1
|
||||
and so.skip_delivery_note = 0 and (
|
||||
so_item.item_code=%s or
|
||||
pk_item.item_code=%s )
|
||||
""",
|
||||
(self.sales_order, self.production_item, self.production_item),
|
||||
as_dict=1,
|
||||
|
||||
SalesOrder = frappe.qb.DocType("Sales Order")
|
||||
SalesOrderItem = frappe.qb.DocType("Sales Order Item")
|
||||
PackedItem = frappe.qb.DocType("Packed Item")
|
||||
ProductBundleItem = frappe.qb.DocType("Product Bundle Item")
|
||||
|
||||
so = (
|
||||
frappe.qb.from_(SalesOrder)
|
||||
.inner_join(SalesOrderItem)
|
||||
.on(SalesOrderItem.parent == SalesOrder.name)
|
||||
.left_join(ProductBundleItem)
|
||||
.on(ProductBundleItem.parent == SalesOrderItem.item_code)
|
||||
.select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
|
||||
.where(
|
||||
(SalesOrder.skip_delivery_note == 0)
|
||||
& (SalesOrder.docstatus == 1)
|
||||
& (SalesOrder.name == self.sales_order)
|
||||
& (
|
||||
(SalesOrderItem.item_code == self.production_item)
|
||||
| (ProductBundleItem.item_code == self.production_item)
|
||||
)
|
||||
)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
if not so:
|
||||
so = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
so.name, so_item.delivery_date, so.project
|
||||
from
|
||||
`tabSales Order` so, `tabSales Order Item` so_item, `tabPacked Item` packed_item
|
||||
where so.name=%s
|
||||
and so.name=so_item.parent
|
||||
and so.name=packed_item.parent
|
||||
and so.skip_delivery_note = 0
|
||||
and so_item.item_code = packed_item.parent_item
|
||||
and so.docstatus = 1 and packed_item.item_code=%s
|
||||
""",
|
||||
(self.sales_order, self.production_item),
|
||||
as_dict=1,
|
||||
so = (
|
||||
frappe.qb.from_(SalesOrder)
|
||||
.inner_join(SalesOrderItem)
|
||||
.on(SalesOrderItem.parent == SalesOrder.name)
|
||||
.inner_join(PackedItem)
|
||||
.on(PackedItem.parent == SalesOrder.name)
|
||||
.select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date)
|
||||
.where(
|
||||
(SalesOrder.name == self.sales_order)
|
||||
& (SalesOrder.skip_delivery_note == 0)
|
||||
& (SalesOrderItem.item_code == PackedItem.parent_item)
|
||||
& (SalesOrder.docstatus == 1)
|
||||
& (PackedItem.item_code == self.production_item)
|
||||
)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
if len(so):
|
||||
@@ -370,7 +388,7 @@ class WorkOrder(Document):
|
||||
if self.docstatus == 0:
|
||||
status = "Draft"
|
||||
elif self.docstatus == 1:
|
||||
if status != "Stopped":
|
||||
if status not in ["Closed", "Stopped"]:
|
||||
status = "Not Started"
|
||||
if flt(self.material_transferred_for_manufacturing) > 0:
|
||||
status = "In Process"
|
||||
@@ -426,7 +444,7 @@ class WorkOrder(Document):
|
||||
|
||||
from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item
|
||||
|
||||
if self.sales_order and self.sales_order_item:
|
||||
if self.sales_order and self.sales_order_item and not self.production_plan_sub_assembly_item:
|
||||
update_produced_qty_in_so_item(self.sales_order, self.sales_order_item)
|
||||
|
||||
if self.production_plan:
|
||||
@@ -818,7 +836,7 @@ class WorkOrder(Document):
|
||||
doc.db_set("status", doc.status)
|
||||
|
||||
def update_work_order_qty_in_so(self):
|
||||
if not self.sales_order and not self.sales_order_item:
|
||||
if (not self.sales_order and not self.sales_order_item) or self.production_plan_sub_assembly_item:
|
||||
return
|
||||
|
||||
total_bundle_qty = 1
|
||||
@@ -1359,7 +1377,11 @@ def get_item_details(item, project=None, skip_bom_info=False, throw=True):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_work_order(bom_no, item, qty=0, project=None, variant_items=None, use_multi_level_bom=None):
|
||||
def make_work_order(
|
||||
bom_no, item, qty=0, company=None, project=None, variant_items=None, use_multi_level_bom=None
|
||||
):
|
||||
from erpnext import get_default_company
|
||||
|
||||
if not frappe.has_permission("Work Order", "write"):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
@@ -1374,6 +1396,7 @@ def make_work_order(bom_no, item, qty=0, project=None, variant_items=None, use_m
|
||||
|
||||
wo_doc = frappe.new_doc("Work Order")
|
||||
wo_doc.production_item = item
|
||||
wo_doc.company = company or get_default_company()
|
||||
wo_doc.update(item_details)
|
||||
wo_doc.bom_no = bom_no
|
||||
wo_doc.use_multi_level_bom = cint(use_multi_level_bom)
|
||||
@@ -1462,7 +1485,13 @@ def set_work_order_ops(name):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
|
||||
def make_stock_entry(
|
||||
work_order_id: str,
|
||||
purpose: str,
|
||||
qty: float | None = None,
|
||||
target_warehouse: str | None = None,
|
||||
source_stock_entry: str | None = None,
|
||||
):
|
||||
work_order = frappe.get_doc("Work Order", work_order_id)
|
||||
if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"):
|
||||
wip_warehouse = work_order.wip_warehouse
|
||||
@@ -1499,6 +1528,8 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
|
||||
if purpose == "Disassemble":
|
||||
stock_entry.from_warehouse = work_order.fg_warehouse
|
||||
stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse
|
||||
if source_stock_entry:
|
||||
stock_entry.source_stock_entry = source_stock_entry
|
||||
|
||||
stock_entry.set_stock_entry_type()
|
||||
stock_entry.get_items()
|
||||
@@ -1509,6 +1540,28 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
|
||||
return stock_entry.as_dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_disassembly_available_qty(stock_entry_name: str, current_se_name: str | None = None) -> float:
|
||||
se = frappe.db.get_value("Stock Entry", stock_entry_name, ["fg_completed_qty"], as_dict=True)
|
||||
if not se:
|
||||
return 0.0
|
||||
|
||||
filters = {
|
||||
"source_stock_entry": stock_entry_name,
|
||||
"purpose": "Disassemble",
|
||||
"docstatus": 1,
|
||||
}
|
||||
|
||||
if current_se_name:
|
||||
filters["name"] = ("!=", current_se_name)
|
||||
|
||||
already_disassembled = flt(
|
||||
frappe.db.get_value("Stock Entry", filters, "sum(fg_completed_qty)", order_by=None)
|
||||
)
|
||||
|
||||
return flt(se.fg_completed_qty) - already_disassembled
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_default_warehouse():
|
||||
doc = frappe.get_cached_doc("Manufacturing Settings")
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-11-19 15:48:16.823384",
|
||||
"modified": "2025-12-02 11:16:05.081613",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order Item",
|
||||
|
||||
@@ -79,9 +79,6 @@ class Workstation(Document):
|
||||
self.total_working_hours += row.hours
|
||||
|
||||
def validate_working_hours(self, row):
|
||||
if not (row.start_time and row.end_time):
|
||||
frappe.throw(_("Row #{0}: Start Time and End Time are required").format(row.idx))
|
||||
|
||||
if get_time(row.start_time) >= get_time(row.end_time):
|
||||
frappe.throw(_("Row #{0}: Start Time must be before End Time").format(row.idx))
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ erpnext.patches.v12_0.make_item_manufacturer
|
||||
erpnext.patches.v12_0.move_item_tax_to_item_tax_template
|
||||
erpnext.patches.v11_1.set_variant_based_on
|
||||
erpnext.patches.v11_1.woocommerce_set_creation_user
|
||||
erpnext.patches.v11_1.rename_depends_on_lwp
|
||||
execute:frappe.delete_doc("Report", "Inactive Items")
|
||||
erpnext.patches.v11_1.delete_scheduling_tool
|
||||
erpnext.patches.v12_0.rename_tolerance_fields
|
||||
@@ -432,3 +431,4 @@ erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges
|
||||
erpnext.patches.v16_0.set_ordered_qty_in_quotation_item
|
||||
erpnext.patches.v15_0.replace_http_with_https_in_sales_partner
|
||||
erpnext.patches.v16_0.add_portal_redirects
|
||||
erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import frappe
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
|
||||
def execute():
|
||||
PurchaseOrderItem = DocType("Purchase Order Item")
|
||||
MaterialRequestItem = DocType("Material Request Item")
|
||||
|
||||
poi_query = (
|
||||
frappe.qb.from_(PurchaseOrderItem)
|
||||
.select(PurchaseOrderItem.sales_order_item, Sum(PurchaseOrderItem.stock_qty))
|
||||
.where(PurchaseOrderItem.sales_order_item.isnotnull() & PurchaseOrderItem.docstatus == 1)
|
||||
.groupby(PurchaseOrderItem.sales_order_item)
|
||||
)
|
||||
|
||||
mri_query = (
|
||||
frappe.qb.from_(MaterialRequestItem)
|
||||
.select(MaterialRequestItem.sales_order_item, Sum(MaterialRequestItem.stock_qty))
|
||||
.where(MaterialRequestItem.sales_order_item.isnotnull() & MaterialRequestItem.docstatus == 1)
|
||||
.groupby(MaterialRequestItem.sales_order_item)
|
||||
)
|
||||
|
||||
poi_data = poi_query.run()
|
||||
mri_data = mri_query.run()
|
||||
|
||||
updates_against_poi = {data[0]: {"ordered_qty": data[1]} for data in poi_data}
|
||||
updates_against_mri = {data[0]: {"requested_qty": data[1], "ordered_qty": 0} for data in mri_data}
|
||||
|
||||
frappe.db.auto_commit_on_many_writes = 1
|
||||
frappe.db.bulk_update("Sales Order Item", updates_against_mri)
|
||||
frappe.db.bulk_update("Sales Order Item", updates_against_poi)
|
||||
frappe.db.auto_commit_on_many_writes = 0
|
||||
@@ -47,21 +47,8 @@ def create_customer_or_supplier():
|
||||
if party_exists(doctype, user):
|
||||
return
|
||||
|
||||
party = frappe.new_doc(doctype)
|
||||
fullname = frappe.utils.get_fullname(user)
|
||||
|
||||
if not doctype == "Customer":
|
||||
party.update(
|
||||
{
|
||||
"supplier_name": fullname,
|
||||
"supplier_group": "All Supplier Groups",
|
||||
"supplier_type": "Individual",
|
||||
}
|
||||
)
|
||||
|
||||
party.flags.ignore_mandatory = True
|
||||
party.insert(ignore_permissions=True)
|
||||
|
||||
party = create_party(doctype, fullname)
|
||||
alternate_doctype = "Customer" if doctype == "Supplier" else "Supplier"
|
||||
|
||||
if party_exists(alternate_doctype, user):
|
||||
@@ -69,6 +56,22 @@ def create_customer_or_supplier():
|
||||
fullname += "-" + doctype
|
||||
|
||||
create_party_contact(doctype, fullname, user, party.name)
|
||||
return party
|
||||
|
||||
|
||||
def create_party(doctype, fullname):
|
||||
party = frappe.new_doc(doctype)
|
||||
# Can't set parent party as group
|
||||
|
||||
party.update(
|
||||
{
|
||||
f"{doctype.lower()}_name": fullname,
|
||||
f"{doctype.lower()}_type": "Individual",
|
||||
}
|
||||
)
|
||||
|
||||
party.flags.ignore_mandatory = True
|
||||
party.insert(ignore_permissions=True)
|
||||
|
||||
return party
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import frappe
|
||||
from frappe.tests.utils import change_settings
|
||||
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.timesheet.timesheet import OverlapError, make_sales_invoice
|
||||
from erpnext.setup.doctype.employee.test_employee import make_employee
|
||||
@@ -202,6 +203,58 @@ class TestTimesheet(unittest.TestCase):
|
||||
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,
|
||||
@@ -211,6 +264,7 @@ def make_timesheet(
|
||||
project=None,
|
||||
task=None,
|
||||
company=None,
|
||||
do_not_submit=False,
|
||||
):
|
||||
update_activity_type(activity_type)
|
||||
timesheet = frappe.new_doc("Timesheet")
|
||||
@@ -237,7 +291,8 @@ def make_timesheet(
|
||||
else:
|
||||
timesheet.save(ignore_permissions=True)
|
||||
|
||||
timesheet.submit()
|
||||
if not do_not_submit:
|
||||
timesheet.submit()
|
||||
|
||||
return timesheet
|
||||
|
||||
|
||||
@@ -260,6 +260,33 @@ frappe.ui.form.on("Timesheet", {
|
||||
parent_project: function (frm) {
|
||||
set_project_in_timelog(frm);
|
||||
},
|
||||
|
||||
employee: function (frm) {
|
||||
if (frm.doc.employee && frm.doc.time_logs) {
|
||||
const selected_employee = frm.doc.employee;
|
||||
frm.doc.time_logs.forEach((row) => {
|
||||
if (row.activity_type) {
|
||||
frappe.call({
|
||||
method: "erpnext.projects.doctype.timesheet.timesheet.get_activity_cost",
|
||||
args: {
|
||||
employee: frm.doc.employee,
|
||||
activity_type: row.activity_type,
|
||||
currency: frm.doc.currency,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
if (selected_employee !== frm.doc.employee) return;
|
||||
row.billing_rate = r.message["billing_rate"];
|
||||
row.costing_rate = r.message["costing_rate"];
|
||||
frm.refresh_fields("time_logs");
|
||||
calculate_billing_costing_amount(frm, row.doctype, row.name);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Timesheet Detail", {
|
||||
|
||||
@@ -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": "2023-04-20 15:59:11.107831",
|
||||
"modified": "2026-04-06 22:30:28.513139",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"name": "Timesheet",
|
||||
|
||||
@@ -50,7 +50,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
|
||||
@@ -126,6 +128,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"
|
||||
|
||||
@@ -423,7 +428,9 @@ def get_timesheet_data(name, project):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_sales_invoice(source_name, item_code=None, customer=None, currency=None):
|
||||
def make_sales_invoice(
|
||||
source_name: str, item_code: str | None = None, customer: str | None = None, currency: str | None = None
|
||||
):
|
||||
target = frappe.new_doc("Sales Invoice")
|
||||
timesheet = frappe.get_doc("Timesheet", source_name)
|
||||
|
||||
@@ -452,7 +459,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,9 @@
|
||||
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"];
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ erpnext.buying = {
|
||||
|
||||
this.toggle_subcontracting_fields();
|
||||
super.refresh();
|
||||
this.prevent_past_schedule_dates(this.frm);
|
||||
}
|
||||
|
||||
toggle_subcontracting_fields() {
|
||||
@@ -183,6 +184,28 @@ erpnext.buying = {
|
||||
erpnext.utils.set_letter_head(this.frm)
|
||||
}
|
||||
|
||||
schedule_date(doc, cdt, cdn) {
|
||||
if (doc.schedule_date && !cdt.endsWith(" Item")) {
|
||||
doc.items.forEach((d) => {
|
||||
frappe.model.set_value(d.doctype, d.name, "schedule_date", doc.schedule_date);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
transaction_date() {
|
||||
super.transaction_date();
|
||||
this.frm.set_value("schedule_date", "");
|
||||
this.prevent_past_schedule_dates(this.frm);
|
||||
}
|
||||
|
||||
prevent_past_schedule_dates(frm) {
|
||||
if (frm.doc.transaction_date && frm.fields_dict["schedule_date"]) {
|
||||
frm.fields_dict["schedule_date"].datepicker?.update({
|
||||
minDate: new Date(frm.doc.transaction_date),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
supplier_address() {
|
||||
erpnext.utils.get_address_display(this.frm);
|
||||
erpnext.utils.set_taxes_from_address(this.frm, "supplier_address", "supplier_address", "supplier_address");
|
||||
|
||||
@@ -173,9 +173,15 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
if (!tax.dont_recompute_tax) {
|
||||
tax.item_wise_tax_detail = {};
|
||||
}
|
||||
var tax_fields = ["total", "tax_amount_after_discount_amount",
|
||||
"tax_amount_for_current_item", "grand_total_for_current_item",
|
||||
"tax_fraction_for_current_item", "grand_total_fraction_for_current_item"];
|
||||
var tax_fields = [
|
||||
"net_amount",
|
||||
"total",
|
||||
"tax_amount_after_discount_amount",
|
||||
"tax_amount_for_current_item",
|
||||
"grand_total_for_current_item",
|
||||
"tax_fraction_for_current_item",
|
||||
"grand_total_fraction_for_current_item",
|
||||
];
|
||||
|
||||
if (cstr(tax.charge_type) != "Actual" &&
|
||||
!(me.discount_amount_applied && me.frm.doc.apply_discount_on=="Grand Total")) {
|
||||
@@ -363,9 +369,14 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
var item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
|
||||
$.each(doc.taxes, function(i, tax) {
|
||||
// tax_amount represents the amount of tax for the current step
|
||||
var current_tax_amount = me.get_current_tax_amount(item, tax, item_tax_map);
|
||||
var [current_net_amount, current_tax_amount] = me.get_current_tax_amount(
|
||||
item,
|
||||
tax,
|
||||
item_tax_map
|
||||
);
|
||||
if (frappe.flags.round_row_wise_tax) {
|
||||
current_tax_amount = flt(current_tax_amount, precision("tax_amount", tax));
|
||||
current_net_amount = flt(current_net_amount, precision("net_amount", tax));
|
||||
}
|
||||
|
||||
// Adjust divisional loss to the last item
|
||||
@@ -380,6 +391,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
if (tax.charge_type != "Actual" &&
|
||||
!(me.discount_amount_applied && me.frm.doc.apply_discount_on=="Grand Total")) {
|
||||
tax.tax_amount += current_tax_amount;
|
||||
tax.net_amount += current_net_amount;
|
||||
}
|
||||
|
||||
// store tax_amount for current item as it will be used for
|
||||
@@ -430,8 +442,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
|
||||
for (const [i, tax] of doc.taxes.entries()) {
|
||||
me.round_off_totals(tax);
|
||||
me.set_in_company_currency(tax,
|
||||
["tax_amount", "tax_amount_after_discount_amount"]);
|
||||
me.set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount", "net_amount"]);
|
||||
|
||||
me.round_off_base_values(tax);
|
||||
|
||||
@@ -464,6 +475,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
get_current_tax_amount(item, tax, item_tax_map) {
|
||||
var tax_rate = this._get_tax_rate(tax, item_tax_map);
|
||||
var current_tax_amount = 0.0;
|
||||
var current_net_amount = 0.0;
|
||||
|
||||
// To set row_id by default as previous row.
|
||||
if(["On Previous Row Amount", "On Previous Row Total"].includes(tax.charge_type)) {
|
||||
@@ -476,21 +488,27 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
}
|
||||
}
|
||||
if(tax.charge_type == "Actual") {
|
||||
current_net_amount = item.net_amount
|
||||
// distribute the tax amount proportionally to each item row
|
||||
var actual = flt(tax.tax_amount, precision("tax_amount", tax));
|
||||
current_tax_amount = this.frm.doc.net_total ?
|
||||
((item.net_amount / this.frm.doc.net_total) * actual) : 0.0;
|
||||
|
||||
} else if(tax.charge_type == "On Net Total") {
|
||||
if (tax.account_head in item_tax_map) {
|
||||
current_net_amount = item.net_amount
|
||||
};
|
||||
current_tax_amount = (tax_rate / 100.0) * item.net_amount;
|
||||
} else if(tax.charge_type == "On Previous Row Amount") {
|
||||
current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item
|
||||
current_tax_amount = (tax_rate / 100.0) *
|
||||
this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item;
|
||||
|
||||
} else if(tax.charge_type == "On Previous Row Total") {
|
||||
current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_for_current_item
|
||||
current_tax_amount = (tax_rate / 100.0) *
|
||||
this.frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_for_current_item;
|
||||
} else if (tax.charge_type == "On Item Quantity") {
|
||||
// don't sum current net amount due to the field being a currency field
|
||||
current_tax_amount = tax_rate * item.qty;
|
||||
}
|
||||
|
||||
@@ -498,7 +516,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
this.set_item_wise_tax(item, tax, tax_rate, current_tax_amount);
|
||||
}
|
||||
|
||||
return current_tax_amount;
|
||||
return [current_net_amount, current_tax_amount];
|
||||
}
|
||||
|
||||
set_item_wise_tax(item, tax, tax_rate, current_tax_amount) {
|
||||
@@ -532,7 +550,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
}
|
||||
|
||||
tax.tax_amount = flt(tax.tax_amount, precision("tax_amount", tax));
|
||||
tax.tax_amount_after_discount_amount = flt(tax.tax_amount_after_discount_amount, precision("tax_amount", tax));
|
||||
tax.net_amount = flt(tax.net_amount, precision("net_amount", tax));
|
||||
tax.tax_amount_after_discount_amount = flt(
|
||||
tax.tax_amount_after_discount_amount,
|
||||
precision("tax_amount", tax)
|
||||
);
|
||||
}
|
||||
|
||||
round_off_base_values(tax) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user