Merge pull request #39639 from frappe/version-15-hotfix

chore: release v15
This commit is contained in:
rohitwaghchaure 2024-01-30 19:42:27 +05:30 committed by GitHub
commit 32f77eae5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 1443 additions and 335 deletions

View File

@ -150,6 +150,20 @@ class JournalEntry(AccountsController):
if not self.title: if not self.title:
self.title = self.get_title() self.title = self.get_title()
def submit(self):
if len(self.accounts) > 100:
msgprint(_("The task has been enqueued as a background job."), alert=True)
self.queue_action("submit", timeout=4600)
else:
return self._submit()
def cancel(self):
if len(self.accounts) > 100:
msgprint(_("The task has been enqueued as a background job."), alert=True)
self.queue_action("cancel", timeout=4600)
else:
return self._cancel()
def on_submit(self): def on_submit(self):
self.validate_cheque_info() self.validate_cheque_info()
self.check_credit_limit() self.check_credit_limit()

View File

@ -640,7 +640,7 @@ frappe.ui.form.on('Payment Entry', {
get_outstanding_invoices_or_orders: function(frm, get_outstanding_invoices, get_orders_to_be_billed) { get_outstanding_invoices_or_orders: function(frm, get_outstanding_invoices, get_orders_to_be_billed) {
const today = frappe.datetime.get_today(); const today = frappe.datetime.get_today();
const fields = [ let fields = [
{fieldtype:"Section Break", label: __("Posting Date")}, {fieldtype:"Section Break", label: __("Posting Date")},
{fieldtype:"Date", label: __("From Date"), {fieldtype:"Date", label: __("From Date"),
fieldname:"from_posting_date", default:frappe.datetime.add_days(today, -30)}, fieldname:"from_posting_date", default:frappe.datetime.add_days(today, -30)},
@ -655,18 +655,29 @@ frappe.ui.form.on('Payment Entry', {
fieldname:"outstanding_amt_greater_than", default: 0}, fieldname:"outstanding_amt_greater_than", default: 0},
{fieldtype:"Column Break"}, {fieldtype:"Column Break"},
{fieldtype:"Float", label: __("Less Than Amount"), fieldname:"outstanding_amt_less_than"}, {fieldtype:"Float", label: __("Less Than Amount"), fieldname:"outstanding_amt_less_than"},
{fieldtype:"Section Break"}, ];
{fieldtype:"Link", label:__("Cost Center"), fieldname:"cost_center", options:"Cost Center",
"get_query": function() { if (frm.dimension_filters) {
return { let column_break_insertion_point = Math.ceil((frm.dimension_filters.length)/2);
"filters": {"company": frm.doc.company}
} fields.push({fieldtype:"Section Break"});
frm.dimension_filters.map((elem, idx)=>{
fields.push({
fieldtype: "Link",
label: elem.document_type == "Cost Center" ? "Cost Center" : elem.label,
options: elem.document_type,
fieldname: elem.fieldname || elem.document_type
});
if(idx+1 == column_break_insertion_point) {
fields.push({fieldtype:"Column Break"});
} }
}, });
{fieldtype:"Column Break"}, }
fields = fields.concat([
{fieldtype:"Section Break"}, {fieldtype:"Section Break"},
{fieldtype:"Check", label: __("Allocate Payment Amount"), fieldname:"allocate_payment_amount", default:1}, {fieldtype:"Check", label: __("Allocate Payment Amount"), fieldname:"allocate_payment_amount", default:1},
]; ]);
let btn_text = ""; let btn_text = "";

View File

@ -87,12 +87,14 @@
"status", "status",
"custom_remarks", "custom_remarks",
"remarks", "remarks",
"base_in_words",
"column_break_16", "column_break_16",
"letter_head", "letter_head",
"print_heading", "print_heading",
"bank", "bank",
"bank_account_no", "bank_account_no",
"payment_order", "payment_order",
"in_words",
"subscription_section", "subscription_section",
"auto_repeat", "auto_repeat",
"amended_from", "amended_from",
@ -746,6 +748,20 @@
"hidden": 1, "hidden": 1,
"label": "Book Advance Payments in Separate Party Account", "label": "Book Advance Payments in Separate Party Account",
"read_only": 1 "read_only": 1
},
{
"fieldname": "base_in_words",
"fieldtype": "Small Text",
"label": "In Words (Company Currency)",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "in_words",
"fieldtype": "Small Text",
"label": "In Words",
"print_hide": 1,
"read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
@ -759,7 +775,7 @@
"table_fieldname": "payment_entries" "table_fieldname": "payment_entries"
} }
], ],
"modified": "2023-11-23 12:07:20.887885", "modified": "2024-01-03 12:46:41.759121",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Payment Entry", "name": "Payment Entry",

View File

@ -13,6 +13,7 @@ from pypika import Case
from pypika.functions import Coalesce, Sum from pypika.functions import Coalesce, Sum
import erpnext import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.accounts.doctype.bank_account.bank_account import ( from erpnext.accounts.doctype.bank_account.bank_account import (
get_bank_account_details, get_bank_account_details,
get_party_bank_account, get_party_bank_account,
@ -95,6 +96,7 @@ class PaymentEntry(AccountsController):
self.validate_paid_invoices() self.validate_paid_invoices()
self.ensure_supplier_is_not_blocked() self.ensure_supplier_is_not_blocked()
self.set_status() self.set_status()
self.set_total_in_words()
def on_submit(self): def on_submit(self):
if self.difference_amount: if self.difference_amount:
@ -107,7 +109,7 @@ class PaymentEntry(AccountsController):
def set_liability_account(self): def set_liability_account(self):
# Auto setting liability account should only be done during 'draft' status # Auto setting liability account should only be done during 'draft' status
if self.docstatus > 0: if self.docstatus > 0 or self.payment_type == "Internal Transfer":
return return
if not frappe.db.get_value( if not frappe.db.get_value(
@ -702,6 +704,21 @@ class PaymentEntry(AccountsController):
self.db_set("status", self.status, update_modified=True) self.db_set("status", self.status, update_modified=True)
def set_total_in_words(self):
from frappe.utils import money_in_words
if self.payment_type in ("Pay", "Internal Transfer"):
base_amount = abs(self.base_paid_amount)
amount = abs(self.paid_amount)
currency = self.paid_from_account_currency
elif self.payment_type == "Receive":
base_amount = abs(self.base_received_amount)
amount = abs(self.received_amount)
currency = self.paid_to_account_currency
self.base_in_words = money_in_words(base_amount, self.company_currency)
self.in_words = money_in_words(amount, currency)
def set_tax_withholding(self): def set_tax_withholding(self):
if not self.party_type == "Supplier": if not self.party_type == "Supplier":
return return
@ -1588,6 +1605,13 @@ def get_outstanding_reference_documents(args, validate=False):
condition += " and cost_center='%s'" % args.get("cost_center") condition += " and cost_center='%s'" % args.get("cost_center")
accounting_dimensions_filter.append(ple.cost_center == args.get("cost_center")) accounting_dimensions_filter.append(ple.cost_center == args.get("cost_center"))
# dynamic dimension filters
active_dimensions = get_dimensions()[0]
for dim in active_dimensions:
if args.get(dim.fieldname):
condition += " and {0}='{1}'".format(dim.fieldname, args.get(dim.fieldname))
accounting_dimensions_filter.append(ple[dim.fieldname] == args.get(dim.fieldname))
date_fields_dict = { date_fields_dict = {
"posting_date": ["from_posting_date", "to_posting_date"], "posting_date": ["from_posting_date", "to_posting_date"],
"due_date": ["from_due_date", "to_due_date"], "due_date": ["from_due_date", "to_due_date"],
@ -1821,6 +1845,12 @@ def get_orders_to_be_billed(
if doc and hasattr(doc, "cost_center") and doc.cost_center: if doc and hasattr(doc, "cost_center") and doc.cost_center:
condition = " and cost_center='%s'" % cost_center condition = " and cost_center='%s'" % cost_center
# dynamic dimension filters
active_dimensions = get_dimensions()[0]
for dim in active_dimensions:
if filters.get(dim.fieldname):
condition += " and {0}='{1}'".format(dim.fieldname, filters.get(dim.fieldname))
if party_account_currency == company_currency: if party_account_currency == company_currency:
grand_total_field = "base_grand_total" grand_total_field = "base_grand_total"
rounded_total_field = "base_rounded_total" rounded_total_field = "base_rounded_total"

View File

@ -95,6 +95,8 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
this.frm.change_custom_button_type(__('Allocate'), null, 'default'); this.frm.change_custom_button_type(__('Allocate'), null, 'default');
} }
this.frm.trigger("set_query_for_dimension_filters");
// check for any running reconciliation jobs // check for any running reconciliation jobs
if (this.frm.doc.receivable_payable_account) { if (this.frm.doc.receivable_payable_account) {
this.frm.call({ this.frm.call({
@ -125,6 +127,25 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
} }
} }
set_query_for_dimension_filters() {
frappe.call({
method: "erpnext.accounts.doctype.payment_reconciliation.payment_reconciliation.get_queries_for_dimension_filters",
args: {
company: this.frm.doc.company,
},
callback: (r) => {
if (!r.exc && r.message) {
r.message.forEach(x => {
this.frm.set_query(x.fieldname, () => {
return {
'filters': x.filters
};
});
});
}
}
});
}
company() { company() {
this.frm.set_value('party', ''); this.frm.set_value('party', '');

View File

@ -25,7 +25,9 @@
"invoice_limit", "invoice_limit",
"payment_limit", "payment_limit",
"bank_cash_account", "bank_cash_account",
"accounting_dimensions_section",
"cost_center", "cost_center",
"dimension_col_break",
"sec_break1", "sec_break1",
"invoice_name", "invoice_name",
"invoices", "invoices",
@ -208,6 +210,18 @@
"fieldname": "payment_name", "fieldname": "payment_name",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Filter on Payment" "label": "Filter on Payment"
},
{
"collapsible": 1,
"collapsible_depends_on": "eval: doc.invoices.length == 0",
"depends_on": "eval:doc.receivable_payable_account",
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions Filter"
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
} }
], ],
"hide_toolbar": 1, "hide_toolbar": 1,
@ -215,7 +229,7 @@
"is_virtual": 1, "is_virtual": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2023-11-17 17:33:55.701726", "modified": "2023-12-14 13:38:16.264013",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Payment Reconciliation", "name": "Payment Reconciliation",

View File

@ -10,6 +10,7 @@ from frappe.query_builder.custom import ConstantColumn
from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
import erpnext import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import ( from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
is_any_doc_running, is_any_doc_running,
) )
@ -70,6 +71,7 @@ class PaymentReconciliation(Document):
self.common_filter_conditions = [] self.common_filter_conditions = []
self.accounting_dimension_filter_conditions = [] self.accounting_dimension_filter_conditions = []
self.ple_posting_date_filter = [] self.ple_posting_date_filter = []
self.dimensions = get_dimensions()[0]
def load_from_db(self): def load_from_db(self):
# 'modified' attribute is required for `run_doc_method` to work properly. # 'modified' attribute is required for `run_doc_method` to work properly.
@ -172,6 +174,14 @@ class PaymentReconciliation(Document):
if self.payment_name: if self.payment_name:
condition.update({"name": self.payment_name}) condition.update({"name": self.payment_name})
# pass dynamic dimension filter values to query builder
dimensions = {}
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
dimensions.update({dimension: self.get(dimension)})
condition.update({"accounting_dimensions": dimensions})
payment_entries = get_advance_payment_entries_for_regional( payment_entries = get_advance_payment_entries_for_regional(
self.party_type, self.party_type,
self.party, self.party,
@ -185,66 +195,67 @@ class PaymentReconciliation(Document):
return payment_entries return payment_entries
def get_jv_entries(self): def get_jv_entries(self):
condition = self.get_conditions() je = qb.DocType("Journal Entry")
jea = qb.DocType("Journal Entry Account")
conditions = self.get_journal_filter_conditions()
# Dimension filters
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
conditions.append(jea[dimension] == self.get(dimension))
if self.payment_name: if self.payment_name:
condition += f" and t1.name like '%%{self.payment_name}%%'" conditions.append(je.name.like(f"%%{self.payment_name}%%"))
if self.get("cost_center"): if self.get("cost_center"):
condition += f" and t2.cost_center = '{self.cost_center}' " conditions.append(jea.cost_center == self.cost_center)
dr_or_cr = ( dr_or_cr = (
"credit_in_account_currency" "credit_in_account_currency"
if erpnext.get_party_account_type(self.party_type) == "Receivable" if erpnext.get_party_account_type(self.party_type) == "Receivable"
else "debit_in_account_currency" else "debit_in_account_currency"
) )
conditions.append(jea[dr_or_cr].gt(0))
bank_account_condition = ( if self.bank_cash_account:
"t2.against_account like %(bank_cash_account)s" if self.bank_cash_account else "1=1" conditions.append(jea.against_account.like(f"%%{self.bank_cash_account}%%"))
journal_query = (
qb.from_(je)
.inner_join(jea)
.on(jea.parent == je.name)
.select(
ConstantColumn("Journal Entry").as_("reference_type"),
je.name.as_("reference_name"),
je.posting_date,
je.remark.as_("remarks"),
jea.name.as_("reference_row"),
jea[dr_or_cr].as_("amount"),
jea.is_advance,
jea.exchange_rate,
jea.account_currency.as_("currency"),
jea.cost_center.as_("cost_center"),
)
.where(
(je.docstatus == 1)
& (jea.party_type == self.party_type)
& (jea.party == self.party)
& (jea.account == self.receivable_payable_account)
& (
(jea.reference_type == "")
| (jea.reference_type.isnull())
| (jea.reference_type.isin(("Sales Order", "Purchase Order")))
)
)
.where(Criterion.all(conditions))
.orderby(je.posting_date)
) )
limit = f"limit {self.payment_limit}" if self.payment_limit else " " if self.payment_limit:
journal_query = journal_query.limit(self.payment_limit)
# nosemgrep journal_entries = journal_query.run(as_dict=True)
journal_entries = frappe.db.sql(
"""
select
"Journal Entry" as reference_type, t1.name as reference_name,
t1.posting_date, t1.remark as remarks, t2.name as reference_row,
{dr_or_cr} as amount, t2.is_advance, t2.exchange_rate,
t2.account_currency as currency, t2.cost_center as cost_center
from
`tabJournal Entry` t1, `tabJournal Entry Account` t2
where
t1.name = t2.parent and t1.docstatus = 1 and t2.docstatus = 1
and t2.party_type = %(party_type)s and t2.party = %(party)s
and t2.account = %(account)s and {dr_or_cr} > 0 {condition}
and (t2.reference_type is null or t2.reference_type = '' or
(t2.reference_type in ('Sales Order', 'Purchase Order')
and t2.reference_name is not null and t2.reference_name != ''))
and (CASE
WHEN t1.voucher_type in ('Debit Note', 'Credit Note')
THEN 1=1
ELSE {bank_account_condition}
END)
order by t1.posting_date
{limit}
""".format(
**{
"dr_or_cr": dr_or_cr,
"bank_account_condition": bank_account_condition,
"condition": condition,
"limit": limit,
}
),
{
"party_type": self.party_type,
"party": self.party,
"account": self.receivable_payable_account,
"bank_cash_account": "%%%s%%" % self.bank_cash_account,
},
as_dict=1,
)
return list(journal_entries) return list(journal_entries)
@ -298,6 +309,7 @@ class PaymentReconciliation(Document):
min_outstanding=-(self.minimum_payment_amount) if self.minimum_payment_amount else None, min_outstanding=-(self.minimum_payment_amount) if self.minimum_payment_amount else None,
max_outstanding=-(self.maximum_payment_amount) if self.maximum_payment_amount else None, max_outstanding=-(self.maximum_payment_amount) if self.maximum_payment_amount else None,
get_payments=True, get_payments=True,
accounting_dimensions=self.accounting_dimension_filter_conditions,
) )
for inv in return_outstanding: for inv in return_outstanding:
@ -446,8 +458,15 @@ class PaymentReconciliation(Document):
row = self.append("allocation", {}) row = self.append("allocation", {})
row.update(entry) row.update(entry)
def update_dimension_values_in_allocated_entries(self, res):
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
res[dimension] = self.get(dimension)
return res
def get_allocated_entry(self, pay, inv, allocated_amount): def get_allocated_entry(self, pay, inv, allocated_amount):
return frappe._dict( res = frappe._dict(
{ {
"reference_type": pay.get("reference_type"), "reference_type": pay.get("reference_type"),
"reference_name": pay.get("reference_name"), "reference_name": pay.get("reference_name"),
@ -463,6 +482,9 @@ class PaymentReconciliation(Document):
} }
) )
res = self.update_dimension_values_in_allocated_entries(res)
return res
def reconcile_allocations(self, skip_ref_details_update_for_pe=False): def reconcile_allocations(self, skip_ref_details_update_for_pe=False):
adjust_allocations_for_taxes(self) adjust_allocations_for_taxes(self)
dr_or_cr = ( dr_or_cr = (
@ -485,10 +507,10 @@ class PaymentReconciliation(Document):
reconciled_entry.append(payment_details) reconciled_entry.append(payment_details)
if entry_list: if entry_list:
reconcile_against_document(entry_list, skip_ref_details_update_for_pe) reconcile_against_document(entry_list, skip_ref_details_update_for_pe, self.dimensions)
if dr_or_cr_notes: if dr_or_cr_notes:
reconcile_dr_cr_note(dr_or_cr_notes, self.company) reconcile_dr_cr_note(dr_or_cr_notes, self.company, self.dimensions)
@frappe.whitelist() @frappe.whitelist()
def reconcile(self): def reconcile(self):
@ -517,7 +539,7 @@ class PaymentReconciliation(Document):
self.get_unreconciled_entries() self.get_unreconciled_entries()
def get_payment_details(self, row, dr_or_cr): def get_payment_details(self, row, dr_or_cr):
return frappe._dict( payment_details = frappe._dict(
{ {
"voucher_type": row.get("reference_type"), "voucher_type": row.get("reference_type"),
"voucher_no": row.get("reference_name"), "voucher_no": row.get("reference_name"),
@ -539,6 +561,12 @@ class PaymentReconciliation(Document):
} }
) )
for x in self.dimensions:
if row.get(x.fieldname):
payment_details[x.fieldname] = row.get(x.fieldname)
return payment_details
def check_mandatory_to_fetch(self): def check_mandatory_to_fetch(self):
for fieldname in ["company", "party_type", "party", "receivable_payable_account"]: for fieldname in ["company", "party_type", "party", "receivable_payable_account"]:
if not self.get(fieldname): if not self.get(fieldname):
@ -646,6 +674,13 @@ class PaymentReconciliation(Document):
if not invoices_to_reconcile: if not invoices_to_reconcile:
frappe.throw(_("No records found in Allocation table")) frappe.throw(_("No records found in Allocation table"))
def build_dimensions_filter_conditions(self):
ple = qb.DocType("Payment Ledger Entry")
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
self.accounting_dimension_filter_conditions.append(ple[dimension] == self.get(dimension))
def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False): def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):
self.common_filter_conditions.clear() self.common_filter_conditions.clear()
self.accounting_dimension_filter_conditions.clear() self.accounting_dimension_filter_conditions.clear()
@ -669,40 +704,30 @@ class PaymentReconciliation(Document):
if self.to_payment_date: if self.to_payment_date:
self.ple_posting_date_filter.append(ple.posting_date.lte(self.to_payment_date)) self.ple_posting_date_filter.append(ple.posting_date.lte(self.to_payment_date))
def get_conditions(self, get_payments=False): self.build_dimensions_filter_conditions()
condition = " and company = '{0}' ".format(self.company)
if self.get("cost_center") and get_payments: def get_journal_filter_conditions(self):
condition = " and cost_center = '{0}' ".format(self.cost_center) conditions = []
je = qb.DocType("Journal Entry")
jea = qb.DocType("Journal Entry Account")
conditions.append(je.company == self.company)
condition += ( if self.from_payment_date:
" and posting_date >= {0}".format(frappe.db.escape(self.from_payment_date)) conditions.append(je.posting_date.gte(self.from_payment_date))
if self.from_payment_date
else "" if self.to_payment_date:
) conditions.append(je.posting_date.lte(self.to_payment_date))
condition += (
" and posting_date <= {0}".format(frappe.db.escape(self.to_payment_date))
if self.to_payment_date
else ""
)
if self.minimum_payment_amount: if self.minimum_payment_amount:
condition += ( conditions.append(je.total_debit.gte(self.minimum_payment_amount))
" and unallocated_amount >= {0}".format(flt(self.minimum_payment_amount))
if get_payments
else " and total_debit >= {0}".format(flt(self.minimum_payment_amount))
)
if self.maximum_payment_amount: if self.maximum_payment_amount:
condition += ( conditions.append(je.total_debit.lte(self.maximum_payment_amount))
" and unallocated_amount <= {0}".format(flt(self.maximum_payment_amount))
if get_payments
else " and total_debit <= {0}".format(flt(self.maximum_payment_amount))
)
return condition return conditions
def reconcile_dr_cr_note(dr_cr_notes, company): def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None):
for inv in dr_cr_notes: for inv in dr_cr_notes:
voucher_type = "Credit Note" if inv.voucher_type == "Sales Invoice" else "Debit Note" voucher_type = "Credit Note" if inv.voucher_type == "Sales Invoice" else "Debit Note"
@ -752,6 +777,15 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
} }
) )
# Credit Note(JE) will inherit the same dimension values as payment
dimensions_dict = frappe._dict()
if active_dimensions:
for dim in active_dimensions:
dimensions_dict[dim.fieldname] = inv.get(dim.fieldname)
jv.accounts[0].update(dimensions_dict)
jv.accounts[1].update(dimensions_dict)
jv.flags.ignore_mandatory = True jv.flags.ignore_mandatory = True
jv.flags.ignore_exchange_rate = True jv.flags.ignore_exchange_rate = True
jv.remark = None jv.remark = None
@ -785,9 +819,27 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
inv.against_voucher, inv.against_voucher,
None, None,
inv.cost_center, inv.cost_center,
dimensions_dict,
) )
@erpnext.allow_regional @erpnext.allow_regional
def adjust_allocations_for_taxes(doc): def adjust_allocations_for_taxes(doc):
pass pass
@frappe.whitelist()
def get_queries_for_dimension_filters(company: str = None):
dimensions_with_filters = []
for d in get_dimensions()[0]:
filters = {}
meta = frappe.get_meta(d.document_type)
if meta.has_field("company") and company:
filters.update({"company": company})
if meta.is_tree:
filters.update({"is_group": 0})
dimensions_with_filters.append({"fieldname": d.fieldname, "filters": filters})
return dimensions_with_filters

