mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-31 16:24:37 +00:00
Compare commits
31 Commits
v15.118.2
...
version-15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf42c52530 | ||
|
|
42d53783bb | ||
|
|
276d023946 | ||
|
|
72f293f131 | ||
|
|
b826b7c3e6 | ||
|
|
b2918b8bb3 | ||
|
|
f727f863bb | ||
|
|
bb36a4fd08 | ||
|
|
ebf5a462b3 | ||
|
|
bae02a6212 | ||
|
|
54390bdeb9 | ||
|
|
82850fb447 | ||
|
|
478426b436 | ||
|
|
83837868a0 | ||
|
|
972a990b01 | ||
|
|
310b9d4e65 | ||
|
|
68c24f3767 | ||
|
|
f2ec60b7a5 | ||
|
|
1602639a80 | ||
|
|
848335086c | ||
|
|
9a596594da | ||
|
|
455d6d4ac1 | ||
|
|
7cecff9fa4 | ||
|
|
94d63ebb49 | ||
|
|
ade3f791a4 | ||
|
|
967955a926 | ||
|
|
71b5f41556 | ||
|
|
4f07e2503b | ||
|
|
16e14d70b5 | ||
|
|
33d3696385 | ||
|
|
a51750db56 |
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.118.2"
|
||||
__version__ = "15.112.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -3316,13 +3316,11 @@ def set_paid_amount_and_received_amount(
|
||||
company_currency = frappe.get_cached_value("Company", doc.get("company"), "default_currency")
|
||||
if bank and company_currency != bank.account_currency:
|
||||
# doc currency can be different from bank currency
|
||||
posting_date = doc.get("posting_date") or doc.get("transaction_date")
|
||||
conversion_rate = get_exchange_rate(
|
||||
bank.account_currency, party_account_currency, posting_date
|
||||
)
|
||||
conversion_rate = get_exchange_rate(bank.account_currency, party_account_currency)
|
||||
received_amount = paid_amount / conversion_rate
|
||||
else:
|
||||
received_amount = paid_amount * doc.get("conversion_rate", 1)
|
||||
conversion_rate = get_exchange_rate(doc.get("currency", company_currency), company_currency)
|
||||
received_amount = paid_amount * conversion_rate
|
||||
|
||||
# if payment type is pay, then paid amount and received amount are swapped
|
||||
if payment_type == "Pay":
|
||||
|
||||
@@ -15,6 +15,7 @@ from erpnext.accounts.doctype.account_closing_balance.account_closing_balance im
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
)
|
||||
from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled
|
||||
from erpnext.accounts.utils import get_account_currency, get_fiscal_year
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
|
||||
@@ -46,6 +47,14 @@ class PeriodClosingVoucher(AccountsController):
|
||||
self.block_if_future_closing_voucher_exists()
|
||||
self.check_closing_account_type()
|
||||
self.check_closing_account_currency()
|
||||
self.validate_accounts_not_frozen()
|
||||
|
||||
def validate_accounts_not_frozen(self, for_cancellation=False):
|
||||
posting_date = self.period_end_date
|
||||
if for_cancellation and is_immutable_ledger_enabled():
|
||||
posting_date = getdate()
|
||||
|
||||
check_freezing_date(posting_date, self.company)
|
||||
|
||||
def validate_start_and_end_date(self):
|
||||
self.fy_start_date, self.fy_end_date = frappe.db.get_value(
|
||||
@@ -147,6 +156,7 @@ class PeriodClosingVoucher(AccountsController):
|
||||
"Process Period Closing Voucher",
|
||||
)
|
||||
self.block_if_future_closing_voucher_exists()
|
||||
self.validate_accounts_not_frozen(for_cancellation=True)
|
||||
|
||||
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
self.cancel_process_pcv_docs()
|
||||
|
||||
@@ -22,27 +22,50 @@ frappe.ui.form.on("Repost Accounting Ledger", {
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
frm.add_custom_button(__("Show Preview"), () => {
|
||||
frm.call({
|
||||
method: "generate_preview",
|
||||
doc: frm.doc,
|
||||
freeze: true,
|
||||
freeze_message: __("Generating Preview"),
|
||||
callback: function (r) {
|
||||
if (r && r.message) {
|
||||
let content = r.message;
|
||||
let opts = {
|
||||
title: "Preview",
|
||||
subtitle: "preview",
|
||||
content: content,
|
||||
print_settings: { orientation: "landscape" },
|
||||
columns: [],
|
||||
data: [],
|
||||
};
|
||||
frappe.render_grid(opts);
|
||||
}
|
||||
},
|
||||
// the server refuses only while the job is alive, so a dead one can be restarted here
|
||||
if (frm.doc.docstatus == 1 && !["Completed", "Cancelled"].includes(frm.doc.status)) {
|
||||
frm.add_custom_button(__("Start Reposting"), () => {
|
||||
frm.events.start_repost(frm);
|
||||
});
|
||||
}
|
||||
|
||||
if (frm.doc.docstatus != 2) {
|
||||
frm.add_custom_button(__("Show Preview"), () => {
|
||||
frm.events.generate_preview(frm);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
generate_preview: function (frm) {
|
||||
frm.call({
|
||||
method: "generate_preview",
|
||||
doc: frm.doc,
|
||||
freeze: true,
|
||||
freeze_message: __("Generating Preview"),
|
||||
callback: function (r) {
|
||||
if (r && r.message) {
|
||||
let content = r.message;
|
||||
let opts = {
|
||||
title: "Preview",
|
||||
subtitle: "preview",
|
||||
content: content,
|
||||
print_settings: { orientation: "landscape" },
|
||||
columns: [],
|
||||
data: [],
|
||||
};
|
||||
frappe.render_grid(opts);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
start_repost: function (frm) {
|
||||
frm.call({
|
||||
method: "start_repost",
|
||||
doc: frm.doc,
|
||||
callback: function (r) {
|
||||
frm.reload_doc();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"creation": "2023-07-04 13:07:32.923675",
|
||||
"default_view": "List",
|
||||
"doctype": "DocType",
|
||||
@@ -7,16 +8,24 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"company",
|
||||
"column_break_vpup",
|
||||
"delete_cancelled_entries",
|
||||
"column_break_vpup",
|
||||
"status",
|
||||
"section_break_metl",
|
||||
"vouchers",
|
||||
"amended_from"
|
||||
"error_section",
|
||||
"error_log",
|
||||
"miscellaneous_section",
|
||||
"amended_from",
|
||||
"column_break_hrah",
|
||||
"scheduled_job"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Company",
|
||||
"options": "Company"
|
||||
},
|
||||
@@ -48,12 +57,54 @@
|
||||
"fieldname": "delete_cancelled_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Delete Cancelled Ledger Entries"
|
||||
},
|
||||
{
|
||||
"fieldname": "error_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Error"
|
||||
},
|
||||
{
|
||||
"fieldname": "error_log",
|
||||
"fieldtype": "Code",
|
||||
"label": "Error Log",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "miscellaneous_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Miscellaneous"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_hrah",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.docstatus >= 1;",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "\nQueued\nIn Progress\nPartially Reposted\nCompleted\nFailed\nCancelled",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "scheduled_job",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Scheduled Job",
|
||||
"no_copy": 1,
|
||||
"options": "RQ Job",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-06-03 17:30:37.012593",
|
||||
"modified": "2026-07-28 00:56:50.290314",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Repost Accounting Ledger",
|
||||
@@ -80,4 +131,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,14 @@ import frappe
|
||||
from frappe import _, qb
|
||||
from frappe.desk.form.linked_with import get_child_tables_of_doctypes
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.background_jobs import create_job_id, is_job_enqueued
|
||||
from frappe.utils.data import comma_and
|
||||
from frappe.utils.scheduler import is_scheduler_inactive
|
||||
|
||||
# a batch has to finish well within the timeout of the job reposting it
|
||||
MAX_VOUCHERS_PER_REPOST = 50
|
||||
|
||||
HANDLED_VOUCHER_STATUSES = ("Reposted", "Skipped")
|
||||
|
||||
from erpnext.stock import get_warehouse_account_map
|
||||
|
||||
@@ -28,6 +35,11 @@ class RepostAccountingLedger(Document):
|
||||
amended_from: DF.Link | None
|
||||
company: DF.Link | None
|
||||
delete_cancelled_entries: DF.Check
|
||||
error_log: DF.Code | None
|
||||
scheduled_job: DF.Link | None
|
||||
status: DF.Literal[
|
||||
"", "Queued", "In Progress", "Partially Reposted", "Completed", "Failed", "Cancelled"
|
||||
]
|
||||
vouchers: DF.Table[RepostAccountingLedgerItems]
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -37,6 +49,11 @@ class RepostAccountingLedger(Document):
|
||||
|
||||
def validate(self):
|
||||
self.validate_vouchers()
|
||||
self.validate_repost_preconditions()
|
||||
|
||||
def validate_repost_preconditions(self):
|
||||
"""The checks a repost queued days ago could have outlived, re-run before it touches
|
||||
the ledger. Vouchers cancelled since are skipped one by one while reposting."""
|
||||
self.validate_for_closed_fiscal_year()
|
||||
self.validate_for_deferred_accounting()
|
||||
|
||||
@@ -73,8 +90,52 @@ class RepostAccountingLedger(Document):
|
||||
frappe.throw(_("Cannot Resubmit Ledger entries for vouchers in Closed fiscal year."))
|
||||
|
||||
def validate_vouchers(self):
|
||||
if self.vouchers:
|
||||
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
|
||||
if not self.vouchers:
|
||||
frappe.throw(_("Add atleast one voucher to repost."))
|
||||
|
||||
if len(self.vouchers) > MAX_VOUCHERS_PER_REPOST:
|
||||
frappe.throw(
|
||||
_("Cannot repost more than {0} vouchers at once. Split them into multiple documents.").format(
|
||||
MAX_VOUCHERS_PER_REPOST
|
||||
)
|
||||
)
|
||||
|
||||
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
|
||||
|
||||
self.validate_no_duplicate_vouchers()
|
||||
self.validate_vouchers_are_submitted()
|
||||
|
||||
def validate_no_duplicate_vouchers(self):
|
||||
vouchers = [(x.voucher_type, x.voucher_no) for x in self.vouchers]
|
||||
|
||||
if len(vouchers) != len(set(vouchers)):
|
||||
frappe.throw(_("Duplicate vouchers found. Remove the duplicate vouchers to continue to repost."))
|
||||
|
||||
def validate_vouchers_are_submitted(self):
|
||||
voucher_type_wise_map = {}
|
||||
for d in self.vouchers:
|
||||
voucher_type_wise_map.setdefault(d.voucher_type, [])
|
||||
voucher_type_wise_map[d.voucher_type].append(d.voucher_no)
|
||||
|
||||
non_submitted_vouchers = []
|
||||
for key in voucher_type_wise_map.keys():
|
||||
non_submitted_vouchers.extend(
|
||||
frappe.get_all(
|
||||
key,
|
||||
filters={"name": ["in", voucher_type_wise_map[key]], "docstatus": ["!=", 1]},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
if non_submitted_vouchers:
|
||||
frappe.throw(
|
||||
_("The following vouchers are not submitted: {0}").format(
|
||||
comma_and(non_submitted_vouchers, add_quotes=True)
|
||||
)
|
||||
)
|
||||
|
||||
def on_discard(self):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
def get_existing_ledger_entries(self):
|
||||
vouchers = [x.voucher_no for x in self.vouchers]
|
||||
@@ -139,80 +200,245 @@ class RepostAccountingLedger(Document):
|
||||
return rendered_page
|
||||
|
||||
def on_submit(self):
|
||||
if len(self.vouchers) > 5:
|
||||
job_name = "repost_accounting_ledger_" + self.name
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost",
|
||||
account_repost_doc=self.name,
|
||||
is_async=True,
|
||||
job_name=job_name,
|
||||
enqueue_after_commit=True,
|
||||
self.start_repost()
|
||||
|
||||
def before_cancel(self):
|
||||
self._raise_error_if_reposting_in_progress()
|
||||
|
||||
def on_cancel(self):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
def _raise_error_if_reposting_in_progress(self):
|
||||
if self.scheduled_job and is_job_enqueued(_repost_job_id(self.name)):
|
||||
frappe.throw(_("Reposting is still in progress in background."))
|
||||
|
||||
@frappe.whitelist()
|
||||
def start_repost(self):
|
||||
if self.docstatus != 1:
|
||||
frappe.throw(_("Reposting can be started only for submitted document."))
|
||||
|
||||
# under a row lock, so two concurrent starts cannot both get past here
|
||||
status = frappe.db.get_value(self.doctype, self.name, "status", for_update=True)
|
||||
if status in ("Completed", "Cancelled"):
|
||||
frappe.throw(_("Reposting cannot be started when status is {0}.").format(status))
|
||||
|
||||
# `Queued` and `In Progress` are held back by the job, not by the status: a worker that
|
||||
# died leaves the status behind and the document has to stay restartable
|
||||
self._raise_error_if_reposting_in_progress()
|
||||
|
||||
self.check_permission("write")
|
||||
|
||||
# workers pick up enqueued jobs whether or not the scheduler runs, so this is a warning
|
||||
if is_scheduler_inactive():
|
||||
frappe.msgprint(
|
||||
_("Scheduler is inactive. Reposting will only run once background jobs are processed."),
|
||||
alert=True,
|
||||
indicator="orange",
|
||||
)
|
||||
frappe.msgprint(_("Repost has started in the background"))
|
||||
else:
|
||||
start_repost(self.name)
|
||||
|
||||
self.db_set({"status": "Queued", "scheduled_job": create_job_id(_repost_job_id(self.name))})
|
||||
_enqueue_repost(self.name)
|
||||
frappe.msgprint(_("Repost has started in the background"), alert=True, indicator="blue")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def start_repost(account_repost_doc: str | None = None) -> None:
|
||||
from erpnext.accounts.general_ledger import make_reverse_gl_entries
|
||||
def _repost_job_id(repost_doc_name: str) -> str:
|
||||
"""Derived from the document, so a repost can only ever have one job."""
|
||||
return f"repost_accounting_ledger::{repost_doc_name}"
|
||||
|
||||
|
||||
def _enqueue_repost(repost_doc_name: str) -> None:
|
||||
"""Hand the repost to a background worker.
|
||||
|
||||
Tests run it in the foreground, inside their own transaction: documents edited after submit
|
||||
repost themselves through `repost_accounting_entries`, and tests across apps assert on the
|
||||
ledger right after doing so.
|
||||
"""
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.repost",
|
||||
repost_doc_name=repost_doc_name,
|
||||
commit=not frappe.flags.in_test,
|
||||
queue="long",
|
||||
timeout=1500,
|
||||
job_id=_repost_job_id(repost_doc_name),
|
||||
deduplicate=True,
|
||||
enqueue_after_commit=True,
|
||||
now=frappe.flags.in_test,
|
||||
)
|
||||
|
||||
|
||||
def _lock_vouchers(vouchers) -> dict:
|
||||
"""Lock every voucher up front so a concurrent repost cannot touch the same GL entries.
|
||||
|
||||
Returns them keyed by voucher, so reposting does not load them again. These are file locks
|
||||
under the site directory: they serialise nothing across hosts that do not share it, and a
|
||||
worker killed outright leaves them behind until they expire.
|
||||
"""
|
||||
locked_docs = {}
|
||||
try:
|
||||
for x in vouchers:
|
||||
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
|
||||
doc.lock()
|
||||
locked_docs[(x.voucher_type, x.voucher_no)] = doc
|
||||
except Exception:
|
||||
for doc in locked_docs.values():
|
||||
doc.unlock()
|
||||
raise
|
||||
return locked_docs
|
||||
|
||||
|
||||
def repost(repost_doc_name: str, commit: bool = True):
|
||||
"""Repost every voucher of the document, one transaction at a time.
|
||||
|
||||
`commit` says whether this call owns the transaction. The background job does, and commits
|
||||
after every voucher so progress survives a crash; a caller inside its own passes `False`.
|
||||
"""
|
||||
from erpnext.accounts.utils import _delete_accounting_ledger_entries, _delete_adv_pl_entries
|
||||
|
||||
frappe.flags.through_repost_accounting_ledger = True
|
||||
if account_repost_doc:
|
||||
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
|
||||
repost_doc.check_permission("write")
|
||||
|
||||
if repost_doc.docstatus == 1:
|
||||
# Prevent repost on invoices with deferred accounting
|
||||
repost_doc.validate_for_deferred_accounting()
|
||||
repost_doc = frappe.get_doc("Repost Accounting Ledger", repost_doc_name)
|
||||
locked_docs = {}
|
||||
|
||||
for x in repost_doc.vouchers:
|
||||
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
|
||||
try:
|
||||
repost_doc.validate_repost_preconditions()
|
||||
|
||||
# a retry leaves the vouchers it is done with alone: they are not locked, not loaded
|
||||
# and not reposted again
|
||||
pending = [x for x in repost_doc.vouchers if x.status not in HANDLED_VOUCHER_STATUSES]
|
||||
locked_docs = _lock_vouchers(pending)
|
||||
|
||||
repost_doc.db_set("status", "In Progress", commit=commit)
|
||||
|
||||
for position, x in enumerate(pending, start=1):
|
||||
frappe.publish_progress(
|
||||
position * 100 / len(pending),
|
||||
doctype=repost_doc.doctype,
|
||||
docname=repost_doc.name,
|
||||
description=_("Reposting {0} {1}").format(x.voucher_type, x.voucher_no),
|
||||
)
|
||||
|
||||
save_point = "reposting"
|
||||
frappe.db.savepoint(save_point=save_point)
|
||||
try:
|
||||
doc = locked_docs[(x.voucher_type, x.voucher_no)]
|
||||
|
||||
if doc.docstatus == 2:
|
||||
x.db_set({"status": "Skipped", "traceback": ""})
|
||||
continue
|
||||
|
||||
if repost_doc.delete_cancelled_entries:
|
||||
frappe.db.delete(
|
||||
"GL Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
|
||||
)
|
||||
frappe.db.delete(
|
||||
"Payment Ledger Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
|
||||
)
|
||||
frappe.db.delete(
|
||||
"Advance Payment Ledger Entry",
|
||||
filters={"voucher_type": doc.doctype, "voucher_no": doc.name},
|
||||
)
|
||||
_delete_accounting_ledger_entries(doc.doctype, doc.name)
|
||||
_delete_adv_pl_entries(doc.doctype, doc.name)
|
||||
|
||||
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
|
||||
if not repost_doc.delete_cancelled_entries:
|
||||
doc.docstatus = 2
|
||||
doc.make_gl_entries_on_cancel(from_repost=True)
|
||||
_repost_vouchers(doc, repost_doc.delete_cancelled_entries)
|
||||
except Exception:
|
||||
frappe.db.rollback(save_point=save_point)
|
||||
|
||||
doc.docstatus = 1
|
||||
if doc.doctype == "Sales Invoice":
|
||||
doc.force_set_against_income_account()
|
||||
else:
|
||||
doc.force_set_against_expense_account()
|
||||
doc.make_gl_entries()
|
||||
x.db_set({"status": "Failed", "traceback": frappe.get_traceback()})
|
||||
else:
|
||||
x.db_set({"status": "Reposted", "traceback": ""})
|
||||
finally:
|
||||
if commit:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
elif doc.doctype == "Purchase Receipt":
|
||||
if not repost_doc.delete_cancelled_entries:
|
||||
doc.docstatus = 2
|
||||
doc.make_gl_entries_on_cancel(from_repost=True)
|
||||
except Exception:
|
||||
if commit:
|
||||
frappe.db.rollback()
|
||||
|
||||
doc.docstatus = 1
|
||||
doc.make_gl_entries(from_repost=True)
|
||||
_record_repost_failure(repost_doc, commit=commit)
|
||||
raise
|
||||
else:
|
||||
repost_doc.db_set({"status": _derive_status(repost_doc), "error_log": ""}, notify=True)
|
||||
finally:
|
||||
for doc in locked_docs.values():
|
||||
doc.unlock()
|
||||
if commit:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
elif doc.doctype in ["Payment Entry", "Journal Entry", "Expense Claim"]:
|
||||
if not repost_doc.delete_cancelled_entries:
|
||||
doc.make_gl_entries(1)
|
||||
doc.make_gl_entries()
|
||||
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
|
||||
if hasattr(doc, "make_gl_entries") and callable(doc.make_gl_entries):
|
||||
if not repost_doc.delete_cancelled_entries:
|
||||
if "cancel" in inspect.getfullargspec(doc.make_gl_entries):
|
||||
doc.make_gl_entries(cancel=1)
|
||||
else:
|
||||
make_reverse_gl_entries(voucher_type=doc.doctype, voucher_no=doc.name)
|
||||
doc.make_gl_entries()
|
||||
|
||||
def _derive_status(repost_doc) -> str:
|
||||
"""Vouchers are committed one by one, so the status follows what was actually handled."""
|
||||
handled = sum(1 for voucher in repost_doc.vouchers if voucher.status in HANDLED_VOUCHER_STATUSES)
|
||||
|
||||
if handled == len(repost_doc.vouchers):
|
||||
return "Completed"
|
||||
elif handled == 0:
|
||||
return "Failed"
|
||||
|
||||
return "Partially Reposted"
|
||||
|
||||
|
||||
def _record_repost_failure(repost_doc, commit=False) -> None:
|
||||
"""Persist the traceback of a run that could not finish, without discarding its progress."""
|
||||
# the traceback with frame locals goes to the Error Log, which is permissioned separately
|
||||
traceback = frappe.get_traceback()
|
||||
|
||||
frappe.log_error(
|
||||
title=_("Unable to Repost Accounting Ledger"),
|
||||
reference_doctype=repost_doc.doctype,
|
||||
reference_name=repost_doc.name,
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
repost_doc.doctype, repost_doc.name, {"error_log": traceback, "status": _derive_status(repost_doc)}
|
||||
)
|
||||
|
||||
if commit:
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
def _repost_vouchers(doc, delete_cancelled_entries: bool | int | None):
|
||||
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
|
||||
_repost_invoices(doc, delete_cancelled_entries)
|
||||
|
||||
elif doc.doctype == "Purchase Receipt":
|
||||
_repost_purchase_receipt(doc, delete_cancelled_entries)
|
||||
|
||||
elif doc.doctype in ["Payment Entry", "Journal Entry"]:
|
||||
_repost_pe_je(doc, delete_cancelled_entries)
|
||||
|
||||
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
|
||||
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries)
|
||||
|
||||
|
||||
def _repost_invoices(invoice_doc, delete_cancelled_entries):
|
||||
if not delete_cancelled_entries:
|
||||
invoice_doc.docstatus = 2
|
||||
invoice_doc.make_gl_entries_on_cancel(from_repost=True)
|
||||
|
||||
invoice_doc.docstatus = 1
|
||||
if invoice_doc.doctype == "Sales Invoice":
|
||||
invoice_doc.force_set_against_income_account()
|
||||
else:
|
||||
invoice_doc.force_set_against_expense_account()
|
||||
invoice_doc.make_gl_entries()
|
||||
|
||||
|
||||
def _repost_purchase_receipt(receipt_doc, delete_cancelled_entries):
|
||||
if not delete_cancelled_entries:
|
||||
receipt_doc.docstatus = 2
|
||||
receipt_doc.make_gl_entries_on_cancel(from_repost=True)
|
||||
|
||||
receipt_doc.docstatus = 1
|
||||
receipt_doc.make_gl_entries(from_repost=True)
|
||||
|
||||
|
||||
def _repost_pe_je(entry_doc, delete_cancelled_entries):
|
||||
if not delete_cancelled_entries:
|
||||
entry_doc.make_gl_entries(cancel=1)
|
||||
entry_doc.make_gl_entries()
|
||||
|
||||
|
||||
def _repost_allowed_hook_doctypes(repost_doc, delete_cancelled_entries: bool | int | None):
|
||||
from erpnext.accounts.general_ledger import make_reverse_gl_entries
|
||||
|
||||
if hasattr(repost_doc, "make_gl_entries") and callable(repost_doc.make_gl_entries):
|
||||
if not delete_cancelled_entries:
|
||||
if "cancel" in inspect.getfullargspec(repost_doc.make_gl_entries).args:
|
||||
repost_doc.make_gl_entries(cancel=1)
|
||||
else:
|
||||
make_reverse_gl_entries(voucher_type=repost_doc.doctype, voucher_no=repost_doc.name)
|
||||
repost_doc.make_gl_entries()
|
||||
|
||||
|
||||
def get_allowed_types_from_settings(child_doc: bool = False):
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
frappe.listview_settings["Repost Accounting Ledger"] = {
|
||||
add_fields: ["status"],
|
||||
// drafts and cancelled documents are coloured by the framework before it gets here
|
||||
get_indicator: function (doc) {
|
||||
if (!doc.status) return;
|
||||
|
||||
const status_color = {
|
||||
Queued: "yellow",
|
||||
"In Progress": "blue",
|
||||
"Partially Reposted": "orange",
|
||||
Completed: "green",
|
||||
Failed: "red",
|
||||
};
|
||||
return [__(doc.status), status_color[doc.status] || "gray", "status,=," + doc.status];
|
||||
},
|
||||
};
|
||||
@@ -1,20 +1,35 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
from frappe.utils import add_days, nowdate, today
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
|
||||
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
|
||||
_lock_vouchers,
|
||||
_record_repost_failure,
|
||||
_repost_allowed_hook_doctypes,
|
||||
_repost_job_id,
|
||||
_repost_vouchers,
|
||||
repost,
|
||||
)
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries, make_purchase_receipt
|
||||
|
||||
REPOST_MODULE = "erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger"
|
||||
SIMULATED_FAILURE = "Simulated repost failure"
|
||||
|
||||
|
||||
class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
||||
def setUp(self):
|
||||
@@ -26,8 +41,8 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_01_basic_functions(self):
|
||||
si = create_sales_invoice(
|
||||
def make_invoice(self, **kwargs):
|
||||
return create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
@@ -35,8 +50,71 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def make_invoice_and_payment(self):
|
||||
si = self.make_invoice()
|
||||
pe = get_payment_entry(si.doctype, si.name)
|
||||
pe.save().submit()
|
||||
return si, pe
|
||||
|
||||
def create_repost_doc(self, vouchers, delete_cancelled_entries=False, submit=False):
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = self.company
|
||||
ral.delete_cancelled_entries = delete_cancelled_entries
|
||||
for voucher in vouchers:
|
||||
ral.append("vouchers", {"voucher_type": voucher.doctype, "voucher_no": voucher.name})
|
||||
|
||||
ral.save()
|
||||
if submit:
|
||||
ral.submit()
|
||||
ral.reload()
|
||||
return ral
|
||||
|
||||
@contextmanager
|
||||
def patched_repost(self, fail_for=()):
|
||||
"""Yield the vouchers handed over to `_repost_vouchers`, failing the given types."""
|
||||
reposted = []
|
||||
|
||||
def repost_voucher(doc, delete_cancelled_entries):
|
||||
reposted.append(doc.name)
|
||||
if doc.doctype in fail_for:
|
||||
frappe.throw(SIMULATED_FAILURE)
|
||||
_repost_vouchers(doc, delete_cancelled_entries)
|
||||
|
||||
with patch(f"{REPOST_MODULE}._repost_vouchers", new=repost_voucher):
|
||||
yield reposted
|
||||
|
||||
def make_period_closing_voucher(self):
|
||||
fy = get_fiscal_year(today(), company=self.company)
|
||||
pcv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Period Closing Voucher",
|
||||
"transaction_date": today(),
|
||||
"period_start_date": fy[1],
|
||||
"period_end_date": today(),
|
||||
"company": self.company,
|
||||
"fiscal_year": fy[0],
|
||||
"cost_center": self.cost_center,
|
||||
"closing_account_head": self.retained_earnings,
|
||||
"remarks": "test",
|
||||
}
|
||||
)
|
||||
return pcv.save().submit()
|
||||
|
||||
def get_gl_totals(self, voucher_no, is_cancelled=0):
|
||||
gl = qb.DocType("GL Entry")
|
||||
return (
|
||||
qb.from_(gl)
|
||||
.select(Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
|
||||
.where((gl.voucher_no == voucher_no) & (gl.is_cancelled == is_cancelled))
|
||||
.run()
|
||||
)[0]
|
||||
|
||||
def test_01_basic_functions(self):
|
||||
si = self.make_invoice()
|
||||
|
||||
preq = frappe.get_doc(
|
||||
make_payment_request(
|
||||
dt=si.doctype,
|
||||
@@ -70,51 +148,24 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
||||
gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": self.debit_to})
|
||||
frappe.db.set_value("GL Entry", gle[0], "debit", 90)
|
||||
|
||||
gl = qb.DocType("GL Entry")
|
||||
res = (
|
||||
qb.from_(gl)
|
||||
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
|
||||
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
|
||||
.run()
|
||||
)
|
||||
|
||||
# Assert incorrect ledger balance
|
||||
self.assertNotEqual(res[0], (si.name, 100, 100))
|
||||
self.assertNotEqual(self.get_gl_totals(si.name), (100, 100))
|
||||
|
||||
# Submit repost document
|
||||
ral.save().submit()
|
||||
|
||||
res = (
|
||||
qb.from_(gl)
|
||||
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
|
||||
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
|
||||
.run()
|
||||
)
|
||||
|
||||
# Ledger should reflect correct amount post repost
|
||||
self.assertEqual(res[0], (si.name, 100, 100))
|
||||
self.assertEqual(self.get_gl_totals(si.name), (100, 100))
|
||||
|
||||
def test_02_deferred_accounting_valiations(self):
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debit_to,
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
do_not_submit=True,
|
||||
)
|
||||
si = self.make_invoice(do_not_submit=True)
|
||||
si.items[0].enable_deferred_revenue = True
|
||||
si.items[0].deferred_revenue_account = self.deferred_revenue
|
||||
si.items[0].service_start_date = nowdate()
|
||||
si.items[0].service_end_date = add_days(nowdate(), 90)
|
||||
si.save().submit()
|
||||
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = self.company
|
||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||
self.assertRaises(frappe.ValidationError, ral.save)
|
||||
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
|
||||
|
||||
@change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
|
||||
def test_04_pcv_validation(self):
|
||||
@@ -122,86 +173,29 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
||||
gl = frappe.qb.DocType("GL Entry")
|
||||
qb.from_(gl).delete().where(gl.company == self.company).run()
|
||||
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debit_to,
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
)
|
||||
fy = get_fiscal_year(today(), company=self.company)
|
||||
pcv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Period Closing Voucher",
|
||||
"transaction_date": today(),
|
||||
"period_start_date": fy[1],
|
||||
"period_end_date": today(),
|
||||
"company": self.company,
|
||||
"fiscal_year": fy[0],
|
||||
"cost_center": self.cost_center,
|
||||
"closing_account_head": self.retained_earnings,
|
||||
"remarks": "test",
|
||||
}
|
||||
)
|
||||
pcv.save().submit()
|
||||
si = self.make_invoice()
|
||||
pcv = self.make_period_closing_voucher()
|
||||
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = self.company
|
||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||
self.assertRaises(frappe.ValidationError, ral.save)
|
||||
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
|
||||
|
||||
pcv.reload()
|
||||
pcv.cancel()
|
||||
pcv.delete()
|
||||
|
||||
def test_03_deletion_flag_and_preview_function(self):
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debit_to,
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
)
|
||||
|
||||
pe = get_payment_entry(si.doctype, si.name)
|
||||
pe.save().submit()
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
|
||||
# with deletion flag set
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = self.company
|
||||
ral.delete_cancelled_entries = True
|
||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
|
||||
ral.save().submit()
|
||||
self.create_repost_doc([si, pe], delete_cancelled_entries=True, submit=True)
|
||||
|
||||
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
|
||||
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
|
||||
|
||||
def test_05_without_deletion_flag(self):
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debit_to,
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
)
|
||||
|
||||
pe = get_payment_entry(si.doctype, si.name)
|
||||
pe.save().submit()
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
|
||||
# without deletion flag set
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = self.company
|
||||
ral.delete_cancelled_entries = False
|
||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
|
||||
ral.save().submit()
|
||||
self.create_repost_doc([si, pe], submit=True)
|
||||
|
||||
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
|
||||
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
|
||||
@@ -247,11 +241,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
||||
another_provisional_account,
|
||||
)
|
||||
|
||||
repost_doc = frappe.new_doc("Repost Accounting Ledger")
|
||||
repost_doc.company = self.company
|
||||
repost_doc.delete_cancelled_entries = True
|
||||
repost_doc.append("vouchers", {"voucher_type": pr.doctype, "voucher_no": pr.name})
|
||||
repost_doc.save().submit()
|
||||
repost_doc = self.create_repost_doc([pr], delete_cancelled_entries=True, submit=True)
|
||||
|
||||
pr_gles_after_repost = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True)
|
||||
expected_pr_gles_after_repost = [
|
||||
@@ -272,6 +262,279 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
||||
company.default_provisional_account = None
|
||||
company.save()
|
||||
|
||||
def test_07_voucher_validations(self):
|
||||
submitted_si = self.make_invoice()
|
||||
draft_si = self.make_invoice(do_not_submit=True)
|
||||
cancelled_si = self.make_invoice()
|
||||
cancelled_si.cancel()
|
||||
|
||||
for vouchers, exception, message in (
|
||||
([], frappe.ValidationError, "Add atleast one voucher"),
|
||||
([submitted_si, submitted_si], frappe.ValidationError, "Duplicate vouchers found"),
|
||||
([draft_si], frappe.ValidationError, f"not submitted.*{draft_si.name}"),
|
||||
# cancelled vouchers don't make it past link validation
|
||||
([cancelled_si], frappe.CancelledLinkError, "Cannot link cancelled document"),
|
||||
):
|
||||
with self.subTest(vouchers=[x.name for x in vouchers]):
|
||||
self.assertRaisesRegex(exception, message, self.create_repost_doc, vouchers)
|
||||
|
||||
self.create_repost_doc([submitted_si])
|
||||
|
||||
def test_08_voucher_count_limit(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
another_si = self.make_invoice()
|
||||
|
||||
with patch(f"{REPOST_MODULE}.MAX_VOUCHERS_PER_REPOST", 2):
|
||||
self.create_repost_doc([si, pe])
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Cannot repost more than 2 vouchers",
|
||||
self.create_repost_doc,
|
||||
[si, pe, another_si],
|
||||
)
|
||||
|
||||
def test_09_status_lifecycle(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
|
||||
ral = self.create_repost_doc([si, pe])
|
||||
self.assertEqual(ral.status, "")
|
||||
|
||||
ral.submit()
|
||||
ral.reload()
|
||||
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
self.assertFalse(ral.error_log)
|
||||
for voucher in ral.vouchers:
|
||||
self.assertEqual(voucher.status, "Reposted")
|
||||
self.assertFalse(voucher.traceback)
|
||||
|
||||
ral.cancel()
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Cancelled")
|
||||
|
||||
# the `discard` flow (and the `on_discard` hook it triggers) only exists on v16,
|
||||
# so there is nothing to assert here on v15
|
||||
|
||||
def test_10_start_repost_guards(self):
|
||||
si = self.make_invoice()
|
||||
ral = self.create_repost_doc([si])
|
||||
|
||||
self.assertRaisesRegex(frappe.ValidationError, "only for submitted document", ral.start_repost)
|
||||
|
||||
ral.submit()
|
||||
ral.reload()
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError, "cannot be started when status is Completed", ral.start_repost
|
||||
)
|
||||
|
||||
# a document left behind by a worker that died mid-repost
|
||||
ral.db_set("status", "In Progress")
|
||||
|
||||
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=True):
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError, "still in progress in background", ral.start_repost
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "still in progress in background", ral.cancel)
|
||||
|
||||
# `cancel` flips docstatus in memory before running `before_cancel`
|
||||
ral.reload()
|
||||
|
||||
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=False):
|
||||
# the job is gone, so `In Progress` must not keep the document stuck
|
||||
ral.start_repost()
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
|
||||
def test_11_repost_job_is_tied_to_the_document(self):
|
||||
si = self.make_invoice()
|
||||
ral = self.create_repost_doc([si], submit=True)
|
||||
ral.db_set("status", "Failed")
|
||||
|
||||
with patch(f"{REPOST_MODULE}.frappe.enqueue") as enqueue:
|
||||
ral.start_repost()
|
||||
|
||||
kwargs = enqueue.call_args.kwargs
|
||||
self.assertEqual(kwargs["repost_doc_name"], ral.name)
|
||||
self.assertEqual(kwargs["job_id"], _repost_job_id(ral.name))
|
||||
# a second start cannot queue a second job for the same document
|
||||
self.assertTrue(kwargs["deduplicate"])
|
||||
|
||||
def test_12_voucher_failures_are_isolated_and_retried(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
pe_gl_entries = frappe.db.count("GL Entry", {"voucher_no": pe.name})
|
||||
|
||||
# the deletion flag drops the existing entries before reposting them
|
||||
ral = self.create_repost_doc([si, pe], delete_cancelled_entries=True)
|
||||
with self.patched_repost(fail_for=["Payment Entry"]):
|
||||
ral.submit()
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Partially Reposted")
|
||||
|
||||
si_row, pe_row = ral.vouchers
|
||||
self.assertEqual((si_row.status, pe_row.status), ("Reposted", "Failed"))
|
||||
self.assertFalse(si_row.traceback)
|
||||
self.assertIn(SIMULATED_FAILURE, pe_row.traceback)
|
||||
|
||||
# the failed voucher is rolled back to its savepoint, so its entries are back
|
||||
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pe.name}), pe_gl_entries)
|
||||
|
||||
# a retry only picks up the vouchers that are not reposted yet, and leaves the rest
|
||||
# alone entirely: they are not locked or loaded either
|
||||
with (
|
||||
patch(f"{REPOST_MODULE}._lock_vouchers", side_effect=_lock_vouchers) as lock_vouchers,
|
||||
self.patched_repost() as retried,
|
||||
):
|
||||
ral.start_repost()
|
||||
|
||||
self.assertEqual(retried, [pe.name])
|
||||
self.assertEqual([x.voucher_no for x in lock_vouchers.call_args.args[0]], [pe.name])
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
for voucher in ral.vouchers:
|
||||
self.assertEqual(voucher.status, "Reposted")
|
||||
self.assertFalse(voucher.traceback)
|
||||
|
||||
def test_13_status_of_a_run_that_could_not_finish(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
|
||||
ral = self.create_repost_doc([si, pe])
|
||||
with self.patched_repost(fail_for=["Payment Entry"]):
|
||||
ral.submit()
|
||||
|
||||
ral.reload()
|
||||
|
||||
# the job dies after the loop committed the invoice, e.g. killed or timed out
|
||||
try:
|
||||
frappe.throw(SIMULATED_FAILURE)
|
||||
except frappe.ValidationError:
|
||||
_record_repost_failure(ral)
|
||||
|
||||
ral.reload()
|
||||
|
||||
# progress already committed must not be reported as a total failure
|
||||
self.assertEqual(ral.status, "Partially Reposted")
|
||||
self.assertIn(SIMULATED_FAILURE, ral.error_log)
|
||||
self.assertTrue(
|
||||
frappe.db.exists("Error Log", {"reference_doctype": ral.doctype, "reference_name": ral.name})
|
||||
)
|
||||
|
||||
@change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
|
||||
def test_14_period_closed_after_the_repost_was_started(self):
|
||||
gl = qb.DocType("GL Entry")
|
||||
qb.from_(gl).delete().where(gl.company == self.company).run()
|
||||
|
||||
si = self.make_invoice()
|
||||
ral = self.create_repost_doc([si], submit=True)
|
||||
ral.db_set("status", "Failed")
|
||||
ral.vouchers[0].db_set("status", "Pending")
|
||||
|
||||
# the period is closed between the repost being started and the job running
|
||||
self.make_period_closing_voucher()
|
||||
|
||||
gl_entries = frappe.db.count("GL Entry", {"voucher_no": si.name})
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Closed fiscal year", repost, ral.name, commit=False)
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Failed")
|
||||
self.assertIn("Closed fiscal year", ral.error_log)
|
||||
|
||||
# the ledger is left exactly as it was
|
||||
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": si.name}), gl_entries)
|
||||
self.assertEqual(ral.vouchers[0].status, "Pending")
|
||||
|
||||
def test_15_failed_repost_skips_cancelled_voucher(self):
|
||||
si = self.make_invoice()
|
||||
|
||||
ral = self.create_repost_doc([si])
|
||||
with self.patched_repost(fail_for=["Sales Invoice"]):
|
||||
ral.submit()
|
||||
|
||||
ral.reload()
|
||||
self.assertEqual(ral.status, "Failed")
|
||||
|
||||
si.reload()
|
||||
si.cancel()
|
||||
|
||||
ral.start_repost()
|
||||
ral.reload()
|
||||
|
||||
# nothing was reposted, but there is nothing left to repost either
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
self.assertEqual(ral.vouchers[0].status, "Skipped")
|
||||
self.assertFalse(ral.vouchers[0].traceback)
|
||||
|
||||
def test_16_concurrent_repost_is_blocked_by_voucher_lock(self):
|
||||
si, pe = self.make_invoice_and_payment()
|
||||
ral = self.create_repost_doc([si, pe])
|
||||
|
||||
# a concurrent repost holding the lock on the second voucher
|
||||
locked_pe = frappe.get_doc(pe.doctype, pe.name)
|
||||
locked_pe.lock()
|
||||
try:
|
||||
self.assertRaises(frappe.DocumentLockedError, ral.submit)
|
||||
|
||||
# vouchers locked before the failure are released again
|
||||
self.assertFalse(frappe.get_doc(si.doctype, si.name).is_locked)
|
||||
finally:
|
||||
locked_pe.unlock()
|
||||
|
||||
def test_17_journal_entry_repost(self):
|
||||
je = make_journal_entry("_Test Bank - _TC", "_Test Cash - _TC", 500, submit=True)
|
||||
je = frappe.get_doc("Journal Entry", je.name)
|
||||
|
||||
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
|
||||
|
||||
# without the deletion flag the 2 original entries are marked as cancelled,
|
||||
# along with the 2 reverse entries booked against them
|
||||
for delete_cancelled_entries, cancelled_entries in ((False, 4), (True, 0)):
|
||||
with self.subTest(delete_cancelled_entries=delete_cancelled_entries):
|
||||
ral = self.create_repost_doc(
|
||||
[je], delete_cancelled_entries=delete_cancelled_entries, submit=True
|
||||
)
|
||||
|
||||
self.assertEqual(ral.status, "Completed")
|
||||
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
|
||||
self.assertEqual(
|
||||
frappe.db.count("GL Entry", {"voucher_no": je.name, "is_cancelled": 1}),
|
||||
cancelled_entries,
|
||||
)
|
||||
|
||||
def test_18_hook_allowed_doctype_repost(self):
|
||||
class VoucherWithCancelArg:
|
||||
doctype = "Test Repost Voucher"
|
||||
name = "TRV-00001"
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def make_gl_entries(self, cancel=0):
|
||||
self.calls.append(cancel)
|
||||
|
||||
class VoucherWithoutCancelArg(VoucherWithCancelArg):
|
||||
def make_gl_entries(self):
|
||||
self.calls.append("repost")
|
||||
|
||||
# vouchers that can reverse their own entries are asked to do so first
|
||||
doc = VoucherWithCancelArg()
|
||||
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
|
||||
self.assertEqual(doc.calls, [1, 0])
|
||||
|
||||
# nothing to reverse when the old entries are deleted
|
||||
doc = VoucherWithCancelArg()
|
||||
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=True)
|
||||
self.assertEqual(doc.calls, [0])
|
||||
|
||||
# the rest fall back to the generic reversal
|
||||
doc = VoucherWithoutCancelArg()
|
||||
with patch("erpnext.accounts.general_ledger.make_reverse_gl_entries") as make_reverse_gl_entries:
|
||||
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
|
||||
|
||||
make_reverse_gl_entries.assert_called_once_with(voucher_type=doc.doctype, voucher_no=doc.name)
|
||||
self.assertEqual(doc.calls, ["repost"])
|
||||
|
||||
|
||||
def update_repost_settings():
|
||||
allowed_types = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_rename": 1,
|
||||
"creation": "2023-07-04 14:14:01.243848",
|
||||
"doctype": "DocType",
|
||||
@@ -7,28 +8,63 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"voucher_type",
|
||||
"voucher_no"
|
||||
"column_break_ndex",
|
||||
"voucher_no",
|
||||
"reposting_status_section",
|
||||
"status",
|
||||
"traceback"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"columns": 5,
|
||||
"fieldname": "voucher_type",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Voucher Type",
|
||||
"options": "DocType"
|
||||
"options": "DocType",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ndex",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"columns": 5,
|
||||
"fieldname": "voucher_no",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Voucher No",
|
||||
"options": "voucher_type"
|
||||
"options": "voucher_type",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "reposting_status_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Reposting Status"
|
||||
},
|
||||
{
|
||||
"columns": 2,
|
||||
"default": "Pending",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "Pending\nReposted\nSkipped\nFailed",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "traceback",
|
||||
"fieldtype": "Code",
|
||||
"label": "Traceback",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-07-04 14:15:51.165584",
|
||||
"modified": "2026-07-29 02:41:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Repost Accounting Ledger Items",
|
||||
@@ -37,4 +73,4 @@
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ class RepostAccountingLedgerItems(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
voucher_no: DF.DynamicLink | None
|
||||
voucher_type: DF.Link | None
|
||||
status: DF.Literal["Pending", "Reposted", "Skipped", "Failed"]
|
||||
traceback: DF.Code | None
|
||||
voucher_no: DF.DynamicLink
|
||||
voucher_type: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
@@ -2802,12 +2802,15 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
|
||||
old_perpetual_inventory = erpnext.is_perpetual_inventory_enabled("_Test Company 1")
|
||||
frappe.local.enable_perpetual_inventory["_Test Company 1"] = 1
|
||||
old_inventory_account = frappe.db.get_value("Company", "_Test Company 1", "default_inventory_account")
|
||||
|
||||
frappe.db.set_value(
|
||||
"Company",
|
||||
"_Test Company 1",
|
||||
"stock_received_but_not_billed",
|
||||
"Stock Received But Not Billed - _TC1",
|
||||
{
|
||||
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC1",
|
||||
"default_inventory_account": "Stock In Hand - _TC1",
|
||||
},
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Company",
|
||||
@@ -2852,6 +2855,7 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
|
||||
# tear down
|
||||
frappe.local.enable_perpetual_inventory["_Test Company 1"] = old_perpetual_inventory
|
||||
frappe.db.set_value("Company", "_Test Company 1", "default_inventory_account", old_inventory_account)
|
||||
frappe.db.set_single_value("Stock Settings", "allow_negative_stock", old_negative_stock)
|
||||
|
||||
def test_sle_for_target_warehouse(self):
|
||||
|
||||
@@ -80,8 +80,7 @@
|
||||
"fieldname": "cost_center",
|
||||
"fieldtype": "Link",
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center",
|
||||
"reqd": 1
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "shipping_amount_section",
|
||||
@@ -139,18 +138,20 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-truck",
|
||||
"idx": 1,
|
||||
"modified": "2019-05-25 23:12:26.156405",
|
||||
"links": [],
|
||||
"modified": "2026-07-22 14:53:27.315435",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Shipping Rule",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -196,5 +197,8 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_order": "ASC"
|
||||
}
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
|
||||
@@ -36,18 +36,17 @@ class ShippingRule(Document):
|
||||
from erpnext.accounts.doctype.shipping_rule_condition.shipping_rule_condition import (
|
||||
ShippingRuleCondition,
|
||||
)
|
||||
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import (
|
||||
ShippingRuleCountry,
|
||||
)
|
||||
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ShippingRuleCountry
|
||||
|
||||
account: DF.Link
|
||||
calculate_based_on: DF.Literal["Fixed", "Net Total", "Net Weight"]
|
||||
company: DF.Link
|
||||
conditions: DF.Table[ShippingRuleCondition]
|
||||
cost_center: DF.Link
|
||||
cost_center: DF.Link | None
|
||||
countries: DF.Table[ShippingRuleCountry]
|
||||
disabled: DF.Check
|
||||
label: DF.Data
|
||||
project: DF.Link | None
|
||||
shipping_amount: DF.Currency
|
||||
shipping_rule_type: DF.Literal["Selling", "Buying"]
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -96,3 +96,29 @@ frappe.ui.form.on("Subscription", {
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Subscription Plan Detail", {
|
||||
plan: function (frm, cdt, cdn) {
|
||||
const row = locals[cdt][cdn];
|
||||
if (!row.plan) return;
|
||||
const requested_plan = row.plan;
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.subscription.subscription.get_plan_dimensions",
|
||||
args: {
|
||||
plan: requested_plan,
|
||||
company: frm.doc.company,
|
||||
party_type: frm.doc.party_type,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (!r.message || locals[cdt]?.[cdn]?.plan !== requested_plan) return;
|
||||
// Only fill dimensions left empty, so a manual entry or an earlier plan is never overwritten.
|
||||
for (const [dimension, value] of Object.entries(r.message)) {
|
||||
if (frm.fields_dict[dimension] && !frm.doc[dimension]) {
|
||||
frm.set_value(dimension, value);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
)
|
||||
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
|
||||
from erpnext.accounts.party import get_party_account_currency
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||
|
||||
|
||||
class InvoiceCancelled(frappe.ValidationError):
|
||||
@@ -747,6 +748,39 @@ def get_prorata_factor(
|
||||
return diff / plan_days
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_plan_dimensions(
|
||||
plan: str, company: str | None = None, party_type: str | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Resolve a plan's accounting dimensions, falling back to the plan item's company defaults."""
|
||||
plan_doc = frappe.get_cached_doc("Subscription Plan", plan)
|
||||
|
||||
dimensions = {}
|
||||
for dimension in ["cost_center", *get_accounting_dimensions()]:
|
||||
value = plan_doc.get(dimension) or get_item_dimension(plan_doc.item, dimension, company, party_type)
|
||||
if value:
|
||||
dimensions[dimension] = value
|
||||
|
||||
return dimensions
|
||||
|
||||
|
||||
def get_item_dimension(
|
||||
item_code: str, dimension: str, company: str | None, party_type: str | None
|
||||
) -> str | None:
|
||||
if not company:
|
||||
return None
|
||||
|
||||
item_defaults = get_item_defaults(item_code, company)
|
||||
if dimension != "cost_center":
|
||||
return item_defaults.get(dimension)
|
||||
|
||||
selling = item_defaults.get("selling_cost_center")
|
||||
buying = item_defaults.get("buying_cost_center")
|
||||
if party_type == "Supplier":
|
||||
return buying or selling
|
||||
return selling or buying
|
||||
|
||||
|
||||
def process_all(subscription: list, posting_date: DateTimeLikeObject | None = None) -> None:
|
||||
"""
|
||||
Task to updates the status of all `Subscription` apart from those that are cancelled
|
||||
|
||||
@@ -17,7 +17,7 @@ from frappe.utils.data import (
|
||||
nowdate,
|
||||
)
|
||||
|
||||
from erpnext.accounts.doctype.subscription.subscription import get_prorata_factor
|
||||
from erpnext.accounts.doctype.subscription.subscription import get_plan_dimensions, get_prorata_factor
|
||||
|
||||
test_dependencies = ("UOM", "Item Group", "Item")
|
||||
|
||||
@@ -583,6 +583,48 @@ class TestSubscription(FrappeTestCase):
|
||||
subscription.process(nowdate())
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_plan_dimensions_resolve_from_plan_then_item(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
# Plan-level cost center takes precedence.
|
||||
create_plan(plan_name="_Test Sub Plan CC", cost=100, currency="INR")
|
||||
frappe.db.set_value(
|
||||
"Subscription Plan", "_Test Sub Plan CC", "cost_center", "_Test Cost Center - _TC"
|
||||
)
|
||||
self.assertEqual(
|
||||
get_plan_dimensions("_Test Sub Plan CC", "_Test Company", "Customer").get("cost_center"),
|
||||
"_Test Cost Center - _TC",
|
||||
)
|
||||
|
||||
# No plan cost center: fall back to the item's company default (selling vs buying by party type).
|
||||
item = make_item(
|
||||
"_Test Sub Dimension Item",
|
||||
{
|
||||
"is_stock_item": 0,
|
||||
"item_defaults": [
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"default_warehouse": "_Test Warehouse - _TC",
|
||||
"selling_cost_center": "_Test Cost Center - _TC",
|
||||
"buying_cost_center": "_Test Cost Center 2 - _TC",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
create_plan(plan_name="_Test Sub Plan No CC", cost=100, currency="INR", item=item.name)
|
||||
|
||||
self.assertEqual(
|
||||
get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Customer").get("cost_center"),
|
||||
"_Test Cost Center - _TC",
|
||||
)
|
||||
self.assertEqual(
|
||||
get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Supplier").get("cost_center"),
|
||||
"_Test Cost Center 2 - _TC",
|
||||
)
|
||||
|
||||
# Without a company the item fallback is skipped.
|
||||
self.assertNotIn("cost_center", get_plan_dimensions("_Test Sub Plan No CC"))
|
||||
|
||||
|
||||
def make_plans():
|
||||
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")
|
||||
|
||||
@@ -106,6 +106,7 @@ class ReceivablePayableReport:
|
||||
|
||||
def get_data(self):
|
||||
self.get_sales_invoices_or_customers_based_on_sales_person()
|
||||
self.get_invoices_based_on_sales_partner()
|
||||
|
||||
# Get invoice details like bill_no, due_date etc for all invoices
|
||||
self.get_invoice_details()
|
||||
@@ -241,6 +242,12 @@ class ReceivablePayableReport:
|
||||
):
|
||||
return
|
||||
|
||||
if self.filters.get("sales_partner"):
|
||||
# a return is folded onto the invoice it settles, so match that invoice's
|
||||
# partner (like the sales_person filter above), not the return's own
|
||||
if ple.against_voucher_no not in self.sales_partner_invoices:
|
||||
return
|
||||
|
||||
if self.filters.get("ignore_accounts"):
|
||||
key = (ple.against_voucher_type, ple.against_voucher_no, ple.party)
|
||||
else:
|
||||
@@ -469,7 +476,7 @@ class ReceivablePayableReport:
|
||||
"company": self.filters.company,
|
||||
"docstatus": 1,
|
||||
},
|
||||
fields=["name", "due_date", "po_no"],
|
||||
fields=["name", "due_date", "po_no", "sales_partner"],
|
||||
)
|
||||
for d in si_list:
|
||||
self.invoice_details.setdefault(d.name, d)
|
||||
@@ -903,6 +910,22 @@ class ReceivablePayableReport:
|
||||
for d in records:
|
||||
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
|
||||
|
||||
def get_invoices_based_on_sales_partner(self):
|
||||
if not self.filters.get("sales_partner"):
|
||||
return
|
||||
|
||||
self.sales_partner_invoices = set(
|
||||
frappe.get_all(
|
||||
"Sales Invoice",
|
||||
filters={
|
||||
"sales_partner": self.filters.get("sales_partner"),
|
||||
"docstatus": 1,
|
||||
"company": self.filters.company,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
def prepare_conditions(self):
|
||||
self.qb_selection_filter = []
|
||||
self.or_filters = []
|
||||
@@ -1005,15 +1028,6 @@ class ReceivablePayableReport:
|
||||
|
||||
self.qb_selection_filter.append(Criterion.any([customer_ptt, sales_ptt]))
|
||||
|
||||
if self.filters.get("sales_partner"):
|
||||
self.qb_selection_filter.append(
|
||||
self.ple.party.isin(
|
||||
qb.from_(self.customer)
|
||||
.select(self.customer.name)
|
||||
.where(self.customer.default_sales_partner == self.filters.get("sales_partner"))
|
||||
)
|
||||
)
|
||||
|
||||
def exclude_employee_transaction(self):
|
||||
self.qb_selection_filter.append(self.ple.party_type != "Employee")
|
||||
|
||||
@@ -1113,9 +1127,6 @@ class ReceivablePayableReport:
|
||||
if self.account_type == "Receivable":
|
||||
fields = ["customer_name", "territory", "customer_group", "customer_primary_contact"]
|
||||
|
||||
if self.filters.get("sales_partner"):
|
||||
fields.append("default_sales_partner")
|
||||
|
||||
self.party_details[party] = frappe.db.get_value(
|
||||
"Customer",
|
||||
party,
|
||||
@@ -1242,7 +1253,7 @@ class ReceivablePayableReport:
|
||||
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
|
||||
|
||||
if self.filters.sales_partner:
|
||||
self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
|
||||
self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data")
|
||||
|
||||
if self.filters.account_type == "Payable":
|
||||
self.add_column(
|
||||
|
||||
@@ -7,6 +7,7 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.accounts_receivable.accounts_receivable import execute
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
|
||||
@@ -1303,3 +1304,61 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
|
||||
self.assertIn(original_customer, parties)
|
||||
self.assertNotIn(second_customer, parties)
|
||||
self.assertEqual(allowed_invoice.customer, original_customer)
|
||||
|
||||
def test_receivable_filtered_by_sales_partner(self):
|
||||
frappe.set_user("Administrator")
|
||||
partner_a, partner_b = "_Test AR Sales Partner A", "_Test AR Sales Partner B"
|
||||
for partner in (partner_a, partner_b):
|
||||
if not frappe.db.exists("Sales Partner", partner):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Sales Partner",
|
||||
"partner_name": partner,
|
||||
"commission_rate": 0,
|
||||
"territory": "All Territories",
|
||||
}
|
||||
).insert()
|
||||
|
||||
def _si(sales_partner):
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True, qty=2)
|
||||
si.sales_partner = sales_partner
|
||||
return si.save().submit()
|
||||
|
||||
partner_a_si = _si(partner_a)
|
||||
partner_b_si = _si(partner_b)
|
||||
no_partner_si = _si(None)
|
||||
|
||||
# a return is folded onto the invoice it settles, so it nets against that
|
||||
# invoice's partner even when the return's own partner is cleared
|
||||
no_partner_return = make_return_doc("Sales Invoice", partner_a_si.name)
|
||||
no_partner_return.sales_partner = None
|
||||
no_partner_return.items[0].qty = -1
|
||||
no_partner_return.update_outstanding_for_self = 0
|
||||
no_partner_return.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Customer",
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
def rows_for(partner):
|
||||
return {
|
||||
r.voucher_no: r
|
||||
for r in execute({**filters, "sales_partner": partner})[1]
|
||||
if r.get("voucher_no")
|
||||
}
|
||||
|
||||
rows_a = rows_for(partner_a)
|
||||
self.assertIn(partner_a_si.name, rows_a)
|
||||
self.assertEqual(rows_a[partner_a_si.name].sales_partner, partner_a)
|
||||
self.assertNotIn(partner_b_si.name, rows_a)
|
||||
self.assertNotIn(no_partner_si.name, rows_a)
|
||||
self.assertNotIn(no_partner_return.name, rows_a)
|
||||
self.assertEqual(rows_a[partner_a_si.name].credit_note, 100)
|
||||
self.assertEqual(rows_a[partner_a_si.name].outstanding, 100)
|
||||
|
||||
rows_b = rows_for(partner_b)
|
||||
self.assertIn(partner_b_si.name, rows_b)
|
||||
self.assertNotIn(partner_a_si.name, rows_b)
|
||||
|
||||
@@ -132,8 +132,8 @@ class AccountsReceivableSummary(ReceivablePayableReport):
|
||||
if row.sales_person:
|
||||
self.party_total[row.party].sales_person.append(row.get("sales_person", ""))
|
||||
|
||||
if self.filters.sales_partner:
|
||||
self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner", "")
|
||||
if self.filters.sales_partner and row.get("sales_partner"):
|
||||
self.party_total[row.party]["sales_partner"] = row.get("sales_partner")
|
||||
|
||||
def get_columns(self):
|
||||
self.columns = []
|
||||
@@ -191,7 +191,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
|
||||
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
|
||||
|
||||
if self.filters.sales_partner:
|
||||
self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
|
||||
self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data")
|
||||
|
||||
else:
|
||||
self.add_column(
|
||||
|
||||
@@ -193,3 +193,42 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
|
||||
report = execute(filters)
|
||||
rpt_output = report[1]
|
||||
self.assertEqual(len(rpt_output), 0)
|
||||
|
||||
def test_03_summary_sales_partner_column(self):
|
||||
partner = "_Test AR Summary Sales Partner"
|
||||
if not frappe.db.exists("Sales Partner", partner):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Sales Partner",
|
||||
"partner_name": partner,
|
||||
"commission_rate": 0,
|
||||
"territory": "All Territories",
|
||||
}
|
||||
).insert()
|
||||
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debit_to,
|
||||
posting_date=today(),
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=200,
|
||||
price_list_rate=200,
|
||||
do_not_submit=True,
|
||||
)
|
||||
si.sales_partner = partner
|
||||
si.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"customer": self.customer,
|
||||
"posting_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"sales_partner": partner,
|
||||
}
|
||||
|
||||
rpt_output = execute(filters)[1]
|
||||
self.assertEqual(len(rpt_output), 1)
|
||||
self.assertEqual(rpt_output[0].get("sales_partner"), partner)
|
||||
|
||||
@@ -7,6 +7,7 @@ from frappe import _
|
||||
from frappe.utils import flt, getdate
|
||||
from pypika import Tuple
|
||||
|
||||
from erpnext.accounts.report.utils import validate_mandatory_date_range
|
||||
from erpnext.accounts.utils import get_currency_precision
|
||||
|
||||
|
||||
@@ -33,9 +34,7 @@ def execute(filters=None):
|
||||
|
||||
def validate_filters(filters):
|
||||
"""Validate if dates are properly set"""
|
||||
filters = frappe._dict(filters or {})
|
||||
if filters.from_date > filters.to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
validate_mandatory_date_range(filters or {})
|
||||
|
||||
|
||||
def get_result(filters, tds_accounts, tax_category_map, net_total_map):
|
||||
|
||||
@@ -5,6 +5,7 @@ from erpnext.accounts.report.tax_withholding_details.tax_withholding_details imp
|
||||
get_result,
|
||||
get_tds_docs,
|
||||
)
|
||||
from erpnext.accounts.report.utils import validate_mandatory_date_range
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
|
||||
@@ -33,8 +34,7 @@ def execute(filters=None):
|
||||
|
||||
def validate_filters(filters):
|
||||
"""Validate if dates are properly set and lie in the same fiscal year"""
|
||||
if filters.from_date > filters.to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
validate_mandatory_date_range(filters)
|
||||
|
||||
from_year = get_fiscal_year(filters.from_date)[0]
|
||||
to_year = get_fiscal_year(filters.to_date)[0]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt, formatdate, get_datetime_str, get_table_name
|
||||
@@ -16,6 +17,19 @@ from erpnext.setup.utils import get_exchange_rate
|
||||
__exchange_rates = {}
|
||||
|
||||
|
||||
def validate_mandatory_date_range(filters, from_field="from_date", to_field="to_date"):
|
||||
from_date = filters.get(from_field)
|
||||
to_date = filters.get(to_field)
|
||||
|
||||
if not from_date or not to_date:
|
||||
frappe.throw(
|
||||
_("{0} and {1} are mandatory").format(frappe.bold(_("From Date")), frappe.bold(_("To Date")))
|
||||
)
|
||||
|
||||
if from_date > to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
|
||||
|
||||
def get_currency(filters):
|
||||
"""
|
||||
Returns a dictionary containing currency information. The keys of the dict are
|
||||
|
||||
@@ -1239,7 +1239,7 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
|
||||
return {
|
||||
"company": purchase_doc.company,
|
||||
"purchase_date": purchase_doc.get("posting_date"),
|
||||
"gross_purchase_amount": flt(first_item.base_net_amount),
|
||||
"gross_purchase_amount": flt(first_item.valuation_rate) * flt(first_item.qty),
|
||||
"asset_quantity": first_item.qty,
|
||||
"cost_center": first_item.cost_center or purchase_doc.get("cost_center"),
|
||||
"asset_location": first_item.get("asset_location"),
|
||||
|
||||
@@ -306,7 +306,9 @@ def bom(doctype, txt, searchfield, start, page_len, filters):
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_project_name(doctype, txt, searchfield, start, page_len, filters):
|
||||
def get_project_name(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None
|
||||
):
|
||||
proj = qb.DocType("Project")
|
||||
qb_filter_and_conditions = []
|
||||
qb_filter_or_conditions = []
|
||||
@@ -321,7 +323,7 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
|
||||
if filters.get("company"):
|
||||
qb_filter_and_conditions.append(proj.company == filters.get("company"))
|
||||
|
||||
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled"]))
|
||||
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled", "On hold"]))
|
||||
|
||||
q = qb.from_(proj)
|
||||
|
||||
|
||||
@@ -177,13 +177,18 @@ class StockController(AccountsController):
|
||||
)
|
||||
|
||||
is_asset_pr = any(d.get("is_fixed_asset") for d in self.get("items"))
|
||||
need_inventory_map = (self.get_stock_items() or self.get("packed_items")) and cint(
|
||||
erpnext.is_perpetual_inventory_enabled(self.company)
|
||||
)
|
||||
|
||||
if (
|
||||
cint(erpnext.is_perpetual_inventory_enabled(self.company))
|
||||
or provisional_accounting_for_non_stock_items
|
||||
or is_asset_pr
|
||||
):
|
||||
warehouse_account = get_warehouse_account_map(self.company)
|
||||
warehouse_account = frappe._dict()
|
||||
if need_inventory_map:
|
||||
warehouse_account = get_warehouse_account_map(self.company)
|
||||
|
||||
if self.docstatus == 1:
|
||||
if not gl_entries:
|
||||
|
||||
@@ -67,6 +67,14 @@ class PlantFloor(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_stock_summary(warehouse, start=0, item_code=None, item_group=None):
|
||||
frappe.has_permission("Warehouse", doc=warehouse, throw=True)
|
||||
|
||||
if item_code:
|
||||
frappe.has_permission("Item", doc=item_code, throw=True)
|
||||
|
||||
if item_group:
|
||||
frappe.has_permission("Item Group", doc=item_group, throw=True)
|
||||
|
||||
stock_details = get_stock_details(warehouse, start=start, item_code=item_code, item_group=item_group)
|
||||
|
||||
max_count = 0.0
|
||||
|
||||
@@ -46,3 +46,60 @@ frappe.views.calendar["Work Order"] = {
|
||||
],
|
||||
get_events_method: "frappe.desk.calendar.get_events",
|
||||
};
|
||||
|
||||
const WORK_ORDER_GANTT_COLORS = {
|
||||
Draft: "red",
|
||||
Stopped: "red",
|
||||
"Not Started": "red",
|
||||
"In Process": "orange",
|
||||
Completed: "green",
|
||||
"Stock Reserved": "blue",
|
||||
"Stock Partially Reserved": "orange",
|
||||
Cancelled: "gray",
|
||||
};
|
||||
|
||||
if (!frappe.views.GanttView.prototype._work_order_status_colors) {
|
||||
frappe.views.GanttView.prototype._work_order_status_colors = true;
|
||||
|
||||
const prepare_tasks = frappe.views.GanttView.prototype.prepare_tasks;
|
||||
frappe.views.GanttView.prototype.prepare_tasks = function () {
|
||||
prepare_tasks.call(this);
|
||||
if (this.doctype === "Work Order") {
|
||||
set_work_order_bar_classes(this);
|
||||
}
|
||||
};
|
||||
|
||||
const set_colors = frappe.views.GanttView.prototype.set_colors;
|
||||
frappe.views.GanttView.prototype.set_colors = function () {
|
||||
set_colors.call(this);
|
||||
if (this.doctype === "Work Order") {
|
||||
set_work_order_bar_styles(this);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function set_work_order_bar_classes(view) {
|
||||
view.tasks.forEach((task, idx) => {
|
||||
const color = WORK_ORDER_GANTT_COLORS[view.data[idx].status];
|
||||
if (color) {
|
||||
task.custom_class = "wo-" + color;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function set_work_order_bar_styles(view) {
|
||||
const style = [...new Set(Object.values(WORK_ORDER_GANTT_COLORS))]
|
||||
.map(
|
||||
(color) => `
|
||||
.gantt .bar-wrapper.wo-${color} .bar {
|
||||
fill: var(--${color}-300);
|
||||
}
|
||||
.gantt .bar-wrapper.wo-${color} .bar-progress {
|
||||
fill: var(--${color}-300);
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
view.$result.prepend(`<style>${style}</style>`);
|
||||
}
|
||||
|
||||
@@ -445,3 +445,5 @@ erpnext.patches.v16_0.backfill_pick_list_transferred_qty
|
||||
erpnext.patches.v16_0.access_control_for_project_users
|
||||
erpnext.patches.v16_0.rename_ar_ap_ageing_filter
|
||||
erpnext.patches.v15_0.fix_titles
|
||||
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
|
||||
erpnext.patches.v16_0.merge_seeded_item_group_root
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Coalesce
|
||||
|
||||
|
||||
def execute():
|
||||
"""Backfill the statuses of documents reposted before those fields existed.
|
||||
|
||||
Without it they show up as drafts and are offered a `Start Reposting` button that would
|
||||
repost vouchers which are already reposted.
|
||||
"""
|
||||
ral = frappe.qb.DocType("Repost Accounting Ledger")
|
||||
items = frappe.qb.DocType("Repost Accounting Ledger Items")
|
||||
|
||||
reposted = (
|
||||
frappe.qb.from_(ral).select(ral.name).where((ral.docstatus == 1) & (Coalesce(ral.status, "") == ""))
|
||||
)
|
||||
frappe.qb.update(items).set(items.status, "Reposted").where(items.parent.isin(reposted)).run()
|
||||
|
||||
for docstatus, status in ((1, "Completed"), (2, "Cancelled")):
|
||||
(
|
||||
frappe.qb.update(ral)
|
||||
.set(ral.status, status)
|
||||
.where((ral.docstatus == docstatus) & (Coalesce(ral.status, "") == ""))
|
||||
.run()
|
||||
)
|
||||
23
erpnext/patches/v16_0/merge_seeded_item_group_root.py
Normal file
23
erpnext/patches/v16_0/merge_seeded_item_group_root.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import frappe
|
||||
from frappe.utils.nestedset import get_root_of
|
||||
|
||||
SEEDED_ROOT = "All Item Groups"
|
||||
|
||||
|
||||
def execute():
|
||||
"""Collapse the "All Item Groups" node seeded under a pre-existing root.
|
||||
|
||||
Setup seeding always inserted "All Item Groups" as a parentless group. On a
|
||||
site where another app had already created the root (under a translated
|
||||
name), it was re-parented instead, leaving a second group-root holding the
|
||||
standard Item Groups.
|
||||
"""
|
||||
root = get_root_of("Item Group")
|
||||
if not root or root == SEEDED_ROOT:
|
||||
return
|
||||
|
||||
seeded = frappe.db.get_value("Item Group", SEEDED_ROOT, ["parent_item_group", "is_group"], as_dict=True)
|
||||
if not seeded or not seeded.is_group or seeded.parent_item_group != root:
|
||||
return
|
||||
|
||||
frappe.rename_doc("Item Group", SEEDED_ROOT, root, merge=True, show_alert=False)
|
||||
@@ -282,6 +282,23 @@ class TestProject(FrappeTestCase):
|
||||
project.save()
|
||||
self.assertEqual(project.percent_complete, 100)
|
||||
|
||||
def test_on_hold_project_keeps_status(self):
|
||||
project, tasks = self._project_with_tasks("Task Completion", 4)
|
||||
|
||||
# an On hold project is not auto-flipped to Completed even at 100%
|
||||
project.status = "On hold"
|
||||
for task in tasks:
|
||||
frappe.db.set_value("Task", task, "status", "Completed")
|
||||
project.update_percent_complete()
|
||||
self.assertEqual(project.percent_complete, 100)
|
||||
self.assertEqual(project.status, "On hold")
|
||||
|
||||
# nor auto-flipped back to Open when below 100%
|
||||
frappe.db.set_value("Task", tasks[0], "status", "Open")
|
||||
project.update_percent_complete()
|
||||
self.assertEqual(project.percent_complete, 75)
|
||||
self.assertEqual(project.status, "On hold")
|
||||
|
||||
def _create_portal_user(self, email):
|
||||
"""A user with no Project-related role, so read access can only come from
|
||||
control_access_for_project_users() sharing the doc with them."""
|
||||
|
||||
@@ -15,6 +15,12 @@ frappe.ui.form.on("Task", {
|
||||
},
|
||||
|
||||
onload: function (frm) {
|
||||
frm.set_query("project", function () {
|
||||
return {
|
||||
query: "erpnext.controllers.queries.get_project_name",
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("task", "depends_on", function () {
|
||||
let filters = {
|
||||
name: ["!=", frm.doc.name],
|
||||
|
||||
@@ -30,6 +30,7 @@ frappe.ui.form.on("Timesheet", {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
status: "Open",
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -122,6 +123,7 @@ frappe.ui.form.on("Timesheet", {
|
||||
return {
|
||||
filters: {
|
||||
customer: doc.customer,
|
||||
status: "Open",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ frappe.query_reports["Project Summary"] = {
|
||||
fieldname: "status",
|
||||
label: __("Status"),
|
||||
fieldtype: "Select",
|
||||
options: "\nOpen\nCompleted\nCancelled",
|
||||
options: "\nOpen\nOn hold\nCompleted\nCancelled",
|
||||
default: "Open",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -238,7 +238,7 @@ def get_invoice_summary(items, taxes):
|
||||
# Preflight for successful e-invoice export.
|
||||
def sales_invoice_validate(doc):
|
||||
# Validate company
|
||||
if doc.doctype != "Sales Invoice":
|
||||
if doc.doctype != "Sales Invoice" or doc.is_opening == "Yes":
|
||||
return
|
||||
|
||||
if not doc.company_address:
|
||||
@@ -322,7 +322,7 @@ def sales_invoice_validate(doc):
|
||||
# Ensure payment details are valid for e-invoice.
|
||||
def sales_invoice_on_submit(doc, method):
|
||||
# Validate payment details
|
||||
if get_company_country(doc.company) not in [
|
||||
if doc.is_opening == "Yes" or get_company_country(doc.company) not in [
|
||||
"Italy",
|
||||
"Italia",
|
||||
"Italian Republic",
|
||||
@@ -388,7 +388,7 @@ def generate_single_invoice(docname):
|
||||
|
||||
# Delete e-invoice attachment on cancel.
|
||||
def sales_invoice_on_cancel(doc, method):
|
||||
if get_company_country(doc.company) not in [
|
||||
if doc.is_opening == "Yes" or get_company_country(doc.company) not in [
|
||||
"Italy",
|
||||
"Italia",
|
||||
"Italian Republic",
|
||||
|
||||
@@ -147,6 +147,9 @@ class Quotation(SellingController):
|
||||
|
||||
make_packing_list(self)
|
||||
|
||||
def after_insert(self):
|
||||
self.carry_forward_communication()
|
||||
|
||||
def before_submit(self):
|
||||
self.set_has_alternative_item()
|
||||
|
||||
@@ -292,7 +295,6 @@ class Quotation(SellingController):
|
||||
# update enquiry status
|
||||
self.update_opportunity("Quotation")
|
||||
self.update_lead()
|
||||
self.carry_forward_communication()
|
||||
|
||||
def on_cancel(self):
|
||||
if self.lost_reasons:
|
||||
|
||||
@@ -16,6 +16,8 @@ from frappe.utils.nestedset import (
|
||||
|
||||
test_records = frappe.get_test_records("Item Group")
|
||||
|
||||
TRANSLATED_ROOT = "Todos os Grupos de Itens"
|
||||
|
||||
|
||||
class TestItem(unittest.TestCase):
|
||||
def test_basic_tree(self, records=None):
|
||||
@@ -234,3 +236,46 @@ class TestItem(unittest.TestCase):
|
||||
"_Test Item Group B - 3",
|
||||
merge=True,
|
||||
)
|
||||
|
||||
def test_patch_merges_seeded_root_into_existing_root(self):
|
||||
from erpnext.patches.v16_0.merge_seeded_item_group_root import execute
|
||||
|
||||
self.nest_root_under(TRANSLATED_ROOT)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Item Group", "All Item Groups", "parent_item_group"), TRANSLATED_ROOT
|
||||
)
|
||||
|
||||
execute()
|
||||
|
||||
self.assertFalse(frappe.db.exists("Item Group", "All Item Groups"))
|
||||
self.assertEqual(self.get_root_names(), [TRANSLATED_ROOT])
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Item Group", "_Test Item Group B", "parent_item_group"), TRANSLATED_ROOT
|
||||
)
|
||||
self.test_basic_tree()
|
||||
|
||||
# restore the original root name for the tests that follow
|
||||
frappe.rename_doc("Item Group", TRANSLATED_ROOT, "All Item Groups")
|
||||
self.assertEqual(self.get_root_names(), ["All Item Groups"])
|
||||
self.test_basic_tree()
|
||||
|
||||
def nest_root_under(self, new_root):
|
||||
"""Recreate the tree left behind by seeding a root under a pre-existing one."""
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": new_root,
|
||||
"is_group": 1,
|
||||
"parent_item_group": "All Item Groups",
|
||||
}
|
||||
).insert()
|
||||
|
||||
ig = frappe.qb.DocType("Item Group")
|
||||
frappe.qb.update(ig).set(ig.parent_item_group, "").where(ig.name == new_root).run()
|
||||
frappe.qb.update(ig).set(ig.parent_item_group, new_root).where(ig.name == "All Item Groups").run()
|
||||
rebuild_tree("Item Group", "parent_item_group")
|
||||
|
||||
def get_root_names(self):
|
||||
return frappe.db.sql_list(
|
||||
"""select name from `tabItem Group` where ifnull(parent_item_group, '')=''"""
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from frappe.desk.doctype.global_search_settings.global_search_settings import (
|
||||
)
|
||||
from frappe.desk.page.setup_wizard.setup_wizard import make_records
|
||||
from frappe.utils import cstr, getdate
|
||||
from frappe.utils.nestedset import get_root_of
|
||||
|
||||
from erpnext.accounts.doctype.account.account import RootNotEditable
|
||||
from erpnext.regional.address_template.setup import set_up_address_templates
|
||||
@@ -24,46 +25,48 @@ def read_lines(filename: str) -> list[str]:
|
||||
|
||||
|
||||
def install(country=None):
|
||||
root_item_group = get_root_of("Item Group") or _("All Item Groups")
|
||||
records = [
|
||||
# ensure at least an empty Address Template exists for this Country
|
||||
{"doctype": "Address Template", "country": country},
|
||||
# item group
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("All Item Groups"),
|
||||
"item_group_name": root_item_group,
|
||||
"is_group": 1,
|
||||
"parent_item_group": "",
|
||||
"__condition": lambda: not frappe.db.exists("Item Group", root_item_group),
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Products"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
"show_in_website": 1,
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Raw Material"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Services"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Sub Assemblies"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Consumable"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
},
|
||||
# Stock Entry Type
|
||||
{
|
||||
|
||||
@@ -79,10 +79,13 @@ def get_warehouse_account(warehouse, warehouse_account=None):
|
||||
account = get_company_default_inventory_account(warehouse.company)
|
||||
|
||||
if not account and warehouse.company:
|
||||
account = frappe.db.get_value(
|
||||
"Account", {"account_type": "Stock", "is_group": 0, "company": warehouse.company}, "name"
|
||||
inventory_accounts = frappe.get_all(
|
||||
"Account", {"account_type": "Stock", "is_group": 0, "company": warehouse.company}, pluck="name"
|
||||
)
|
||||
|
||||
if len(inventory_accounts) == 1:
|
||||
account = inventory_accounts[0]
|
||||
|
||||
if not account and warehouse.company and not warehouse.is_group:
|
||||
frappe.throw(
|
||||
_("Please set Account in Warehouse {0} or Default Inventory Account in Company {1}").format(
|
||||
|
||||
@@ -706,6 +706,76 @@ class TestDeliveryNote(FrappeTestCase):
|
||||
|
||||
self.assertEqual(gle_warehouse_amount, 1400)
|
||||
|
||||
def test_return_bundle_voucher_detail_no_as_packed_item(self):
|
||||
"""Return bundle whose voucher_detail_no is the Packed Item (SLE-driven path) must still value on repost."""
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
packed_item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "BATCH-DN-RET-VDN-.#####",
|
||||
}
|
||||
).name
|
||||
bundle_item = make_item(properties={"is_stock_item": 0, "is_sales_item": 1}).name
|
||||
make_product_bundle(bundle_item, [packed_item], qty=20)
|
||||
|
||||
make_stock_entry(item_code=packed_item, target=warehouse, qty=60, basic_rate=35)
|
||||
|
||||
dn = create_delivery_note(item_code=bundle_item, warehouse=warehouse, qty=3)
|
||||
|
||||
return_dn = make_sales_return(dn.name)
|
||||
return_dn.items[0].qty = -2
|
||||
return_dn.submit()
|
||||
return_dn.reload()
|
||||
|
||||
packed_row = return_dn.packed_items[0]
|
||||
bundle = frappe.get_doc("Serial and Batch Bundle", packed_row.serial_and_batch_bundle)
|
||||
|
||||
# Reproduce the reported state: bundle points at the Packed Item (not the DN Item), valuation at 0.
|
||||
bundle.db_set("voucher_detail_no", packed_row.name)
|
||||
bundle.db_set({"avg_rate": 0, "total_amount": 0})
|
||||
for entry in bundle.entries:
|
||||
entry.db_set({"incoming_rate": 0, "stock_value_difference": 0})
|
||||
packed_row.db_set("incoming_rate", 0)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_type": "Delivery Note",
|
||||
"voucher_no": return_dn.name,
|
||||
"item_code": packed_item,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
{"incoming_rate": 0, "stock_value_difference": 0},
|
||||
)
|
||||
|
||||
frappe.get_doc(
|
||||
doctype="Repost Item Valuation",
|
||||
based_on="Transaction",
|
||||
voucher_type="Delivery Note",
|
||||
voucher_no=return_dn.name,
|
||||
posting_date=return_dn.posting_date,
|
||||
posting_time=return_dn.posting_time,
|
||||
).submit()
|
||||
|
||||
bundle.reload()
|
||||
self.assertEqual(flt(bundle.avg_rate), 35)
|
||||
|
||||
incoming_rate, stock_value_difference = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_type": "Delivery Note",
|
||||
"voucher_no": return_dn.name,
|
||||
"item_code": packed_item,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
["incoming_rate", "stock_value_difference"],
|
||||
)
|
||||
self.assertEqual(flt(incoming_rate), 35)
|
||||
self.assertEqual(flt(stock_value_difference), 1400)
|
||||
|
||||
def test_bin_details_of_packed_item(self):
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
@@ -194,8 +194,10 @@ class TestLandedCostVoucher(FrappeTestCase):
|
||||
|
||||
epi = is_perpetual_inventory_enabled(company_a)
|
||||
company_doc = frappe.get_doc("Company", company_a)
|
||||
old_inventory_account = company_doc.default_inventory_account
|
||||
company_doc.enable_perpetual_inventory = 1
|
||||
company_doc.stock_received_but_not_billed = srbnb
|
||||
company_doc.default_inventory_account = "Stock In Hand - _TC"
|
||||
company_doc.save()
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
@@ -223,7 +225,11 @@ class TestLandedCostVoucher(FrappeTestCase):
|
||||
distribute_landed_cost_on_items(lcv)
|
||||
lcv.submit()
|
||||
|
||||
frappe.db.set_value("Company", company_a, "enable_perpetual_inventory", epi)
|
||||
frappe.db.set_value(
|
||||
"Company",
|
||||
company_a,
|
||||
{"enable_perpetual_inventory": epi, "default_inventory_account": old_inventory_account},
|
||||
)
|
||||
frappe.local.enable_perpetual_inventory = {}
|
||||
|
||||
def test_landed_cost_voucher_for_zero_purchase_rate(self):
|
||||
|
||||
@@ -404,29 +404,10 @@ class PurchaseReceipt(BuyingController):
|
||||
self.set_consumed_qty_in_subcontract_order()
|
||||
self.reserve_stock_for_sales_order()
|
||||
|
||||
def check_next_docstatus(self):
|
||||
submit_rv = frappe.db.sql(
|
||||
"""select t1.name
|
||||
from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2
|
||||
where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""",
|
||||
(self.name),
|
||||
)
|
||||
if submit_rv:
|
||||
frappe.throw(_("Purchase Invoice {0} is already submitted").format(self.submit_rv[0][0]))
|
||||
|
||||
def on_cancel(self):
|
||||
super().on_cancel()
|
||||
|
||||
self.check_on_hold_or_closed_status()
|
||||
# Check if Purchase Invoice has been submitted against current Purchase Order
|
||||
submitted = frappe.db.sql(
|
||||
"""select t1.name
|
||||
from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2
|
||||
where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""",
|
||||
self.name,
|
||||
)
|
||||
if submitted:
|
||||
frappe.throw(_("Purchase Invoice {0} is already submitted").format(submitted[0][0]))
|
||||
|
||||
self.update_prevdoc_status()
|
||||
self.update_billing_status()
|
||||
|
||||
@@ -3263,11 +3263,14 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
|
||||
old_perpetual_inventory = erpnext.is_perpetual_inventory_enabled("_Test Company")
|
||||
frappe.local.enable_perpetual_inventory["_Test Company"] = 1
|
||||
old_inventory_account = frappe.db.get_value("Company", "_Test Company", "default_inventory_account")
|
||||
frappe.db.set_value(
|
||||
"Company",
|
||||
"_Test Company",
|
||||
"stock_received_but_not_billed",
|
||||
"Stock Received But Not Billed - _TC",
|
||||
{
|
||||
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC",
|
||||
"default_inventory_account": "Stock In Hand - _TC",
|
||||
},
|
||||
)
|
||||
|
||||
pr = make_purchase_receipt(qty=10, rate=1000, do_not_submit=1)
|
||||
@@ -3296,13 +3299,14 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True, as_dict=False)
|
||||
warehouse_account = get_warehouse_account_map("_Test Company")
|
||||
expected_gle = (
|
||||
("Stock Received But Not Billed - _TC", 0, 10000, "Main - _TC"),
|
||||
("Freight and Forwarding Charges - _TC", 0, 2000, "Main - _TC"),
|
||||
("Expenses Included In Valuation - _TC", 0, 2000, "Main - _TC"),
|
||||
(warehouse_account[pr.items[0].warehouse]["account"], 14000, 0, "Main - _TC"),
|
||||
("Stock Received But Not Billed - _TC", 0.0, 10000.0, "Main - _TC"),
|
||||
("Freight and Forwarding Charges - _TC", 0.0, 2000.0, "Main - _TC"),
|
||||
("Expenses Included In Valuation - _TC", 0.0, 2000.0, "Main - _TC"),
|
||||
(warehouse_account[pr.items[0].warehouse]["account"], 14000.0, 0.0, "Main - _TC"),
|
||||
)
|
||||
self.assertSequenceEqual(expected_gle, gl_entries)
|
||||
self.assertCountEqual(expected_gle, gl_entries)
|
||||
frappe.local.enable_perpetual_inventory["_Test Company"] = old_perpetual_inventory
|
||||
frappe.db.set_value("Company", "_Test Company", "default_inventory_account", old_inventory_account)
|
||||
|
||||
def test_manufacturing_and_expiry_date_for_batch(self):
|
||||
item = make_item(
|
||||
@@ -5446,6 +5450,32 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account)
|
||||
self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2)
|
||||
|
||||
def test_cancel_blocked_by_submitted_invoice_rolls_back(self):
|
||||
"""A submitted Purchase Invoice must block cancelling its Purchase Receipt. Frappe's backlink
|
||||
check rejects the cancel only after on_cancel has run stock, GL, and status work, so the whole
|
||||
transaction has to roll back: the receipt stays submitted with no leaked ledger entries."""
|
||||
pr = make_purchase_receipt()
|
||||
pi = make_purchase_invoice(pr.name)
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
pr.reload()
|
||||
status_before = pr.status
|
||||
sle_before = frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name})
|
||||
gle_before = frappe.db.count("GL Entry", {"voucher_no": pr.name})
|
||||
|
||||
frappe.db.savepoint("before_blocked_cancel")
|
||||
with self.assertRaises(frappe.LinkExistsError) as cm:
|
||||
pr.cancel()
|
||||
self.assertIn(pi.name, str(cm.exception))
|
||||
frappe.db.rollback(save_point="before_blocked_cancel") # mimic the request-level rollback
|
||||
|
||||
pr.reload()
|
||||
self.assertEqual(pr.docstatus, 1)
|
||||
self.assertEqual(pr.status, status_before)
|
||||
self.assertEqual(frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name}), sle_before)
|
||||
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pr.name}), gle_before)
|
||||
|
||||
|
||||
def prepare_data_for_internal_transfer():
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
|
||||
|
||||
@@ -505,6 +505,11 @@ class SerialandBatchBundle(Document):
|
||||
self.child_table, self.voucher_detail_no, field
|
||||
)
|
||||
|
||||
if not return_against_voucher_detail_no and self.voucher_type in ("Delivery Note", "Sales Invoice"):
|
||||
# Bundles built via the use_serial_batch_fields / SLE-driven path keep the Packed Item
|
||||
# as voucher_detail_no (not remapped to the DN/SI Item), so the lookup above misses.
|
||||
return_against_voucher_detail_no = self.get_return_against_packed_item(field)
|
||||
|
||||
filters = [
|
||||
["Serial and Batch Bundle", "voucher_no", "=", return_against],
|
||||
["Serial and Batch Entry", "docstatus", "=", 1],
|
||||
@@ -548,6 +553,16 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
return valuation_details
|
||||
|
||||
def get_return_against_packed_item(self, field):
|
||||
"""Resolve the original DN/SI Item when a return bundle's voucher_detail_no is the Packed Item."""
|
||||
parent_detail_docname = frappe.db.get_value(
|
||||
"Packed Item", self.voucher_detail_no, "parent_detail_docname"
|
||||
)
|
||||
if not parent_detail_docname:
|
||||
return
|
||||
|
||||
return frappe.db.get_value(self.child_table, parent_detail_docname, field)
|
||||
|
||||
def get_legacy_valuation_rate_for_return_entry(
|
||||
self, return_against, return_against_voucher_detail_no, return_warehouse=None
|
||||
):
|
||||
|
||||
@@ -1321,6 +1321,7 @@ class StockEntry(StockController):
|
||||
# Set rate for outgoing items
|
||||
outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate)
|
||||
finished_item_qty = sum(d.transfer_qty for d in self.items if d.is_finished_item)
|
||||
has_consumption_basis = self.has_consumption_basis()
|
||||
|
||||
items = []
|
||||
# Set basic rate for incoming items
|
||||
@@ -1328,6 +1329,8 @@ class StockEntry(StockController):
|
||||
if d.s_warehouse or d.set_basic_rate_manually:
|
||||
continue
|
||||
|
||||
rate_derived_from_consumption = False
|
||||
|
||||
if d.allow_zero_valuation_rate:
|
||||
d.basic_rate = 0.0
|
||||
items.append(d.item_code)
|
||||
@@ -1335,12 +1338,17 @@ class StockEntry(StockController):
|
||||
elif d.is_finished_item:
|
||||
if self.purpose == "Manufacture":
|
||||
d.basic_rate = self.get_basic_rate_for_manufactured_item(
|
||||
finished_item_qty, outgoing_items_cost
|
||||
finished_item_qty, outgoing_items_cost, has_consumption_basis
|
||||
)
|
||||
rate_derived_from_consumption = has_consumption_basis
|
||||
elif self.purpose == "Repack":
|
||||
d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost)
|
||||
# Repack rate comes from consumed source-warehouse rows, not consumption entries
|
||||
rate_derived_from_consumption = any(item.s_warehouse for item in self.get("items"))
|
||||
|
||||
if not d.basic_rate and not d.allow_zero_valuation_rate:
|
||||
# A rate of zero derived from the consumed items is their actual cost, not a missing
|
||||
# rate. Falling back to the item's valuation here would value free inputs as output.
|
||||
if not d.basic_rate and not d.allow_zero_valuation_rate and not rate_derived_from_consumption:
|
||||
if self.is_new():
|
||||
raise_error_if_no_rate = False
|
||||
|
||||
@@ -1375,6 +1383,31 @@ class StockEntry(StockController):
|
||||
|
||||
frappe.msgprint(message, alert=True)
|
||||
|
||||
def has_consumption_basis(self) -> bool:
|
||||
"""Whether the cost of the consumed items is known, even when that cost is zero."""
|
||||
if any(d.s_warehouse for d in self.get("items")):
|
||||
return True
|
||||
|
||||
settings = frappe.get_single("Manufacturing Settings")
|
||||
if settings.material_consumption and settings.get_rm_cost_from_consumption_entry and self.work_order:
|
||||
return bool(self.get_consumption_entries())
|
||||
|
||||
return False
|
||||
|
||||
def get_consumption_entries(self) -> list[str]:
|
||||
# Cached: queried in both has_consumption_basis() and get_basic_rate_for_manufactured_item()
|
||||
if getattr(self, "_consumption_entries", None) is None:
|
||||
self._consumption_entries = frappe.get_all(
|
||||
"Stock Entry",
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"work_order": self.work_order,
|
||||
"purpose": "Material Consumption for Manufacture",
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
return self._consumption_entries
|
||||
|
||||
def set_rate_for_outgoing_items(self, reset_outgoing_rate=True, raise_error_if_no_rate=True):
|
||||
outgoing_items_cost = 0.0
|
||||
for d in self.get("items"):
|
||||
@@ -1428,21 +1461,16 @@ class StockEntry(StockController):
|
||||
)
|
||||
return flt(outgoing_items_cost / total_fg_qty)
|
||||
|
||||
def get_basic_rate_for_manufactured_item(self, finished_item_qty, outgoing_items_cost=0) -> float:
|
||||
def get_basic_rate_for_manufactured_item(
|
||||
self, finished_item_qty, outgoing_items_cost=0, has_consumption_basis=False
|
||||
) -> float:
|
||||
settings = frappe.get_single("Manufacturing Settings")
|
||||
scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_scrap_item])
|
||||
|
||||
if settings.material_consumption:
|
||||
if settings.get_rm_cost_from_consumption_entry and self.work_order:
|
||||
# Validate only if Material Consumption Entry exists for the Work Order.
|
||||
if frappe.db.exists(
|
||||
"Stock Entry",
|
||||
{
|
||||
"docstatus": 1,
|
||||
"work_order": self.work_order,
|
||||
"purpose": "Material Consumption for Manufacture",
|
||||
},
|
||||
):
|
||||
if self.get_consumption_entries():
|
||||
for item in self.items:
|
||||
if not item.is_finished_item and not item.is_scrap_item:
|
||||
label = frappe.get_meta(settings.doctype).get_label(
|
||||
@@ -1489,7 +1517,9 @@ class StockEntry(StockController):
|
||||
)
|
||||
).run()[0][0] or 0
|
||||
|
||||
elif not outgoing_items_cost:
|
||||
# Estimate from the BOM only when nothing was consumed. A consumed cost of zero is a
|
||||
# real cost, so substituting BOM rates would value free inputs as output.
|
||||
elif not outgoing_items_cost and not has_consumption_basis:
|
||||
bom_items = self.get_bom_raw_materials(finished_item_qty)
|
||||
outgoing_items_cost = sum([flt(row.qty) * flt(row.rate) for row in bom_items.values()])
|
||||
|
||||
|
||||
@@ -2574,6 +2574,149 @@ class TestStockEntry(FrappeTestCase):
|
||||
material_request.reload()
|
||||
self.assertEqual(material_request.transfer_status, "Completed")
|
||||
|
||||
def test_manufacture_with_zero_valued_raw_material(self):
|
||||
# A finished good produced from free inputs is worth nothing. Falling back to the item's
|
||||
# own valuation would create value out of nothing and inflate it on every production run.
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
fg_warehouse = "Finished Goods - _TC"
|
||||
|
||||
rm_receipt = make_stock_entry(item_code=rm_item, target=warehouse, qty=100, rate=0, do_not_save=True)
|
||||
rm_receipt.items[0].allow_zero_valuation_rate = 1
|
||||
rm_receipt.save()
|
||||
rm_receipt.submit()
|
||||
|
||||
# the finished good already carries a valuation in the target warehouse
|
||||
make_stock_entry(item_code=fg_item, target=fg_warehouse, qty=10, rate=100)
|
||||
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.purpose = se.stock_entry_type = "Manufacture"
|
||||
se.company = "_Test Company"
|
||||
se.append(
|
||||
"items",
|
||||
{"item_code": rm_item, "s_warehouse": warehouse, "qty": 10, "conversion_factor": 1},
|
||||
)
|
||||
se.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": fg_item,
|
||||
"t_warehouse": fg_warehouse,
|
||||
"qty": 10,
|
||||
"is_finished_item": 1,
|
||||
"conversion_factor": 1,
|
||||
},
|
||||
)
|
||||
se.save()
|
||||
|
||||
self.assertEqual(se.items[0].basic_amount, 0)
|
||||
self.assertEqual(se.items[1].basic_rate, 0)
|
||||
self.assertEqual(se.items[1].basic_amount, 0)
|
||||
|
||||
se.submit()
|
||||
|
||||
fg_sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": se.name, "item_code": fg_item, "is_cancelled": 0},
|
||||
["incoming_rate", "stock_value_difference"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
self.assertEqual(fg_sle.incoming_rate, 0)
|
||||
self.assertEqual(fg_sle.stock_value_difference, 0)
|
||||
|
||||
def _make_wo_for_free_raw_material(self, rm_item, fg_item, bom_no):
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_stock_entry_from_wo,
|
||||
)
|
||||
|
||||
receipt = make_stock_entry(item_code=rm_item, target="Stores - _TC", qty=10, rate=0, do_not_save=True)
|
||||
receipt.items[0].allow_zero_valuation_rate = 1
|
||||
receipt.save()
|
||||
receipt.submit()
|
||||
|
||||
wo = make_wo_order_test_record(production_item=fg_item, bom_no=bom_no, qty=10)
|
||||
|
||||
transfer = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Material Transfer for Manufacture", 10))
|
||||
transfer.items[0].s_warehouse = "Stores - _TC"
|
||||
transfer.insert().submit()
|
||||
|
||||
return wo
|
||||
|
||||
@change_settings(
|
||||
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 0}
|
||||
)
|
||||
def test_manufacture_does_not_fall_back_to_bom_cost_for_free_raw_material(self):
|
||||
# The BOM is only an estimate for when nothing was consumed. Items that were consumed and
|
||||
# cost nothing are a real cost, so a BOM rate must not stand in for them.
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_stock_entry_from_wo,
|
||||
)
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Price",
|
||||
"item_code": rm_item,
|
||||
"price_list": "_Test Price List India",
|
||||
"price_list_rate": 150,
|
||||
"buying": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
# price the BOM off the price list so that it carries a rate the free stock does not
|
||||
bom = make_bom(item=fg_item, raw_materials=[rm_item], do_not_save=True)
|
||||
bom.rm_cost_as_per = "Price List"
|
||||
bom.buying_price_list = "_Test Price List India"
|
||||
bom.currency = "INR"
|
||||
bom.save()
|
||||
bom.submit()
|
||||
|
||||
wo = self._make_wo_for_free_raw_material(rm_item, fg_item, bom.name)
|
||||
|
||||
manufacture = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10))
|
||||
manufacture.save()
|
||||
|
||||
fg_row = next(d for d in manufacture.items if d.is_finished_item)
|
||||
self.assertEqual(fg_row.basic_rate, 0)
|
||||
self.assertEqual(fg_row.basic_amount, 0)
|
||||
|
||||
@change_settings(
|
||||
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1}
|
||||
)
|
||||
def test_manufacture_with_zero_valued_consumption_entry(self):
|
||||
# The raw material is consumed by a separate entry, so the Manufacture entry carries no
|
||||
# consumed rows of its own. Its cost is still known, and it is zero.
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_stock_entry_from_wo,
|
||||
)
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
# the finished good already carries a valuation in the work order's target warehouse
|
||||
make_stock_entry(item_code=fg_item, target="_Test Warehouse 1 - _TC", qty=10, rate=100)
|
||||
|
||||
bom = make_bom(item=fg_item, raw_materials=[rm_item]).name
|
||||
wo = self._make_wo_for_free_raw_material(rm_item, fg_item, bom)
|
||||
|
||||
consumption = frappe.get_doc(
|
||||
make_stock_entry_from_wo(wo.name, "Material Consumption for Manufacture", 10)
|
||||
)
|
||||
consumption.insert().submit()
|
||||
|
||||
manufacture = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10))
|
||||
manufacture.save()
|
||||
|
||||
fg_row = next(d for d in manufacture.items if d.is_finished_item)
|
||||
self.assertEqual(fg_row.basic_rate, 0)
|
||||
self.assertEqual(fg_row.basic_amount, 0)
|
||||
|
||||
def test_disassemble_entry_without_wo(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
|
||||
|
||||
@@ -1292,6 +1292,7 @@ def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_d
|
||||
return flt(result[0][0]) if result and result[0][0] else 0.0
|
||||
|
||||
|
||||
# nosemgrep: missing-argument-type-hint
|
||||
@frappe.whitelist()
|
||||
def get_stock_balance_for(
|
||||
item_code: str,
|
||||
@@ -1364,7 +1365,7 @@ def get_stock_balance_for(
|
||||
or 0
|
||||
)
|
||||
|
||||
if row.use_serial_batch_fields and row.batch_no and (qty or row.current_qty):
|
||||
if row and row.use_serial_batch_fields and row.batch_no and (qty or row.current_qty):
|
||||
rate = get_incoming_rate(
|
||||
frappe._dict(
|
||||
{
|
||||
|
||||
@@ -103,6 +103,44 @@ class TestWarehouse(FrappeTestCase):
|
||||
children = get_children("Warehouse", parent=company, company=company, is_root=True)
|
||||
self.assertTrue(any(wh["value"] == "_Test Warehouse - _TC" for wh in children))
|
||||
|
||||
def test_inventory_account_fallback_with_multiple_stock_accounts(self):
|
||||
from erpnext.stock import get_warehouse_account
|
||||
|
||||
company = create_inventory_fallback_company()
|
||||
frappe.db.set_value("Company", company, "default_inventory_account", None)
|
||||
if frappe.db.exists("Account", "Extra Inventory Account - _TCIF"):
|
||||
frappe.delete_doc("Account", "Extra Inventory Account - _TCIF")
|
||||
|
||||
warehouse = frappe.get_doc("Warehouse", {"company": company, "is_group": 0})
|
||||
single_account = frappe.db.get_value(
|
||||
"Account", {"account_type": "Stock", "is_group": 0, "company": company}, "name"
|
||||
)
|
||||
self.assertEqual(get_warehouse_account(warehouse), single_account)
|
||||
|
||||
create_account(
|
||||
account_name="Extra Inventory Account",
|
||||
parent_account=frappe.db.get_value("Account", single_account, "parent_account"),
|
||||
account_type="Stock",
|
||||
company=company,
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, get_warehouse_account, warehouse)
|
||||
|
||||
|
||||
def create_inventory_fallback_company():
|
||||
company = "_Test Company Inventory Fallback"
|
||||
if not frappe.db.exists("Company", company):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Company",
|
||||
"company_name": company,
|
||||
"abbr": "_TCIF",
|
||||
"default_currency": "INR",
|
||||
"enable_perpetual_inventory": 0,
|
||||
"country": "India",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
return company
|
||||
|
||||
|
||||
def create_warehouse(warehouse_name, properties=None, company=None):
|
||||
if not company:
|
||||
|
||||
@@ -8,6 +8,7 @@ from frappe.utils import add_to_date, cint, flt, get_datetime, get_table_name, g
|
||||
from frappe.utils.deprecations import deprecated
|
||||
from pypika import functions as fn
|
||||
|
||||
from erpnext.accounts.report.utils import validate_mandatory_date_range
|
||||
from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter
|
||||
|
||||
SLE_COUNT_LIMIT = 100_000
|
||||
@@ -29,8 +30,7 @@ def execute(filters=None):
|
||||
_("Please select either the Item or Warehouse or Warehouse Type filter to generate the report.")
|
||||
)
|
||||
|
||||
if filters.from_date > filters.to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
validate_mandatory_date_range(filters)
|
||||
|
||||
float_precision = cint(frappe.db.get_default("float_precision")) or 3
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from frappe import _
|
||||
from frappe.utils import date_diff
|
||||
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import get_gl_entries
|
||||
from erpnext.accounts.report.utils import validate_mandatory_date_range
|
||||
|
||||
Filters = frappe._dict
|
||||
Row = frappe._dict
|
||||
@@ -34,8 +35,7 @@ def update_filters_with_account(filters: Filters) -> None:
|
||||
|
||||
|
||||
def validate_filters(filters: Filters) -> None:
|
||||
if filters.from_date > filters.to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
validate_mandatory_date_range(filters)
|
||||
|
||||
|
||||
def get_columns() -> Columns:
|
||||
|
||||
Reference in New Issue
Block a user