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: files:
- source: /erpnext/locale/main.pot - 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_title: "fix: sync translations from crowdin"
pull_request_labels: pull_request_labels:
- translation - translation
- skip-release-notes
pull_request_reviewers: pull_request_reviewers:
- barredterra # change to your GitHub username if you copied this file - barredterra # change to your GitHub username if you copied this file
commit_message: "fix: %language% translations" commit_message: "fix: %language% translations"
append_commit_message: false 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 return is_group
@frappe.whitelist()
def get_chart(chart_template, existing_company=None): def get_chart(chart_template, existing_company=None):
chart = {} chart = {}
if existing_company: if existing_company:

View File

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

View File

@@ -8,6 +8,7 @@ import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt from frappe.utils import cint, flt
from erpnext import get_default_cost_center 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) voucher_allocated_amounts = get_total_allocated_amount(voucher_docs)
for voucher in vouchers: for voucher in vouchers:
rows = voucher_allocated_amounts.get((voucher.get("doctype"), voucher.get("name"))) or [] if amount := get_allocated_amount(voucher_allocated_amounts, voucher, gl_account):
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"]:
voucher["paid_amount"] -= amount voucher["paid_amount"] -= amount
copied.append(voucher) copied.append(voucher)
return copied 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( def check_matching(
bank_account, bank_account,
company, company,
@@ -796,26 +804,20 @@ def get_je_matching_query(
je = frappe.qb.DocType("Journal Entry") je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account") 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_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) filter_by_date = je.posting_date.between(from_date, to_date)
if cint(filter_by_reference_date): if cint(filter_by_reference_date):
filter_by_date = je.cheque_date.between(from_reference_date, to_reference_date) filter_by_date = je.cheque_date.between(from_reference_date, to_reference_date)
query = ( subquery = (
frappe.qb.from_(jea) frappe.qb.from_(jea)
.join(je) .join(je)
.on(jea.parent == je.name) .on(jea.parent == je.name)
.select( .select(
(ref_rank + amount_rank + 1).as_("rank"), Sum(getattr(jea, amount_field)).as_("paid_amount"),
ConstantColumn("Journal Entry").as_("doctype"), ConstantColumn("Journal Entry").as_("doctype"),
je.name, je.name,
getattr(jea, amount_field).as_("paid_amount"),
je.cheque_no.as_("reference_no"), je.cheque_no.as_("reference_no"),
je.cheque_date.as_("reference_date"), je.cheque_date.as_("reference_date"),
je.pay_to_recd_from.as_("party"), je.pay_to_recd_from.as_("party"),
@@ -827,13 +829,26 @@ def get_je_matching_query(
.where(je.voucher_type != "Opening Entry") .where(je.voucher_type != "Opening Entry")
.where(je.clearance_date.isnull()) .where(je.clearance_date.isnull())
.where(jea.account == common_filters.bank_account) .where(jea.account == common_filters.bank_account)
.where(amount_equality if exact_match else getattr(jea, amount_field) > 0.0)
.where(filter_by_date) .where(filter_by_date)
.groupby(je.name)
.orderby(je.cheque_date if cint(filter_by_reference_date) else je.posting_date) .orderby(je.cheque_date if cint(filter_by_reference_date) else je.posting_date)
) )
if frappe.flags.auto_reconcile_vouchers is True: 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 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 () { frm.set_query("payment_document", "payment_entries", function () {
const payment_doctypes = frm.events.get_payment_doctypes(frm); 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) { function set_bank_statement_filter(frm) {
frm.set_query("bank_statement", function () { frm.set_query("bank_statement", function () {
return { return {

View File

@@ -5,7 +5,7 @@ import frappe
from frappe import _ from frappe import _
from frappe.model.docstatus import DocStatus from frappe.model.docstatus import DocStatus
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import flt from frappe.utils import flt, getdate
class BankTransaction(Document): class BankTransaction(Document):
@@ -84,16 +84,16 @@ class BankTransaction(Document):
if not self.payment_entries: if not self.payment_entries:
return return
pe = [] references = set()
for row in self.payment_entries: for row in self.payment_entries:
reference = (row.payment_document, row.payment_entry) reference = (row.payment_document, row.payment_entry)
if reference in pe: if reference in references:
frappe.throw( frappe.throw(
_("{0} {1} is allocated twice in this Bank Transaction").format( _("{0} {1} is allocated twice in this Bank Transaction").format(
row.payment_document, row.payment_entry row.payment_document, row.payment_entry
) )
) )
pe.append(reference) references.add(reference)
def update_allocated_amount(self): def update_allocated_amount(self):
allocated_amount = ( allocated_amount = (
@@ -104,6 +104,19 @@ class BankTransaction(Document):
self.allocated_amount = flt(allocated_amount, self.precision("allocated_amount")) self.allocated_amount = flt(allocated_amount, self.precision("allocated_amount"))
self.unallocated_amount = flt(unallocated_amount, self.precision("unallocated_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): def before_submit(self):
self.allocate_payment_entries() self.allocate_payment_entries()
self.set_status() self.set_status()
@@ -113,13 +126,14 @@ class BankTransaction(Document):
def before_update_after_submit(self): def before_update_after_submit(self):
self.validate_duplicate_references() self.validate_duplicate_references()
self.allocate_payment_entries()
self.update_allocated_amount() self.update_allocated_amount()
self.delink_old_payment_entries()
self.allocate_payment_entries()
self.set_status() self.set_status()
def on_cancel(self): def on_cancel(self):
for payment_entry in self.payment_entries: 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() self.set_status()
@@ -152,43 +166,55 @@ class BankTransaction(Document):
- 0 > a: Error: already over-allocated - 0 > a: Error: already over-allocated
- clear means: set the latest transaction date as clearance date - 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 remaining_amount = self.unallocated_amount
to_remove = []
payment_entry_docs = [(pe.payment_document, pe.payment_entry) for pe in self.payment_entries] 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) 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: for payment_entry in list(self.payment_entries):
if payment_entry.allocated_amount == 0.0: if payment_entry.allocated_amount != 0:
unallocated_amount, should_clear, latest_transaction = get_clearance_details( continue
self,
payment_entry, allocable_amount, should_clear, clearance_date = get_clearance_details(
pe_bt_allocations.get((payment_entry.payment_document, payment_entry.payment_entry)) self,
or [], 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: self.update_allocated_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)
@frappe.whitelist() @frappe.whitelist()
def remove_payment_entries(self): def remove_payment_entries(self):
@@ -199,14 +225,64 @@ class BankTransaction(Document):
def remove_payment_entry(self, payment_entry): def remove_payment_entry(self, payment_entry):
"Clear payment entry and clearance" "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) self.remove(payment_entry)
def clear_linked_payment_entry(self, payment_entry, for_cancel=False): def delink_payment_entry(self, payment_entry):
clearance_date = None if for_cancel else self.date if payment_entry.payment_document == "Bank Transaction":
set_voucher_clearance( self.update_linked_bank_transaction(payment_entry.payment_entry, allocated_amount=None)
payment_entry.payment_document, payment_entry.payment_entry, clearance_date, self 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): def auto_set_party(self):
from erpnext.accounts.doctype.bank_transaction.auto_match_party import AutoMatchParty 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") 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. There should only be one bank gl entry for a voucher, except for JE.
Could be none for a Bank Transaction. For JE, there can be multiple bank gl entries for the same account.
But if a JE, could affect two banks. In this case, the allocable_amount will be the sum of amounts of all gl entries of the account.
Should only clear the voucher if all bank gles are allocated. 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_date = getdate(transaction.date)
transaction.unallocated_amount,
get_paid_amount(payment_entry, transaction.currency, gl_bank_account), if payment_entry.payment_document == "Bank Transaction":
) bt = frappe.db.get_value(
unmatched_gles = len(gles) "Bank Transaction",
latest_transaction = transaction payment_entry.payment_entry,
for gle in gles: ("unallocated_amount", "bank_account"),
if gle["gl_account"] == gl_bank_account: as_dict=True,
if gle["amount"] <= 0.0: )
frappe.throw(
_("Voucher {0} value is broken: {1}").format(payment_entry.payment_entry, gle["amount"]) 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 return abs(bt.unallocated_amount), True, transaction_date
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 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 # nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
return frappe.db.sql( if not docs:
return {}
result = frappe.db.sql(
""" """
SELECT SELECT
ABS(gle.credit_in_account_currency - gle.debit_in_account_currency) AS amount, gle.voucher_type AS doctype,
gle.account AS gl_account gle.voucher_no AS docname,
FROM gle.account AS gl_account,
`tabGL Entry` gle SUM(ABS(gle.credit_in_account_currency - gle.debit_in_account_currency)) AS amount
LEFT JOIN FROM
`tabAccount` ac ON ac.name=gle.account `tabGL Entry` gle
WHERE LEFT JOIN
ac.account_type = 'Bank' `tabAccount` ac ON ac.name = gle.account
AND gle.voucher_type = %(doctype)s WHERE
AND gle.voucher_no = %(docname)s ac.account_type = 'Bank'
AND is_cancelled = 0 AND (gle.voucher_type, gle.voucher_no) IN %(docs)s
""", AND gle.is_cancelled = 0
dict(doctype=doctype, docname=docname), GROUP BY
gle.voucher_type, gle.voucher_no, gle.account
""",
{"docs": docs},
as_dict=True, 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): def get_total_allocated_amount(docs):
""" """
Gets the sum of allocations for a voucher on each bank GL account 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 NOTE: query may also include just saved vouchers/payments but with zero allocated_amount
""" """
if not docs: if not docs:
@@ -311,11 +423,10 @@ def get_total_allocated_amount(docs):
# nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql # nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
result = frappe.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 SELECT
ROW_NUMBER() OVER w AS rownum, ROW_NUMBER() OVER w AS rownum,
SUM(btp.allocated_amount) OVER(PARTITION BY ba.account, btp.payment_document, btp.payment_entry) AS total, 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, FIRST_VALUE(bt.date) OVER w AS latest_date,
ba.account AS gl_account, ba.account AS gl_account,
btp.payment_document, btp.payment_document,
@@ -338,104 +449,14 @@ def get_total_allocated_amount(docs):
payment_allocation_details = {} payment_allocation_details = {}
for row in result: for row in result:
# Why is this *sometimes* a byte string? row["latest_date"] = getdate(row["latest_date"])
if isinstance(row["latest_name"], bytes): payment_allocation_details.setdefault((row["payment_document"], row["payment_entry"]), {})[
row["latest_name"] = row["latest_name"].decode() row["gl_account"]
row["latest_date"] = frappe.utils.getdate(row["latest_date"]) ] = row
payment_allocation_details.setdefault((row["payment_document"], row["payment_entry"]), []).append(row)
return payment_allocation_details 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): def get_reconciled_bank_transactions(doctype, docname):
return frappe.get_all( return frappe.get_all(
"Bank Transaction Payments", "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): def remove_from_bank_transaction(doctype, docname):
"""Remove a (cancelled) voucher from all Bank Transactions.""" """Remove a (cancelled) voucher from all Bank Transactions."""
for bt_name in get_reconciled_bank_transactions(doctype, docname): for bt_name in get_reconciled_bank_transactions(doctype, docname):

View File

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

View File

@@ -579,8 +579,22 @@ class JournalEntry(AccountsController):
if customers: if customers:
from erpnext.selling.doctype.customer.customer import check_credit_limit 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: 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): def validate_cheque_info(self):
if self.voucher_type in ["Bank Entry"]: if self.voucher_type in ["Bank Entry"]:
@@ -828,14 +842,13 @@ class JournalEntry(AccountsController):
"Debit Note", "Debit Note",
"Credit Note", "Credit Note",
]: ]:
invoice = frappe.db.get_value( invoice = frappe.get_doc(reference_type, reference_name)
reference_type, reference_name, ["docstatus", "outstanding_amount"], as_dict=1
)
if invoice.docstatus != 1: if invoice.docstatus != 1:
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name)) 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( frappe.throw(
_("Payment against {0} {1} cannot be greater than Outstanding Amount {2}").format( _("Payment against {0} {1} cannot be greater than Outstanding Amount {2}").format(
reference_type, reference_name, invoice.outstanding_amount reference_type, reference_name, invoice.outstanding_amount

View File

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

View File

@@ -7,6 +7,7 @@ from functools import reduce
import frappe import frappe
from frappe import ValidationError, _, qb, scrub, throw 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 import Tuple
from frappe.query_builder.functions import Count from frappe.query_builder.functions import Count
from frappe.utils import cint, comma_or, flt, getdate, nowdate from frappe.utils import cint, comma_or, flt, getdate, nowdate
@@ -37,7 +38,11 @@ from erpnext.accounts.general_ledger import (
make_reverse_gl_entries, make_reverse_gl_entries,
process_gl_map, 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 ( from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal, cancel_exchange_gain_loss_journal,
get_account_currency, get_account_currency,
@@ -524,12 +529,12 @@ class PaymentEntry(AccountsController):
self.party_name = frappe.db.get_value(self.party_type, self.party, "name") self.party_name = frappe.db.get_value(self.party_type, self.party, "name")
if self.party: if self.party:
if not self.contact_person: if self.party_type == "Employee":
set_contact_details( self.contact_person = None
self, party=frappe._dict({"name": self.party}), party_type=self.party_type elif not self.contact_person:
) self.contact_person = get_default_contact(self.party_type, self.party)
else:
complete_contact_details(self) complete_contact_details(self)
if not self.party_account: if not self.party_account:
party_account = get_party_account(self.party_type, self.party, self.company) 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")) outstanding = flt(invoice_paid_amount_map.get(key, {}).get("outstanding"))
discounted_amt = flt(invoice_paid_amount_map.get(key, {}).get("discounted_amt")) 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: if cancel:
frappe.db.sql( frappe.db.sql(
""" """
UPDATE `tabPayment Schedule` UPDATE `tabPayment Schedule`
SET SET
paid_amount = `paid_amount` - %s, paid_amount = `paid_amount` - %s,
base_paid_amount = `base_paid_amount` - %s,
discounted_amount = `discounted_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""", 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: else:
if allocated_amount > outstanding: if allocated_amount > outstanding:
@@ -850,10 +878,20 @@ class PaymentEntry(AccountsController):
UPDATE `tabPayment Schedule` UPDATE `tabPayment Schedule`
SET SET
paid_amount = `paid_amount` + %s, paid_amount = `paid_amount` + %s,
base_paid_amount = `base_paid_amount` + %s,
discounted_amount = `discounted_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""", 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( def get_allocated_amount_in_transaction_currency(

View File

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

View File

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

View File

@@ -2700,6 +2700,78 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin):
self.assertRaises(StockOverReturnError, return_doc.save) 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): def set_advance_flag(company, flag, default_account):
frappe.db.set_value( frappe.db.set_value(

View File

@@ -8,6 +8,8 @@ from frappe import _, qb
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils.data import comma_and from frappe.utils.data import comma_and
from erpnext.stock import get_warehouse_account_map
class RepostAccountingLedger(Document): class RepostAccountingLedger(Document):
# begin: auto-generated types # begin: auto-generated types
@@ -97,6 +99,9 @@ class RepostAccountingLedger(Document):
doc = frappe.get_doc(x.voucher_type, x.voucher_no) doc = frappe.get_doc(x.voucher_type, x.voucher_no)
if doc.doctype in ["Payment Entry", "Journal Entry"]: if doc.doctype in ["Payment Entry", "Journal Entry"]:
gle_map = doc.build_gl_map() 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: else:
gle_map = doc.get_gl_entries() 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.force_set_against_expense_account()
doc.make_gl_entries() 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"]: elif doc.doctype in ["Payment Entry", "Journal Entry", "Expense Claim"]:
if not repost_doc.delete_cancelled_entries: if not repost_doc.delete_cancelled_entries:
doc.make_gl_entries(1) 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.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.accounts.utils import get_fiscal_year 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): 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": si.name, "is_cancelled": 1}))
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.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(): 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") repost_settings = frappe.get_doc("Repost Accounting Ledger Settings")
for x in allowed_types: for x in allowed_types:
repost_settings.append("allowed_types", {"document_type": x, "allowed": True}) repost_settings.append("allowed_types", {"document_type": x, "allowed": True})

View File

@@ -1827,17 +1827,6 @@ class TestSalesInvoice(IntegrationTestCase):
for field in expected_gle: for field in expected_gle:
self.assertEqual(expected_gle[field], gle[field]) 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): def test_invalid_currency(self):
# Customer currency = USD # Customer currency = USD

View File

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

View File

@@ -517,7 +517,7 @@ class ReceivablePayableReport:
select select
si.name, si.party_account_currency, si.currency, si.conversion_rate, 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, 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 from `tab{row.voucher_type}` si, `tabPayment Schedule` ps
where where
si.name = ps.parent and ps.parenttype = '{row.voucher_type}' and si.name = ps.parent and ps.parenttype = '{row.voucher_type}' and
@@ -540,20 +540,24 @@ class ReceivablePayableReport:
# Deduct that from paid amount pre allocation # Deduct that from paid amount pre allocation
row.paid -= flt(payment_terms_details[0].total_advance) 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 single payment terms, no need to split the row
if len(payment_terms_details) == 1 and payment_terms_details[0].payment_term: 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 return
for d in payment_terms_details: for d in payment_terms_details:
term = frappe._dict(original_row) 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): def append_payment_term(self, row, d, term, company_currency):
if d.currency == d.party_account_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 invoiced = d.payment_amount
else: paid_amount = d.paid_amount
invoiced = d.base_payment_amount
row.payment_terms.append( row.payment_terms.append(
term.update( term.update(
@@ -562,15 +566,15 @@ class ReceivablePayableReport:
"invoiced": invoiced, "invoiced": invoiced,
"invoice_grand_total": row.invoiced, "invoice_grand_total": row.invoiced,
"payment_term": d.description or d.payment_term, "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, "credit_note": 0.0,
"outstanding": invoiced - d.paid_amount - d.discounted_amount, "outstanding": invoiced - paid_amount - d.discounted_amount,
} }
) )
) )
if d.paid_amount: if paid_amount:
row["paid"] -= d.paid_amount + d.discounted_amount row["paid"] -= paid_amount + d.discounted_amount
def allocate_closing_to_term(self, row, term, key): def allocate_closing_to_term(self, row, term, key):
if row[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): def get_asset_details_for_grouped_by_category(filters):
condition = "" condition = ""
if filters.get("asset"): 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): def get_assets_for_grouped_by_asset(filters):
condition = "" condition = ""
if filters.get("asset"): if filters.get("asset"):
@@ -405,7 +405,7 @@ def get_assets_for_grouped_by_asset(filters):
group by a.name group by a.name
union union
SELECT a.name as name, 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 0
else else
a.opening_accumulated_depreciation a.opening_accumulated_depreciation

View File

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

View File

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

View File

@@ -444,21 +444,22 @@ class AccountsController(TransactionBase):
) )
def validate_party_address_and_contact(self): def validate_party_address_and_contact(self):
party, party_type = None, None party_type, party = self.get_party()
if self.get("customer"):
party, party_type = self.customer, "Customer" if not (party_type and party):
return
if party_type == "Customer":
billing_address, shipping_address = ( billing_address, shipping_address = (
self.get("customer_address"), self.get("customer_address"),
self.get("shipping_address_name"), self.get("shipping_address_name"),
) )
self.validate_party_address(party, party_type, billing_address, shipping_address) self.validate_party_address(party, party_type, billing_address, shipping_address)
elif self.get("supplier"): elif party_type == "Supplier":
party, party_type = self.supplier, "Supplier"
billing_address = self.get("supplier_address") billing_address = self.get("supplier_address")
self.validate_party_address(party, party_type, billing_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): def validate_party_address(self, party, party_type, billing_address, shipping_address=None):
if billing_address or shipping_address: 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") base_grand_total * flt(d.invoice_portion) / 100, d.precision("base_payment_amount")
) )
d.outstanding = d.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: elif not d.invoice_portion:
d.base_payment_amount = flt( d.base_payment_amount = flt(
d.payment_amount * self.get("conversion_rate"), d.precision("base_payment_amount") 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) default_currency = erpnext.get_company_currency(self.company)
if not default_currency: if not default_currency:
throw(_("Please enter default currency in Company Master")) throw(_("Please enter default currency in Company Master"))
if (
(self.currency == default_currency and flt(self.conversion_rate) != 1.00) if not self.conversion_rate:
or not self.conversion_rate throw(_("Conversion rate cannot be 0"))
or (self.currency != default_currency and flt(self.conversion_rate) == 1.00)
): if self.currency == default_currency and flt(self.conversion_rate) != 1.00:
throw(_("Conversion rate cannot be 0 or 1")) 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): def check_finance_books(self, item, asset):
if ( 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) 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): def __get_batch_nos_for_bundle(self, qty, key):
available_batches = defaultdict(float) 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(): for batch_no, batch_qty in self.available_materials[key]["batch_no"].items():
if flt(batch_qty, precision) <= 0:
continue
qty_to_consumed = 0 qty_to_consumed = 0
if qty > 0: if qty > 0:
if batch_qty >= qty: 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.grand_total - flt(self.doc.discount_amount) - tax.total,
self.doc.precision("rounding_adjustment"), self.doc.precision("rounding_adjustment"),
) )
logger.debug( logger.debug(
f" net_amount: {current_net_amount:<20} tax_amount: {current_tax_amount:<20} - {tax.description}" 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 return
total_for_discount_amount = self.get_total_for_discount_amount() total_for_discount_amount = self.get_total_for_discount_amount()
taxes = self.doc.get("taxes")
net_total = 0 net_total = 0
expected_net_total = 0
if total_for_discount_amount: if total_for_discount_amount:
# calculate item amount after Discount Amount # calculate item amount after Discount Amount
for i, item in enumerate(self._items): for item in self._items:
distributed_amount = ( distributed_amount = (
flt(self.doc.discount_amount) * item.net_amount / total_for_discount_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( item.distributed_discount_amount = flt(
distributed_amount, item.precision("distributed_discount_amount") distributed_amount, item.precision("distributed_discount_amount")
) )
net_total += item.net_amount net_total += item.net_amount
# discount amount rounding loss adjustment if no taxes # discount amount rounding adjustment
if ( if rounding_difference := flt(
self.doc.apply_discount_on == "Net Total" expected_net_total - net_total, self.doc.precision("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"),
)
item.net_amount = flt( 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( item.distributed_discount_amount = flt(
distributed_amount + discount_amount_loss, distributed_amount + rounding_difference,
item.precision("distributed_discount_amount"), item.precision("distributed_discount_amount"),
) )
net_total += rounding_difference
item.net_rate = ( item.net_rate = (
flt(item.net_amount / item.qty, item.precision("net_rate")) if item.qty else 0 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): def get_total_for_discount_amount(self):
if self.doc.apply_discount_on == "Net Total": if self.doc.apply_discount_on == "Net Total":
return self.doc.net_total return self.doc.net_total
else:
actual_taxes_dict = {}
for tax in self.doc.get("taxes"): total_actual_tax = 0
if tax.charge_type in ["Actual", "On Item Quantity"]: actual_taxes_dict = {}
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)
return flt( def update_actual_tax_dict(tax, tax_amount):
self.doc.grand_total - sum(actual_taxes_dict.values()), self.doc.precision("grand_total") 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): def calculate_total_advance(self):
if not self.doc.docstatus.is_cancelled(): if not self.doc.docstatus.is_cancelled():

View File

@@ -69,7 +69,7 @@ def get_transaction_list(
filters=None, filters=None,
limit_start=0, limit_start=0,
limit_page_length=20, limit_page_length=20,
order_by="creation", order_by="creation desc",
custom=False, custom=False,
): ):
user = frappe.session.user user = frappe.session.user
@@ -115,7 +115,7 @@ def get_transaction_list(
limit_page_length, limit_page_length,
fields="name", fields="name",
ignore_permissions=ignore_permissions, ignore_permissions=ignore_permissions,
order_by="creation desc", order_by=order_by,
) )
if custom: 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, qty: data.qty || 0.0,
project: frm.doc.project, project: frm.doc.project,
variant_items: variant_items, 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, freeze: true,
callback(r) { callback(r) {
@@ -331,12 +331,14 @@ frappe.ui.form.on("BOM", {
}, },
}); });
fields.push({ if (!frm.doc.track_semi_finished_goods) {
fieldtype: "Check", fields.push({
label: __("Use Multi-Level BOM"), fieldtype: "Check",
fieldname: "use_multi_level_bom", label: __("Use Multi-Level BOM"),
default: frm.doc?.__onload.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) || []; 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) { setup_stock_entry(frm) {
if ( if (
frm.doc.manufactured_qty &&
frm.doc.finished_good && frm.doc.finished_good &&
frm.doc.docstatus === 1 && frm.doc.docstatus === 1 &&
!frm.doc.is_subcontracted && !frm.doc.is_subcontracted &&
@@ -86,11 +87,16 @@ frappe.ui.form.on("Job Card", {
frm.toggle_enable("for_quantity", !has_stock_entry); 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 to_request = frm.doc.for_quantity > frm.doc.transferred_qty;
let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer; 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.add_custom_button(__("Material Request"), () => {
frm.trigger("make_material_request"); frm.trigger("make_material_request");
}); });
@@ -100,7 +106,7 @@ frappe.ui.form.on("Job Card", {
// in case of multiple items in JC // in case of multiple items in JC
let to_transfer = frm.doc.items.some((row) => row.transferred_qty < row.required_qty); 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.add_custom_button(__("Material Transfer"), () => {
frm.trigger("make_stock_entry"); 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.for_quantity + frm.doc.process_loss_qty > frm.doc.total_completed_qty &&
(frm.doc.skip_material_transfer || (frm.doc.skip_material_transfer ||
frm.doc.transferred_qty >= frm.doc.for_quantity + frm.doc.process_loss_qty || 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) { if (!frm.doc.time_logs?.length) {
frm.add_custom_button(__("Start Job"), () => { frm.add_custom_button(__("Start Job"), () => {
@@ -163,7 +170,8 @@ frappe.ui.form.on("Job Card", {
}); });
}); });
} else { } 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) { if (!frm.doc.is_paused) {
frm.add_custom_button(__("Pause Job"), () => { frm.add_custom_button(__("Pause Job"), () => {
frm.call({ frm.call({
@@ -214,20 +222,60 @@ frappe.ui.form.on("Job Card", {
let fields = [ let fields = [
{ {
fieldtype: "Float", fieldtype: "Float",
label: __("Completed Quantity"), label: __("Qty to Manufacture"),
fieldname: "qty", fieldname: "for_quantity",
reqd: 1, 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", fieldtype: "Datetime",
label: __("End Time"), label: __("End Time"),
fieldname: "end_time", fieldname: "end_time",
default: frappe.datetime.now_datetime(), default: frappe.datetime.now_datetime(),
}, });
]; }
frappe.prompt( frm.job_completion_dialog = frappe.prompt(
fields, fields,
(data) => { (data) => {
if (data.qty <= 0) { if (data.qty <= 0) {
@@ -238,7 +286,8 @@ frappe.ui.form.on("Job Card", {
method: "complete_job_card", method: "complete_job_card",
doc: frm.doc, doc: frm.doc,
args: { args: {
qty: data.qty, qty: data.completed_qty,
for_quantity: data.for_quantity,
end_time: data.end_time, end_time: data.end_time,
}, },
callback: function (r) { callback: function (r) {
@@ -619,15 +668,46 @@ frappe.ui.form.on("Job Card", {
}); });
frappe.ui.form.on("Job Card Time Log", { 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); frm.events.set_total_completed_qty(frm);
}, },
to_time: function (frm) { to_time: function (frm) {
frm.set_value("started_time", ""); 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) { function get_seconds_diff(d1, d2) {
return moment(d1).diff(d2, "seconds"); 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", "status",
"operation_row_id", "operation_row_id",
"is_paused", "is_paused",
"track_semi_finished_goods",
"column_break_20", "column_break_20",
"operation_row_number", "operation_row_number",
"operation_id", "operation_id",
@@ -525,15 +526,16 @@
"fieldname": "finished_good", "fieldname": "finished_good",
"fieldtype": "Link", "fieldtype": "Link",
"in_preview": 1, "in_preview": 1,
"label": "Finished Good", "label": "Item to Manufacture",
"options": "Item", "options": "Item",
"read_only": 1 "read_only": 1
}, },
{ {
"depends_on": "eval:doc.track_semi_finished_goods",
"fieldname": "target_warehouse", "fieldname": "target_warehouse",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Target Warehouse", "label": "Target Warehouse",
"mandatory_depends_on": "eval:doc.finished_good", "mandatory_depends_on": "eval:doc.track_semi_finished_goods",
"options": "Warehouse" "options": "Warehouse"
}, },
{ {
@@ -555,7 +557,7 @@
{ {
"fieldname": "semi_fg_bom", "fieldname": "semi_fg_bom",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Semi Finished Goods BOM", "label": "Manufacturing BOM",
"options": "BOM", "options": "BOM",
"read_only": 1 "read_only": 1
}, },
@@ -610,11 +612,19 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Is Paused", "label": "Is Paused",
"read_only": 1 "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, "is_submittable": 1,
"links": [], "links": [],
"modified": "2024-06-03 17:44:18.324743", "modified": "2025-03-30 18:53:38.206399",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Job Card", "name": "Job Card",
@@ -667,10 +677,11 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"show_preview_popup": 1, "show_preview_popup": 1,
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"title_field": "operation", "title_field": "operation",
"track_changes": 1 "track_changes": 1
} }

View File

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

View File

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

View File

@@ -2788,6 +2788,109 @@ class TestWorkOrder(IntegrationTestCase):
batch_no = get_batch_from_bundle(row.serial_and_batch_bundle) batch_no = get_batch_from_bundle(row.serial_and_batch_bundle)
self.assertEqual(batch_no, itemwise_batches[row.item_code]) 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): def make_operation(**kwargs):
kwargs = frappe._dict(kwargs) kwargs = frappe._dict(kwargs)

View File

@@ -161,6 +161,8 @@ frappe.ui.form.on("Work Order", {
erpnext.work_order.set_custom_buttons(frm); erpnext.work_order.set_custom_buttons(frm);
frm.set_intro(""); frm.set_intro("");
frm.toggle_enable("use_multi_level_bom", !frm.doc.track_semi_finished_goods);
if (frm.doc.docstatus === 0 && !frm.is_new()) { if (frm.doc.docstatus === 0 && !frm.is_new()) {
frm.set_intro(__("Submit this Work Order for further processing.")); frm.set_intro(__("Submit this Work Order for further processing."));
} else { } 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") 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.disable_add_row_in_gross_profit
erpnext.patches.v14_0.update_posting_datetime 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.rename_field_from_rate_difference_to_amount_difference
erpnext.patches.v15_0.recalculate_amount_difference_field erpnext.patches.v15_0.recalculate_amount_difference_field #2025-03-18
erpnext.patches.v15_0.rename_sla_fields #2025-03-12 erpnext.patches.v15_0.rename_sla_fields #2025-03-12
erpnext.stock.doctype.stock_ledger_entry.patches.ensure_sle_indexes erpnext.stock.doctype.stock_ledger_entry.patches.ensure_sle_indexes
erpnext.patches.v15_0.update_query_report erpnext.patches.v15_0.update_query_report
erpnext.patches.v15_0.set_purchase_receipt_row_item_to_capitalization_stock_item 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(): def execute():
if not frappe.db.has_table("Closing Stock Balance"):
return
add_inventory_dimensions_to_stock_closing_balance() add_inventory_dimensions_to_stock_closing_balance()
create_stock_closing_entries() 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 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"]); 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; return;
} }
var total_for_discount_amount = this.get_total_for_discount_amount(); const total_for_discount_amount = this.get_total_for_discount_amount();
var net_total = 0; let net_total = 0;
let expected_net_total = 0;
// calculate item amount after Discount Amount // calculate item amount after Discount Amount
if (total_for_discount_amount) { if (total_for_discount_amount) {
$.each(this.frm._items || [], function(i, item) { $.each(this.frm._items || [], function(i, item) {
distributed_amount = flt(me.frm.doc.discount_amount) * item.net_amount / total_for_discount_amount; 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; net_total += item.net_amount;
// discount amount rounding loss adjustment if no taxes // discount amount rounding adjustment
if ((!(me.frm.doc.taxes || []).length || total_for_discount_amount==me.frm.doc.net_total || (me.frm.doc.apply_discount_on == "Net Total")) // assignment to rounding_difference is intentional
&& i == (me.frm._items || []).length - 1) { const rounding_difference = flt(expected_net_total - net_total, precision("net_total"));
var discount_amount_loss = flt(me.frm.doc.net_total - net_total if (rounding_difference) {
- me.frm.doc.discount_amount, precision("net_total")); item.net_amount = flt(item.net_amount + rounding_difference, precision("net_amount", item));
item.net_amount = flt(item.net_amount + discount_amount_loss, net_total += rounding_difference;
precision("net_amount", item));
} }
item.net_rate = item.qty ? flt(item.net_amount / item.qty, precision("net_rate", item)) : 0; 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"]); 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() { 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; 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) { let total_actual_tax = 0.0;
if (["Actual", "On Item Quantity"].includes(tax.charge_type)) { let actual_taxes_dict = {};
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;
}
});
$.each(actual_taxes_dict, function(key, value) { function update_actual_taxes_dict(tax, tax_amount) {
if (value) total_actual_tax += value; 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) { 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( if(
this.frm.docstatus < 2 this.frm.docstatus < 2
&& this.frm.fields_dict["payment_terms_template"] && this.frm.fields_dict["payment_terms_template"]
@@ -335,7 +348,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
let d = locals[cdt][cdn]; let d = locals[cdt][cdn];
return { return {
filters: { filters: {
docstatus: ("<", 2), docstatus: ["<", 2],
inspection_type: inspection_type, inspection_type: inspection_type,
reference_name: doc.name, reference_name: doc.name,
item_code: d.item_code item_code: d.item_code

View File

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

View File

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

View File

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

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 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: def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float:
"""Returns the unreserved quantity for the Sales Order Item.""" """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 from erpnext.stock.utils import get_combine_datetime
sle = frappe.qb.DocType("Stock Ledger Entry") 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) posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time)
if not self.sle.creation: if not self.sle.creation:
@@ -301,8 +300,6 @@ class DeprecatedBatchNoValuation:
query = ( query = (
frappe.qb.from_(sle) frappe.qb.from_(sle)
.inner_join(batch)
.on(sle.batch_no == batch.name)
.select( .select(
sle.stock_value, sle.stock_value,
sle.qty_after_transaction, sle.qty_after_transaction,
@@ -310,7 +307,6 @@ class DeprecatedBatchNoValuation:
.where( .where(
(sle.item_code == self.sle.item_code) (sle.item_code == self.sle.item_code)
& (sle.warehouse == self.sle.warehouse) & (sle.warehouse == self.sle.warehouse)
& (sle.batch_no.isnotnull())
& (sle.is_cancelled == 0) & (sle.is_cancelled == 0)
) )
.where(timestamp_condition) .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 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) parent_doc = frappe.qb.DocType(doctype)
child_doc = frappe.qb.DocType(doctype + " Item") 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(parent_doc.docstatus == 1)
.where(child_doc.item_code == item_code) .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"): if doctype in ("Purchase Receipt", "Purchase Invoice"):
query = query.select(parent_doc.posting_date, parent_doc.posting_time) query = query.select(parent_doc.posting_date, parent_doc.posting_time)
query = query.orderby( query = query.orderby(

View File

@@ -4126,6 +4126,54 @@ class TestPurchaseReceipt(IntegrationTestCase):
pr.reload() pr.reload()
self.assertEqual(pr.status, "To Bill") 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(): def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -19,6 +19,7 @@
"allow_negative_stock", "allow_negative_stock",
"via_landed_cost_voucher", "via_landed_cost_voucher",
"allow_zero_rate", "allow_zero_rate",
"recreate_stock_ledgers",
"amended_from", "amended_from",
"error_section", "error_section",
"error_log", "error_log",
@@ -220,12 +221,20 @@
"label": "Reposting Data File", "label": "Reposting Data File",
"no_copy": 1, "no_copy": 1,
"read_only": 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, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2024-06-27 16:55:23.150146", "modified": "2025-03-31 12:38:20.566196",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Repost Item Valuation", "name": "Repost Item Valuation",
@@ -274,7 +283,8 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

@@ -47,6 +47,7 @@ class RepostItemValuation(Document):
items_to_be_repost: DF.Code | None items_to_be_repost: DF.Code | None
posting_date: DF.Date posting_date: DF.Date
posting_time: DF.Time | None posting_time: DF.Time | None
recreate_stock_ledgers: DF.Check
reposting_data_file: DF.Attach | None reposting_data_file: DF.Attach | None
status: DF.Literal["Queued", "In Progress", "Completed", "Skipped", "Failed"] status: DF.Literal["Queued", "In Progress", "Completed", "Skipped", "Failed"]
total_reposting_count: DF.Int total_reposting_count: DF.Int
@@ -74,6 +75,7 @@ class RepostItemValuation(Document):
self.reset_field_values() self.reset_field_values()
self.set_company() self.set_company()
self.validate_accounts_freeze() self.validate_accounts_freeze()
self.reset_recreate_stock_ledgers()
def validate_period_closing_voucher(self): def validate_period_closing_voucher(self):
# Period Closing Voucher # 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): def get_closing_stock_balance(self):
filters = { filters = {
"company": self.company, "company": self.company,
@@ -245,6 +251,16 @@ class RepostItemValuation(Document):
filters, 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(): def on_doctype_update():
frappe.db.add_index("Repost Item Valuation", ["warehouse", "item_code"], "item_warehouse") 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: if not frappe.flags.in_test:
frappe.db.commit() frappe.db.commit()
if doc.recreate_stock_ledgers:
doc.recreate_stock_ledger_entries()
repost_sl_entries(doc) repost_sl_entries(doc)
repost_gl_entries(doc) repost_gl_entries(doc)
@@ -286,7 +305,7 @@ def repost(doc):
status = "Failed" status = "Failed"
# If failed because of timeout, set status to In Progress # 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" status = "In Progress"
if traceback: if traceback:
@@ -301,13 +320,14 @@ def repost(doc):
}, },
) )
outgoing_email_account = frappe.get_cached_value( if status == "Failed":
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name" 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): if outgoing_email_account and not isinstance(e, RecoverableErrors):
notify_error_to_stock_managers(doc, message) notify_error_to_stock_managers(doc, message)
doc.set_status("Failed") doc.set_status("Failed")
finally: finally:
if not frappe.flags.in_test: if not frappe.flags.in_test:
frappe.db.commit() 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 () { this.frm.fields_dict.items.grid.get_field("expense_account").get_query = function () {
if (erpnext.is_perpetual_inventory_enabled(me.frm.doc.company)) { if (erpnext.is_perpetual_inventory_enabled(me.frm.doc.company)) {
return { return {
@@ -1143,8 +1139,6 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
this.frm.trigger("toggle_display_account_head"); this.frm.trigger("toggle_display_account_head");
erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype); 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.set_default_account("cost_center", "cost_center");
this.frm.refresh_fields("items"); this.frm.refresh_fields("items");

View File

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

View File

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

View File

@@ -22,6 +22,8 @@
"allow_to_edit_stock_uom_qty_for_sales", "allow_to_edit_stock_uom_qty_for_sales",
"column_break_lznj", "column_break_lznj",
"allow_to_edit_stock_uom_qty_for_purchase", "allow_to_edit_stock_uom_qty_for_purchase",
"section_break_ylhd",
"allow_uom_with_conversion_rate_defined_in_item",
"stock_validations_tab", "stock_validations_tab",
"section_break_9", "section_break_9",
"over_delivery_receipt_allowance", "over_delivery_receipt_allowance",
@@ -498,6 +500,17 @@
{ {
"fieldname": "column_break_wslv", "fieldname": "column_break_wslv",
"fieldtype": "Column Break" "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", "icon": "icon-cog",
@@ -505,7 +518,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-02-28 15:08:35.938840", "modified": "2025-03-31 15:34:20.752065",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Settings", "name": "Stock Settings",
@@ -526,8 +539,9 @@
} }
], ],
"quick_entry": 1, "quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "ASC", "sort_order": "ASC",
"states": [], "states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@@ -33,6 +33,7 @@ class StockSettings(Document):
allow_partial_reservation: DF.Check allow_partial_reservation: DF.Check
allow_to_edit_stock_uom_qty_for_purchase: DF.Check allow_to_edit_stock_uom_qty_for_purchase: DF.Check
allow_to_edit_stock_uom_qty_for_sales: DF.Check allow_to_edit_stock_uom_qty_for_sales: DF.Check
allow_uom_with_conversion_rate_defined_in_item: DF.Check
auto_create_serial_and_batch_bundle_for_outward: DF.Check auto_create_serial_and_batch_bundle_for_outward: DF.Check
auto_indent: DF.Check auto_indent: DF.Check
auto_insert_price_list_rate_if_missing: 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) frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransaction)
def validate_cancellation(args): def validate_cancellation(kargs):
if args[0].get("is_cancelled"): if kargs[0].get("is_cancelled"):
repost_entry = frappe.db.get_value( repost_entry = frappe.db.get_value(
"Repost Item Valuation", "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"], ["name", "status"],
as_dict=1, 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) 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 # 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) 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): 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 = 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) stock_entry.calculate_rate_and_amount(reset_outgoing_rate=False, raise_error_if_no_rate=False)