View File

@ -23,7 +23,9 @@
"difference_account", "difference_account",
"exchange_rate", "exchange_rate",
"currency", "currency",
"cost_center" "accounting_dimensions_section",
"cost_center",
"dimension_col_break"
], ],
"fields": [ "fields": [
{ {
@ -151,12 +153,26 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Cost Center", "label": "Cost Center",
"options": "Cost Center" "options": "Cost Center"
},
{
"fieldname": "gain_loss_posting_date",
"fieldtype": "Date",
"label": "Difference Posting Date"
},
{
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
} }
], ],
"is_virtual": 1, "is_virtual": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-11-17 17:33:38.612615", "modified": "2023-12-14 13:38:26.104150",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Payment Reconciliation Allocation", "name": "Payment Reconciliation Allocation",

View File

@ -11,7 +11,6 @@ from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_lo
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
SalesInvoice, SalesInvoice,
get_bank_cash_account,
get_mode_of_payment_info, get_mode_of_payment_info,
update_multi_mode_option, update_multi_mode_option,
) )
@ -208,7 +207,6 @@ class POSInvoice(SalesInvoice):
self.validate_stock_availablility() self.validate_stock_availablility()
self.validate_return_items_qty() self.validate_return_items_qty()
self.set_status() self.set_status()
self.set_account_for_mode_of_payment()
self.validate_pos() self.validate_pos()
self.validate_payment_amount() self.validate_payment_amount()
self.validate_loyalty_transaction() self.validate_loyalty_transaction()
@ -643,11 +641,6 @@ class POSInvoice(SalesInvoice):
update_multi_mode_option(self, pos_profile) update_multi_mode_option(self, pos_profile)
self.paid_amount = 0 self.paid_amount = 0
def set_account_for_mode_of_payment(self):
for pay in self.payments:
if not pay.account:
pay.account = get_bank_cash_account(pay.mode_of_payment, self.company).get("account")
@frappe.whitelist() @frappe.whitelist()
def create_payment_request(self): def create_payment_request(self):
for pay in self.payments: for pay in self.payments:

View File

@ -1985,6 +1985,21 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
self.assertEqual(pi.items[0].cost_center, "_Test Cost Center Buying - _TC") self.assertEqual(pi.items[0].cost_center, "_Test Cost Center Buying - _TC")
def test_debit_note_with_account_mismatch(self):
new_creditors = create_account(
parent_account="Accounts Payable - _TC",
account_name="Creditors 2",
company="_Test Company",
account_type="Payable",
)
pi = make_purchase_invoice(qty=1, rate=1000)
dr_note = make_purchase_invoice(
qty=-1, rate=1000, is_return=1, return_against=pi.name, do_not_save=True
)
dr_note.credit_to = new_creditors
self.assertRaises(frappe.ValidationError, dr_note.save)
def test_debit_note_without_item(self): def test_debit_note_without_item(self):
pi = make_purchase_invoice(item_name="_Test Item", qty=10, do_not_submit=True) pi = make_purchase_invoice(item_name="_Test Item", qty=10, do_not_submit=True)
pi.items[0].item_code = "" pi.items[0].item_code = ""

View File

