Merge branch 'develop' into bank-transaction-entries

This commit is contained in:
Abdeali Chharchhoda
2025-04-01 12:12:07 +05:30
88 changed files with 83394 additions and 970654 deletions

View File

@@ -0,0 +1,30 @@
name: "Auto-label PRs based on title"
on:
pull_request_target:
types: [opened, reopened]
jobs:
add-label-if-prefix-matches:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Check PR title and add label if it matches prefixes
uses: actions/github-script@v7
continue-on-error: true
with:
script: |
const title = context.payload.pull_request.title.toLowerCase();
const prefixes = ['chore', 'ci', 'style', 'test', 'refactor'];
// Check if the PR title starts with any of the prefixes
if (prefixes.some(prefix => title.startsWith(prefix))) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
labels: ['skip-release-notes']
});
}

View File

@@ -1,10 +1,14 @@
files:
- source: /erpnext/locale/main.pot
translation: /erpnext/locale/%locale_with_underscore%.po
translation: /erpnext/locale/%two_letters_code%.po
pull_request_title: "fix: sync translations from crowdin"
pull_request_labels:
- translation
- skip-release-notes
pull_request_reviewers:
- barredterra # change to your GitHub username if you copied this file
commit_message: "fix: %language% translations"
append_commit_message: false
languages_mapping:
two_letters_code:
pt-BR: pt_BR

View File

@@ -116,6 +116,7 @@ def identify_is_group(child):
return is_group
@frappe.whitelist()
def get_chart(chart_template, existing_company=None):
chart = {}
if existing_company:

View File

