mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-26 01:58:31 +00:00
Merge branch 'develop' into perf-gl
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}]
|
||||
@@ -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");
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`")
|
||||
|
||||
@@ -417,6 +417,7 @@
|
||||
"options": "Project"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "icon-cog",
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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":
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}]
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
2497
erpnext/locale/ar.po
2497
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
2501
erpnext/locale/bs.po
2501
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
2499
erpnext/locale/de.po
2499
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
2499
erpnext/locale/eo.po
2499
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
2497
erpnext/locale/es.po
2497
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
2737
erpnext/locale/fa.po
2737
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
2497
erpnext/locale/fr.po
2497
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
2495
erpnext/locale/hr.po
2495
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
2495
erpnext/locale/hu.po
2495
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2495
erpnext/locale/pl.po
2495
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
2495
erpnext/locale/pt.po
2495
erpnext/locale/pt.po
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
Reference in New Issue
Block a user