@ -420,7 +420,8 @@ class SalesInvoice(SellingController):
self.calculate_taxes_and_totals() self.calculate_taxes_and_totals()
def before_save(self): def before_save(self):
set_account_for_mode_of_payment(self) self.set_account_for_mode_of_payment()
self.set_paid_amount()
def on_submit(self): def on_submit(self):
self.validate_pos_paid_amount() self.validate_pos_paid_amount()
@ -706,9 +707,6 @@ class SalesInvoice(SellingController):
): ):
data.sales_invoice = sales_invoice data.sales_invoice = sales_invoice
def on_update(self):
self.set_paid_amount()
def on_update_after_submit(self): def on_update_after_submit(self):
if hasattr(self, "repost_required"): if hasattr(self, "repost_required"):
fields_to_check = [ fields_to_check = [
@ -739,6 +737,11 @@ class SalesInvoice(SellingController):
self.paid_amount = paid_amount self.paid_amount = paid_amount
self.base_paid_amount = base_paid_amount self.base_paid_amount = base_paid_amount
def set_account_for_mode_of_payment(self):
for payment in self.payments:
if not payment.account:
payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account")
def validate_time_sheets_are_submitted(self): def validate_time_sheets_are_submitted(self):
for data in self.timesheets: for data in self.timesheets:
if data.time_sheet: if data.time_sheet:
@ -2107,12 +2110,6 @@ def make_sales_return(source_name, target_doc=None):
return make_return_doc("Sales Invoice", source_name, target_doc) return make_return_doc("Sales Invoice", source_name, target_doc)
def set_account_for_mode_of_payment(self):
for data in self.payments:
if not data.account:
data.account = get_bank_cash_account(data.mode_of_payment, self.company).get("account")
def get_inter_company_details(doc, doctype): def get_inter_company_details(doc, doctype):
if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"]: if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"]:
parties = frappe.db.get_all( parties = frappe.db.get_all(

View File

@ -1533,6 +1533,19 @@ class TestSalesInvoice(FrappeTestCase):
self.assertEqual(frappe.db.get_value("Sales Invoice", si1.name, "outstanding_amount"), -1000) self.assertEqual(frappe.db.get_value("Sales Invoice", si1.name, "outstanding_amount"), -1000)
self.assertEqual(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"), 2500) self.assertEqual(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"), 2500)
def test_return_invoice_with_account_mismatch(self):
debtors2 = create_account(
parent_account="Accounts Receivable - _TC",
account_name="Debtors 2",
company="_Test Company",
account_type="Receivable",
)
si = create_sales_invoice(qty=1, rate=1000)
cr_note = create_sales_invoice(
qty=-1, rate=1000, is_return=1, return_against=si.name, debit_to=debtors2, do_not_save=True
)
self.assertRaises(frappe.ValidationError, cr_note.save)
def test_gle_made_when_asset_is_returned(self): def test_gle_made_when_asset_is_returned(self):
create_asset_data() create_asset_data()
asset = create_asset(item_code="Macbook Pro") asset = create_asset(item_code="Macbook Pro")

View File

@ -5,7 +5,7 @@
from collections import OrderedDict from collections import OrderedDict
import frappe import frappe
from frappe import _, qb, scrub from frappe import _, qb, query_builder, scrub
from frappe.query_builder import Criterion from frappe.query_builder import Criterion
from frappe.query_builder.functions import Date, Substring, Sum from frappe.query_builder.functions import Date, Substring, Sum
from frappe.utils import cint, cstr, flt, getdate, nowdate from frappe.utils import cint, cstr, flt, getdate, nowdate
@ -576,6 +576,8 @@ class ReceivablePayableReport(object):
def get_future_payments_from_payment_entry(self): def get_future_payments_from_payment_entry(self):
pe = frappe.qb.DocType("Payment Entry") pe = frappe.qb.DocType("Payment Entry")
pe_ref = frappe.qb.DocType("Payment Entry Reference") pe_ref = frappe.qb.DocType("Payment Entry Reference")
ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"])
return ( return (
frappe.qb.from_(pe) frappe.qb.from_(pe)
.inner_join(pe_ref) .inner_join(pe_ref)
@ -587,6 +589,11 @@ class ReceivablePayableReport(object):
(pe.posting_date).as_("future_date"), (pe.posting_date).as_("future_date"),
(pe_ref.allocated_amount).as_("future_amount"), (pe_ref.allocated_amount).as_("future_amount"),
(pe.reference_no).as_("future_ref"), (pe.reference_no).as_("future_ref"),
ifelse(
pe.payment_type == "Receive",
pe.source_exchange_rate * pe_ref.allocated_amount,
pe.target_exchange_rate * pe_ref.allocated_amount,
).as_("future_amount_in_base_currency"),
) )
.where( .where(
(pe.docstatus < 2) (pe.docstatus < 2)
@ -623,13 +630,24 @@ class ReceivablePayableReport(object):
query = query.select( query = query.select(
Sum(jea.debit_in_account_currency - jea.credit_in_account_currency).as_("future_amount") Sum(jea.debit_in_account_currency - jea.credit_in_account_currency).as_("future_amount")
) )
query = query.select(Sum(jea.debit - jea.credit).as_("future_amount_in_base_currency"))
else: else:
query = query.select( query = query.select(
Sum(jea.credit_in_account_currency - jea.debit_in_account_currency).as_("future_amount") Sum(jea.credit_in_account_currency - jea.debit_in_account_currency).as_("future_amount")
) )
query = query.select(Sum(jea.credit - jea.debit).as_("future_amount_in_base_currency"))
else: else:
query = query.select( query = query.select(
Sum(jea.debit if self.account_type == "Payable" else jea.credit).as_("future_amount") Sum(jea.debit if self.account_type == "Payable" else jea.credit).as_(
"future_amount_in_base_currency"
)
)
query = query.select(
Sum(
jea.debit_in_account_currency
if self.account_type == "Payable"
else jea.credit_in_account_currency
).as_("future_amount")
) )
query = query.having(qb.Field("future_amount") > 0) query = query.having(qb.Field("future_amount") > 0)
@ -645,14 +663,19 @@ class ReceivablePayableReport(object):
row.remaining_balance = row.outstanding row.remaining_balance = row.outstanding
row.future_amount = 0.0 row.future_amount = 0.0
for future in self.future_payments.get((row.voucher_no, row.party), []): for future in self.future_payments.get((row.voucher_no, row.party), []):
if row.remaining_balance > 0 and future.future_amount: if self.filters.in_party_currency:
if future.future_amount > row.outstanding: future_amount_field = "future_amount"
else:
future_amount_field = "future_amount_in_base_currency"
if row.remaining_balance > 0 and future.get(future_amount_field):
if future.get(future_amount_field) > row.outstanding:
row.future_amount = row.outstanding row.future_amount = row.outstanding
future.future_amount = future.future_amount - row.outstanding future[future_amount_field] = future.get(future_amount_field) - row.outstanding
row.remaining_balance = 0 row.remaining_balance = 0
else: else:
row.future_amount += future.future_amount row.future_amount += future.get(future_amount_field)
future.future_amount = 0 future[future_amount_field] = 0
row.remaining_balance = row.outstanding - row.future_amount row.remaining_balance = row.outstanding - row.future_amount
row.setdefault("future_ref", []).append( row.setdefault("future_ref", []).append(

View File

@ -772,3 +772,92 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
# post sorting output should be [[Additional Debtors, ...], [Debtors, ...]] # post sorting output should be [[Additional Debtors, ...], [Debtors, ...]]
report_output = sorted(report_output, key=lambda x: x[0]) report_output = sorted(report_output, key=lambda x: x[0])
self.assertEqual(expected_data, report_output) self.assertEqual(expected_data, report_output)
def test_future_payments_on_foreign_currency(self):
self.customer2 = (
frappe.get_doc(
{
"doctype": "Customer",
"customer_name": "Jane Doe",
"type": "Individual",
"default_currency": "USD",
}
)
.insert()
.submit()
)
si = self.create_sales_invoice(do_not_submit=True)
si.posting_date = add_days(today(), -1)
si.customer = self.customer2
si.currency = "USD"
si.conversion_rate = 80
si.debit_to = self.debtors_usd
si.save().submit()
# full payment in USD
pe = get_payment_entry(si.doctype, si.name)
pe.posting_date = add_days(today(), 1)
pe.base_received_amount = 7500
pe.received_amount = 7500
pe.source_exchange_rate = 75
pe.save().submit()
filters = frappe._dict(
{
"company": self.company,
"report_date": today(),
"range1": 30,
"range2": 60,
"range3": 90,
"range4": 120,
"show_future_payments": True,
"in_party_currency": False,
}
)
report = execute(filters)[1]
self.assertEqual(len(report), 1)
expected_data = [8000.0, 8000.0, 500.0, 7500.0]
row = report[0]
self.assertEqual(
expected_data, [row.invoiced, row.outstanding, row.remaining_balance, row.future_amount]
)
filters.in_party_currency = True
report = execute(filters)[1]
self.assertEqual(len(report), 1)
expected_data = [100.0, 100.0, 0.0, 100.0]
row = report[0]
self.assertEqual(
expected_data, [row.invoiced, row.outstanding, row.remaining_balance, row.future_amount]
)
pe.cancel()
# partial payment in USD on a future date
pe = get_payment_entry(si.doctype, si.name)
pe.posting_date = add_days(today(), 1)
pe.base_received_amount = 6750
pe.received_amount = 6750
pe.source_exchange_rate = 75
pe.paid_amount = 90 # in USD
pe.references[0].allocated_amount = 90
pe.save().submit()
filters.in_party_currency = False
report = execute(filters)[1]
self.assertEqual(len(report), 1)
expected_data = [8000.0, 8000.0, 1250.0, 6750.0]
row = report[0]
self.assertEqual(
expected_data, [row.invoiced, row.outstanding, row.remaining_balance, row.future_amount]
)
filters.in_party_currency = True
report = execute(filters)[1]
self.assertEqual(len(report), 1)
expected_data = [100.0, 100.0, 10.0, 90.0]
row = report[0]
self.assertEqual(
expected_data, [row.invoiced, row.outstanding, row.remaining_balance, row.future_amount]
)

View File

@ -8,6 +8,20 @@ frappe.query_reports["Balance Sheet"] = $.extend(
erpnext.utils.add_dimensions("Balance Sheet", 10); erpnext.utils.add_dimensions("Balance Sheet", 10);
frappe.query_reports["Balance Sheet"]["filters"].push(
{
"fieldname": "selected_view",
"label": __("Select View"),
"fieldtype": "Select",
"options": [
{ "value": "Report", "label": __("Report View") },
{ "value": "Growth", "label": __("Growth View") }
],
"default": "Report",
"reqd": 1
},
);
frappe.query_reports["Balance Sheet"]["filters"].push({ frappe.query_reports["Balance Sheet"]["filters"].push({
fieldname: "accumulated_values", fieldname: "accumulated_values",
label: __("Accumulated Values"), label: __("Accumulated Values"),

View File

@ -8,6 +8,21 @@ frappe.query_reports["Profit and Loss Statement"] = $.extend(
erpnext.utils.add_dimensions("Profit and Loss Statement", 10); erpnext.utils.add_dimensions("Profit and Loss Statement", 10);
frappe.query_reports["Profit and Loss Statement"]["filters"].push(
{
"fieldname": "selected_view",
"label": __("Select View"),
"fieldtype": "Select",
"options": [
{ "value": "Report", "label": __("Report View") },
{ "value": "Growth", "label": __("Growth View") },
{ "value": "Margin", "label": __("Margin View") },
],
"default": "Report",
"reqd": 1
},
);
frappe.query_reports["Profit and Loss Statement"]["filters"].push({ frappe.query_reports["Profit and Loss Statement"]["filters"].push({
fieldname: "accumulated_values", fieldname: "accumulated_values",
label: __("Accumulated Values"), label: __("Accumulated Values"),

View File

@ -240,7 +240,6 @@ def get_balance_on(
cond.append("""gle.cost_center = %s """ % (frappe.db.escape(cost_center, percent=False),)) cond.append("""gle.cost_center = %s """ % (frappe.db.escape(cost_center, percent=False),))
if account: if account:
if not (frappe.flags.ignore_account_permission or ignore_account_permission): if not (frappe.flags.ignore_account_permission or ignore_account_permission):
acc.check_permission("read") acc.check_permission("read")
@ -286,18 +285,22 @@ def get_balance_on(
cond.append("""gle.company = %s """ % (frappe.db.escape(company, percent=False))) cond.append("""gle.company = %s """ % (frappe.db.escape(company, percent=False)))
if account or (party_type and party) or account_type: if account or (party_type and party) or account_type:
precision = get_currency_precision()
if in_account_currency: if in_account_currency:
select_field = "sum(debit_in_account_currency) - sum(credit_in_account_currency)" select_field = (
"sum(round(debit_in_account_currency, %s)) - sum(round(credit_in_account_currency, %s))"
)
else: else:
select_field = "sum(debit) - sum(credit)" select_field = "sum(round(debit, %s)) - sum(round(credit, %s))"
bal = frappe.db.sql( bal = frappe.db.sql(
""" """
SELECT {0} SELECT {0}
FROM `tabGL Entry` gle FROM `tabGL Entry` gle
WHERE {1}""".format( WHERE {1}""".format(
select_field, " and ".join(cond) select_field, " and ".join(cond)
) ),
(precision, precision),
)[0][0] )[0][0]
# if bal is None, return 0 # if bal is None, return 0
return flt(bal) return flt(bal)
@ -453,7 +456,19 @@ def add_cc(args=None):
return cc.name return cc.name
def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # nosemgrep def _build_dimensions_dict_for_exc_gain_loss(
entry: dict | object = None, active_dimensions: list = None
):
dimensions_dict = frappe._dict()
if entry and active_dimensions:
for dim in active_dimensions:
dimensions_dict[dim.fieldname] = entry.get(dim.fieldname)
return dimensions_dict
def reconcile_against_document(
args, skip_ref_details_update_for_pe=False, active_dimensions=None
): # nosemgrep
""" """
Cancel PE or JV, Update against document, split if required and resubmit Cancel PE or JV, Update against document, split if required and resubmit
""" """
@ -482,6 +497,8 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n
check_if_advance_entry_modified(entry) check_if_advance_entry_modified(entry)
validate_allocated_amount(entry) validate_allocated_amount(entry)
dimensions_dict = _build_dimensions_dict_for_exc_gain_loss(entry, active_dimensions)
# update ref in advance entry # update ref in advance entry
if voucher_type == "Journal Entry": if voucher_type == "Journal Entry":
referenced_row = update_reference_in_journal_entry(entry, doc, do_not_save=False) referenced_row = update_reference_in_journal_entry(entry, doc, do_not_save=False)
@ -489,10 +506,14 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n
# amount and account in args # amount and account in args
# referenced_row is used to deduplicate gain/loss journal # referenced_row is used to deduplicate gain/loss journal
entry.update({"referenced_row": referenced_row}) entry.update({"referenced_row": referenced_row})
doc.make_exchange_gain_loss_journal([entry]) doc.make_exchange_gain_loss_journal([entry], dimensions_dict)
else: else:
referenced_row = update_reference_in_payment_entry( referenced_row = update_reference_in_payment_entry(
entry, doc, do_not_save=True, skip_ref_details_update_for_pe=skip_ref_details_update_for_pe entry,
doc,
do_not_save=True,
skip_ref_details_update_for_pe=skip_ref_details_update_for_pe,
dimensions_dict=dimensions_dict,
) )
doc.save(ignore_permissions=True) doc.save(ignore_permissions=True)
@ -649,7 +670,7 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False):
def update_reference_in_payment_entry( def update_reference_in_payment_entry(
d, payment_entry, do_not_save=False, skip_ref_details_update_for_pe=False d, payment_entry, do_not_save=False, skip_ref_details_update_for_pe=False, dimensions_dict=None
): ):
reference_details = { reference_details = {
"reference_doctype": d.against_voucher_type, "reference_doctype": d.against_voucher_type,
@ -662,6 +683,7 @@ def update_reference_in_payment_entry(
else payment_entry.get_exchange_rate(), else payment_entry.get_exchange_rate(),
"exchange_gain_loss": d.difference_amount, "exchange_gain_loss": d.difference_amount,
"account": d.account, "account": d.account,
"dimensions": d.dimensions,
} }
if d.voucher_detail_no: if d.voucher_detail_no:
@ -694,7 +716,9 @@ def update_reference_in_payment_entry(
if not skip_ref_details_update_for_pe: if not skip_ref_details_update_for_pe:
payment_entry.set_missing_ref_details() payment_entry.set_missing_ref_details()
payment_entry.set_amounts() payment_entry.set_amounts()
payment_entry.make_exchange_gain_loss_journal() payment_entry.make_exchange_gain_loss_journal(
frappe._dict({"difference_posting_date": d.difference_posting_date}), dimensions_dict
)
if not do_not_save: if not do_not_save:
payment_entry.save(ignore_permissions=True) payment_entry.save(ignore_permissions=True)
@ -2031,6 +2055,7 @@ def create_gain_loss_journal(
ref2_dn, ref2_dn,
ref2_detail_no, ref2_detail_no,
cost_center, cost_center,
dimensions,
) -> str: ) -> str:
journal_entry = frappe.new_doc("Journal Entry") journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = "Exchange Gain Or Loss" journal_entry.voucher_type = "Exchange Gain Or Loss"
@ -2064,7 +2089,8 @@ def create_gain_loss_journal(
dr_or_cr + "_in_account_currency": 0, dr_or_cr + "_in_account_currency": 0,
} }
) )
if dimensions:
journal_account.update(dimensions)
journal_entry.append("accounts", journal_account) journal_entry.append("accounts", journal_account)
journal_account = frappe._dict( journal_account = frappe._dict(
@ -2080,7 +2106,8 @@ def create_gain_loss_journal(
reverse_dr_or_cr: abs(exc_gain_loss), reverse_dr_or_cr: abs(exc_gain_loss),
} }
) )
if dimensions:
journal_account.update(dimensions)
journal_entry.append("accounts", journal_account) journal_entry.append("accounts", journal_account)
journal_entry.save() journal_entry.save()

View File

@ -571,16 +571,16 @@ frappe.ui.form.on('Asset', {
indicator: 'red' indicator: 'red'
}); });
} }
var is_grouped_asset = frappe.db.get_value('Item', item.item_code, 'is_grouped_asset'); frappe.db.get_value('Item', item.item_code, 'is_grouped_asset', (r) => {
var asset_quantity = is_grouped_asset ? item.qty : 1; var asset_quantity = r.is_grouped_asset ? item.qty : 1;
var purchase_amount = flt(item.valuation_rate * asset_quantity, precision('gross_purchase_amount')); var purchase_amount = flt(item.valuation_rate * asset_quantity, precision('gross_purchase_amount'));
frm.set_value('gross_purchase_amount', purchase_amount);
frm.set_value('purchase_receipt_amount', purchase_amount);
frm.set_value('asset_quantity', asset_quantity);
frm.set_value('cost_center', item.cost_center || purchase_doc.cost_center);
if(item.asset_location) { frm.set_value('location', item.asset_location); }
frm.set_value('gross_purchase_amount', purchase_amount);
frm.set_value('purchase_receipt_amount', purchase_amount);
frm.set_value('asset_quantity', asset_quantity);
frm.set_value('cost_center', item.cost_center || purchase_doc.cost_center);
if(item.asset_location) { frm.set_value('location', item.asset_location); }
});
}, },
set_depreciation_rate: function(frm, row) { set_depreciation_rate: function(frm, row) {

View File

@ -7,6 +7,7 @@ import json
import frappe import frappe
from frappe import _, bold, qb, throw from frappe import _, bold, qb, throw
from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied
from frappe.query_builder import Criterion
from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Abs, Sum from frappe.query_builder.functions import Abs, Sum
from frappe.utils import ( from frappe.utils import (
@ -28,6 +29,7 @@ from frappe.utils import (
import erpnext import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions, get_accounting_dimensions,
get_dimensions,
) )
from erpnext.accounts.doctype.pricing_rule.utils import ( from erpnext.accounts.doctype.pricing_rule.utils import (
apply_pricing_rule_for_free_items, apply_pricing_rule_for_free_items,
@ -200,6 +202,7 @@ class AccountsController(TransactionBase):
self.validate_party() self.validate_party()
self.validate_currency() self.validate_currency()
self.validate_party_account_currency() self.validate_party_account_currency()
self.validate_return_against_account()
if self.doctype in ["Purchase Invoice", "Sales Invoice"]: if self.doctype in ["Purchase Invoice", "Sales Invoice"]:
if invalid_advances := [ if invalid_advances := [
@ -348,6 +351,20 @@ class AccountsController(TransactionBase):
for bundle in bundles: for bundle in bundles:
frappe.delete_doc("Serial and Batch Bundle", bundle.name) frappe.delete_doc("Serial and Batch Bundle", bundle.name)
def validate_return_against_account(self):
if (
self.doctype in ["Sales Invoice", "Purchase Invoice"] and self.is_return and self.return_against
):
cr_dr_account_field = "debit_to" if self.doctype == "Sales Invoice" else "credit_to"
cr_dr_account_label = "Debit To" if self.doctype == "Sales Invoice" else "Credit To"
cr_dr_account = self.get(cr_dr_account_field)
if frappe.get_value(self.doctype, self.return_against, cr_dr_account_field) != cr_dr_account:
frappe.throw(
_("'{0}' account: '{1}' should match the Return Against Invoice").format(
frappe.bold(cr_dr_account_label), frappe.bold(cr_dr_account)
)
)
def validate_deferred_income_expense_account(self): def validate_deferred_income_expense_account(self):
field_map = { field_map = {
"Sales Invoice": "deferred_revenue_account", "Sales Invoice": "deferred_revenue_account",
@ -1246,7 +1263,9 @@ class AccountsController(TransactionBase):
return True return True
return False return False
def make_exchange_gain_loss_journal(self, args: dict = None) -> None: def make_exchange_gain_loss_journal(
self, args: dict = None, dimensions_dict: dict = None
) -> None:
""" """
Make Exchange Gain/Loss journal for Invoices and Payments Make Exchange Gain/Loss journal for Invoices and Payments
""" """
@ -1299,6 +1318,7 @@ class AccountsController(TransactionBase):
self.name, self.name,
arg.get("referenced_row"), arg.get("referenced_row"),
arg.get("cost_center"), arg.get("cost_center"),
dimensions_dict,
) )
frappe.msgprint( frappe.msgprint(
_("Exchange Gain/Loss amount has been booked through {0}").format( _("Exchange Gain/Loss amount has been booked through {0}").format(
@ -1379,6 +1399,7 @@ class AccountsController(TransactionBase):
self.name, self.name,
d.idx, d.idx,
self.cost_center, self.cost_center,
dimensions_dict,
) )
frappe.msgprint( frappe.msgprint(
_("Exchange Gain/Loss amount has been booked through {0}").format( _("Exchange Gain/Loss amount has been booked through {0}").format(
@ -1443,7 +1464,13 @@ class AccountsController(TransactionBase):
if lst: if lst:
from erpnext.accounts.utils import reconcile_against_document from erpnext.accounts.utils import reconcile_against_document
reconcile_against_document(lst) # pass dimension values to utility method
active_dimensions = get_dimensions()[0]
for x in lst:
for dim in active_dimensions:
if self.get(dim.fieldname):
x.update({dim.fieldname: self.get(dim.fieldname)})
reconcile_against_document(lst, active_dimensions=active_dimensions)
def on_cancel(self): def on_cancel(self):
from erpnext.accounts.doctype.bank_transaction.bank_transaction import ( from erpnext.accounts.doctype.bank_transaction.bank_transaction import (
@ -2712,47 +2739,37 @@ def get_common_query(
q = q.select((payment_entry.target_exchange_rate).as_("exchange_rate")) q = q.select((payment_entry.target_exchange_rate).as_("exchange_rate"))
if condition: if condition:
if condition.get("name", None): # conditions should be built as an array and passed as Criterion
q = q.where(payment_entry.name.like(f"%{condition.get('name')}%")) common_filter_conditions = []
common_filter_conditions.append(payment_entry.company == condition["company"])
if condition.get("name", None):
common_filter_conditions.append(payment_entry.name.like(f"%{condition.get('name')}%"))
if condition.get("from_payment_date"):
common_filter_conditions.append(payment_entry.posting_date.gte(condition["from_payment_date"]))
if condition.get("to_payment_date"):
common_filter_conditions.append(payment_entry.posting_date.lte(condition["to_payment_date"]))
q = q.where(payment_entry.company == condition["company"])
q = (
q.where(payment_entry.posting_date >= condition["from_payment_date"])
if condition.get("from_payment_date")
else q
)
q = (
q.where(payment_entry.posting_date <= condition["to_payment_date"])
if condition.get("to_payment_date")
else q
)
if condition.get("get_payments") == True: if condition.get("get_payments") == True:
q = ( if condition.get("cost_center"):
q.where(payment_entry.cost_center == condition["cost_center"]) common_filter_conditions.append(payment_entry.cost_center == condition["cost_center"])
if condition.get("cost_center")
else q if condition.get("accounting_dimensions"):
) for field, val in condition.get("accounting_dimensions").items():
q = ( common_filter_conditions.append(payment_entry[field] == val)
q.where(payment_entry.unallocated_amount >= condition["minimum_payment_amount"])
if condition.get("minimum_payment_amount") if condition.get("minimum_payment_amount"):
else q common_filter_conditions.append(
) payment_entry.unallocated_amount.gte(condition["minimum_payment_amount"])
q = ( )
q.where(payment_entry.unallocated_amount <= condition["maximum_payment_amount"])
if condition.get("maximum_payment_amount") if condition.get("maximum_payment_amount"):
else q common_filter_conditions.append(
) payment_entry.unallocated_amount.lte(condition["maximum_payment_amount"])
else: )
q = ( q = q.where(Criterion.all(common_filter_conditions))
q.where(payment_entry.total_debit >= condition["minimum_payment_amount"])
if condition.get("minimum_payment_amount")
else q
)
q = (
q.where(payment_entry.total_debit <= condition["maximum_payment_amount"])
if condition.get("maximum_payment_amount")
else q
)
q = q.orderby(payment_entry.posting_date) q = q.orderby(payment_entry.posting_date)
q = q.limit(limit) if limit else q q = q.limit(limit) if limit else q

View File

@ -91,7 +91,8 @@ status_map = {
], ],
"Purchase Receipt": [ "Purchase Receipt": [
["Draft", None], ["Draft", None],
["To Bill", "eval:self.per_billed < 100 and self.docstatus == 1"], ["To Bill", "eval:self.per_billed == 0 and self.docstatus == 1"],
["Partly Billed", "eval:self.per_billed > 0 and self.per_billed < 100 and self.docstatus == 1"],
["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"], ["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"],
["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"], ["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"],
["Cancelled", "eval:self.docstatus==2"], ["Cancelled", "eval:self.docstatus==2"],

View File

@ -6,7 +6,7 @@ from collections import defaultdict
from typing import List, Tuple from typing import List, Tuple
import frappe import frappe
from frappe import _ from frappe import _, bold
from frappe.utils import cint, flt, get_link_to_form, getdate from frappe.utils import cint, flt, get_link_to_form, getdate
import erpnext import erpnext
@ -697,6 +697,9 @@ class StockController(AccountsController):
self.validate_in_transit_warehouses() self.validate_in_transit_warehouses()
self.validate_multi_currency() self.validate_multi_currency()
self.validate_packed_items() self.validate_packed_items()
if self.get("is_internal_supplier"):
self.validate_internal_transfer_qty()
else: else:
self.validate_internal_transfer_warehouse() self.validate_internal_transfer_warehouse()
@ -735,6 +738,116 @@ class StockController(AccountsController):
if self.doctype in ("Sales Invoice", "Delivery Note Item") and self.get("packed_items"): if self.doctype in ("Sales Invoice", "Delivery Note Item") and self.get("packed_items"):
frappe.throw(_("Packed Items cannot be transferred internally")) frappe.throw(_("Packed Items cannot be transferred internally"))
def validate_internal_transfer_qty(self):
if self.doctype not in ["Purchase Invoice", "Purchase Receipt"]:
return
item_wise_transfer_qty = self.get_item_wise_inter_transfer_qty()
if not item_wise_transfer_qty:
return
item_wise_received_qty = self.get_item_wise_inter_received_qty()
precision = frappe.get_precision(self.doctype + " Item", "qty")
over_receipt_allowance = frappe.db.get_single_value(
"Stock Settings", "over_delivery_receipt_allowance"
)
parent_doctype = {
"Purchase Receipt": "Delivery Note",
"Purchase Invoice": "Sales Invoice",
}.get(self.doctype)
for key, transferred_qty in item_wise_transfer_qty.items():
recevied_qty = flt(item_wise_received_qty.get(key), precision)
if over_receipt_allowance:
transferred_qty = transferred_qty + flt(
transferred_qty * over_receipt_allowance / 100, precision
)
if recevied_qty > flt(transferred_qty, precision):
frappe.throw(
_("For Item {0} cannot be received more than {1} qty against the {2} {3}").format(
bold(key[1]),
bold(flt(transferred_qty, precision)),
bold(parent_doctype),
get_link_to_form(parent_doctype, self.get("inter_company_reference")),
)
)
def get_item_wise_inter_transfer_qty(self):
reference_field = "inter_company_reference"
if self.doctype == "Purchase Invoice":
reference_field = "inter_company_invoice_reference"
parent_doctype = {
"Purchase Receipt": "Delivery Note",
"Purchase Invoice": "Sales Invoice",
}.get(self.doctype)
child_doctype = parent_doctype + " Item"
parent_tab = frappe.qb.DocType(parent_doctype)
child_tab = frappe.qb.DocType(child_doctype)
query = (
frappe.qb.from_(parent_doctype)
.inner_join(child_tab)
.on(child_tab.parent == parent_tab.name)
.select(
child_tab.name,
child_tab.item_code,
child_tab.qty,
)
.where((parent_tab.name == self.get(reference_field)) & (parent_tab.docstatus == 1))
)
data = query.run(as_dict=True)
item_wise_transfer_qty = defaultdict(float)
for row in data:
item_wise_transfer_qty[(row.name, row.item_code)] += flt(row.qty)
return item_wise_transfer_qty
def get_item_wise_inter_received_qty(self):
child_doctype = self.doctype + " Item"
parent_tab = frappe.qb.DocType(self.doctype)
child_tab = frappe.qb.DocType(child_doctype)
query = (
frappe.qb.from_(self.doctype)
.inner_join(child_tab)
.on(child_tab.parent == parent_tab.name)
.select(
child_tab.item_code,
child_tab.qty,
)
.where(parent_tab.docstatus < 2)
)
if self.doctype == "Purchase Invoice":
query = query.select(
child_tab.sales_invoice_item.as_("name"),
)
query = query.where(
parent_tab.inter_company_invoice_reference == self.inter_company_invoice_reference
)
else:
query = query.select(
child_tab.delivery_note_item.as_("name"),
)
query = query.where(parent_tab.inter_company_reference == self.inter_company_reference)
data = query.run(as_dict=True)
item_wise_transfer_qty = defaultdict(float)
for row in data:
item_wise_transfer_qty[(row.name, row.item_code)] += flt(row.qty)
return item_wise_transfer_qty
def validate_putaway_capacity(self): def validate_putaway_capacity(self):
# if over receipt is attempted while 'apply putaway rule' is disabled # if over receipt is attempted while 'apply putaway rule' is disabled
# and if rule was applied on the transaction, validate it. # and if rule was applied on the transaction, validate it.

View File

@ -260,18 +260,22 @@ class SubcontractingController(StockController):
return frappe.get_all(f"{doctype}", fields=fields, filters=filters) return frappe.get_all(f"{doctype}", fields=fields, filters=filters)
def __get_consumed_items(self, doctype, receipt_items): def __get_consumed_items(self, doctype, receipt_items):
fields = [
"serial_no",
"rm_item_code",
"reference_name",
"batch_no",
"consumed_qty",
"main_item_code",
"parent as voucher_no",
]
if self.subcontract_data.receipt_supplied_items_field != "Purchase Receipt Item Supplied":
fields.append("serial_and_batch_bundle")
return frappe.get_all( return frappe.get_all(
self.subcontract_data.receipt_supplied_items_field, self.subcontract_data.receipt_supplied_items_field,
fields=[ fields=fields,
"serial_no",
"rm_item_code",
"reference_name",
"serial_and_batch_bundle",
"batch_no",
"consumed_qty",
"main_item_code",
"parent as voucher_no",
],
filters={"docstatus": 1, "reference_name": ("in", list(receipt_items)), "parenttype": doctype}, filters={"docstatus": 1, "reference_name": ("in", list(receipt_items)), "parenttype": doctype},
) )

View File

@ -56,6 +56,7 @@ class TestAccountsController(FrappeTestCase):
20 series - Sales Invoice against Journals 20 series - Sales Invoice against Journals
30 series - Sales Invoice against Credit Notes 30 series - Sales Invoice against Credit Notes
40 series - Company default Cost center is unset 40 series - Company default Cost center is unset
50 series - Dimension inheritence
""" """
def setUp(self): def setUp(self):
@ -1188,3 +1189,214 @@ class TestAccountsController(FrappeTestCase):
) )
frappe.db.set_value("Company", self.company, "cost_center", cc) frappe.db.set_value("Company", self.company, "cost_center", cc)
def setup_dimensions(self):
# create dimension
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
)
create_dimension()
# make it non-mandatory
loc = frappe.get_doc("Accounting Dimension", "Location")
for x in loc.dimension_defaults:
x.mandatory_for_bs = False
x.mandatory_for_pl = False
loc.save()
def test_50_dimensions_filter(self):
"""
Test workings of dimension filters
"""
self.setup_dimensions()
rate_in_account_currency = 1
# Invoices
si1 = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True)
si1.department = "Management"
si1.save().submit()
si2 = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True)
si2.department = "Operations"
si2.save().submit()
# Payments
cr_note1 = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True)
cr_note1.department = "Management"
cr_note1.is_return = 1
cr_note1.save().submit()
cr_note2 = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True)
cr_note2.department = "Legal"
cr_note2.is_return = 1
cr_note2.save().submit()
pe1 = get_payment_entry(si1.doctype, si1.name)
pe1.references = []
pe1.department = "Research & Development"
pe1.save().submit()
pe2 = get_payment_entry(si1.doctype, si1.name)
pe2.references = []
pe2.department = "Management"
pe2.save().submit()
je1 = self.create_journal_entry(
acc1=self.debit_usd,
acc1_exc_rate=75,
acc2=self.cash,
acc1_amount=-1,
acc2_amount=-75,
acc2_exc_rate=1,
)
je1.accounts[0].party_type = "Customer"
je1.accounts[0].party = self.customer
je1.accounts[0].department = "Management"
je1.save().submit()
# assert dimension filter's result
pr = self.create_payment_reconciliation()
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 2)
self.assertEqual(len(pr.payments), 5)
pr.department = "Legal"
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 0)
self.assertEqual(len(pr.payments), 1)
pr.department = "Management"
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 3)
pr.department = "Research & Development"
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 0)
self.assertEqual(len(pr.payments), 1)
def test_51_cr_note_should_inherit_dimension(self):
self.setup_dimensions()
rate_in_account_currency = 1
# Invoice
si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True)
si.department = "Management"
si.save().submit()
# Payment
cr_note = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True)
cr_note.department = "Management"
cr_note.is_return = 1
cr_note.save().submit()
pr = self.create_payment_reconciliation()
pr.department = "Management"
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.reconcile()
self.assertEqual(len(pr.invoices), 0)
self.assertEqual(len(pr.payments), 0)
# There should be 2 journals, JE(Cr Note) and JE(Exchange Gain/Loss)
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_cr_note = self.get_journals_for(cr_note.doctype, cr_note.name)
self.assertNotEqual(exc_je_for_si, [])
self.assertEqual(len(exc_je_for_si), 2)
self.assertEqual(len(exc_je_for_cr_note), 2)
self.assertEqual(exc_je_for_si, exc_je_for_cr_note)
for x in exc_je_for_si + exc_je_for_cr_note:
with self.subTest(x=x):
self.assertEqual(
[cr_note.department, cr_note.department],
frappe.db.get_all("Journal Entry Account", filters={"parent": x.parent}, pluck="department"),
)
def test_52_dimension_inhertiance_exc_gain_loss(self):
# Sales Invoice in Foreign Currency
self.setup_dimensions()
rate = 80
rate_in_account_currency = 1
dpt = "Research & Development"
si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_save=True)
si.department = dpt
si.save().submit()
pe = self.create_payment_entry(amount=1, source_exc_rate=82).save()
pe.department = dpt
pe = pe.save().submit()
pr = self.create_payment_reconciliation()
pr.department = dpt
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.reconcile()
self.assertEqual(len(pr.invoices), 0)
self.assertEqual(len(pr.payments), 0)
# Exc Gain/Loss journals should inherit dimension from parent
journals = self.get_journals_for(si.doctype, si.name)
self.assertEqual(
[dpt, dpt],
frappe.db.get_all(
"Journal Entry Account",
filters={"parent": ("in", [x.parent for x in journals])},
pluck="department",
),
)
def test_53_dimension_inheritance_on_advance(self):
self.setup_dimensions()
dpt = "Research & Development"
adv = self.create_payment_entry(amount=1, source_exc_rate=85)
adv.department = dpt
adv.save().submit()
adv.reload()
# Sales Invoices in different exchange rates
si = self.create_sales_invoice(qty=1, conversion_rate=82, rate=1, do_not_submit=True)
si.department = dpt
advances = si.get_advance_entries()
self.assertEqual(len(advances), 1)
self.assertEqual(advances[0].reference_name, adv.name)
si.append(
"advances",
{
"doctype": "Sales Invoice Advance",
"reference_type": advances[0].reference_type,
"reference_name": advances[0].reference_name,
"reference_row": advances[0].reference_row,
"advance_amount": 1,
"allocated_amount": 1,
"ref_exchange_rate": advances[0].exchange_rate,
"remarks": advances[0].remarks,
},
)
si = si.save().submit()
# Outstanding in both currencies should be '0'
adv.reload()
self.assertEqual(si.outstanding_amount, 0)
self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
# Exc Gain/Loss journals should inherit dimension from parent
journals = self.get_journals_for(si.doctype, si.name)
self.assertEqual(
[dpt, dpt],
frappe.db.get_all(
"Journal Entry Account",
filters={"parent": ("in", [x.parent for x in journals])},
pluck="department",
),
)

View File

@ -543,6 +543,8 @@ accounting_dimension_doctypes = [
"Account Closing Balance", "Account Closing Balance",
"Supplier Quotation", "Supplier Quotation",
"Supplier Quotation Item", "Supplier Quotation Item",
"Payment Reconciliation",
"Payment Reconciliation Allocation",
] ]
get_matching_queries = ( get_matching_queries = (

View File

@ -176,8 +176,10 @@ class BOM(WebsiteGenerator):
def autoname(self): def autoname(self):
# ignore amended documents while calculating current index # ignore amended documents while calculating current index
search_key = f"{self.doctype}-{self.item}%"
existing_boms = frappe.get_all( existing_boms = frappe.get_all(
"BOM", filters={"item": self.item, "amended_from": ["is", "not set"]}, pluck="name" "BOM", filters={"name": ("like", search_key), "amended_from": ["is", "not set"]}, pluck="name"
) )
if existing_boms: if existing_boms:

View File

@ -101,6 +101,7 @@ frappe.ui.form.on("BOM Creator", {
} }
}) })
dialog.fields_dict.item_code.get_query = "erpnext.controllers.queries.item_query";
dialog.show(); dialog.show();
}, },
@ -113,6 +114,16 @@ frappe.ui.form.on("BOM Creator", {
} }
} }
}); });
frm.set_query("item_code", "items", function() {
return {
query: "erpnext.controllers.queries.item_query",
}
});
frm.set_query("fg_item", "items", function() {
return {
query: "erpnext.controllers.queries.item_query",
}
});
}, },
refresh(frm) { refresh(frm) {
@ -211,4 +222,4 @@ erpnext.bom.BomConfigurator = class BomConfigurator extends erpnext.TransactionC
} }
}; };
extend_cscript(cur_frm.cscript, new erpnext.bom.BomConfigurator({frm: cur_frm})); extend_cscript(cur_frm.cscript, new erpnext.bom.BomConfigurator({frm: cur_frm}));

