Merge branch 'develop' into perf-gl

This commit is contained in:
Sagar Vora
2025-05-12 14:25:51 +05:30
213 changed files with 33571 additions and 23212 deletions

View File

@@ -58,6 +58,10 @@ class TestAccountingDimension(IntegrationTestCase):
self.assertEqual(gle1.get("department"), "_Test Department - _TC")
def test_mandatory(self):
location = frappe.get_doc("Accounting Dimension", "Location")
location.dimension_defaults[0].mandatory_for_bs = True
location.save()
si = create_sales_invoice(do_not_save=1)
si.append(
"items",
@@ -121,7 +125,6 @@ def create_dimension():
"company": "_Test Company",
"reference_document": "Location",
"default_dimension": "Block 1",
"mandatory_for_bs": 1,
},
)

View File

@@ -58,6 +58,8 @@
"pos_tab",
"pos_setting_section",
"post_change_gl_entries",
"column_break_xrnd",
"use_sales_invoice_in_pos",
"assets_tab",
"asset_settings_section",
"calculate_depr_using_total_days",
@@ -77,9 +79,12 @@
"reports_tab",
"remarks_section",
"general_ledger_remarks_length",
"ignore_is_opening_check_for_reporting",
"column_break_lvjk",
"receivable_payable_remarks_length",
"accounts_receivable_payable_tuning_section",
"receivable_payable_fetch_method",
"legacy_section",
"ignore_is_opening_check_for_reporting",
"payment_request_settings",
"create_pr_in_draft_status"
],
@@ -532,14 +537,43 @@
"fieldtype": "Select",
"label": "Posting Date Inheritance for Exchange Gain / Loss",
"options": "Invoice\nPayment\nReconciliation Date"
},
{
"fieldname": "column_break_xrnd",
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "If enabled, Sales Invoice will be generated instead of POS Invoice in POS Transactions for real-time update of G/L and Stock Ledger.",
"fieldname": "use_sales_invoice_in_pos",
"fieldtype": "Check",
"label": "Use Sales Invoice"
},
{
"default": "Buffered Cursor",
"fieldname": "receivable_payable_fetch_method",
"fieldtype": "Select",
"label": "Data Fetch Method",
"options": "Buffered Cursor\nUnBuffered Cursor"
},
{
"fieldname": "accounts_receivable_payable_tuning_section",
"fieldtype": "Section Break",
"label": "Accounts Receivable / Payable Tuning"
},
{
"fieldname": "legacy_section",
"fieldtype": "Section Break",
"label": "Legacy Fields"
}
],
"grid_page_length": 50,
"icon": "icon-cog",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-01-23 13:15:44.077853",
"modified": "2025-05-05 12:29:38.302027",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",
@@ -564,8 +598,9 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": [],
"track_changes": 1
}
}

View File

@@ -54,6 +54,7 @@ class AccountsSettings(Document):
merge_similar_account_heads: DF.Check
over_billing_allowance: DF.Currency
post_change_gl_entries: DF.Check
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int
role_allowed_to_over_bill: DF.Link | None
@@ -66,6 +67,7 @@ class AccountsSettings(Document):
submit_journal_entries: DF.Check
unlink_advance_payment_on_cancelation_of_order: DF.Check
unlink_payment_on_cancellation_of_invoice: DF.Check
use_sales_invoice_in_pos: DF.Check
# end: auto-generated types
def validate(self):
@@ -92,6 +94,9 @@ class AccountsSettings(Document):
if old_doc.acc_frozen_upto != self.acc_frozen_upto:
self.validate_pending_reposts()
if old_doc.use_sales_invoice_in_pos != self.use_sales_invoice_in_pos:
self.validate_invoice_mode_switch_in_pos()
if clear_cache:
frappe.clear_cache()
@@ -135,3 +140,15 @@ class AccountsSettings(Document):
if self.has_value_changed("reconciliation_queue_size"):
if cint(self.reconciliation_queue_size) < 5 or cint(self.reconciliation_queue_size) > 100:
frappe.throw(_("Queue Size should be between 5 and 100"))
def validate_invoice_mode_switch_in_pos(self):
pos_opening_entries_count = frappe.db.count(
"POS Opening Entry", filters={"docstatus": 1, "status": "Open"}
)
if pos_opening_entries_count:
frappe.throw(
_("{0} can be enabled/disabled after all the POS Opening Entries are closed.").format(
frappe.bold(_("Use Sales Invoice"))
),
title=_("Switch Invoice Mode Error"),
)

View File

@@ -7,6 +7,9 @@ from frappe.tests import IntegrationTestCase
from frappe.utils import add_months, getdate
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import (
set_default_account_for_mode_of_payment,
)
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
@@ -172,11 +175,13 @@ def make_pos_sales_invoice():
customer = make_customer(customer="_Test Customer")
mode_of_payment = frappe.get_doc("Mode of Payment", "Wire Transfer")
set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", "_Test Bank Clearance - _TC")
si = create_sales_invoice(customer=customer, item="_Test Item", is_pos=1, qty=1, rate=1000, do_not_save=1)
si.set("payments", [])
si.append(
"payments", {"mode_of_payment": "Cash", "account": "_Test Bank Clearance - _TC", "amount": 1000}
)
si.append("payments", {"mode_of_payment": "Wire Transfer", "amount": 1000})
si.insert()
si.submit()

View File

@@ -12,6 +12,9 @@ from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool
get_linked_payments,
reconcile_vouchers,
)
from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import (
set_default_account_for_mode_of_payment,
)
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
@@ -430,15 +433,13 @@ def add_vouchers(gl_account="_Test Bank - _TC"):
except frappe.DuplicateEntryError:
pass
mode_of_payment = frappe.get_doc({"doctype": "Mode of Payment", "name": "Cash"})
mode_of_payment = frappe.get_doc({"doctype": "Mode of Payment", "name": "Wire Transfer"})
if not frappe.db.get_value("Mode of Payment Account", {"company": "_Test Company", "parent": "Cash"}):
mode_of_payment.append("accounts", {"company": "_Test Company", "default_account": gl_account})
mode_of_payment.save()
set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", gl_account)
si = create_sales_invoice(customer="Fayva", qty=1, rate=109080, do_not_save=1)
si.is_pos = 1
si.append("payments", {"mode_of_payment": "Cash", "account": gl_account, "amount": 109080})
si.append("payments", {"mode_of_payment": "Wire Transfer", "amount": 109080})
si.insert()
si.submit()

View File

@@ -3,18 +3,22 @@
import unittest
import frappe
from frappe.tests import IntegrationTestCase
from frappe.utils import now_datetime, nowdate
from erpnext.accounts.doctype.budget.budget import BudgetError, get_actual_expense
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.utils import get_fiscal_year
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
EXTRA_TEST_RECORD_DEPENDENCIES = ["Monthly Distribution"]
from erpnext.tests.utils import ERPNextTestSuite
class TestBudget(IntegrationTestCase):
class TestBudget(ERPNextTestSuite):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.make_monthly_distribution()
cls.make_projects()
def test_monthly_budget_crossed_ignore(self):
set_total_expense_zero(nowdate(), "cost_center")

View File

