Merge pull request #39640 from frappe/version-14-hotfix

chore: release v14
This commit is contained in:
rohitwaghchaure
2024-01-30 19:42:12 +05:30
committed by GitHub
52 changed files with 1442 additions and 383 deletions

View File

@@ -41,7 +41,7 @@ def test_record_generator():
]
start = 2012
end = now_datetime().year + 5
end = now_datetime().year + 25
for year in range(start, end):
test_records.append(
{

View File

@@ -78,6 +78,20 @@ class JournalEntry(AccountsController):
if not self.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):
self.validate_cheque_info()
self.check_credit_limit()

View File

@@ -631,7 +631,7 @@ frappe.ui.form.on('Payment Entry', {
get_outstanding_invoices_or_orders: function(frm, get_outstanding_invoices, get_orders_to_be_billed) {
const today = frappe.datetime.get_today();
const fields = [
let fields = [
{fieldtype:"Section Break", label: __("Posting Date")},
{fieldtype:"Date", label: __("From Date"),
fieldname:"from_posting_date", default:frappe.datetime.add_days(today, -30)},
@@ -646,18 +646,29 @@ frappe.ui.form.on('Payment Entry', {
fieldname:"outstanding_amt_greater_than", default: 0},
{fieldtype:"Column Break"},
{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() {
return {
"filters": {"company": frm.doc.company}
}
];
if (frm.dimension_filters) {
let column_break_insertion_point = Math.ceil((frm.dimension_filters.length)/2);
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:"Check", label: __("Allocate Payment Amount"), fieldname:"allocate_payment_amount", default:1},
];
]);
let btn_text = "";

View File

@@ -13,6 +13,7 @@ from pypika import Case
from pypika.functions import Coalesce, Sum
import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.accounts.doctype.bank_account.bank_account import (
get_bank_account_details,
get_party_bank_account,
@@ -1453,6 +1454,13 @@ def get_outstanding_reference_documents(args):
condition += " and cost_center='%s'" % 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 = {
"posting_date": ["from_posting_date", "to_posting_date"],
"due_date": ["from_due_date", "to_due_date"],
@@ -1680,6 +1688,12 @@ def get_orders_to_be_billed(
if doc and hasattr(doc, "cost_center") and doc.cost_center:
condition = " and cost_center='%s'" % cost_center
# dynamic dimension filters
active_dimensions = get_dimensions()[0]
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:
grand_total_field = "base_grand_total"
rounded_total_field = "base_rounded_total"

View File

@@ -705,7 +705,50 @@ class TestPaymentEntry(FrappeTestCase):
pe2.submit()
# create return entry against si1
create_sales_invoice(is_return=1, return_against=si1.name, qty=-1)
cr_note = create_sales_invoice(is_return=1, return_against=si1.name, qty=-1)
si1_outstanding = frappe.db.get_value("Sales Invoice", si1.name, "outstanding_amount")
# create JE(credit note) manually against si1 and cr_note
je = frappe.get_doc(
{
"doctype": "Journal Entry",
"company": si1.company,
"voucher_type": "Credit Note",
"posting_date": nowdate(),
}
)
je.append(
"accounts",
{
"account": si1.debit_to,
"party_type": "Customer",
"party": si1.customer,
"debit": 0,
"credit": 100,
"debit_in_account_currency": 0,
"credit_in_account_currency": 100,
"reference_type": si1.doctype,
"reference_name": si1.name,
"cost_center": si1.items[0].cost_center,
},
)
je.append(
"accounts",
{
"account": cr_note.debit_to,
"party_type": "Customer",
"party": cr_note.customer,
"debit": 100,
"credit": 0,
"debit_in_account_currency": 100,
"credit_in_account_currency": 0,
"reference_type": cr_note.doctype,
"reference_name": cr_note.name,
"cost_center": cr_note.items[0].cost_center,
},
)
je.save().submit()
si1_outstanding = frappe.db.get_value("Sales Invoice", si1.name, "outstanding_amount")
self.assertEqual(si1_outstanding, -100)

View File

@@ -294,7 +294,7 @@ class TestPaymentLedgerEntry(FrappeTestCase):
cr_note1.return_against = si3.name
cr_note1 = cr_note1.save().submit()
pl_entries = (
pl_entries_si3 = (
qb.from_(ple)
.select(
ple.voucher_type,
@@ -309,7 +309,24 @@ class TestPaymentLedgerEntry(FrappeTestCase):
.run(as_dict=True)
)
expected_values = [
pl_entries_cr_note1 = (
qb.from_(ple)
.select(
ple.voucher_type,
ple.voucher_no,
ple.against_voucher_type,
ple.against_voucher_no,
ple.amount,
ple.delinked,
)
.where(
(ple.against_voucher_type == cr_note1.doctype) & (ple.against_voucher_no == cr_note1.name)
)
.orderby(ple.creation)
.run(as_dict=True)
)
expected_values_for_si3 = [
{
"voucher_type": si3.doctype,
"voucher_no": si3.name,
@@ -317,18 +334,21 @@ class TestPaymentLedgerEntry(FrappeTestCase):
"against_voucher_no": si3.name,
"amount": amount,
"delinked": 0,
},
}
]
# credit/debit notes post ledger entries against itself
expected_values_for_cr_note1 = [
{
"voucher_type": cr_note1.doctype,
"voucher_no": cr_note1.name,
"against_voucher_type": si3.doctype,
"against_voucher_no": si3.name,
"against_voucher_type": cr_note1.doctype,
"against_voucher_no": cr_note1.name,
"amount": -amount,
"delinked": 0,
},
]
self.assertEqual(pl_entries[0], expected_values[0])
self.assertEqual(pl_entries[1], expected_values[1])
self.assertEqual(pl_entries_si3, expected_values_for_si3)
self.assertEqual(pl_entries_cr_note1, expected_values_for_cr_note1)
def test_je_against_inv_and_note(self):
ple = self.ple

View File

@@ -83,6 +83,8 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
this.frm.change_custom_button_type('Allocate', null, 'default');
}
this.frm.trigger("set_query_for_dimension_filters");
// check for any running reconciliation jobs
if (this.frm.doc.receivable_payable_account) {
this.frm.call({
@@ -113,6 +115,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() {
this.frm.set_value('party', '');

View File

@@ -24,7 +24,9 @@
"invoice_limit",
"payment_limit",
"bank_cash_account",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"sec_break1",
"invoice_name",
"invoices",
@@ -199,6 +201,18 @@
"fieldname": "payment_name",
"fieldtype": "Data",
"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,
@@ -206,7 +220,7 @@
"is_virtual": 1,
"issingle": 1,
"links": [],
"modified": "2023-11-17 17:33:55.701726",
"modified": "2023-12-14 13:38:16.264013",
"modified_by": "Administrator",
"module": "Accounts",
"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
import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
is_any_doc_running,
)
@@ -28,6 +29,7 @@ class PaymentReconciliation(Document):
self.common_filter_conditions = []
self.accounting_dimension_filter_conditions = []
self.ple_posting_date_filter = []
self.dimensions = get_dimensions()[0]
def load_from_db(self):
# 'modified' attribute is required for `run_doc_method` to work properly.
@@ -110,7 +112,7 @@ class PaymentReconciliation(Document):
def get_payment_entries(self):
order_doctype = "Sales Order" if self.party_type == "Customer" else "Purchase Order"
condition = self.get_conditions(get_payments=True)
condition = self.get_payment_entry_conditions()
payment_entries = get_advance_payment_entries_for_regional(
self.party_type,
@@ -126,66 +128,67 @@ class PaymentReconciliation(Document):
return payment_entries
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:
condition += f" and t1.name like '%%{self.payment_name}%%'"
conditions.append(je.name.like(f"%%{self.payment_name}%%"))
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 = (
"credit_in_account_currency"
if erpnext.get_party_account_type(self.party_type) == "Receivable"
else "debit_in_account_currency"
)
conditions.append(jea[dr_or_cr].gt(0))
bank_account_condition = (
"t2.against_account like %(bank_cash_account)s" if self.bank_cash_account else "1=1"
if self.bank_cash_account:
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 = 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,
)
journal_entries = journal_query.run(as_dict=True)
return list(journal_entries)
@@ -228,20 +231,18 @@ class PaymentReconciliation(Document):
self.common_filter_conditions.append(ple.account == self.receivable_payable_account)
self.get_return_invoices()
return_invoices = [
x for x in self.return_invoices if x.return_against == None or x.return_against == ""
]
outstanding_dr_or_cr = []
if return_invoices:
if self.return_invoices:
ple_query = QueryPaymentLedger()
return_outstanding = ple_query.get_voucher_outstandings(
vouchers=return_invoices,
vouchers=self.return_invoices,
common_filter=self.common_filter_conditions,
posting_date=self.ple_posting_date_filter,
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,
get_payments=True,
accounting_dimensions=self.accounting_dimension_filter_conditions,
)
for inv in return_outstanding:
@@ -391,8 +392,15 @@ class PaymentReconciliation(Document):
row = self.append("allocation", {})
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):
return frappe._dict(
res = frappe._dict(
{
"reference_type": pay.get("reference_type"),
"reference_name": pay.get("reference_name"),
@@ -408,6 +416,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):
adjust_allocations_for_taxes(self)
dr_or_cr = (
@@ -430,10 +441,10 @@ class PaymentReconciliation(Document):
reconciled_entry.append(payment_details)
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:
reconcile_dr_cr_note(dr_or_cr_notes, self.company)
reconcile_dr_cr_note(dr_or_cr_notes, self.company, self.dimensions)
@frappe.whitelist()
def reconcile(self):
@@ -462,7 +473,7 @@ class PaymentReconciliation(Document):
self.get_unreconciled_entries()
def get_payment_details(self, row, dr_or_cr):
return frappe._dict(
payment_details = frappe._dict(
{
"voucher_type": row.get("reference_type"),
"voucher_no": row.get("reference_name"),
@@ -485,6 +496,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):
for fieldname in ["company", "party_type", "party", "receivable_payable_account"]:
if not self.get(fieldname):
@@ -592,6 +609,13 @@ class PaymentReconciliation(Document):
if not invoices_to_reconcile:
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):
self.common_filter_conditions.clear()
self.accounting_dimension_filter_conditions.clear()
@@ -615,40 +639,58 @@ class PaymentReconciliation(Document):
if 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):
condition = " and company = '{0}' ".format(self.company)
self.build_dimensions_filter_conditions()
if self.get("cost_center") and get_payments:
condition = " and cost_center = '{0}' ".format(self.cost_center)
def get_payment_entry_conditions(self):
conditions = []
pe = qb.DocType("Payment Entry")
conditions.append(pe.company == self.company)
condition += (
" and posting_date >= {0}".format(frappe.db.escape(self.from_payment_date))
if self.from_payment_date
else ""
)
condition += (
" and posting_date <= {0}".format(frappe.db.escape(self.to_payment_date))
if self.to_payment_date
else ""
)
if self.get("cost_center"):
conditions.append(pe.cost_center == self.cost_center)
if self.from_payment_date:
conditions.append(pe.posting_date.gte(self.from_payment_date))
if self.to_payment_date:
conditions.append(pe.posting_date.lte(self.to_payment_date))
if self.minimum_payment_amount:
condition += (
" and unallocated_amount >= {0}".format(flt(self.minimum_payment_amount))
if get_payments
else " and total_debit >= {0}".format(flt(self.minimum_payment_amount))
)
conditions.append(pe.unallocated_amount.gte(flt(self.minimum_payment_amount)))
if self.maximum_payment_amount:
condition += (
" and unallocated_amount <= {0}".format(flt(self.maximum_payment_amount))
if get_payments
else " and total_debit <= {0}".format(flt(self.maximum_payment_amount))
)
conditions.append(pe.unallocated_amount.lte(flt(self.maximum_payment_amount)))
return condition
# pass dynamic dimension filter values to payment query
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
conditions.append(pe[dimension] == self.get(dimension))
return conditions
def get_journal_filter_conditions(self):
conditions = []
je = qb.DocType("Journal Entry")
jea = qb.DocType("Journal Entry Account")
conditions.append(je.company == self.company)
if self.from_payment_date:
conditions.append(je.posting_date.gte(self.from_payment_date))
if self.to_payment_date:
conditions.append(je.posting_date.lte(self.to_payment_date))
if self.minimum_payment_amount:
conditions.append(je.total_debit.gte(self.minimum_payment_amount))
if self.maximum_payment_amount:
conditions.append(je.total_debit.lte(self.maximum_payment_amount))
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:
voucher_type = "Credit Note" if inv.voucher_type == "Sales Invoice" else "Debit Note"
@@ -698,6 +740,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.skip_remarks_creation = True
jv.flags.ignore_exchange_rate = True
@@ -731,9 +782,27 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
inv.against_voucher,
None,
inv.cost_center,
dimensions_dict,
)
@erpnext.allow_regional
def adjust_allocations_for_taxes(doc):
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

@@ -24,7 +24,9 @@
"difference_account",
"exchange_rate",
"currency",
"cost_center"
"accounting_dimensions_section",
"cost_center",
"dimension_col_break"
],
"fields": [
{
@@ -157,12 +159,21 @@
"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,
"istable": 1,
"links": [],
"modified": "2023-11-17 17:33:38.612615",
"modified": "2023-12-14 13:38:26.104150",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Reconciliation Allocation",

View File

@@ -13,7 +13,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.sales_invoice.sales_invoice import (
SalesInvoice,
get_bank_cash_account,
get_mode_of_payment_info,
update_multi_mode_option,
)
@@ -52,7 +51,6 @@ class POSInvoice(SalesInvoice):
self.validate_stock_availablility()
self.validate_return_items_qty()
self.set_status()
self.set_account_for_mode_of_payment()
self.validate_pos()
self.validate_payment_amount()
self.validate_loyalty_transaction()
@@ -584,11 +582,6 @@ class POSInvoice(SalesInvoice):
update_multi_mode_option(self, pos_profile)
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()
def create_payment_request(self):
for pay in self.payments:

View File

@@ -99,8 +99,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
}
if(doc.docstatus == 1 && doc.outstanding_amount != 0
&& !(doc.is_return && doc.return_against) && !doc.on_hold) {
if(doc.docstatus == 1 && doc.outstanding_amount != 0 && !doc.on_hold) {
this.frm.add_custom_button(
__('Payment'),
() => this.make_payment_entry(),

View File

@@ -671,9 +671,7 @@ class PurchaseInvoice(BuyingController):
"credit_in_account_currency": base_grand_total
if self.party_account_currency == self.company_currency
else grand_total,
"against_voucher": self.return_against
if cint(self.is_return) and self.return_against
else self.name,
"against_voucher": self.name,
"against_voucher_type": self.doctype,
"project": self.project,
"cost_center": self.cost_center,
@@ -1540,12 +1538,8 @@ class PurchaseInvoice(BuyingController):
elif outstanding_amount > 0 and getdate(self.due_date) >= getdate():
self.status = "Unpaid"
# Check if outstanding amount is 0 due to debit note issued against invoice
elif (
outstanding_amount <= 0
and self.is_return == 0
and frappe.db.get_value(
"Purchase Invoice", {"is_return": 1, "return_against": self.name, "docstatus": 1}
)
elif self.is_return == 0 and frappe.db.get_value(
"Purchase Invoice", {"is_return": 1, "return_against": self.name, "docstatus": 1}
):
self.status = "Debit Note Issued"
elif self.is_return == 1:

View File

@@ -1893,6 +1893,21 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
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 check_gl_entries(
doc,

View File

@@ -91,8 +91,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
if(doc.update_stock) this.show_stock_ledger();
if (doc.docstatus == 1 && doc.outstanding_amount!=0
&& !(cint(doc.is_return) && doc.return_against)) {
if (doc.docstatus == 1 && doc.outstanding_amount!=0) {
this.frm.add_custom_button(
__('Payment'),
() => this.make_payment_entry(),

View File

@@ -245,7 +245,8 @@ class SalesInvoice(SellingController):
self.calculate_taxes_and_totals()
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):
self.validate_pos_paid_amount()
@@ -538,9 +539,6 @@ class SalesInvoice(SellingController):
):
data.sales_invoice = sales_invoice
def on_update(self):
self.set_paid_amount()
def on_update_after_submit(self):
if hasattr(self, "repost_required"):
fields_to_check = [
@@ -571,6 +569,11 @@ class SalesInvoice(SellingController):
self.paid_amount = 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):
for data in self.timesheets:
if data.time_sheet:
@@ -1069,9 +1072,7 @@ class SalesInvoice(SellingController):
"debit_in_account_currency": base_grand_total
if self.party_account_currency == self.company_currency
else grand_total,
"against_voucher": self.return_against
if cint(self.is_return) and self.return_against
else self.name,
"against_voucher": self.name,
"against_voucher_type": self.doctype,
"cost_center": self.cost_center,
"project": self.project,
@@ -1698,12 +1699,8 @@ class SalesInvoice(SellingController):
elif outstanding_amount > 0 and getdate(self.due_date) >= getdate():
self.status = "Unpaid"
# Check if outstanding amount is 0 due to credit note issued against invoice
elif (
outstanding_amount <= 0
and self.is_return == 0
and frappe.db.get_value(
"Sales Invoice", {"is_return": 1, "return_against": self.name, "docstatus": 1}
)
elif self.is_return == 0 and frappe.db.get_value(
"Sales Invoice", {"is_return": 1, "return_against": self.name, "docstatus": 1}
):
self.status = "Credit Note Issued"
elif self.is_return == 1:
@@ -1949,12 +1946,6 @@ def make_sales_return(source_name, target_doc=None):
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):
if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"]:
parties = frappe.db.get_all(

View File

@@ -1528,8 +1528,21 @@ class TestSalesInvoice(FrappeTestCase):
self.assertEqual(party_credited, 1000)
# Check outstanding amount
self.assertFalse(si1.outstanding_amount)
self.assertEqual(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"), 1500)
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)
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):
create_asset_data()

View File

@@ -5,7 +5,7 @@
from collections import OrderedDict
import frappe
from frappe import _, qb, scrub
from frappe import _, qb, query_builder, scrub
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Date, Substring, Sum
from frappe.utils import cint, cstr, flt, getdate, nowdate
@@ -578,6 +578,8 @@ class ReceivablePayableReport(object):
def get_future_payments_from_payment_entry(self):
pe = frappe.qb.DocType("Payment Entry")
pe_ref = frappe.qb.DocType("Payment Entry Reference")
ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"])
return (
frappe.qb.from_(pe)
.inner_join(pe_ref)
@@ -589,6 +591,11 @@ class ReceivablePayableReport(object):
(pe.posting_date).as_("future_date"),
(pe_ref.allocated_amount).as_("future_amount"),
(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(
(pe.docstatus < 2)
@@ -625,13 +632,24 @@ class ReceivablePayableReport(object):
query = query.select(
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:
query = query.select(
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:
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)
@@ -647,14 +665,19 @@ class ReceivablePayableReport(object):
row.remaining_balance = row.outstanding
row.future_amount = 0.0
for future in self.future_payments.get((row.voucher_no, row.party), []):
if row.remaining_balance > 0 and future.future_amount:
if future.future_amount > row.outstanding:
if self.filters.in_party_currency:
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
future.future_amount = future.future_amount - row.outstanding
future[future_amount_field] = future.get(future_amount_field) - row.outstanding
row.remaining_balance = 0
else:
row.future_amount += future.future_amount
future.future_amount = 0
row.future_amount += future.get(future_amount_field)
future[future_amount_field] = 0
row.remaining_balance = row.outstanding - row.future_amount
row.setdefault("future_ref", []).append(

View File

@@ -772,3 +772,92 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
# post sorting output should be [[Additional Debtors, ...], [Debtors, ...]]
report_output = sorted(report_output, key=lambda x: x[0])
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

@@ -434,7 +434,19 @@ def add_cc(args=None):
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
"""
@@ -459,6 +471,8 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n
check_if_advance_entry_modified(entry)
validate_allocated_amount(entry)
dimensions_dict = _build_dimensions_dict_for_exc_gain_loss(entry, active_dimensions)
# update ref in advance entry
if voucher_type == "Journal Entry":
referenced_row = update_reference_in_journal_entry(entry, doc, do_not_save=False)
@@ -466,10 +480,14 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n
# amount and account in args
# referenced_row is used to deduplicate gain/loss journal
entry.update({"referenced_row": referenced_row})
doc.make_exchange_gain_loss_journal([entry])
doc.make_exchange_gain_loss_journal([entry], dimensions_dict)
else:
update_reference_in_payment_entry(
entry, doc, do_not_save=True, skip_ref_details_update_for_pe=skip_ref_details_update_for_pe
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,
dimensions_dict=dimensions_dict,
)
doc.save(ignore_permissions=True)
@@ -618,7 +636,7 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False):
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_doctype": d.against_voucher_type,
@@ -630,6 +648,8 @@ def update_reference_in_payment_entry(
if d.difference_amount is not None
else payment_entry.get_exchange_rate(),
"exchange_gain_loss": d.difference_amount,
"account": d.account,
"dimensions": d.dimensions,
}
if d.voucher_detail_no:
@@ -661,8 +681,9 @@ def update_reference_in_payment_entry(
if not skip_ref_details_update_for_pe:
payment_entry.set_missing_ref_details()
payment_entry.set_amounts()
payment_entry.make_exchange_gain_loss_journal(
frappe._dict({"difference_posting_date": d.difference_posting_date})
frappe._dict({"difference_posting_date": d.difference_posting_date}), dimensions_dict
)
if not do_not_save:
@@ -1991,6 +2012,7 @@ def create_gain_loss_journal(
ref2_dn,
ref2_detail_no,
cost_center,
dimensions,
) -> str:
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = "Exchange Gain Or Loss"
@@ -2024,7 +2046,8 @@ def create_gain_loss_journal(
dr_or_cr + "_in_account_currency": 0,
}
)
if dimensions:
journal_account.update(dimensions)
journal_entry.append("accounts", journal_account)
journal_account = frappe._dict(
@@ -2040,7 +2063,8 @@ def create_gain_loss_journal(
reverse_dr_or_cr: abs(exc_gain_loss),
}
)
if dimensions:
journal_account.update(dimensions)
journal_entry.append("accounts", journal_account)
journal_entry.save()

View File

@@ -519,16 +519,16 @@ frappe.ui.form.on('Asset', {
indicator: 'red'
});
}
var is_grouped_asset = frappe.db.get_value('Item', item.item_code, 'is_grouped_asset');
var asset_quantity = is_grouped_asset ? item.qty : 1;
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); }
frappe.db.get_value('Item', item.item_code, 'is_grouped_asset', (r) => {
var asset_quantity = r.is_grouped_asset ? item.qty : 1;
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); }
});
},
set_depreciation_rate: function(frm, row) {

View File

@@ -10,6 +10,7 @@ from frappe import _
from frappe.utils import (
add_days,
add_months,
add_years,
cint,
date_diff,
flt,
@@ -23,6 +24,7 @@ from frappe.utils import (
import erpnext
from erpnext.accounts.general_ledger import make_reverse_gl_entries
from erpnext.accounts.utils import get_fiscal_year
from erpnext.assets.doctype.asset.depreciation import (
get_depreciation_accounts,
get_disposal_account_and_cost_center,
@@ -381,14 +383,24 @@ class Asset(AccountsController):
should_get_last_day = is_last_day_of_the_month(finance_book.depreciation_start_date)
depreciation_amount = 0
number_of_pending_depreciations = final_number_of_depreciations - start[finance_book.idx - 1]
yearly_opening_wdv = value_after_depreciation
current_fiscal_year_end_date = None
for n in range(start[finance_book.idx - 1], final_number_of_depreciations):
# If depreciation is already completed (for double declining balance)
if skip_row:
continue
schedule_date = add_months(
finance_book.depreciation_start_date, n * cint(finance_book.frequency_of_depreciation)
)
if not current_fiscal_year_end_date:
current_fiscal_year_end_date = get_fiscal_year(finance_book.depreciation_start_date)[2]
elif getdate(schedule_date) > getdate(current_fiscal_year_end_date):
current_fiscal_year_end_date = add_years(current_fiscal_year_end_date, 1)
yearly_opening_wdv = value_after_depreciation
if n > 0 and len(self.get("schedules")) > n - 1:
prev_depreciation_amount = self.get("schedules")[n - 1].depreciation_amount
else:
@@ -397,6 +409,7 @@ class Asset(AccountsController):
depreciation_amount = get_depreciation_amount(
self,
value_after_depreciation,
yearly_opening_wdv,
finance_book,
n,
prev_depreciation_amount,
@@ -494,7 +507,10 @@ class Asset(AccountsController):
if not depreciation_amount:
continue
value_after_depreciation -= flt(depreciation_amount, self.precision("gross_purchase_amount"))
value_after_depreciation = flt(
value_after_depreciation - flt(depreciation_amount),
self.precision("gross_purchase_amount"),
)
# Adjust depreciation amount in the last period based on the expected value after useful life
if finance_book.expected_value_after_useful_life and (
@@ -1380,6 +1396,7 @@ def get_total_days(date, frequency):
def get_depreciation_amount(
asset,
depreciable_value,
yearly_opening_wdv,
fb_row,
schedule_idx=0,
prev_depreciation_amount=0,
@@ -1397,6 +1414,7 @@ def get_depreciation_amount(
asset,
fb_row,
depreciable_value,
yearly_opening_wdv,
schedule_idx,
prev_depreciation_amount,
has_wdv_or_dd_non_yearly_pro_rata,
@@ -1542,6 +1560,7 @@ def get_wdv_or_dd_depr_amount(
asset,
fb_row,
depreciable_value,
yearly_opening_wdv,
schedule_idx,
prev_depreciation_amount,
has_wdv_or_dd_non_yearly_pro_rata,

View File

@@ -845,7 +845,7 @@ class TestDepreciationMethods(AssetSetup):
["2030-12-31", 28630.14, 28630.14],
["2031-12-31", 35684.93, 64315.07],
["2032-12-31", 17842.47, 82157.54],
["2033-06-06", 5342.46, 87500.0],
["2033-06-06", 5342.47, 87500.01],
]
schedules = [
@@ -957,7 +957,7 @@ class TestDepreciationBasics(AssetSetup):
},
)
depreciation_amount = get_depreciation_amount(asset, 100000, asset.finance_books[0])
depreciation_amount = get_depreciation_amount(asset, 100000, 100000, asset.finance_books[0])
self.assertEqual(depreciation_amount, 30000)
def test_make_depreciation_schedule(self):

View File

@@ -7,6 +7,8 @@ import json
import frappe
from frappe import _, bold, qb, throw
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.functions import Abs, Sum
from frappe.utils import (
add_days,
@@ -26,6 +28,7 @@ from frappe.utils import (
import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
get_dimensions,
)
from erpnext.accounts.doctype.pricing_rule.utils import (
apply_pricing_rule_for_free_items,
@@ -184,6 +187,7 @@ class AccountsController(TransactionBase):
self.validate_party()
self.validate_currency()
self.validate_party_account_currency()
self.validate_return_against_account()
if self.doctype in ["Purchase Invoice", "Sales Invoice"]:
if invalid_advances := [
@@ -317,6 +321,20 @@ class AccountsController(TransactionBase):
(self.doctype, self.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):
field_map = {
"Sales Invoice": "deferred_revenue_account",
@@ -1118,7 +1136,9 @@ class AccountsController(TransactionBase):
return True
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
"""
@@ -1173,6 +1193,7 @@ class AccountsController(TransactionBase):
self.name,
arg.get("referenced_row"),
arg.get("cost_center"),
dimensions_dict,
)
frappe.msgprint(
_("Exchange Gain/Loss amount has been booked through {0}").format(
@@ -1253,6 +1274,7 @@ class AccountsController(TransactionBase):
self.name,
d.idx,
self.cost_center,
dimensions_dict,
)
frappe.msgprint(
_("Exchange Gain/Loss amount has been booked through {0}").format(
@@ -1344,7 +1366,13 @@ class AccountsController(TransactionBase):
if lst:
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):
from erpnext.accounts.doctype.bank_transaction.bank_transaction import (
@@ -2530,6 +2558,9 @@ def get_advance_payment_entries(
condition=None,
payment_name=None,
):
pe = qb.DocType("Payment Entry")
per = qb.DocType("Payment Entry Reference")
party_account_field = "paid_from" if party_type == "Customer" else "paid_to"
currency_field = (
"paid_from_account_currency" if party_type == "Customer" else "paid_to_account_currency"
@@ -2540,76 +2571,79 @@ def get_advance_payment_entries(
)
payment_entries_against_order, unallocated_payment_entries = [], []
limit_cond = "limit %s" % limit if limit else ""
if not condition:
condition = []
if payment_name:
condition.append(pe.name.like(f"%%{payment_name}%%"))
if order_list or against_all_orders:
orders_condition = []
if order_list:
reference_condition = " and t2.reference_name in ({0})".format(
", ".join(["%s"] * len(order_list))
orders_condition.append(per.reference_name.isin(order_list))
payment_entries_query = (
qb.from_(pe)
.inner_join(per)
.on(pe.name == per.parent)
.select(
ConstantColumn("Payment Entry").as_("reference_type"),
pe.name.as_("reference_name"),
pe.remarks,
per.allocated_amount.as_("amount"),
per.name.as_("reference_row"),
per.reference_name.as_("against_order"),
pe.posting_date,
pe[currency_field].as_("currency"),
pe[exchange_rate_field].as_("exchange_rate"),
)
else:
reference_condition = ""
order_list = []
payment_name_filter = ""
if payment_name:
payment_name_filter = " and t1.name like '%%{0}%%'".format(payment_name)
if not condition:
condition = ""
payment_entries_against_order = frappe.db.sql(
"""
select
'Payment Entry' as reference_type, t1.name as reference_name,
t1.remarks, t2.allocated_amount as amount, t2.name as reference_row,
t2.reference_name as against_order, t1.posting_date,
t1.{0} as currency, t1.{5} as exchange_rate
from `tabPayment Entry` t1, `tabPayment Entry Reference` t2
where
t1.name = t2.parent and t1.{1} = %s and t1.payment_type = %s
and t1.party_type = %s and t1.party = %s and t1.docstatus = 1
and t2.reference_doctype = %s {2} {3} {6}
order by t1.posting_date {4}
""".format(
currency_field,
party_account_field,
reference_condition,
condition,
limit_cond,
exchange_rate_field,
payment_name_filter,
),
[party_account, payment_type, party_type, party, order_doctype] + order_list,
as_dict=1,
.where(
(pe[party_account_field] == party_account)
& (pe.payment_type == payment_type)
& (pe.party_type == party_type)
& (pe.party == party)
& (pe.docstatus == 1)
& (per.reference_doctype == order_doctype)
)
.where(Criterion.all(condition))
.where(Criterion.all(orders_condition))
.orderby(pe.posting_date)
)
if limit:
payment_entries_query = payment_entries_query.limit(limit)
payment_entries_against_order = payment_entries_query.run(as_dict=1)
if include_unallocated:
payment_name_filter = ""
if payment_name:
payment_name_filter = " and name like '%%{0}%%'".format(payment_name)
unallocated_payment_entries = frappe.db.sql(
"""
select 'Payment Entry' as reference_type, name as reference_name, posting_date,
remarks, unallocated_amount as amount, {2} as exchange_rate, {3} as currency
from `tabPayment Entry`
where
{0} = %s and party_type = %s and party = %s and payment_type = %s
and docstatus = 1 and unallocated_amount > 0 {condition} {4}
order by posting_date {1}
""".format(
party_account_field,
limit_cond,
exchange_rate_field,
currency_field,
payment_name_filter,
condition=condition or "",
),
(party_account, party_type, party, payment_type),
as_dict=1,
unallocated_payment_query = (
qb.from_(pe)
.select(
ConstantColumn("Payment Entry").as_("reference_type"),
pe.name.as_("reference_name"),
pe.posting_date,
pe.remarks,
pe.unallocated_amount.as_("amount"),
pe[exchange_rate_field].as_("exchange_rate"),
pe[currency_field].as_("currency"),
)
.where(
(pe[party_account_field] == party_account)
& (pe.party_type == party_type)
& (pe.party == party)
& (pe.payment_type == payment_type)
& (pe.docstatus == 1)
& (pe.unallocated_amount.gt(0))
)
.where(Criterion.all(condition))
.orderby(pe.posting_date)
)
if limit:
unallocated_payment_query = unallocated_payment_query.limit(limit)
unallocated_payment_entries = unallocated_payment_query.run(as_dict=1)
return list(payment_entries_against_order) + list(unallocated_payment_entries)

View File

@@ -6,7 +6,7 @@ from collections import defaultdict
from typing import List, Tuple
import frappe
from frappe import _
from frappe import _, bold
from frappe.utils import cint, cstr, flt, get_link_to_form, getdate
import erpnext
@@ -402,11 +402,6 @@ class StockController(AccountsController):
d.batch_no = None
d.db_set("batch_no", None)
for data in frappe.get_all(
"Batch", {"reference_name": self.name, "reference_doctype": self.doctype}
):
frappe.delete_doc("Batch", data.name)
def get_sl_entries(self, d, args):
sl_dict = frappe._dict(
{
@@ -673,6 +668,9 @@ class StockController(AccountsController):
self.validate_in_transit_warehouses()
self.validate_multi_currency()
self.validate_packed_items()
if self.get("is_internal_supplier"):
self.validate_internal_transfer_qty()
else:
self.validate_internal_transfer_warehouse()
@@ -711,6 +709,116 @@ class StockController(AccountsController):
if self.doctype in ("Sales Invoice", "Delivery Note Item") and self.get("packed_items"):
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):
# if over receipt is attempted while 'apply putaway rule' is disabled
# and if rule was applied on the transaction, validate it.

View File

@@ -343,7 +343,7 @@ class SubcontractingController(StockController):
i += 1
def __get_materials_from_bom(self, item_code, bom_no, exploded_item=0):
doctype = "BOM Item" if not exploded_item else "BOM Explosion Item"
doctype = "BOM Explosion Item" if exploded_item else "BOM Item"
fields = [f"`tab{doctype}`.`stock_qty` / `tabBOM`.`quantity` as qty_consumed_per_unit"]
alias_dict = {
@@ -447,6 +447,16 @@ class SubcontractingController(StockController):
rm_obj = self.append(self.raw_material_table, bom_item)
rm_obj.reference_name = item_row.name
if self.doctype == self.subcontract_data.order_doctype:
rm_obj.required_qty = qty
rm_obj.amount = rm_obj.required_qty * rm_obj.rate
else:
rm_obj.consumed_qty = 0
setattr(
rm_obj, self.subcontract_data.order_field, item_row.get(self.subcontract_data.order_field)
)
self.__set_batch_nos(bom_item, item_row, rm_obj, qty)
if self.doctype == "Subcontracting Receipt":
args = frappe._dict(
{
@@ -465,16 +475,6 @@ class SubcontractingController(StockController):
)
rm_obj.rate = get_incoming_rate(args)
if self.doctype == self.subcontract_data.order_doctype:
rm_obj.required_qty = qty
rm_obj.amount = rm_obj.required_qty * rm_obj.rate
else:
rm_obj.consumed_qty = 0
setattr(
rm_obj, self.subcontract_data.order_field, item_row.get(self.subcontract_data.order_field)
)
self.__set_batch_nos(bom_item, item_row, rm_obj, qty)
def __get_qty_based_on_material_transfer(self, item_row, transfer_item):
key = (item_row.item_code, item_row.get(self.subcontract_data.order_field))

View File

@@ -56,6 +56,7 @@ class TestAccountsController(FrappeTestCase):
20 series - Sales Invoice against Journals
30 series - Sales Invoice against Credit Notes
40 series - Company default Cost center is unset
50 series - Dimension inheritence
"""
def setUp(self):
@@ -1255,3 +1256,253 @@ class TestAccountsController(FrappeTestCase):
)
frappe.db.set_value("Company", self.company, "cost_center", cc)
def setup_dimensions(self):
if not frappe.db.exists("Accounting Dimension", {"document_type": "Department"}):
frappe.get_doc(
{
"doctype": "Accounting Dimension",
"document_type": "Department",
}
).insert()
else:
dimension = frappe.get_doc("Accounting Dimension", "Department")
dimension.disabled = 0
dimension.save()
if not frappe.db.exists("Accounting Dimension", {"document_type": "Location"}):
dimension1 = frappe.get_doc(
{
"doctype": "Accounting Dimension",
"document_type": "Location",
}
)
dimension1.append(
"dimension_defaults",
{
"company": "_Test Company",
"reference_document": "Location",
"default_dimension": "Block 1",
"mandatory_for_bs": 0,
"mandatory_for_pl": 0,
},
)
dimension1.insert()
dimension1.save()
else:
dimension1 = frappe.get_doc("Accounting Dimension", "Location")
dimension1.disabled = 0
dimension1.save()
def disable_dimensions(self):
if frappe.db.exists("Accounting Dimension", {"document_type": "Department"}):
dimension = frappe.get_doc("Accounting Dimension", "Department")
dimension.disabled = 1
dimension.save()
if frappe.db.exists("Accounting Dimension", {"document_type": "Location"}):
dimension1 = frappe.get_doc("Accounting Dimension", "Location")
dimension1.disabled = 1
dimension1.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)
self.disable_dimensions()
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"),
)
self.disable_dimensions()
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",
),
)
self.disable_dimensions()
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",
),
)
self.disable_dimensions()

View File

@@ -501,6 +501,7 @@ def get_party(user=None):
contact_name = get_contact_name(user)
party = None
contact = None
if contact_name:
contact = frappe.get_doc("Contact", contact_name)
if contact.links:
@@ -538,11 +539,15 @@ def get_party(user=None):
customer.flags.ignore_mandatory = True
customer.insert(ignore_permissions=True)
contact = frappe.new_doc("Contact")
contact.update({"first_name": fullname, "email_ids": [{"email_id": user, "is_primary": 1}]})
if not contact:
contact = frappe.new_doc("Contact")
contact.update({"first_name": fullname, "email_ids": [{"email_id": user, "is_primary": 1}]})
contact.insert(ignore_permissions=True)
contact.reload()
contact.append("links", dict(link_doctype="Customer", link_name=customer.name))
contact.flags.ignore_mandatory = True
contact.insert(ignore_permissions=True)
contact.save(ignore_permissions=True)
return customer

View File

@@ -548,6 +548,8 @@ accounting_dimension_doctypes = [
"Account Closing Balance",
"Supplier Quotation",
"Supplier Quotation Item",
"Payment Reconciliation",
"Payment Reconciliation Allocation",
]
# get matching queries for Bank Reconciliation

View File

@@ -14,7 +14,7 @@ def execute():
"label": "For Income Tax",
"fieldtype": "Check",
"insert_after": "finance_book_name",
"description": "If the asset is put to use for less than 180 days, the first Depreciation Rate will be reduced by 50%.",
"description": "If the asset is put to use for less than 180 days in the first year, the first year's depreciation rate will be reduced by 50%.",
}
]
}

View File

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

View File

@@ -309,7 +309,6 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
balance_qty = obj.qty - ordered_items.get(obj.item_code, 0.0)
target.qty = balance_qty if balance_qty > 0 else 0
target.stock_qty = flt(target.qty) * flt(obj.conversion_factor)
target.delivery_date = nowdate()
if 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.customer, "_Test Customer")
sales_order.delivery_date = "2014-01-01"
sales_order.naming_series = "_T-Quotation-"
sales_order.transaction_date = nowdate()
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.customer, "_Test Customer")
sales_order.delivery_date = "2014-01-01"
sales_order.naming_series = "_T-Quotation-"
sales_order.transaction_date = nowdate()
sales_order.insert()

View File

@@ -20,6 +20,7 @@ from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.selling.doctype.sales_order.sales_order import (
WarehouseRequired,
create_pick_list,
make_delivery_note,
make_material_request,
make_raw_material_request,
@@ -2082,6 +2083,83 @@ class TestSalesOrder(FrappeTestCase):
self.assertEqual(so.items[0].rate, scenario.get("expected_rate"))
self.assertEqual(so.packed_items[0].rate, scenario.get("expected_rate"))
def test_pick_list_without_rejected_materials(self):
serial_and_batch_item = make_item(
"_Test Serial and Batch Item for Rejected Materials",
properties={
"has_serial_no": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "BAT-TSBIFRM-.#####",
"serial_no_series": "SN-TSBIFRM-.#####",
},
).name
serial_item = make_item(
"_Test Serial Item for Rejected Materials",
properties={
"has_serial_no": 1,
"serial_no_series": "SN-TSIFRM-.#####",
},
).name
batch_item = make_item(
"_Test Batch Item for Rejected Materials",
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "BAT-TBIFRM-.#####",
},
).name
normal_item = make_item("_Test Normal Item for Rejected Materials").name
warehouse = "_Test Warehouse - _TC"
rejected_warehouse = "_Test Dummy Rejected Warehouse - _TC"
if not frappe.db.exists("Warehouse", rejected_warehouse):
frappe.get_doc(
{
"doctype": "Warehouse",
"warehouse_name": rejected_warehouse,
"company": "_Test Company",
"warehouse_group": "_Test Warehouse Group",
"is_rejected_warehouse": 1,
}
).insert()
se = make_stock_entry(item_code=normal_item, qty=1, to_warehouse=warehouse, do_not_submit=True)
for item in [serial_and_batch_item, serial_item, batch_item]:
se.append("items", {"item_code": item, "qty": 1, "t_warehouse": warehouse})
se.save()
se.submit()
se = make_stock_entry(
item_code=normal_item, qty=1, to_warehouse=rejected_warehouse, do_not_submit=True
)
for item in [serial_and_batch_item, serial_item, batch_item]:
se.append("items", {"item_code": item, "qty": 1, "t_warehouse": rejected_warehouse})
se.save()
se.submit()
so = make_sales_order(item_code=normal_item, qty=2, do_not_submit=True)
for item in [serial_and_batch_item, serial_item, batch_item]:
so.append("items", {"item_code": item, "qty": 2, "warehouse": warehouse})
so.save()
so.submit()
pick_list = create_pick_list(so.name)
pick_list.save()
for row in pick_list.locations:
self.assertEqual(row.qty, 1.0)
self.assertFalse(row.warehouse == rejected_warehouse)
self.assertTrue(row.warehouse == warehouse)
def automatically_fetch_payment_terms(enable=1):
accounts_settings = frappe.get_doc("Accounts Settings")

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ def get_data():
"Stock Entry": "delivery_note_no",
"Quality Inspection": "reference_name",
"Auto Repeat": "reference_document",
"Purchase Receipt": "inter_company_reference",
},
"internal_links": {
"Sales Order": ["items", "against_sales_order"],
@@ -22,6 +23,9 @@ def get_data():
{"label": _("Reference"), "items": ["Sales Order", "Shipment", "Quality Inspection"]},
{"label": _("Returns"), "items": ["Stock Entry"]},
{"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

@@ -762,6 +762,62 @@ class TestMaterialRequest(FrappeTestCase):
self.assertEqual(mr.per_ordered, 100)
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):
if not frappe.db.exists("Warehouse Type", "Transit"):

View File

@@ -16,6 +16,7 @@
"for_qty",
"column_break_4",
"parent_warehouse",
"consider_rejected_warehouses",
"get_item_locations",
"section_break_6",
"scan_barcode",
@@ -184,11 +185,18 @@
"report_hide": 1,
"reqd": 1,
"search_index": 1
},
{
"default": "0",
"description": "Enable it if users want to consider rejected materials to dispatch.",
"fieldname": "consider_rejected_warehouses",
"fieldtype": "Check",
"label": "Consider Rejected Warehouses"
}
],
"is_submittable": 1,
"links": [],
"modified": "2023-01-24 10:33:43.244476",
"modified": "2024-01-24 17:05:20.317180",
"modified_by": "Administrator",
"module": "Stock",
"name": "Pick List",

View File

@@ -205,6 +205,7 @@ class PickList(Document):
self.item_count_map.get(item_code),
self.company,
picked_item_details=picked_items_details.get(item_code),
consider_rejected_warehouses=self.consider_rejected_warehouses,
),
)
@@ -524,6 +525,7 @@ def get_available_item_locations(
company,
ignore_validation=False,
picked_item_details=None,
consider_rejected_warehouses=False,
):
locations = []
total_picked_qty = (
@@ -534,19 +536,39 @@ def get_available_item_locations(
if has_batch_no and has_serial_no:
locations = get_available_item_locations_for_serial_and_batched_item(
item_code, from_warehouses, required_qty, company, total_picked_qty
item_code,
from_warehouses,
required_qty,
company,
total_picked_qty,
consider_rejected_warehouses=consider_rejected_warehouses,
)
elif has_serial_no:
locations = get_available_item_locations_for_serialized_item(
item_code, from_warehouses, required_qty, company, total_picked_qty
item_code,
from_warehouses,
required_qty,
company,
total_picked_qty,
consider_rejected_warehouses=consider_rejected_warehouses,
)
elif has_batch_no:
locations = get_available_item_locations_for_batched_item(
item_code, from_warehouses, required_qty, company, total_picked_qty
item_code,
from_warehouses,
required_qty,
company,
total_picked_qty,
consider_rejected_warehouses=consider_rejected_warehouses,
)
else:
locations = get_available_item_locations_for_other_item(
item_code, from_warehouses, required_qty, company, total_picked_qty
item_code,
from_warehouses,
required_qty,
company,
total_picked_qty,
consider_rejected_warehouses=consider_rejected_warehouses,
)
total_qty_available = sum(location.get("qty") for location in locations)
@@ -597,7 +619,12 @@ def get_available_item_locations(
def get_available_item_locations_for_serialized_item(
item_code, from_warehouses, required_qty, company, total_picked_qty=0
item_code,
from_warehouses,
required_qty,
company,
total_picked_qty=0,
consider_rejected_warehouses=False,
):
sn = frappe.qb.DocType("Serial No")
query = (
@@ -613,6 +640,10 @@ def get_available_item_locations_for_serialized_item(
else:
query = query.where(Coalesce(sn.warehouse, "") != "")
if not consider_rejected_warehouses:
if rejected_warehouses := get_rejected_warehouses():
query = query.where(sn.warehouse.notin(rejected_warehouses))
serial_nos = query.run(as_list=True)
warehouse_serial_nos_map = frappe._dict()
@@ -627,7 +658,12 @@ def get_available_item_locations_for_serialized_item(
def get_available_item_locations_for_batched_item(
item_code, from_warehouses, required_qty, company, total_picked_qty=0
item_code,
from_warehouses,
required_qty,
company,
total_picked_qty=0,
consider_rejected_warehouses=False,
):
sle = frappe.qb.DocType("Stock Ledger Entry")
batch = frappe.qb.DocType("Batch")
@@ -653,15 +689,28 @@ def get_available_item_locations_for_batched_item(
if from_warehouses:
query = query.where(sle.warehouse.isin(from_warehouses))
if not consider_rejected_warehouses:
if rejected_warehouses := get_rejected_warehouses():
query = query.where(sle.warehouse.notin(rejected_warehouses))
return query.run(as_dict=True)
def get_available_item_locations_for_serial_and_batched_item(
item_code, from_warehouses, required_qty, company, total_picked_qty=0
item_code,
from_warehouses,
required_qty,
company,
total_picked_qty=0,
consider_rejected_warehouses=False,
):
# Get batch nos by FIFO
locations = get_available_item_locations_for_batched_item(
item_code, from_warehouses, required_qty, company
item_code,
from_warehouses,
required_qty,
company,
consider_rejected_warehouses=consider_rejected_warehouses,
)
if locations:
@@ -691,7 +740,12 @@ def get_available_item_locations_for_serial_and_batched_item(
def get_available_item_locations_for_other_item(
item_code, from_warehouses, required_qty, company, total_picked_qty=0
item_code,
from_warehouses,
required_qty,
company,
total_picked_qty=0,
consider_rejected_warehouses=False,
):
bin = frappe.qb.DocType("Bin")
query = (
@@ -708,6 +762,10 @@ def get_available_item_locations_for_other_item(
wh = frappe.qb.DocType("Warehouse")
query = query.from_(wh).where((bin.warehouse == wh.name) & (wh.company == company))
if not consider_rejected_warehouses:
if rejected_warehouses := get_rejected_warehouses():
query = query.where(bin.warehouse.notin(rejected_warehouses))
item_locations = query.run(as_dict=True)
return item_locations
@@ -1028,3 +1086,15 @@ def update_common_item_properties(item, location):
item.serial_no = location.serial_no
item.batch_no = location.batch_no
item.material_request_item = location.material_request_item
def get_rejected_warehouses():
if not hasattr(frappe.local, "rejected_warehouses"):
frappe.local.rejected_warehouses = []
if not frappe.local.rejected_warehouses:
frappe.local.rejected_warehouses = frappe.get_all(
"Warehouse", filters={"is_rejected_warehouse": 1}, pluck="name"
)
return frappe.local.rejected_warehouses

View File

@@ -193,7 +193,6 @@ class TestPurchaseReceipt(FrappeTestCase):
batch_no = pr.items[0].batch_no
pr.cancel()
self.assertFalse(frappe.db.get_value("Batch", {"item": item.name, "reference_name": pr.name}))
self.assertFalse(frappe.db.get_all("Serial No", {"batch_no": batch_no}))
def test_purchase_receipt_gl_entry(self):
@@ -1595,9 +1594,10 @@ class TestPurchaseReceipt(FrappeTestCase):
make_stock_entry(
purpose="Material Receipt",
item_code=item.name,
qty=15,
qty=20,
company=company,
to_warehouse=from_warehouse,
posting_date=add_days(today(), -3),
)
# Step 3: Create Delivery Note with Internal Customer
@@ -1620,13 +1620,15 @@ class TestPurchaseReceipt(FrappeTestCase):
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
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].from_warehouse = target_warehouse
pr.items[0].warehouse = to_warehouse
pr.items[0].rejected_warehouse = from_warehouse
pr.save()
self.assertRaises(OverAllowanceError, pr.submit)
self.assertRaises(frappe.ValidationError, pr.submit)
# Step 5: Test Over Receipt Allowance
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 50)
@@ -1638,8 +1640,10 @@ class TestPurchaseReceipt(FrappeTestCase):
company=company,
from_warehouse=from_warehouse,
to_warehouse=target_warehouse,
posting_date=add_days(pr.posting_date, -1),
)
pr.reload()
pr.submit()
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0)
@@ -2172,6 +2176,19 @@ class TestPurchaseReceipt(FrappeTestCase):
pr_doc.reload()
self.assertFalse(pr_doc.items[0].from_warehouse)
def test_do_not_delete_batch_implicitly(self):
item = make_item(
"_Test Item With Delete Batch",
{"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "TBWDB.#####"},
).name
pr = make_purchase_receipt(item_code=item, qty=10, rate=100)
batch_no = pr.items[0].batch_no
self.assertTrue(frappe.db.exists("Batch", batch_no))
pr.cancel()
self.assertTrue(frappe.db.exists("Batch", batch_no))
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -159,7 +159,6 @@ class StockEntry(StockController):
set_batch_nos(self, "s_warehouse")
self.validate_serialized_batch()
self.set_actual_qty()
self.calculate_rate_and_amount()
self.validate_putaway_capacity()

View File

@@ -737,7 +737,7 @@ class TestStockEntry(FrappeTestCase):
self.assertEqual(batch_in_serial_no, None)
self.assertEqual(frappe.db.get_value("Serial No", serial_no, "status"), "Inactive")
self.assertEqual(frappe.db.exists("Batch", batch_no), None)
self.assertTrue(frappe.db.exists("Batch", batch_no))
def test_serial_batch_item_qty_deduction(self):
"""

View File

@@ -312,7 +312,7 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
sr.cancel()
self.assertEqual(frappe.db.get_value("Serial No", serial_nos[0], "status"), "Inactive")
self.assertEqual(frappe.db.exists("Batch", batch_no), None)
self.assertTrue(frappe.db.exists("Batch", batch_no))
def test_stock_reco_balance_qty_for_serial_and_batch_item(self):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry

View File

@@ -13,6 +13,7 @@
"column_break_3",
"is_group",
"parent_warehouse",
"is_rejected_warehouse",
"column_break_4",
"account",
"company",
@@ -249,13 +250,20 @@
{
"fieldname": "column_break_qajx",
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "If yes, then this warehouse will be used to store rejected materials",
"fieldname": "is_rejected_warehouse",
"fieldtype": "Check",
"label": "Is Rejected Warehouse"
}
],
"icon": "fa fa-building",
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2023-05-29 13:10:43.333160",
"modified": "2024-01-24 16:27:28.299520",
"modified_by": "Administrator",
"module": "Stock",
"name": "Warehouse",

View File

@@ -145,6 +145,7 @@ def create_material_request(material_requests):
mr.log_error("Unable to create material request")
company_wise_mr = frappe._dict({})
for request_type in material_requests:
for company in material_requests[request_type]:
try:
@@ -206,17 +207,19 @@ def create_material_request(material_requests):
mr.submit()
mr_list.append(mr)
company_wise_mr.setdefault(company, []).append(mr)
except Exception:
_log_exception(mr)
if mr_list:
if company_wise_mr:
if getattr(frappe.local, "reorder_email_notify", None) is None:
frappe.local.reorder_email_notify = cint(
frappe.db.get_value("Stock Settings", None, "reorder_email_notify")
)
if frappe.local.reorder_email_notify:
send_email_notification(mr_list)
send_email_notification(company_wise_mr)
if exceptions_list:
notify_errors(exceptions_list)
@@ -224,20 +227,56 @@ def create_material_request(material_requests):
return mr_list
def send_email_notification(mr_list):
def send_email_notification(company_wise_mr):
"""Notify user about auto creation of indent"""
email_list = frappe.db.sql_list(
"""select distinct r.parent
from `tabHas Role` r, tabUser p
where p.name = r.parent and p.enabled = 1 and p.docstatus < 2
and r.role in ('Purchase Manager','Stock Manager')
and p.name not in ('Administrator', 'All', 'Guest')"""
for company, mr_list in company_wise_mr.items():
email_list = get_email_list(company)
if not email_list:
continue
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):

View File

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

View File

@@ -7,11 +7,11 @@
<div class="card-text mb-2">
{{ address.display }}
</div>
<a href="/addresses?name={{address.name}}" class="card-link">
<a href="/address/{{address.name}}" class="card-link">
<svg class="icon icon-sm">
<use href="#icon-edit"></use>
</svg>
{{ _('Edit') }}
</a>
</div>
</div>
</div>

View File

@@ -7,6 +7,6 @@
<p class="card-text text-muted">
{{ address.display }}
</p>
<a href="/addresses?name={{address.name}}" class="card-link">{{ _('Edit') }}</a>
<a href="/address/{{address.name}}" class="card-link">{{ _('Edit') }}</a>
</div>
</div>

View File

@@ -186,3 +186,80 @@ frappe.ready(() => {
}
});
</script>
<script>
frappe.ready(() => {
function get_update_address_dialog() {
let d = new frappe.ui.Dialog({
title: "Select Address",
fields: [{
'fieldtype': 'HTML',
'fieldname': 'address_picker',
}],
primary_action_label: __('Set Address'),
primary_action: () => {
const $card = d.$wrapper.find('.address-card.active');
const address_type = $card.closest('[data-address-type]').attr('data-address-type');
const address_name = $card.closest('[data-address-name]').attr('data-address-name');
frappe.call({
type: "POST",
method: "erpnext.e_commerce.shopping_cart.cart.update_cart_address",
freeze: true,
args: {
address_type,
address_name
},
callback: function(r) {
d.hide();
if (!r.exc) {
$(".cart-tax-items").html(r.message.total);
shopping_cart.parent.find(
`.address-container[data-address-type="${address_type}"]`
).html(r.message.address);
}
}
});
}
});
return d;
}
function get_address_template(type) {
return {
shipping: `<div class="mb-3" data-section="shipping-address">
<div class="row no-gutters" data-fieldname="shipping_address_name">
{% for address in shipping_addresses %}
<div class="mr-3 mb-3 w-100" data-address-name="{{address.name}}" data-address-type="shipping"
{% if doc.shipping_address_name == address.name %} data-active {% endif %}>
{% include "templates/includes/cart/address_picker_card.html" %}
</div>
{% endfor %}
</div>
</div>`,
billing: `<div class="mb-3" data-section="billing-address">
<div class="row no-gutters" data-fieldname="customer_address">
{% for address in billing_addresses %}
<div class="mr-3 mb-3 w-100" data-address-name="{{address.name}}" data-address-type="billing"
{% if doc.shipping_address_name == address.name %} data-active {% endif %}>
{% include "templates/includes/cart/address_picker_card.html" %}
</div>
{% endfor %}
</div>
</div>`,
}[type];
}
$(document).find('.btn-change-address').click(function(e) {
debugger
const d = get_update_address_dialog();
const type = $(e.currentTarget).parents('.address-container').attr('data-address-type');
$(d.get_field('address_picker').wrapper).html(
get_address_template(type)
);
d.show();
});
});
</script>

View File

@@ -12,7 +12,6 @@ $.extend(shopping_cart, {
},
bind_events: function() {
shopping_cart.bind_address_picker_dialog();
shopping_cart.bind_place_order();
shopping_cart.bind_request_quotation();
shopping_cart.bind_change_qty();
@@ -21,78 +20,6 @@ $.extend(shopping_cart, {
shopping_cart.bind_coupon_code();
},
bind_address_picker_dialog: function() {
const d = this.get_update_address_dialog();
this.parent.find('.btn-change-address').on('click', (e) => {
const type = $(e.currentTarget).parents('.address-container').attr('data-address-type');
$(d.get_field('address_picker').wrapper).html(
this.get_address_template(type)
);
d.show();
});
},
get_update_address_dialog() {
let d = new frappe.ui.Dialog({
title: "Select Address",
fields: [{
'fieldtype': 'HTML',
'fieldname': 'address_picker',
}],
primary_action_label: __('Set Address'),
primary_action: () => {
const $card = d.$wrapper.find('.address-card.active');
const address_type = $card.closest('[data-address-type]').attr('data-address-type');
const address_name = $card.closest('[data-address-name]').attr('data-address-name');
frappe.call({
type: "POST",
method: "erpnext.e_commerce.shopping_cart.cart.update_cart_address",
freeze: true,
args: {
address_type,
address_name
},
callback: function(r) {
d.hide();
if (!r.exc) {
$(".cart-tax-items").html(r.message.total);
shopping_cart.parent.find(
`.address-container[data-address-type="${address_type}"]`
).html(r.message.address);
}
}
});
}
});
return d;
},
get_address_template(type) {
return {
shipping: `<div class="mb-3" data-section="shipping-address">
<div class="row no-gutters" data-fieldname="shipping_address_name">
{% for address in shipping_addresses %}
<div class="mr-3 mb-3 w-100" data-address-name="{{address.name}}" data-address-type="shipping"
{% if doc.shipping_address_name == address.name %} data-active {% endif %}>
{% include "templates/includes/cart/address_picker_card.html" %}
</div>
{% endfor %}
</div>
</div>`,
billing: `<div class="mb-3" data-section="billing-address">
<div class="row no-gutters" data-fieldname="customer_address">
{% for address in billing_addresses %}
<div class="mr-3 mb-3 w-100" data-address-name="{{address.name}}" data-address-type="billing"
{% if doc.shipping_address_name == address.name %} data-active {% endif %}>
{% include "templates/includes/cart/address_picker_card.html" %}
</div>
{% endfor %}
</div>
</div>`,
}[type];
},
bind_place_order: function() {
$(".btn-place-order").on("click", function() {
shopping_cart.place_order(this);

View File

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