@@ -160,9 +160,6 @@ def get_payment_entries_for_bank_clearance(
as_dict=1,
)
if bank_account:
condition += "and bank_account = %(bank_account)s"
payment_entries = frappe.db.sql(
f"""
select
@@ -184,7 +181,6 @@ def get_payment_entries_for_bank_clearance(
"account": account,
"from": from_date,
"to": to_date,
"bank_account": bank_account,
},
as_dict=1,
)

View File

@@ -8,6 +8,7 @@ import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt
from erpnext import get_default_cost_center
@@ -517,16 +518,23 @@ def subtract_allocations(gl_account, vouchers):
voucher_allocated_amounts = get_total_allocated_amount(voucher_docs)
for voucher in vouchers:
rows = voucher_allocated_amounts.get((voucher.get("doctype"), voucher.get("name"))) or []
filtered_row = list(filter(lambda row: row.get("gl_account") == gl_account, rows))
if amount := None if not filtered_row else filtered_row[0]["total"]:
if amount := get_allocated_amount(voucher_allocated_amounts, voucher, gl_account):
voucher["paid_amount"] -= amount
copied.append(voucher)
return copied
def get_allocated_amount(voucher_allocated_amounts, voucher, gl_account):
if not (voucher_details := voucher_allocated_amounts.get((voucher.get("doctype"), voucher.get("name")))):
return
if not (row := voucher_details.get(gl_account)):
return
return row.get("total")
def check_matching(
bank_account,
company,
@@ -796,26 +804,20 @@ def get_je_matching_query(
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
ref_condition = je.cheque_no == transaction.reference_number
ref_rank = frappe.qb.terms.Case().when(ref_condition, 1).else_(0)
amount_field = f"{cr_or_dr}_in_account_currency"
amount_equality = getattr(jea, amount_field) == transaction.unallocated_amount
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
filter_by_date = je.posting_date.between(from_date, to_date)
if cint(filter_by_reference_date):
filter_by_date = je.cheque_date.between(from_reference_date, to_reference_date)
query = (
subquery = (
frappe.qb.from_(jea)
.join(je)
.on(jea.parent == je.name)
.select(
(ref_rank + amount_rank + 1).as_("rank"),
Sum(getattr(jea, amount_field)).as_("paid_amount"),
ConstantColumn("Journal Entry").as_("doctype"),
je.name,
getattr(jea, amount_field).as_("paid_amount"),
je.cheque_no.as_("reference_no"),
je.cheque_date.as_("reference_date"),
je.pay_to_recd_from.as_("party"),
@@ -827,13 +829,26 @@ def get_je_matching_query(
.where(je.voucher_type != "Opening Entry")
.where(je.clearance_date.isnull())
.where(jea.account == common_filters.bank_account)
.where(amount_equality if exact_match else getattr(jea, amount_field) > 0.0)
.where(filter_by_date)
.groupby(je.name)
.orderby(je.cheque_date if cint(filter_by_reference_date) else je.posting_date)
)
if frappe.flags.auto_reconcile_vouchers is True:
query = query.where(ref_condition)
subquery = subquery.where(je.cheque_no == transaction.reference_number)
ref_rank = frappe.qb.terms.Case().when(subquery.reference_no == transaction.reference_number, 1).else_(0)
amount_equality = subquery.paid_amount == transaction.unallocated_amount
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
query = (
frappe.qb.from_(subquery)
.select(
"*",
(ref_rank + amount_rank + 1).as_("rank"),
)
.where(amount_equality if exact_match else subquery.paid_amount > 0.0)
)
return query

View File

@@ -10,6 +10,12 @@ frappe.ui.form.on("Bank Transaction", {
},
};
});
frm.set_query("bank_account", function () {
return {
filters: { is_company_account: 1 },
};
});
frm.set_query("payment_document", "payment_entries", function () {
const payment_doctypes = frm.events.get_payment_doctypes(frm);
@@ -47,31 +53,6 @@ frappe.ui.form.on("Bank Transaction", {
},
});
frappe.ui.form.on("Bank Transaction Payments", {
payment_entries_remove: function (frm, cdt, cdn) {
update_clearance_date(frm, cdt, cdn);
},
});
const update_clearance_date = (frm, cdt, cdn) => {
if (frm.doc.docstatus === 1) {
frappe
.xcall("erpnext.accounts.doctype.bank_transaction.bank_transaction.unclear_reference_payment", {
doctype: cdt,
docname: cdn,
bt_name: frm.doc.name,
})
.then((e) => {
if (e == "success") {
frappe.show_alert({
message: __("Document {0} successfully uncleared", [e]),
indicator: "green",
});
}
});
}
};
function set_bank_statement_filter(frm) {
frm.set_query("bank_statement", function () {
return {

View File

@@ -5,7 +5,7 @@ import frappe
from frappe import _
from frappe.model.docstatus import DocStatus
from frappe.model.document import Document
from frappe.utils import flt
from frappe.utils import flt, getdate
class BankTransaction(Document):
@@ -84,16 +84,16 @@ class BankTransaction(Document):
if not self.payment_entries:
return
pe = []
references = set()
for row in self.payment_entries:
reference = (row.payment_document, row.payment_entry)
if reference in pe:
if reference in references:
frappe.throw(
_("{0} {1} is allocated twice in this Bank Transaction").format(
row.payment_document, row.payment_entry
)
)
pe.append(reference)
references.add(reference)
def update_allocated_amount(self):
allocated_amount = (
@@ -104,6 +104,19 @@ class BankTransaction(Document):
self.allocated_amount = flt(allocated_amount, self.precision("allocated_amount"))
self.unallocated_amount = flt(unallocated_amount, self.precision("unallocated_amount"))
def delink_old_payment_entries(self):
if self.flags.updating_linked_bank_transaction:
return
old_doc = self.get_doc_before_save()
payment_entry_names = set(pe.name for pe in self.payment_entries)
for old_pe in old_doc.payment_entries:
if old_pe.name in payment_entry_names:
continue
self.delink_payment_entry(old_pe)
def before_submit(self):
self.allocate_payment_entries()
self.set_status()
@@ -113,13 +126,14 @@ class BankTransaction(Document):
def before_update_after_submit(self):
self.validate_duplicate_references()
self.allocate_payment_entries()
self.update_allocated_amount()
self.delink_old_payment_entries()
self.allocate_payment_entries()
self.set_status()
def on_cancel(self):
for payment_entry in self.payment_entries:
self.clear_linked_payment_entry(payment_entry, for_cancel=True)
self.delink_payment_entry(payment_entry)
self.set_status()
@@ -152,43 +166,55 @@ class BankTransaction(Document):
- 0 > a: Error: already over-allocated
- clear means: set the latest transaction date as clearance date
"""
if self.flags.updating_linked_bank_transaction or not self.payment_entries:
return
remaining_amount = self.unallocated_amount
to_remove = []
payment_entry_docs = [(pe.payment_document, pe.payment_entry) for pe in self.payment_entries]
pe_bt_allocations = get_total_allocated_amount(payment_entry_docs)
gl_entries = get_related_bank_gl_entries(payment_entry_docs)
gl_bank_account = frappe.db.get_value("Bank Account", self.bank_account, "account")
for payment_entry in self.payment_entries:
if payment_entry.allocated_amount == 0.0:
unallocated_amount, should_clear, latest_transaction = get_clearance_details(
self,
payment_entry,
pe_bt_allocations.get((payment_entry.payment_document, payment_entry.payment_entry))
or [],
for payment_entry in list(self.payment_entries):
if payment_entry.allocated_amount != 0:
continue
allocable_amount, should_clear, clearance_date = get_clearance_details(
self,
payment_entry,
pe_bt_allocations.get((payment_entry.payment_document, payment_entry.payment_entry)) or {},
gl_entries.get((payment_entry.payment_document, payment_entry.payment_entry)) or {},
gl_bank_account,
)
if allocable_amount < 0:
frappe.throw(_("Voucher {0} is over-allocated by {1}").format(allocable_amount))
if remaining_amount <= 0:
self.remove(payment_entry)
continue
if allocable_amount == 0:
if should_clear:
self.clear_linked_payment_entry(payment_entry, clearance_date=clearance_date)
self.remove(payment_entry)
continue
should_clear = should_clear and allocable_amount <= remaining_amount
payment_entry.allocated_amount = min(allocable_amount, remaining_amount)
remaining_amount = flt(
remaining_amount - payment_entry.allocated_amount,
self.precision("unallocated_amount"),
)
if payment_entry.payment_document == "Bank Transaction":
self.update_linked_bank_transaction(
payment_entry.payment_entry, payment_entry.allocated_amount
)
elif should_clear:
self.clear_linked_payment_entry(payment_entry, clearance_date=clearance_date)
if 0.0 == unallocated_amount:
if should_clear:
latest_transaction.clear_linked_payment_entry(payment_entry)
to_remove.append(payment_entry)
elif remaining_amount <= 0.0:
to_remove.append(payment_entry)
elif 0.0 < unallocated_amount <= remaining_amount:
payment_entry.allocated_amount = unallocated_amount
remaining_amount -= unallocated_amount
if should_clear:
latest_transaction.clear_linked_payment_entry(payment_entry)
elif 0.0 < unallocated_amount:
payment_entry.allocated_amount = remaining_amount
remaining_amount = 0.0
elif 0.0 > unallocated_amount:
frappe.throw(_("Voucher {0} is over-allocated by {1}").format(unallocated_amount))
for payment_entry in to_remove:
self.remove(payment_entry)
self.update_allocated_amount()
@frappe.whitelist()
def remove_payment_entries(self):
@@ -199,14 +225,64 @@ class BankTransaction(Document):
def remove_payment_entry(self, payment_entry):
"Clear payment entry and clearance"
self.clear_linked_payment_entry(payment_entry, for_cancel=True)
self.delink_payment_entry(payment_entry)
self.remove(payment_entry)
def clear_linked_payment_entry(self, payment_entry, for_cancel=False):
clearance_date = None if for_cancel else self.date
set_voucher_clearance(
payment_entry.payment_document, payment_entry.payment_entry, clearance_date, self
)
def delink_payment_entry(self, payment_entry):
if payment_entry.payment_document == "Bank Transaction":
self.update_linked_bank_transaction(payment_entry.payment_entry, allocated_amount=None)
else:
self.clear_linked_payment_entry(payment_entry, clearance_date=None)
def clear_linked_payment_entry(self, payment_entry, clearance_date=None):
doctype = payment_entry.payment_document
docname = payment_entry.payment_entry
# might be a bank transaction
if doctype not in get_doctypes_for_bank_reconciliation():
return
if doctype == "Sales Invoice":
frappe.db.set_value(
"Sales Invoice Payment",
dict(parenttype=doctype, parent=docname),
"clearance_date",
clearance_date,
)
return
frappe.db.set_value(doctype, docname, "clearance_date", clearance_date)
def update_linked_bank_transaction(self, bank_transaction_name, allocated_amount=None):
"""For when a second bank transaction has fixed another, e.g. refund"""
bt = frappe.get_doc(self.doctype, bank_transaction_name)
if allocated_amount:
bt.append(
"payment_entries",
{
"payment_document": self.doctype,
"payment_entry": self.name,
"allocated_amount": allocated_amount,
},
)
else:
pe = next(
(
pe
for pe in bt.payment_entries
if pe.payment_document == self.doctype and pe.payment_entry == self.name
),
None,
)
if not pe:
return
bt.flags.updating_linked_bank_transaction = True
bt.remove(pe)
bt.save()
def auto_set_party(self):
from erpnext.accounts.doctype.bank_transaction.auto_match_party import AutoMatchParty
@@ -238,71 +314,107 @@ def get_doctypes_for_bank_reconciliation():
return frappe.get_hooks("bank_reconciliation_doctypes")
def get_clearance_details(transaction, payment_entry, bt_allocations):
def get_clearance_details(transaction, payment_entry, bt_allocations, gl_entries, gl_bank_account):
"""
There should only be one bank gle for a voucher.
Could be none for a Bank Transaction.
But if a JE, could affect two banks.
Should only clear the voucher if all bank gles are allocated.
There should only be one bank gl entry for a voucher, except for JE.
For JE, there can be multiple bank gl entries for the same account.
In this case, the allocable_amount will be the sum of amounts of all gl entries of the account.
There will be no gl entry for a Bank Transaction so return the unallocated amount.
Should only clear the voucher if all bank gl entries are allocated.
"""
gl_bank_account = frappe.db.get_value("Bank Account", transaction.bank_account, "account")
gles = get_related_bank_gl_entries(payment_entry.payment_document, payment_entry.payment_entry)
unallocated_amount = min(
transaction.unallocated_amount,
get_paid_amount(payment_entry, transaction.currency, gl_bank_account),
)
unmatched_gles = len(gles)
latest_transaction = transaction
for gle in gles:
if gle["gl_account"] == gl_bank_account:
if gle["amount"] <= 0.0:
frappe.throw(
_("Voucher {0} value is broken: {1}").format(payment_entry.payment_entry, gle["amount"])
transaction_date = getdate(transaction.date)
if payment_entry.payment_document == "Bank Transaction":
bt = frappe.db.get_value(
"Bank Transaction",
payment_entry.payment_entry,
("unallocated_amount", "bank_account"),
as_dict=True,
)
if bt.bank_account != gl_bank_account:
frappe.throw(
_("Bank Account {} in Bank Transaction {} is not matching with Bank Account {}").format(
bt.bank_account, payment_entry.payment_entry, gl_bank_account
)
)
unmatched_gles -= 1
unallocated_amount = gle["amount"]
for a in bt_allocations:
if a["gl_account"] == gle["gl_account"]:
unallocated_amount = gle["amount"] - a["total"]
if frappe.utils.getdate(transaction.date) < a["latest_date"]:
latest_transaction = frappe.get_doc("Bank Transaction", a["latest_name"])
else:
# Must be a Journal Entry affecting more than one bank
for a in bt_allocations:
if a["gl_account"] == gle["gl_account"] and a["total"] == gle["amount"]:
unmatched_gles -= 1
return abs(bt.unallocated_amount), True, transaction_date
return unallocated_amount, unmatched_gles == 0, latest_transaction
if gl_bank_account not in gl_entries:
frappe.throw(
_("{} {} is not affecting bank account {}").format(
payment_entry.payment_document, payment_entry.payment_entry, gl_bank_account
)
)
allocable_amount = gl_entries.pop(gl_bank_account) or 0
if allocable_amount <= 0.0:
frappe.throw(
_("Invalid amount in accounting entries of {} {} for Account {}: {}").format(
payment_entry.payment_document, payment_entry.payment_entry, gl_bank_account, allocable_amount
)
)
matching_bt_allocaion = bt_allocations.pop(gl_bank_account, {})
allocable_amount = flt(
allocable_amount - matching_bt_allocaion.get("total", 0), transaction.precision("unallocated_amount")
)
should_clear = all(
gl_entries[gle_account] == bt_allocations.get(gle_account, {}).get("total", 0)
for gle_account in gl_entries
)
bt_allocation_date = matching_bt_allocaion.get("latest_date", None)
clearance_date = transaction_date if not bt_allocation_date else max(transaction_date, bt_allocation_date)
return allocable_amount, should_clear, clearance_date
def get_related_bank_gl_entries(doctype, docname):
def get_related_bank_gl_entries(docs):
# nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
return frappe.db.sql(
if not docs:
return {}
result = frappe.db.sql(
"""
SELECT
ABS(gle.credit_in_account_currency - gle.debit_in_account_currency) AS amount,
gle.account AS gl_account
FROM
`tabGL Entry` gle
LEFT JOIN
`tabAccount` ac ON ac.name=gle.account
WHERE
ac.account_type = 'Bank'
AND gle.voucher_type = %(doctype)s
AND gle.voucher_no = %(docname)s
AND is_cancelled = 0
""",
dict(doctype=doctype, docname=docname),
SELECT
gle.voucher_type AS doctype,
gle.voucher_no AS docname,
gle.account AS gl_account,
SUM(ABS(gle.credit_in_account_currency - gle.debit_in_account_currency)) AS amount
FROM
`tabGL Entry` gle
LEFT JOIN
`tabAccount` ac ON ac.name = gle.account
WHERE
ac.account_type = 'Bank'
AND (gle.voucher_type, gle.voucher_no) IN %(docs)s
AND gle.is_cancelled = 0
GROUP BY
gle.voucher_type, gle.voucher_no, gle.account
""",
{"docs": docs},
as_dict=True,
)
entries = {}
for row in result:
key = (row["doctype"], row["docname"])
if key not in entries:
entries[key] = {}
entries[key][row["gl_account"]] = row["amount"]
return entries
def get_total_allocated_amount(docs):
"""
Gets the sum of allocations for a voucher on each bank GL account
along with the latest bank transaction name & date
along with the latest bank transaction date
NOTE: query may also include just saved vouchers/payments but with zero allocated_amount
"""
if not docs:
@@ -311,11 +423,10 @@ def get_total_allocated_amount(docs):
# nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
result = frappe.db.sql(
"""
SELECT total, latest_name, latest_date, gl_account, payment_document, payment_entry FROM (
SELECT total, latest_date, gl_account, payment_document, payment_entry FROM (
SELECT
ROW_NUMBER() OVER w AS rownum,
SUM(btp.allocated_amount) OVER(PARTITION BY ba.account, btp.payment_document, btp.payment_entry) AS total,
FIRST_VALUE(bt.name) OVER w AS latest_name,
FIRST_VALUE(bt.date) OVER w AS latest_date,
ba.account AS gl_account,
btp.payment_document,
@@ -338,104 +449,14 @@ def get_total_allocated_amount(docs):
payment_allocation_details = {}
for row in result:
# Why is this *sometimes* a byte string?
if isinstance(row["latest_name"], bytes):
row["latest_name"] = row["latest_name"].decode()
row["latest_date"] = frappe.utils.getdate(row["latest_date"])
payment_allocation_details.setdefault((row["payment_document"], row["payment_entry"]), []).append(row)
row["latest_date"] = getdate(row["latest_date"])
payment_allocation_details.setdefault((row["payment_document"], row["payment_entry"]), {})[
row["gl_account"]
] = row
return payment_allocation_details
def get_paid_amount(payment_entry, currency, gl_bank_account):
if payment_entry.payment_document in ["Payment Entry", "Sales Invoice", "Purchase Invoice"]:
paid_amount_field = "paid_amount"
if payment_entry.payment_document == "Payment Entry":
doc = frappe.get_doc("Payment Entry", payment_entry.payment_entry)
if doc.payment_type == "Receive":
paid_amount_field = (
"received_amount" if doc.paid_to_account_currency == currency else "base_received_amount"
)
elif doc.payment_type == "Pay":
paid_amount_field = (
"paid_amount" if doc.paid_from_account_currency == currency else "base_paid_amount"
)
return frappe.db.get_value(
payment_entry.payment_document, payment_entry.payment_entry, paid_amount_field
)
elif payment_entry.payment_document == "Journal Entry":
return abs(
frappe.db.get_value(
"Journal Entry Account",
{"parent": payment_entry.payment_entry, "account": gl_bank_account},
"sum(debit_in_account_currency-credit_in_account_currency)",
)
or 0
)
elif payment_entry.payment_document == "Expense Claim":
return frappe.db.get_value(
payment_entry.payment_document, payment_entry.payment_entry, "total_amount_reimbursed"
)
elif payment_entry.payment_document == "Loan Disbursement":
return frappe.db.get_value(
payment_entry.payment_document, payment_entry.payment_entry, "disbursed_amount"
)
elif payment_entry.payment_document == "Loan Repayment":
return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "amount_paid")
elif payment_entry.payment_document == "Bank Transaction":
dep, wth = frappe.db.get_value(
"Bank Transaction", payment_entry.payment_entry, ("deposit", "withdrawal")
)
return abs(flt(wth) - flt(dep))
else:
frappe.throw(
f"Please reconcile {payment_entry.payment_document}: {payment_entry.payment_entry} manually"
)
def set_voucher_clearance(doctype, docname, clearance_date, self):
if doctype in get_doctypes_for_bank_reconciliation():
if (
doctype == "Payment Entry"
and frappe.db.get_value("Payment Entry", docname, "payment_type") == "Internal Transfer"
and len(get_reconciled_bank_transactions(doctype, docname)) < 2
):
return
if doctype == "Sales Invoice":
frappe.db.set_value(
"Sales Invoice Payment",
dict(parenttype=doctype, parent=docname),
"clearance_date",
clearance_date,
)
return
frappe.db.set_value(doctype, docname, "clearance_date", clearance_date)
elif doctype == "Bank Transaction":
# For when a second bank transaction has fixed another, e.g. refund
bt = frappe.get_doc(doctype, docname)
if clearance_date:
vouchers = [{"payment_doctype": "Bank Transaction", "payment_name": self.name}]
bt.add_payment_entries(vouchers)
bt.save()
else:
for pe in bt.payment_entries:
if pe.payment_document == self.doctype and pe.payment_entry == self.name:
bt.remove(pe)
bt.save()
break
def get_reconciled_bank_transactions(doctype, docname):
return frappe.get_all(
"Bank Transaction Payments",
@@ -444,13 +465,6 @@ def get_reconciled_bank_transactions(doctype, docname):
)
@frappe.whitelist()
def unclear_reference_payment(doctype, docname, bt_name):
bt = frappe.get_doc("Bank Transaction", bt_name)
set_voucher_clearance(doctype, docname, None, bt)
return docname
def remove_from_bank_transaction(doctype, docname):
"""Remove a (cancelled) voucher from all Bank Transactions."""
for bt_name in get_reconciled_bank_transactions(doctype, docname):

View File

@@ -105,7 +105,8 @@
"label": "Cost Center",
"oldfieldname": "cost_center",
"oldfieldtype": "Link",
"options": "Cost Center"
"options": "Cost Center",
"search_index": 1
},
{
"fieldname": "debit",
@@ -358,7 +359,7 @@
"idx": 1,
"in_create": 1,
"links": [],
"modified": "2025-02-21 14:36:49.431166",
"modified": "2025-03-21 15:29:11.221890",
"modified_by": "Administrator",
"module": "Accounts",
"name": "GL Entry",

View File

@@ -579,8 +579,22 @@ class JournalEntry(AccountsController):
if customers:
from erpnext.selling.doctype.customer.customer import check_credit_limit
customer_details = frappe._dict(
frappe.db.get_all(
"Customer Credit Limit",
filters={
"parent": ["in", customers],
"parenttype": ["=", "Customer"],
"company": ["=", self.company],
},
fields=["parent", "bypass_credit_limit_check"],
as_list=True,
)
)
for customer in customers:
check_credit_limit(customer, self.company)
ignore_outstanding_sales_order = bool(customer_details.get(customer))
check_credit_limit(customer, self.company, ignore_outstanding_sales_order)
def validate_cheque_info(self):
if self.voucher_type in ["Bank Entry"]:
@@ -828,14 +842,13 @@ class JournalEntry(AccountsController):
"Debit Note",
"Credit Note",
]:
invoice = frappe.db.get_value(
reference_type, reference_name, ["docstatus", "outstanding_amount"], as_dict=1
)
invoice = frappe.get_doc(reference_type, reference_name)
if invoice.docstatus != 1:
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
if total and flt(invoice.outstanding_amount) < total:
precision = invoice.precision("outstanding_amount")
if total and flt(invoice.outstanding_amount, precision) < flt(total, precision):
frappe.throw(
_("Payment against {0} {1} cannot be greater than Outstanding Amount {2}").format(
reference_type, reference_name, invoice.outstanding_amount

View File

@@ -200,14 +200,14 @@
"fieldtype": "Column Break"
},
{
"depends_on": "party",
"depends_on": "eval: doc.party && doc.party_type !== \"Employee\"",
"fieldname": "contact_person",
"fieldtype": "Link",
"label": "Contact",
"options": "Contact"
},
{
"depends_on": "contact_person",
"depends_on": "eval: (doc.contact_person || doc.party_type === \"Employee\") && doc.contact_email",
"fieldname": "contact_email",
"fieldtype": "Data",
"label": "Email",
@@ -777,7 +777,7 @@
"table_fieldname": "payment_entries"
}
],
"modified": "2025-01-31 11:24:58.076393",
"modified": "2025-03-24 16:18:19.920701",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry",

View File

@@ -7,6 +7,7 @@ from functools import reduce
import frappe
from frappe import ValidationError, _, qb, scrub, throw
from frappe.model.meta import get_field_precision
from frappe.query_builder import Tuple
from frappe.query_builder.functions import Count
from frappe.utils import cint, comma_or, flt, getdate, nowdate
@@ -37,7 +38,11 @@ from erpnext.accounts.general_ledger import (
make_reverse_gl_entries,
process_gl_map,
)
from erpnext.accounts.party import complete_contact_details, get_party_account, set_contact_details
from erpnext.accounts.party import (
complete_contact_details,
get_default_contact,
get_party_account,
)
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
get_account_currency,
@@ -524,12 +529,12 @@ class PaymentEntry(AccountsController):
self.party_name = frappe.db.get_value(self.party_type, self.party, "name")
if self.party:
if not self.contact_person:
set_contact_details(
self, party=frappe._dict({"name": self.party}), party_type=self.party_type
)
else:
complete_contact_details(self)
if self.party_type == "Employee":
self.contact_person = None
elif not self.contact_person:
self.contact_person = get_default_contact(self.party_type, self.party)
complete_contact_details(self)
if not self.party_account:
party_account = get_party_account(self.party_type, self.party, self.company)
@@ -825,16 +830,39 @@ class PaymentEntry(AccountsController):
outstanding = flt(invoice_paid_amount_map.get(key, {}).get("outstanding"))
discounted_amt = flt(invoice_paid_amount_map.get(key, {}).get("discounted_amt"))
conversion_rate = frappe.db.get_value(key[2], {"name": key[1]}, "conversion_rate")
base_paid_amount_precision = get_field_precision(
frappe.get_meta("Payment Schedule").get_field("base_paid_amount")
)
base_outstanding_precision = get_field_precision(
frappe.get_meta("Payment Schedule").get_field("base_outstanding")
)
base_paid_amount = flt(
(allocated_amount - discounted_amt) * conversion_rate, base_paid_amount_precision
)
base_outstanding = flt(allocated_amount * conversion_rate, base_outstanding_precision)
if cancel:
frappe.db.sql(
"""
UPDATE `tabPayment Schedule`
SET
paid_amount = `paid_amount` - %s,
base_paid_amount = `base_paid_amount` - %s,
discounted_amount = `discounted_amount` - %s,
outstanding = `outstanding` + %s
outstanding = `outstanding` + %s,
base_outstanding = `base_outstanding` - %s
WHERE parent = %s and payment_term = %s""",
(allocated_amount - discounted_amt, discounted_amt, allocated_amount, key[1], key[0]),
(
allocated_amount - discounted_amt,
base_paid_amount,
discounted_amt,
allocated_amount,
base_outstanding,
key[1],
key[0],
),
)
else:
if allocated_amount > outstanding:
@@ -850,10 +878,20 @@ class PaymentEntry(AccountsController):
UPDATE `tabPayment Schedule`
SET
paid_amount = `paid_amount` + %s,
base_paid_amount = `base_paid_amount` + %s,
discounted_amount = `discounted_amount` + %s,
outstanding = `outstanding` - %s
outstanding = `outstanding` - %s,
base_outstanding = `base_outstanding` - %s
WHERE parent = %s and payment_term = %s""",
(allocated_amount - discounted_amt, discounted_amt, allocated_amount, key[1], key[0]),
(
allocated_amount - discounted_amt,
base_paid_amount,
discounted_amt,
allocated_amount,
base_outstanding,
key[1],
key[0],
),
)
def get_allocated_amount_in_transaction_currency(

View File

@@ -24,7 +24,9 @@
"paid_amount",
"discounted_amount",
"column_break_3",
"base_payment_amount"
"base_payment_amount",
"base_outstanding",
"base_paid_amount"
],
"fields": [
{
@@ -155,18 +157,34 @@
"fieldtype": "Currency",
"label": "Payment Amount (Company Currency)",
"options": "Company:company:default_currency"
},
{
"fieldname": "base_outstanding",
"fieldtype": "Currency",
"label": "Outstanding (Company Currency)",
"options": "Company:company:default_currency",
"read_only": 1
},
{
"depends_on": "base_paid_amount",
"fieldname": "base_paid_amount",
"fieldtype": "Currency",
"label": "Paid Amount (Company Currency)",
"options": "Company:company:default_currency",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:11.356171",
"modified": "2025-03-11 11:06:51.792982",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Schedule",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],

View File

@@ -14,6 +14,8 @@ class PaymentSchedule(Document):
if TYPE_CHECKING:
from frappe.types import DF
base_outstanding: DF.Currency
base_paid_amount: DF.Currency
base_payment_amount: DF.Currency
description: DF.SmallText | None
discount: DF.Float

View File

@@ -2700,6 +2700,78 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin):
self.assertRaises(StockOverReturnError, return_doc.save)
def test_apply_discount_on_grand_total(self):
"""
To test if after applying discount on grand total,
the grand total is calculated correctly without any rounding errors
"""
invoice = make_purchase_invoice(qty=2, rate=100, do_not_save=True, do_not_submit=True)
invoice.append(
"items",
{
"item_code": "_Test Item",
"qty": 1,
"rate": 21.39,
},
)
invoice.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account VAT - _TC",
"description": "VAT",
"rate": 15.5,
},
)
# the grand total here will be 255.71
invoice.disable_rounded_total = 1
# apply discount on grand total to adjust the grand total to 255
invoice.discount_amount = 0.71
invoice.save()
# check if grand total is 496 and not something like 254.99 due to rounding errors
self.assertEqual(invoice.grand_total, 255)
def test_apply_discount_on_grand_total_with_previous_row_total_tax(self):
"""
To test if after applying discount on grand total,
where the tax is calculated on previous row total, the grand total is calculated correctly
"""
invoice = make_purchase_invoice(qty=2, rate=100, do_not_save=True, do_not_submit=True)
invoice.extend(
"taxes",
[
{
"charge_type": "Actual",
"account_head": "_Test Account VAT - _TC",
"description": "VAT",
"tax_amount": 100,
},
{
"charge_type": "On Previous Row Amount",
"account_head": "_Test Account VAT - _TC",
"description": "VAT",
"row_id": 1,
"rate": 10,
},
{
"charge_type": "On Previous Row Total",
"account_head": "_Test Account VAT - _TC",
"description": "VAT",
"row_id": 1,
"rate": 10,
},
],
)
# the total here will be 340, so applying 40 discount
invoice.discount_amount = 40
invoice.save()
self.assertEqual(invoice.grand_total, 300)
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(

View File

@@ -8,6 +8,8 @@ from frappe import _, qb
from frappe.model.document import Document
from frappe.utils.data import comma_and
from erpnext.stock import get_warehouse_account_map
class RepostAccountingLedger(Document):
# begin: auto-generated types
@@ -97,6 +99,9 @@ class RepostAccountingLedger(Document):
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
if doc.doctype in ["Payment Entry", "Journal Entry"]:
gle_map = doc.build_gl_map()
elif doc.doctype == "Purchase Receipt":
warehouse_account_map = get_warehouse_account_map(doc.company)
gle_map = doc.get_gl_entries(warehouse_account_map)
else:
gle_map = doc.get_gl_entries()
@@ -177,6 +182,14 @@ def start_repost(account_repost_doc=str) -> None:
doc.force_set_against_expense_account()
doc.make_gl_entries()
elif doc.doctype == "Purchase Receipt":
if not repost_doc.delete_cancelled_entries:
doc.docstatus = 2
doc.make_gl_entries_on_cancel()
doc.docstatus = 1
doc.make_gl_entries(from_repost=True)
elif doc.doctype in ["Payment Entry", "Journal Entry", "Expense Claim"]:
if not repost_doc.delete_cancelled_entries:
doc.make_gl_entries(1)

View File

@@ -12,6 +12,8 @@ from erpnext.accounts.doctype.payment_request.payment_request import make_paymen
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.accounts.utils import get_fiscal_year
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries, make_purchase_receipt
class TestRepostAccountingLedger(AccountsTestMixin, IntegrationTestCase):
@@ -209,9 +211,81 @@ class TestRepostAccountingLedger(AccountsTestMixin, IntegrationTestCase):
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
def test_06_repost_purchase_receipt(self):
from erpnext.accounts.doctype.account.test_account import create_account
provisional_account = create_account(
account_name="Provision Account",
parent_account="Current Liabilities - _TC",
company=self.company,
)
another_provisional_account = create_account(
account_name="Another Provision Account",
parent_account="Current Liabilities - _TC",
company=self.company,
)
company = frappe.get_doc("Company", self.company)
company.enable_provisional_accounting_for_non_stock_items = 1
company.default_provisional_account = provisional_account
company.save()
test_cc = company.cost_center
default_expense_account = company.default_expense_account
item = make_item(properties={"is_stock_item": 0})
pr = make_purchase_receipt(company=self.company, item_code=item.name, rate=1000.0, qty=1.0)
pr_gl_entries = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True)
expected_pr_gles = [
{"account": provisional_account, "debit": 0.0, "credit": 1000.0, "cost_center": test_cc},
{"account": default_expense_account, "debit": 1000.0, "credit": 0.0, "cost_center": test_cc},
]
self.assertEqual(expected_pr_gles, pr_gl_entries)
# change the provisional account
frappe.db.set_value(
"Purchase Receipt Item",
pr.items[0].name,
"provisional_expense_account",
another_provisional_account,
)
repost_doc = frappe.new_doc("Repost Accounting Ledger")
repost_doc.company = self.company
repost_doc.delete_cancelled_entries = True
repost_doc.append("vouchers", {"voucher_type": pr.doctype, "voucher_no": pr.name})
repost_doc.save().submit()
pr_gles_after_repost = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True)
expected_pr_gles_after_repost = [
{"account": default_expense_account, "debit": 1000.0, "credit": 0.0, "cost_center": test_cc},
{"account": another_provisional_account, "debit": 0.0, "credit": 1000.0, "cost_center": test_cc},
]
self.assertEqual(len(pr_gles_after_repost), len(expected_pr_gles_after_repost))
self.assertEqual(expected_pr_gles_after_repost, pr_gles_after_repost)
# teardown
repost_doc.cancel()
repost_doc.delete()
pr.reload()
pr.cancel()
company.enable_provisional_accounting_for_non_stock_items = 0
company.default_provisional_account = None
company.save()
def update_repost_settings():
allowed_types = ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Journal Entry"]
allowed_types = [
"Sales Invoice",
"Purchase Invoice",
"Payment Entry",
"Journal Entry",
"Purchase Receipt",
]
repost_settings = frappe.get_doc("Repost Accounting Ledger Settings")
for x in allowed_types:
repost_settings.append("allowed_types", {"document_type": x, "allowed": True})

View File

@@ -1827,17 +1827,6 @@ class TestSalesInvoice(IntegrationTestCase):
for field in expected_gle:
self.assertEqual(expected_gle[field], gle[field])
def test_invoice_exchange_rate(self):
si = create_sales_invoice(
customer="_Test Customer USD",
debit_to="_Test Receivable USD - _TC",
currency="USD",
conversion_rate=1,
do_not_save=1,
)
self.assertRaises(frappe.ValidationError, si.save)
def test_invalid_currency(self):
# Customer currency = USD

View File

@@ -280,32 +280,50 @@ def get_regional_address_details(party_details, doctype, company):
def complete_contact_details(party_details):
if not party_details.contact_person:
party_details.update(
{
"contact_person": None,
"contact_display": None,
"contact_email": None,
"contact_mobile": None,
"contact_phone": None,
"contact_designation": None,
"contact_department": None,
}
contact_details = frappe._dict()
if party_details.party_type == "Employee":
contact_details = frappe.db.get_value(
"Employee",
party_details.party,
[
"employee_name as contact_display",
"prefered_email as contact_email",
"cell_number as contact_mobile",
"designation as contact_designation",
"department as contact_department",
],
as_dict=True,
)
contact_details.update({"contact_person": None, "contact_phone": None})
elif party_details.contact_person:
contact_details = frappe.db.get_value(
"Contact",
party_details.contact_person,
[
"name as contact_person",
"full_name as contact_display",
"email_id as contact_email",
"mobile_no as contact_mobile",
"phone as contact_phone",
"designation as contact_designation",
"department as contact_department",
],
as_dict=True,
)
else:
fields = [
"name as contact_person",
"full_name as contact_display",
"email_id as contact_email",
"mobile_no as contact_mobile",
"phone as contact_phone",
"designation as contact_designation",
"department as contact_department",
]
contact_details = {
"contact_person": None,
"contact_display": None,
"contact_email": None,
"contact_mobile": None,
"contact_phone": None,
"contact_designation": None,
"contact_department": None,
}
contact_details = frappe.db.get_value("Contact", party_details.contact_person, fields, as_dict=True)
party_details.update(contact_details)
party_details.update(contact_details)
def set_contact_details(party_details, party, party_type):
@@ -780,9 +798,9 @@ def validate_account_party_type(self):
account_type = frappe.get_cached_value("Account", self.account, "account_type")
if account_type and (account_type not in ["Receivable", "Payable", "Equity"]):
frappe.throw(
_(
"Party Type and Party can only be set for Receivable / Payable account<br><br>" "{0}"
).format(self.account)
_("Party Type and Party can only be set for Receivable / Payable account<br><br>{0}").format(
self.account
)
)

View File

@@ -517,7 +517,7 @@ class ReceivablePayableReport:
select
si.name, si.party_account_currency, si.currency, si.conversion_rate,
si.total_advance, ps.due_date, ps.payment_term, ps.payment_amount, ps.base_payment_amount,
ps.description, ps.paid_amount, ps.discounted_amount
ps.description, ps.paid_amount, ps.base_paid_amount, ps.discounted_amount
from `tab{row.voucher_type}` si, `tabPayment Schedule` ps
where
si.name = ps.parent and ps.parenttype = '{row.voucher_type}' and
@@ -540,20 +540,24 @@ class ReceivablePayableReport:
# Deduct that from paid amount pre allocation
row.paid -= flt(payment_terms_details[0].total_advance)
company_currency = frappe.get_value("Company", self.filters.get("company"), "default_currency")
# If single payment terms, no need to split the row
if len(payment_terms_details) == 1 and payment_terms_details[0].payment_term:
self.append_payment_term(row, payment_terms_details[0], original_row)
self.append_payment_term(row, payment_terms_details[0], original_row, company_currency)
return
for d in payment_terms_details:
term = frappe._dict(original_row)
self.append_payment_term(row, d, term)
self.append_payment_term(row, d, term, company_currency)
def append_payment_term(self, row, d, term):
if d.currency == d.party_account_currency:
def append_payment_term(self, row, d, term, company_currency):
invoiced = d.base_payment_amount
paid_amount = d.base_paid_amount
if company_currency == d.party_account_currency or self.filters.get("in_party_currency"):
invoiced = d.payment_amount
else:
invoiced = d.base_payment_amount
paid_amount = d.paid_amount
row.payment_terms.append(
term.update(
@@ -562,15 +566,15 @@ class ReceivablePayableReport:
"invoiced": invoiced,
"invoice_grand_total": row.invoiced,
"payment_term": d.description or d.payment_term,
"paid": d.paid_amount + d.discounted_amount,
"paid": paid_amount + d.discounted_amount,
"credit_note": 0.0,
"outstanding": invoiced - d.paid_amount - d.discounted_amount,
"outstanding": invoiced - paid_amount - d.discounted_amount,
}
)
)
if d.paid_amount:
row["paid"] -= d.paid_amount + d.discounted_amount
if paid_amount:
row["paid"] -= paid_amount + d.discounted_amount
def allocate_closing_to_term(self, row, term, key):
if row[key]:

View File

@@ -145,6 +145,130 @@ def get_asset_categories_for_grouped_by_category(filters):
)
def get_assets_for_grouped_by_category(filters):
condition = ""
if filters.get("asset_category"):
condition = f" and a.asset_category = '{filters.get('asset_category')}'"
finance_book_filter = ""
if filters.get("finance_book"):
finance_book_filter += " and ifnull(gle.finance_book, '')=%(finance_book)s"
condition += " and exists (select 1 from `tabAsset Depreciation Schedule` ads where ads.asset = a.name and ads.finance_book = %(finance_book)s)"
# nosemgrep
return frappe.db.sql(
f"""
SELECT results.asset_category,
sum(results.accumulated_depreciation_as_on_from_date) as accumulated_depreciation_as_on_from_date,
sum(results.depreciation_eliminated_via_reversal) as depreciation_eliminated_via_reversal,
sum(results.depreciation_eliminated_during_the_period) as depreciation_eliminated_during_the_period,
sum(results.depreciation_amount_during_the_period) as depreciation_amount_during_the_period
from (SELECT a.asset_category,
ifnull(sum(case when gle.posting_date < %(from_date)s and (ifnull(a.disposal_date, 0) = 0 or a.disposal_date >= %(from_date)s) then
gle.debit
else
0
end), 0) as accumulated_depreciation_as_on_from_date,
ifnull(sum(case when gle.posting_date <= %(to_date)s and ifnull(a.disposal_date, 0) = 0 then
gle.credit
else
0
end), 0) as depreciation_eliminated_via_reversal,
ifnull(sum(case when ifnull(a.disposal_date, 0) != 0 and a.disposal_date >= %(from_date)s
and a.disposal_date <= %(to_date)s and gle.posting_date <= a.disposal_date then
gle.debit
else
0
end), 0) as depreciation_eliminated_during_the_period,
ifnull(sum(case when gle.posting_date >= %(from_date)s and gle.posting_date <= %(to_date)s
and (ifnull(a.disposal_date, 0) = 0 or gle.posting_date <= a.disposal_date) then
gle.debit
else
0
end), 0) as depreciation_amount_during_the_period
from `tabGL Entry` gle
join `tabAsset` a on
gle.against_voucher = a.name
join `tabAsset Category Account` aca on
aca.parent = a.asset_category and aca.company_name = %(company)s
join `tabCompany` company on
company.name = %(company)s
where
a.docstatus=1
and a.company=%(company)s
and a.purchase_date <= %(to_date)s
and gle.is_cancelled = 0
and gle.account = ifnull(aca.depreciation_expense_account, company.depreciation_expense_account)
{condition} {finance_book_filter}
group by a.asset_category
union
SELECT a.asset_category,
ifnull(sum(case when ifnull(a.disposal_date, 0) != 0 and a.disposal_date < %(from_date)s then
0
else
a.opening_accumulated_depreciation
end), 0) as accumulated_depreciation_as_on_from_date,
0 as depreciation_eliminated_via_reversal,
ifnull(sum(case when a.disposal_date >= %(from_date)s and a.disposal_date <= %(to_date)s then
a.opening_accumulated_depreciation
else
0
end), 0) as depreciation_eliminated_during_the_period,
0 as depreciation_amount_during_the_period
from `tabAsset` a
where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s {condition}
group by a.asset_category) as results
group by results.asset_category
""",
{
"to_date": filters.to_date,
"from_date": filters.from_date,
"company": filters.company,
"finance_book": filters.get("finance_book", ""),
},
as_dict=1,
)
def get_group_by_asset_data(filters):
data = []
asset_details = get_asset_details_for_grouped_by_category(filters)
assets = get_assets_for_grouped_by_asset(filters)
for asset_detail in asset_details:
row = frappe._dict()
row.update(asset_detail)
row.value_as_on_to_date = (
flt(row.value_as_on_from_date)
+ flt(row.value_of_new_purchase)
- flt(row.value_of_sold_asset)
- flt(row.value_of_scrapped_asset)
- flt(row.value_of_capitalized_asset)
)
row.update(next(asset for asset in assets if asset["asset"] == asset_detail.get("name", "")))
row.accumulated_depreciation_as_on_to_date = (
flt(row.accumulated_depreciation_as_on_from_date)
+ flt(row.depreciation_amount_during_the_period)
- flt(row.depreciation_eliminated_during_the_period)
- flt(row.depreciation_eliminated_via_reversal)
)
row.net_asset_value_as_on_from_date = flt(row.value_as_on_from_date) - flt(
row.accumulated_depreciation_as_on_from_date
)
row.net_asset_value_as_on_to_date = flt(row.value_as_on_to_date) - flt(
row.accumulated_depreciation_as_on_to_date
)
data.append(row)
return data
def get_asset_details_for_grouped_by_category(filters):
condition = ""
if filters.get("asset"):
@@ -224,130 +348,6 @@ def get_asset_details_for_grouped_by_category(filters):
)
def get_group_by_asset_data(filters):
data = []
asset_details = get_asset_details_for_grouped_by_category(filters)
assets = get_assets_for_grouped_by_asset(filters)
for asset_detail in asset_details:
row = frappe._dict()
row.update(asset_detail)
row.value_as_on_to_date = (
flt(row.value_as_on_from_date)
+ flt(row.value_of_new_purchase)
- flt(row.value_of_sold_asset)
- flt(row.value_of_scrapped_asset)
- flt(row.value_of_capitalized_asset)
)
row.update(next(asset for asset in assets if asset["asset"] == asset_detail.get("name", "")))
row.accumulated_depreciation_as_on_to_date = (
flt(row.accumulated_depreciation_as_on_from_date)
+ flt(row.depreciation_amount_during_the_period)
- flt(row.depreciation_eliminated_during_the_period)
- flt(row.depreciation_eliminated_via_reversal)
)
row.net_asset_value_as_on_from_date = flt(row.value_as_on_from_date) - flt(
row.accumulated_depreciation_as_on_from_date
)
row.net_asset_value_as_on_to_date = flt(row.value_as_on_to_date) - flt(
row.accumulated_depreciation_as_on_to_date
)
data.append(row)
return data
def get_assets_for_grouped_by_category(filters):
condition = ""
if filters.get("asset_category"):
condition = f" and a.asset_category = '{filters.get('asset_category')}'"
finance_book_filter = ""
if filters.get("finance_book"):
finance_book_filter += " and ifnull(gle.finance_book, '')=%(finance_book)s"
condition += " and exists (select 1 from `tabAsset Depreciation Schedule` ads where ads.asset = a.name and ads.finance_book = %(finance_book)s)"
# nosemgrep
return frappe.db.sql(
f"""
SELECT results.asset_category,
sum(results.accumulated_depreciation_as_on_from_date) as accumulated_depreciation_as_on_from_date,
sum(results.depreciation_eliminated_via_reversal) as depreciation_eliminated_via_reversal,
sum(results.depreciation_eliminated_during_the_period) as depreciation_eliminated_during_the_period,
sum(results.depreciation_amount_during_the_period) as depreciation_amount_during_the_period
from (SELECT a.asset_category,
ifnull(sum(case when gle.posting_date < %(from_date)s and (ifnull(a.disposal_date, 0) = 0 or a.disposal_date >= %(from_date)s) then
gle.debit
else
0
end), 0) as accumulated_depreciation_as_on_from_date,
ifnull(sum(case when gle.posting_date <= %(to_date)s and ifnull(a.disposal_date, 0) = 0 then
gle.credit
else
0
end), 0) as depreciation_eliminated_via_reversal,
ifnull(sum(case when ifnull(a.disposal_date, 0) != 0 and a.disposal_date >= %(from_date)s
and a.disposal_date <= %(to_date)s and gle.posting_date <= a.disposal_date then
gle.debit
else
0
end), 0) as depreciation_eliminated_during_the_period,
ifnull(sum(case when gle.posting_date >= %(from_date)s and gle.posting_date <= %(to_date)s
and (ifnull(a.disposal_date, 0) = 0 or gle.posting_date <= a.disposal_date) then
gle.debit
else
0
end), 0) as depreciation_amount_during_the_period
from `tabGL Entry` gle
join `tabAsset` a on
gle.against_voucher = a.name
join `tabAsset Category Account` aca on
aca.parent = a.asset_category and aca.company_name = %(company)s
join `tabCompany` company on
company.name = %(company)s
where
a.docstatus=1
and a.company=%(company)s
and a.purchase_date <= %(to_date)s
and gle.is_cancelled = 0
and gle.account = ifnull(aca.depreciation_expense_account, company.depreciation_expense_account)
{condition} {finance_book_filter}
group by a.asset_category
union
SELECT a.asset_category,
ifnull(sum(case when ifnull(a.disposal_date, 0) != 0 and (a.disposal_date < %(from_date)s or a.disposal_date > %(to_date)s) then
0
else
a.opening_accumulated_depreciation
end), 0) as accumulated_depreciation_as_on_from_date,
0 as depreciation_eliminated_via_reversal,
ifnull(sum(case when a.disposal_date >= %(from_date)s and a.disposal_date <= %(to_date)s then
a.opening_accumulated_depreciation
else
0
end), 0) as depreciation_eliminated_during_the_period,
0 as depreciation_amount_during_the_period
from `tabAsset` a
where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s {condition}
group by a.asset_category) as results
group by results.asset_category
""",
{
"to_date": filters.to_date,
"from_date": filters.from_date,
"company": filters.company,
"finance_book": filters.get("finance_book", ""),
},
as_dict=1,
)
def get_assets_for_grouped_by_asset(filters):
condition = ""
if filters.get("asset"):
@@ -405,7 +405,7 @@ def get_assets_for_grouped_by_asset(filters):
group by a.name
union
SELECT a.name as name,
ifnull(sum(case when ifnull(a.disposal_date, 0) != 0 and (a.disposal_date < %(from_date)s or a.disposal_date > %(to_date)s) then
ifnull(sum(case when ifnull(a.disposal_date, 0) != 0 and a.disposal_date < %(from_date)s then
0
else
a.opening_accumulated_depreciation

View File

@@ -91,6 +91,7 @@ class AccountsTestMixin:
"attribute_name": "bank",
"account_name": "HDFC",
"parent_account": "Bank Accounts - " + abbr,
"account_type": "Bank",
}
),
frappe._dict(

View File

@@ -9,8 +9,8 @@ import frappe
import frappe.defaults
from frappe import _, qb, throw
from frappe.model.meta import get_field_precision
from frappe.query_builder import AliasedQuery, Criterion, Table
from frappe.query_builder.functions import Count, Round, Sum
from frappe.query_builder import AliasedQuery, Case, Criterion, Table
from frappe.query_builder.functions import Count, Max, Round, Sum
from frappe.query_builder.utils import DocType
from frappe.utils import (
add_days,
@@ -2008,6 +2008,15 @@ class QueryPaymentLedger:
.select(
ple.against_voucher_no.as_("voucher_no"),
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
Max(
Case().when(
(
(ple.voucher_no == ple.against_voucher_no)
& (ple.voucher_type == ple.against_voucher_type)
),
(ple.posting_date),
)
).as_("invoice_date"),
)
.where(ple.delinked == 0)
.where(Criterion.all(filter_on_against_voucher_no))
@@ -2015,7 +2024,7 @@ class QueryPaymentLedger:
.where(Criterion.all(self.dimensions_filter))
.where(Criterion.all(self.voucher_posting_date))
.groupby(ple.against_voucher_type, ple.against_voucher_no, ple.party_type, ple.party)
.orderby(ple.posting_date, ple.voucher_no)
.orderby(ple.invoice_date, ple.voucher_no)
.having(qb.Field("amount_in_account_currency") > 0)
.limit(self.limit)
.run()

View File

@@ -444,21 +444,22 @@ class AccountsController(TransactionBase):
)
def validate_party_address_and_contact(self):
party, party_type = None, None
if self.get("customer"):
party, party_type = self.customer, "Customer"
party_type, party = self.get_party()
if not (party_type and party):
return
if party_type == "Customer":
billing_address, shipping_address = (
self.get("customer_address"),
self.get("shipping_address_name"),
)
self.validate_party_address(party, party_type, billing_address, shipping_address)
elif self.get("supplier"):
party, party_type = self.supplier, "Supplier"
elif party_type == "Supplier":
billing_address = self.get("supplier_address")
self.validate_party_address(party, party_type, billing_address)
if party and party_type:
self.validate_party_contact(party, party_type)
self.validate_party_contact(party, party_type)
def validate_party_address(self, party, party_type, billing_address, shipping_address=None):
if billing_address or shipping_address:
@@ -2380,6 +2381,9 @@ class AccountsController(TransactionBase):
base_grand_total * flt(d.invoice_portion) / 100, d.precision("base_payment_amount")
)
d.outstanding = d.payment_amount
d.base_outstanding = flt(
d.payment_amount * self.get("conversion_rate"), d.precision("base_outstanding")
)
elif not d.invoice_portion:
d.base_payment_amount = flt(
d.payment_amount * self.get("conversion_rate"), d.precision("base_payment_amount")
@@ -2708,12 +2712,17 @@ class AccountsController(TransactionBase):
default_currency = erpnext.get_company_currency(self.company)
if not default_currency:
throw(_("Please enter default currency in Company Master"))
if (
(self.currency == default_currency and flt(self.conversion_rate) != 1.00)
or not self.conversion_rate
or (self.currency != default_currency and flt(self.conversion_rate) == 1.00)
):
throw(_("Conversion rate cannot be 0 or 1"))
if not self.conversion_rate:
throw(_("Conversion rate cannot be 0"))
if self.currency == default_currency and flt(self.conversion_rate) != 1.00:
throw(_("Conversion rate must be 1.00 if document currency is same as company currency"))
if self.currency != default_currency and flt(self.conversion_rate) == 1.00:
frappe.msgprint(
_("Conversion rate is 1.00, but document currency is different from company currency")
)
def check_finance_books(self, item, asset):
if (

View File

@@ -905,3 +905,32 @@ def get_filtered_child_rows(doctype, txt, searchfield, start, page_len, filters)
)
return query.run(as_dict=False)
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_item_uom_query(doctype, txt, searchfield, start, page_len, filters):
if frappe.db.get_single_value("Stock Settings", "allow_uom_with_conversion_rate_defined_in_item"):
query_filters = {"parent": filters.get("item_code")}
if txt:
query_filters["uom"] = ["like", f"%{txt}%"]
return frappe.get_all(
"UOM Conversion Detail",
filters=query_filters,
fields=["uom", "conversion_factor"],
limit_start=start,
limit_page_length=page_len,
order_by="idx",
as_list=1,
)
return frappe.get_all(
"UOM",
filters={"name": ["like", f"%{txt}%"]},
fields=["name"],
limit_start=start,
limit_page_length=page_len,
as_list=1,
)

View File

@@ -551,7 +551,11 @@ class SubcontractingController(StockController):
def __get_batch_nos_for_bundle(self, qty, key):
available_batches = defaultdict(float)
precision = frappe.get_precision("Subcontracting Receipt Supplied Item", "consumed_qty")
for batch_no, batch_qty in self.available_materials[key]["batch_no"].items():
if flt(batch_qty, precision) <= 0:
continue
qty_to_consumed = 0
if qty > 0:
if batch_qty >= qty:

View File

@@ -467,6 +467,7 @@ class calculate_taxes_and_totals:
self.doc.grand_total - flt(self.doc.discount_amount) - tax.total,
self.doc.precision("rounding_adjustment"),
)
logger.debug(
f" net_amount: {current_net_amount:<20} tax_amount: {current_tax_amount:<20} - {tax.description}"
)
@@ -727,40 +728,36 @@ class calculate_taxes_and_totals:
return
total_for_discount_amount = self.get_total_for_discount_amount()
taxes = self.doc.get("taxes")
net_total = 0
expected_net_total = 0
if total_for_discount_amount:
# calculate item amount after Discount Amount
for i, item in enumerate(self._items):
for item in self._items:
distributed_amount = (
flt(self.doc.discount_amount) * item.net_amount / total_for_discount_amount
)
item.net_amount = flt(item.net_amount - distributed_amount, item.precision("net_amount"))
adjusted_net_amount = item.net_amount - distributed_amount
expected_net_total += adjusted_net_amount
item.net_amount = flt(adjusted_net_amount, item.precision("net_amount"))
item.distributed_discount_amount = flt(
distributed_amount, item.precision("distributed_discount_amount")
)
net_total += item.net_amount
# discount amount rounding loss adjustment if no taxes
if (
self.doc.apply_discount_on == "Net Total"
or not taxes
or total_for_discount_amount == self.doc.net_total
) and i == len(self._items) - 1:
discount_amount_loss = flt(
self.doc.net_total - net_total - self.doc.discount_amount,
self.doc.precision("net_total"),
)
# discount amount rounding adjustment
if rounding_difference := flt(
expected_net_total - net_total, self.doc.precision("net_total")
):
item.net_amount = flt(
item.net_amount + discount_amount_loss, item.precision("net_amount")
item.net_amount + rounding_difference, item.precision("net_amount")
)
item.distributed_discount_amount = flt(
distributed_amount + discount_amount_loss,
distributed_amount + rounding_difference,
item.precision("distributed_discount_amount"),
)
net_total += rounding_difference
item.net_rate = (
flt(item.net_amount / item.qty, item.precision("net_rate")) if item.qty else 0
@@ -776,20 +773,44 @@ class calculate_taxes_and_totals:
def get_total_for_discount_amount(self):
if self.doc.apply_discount_on == "Net Total":
return self.doc.net_total
else:
actual_taxes_dict = {}
for tax in self.doc.get("taxes"):
if tax.charge_type in ["Actual", "On Item Quantity"]:
tax_amount = self.get_tax_amount_if_for_valuation_or_deduction(tax.tax_amount, tax)
actual_taxes_dict.setdefault(tax.idx, tax_amount)
elif tax.row_id in actual_taxes_dict:
actual_tax_amount = flt(actual_taxes_dict.get(tax.row_id, 0)) * flt(tax.rate) / 100
actual_taxes_dict.setdefault(tax.idx, actual_tax_amount)
total_actual_tax = 0
actual_taxes_dict = {}
return flt(
self.doc.grand_total - sum(actual_taxes_dict.values()), self.doc.precision("grand_total")
def update_actual_tax_dict(tax, tax_amount):
nonlocal total_actual_tax
if tax.get("add_deduct_tax") == "Deduct":
tax_amount *= -1
if tax.get("category") != "Valuation":
total_actual_tax += tax_amount
actual_taxes_dict[int(tax.idx)] = {
"tax_amount": tax_amount,
"cumulative_tax_amount": total_actual_tax,
}
for tax in self.doc.get("taxes"):
if tax.charge_type in ["Actual", "On Item Quantity"]:
update_actual_tax_dict(tax, tax.tax_amount)
continue
if not tax.row_id:
continue
base_row = actual_taxes_dict.get(int(tax.row_id))
if not base_row:
continue
base_tax_amount = (
base_row["tax_amount"]
if tax.charge_type == "On Previous Row Amount"
else base_row["cumulative_tax_amount"]
)
update_actual_tax_dict(tax, base_tax_amount * tax.rate / 100)
return self.doc.grand_total - total_actual_tax
def calculate_total_advance(self):
if not self.doc.docstatus.is_cancelled():

View File

@@ -69,7 +69,7 @@ def get_transaction_list(
filters=None,
limit_start=0,
limit_page_length=20,
order_by="creation",
order_by="creation desc",
custom=False,
):
user = frappe.session.user
@@ -115,7 +115,7 @@ def get_transaction_list(
limit_page_length,
fields="name",
ignore_permissions=ignore_permissions,
order_by="creation desc",
order_by=order_by,
)
if custom:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -242,7 +242,7 @@ frappe.ui.form.on("BOM", {
qty: data.qty || 0.0,
project: frm.doc.project,
variant_items: variant_items,
use_multi_level_bom: use_multi_level_bom,
use_multi_level_bom: frm.doc?.track_semi_finished_goods ? 0 : use_multi_level_bom,
},
freeze: true,
callback(r) {
@@ -331,12 +331,14 @@ frappe.ui.form.on("BOM", {
},
});
fields.push({
fieldtype: "Check",
label: __("Use Multi-Level BOM"),
fieldname: "use_multi_level_bom",
default: frm.doc?.__onload.use_multi_level_bom,
});
if (!frm.doc.track_semi_finished_goods) {
fields.push({
fieldtype: "Check",
label: __("Use Multi-Level BOM"),
fieldname: "use_multi_level_bom",
default: frm.doc?.__onload.use_multi_level_bom,
});
}
}
var has_template_rm = frm.doc.items.filter((d) => d.has_variants === 1) || [];

View File

@@ -45,6 +45,7 @@ frappe.ui.form.on("Job Card", {
setup_stock_entry(frm) {
if (
frm.doc.manufactured_qty &&
frm.doc.finished_good &&
frm.doc.docstatus === 1 &&
!frm.doc.is_subcontracted &&
@@ -86,11 +87,16 @@ frappe.ui.form.on("Job Card", {
frm.toggle_enable("for_quantity", !has_stock_entry);
if (!frm.is_new() && !frm.doc.skip_material_transfer && has_items && frm.doc.docstatus < 2) {
if (frm.doc.docstatus != 0) {
frm.fields_dict["time_logs"].grid.update_docfield_property("completed_qty", "read_only", 1);
frm.fields_dict["time_logs"].grid.update_docfield_property("time_in_mins", "read_only", 1);
}
if (!frm.is_new() && !frm.doc.skip_material_transfer && frm.doc.docstatus < 2) {
let to_request = frm.doc.for_quantity > frm.doc.transferred_qty;
let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer;
if (to_request || excess_transfer_allowed) {
if (has_items && (to_request || excess_transfer_allowed)) {
frm.add_custom_button(__("Material Request"), () => {
frm.trigger("make_material_request");
});
@@ -100,7 +106,7 @@ frappe.ui.form.on("Job Card", {
// in case of multiple items in JC
let to_transfer = frm.doc.items.some((row) => row.transferred_qty < row.required_qty);
if (to_transfer || excess_transfer_allowed) {
if (has_items && (to_transfer || excess_transfer_allowed)) {
frm.add_custom_button(__("Material Transfer"), () => {
frm.trigger("make_stock_entry");
});
@@ -127,7 +133,8 @@ frappe.ui.form.on("Job Card", {
frm.doc.for_quantity + frm.doc.process_loss_qty > frm.doc.total_completed_qty &&
(frm.doc.skip_material_transfer ||
frm.doc.transferred_qty >= frm.doc.for_quantity + frm.doc.process_loss_qty ||
!frm.doc.finished_good)
!frm.doc.finished_good ||
!has_items?.length)
) {
if (!frm.doc.time_logs?.length) {
frm.add_custom_button(__("Start Job"), () => {
@@ -163,7 +170,8 @@ frappe.ui.form.on("Job Card", {
});
});
} else {
if (frm.doc.for_quantity - frm.doc.manufactured_qty > 0) {
let manufactured_qty = frm.doc.manufactured_qty || frm.doc.total_completed_qty;
if (frm.doc.for_quantity - (manufactured_qty + frm.doc.process_loss_qty) > 0) {
if (!frm.doc.is_paused) {
frm.add_custom_button(__("Pause Job"), () => {
frm.call({
@@ -214,20 +222,60 @@ frappe.ui.form.on("Job Card", {
let fields = [
{
fieldtype: "Float",
label: __("Completed Quantity"),
fieldname: "qty",
label: __("Qty to Manufacture"),
fieldname: "for_quantity",
reqd: 1,
default: frm.doc.for_quantity - frm.doc.total_completed_qty,
default: frm.doc.for_quantity,
change() {
let doc = frm.job_completion_dialog;
doc.set_value("completed_qty", doc.get_value("for_quantity"));
doc.set_value("process_loss_qty", 0);
},
},
{
fieldtype: "Float",
label: __("Completed Quantity"),
fieldname: "completed_qty",
reqd: 1,
default: frm.doc.for_quantity - frm.doc.total_completed_qty,
change() {
let doc = frm.job_completion_dialog;
let process_loss_qty = doc.get_value("for_quantity") - doc.get_value("completed_qty");
if (process_loss_qty > 0 && process_loss_qty != doc.get_value("process_loss_qty")) {
doc.set_value("process_loss_qty", process_loss_qty);
}
},
},
{
fieldtype: "Float",
label: __("Process Loss Quantity"),
fieldname: "process_loss_qty",
reqd: 1,
onchange() {
let doc = frm.job_completion_dialog;
let completed_qty = doc.get_value("for_quantity") - doc.get_value("process_loss_qty");
doc.set_value("completed_qty", completed_qty);
},
},
{
fieldtype: "Section Break",
},
];
let last_completed_row = get_last_completed_row(frm.doc.time_logs);
if (!last_completed_row || !last_completed_row.to_time) {
fields.push({
fieldtype: "Datetime",
label: __("End Time"),
fieldname: "end_time",
default: frappe.datetime.now_datetime(),
},
];
});
}
frappe.prompt(
frm.job_completion_dialog = frappe.prompt(
fields,
(data) => {
if (data.qty <= 0) {
@@ -238,7 +286,8 @@ frappe.ui.form.on("Job Card", {
method: "complete_job_card",
doc: frm.doc,
args: {
qty: data.qty,
qty: data.completed_qty,
for_quantity: data.for_quantity,
end_time: data.end_time,
},
callback: function (r) {
@@ -619,15 +668,46 @@ frappe.ui.form.on("Job Card", {
});
frappe.ui.form.on("Job Card Time Log", {
completed_qty: function (frm) {
completed_qty: function (frm, cdt, cdn) {
let row = locals[cdt][cdn];
if (!row.completed_qty) {
frappe.model.set_value(row.doctype, row.name, {
time_in_mins: 0,
to_time: "",
});
}
frm.events.set_total_completed_qty(frm);
},
to_time: function (frm) {
frm.set_value("started_time", "");
},
time_in_mins(frm, cdt, cdn) {
let d = locals[cdt][cdn];
if (d.time_in_mins) {
d.to_time = add_mins_to_time(d.from_time, d.time_in_mins);
frappe.model.set_value(cdt, cdn, "to_time", d.to_time);
}
},
});
function get_seconds_diff(d1, d2) {
return moment(d1).diff(d2, "seconds");
}
function add_mins_to_time(datetime, mins) {
let new_date = moment(datetime).add(mins, "minutes");
return new_date.format("YYYY-MM-DD HH:mm:ss");
}
function get_last_completed_row(time_logs) {
let completed_rows = time_logs.filter((d) => d.to_time);
if (completed_rows?.length) {
let last_completed_row = completed_rows[completed_rows.length - 1];
return last_completed_row;
}
}

View File

@@ -73,6 +73,7 @@
"status",
"operation_row_id",
"is_paused",
"track_semi_finished_goods",
"column_break_20",
"operation_row_number",
"operation_id",
@@ -525,15 +526,16 @@
"fieldname": "finished_good",
"fieldtype": "Link",
"in_preview": 1,
"label": "Finished Good",
"label": "Item to Manufacture",
"options": "Item",
"read_only": 1
},
{
"depends_on": "eval:doc.track_semi_finished_goods",
"fieldname": "target_warehouse",
"fieldtype": "Link",
"label": "Target Warehouse",
"mandatory_depends_on": "eval:doc.finished_good",
"mandatory_depends_on": "eval:doc.track_semi_finished_goods",
"options": "Warehouse"
},
{
@@ -555,7 +557,7 @@
{
"fieldname": "semi_fg_bom",
"fieldtype": "Link",
"label": "Semi Finished Goods BOM",
"label": "Manufacturing BOM",
"options": "BOM",
"read_only": 1
},
@@ -610,11 +612,19 @@
"fieldtype": "Check",
"label": "Is Paused",
"read_only": 1
},
{
"default": "0",
"fetch_from": "work_order.track_semi_finished_goods",
"fieldname": "track_semi_finished_goods",
"fieldtype": "Check",
"label": "Track Semi Finished Goods"
}
],
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2024-06-03 17:44:18.324743",
"modified": "2025-03-30 18:53:38.206399",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card",
@@ -667,10 +677,11 @@
"write": 1
}
],
"row_format": "Dynamic",
"show_preview_popup": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "operation",
"track_changes": 1
}
}

View File

@@ -129,6 +129,7 @@ class JobCard(Document):
time_required: DF.Float
total_completed_qty: DF.Float
total_time_in_mins: DF.Float
track_semi_finished_goods: DF.Check
transferred_qty: DF.Float
wip_warehouse: DF.Link | None
work_order: DF.Link
@@ -723,7 +724,7 @@ class JobCard(Document):
)
def validate_job_card(self):
if self.finished_good:
if self.track_semi_finished_goods:
return
if self.work_order and frappe.get_cached_value("Work Order", self.work_order, "status") == "Stopped":
@@ -794,7 +795,7 @@ class JobCard(Document):
)
def update_work_order(self):
if self.finished_good:
if self.track_semi_finished_goods:
return
if not self.work_order:
@@ -1037,7 +1038,7 @@ class JobCard(Document):
if self.docstatus == 0 and self.time_logs:
self.status = "Work In Progress"
if not self.finished_good and self.docstatus < 2:
if not self.track_semi_finished_goods and self.docstatus < 2:
if flt(self.for_quantity) <= flt(self.transferred_qty):
self.status = "Material Transferred"
@@ -1187,6 +1188,14 @@ class JobCard(Document):
row = self.append("time_logs", kwargs)
row.db_update()
self.db_set("status", "Work In Progress")
elif not kwargs.from_time and not kwargs.to_time and kwargs.completed_qty:
update_status = True
for row in self.time_logs:
if row.employee != kwargs.employee:
continue
row.completed_qty = kwargs.completed_qty
row.db_update()
else:
update_status = True
for row in self.time_logs:
@@ -1246,6 +1255,13 @@ class JobCard(Document):
if kwargs.end_time:
self.add_time_logs(to_time=kwargs.end_time, completed_qty=kwargs.qty, employees=self.employee)
if kwargs.for_quantity:
self.for_quantity = kwargs.for_quantity
self.save()
else:
self.add_time_logs(completed_qty=kwargs.qty, employees=self.employee)
self.save()
if kwargs.auto_submit:
@@ -1423,9 +1439,19 @@ def make_stock_entry(source_name, target_doc=None):
target.qty = pending_rm_qty
def set_missing_values(source, target):
if source.finished_good and not source.target_warehouse:
frappe.throw(_("Please set the Target Warehouse in the Job Card"))
if not source.skip_material_transfer or source.backflush_from_wip_warehouse:
if not source.wip_warehouse:
frappe.throw(_("Please set the WIP Warehouse in the Job Card"))
target.purpose = "Material Transfer for Manufacture"
target.from_bom = 1
if source.semi_fg_bom:
target.bom_no = source.semi_fg_bom
# avoid negative 'For Quantity'
pending_fg_qty = flt(source.get("for_quantity", 0)) - flt(source.get("transferred_qty", 0))
target.fg_completed_qty = pending_fg_qty if pending_fg_qty > 0 else 0

View File

@@ -37,8 +37,7 @@
"fieldname": "time_in_mins",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Time In Mins",
"read_only": 1
"label": "Time In Mins"
},
{
"allow_on_submit": 1,
@@ -64,18 +63,20 @@
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-05-21 12:41:55.765860",
"modified": "2025-03-25 20:05:13.807905",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card Time Log",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": [],
"track_changes": 1
}
}

View File

@@ -2788,6 +2788,109 @@ class TestWorkOrder(IntegrationTestCase):
batch_no = get_batch_from_bundle(row.serial_and_batch_bundle)
self.assertEqual(batch_no, itemwise_batches[row.item_code])
def test_work_order_valuation_auto_pick(self):
fg_item = "Test FG Item For Non Transfer Item Batch"
rm_item = "Test RM Item For Non Transfer Item Batch"
make_item(fg_item, {"is_stock_item": 1})
make_item(
rm_item,
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TST-BATCH-NTI-.###",
},
)
source_warehouse = "_Test Warehouse - _TC"
wip_warehouse = "Stores - _TC"
finished_goods_warehouse = create_warehouse("_Test Finished Goods Warehouse", company="_Test Company")
batches = make_stock_in_entries_and_get_batches(rm_item, source_warehouse, wip_warehouse)
if not frappe.db.get_value("BOM", {"item": fg_item}):
make_bom(item=fg_item, raw_materials=[rm_item])
wo = make_wo_order_test_record(
item=fg_item,
qty=5,
source_warehouse=source_warehouse,
wip_warehouse=wip_warehouse,
fg_warehouse=finished_goods_warehouse,
)
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 5))
stock_entry.items[0].batch_no = batches[1]
stock_entry.items[0].use_serial_batch_fields = 1
stock_entry.submit()
stock_entry.reload()
self.assertEqual(stock_entry.items[0].valuation_rate, 200)
original_value = frappe.db.get_single_value(
"Stock Settings", "auto_create_serial_and_batch_bundle_for_outward"
)
original_based_on = frappe.db.get_single_value("Stock Settings", "pick_serial_and_batch_based_on")
frappe.db.set_single_value("Stock Settings", "auto_create_serial_and_batch_bundle_for_outward", 1)
frappe.db.set_single_value("Stock Settings", "pick_serial_and_batch_based_on", "Expiry")
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 5))
stock_entry.items[0].use_serial_batch_fields = 1
stock_entry.submit()
stock_entry.reload()
batch_no = get_batch_from_bundle(stock_entry.items[0].serial_and_batch_bundle)
self.assertEqual(batch_no, batches[1])
self.assertEqual(stock_entry.items[0].valuation_rate, 200)
self.assertEqual(stock_entry.items[1].valuation_rate, 200)
frappe.db.set_single_value(
"Stock Settings", "auto_create_serial_and_batch_bundle_for_outward", original_value
)
frappe.db.set_single_value("Stock Settings", "pick_serial_and_batch_based_on", original_based_on)
def make_stock_in_entries_and_get_batches(rm_item, source_warehouse, wip_warehouse):
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
batches = []
for qty, rate in ((5, 100), (5, 200)):
stock_entry = make_stock_entry_test_record(
item_code=rm_item,
target=source_warehouse,
qty=qty,
basic_rate=rate,
)
stock_entry.submit()
stock_entry.reload()
batch_no = get_batch_from_bundle(stock_entry.items[0].serial_and_batch_bundle)
batch_doc = frappe.get_doc("Batch", batch_no)
# keep early expiry date for the batch having rate 200
days = 10 if rate == 100 else 1
batch_doc.db_set("expiry_date", add_to_date(now(), days=days))
batches.append(batch_no)
stock_entry = make_stock_entry_test_record(
item_code=rm_item,
target=wip_warehouse,
qty=qty,
basic_rate=rate,
)
stock_entry.submit()
stock_entry.reload()
batch_no = get_batch_from_bundle(stock_entry.items[0].serial_and_batch_bundle)
batch_doc = frappe.get_doc("Batch", batch_no)
batch_doc.db_set("expiry_date", add_to_date(now(), days=10))
return batches
def make_operation(**kwargs):
kwargs = frappe._dict(kwargs)

View File

@@ -161,6 +161,8 @@ frappe.ui.form.on("Work Order", {
erpnext.work_order.set_custom_buttons(frm);
frm.set_intro("");
frm.toggle_enable("use_multi_level_bom", !frm.doc.track_semi_finished_goods);
if (frm.doc.docstatus === 0 && !frm.is_new()) {
frm.set_intro(__("Submit this Work Order for further processing."));
} else {

View File

@@ -402,9 +402,10 @@ erpnext.patches.v15_0.sync_auto_reconcile_config
execute:frappe.db.set_single_value("Accounts Settings", "exchange_gain_loss_posting_date", "Payment")
erpnext.patches.v14_0.disable_add_row_in_gross_profit
erpnext.patches.v14_0.update_posting_datetime
erpnext.patches.v15_0.rename_field_from_rate_difference_to_amount_difference #2025-03-18
erpnext.patches.v15_0.recalculate_amount_difference_field
erpnext.patches.v15_0.rename_field_from_rate_difference_to_amount_difference
erpnext.patches.v15_0.recalculate_amount_difference_field #2025-03-18
erpnext.patches.v15_0.rename_sla_fields #2025-03-12
erpnext.stock.doctype.stock_ledger_entry.patches.ensure_sle_indexes
erpnext.patches.v15_0.update_query_report
erpnext.patches.v15_0.set_purchase_receipt_row_item_to_capitalization_stock_item
erpnext.patches.v15_0.update_payment_schedule_fields_in_invoices

View File

@@ -9,6 +9,9 @@ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import (
def execute():
if not frappe.db.has_table("Closing Stock Balance"):
return
add_inventory_dimensions_to_stock_closing_balance()
create_stock_closing_entries()

View File

@@ -0,0 +1,18 @@
import frappe
from frappe.query_builder import DocType
def execute():
invoice_types = ["Sales Invoice", "Purchase Invoice"]
for invoice_type in invoice_types:
invoice = DocType(invoice_type)
invoice_details = frappe.qb.from_(invoice).select(invoice.conversion_rate, invoice.name)
update_payment_schedule(invoice_details)
def update_payment_schedule(invoice_details):
ps = DocType("Payment Schedule")
frappe.qb.update(ps).join(invoice_details).on(ps.parent == invoice_details.name).set(
ps.base_paid_amount, ps.paid_amount * invoice_details.conversion_rate
).set(ps.base_outstanding, ps.outstanding * invoice_details.conversion_rate).run()

View File

@@ -621,7 +621,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
}
this.frm.doc.total_taxes_and_charges = flt(this.frm.doc.grand_total - this.frm.doc.net_total
- flt(this.frm.doc.rounding_adjustment), precision("total_taxes_and_charges"));
- flt(this.frm.doc.grand_total_diff), precision("total_taxes_and_charges"));
this.set_in_company_currency(this.frm.doc, ["total_taxes_and_charges", "rounding_adjustment"]);
@@ -711,22 +711,26 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
return;
}
var total_for_discount_amount = this.get_total_for_discount_amount();
var net_total = 0;
const total_for_discount_amount = this.get_total_for_discount_amount();
let net_total = 0;
let expected_net_total = 0;
// calculate item amount after Discount Amount
if (total_for_discount_amount) {
$.each(this.frm._items || [], function(i, item) {
distributed_amount = flt(me.frm.doc.discount_amount) * item.net_amount / total_for_discount_amount;
item.net_amount = flt(item.net_amount - distributed_amount, precision("net_amount", item));
const adjusted_net_amount = item.net_amount - distributed_amount;
expected_net_total += adjusted_net_amount
item.net_amount = flt(adjusted_net_amount, precision("net_amount", item));
net_total += item.net_amount;
// discount amount rounding loss adjustment if no taxes
if ((!(me.frm.doc.taxes || []).length || total_for_discount_amount==me.frm.doc.net_total || (me.frm.doc.apply_discount_on == "Net Total"))
&& i == (me.frm._items || []).length - 1) {
var discount_amount_loss = flt(me.frm.doc.net_total - net_total
- me.frm.doc.discount_amount, precision("net_total"));
item.net_amount = flt(item.net_amount + discount_amount_loss,
precision("net_amount", item));
// discount amount rounding adjustment
// assignment to rounding_difference is intentional
const rounding_difference = flt(expected_net_total - net_total, precision("net_total"));
if (rounding_difference) {
item.net_amount = flt(item.net_amount + rounding_difference, precision("net_amount", item));
net_total += rounding_difference;
}
item.net_rate = item.qty ? flt(item.net_amount / item.qty, precision("net_rate", item)) : 0;
me.set_in_company_currency(item, ["net_rate", "net_amount"]);
@@ -739,29 +743,38 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
}
get_total_for_discount_amount() {
if(this.frm.doc.apply_discount_on == "Net Total") {
if(this.frm.doc.apply_discount_on == "Net Total")
return this.frm.doc.net_total;
} else {
var total_actual_tax = 0.0;
var actual_taxes_dict = {};
$.each(this.frm.doc["taxes"] || [], function(i, tax) {
if (["Actual", "On Item Quantity"].includes(tax.charge_type)) {
var tax_amount = (tax.category == "Valuation") ? 0.0 : tax.tax_amount;
tax_amount *= (tax.add_deduct_tax == "Deduct") ? -1.0 : 1.0;
actual_taxes_dict[tax.idx] = tax_amount;
} else if (actual_taxes_dict[tax.row_id] !== null) {
var actual_tax_amount = flt(actual_taxes_dict[tax.row_id]) * flt(tax.rate) / 100;
actual_taxes_dict[tax.idx] = actual_tax_amount;
}
});
let total_actual_tax = 0.0;
let actual_taxes_dict = {};
$.each(actual_taxes_dict, function(key, value) {
if (value) total_actual_tax += value;
});
function update_actual_taxes_dict(tax, tax_amount) {
if (tax.add_deduct_tax == "Deduct") tax_amount *= -1;
if (tax.category != "Valuation") total_actual_tax += tax_amount;
return flt(this.frm.doc.grand_total - total_actual_tax, precision("grand_total"));
actual_taxes_dict[tax.idx] = {
tax_amount: tax_amount,
cumulative_total: total_actual_tax
};
}
$.each(this.frm.doc["taxes"] || [], function(i, tax) {
if (["Actual", "On Item Quantity"].includes(tax.charge_type)) {
update_actual_taxes_dict(tax, tax.tax_amount);
return;
}
const base_row = actual_taxes_dict[tax.row_id];
if (!base_row) return;
// if charge type is 'On Previous Row Amount', calculate tax on previous row amount
// else (On Previous Row Total) calculate tax on cumulative total
const base_tax_amount = tax.charge_type == "On Previous Row Amount" ? base_row["tax_amount"]: base_row["cumulative_total"];
update_actual_taxes_dict(tax, base_tax_amount * tax.rate / 100);
});
return this.frm.doc.grand_total - total_actual_tax;
}
calculate_total_advance(update_paid_amount) {

View File

@@ -150,6 +150,19 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
});
}
if (this.frm.fields_dict["items"].grid.get_field("uom")) {
this.frm.set_query("uom", "items", function(doc, cdt, cdn) {
let row = locals[cdt][cdn];
return {
query: "erpnext.controllers.queries.get_item_uom_query",
filters: {
"item_code": row.item_code
}
};
});
}
if(
this.frm.docstatus < 2
&& this.frm.fields_dict["payment_terms_template"]
@@ -335,7 +348,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
let d = locals[cdt][cdn];
return {
filters: {
docstatus: ("<", 2),
docstatus: ["<", 2],
inspection_type: inspection_type,
reference_name: doc.name,
item_code: d.item_code

View File

@@ -447,22 +447,21 @@ erpnext.sales_common = {
args: { project: this.frm.doc.project },
callback: function (r, rt) {
if (!r.exc) {
$.each(me.frm.doc["items"] || [], function (i, row) {
if (r.message) {
if (r.message) {
$.each(me.frm.doc["items"] || [], function (i, row) {
frappe.model.set_value(
row.doctype,
row.name,
"cost_center",
r.message
);
frappe.msgprint(
__(
"Cost Center For Item with Item Code {0} has been Changed to {1}",
[row.item_name, r.message]
)
);
}
});
});
frappe.msgprint(
__("Cost Center for Item rows has been updated to {0}", [
r.message,
])
);
}
}
},
});

View File

@@ -607,7 +607,7 @@
padding: var(--padding-sm);
&:hover {
background-color: var(--gray-50);
background-color: var(--control-bg);
}
> .invoice-name-date {
@@ -1157,8 +1157,8 @@
}
> .new-btn {
background-color: var(--blue-500);
color: white;
background-color: var(--btn-primary);
color: var(--neutral);
font-weight: 500;
}
}

View File

@@ -1,8 +1,4 @@
{{ address_line1 }}<br>
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
{% if country in ["Germany", "Deutschland"] %}
{{ pincode }} {{ city }}
{% else %}
{{ pincode }} {{ city | upper }}<br>
{{ country | upper }}
{% endif %}
{{ pincode }} {{ city | upper }}<br>
{{ country | upper }}

View File

@@ -0,0 +1,4 @@
{{ address_line1 }}<br>
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
{{ pincode }} {{ city | upper }}<br>
{{ country | upper }}

View File

@@ -766,6 +766,13 @@ class SalesOrder(SellingController):
voucher_type=self.doctype, voucher_no=self.name, sre_list=sre_list, notify=notify
)
def set_missing_values(self, for_validate=False):
super().set_missing_values(for_validate)
if self.delivery_date:
for item in self.items:
item.delivery_date = self.delivery_date
def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float:
"""Returns the unreserved quantity for the Sales Order Item."""

View File

@@ -286,7 +286,6 @@ class DeprecatedBatchNoValuation:
from erpnext.stock.utils import get_combine_datetime
sle = frappe.qb.DocType("Stock Ledger Entry")
batch = frappe.qb.DocType("Batch")
posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time)
if not self.sle.creation:
@@ -301,8 +300,6 @@ class DeprecatedBatchNoValuation:
query = (
frappe.qb.from_(sle)
.inner_join(batch)
.on(sle.batch_no == batch.name)
.select(
sle.stock_value,
sle.qty_after_transaction,
@@ -310,7 +307,6 @@ class DeprecatedBatchNoValuation:
.where(
(sle.item_code == self.sle.item_code)
& (sle.warehouse == self.sle.warehouse)
& (sle.batch_no.isnotnull())
& (sle.is_cancelled == 0)
)
.where(timestamp_condition)

View File

@@ -1200,7 +1200,7 @@ def get_last_purchase_details(item_code, doc_name=None, conversion_rate=1.0):
return out
def get_purchase_voucher_details(doctype, item_code, document_name):
def get_purchase_voucher_details(doctype, item_code, document_name=None):
parent_doc = frappe.qb.DocType(doctype)
child_doc = frappe.qb.DocType(doctype + " Item")
@@ -1219,9 +1219,11 @@ def get_purchase_voucher_details(doctype, item_code, document_name):
)
.where(parent_doc.docstatus == 1)
.where(child_doc.item_code == item_code)
.where(parent_doc.name != document_name)
)
if document_name:
query = query.where(parent_doc.name != document_name)
if doctype in ("Purchase Receipt", "Purchase Invoice"):
query = query.select(parent_doc.posting_date, parent_doc.posting_time)
query = query.orderby(

View File

@@ -4126,6 +4126,54 @@ class TestPurchaseReceipt(IntegrationTestCase):
pr.reload()
self.assertEqual(pr.status, "To Bill")
def test_recreate_stock_ledgers(self):
item_code = "Test Item for Recreate Stock Ledgers"
create_item(item_code)
pr = make_purchase_receipt(item_code=item_code, qty=10, rate=100)
pr.submit()
sles = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": pr.doctype, "voucher_no": pr.name},
pluck="name",
)
self.assertTrue(sles)
for row in sles:
doc = frappe.get_doc("Stock Ledger Entry", row)
doc.delete()
sles = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": pr.doctype, "voucher_no": pr.name},
pluck="name",
)
self.assertFalse(sles)
frappe.get_doc(
{
"doctype": "Repost Item Valuation",
"based_on": "Transaction",
"voucher_type": pr.doctype,
"voucher_no": pr.name,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
"company": pr.company,
"recreate_stock_ledgers": 1,
}
).submit()
sles = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": pr.doctype, "voucher_no": pr.name},
pluck="name",
)
self.assertTrue(sles)
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -19,6 +19,7 @@
"allow_negative_stock",
"via_landed_cost_voucher",
"allow_zero_rate",
"recreate_stock_ledgers",
"amended_from",
"error_section",
"error_log",
@@ -220,12 +221,20 @@
"label": "Reposting Data File",
"no_copy": 1,
"read_only": 1
},
{
"default": "0",
"depends_on": "eval:doc.based_on == \"Transaction\"",
"fieldname": "recreate_stock_ledgers",
"fieldtype": "Check",
"label": "Recreate Stock Ledgers"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2024-06-27 16:55:23.150146",
"modified": "2025-03-31 12:38:20.566196",
"modified_by": "Administrator",
"module": "Stock",
"name": "Repost Item Valuation",
@@ -274,7 +283,8 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -47,6 +47,7 @@ class RepostItemValuation(Document):
items_to_be_repost: DF.Code | None
posting_date: DF.Date
posting_time: DF.Time | None
recreate_stock_ledgers: DF.Check
reposting_data_file: DF.Attach | None
status: DF.Literal["Queued", "In Progress", "Completed", "Skipped", "Failed"]
total_reposting_count: DF.Int
@@ -74,6 +75,7 @@ class RepostItemValuation(Document):
self.reset_field_values()
self.set_company()
self.validate_accounts_freeze()
self.reset_recreate_stock_ledgers()
def validate_period_closing_voucher(self):
# Period Closing Voucher
@@ -108,6 +110,10 @@ class RepostItemValuation(Document):
)
)
def reset_recreate_stock_ledgers(self):
if self.recreate_stock_ledgers and self.based_on != "Transaction":
self.recreate_stock_ledgers = 0
def get_closing_stock_balance(self):
filters = {
"company": self.company,
@@ -245,6 +251,16 @@ class RepostItemValuation(Document):
filters,
)
def recreate_stock_ledger_entries(self):
"""Recreate Stock Ledger Entries for the transaction."""
if self.based_on == "Transaction" and self.recreate_stock_ledgers:
doc = frappe.get_doc(self.voucher_type, self.voucher_no)
doc.docstatus = 2
doc.update_stock_ledger(allow_negative_stock=True, via_landed_cost_voucher=True)
doc.docstatus = 1
doc.update_stock_ledger(allow_negative_stock=True)
def on_doctype_update():
frappe.db.add_index("Repost Item Valuation", ["warehouse", "item_code"], "item_warehouse")
@@ -263,6 +279,9 @@ def repost(doc):
if not frappe.flags.in_test:
frappe.db.commit()
if doc.recreate_stock_ledgers:
doc.recreate_stock_ledger_entries()
repost_sl_entries(doc)
repost_gl_entries(doc)
@@ -286,7 +305,7 @@ def repost(doc):
status = "Failed"
# If failed because of timeout, set status to In Progress
if traceback and "timeout" in traceback.lower():
if traceback and ("timeout" in traceback.lower() or "Deadlock found" in traceback):
status = "In Progress"
if traceback:
@@ -301,13 +320,14 @@ def repost(doc):
},
)
outgoing_email_account = frappe.get_cached_value(
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
)
if status == "Failed":
outgoing_email_account = frappe.get_cached_value(
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
)
if outgoing_email_account and not isinstance(e, RecoverableErrors):
notify_error_to_stock_managers(doc, message)
doc.set_status("Failed")
if outgoing_email_account and not isinstance(e, RecoverableErrors):
notify_error_to_stock_managers(doc, message)
doc.set_status("Failed")
finally:
if not frappe.flags.in_test:
frappe.db.commit()

View File

@@ -1026,10 +1026,6 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
};
});
if (me.frm.doc.company && erpnext.is_perpetual_inventory_enabled(me.frm.doc.company)) {
this.frm.add_fetch("company", "stock_adjustment_account", "expense_account");
}
this.frm.fields_dict.items.grid.get_field("expense_account").get_query = function () {
if (erpnext.is_perpetual_inventory_enabled(me.frm.doc.company)) {
return {
@@ -1143,8 +1139,6 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
this.frm.trigger("toggle_display_account_head");
erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype);
if (this.frm.doc.company && erpnext.is_perpetual_inventory_enabled(this.frm.doc.company))
this.set_default_account("stock_adjustment_account", "expense_account");
this.set_default_account("cost_center", "cost_center");
this.frm.refresh_fields("items");

View File

@@ -1725,7 +1725,7 @@ class StockEntry(StockController):
if self.purpose == "Material Issue":
ret["expense_account"] = item.get("expense_account") or item_group_defaults.get("expense_account")
if self.purpose == "Manufacture":
if self.purpose == "Manufacture" or not ret.get("expense_account"):
ret["expense_account"] = frappe.get_cached_value(
"Company", self.company, "stock_adjustment_account"
)

View File

@@ -250,6 +250,7 @@
},
{
"depends_on": "eval:doc.uom != doc.stock_uom",
"fetch_from": "item_code.stock_uom",
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
@@ -588,7 +589,8 @@
"label": "Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
"print_hide": 1,
"search_index": 1
},
{
"default": "0",
@@ -606,18 +608,20 @@
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:44.056282",
"modified": "2025-03-26 21:00:58.544797",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Detail",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": []
}
}

View File

@@ -22,6 +22,8 @@
"allow_to_edit_stock_uom_qty_for_sales",
"column_break_lznj",
"allow_to_edit_stock_uom_qty_for_purchase",
"section_break_ylhd",
"allow_uom_with_conversion_rate_defined_in_item",
"stock_validations_tab",
"section_break_9",
"over_delivery_receipt_allowance",
@@ -498,6 +500,17 @@
{
"fieldname": "column_break_wslv",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_ylhd",
"fieldtype": "Section Break"
},
{
"default": "0",
"description": "If enabled, the system will allow selecting UOMs in sales and purchase transactions only if the conversion rate is set in the item master.",
"fieldname": "allow_uom_with_conversion_rate_defined_in_item",
"fieldtype": "Check",
"label": "Allow UOM with Conversion Rate Defined in Item"
}
],
"icon": "icon-cog",
@@ -505,7 +518,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-02-28 15:08:35.938840",
"modified": "2025-03-31 15:34:20.752065",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",
@@ -526,8 +539,9 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": [],
"track_changes": 1
}
}

View File

@@ -33,6 +33,7 @@ class StockSettings(Document):
allow_partial_reservation: DF.Check
allow_to_edit_stock_uom_qty_for_purchase: DF.Check
allow_to_edit_stock_uom_qty_for_sales: DF.Check
allow_uom_with_conversion_rate_defined_in_item: DF.Check
auto_create_serial_and_batch_bundle_for_outward: DF.Check
auto_indent: DF.Check
auto_insert_price_list_rate_if_missing: DF.Check

View File

@@ -185,11 +185,16 @@ def validate_serial_no(sle):
frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransaction)
def validate_cancellation(args):
if args[0].get("is_cancelled"):
def validate_cancellation(kargs):
if kargs[0].get("is_cancelled"):
repost_entry = frappe.db.get_value(
"Repost Item Valuation",
{"voucher_type": args[0].voucher_type, "voucher_no": args[0].voucher_no, "docstatus": 1},
{
"voucher_type": kargs[0].voucher_type,
"voucher_no": kargs[0].voucher_no,
"docstatus": 1,
"recreate_stock_ledgers": 0,
},
["name", "status"],
as_dict=1,
)
@@ -1213,9 +1218,21 @@ class update_entries_after:
frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate)
# Update outgoing item's rate, recalculate FG Item's rate and total incoming/outgoing amount
if not sle.dependant_sle_voucher_detail_no:
if not sle.dependant_sle_voucher_detail_no or self.is_manufacture_entry_with_sabb(sle):
self.recalculate_amounts_in_stock_entry(sle.voucher_no, sle.voucher_detail_no)
def is_manufacture_entry_with_sabb(self, sle):
if (
self.args.get("sle_id")
and sle.serial_and_batch_bundle
and sle.auto_created_serial_and_batch_bundle
):
purpose = frappe.get_cached_value("Stock Entry", sle.voucher_no, "purpose")
if purpose in ["Manufacture", "Repack"]:
return True
return False
def recalculate_amounts_in_stock_entry(self, voucher_no, voucher_detail_no):
stock_entry = frappe.get_doc("Stock Entry", voucher_no, for_update=True)
stock_entry.calculate_rate_and_amount(reset_outgoing_rate=False, raise_error_if_no_rate=False)