mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-01 08:44:36 +00:00
Compare commits
42 Commits
v16.30.0
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ce88af3c9 | ||
|
|
684ae4d762 | ||
|
|
d09c04a623 | ||
|
|
624a236f88 | ||
|
|
7bc04752fc | ||
|
|
810da8f542 | ||
|
|
283ee6e07b | ||
|
|
e3d8336213 | ||
|
|
5595d1ed2f | ||
|
|
ebba4e9958 | ||
|
|
59bb56aa8d | ||
|
|
eb969a5866 | ||
|
|
9b452f12b7 | ||
|
|
c0ac8aaf86 | ||
|
|
0e46937f60 | ||
|
|
0c0350110e | ||
|
|
a620648471 | ||
|
|
5a75be871a | ||
|
|
3ba83134af | ||
|
|
76fce556c8 | ||
|
|
d104d8e723 | ||
|
|
249acdd7e2 | ||
|
|
17aeb0b55b | ||
|
|
2d03d80269 | ||
|
|
fb6c87dd0c | ||
|
|
34cbd3c8d5 | ||
|
|
9c86f98e5f | ||
|
|
d1d214ddee | ||
|
|
4ffa950aa3 | ||
|
|
43fd439866 | ||
|
|
aa60192ab7 | ||
|
|
b556b012f0 | ||
|
|
49924ddd1d | ||
|
|
1ea1ce15d0 | ||
|
|
04e1ca8226 | ||
|
|
16be0f0944 | ||
|
|
7daa1dacc3 | ||
|
|
87735b1f68 | ||
|
|
5e584d1cfb | ||
|
|
123e205bbd | ||
|
|
b432a10222 | ||
|
|
ca657d2629 |
@@ -24,7 +24,8 @@
|
||||
"account_number": "11530"
|
||||
},
|
||||
"account_number": "115",
|
||||
"is_group": 1
|
||||
"is_group": 1,
|
||||
"account_type": "Bank"
|
||||
},
|
||||
"Trade Receivables": {
|
||||
"Trade Debtors": {
|
||||
@@ -529,6 +530,13 @@
|
||||
"account_number": "630",
|
||||
"is_group": 1
|
||||
},
|
||||
"Accrued Manufacturing Expenses": {
|
||||
"Accrued Expenses - Manufacturing": {
|
||||
"account_number": "63510"
|
||||
},
|
||||
"account_number": "635",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "63",
|
||||
"is_group": 1
|
||||
},
|
||||
@@ -814,4 +822,4 @@
|
||||
"root_type": "Expense"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3326,13 +3326,11 @@ def set_paid_amount_and_received_amount(
|
||||
company_currency = frappe.get_cached_value("Company", doc.get("company"), "default_currency")
|
||||
if bank and company_currency != bank.account_currency:
|
||||
# doc currency can be different from bank currency
|
||||
posting_date = doc.get("posting_date") or doc.get("transaction_date")
|
||||
conversion_rate = get_exchange_rate(
|
||||
bank.account_currency, party_account_currency, posting_date
|
||||
)
|
||||
conversion_rate = get_exchange_rate(bank.account_currency, party_account_currency)
|
||||
received_amount = paid_amount / conversion_rate
|
||||
else:
|
||||
received_amount = paid_amount * doc.get("conversion_rate", 1)
|
||||
conversion_rate = get_exchange_rate(doc.get("currency", company_currency), company_currency)
|
||||
received_amount = paid_amount * conversion_rate
|
||||
|
||||
# if payment type is pay, then paid amount and received amount are swapped
|
||||
if payment_type == "Pay":
|
||||
|
||||
@@ -15,6 +15,7 @@ from erpnext.accounts.doctype.account_closing_balance.account_closing_balance im
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
)
|
||||
from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled
|
||||
from erpnext.accounts.utils import get_account_currency, get_fiscal_year
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
|
||||
@@ -46,6 +47,14 @@ class PeriodClosingVoucher(AccountsController):
|
||||
self.block_if_future_closing_voucher_exists()
|
||||
self.check_closing_account_type()
|
||||
self.check_closing_account_currency()
|
||||
self.validate_accounts_not_frozen()
|
||||
|
||||
def validate_accounts_not_frozen(self, for_cancellation=False):
|
||||
posting_date = self.period_end_date
|
||||
if for_cancellation and is_immutable_ledger_enabled():
|
||||
posting_date = getdate()
|
||||
|
||||
check_freezing_date(posting_date, self.company)
|
||||
|
||||
def validate_start_and_end_date(self):
|
||||
self.fy_start_date, self.fy_end_date = frappe.db.get_value(
|
||||
@@ -147,6 +156,7 @@ class PeriodClosingVoucher(AccountsController):
|
||||
"Process Period Closing Voucher",
|
||||
)
|
||||
self.block_if_future_closing_voucher_exists()
|
||||
self.validate_accounts_not_frozen(for_cancellation=True)
|
||||
|
||||
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
self.cancel_process_pcv_docs()
|
||||
|
||||
@@ -1380,7 +1380,20 @@ class PurchaseInvoice(BuyingController):
|
||||
)
|
||||
|
||||
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
|
||||
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
|
||||
stock_asset_rbnb = (
|
||||
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
|
||||
if item.is_fixed_asset
|
||||
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
|
||||
)
|
||||
fallback_account = (
|
||||
(item.expense_account or stock_asset_rbnb)
|
||||
if self.is_return
|
||||
else (stock_asset_rbnb or item.expense_account)
|
||||
)
|
||||
cost_of_goods_sold_account = (
|
||||
self.get_company_default("default_expense_account", ignore_validation=True)
|
||||
or fallback_account
|
||||
)
|
||||
stock_adjustment_amt = stock_amount - warehouse_debit_amount
|
||||
|
||||
gl_entries.append(
|
||||
@@ -1405,7 +1418,20 @@ class PurchaseInvoice(BuyingController):
|
||||
and warehouse_debit_amount
|
||||
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
|
||||
):
|
||||
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
|
||||
stock_asset_rbnb = (
|
||||
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
|
||||
if item.is_fixed_asset
|
||||
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
|
||||
)
|
||||
fallback_account = (
|
||||
(item.expense_account or stock_asset_rbnb)
|
||||
if self.is_return
|
||||
else (stock_asset_rbnb or item.expense_account)
|
||||
)
|
||||
cost_of_goods_sold_account = (
|
||||
self.get_company_default("default_expense_account", ignore_validation=True)
|
||||
or fallback_account
|
||||
)
|
||||
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
|
||||
stock_adjustment_amt = warehouse_debit_amount - stock_amount
|
||||
|
||||
|
||||
@@ -1490,6 +1490,96 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
)
|
||||
frappe.db.set_value("Company", "_Test Company", "exchange_gain_loss_account", original_account)
|
||||
|
||||
def test_stock_adjustment_account_fallbacks_when_default_expense_account_unset(self):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import PurchaseInvoice
|
||||
|
||||
class StockAdjustmentInvoice:
|
||||
company = "_Test Company"
|
||||
conversion_rate = 1
|
||||
update_stock = 1
|
||||
is_internal_supplier = 0
|
||||
return_against = None
|
||||
project = None
|
||||
|
||||
def __init__(self, is_return, defaults):
|
||||
self.is_return = is_return
|
||||
self.defaults = defaults
|
||||
|
||||
def get(self, fieldname):
|
||||
return None
|
||||
|
||||
def get_company_default(self, fieldname, ignore_validation=False):
|
||||
return self.defaults.get(fieldname)
|
||||
|
||||
def get_gl_dict(self, args, *unused_args, **unused_kwargs):
|
||||
return frappe._dict(args)
|
||||
|
||||
def make_invoice(is_return, defaults):
|
||||
return StockAdjustmentInvoice(is_return, defaults)
|
||||
|
||||
def make_item(is_fixed_asset=0, expense_account="Item Expense - _TC"):
|
||||
return frappe._dict(
|
||||
{
|
||||
"name": "row-1",
|
||||
"warehouse": "Stores - _TC",
|
||||
"valuation_rate": 10,
|
||||
"qty": 10,
|
||||
"conversion_factor": 1,
|
||||
"base_net_amount": 100,
|
||||
"item_tax_amount": 0,
|
||||
"landed_cost_voucher_amount": 0,
|
||||
"sales_incoming_rate": 0,
|
||||
"is_fixed_asset": is_fixed_asset,
|
||||
"expense_account": expense_account,
|
||||
"cost_center": "Main - _TC",
|
||||
"project": None,
|
||||
"precision": lambda fieldname: 2,
|
||||
}
|
||||
)
|
||||
|
||||
defaults = {
|
||||
"default_expense_account": None,
|
||||
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC",
|
||||
"asset_received_but_not_billed": "Asset Received But Not Billed - _TC",
|
||||
}
|
||||
test_cases = (
|
||||
(
|
||||
"company default expense",
|
||||
0,
|
||||
make_item(),
|
||||
{**defaults, "default_expense_account": "Default Expense - _TC"},
|
||||
"Default Expense - _TC",
|
||||
),
|
||||
("stock rbnb", 0, make_item(), defaults, "Stock Received But Not Billed - _TC"),
|
||||
(
|
||||
"asset rbnb",
|
||||
0,
|
||||
make_item(is_fixed_asset=1),
|
||||
defaults,
|
||||
"Asset Received But Not Billed - _TC",
|
||||
),
|
||||
("return item expense", 1, make_item(), defaults, "Item Expense - _TC"),
|
||||
(
|
||||
"return without item expense",
|
||||
1,
|
||||
make_item(expense_account=None),
|
||||
defaults,
|
||||
"Stock Received But Not Billed - _TC",
|
||||
),
|
||||
)
|
||||
|
||||
for label, is_return, item, company_defaults, expected_account in test_cases:
|
||||
with self.subTest(label=label):
|
||||
invoice = make_invoice(is_return, company_defaults)
|
||||
gl_entries = []
|
||||
PurchaseInvoice.make_stock_adjustment_entry(
|
||||
invoice, gl_entries, item, {(item.name, item.warehouse): 90}, "INR"
|
||||
)
|
||||
|
||||
self.assertEqual(gl_entries[0].account, expected_account)
|
||||
self.assertEqual(gl_entries[0].debit, 10)
|
||||
self.assertEqual(gl_entries[0].debit_in_transaction_currency, 10)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1})
|
||||
def test_purchase_invoice_advance_taxes(self):
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
|
||||
@@ -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",
|
||||
@@ -76,8 +127,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +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
|
||||
|
||||
from erpnext.stock import get_warehouse_account_map
|
||||
# a batch has to finish well within the timeout of the job reposting it
|
||||
MAX_VOUCHERS_PER_REPOST = 50
|
||||
|
||||
HANDLED_VOUCHER_STATUSES = ("Reposted", "Skipped")
|
||||
|
||||
|
||||
class RepostAccountingLedger(Document):
|
||||
@@ -28,6 +33,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 +47,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 +88,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 +198,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.in_test,
|
||||
queue="long",
|
||||
timeout=1500,
|
||||
job_id=_repost_job_id(repost_doc_name),
|
||||
deduplicate=True,
|
||||
enqueue_after_commit=True,
|
||||
now=frappe.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,27 +1,42 @@
|
||||
# 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.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.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
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
REPOST_MODULE = "erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger"
|
||||
SIMULATED_FAILURE = "Simulated repost failure"
|
||||
|
||||
|
||||
class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0)
|
||||
update_repost_settings()
|
||||
|
||||
def test_01_basic_functions(self):
|
||||
si = create_sales_invoice(
|
||||
def make_invoice(self, **kwargs):
|
||||
return create_sales_invoice(
|
||||
item="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
@@ -29,8 +44,71 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
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 = "_Test 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="_Test Company")
|
||||
pcv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Period Closing Voucher",
|
||||
"transaction_date": today(),
|
||||
"period_start_date": fy[1],
|
||||
"period_end_date": today(),
|
||||
"company": "_Test Company",
|
||||
"fiscal_year": fy[0],
|
||||
"cost_center": "Main - _TC",
|
||||
"closing_account_head": "Retained Earnings - _TC",
|
||||
"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,
|
||||
@@ -64,51 +142,24 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": "Debtors - _TC"})
|
||||
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="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
debit_to="Debtors - _TC",
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
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 = "Deferred Revenue - _TC"
|
||||
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 = "_Test 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])
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
|
||||
def test_04_pcv_validation(self):
|
||||
@@ -116,86 +167,29 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
gl = frappe.qb.DocType("GL Entry")
|
||||
qb.from_(gl).delete().where(gl.company == "_Test Company").run()
|
||||
|
||||
si = create_sales_invoice(
|
||||
item="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
debit_to="Debtors - _TC",
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
rate=100,
|
||||
)
|
||||
fy = get_fiscal_year(today(), company="_Test Company")
|
||||
pcv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Period Closing Voucher",
|
||||
"transaction_date": today(),
|
||||
"period_start_date": fy[1],
|
||||
"period_end_date": today(),
|
||||
"company": "_Test Company",
|
||||
"fiscal_year": fy[0],
|
||||
"cost_center": "Main - _TC",
|
||||
"closing_account_head": "Retained Earnings - _TC",
|
||||
"remarks": "test",
|
||||
}
|
||||
)
|
||||
pcv.save().submit()
|
||||
si = self.make_invoice()
|
||||
pcv = self.make_period_closing_voucher()
|
||||
|
||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||
ral.company = "_Test 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="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
debit_to="Debtors - _TC",
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
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 = "_Test 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="_Test Item",
|
||||
company="_Test Company",
|
||||
customer="_Test Customer",
|
||||
debit_to="Debtors - _TC",
|
||||
parent_cost_center="Main - _TC",
|
||||
cost_center="Main - _TC",
|
||||
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 = "_Test 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}))
|
||||
@@ -246,11 +240,7 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
another_provisional_account,
|
||||
)
|
||||
|
||||
repost_doc = frappe.new_doc("Repost Accounting Ledger")
|
||||
repost_doc.company = "_Test 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 = [
|
||||
@@ -271,6 +261,281 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
|
||||
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")
|
||||
|
||||
discarded = self.create_repost_doc([si])
|
||||
discarded.discard()
|
||||
discarded.reload()
|
||||
self.assertEqual(discarded.status, "Cancelled")
|
||||
|
||||
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})
|
||||
)
|
||||
|
||||
@ERPNextTestSuite.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 == "_Test 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,34 +8,70 @@
|
||||
"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": "2024-03-27 13:10:32.170897",
|
||||
"modified": "2026-07-29 02:41:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Repost Accounting Ledger Items",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"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
|
||||
|
||||
@@ -96,3 +96,29 @@ frappe.ui.form.on("Subscription", {
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Subscription Plan Detail", {
|
||||
plan: function (frm, cdt, cdn) {
|
||||
const row = locals[cdt][cdn];
|
||||
if (!row.plan) return;
|
||||
const requested_plan = row.plan;
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.subscription.subscription.get_plan_dimensions",
|
||||
args: {
|
||||
plan: requested_plan,
|
||||
company: frm.doc.company,
|
||||
party_type: frm.doc.party_type,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (!r.message || locals[cdt]?.[cdn]?.plan !== requested_plan) return;
|
||||
// Only fill dimensions left empty, so a manual entry or an earlier plan is never overwritten.
|
||||
for (const [dimension, value] of Object.entries(r.message)) {
|
||||
if (frm.fields_dict[dimension] && !frm.doc[dimension]) {
|
||||
frm.set_value(dimension, value);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
)
|
||||
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||
|
||||
|
||||
class InvoiceCancelled(frappe.ValidationError):
|
||||
@@ -801,6 +802,39 @@ def get_prorata_factor(
|
||||
return diff / plan_days
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_plan_dimensions(
|
||||
plan: str, company: str | None = None, party_type: str | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Resolve a plan's accounting dimensions, falling back to the plan item's company defaults."""
|
||||
plan_doc = frappe.get_cached_doc("Subscription Plan", plan)
|
||||
|
||||
dimensions = {}
|
||||
for dimension in ["cost_center", *get_accounting_dimensions()]:
|
||||
value = plan_doc.get(dimension) or get_item_dimension(plan_doc.item, dimension, company, party_type)
|
||||
if value:
|
||||
dimensions[dimension] = value
|
||||
|
||||
return dimensions
|
||||
|
||||
|
||||
def get_item_dimension(
|
||||
item_code: str, dimension: str, company: str | None, party_type: str | None
|
||||
) -> str | None:
|
||||
if not company:
|
||||
return None
|
||||
|
||||
item_defaults = get_item_defaults(item_code, company)
|
||||
if dimension != "cost_center":
|
||||
return item_defaults.get(dimension)
|
||||
|
||||
selling = item_defaults.get("selling_cost_center")
|
||||
buying = item_defaults.get("buying_cost_center")
|
||||
if party_type == "Supplier":
|
||||
return buying or selling
|
||||
return selling or buying
|
||||
|
||||
|
||||
def process_all(subscription: list, posting_date: DateTimeLikeObject | None = None) -> None:
|
||||
"""
|
||||
Task to updates the status of all `Subscription` apart from those that are cancelled
|
||||
|
||||
@@ -17,7 +17,12 @@ from frappe.utils.data import (
|
||||
)
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.subscription.subscription import Subscription, get_prorata_factor, process_all
|
||||
from erpnext.accounts.doctype.subscription.subscription import (
|
||||
Subscription,
|
||||
get_plan_dimensions,
|
||||
get_prorata_factor,
|
||||
process_all,
|
||||
)
|
||||
from erpnext.accounts.utils import update_subscription_on_invoice_update
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -804,6 +809,48 @@ class TestSubscription(ERPNextTestSuite):
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
def test_plan_dimensions_resolve_from_plan_then_item(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
# Plan-level cost center takes precedence.
|
||||
create_plan(plan_name="_Test Sub Plan CC", cost=100, currency="INR")
|
||||
frappe.db.set_value(
|
||||
"Subscription Plan", "_Test Sub Plan CC", "cost_center", "_Test Cost Center - _TC"
|
||||
)
|
||||
self.assertEqual(
|
||||
get_plan_dimensions("_Test Sub Plan CC", "_Test Company", "Customer").get("cost_center"),
|
||||
"_Test Cost Center - _TC",
|
||||
)
|
||||
|
||||
# No plan cost center: fall back to the item's company default (selling vs buying by party type).
|
||||
item = make_item(
|
||||
"_Test Sub Dimension Item",
|
||||
{
|
||||
"is_stock_item": 0,
|
||||
"item_defaults": [
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"default_warehouse": "_Test Warehouse - _TC",
|
||||
"selling_cost_center": "_Test Cost Center - _TC",
|
||||
"buying_cost_center": "_Test Cost Center 2 - _TC",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
create_plan(plan_name="_Test Sub Plan No CC", cost=100, currency="INR", item=item.name)
|
||||
|
||||
self.assertEqual(
|
||||
get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Customer").get("cost_center"),
|
||||
"_Test Cost Center - _TC",
|
||||
)
|
||||
self.assertEqual(
|
||||
get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Supplier").get("cost_center"),
|
||||
"_Test Cost Center 2 - _TC",
|
||||
)
|
||||
|
||||
# Without a company the item fallback is skipped.
|
||||
self.assertNotIn("cost_center", get_plan_dimensions("_Test Sub Plan No CC"))
|
||||
|
||||
|
||||
def make_plans():
|
||||
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")
|
||||
|
||||
@@ -117,8 +117,11 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
{
|
||||
fieldname: "supplier_group",
|
||||
label: __("Supplier Group"),
|
||||
fieldtype: "Link",
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Supplier Group",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Supplier Group", txt);
|
||||
},
|
||||
hidden: 1,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -117,6 +117,36 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertEqual(len(report[1]), 2)
|
||||
self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
|
||||
|
||||
def test_supplier_group_filter(self):
|
||||
pi = self.create_purchase_invoice()
|
||||
supplier_group = frappe.db.get_value("Supplier", self.supplier, "supplier_group")
|
||||
other_group = frappe.get_doc(
|
||||
doctype="Supplier Group",
|
||||
supplier_group_name="_Test Supplier Group AP",
|
||||
parent_supplier_group="All Supplier Groups",
|
||||
).insert()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Supplier",
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"supplier_group": supplier_group,
|
||||
}
|
||||
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
|
||||
|
||||
filters.update({"supplier_group": [other_group.name]})
|
||||
self.assertEqual(len(execute(filters)[1]), 0)
|
||||
|
||||
filters.update({"supplier_group": [supplier_group, other_group.name]})
|
||||
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
|
||||
|
||||
filters.update({"supplier_group": ["All Supplier Groups"]})
|
||||
self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]])
|
||||
|
||||
filters.update({"supplier_group": ["_Test Supplier Group Mars"]})
|
||||
self.assertRaises(frappe.ValidationError, execute, filters)
|
||||
|
||||
def test_project_filter(self):
|
||||
project = frappe.get_doc(
|
||||
{"doctype": "Project", "project_name": "_Test AP Project", "company": self.company}
|
||||
|
||||
@@ -100,8 +100,11 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
{
|
||||
fieldname: "supplier_group",
|
||||
label: __("Supplier Group"),
|
||||
fieldtype: "Link",
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Supplier Group",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Supplier Group", txt);
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "based_on_payment_terms",
|
||||
|
||||
@@ -140,8 +140,11 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
{
|
||||
fieldname: "territory",
|
||||
label: __("Territory"),
|
||||
fieldtype: "Link",
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Territory",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Territory", txt);
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "group_by_party",
|
||||
|
||||
@@ -108,6 +108,7 @@ class ReceivablePayableReport:
|
||||
|
||||
def get_data(self):
|
||||
self.get_sales_invoices_or_customers_based_on_sales_person()
|
||||
self.get_invoices_based_on_sales_partner()
|
||||
|
||||
# Get invoice details like bill_no, due_date etc for all invoices
|
||||
self.get_invoice_details()
|
||||
@@ -243,6 +244,12 @@ class ReceivablePayableReport:
|
||||
):
|
||||
return
|
||||
|
||||
if self.filters.get("sales_partner"):
|
||||
# a return is folded onto the invoice it settles, so match that invoice's
|
||||
# partner (like the sales_person filter above), not the return's own
|
||||
if ple.against_voucher_no not in self.sales_partner_invoices:
|
||||
return
|
||||
|
||||
if self.filters.get("ignore_accounts"):
|
||||
key = (ple.against_voucher_type, ple.against_voucher_no, ple.party)
|
||||
else:
|
||||
@@ -471,7 +478,7 @@ class ReceivablePayableReport:
|
||||
"company": self.filters.company,
|
||||
"docstatus": 1,
|
||||
},
|
||||
fields=["name", "due_date", "po_no"],
|
||||
fields=["name", "due_date", "po_no", "sales_partner"],
|
||||
)
|
||||
for d in si_list:
|
||||
self.invoice_details.setdefault(d.name, d)
|
||||
@@ -909,6 +916,22 @@ class ReceivablePayableReport:
|
||||
for d in records:
|
||||
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
|
||||
|
||||
def get_invoices_based_on_sales_partner(self):
|
||||
if not self.filters.get("sales_partner"):
|
||||
return
|
||||
|
||||
self.sales_partner_invoices = set(
|
||||
frappe.get_all(
|
||||
"Sales Invoice",
|
||||
filters={
|
||||
"sales_partner": self.filters.get("sales_partner"),
|
||||
"docstatus": 1,
|
||||
"company": self.filters.company,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
|
||||
def prepare_conditions(self):
|
||||
self.qb_selection_filter = []
|
||||
self.or_filters = []
|
||||
@@ -996,7 +1019,13 @@ class ReceivablePayableReport:
|
||||
self.qb_selection_filter.append(self.ple.party.isin(customers))
|
||||
|
||||
if self.filters.get("territory"):
|
||||
self.get_hierarchical_filters("Territory", "territory")
|
||||
territories = get_nested_set_children("Territory", self.filters.territory)
|
||||
customers = (
|
||||
qb.from_(self.customer)
|
||||
.select(self.customer.name)
|
||||
.where(self.customer["territory"].isin(territories))
|
||||
)
|
||||
self.qb_selection_filter.append(self.ple.party.isin(customers))
|
||||
|
||||
if self.filters.get("payment_terms_template"):
|
||||
customer_ptt = self.ple.party.isin(
|
||||
@@ -1011,26 +1040,16 @@ class ReceivablePayableReport:
|
||||
|
||||
self.qb_selection_filter.append(Criterion.any([customer_ptt, sales_ptt]))
|
||||
|
||||
if self.filters.get("sales_partner"):
|
||||
self.qb_selection_filter.append(
|
||||
self.ple.party.isin(
|
||||
qb.from_(self.customer)
|
||||
.select(self.customer.name)
|
||||
.where(self.customer.default_sales_partner == self.filters.get("sales_partner"))
|
||||
)
|
||||
)
|
||||
|
||||
def exclude_employee_transaction(self):
|
||||
self.qb_selection_filter.append(self.ple.party_type != "Employee")
|
||||
|
||||
def add_supplier_filters(self):
|
||||
supplier = qb.DocType("Supplier")
|
||||
if self.filters.get("supplier_group"):
|
||||
groups = get_party_group_with_children("Supplier", self.filters.supplier_group)
|
||||
self.qb_selection_filter.append(
|
||||
self.ple.party.isin(
|
||||
qb.from_(supplier)
|
||||
.select(supplier.name)
|
||||
.where(supplier.supplier_group == self.filters.get("supplier_group"))
|
||||
qb.from_(supplier).select(supplier.name).where(supplier.supplier_group.isin(groups))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1082,16 +1101,6 @@ class ReceivablePayableReport:
|
||||
|
||||
return ptt
|
||||
|
||||
def get_hierarchical_filters(self, doctype, key):
|
||||
lft, rgt = frappe.db.get_value(doctype, self.filters.get(key), ["lft", "rgt"])
|
||||
|
||||
doc = qb.DocType(doctype)
|
||||
ple = self.ple
|
||||
customer = self.customer
|
||||
groups = qb.from_(doc).select(doc.name).where((doc.lft >= lft) & (doc.rgt <= rgt))
|
||||
customers = qb.from_(customer).select(customer.name).where(customer[key].isin(groups))
|
||||
self.qb_selection_filter.append(ple.party.isin(customers))
|
||||
|
||||
def add_accounting_dimensions_filters(self):
|
||||
accounting_dimensions = get_accounting_dimensions(as_list=False)
|
||||
|
||||
@@ -1119,9 +1128,6 @@ class ReceivablePayableReport:
|
||||
if self.account_type == "Receivable":
|
||||
fields = ["customer_name", "territory", "customer_group", "customer_primary_contact"]
|
||||
|
||||
if self.filters.get("sales_partner"):
|
||||
fields.append("default_sales_partner")
|
||||
|
||||
self.party_details[party] = frappe.db.get_value(
|
||||
"Customer",
|
||||
party,
|
||||
@@ -1251,7 +1257,7 @@ class ReceivablePayableReport:
|
||||
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
|
||||
|
||||
if self.filters.sales_partner:
|
||||
self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
|
||||
self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data")
|
||||
|
||||
if self.filters.account_type == "Payable":
|
||||
self.add_column(
|
||||
@@ -1338,19 +1344,23 @@ def get_party_group_with_children(party, party_groups):
|
||||
if party not in ("Customer", "Supplier"):
|
||||
return []
|
||||
|
||||
group_dtype = f"{party} Group"
|
||||
if not isinstance(party_groups, list):
|
||||
party_groups = [d.strip() for d in party_groups.strip().split(",") if d]
|
||||
return get_nested_set_children(f"{party} Group", party_groups)
|
||||
|
||||
all_party_groups = []
|
||||
for d in party_groups:
|
||||
if frappe.db.exists(group_dtype, d):
|
||||
lft, rgt = frappe.db.get_value(group_dtype, d, ["lft", "rgt"])
|
||||
children = frappe.get_all(
|
||||
group_dtype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name"
|
||||
)
|
||||
all_party_groups += children
|
||||
|
||||
def get_nested_set_children(doctype, values):
|
||||
if not isinstance(values, list):
|
||||
values = [d.strip() for d in values.split(",") if d.strip()]
|
||||
|
||||
if not values:
|
||||
frappe.throw(_("Please select a valid {0}").format(_(doctype)))
|
||||
|
||||
all_values = []
|
||||
for d in values:
|
||||
if frappe.db.exists(doctype, d):
|
||||
lft, rgt = frappe.db.get_value(doctype, d, ["lft", "rgt"])
|
||||
children = frappe.get_all(doctype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name")
|
||||
all_values += children
|
||||
else:
|
||||
frappe.throw(_("{0}: {1} does not exist").format(group_dtype, d))
|
||||
frappe.throw(_("{0}: {1} does not exist").format(doctype, d))
|
||||
|
||||
return list(set(all_party_groups))
|
||||
return list(set(all_values))
|
||||
|
||||
@@ -6,6 +6,7 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.accounts_receivable.accounts_receivable import execute
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -778,6 +779,38 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
# Assert that the customer group of each row is in the list of customer groups
|
||||
self.assertIn(row.customer_group, cus_groups_list)
|
||||
|
||||
def test_territory_filter(self):
|
||||
self.create_sales_invoice()
|
||||
territory = frappe.db.get_value("Customer", self.customer, "territory")
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"territory": territory,
|
||||
}
|
||||
report = execute(filters)[1]
|
||||
self.assertEqual(len(report), 1)
|
||||
self.assertEqual(
|
||||
[100.0, 100.0, territory], [report[0].invoiced, report[0].outstanding, report[0].territory]
|
||||
)
|
||||
|
||||
filters.update({"territory": ["_Test Territory United States"]})
|
||||
self.assertEqual(len(execute(filters)[1]), 0)
|
||||
|
||||
filters.update({"territory": [territory, "_Test Territory United States"]})
|
||||
self.assertEqual(len(execute(filters)[1]), 1)
|
||||
|
||||
frappe.db.set_value("Customer", self.customer, "territory", "_Test Territory Maharashtra")
|
||||
filters.update({"territory": ["_Test Territory India"]})
|
||||
self.assertEqual(len(execute(filters)[1]), 1)
|
||||
|
||||
filters.update({"territory": ["_Test Territory Mars"]})
|
||||
self.assertRaises(frappe.ValidationError, execute, filters)
|
||||
|
||||
filters.update({"territory": " "})
|
||||
self.assertRaises(frappe.ValidationError, execute, filters)
|
||||
|
||||
def test_party_account_filter(self):
|
||||
si1 = self.create_sales_invoice()
|
||||
jane = frappe.get_doc(
|
||||
@@ -1292,3 +1325,61 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertIn(original_customer, parties)
|
||||
self.assertNotIn(second_customer, parties)
|
||||
self.assertEqual(allowed_invoice.customer, original_customer)
|
||||
|
||||
def test_receivable_filtered_by_sales_partner(self):
|
||||
frappe.set_user("Administrator")
|
||||
partner_a, partner_b = "_Test AR Sales Partner A", "_Test AR Sales Partner B"
|
||||
for partner in (partner_a, partner_b):
|
||||
if not frappe.db.exists("Sales Partner", partner):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Sales Partner",
|
||||
"partner_name": partner,
|
||||
"commission_rate": 0,
|
||||
"territory": "All Territories",
|
||||
}
|
||||
).insert()
|
||||
|
||||
def _si(sales_partner):
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True, qty=2)
|
||||
si.sales_partner = sales_partner
|
||||
return si.save().submit()
|
||||
|
||||
partner_a_si = _si(partner_a)
|
||||
partner_b_si = _si(partner_b)
|
||||
no_partner_si = _si(None)
|
||||
|
||||
# a return is folded onto the invoice it settles, so it nets against that
|
||||
# invoice's partner even when the return's own partner is cleared
|
||||
no_partner_return = make_return_doc("Sales Invoice", partner_a_si.name)
|
||||
no_partner_return.sales_partner = None
|
||||
no_partner_return.items[0].qty = -1
|
||||
no_partner_return.update_outstanding_for_self = 0
|
||||
no_partner_return.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Customer",
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
def rows_for(partner):
|
||||
return {
|
||||
r.voucher_no: r
|
||||
for r in execute({**filters, "sales_partner": partner})[1]
|
||||
if r.get("voucher_no")
|
||||
}
|
||||
|
||||
rows_a = rows_for(partner_a)
|
||||
self.assertIn(partner_a_si.name, rows_a)
|
||||
self.assertEqual(rows_a[partner_a_si.name].sales_partner, partner_a)
|
||||
self.assertNotIn(partner_b_si.name, rows_a)
|
||||
self.assertNotIn(no_partner_si.name, rows_a)
|
||||
self.assertNotIn(no_partner_return.name, rows_a)
|
||||
self.assertEqual(rows_a[partner_a_si.name].credit_note, 100)
|
||||
self.assertEqual(rows_a[partner_a_si.name].outstanding, 100)
|
||||
|
||||
rows_b = rows_for(partner_b)
|
||||
self.assertIn(partner_b_si.name, rows_b)
|
||||
self.assertNotIn(partner_a_si.name, rows_b)
|
||||
|
||||
@@ -106,8 +106,11 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
{
|
||||
fieldname: "territory",
|
||||
label: __("Territory"),
|
||||
fieldtype: "Link",
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Territory",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Territory", txt);
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "sales_partner",
|
||||
|
||||
@@ -132,8 +132,8 @@ class AccountsReceivableSummary(ReceivablePayableReport):
|
||||
if row.sales_person:
|
||||
self.party_total[row.party].sales_person.append(row.get("sales_person", ""))
|
||||
|
||||
if self.filters.sales_partner:
|
||||
self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner", "")
|
||||
if self.filters.sales_partner and row.get("sales_partner"):
|
||||
self.party_total[row.party]["sales_partner"] = row.get("sales_partner")
|
||||
|
||||
def get_columns(self):
|
||||
self.columns = []
|
||||
@@ -191,7 +191,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
|
||||
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
|
||||
|
||||
if self.filters.sales_partner:
|
||||
self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
|
||||
self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data")
|
||||
|
||||
else:
|
||||
self.add_column(
|
||||
|
||||
@@ -191,3 +191,42 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
report = execute(filters)
|
||||
rpt_output = report[1]
|
||||
self.assertEqual(len(rpt_output), 0)
|
||||
|
||||
def test_03_summary_sales_partner_column(self):
|
||||
partner = "_Test AR Summary Sales Partner"
|
||||
if not frappe.db.exists("Sales Partner", partner):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Sales Partner",
|
||||
"partner_name": partner,
|
||||
"commission_rate": 0,
|
||||
"territory": "All Territories",
|
||||
}
|
||||
).insert()
|
||||
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debit_to,
|
||||
posting_date=today(),
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=200,
|
||||
price_list_rate=200,
|
||||
do_not_submit=True,
|
||||
)
|
||||
si.sales_partner = partner
|
||||
si.save().submit()
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"customer": self.customer,
|
||||
"posting_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"sales_partner": partner,
|
||||
}
|
||||
|
||||
rpt_output = execute(filters)[1]
|
||||
self.assertEqual(len(rpt_output), 1)
|
||||
self.assertEqual(rpt_output[0].get("sales_partner"), partner)
|
||||
|
||||
@@ -5,6 +5,8 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import IfNull
|
||||
|
||||
from erpnext.accounts.report.utils import validate_mandatory_date_range
|
||||
|
||||
|
||||
class TaxWithholdingDetailsReport:
|
||||
party_types = ("Customer", "Supplier")
|
||||
@@ -25,11 +27,7 @@ class TaxWithholdingDetailsReport:
|
||||
return self.get_columns(), self.get_data()
|
||||
|
||||
def validate_filters(self):
|
||||
if not self.filters.from_date or not self.filters.to_date:
|
||||
frappe.throw(_("From Date and To Date are required"))
|
||||
|
||||
if self.filters.from_date > self.filters.to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
validate_mandatory_date_range(self.filters)
|
||||
|
||||
def get_data(self):
|
||||
self.entries = self.get_entries_query().run(as_dict=True)
|
||||
|
||||
@@ -21,8 +21,7 @@ class TDSComputationSummaryReport(TaxWithholdingDetailsReport):
|
||||
AGGREGATE_FIELDS = ("total_amount", "tax_amount")
|
||||
|
||||
def validate_filters(self):
|
||||
if self.filters.from_date > self.filters.to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
super().validate_filters()
|
||||
|
||||
from_year = get_fiscal_year(self.filters.from_date)[0]
|
||||
to_year = get_fiscal_year(self.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
|
||||
|
||||
@@ -1332,7 +1332,7 @@ def has_active_capitalization(asset):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
|
||||
def get_values_from_purchase_doc(purchase_doc_name: str, item_code: str, doctype: str):
|
||||
purchase_doc = frappe.get_doc(doctype, purchase_doc_name)
|
||||
matching_items = [item for item in purchase_doc.items if item.item_code == item_code]
|
||||
|
||||
@@ -1344,7 +1344,7 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
|
||||
return {
|
||||
"company": purchase_doc.company,
|
||||
"purchase_date": purchase_doc.get("posting_date"),
|
||||
"net_purchase_amount": flt(first_item.base_net_amount),
|
||||
"net_purchase_amount": flt(first_item.valuation_rate) * flt(first_item.qty),
|
||||
"asset_quantity": first_item.qty,
|
||||
"cost_center": first_item.cost_center or purchase_doc.get("cost_center"),
|
||||
"asset_location": first_item.get("asset_location"),
|
||||
|
||||
@@ -3873,6 +3873,7 @@ def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
|
||||
|
||||
for d in deleted_children:
|
||||
validate_child_on_delete(d, parent, ordered_item)
|
||||
d.flags.ignore_permissions = True
|
||||
d.cancel()
|
||||
d.delete()
|
||||
|
||||
|
||||
@@ -332,7 +332,9 @@ def bom(doctype, txt, searchfield, start, page_len, filters):
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_project_name(doctype, txt, searchfield, start, page_len, filters):
|
||||
def get_project_name(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None
|
||||
):
|
||||
proj = qb.DocType("Project")
|
||||
qb_filter_and_conditions = []
|
||||
qb_filter_or_conditions = []
|
||||
@@ -347,7 +349,7 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
|
||||
if filters.get("company"):
|
||||
qb_filter_and_conditions.append(proj.company == filters.get("company"))
|
||||
|
||||
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled"]))
|
||||
qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled", "On hold"]))
|
||||
|
||||
q = qb.from_(proj)
|
||||
|
||||
|
||||
@@ -67,6 +67,14 @@ class PlantFloor(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_stock_summary(warehouse, start=0, item_code=None, item_group=None):
|
||||
frappe.has_permission("Warehouse", doc=warehouse, throw=True)
|
||||
|
||||
if item_code:
|
||||
frappe.has_permission("Item", doc=item_code, throw=True)
|
||||
|
||||
if item_group:
|
||||
frappe.has_permission("Item Group", doc=item_group, throw=True)
|
||||
|
||||
stock_details = get_stock_details(warehouse, start=start, item_code=item_code, item_group=item_group)
|
||||
|
||||
max_count = 0.0
|
||||
|
||||
@@ -46,3 +46,60 @@ frappe.views.calendar["Work Order"] = {
|
||||
],
|
||||
get_events_method: "frappe.desk.calendar.get_events",
|
||||
};
|
||||
|
||||
const WORK_ORDER_GANTT_COLORS = {
|
||||
Draft: "red",
|
||||
Stopped: "red",
|
||||
"Not Started": "red",
|
||||
"In Process": "orange",
|
||||
Completed: "green",
|
||||
"Stock Reserved": "blue",
|
||||
"Stock Partially Reserved": "orange",
|
||||
Cancelled: "gray",
|
||||
};
|
||||
|
||||
if (!frappe.views.GanttView.prototype._work_order_status_colors) {
|
||||
frappe.views.GanttView.prototype._work_order_status_colors = true;
|
||||
|
||||
const prepare_tasks = frappe.views.GanttView.prototype.prepare_tasks;
|
||||
frappe.views.GanttView.prototype.prepare_tasks = function () {
|
||||
prepare_tasks.call(this);
|
||||
if (this.doctype === "Work Order") {
|
||||
set_work_order_bar_classes(this);
|
||||
}
|
||||
};
|
||||
|
||||
const set_colors = frappe.views.GanttView.prototype.set_colors;
|
||||
frappe.views.GanttView.prototype.set_colors = function () {
|
||||
set_colors.call(this);
|
||||
if (this.doctype === "Work Order") {
|
||||
set_work_order_bar_styles(this);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function set_work_order_bar_classes(view) {
|
||||
view.tasks.forEach((task, idx) => {
|
||||
const color = WORK_ORDER_GANTT_COLORS[view.data[idx].status];
|
||||
if (color) {
|
||||
task.custom_class = "wo-" + color;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function set_work_order_bar_styles(view) {
|
||||
const style = [...new Set(Object.values(WORK_ORDER_GANTT_COLORS))]
|
||||
.map(
|
||||
(color) => `
|
||||
.gantt .bar-wrapper.wo-${color} .bar {
|
||||
fill: var(--${color}-300);
|
||||
}
|
||||
.gantt .bar-wrapper.wo-${color} .bar-progress {
|
||||
fill: var(--${color}-300);
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
view.$result.prepend(`<style>${style}</style>`);
|
||||
}
|
||||
|
||||
@@ -494,3 +494,5 @@ erpnext.patches.v16_0.access_control_for_project_users
|
||||
erpnext.patches.v16_0.enable_book_stock_expense_gl_entries
|
||||
erpnext.patches.v16_0.rename_ar_ap_ageing_filter
|
||||
erpnext.patches.v16_0.fix_subcontracting_titles
|
||||
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
|
||||
erpnext.patches.v16_0.merge_seeded_item_group_root
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Coalesce
|
||||
|
||||
|
||||
def execute():
|
||||
"""Backfill the statuses of documents reposted before those fields existed.
|
||||
|
||||
Without it they show up as drafts and are offered a `Start Reposting` button that would
|
||||
repost vouchers which are already reposted.
|
||||
"""
|
||||
ral = frappe.qb.DocType("Repost Accounting Ledger")
|
||||
items = frappe.qb.DocType("Repost Accounting Ledger Items")
|
||||
|
||||
reposted = (
|
||||
frappe.qb.from_(ral).select(ral.name).where((ral.docstatus == 1) & (Coalesce(ral.status, "") == ""))
|
||||
)
|
||||
frappe.qb.update(items).set(items.status, "Reposted").where(items.parent.isin(reposted)).run()
|
||||
|
||||
for docstatus, status in ((1, "Completed"), (2, "Cancelled")):
|
||||
(
|
||||
frappe.qb.update(ral)
|
||||
.set(ral.status, status)
|
||||
.where((ral.docstatus == docstatus) & (Coalesce(ral.status, "") == ""))
|
||||
.run()
|
||||
)
|
||||
23
erpnext/patches/v16_0/merge_seeded_item_group_root.py
Normal file
23
erpnext/patches/v16_0/merge_seeded_item_group_root.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import frappe
|
||||
from frappe.utils.nestedset import get_root_of
|
||||
|
||||
SEEDED_ROOT = "All Item Groups"
|
||||
|
||||
|
||||
def execute():
|
||||
"""Collapse the "All Item Groups" node seeded under a pre-existing root.
|
||||
|
||||
Setup seeding always inserted "All Item Groups" as a parentless group. On a
|
||||
site where another app had already created the root (under a translated
|
||||
name), it was re-parented instead, leaving a second group-root holding the
|
||||
standard Item Groups.
|
||||
"""
|
||||
root = get_root_of("Item Group")
|
||||
if not root or root == SEEDED_ROOT:
|
||||
return
|
||||
|
||||
seeded = frappe.db.get_value("Item Group", SEEDED_ROOT, ["parent_item_group", "is_group"], as_dict=True)
|
||||
if not seeded or not seeded.is_group or seeded.parent_item_group != root:
|
||||
return
|
||||
|
||||
frappe.rename_doc("Item Group", SEEDED_ROOT, root, merge=True, show_alert=False)
|
||||
@@ -299,6 +299,23 @@ class TestProject(ERPNextTestSuite):
|
||||
project.save()
|
||||
self.assertEqual(project.percent_complete, 100)
|
||||
|
||||
def test_on_hold_project_keeps_status(self):
|
||||
project, tasks = self._project_with_tasks("Task Completion", 4)
|
||||
|
||||
# an On hold project is not auto-flipped to Completed even at 100%
|
||||
project.status = "On hold"
|
||||
for task in tasks:
|
||||
frappe.db.set_value("Task", task, "status", "Completed")
|
||||
project.update_percent_complete()
|
||||
self.assertEqual(project.percent_complete, 100)
|
||||
self.assertEqual(project.status, "On hold")
|
||||
|
||||
# nor auto-flipped back to Open when below 100%
|
||||
frappe.db.set_value("Task", tasks[0], "status", "Open")
|
||||
project.update_percent_complete()
|
||||
self.assertEqual(project.percent_complete, 75)
|
||||
self.assertEqual(project.status, "On hold")
|
||||
|
||||
def _create_portal_user(self, email):
|
||||
"""A user with no Project-related role, so read access can only come from
|
||||
control_access_for_project_users() sharing the doc with them."""
|
||||
|
||||
@@ -14,6 +14,12 @@ frappe.ui.form.on("Task", {
|
||||
};
|
||||
},
|
||||
onload: function (frm) {
|
||||
frm.set_query("project", function () {
|
||||
return {
|
||||
query: "erpnext.controllers.queries.get_project_name",
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("task", "depends_on", function () {
|
||||
let filters = {
|
||||
name: ["!=", frm.doc.name],
|
||||
|
||||
@@ -30,6 +30,7 @@ frappe.ui.form.on("Timesheet", {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
status: "Open",
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -122,6 +123,7 @@ frappe.ui.form.on("Timesheet", {
|
||||
return {
|
||||
filters: {
|
||||
customer: doc.customer,
|
||||
status: "Open",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ frappe.query_reports["Project Summary"] = {
|
||||
fieldname: "status",
|
||||
label: __("Status"),
|
||||
fieldtype: "Select",
|
||||
options: "\nOpen\nCompleted\nCancelled",
|
||||
options: "\nOpen\nOn hold\nCompleted\nCancelled",
|
||||
default: "Open",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -219,7 +219,7 @@ def append_row_as_charges(items, tax, reference_row, summary_data):
|
||||
# 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:
|
||||
@@ -303,7 +303,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",
|
||||
@@ -369,7 +369,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",
|
||||
|
||||
@@ -151,6 +151,9 @@ class Quotation(SellingController):
|
||||
|
||||
make_packing_list(self)
|
||||
|
||||
def after_insert(self):
|
||||
self.carry_forward_communication()
|
||||
|
||||
def before_submit(self):
|
||||
self.set_has_alternative_item()
|
||||
|
||||
@@ -296,7 +299,6 @@ class Quotation(SellingController):
|
||||
# update enquiry status
|
||||
self.update_opportunity("Quotation")
|
||||
self.update_lead()
|
||||
self.carry_forward_communication()
|
||||
|
||||
def on_cancel(self):
|
||||
if self.lost_reasons:
|
||||
|
||||
@@ -688,6 +688,51 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
frappe.ValidationError, update_child_qty_rate, "Sales Order", trans_item, so.name
|
||||
)
|
||||
|
||||
def test_update_child_removing_item_without_cancel_and_delete_perms(self):
|
||||
for workflow_name in frappe.get_all(
|
||||
"Workflow", filters={"document_type": "Sales Order", "is_active": 1}, pluck="name"
|
||||
):
|
||||
workflow = frappe.get_doc("Workflow", workflow_name)
|
||||
workflow.is_active = 0
|
||||
workflow.save()
|
||||
|
||||
role = "_Test Sales Order Item Editor"
|
||||
if not frappe.db.exists("Role", role):
|
||||
frappe.get_doc({"doctype": "Role", "role_name": role, "desk_access": 1}).insert()
|
||||
|
||||
frappe.permissions.add_permission("Sales Order", role, 0)
|
||||
for right, value in {
|
||||
"read": 1,
|
||||
"write": 1,
|
||||
"create": 1,
|
||||
"submit": 1,
|
||||
"cancel": 0,
|
||||
"delete": 0,
|
||||
}.items():
|
||||
frappe.permissions.update_permission_property("Sales Order", role, 0, right, value)
|
||||
frappe.clear_cache()
|
||||
|
||||
so = make_sales_order(**{"item_list": [{"item_code": "_Test Item", "qty": 5, "rate": 1000}]})
|
||||
trans_item = json.dumps(
|
||||
[
|
||||
{"item_code": "_Test Item", "qty": 5, "rate": 1000, "docname": so.items[0].name},
|
||||
{"item_code": "_Test Item 2", "qty": 2, "rate": 500},
|
||||
]
|
||||
)
|
||||
update_child_qty_rate("Sales Order", trans_item, so.name)
|
||||
so.reload()
|
||||
self.assertEqual(len(so.items), 2)
|
||||
|
||||
test_user = create_user("test_so_item_editor@example.com", role, "Accounts User", "Stock User")
|
||||
trans_item = json.dumps(
|
||||
[{"item_code": "_Test Item", "qty": 5, "rate": 1000, "docname": so.items[0].name}]
|
||||
)
|
||||
with self.set_user(test_user.name):
|
||||
update_child_qty_rate("Sales Order", trans_item, so.name)
|
||||
|
||||
so.reload()
|
||||
self.assertEqual(len(so.items), 1)
|
||||
|
||||
def test_update_child_qty_rate_with_workflow(self):
|
||||
from frappe.model.workflow import apply_workflow
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.utils.nestedset import (
|
||||
@@ -14,6 +15,8 @@ from frappe.utils.nestedset import (
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
TRANSLATED_ROOT = "Todos os Grupos de Itens"
|
||||
|
||||
|
||||
class TestItemGroup(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
@@ -209,6 +212,54 @@ class TestItemGroup(ERPNextTestSuite):
|
||||
merge=True,
|
||||
)
|
||||
|
||||
def test_preset_records_use_existing_root(self):
|
||||
from erpnext.setup.setup_wizard.operations import install_fixtures
|
||||
|
||||
with patch.object(install_fixtures, "get_root_of", return_value=TRANSLATED_ROOT):
|
||||
records = [
|
||||
r for r in install_fixtures.get_preset_records("India") if r["doctype"] == "Item Group"
|
||||
]
|
||||
|
||||
root_record, *child_records = records
|
||||
self.assertEqual(root_record["item_group_name"], TRANSLATED_ROOT)
|
||||
self.assertTrue(root_record["__condition"]())
|
||||
self.assertEqual({r["parent_item_group"] for r in child_records}, {TRANSLATED_ROOT})
|
||||
|
||||
with patch.object(install_fixtures, "get_root_of", return_value="All Item Groups"):
|
||||
root_record = next(
|
||||
r for r in install_fixtures.get_preset_records("India") if r["doctype"] == "Item Group"
|
||||
)
|
||||
self.assertFalse(root_record["__condition"]())
|
||||
|
||||
def test_patch_merges_seeded_root_into_existing_root(self):
|
||||
from erpnext.patches.v16_0.merge_seeded_item_group_root import execute
|
||||
|
||||
self._nest_root_under(TRANSLATED_ROOT)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Item Group", "All Item Groups", "parent_item_group"), TRANSLATED_ROOT
|
||||
)
|
||||
|
||||
execute()
|
||||
|
||||
self.assertFalse(frappe.db.exists("Item Group", "All Item Groups"))
|
||||
self.assertEqual(
|
||||
frappe.get_all("Item Group", filters={"parent_item_group": ("is", "not set")}, pluck="name"),
|
||||
[TRANSLATED_ROOT],
|
||||
)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Item Group", "_Test Item Group B", "parent_item_group"), TRANSLATED_ROOT
|
||||
)
|
||||
self.test_basic_tree()
|
||||
|
||||
def _nest_root_under(self, new_root):
|
||||
"""Recreate the tree left behind by seeding a root under a pre-existing one."""
|
||||
frappe.get_doc({"doctype": "Item Group", "item_group_name": new_root, "is_group": 1}).insert()
|
||||
|
||||
ig = frappe.qb.DocType("Item Group")
|
||||
frappe.qb.update(ig).set(ig.parent_item_group, "").where(ig.name == new_root).run()
|
||||
frappe.qb.update(ig).set(ig.parent_item_group, new_root).where(ig.name == "All Item Groups").run()
|
||||
rebuild_tree("Item Group")
|
||||
|
||||
def _move_it_back(self):
|
||||
group_b = frappe.get_doc("Item Group", "_Test Item Group B")
|
||||
group_b.parent_item_group = "All Item Groups"
|
||||
|
||||
@@ -12,6 +12,7 @@ from frappe.desk.doctype.global_search_settings.global_search_settings import (
|
||||
)
|
||||
from frappe.desk.page.setup_wizard.setup_wizard import make_records
|
||||
from frappe.utils import cstr, getdate
|
||||
from frappe.utils.nestedset import get_root_of
|
||||
|
||||
from erpnext.accounts.doctype.account.account import RootNotEditable
|
||||
from erpnext.regional.address_template.setup import set_up_address_templates
|
||||
@@ -24,46 +25,48 @@ def read_lines(filename: str) -> list[str]:
|
||||
|
||||
|
||||
def get_preset_records(country=None):
|
||||
root_item_group = get_root_of("Item Group") or _("All Item Groups")
|
||||
records = [
|
||||
# ensure at least an empty Address Template exists for this Country
|
||||
{"doctype": "Address Template", "country": country},
|
||||
# item group
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("All Item Groups"),
|
||||
"item_group_name": root_item_group,
|
||||
"is_group": 1,
|
||||
"parent_item_group": "",
|
||||
"__condition": lambda: not frappe.db.exists("Item Group", root_item_group),
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Products"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
"show_in_website": 1,
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Raw Material"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Services"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Sub Assemblies"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Consumable"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
},
|
||||
# Stock Entry Type
|
||||
{
|
||||
|
||||
@@ -79,10 +79,13 @@ def get_warehouse_account(warehouse, warehouse_account=None):
|
||||
account = get_company_default_inventory_account(warehouse.company)
|
||||
|
||||
if not account and warehouse.company:
|
||||
account = frappe.db.get_value(
|
||||
"Account", {"account_type": "Stock", "is_group": 0, "company": warehouse.company}, "name"
|
||||
inventory_accounts = frappe.get_all(
|
||||
"Account", {"account_type": "Stock", "is_group": 0, "company": warehouse.company}, pluck="name"
|
||||
)
|
||||
|
||||
if len(inventory_accounts) == 1:
|
||||
account = inventory_accounts[0]
|
||||
|
||||
if not account and warehouse.company and not warehouse.is_group:
|
||||
frappe.throw(
|
||||
_("Please set Account in Warehouse {0} or Default Inventory Account in Company {1}").format(
|
||||
|
||||
@@ -712,6 +712,76 @@ class TestDeliveryNote(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(gle_warehouse_amount, 1400)
|
||||
|
||||
def test_return_bundle_voucher_detail_no_as_packed_item(self):
|
||||
"""Return bundle whose voucher_detail_no is the Packed Item (SLE-driven path) must still value on repost."""
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
packed_item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "BATCH-DN-RET-VDN-.#####",
|
||||
}
|
||||
).name
|
||||
bundle_item = make_item(properties={"is_stock_item": 0, "is_sales_item": 1}).name
|
||||
make_product_bundle(bundle_item, [packed_item], qty=20)
|
||||
|
||||
make_stock_entry(item_code=packed_item, target=warehouse, qty=60, basic_rate=35)
|
||||
|
||||
dn = create_delivery_note(item_code=bundle_item, warehouse=warehouse, qty=3)
|
||||
|
||||
return_dn = make_sales_return(dn.name)
|
||||
return_dn.items[0].qty = -2
|
||||
return_dn.submit()
|
||||
return_dn.reload()
|
||||
|
||||
packed_row = return_dn.packed_items[0]
|
||||
bundle = frappe.get_doc("Serial and Batch Bundle", packed_row.serial_and_batch_bundle)
|
||||
|
||||
# Reproduce the reported state: bundle points at the Packed Item (not the DN Item), valuation at 0.
|
||||
bundle.db_set("voucher_detail_no", packed_row.name)
|
||||
bundle.db_set({"avg_rate": 0, "total_amount": 0})
|
||||
for entry in bundle.entries:
|
||||
entry.db_set({"incoming_rate": 0, "stock_value_difference": 0})
|
||||
packed_row.db_set("incoming_rate", 0)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_type": "Delivery Note",
|
||||
"voucher_no": return_dn.name,
|
||||
"item_code": packed_item,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
{"incoming_rate": 0, "stock_value_difference": 0},
|
||||
)
|
||||
|
||||
frappe.get_doc(
|
||||
doctype="Repost Item Valuation",
|
||||
based_on="Transaction",
|
||||
voucher_type="Delivery Note",
|
||||
voucher_no=return_dn.name,
|
||||
posting_date=return_dn.posting_date,
|
||||
posting_time=return_dn.posting_time,
|
||||
).submit()
|
||||
|
||||
bundle.reload()
|
||||
self.assertEqual(flt(bundle.avg_rate), 35)
|
||||
|
||||
incoming_rate, stock_value_difference = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_type": "Delivery Note",
|
||||
"voucher_no": return_dn.name,
|
||||
"item_code": packed_item,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
["incoming_rate", "stock_value_difference"],
|
||||
)
|
||||
self.assertEqual(flt(incoming_rate), 35)
|
||||
self.assertEqual(flt(stock_value_difference), 1400)
|
||||
|
||||
def test_bin_details_of_packed_item(self):
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
@@ -199,8 +199,10 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
|
||||
epi = is_perpetual_inventory_enabled(company_a)
|
||||
company_doc = frappe.get_doc("Company", company_a)
|
||||
old_inventory_account = company_doc.default_inventory_account
|
||||
company_doc.enable_perpetual_inventory = 1
|
||||
company_doc.stock_received_but_not_billed = srbnb
|
||||
company_doc.default_inventory_account = "Stock In Hand - _TC"
|
||||
company_doc.save()
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
@@ -228,7 +230,11 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
distribute_landed_cost_on_items(lcv)
|
||||
lcv.submit()
|
||||
|
||||
frappe.db.set_value("Company", company_a, "enable_perpetual_inventory", epi)
|
||||
frappe.db.set_value(
|
||||
"Company",
|
||||
company_a,
|
||||
{"enable_perpetual_inventory": epi, "default_inventory_account": old_inventory_account},
|
||||
)
|
||||
frappe.local.enable_perpetual_inventory = {}
|
||||
|
||||
def test_landed_cost_voucher_for_zero_purchase_rate(self):
|
||||
|
||||
@@ -433,29 +433,10 @@ class PurchaseReceipt(BuyingController):
|
||||
row.received_qty,
|
||||
)
|
||||
|
||||
def check_next_docstatus(self):
|
||||
submit_rv = frappe.db.sql(
|
||||
"""select t1.name
|
||||
from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2
|
||||
where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""",
|
||||
(self.name),
|
||||
)
|
||||
if submit_rv:
|
||||
frappe.throw(_("Purchase Invoice {0} is already submitted").format(self.submit_rv[0][0]))
|
||||
|
||||
def on_cancel(self):
|
||||
super().on_cancel()
|
||||
|
||||
self.check_for_on_hold_or_closed_status("Purchase Order", "purchase_order")
|
||||
# Check if Purchase Invoice has been submitted against current Purchase Order
|
||||
submitted = frappe.db.sql(
|
||||
"""select t1.name
|
||||
from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2
|
||||
where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""",
|
||||
self.name,
|
||||
)
|
||||
if submitted:
|
||||
frappe.throw(_("Purchase Invoice {0} is already submitted").format(submitted[0][0]))
|
||||
|
||||
self.update_prevdoc_status()
|
||||
self.update_billing_status()
|
||||
|
||||
@@ -3134,11 +3134,14 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
|
||||
old_perpetual_inventory = erpnext.is_perpetual_inventory_enabled("_Test Company")
|
||||
frappe.local.enable_perpetual_inventory["_Test Company"] = 1
|
||||
old_inventory_account = frappe.db.get_value("Company", "_Test Company", "default_inventory_account")
|
||||
frappe.db.set_value(
|
||||
"Company",
|
||||
"_Test Company",
|
||||
"stock_received_but_not_billed",
|
||||
"Stock Received But Not Billed - _TC",
|
||||
{
|
||||
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC",
|
||||
"default_inventory_account": "Stock In Hand - _TC",
|
||||
},
|
||||
)
|
||||
|
||||
pr = make_purchase_receipt(qty=10, rate=1000, do_not_submit=1)
|
||||
@@ -3174,6 +3177,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
)
|
||||
self.assertCountEqual(expected_gle, gl_entries)
|
||||
frappe.local.enable_perpetual_inventory["_Test Company"] = old_perpetual_inventory
|
||||
frappe.db.set_value("Company", "_Test Company", "default_inventory_account", old_inventory_account)
|
||||
|
||||
def test_purchase_receipt_with_use_serial_batch_field_for_rejected_qty(self):
|
||||
batch_item = make_item(
|
||||
@@ -6180,6 +6184,32 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account)
|
||||
self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2)
|
||||
|
||||
def test_cancel_blocked_by_submitted_invoice_rolls_back(self):
|
||||
"""A submitted Purchase Invoice must block cancelling its Purchase Receipt. Frappe's backlink
|
||||
check rejects the cancel only after on_cancel has run stock, GL, and status work, so the whole
|
||||
transaction has to roll back: the receipt stays submitted with no leaked ledger entries."""
|
||||
pr = make_purchase_receipt()
|
||||
pi = make_purchase_invoice(pr.name)
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
pr.reload()
|
||||
status_before = pr.status
|
||||
sle_before = frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name})
|
||||
gle_before = frappe.db.count("GL Entry", {"voucher_no": pr.name})
|
||||
|
||||
frappe.db.savepoint("before_blocked_cancel")
|
||||
with self.assertRaises(frappe.LinkExistsError) as cm:
|
||||
pr.cancel()
|
||||
self.assertIn(pi.name, str(cm.exception))
|
||||
frappe.db.rollback(save_point="before_blocked_cancel") # mimic the request-level rollback
|
||||
|
||||
pr.reload()
|
||||
self.assertEqual(pr.docstatus, 1)
|
||||
self.assertEqual(pr.status, status_before)
|
||||
self.assertEqual(frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name}), sle_before)
|
||||
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pr.name}), gle_before)
|
||||
|
||||
|
||||
def create_asset_category_for_pr_test():
|
||||
category_name = "Test Asset Category for PR"
|
||||
|
||||
@@ -517,6 +517,11 @@ class SerialandBatchBundle(Document):
|
||||
self.child_table, self.voucher_detail_no, field
|
||||
)
|
||||
|
||||
if not return_against_voucher_detail_no and self.voucher_type in ("Delivery Note", "Sales Invoice"):
|
||||
# Bundles built via the use_serial_batch_fields / SLE-driven path keep the Packed Item
|
||||
# as voucher_detail_no (not remapped to the DN/SI Item), so the lookup above misses.
|
||||
return_against_voucher_detail_no = self.get_return_against_packed_item(field)
|
||||
|
||||
filters = [
|
||||
["Serial and Batch Bundle", "voucher_no", "=", return_against],
|
||||
["Serial and Batch Entry", "docstatus", "=", 1],
|
||||
@@ -560,6 +565,16 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
return valuation_details
|
||||
|
||||
def get_return_against_packed_item(self, field):
|
||||
"""Resolve the original DN/SI Item when a return bundle's voucher_detail_no is the Packed Item."""
|
||||
parent_detail_docname = frappe.db.get_value(
|
||||
"Packed Item", self.voucher_detail_no, "parent_detail_docname"
|
||||
)
|
||||
if not parent_detail_docname:
|
||||
return
|
||||
|
||||
return frappe.db.get_value(self.child_table, parent_detail_docname, field)
|
||||
|
||||
def get_legacy_valuation_rate_for_return_entry(
|
||||
self, return_against, return_against_voucher_detail_no, return_warehouse=None
|
||||
):
|
||||
|
||||
@@ -998,7 +998,10 @@ frappe.ui.form.on("Stock Entry Detail", {
|
||||
}
|
||||
|
||||
if (frm.doc.purpose === "Receive from Customer") {
|
||||
item.t_warehouse = frm.doc.items.find((item) => item.scio_detail).t_warehouse;
|
||||
const scio_row = frm.doc.items.find((row) => row.scio_detail);
|
||||
if (scio_row) {
|
||||
item.t_warehouse = scio_row.t_warehouse;
|
||||
}
|
||||
}
|
||||
},
|
||||
set_basic_rate_manually(frm, cdt, cdn) {
|
||||
|
||||
@@ -1320,9 +1320,11 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
first_row_by_item.setdefault(key, item)
|
||||
|
||||
for key, transfer_qty in transfer_by_item.items():
|
||||
pending_qty = max(0.0, pending_by_item[key])
|
||||
item = first_row_by_item[key]
|
||||
precision = item.precision("qty")
|
||||
transfer_qty = flt(transfer_qty, precision)
|
||||
pending_qty = max(0.0, flt(pending_by_item[key], precision))
|
||||
if transfer_qty > pending_qty:
|
||||
item = first_row_by_item[key]
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Cannot transfer {1} {2} of Item {3}. "
|
||||
@@ -1443,6 +1445,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
"""
|
||||
# Set rate for outgoing items
|
||||
outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate)
|
||||
has_consumption_basis = self.has_consumption_basis()
|
||||
|
||||
items = []
|
||||
# Set basic rate for incoming items
|
||||
@@ -1456,16 +1459,21 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
d.basic_amount = 0.0
|
||||
continue
|
||||
|
||||
rate_derived_from_consumption = False
|
||||
|
||||
if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer":
|
||||
d.basic_rate = 0.0
|
||||
items.append(d.item_code)
|
||||
elif d.is_finished_item:
|
||||
if self.purpose == "Manufacture":
|
||||
d.basic_rate = self.get_basic_rate_for_manufactured_item(
|
||||
d.transfer_qty, outgoing_items_cost
|
||||
d.transfer_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 self.bom_no:
|
||||
d.basic_rate *= frappe.get_value("BOM", self.bom_no, "cost_allocation_per") / 100
|
||||
@@ -1478,7 +1486,9 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
if cost_allocation_per and flt(d.transfer_qty):
|
||||
d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty
|
||||
|
||||
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
|
||||
|
||||
@@ -1513,6 +1523,31 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
|
||||
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"):
|
||||
@@ -1566,21 +1601,16 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
)
|
||||
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_legacy_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.type and not item.is_legacy_scrap_item:
|
||||
label = frappe.get_meta(settings.doctype).get_label(
|
||||
@@ -1627,7 +1657,9 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
)
|
||||
).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()])
|
||||
|
||||
|
||||
@@ -929,6 +929,38 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
fg_cost = next(filter(lambda x: x.item_code == "_Test FG Item 2", stock_entry.get("items"))).amount
|
||||
self.assertEqual(fg_cost, flt(rm_cost + bom_operation_cost + work_order.additional_operating_cost, 2))
|
||||
|
||||
@ERPNextTestSuite.change_settings("System Settings", {"float_precision": 3})
|
||||
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"backflush_raw_materials_based_on": "BOM"})
|
||||
def test_material_transfer_for_manufacture_qty_precision(self):
|
||||
work_order = frappe.new_doc("Work Order")
|
||||
work_order.append(
|
||||
"required_items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"required_qty": 33.876,
|
||||
"transferred_qty": 33.875,
|
||||
},
|
||||
)
|
||||
|
||||
stock_entry = frappe.new_doc("Stock Entry")
|
||||
stock_entry.work_order = "Test Work Order"
|
||||
stock_entry.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"s_warehouse": "_Test Warehouse - _TC",
|
||||
"qty": 0.001,
|
||||
"uom": "Nos",
|
||||
},
|
||||
)
|
||||
|
||||
stock_entry.pro_doc = work_order
|
||||
stock_entry._validate_no_excess_transfer()
|
||||
|
||||
stock_entry.items[0].qty = 0.002
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
stock_entry._validate_no_excess_transfer()
|
||||
|
||||
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"material_consumption": 1})
|
||||
def test_work_order_manufacture_with_material_consumption(self):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
@@ -2654,6 +2686,149 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
se.save()
|
||||
se.submit()
|
||||
|
||||
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
|
||||
|
||||
@ERPNextTestSuite.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)
|
||||
|
||||
@ERPNextTestSuite.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
|
||||
|
||||
|
||||
@@ -1309,6 +1309,7 @@ def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_d
|
||||
return flt(result[0][0]) if result and result[0][0] else 0.0
|
||||
|
||||
|
||||
# nosemgrep: missing-argument-type-hint
|
||||
@frappe.whitelist()
|
||||
def get_stock_balance_for(
|
||||
item_code: str,
|
||||
@@ -1381,7 +1382,7 @@ def get_stock_balance_for(
|
||||
or 0
|
||||
)
|
||||
|
||||
if row.use_serial_batch_fields and row.batch_no and (qty or row.current_qty):
|
||||
if row and row.use_serial_batch_fields and row.batch_no and (qty or row.current_qty):
|
||||
rate = get_incoming_rate(
|
||||
frappe._dict(
|
||||
{
|
||||
|
||||
@@ -1170,7 +1170,7 @@ class StockReservation:
|
||||
|
||||
self.available_qty_to_reserve = self.get_available_qty_to_reserve(item_code, warehouse)
|
||||
if not self.available_qty_to_reserve:
|
||||
self.throw_stock_not_exists_error(item.idx, item_code, warehouse)
|
||||
self.throw_stock_not_exists_error(item.get("idx"), item_code, warehouse)
|
||||
|
||||
self.qty_to_be_reserved = (
|
||||
qty if self.available_qty_to_reserve >= qty else self.available_qty_to_reserve
|
||||
@@ -1230,13 +1230,16 @@ class StockReservation:
|
||||
)
|
||||
|
||||
def throw_stock_not_exists_error(self, idx, item_code, warehouse):
|
||||
frappe.msgprint(
|
||||
_("Row #{0}: Stock not available to reserve for the Item {1} in Warehouse {2}.").format(
|
||||
if idx:
|
||||
msg = _("Row #{0}: Stock not available to reserve for the Item {1} in Warehouse {2}.").format(
|
||||
idx, frappe.bold(item_code), frappe.bold(warehouse)
|
||||
),
|
||||
title=_("Stock Reservation"),
|
||||
indicator="orange",
|
||||
)
|
||||
)
|
||||
else:
|
||||
msg = _("Stock not available to reserve for the Item {0} in Warehouse {1}.").format(
|
||||
frappe.bold(item_code), frappe.bold(warehouse)
|
||||
)
|
||||
|
||||
frappe.msgprint(msg, title=_("Stock Reservation"), indicator="orange")
|
||||
|
||||
def get_available_qty_to_reserve(self, item_code, warehouse, ignore_sre=None):
|
||||
available_qty = get_stock_balance(item_code, warehouse)
|
||||
|
||||
@@ -95,6 +95,44 @@ class TestWarehouse(ERPNextTestSuite):
|
||||
children = get_children("Warehouse", parent=company, company=company, is_root=True)
|
||||
self.assertTrue(any(wh["value"] == "_Test Warehouse - _TC" for wh in children))
|
||||
|
||||
def test_inventory_account_fallback_with_multiple_stock_accounts(self):
|
||||
from erpnext.stock import get_warehouse_account
|
||||
|
||||
company = create_inventory_fallback_company()
|
||||
frappe.db.set_value("Company", company, "default_inventory_account", None)
|
||||
if frappe.db.exists("Account", "Extra Inventory Account - _TCIF"):
|
||||
frappe.delete_doc("Account", "Extra Inventory Account - _TCIF")
|
||||
|
||||
warehouse = frappe.get_doc("Warehouse", {"company": company, "is_group": 0})
|
||||
single_account = frappe.db.get_value(
|
||||
"Account", {"account_type": "Stock", "is_group": 0, "company": company}, "name"
|
||||
)
|
||||
self.assertEqual(get_warehouse_account(warehouse), single_account)
|
||||
|
||||
create_account(
|
||||
account_name="Extra Inventory Account",
|
||||
parent_account=frappe.db.get_value("Account", single_account, "parent_account"),
|
||||
account_type="Stock",
|
||||
company=company,
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, get_warehouse_account, warehouse)
|
||||
|
||||
|
||||
def create_inventory_fallback_company():
|
||||
company = "_Test Company Inventory Fallback"
|
||||
if not frappe.db.exists("Company", company):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Company",
|
||||
"company_name": company,
|
||||
"abbr": "_TCIF",
|
||||
"default_currency": "INR",
|
||||
"enable_perpetual_inventory": 0,
|
||||
"country": "India",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
return company
|
||||
|
||||
|
||||
def create_warehouse(warehouse_name, properties=None, company=None):
|
||||
if not company:
|
||||
|
||||
@@ -14,7 +14,7 @@ from frappe.utils.caching import request_cache
|
||||
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):
|
||||
@@ -214,11 +214,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)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from frappe import _
|
||||
from frappe.utils import add_to_date, cint, flt, get_datetime, get_table_name, getdate
|
||||
from pypika import functions as fn
|
||||
|
||||
from erpnext.accounts.report.utils import validate_mandatory_date_range
|
||||
from erpnext.deprecation_dumpster import deprecated
|
||||
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import StockClosing
|
||||
from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter
|
||||
@@ -30,8 +31,7 @@ def execute(filters=None):
|
||||
_("Please select either the Item or Warehouse or Warehouse Type filter to generate the report.")
|
||||
)
|
||||
|
||||
if filters.from_date > filters.to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
validate_mandatory_date_range(filters)
|
||||
|
||||
float_precision = cint(frappe.db.get_default("float_precision")) or 3
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from frappe import _
|
||||
from frappe.utils import date_diff
|
||||
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import get_gl_entries
|
||||
from erpnext.accounts.report.utils import validate_mandatory_date_range
|
||||
|
||||
Filters = frappe._dict
|
||||
Row = frappe._dict
|
||||
@@ -34,8 +35,7 @@ def update_filters_with_account(filters: Filters) -> None:
|
||||
|
||||
|
||||
def validate_filters(filters: Filters) -> None:
|
||||
if filters.from_date > filters.to_date:
|
||||
frappe.throw(_("From Date must be before To Date"))
|
||||
validate_mandatory_date_range(filters)
|
||||
|
||||
|
||||
def get_columns() -> Columns:
|
||||
|
||||
Reference in New Issue
Block a user