View File

@ -790,24 +790,25 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
if (me.frm.doc.price_list_currency == company_currency) { if (me.frm.doc.price_list_currency == company_currency) {
me.frm.set_value('plc_conversion_rate', 1.0); me.frm.set_value('plc_conversion_rate', 1.0);
} }
if (company_doc && company_doc.default_letter_head) { if (company_doc){
if(me.frm.fields_dict.letter_head) { if (company_doc.default_letter_head) {
me.frm.set_value("letter_head", company_doc.default_letter_head); if(me.frm.fields_dict.letter_head) {
me.frm.set_value("letter_head", company_doc.default_letter_head);
}
}
let selling_doctypes_for_tc = ["Sales Invoice", "Quotation", "Sales Order", "Delivery Note"];
if (company_doc.default_selling_terms && frappe.meta.has_field(me.frm.doc.doctype, "tc_name") &&
selling_doctypes_for_tc.indexOf(me.frm.doc.doctype) != -1) {
me.frm.set_value("tc_name", company_doc.default_selling_terms);
}
let buying_doctypes_for_tc = ["Request for Quotation", "Supplier Quotation", "Purchase Order",
"Material Request", "Purchase Receipt"];
// Purchase Invoice is excluded as per issue #3345
if (company_doc.default_buying_terms && frappe.meta.has_field(me.frm.doc.doctype, "tc_name") &&
buying_doctypes_for_tc.indexOf(me.frm.doc.doctype) != -1) {
me.frm.set_value("tc_name", company_doc.default_buying_terms);
} }
} }
let selling_doctypes_for_tc = ["Sales Invoice", "Quotation", "Sales Order", "Delivery Note"];
if (company_doc.default_selling_terms && frappe.meta.has_field(me.frm.doc.doctype, "tc_name") &&
selling_doctypes_for_tc.indexOf(me.frm.doc.doctype) != -1) {
me.frm.set_value("tc_name", company_doc.default_selling_terms);
}
let buying_doctypes_for_tc = ["Request for Quotation", "Supplier Quotation", "Purchase Order",
"Material Request", "Purchase Receipt"];
// Purchase Invoice is excluded as per issue #3345
if (company_doc.default_buying_terms && frappe.meta.has_field(me.frm.doc.doctype, "tc_name") &&
buying_doctypes_for_tc.indexOf(me.frm.doc.doctype) != -1) {
me.frm.set_value("tc_name", company_doc.default_buying_terms);
}
frappe.run_serially([ frappe.run_serially([
() => me.frm.script_manager.trigger("currency"), () => me.frm.script_manager.trigger("currency"),
() => me.update_item_tax_map(), () => me.update_item_tax_map(),

View File

@ -2,7 +2,58 @@ frappe.provide("erpnext.financial_statements");
erpnext.financial_statements = { erpnext.financial_statements = {
"filters": get_filters(), "filters": get_filters(),
"baseData": null,
"formatter": function(value, row, column, data, default_formatter, filter) { "formatter": function(value, row, column, data, default_formatter, filter) {
if(frappe.query_report.get_filter_value("selected_view") == "Growth" && data && column.colIndex >= 3){
//Assuming that the first three columns are s.no, account name and the very first year of the accounting values, to calculate the relative percentage values of the successive columns.
const lastAnnualValue = row[column.colIndex - 1].content;
const currentAnnualvalue = data[column.fieldname];
if(currentAnnualvalue == undefined) return 'NA'; //making this not applicable for undefined/null values
let annualGrowth = 0;
if(lastAnnualValue == 0 && currentAnnualvalue > 0){
//If the previous year value is 0 and the current value is greater than 0
annualGrowth = 1;
}
else if(lastAnnualValue > 0){
annualGrowth = (currentAnnualvalue - lastAnnualValue) / lastAnnualValue;
}
const growthPercent = (Math.round(annualGrowth*10000)/100); //calculating the rounded off percentage
value = $(`<span>${((growthPercent >=0)? '+':'' )+growthPercent+'%'}</span>`);
if(growthPercent < 0){
value = $(value).addClass("text-danger");
}
else{
value = $(value).addClass("text-success");
}
value = $(value).wrap("<p></p>").parent().html();
return value;
}
else if(frappe.query_report.get_filter_value("selected_view") == "Margin" && data){
if(column.fieldname =="account" && data.account_name == __("Income")){
//Taking the total income from each column (for all the financial years) as the base (100%)
this.baseData = row;
}
if(column.colIndex >= 2){
//Assuming that the first two columns are s.no and account name, to calculate the relative percentage values of the successive columns.
const currentAnnualvalue = data[column.fieldname];
const baseValue = this.baseData[column.colIndex].content;
if(currentAnnualvalue == undefined || baseValue <= 0) return 'NA';
const marginPercent = Math.round((currentAnnualvalue/baseValue)*10000)/100;
value = $(`<span>${marginPercent+'%'}</span>`);
if(marginPercent < 0)
value = $(value).addClass("text-danger");
else
value = $(value).addClass("text-success");
value = $(value).wrap("<p></p>").parent().html();
return value;
}
}
if (data && column.fieldname=="account") { if (data && column.fieldname=="account") {
value = data.account_name || value; value = data.account_name || value;
@ -74,22 +125,24 @@ erpnext.financial_statements = {
}); });
}); });
const views_menu = report.page.add_custom_button_group(__('Financial Statements')); if (report.page){
const views_menu = report.page.add_custom_button_group(__('Financial Statements'));
report.page.add_custom_menu_item(views_menu, __("Balance Sheet"), function() { report.page.add_custom_menu_item(views_menu, __("Balance Sheet"), function() {
var filters = report.get_values(); var filters = report.get_values();
frappe.set_route('query-report', 'Balance Sheet', {company: filters.company}); frappe.set_route('query-report', 'Balance Sheet', {company: filters.company});
}); });
report.page.add_custom_menu_item(views_menu, __("Profit and Loss"), function() { report.page.add_custom_menu_item(views_menu, __("Profit and Loss"), function() {
var filters = report.get_values(); var filters = report.get_values();
frappe.set_route('query-report', 'Profit and Loss Statement', {company: filters.company}); frappe.set_route('query-report', 'Profit and Loss Statement', {company: filters.company});
}); });
report.page.add_custom_menu_item(views_menu, __("Cash Flow Statement"), function() { report.page.add_custom_menu_item(views_menu, __("Cash Flow Statement"), function() {
var filters = report.get_values(); var filters = report.get_values();
frappe.set_route('query-report', 'Cash Flow', {company: filters.company}); frappe.set_route('query-report', 'Cash Flow', {company: filters.company});
}); });
}
} }
}; };

