mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-04 18:23:05 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7098602dcc | ||
|
|
32b56ac505 | ||
|
|
9f1bdba9a7 | ||
|
|
ade3f791a4 | ||
|
|
967955a926 | ||
|
|
71b5f41556 | ||
|
|
4f07e2503b | ||
|
|
16e14d70b5 | ||
|
|
33d3696385 | ||
|
|
c630226846 | ||
|
|
d5b49cd66e | ||
|
|
abc53b0d39 | ||
|
|
1d60ab449c | ||
|
|
41effcf754 | ||
|
|
6939d9a76a | ||
|
|
12b4c134ca | ||
|
|
20b6dd3d0f | ||
|
|
1ac19d7202 | ||
|
|
25e5b107be | ||
|
|
43a96c3109 | ||
|
|
da698b7498 | ||
|
|
8cceb6af10 | ||
|
|
0ad0d7733b | ||
|
|
d96999de7f | ||
|
|
5a1a9b2034 | ||
|
|
8f68b7ed20 | ||
|
|
ceb677844f | ||
|
|
ae0cd164f3 | ||
|
|
c70cf8e554 | ||
|
|
98a0fd814e | ||
|
|
982648cffd | ||
|
|
53d9d1c50d | ||
|
|
2a95bd2b83 | ||
|
|
a0b26d1dc1 | ||
|
|
a2ace9d394 | ||
|
|
168ec661e4 | ||
|
|
f38b3b422d | ||
|
|
08f92ed3f8 | ||
|
|
92a7dca67c | ||
|
|
d9706271ff | ||
|
|
435fe19398 | ||
|
|
97fa5435bb | ||
|
|
7a858be920 | ||
|
|
e2319c3ffe | ||
|
|
60b4e6053d | ||
|
|
1721c408eb | ||
|
|
4fe91bd8b8 | ||
|
|
4e8f5de5cb | ||
|
|
b5d8c7515f | ||
|
|
348555b127 | ||
|
|
04a281e299 | ||
|
|
0dc5894499 | ||
|
|
0752fcfe69 | ||
|
|
56bd024f39 | ||
|
|
df936009e5 | ||
|
|
ce3bd02f82 | ||
|
|
6fa522d031 | ||
|
|
3ad971d3f6 | ||
|
|
48082020e8 | ||
|
|
bb1320f8df | ||
|
|
745513d0c2 | ||
|
|
c740d7db13 | ||
|
|
b9c9b76f5b | ||
|
|
da0e3b5882 | ||
|
|
3d4198494b | ||
|
|
c5235af6bf | ||
|
|
c1afc55abd | ||
|
|
d9373850ad | ||
|
|
27a48a7ab8 | ||
|
|
1ff297e9da |
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.118.0"
|
||||
__version__ = "15.118.3"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -15,7 +15,7 @@ class ERPNextAddress(Address):
|
||||
|
||||
def link_address(self):
|
||||
"""Link address based on owner"""
|
||||
if self.is_your_company_address:
|
||||
if self.get("is_your_company_address"):
|
||||
return
|
||||
|
||||
return super().link_address()
|
||||
@@ -26,7 +26,9 @@ class ERPNextAddress(Address):
|
||||
self.is_your_company_address = 1
|
||||
|
||||
def validate_reference(self):
|
||||
if self.is_your_company_address and not [row for row in self.links if row.link_doctype == "Company"]:
|
||||
if self.get("is_your_company_address") and not [
|
||||
row for row in self.links if row.link_doctype == "Company"
|
||||
]:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Address needs to be linked to a Company. Please add a row for Company in the Links table."
|
||||
|
||||
@@ -619,15 +619,26 @@ class ExchangeRateRevaluation(Document):
|
||||
if journals:
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
|
||||
for x in journals:
|
||||
reversal = make_reverse_journal_entry(x)
|
||||
reversal.posting_date = nowdate()
|
||||
reversal.submit()
|
||||
frappe.msgprint(
|
||||
_("Revaluation journal for {0} has been created: {1}").format(
|
||||
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
|
||||
)
|
||||
if drafts := frappe.db.get_all(
|
||||
"Journal Entry",
|
||||
filters={"docstatus": 0, "reversal_of": ["in", journals]},
|
||||
pluck="name",
|
||||
):
|
||||
part = "journals are" if len(drafts) > 1 else "journal is"
|
||||
doc_links = ", ".join(["{}".format(get_link_to_form("Journal Entry", x)) for x in drafts])
|
||||
frappe.throw(
|
||||
msg=_("Reverse {0} already available in draft status: {1}").format(part, doc_links),
|
||||
)
|
||||
else:
|
||||
for x in journals:
|
||||
reversal = make_reverse_journal_entry(x)
|
||||
reversal.posting_date = nowdate()
|
||||
reversal.save()
|
||||
frappe.msgprint(
|
||||
_("A draft reverse journal for {0} has been created: {1}").format(
|
||||
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
|
||||
@@ -361,6 +361,14 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
|
||||
self.assertFalse(ret.get("reversals_posted"))
|
||||
|
||||
err.make_reverse_journal()
|
||||
# submit
|
||||
draft = frappe.db.get_all(
|
||||
"Journal Entry",
|
||||
filters={"docstatus": 0, "reversal_of": je.name, "voucher_type": "Exchange Rate Revaluation"},
|
||||
pluck="name",
|
||||
)
|
||||
self.assertIsNotNone(draft)
|
||||
frappe.get_doc("Journal Entry", draft[0]).submit()
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertTrue(ret.get("journals_posted"))
|
||||
self.assertTrue(ret.get("reversals_posted"))
|
||||
|
||||
@@ -41,7 +41,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
|
||||
frm.set_read_only();
|
||||
erpnext.journal_entry.lock_reversal_entry(frm);
|
||||
}
|
||||
|
||||
erpnext.toggle_naming_series();
|
||||
@@ -513,6 +513,14 @@ $.extend(erpnext.journal_entry, {
|
||||
});
|
||||
},
|
||||
|
||||
lock_reversal_entry: function (frm) {
|
||||
frm.fields
|
||||
.filter((field) => field.has_input)
|
||||
.filter((field) => field.df.fieldname != "posting_date")
|
||||
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
|
||||
frm.set_df_property("accounts", "read_only", 1);
|
||||
},
|
||||
|
||||
set_debit_credit_in_company_currency: function (frm, cdt, cdn) {
|
||||
var row = locals[cdt][cdn];
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ def initialize_parallel_threads(docname: str):
|
||||
)
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
if not frappe.flags.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
else:
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
@@ -272,7 +272,7 @@ def schedule_next_date(docname: str):
|
||||
)
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
if not frappe.flags.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
frappe.enqueue(
|
||||
@@ -449,7 +449,7 @@ def summarize_and_post_ledger_entries(docname):
|
||||
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
if not frappe.flags.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
|
||||
@@ -599,7 +599,7 @@ def process_individual_date(docname: str, row_name, date, report_type, parentfie
|
||||
"Completed",
|
||||
)
|
||||
# commit heavy computation before touching PPCV or PPCVD
|
||||
if not frappe.in_test:
|
||||
if not frappe.flags.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
# chain call
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -853,7 +853,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
|
||||
|
||||
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
|
||||
|
||||
companies = frappe.get_all(
|
||||
companies = frappe.get_list(
|
||||
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -69,10 +69,10 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -98,10 +98,10 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -55,8 +55,7 @@ class ReceivablePayableReport:
|
||||
self.filters.report_date = getdate(self.filters.report_date or nowdate())
|
||||
self.age_as_on = (
|
||||
getdate(nowdate())
|
||||
if "calculate_ageing_with" not in self.filters
|
||||
or self.filters.calculate_ageing_with == "Today Date"
|
||||
if "age_as_on" not in self.filters or self.filters.age_as_on == "Today"
|
||||
else self.filters.report_date
|
||||
)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -73,6 +73,7 @@ def execute(filters=None):
|
||||
"parent_section": None,
|
||||
"indent": 0.0,
|
||||
"section": cash_flow_section["section_header"],
|
||||
"currency": company_currency,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
||||
)
|
||||
if total_base_amount
|
||||
else 0,
|
||||
"currency": filters.currency,
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -269,6 +270,7 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_
|
||||
"buying_amount": total_buying_amount,
|
||||
"gross_profit": total_gross_profit,
|
||||
"gross_profit_percent": flt(gross_profit_percent, currency_precision),
|
||||
"currency": filters.currency,
|
||||
}
|
||||
|
||||
total_row = [total_row.get(col, None) for col in [*group_columns, "currency"]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -171,14 +171,12 @@
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "{supplier_name}",
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Title",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"reqd": 1
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "naming_series",
|
||||
@@ -1309,7 +1307,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-31 17:19:40.816883",
|
||||
"modified": "2026-07-28 12:20:11.284370",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order",
|
||||
|
||||
@@ -162,7 +162,7 @@ class PurchaseOrder(BuyingController):
|
||||
taxes_and_charges_deducted: DF.Currency
|
||||
tc_name: DF.Link | None
|
||||
terms: DF.TextEditor | None
|
||||
title: DF.Data
|
||||
title: DF.Data | None
|
||||
to_date: DF.Date | None
|
||||
total: DF.Currency
|
||||
total_net_weight: DF.Float
|
||||
|
||||
@@ -14,7 +14,6 @@ def execute(filters=None):
|
||||
conditions = get_columns(filters, "Purchase Order")
|
||||
data = get_data(filters, conditions)
|
||||
chart_data = get_chart_data(data, conditions, filters)
|
||||
|
||||
return conditions["columns"], data, None, chart_data
|
||||
|
||||
|
||||
@@ -39,9 +38,15 @@ def get_chart_data(data, conditions, filters):
|
||||
labels = [column.split(":")[0] for column in columns]
|
||||
datapoints = [0] * len(labels)
|
||||
|
||||
group_by_col_idx = None
|
||||
if filters.get("group_by"):
|
||||
group_by_col_idx = conditions["columns"].index(conditions["grbc"][0])
|
||||
|
||||
for row in data:
|
||||
# If group by filter, don't add first row of group (it's already summed)
|
||||
if not row[start]:
|
||||
# Skip the final grand-total row
|
||||
if row[0] == f"'{_('Total')}'":
|
||||
continue
|
||||
if group_by_col_idx is not None and row[group_by_col_idx] == "":
|
||||
continue
|
||||
# Remove None values and compute only periodic data
|
||||
row = [x if x else 0 for x in row[start:-2]]
|
||||
@@ -60,4 +65,6 @@ def get_chart_data(data, conditions, filters):
|
||||
"type": "line",
|
||||
"lineOptions": {"regionFill": 1},
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"currency": conditions.get("company_currency"),
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import DateTimeLikeObject, getdate, today
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
|
||||
@@ -42,6 +43,9 @@ def get_columns(filters, trans):
|
||||
"addl_tables": based_on_details["addl_tables"],
|
||||
"addl_tables_relational_cond": based_on_details.get("addl_tables_relational_cond", ""),
|
||||
}
|
||||
conditions["company_currency"] = (
|
||||
erpnext.get_company_currency(filters.get("company")) if filters.get("company") else None
|
||||
)
|
||||
|
||||
return conditions
|
||||
|
||||
@@ -206,7 +210,7 @@ def get_data(filters, conditions):
|
||||
|
||||
data.append(des)
|
||||
|
||||
total_row = calculate_total_row(data1, conditions["columns"])
|
||||
total_row = calculate_total_row(data1, conditions["columns"], conditions.get("company_currency"))
|
||||
data.append(total_row)
|
||||
else:
|
||||
data = frappe.db.sql(
|
||||
@@ -231,19 +235,24 @@ def get_data(filters, conditions):
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
total_row = calculate_total_row(data, conditions["columns"])
|
||||
total_row = calculate_total_row(data, conditions["columns"], conditions.get("company_currency"))
|
||||
data.append(total_row)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def calculate_total_row(data, columns):
|
||||
def calculate_total_row(data, columns, company_currency=None):
|
||||
def wrap_in_quotes(label):
|
||||
return f"'{label}'"
|
||||
|
||||
total_values = {}
|
||||
currency_col_idx = None
|
||||
for i, col in enumerate(columns):
|
||||
if "Float" in col or "Currency/currency" in col:
|
||||
# based-on and group-by columns are dicts, periodic and total columns are strings
|
||||
if isinstance(col, dict):
|
||||
if col.get("fieldtype") == "Link" and col.get("options") == "Currency":
|
||||
currency_col_idx = i
|
||||
elif "Float" in col or "Currency/currency" in col:
|
||||
total_values[i] = 0
|
||||
|
||||
for row in data:
|
||||
@@ -254,6 +263,9 @@ def calculate_total_row(data, columns):
|
||||
for i in range(1, len(columns)):
|
||||
total_row.append(total_values.get(i, None))
|
||||
|
||||
if currency_col_idx is not None:
|
||||
total_row[currency_col_idx] = company_currency
|
||||
|
||||
return total_row
|
||||
|
||||
|
||||
|
||||
@@ -277,13 +277,17 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
self.save()
|
||||
|
||||
else:
|
||||
frappe.throw(_("Cannot declare as lost, because Quotation has been made."))
|
||||
frappe.throw(_("Cannot declare as Lost because an active Quotation exists."))
|
||||
|
||||
def has_active_quotation(self):
|
||||
if not self.get("items", []):
|
||||
return frappe.get_all(
|
||||
"Quotation",
|
||||
{"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1},
|
||||
{
|
||||
"opportunity": self.name,
|
||||
"status": ("not in", ["Lost", "Cancelled", "Expired"]),
|
||||
"docstatus": 1,
|
||||
},
|
||||
"name",
|
||||
)
|
||||
else:
|
||||
@@ -292,14 +296,20 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
select q.name
|
||||
from `tabQuotation` q, `tabQuotation Item` qi
|
||||
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
|
||||
and q.status not in ('Lost', 'Closed')""",
|
||||
and q.status not in ('Lost', 'Cancelled', 'Expired')""",
|
||||
self.name,
|
||||
)
|
||||
|
||||
def has_ordered_quotation(self):
|
||||
if not self.get("items", []):
|
||||
return frappe.get_all(
|
||||
"Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name"
|
||||
"Quotation",
|
||||
{
|
||||
"opportunity": self.name,
|
||||
"status": ("in", ["Ordered", "Partially Ordered"]),
|
||||
"docstatus": 1,
|
||||
},
|
||||
"name",
|
||||
)
|
||||
else:
|
||||
return frappe.db.sql(
|
||||
@@ -307,7 +317,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
select q.name
|
||||
from `tabQuotation` q, `tabQuotation Item` qi
|
||||
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
|
||||
and q.status = 'Ordered'""",
|
||||
and q.status in ('Ordered', 'Partially Ordered')""",
|
||||
self.name,
|
||||
)
|
||||
|
||||
|
||||
@@ -787,7 +787,7 @@ class BOM(WebsiteGenerator):
|
||||
|
||||
for d in self.get("items"):
|
||||
old_rate = d.rate
|
||||
if not self.bom_creator and d.is_stock_item:
|
||||
if d.is_stock_item:
|
||||
d.rate = self.get_rm_rate(
|
||||
{
|
||||
"company": self.company,
|
||||
|
||||
@@ -29,6 +29,7 @@ from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_childr
|
||||
from erpnext.manufacturing.doctype.bom.bom import validate_bom_no
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock.doctype.item.item import get_uom_conv_factor
|
||||
from erpnext.stock.get_item_details import get_conversion_factor
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
from erpnext.utilities.transaction_base import validate_uom_is_integer
|
||||
@@ -1228,9 +1229,16 @@ def get_exploded_items(item_details, company, bom_no, include_non_stock_items, p
|
||||
|
||||
|
||||
def get_uom_conversion_factor(item_code, uom):
|
||||
return frappe.db.get_value(
|
||||
item = frappe.get_cached_value("Item", item_code, ["variant_of", "stock_uom"], as_dict=True)
|
||||
conversion_factor = frappe.db.get_value(
|
||||
"UOM Conversion Detail", {"parent": item_code, "uom": uom}, "conversion_factor"
|
||||
)
|
||||
if not conversion_factor and item.variant_of:
|
||||
conversion_factor = frappe.db.get_value(
|
||||
"UOM Conversion Detail", {"parent": item.variant_of, "uom": uom}, "conversion_factor"
|
||||
)
|
||||
|
||||
return conversion_factor or get_uom_conv_factor(uom, item.stock_uom)
|
||||
|
||||
|
||||
def get_subitems(
|
||||
|
||||
@@ -1606,6 +1606,118 @@ class TestProductionPlan(FrappeTestCase):
|
||||
self.assertTrue(row.warehouse == mrp_warhouse)
|
||||
self.assertEqual(row.quantity, 12.0)
|
||||
|
||||
def test_purchase_uom_falls_back_to_uom_conversion_factor(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
if not frappe.db.exists("UOM Conversion Factor", {"from_uom": "Kg", "to_uom": "Gram"}):
|
||||
frappe.get_doc(
|
||||
doctype="UOM Conversion Factor",
|
||||
category="Mass",
|
||||
from_uom="Kg",
|
||||
to_uom="Gram",
|
||||
value=1000,
|
||||
).insert()
|
||||
|
||||
rm = make_item("Test RM Item Global CF", {"is_stock_item": 1, "stock_uom": "Gram"})
|
||||
rm.purchase_uom = "Kg"
|
||||
rm.save()
|
||||
self.assertFalse([row for row in rm.uoms if row.uom == "Kg"])
|
||||
|
||||
bom_tree = {"Test FG Item Global CF": {rm.name: {}}}
|
||||
parent_bom = create_nested_bom(bom_tree, prefix="")
|
||||
|
||||
plan = create_production_plan(
|
||||
item_code=parent_bom.item,
|
||||
planned_qty=2000,
|
||||
ignore_existing_ordered_qty=1,
|
||||
skip_getting_mr_items=1,
|
||||
do_not_submit=1,
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
plan.for_warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
items = get_items_for_material_requests(
|
||||
plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}]
|
||||
)
|
||||
|
||||
row = frappe._dict(next(item for item in items if item["item_code"] == rm.name))
|
||||
self.assertEqual(row.uom, "Kg")
|
||||
self.assertEqual(row.conversion_factor, 1000)
|
||||
self.assertEqual(row.quantity, 2)
|
||||
|
||||
def test_variant_inherits_purchase_uom_conversion_factor_of_template(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
template = make_item(
|
||||
"TRMVCF",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"stock_uom": "Nos",
|
||||
"has_variants": 1,
|
||||
"attributes": [{"attribute": "Colour"}],
|
||||
},
|
||||
)
|
||||
if not [row for row in template.uoms if row.uom == "Box"]:
|
||||
template.purchase_uom = "Box"
|
||||
template.append("uoms", {"uom": "Box", "conversion_factor": 12})
|
||||
template.save()
|
||||
|
||||
if not frappe.db.exists("Item", "TRMVCF-RED"):
|
||||
create_variant("TRMVCF", {"Colour": "Red"}).insert()
|
||||
|
||||
variant = frappe.get_doc("Item", "TRMVCF-RED")
|
||||
variant.uoms = [row for row in variant.uoms if row.uom != "Box"]
|
||||
variant.purchase_uom = "Box"
|
||||
variant.save()
|
||||
|
||||
bom_tree = {"Test FG Item Variant CF": {variant.name: {}}}
|
||||
parent_bom = create_nested_bom(bom_tree, prefix="")
|
||||
|
||||
plan = create_production_plan(
|
||||
item_code=parent_bom.item,
|
||||
planned_qty=24,
|
||||
ignore_existing_ordered_qty=1,
|
||||
skip_getting_mr_items=1,
|
||||
do_not_submit=1,
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
plan.for_warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
items = get_items_for_material_requests(
|
||||
plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}]
|
||||
)
|
||||
|
||||
row = frappe._dict(next(item for item in items if item["item_code"] == variant.name))
|
||||
self.assertEqual(row.conversion_factor, 12)
|
||||
self.assertEqual(row.quantity, 2)
|
||||
|
||||
def test_missing_purchase_uom_conversion_factor_throws(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
rm = make_item("Test RM Item Missing CF", {"is_stock_item": 1, "stock_uom": "Nos"})
|
||||
rm.purchase_uom = "Box"
|
||||
rm.save()
|
||||
|
||||
bom_tree = {"Test FG Item Missing CF": {rm.name: {}}}
|
||||
parent_bom = create_nested_bom(bom_tree, prefix="")
|
||||
|
||||
plan = create_production_plan(
|
||||
item_code=parent_bom.item,
|
||||
planned_qty=10,
|
||||
ignore_existing_ordered_qty=1,
|
||||
skip_getting_mr_items=1,
|
||||
do_not_submit=1,
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
plan.for_warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
with self.assertRaises(frappe.ValidationError) as error:
|
||||
get_items_for_material_requests(
|
||||
plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}]
|
||||
)
|
||||
|
||||
self.assertIn("UOM Conversion factor", str(error.exception))
|
||||
|
||||
def test_mr_qty_for_same_rm_with_different_sub_assemblies(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
|
||||
@@ -69,6 +69,11 @@ frappe.ui.form.on("BOM Operation", {
|
||||
const d = locals[cdt][cdn];
|
||||
frm.events.calculate_operating_cost(frm, d);
|
||||
},
|
||||
|
||||
hour_rate: function (frm, cdt, cdn) {
|
||||
const d = locals[cdt][cdn];
|
||||
frm.events.calculate_operating_cost(frm, d);
|
||||
},
|
||||
});
|
||||
|
||||
frappe.tour["Routing"] = [
|
||||
|
||||
@@ -1461,7 +1461,13 @@ def get_item_details(item, project=None, skip_bom_info=False, throw=True):
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_work_order(
|
||||
bom_no, item, qty=0, company=None, project=None, variant_items=None, use_multi_level_bom=None
|
||||
bom_no: str,
|
||||
item: str,
|
||||
qty: float = 0,
|
||||
company: str | None = None,
|
||||
project: str | None = None,
|
||||
variant_items: str | list | None = None,
|
||||
use_multi_level_bom: bool | None = None,
|
||||
):
|
||||
from erpnext import get_default_company
|
||||
|
||||
@@ -1470,7 +1476,8 @@ def make_work_order(
|
||||
|
||||
item_details = get_item_details(item, project)
|
||||
|
||||
if frappe.db.get_value("Item", item, "variant_of"):
|
||||
# selected BOM already belongs to this variant — keep it
|
||||
if frappe.db.get_value("Item", item, "variant_of") and frappe.db.get_value("BOM", bom_no, "item") != item:
|
||||
if variant_bom := frappe.db.get_value(
|
||||
"BOM",
|
||||
{"item": item, "is_default": 1, "docstatus": 1},
|
||||
|
||||
@@ -72,7 +72,7 @@ class TestWorkstation(FrappeTestCase):
|
||||
|
||||
test_routing_operations = [
|
||||
{"operation": "Test Operation A", "workstation": "_Test Workstation A", "time_in_mins": 60},
|
||||
{"operation": "Test Operation B", "workstation": "_Test Workstation A", "time_in_mins": 60},
|
||||
{"operation": "Test Operation B", "workstation": "_Test Workstation A", "time_in_mins": 30},
|
||||
]
|
||||
routing_doc = create_routing(routing_name="Routing Test", operations=test_routing_operations)
|
||||
bom_doc = setup_bom(item_code="_Testing Item", routing=routing_doc.name, currency="INR")
|
||||
@@ -94,6 +94,17 @@ class TestWorkstation(FrappeTestCase):
|
||||
self.assertEqual(bom_doc.operations[0].hour_rate, 250)
|
||||
self.assertEqual(bom_doc.operations[1].hour_rate, 250)
|
||||
|
||||
# hour_rate propagation must also refresh operating_cost (hour_rate * time_in_mins / 60)
|
||||
# on the Routing's BOM Operation rows; the 30-min op exercises the arithmetic.
|
||||
for operation, expected_operating_cost in (("Test Operation A", 250), ("Test Operation B", 125)):
|
||||
hour_rate, operating_cost = frappe.db.get_value(
|
||||
"BOM Operation",
|
||||
{"parent": routing_doc.name, "parenttype": "Routing", "operation": operation},
|
||||
["hour_rate", "operating_cost"],
|
||||
)
|
||||
self.assertEqual(hour_rate, 250)
|
||||
self.assertEqual(operating_cost, expected_operating_cost)
|
||||
|
||||
|
||||
def make_workstation(*args, **kwargs):
|
||||
args = args if args else kwargs
|
||||
|
||||
@@ -152,9 +152,10 @@ class Workstation(Document):
|
||||
|
||||
for bom_no in bom_list:
|
||||
frappe.db.sql(
|
||||
"""update `tabBOM Operation` set hour_rate = %s
|
||||
"""update `tabBOM Operation`
|
||||
set hour_rate = %s, operating_cost = %s * time_in_mins / 60
|
||||
where parent = %s and workstation = %s""",
|
||||
(self.hour_rate, bom_no[0], self.name),
|
||||
(self.hour_rate, self.hour_rate, bom_no[0], self.name),
|
||||
)
|
||||
|
||||
def validate_workstation_holiday(self, schedule_date, skip_holiday_list_check=False):
|
||||
|
||||
@@ -443,3 +443,6 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
|
||||
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
|
||||
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
|
||||
|
||||
20
erpnext/patches/v15_0/fix_titles.py
Normal file
20
erpnext/patches/v15_0/fix_titles.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
"""
|
||||
These doctypes point `title_field` at the party name field, so their `title`
|
||||
default was never rendered and got stored as the literal template string.
|
||||
"""
|
||||
|
||||
for doctype, source_field in (
|
||||
("Purchase Order", "supplier_name"),
|
||||
("Subcontracting Order", "supplier_name"),
|
||||
("Sales Order", "customer_name"),
|
||||
):
|
||||
table = frappe.qb.DocType(doctype)
|
||||
(
|
||||
frappe.qb.update(table)
|
||||
.set(table.title, table[source_field])
|
||||
.where(table.title == f"{{{source_field}}}")
|
||||
).run()
|
||||
@@ -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()
|
||||
)
|
||||
45
erpnext/patches/v16_0/rename_ar_ap_ageing_filter.py
Normal file
45
erpnext/patches/v16_0/rename_ar_ap_ageing_filter.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import frappe
|
||||
|
||||
REPORTS = (
|
||||
"Accounts Receivable",
|
||||
"Accounts Payable",
|
||||
"Accounts Receivable Summary",
|
||||
"Accounts Payable Summary",
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
# filter `calculate_ageing_with` -> `age_as_on`, option "Today Date" -> "Today"
|
||||
_migrate("Auto Email Report", "filters", "report")
|
||||
_migrate("Dashboard Chart", "filters_json", "report_name", type_field="chart_type")
|
||||
_migrate("Number Card", "filters_json", "report_name", type_field="type")
|
||||
|
||||
|
||||
def _migrate(doctype, filter_field, report_field, type_field=None):
|
||||
conditions = {report_field: ("in", REPORTS)}
|
||||
if type_field:
|
||||
conditions[type_field] = "Report"
|
||||
|
||||
for row in frappe.get_all(doctype, filters=conditions, fields=["name", filter_field]):
|
||||
updated = _rewrite(row.get(filter_field))
|
||||
if updated is not None:
|
||||
frappe.db.set_value(doctype, row.name, filter_field, updated, update_modified=False)
|
||||
|
||||
|
||||
def _rewrite(raw):
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
try:
|
||||
filters = frappe.parse_json(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if not isinstance(filters, dict) or "calculate_ageing_with" not in filters:
|
||||
return None
|
||||
|
||||
filters["age_as_on"] = filters.pop("calculate_ageing_with")
|
||||
if filters["age_as_on"] == "Today Date":
|
||||
filters["age_as_on"] = "Today"
|
||||
|
||||
return frappe.as_json(filters, indent=None)
|
||||
@@ -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",
|
||||
|
||||
@@ -292,6 +292,7 @@ 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:
|
||||
@@ -303,6 +304,18 @@ class Quotation(SellingController):
|
||||
self.update_opportunity("Open")
|
||||
self.update_lead()
|
||||
|
||||
def carry_forward_communication(self):
|
||||
from erpnext.crm.utils import copy_comments, link_communications
|
||||
|
||||
if not (
|
||||
self.opportunity
|
||||
and frappe.get_single_value("CRM Settings", "carry_forward_communication_and_comments")
|
||||
):
|
||||
return
|
||||
|
||||
copy_comments("Opportunity", self.opportunity, self)
|
||||
link_communications("Opportunity", self.opportunity, self)
|
||||
|
||||
def print_other_charges(self, docname):
|
||||
print_lst = []
|
||||
for d in self.get("taxes"):
|
||||
|
||||
@@ -187,7 +187,6 @@
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "{customer_name}",
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
@@ -1680,7 +1679,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-06 15:33:49.059029",
|
||||
"modified": "2026-07-28 12:20:44.130918",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
from frappe import _
|
||||
|
||||
from erpnext.controllers.trends import get_columns, get_data
|
||||
@@ -40,9 +39,15 @@ def get_chart_data(data, conditions, filters):
|
||||
labels = [column.split(":")[0] for column in columns]
|
||||
datapoints = [0] * len(labels)
|
||||
|
||||
group_by_col_idx = None
|
||||
if filters.get("group_by"):
|
||||
group_by_col_idx = conditions["columns"].index(conditions["grbc"][0])
|
||||
|
||||
for row in data:
|
||||
# If group by filter, don't add first row of group (it's already summed)
|
||||
if not row[start]:
|
||||
# Skip the final grand-total row
|
||||
if row[0] == f"'{_('Total')}'":
|
||||
continue
|
||||
if group_by_col_idx is not None and row[group_by_col_idx] == "":
|
||||
continue
|
||||
# Remove None values and compute only periodic data
|
||||
row = [x if x else 0 for x in row[start:-2]]
|
||||
@@ -59,4 +64,6 @@ def get_chart_data(data, conditions, filters):
|
||||
"type": "line",
|
||||
"lineOptions": {"regionFill": 1},
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"currency": conditions.get("company_currency"),
|
||||
}
|
||||
|
||||
@@ -39,9 +39,15 @@ def get_chart_data(data, conditions, filters):
|
||||
labels = [column.split(":")[0] for column in columns]
|
||||
datapoints = [0] * len(labels)
|
||||
|
||||
group_by_col_idx = None
|
||||
if filters.get("group_by"):
|
||||
group_by_col_idx = conditions["columns"].index(conditions["grbc"][0])
|
||||
|
||||
for row in data:
|
||||
# If group by filter, don't add first row of group (it's already summed)
|
||||
if not row[start]:
|
||||
# Skip the final grand-total row
|
||||
if row[0] == f"'{_('Total')}'":
|
||||
continue
|
||||
if group_by_col_idx is not None and row[group_by_col_idx] == "":
|
||||
continue
|
||||
# Remove None values and compute only periodic data
|
||||
row = [x if x else 0 for x in row[start:-2]]
|
||||
@@ -58,4 +64,6 @@ def get_chart_data(data, conditions, filters):
|
||||
"type": "line",
|
||||
"lineOptions": {"regionFill": 1},
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"currency": conditions.get("company_currency"),
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class TermsandConditions(Document):
|
||||
|
||||
def validate(self):
|
||||
if self.terms:
|
||||
validate_template(self.terms)
|
||||
validate_template(self.terms, restrict_globals=True)
|
||||
if not cint(self.buying) and not cint(self.selling) and not cint(self.hr) and not cint(self.disabled):
|
||||
throw(_("At least one of the Applicable Modules should be selected"))
|
||||
|
||||
@@ -40,7 +40,10 @@ def get_terms_and_conditions(template_name, doc):
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
|
||||
terms_and_conditions = frappe.get_doc("Terms and Conditions", template_name)
|
||||
tnc = frappe.get_cached_doc("Terms and Conditions", template_name)
|
||||
tnc.check_permission()
|
||||
|
||||
if terms_and_conditions.terms:
|
||||
return frappe.render_template(terms_and_conditions.terms, doc)
|
||||
if not tnc.terms:
|
||||
return
|
||||
|
||||
return frappe.render_template(tnc.terms, doc, restrict_globals=1)
|
||||
|
||||
@@ -64,6 +64,7 @@ class DeprecatedSerialNoValuation:
|
||||
| (table.serial_no.like("%\n" + serial_no))
|
||||
| (table.serial_no.like("%\n" + serial_no + "\n%"))
|
||||
)
|
||||
& (table.item_code == self.sle.item_code)
|
||||
& (table.company == self.sle.company)
|
||||
& (table.warehouse == self.sle.warehouse)
|
||||
& (table.serial_and_batch_bundle.isnull())
|
||||
|
||||
@@ -298,7 +298,7 @@ def get_batches_by_oldest(item_code, warehouse):
|
||||
"""Returns the oldest batch and qty for the given item_code and warehouse"""
|
||||
batches = get_batch_qty(item_code=item_code, warehouse=warehouse)
|
||||
batches_dates = [[batch, frappe.get_value("Batch", batch.batch_no, "expiry_date")] for batch in batches]
|
||||
batches_dates.sort(key=lambda tup: tup[1])
|
||||
batches_dates.sort(key=lambda tup: (tup[1] is None, tup[1]))
|
||||
return batches_dates
|
||||
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ class Bin(Document):
|
||||
& (subcontract_order.docstatus == 1)
|
||||
)
|
||||
if subcontract_doctype == "Purchase Order"
|
||||
else (subcontract_order.docstatus == 1)
|
||||
else ((subcontract_order.status != "Closed") & (subcontract_order.docstatus == 1))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -199,6 +199,7 @@ class Bin(Document):
|
||||
else (
|
||||
(Coalesce(se.subcontracting_order, "") != "")
|
||||
& (subcontract_order.name == se.subcontracting_order)
|
||||
& (subcontract_order.status != "Closed")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
{
|
||||
"fieldname": "default_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Default Warehouse",
|
||||
"options": "Warehouse",
|
||||
@@ -63,7 +64,8 @@
|
||||
"fieldname": "buying_cost_center",
|
||||
"fieldtype": "Link",
|
||||
"label": "Default Buying Cost Center",
|
||||
"options": "Cost Center"
|
||||
"options": "Cost Center",
|
||||
"ignore_user_permissions": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "default_supplier",
|
||||
@@ -90,6 +92,7 @@
|
||||
"fieldname": "selling_cost_center",
|
||||
"fieldtype": "Link",
|
||||
"label": "Default Selling Cost Center",
|
||||
"ignore_user_permissions": 1,
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
@@ -99,12 +102,14 @@
|
||||
{
|
||||
"fieldname": "income_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Default Income Account",
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"fieldname": "default_discount_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Default Discount Account",
|
||||
"options": "Account"
|
||||
},
|
||||
@@ -123,6 +128,7 @@
|
||||
"depends_on": "eval: parent.enable_deferred_expense",
|
||||
"fieldname": "deferred_expense_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Deferred Expense Account",
|
||||
"options": "Account"
|
||||
},
|
||||
@@ -130,6 +136,7 @@
|
||||
"depends_on": "eval: parent.enable_deferred_revenue",
|
||||
"fieldname": "deferred_revenue_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Deferred Revenue Account",
|
||||
"options": "Account"
|
||||
},
|
||||
@@ -140,7 +147,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-03-17 13:46:09.719105",
|
||||
"modified": "2026-07-28 15:39:44.848087",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item Default",
|
||||
|
||||
@@ -1,161 +1,72 @@
|
||||
{
|
||||
"allow_copy": 0,
|
||||
"allow_import": 0,
|
||||
"allow_rename": 0,
|
||||
"autoname": "hash",
|
||||
"beta": 0,
|
||||
"creation": "2013-03-07 11:42:59",
|
||||
"custom": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"editable_grid": 1,
|
||||
"actions": [],
|
||||
"autoname": "hash",
|
||||
"creation": "2013-03-07 11:42:59",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"warehouse",
|
||||
"warehouse_group",
|
||||
"warehouse_reorder_level",
|
||||
"warehouse_reorder_qty",
|
||||
"material_request_type"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"fieldname": "warehouse_group",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_list_view": 1,
|
||||
"label": "Check in (group)",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "Warehouse",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
"columns": 3,
|
||||
"fieldname": "warehouse_group",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Check Availability in Warehouse",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"fieldname": "warehouse",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_list_view": 1,
|
||||
"label": "Request for",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "Warehouse",
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 1,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
"columns": 2,
|
||||
"fieldname": "warehouse",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Request for",
|
||||
"options": "Warehouse",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"fieldname": "warehouse_reorder_level",
|
||||
"fieldtype": "Float",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_list_view": 1,
|
||||
"label": "Re-order Level",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
"fieldname": "warehouse_reorder_level",
|
||||
"fieldtype": "Float",
|
||||
"in_list_view": 1,
|
||||
"label": "Re-order Level"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"fieldname": "warehouse_reorder_qty",
|
||||
"fieldtype": "Float",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_list_view": 1,
|
||||
"label": "Re-order Qty",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
"fieldname": "warehouse_reorder_qty",
|
||||
"fieldtype": "Float",
|
||||
"in_list_view": 1,
|
||||
"label": "Re-order Qty"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"fieldname": "material_request_type",
|
||||
"fieldtype": "Select",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_list_view": 1,
|
||||
"label": "Material Request Type",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "Purchase\nTransfer\nMaterial Issue\nManufacture",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 1,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
"fieldname": "material_request_type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Material Request Type",
|
||||
"options": "Purchase\nTransfer\nMaterial Issue\nManufacture",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"hide_heading": 0,
|
||||
"hide_toolbar": 0,
|
||||
"idx": 1,
|
||||
"image_view": 0,
|
||||
"in_create": 1,
|
||||
|
||||
"is_submittable": 0,
|
||||
"issingle": 0,
|
||||
"istable": 1,
|
||||
"max_attachments": 0,
|
||||
"modified": "2023-06-21 15:13:38.270046",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item Reorder",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"quick_entry": 0,
|
||||
"read_only": 0,
|
||||
"read_only_onload": 0,
|
||||
"sort_order": "ASC",
|
||||
"track_seen": 0
|
||||
}
|
||||
],
|
||||
"idx": 1,
|
||||
"in_create": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-28 17:05:12.778047",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item Reorder",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
@@ -820,7 +820,10 @@ def raise_work_orders(material_request):
|
||||
|
||||
for d in mr.items:
|
||||
if (d.stock_qty - d.ordered_qty) > 0:
|
||||
if frappe.db.exists("BOM", {"item": d.item_code, "is_default": 1}):
|
||||
if frappe.db.exists("BOM", {"item": d.item_code, "is_default": 1, "is_active": 1}) or (
|
||||
(variant_of := frappe.get_value("Item", d.item_code, "variant_of"))
|
||||
and frappe.db.exists("BOM", {"item": variant_of, "is_default": 1, "is_active": 1})
|
||||
):
|
||||
wo_order = frappe.new_doc("Work Order")
|
||||
wo_order.update(
|
||||
{
|
||||
|
||||
@@ -1339,6 +1339,9 @@ def create_dn_wo_so(pick_list, delivery_note=None):
|
||||
|
||||
delivery_note.company = pick_list.company
|
||||
|
||||
if not delivery_note.customer:
|
||||
delivery_note.customer = pick_list.customer
|
||||
|
||||
item_table_mapper_without_so = {
|
||||
"doctype": "Delivery Note Item",
|
||||
"field_map": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2751,6 +2894,50 @@ class TestStockEntry(FrappeTestCase):
|
||||
|
||||
self.assertEqual(se.process_loss_qty, 50)
|
||||
|
||||
@change_settings("Stock Settings", {"allow_negative_stock": 0})
|
||||
def test_cancel_seeds_replay_from_before_posting_datetime_bucket(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
item = make_item().name
|
||||
warehouse = create_warehouse("Cancel Replay Warehouse", company="_Test Company")
|
||||
|
||||
def stock_entry(qty, posting_date, **kwargs):
|
||||
return make_stock_entry(
|
||||
item_code=item,
|
||||
qty=qty,
|
||||
company="_Test Company",
|
||||
posting_date=posting_date,
|
||||
posting_time="10:00:00",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
stock_entry(25, add_days(today(), -3), to_warehouse=warehouse, rate=10)
|
||||
|
||||
bucket_date = add_days(today(), -2)
|
||||
stock_entry(20, bucket_date, from_warehouse=warehouse)
|
||||
receipt = stock_entry(75, bucket_date, to_warehouse=warehouse, rate=10)
|
||||
duplicate_issue = stock_entry(20, bucket_date, from_warehouse=warehouse)
|
||||
|
||||
frappe.flags.dont_execute_stock_reposts = True
|
||||
self.addCleanup(frappe.flags.pop, "dont_execute_stock_reposts")
|
||||
|
||||
duplicate_issue.reload()
|
||||
duplicate_issue.cancel()
|
||||
|
||||
self.assertEqual(
|
||||
flt(
|
||||
frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": receipt.name, "is_cancelled": 0},
|
||||
"qty_after_transaction",
|
||||
)
|
||||
),
|
||||
80,
|
||||
)
|
||||
|
||||
overdraw = stock_entry(100, add_days(today(), -1), from_warehouse=warehouse, do_not_submit=True)
|
||||
self.assertRaises(NegativeStockError, overdraw.submit)
|
||||
|
||||
|
||||
def make_serialized_item(**args):
|
||||
args = frappe._dict(args)
|
||||
|
||||
@@ -1357,6 +1357,81 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
|
||||
# receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error).
|
||||
self.assertEqual(qty_after(receipt2), 10)
|
||||
|
||||
def test_cancel_shifts_same_timestamp_delivery_notes(self):
|
||||
item = make_item("Test Shifts Same Timestamp Multiple DNs").name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
posting_date = today()
|
||||
posting_time = "10:00:00"
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item,
|
||||
to_warehouse=warehouse,
|
||||
qty=100,
|
||||
rate=10,
|
||||
posting_date=posting_date,
|
||||
posting_time="09:00:00",
|
||||
)
|
||||
|
||||
dns = []
|
||||
for i in range(5):
|
||||
dns.append(
|
||||
create_delivery_note(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=20,
|
||||
rate=10 * i,
|
||||
posting_date=posting_date,
|
||||
posting_time=posting_time,
|
||||
)
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
dn = dns[2]
|
||||
dn.cancel()
|
||||
|
||||
expected_qty_after_transaction_of_dns3 = 40
|
||||
qty_after_transaction_of_dns3 = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": dns[3].name, "is_cancelled": 0},
|
||||
"qty_after_transaction",
|
||||
)
|
||||
|
||||
self.assertEqual(expected_qty_after_transaction_of_dns3, qty_after_transaction_of_dns3)
|
||||
|
||||
def test_cancel_updates_bin_stock_value_when_no_sle_shares_timestamp(self):
|
||||
item = make_item("Test Cancel Bin Stock Value Lone Timestamp").name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
posting_date = today()
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item,
|
||||
to_warehouse=warehouse,
|
||||
qty=100,
|
||||
rate=10,
|
||||
posting_date=posting_date,
|
||||
posting_time="09:00:00",
|
||||
)
|
||||
|
||||
dn = create_delivery_note(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=20,
|
||||
rate=10,
|
||||
posting_date=posting_date,
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
|
||||
dn.cancel()
|
||||
|
||||
# Nothing else sits on the delivery note's posting datetime, so the cancellation leaves no
|
||||
# live SLE to reprocess. The bin must still fall back to the receipt's stock value.
|
||||
bin_qty, bin_stock_value = frappe.db.get_value(
|
||||
"Bin", {"item_code": item, "warehouse": warehouse}, ["actual_qty", "stock_value"]
|
||||
)
|
||||
|
||||
self.assertEqual(bin_qty, 100)
|
||||
self.assertEqual(bin_stock_value, 1000)
|
||||
|
||||
def test_get_next_stock_reco_respects_creation_order(self):
|
||||
# A stock reco sharing the exact posting timestamp of the current entry must only count as the
|
||||
# "next" reco when it was created after that entry. A reco created before it actually precedes
|
||||
|
||||
@@ -8,7 +8,7 @@ from frappe.utils import cint
|
||||
from frappe.utils.nestedset import NestedSet
|
||||
from pypika.terms import ExistsCriterion
|
||||
|
||||
from erpnext.stock import get_warehouse_account
|
||||
from erpnext.stock import get_warehouse_account, get_warehouse_account_map
|
||||
|
||||
|
||||
class Warehouse(NestedSet):
|
||||
@@ -195,11 +195,19 @@ def get_child_warehouses(warehouse):
|
||||
|
||||
def get_warehouses_based_on_account(account, company=None):
|
||||
warehouses = []
|
||||
warehouse_account_map = None
|
||||
for d in frappe.get_all(
|
||||
"Warehouse", fields=["name", "is_group"], filters={"account": account, "disabled": 0}
|
||||
):
|
||||
if d.is_group:
|
||||
warehouses.extend(get_child_warehouses(d.name))
|
||||
# Keep only children whose effective account matches; a child can override the group's account
|
||||
if warehouse_account_map is None:
|
||||
warehouse_account_map = get_warehouse_account_map(company)
|
||||
warehouses.extend(
|
||||
w
|
||||
for w in get_child_warehouses(d.name)
|
||||
if (warehouse_account_map.get(w) or {}).get("account") == account
|
||||
)
|
||||
else:
|
||||
warehouses.append(d.name)
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru
|
||||
for_validate = process_string_args(for_validate)
|
||||
overwrite_warehouse = process_string_args(overwrite_warehouse)
|
||||
item = frappe.get_cached_doc("Item", args.item_code)
|
||||
item.check_permission()
|
||||
validate_item_details(args, item)
|
||||
|
||||
if isinstance(doc, str):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -14,12 +14,12 @@ def execute(filters=None):
|
||||
conditions = get_columns(filters, "Delivery Note")
|
||||
data = get_data(filters, conditions)
|
||||
|
||||
chart_data = get_chart_data(data, filters)
|
||||
chart_data = get_chart_data(data, conditions, filters)
|
||||
|
||||
return conditions["columns"], data, None, chart_data
|
||||
|
||||
|
||||
def get_chart_data(data, filters):
|
||||
def get_chart_data(data, conditions, filters):
|
||||
def wrap_in_quotes(label):
|
||||
return f"'{label}'"
|
||||
|
||||
@@ -52,4 +52,6 @@ def get_chart_data(data, filters):
|
||||
},
|
||||
"type": "bar",
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"currency": conditions.get("company_currency"),
|
||||
}
|
||||
|
||||
@@ -14,12 +14,12 @@ def execute(filters=None):
|
||||
conditions = get_columns(filters, "Purchase Receipt")
|
||||
data = get_data(filters, conditions)
|
||||
|
||||
chart_data = get_chart_data(data, filters)
|
||||
chart_data = get_chart_data(data, conditions, filters)
|
||||
|
||||
return conditions["columns"], data, None, chart_data
|
||||
|
||||
|
||||
def get_chart_data(data, filters):
|
||||
def get_chart_data(data, conditions, filters):
|
||||
def wrap_in_quotes(label):
|
||||
return f"'{label}'"
|
||||
|
||||
@@ -53,4 +53,6 @@ def get_chart_data(data, filters):
|
||||
"type": "bar",
|
||||
"colors": ["#5e64ff"],
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"currency": conditions.get("company_currency"),
|
||||
}
|
||||
|
||||
@@ -325,6 +325,7 @@ class FIFOSlots:
|
||||
del stock_ledger_entries
|
||||
|
||||
self._recompute_moving_average_slots()
|
||||
self._rebalance_batch_slots()
|
||||
|
||||
if not self.filters.get("show_warehouse_wise_stock"):
|
||||
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
|
||||
@@ -346,6 +347,29 @@ class FIFOSlots:
|
||||
if is_qty_slot(slot):
|
||||
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate)
|
||||
|
||||
def _rebalance_batch_slots(self) -> None:
|
||||
for item_dict in self.item_details.values():
|
||||
if item_dict.get("has_batch_no"):
|
||||
self._rebalance_batch_slot_values(item_dict["fifo_queue"])
|
||||
|
||||
def _rebalance_batch_slot_values(self, fifo_queue: list) -> None:
|
||||
"""A batch is one valuation pool, so per-slot value differences are stale
|
||||
detail: spread the pool value over its slots in proportion to qty."""
|
||||
groups = {}
|
||||
for slot in fifo_queue:
|
||||
if is_batch_slot(slot):
|
||||
key = slot[BATCH_SLOT_BATCH_INDEX] if slot[BATCH_SLOT_VALUATION_INDEX] else None
|
||||
groups.setdefault(key, []).append(slot)
|
||||
|
||||
for slots in groups.values():
|
||||
total_qty = sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots)
|
||||
if total_qty <= 0:
|
||||
continue
|
||||
|
||||
rate = sum(flt(slot[BATCH_SLOT_VALUE_INDEX]) for slot in slots) / total_qty
|
||||
for slot in slots:
|
||||
slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate)
|
||||
|
||||
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
|
||||
if stock_ledger_entries is not None:
|
||||
return frappe._dict({}), frappe._dict({})
|
||||
|
||||
@@ -7,6 +7,8 @@ import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import (
|
||||
BATCH_SLOT_QTY_INDEX,
|
||||
BATCH_SLOT_VALUE_INDEX,
|
||||
FIFOSlots,
|
||||
format_report_data,
|
||||
get_average_age,
|
||||
@@ -569,10 +571,11 @@ class TestStockAgeing(FrappeTestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_partial_batch_reco_keeps_existing_slot_values(self):
|
||||
def test_partial_batch_reco_pools_slot_values(self):
|
||||
"""Ledger (same wh, batch B): [+10 @ 100, single-SLE reco >> 12]
|
||||
The reco entry qty (delta 2) does not cover the whole batch, so
|
||||
stock_value_difference / qty is not the batch rate: skip the rescale."""
|
||||
stock_value_difference / qty is not the batch rate: skip the rescale.
|
||||
The batch total (1400) is untouched, then pooled across both slots."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
@@ -612,11 +615,163 @@ class TestStockAgeing(FrappeTestCase):
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
queue = slots[item_code]["fifo_queue"]
|
||||
|
||||
self.assertEqual(
|
||||
[slot[:4] for slot in queue],
|
||||
[
|
||||
[batch_no, 1, 10.0, "2021-12-01"],
|
||||
[batch_no, 1, 2.0, "2021-12-01"],
|
||||
],
|
||||
)
|
||||
self.assertAlmostEqual(queue[0][4], 1166.67, places=2)
|
||||
self.assertAlmostEqual(queue[1][4], 233.33, places=2)
|
||||
|
||||
def test_batch_receipts_at_differing_rates_pool_slot_values(self):
|
||||
"""Ledger (same wh, batch B): [+10 @ 0, +10 @ 10] and no issue.
|
||||
Nothing goes negative, but the batch is one valuation pool, so both
|
||||
age slots carry the pooled rate instead of their receipt value."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batch Pool Split",
|
||||
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
|
||||
).name
|
||||
|
||||
batch_no = "SA-POOL-SPLIT-BATCH"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
|
||||
return frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=actual_qty,
|
||||
qty_after_transaction=qty_after,
|
||||
stock_value_difference=stock_value_difference,
|
||||
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
|
||||
warehouse="WH 1",
|
||||
posting_date=posting_date,
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no=voucher_no,
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
sle = [
|
||||
make_sle("2021-12-01", "001", 10, 10, 0),
|
||||
make_sle("2021-12-02", "002", 10, 20, 100),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
queue = slots[item_code]["fifo_queue"]
|
||||
|
||||
self.assertEqual(
|
||||
queue,
|
||||
[
|
||||
[batch_no, 1, 10.0, "2021-12-01", 1000.0],
|
||||
[batch_no, 1, 2.0, "2021-12-01", 400.0],
|
||||
[batch_no, 1, 10.0, "2021-12-01", 50.0],
|
||||
[batch_no, 1, 10.0, "2021-12-01", 50.0],
|
||||
],
|
||||
)
|
||||
|
||||
def test_batch_pooling_preserves_total_on_repeating_rate(self):
|
||||
"""Ledger (same wh, batch B): [+3 @ 100/3, +6 @ 0, +2 @ 0]
|
||||
The pooled rate does not terminate, so assert the redistributed
|
||||
slot values still add back to the batch total."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batch Pool Residual",
|
||||
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
|
||||
).name
|
||||
|
||||
batch_no = "SA-POOL-RESIDUAL-BATCH"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
|
||||
return frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=actual_qty,
|
||||
qty_after_transaction=qty_after,
|
||||
stock_value_difference=stock_value_difference,
|
||||
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
|
||||
warehouse="WH 1",
|
||||
posting_date=posting_date,
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no=voucher_no,
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
sle = [
|
||||
make_sle("2021-12-01", "001", 3, 3, 100),
|
||||
make_sle("2021-12-02", "002", 6, 9, 0),
|
||||
make_sle("2021-12-03", "003", 2, 11, 0),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
queue = slots[item_code]["fifo_queue"]
|
||||
|
||||
self.assertEqual([slot[BATCH_SLOT_QTY_INDEX] for slot in queue], [3.0, 6.0, 2.0])
|
||||
self.assertEqual(sum(slot[BATCH_SLOT_VALUE_INDEX] for slot in queue), 100.0)
|
||||
|
||||
def test_batch_issue_at_pooled_rate_keeps_slot_values_positive(self):
|
||||
"""Ledger (same wh, batch B): [+10 @ 0, +10 @ 10, -4 @ pooled 5]
|
||||
Consuming the zero-valued head slot at the pooled rate drives it
|
||||
negative; slot values are then rebalanced to the batch pool rate."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batch Pool Rebalance",
|
||||
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
|
||||
).name
|
||||
|
||||
batch_no = "SA-POOL-REBALANCE-BATCH"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
|
||||
return frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=actual_qty,
|
||||
qty_after_transaction=qty_after,
|
||||
stock_value_difference=stock_value_difference,
|
||||
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
|
||||
warehouse="WH 1",
|
||||
posting_date=posting_date,
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no=voucher_no,
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
sle = [
|
||||
make_sle("2021-12-01", "001", 10, 10, 0),
|
||||
make_sle("2021-12-02", "002", 10, 20, 100),
|
||||
make_sle("2021-12-03", "003", -4, 16, -20),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
queue = slots[item_code]["fifo_queue"]
|
||||
|
||||
self.assertEqual(
|
||||
queue,
|
||||
[
|
||||
[batch_no, 1, 6.0, "2021-12-01", 30.0],
|
||||
[batch_no, 1, 10.0, "2021-12-01", 50.0],
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ frappe.query_reports["Stock Balance"] = {
|
||||
fieldname: "include_zero_stock_items",
|
||||
label: __("Include Zero Stock Items"),
|
||||
fieldtype: "Check",
|
||||
default: 0,
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "show_dimension_wise_stock",
|
||||
|
||||
@@ -799,9 +799,18 @@ class update_entries_after:
|
||||
|
||||
def process_sle_against_current_timestamp(self):
|
||||
sl_entries = get_sle_against_current_voucher(self.args)
|
||||
if self.args.get("cancelled") and sl_entries:
|
||||
self.seed_previous_sle_for_cancellation(sl_entries[0])
|
||||
for sle in sl_entries:
|
||||
self.process_sle(sle)
|
||||
|
||||
def seed_previous_sle_for_cancellation(self, anchor_sle):
|
||||
args = frappe._dict(anchor_sle)
|
||||
args["sle_id"] = args.name
|
||||
prev_sle = get_previous_sle_of_current_voucher(args)
|
||||
if prev_sle:
|
||||
self.prev_sle_dict[(anchor_sle.item_code, anchor_sle.warehouse)] = prev_sle
|
||||
|
||||
def get_future_entries_to_fix(self):
|
||||
# includes current entry!
|
||||
args = self.data[self.args.warehouse].previous_sle or frappe._dict(
|
||||
@@ -1762,7 +1771,7 @@ def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_vouc
|
||||
voucher_no = args.get("voucher_no")
|
||||
voucher_condition = f"and voucher_no != '{voucher_no}'"
|
||||
|
||||
elif args.get("creation") and args.get("sle_id"):
|
||||
elif args.get("creation") and args.get("sle_id") and not args.get("cancelled"):
|
||||
creation = args.get("creation")
|
||||
operator = "<="
|
||||
voucher_condition = f"and creation < '{creation}'"
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
"fields": [
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "{supplier_name}",
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
@@ -465,7 +464,7 @@
|
||||
"icon": "fa fa-file-text",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-12-06 15:21:49.924146",
|
||||
"modified": "2026-07-28 12:21:09.663812",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Subcontracting",
|
||||
"name": "Subcontracting Order",
|
||||
|
||||
@@ -336,6 +336,85 @@ class TestSubcontractingOrder(FrappeTestCase):
|
||||
bin_after_cancel_sco.reserved_qty_for_sub_contract, bin_before_sco.reserved_qty_for_sub_contract
|
||||
)
|
||||
|
||||
def test_close_subcontracting_order_releases_reserved_qty(self):
|
||||
# RM in stock at the reserve warehouse for transfer
|
||||
make_stock_entry(target="_Test Warehouse - _TC", item_code="_Test Item", qty=10, basic_rate=100)
|
||||
make_stock_entry(
|
||||
target="_Test Warehouse - _TC", item_code="_Test Item Home Desktop 100", qty=20, basic_rate=100
|
||||
)
|
||||
|
||||
bin_before_sco = frappe.db.get_value(
|
||||
"Bin",
|
||||
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
|
||||
fieldname="reserved_qty_for_sub_contract",
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
# Create SCO with a reserve warehouse on the supplied items
|
||||
service_items = [
|
||||
{
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"item_code": "Subcontracted Service Item 1",
|
||||
"qty": 10,
|
||||
"rate": 100,
|
||||
"fg_item": "_Test FG Item",
|
||||
"fg_item_qty": 10,
|
||||
},
|
||||
]
|
||||
sco = get_subcontracting_order(service_items=service_items)
|
||||
|
||||
# Transfer only 90% of the raw materials to the supplier warehouse
|
||||
ste = frappe.get_doc(make_rm_stock_entry(sco.name))
|
||||
for item in ste.items:
|
||||
item.qty *= 0.9
|
||||
ste.save()
|
||||
ste.submit()
|
||||
sco.load_from_db()
|
||||
self.assertEqual(sco.status, "Partial Material Transferred")
|
||||
|
||||
# Receive only a partial qty so the order stays open (per_received < 100)
|
||||
scr = make_subcontracting_receipt(sco.name)
|
||||
scr.items[0].qty -= 1
|
||||
scr.save()
|
||||
scr.submit()
|
||||
sco.load_from_db()
|
||||
self.assertEqual(sco.status, "Partially Received")
|
||||
|
||||
# Keep another SCO open so transfers from the closed SCO must not reduce its reservation
|
||||
open_sco = get_subcontracting_order(service_items=service_items)
|
||||
self.assertEqual(open_sco.status, "Open")
|
||||
|
||||
bin_before_close = frappe.db.get_value(
|
||||
"Bin",
|
||||
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
|
||||
fieldname=["reserved_qty_for_sub_contract", "projected_qty"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
# One unit remains reserved for the partially transferred SCO, plus ten for the open SCO
|
||||
self.assertEqual(
|
||||
bin_before_close.reserved_qty_for_sub_contract,
|
||||
bin_before_sco.reserved_qty_for_sub_contract + 11,
|
||||
)
|
||||
|
||||
# Close the partially-received order
|
||||
sco.update_status("Closed")
|
||||
self.assertEqual(sco.status, "Closed")
|
||||
|
||||
bin_after_close = frappe.db.get_value(
|
||||
"Bin",
|
||||
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
|
||||
fieldname=["reserved_qty_for_sub_contract", "projected_qty"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
# Closing releases the remaining unit without applying its transfer against the open SCO
|
||||
self.assertEqual(
|
||||
bin_after_close.reserved_qty_for_sub_contract,
|
||||
bin_before_sco.reserved_qty_for_sub_contract + 10,
|
||||
)
|
||||
self.assertEqual(bin_after_close.projected_qty, bin_before_close.projected_qty + 1)
|
||||
|
||||
def test_send_to_subcontractor_ste_submit_without_sco_write_permission(self):
|
||||
"""A Stock-only user (can submit Stock Entries but has no Subcontracting Order write) must be
|
||||
able to submit and cancel a 'Send to Subcontractor' Stock Entry. The SCO status update on the
|
||||
|
||||
@@ -49,3 +49,18 @@ class TestInit(unittest.TestCase):
|
||||
from frappe.tests.test_patches import check_patch_files
|
||||
|
||||
check_patch_files("erpnext")
|
||||
|
||||
def test_no_unrendered_title_templates(self):
|
||||
modules = frappe.get_all("Module Def", filters={"app_name": "erpnext"}, pluck="name")
|
||||
for doctype in frappe.get_all("DocType", filters={"module": ("in", modules)}, pluck="name"):
|
||||
meta = frappe.get_meta(doctype)
|
||||
field = meta.get_field("title")
|
||||
if not field or not field.default or "{" not in field.default:
|
||||
continue
|
||||
|
||||
self.assertEqual(
|
||||
meta.title_field,
|
||||
"title",
|
||||
f"{doctype}: title default {field.default!r} is stored verbatim because "
|
||||
"Document.set_title_field() only renders it when title_field is 'title'",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user