@@ -197,7 +197,7 @@ frappe.ui.form.on("Invoice Discounting", {
from_date: frm.doc.posting_date,
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
company: frm.doc.company,
group_by: "Group by Voucher (Consolidated)",
categorize_by: "Categorize by Voucher (Consolidated)",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");

View File

@@ -35,7 +35,7 @@ frappe.ui.form.on("Journal Entry", {
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
company: frm.doc.company,
finance_book: frm.doc.finance_book,
group_by: "",
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");

View File

@@ -253,11 +253,20 @@ class JournalEntry(AccountsController):
def validate_inter_company_accounts(self):
if self.voucher_type == "Inter Company Journal Entry" and self.inter_company_journal_entry_reference:
doc = frappe.get_doc("Journal Entry", self.inter_company_journal_entry_reference)
doc = frappe.db.get_value(
"Journal Entry",
self.inter_company_journal_entry_reference,
["company", "total_debit", "total_credit"],
as_dict=True,
)
account_currency = frappe.get_cached_value("Company", self.company, "default_currency")
previous_account_currency = frappe.get_cached_value("Company", doc.company, "default_currency")
if account_currency == previous_account_currency:
if self.total_credit != doc.total_debit or self.total_debit != doc.total_credit:
credit_precision = self.precision("total_credit")
debit_precision = self.precision("total_debit")
if (flt(self.total_credit, credit_precision) != flt(doc.total_debit, debit_precision)) or (
flt(self.total_debit, debit_precision) != flt(doc.total_credit, credit_precision)
):
frappe.throw(_("Total Credit/ Debit Amount should be same as linked Journal Entry"))
def validate_depr_entry_voucher_type(self):
@@ -1262,9 +1271,7 @@ class JournalEntry(AccountsController):
@frappe.whitelist()
def get_default_bank_cash_account(
company, account_type=None, mode_of_payment=None, account=None, ignore_permissions=False
):
def get_default_bank_cash_account(company, account_type=None, mode_of_payment=None, account=None):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
if mode_of_payment:
@@ -1302,7 +1309,7 @@ def get_default_bank_cash_account(
return frappe._dict(
{
"account": account,
"balance": get_balance_on(account, ignore_account_permission=ignore_permissions),
"balance": get_balance_on(account),
"account_currency": account_details.account_currency,
"account_type": account_details.account_type,
}

View File

@@ -2,8 +2,26 @@
# See license.txt
import unittest
import frappe
from frappe.tests import IntegrationTestCase
class TestModeofPayment(IntegrationTestCase):
pass
def set_default_account_for_mode_of_payment(mode_of_payment, company, account):
mode_of_payment.reload()
if frappe.db.exists(
"Mode of Payment Account", {"parent": mode_of_payment.mode_of_payment, "company": company}
):
frappe.db.set_value(
"Mode of Payment Account",
{"parent": mode_of_payment.mode_of_payment, "company": company},
"default_account",
account,
)
return
mode_of_payment.append("accounts", {"company": company, "default_account": account})
mode_of_payment.save()

View File

@@ -1,44 +0,0 @@
[{
"doctype": "Monthly Distribution",
"distribution_id": "_Test Distribution",
"fiscal_year": "_Test Fiscal Year 2013",
"percentages": [
{
"month": "January",
"percentage_allocation": "8"
}, {
"month": "February",
"percentage_allocation": "8"
}, {
"month": "March",
"percentage_allocation": "8"
}, {
"month": "April",
"percentage_allocation": "8"
}, {
"month": "May",
"percentage_allocation": "8"
}, {
"month": "June",
"percentage_allocation": "8"
}, {
"month": "July",
"percentage_allocation": "8"
}, {
"month": "August",
"percentage_allocation": "8"
}, {
"month": "September",
"percentage_allocation": "8"
}, {
"month": "October",
"percentage_allocation": "8"
}, {
"month": "November",
"percentage_allocation": "10"
}, {
"month": "December",
"percentage_allocation": "10"
}
]
}]

View File

@@ -410,7 +410,7 @@ frappe.ui.form.on("Payment Entry", {
from_date: frm.doc.posting_date,
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
company: frm.doc.company,
group_by: "",
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");

View File

@@ -2081,7 +2081,7 @@ class PaymentEntry(AccountsController):
# Re allocate amount to those references which have PR set (Higher priority)
for ref in self.references:
if not ref.payment_request:
if not (ref.reference_doctype and ref.reference_name and ref.payment_request):
continue
# fetch outstanding_amount of `Reference` (Payment Term) and `Payment Request` to allocate new amount
@@ -2132,7 +2132,7 @@ class PaymentEntry(AccountsController):
)
# Re allocate amount to those references which have no PR (Lower priority)
for ref in self.references:
if ref.payment_request:
if ref.payment_request or not (ref.reference_doctype and ref.reference_name):
continue
key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term"))
@@ -2966,7 +2966,6 @@ def get_payment_entry(
party_type=None,
payment_type=None,
reference_date=None,
ignore_permissions=False,
created_from_payment_request=False,
):
doc = frappe.get_doc(dt, dn)
@@ -2988,14 +2987,14 @@ def get_payment_entry(
)
# bank or cash
bank = get_bank_cash_account(doc, bank_account, ignore_permissions=ignore_permissions)
bank = get_bank_cash_account(doc, bank_account)
# if default bank or cash account is not set in company master and party has default company bank account, fetch it
if party_type in ["Customer", "Supplier"] and not bank:
party_bank_account = get_party_bank_account(party_type, doc.get(scrub(party_type)))
if party_bank_account:
account = frappe.db.get_value("Bank Account", party_bank_account, "account")
bank = get_bank_cash_account(doc, account, ignore_permissions=ignore_permissions)
bank = get_bank_cash_account(doc, account)
paid_amount, received_amount = set_paid_amount_and_received_amount(
dt, party_account_currency, bank, outstanding_amount, payment_type, bank_amount, doc
@@ -3025,6 +3024,8 @@ def get_payment_entry(
party_account_currency if payment_type == "Receive" else bank.account_currency
)
pe.paid_to_account_currency = party_account_currency if payment_type == "Pay" else bank.account_currency
pe.paid_from_account_type = frappe.db.get_value("Account", pe.paid_from, "account_type")
pe.paid_to_account_type = frappe.db.get_value("Account", pe.paid_to, "account_type")
pe.paid_amount = paid_amount
pe.received_amount = received_amount
pe.letter_head = doc.get("letter_head")
@@ -3304,13 +3305,12 @@ def update_accounting_dimensions(pe, doc):
pe.set(dimension, doc.get(dimension))
def get_bank_cash_account(doc, bank_account, ignore_permissions=False):
def get_bank_cash_account(doc, bank_account):
bank = get_default_bank_cash_account(
doc.company,
"Bank",
mode_of_payment=doc.get("mode_of_payment"),
account=bank_account,
ignore_permissions=ignore_permissions,
)
if not bank:

View File

@@ -672,7 +672,12 @@ def get_amount(ref_doc, payment_account=None):
dt = ref_doc.doctype
if dt in ["Sales Order", "Purchase Order"]:
grand_total = (flt(ref_doc.rounded_total) or flt(ref_doc.grand_total)) - ref_doc.advance_paid
advance_amount = flt(ref_doc.advance_paid)
if ref_doc.party_account_currency != ref_doc.currency:
advance_amount = flt(flt(ref_doc.advance_paid) / ref_doc.conversion_rate)
grand_total = (flt(ref_doc.rounded_total) or flt(ref_doc.grand_total)) - advance_amount
elif dt in ["Sales Invoice", "Purchase Invoice"]:
if (
dt == "Sales Invoice"

View File

@@ -47,7 +47,7 @@ frappe.ui.form.on("Period Closing Voucher", {
from_date: frm.doc.posting_date,
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
company: frm.doc.company,
group_by: "",
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");

View File

@@ -2,7 +2,7 @@
// For license information, please see license.txt
frappe.ui.form.on("POS Closing Entry", {
onload: function (frm) {
onload: async function (frm) {
frm.ignore_doctypes_on_cancel_all = ["POS Invoice Merge Log"];
frm.set_query("pos_profile", function (doc) {
return {
@@ -36,6 +36,15 @@ frappe.ui.form.on("POS Closing Entry", {
}
});
const is_pos_using_sales_invoice = await frappe.db.get_single_value(
"Accounts Settings",
"use_sales_invoice_in_pos"
);
if (is_pos_using_sales_invoice) {
frm.set_df_property("pos_transactions", "hidden", 1);
}
set_html_data(frm);
if (frm.doc.docstatus == 1) {
@@ -83,6 +92,7 @@ frappe.ui.form.on("POS Closing Entry", {
() => frappe.dom.freeze(__("Loading Invoices! Please Wait...")),
() => frm.trigger("set_opening_amounts"),
() => frm.trigger("get_pos_invoices"),
() => frm.trigger("get_sales_invoices"),
() => frappe.dom.unfreeze(),
]);
}
@@ -113,7 +123,25 @@ frappe.ui.form.on("POS Closing Entry", {
},
callback: (r) => {
let pos_docs = r.message;
set_form_data(pos_docs, frm);
set_pos_transaction_form_data(pos_docs, frm);
refresh_fields(frm);
set_html_data(frm);
},
});
},
get_sales_invoices(frm) {
return frappe.call({
method: "erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry.get_sales_invoices",
args: {
start: frappe.datetime.get_datetime_as_string(frm.doc.period_start_date),
end: frappe.datetime.get_datetime_as_string(frm.doc.period_end_date),
pos_profile: frm.doc.pos_profile,
user: frm.doc.user,
},
callback: (r) => {
let sales_docs = r.message;
set_sales_invoice_transaction_form_data(sales_docs, frm);
refresh_fields(frm);
set_html_data(frm);
},
@@ -132,9 +160,40 @@ frappe.ui.form.on("POS Closing Entry", {
row.expected_amount = row.opening_amount;
}
const is_pos_using_sales_invoice = await frappe.db.get_single_value(
"Accounts Settings",
"use_sales_invoice_in_pos"
);
if (is_pos_using_sales_invoice) {
await Promise.all([
frappe.call({
method: "erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry.get_pos_invoices",
args: {
start: frappe.datetime.get_datetime_as_string(frm.doc.period_start_date),
end: frappe.datetime.get_datetime_as_string(frm.doc.period_end_date),
pos_profile: frm.doc.pos_profile,
user: frm.doc.user,
},
callback: (r) => {
let pos_invoices = r.message;
for (let doc of pos_invoices) {
frm.doc.grand_total += flt(doc.grand_total);
frm.doc.net_total += flt(doc.net_total);
frm.doc.total_quantity += flt(doc.total_qty);
refresh_payments(doc, frm, false);
refresh_taxes(doc, frm);
refresh_fields(frm);
set_html_data(frm);
}
},
}),
]);
}
await Promise.all([
frappe.call({
method: "erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry.get_pos_invoices",
method: "erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry.get_sales_invoices",
args: {
start: frappe.datetime.get_datetime_as_string(frm.doc.period_start_date),
end: frappe.datetime.get_datetime_as_string(frm.doc.period_end_date),
@@ -142,8 +201,8 @@ frappe.ui.form.on("POS Closing Entry", {
user: frm.doc.user,
},
callback: (r) => {
let pos_invoices = r.message;
for (let doc of pos_invoices) {
let sales_invoices = r.message;
for (let doc of sales_invoices) {
frm.doc.grand_total += flt(doc.grand_total);
frm.doc.net_total += flt(doc.net_total);
frm.doc.total_quantity += flt(doc.total_qty);
@@ -155,6 +214,7 @@ frappe.ui.form.on("POS Closing Entry", {
},
}),
]);
frappe.dom.unfreeze();
},
});
@@ -166,7 +226,7 @@ frappe.ui.form.on("POS Closing Entry Detail", {
},
});
function set_form_data(data, frm) {
function set_pos_transaction_form_data(data, frm) {
data.forEach((d) => {
add_to_pos_transaction(d, frm);
frm.doc.grand_total += flt(d.grand_total);
@@ -177,6 +237,17 @@ function set_form_data(data, frm) {
});
}
function set_sales_invoice_transaction_form_data(data, frm) {
data.forEach((d) => {
add_to_sales_invoice_transaction(d, frm);
frm.doc.grand_total += flt(d.grand_total);
frm.doc.net_total += flt(d.net_total);
frm.doc.total_quantity += flt(d.total_qty);
refresh_payments(d, frm, true);
refresh_taxes(d, frm);
});
}
function add_to_pos_transaction(d, frm) {
frm.add_child("pos_transactions", {
pos_invoice: d.name,
@@ -186,6 +257,15 @@ function add_to_pos_transaction(d, frm) {
});
}
function add_to_sales_invoice_transaction(d, frm) {
frm.add_child("sales_invoice_transactions", {
sales_invoice: d.name,
posting_date: d.posting_date,
grand_total: d.grand_total,
customer: d.customer,
});
}
function refresh_payments(d, frm, is_new) {
d.payments.forEach((p) => {
const payment = frm.doc.payment_reconciliation.find(
@@ -226,6 +306,7 @@ function refresh_taxes(d, frm) {
function reset_values(frm) {
frm.set_value("pos_transactions", []);
frm.set_value("sales_invoice_transactions", []);
frm.set_value("payment_reconciliation", []);
frm.set_value("taxes", []);
frm.set_value("grand_total", 0);
@@ -235,6 +316,7 @@ function reset_values(frm) {
function refresh_fields(frm) {
frm.refresh_field("pos_transactions");
frm.refresh_field("sales_invoice_transactions");
frm.refresh_field("payment_reconciliation");
frm.refresh_field("taxes");
frm.refresh_field("grand_total");

View File

@@ -21,6 +21,7 @@
"user",
"section_break_12",
"pos_transactions",
"sales_invoice_transactions",
"section_break_9",
"payment_reconciliation_details",
"section_break_11",
@@ -227,8 +228,15 @@
"label": "Posting Time",
"no_copy": 1,
"reqd": 1
},
{
"fieldname": "sales_invoice_transactions",
"fieldtype": "Table",
"label": "Sales Invoice Transactions",
"options": "Sales Invoice Reference"
}
],
"grid_page_length": 50,
"is_submittable": 1,
"links": [
{
@@ -236,7 +244,7 @@
"link_fieldname": "pos_closing_entry"
}
],
"modified": "2024-03-27 13:10:14.073467",
"modified": "2025-03-19 19:49:58.845697",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Closing Entry",
@@ -285,8 +293,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -28,8 +28,9 @@ class POSClosingEntry(StatusUpdater):
from erpnext.accounts.doctype.pos_closing_entry_taxes.pos_closing_entry_taxes import (
POSClosingEntryTaxes,
)
from erpnext.accounts.doctype.pos_invoice_reference.pos_invoice_reference import (
POSInvoiceReference,
from erpnext.accounts.doctype.pos_invoice_reference.pos_invoice_reference import POSInvoiceReference
from erpnext.accounts.doctype.sales_invoice_reference.sales_invoice_reference import (
SalesInvoiceReference,
)
amended_from: DF.Link | None
@@ -45,6 +46,7 @@ class POSClosingEntry(StatusUpdater):
pos_transactions: DF.Table[POSInvoiceReference]
posting_date: DF.Date
posting_time: DF.Time
sales_invoice_transactions: DF.Table[SalesInvoiceReference]
status: DF.Literal["Draft", "Submitted", "Queued", "Failed", "Cancelled"]
taxes: DF.Table[POSClosingEntryTaxes]
total_quantity: DF.Float
@@ -58,8 +60,20 @@ class POSClosingEntry(StatusUpdater):
if frappe.db.get_value("POS Opening Entry", self.pos_opening_entry, "status") != "Open":
frappe.throw(_("Selected POS Opening Entry should be open."), title=_("Invalid Opening Entry"))
self.validate_duplicate_pos_invoices()
self.validate_pos_invoices()
self.is_pos_using_sales_invoice = frappe.db.get_single_value(
"Accounts Settings", "use_sales_invoice_in_pos"
)
if self.is_pos_using_sales_invoice == 0:
self.validate_duplicate_pos_invoices()
self.validate_pos_invoices()
if self.is_pos_using_sales_invoice == 1:
if len(self.pos_transactions) != 0:
frappe.throw(_("POS Invoices can't be added when Sales Invoice is enabled"))
self.validate_duplicate_sales_invoices()
self.validate_sales_invoices()
def validate_duplicate_pos_invoices(self):
pos_occurences = {}
@@ -114,6 +128,71 @@ class POSClosingEntry(StatusUpdater):
frappe.throw(error_list, title=_("Invalid POS Invoices"), as_list=True)
def validate_duplicate_sales_invoices(self):
sales_invoice_occurrences = {}
for idx, inv in enumerate(self.sales_invoice_transactions, 1):
sales_invoice_occurrences.setdefault(inv.sales_invoice, []).append(idx)
error_list = []
for key, value in sales_invoice_occurrences.items():
if len(value) > 1:
error_list.append(
_("{0} is added multiple times on rows: {1}").format(frappe.bold(key), frappe.bold(value))
)
if error_list:
frappe.throw(error_list, title=_("Duplicate Sales Invoices found"), as_list=True)
def validate_sales_invoices(self):
invalid_rows = []
for d in self.sales_invoice_transactions:
invalid_row = {"idx": d.idx}
sales_invoice = frappe.db.get_values(
"Sales Invoice",
d.sales_invoice,
[
"pos_profile",
"docstatus",
"is_pos",
"owner",
"is_created_using_pos",
"is_consolidated",
"pos_closing_entry",
],
as_dict=1,
)[0]
if sales_invoice.pos_closing_entry:
invalid_row.setdefault("msg", []).append(_("Sales Invoice is already consolidated"))
invalid_rows.append(invalid_row)
continue
if sales_invoice.is_pos == 0:
invalid_row.setdefault("msg", []).append(_("Sales Invoice does not have Payments"))
if sales_invoice.is_created_using_pos == 0:
invalid_row.setdefault("msg", []).append(_("Sales Invoice is not created using POS"))
if sales_invoice.pos_profile != self.pos_profile:
invalid_row.setdefault("msg", []).append(
_("POS Profile doesn't match {}").format(frappe.bold(self.pos_profile))
)
if sales_invoice.docstatus != 1:
invalid_row.setdefault("msg", []).append(_("Sales Invoice is not submitted"))
if sales_invoice.owner != self.user:
invalid_row.setdefault("msg", []).append(
_("Sales Invoice isn't created by user {}").format(frappe.bold(self.owner))
)
if invalid_row.get("msg"):
invalid_rows.append(invalid_row)
if not invalid_rows:
return
error_list = []
for row in invalid_rows:
for msg in row.get("msg"):
error_list.append(_("Row #{}: {}").format(row.get("idx"), msg))
frappe.throw(error_list, title=_("Invalid Sales Invoices"), as_list=True)
@frappe.whitelist()
def get_payment_reconciliation_details(self):
currency = frappe.get_cached_value("Company", self.company, "default_currency")
@@ -130,9 +209,13 @@ class POSClosingEntry(StatusUpdater):
docname=f"POS Opening Entry/{self.pos_opening_entry}",
)
self.update_sales_invoices_closing_entry()
def on_cancel(self):
unconsolidate_pos_invoices(closing_entry=self)
self.update_sales_invoices_closing_entry(cancel=True)
@frappe.whitelist()
def retry(self):
consolidate_pos_invoices(closing_entry=self)
@@ -143,6 +226,12 @@ class POSClosingEntry(StatusUpdater):
opening_entry.set_status()
opening_entry.save()
def update_sales_invoices_closing_entry(self, cancel=False):
for d in self.sales_invoice_transactions:
frappe.db.set_value(
"Sales Invoice", d.sales_invoice, "pos_closing_entry", self.name if not cancel else None
)
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
@@ -173,6 +262,33 @@ def get_pos_invoices(start, end, pos_profile, user):
return data
@frappe.whitelist()
def get_sales_invoices(start, end, pos_profile, user):
data = frappe.db.sql(
"""
select
name, timestamp(posting_date, posting_time) as "timestamp"
from
`tabSales Invoice`
where
owner = %s
and docstatus = 1
and is_pos = 1
and pos_profile = %s
and is_created_using_pos = 1
and ifnull(pos_closing_entry,'') = ''
""",
(user, pos_profile),
as_dict=1,
)
data = [d for d in data if get_datetime(start) <= get_datetime(d.timestamp) <= get_datetime(end)]
# need to get taxes and payments so can't avoid get_doc
data = [frappe.get_doc("Sales Invoice", d.name).as_dict() for d in data]
return data
def make_closing_entry_from_opening(opening_entry):
closing_entry = frappe.new_doc("POS Closing Entry")
closing_entry.pos_opening_entry = opening_entry.name
@@ -185,7 +301,20 @@ def make_closing_entry_from_opening(opening_entry):
closing_entry.net_total = 0
closing_entry.total_quantity = 0
invoices = get_pos_invoices(
is_pos_using_sales_invoice = frappe.db.get_single_value("Accounts Settings", "use_sales_invoice_in_pos")
pos_invoices = (
get_pos_invoices(
closing_entry.period_start_date,
closing_entry.period_end_date,
closing_entry.pos_profile,
closing_entry.user,
)
if is_pos_using_sales_invoice == 0
else []
)
sales_invoices = get_sales_invoices(
closing_entry.period_start_date,
closing_entry.period_end_date,
closing_entry.pos_profile,
@@ -193,6 +322,7 @@ def make_closing_entry_from_opening(opening_entry):
)
pos_transactions = []
sales_invoice_transactions = []
taxes = []
payments = []
for detail in opening_entry.balance_details:
@@ -206,7 +336,7 @@ def make_closing_entry_from_opening(opening_entry):
)
)
for d in invoices:
for d in pos_invoices:
pos_transactions.append(
frappe._dict(
{
@@ -217,6 +347,20 @@ def make_closing_entry_from_opening(opening_entry):
}
)
)
for d in sales_invoices:
sales_invoice_transactions.append(
frappe._dict(
{
"sales_invoice": d.name,
"posting_date": d.posting_date,
"grand_total": d.grand_total,
"customer": d.customer,
}
)
)
for d in [*pos_invoices, *sales_invoices]:
closing_entry.grand_total += flt(d.grand_total)
closing_entry.net_total += flt(d.net_total)
closing_entry.total_quantity += flt(d.total_qty)
@@ -246,6 +390,7 @@ def make_closing_entry_from_opening(opening_entry):
)
closing_entry.set("pos_transactions", pos_transactions)
closing_entry.set("sales_invoice_transactions", sales_invoice_transactions)
closing_entry.set("payment_reconciliation", payments)
closing_entry.set("taxes", taxes)

View File

@@ -159,6 +159,10 @@ class TestPOSClosingEntry(IntegrationTestCase):
"""
create_dimension()
location = frappe.get_doc("Accounting Dimension", "Location")
location.dimension_defaults[0].mandatory_for_bs = True
location.save()
pos_profile = make_pos_profile(do_not_insert=1, do_not_set_accounting_dimension=1)
self.assertRaises(frappe.ValidationError, pos_profile.insert)
@@ -289,6 +293,46 @@ class TestPOSClosingEntry(IntegrationTestCase):
batch_qty_with_pos = get_batch_qty(batch_no, "_Test Warehouse - _TC", item_code)
self.assertEqual(batch_qty_with_pos, 10.0)
def test_closing_entries_with_sales_invoice(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
test_user, pos_profile = init_user_and_profile()
# Deleting all opening entry
frappe.db.sql("delete from `tabPOS Opening Entry`")
with self.change_settings("Accounts Settings", {"use_sales_invoice_in_pos": 1}):
opening_entry = create_opening_entry(pos_profile, test_user.name)
pos_si = create_sales_invoice(qty=10, do_not_save=1)
pos_si.is_pos = 1
pos_si.pos_profile = pos_profile.name
pos_si.is_created_using_pos = 1
pos_si.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000})
pos_si.save()
pos_si.submit()
pos_si2 = create_sales_invoice(qty=5, do_not_save=1)
pos_si2.is_pos = 1
pos_si2.pos_profile = pos_profile.name
pos_si2.is_created_using_pos = 1
pos_si2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000})
pos_si2.save()
pos_si2.submit()
pcv_doc = make_closing_entry_from_opening(opening_entry)
payment = pcv_doc.payment_reconciliation[0]
self.assertEqual(payment.mode_of_payment, "Cash")
for d in pcv_doc.payment_reconciliation:
if d.mode_of_payment == "Cash":
d.closing_amount = 1500
pcv_doc.submit()
self.assertEqual(pcv_doc.total_quantity, 15)
self.assertEqual(pcv_doc.net_total, 1500)
def init_user_and_profile(**args):
user = "test@example.com"

View File

@@ -323,3 +323,15 @@ frappe.ui.form.on("POS Invoice", {
});
},
});
frappe.ui.form.on("Sales Invoice Payment", {
mode_of_payment: function (frm) {
frappe.call({
doc: frm.doc,
method: "set_account_for_mode_of_payment",
callback: function (r) {
refresh_field("payments");
},
});
},
});

View File

@@ -4,6 +4,7 @@
import frappe
from frappe import _, bold
from frappe.model.mapper import map_child_doc, map_doc
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate
from frappe.utils.nestedset import get_descendants_of
@@ -17,13 +18,10 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
)
from erpnext.accounts.party import get_due_date, get_party_account
from erpnext.controllers.queries import item_query as _item_query
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
class PartialPaymentValidationError(frappe.ValidationError):
pass
class POSInvoice(SalesInvoice):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -197,6 +195,7 @@ class POSInvoice(SalesInvoice):
# run on validate method of selling controller
super(SalesInvoice, self).validate()
self.validate_pos_opening_entry()
self.validate_is_pos_using_sales_invoice()
self.validate_auto_set_posting_time()
self.validate_mode_of_payment()
self.validate_uom_is_integer("stock_uom", "stock_qty")
@@ -244,6 +243,9 @@ class POSInvoice(SalesInvoice):
update_coupon_code_count(self.coupon_code, "used")
self.clear_unallocated_mode_of_payments()
if self.is_return and self.is_pos_using_sales_invoice:
self.create_and_add_consolidated_sales_invoice()
def before_cancel(self):
if (
self.consolidated_invoice
@@ -287,6 +289,47 @@ class POSInvoice(SalesInvoice):
sip = frappe.qb.DocType("Sales Invoice Payment")
frappe.qb.from_(sip).delete().where(sip.parent == self.name).where(sip.amount == 0).run()
def create_and_add_consolidated_sales_invoice(self):
sales_inv = self.create_return_sales_invoice()
self.db_set("consolidated_invoice", sales_inv.name)
self.set_status(update=True)
def create_return_sales_invoice(self):
return_sales_invoice = frappe.new_doc("Sales Invoice")
return_sales_invoice.is_pos = 1
return_sales_invoice.is_return = 1
map_doc(self, return_sales_invoice, table_map={"doctype": return_sales_invoice.doctype})
return_sales_invoice.is_created_using_pos = 1
return_sales_invoice.is_consolidated = 1
return_sales_invoice.return_against = frappe.db.get_value(
"POS Invoice", self.return_against, "consolidated_invoice"
)
items, taxes, payments = [], [], []
for d in self.items:
si_item = map_child_doc(d, return_sales_invoice, {"doctype": "Sales Invoice Item"})
si_item.pos_invoice = self.name
si_item.pos_invoice_item = d.name
si_item.sales_invoice_item = get_sales_invoice_item_from_consolidated_invoice(
self.return_against, d.pos_invoice_item
)
items.append(si_item)
for d in self.get("taxes"):
tax = map_child_doc(d, return_sales_invoice, {"doctype": "Sales Taxes and Charges"})
taxes.append(tax)
for d in self.get("payments"):
payment = map_child_doc(d, return_sales_invoice, {"doctype": "Sales Invoice Payment"})
payments.append(payment)
return_sales_invoice.set("items", items)
return_sales_invoice.set("taxes", taxes)
return_sales_invoice.set("payments", payments)
return_sales_invoice.save()
return_sales_invoice.submit()
return return_sales_invoice
def delink_serial_and_batch_bundle(self):
for row in self.items:
if row.serial_and_batch_bundle:
@@ -378,6 +421,13 @@ class POSInvoice(SalesInvoice):
title=_("Item Unavailable"),
)
def validate_is_pos_using_sales_invoice(self):
self.is_pos_using_sales_invoice = frappe.db.get_single_value(
"Accounts Settings", "use_sales_invoice_in_pos"
)
if self.is_pos_using_sales_invoice and not self.is_return:
frappe.throw(_("Sales Invoice mode is activated in POS. Please create Sales Invoice instead."))
def validate_serialised_or_batched_item(self):
error_msg = []
for d in self.get("items"):
@@ -502,20 +552,6 @@ class POSInvoice(SalesInvoice):
if self.redeem_loyalty_points and self.loyalty_program and self.loyalty_points:
validate_loyalty_points(self, self.loyalty_points)
def validate_full_payment(self):
invoice_total = flt(self.rounded_total) or flt(self.grand_total)
if self.docstatus == 1:
if self.is_return and self.paid_amount != invoice_total:
frappe.throw(
msg=_("Partial Payment in POS Invoice is not allowed."), exc=PartialPaymentValidationError
)
if self.paid_amount < invoice_total:
frappe.throw(
msg=_("Partial Payment in POS Invoice is not allowed."), exc=PartialPaymentValidationError
)
def set_status(self, update=False, status=None, update_modified=True):
if self.is_new():
if self.get("amended_from"):

View File

@@ -7,8 +7,12 @@ import frappe
from frappe import _
from frappe.tests import IntegrationTestCase
from erpnext.accounts.doctype.pos_invoice.pos_invoice import PartialPaymentValidationError, make_sales_return
from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import (
set_default_account_for_mode_of_payment,
)
from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.accounts.doctype.sales_invoice.sales_invoice import PartialPaymentValidationError
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
@@ -33,6 +37,8 @@ class TestPOSInvoice(IntegrationTestCase):
cls.test_user, cls.pos_profile = init_user_and_profile()
create_opening_entry(cls.pos_profile, cls.test_user)
mode_of_payment = frappe.get_doc("Mode of Payment", "Bank Draft")
set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", "_Test Bank - _TC")
def tearDown(self):
if frappe.session.user != "Administrator":
@@ -235,12 +241,8 @@ class TestPOSInvoice(IntegrationTestCase):
pos = create_pos_invoice(qty=10, do_not_save=True)
pos.set("payments", [])
pos.append(
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 500}
)
pos.append(
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 500, "default": 1}
)
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 500})
pos.append("payments", {"mode_of_payment": "Cash", "amount": 500, "default": 1})
pos.insert()
pos.submit()
@@ -279,9 +281,7 @@ class TestPOSInvoice(IntegrationTestCase):
do_not_save=1,
)
pos.append(
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000, "default": 1}
)
pos.append("payments", {"mode_of_payment": "Cash", "amount": 1000, "default": 1})
pos.insert()
pos.submit()
@@ -322,9 +322,7 @@ class TestPOSInvoice(IntegrationTestCase):
do_not_save=1,
)
pos.append(
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 2000, "default": 1}
)
pos.append("payments", {"mode_of_payment": "Cash", "amount": 2000, "default": 1})
pos.insert()
pos.submit()
@@ -335,9 +333,7 @@ class TestPOSInvoice(IntegrationTestCase):
# partial return 1
pos_return1.get("items")[0].qty = -1
pos_return1.set("payments", [])
pos_return1.append(
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": -1000, "default": 1}
)
pos_return1.append("payments", {"mode_of_payment": "Cash", "amount": -1000, "default": 1})
pos_return1.paid_amount = -1000
pos_return1.submit()
pos_return1.reload()
@@ -354,9 +350,7 @@ class TestPOSInvoice(IntegrationTestCase):
# partial return 2
pos_return2 = make_sales_return(pos.name)
pos_return2.set("payments", [])
pos_return2.append(
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": -1000, "default": 1}
)
pos_return2.append("payments", {"mode_of_payment": "Cash", "amount": -1000, "default": 1})
pos_return2.paid_amount = -1000
pos_return2.submit()
@@ -376,10 +370,8 @@ class TestPOSInvoice(IntegrationTestCase):
)
pos.set("payments", [])
pos.append("payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 50})
pos.append(
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 60, "default": 1}
)
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50})
pos.append("payments", {"mode_of_payment": "Cash", "amount": 60, "default": 1})
pos.insert()
pos.submit()
@@ -397,7 +389,7 @@ class TestPOSInvoice(IntegrationTestCase):
pos_inv = create_pos_invoice(rate=10000, do_not_save=1)
pos_inv.append(
"payments",
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 9000},
{"mode_of_payment": "Cash", "amount": 9000},
)
pos_inv.insert()
self.assertRaises(PartialPaymentValidationError, pos_inv.submit)
@@ -429,9 +421,7 @@ class TestPOSInvoice(IntegrationTestCase):
do_not_save=1,
)
pos.append(
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000}
)
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000})
pos.insert()
pos.submit()
@@ -450,9 +440,7 @@ class TestPOSInvoice(IntegrationTestCase):
do_not_save=1,
)
pos2.append(
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000}
)
pos2.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000})
pos2.insert()
self.assertRaises(frappe.ValidationError, pos2.submit)
@@ -502,9 +490,7 @@ class TestPOSInvoice(IntegrationTestCase):
do_not_save=1,
)
pos2.append(
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000}
)
pos2.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000})
pos2.insert()
self.assertRaises(frappe.ValidationError, pos2.submit)
@@ -569,9 +555,7 @@ class TestPOSInvoice(IntegrationTestCase):
)
pos.get("items")[0].has_serial_no = 1
pos.set("payments", [])
pos.append(
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000, "default": 1}
)
pos.append("payments", {"mode_of_payment": "Cash", "amount": 1000, "default": 1})
pos = pos.save().submit()
# make a return
@@ -617,7 +601,7 @@ class TestPOSInvoice(IntegrationTestCase):
inv = create_pos_invoice(customer="Test Loyalty Customer", rate=10000, do_not_save=1)
inv.append(
"payments",
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 10000},
{"mode_of_payment": "Cash", "amount": 10000},
)
inv.insert()
inv.submit()
@@ -649,7 +633,7 @@ class TestPOSInvoice(IntegrationTestCase):
pos_inv = create_pos_invoice(customer="Test Loyalty Customer", rate=10000, do_not_save=1)
pos_inv.append(
"payments",
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 10000},
{"mode_of_payment": "Cash", "amount": 10000},
)
pos_inv.paid_amount = 10000
pos_inv.submit()
@@ -664,7 +648,7 @@ class TestPOSInvoice(IntegrationTestCase):
inv.loyalty_amount = inv.loyalty_points * before_lp_details.conversion_factor
inv.append(
"payments",
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 10000 - inv.loyalty_amount},
{"mode_of_payment": "Cash", "amount": 10000 - inv.loyalty_amount},
)
inv.paid_amount = 10000
inv.submit()
@@ -685,12 +669,12 @@ class TestPOSInvoice(IntegrationTestCase):
frappe.db.sql("delete from `tabPOS Invoice`")
test_user, pos_profile = init_user_and_profile()
pos_inv = create_pos_invoice(rate=300, additional_discount_percentage=10, do_not_submit=1)
pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 270})
pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 270})
pos_inv.save()
pos_inv.submit()
pos_inv2 = create_pos_invoice(rate=3200, do_not_submit=1)
pos_inv2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3200})
pos_inv2.append("payments", {"mode_of_payment": "Cash", "amount": 3200})
pos_inv2.save()
pos_inv2.submit()
@@ -711,7 +695,7 @@ class TestPOSInvoice(IntegrationTestCase):
frappe.db.sql("delete from `tabPOS Invoice`")
test_user, pos_profile = init_user_and_profile()
pos_inv = create_pos_invoice(rate=300, do_not_submit=1)
pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 300})
pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 300})
pos_inv.append(
"taxes",
{
@@ -728,7 +712,7 @@ class TestPOSInvoice(IntegrationTestCase):
pos_inv2 = create_pos_invoice(rate=300, qty=2, do_not_submit=1)
pos_inv2.additional_discount_percentage = 10
pos_inv2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 540})
pos_inv2.append("payments", {"mode_of_payment": "Cash", "amount": 540})
pos_inv2.append(
"taxes",
{
@@ -766,7 +750,7 @@ class TestPOSInvoice(IntegrationTestCase):
frappe.db.sql("delete from `tabPOS Invoice`")
test_user, pos_profile = init_user_and_profile()
pos_inv = create_pos_invoice(item=item, rate=300, do_not_submit=1)
pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 300})
pos_inv.append("payments", {"mode_of_payment": "Cash", "amount": 300})
pos_inv.append(
"taxes",
{
@@ -781,7 +765,7 @@ class TestPOSInvoice(IntegrationTestCase):
self.assertRaises(frappe.ValidationError, pos_inv.submit)
pos_inv2 = create_pos_invoice(item=item, rate=400, do_not_submit=1)
pos_inv2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 400})
pos_inv2.append("payments", {"mode_of_payment": "Cash", "amount": 400})
pos_inv2.append(
"taxes",
{
@@ -826,7 +810,7 @@ class TestPOSInvoice(IntegrationTestCase):
pos_inv1 = create_pos_invoice(item="_BATCH ITEM Test For Reserve", rate=300, qty=15, do_not_save=1)
pos_inv1.append(
"payments",
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 4500},
{"mode_of_payment": "Cash", "amount": 4500},
)
pos_inv1.items[0].batch_no = batch_no
pos_inv1.save()
@@ -847,7 +831,7 @@ class TestPOSInvoice(IntegrationTestCase):
)
pos_inv2.append(
"payments",
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3000},
{"mode_of_payment": "Cash", "amount": 3000},
)
pos_inv2.save()
pos_inv2.submit()
@@ -887,7 +871,7 @@ class TestPOSInvoice(IntegrationTestCase):
)
pos_inv1.append(
"payments",
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 300},
{"mode_of_payment": "Cash", "amount": 300},
)
pos_inv1.save()
pos_inv1.submit()

View File

@@ -2,13 +2,13 @@
# For license information, please see license.txt
import hashlib
import json
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import map_child_doc, map_doc
from frappe.query_builder import DocType
from frappe.utils import cint, flt, get_time, getdate, nowdate, nowtime
from frappe.utils.background_jobs import enqueue, is_job_enqueued
from frappe.utils.scheduler import is_scheduler_inactive
@@ -16,6 +16,7 @@ from frappe.utils.scheduler import is_scheduler_inactive
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_checks_for_pl_and_bs_accounts,
)
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
from erpnext.controllers.taxes_and_totals import ItemWiseTaxDetail
@@ -238,7 +239,7 @@ class POSInvoiceMergeLog(Document):
si_item.pos_invoice = doc.name
si_item.pos_invoice_item = item.name
if doc.is_return:
si_item.sales_invoice_item = get_sales_invoice_item(
si_item.sales_invoice_item = get_sales_invoice_item_from_consolidated_invoice(
doc.return_against, item.pos_invoice_item
)
if item.serial_and_batch_bundle:
@@ -303,10 +304,17 @@ class POSInvoiceMergeLog(Document):
accounting_dimensions = get_checks_for_pl_and_bs_accounts()
accounting_dimensions_fields = [d.fieldname for d in accounting_dimensions]
dimension_values = frappe.db.get_value(
"POS Profile", {"name": invoice.pos_profile}, accounting_dimensions_fields, as_dict=1
"POS Profile",
{"name": invoice.pos_profile},
[*accounting_dimensions_fields, "cost_center", "project"],
as_dict=1,
)
for dimension in accounting_dimensions:
dimension_value = dimension_values.get(dimension.fieldname)
dimension_value = (
data[0].get(dimension.fieldname)
if data[0].get(dimension.fieldname)
else dimension_values.get(dimension.fieldname)
)
if not dimension_value and (dimension.mandatory_for_pl or dimension.mandatory_for_bs):
frappe.throw(
@@ -318,6 +326,14 @@ class POSInvoiceMergeLog(Document):
invoice.set(dimension.fieldname, dimension_value)
invoice.set(
"cost_center",
data[0].get("cost_center") if data[0].get("cost_center") else dimension_values.get("cost_center"),
)
invoice.set(
"project", data[0].get("project") if data[0].get("project") else dimension_values.get("project")
)
if self.merge_invoices_based_on == "Customer Group":
invoice.flags.ignore_pos_profile = True
invoice.pos_profile = ""
@@ -337,7 +353,7 @@ class POSInvoiceMergeLog(Document):
for doc in invoice_docs:
doc.load_from_db()
inv = sales_invoice
if doc.is_return:
if doc.is_return and credit_notes:
for key, value in credit_notes.items():
if doc.name in value:
inv = key
@@ -446,9 +462,34 @@ def get_invoice_customer_map(pos_invoices):
pos_invoice_customer_map.setdefault(customer, [])
pos_invoice_customer_map[customer].append(invoice)
for customer, invoices in pos_invoice_customer_map.items():
pos_invoice_customer_map[customer] = split_invoices_by_accounting_dimension(invoices)
return pos_invoice_customer_map
def split_invoices_by_accounting_dimension(pos_invoices):
# pos_invoices = {
# {'dim_field1': 'dim_field1_value1', 'dim_field2': 'dim_field2_value1'}: [],
# {'dim_field1': 'dim_field1_value2', 'dim_field2': 'dim_field2_value1'}: []
# }
pos_invoice_accounting_dimensions_map = {}
for invoice in pos_invoices:
dimension_fields = [d.fieldname for d in get_checks_for_pl_and_bs_accounts()]
accounting_dimensions = frappe.db.get_value(
"POS Invoice", invoice.pos_invoice, [*dimension_fields, "cost_center", "project"], as_dict=1
)
accounting_dimensions_dic_hash = hashlib.sha256(
json.dumps(accounting_dimensions).encode()
).hexdigest()
pos_invoice_accounting_dimensions_map.setdefault(accounting_dimensions_dic_hash, [])
pos_invoice_accounting_dimensions_map[accounting_dimensions_dic_hash].append(invoice)
return pos_invoice_accounting_dimensions_map
def consolidate_pos_invoices(pos_invoices=None, closing_entry=None):
invoices = pos_invoices or (closing_entry and closing_entry.get("pos_transactions"))
if frappe.flags.in_test and not invoices:
@@ -532,20 +573,21 @@ def split_invoices(invoices):
def create_merge_logs(invoice_by_customer, closing_entry=None):
try:
for customer, invoices in invoice_by_customer.items():
for _invoices in split_invoices(invoices):
merge_log = frappe.new_doc("POS Invoice Merge Log")
merge_log.posting_date = (
getdate(closing_entry.get("posting_date")) if closing_entry else nowdate()
)
merge_log.posting_time = (
get_time(closing_entry.get("posting_time")) if closing_entry else nowtime()
)
merge_log.customer = customer
merge_log.pos_closing_entry = closing_entry.get("name") if closing_entry else None
merge_log.set("pos_invoices", _invoices)
merge_log.save(ignore_permissions=True)
merge_log.submit()
for customer, invoices_acc_dim in invoice_by_customer.items():
for invoices in invoices_acc_dim.values():
for _invoices in split_invoices(invoices):
merge_log = frappe.new_doc("POS Invoice Merge Log")
merge_log.posting_date = (
getdate(closing_entry.get("posting_date")) if closing_entry else nowdate()
)
merge_log.posting_time = (
get_time(closing_entry.get("posting_time")) if closing_entry else nowtime()
)
merge_log.customer = customer
merge_log.pos_closing_entry = closing_entry.get("name") if closing_entry else None
merge_log.set("pos_invoices", _invoices)
merge_log.save(ignore_permissions=True)
merge_log.submit()
if closing_entry:
closing_entry.set_status(update=True, status="Submitted")
closing_entry.db_set("error_message", "")
@@ -633,26 +675,3 @@ def get_error_message(message) -> str:
return message["message"]
except Exception:
return str(message)
def get_sales_invoice_item(return_against_pos_invoice, pos_invoice_item):
try:
SalesInvoice = DocType("Sales Invoice")
SalesInvoiceItem = DocType("Sales Invoice Item")
query = (
frappe.qb.from_(SalesInvoice)
.from_(SalesInvoiceItem)
.select(SalesInvoiceItem.name)
.where(
(SalesInvoice.name == SalesInvoiceItem.parent)
& (SalesInvoice.is_return == 0)
& (SalesInvoiceItem.pos_invoice == return_against_pos_invoice)
& (SalesInvoiceItem.pos_invoice_item == pos_invoice_item)
)
)
result = query.run(as_dict=True)
return result[0].name if result else None
except Exception:
return None

View File

@@ -472,3 +472,58 @@ class TestPOSInvoiceMergeLog(IntegrationTestCase):
frappe.set_user("Administrator")
frappe.db.sql("delete from `tabPOS Profile`")
frappe.db.sql("delete from `tabPOS Invoice`")
def test_separate_consolidated_invoice_for_different_accounting_dimensions(self):
"""
Creating 3 POS Invoices where first POS Invoice has different Cost Center than the other two.
Consolidate the Invoices.
Check whether the first POS Invoice is consolidated with a separate Sales Invoice than the other two.
Check whether the second and third POS Invoice are consolidated with the same Sales Invoice.
"""
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
frappe.db.sql("delete from `tabPOS Invoice`")
create_cost_center(cost_center_name="_Test POS Cost Center 1", is_group=0)
create_cost_center(cost_center_name="_Test POS Cost Center 2", is_group=0)
try:
test_user, pos_profile = init_user_and_profile()
pos_inv = create_pos_invoice(rate=300, do_not_submit=1)
pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 300})
pos_inv.cost_center = "_Test POS Cost Center 1 - _TC"
pos_inv.save()
pos_inv.submit()
pos_inv2 = create_pos_invoice(rate=3200, do_not_submit=1)
pos_inv2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3200})
pos_inv.cost_center = "_Test POS Cost Center 2 - _TC"
pos_inv2.save()
pos_inv2.submit()
pos_inv3 = create_pos_invoice(rate=2300, do_not_submit=1)
pos_inv3.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 2300})
pos_inv.cost_center = "_Test POS Cost Center 2 - _TC"
pos_inv3.save()
pos_inv3.submit()
consolidate_pos_invoices()
pos_inv.load_from_db()
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv.consolidated_invoice))
pos_inv2.load_from_db()
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv2.consolidated_invoice))
self.assertFalse(pos_inv.consolidated_invoice == pos_inv3.consolidated_invoice)
pos_inv3.load_from_db()
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice))
self.assertTrue(pos_inv2.consolidated_invoice == pos_inv3.consolidated_invoice)
finally:
frappe.set_user("Administrator")
frappe.db.sql("delete from `tabPOS Profile`")
frappe.db.sql("delete from `tabPOS Invoice`")

View File

@@ -417,6 +417,7 @@
"options": "Project"
}
],
"grid_page_length": 50,
"icon": "icon-cog",
"idx": 1,
"index_web_pages_for_search": 1,

View File

@@ -12,7 +12,7 @@
"posting_date",
"company",
"account",
"group_by",
"categorize_by",
"cost_center",
"territory",
"ignore_exchange_rate_revaluation_journals",
@@ -174,14 +174,6 @@
"fieldtype": "Date",
"label": "Start Date"
},
{
"default": "Group by Voucher (Consolidated)",
"depends_on": "eval:(doc.report == 'General Ledger');",
"fieldname": "group_by",
"fieldtype": "Select",
"label": "Group By",
"options": "\nGroup by Voucher\nGroup by Voucher (Consolidated)"
},
{
"depends_on": "eval: (doc.report == 'General Ledger');",
"fieldname": "currency",
@@ -397,10 +389,18 @@
"fieldname": "show_remarks",
"fieldtype": "Check",
"label": "Show Remarks"
},
{
"default": "Categorize by Voucher (Consolidated)",
"depends_on": "eval:(doc.report == 'General Ledger');",
"fieldname": "categorize_by",
"fieldtype": "Select",
"label": "Categorize By",
"options": "\nCategorize by Voucher\nCategorize by Voucher (Consolidated)"
}
],
"links": [],
"modified": "2024-12-11 12:11:13.543134",
"modified": "2025-04-30 14:43:23.643006",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Statement Of Accounts",
@@ -432,8 +432,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -44,6 +44,7 @@ class ProcessStatementOfAccounts(Document):
ageing_based_on: DF.Literal["Due Date", "Posting Date"]
based_on_payment_terms: DF.Check
body: DF.TextEditor | None
categorize_by: DF.Literal["", "Categorize by Voucher", "Categorize by Voucher (Consolidated)"]
cc_to: DF.TableMultiSelect[ProcessStatementOfAccountsCC]
collection_name: DF.DynamicLink | None
company: DF.Link
@@ -56,7 +57,6 @@ class ProcessStatementOfAccounts(Document):
finance_book: DF.Link | None
frequency: DF.Literal["Weekly", "Monthly", "Quarterly"]
from_date: DF.Date | None
group_by: DF.Literal["", "Group by Voucher", "Group by Voucher (Consolidated)"]
ignore_cr_dr_notes: DF.Check
ignore_exchange_rate_revaluation_journals: DF.Check
include_ageing: DF.Check
@@ -204,7 +204,7 @@ def get_gl_filters(doc, entry, tax_id, presentation_currency):
"party": [entry.customer],
"party_name": [entry.customer_name] if entry.customer_name else None,
"presentation_currency": presentation_currency,
"group_by": doc.group_by,
"categorize_by": doc.categorize_by,
"currency": doc.currency,
"project": [p.project_name for p in doc.project],
"show_opening_entries": 0,

View File

@@ -144,8 +144,10 @@
"contact_mobile",
"contact_email",
"company_shipping_address_section",
"shipping_address",
"dispatch_address",
"dispatch_address_display",
"column_break_126",
"shipping_address",
"shipping_address_display",
"company_billing_address_section",
"billing_address",
@@ -1548,7 +1550,7 @@
{
"fieldname": "company_shipping_address_section",
"fieldtype": "Section Break",
"label": "Company Shipping Address"
"label": "Shipping Address"
},
{
"fieldname": "column_break_126",
@@ -1635,13 +1637,28 @@
"fieldtype": "Data",
"label": "Sender",
"options": "Email"
},
{
"fieldname": "dispatch_address_display",
"fieldtype": "Text Editor",
"label": "Dispatch Address",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "dispatch_address",
"fieldtype": "Link",
"label": "Select Dispatch Address ",
"options": "Address",
"print_hide": 1
}
],
"grid_page_length": 50,
"icon": "fa fa-file-text",
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2025-01-14 11:39:04.564610",
"modified": "2025-04-09 16:49:22.175081",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",
@@ -1696,6 +1713,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"search_fields": "posting_date, supplier, bill_no, base_grand_total, outstanding_amount",
"sender_field": "sender",
"show_name_in_global_search": 1,

View File

@@ -117,6 +117,8 @@ class PurchaseInvoice(BuyingController):
currency: DF.Link | None
disable_rounded_total: DF.Check
discount_amount: DF.Currency
dispatch_address: DF.Link | None
dispatch_address_display: DF.TextEditor | None
due_date: DF.Date | None
from_date: DF.Date | None
grand_total: DF.Currency

View File

@@ -1702,6 +1702,9 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin):
# Configure Buying Settings to allow rate change
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 0)
# Configure Accounts Settings to allow 300% over billing
frappe.db.set_single_value("Accounts Settings", "over_billing_allowance", 300)
# Create PR: rate = 1000, qty = 5
pr = make_purchase_receipt(
item_code="_Test Non Stock Item", rate=1000, posting_date=add_days(nowdate(), -2)
@@ -2773,6 +2776,43 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin):
self.assertEqual(invoice.grand_total, 300)
def test_pr_pi_over_billing(self):
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
make_purchase_invoice as make_purchase_invoice_from_pr,
)
# Configure Buying Settings to allow rate change
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 0)
pr = make_purchase_receipt(qty=10, rate=10)
pi = make_purchase_invoice_from_pr(pr.name)
pi.items[0].rate = 12
# Test 1 - This will fail because over billing is not allowed
self.assertRaises(frappe.ValidationError, pi.submit)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 1)
# Test 2 - This will now submit because over billing allowance is ignored when set_landed_cost_based_on_purchase_invoice_rate is checked
pi.submit()
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
frappe.db.set_single_value("Accounts Settings", "over_billing_allowance", 20)
pi.cancel()
pi = make_purchase_invoice_from_pr(pr.name)
pi.items[0].rate = 12
# Test 3 - This will now submit because over billing is allowed upto 20%
pi.submit()
pi.reload()
pi.cancel()
pi = make_purchase_invoice_from_pr(pr.name)
pi.items[0].rate = 13
# Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail
self.assertRaises(frappe.ValidationError, pi.submit)
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(

View File

@@ -180,6 +180,10 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
}
erpnext.accounts.unreconcile_payment.add_unreconcile_btn(me.frm);
if (this.frm.doc.is_created_using_pos && !this.frm.doc.is_return) {
erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype);
}
}
make_invoice_discounting() {
@@ -1082,6 +1086,18 @@ frappe.ui.form.on("Sales Invoice Timesheet", {
},
});
frappe.ui.form.on("Sales Invoice Payment", {
mode_of_payment: function (frm) {
frappe.call({
doc: frm.doc,
method: "set_account_for_mode_of_payment",
callback: function (r) {
refresh_field("payments");
},
});
},
});
var set_timesheet_detail_rate = function (cdt, cdn, currency, timelog) {
frappe.call({
method: "erpnext.projects.doctype.timesheet.timesheet.get_timesheet_detail_rate",

View File

@@ -29,6 +29,8 @@
"update_billed_amount_in_delivery_note",
"is_debit_note",
"amended_from",
"is_created_using_pos",
"pos_closing_entry",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
@@ -2199,6 +2201,23 @@
"label": "Company Contact Person",
"options": "Contact",
"print_hide": 1
},
{
"default": "0",
"fieldname": "is_created_using_pos",
"fieldtype": "Check",
"hidden": 1,
"label": "Is created using POS",
"print_hide": 1
},
{
"depends_on": "is_created_using_pos",
"fieldname": "pos_closing_entry",
"fieldtype": "Link",
"hidden": 1,
"label": "POS Closing Entry",
"options": "POS Closing Entry",
"print_hide": 1
}
],
"grid_page_length": 50,

View File

@@ -51,6 +51,10 @@ from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amou
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
class PartialPaymentValidationError(frappe.ValidationError):
pass
class SalesInvoice(SellingController):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -133,6 +137,7 @@ class SalesInvoice(SellingController):
inter_company_invoice_reference: DF.Link | None
is_cash_or_non_trade_discount: DF.Check
is_consolidated: DF.Check
is_created_using_pos: DF.Check
is_debit_note: DF.Check
is_discounted: DF.Check
is_internal_customer: DF.Check
@@ -162,6 +167,7 @@ class SalesInvoice(SellingController):
plc_conversion_rate: DF.Float
po_date: DF.Date | None
po_no: DF.Data | None
pos_closing_entry: DF.Link | None
pos_profile: DF.Link | None
posting_date: DF.Date
posting_time: DF.Time | None
@@ -306,6 +312,10 @@ class SalesInvoice(SellingController):
if cint(self.is_pos):
self.validate_pos()
if cint(self.is_created_using_pos):
self.validate_created_using_pos()
self.validate_full_payment()
self.validate_dropship_item()
if cint(self.update_stock):
@@ -528,7 +538,22 @@ class SalesInvoice(SellingController):
)
frappe.throw(msg, title=_("Not Allowed"))
def check_if_created_using_pos_and_pos_closing_entry_generated(self):
if self.doctype == "Sales Invoice" and self.is_created_using_pos and self.pos_closing_entry:
pos_closing_entry_docstatus = frappe.db.get_value(
"POS Closing Entry", self.pos_closing_entry, "docstatus"
)
if pos_closing_entry_docstatus == 1:
frappe.throw(
msg=_("To cancel this Sales Invoice you need to cancel the POS Closing Entry {}.").format(
get_link_to_form("POS Closing Entry", self.pos_closing_entry)
),
title=_("Not Allowed"),
)
def before_cancel(self):
# check if generated via POS and already included in POS Closing Entry
self.check_if_created_using_pos_and_pos_closing_entry_generated()
self.check_if_consolidated_invoice()
super().before_cancel()
@@ -598,6 +623,15 @@ class SalesInvoice(SellingController):
self.delete_auto_created_batches()
if (
self.doctype == "Sales Invoice"
and self.is_pos
and self.is_return
and self.is_created_using_pos
and not self.pos_closing_entry
):
self.cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode()
def update_status_updater_args(self):
if not cint(self.update_stock):
return
@@ -669,6 +703,15 @@ class SalesInvoice(SellingController):
timesheet.flags.ignore_validate_update_after_submit = True
timesheet.db_update_all()
def cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode(self):
pos_invoices = frappe.get_all(
"POS Invoice", filters={"consolidated_invoice": self.name}, pluck="name"
)
if pos_invoices:
for pos_invoice in pos_invoices:
pos_invoice_doc = frappe.get_doc("POS Invoice", pos_invoice)
pos_invoice_doc.cancel()
@frappe.whitelist()
def set_missing_values(self, for_validate=False):
pos = self.set_pos_fields(for_validate)
@@ -704,6 +747,13 @@ class SalesInvoice(SellingController):
"allow_print_before_pay": pos.get("allow_print_before_pay"),
}
@frappe.whitelist()
def reset_mode_of_payments(self):
if self.pos_profile:
pos_profile = frappe.get_cached_doc("POS Profile", self.pos_profile)
update_multi_mode_option(self, pos_profile)
self.paid_amount = 0
def update_time_sheet(self, sales_invoice):
for d in self.timesheets:
if d.time_sheet:
@@ -754,10 +804,10 @@ class SalesInvoice(SellingController):
self.paid_amount = paid_amount
self.base_paid_amount = base_paid_amount
@frappe.whitelist()
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")
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:
@@ -1025,6 +1075,32 @@ class SalesInvoice(SellingController):
) > 1.0 / (10.0 ** (self.precision("grand_total") + 1.0)):
frappe.throw(_("Paid amount + Write Off Amount can not be greater than Grand Total"))
def validate_created_using_pos(self):
if self.is_created_using_pos and not self.pos_profile:
frappe.throw(_("POS Profile is mandatory to mark this invoice as POS Transaction."))
self.is_pos_using_sales_invoice = frappe.db.get_single_value(
"Accounts Settings", "use_sales_invoice_in_pos"
)
if not self.is_pos_using_sales_invoice and not self.is_return:
frappe.throw(_("Transactions using Sales Invoice in POS are disabled."))
def validate_full_payment(self):
invoice_total = flt(self.rounded_total) or flt(self.grand_total)
if self.docstatus == 1:
if self.is_return and self.paid_amount != invoice_total:
frappe.throw(
msg=_("Partial Payment in POS Transactions are not allowed."),
exc=PartialPaymentValidationError,
)
if self.paid_amount < invoice_total:
frappe.throw(
msg=_("Partial Payment in POS Transactions are not allowed."),
exc=PartialPaymentValidationError,
)
def validate_warehouse(self):
super().validate_warehouse()
@@ -1345,7 +1421,7 @@ class SalesInvoice(SellingController):
)
for item in self.get("items"):
if flt(item.base_net_amount, item.precision("base_net_amount")):
if flt(item.base_net_amount, item.precision("base_net_amount")) or item.is_fixed_asset:
# Do not book income for transfer within same company
if self.is_internal_transfer():
continue
@@ -2295,7 +2371,10 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
# Invert Addresses
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
update_address(
target_doc, "shipping_address", "shipping_address_display", source_doc.customer_address
target_doc, "dispatch_address", "dispatch_address_display", source_doc.dispatch_address_name
)
update_address(
target_doc, "shipping_address", "shipping_address_display", source_doc.shipping_address_name
)
update_address(
target_doc, "billing_address", "billing_address_display", source_doc.customer_address

View File

@@ -55,20 +55,6 @@
],
"plc_conversion_rate": 1.0,
"price_list_currency": "INR",
"sales_team": [
{
"allocated_percentage": 65.5,
"doctype": "Sales Team",
"parentfield": "sales_team",
"sales_person": "_Test Sales Person 1"
},
{
"allocated_percentage": 34.5,
"doctype": "Sales Team",
"parentfield": "sales_team",
"sales_person": "_Test Sales Person 2"
}
],
"selling_price_list": "_Test Price List",
"territory": "_Test Territory"
},

View File

@@ -12,6 +12,9 @@ from frappe.utils import add_days, flt, format_date, getdate, nowdate, today
import erpnext
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
from erpnext.accounts.doctype.mode_of_payment.test_mode_of_payment import (
set_default_account_for_mode_of_payment,
)
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import WarehouseMissingError
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
@@ -45,6 +48,7 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import
)
from erpnext.stock.get_item_details import get_item_tax_map
from erpnext.stock.utils import get_incoming_rate, get_stock_balance
from erpnext.tests.utils import ERPNextTestSuite
class UnitTestSalesInvoice(UnitTestCase):
@@ -56,13 +60,18 @@ class UnitTestSalesInvoice(UnitTestCase):
pass
class TestSalesInvoice(IntegrationTestCase):
class TestSalesInvoice(ERPNextTestSuite):
def setUp(self):
from erpnext.stock.doctype.stock_ledger_entry.test_stock_ledger_entry import create_items
create_items(["_Test Internal Transfer Item"], uoms=[{"uom": "Box", "conversion_factor": 10}])
create_internal_parties()
setup_accounts()
mode_of_payment = frappe.get_doc("Mode of Payment", "Bank Draft")
set_default_account_for_mode_of_payment(mode_of_payment, "_Test Company", "_Test Bank - _TC")
set_default_account_for_mode_of_payment(
mode_of_payment, "_Test Company with perpetual inventory", "_Test Bank - TCP1"
)
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None)
def tearDown(self):
@@ -79,6 +88,8 @@ class TestSalesInvoice(IntegrationTestCase):
def setUpClass(cls):
super().setUpClass()
cls.enterClassContext(cls.change_settings("Selling Settings", validate_selling_price=0))
cls.make_employees()
cls.make_sales_person()
unlink_payment_on_cancel_of_invoice()
@classmethod
@@ -982,10 +993,8 @@ class TestSalesInvoice(IntegrationTestCase):
pos.is_pos = 1
pos.update_stock = 1
pos.append(
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - TCP1", "amount": 50}
)
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - TCP1", "amount": 50})
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50})
pos.append("payments", {"mode_of_payment": "Cash", "amount": 50})
taxes = get_taxes_and_charges()
pos.taxes = []
@@ -1014,10 +1023,8 @@ class TestSalesInvoice(IntegrationTestCase):
pos.is_pos = 1
pos.pos_profile = pos_profile.name
pos.append(
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 500}
)
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 500})
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 500})
pos.append("payments", {"mode_of_payment": "Cash", "amount": 500})
pos.insert()
pos.submit()
@@ -1060,10 +1067,8 @@ class TestSalesInvoice(IntegrationTestCase):
pos.is_pos = 1
pos.update_stock = 1
pos.append(
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - TCP1", "amount": 50}
)
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - TCP1", "amount": 60})
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50})
pos.append("payments", {"mode_of_payment": "Cash", "amount": 60})
pos.write_off_outstanding_amount_automatically = 1
pos.insert()
@@ -1103,10 +1108,8 @@ class TestSalesInvoice(IntegrationTestCase):
pos.is_pos = 1
pos.update_stock = 1
pos.append(
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - TCP1", "amount": 50}
)
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - TCP1", "amount": 40})
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50})
pos.append("payments", {"mode_of_payment": "Cash", "amount": 40})
pos.write_off_outstanding_amount_automatically = 1
pos.insert()
@@ -1120,7 +1123,7 @@ class TestSalesInvoice(IntegrationTestCase):
pos = create_sales_invoice(do_not_save=True)
pos.is_pos = 1
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 100})
pos.append("payments", {"mode_of_payment": "Cash", "amount": 100})
pos.save().submit()
self.assertEqual(pos.outstanding_amount, 0.0)
self.assertEqual(pos.status, "Paid")
@@ -1191,10 +1194,8 @@ class TestSalesInvoice(IntegrationTestCase):
for tax in taxes:
pos.append("taxes", tax)
pos.append(
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - TCP1", "amount": 50}
)
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - TCP1", "amount": 60})
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 50})
pos.append("payments", {"mode_of_payment": "Cash", "amount": 60})
pos.insert()
pos.submit()
@@ -2229,13 +2230,13 @@ class TestSalesInvoice(IntegrationTestCase):
self.assertEqual(expected_account_values[1], gle.credit)
def test_rounding_adjustment_3(self):
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
disable_dimension,
)
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import create_dimension
# Dimension creates custom field, which does an implicit DB commit as it is a DDL command
# Ensure dimension don't have any mandatory fields
create_dimension()
# rollback from tearDown() happens till here
si = create_sales_invoice(do_not_save=True)
si.items = []
for d in [(1122, 2), (1122.01, 1), (1122.01, 1)]:
@@ -2316,8 +2317,6 @@ class TestSalesInvoice(IntegrationTestCase):
self.assertEqual(round_off_gle.cost_center, "_Test Cost Center 2 - _TC")
self.assertEqual(round_off_gle.location, "Block 1")
disable_dimension()
def test_sales_invoice_with_shipping_rule(self):
from erpnext.accounts.doctype.shipping_rule.test_shipping_rule import create_shipping_rule
@@ -3963,10 +3962,8 @@ class TestSalesInvoice(IntegrationTestCase):
pos = create_sales_invoice(qty=10, do_not_save=True)
pos.is_pos = 1
pos.pos_profile = pos_profile.name
pos.append(
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 500}
)
pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 500})
pos.append("payments", {"mode_of_payment": "Bank Draft", "amount": 500})
pos.append("payments", {"mode_of_payment": "Cash", "amount": 500})
pos.save().submit()
pos_return = make_sales_return(pos.name)
@@ -4337,7 +4334,7 @@ class TestSalesInvoice(IntegrationTestCase):
pos.is_pos = 1
pos.pos_profile = pos_profile.name
pos.debit_to = "_Test Receivable USD - _TC"
pos.append("payments", {"mode_of_payment": "Cash", "account": "_Test Bank - _TC", "amount": 20.35})
pos.append("payments", {"mode_of_payment": "Cash", "amount": 20.35})
pos.save().submit()
pos_return = make_sales_return(pos.name)
@@ -4386,6 +4383,27 @@ class TestSalesInvoice(IntegrationTestCase):
self.assertRaises(StockOverReturnError, return_doc.save)
def test_pos_sales_invoice_creation_during_pos_invoice_mode(self):
# Deleting all opening entry
frappe.db.sql("delete from `tabPOS Opening Entry`")
with self.change_settings("Accounts Settings", {"use_sales_invoice_in_pos": 0}):
pos_profile = make_pos_profile()
pos_profile.payments = []
pos_profile.append("payments", {"default": 1, "mode_of_payment": "Cash"})
pos_profile.save()
pos = create_sales_invoice(qty=10, do_not_save=True)
pos.is_pos = 1
pos.pos_profile = pos_profile.name
pos.is_created_using_pos = 1
pos.append("payments", {"mode_of_payment": "Cash", "amount": 1000})
self.assertRaises(frappe.ValidationError, pos.insert)
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(

View File

@@ -0,0 +1,85 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-03-19 15:01:28.834774",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"sales_invoice",
"posting_date",
"column_break_fear",
"customer",
"grand_total",
"is_return",
"return_against"
],
"fields": [
{
"fieldname": "sales_invoice",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Sales Invoice",
"options": "Sales Invoice",
"reqd": 1
},
{
"fieldname": "posting_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Date",
"reqd": 1
},
{
"fieldname": "column_break_fear",
"fieldtype": "Column Break"
},
{
"fetch_from": "sales_invoice.customer",
"fieldname": "customer",
"fieldtype": "Link",
"label": "Customer",
"options": "Customer",
"read_only": 1,
"reqd": 1
},
{
"default": "0",
"fetch_from": "sales_invoice.is_return",
"fieldname": "is_return",
"fieldtype": "Check",
"label": "Is Return",
"read_only": 1
},
{
"fetch_from": "sales_invoice.return_against",
"fieldname": "return_against",
"fieldtype": "Link",
"label": "Return Against",
"options": "Sales Invoice",
"read_only": 1
},
{
"fetch_from": "sales_invoice.grand_total",
"fieldname": "grand_total",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Amount",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-03-20 01:14:57.890299",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Reference",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,28 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SalesInvoiceReference(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
customer: DF.Link
grand_total: DF.Currency
is_return: DF.Check
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
posting_date: DF.Date
return_against: DF.Link | None
sales_invoice: DF.Link
# end: auto-generated types
pass

View File

@@ -71,6 +71,7 @@ def get_party_details(
party_address=None,
company_address=None,
shipping_address=None,
dispatch_address=None,
pos_profile=None,
):
if not party:
@@ -92,6 +93,7 @@ def get_party_details(
party_address,
company_address,
shipping_address,
dispatch_address,
pos_profile,
)
@@ -111,6 +113,7 @@ def _get_party_details(
party_address=None,
company_address=None,
shipping_address=None,
dispatch_address=None,
pos_profile=None,
):
party_details = frappe._dict(
@@ -134,6 +137,7 @@ def _get_party_details(
party_address,
company_address,
shipping_address,
dispatch_address,
ignore_permissions=ignore_permissions,
)
set_contact_details(party_details, party, party_type)
@@ -191,34 +195,51 @@ def set_address_details(
party_address=None,
company_address=None,
shipping_address=None,
dispatch_address=None,
*,
ignore_permissions=False,
):
billing_address_field = (
# party_billing
party_billing_field = (
"customer_address" if party_type in ["Lead", "Prospect"] else party_type.lower() + "_address"
)
party_details[billing_address_field] = party_address or get_default_address(party_type, party.name)
party_details[party_billing_field] = party_address or get_default_address(party_type, party.name)
if doctype:
party_details.update(
get_fetch_values(doctype, billing_address_field, party_details[billing_address_field])
get_fetch_values(doctype, party_billing_field, party_details[party_billing_field])
)
# address display
party_details.address_display = render_address(
party_details[billing_address_field], check_permissions=not ignore_permissions
)
# shipping address
if party_type in ["Customer", "Lead"]:
party_details.shipping_address_name = shipping_address or get_party_shipping_address(
party_type, party.name
)
party_details.shipping_address = render_address(
party_details["shipping_address_name"], check_permissions=not ignore_permissions
)
if doctype:
party_details.update(
get_fetch_values(doctype, "shipping_address_name", party_details.shipping_address_name)
)
party_details.address_display = render_address(
party_details[party_billing_field], check_permissions=not ignore_permissions
)
# party_shipping
if party_type in ["Customer", "Lead"]:
party_shipping_field = "shipping_address_name"
party_shipping_display = "shipping_address"
default_shipping = shipping_address
else:
# Supplier
party_shipping_field = "dispatch_address"
party_shipping_display = "dispatch_address_display"
default_shipping = dispatch_address
party_details[party_shipping_field] = default_shipping or get_party_shipping_address(
party_type, party.name
)
party_details[party_shipping_display] = render_address(
party_details[party_shipping_field], check_permissions=not ignore_permissions
)
if doctype:
party_details.update(
get_fetch_values(doctype, party_shipping_field, party_details[party_shipping_field])
)
# company_address
if company_address:
party_details.company_address = company_address
else:
@@ -256,22 +277,20 @@ def set_address_details(
**get_fetch_values(doctype, "shipping_address", party_details.billing_address),
)
party_address, shipping_address = (
party_details.get(billing_address_field),
party_details.shipping_address_name,
party_billing, party_shipping = (
party_details.get(party_billing_field),
party_details.get(party_shipping_field),
)
party_details["tax_category"] = get_address_tax_category(
party.get("tax_category"),
party_address,
shipping_address if party_type != "Supplier" else party_address,
party.get("tax_category"), party_billing, party_shipping
)
if doctype in TRANSACTION_TYPES:
with temporary_flag("company", company):
get_regional_address_details(party_details, doctype, company)
return party_address, shipping_address
return party_billing, party_shipping
@erpnext.allow_regional
@@ -651,10 +670,10 @@ def validate_due_date(posting_date, due_date, bill_date=None, template_name=None
frappe.throw(_("Due Date cannot be before {0}").format(doctype_date))
else:
validate_due_date_with_template(posting_date, due_date, bill_date, template_name)
validate_due_date_with_template(posting_date, due_date, bill_date, template_name, doctype)
def validate_due_date_with_template(posting_date, due_date, bill_date, template_name):
def validate_due_date_with_template(posting_date, due_date, bill_date, template_name, doctype=None):
if not template_name:
return
@@ -664,13 +683,12 @@ def validate_due_date_with_template(posting_date, due_date, bill_date, template_
return
if default_due_date != posting_date and getdate(due_date) > getdate(default_due_date):
is_credit_controller = (
frappe.db.get_single_value("Accounts Settings", "credit_controller") in frappe.get_roles()
)
if is_credit_controller:
if frappe.db.get_single_value("Accounts Settings", "credit_controller") in frappe.get_roles():
party_type = "supplier" if doctype == "Purchase Invoice" else "customer"
msgprint(
_("Note: Due Date exceeds allowed customer credit days by {0} day(s)").format(
date_diff(due_date, default_due_date)
_("Note: Due Date exceeds allowed {0} credit days by {1} day(s)").format(
party_type, date_diff(due_date, default_due_date)
)
)
else:
@@ -921,12 +939,16 @@ def get_party_shipping_address(doctype: str, name: str) -> str | None:
["is_shipping_address", "=", 1],
["address_type", "=", "Shipping"],
],
pluck="name",
limit=1,
fields=["name", "is_shipping_address"],
order_by="is_shipping_address DESC",
)
return shipping_addresses[0] if shipping_addresses else None
if shipping_addresses and shipping_addresses[0].is_shipping_address == 1:
return shipping_addresses[0].name
if len(shipping_addresses) == 1:
return shipping_addresses[0].name
else:
return None
def get_partywise_advanced_payment_amount(

View File

@@ -54,6 +54,10 @@ class ReceivablePayableReport:
self.filters.range = "30, 60, 90, 120"
self.ranges = [num.strip() for num in self.filters.range.split(",") if num.strip().isdigit()]
self.range_numbers = [num for num in range(1, len(self.ranges) + 2)]
self.ple_fetch_method = (
frappe.db.get_single_value("Accounts Settings", "receivable_payable_fetch_method")
or "Buffered Cursor"
) # Fail Safe
def run(self, args):
self.filters.update(args)
@@ -90,10 +94,7 @@ class ReceivablePayableReport:
self.skip_total_row = 1
def get_data(self):
self.get_ple_entries()
self.get_sales_invoices_or_customers_based_on_sales_person()
self.voucher_balance = OrderedDict()
self.init_voucher_balance() # invoiced, paid, credit_note, outstanding
# Build delivery note map against all sales invoices
self.build_delivery_note_map()
@@ -110,12 +111,40 @@ class ReceivablePayableReport:
# Get Exchange Rate Revaluations
self.get_exchange_rate_revaluations()
self.prepare_ple_query()
self.data = []
self.voucher_balance = OrderedDict()
if self.ple_fetch_method == "Buffered Cursor":
self.fetch_ple_in_buffered_cursor()
elif self.ple_fetch_method == "UnBuffered Cursor":
self.fetch_ple_in_unbuffered_cursor()
self.build_data()
def fetch_ple_in_buffered_cursor(self):
self.ple_entries = frappe.db.sql(self.ple_query.get_sql(), as_dict=True)
for ple in self.ple_entries:
self.init_voucher_balance(ple) # invoiced, paid, credit_note, outstanding
# This is unavoidable. Initialization and allocation cannot happen in same loop
for ple in self.ple_entries:
self.update_voucher_balance(ple)
self.build_data()
delattr(self, "ple_entries")
def fetch_ple_in_unbuffered_cursor(self):
self.ple_entries = []
with frappe.db.unbuffered_cursor():
for ple in frappe.db.sql(self.ple_query.get_sql(), as_dict=True, as_iterator=True):
self.init_voucher_balance(ple) # invoiced, paid, credit_note, outstanding
self.ple_entries.append(ple)
# This is unavoidable. Initialization and allocation cannot happen in same loop
for ple in self.ple_entries:
self.update_voucher_balance(ple)
delattr(self, "ple_entries")
def build_voucher_dict(self, ple):
return frappe._dict(
@@ -136,26 +165,22 @@ class ReceivablePayableReport:
outstanding_in_account_currency=0.0,
)
def init_voucher_balance(self):
# build all keys, since we want to exclude vouchers beyond the report date
for ple in self.ple_entries:
# get the balance object for voucher_type
def init_voucher_balance(self, ple):
if self.filters.get("ignore_accounts"):
key = (ple.voucher_type, ple.voucher_no, ple.party)
else:
key = (ple.account, ple.voucher_type, ple.voucher_no, ple.party)
if self.filters.get("ignore_accounts"):
key = (ple.voucher_type, ple.voucher_no, ple.party)
else:
key = (ple.account, ple.voucher_type, ple.voucher_no, ple.party)
if key not in self.voucher_balance:
self.voucher_balance[key] = self.build_voucher_dict(ple)
if key not in self.voucher_balance:
self.voucher_balance[key] = self.build_voucher_dict(ple)
if ple.voucher_type == ple.against_voucher_type and ple.voucher_no == ple.against_voucher_no:
self.voucher_balance[key].cost_center = ple.cost_center
if ple.voucher_type == ple.against_voucher_type and ple.voucher_no == ple.against_voucher_no:
self.voucher_balance[key].cost_center = ple.cost_center
self.get_invoices(ple)
self.get_invoices(ple)
if self.filters.get("group_by_party"):
self.init_subtotal_row(ple.party)
if self.filters.get("group_by_party"):
self.init_subtotal_row(ple.party)
if self.filters.get("group_by_party") and not self.filters.get("in_party_currency"):
self.init_subtotal_row("Total")
@@ -778,7 +803,7 @@ class ReceivablePayableReport:
)
row["range" + str(index + 1)] = row.outstanding
def get_ple_entries(self):
def prepare_ple_query(self):
# get all the GL entries filtered by the given filters
self.prepare_conditions()
@@ -831,7 +856,7 @@ class ReceivablePayableReport:
else:
query = query.orderby(self.ple.posting_date, self.ple.party)
self.ple_entries = query.run(as_dict=True)
self.ple_query = query
def get_sales_invoices_or_customers_based_on_sales_person(self):
if self.filters.get("sales_person"):

View File

@@ -144,10 +144,10 @@ class PartyLedgerSummaryReport:
if self.party_naming_by == "Naming Series":
columns.append(
{
"label": _(self.filters.party_type + "Name"),
"label": _(self.filters.party_type + " Name"),
"fieldtype": "Data",
"fieldname": "party_name",
"width": 110,
"width": 150,
}
)
@@ -252,12 +252,13 @@ class PartyLedgerSummaryReport:
self.party_data = frappe._dict({})
for gle in self.gl_entries:
party_details = self.party_details.get(gle.party)
party_name = party_details.get(f"{scrub(self.filters.party_type)}_name", "")
self.party_data.setdefault(
gle.party,
frappe._dict(
{
**party_details,
"party_name": gle.party,
"party_name": party_name,
"opening_balance": 0,
"invoiced_amount": 0,
"paid_amount": 0,

View File

@@ -49,7 +49,7 @@ frappe.query_reports["General Ledger"] = {
label: __("Voucher No"),
fieldtype: "Data",
on_change: function () {
frappe.query_report.set_filter_value("group_by", "Group by Voucher (Consolidated)");
frappe.query_report.set_filter_value("categorize_by", "Categorize by Voucher (Consolidated)");
},
},
{
@@ -112,29 +112,29 @@ frappe.query_reports["General Ledger"] = {
hidden: 1,
},
{
fieldname: "group_by",
label: __("Group by"),
fieldname: "categorize_by",
label: __("Categorize by"),
fieldtype: "Select",
options: [
"",
{
label: __("Group by Voucher"),
value: "Group by Voucher",
label: __("Categorize by Voucher"),
value: "Categorize by Voucher",
},
{
label: __("Group by Voucher (Consolidated)"),
value: "Group by Voucher (Consolidated)",
label: __("Categorize by Voucher (Consolidated)"),
value: "Categorize by Voucher (Consolidated)",
},
{
label: __("Group by Account"),
value: "Group by Account",
label: __("Categorize by Account"),
value: "Categorize by Account",
},
{
label: __("Group by Party"),
value: "Group by Party",
label: __("Categorize by Party"),
value: "Categorize by Party",
},
],
default: "Group by Voucher (Consolidated)",
default: "Categorize by Voucher (Consolidated)",
},
{
fieldname: "tax_id",
@@ -218,6 +218,8 @@ frappe.query_reports["General Ledger"] = {
fieldtype: "Check",
},
],
collapsible_filters: true,
seperate_check_filters: true,
};
erpnext.utils.add_dimensions("General Ledger", 15);

View File

@@ -69,13 +69,17 @@ def validate_filters(filters, account_details):
if not account_details.get(account):
frappe.throw(_("Account {0} does not exists").format(account))
if filters.get("account") and filters.get("group_by") == "Group by Account":
if not filters.get("categorize_by") and filters.get("group_by"):
filters["categorize_by"] = filters["group_by"]
filters["categorize_by"] = filters["categorize_by"].replace("Group by", "Categorize by")
if filters.get("account") and filters.get("categorize_by") == "Categorize by Account":
filters.account = frappe.parse_json(filters.get("account"))
for account in filters.account:
if account_details[account].is_group == 0:
frappe.throw(_("Can not filter based on Child Account, if grouped by Account"))
if filters.get("voucher_no") and filters.get("group_by") in ["Group by Voucher"]:
if filters.get("voucher_no") and filters.get("categorize_by") in ["Categorize by Voucher"]:
frappe.throw(_("Can not filter based on Voucher No, if grouped by Voucher"))
if filters.from_date > filters.to_date:
@@ -169,9 +173,9 @@ def get_gl_entries(filters, accounting_dimensions):
if filters.get("include_dimensions"):
order_by_statement = "order by posting_date, creation"
if filters.get("group_by") == "Group by Voucher":
if filters.get("categorize_by") == "Categorize by Voucher":
order_by_statement = "order by posting_date, voucher_type, voucher_no"
if filters.get("group_by") == "Group by Account":
if filters.get("categorize_by") == "Categorize by Account":
order_by_statement = "order by account, posting_date, creation"
if filters.get("include_default_book_entries"):
@@ -266,7 +270,7 @@ def get_conditions(filters):
if filters.get("voucher_no_not_in"):
conditions.append("voucher_no not in %(voucher_no_not_in)s")
if filters.get("group_by") == "Group by Party" and not filters.get("party_type"):
if filters.get("categorize_by") == "Categorize by Party" and not filters.get("party_type"):
conditions.append("party_type in ('Customer', 'Supplier')")
if filters.get("party_type"):
@@ -278,7 +282,7 @@ def get_conditions(filters):
if not (
filters.get("account")
or filters.get("party")
or filters.get("group_by") in ["Group by Account", "Group by Party"]
or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"]
):
if not ignore_is_opening:
conditions.append("(posting_date >=%(from_date)s or is_opening = 'Yes')")
@@ -397,11 +401,11 @@ def get_data_with_opening_closing(filters, account_details, accounting_dimension
# Opening for filtered account
add_total_to_data(totals, "opening")
if filters.get("group_by") != "Group by Voucher (Consolidated)":
set_opening_closing = (not filters.get("group_by") and not filters.get("voucher_no")) or (
filters.get("group_by") and filters.get("group_by") != "Group by Voucher"
if filters.get("categorize_by") != "Categorize by Voucher (Consolidated)":
set_opening_closing = (not filters.get("categorize_by") and not filters.get("voucher_no")) or (
filters.get("categorize_by") and filters.get("categorize_by") != "Categorize by Voucher"
)
set_total = filters.get("group_by") or not filters.voucher_no
set_total = filters.get("categorize_by") or not filters.voucher_no
for acc_dict in gle_map.values():
if not acc_dict.entries:
@@ -444,9 +448,9 @@ def get_totals_dict():
def get_group_by_field(group_by):
if group_by == "Group by Party":
if group_by == "Categorize by Party":
return "party"
elif group_by in ["Group by Voucher (Consolidated)", "Group by Account"]:
elif group_by in ["Categorize by Voucher (Consolidated)", "Categorize by Account"]:
return "account"
else:
return "voucher_no"
@@ -454,7 +458,7 @@ def get_group_by_field(group_by):
def initialize_gle_map(gl_entries, filters):
gle_map = {}
group_by = get_group_by_field(filters.get("group_by"))
group_by = get_group_by_field(filters.get("categorize_by"))
for gle in gl_entries:
group_by_value = gle.get(group_by)
@@ -470,8 +474,8 @@ def initialize_gle_map(gl_entries, filters):
def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map):
entries = []
consolidated_gle = {}
group_by = get_group_by_field(filters.get("group_by"))
group_by_voucher_consolidated = filters.get("group_by") == "Group by Voucher (Consolidated)"
group_by = get_group_by_field(filters.get("categorize_by"))
group_by_voucher_consolidated = filters.get("categorize_by") == "Categorize by Voucher (Consolidated)"
if filters.get("show_net_values_in_party_account"):
account_type_map = get_account_type_map(filters.get("company"))

View File

@@ -155,7 +155,7 @@ class TestGeneralLedger(IntegrationTestCase):
"from_date": today(),
"to_date": today(),
"account": [account.name],
"group_by": "Group by Voucher (Consolidated)",
"categorize_by": "Categorize by Voucher (Consolidated)",
}
)
)
@@ -246,7 +246,7 @@ class TestGeneralLedger(IntegrationTestCase):
"from_date": today(),
"to_date": today(),
"account": [account.name],
"group_by": "Group by Voucher (Consolidated)",
"categorize_by": "Categorize by Voucher (Consolidated)",
"ignore_err": True,
}
)
@@ -261,7 +261,7 @@ class TestGeneralLedger(IntegrationTestCase):
"from_date": today(),
"to_date": today(),
"account": [account.name],
"group_by": "Group by Voucher (Consolidated)",
"categorize_by": "Categorize by Voucher (Consolidated)",
"ignore_err": False,
}
)
@@ -308,7 +308,7 @@ class TestGeneralLedger(IntegrationTestCase):
"from_date": si.posting_date,
"to_date": si.posting_date,
"account": [si.debit_to],
"group_by": "Group by Voucher (Consolidated)",
"categorize_by": "Categorize by Voucher (Consolidated)",
"ignore_cr_dr_notes": False,
}
)
@@ -325,7 +325,7 @@ class TestGeneralLedger(IntegrationTestCase):
"from_date": si.posting_date,
"to_date": si.posting_date,
"account": [si.debit_to],
"group_by": "Group by Voucher (Consolidated)",
"categorize_by": "Categorize by Voucher (Consolidated)",
"ignore_cr_dr_notes": True,
}
)

View File

@@ -14,8 +14,8 @@ DEFAULT_FILTERS = {
REPORT_FILTER_TEST_CASES: list[tuple[ReportName, ReportFilters]] = [
("General Ledger", {"group_by": "Group by Voucher (Consolidated)"}),
("General Ledger", {"group_by": "Group by Voucher (Consolidated)", "include_dimensions": 1}),
("General Ledger", {"categorize_by": "Categorize by Voucher (Consolidated)"}),
("General Ledger", {"categorize_by": "Categorize by Voucher (Consolidated)", "include_dimensions": 1}),
("Accounts Payable", {"range": "30, 60, 90, 120"}),
("Accounts Receivable", {"range": "30, 60, 90, 120"}),
("Consolidated Financial Statement", {"report": "Balance Sheet"}),

View File

@@ -661,10 +661,6 @@ frappe.ui.form.on("Asset", {
} else {
frm.set_value("purchase_invoice_item", data.purchase_invoice_item);
}
let is_editable = !data.is_multiple_items; // if multiple items, then fields should be read-only
frm.set_df_property("gross_purchase_amount", "read_only", is_editable);
frm.set_df_property("asset_quantity", "read_only", is_editable);
}
},
});

View File

@@ -478,6 +478,7 @@
"fieldname": "total_asset_cost",
"fieldtype": "Currency",
"label": "Total Asset Cost",
"no_copy": 1,
"options": "Company:company:default_currency",
"read_only": 1
},
@@ -486,6 +487,7 @@
"fieldname": "additional_asset_cost",
"fieldtype": "Currency",
"label": "Additional Asset Cost",
"no_copy": 1,
"options": "Company:company:default_currency",
"read_only": 1
},
@@ -589,7 +591,7 @@
"link_fieldname": "target_asset"
}
],
"modified": "2025-04-15 16:33:17.189524",
"modified": "2025-04-24 15:31:47.373274",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset",

View File

@@ -1170,7 +1170,6 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
frappe.throw(_(f"Selected {doctype} does not contain the Item Code {item_code}"))
first_item = matching_items[0]
is_multiple_items = len(matching_items) > 1
return {
"company": purchase_doc.company,
@@ -1179,7 +1178,6 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
"asset_quantity": first_item.qty,
"cost_center": first_item.cost_center or purchase_doc.get("cost_center"),
"asset_location": first_item.get("asset_location"),
"is_multiple_items": is_multiple_items,
"purchase_receipt_item": first_item.name if doctype == "Purchase Receipt" else None,
"purchase_invoice_item": first_item.name if doctype == "Purchase Invoice" else None,
}

View File

@@ -752,16 +752,15 @@ def get_gl_entries_on_asset_disposal(
def get_asset_details(asset, finance_book=None):
fixed_asset_account, accumulated_depr_account, _ = get_depreciation_accounts(
asset.asset_category, asset.company
value_after_depreciation = asset.get_value_after_depreciation(finance_book)
accumulated_depr_amount = flt(asset.gross_purchase_amount) - flt(value_after_depreciation)
fixed_asset_account, accumulated_depr_account, _ = get_asset_accounts(
asset.asset_category, asset.company, accumulated_depr_amount
)
disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(asset.company)
depreciation_cost_center = asset.cost_center or depreciation_cost_center
value_after_depreciation = asset.get_value_after_depreciation(finance_book)
accumulated_depr_amount = flt(asset.gross_purchase_amount) - flt(value_after_depreciation)
return (
fixed_asset_account,
asset,
@@ -773,6 +772,48 @@ def get_asset_details(asset, finance_book=None):
)
def get_asset_accounts(asset_category, company, accumulated_depr_amount):
fixed_asset_account = accumulated_depreciation_account = depreciation_expense_account = None
accounts = frappe.db.get_value(
"Asset Category Account",
filters={"parent": asset_category, "company_name": company},
fieldname=[
"fixed_asset_account",
"accumulated_depreciation_account",
"depreciation_expense_account",
],
as_dict=1,
)
if accounts:
fixed_asset_account = accounts.fixed_asset_account
accumulated_depreciation_account = accounts.accumulated_depreciation_account
depreciation_expense_account = accounts.depreciation_expense_account
if not fixed_asset_account:
frappe.throw(_("Please set Fixed Asset Account in Asset Category {0}").format(asset_category))
if accumulated_depr_amount:
accounts = frappe.get_cached_value(
"Company", company, ["accumulated_depreciation_account", "depreciation_expense_account"]
)
if not accumulated_depreciation_account:
accumulated_depreciation_account = accounts[0]
if not depreciation_expense_account:
depreciation_expense_account = accounts[1]
if not accumulated_depreciation_account or not depreciation_expense_account:
frappe.throw(
_("Please set Depreciation related Accounts in Asset Category {0} or Company {1}").format(
asset_category, company
)
)
return fixed_asset_account, accumulated_depreciation_account, depreciation_expense_account
def get_profit_gl_entries(
asset, profit_amount, gl_entries, disposal_account, depreciation_cost_center, date=None
):

View File

@@ -152,6 +152,9 @@ class AssetMovement(Document):
""",
args,
)
self.validate_movement_cancellation(d, latest_movement_entry)
if latest_movement_entry:
current_location = latest_movement_entry[0][0]
current_employee = latest_movement_entry[0][1]
@@ -179,3 +182,12 @@ class AssetMovement(Document):
d.asset,
_("Asset issued to Employee {0}").format(get_link_to_form("Employee", current_employee)),
)
def validate_movement_cancellation(self, row, latest_movement_entry):
asset_doc = frappe.get_doc("Asset", row.asset)
if not latest_movement_entry and asset_doc.docstatus == 1:
frappe.throw(
_(
"Asset {0} has only one movement record. Please create another movement before deleting this one to maintain asset tracking."
).format(row.asset)
)

View File

@@ -147,6 +147,45 @@ class TestAssetMovement(IntegrationTestCase):
movement1.cancel()
self.assertEqual(frappe.db.get_value("Asset", asset.name, "location"), "Test Location")
def test_last_movement_cancellation_validation(self):
pr = make_purchase_receipt(item_code="Macbook Pro", qty=1, rate=100000.0, location="Test Location")
asset_name = frappe.db.get_value("Asset", {"purchase_receipt": pr.name}, "name")
asset = frappe.get_doc("Asset", asset_name)
asset.calculate_depreciation = 1
asset.available_for_use_date = "2020-06-06"
asset.purchase_date = "2020-06-06"
asset.append(
"finance_books",
{
"expected_value_after_useful_life": 10000,
"next_depreciation_date": "2020-12-31",
"depreciation_method": "Straight Line",
"total_number_of_depreciations": 3,
"frequency_of_depreciation": 10,
},
)
if asset.docstatus == 0:
asset.submit()
AssetMovement = frappe.qb.DocType("Asset Movement")
AssetMovementItem = frappe.qb.DocType("Asset Movement Item")
asset_movement = (
frappe.qb.from_(AssetMovement)
.join(AssetMovementItem)
.on(AssetMovementItem.parent == AssetMovement.name)
.select(AssetMovement.name)
.where(
(AssetMovementItem.asset == asset.name)
& (AssetMovement.company == asset.company)
& (AssetMovement.docstatus == 1)
)
).run(as_dict=True)
asset_movement_doc = frappe.get_doc("Asset Movement", asset_movement[0].name)
self.assertRaises(frappe.ValidationError, asset_movement_doc.cancel)
def create_asset_movement(**args):
args = frappe._dict(args)

View File

@@ -135,9 +135,11 @@ class AssetRepair(AccountsController):
self.increase_asset_value()
total_repair_cost = self.get_total_value_of_stock_consumed()
if self.capitalize_repair_cost:
self.asset_doc.total_asset_cost += self.repair_cost
self.asset_doc.additional_asset_cost += self.repair_cost
total_repair_cost += self.repair_cost
self.asset_doc.total_asset_cost += total_repair_cost
self.asset_doc.additional_asset_cost += total_repair_cost
if self.get("stock_consumption"):
self.check_for_stock_items_and_warehouse()
@@ -176,9 +178,11 @@ class AssetRepair(AccountsController):
self.decrease_asset_value()
total_repair_cost = self.get_total_value_of_stock_consumed()
if self.capitalize_repair_cost:
self.asset_doc.total_asset_cost -= self.repair_cost
self.asset_doc.additional_asset_cost -= self.repair_cost
total_repair_cost += self.repair_cost
self.asset_doc.total_asset_cost -= total_repair_cost
self.asset_doc.additional_asset_cost -= total_repair_cost
if self.get("capitalize_repair_cost"):
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry")

View File

@@ -25,6 +25,9 @@
"disable_last_purchase_rate",
"show_pay_button",
"use_transaction_date_exchange_rate",
"allow_zero_qty_in_request_for_quotation",
"allow_zero_qty_in_supplier_quotation",
"allow_zero_qty_in_purchase_order",
"subcontract",
"backflush_raw_materials_of_subcontract_based_on",
"column_break_11",
@@ -207,14 +210,36 @@
"fieldtype": "Select",
"label": "Update frequency of Project",
"options": "Each Transaction\nManual"
},
{
"default": "0",
"description": "Allows users to submit Purchase Orders with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.",
"fieldname": "allow_zero_qty_in_purchase_order",
"fieldtype": "Check",
"label": "Allow Purchase Order with Zero Quantity"
},
{
"default": "0",
"description": "Allows users to submit Request for Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.",
"fieldname": "allow_zero_qty_in_request_for_quotation",
"fieldtype": "Check",
"label": "Allow Request for Quotation with Zero Quantity"
},
{
"default": "0",
"description": "Allows users to submit Supplier Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.",
"fieldname": "allow_zero_qty_in_supplier_quotation",
"fieldtype": "Check",
"label": "Allow Supplier Quotation with Zero Quantity"
}
],
"grid_page_length": 50,
"icon": "fa fa-cog",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2024-03-27 13:06:43.375495",
"modified": "2025-05-06 15:21:49.639642",
"modified_by": "Administrator",
"module": "Buying",
"name": "Buying Settings",
@@ -260,8 +285,9 @@
"role": "Purchase User"
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -18,6 +18,9 @@ class BuyingSettings(Document):
from frappe.types import DF
allow_multiple_items: DF.Check
allow_zero_qty_in_purchase_order: DF.Check
allow_zero_qty_in_request_for_quotation: DF.Check
allow_zero_qty_in_supplier_quotation: DF.Check
auto_create_purchase_receipt: DF.Check
auto_create_subcontracting_order: DF.Check
backflush_raw_materials_of_subcontract_based_on: DF.Literal[

View File

@@ -0,0 +1,41 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-04-27 12:05:44.989999",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"company",
"customer_number"
],
"fields": [
{
"fieldname": "company",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Company",
"options": "Company"
},
{
"fieldname": "customer_number",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Customer Number"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-04-27 12:06:04.146431",
"modified_by": "Administrator",
"module": "Buying",
"name": "Customer Number At Supplier",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,24 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class CustomerNumberAtSupplier(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
company: DF.Link | None
customer_number: DF.Data | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
# end: auto-generated types
pass

View File

@@ -26,7 +26,15 @@ frappe.ui.form.on("Purchase Order", {
}
frm.set_indicator_formatter("item_code", function (doc) {
return doc.qty <= doc.received_qty ? "green" : "orange";
let color;
if (!doc.qty && frm.doc.has_unit_price_items) {
color = "yellow";
} else if (doc.qty <= doc.received_qty) {
color = "green";
} else {
color = "orange";
}
return color;
});
frm.set_query("expense_account", "items", function () {
@@ -63,6 +71,10 @@ frappe.ui.form.on("Purchase Order", {
}
});
}
if (frm.doc.docstatus == 0) {
erpnext.set_unit_price_items_note(frm);
}
},
supplier: function (frm) {

View File

@@ -24,6 +24,7 @@
"apply_tds",
"tax_withholding_category",
"is_subcontracted",
"has_unit_price_items",
"supplier_warehouse",
"amended_from",
"accounting_dimensions_section",
@@ -109,8 +110,10 @@
"contact_mobile",
"contact_email",
"shipping_address_section",
"shipping_address",
"dispatch_address",
"dispatch_address_display",
"column_break_99",
"shipping_address",
"shipping_address_display",
"company_billing_address_section",
"billing_address",
@@ -1282,13 +1285,36 @@
"oldfieldtype": "Select",
"options": "Not Initiated\nInitiated\nPartially Paid\nFully Paid",
"print_hide": 1
},
{
"default": "0",
"fieldname": "has_unit_price_items",
"fieldtype": "Check",
"hidden": 1,
"label": "Has Unit Price Items",
"no_copy": 1
},
{
"fieldname": "dispatch_address",
"fieldtype": "Link",
"label": "Dispatch Address",
"options": "Address",
"print_hide": 1
},
{
"fieldname": "dispatch_address_display",
"fieldtype": "Text Editor",
"label": "Dispatch Address Details",
"print_hide": 1,
"read_only": 1
}
],
"grid_page_length": 50,
"icon": "fa fa-file-text",
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2024-03-27 13:10:24.518785",
"modified": "2025-04-09 16:54:08.836106",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",
@@ -1335,6 +1361,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"search_fields": "status, transaction_date, supplier, grand_total",
"show_name_in_global_search": 1,
"sort_field": "creation",

View File

@@ -92,9 +92,12 @@ class PurchaseOrder(BuyingController):
customer_name: DF.Data | None
disable_rounded_total: DF.Check
discount_amount: DF.Currency
dispatch_address: DF.Link | None
dispatch_address_display: DF.TextEditor | None
from_date: DF.Date | None
grand_total: DF.Currency
group_same_items: DF.Check
has_unit_price_items: DF.Check
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
@@ -189,6 +192,10 @@ class PurchaseOrder(BuyingController):
self.set_onload("supplier_tds", supplier_tds)
self.set_onload("can_update_items", self.can_update_items())
def before_validate(self):
self.set_has_unit_price_items()
self.flags.allow_zero_qty = self.has_unit_price_items
def validate(self):
super().validate()
@@ -225,6 +232,17 @@ class PurchaseOrder(BuyingController):
)
self.reset_default_field_value("set_warehouse", "items", "warehouse")
def set_has_unit_price_items(self):
"""
If permitted in settings and any item has 0 qty, the PO has unit price items.
"""
if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order"):
return
self.has_unit_price_items = any(
not row.qty for row in self.get("items") if (row.item_code and not row.qty)
)
def validate_with_previous_doc(self):
mri_compare_fields = [["project", "="], ["item_code", "="]]
if self.is_subcontracted:
@@ -729,8 +747,13 @@ def set_missing_values(source, target):
@frappe.whitelist()
def make_purchase_receipt(source_name, target_doc=None):
has_unit_price_items = frappe.db.get_value("Purchase Order", source_name, "has_unit_price_items")
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
def update_item(obj, target, source_parent):
target.qty = flt(obj.qty) - flt(obj.received_qty)
target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
target.base_amount = (
@@ -761,7 +784,9 @@ def make_purchase_receipt(source_name, target_doc=None):
"wip_composite_asset": "wip_composite_asset",
},
"postprocess": update_item,
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty)
"condition": lambda doc: (
True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty)
)
and doc.delivered_by_supplier != 1,
},
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},

View File

@@ -5,7 +5,7 @@
import json
import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings
from frappe.utils import add_days, flt, getdate, nowdate
from frappe.utils.data import today
@@ -61,6 +61,13 @@ class TestPurchaseOrder(IntegrationTestCase):
po.save()
self.assertEqual(po.items[1].qty, 1)
def test_purchase_order_zero_qty(self):
po = create_purchase_order(qty=0, do_not_save=True)
with change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1}):
po.save()
self.assertEqual(po.items[0].qty, 0)
def test_make_purchase_receipt(self):
po = create_purchase_order(do_not_submit=True)
self.assertRaises(frappe.ValidationError, make_purchase_receipt, po.name)
@@ -1248,6 +1255,80 @@ class TestPurchaseOrder(IntegrationTestCase):
po.reload()
self.assertEqual(po.per_billed, 100)
@IntegrationTestCase.change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1})
def test_receive_zero_qty_purchase_order(self):
"""
Test the flow of a Unit Price PO and PR creation against it until completion.
Flow:
PO Qty 0 -> Receive +5 -> Receive +5 -> Update PO Qty +10 -> PO is 100% received
"""
po = create_purchase_order(qty=0)
pr = make_purchase_receipt(po.name)
self.assertEqual(pr.items[0].qty, 0)
pr.items[0].qty = 5
pr.submit()
po.reload()
self.assertEqual(po.items[0].received_qty, 5)
self.assertFalse(po.per_received)
self.assertEqual(po.status, "To Receive and Bill")
# Update PO Item Qty to 10 after receipt of items
first_item_of_po = po.items[0]
trans_item = json.dumps(
[
{
"item_code": first_item_of_po.item_code,
"rate": first_item_of_po.rate,
"qty": 10,
"docname": first_item_of_po.name,
}
]
)
update_child_qty_rate("Purchase Order", trans_item, po.name)
# Test: PR can be made against PO as long PO qty is 0 OR PO qty > received qty
pr2 = make_purchase_receipt(po.name)
po.reload()
self.assertEqual(po.items[0].qty, 10)
self.assertEqual(pr2.items[0].qty, 5)
pr2.submit()
# PO should be updated to 100% received
po.reload()
self.assertEqual(po.items[0].qty, 10)
self.assertEqual(po.items[0].received_qty, 10)
self.assertEqual(po.per_received, 100.0)
self.assertEqual(po.status, "To Bill")
@IntegrationTestCase.change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1})
def test_bill_zero_qty_purchase_order(self):
po = create_purchase_order(qty=0)
self.assertEqual(po.grand_total, 0)
self.assertFalse(po.per_billed)
self.assertEqual(po.items[0].qty, 0)
self.assertEqual(po.items[0].rate, 500)
pi = make_pi_from_po(po.name)
self.assertEqual(pi.items[0].qty, 0)
self.assertEqual(pi.items[0].rate, 500)
pi.items[0].qty = 5
pi.submit()
self.assertEqual(pi.grand_total, 2500)
po.reload()
self.assertEqual(po.items[0].amount, 0)
self.assertEqual(po.items[0].billed_amt, 2500)
# PO still has qty 0, so billed % should be unset
self.assertFalse(po.per_billed)
self.assertEqual(po.status, "To Receive and Bill")
def create_po_for_sc_testing():
from erpnext.controllers.tests.test_subcontracting_controller import (

View File

@@ -28,6 +28,10 @@ frappe.ui.form.on("Request for Quotation", {
is_group: 0,
},
}));
frm.set_indicator_formatter("item_code", function (doc) {
return !doc.qty && frm.doc.has_unit_price_items ? "yellow" : "";
});
},
onload: function (frm) {
@@ -163,6 +167,10 @@ frappe.ui.form.on("Request for Quotation", {
__("View")
);
}
if (frm.doc.docstatus === 0) {
erpnext.set_unit_price_items_note(frm);
}
},
show_supplier_quotation_comparison(frm) {

View File

@@ -16,6 +16,7 @@
"transaction_date",
"schedule_date",
"status",
"has_unit_price_items",
"amended_from",
"suppliers_section",
"suppliers",
@@ -306,13 +307,22 @@
"fieldtype": "Text Editor",
"label": "Billing Address Details",
"read_only": 1
},
{
"default": "0",
"fieldname": "has_unit_price_items",
"fieldtype": "Check",
"hidden": 1,
"label": "Has Unit Price Items",
"no_copy": 1
}
],
"grid_page_length": 50,
"icon": "fa fa-shopping-cart",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2024-03-27 13:10:33.030915",
"modified": "2025-03-03 16:48:39.856779",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation",
@@ -377,6 +387,7 @@
"role": "All"
}
],
"row_format": "Dynamic",
"search_fields": "status, transaction_date",
"show_name_in_global_search": 1,
"sort_field": "creation",

View File

@@ -42,6 +42,7 @@ class RequestforQuotation(BuyingController):
billing_address_display: DF.TextEditor | None
company: DF.Link
email_template: DF.Link | None
has_unit_price_items: DF.Check
incoterm: DF.Link | None
items: DF.Table[RequestforQuotationItem]
letter_head: DF.Link | None
@@ -61,6 +62,10 @@ class RequestforQuotation(BuyingController):
vendor: DF.Link | None
# end: auto-generated types
def before_validate(self):
self.set_has_unit_price_items()
self.flags.allow_zero_qty = self.has_unit_price_items
def validate(self):
self.validate_duplicate_supplier()
self.validate_supplier_list()
@@ -73,6 +78,17 @@ class RequestforQuotation(BuyingController):
# after amend and save, status still shows as cancelled, until submit
self.db_set("status", "Draft")
def set_has_unit_price_items(self):
"""
If permitted in settings and any item has 0 qty, the RFQ has unit price items.
"""
if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_request_for_quotation"):
return
self.has_unit_price_items = any(
not row.qty for row in self.get("items") if (row.item_code and not row.qty)
)
def validate_duplicate_supplier(self):
supplier_list = [d.supplier for d in self.suppliers]
if len(supplier_list) != len(set(supplier_list)):
@@ -440,11 +456,10 @@ def create_supplier_quotation(doc):
def add_items(sq_doc, supplier, items):
for data in items:
if data.get("qty") > 0:
if isinstance(data, dict):
data = frappe._dict(data)
if isinstance(data, dict):
data = frappe._dict(data)
create_rfq_items(sq_doc, supplier, data)
create_rfq_items(sq_doc, supplier, data)
def create_rfq_items(sq_doc, supplier, data):

View File

@@ -5,7 +5,7 @@
from urllib.parse import urlparse
import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings
from frappe.utils import nowdate
from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
@@ -41,6 +41,16 @@ class TestRequestforQuotation(IntegrationTestCase):
rfq.save()
self.assertEqual(rfq.items[0].qty, 1)
def test_rfq_zero_qty(self):
"""
Test if RFQ with zero qty (Unit Price Item) is conditionally allowed.
"""
rfq = make_request_for_quotation(qty=0, do_not_save=True)
with change_settings("Buying Settings", {"allow_zero_qty_in_request_for_quotation": 1}):
rfq.save()
self.assertEqual(rfq.items[0].qty, 0)
def test_quote_status(self):
rfq = make_request_for_quotation()
@@ -181,6 +191,32 @@ class TestRequestforQuotation(IntegrationTestCase):
supplier_doc.reload()
self.assertTrue(supplier_doc.portal_users[0].user)
@IntegrationTestCase.change_settings("Buying Settings", {"allow_zero_qty_in_request_for_quotation": 1})
def test_supplier_quotation_from_zero_qty_rfq(self):
rfq = make_request_for_quotation(qty=0)
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get("suppliers")[0].supplier)
self.assertEqual(len(sq.items), 1)
self.assertEqual(sq.items[0].qty, 0)
self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code)
@IntegrationTestCase.change_settings(
"Buying Settings",
{
"allow_zero_qty_in_request_for_quotation": 1,
"allow_zero_qty_in_supplier_quotation": 1,
},
)
def test_supplier_quotation_from_zero_qty_rfq_in_portal(self):
rfq = make_request_for_quotation(qty=0)
rfq.supplier = rfq.suppliers[0].supplier
sq_name = create_supplier_quotation(rfq)
sq = frappe.get_doc("Supplier Quotation", sq_name)
self.assertEqual(len(sq.items), 1)
self.assertEqual(sq.items[0].qty, 0)
self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code)
def make_request_for_quotation(**args) -> "RequestforQuotation":
"""

View File

@@ -63,6 +63,7 @@
"fieldtype": "Column Break"
},
{
"fetch_from": "item_code.item_name",
"fieldname": "item_name",
"fieldtype": "Data",
"in_global_search": 1,
@@ -261,15 +262,16 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:33.272106",
"modified": "2025-04-28 23:30:22.927989",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Item",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -160,7 +160,7 @@ frappe.ui.form.on("Supplier", {
address_dict: frm.doc.supplier_primary_address,
},
callback: function (r) {
frm.set_value("primary_address", r.message);
frm.set_value("primary_address", frappe.utils.html2text(r.message));
},
});
}

View File

@@ -33,6 +33,7 @@
"column_break_30",
"website",
"language",
"customer_numbers",
"dashboard_tab",
"tax_tab",
"tax_id",
@@ -473,8 +474,15 @@
{
"fieldname": "column_break_mglr",
"fieldtype": "Column Break"
},
{
"fieldname": "customer_numbers",
"fieldtype": "Table",
"label": "Customer Numbers",
"options": "Customer Number At Supplier"
}
],
"grid_page_length": 50,
"icon": "fa fa-user",
"idx": 370,
"image_field": "image",
@@ -485,7 +493,7 @@
"link_fieldname": "party"
}
],
"modified": "2024-05-08 18:02:57.342931",
"modified": "2025-04-27 12:07:10.859758",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier",
@@ -544,6 +552,7 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"search_fields": "supplier_group",
"show_name_in_global_search": 1,
"sort_field": "creation",
@@ -551,4 +560,4 @@
"states": [],
"title_field": "supplier_name",
"track_changes": 1
}
}

View File

@@ -32,6 +32,9 @@ class Supplier(TransactionBase):
AllowedToTransactWith,
)
from erpnext.accounts.doctype.party_account.party_account import PartyAccount
from erpnext.buying.doctype.customer_number_at_supplier.customer_number_at_supplier import (
CustomerNumberAtSupplier,
)
from erpnext.utilities.doctype.portal_user.portal_user import PortalUser
accounts: DF.Table[PartyAccount]
@@ -39,6 +42,7 @@ class Supplier(TransactionBase):
allow_purchase_invoice_creation_without_purchase_receipt: DF.Check
companies: DF.Table[AllowedToTransactWith]
country: DF.Link | None
customer_numbers: DF.Table[CustomerNumberAtSupplier]
default_bank_account: DF.Link | None
default_currency: DF.Link | None
default_price_list: DF.Link | None

View File

@@ -11,6 +11,11 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
Quotation: "Quotation",
};
const me = this;
this.frm.set_indicator_formatter("item_code", function (doc) {
return !doc.qty && me.frm.doc.has_unit_price_items ? "yellow" : "";
});
super.setup();
}
@@ -30,6 +35,8 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
this.frm.page.set_inner_btn_group_as_primary(__("Create"));
this.frm.add_custom_button(__("Quotation"), this.make_quotation.bind(this), __("Create"));
} else if (this.frm.doc.docstatus === 0) {
erpnext.set_unit_price_items_note(this.frm);
this.frm.add_custom_button(
__("Material Request"),
function () {

View File

@@ -19,6 +19,7 @@
"transaction_date",
"valid_till",
"quotation_number",
"has_unit_price_items",
"amended_from",
"accounting_dimensions_section",
"cost_center",
@@ -921,14 +922,23 @@
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"default": "0",
"fieldname": "has_unit_price_items",
"fieldtype": "Check",
"hidden": 1,
"label": "Has Unit Price Items",
"no_copy": 1
}
],
"grid_page_length": 50,
"icon": "fa fa-shopping-cart",
"idx": 29,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2024-03-28 10:20:30.231915",
"modified": "2025-03-03 17:39:38.459977",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation",
@@ -989,6 +999,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"search_fields": "status, transaction_date, supplier,grand_total",
"show_name_in_global_search": 1,
"sort_field": "creation",

View File

@@ -60,6 +60,7 @@ class SupplierQuotation(BuyingController):
discount_amount: DF.Currency
grand_total: DF.Currency
group_same_items: DF.Check
has_unit_price_items: DF.Check
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
@@ -103,6 +104,10 @@ class SupplierQuotation(BuyingController):
valid_till: DF.Date | None
# end: auto-generated types
def before_validate(self):
self.set_has_unit_price_items()
self.flags.allow_zero_qty = self.has_unit_price_items
def validate(self):
super().validate()
@@ -129,6 +134,17 @@ class SupplierQuotation(BuyingController):
def on_trash(self):
pass
def set_has_unit_price_items(self):
"""
If permitted in settings and any item has 0 qty, the SQ has unit price items.
"""
if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_supplier_quotation"):
return
self.has_unit_price_items = any(
not row.qty for row in self.get("items") if (row.item_code and not row.qty)
)
def validate_with_previous_doc(self):
super().validate_with_previous_doc(
{
@@ -234,6 +250,7 @@ def make_purchase_order(source_name, target_doc=None):
{
"Supplier Quotation": {
"doctype": "Purchase Order",
"field_no_map": ["transaction_date"],
"validation": {
"docstatus": ["=", 1],
},

View File

@@ -3,8 +3,10 @@
import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase, UnitTestCase, change_settings
from frappe.utils import add_days, today
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order
from erpnext.controllers.accounts_controller import InvalidQtyError
@@ -29,9 +31,18 @@ class TestPurchaseOrder(IntegrationTestCase):
sq.save()
self.assertEqual(sq.items[0].qty, 1)
def test_make_purchase_order(self):
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order
def test_supplier_quotation_zero_qty(self):
"""
Test if RFQ with zero qty (Unit Price Item) is conditionally allowed.
"""
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
sq.items[0].qty = 0
with change_settings("Buying Settings", {"allow_zero_qty_in_supplier_quotation": 1}):
sq.save()
self.assertEqual(sq.items[0].qty, 0)
def test_make_purchase_order(self):
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]).insert()
self.assertRaises(frappe.ValidationError, make_purchase_order, sq.name)
@@ -47,6 +58,17 @@ class TestPurchaseOrder(IntegrationTestCase):
for doc in po.get("items"):
if doc.get("item_code"):
doc.set("schedule_date", "2013-04-12")
doc.set("schedule_date", add_days(today(), 1))
po.insert()
@IntegrationTestCase.change_settings("Buying Settings", {"allow_zero_qty_in_supplier_quotation": 1})
def test_map_purchase_order_from_zero_qty_supplier_quotation(self):
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
sq.items[0].qty = 0
sq.submit()
po = make_purchase_order(sq.name)
self.assertEqual(len(po.get("items")), 1)
self.assertEqual(po.get("items")[0].qty, 0)
self.assertEqual(po.get("items")[0].item_code, sq.get("items")[0].item_code)

View File

@@ -76,14 +76,14 @@ frappe.query_reports["Supplier Quotation Comparison"] = {
},
},
{
fieldname: "group_by",
label: __("Group by"),
fieldname: "categorize_by",
label: __("Categorize by"),
fieldtype: "Select",
options: [
{ label: __("Group by Supplier"), value: "Group by Supplier" },
{ label: __("Group by Item"), value: "Group by Item" },
{ label: __("Categorize by Supplier"), value: "Categorize by Supplier" },
{ label: __("Categorize by Item"), value: "Categorize by Item" },
],
default: __("Group by Supplier"),
default: __("Categorize by Supplier"),
},
{
fieldtype: "Check",

View File

@@ -15,6 +15,8 @@ def execute(filters=None):
if not filters:
return [], []
validate_filters(filters)
columns = get_columns(filters)
supplier_quotation_data = get_data(filters)
@@ -24,6 +26,12 @@ def execute(filters=None):
return columns, data, message, chart_data
def validate_filters(filters):
if not filters.get("categorize_by") and filters.get("group_by"):
filters["categorize_by"] = filters["group_by"]
filters["categorize_by"] = filters["categorize_by"].replace("Group by", "Categorize by")
def get_data(filters):
sq = frappe.qb.DocType("Supplier Quotation")
sq_item = frappe.qb.DocType("Supplier Quotation Item")
@@ -82,20 +90,14 @@ def prepare_data(supplier_quotation_data, filters):
group_wise_map = defaultdict(list)
supplier_qty_price_map = {}
group_by_field = "supplier_name" if filters.get("group_by") == "Group by Supplier" else "item_code"
company_currency = frappe.db.get_default("currency")
group_by_field = (
"supplier_name" if filters.get("categorize_by") == "Categorize by Supplier" else "item_code"
)
float_precision = cint(frappe.db.get_default("float_precision")) or 2
for data in supplier_quotation_data:
group = data.get(group_by_field) # get item or supplier value for this row
supplier_currency = frappe.db.get_value("Supplier", data.get("supplier_name"), "default_currency")
if supplier_currency:
exchange_rate = get_exchange_rate(supplier_currency, company_currency)
else:
exchange_rate = 1
row = {
"item_code": ""
if group_by_field == "item_code"
@@ -103,7 +105,7 @@ def prepare_data(supplier_quotation_data, filters):
"supplier_name": "" if group_by_field == "supplier_name" else data.get("supplier_name"),
"quotation": data.get("parent"),
"qty": data.get("qty"),
"price": flt(data.get("amount") * exchange_rate, float_precision),
"price": flt(data.get("amount"), float_precision),
"uom": data.get("uom"),
"price_list_currency": data.get("price_list_currency"),
"currency": data.get("currency"),
@@ -209,6 +211,13 @@ def get_columns(filters):
columns = [
{"fieldname": "uom", "label": _("UOM"), "fieldtype": "Link", "options": "UOM", "width": 90},
{"fieldname": "qty", "label": _("Quantity"), "fieldtype": "Float", "width": 80},
{
"fieldname": "stock_uom",
"label": _("Stock UOM"),
"fieldtype": "Link",
"options": "UOM",
"width": 90,
},
{
"fieldname": "currency",
"label": _("Currency"),
@@ -223,13 +232,6 @@ def get_columns(filters):
"options": "currency",
"width": 110,
},
{
"fieldname": "stock_uom",
"label": _("Stock UOM"),
"fieldtype": "Link",
"options": "UOM",
"width": 90,
},
{
"fieldname": "price_per_unit",
"label": _("Price per Unit (Stock UOM)"),
@@ -274,7 +276,7 @@ def get_columns(filters):
},
]
if filters.get("group_by") == "Group by Item":
if filters.get("categorize_by") == "Categorize by Item":
group_by_columns.reverse()
columns[0:0] = group_by_columns # add positioned group by columns to the report

View File

@@ -1262,6 +1262,9 @@ class AccountsController(TransactionBase):
)
def validate_qty_is_not_zero(self):
if self.flags.allow_zero_qty:
return
for item in self.items:
if self.doctype == "Purchase Receipt" and item.rejected_qty:
continue
@@ -3759,9 +3762,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
)
if amount_below_billed_amt and row_rate > 0.0:
frappe.throw(
_("Row #{0}: Cannot set Rate if amount is greater than billed amount for Item {1}.").format(
child_item.idx, child_item.item_code
)
_(
"Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}."
).format(child_item.idx, child_item.item_code)
)
else:
child_item.rate = row_rate

View File

@@ -98,7 +98,29 @@ class BuyingController(SubcontractingController):
item.from_warehouse,
type_of_transaction="Outward",
do_not_submit=True,
qty=item.qty,
)
elif (
not self.is_new()
and item.serial_and_batch_bundle
and next(
(
old_item
for old_item in self.get_doc_before_save().items
if old_item.name == item.name and old_item.qty != item.qty
),
None,
)
and len(
sabe := frappe.get_all(
"Serial and Batch Entry",
filters={"parent": item.serial_and_batch_bundle, "serial_no": ["is", "not set"]},
pluck="name",
)
)
== 1
):
frappe.set_value("Serial and Batch Entry", sabe[0], "qty", item.qty)
def set_rate_for_standalone_debit_note(self):
if self.get("is_return") and self.get("update_stock") and not self.return_against:
@@ -141,6 +163,7 @@ class BuyingController(SubcontractingController):
company=self.company,
party_address=self.get("supplier_address"),
shipping_address=self.get("shipping_address"),
dispatch_address=self.get("dispatch_address"),
company_address=self.get("billing_address"),
fetch_payment_terms_template=not self.get("ignore_default_payment_terms_template"),
ignore_permissions=self.flags.ignore_permissions,
@@ -242,6 +265,7 @@ class BuyingController(SubcontractingController):
address_dict = {
"supplier_address": "address_display",
"shipping_address": "shipping_address_display",
"dispatch_address": "dispatch_address_display",
"billing_address": "billing_address_display",
}

View File

@@ -6,6 +6,7 @@ from collections import defaultdict
import frappe
from frappe import _
from frappe.model.meta import get_field_precision
from frappe.query_builder import DocType
from frappe.utils import cint, flt, format_datetime, get_datetime
import erpnext
@@ -347,6 +348,16 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
"Company", company, "default_warehouse_for_sales_return"
)
if doctype == "Sales Invoice":
inv_is_consolidated, inv_is_pos = frappe.db.get_value(
"Sales Invoice", source_name, ["is_consolidated", "is_pos"]
)
if inv_is_consolidated and inv_is_pos:
frappe.throw(
_("Cannot create return for consolidated invoice {0}.").format(source_name),
title=_("Cannot Create Return"),
)
def set_missing_values(source, target):
doc = frappe.get_doc(target)
doc.is_return = 1
@@ -377,6 +388,8 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
if doc.get("is_return"):
if doc.doctype == "Sales Invoice" or doc.doctype == "POS Invoice":
doc.consolidated_invoice = ""
if doc.doctype == "Sales Invoice":
doc.pos_closing_entry = ""
# no copy enabled for party_account_currency
doc.party_account_currency = source.party_account_currency
doc.set("payments", [])
@@ -1169,26 +1182,49 @@ def get_payment_data(invoice):
@frappe.whitelist()
def get_pos_invoice_item_returned_qty(pos_invoice, customer, item_row_name):
is_return, docstatus = frappe.db.get_value("POS Invoice", pos_invoice, ["is_return", "docstatus"])
def get_invoice_item_returned_qty(doctype, invoice, customer, item_row_name):
is_return, docstatus = frappe.db.get_value(doctype, invoice, ["is_return", "docstatus"])
if not is_return and docstatus == 1:
return get_returned_qty_map_for_row(pos_invoice, customer, item_row_name, "POS Invoice")
return get_returned_qty_map_for_row(invoice, customer, item_row_name, doctype)
@frappe.whitelist()
def is_pos_invoice_returnable(pos_invoice):
def is_invoice_returnable(doctype, invoice):
is_return, docstatus, customer = frappe.db.get_value(
"POS Invoice", pos_invoice, ["is_return", "docstatus", "customer"]
doctype, invoice, ["is_return", "docstatus", "customer"]
)
if is_return or docstatus == 0:
return False
invoice_item_qty = frappe.db.get_all("POS Invoice Item", {"parent": pos_invoice}, ["name", "qty"])
invoice_item_qty = frappe.db.get_all(f"{doctype} Item", {"parent": invoice}, ["name", "qty"])
already_full_returned = 0
for d in invoice_item_qty:
returned_qty = get_returned_qty_map_for_row(pos_invoice, customer, d.name, "POS Invoice")
returned_qty = get_returned_qty_map_for_row(invoice, customer, d.name, doctype)
if returned_qty.qty == d.qty:
already_full_returned += 1
return len(invoice_item_qty) != already_full_returned
def get_sales_invoice_item_from_consolidated_invoice(return_against_pos_invoice, pos_invoice_item):
try:
SalesInvoice = DocType("Sales Invoice")
SalesInvoiceItem = DocType("Sales Invoice Item")
query = (
frappe.qb.from_(SalesInvoice)
.from_(SalesInvoiceItem)
.select(SalesInvoiceItem.name)
.where(
(SalesInvoice.name == SalesInvoiceItem.parent)
& (SalesInvoice.is_return == 0)
& (SalesInvoiceItem.pos_invoice == return_against_pos_invoice)
& (SalesInvoiceItem.pos_invoice_item == pos_invoice_item)
)
)
result = query.run(as_dict=True)
return result[0].name if result else None
except Exception:
return None

View File

@@ -811,7 +811,7 @@ class StockController(AccountsController):
)
def make_package_for_transfer(
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None, qty=0
):
return make_bundle_for_material_transfer(
is_new=self.is_new(),
@@ -822,6 +822,7 @@ class StockController(AccountsController):
warehouse=warehouse,
type_of_transaction=type_of_transaction,
do_not_submit=do_not_submit,
qty=qty,
)
def get_sl_entries(self, d, args):
@@ -1050,6 +1051,16 @@ class StockController(AccountsController):
def validate_qi_presence(self, row):
"""Check if QI is present on row level. Warn on save and stop on submit if missing."""
if self.doctype in [
"Purchase Receipt",
"Purchase Invoice",
"Sales Invoice",
"Delivery Note",
] and frappe.db.get_single_value(
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
):
return
if not row.quality_inspection:
msg = _("Row #{0}: Quality Inspection is required for Item {1}").format(
row.idx, frappe.bold(row.item_code)
@@ -1808,15 +1819,24 @@ def make_bundle_for_material_transfer(**kwargs):
kwargs.type_of_transaction = "Inward"
bundle_doc = frappe.copy_doc(bundle_doc)
bundle_doc.docstatus = 0
bundle_doc.warehouse = kwargs.warehouse
bundle_doc.type_of_transaction = kwargs.type_of_transaction
bundle_doc.voucher_type = kwargs.voucher_type
bundle_doc.voucher_no = "" if kwargs.is_new or kwargs.docstatus == 2 else kwargs.voucher_no
bundle_doc.is_cancelled = 0
qty = 0
if (
len(bundle_doc.entries) == 1
and flt(kwargs.qty) < flt(bundle_doc.total_qty)
and not bundle_doc.has_serial_no
):
qty = kwargs.qty
for row in bundle_doc.entries:
row.is_outward = 0
row.qty = abs(row.qty)
row.qty = abs(qty or row.qty)
row.stock_value_difference = abs(row.stock_value_difference)
if kwargs.type_of_transaction == "Outward":
row.qty *= -1

View File

@@ -8,18 +8,26 @@ from frappe.custom.doctype.property_setter.property_setter import make_property_
from frappe.tests import IntegrationTestCase
from erpnext.controllers import queries
from erpnext.tests.utils import ERPNextTestSuite
def add_default_params(func, doctype):
return partial(func, doctype=doctype, txt="", searchfield="name", start=0, page_len=20, filters=None)
EXTRA_TEST_RECORD_DEPENDENCIES = ["Employee", "Lead", "Item", "BOM", "Project", "Account"]
EXTRA_TEST_RECORD_DEPENDENCIES = ["Item", "BOM", "Account"]
class TestQueries(IntegrationTestCase):
class TestQueries(ERPNextTestSuite):
# All tests are based on self.globalTestRecords[doctype]
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.make_employees()
cls.make_leads()
cls.make_projects()
def assert_nested_in(self, item, container):
self.assertIn(item, [vals for tuples in container for vals in tuples])
@@ -105,7 +113,7 @@ class TestQueries(IntegrationTestCase):
{
"user": user.name,
"doctype": "Employee",
"docname": "_T-Employee-00001",
"docname": self.employees[0].name,
"is_default": 1,
"apply_to_all_doctypes": 1,
"applicable_doctypes": [],

View File

@@ -8,17 +8,23 @@ from frappe.utils import random_string, today
from erpnext.crm.doctype.lead.lead import make_opportunity
from erpnext.crm.utils import get_linked_prospect
from erpnext.tests.utils import ERPNextTestSuite
class TestLead(IntegrationTestCase):
class TestLead(ERPNextTestSuite):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.make_leads()
def test_make_customer(self):
from erpnext.crm.doctype.lead.lead import make_customer
frappe.delete_doc_if_exists("Customer", "_Test Lead")
customer = make_customer("_T-Lead-00001")
customer = make_customer(self.leads[0].name)
self.assertEqual(customer.doctype, "Customer")
self.assertEqual(customer.lead_name, "_T-Lead-00001")
self.assertEqual(customer.lead_name, self.leads[0].name)
customer.company = "_Test Company"
customer.customer_group = "_Test Customer Group"
@@ -42,9 +48,9 @@ class TestLead(IntegrationTestCase):
def test_make_customer_from_organization(self):
from erpnext.crm.doctype.lead.lead import make_customer
customer = make_customer("_T-Lead-00002")
customer = make_customer(self.leads[1].name)
self.assertEqual(customer.doctype, "Customer")
self.assertEqual(customer.lead_name, "_T-Lead-00002")
self.assertEqual(customer.lead_name, self.leads[1].name)
customer.company = "_Test Company"
customer.customer_group = "_Test Customer Group"

View File

@@ -1,34 +0,0 @@
[
{
"doctype": "Lead",
"email_id": "test_lead@example.com",
"lead_name": "_Test Lead",
"status": "Open",
"territory": "_Test Territory"
},
{
"doctype": "Lead",
"email_id": "test_lead1@example.com",
"lead_name": "_Test Lead 1",
"status": "Open"
},
{
"doctype": "Lead",
"email_id": "test_lead2@example.com",
"lead_name": "_Test Lead 2",
"status": "Lead"
},
{
"doctype": "Lead",
"email_id": "test_lead3@example.com",
"lead_name": "_Test Lead 3",
"status": "Converted"
},
{
"doctype": "Lead",
"email_id": "test_lead4@example.com",
"lead_name": "_Test Lead 4",
"company_name": "_Test Lead 4",
"status": "Open"
}
]

View File

@@ -111,6 +111,13 @@ frappe.ui.form.on("Opportunity", {
},
__("Create")
);
let company_currency = erpnext.get_currency(frm.doc.company);
if (company_currency != frm.doc.currency) {
frm.add_custom_button(__("Fetch Latest Exchange Rate"), function () {
frm.trigger("currency");
});
}
}
if (!frm.doc.__islocal && frm.perm[0].write && frm.doc.docstatus == 0) {
@@ -152,7 +159,7 @@ frappe.ui.form.on("Opportunity", {
currency: function (frm) {
let company_currency = erpnext.get_currency(frm.doc.company);
if (company_currency != frm.doc.company) {
if (company_currency != frm.doc.currency) {
frappe.call({
method: "erpnext.setup.utils.get_exchange_rate",
args: {
@@ -278,7 +285,6 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
}
this.setup_queries();
this.frm.trigger("currency");
}
refresh() {

View File

@@ -10,9 +10,40 @@ from erpnext.crm.doctype.lead.lead import make_customer
from erpnext.crm.doctype.lead.test_lead import make_lead
from erpnext.crm.doctype.opportunity.opportunity import make_quotation
from erpnext.crm.utils import get_linked_communication_list
from erpnext.tests.utils import ERPNextTestSuite
class TestOpportunity(IntegrationTestCase):
class TestOpportunity(ERPNextTestSuite):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Only first lead is required
# TODO: dynamically generate limited test records
cls.make_leads()
cls.make_opportunities()
@classmethod
def make_opportunities(cls):
records = [
{
"doctype": "Opportunity",
"name": "_Test Opportunity 1",
"opportunity_from": "Lead",
"enquiry_type": "Sales",
"party_name": cls.leads[0].name,
"transaction_date": "2013-12-12",
"items": [
{"item_name": "Test Item", "description": "Some description", "qty": 5, "rate": 100}
],
}
]
cls.opportunities = []
for x in records:
if not frappe.db.exists("Opportunity", {"name": x.get("name")}):
cls.opportunities.append(frappe.get_doc(x).insert())
else:
cls.opportunities.append(frappe.get_doc("Opportunity", {"party_name": x.get("party_name")}))
def test_opportunity_status(self):
doc = make_opportunity(with_items=0)
quotation = make_quotation(doc.name)

View File

@@ -1,16 +0,0 @@
[
{
"doctype": "Opportunity",
"name": "_Test Opportunity 1",
"opportunity_from": "Lead",
"enquiry_type": "Sales",
"party_name": "_T-Lead-00001",
"transaction_date": "2013-12-12",
"items": [{
"item_name": "Test Item",
"description": "Some description",
"qty": 5,
"rate": 100
}]
}
]

View File

@@ -147,14 +147,37 @@ def link_open_events(ref_doctype, ref_docname, doc):
def get_open_activities(ref_doctype, ref_docname):
tasks = get_open_todos(ref_doctype, ref_docname)
events = get_open_events(ref_doctype, ref_docname)
tasks_history = get_closed_todos(ref_doctype, ref_docname)
events_history = get_closed_events(ref_doctype, ref_docname)
return {"tasks": tasks, "events": events}
return {
"tasks": tasks,
"events": events,
"tasks_history": tasks_history,
"events_history": events_history,
}
def get_closed_todos(ref_doctype, ref_docname):
return get_filtered_todos(ref_doctype, ref_docname, status=("!=", "Open"))
def get_open_todos(ref_doctype, ref_docname):
return get_filtered_todos(ref_doctype, ref_docname, status="Open")
def get_open_events(ref_doctype, ref_docname):
return get_filtered_events(ref_doctype, ref_docname, open=True)
def get_closed_events(ref_doctype, ref_docname):
return get_filtered_events(ref_doctype, ref_docname, open=False)
def get_filtered_todos(ref_doctype, ref_docname, status: str | tuple[str, str]):
return frappe.get_all(
"ToDo",
filters={"reference_type": ref_doctype, "reference_name": ref_docname, "status": "Open"},
filters={"reference_type": ref_doctype, "reference_name": ref_docname, "status": status},
fields=[
"name",
"description",
@@ -164,10 +187,15 @@ def get_open_todos(ref_doctype, ref_docname):
)
def get_open_events(ref_doctype, ref_docname):
def get_filtered_events(ref_doctype, ref_docname, open: bool):
event = frappe.qb.DocType("Event")
event_link = frappe.qb.DocType("Event Participants")
if open:
event_status_filter = event.status == "Open"
else:
event_status_filter = event.status != "Open"
query = (
frappe.qb.from_(event)
.join(event_link)
@@ -183,7 +211,7 @@ def get_open_events(ref_doctype, ref_docname):
.where(
(event_link.reference_doctype == ref_doctype)
& (event_link.reference_docname == ref_docname)
& (event.status == "Open")
& (event_status_filter)
)
)
data = query.run(as_dict=True)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More