View File

@ -25,6 +25,10 @@ erpnext.accounts.dimensions = {
}, },
setup_filters(frm, doctype) { setup_filters(frm, doctype) {
if (doctype == 'Payment Entry' && this.accounting_dimensions) {
frm.dimension_filters = this.accounting_dimensions
}
if (this.accounting_dimensions) { if (this.accounting_dimensions) {
this.accounting_dimensions.forEach((dimension) => { this.accounting_dimensions.forEach((dimension) => {
frappe.model.with_doctype(dimension['document_type'], () => { frappe.model.with_doctype(dimension['document_type'], () => {

View File

@ -71,6 +71,10 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
let warehouse = this.item?.type_of_transaction === "Outward" ? let warehouse = this.item?.type_of_transaction === "Outward" ?
(this.item.warehouse || this.item.s_warehouse) : ""; (this.item.warehouse || this.item.s_warehouse) : "";
if (!warehouse && this.frm.doc.doctype === 'Stock Reconciliation') {
warehouse = this.get_warehouse();
}
return { return {
'item_code': this.item.item_code, 'item_code': this.item.item_code,
'warehouse': ["=", warehouse] 'warehouse': ["=", warehouse]

View File

@ -391,7 +391,6 @@ def _make_sales_order(source_name, target_doc=None, customer_group=None, ignore_
balance_qty = obj.qty - ordered_items.get(obj.item_code, 0.0) balance_qty = obj.qty - ordered_items.get(obj.item_code, 0.0)
target.qty = balance_qty if balance_qty > 0 else 0 target.qty = balance_qty if balance_qty > 0 else 0
target.stock_qty = flt(target.qty) * flt(obj.conversion_factor) target.stock_qty = flt(target.qty) * flt(obj.conversion_factor)
target.delivery_date = nowdate()
if obj.against_blanket_order: if obj.against_blanket_order:
target.against_blanket_order = obj.against_blanket_order target.against_blanket_order = obj.against_blanket_order

View File

@ -87,7 +87,6 @@ class TestQuotation(FrappeTestCase):
self.assertEqual(sales_order.get("items")[0].prevdoc_docname, quotation.name) self.assertEqual(sales_order.get("items")[0].prevdoc_docname, quotation.name)
self.assertEqual(sales_order.customer, "_Test Customer") self.assertEqual(sales_order.customer, "_Test Customer")
sales_order.delivery_date = "2014-01-01"
sales_order.naming_series = "_T-Quotation-" sales_order.naming_series = "_T-Quotation-"
sales_order.transaction_date = nowdate() sales_order.transaction_date = nowdate()
sales_order.insert() sales_order.insert()
@ -120,7 +119,6 @@ class TestQuotation(FrappeTestCase):
self.assertEqual(sales_order.get("items")[0].prevdoc_docname, quotation.name) self.assertEqual(sales_order.get("items")[0].prevdoc_docname, quotation.name)
self.assertEqual(sales_order.customer, "_Test Customer") self.assertEqual(sales_order.customer, "_Test Customer")
sales_order.delivery_date = "2014-01-01"
sales_order.naming_series = "_T-Quotation-" sales_order.naming_series = "_T-Quotation-"
sales_order.transaction_date = nowdate() sales_order.transaction_date = nowdate()
sales_order.insert() sales_order.insert()

View File

@ -118,6 +118,7 @@
"oldfieldtype": "Link", "oldfieldtype": "Link",
"options": "Item", "options": "Item",
"print_width": "150px", "print_width": "150px",
"reqd": 1,
"width": "150px" "width": "150px"
}, },
{ {
@ -908,7 +909,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-11-24 13:24:55.756320", "modified": "2024-01-25 14:24:00.330219",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Sales Order Item", "name": "Sales Order Item",

View File

@ -43,7 +43,8 @@ class SalesOrderItem(Document):
gross_profit: DF.Currency gross_profit: DF.Currency
image: DF.Attach | None image: DF.Attach | None
is_free_item: DF.Check is_free_item: DF.Check
item_code: DF.Link | None is_stock_item: DF.Check
item_code: DF.Link
item_group: DF.Link | None item_group: DF.Link | None
item_name: DF.Data item_name: DF.Data
item_tax_rate: DF.Code | None item_tax_rate: DF.Code | None

View File

@ -210,7 +210,6 @@ def get_so_with_invoices(filters):
.where( .where(
(so.docstatus == 1) (so.docstatus == 1)
& (so.status.isin(["To Deliver and Bill", "To Bill"])) & (so.status.isin(["To Deliver and Bill", "To Bill"]))
& (so.payment_terms_template != "NULL")
& (so.company == conditions.company) & (so.company == conditions.company)
& (so.transaction_date[conditions.start_date : conditions.end_date]) & (so.transaction_date[conditions.start_date : conditions.end_date])
) )

View File

@ -52,7 +52,7 @@ frappe.ui.form.on('Batch', {
// sort by qty // sort by qty
r.message.sort(function(a, b) { a.qty > b.qty ? 1 : -1 }); r.message.sort(function(a, b) { a.qty > b.qty ? 1 : -1 });
var rows = $('<div></div>').appendTo(section); const rows = $('<div></div>').appendTo(section);
// show // show
(r.message || []).forEach(function(d) { (r.message || []).forEach(function(d) {
@ -76,7 +76,7 @@ frappe.ui.form.on('Batch', {
// move - ask for target warehouse and make stock entry // move - ask for target warehouse and make stock entry
rows.find('.btn-move').on('click', function() { rows.find('.btn-move').on('click', function() {
var $btn = $(this); const $btn = $(this);
const fields = [ const fields = [
{ {
fieldname: 'to_warehouse', fieldname: 'to_warehouse',
@ -115,7 +115,7 @@ frappe.ui.form.on('Batch', {
// split - ask for new qty and batch ID (optional) // split - ask for new qty and batch ID (optional)
// and make stock entry via batch.batch_split // and make stock entry via batch.batch_split
rows.find('.btn-split').on('click', function() { rows.find('.btn-split').on('click', function() {
var $btn = $(this); const $btn = $(this);
frappe.prompt([{ frappe.prompt([{
fieldname: 'qty', fieldname: 'qty',
label: __('New Batch Qty'), label: __('New Batch Qty'),
@ -128,19 +128,16 @@ frappe.ui.form.on('Batch', {
fieldtype: 'Data', fieldtype: 'Data',
}], }],
(data) => { (data) => {
frappe.call({ frappe.xcall(
method: 'erpnext.stock.doctype.batch.batch.split_batch', 'erpnext.stock.doctype.batch.batch.split_batch',
args: { {
item_code: frm.doc.item, item_code: frm.doc.item,
batch_no: frm.doc.name, batch_no: frm.doc.name,
qty: data.qty, qty: data.qty,
warehouse: $btn.attr('data-warehouse'), warehouse: $btn.attr('data-warehouse'),
new_batch_id: data.new_batch_id new_batch_id: data.new_batch_id
}, }
callback: (r) => { ).then(() => frm.reload_doc());
frm.refresh();
},
});
}, },
__('Split Batch'), __('Split Batch'),
__('Split') __('Split')

View File

@ -9,7 +9,7 @@ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.model.naming import make_autoname, revert_series_if_last from frappe.model.naming import make_autoname, revert_series_if_last
from frappe.query_builder.functions import CurDate, Sum from frappe.query_builder.functions import CurDate, Sum
from frappe.utils import cint, flt, get_link_to_form, nowtime, today from frappe.utils import cint, flt, get_link_to_form
from frappe.utils.data import add_days from frappe.utils.data import add_days
from frappe.utils.jinja import render_template from frappe.utils.jinja import render_template
@ -248,8 +248,9 @@ def get_batches_by_oldest(item_code, warehouse):
@frappe.whitelist() @frappe.whitelist()
def split_batch(batch_no, item_code, warehouse, qty, new_batch_id=None): def split_batch(
batch_no: str, item_code: str, warehouse: str, qty: float, new_batch_id: str | None = None
):
"""Split the batch into a new batch""" """Split the batch into a new batch"""
batch = frappe.get_doc(dict(doctype="Batch", item=item_code, batch_id=new_batch_id)).insert() batch = frappe.get_doc(dict(doctype="Batch", item=item_code, batch_id=new_batch_id)).insert()
qty = flt(qty) qty = flt(qty)
@ -257,29 +258,21 @@ def split_batch(batch_no, item_code, warehouse, qty, new_batch_id=None):
company = frappe.db.get_value("Warehouse", warehouse, "company") company = frappe.db.get_value("Warehouse", warehouse, "company")
from_bundle_id = make_batch_bundle( from_bundle_id = make_batch_bundle(
frappe._dict( item_code=item_code,
{ warehouse=warehouse,
"item_code": item_code, batches=frappe._dict({batch_no: qty}),
"warehouse": warehouse, company=company,
"batches": frappe._dict({batch_no: qty}), type_of_transaction="Outward",
"company": company, qty=qty,
"type_of_transaction": "Outward",
"qty": qty,
}
)
) )
to_bundle_id = make_batch_bundle( to_bundle_id = make_batch_bundle(
frappe._dict( item_code=item_code,
{ warehouse=warehouse,
"item_code": item_code, batches=frappe._dict({batch.name: qty}),
"warehouse": warehouse, company=company,
"batches": frappe._dict({batch.name: qty}), type_of_transaction="Inward",
"company": company, qty=qty,
"type_of_transaction": "Inward",
"qty": qty,
}
)
) )
stock_entry = frappe.get_doc( stock_entry = frappe.get_doc(
@ -304,21 +297,30 @@ def split_batch(batch_no, item_code, warehouse, qty, new_batch_id=None):
return batch.name return batch.name
def make_batch_bundle(kwargs): def make_batch_bundle(
item_code: str,
warehouse: str,
batches: dict[str, float],
company: str,
type_of_transaction: str,
qty: float,
):
from frappe.utils import nowtime, today
from erpnext.stock.serial_batch_bundle import SerialBatchCreation from erpnext.stock.serial_batch_bundle import SerialBatchCreation
return ( return (
SerialBatchCreation( SerialBatchCreation(
{ {
"item_code": kwargs.item_code, "item_code": item_code,
"warehouse": kwargs.warehouse, "warehouse": warehouse,
"posting_date": today(), "posting_date": today(),
"posting_time": nowtime(), "posting_time": nowtime(),
"voucher_type": "Stock Entry", "voucher_type": "Stock Entry",
"qty": flt(kwargs.qty), "qty": qty,
"type_of_transaction": kwargs.type_of_transaction, "type_of_transaction": type_of_transaction,
"company": kwargs.company, "company": company,
"batches": kwargs.batches, "batches": batches,
"do_not_submit": True, "do_not_submit": True,
} }
) )

View File

@ -8,6 +8,7 @@ def get_data():
"Stock Entry": "delivery_note_no", "Stock Entry": "delivery_note_no",
"Quality Inspection": "reference_name", "Quality Inspection": "reference_name",
"Auto Repeat": "reference_document", "Auto Repeat": "reference_document",
"Purchase Receipt": "inter_company_reference",
}, },
"internal_links": { "internal_links": {
"Sales Order": ["items", "against_sales_order"], "Sales Order": ["items", "against_sales_order"],
@ -22,6 +23,9 @@ def get_data():
{"label": _("Reference"), "items": ["Sales Order", "Shipment", "Quality Inspection"]}, {"label": _("Reference"), "items": ["Sales Order", "Shipment", "Quality Inspection"]},
{"label": _("Returns"), "items": ["Stock Entry"]}, {"label": _("Returns"), "items": ["Stock Entry"]},
{"label": _("Subscription"), "items": ["Auto Repeat"]}, {"label": _("Subscription"), "items": ["Auto Repeat"]},
{"label": _("Internal Transfer"), "items": ["Material Request", "Purchase Order"]}, {
"label": _("Internal Transfer"),
"items": ["Material Request", "Purchase Order", "Purchase Receipt"],
},
], ],
} }

View File

@ -429,6 +429,9 @@ frappe.ui.form.on("Material Request Item", {
rate: function(frm, doctype, name) { rate: function(frm, doctype, name) {
const item = locals[doctype][name]; const item = locals[doctype][name];
item.amount = flt(item.qty) * flt(item.rate);
frappe.model.set_value(doctype, name, "amount", item.amount);
refresh_field("amount", item.name, item.parentfield);
frm.events.get_item_data(frm, item, false); frm.events.get_item_data(frm, item, false);
}, },

View File

@ -762,6 +762,62 @@ class TestMaterialRequest(FrappeTestCase):
self.assertEqual(mr.per_ordered, 100) self.assertEqual(mr.per_ordered, 100)
self.assertEqual(existing_requested_qty, current_requested_qty) self.assertEqual(existing_requested_qty, current_requested_qty)
def test_auto_email_users_with_company_user_permissions(self):
from erpnext.stock.reorder_item import get_email_list
comapnywise_users = {
"_Test Company": "test_auto_email_@example.com",
"_Test Company 1": "test_auto_email_1@example.com",
}
permissions = []
for company, user in comapnywise_users.items():
if not frappe.db.exists("User", user):
frappe.get_doc(
{
"doctype": "User",
"email": user,
"first_name": user,
"send_notifications": 0,
"enabled": 1,
"user_type": "System User",
"roles": [{"role": "Purchase Manager"}],
}
).insert(ignore_permissions=True)
if not frappe.db.exists(
"User Permission", {"user": user, "allow": "Company", "for_value": company}
):
perm_doc = frappe.get_doc(
{
"doctype": "User Permission",
"user": user,
"allow": "Company",
"for_value": company,
"apply_to_all_doctypes": 1,
}
).insert(ignore_permissions=True)
permissions.append(perm_doc)
comapnywise_mr_list = frappe._dict({})
mr1 = make_material_request()
comapnywise_mr_list.setdefault(mr1.company, []).append(mr1.name)
mr2 = make_material_request(
company="_Test Company 1", warehouse="Stores - _TC1", cost_center="Main - _TC1"
)
comapnywise_mr_list.setdefault(mr2.company, []).append(mr2.name)
for company, mr_list in comapnywise_mr_list.items():
emails = get_email_list(company)
self.assertTrue(comapnywise_users[company] in emails)
for perm in permissions:
perm.delete()
def get_in_transit_warehouse(company): def get_in_transit_warehouse(company):
if not frappe.db.exists("Warehouse Type", "Transit"): if not frappe.db.exists("Warehouse Type", "Transit"):

View File

@ -8,8 +8,10 @@ frappe.listview_settings['Purchase Receipt'] = {
return [__("Closed"), "green", "status,=,Closed"]; return [__("Closed"), "green", "status,=,Closed"];
} else if (flt(doc.per_returned, 2) === 100) { } else if (flt(doc.per_returned, 2) === 100) {
return [__("Return Issued"), "grey", "per_returned,=,100"]; return [__("Return Issued"), "grey", "per_returned,=,100"];
} else if (flt(doc.grand_total) !== 0 && flt(doc.per_billed, 2) < 100) { } else if (flt(doc.grand_total) !== 0 && flt(doc.per_billed, 2) == 0) {
return [__("To Bill"), "orange", "per_billed,<,100"]; return [__("To Bill"), "orange", "per_billed,<,100"];
} else if (flt(doc.per_billed, 2) > 0 && flt(doc.per_billed, 2) < 100) {
return [__("Partly Billed"), "yellow", "per_billed,<,100"];
} else if (flt(doc.grand_total) === 0 || flt(doc.per_billed, 2) === 100) { } else if (flt(doc.grand_total) === 0 || flt(doc.per_billed, 2) === 100) {
return [__("Completed"), "green", "per_billed,=,100"]; return [__("Completed"), "green", "per_billed,=,100"];
} }

View File

@ -704,7 +704,7 @@ class TestPurchaseReceipt(FrappeTestCase):
pr2.load_from_db() pr2.load_from_db()
self.assertEqual(pr2.get("items")[0].billed_amt, 2000) self.assertEqual(pr2.get("items")[0].billed_amt, 2000)
self.assertEqual(pr2.per_billed, 80) self.assertEqual(pr2.per_billed, 80)
self.assertEqual(pr2.status, "To Bill") self.assertEqual(pr2.status, "Partly Billed")
pr2.cancel() pr2.cancel()
pi2.reload() pi2.reload()
@ -1115,7 +1115,7 @@ class TestPurchaseReceipt(FrappeTestCase):
pi.load_from_db() pi.load_from_db()
pr.load_from_db() pr.load_from_db()
self.assertEqual(pr.status, "To Bill") self.assertEqual(pr.status, "Partly Billed")
self.assertAlmostEqual(pr.per_billed, 50.0, places=2) self.assertAlmostEqual(pr.per_billed, 50.0, places=2)
def test_purchase_receipt_with_exchange_rate_difference(self): def test_purchase_receipt_with_exchange_rate_difference(self):
@ -1638,9 +1638,10 @@ class TestPurchaseReceipt(FrappeTestCase):
make_stock_entry( make_stock_entry(
purpose="Material Receipt", purpose="Material Receipt",
item_code=item.name, item_code=item.name,
qty=15, qty=20,
company=company, company=company,
to_warehouse=from_warehouse, to_warehouse=from_warehouse,
posting_date=add_days(today(), -3),
) )
# Step 3: Create Delivery Note with Internal Customer # Step 3: Create Delivery Note with Internal Customer
@ -1663,13 +1664,15 @@ class TestPurchaseReceipt(FrappeTestCase):
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
pr = make_inter_company_purchase_receipt(dn.name) pr = make_inter_company_purchase_receipt(dn.name)
pr.set_posting_time = 1
pr.posting_date = today()
pr.items[0].qty = 15 pr.items[0].qty = 15
pr.items[0].from_warehouse = target_warehouse pr.items[0].from_warehouse = target_warehouse
pr.items[0].warehouse = to_warehouse pr.items[0].warehouse = to_warehouse
pr.items[0].rejected_warehouse = from_warehouse pr.items[0].rejected_warehouse = from_warehouse
pr.save() pr.save()
self.assertRaises(OverAllowanceError, pr.submit) self.assertRaises(frappe.ValidationError, pr.submit)
# Step 5: Test Over Receipt Allowance # Step 5: Test Over Receipt Allowance
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 50) frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 50)
@ -1681,8 +1684,10 @@ class TestPurchaseReceipt(FrappeTestCase):
company=company, company=company,
from_warehouse=from_warehouse, from_warehouse=from_warehouse,
to_warehouse=target_warehouse, to_warehouse=target_warehouse,
posting_date=add_days(pr.posting_date, -1),
) )
pr.reload()
pr.submit() pr.submit()
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0) frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0)

View File

@ -250,6 +250,7 @@ class SerialandBatchBundle(Document):
for d in self.entries: for d in self.entries:
available_qty = 0 available_qty = 0
if self.has_serial_no: if self.has_serial_no:
d.incoming_rate = abs(sn_obj.serial_no_incoming_rate.get(d.serial_no, 0.0)) d.incoming_rate = abs(sn_obj.serial_no_incoming_rate.get(d.serial_no, 0.0))
else: else:
@ -892,6 +893,13 @@ class SerialandBatchBundle(Document):
elif batch_nos: elif batch_nos:
self.set("entries", batch_nos) self.set("entries", batch_nos)
def delete_serial_batch_entries(self):
SBBE = frappe.qb.DocType("Serial and Batch Entry")
frappe.qb.from_(SBBE).delete().where(SBBE.parent == self.name).run()
self.set("entries", [])
@frappe.whitelist() @frappe.whitelist()
def download_blank_csv_template(content): def download_blank_csv_template(content):
@ -1374,10 +1382,12 @@ def get_available_serial_nos(kwargs):
elif kwargs.based_on == "Expiry": elif kwargs.based_on == "Expiry":
order_by = "amc_expiry_date asc" order_by = "amc_expiry_date asc"
filters = {"item_code": kwargs.item_code, "warehouse": ("is", "set")} filters = {"item_code": kwargs.item_code}
if kwargs.warehouse: if not kwargs.get("ignore_warehouse"):
filters["warehouse"] = kwargs.warehouse filters["warehouse"] = ("is", "set")
if kwargs.warehouse:
filters["warehouse"] = kwargs.warehouse
# Since SLEs are not present against Reserved Stock [POS invoices, SRE], need to ignore reserved serial nos. # Since SLEs are not present against Reserved Stock [POS invoices, SRE], need to ignore reserved serial nos.
ignore_serial_nos = get_reserved_serial_nos(kwargs) ignore_serial_nos = get_reserved_serial_nos(kwargs)

View File

@ -228,7 +228,6 @@ class StockEntry(StockController):
self.fg_completed_qty = 0.0 self.fg_completed_qty = 0.0
self.validate_serialized_batch() self.validate_serialized_batch()
self.set_actual_qty()
self.calculate_rate_and_amount() self.calculate_rate_and_amount()
self.validate_putaway_capacity() self.validate_putaway_capacity()

View File

@ -156,6 +156,7 @@ class StockReconciliation(StockController):
"warehouse": item.warehouse, "warehouse": item.warehouse,
"posting_date": self.posting_date, "posting_date": self.posting_date,
"posting_time": self.posting_time, "posting_time": self.posting_time,
"ignore_warehouse": 1,
} }
) )
) )
@ -780,7 +781,20 @@ class StockReconciliation(StockController):
current_qty = 0.0 current_qty = 0.0
if row.current_serial_and_batch_bundle: if row.current_serial_and_batch_bundle:
current_qty = self.get_qty_for_serial_and_batch_bundle(row) current_qty = self.get_current_qty_for_serial_or_batch(row)
elif row.serial_no:
item_dict = get_stock_balance_for(
row.item_code,
row.warehouse,
self.posting_date,
self.posting_time,
voucher_no=self.name,
)
current_qty = item_dict.get("qty")
row.current_serial_no = item_dict.get("serial_nos")
row.current_valuation_rate = item_dict.get("rate")
val_rate = item_dict.get("rate")
elif row.batch_no: elif row.batch_no:
current_qty = get_batch_qty_for_stock_reco( current_qty = get_batch_qty_for_stock_reco(
row.item_code, row.warehouse, row.batch_no, self.posting_date, self.posting_time, self.name row.item_code, row.warehouse, row.batch_no, self.posting_date, self.posting_time, self.name
@ -788,15 +802,16 @@ class StockReconciliation(StockController):
precesion = row.precision("current_qty") precesion = row.precision("current_qty")
if flt(current_qty, precesion) != flt(row.current_qty, precesion): if flt(current_qty, precesion) != flt(row.current_qty, precesion):
val_rate = get_valuation_rate( if not row.serial_no:
row.item_code, val_rate = get_valuation_rate(
row.warehouse, row.item_code,
self.doctype, row.warehouse,
self.name, self.doctype,
company=self.company, self.name,
batch_no=row.batch_no, company=self.company,
serial_and_batch_bundle=row.current_serial_and_batch_bundle, batch_no=row.batch_no,
) serial_and_batch_bundle=row.current_serial_and_batch_bundle,
)
row.current_valuation_rate = val_rate row.current_valuation_rate = val_rate
row.current_qty = current_qty row.current_qty = current_qty
@ -842,11 +857,56 @@ class StockReconciliation(StockController):
return allow_negative_stock return allow_negative_stock
def get_qty_for_serial_and_batch_bundle(self, row): def get_current_qty_for_serial_or_batch(self, row):
doc = frappe.get_doc("Serial and Batch Bundle", row.current_serial_and_batch_bundle) doc = frappe.get_doc("Serial and Batch Bundle", row.current_serial_and_batch_bundle)
precision = doc.entries[0].precision("qty") current_qty = 0.0
if doc.has_serial_no:
current_qty = self.get_current_qty_for_serial_nos(doc)
elif doc.has_batch_no:
current_qty = self.get_current_qty_for_batch_nos(doc)
current_qty = 0 return abs(current_qty)
def get_current_qty_for_serial_nos(self, doc):
serial_nos_details = get_available_serial_nos(
frappe._dict(
{
"item_code": doc.item_code,
"warehouse": doc.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"voucher_no": self.name,
"ignore_warehouse": 1,
}
)
)
if not serial_nos_details:
return 0.0
doc.delete_serial_batch_entries()
current_qty = 0.0
for serial_no_row in serial_nos_details:
current_qty += 1
doc.append(
"entries",
{
"serial_no": serial_no_row.serial_no,
"qty": -1,
"warehouse": doc.warehouse,
"batch_no": serial_no_row.batch_no,
},
)
doc.set_incoming_rate(save=True)
doc.calculate_qty_and_amount(save=True)
doc.db_update_all()
return current_qty
def get_current_qty_for_batch_nos(self, doc):
current_qty = 0.0
precision = doc.entries[0].precision("qty")
for d in doc.entries: for d in doc.entries:
qty = ( qty = (
get_batch_qty( get_batch_qty(
@ -864,7 +924,7 @@ class StockReconciliation(StockController):
current_qty += qty current_qty += qty
return abs(current_qty) return current_qty
def get_batch_qty_for_stock_reco( def get_batch_qty_for_stock_reco(

View File

@ -925,6 +925,74 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
self.assertEqual(len(serial_batch_bundle), 0) self.assertEqual(len(serial_batch_bundle), 0)
def test_backdated_purchase_receipt_with_stock_reco(self):
item_code = self.make_item(
properties={
"is_stock_item": 1,
"has_serial_no": 1,
"serial_no_series": "TEST-SERIAL-.###",
}
).name
warehouse = "_Test Warehouse - _TC"
# Step - 1: Create a Backdated Purchase Receipt
pr1 = make_purchase_receipt(
item_code=item_code, warehouse=warehouse, qty=10, rate=100, posting_date=add_days(nowdate(), -3)
)
pr1.reload()
serial_nos = sorted(get_serial_nos_from_bundle(pr1.items[0].serial_and_batch_bundle))[:5]
# Step - 2: Create a Stock Reconciliation
sr1 = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=5,
serial_no=serial_nos,
)
data = frappe.get_all(
"Stock Ledger Entry",
fields=["serial_no", "actual_qty", "stock_value_difference"],
filters={"voucher_no": sr1.name, "is_cancelled": 0},
order_by="creation",
)
for d in data:
if d.actual_qty < 0:
self.assertEqual(d.actual_qty, -10.0)
self.assertAlmostEqual(d.stock_value_difference, -1000.0)
else:
self.assertEqual(d.actual_qty, 5.0)
self.assertAlmostEqual(d.stock_value_difference, 500.0)
# Step - 3: Create a Purchase Receipt before the first Purchase Receipt
make_purchase_receipt(
item_code=item_code, warehouse=warehouse, qty=10, rate=200, posting_date=add_days(nowdate(), -5)
)
data = frappe.get_all(
"Stock Ledger Entry",
fields=["serial_no", "actual_qty", "stock_value_difference"],
filters={"voucher_no": sr1.name, "is_cancelled": 0},
order_by="creation",
)
for d in data:
if d.actual_qty < 0:
self.assertEqual(d.actual_qty, -20.0)
self.assertAlmostEqual(d.stock_value_difference, -3000.0)
else:
self.assertEqual(d.actual_qty, 5.0)
self.assertAlmostEqual(d.stock_value_difference, 500.0)
active_serial_no = frappe.get_all(
"Serial No", filters={"status": "Active", "item_code": item_code}
)
self.assertEqual(len(active_serial_no), 5)
def create_batch_item_with_batch(item_name, batch_id): def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1) batch_item_doc = create_item(item_name, is_stock_item=1)

View File

@ -145,6 +145,7 @@ def create_material_request(material_requests):
mr.log_error("Unable to create material request") mr.log_error("Unable to create material request")
company_wise_mr = frappe._dict({})
for request_type in material_requests: for request_type in material_requests:
for company in material_requests[request_type]: for company in material_requests[request_type]:
try: try:
@ -206,17 +207,19 @@ def create_material_request(material_requests):
mr.submit() mr.submit()
mr_list.append(mr) mr_list.append(mr)
company_wise_mr.setdefault(company, []).append(mr)
except Exception: except Exception:
_log_exception(mr) _log_exception(mr)
if mr_list: if company_wise_mr:
if getattr(frappe.local, "reorder_email_notify", None) is None: if getattr(frappe.local, "reorder_email_notify", None) is None:
frappe.local.reorder_email_notify = cint( frappe.local.reorder_email_notify = cint(
frappe.db.get_value("Stock Settings", None, "reorder_email_notify") frappe.db.get_value("Stock Settings", None, "reorder_email_notify")
) )
if frappe.local.reorder_email_notify: if frappe.local.reorder_email_notify:
send_email_notification(mr_list) send_email_notification(company_wise_mr)
if exceptions_list: if exceptions_list:
notify_errors(exceptions_list) notify_errors(exceptions_list)
@ -224,20 +227,56 @@ def create_material_request(material_requests):
return mr_list return mr_list
def send_email_notification(mr_list): def send_email_notification(company_wise_mr):
"""Notify user about auto creation of indent""" """Notify user about auto creation of indent"""
email_list = frappe.db.sql_list( for company, mr_list in company_wise_mr.items():
"""select distinct r.parent email_list = get_email_list(company)
from `tabHas Role` r, tabUser p
where p.name = r.parent and p.enabled = 1 and p.docstatus < 2 if not email_list:
and r.role in ('Purchase Manager','Stock Manager') continue
and p.name not in ('Administrator', 'All', 'Guest')"""
msg = frappe.render_template("templates/emails/reorder_item.html", {"mr_list": mr_list})
frappe.sendmail(
recipients=email_list, subject=_("Auto Material Requests Generated"), message=msg
)
def get_email_list(company):
users = get_comapny_wise_users(company)
user_table = frappe.qb.DocType("User")
role_table = frappe.qb.DocType("Has Role")
query = (
frappe.qb.from_(user_table)
.inner_join(role_table)
.on(user_table.name == role_table.parent)
.select(user_table.email)
.where(
(role_table.role.isin(["Purchase Manager", "Stock Manager"]))
& (user_table.name.notin(["Administrator", "All", "Guest"]))
& (user_table.enabled == 1)
& (user_table.docstatus < 2)
)
) )
msg = frappe.render_template("templates/emails/reorder_item.html", {"mr_list": mr_list}) if users:
query = query.where(user_table.name.isin(users))
frappe.sendmail(recipients=email_list, subject=_("Auto Material Requests Generated"), message=msg) emails = query.run(as_dict=True)
return list(set([email.email for email in emails]))
def get_comapny_wise_users(company):
users = frappe.get_all(
"User Permission",
filters={"allow": "Company", "for_value": company, "apply_to_all_doctypes": 1},
fields=["user"],
)
return [user.user for user in users]
def notify_errors(exceptions_list): def notify_errors(exceptions_list):

View File

@ -22,9 +22,8 @@ def get_columns(filters):
{"label": _("Posting Time"), "fieldtype": "Time", "fieldname": "posting_time", "width": 90}, {"label": _("Posting Time"), "fieldtype": "Time", "fieldname": "posting_time", "width": 90},
{ {
"label": _("Voucher Type"), "label": _("Voucher Type"),
"fieldtype": "Link", "fieldtype": "Data",
"fieldname": "voucher_type", "fieldname": "voucher_type",
"options": "DocType",
"width": 160, "width": 160,
}, },
{ {

View File

@ -99,7 +99,7 @@ frappe.query_reports["Stock Balance"] = {
"fieldname": 'ignore_closing_balance', "fieldname": 'ignore_closing_balance',
"label": __('Ignore Closing Balance'), "label": __('Ignore Closing Balance'),
"fieldtype": 'Check', "fieldtype": 'Check',
"default": 1 "default": 0
}, },
], ],

View File

@ -9,9 +9,18 @@ from typing import Optional, Set, Tuple
import frappe import frappe
from frappe import _, scrub from frappe import _, scrub
from frappe.model.meta import get_field_precision from frappe.model.meta import get_field_precision
from frappe.query_builder import Case
from frappe.query_builder.functions import CombineDatetime, Sum from frappe.query_builder.functions import CombineDatetime, Sum
from frappe.utils import cint, flt, get_link_to_form, getdate, now, nowdate, nowtime, parse_json from frappe.utils import (
cint,
cstr,
flt,
get_link_to_form,
getdate,
now,
nowdate,
nowtime,
parse_json,
)
import erpnext import erpnext
from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty
@ -712,11 +721,10 @@ class update_entries_after(object):
if ( if (
sle.voucher_type == "Stock Reconciliation" sle.voucher_type == "Stock Reconciliation"
and ( and (sle.batch_no or sle.serial_no or sle.serial_and_batch_bundle)
sle.batch_no or (sle.has_batch_no and sle.serial_and_batch_bundle and not sle.has_serial_no)
)
and sle.voucher_detail_no and sle.voucher_detail_no
and not self.args.get("sle_id") and not self.args.get("sle_id")
and sle.is_cancelled == 0
): ):
self.reset_actual_qty_for_stock_reco(sle) self.reset_actual_qty_for_stock_reco(sle)
@ -737,6 +745,23 @@ class update_entries_after(object):
if sle.serial_and_batch_bundle: if sle.serial_and_batch_bundle:
self.calculate_valuation_for_serial_batch_bundle(sle) self.calculate_valuation_for_serial_batch_bundle(sle)
elif sle.serial_no and not self.args.get("sle_id"):
# Only run in reposting
self.get_serialized_values(sle)
self.wh_data.qty_after_transaction += flt(sle.actual_qty)
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no:
self.wh_data.qty_after_transaction = sle.qty_after_transaction
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(
self.wh_data.valuation_rate
)
elif (
sle.batch_no
and frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True)
and not self.args.get("sle_id")
):
# Only run in reposting
self.update_batched_values(sle)
else: else:
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no and not has_dimensions: if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no and not has_dimensions:
# assert # assert
@ -782,6 +807,45 @@ class update_entries_after(object):
): ):
self.update_outgoing_rate_on_transaction(sle) self.update_outgoing_rate_on_transaction(sle)
def get_serialized_values(self, sle):
incoming_rate = flt(sle.incoming_rate)
actual_qty = flt(sle.actual_qty)
serial_nos = cstr(sle.serial_no).split("\n")
if incoming_rate < 0:
# wrong incoming rate
incoming_rate = self.wh_data.valuation_rate
stock_value_change = 0
if actual_qty > 0:
stock_value_change = actual_qty * incoming_rate
else:
# In case of delivery/stock issue, get average purchase rate
# of serial nos of current entry
if not sle.is_cancelled:
outgoing_value = self.get_incoming_value_for_serial_nos(sle, serial_nos)
stock_value_change = -1 * outgoing_value
else:
stock_value_change = actual_qty * sle.outgoing_rate
new_stock_qty = self.wh_data.qty_after_transaction + actual_qty
if new_stock_qty > 0:
new_stock_value = (
self.wh_data.qty_after_transaction * self.wh_data.valuation_rate
) + stock_value_change
if new_stock_value >= 0:
# calculate new valuation rate only if stock value is positive
# else it remains the same as that of previous entry
self.wh_data.valuation_rate = new_stock_value / new_stock_qty
if not self.wh_data.valuation_rate and sle.voucher_detail_no:
allow_zero_rate = self.check_if_allow_zero_valuation_rate(
sle.voucher_type, sle.voucher_detail_no
)
if not allow_zero_rate:
self.wh_data.valuation_rate = self.get_fallback_rate(sle)
def reset_actual_qty_for_stock_reco(self, sle): def reset_actual_qty_for_stock_reco(self, sle):
doc = frappe.get_cached_doc("Stock Reconciliation", sle.voucher_no) doc = frappe.get_cached_doc("Stock Reconciliation", sle.voucher_no)
doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty > 0) doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty > 0)
@ -795,6 +859,36 @@ class update_entries_after(object):
if abs(sle.actual_qty) == 0.0: if abs(sle.actual_qty) == 0.0:
sle.is_cancelled = 1 sle.is_cancelled = 1
if sle.serial_and_batch_bundle and frappe.get_cached_value(
"Item", sle.item_code, "has_serial_no"
):
self.update_serial_no_status(sle)
def update_serial_no_status(self, sle):
from erpnext.stock.serial_batch_bundle import get_serial_nos
serial_nos = get_serial_nos(sle.serial_and_batch_bundle)
if not serial_nos:
return
warehouse = None
status = "Inactive"
if sle.actual_qty > 0:
warehouse = sle.warehouse
status = "Active"
sn_table = frappe.qb.DocType("Serial No")
query = (
frappe.qb.update(sn_table)
.set(sn_table.warehouse, warehouse)
.set(sn_table.status, status)
.where(sn_table.name.isin(serial_nos))
)
query.run()
def calculate_valuation_for_serial_batch_bundle(self, sle): def calculate_valuation_for_serial_batch_bundle(self, sle):
doc = frappe.get_cached_doc("Serial and Batch Bundle", sle.serial_and_batch_bundle) doc = frappe.get_cached_doc("Serial and Batch Bundle", sle.serial_and_batch_bundle)
@ -1171,11 +1265,12 @@ class update_entries_after(object):
outgoing_rate = get_batch_incoming_rate( outgoing_rate = get_batch_incoming_rate(
item_code=sle.item_code, item_code=sle.item_code,
warehouse=sle.warehouse, warehouse=sle.warehouse,
serial_and_batch_bundle=sle.serial_and_batch_bundle, batch_no=sle.batch_no,
posting_date=sle.posting_date, posting_date=sle.posting_date,
posting_time=sle.posting_time, posting_time=sle.posting_time,
creation=sle.creation, creation=sle.creation,
) )
if outgoing_rate is None: if outgoing_rate is None:
# This can *only* happen if qty available for the batch is zero. # This can *only* happen if qty available for the batch is zero.
# in such case fall back various other rates. # in such case fall back various other rates.
@ -1449,11 +1544,10 @@ def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None):
def get_batch_incoming_rate( def get_batch_incoming_rate(
item_code, warehouse, serial_and_batch_bundle, posting_date, posting_time, creation=None item_code, warehouse, batch_no, posting_date, posting_time, creation=None
): ):
sle = frappe.qb.DocType("Stock Ledger Entry") sle = frappe.qb.DocType("Stock Ledger Entry")
batch_ledger = frappe.qb.DocType("Serial and Batch Entry")
timestamp_condition = CombineDatetime(sle.posting_date, sle.posting_time) < CombineDatetime( timestamp_condition = CombineDatetime(sle.posting_date, sle.posting_time) < CombineDatetime(
posting_date, posting_time posting_date, posting_time
@ -1464,28 +1558,13 @@ def get_batch_incoming_rate(
== CombineDatetime(posting_date, posting_time) == CombineDatetime(posting_date, posting_time)
) & (sle.creation < creation) ) & (sle.creation < creation)
batches = frappe.get_all(
"Serial and Batch Entry", fields=["batch_no"], filters={"parent": serial_and_batch_bundle}
)
batch_details = ( batch_details = (
frappe.qb.from_(sle) frappe.qb.from_(sle)
.inner_join(batch_ledger) .select(Sum(sle.stock_value_difference).as_("batch_value"), Sum(sle.actual_qty).as_("batch_qty"))
.on(sle.serial_and_batch_bundle == batch_ledger.parent)
.select(
Sum(
Case()
.when(sle.actual_qty > 0, batch_ledger.qty * batch_ledger.incoming_rate)
.else_(batch_ledger.qty * batch_ledger.outgoing_rate * -1)
).as_("batch_value"),
Sum(Case().when(sle.actual_qty > 0, batch_ledger.qty).else_(batch_ledger.qty * -1)).as_(
"batch_qty"
),
)
.where( .where(
(sle.item_code == item_code) (sle.item_code == item_code)
& (sle.warehouse == warehouse) & (sle.warehouse == warehouse)
& (batch_ledger.batch_no.isin([row.batch_no for row in batches])) & (sle.batch_no == batch_no)
& (sle.is_cancelled == 0) & (sle.is_cancelled == 0)
) )
.where(timestamp_condition) .where(timestamp_condition)

View File

@ -8,26 +8,29 @@
"allow_print": 0, "allow_print": 0,
"amount": 0.0, "amount": 0.0,
"amount_based_on_field": 0, "amount_based_on_field": 0,
"anonymous": 0,
"apply_document_permissions": 1,
"condition_json": "[]",
"creation": "2016-06-24 15:50:33.196990", "creation": "2016-06-24 15:50:33.196990",
"doc_type": "Address", "doc_type": "Address",
"docstatus": 0, "docstatus": 0,
"doctype": "Web Form", "doctype": "Web Form",
"idx": 0, "idx": 0,
"is_standard": 1, "is_standard": 1,
"list_columns": [],
"list_title": "",
"login_required": 1, "login_required": 1,
"max_attachment_size": 0, "max_attachment_size": 0,
"modified": "2019-10-15 06:55:30.405119", "modified": "2024-01-24 10:28:35.026064",
"modified_by": "Administrator", "modified_by": "rohitw1991@gmail.com",
"module": "Utilities", "module": "Utilities",
"name": "addresses", "name": "addresses",
"owner": "Administrator", "owner": "Administrator",
"published": 1, "published": 1,
"route": "address", "route": "address",
"route_to_success_link": 0,
"show_attachments": 0, "show_attachments": 0,
"show_in_grid": 0, "show_list": 1,
"show_sidebar": 0, "show_sidebar": 0,
"sidebar_items": [],
"success_url": "/addresses", "success_url": "/addresses",
"title": "Address", "title": "Address",
"web_form_fields": [ "web_form_fields": [