diff --git a/.mergify.yml b/.mergify.yml
index d7f82e696b4..c5f3d83ce63 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -9,6 +9,7 @@ pull_request_rules:
- author!=nabinhait
- author!=ankush
- author!=deepeshgarg007
+ - author!=frappe-pr-bot
- author!=mergify[bot]
- or:
diff --git a/README.md b/README.md
index cea3472447f..0708266a470 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,8 @@ GNU/General Public License (see [license.txt](license.txt))
The ERPNext code is licensed as GNU General Public License (v3) and the Documentation is licensed as Creative Commons (CC-BY-SA-3.0) and the copyright is owned by Frappe Technologies Pvt Ltd (Frappe) and Contributors.
+By contributing to ERPNext, you agree that your contributions will be licensed under its GNU General Public License (v3).
+
## Logo and Trademark Policy
Please read our [Logo and Trademark Policy](TRADEMARK_POLICY.md).
diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py
index 3f5c064f4b9..d8880f70417 100644
--- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py
+++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py
@@ -53,15 +53,13 @@ class BankStatementImport(DataImport):
if "Bank Account" not in json.dumps(preview["columns"]):
frappe.throw(_("Please add the Bank Account column"))
- from frappe.core.page.background_jobs.background_jobs import get_info
+ from frappe.utils.background_jobs import is_job_queued
from frappe.utils.scheduler import is_scheduler_inactive
if is_scheduler_inactive() and not frappe.flags.in_test:
frappe.throw(_("Scheduler is inactive. Cannot import data."), title=_("Scheduler Inactive"))
- enqueued_jobs = [d.get("job_name") for d in get_info()]
-
- if self.name not in enqueued_jobs:
+ if not is_job_queued(self.name):
enqueue(
start_import,
queue="default",
diff --git a/erpnext/accounts/doctype/ledger_merge/ledger_merge.py b/erpnext/accounts/doctype/ledger_merge/ledger_merge.py
index 18e5a1ac85b..7cd6d04c35c 100644
--- a/erpnext/accounts/doctype/ledger_merge/ledger_merge.py
+++ b/erpnext/accounts/doctype/ledger_merge/ledger_merge.py
@@ -4,22 +4,20 @@
import frappe
from frappe import _
from frappe.model.document import Document
+from frappe.utils.background_jobs import is_job_queued
from erpnext.accounts.doctype.account.account import merge_account
class LedgerMerge(Document):
def start_merge(self):
- from frappe.core.page.background_jobs.background_jobs import get_info
from frappe.utils.background_jobs import enqueue
from frappe.utils.scheduler import is_scheduler_inactive
if is_scheduler_inactive() and not frappe.flags.in_test:
frappe.throw(_("Scheduler is inactive. Cannot merge accounts."), title=_("Scheduler Inactive"))
- enqueued_jobs = [d.get("job_name") for d in get_info()]
-
- if self.name not in enqueued_jobs:
+ if not is_job_queued(self.name):
enqueue(
start_merge,
queue="default",
diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
index 99377421c58..f7df1ff4a10 100644
--- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
+++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
@@ -6,7 +6,7 @@ import frappe
from frappe import _, scrub
from frappe.model.document import Document
from frappe.utils import flt, nowdate
-from frappe.utils.background_jobs import enqueue
+from frappe.utils.background_jobs import enqueue, is_job_queued
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
@@ -207,14 +207,12 @@ class OpeningInvoiceCreationTool(Document):
if len(invoices) < 50:
return start_import(invoices)
else:
- from frappe.core.page.background_jobs.background_jobs import get_info
from frappe.utils.scheduler import is_scheduler_inactive
if is_scheduler_inactive() and not frappe.flags.in_test:
frappe.throw(_("Scheduler is inactive. Cannot import data."), title=_("Scheduler Inactive"))
- enqueued_jobs = [d.get("job_name") for d in get_info()]
- if self.name not in enqueued_jobs:
+ if not is_job_queued(self.name):
enqueue(
start_import,
queue="default",
diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
index 81a234a20a7..a0a1c8c2696 100644
--- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
+++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
@@ -6,11 +6,10 @@ import json
import frappe
from frappe import _
-from frappe.core.page.background_jobs.background_jobs import get_info
from frappe.model.document import Document
from frappe.model.mapper import map_child_doc, map_doc
from frappe.utils import cint, flt, get_time, getdate, nowdate, nowtime
-from frappe.utils.background_jobs import enqueue
+from frappe.utils.background_jobs import enqueue, is_job_queued
from frappe.utils.scheduler import is_scheduler_inactive
@@ -467,7 +466,7 @@ def enqueue_job(job, **kwargs):
closing_entry = kwargs.get("closing_entry") or {}
job_name = closing_entry.get("name")
- if not job_already_enqueued(job_name):
+ if not is_job_queued(job_name):
enqueue(
job,
**kwargs,
@@ -491,12 +490,6 @@ def check_scheduler_status():
frappe.throw(_("Scheduler is inactive. Cannot enqueue job."), title=_("Scheduler Inactive"))
-def job_already_enqueued(job_name):
- enqueued_jobs = [d.get("job_name") for d in get_info()]
- if job_name in enqueued_jobs:
- return True
-
-
def safe_load_json(message):
try:
json_message = json.loads(message).get("message")
diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
index 82705a9cea4..0da44a464e7 100644
--- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
+++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
@@ -25,7 +25,7 @@
-
| {{ _("Date") }} | diff --git a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py index fac9be7bdb1..4d28d106604 100644 --- a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py +++ b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py @@ -34,8 +34,8 @@ pricing_rule_fields = [ other_fields = [ "min_qty", "max_qty", - "min_amt", - "max_amt", + "min_amount", + "max_amount", "priority", "warehouse", "threshold_percentage", @@ -246,7 +246,11 @@ def prepare_pricing_rule( def set_args(args, pr, doc, child_doc, discount_fields, child_doc_fields): pr.update(args) for field in other_fields + discount_fields: - pr.set(field, child_doc_fields.get(field)) + target_field = field + if target_field in ["min_amount", "max_amount"]: + target_field = "min_amt" if field == "min_amount" else "max_amt" + + pr.set(target_field, child_doc_fields.get(field)) pr.promotional_scheme_id = child_doc_fields.name pr.promotional_scheme = doc.name diff --git a/erpnext/accounts/doctype/promotional_scheme/test_promotional_scheme.py b/erpnext/accounts/doctype/promotional_scheme/test_promotional_scheme.py index b3b9d7b2086..9e576fb8775 100644 --- a/erpnext/accounts/doctype/promotional_scheme/test_promotional_scheme.py +++ b/erpnext/accounts/doctype/promotional_scheme/test_promotional_scheme.py @@ -90,6 +90,23 @@ class TestPromotionalScheme(unittest.TestCase): price_rules = frappe.get_all("Pricing Rule", filters={"promotional_scheme": ps.name}) self.assertEqual(price_rules, []) + def test_min_max_amount_configuration(self): + ps = make_promotional_scheme() + ps.price_discount_slabs[0].min_amount = 10 + ps.price_discount_slabs[0].max_amount = 1000 + ps.save() + + price_rules_data = frappe.db.get_value( + "Pricing Rule", {"promotional_scheme": ps.name}, ["min_amt", "max_amt"], as_dict=1 + ) + + self.assertEqual(price_rules_data.min_amt, 10) + self.assertEqual(price_rules_data.max_amt, 1000) + + frappe.delete_doc("Promotional Scheme", ps.name) + price_rules = frappe.get_all("Pricing Rule", filters={"promotional_scheme": ps.name}) + self.assertEqual(price_rules, []) + def make_promotional_scheme(**args): args = frappe._dict(args) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 1f5879d7bff..608f0828fee 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -7,17 +7,7 @@ from frappe import _, msgprint, throw from frappe.contacts.doctype.address.address import get_address_display from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values -from frappe.utils import ( - add_days, - add_months, - cint, - cstr, - flt, - formatdate, - get_link_to_form, - getdate, - nowdate, -) +from frappe.utils import add_days, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate import erpnext from erpnext.accounts.deferred_revenue import validate_service_stop_date @@ -35,7 +25,6 @@ from erpnext.assets.doctype.asset.depreciation import ( get_disposal_account_and_cost_center, get_gl_entries_on_asset_disposal, get_gl_entries_on_asset_regain, - make_depreciation_entry, ) from erpnext.controllers.accounts_controller import validate_account_head from erpnext.controllers.selling_controller import SellingController @@ -186,7 +175,7 @@ class SalesInvoice(SellingController): if self.update_stock: frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale")) - elif asset.status in ("Scrapped", "Cancelled") or ( + elif asset.status in ("Scrapped", "Cancelled", "Capitalized", "Decapitalized") or ( asset.status == "Sold" and not self.is_return ): frappe.throw( @@ -1097,7 +1086,7 @@ class SalesInvoice(SellingController): asset.db_set("disposal_date", None) if asset.calculate_depreciation: - self.reverse_depreciation_entry_made_after_sale(asset) + self.reverse_depreciation_entry_made_after_disposal(asset) self.reset_depreciation_schedule(asset) else: @@ -1162,101 +1151,6 @@ class SalesInvoice(SellingController): self.check_finance_books(item, asset) return asset - def check_finance_books(self, item, asset): - if ( - len(asset.finance_books) > 1 and not item.finance_book and asset.finance_books[0].finance_book - ): - frappe.throw( - _("Select finance book for the item {0} at row {1}").format(item.item_code, item.idx) - ) - - def depreciate_asset(self, asset): - asset.flags.ignore_validate_update_after_submit = True - asset.prepare_depreciation_data(date_of_sale=self.posting_date) - asset.save() - - make_depreciation_entry(asset.name, self.posting_date) - - def reset_depreciation_schedule(self, asset): - asset.flags.ignore_validate_update_after_submit = True - - # recreate original depreciation schedule of the asset - asset.prepare_depreciation_data(date_of_return=self.posting_date) - - self.modify_depreciation_schedule_for_asset_repairs(asset) - asset.save() - - def modify_depreciation_schedule_for_asset_repairs(self, asset): - asset_repairs = frappe.get_all( - "Asset Repair", filters={"asset": asset.name}, fields=["name", "increase_in_asset_life"] - ) - - for repair in asset_repairs: - if repair.increase_in_asset_life: - asset_repair = frappe.get_doc("Asset Repair", repair.name) - asset_repair.modify_depreciation_schedule() - asset.prepare_depreciation_data() - - def reverse_depreciation_entry_made_after_sale(self, asset): - from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry - - posting_date_of_original_invoice = self.get_posting_date_of_sales_invoice() - - row = -1 - finance_book = asset.get("schedules")[0].get("finance_book") - for schedule in asset.get("schedules"): - if schedule.finance_book != finance_book: - row = 0 - finance_book = schedule.finance_book - else: - row += 1 - - if schedule.schedule_date == posting_date_of_original_invoice: - if not self.sale_was_made_on_original_schedule_date( - asset, schedule, row, posting_date_of_original_invoice - ) or self.sale_happens_in_the_future(posting_date_of_original_invoice): - - reverse_journal_entry = make_reverse_journal_entry(schedule.journal_entry) - reverse_journal_entry.posting_date = nowdate() - frappe.flags.is_reverse_depr_entry = True - reverse_journal_entry.submit() - - frappe.flags.is_reverse_depr_entry = False - asset.flags.ignore_validate_update_after_submit = True - schedule.journal_entry = None - depreciation_amount = self.get_depreciation_amount_in_je(reverse_journal_entry) - asset.finance_books[0].value_after_depreciation += depreciation_amount - asset.save() - - def get_posting_date_of_sales_invoice(self): - return frappe.db.get_value("Sales Invoice", self.return_against, "posting_date") - - # if the invoice had been posted on the date the depreciation was initially supposed to happen, the depreciation shouldn't be undone - def sale_was_made_on_original_schedule_date( - self, asset, schedule, row, posting_date_of_original_invoice - ): - for finance_book in asset.get("finance_books"): - if schedule.finance_book == finance_book.finance_book: - orginal_schedule_date = add_months( - finance_book.depreciation_start_date, row * cint(finance_book.frequency_of_depreciation) - ) - - if orginal_schedule_date == posting_date_of_original_invoice: - return True - return False - - def sale_happens_in_the_future(self, posting_date_of_original_invoice): - if posting_date_of_original_invoice > getdate(): - return True - - return False - - def get_depreciation_amount_in_je(self, journal_entry): - if journal_entry.accounts[0].debit_in_account_currency: - return journal_entry.accounts[0].debit_in_account_currency - else: - return journal_entry.accounts[0].credit_in_account_currency - @property def enable_discount_accounting(self): if not hasattr(self, "_enable_discount_accounting"): diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 16072f331f1..f4a50a5f915 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -489,7 +489,6 @@ def make_reverse_gl_entries( ).run(as_dict=1) if gl_entries: - create_payment_ledger_entry(gl_entries, cancel=1) create_payment_ledger_entry( gl_entries, cancel=1, adv_adj=adv_adj, update_outstanding=update_outstanding ) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 3f504b13f67..8557c03bd5e 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -784,7 +784,7 @@ class ReceivablePayableReport(object): def add_customer_filters( self, ): - self.customter = qb.DocType("Customer") + self.customer = qb.DocType("Customer") if self.filters.get("customer_group"): self.get_hierarchical_filters("Customer Group", "customer_group") @@ -838,7 +838,7 @@ class ReceivablePayableReport(object): 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.isin(ple.party.isin(customers))) + self.qb_selection_filter.append(ple.party.isin(customers)) def add_accounting_dimensions_filters(self): accounting_dimensions = get_accounting_dimensions(as_list=False) diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py index ac706666547..c987231fe17 100644 --- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py +++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py @@ -97,6 +97,7 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum row.update({"rate": d.base_net_rate, "amount": d.base_net_amount}) total_tax = 0 + total_other_charges = 0 for tax in tax_columns: item_tax = itemised_tax.get(d.name, {}).get(tax, {}) row.update( @@ -105,10 +106,18 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum frappe.scrub(tax + " Amount"): item_tax.get("tax_amount", 0), } ) - total_tax += flt(item_tax.get("tax_amount")) + if item_tax.get("is_other_charges"): + total_other_charges += flt(item_tax.get("tax_amount")) + else: + total_tax += flt(item_tax.get("tax_amount")) row.update( - {"total_tax": total_tax, "total": d.base_net_amount + total_tax, "currency": company_currency} + { + "total_tax": total_tax, + "total_other_charges": total_other_charges, + "total": d.base_net_amount + total_tax, + "currency": company_currency, + } ) if filters.get("group_by"): @@ -477,7 +486,7 @@ def get_tax_accounts( tax_details = frappe.db.sql( """ select - name, parent, description, item_wise_tax_detail, + name, parent, description, item_wise_tax_detail, account_head, charge_type, {add_deduct_tax}, base_tax_amount_after_discount_amount from `tab%s` where @@ -493,11 +502,22 @@ def get_tax_accounts( tuple([doctype] + list(invoice_item_row)), ) + account_doctype = frappe.qb.DocType("Account") + + query = ( + frappe.qb.from_(account_doctype) + .select(account_doctype.name) + .where((account_doctype.account_type == "Tax")) + ) + + tax_accounts = query.run() + for ( name, parent, description, item_wise_tax_detail, + account_head, charge_type, add_deduct_tax, tax_amount, @@ -540,7 +560,11 @@ def get_tax_accounts( ) itemised_tax.setdefault(d.name, {})[description] = frappe._dict( - {"tax_rate": tax_rate, "tax_amount": tax_value} + { + "tax_rate": tax_rate, + "tax_amount": tax_value, + "is_other_charges": 0 if tuple([account_head]) in tax_accounts else 1, + } ) except ValueError: @@ -583,6 +607,13 @@ def get_tax_accounts( "options": "currency", "width": 100, }, + { + "label": _("Total Other Charges"), + "fieldname": "total_other_charges", + "fieldtype": "Currency", + "options": "currency", + "width": 100, + }, { "label": _("Total"), "fieldname": "total", diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index a22d70dd630..8ac7ed6387b 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -79,12 +79,12 @@ class Asset(AccountsController): _("Purchase Invoice cannot be made against an existing asset {0}").format(self.name) ) - def prepare_depreciation_data(self, date_of_sale=None, date_of_return=None): + def prepare_depreciation_data(self, date_of_disposal=None, date_of_return=None): if self.calculate_depreciation: self.value_after_depreciation = 0 self.set_depreciation_rate() - self.make_depreciation_schedule(date_of_sale) - self.set_accumulated_depreciation(date_of_sale, date_of_return) + self.make_depreciation_schedule(date_of_disposal) + self.set_accumulated_depreciation(date_of_disposal, date_of_return) else: self.finance_books = [] self.value_after_depreciation = flt(self.gross_purchase_amount) - flt( @@ -223,7 +223,7 @@ class Asset(AccountsController): self.get_depreciation_rate(d, on_validate=True), d.precision("rate_of_depreciation") ) - def make_depreciation_schedule(self, date_of_sale): + def make_depreciation_schedule(self, date_of_disposal): if "Manual" not in [d.depreciation_method for d in self.finance_books] and not self.get( "schedules" ): @@ -235,9 +235,9 @@ class Asset(AccountsController): start = self.clear_depreciation_schedule() for finance_book in self.get("finance_books"): - self._make_depreciation_schedule(finance_book, start, date_of_sale) + self._make_depreciation_schedule(finance_book, start, date_of_disposal) - def _make_depreciation_schedule(self, finance_book, start, date_of_sale): + def _make_depreciation_schedule(self, finance_book, start, date_of_disposal): self.validate_asset_finance_books(finance_book) value_after_depreciation = self._get_value_after_depreciation(finance_book) @@ -274,15 +274,15 @@ class Asset(AccountsController): monthly_schedule_date = add_months(schedule_date, -finance_book.frequency_of_depreciation + 1) # if asset is being sold - if date_of_sale: + if date_of_disposal: from_date = self.get_from_date(finance_book.finance_book) depreciation_amount, days, months = self.get_pro_rata_amt( - finance_book, depreciation_amount, from_date, date_of_sale + finance_book, depreciation_amount, from_date, date_of_disposal ) if depreciation_amount > 0: self._add_depreciation_row( - date_of_sale, + date_of_disposal, depreciation_amount, finance_book.depreciation_method, finance_book.finance_book, diff --git a/erpnext/assets/doctype/asset/asset_list.js b/erpnext/assets/doctype/asset/asset_list.js index 4302cb2c518..3d00eb74aa0 100644 --- a/erpnext/assets/doctype/asset/asset_list.js +++ b/erpnext/assets/doctype/asset/asset_list.js @@ -10,6 +10,9 @@ frappe.listview_settings['Asset'] = { } else if (doc.status === "Sold") { return [__("Sold"), "green", "status,=,Sold"]; + } else if (["Capitalized", "Decapitalized"].includes(doc.status)) { + return [__(doc.status), "grey", "status,=," + doc.status]; + } else if (doc.status === "Scrapped") { return [__("Scrapped"), "grey", "status,=,Scrapped"]; diff --git a/erpnext/assets/doctype/asset/depreciation.py b/erpnext/assets/doctype/asset/depreciation.py index 3f7e9459943..74386384c5d 100644 --- a/erpnext/assets/doctype/asset/depreciation.py +++ b/erpnext/assets/doctype/asset/depreciation.py @@ -11,7 +11,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( ) -def post_depreciation_entries(date=None): +def post_depreciation_entries(date=None, commit=True): # Return if automatic booking of asset depreciation is disabled if not cint( frappe.db.get_value("Accounts Settings", None, "book_asset_depreciation_entry_automatically") @@ -22,7 +22,8 @@ def post_depreciation_entries(date=None): date = today() for asset in get_depreciable_assets(date): make_depreciation_entry(asset, date) - frappe.db.commit() + if commit: + frappe.db.commit() def get_depreciable_assets(date): @@ -190,7 +191,7 @@ def scrap_asset(asset_name): if asset.docstatus != 1: frappe.throw(_("Asset {0} must be submitted").format(asset.name)) - elif asset.status in ("Cancelled", "Sold", "Scrapped"): + elif asset.status in ("Cancelled", "Sold", "Scrapped", "Capitalized", "Decapitalized"): frappe.throw( _("Asset {0} cannot be scrapped, as it is already {1}").format(asset.name, asset.status) ) @@ -358,3 +359,30 @@ def get_disposal_account_and_cost_center(company): frappe.throw(_("Please set 'Asset Depreciation Cost Center' in Company {0}").format(company)) return disposal_account, depreciation_cost_center + + +@frappe.whitelist() +def get_value_after_depreciation_on_disposal_date(asset, disposal_date, finance_book=None): + asset_doc = frappe.get_doc("Asset", asset) + + if asset_doc.calculate_depreciation: + asset_doc.prepare_depreciation_data(getdate(disposal_date)) + + finance_book_id = 1 + if finance_book: + for fb in asset_doc.finance_books: + if fb.finance_book == finance_book: + finance_book_id = fb.idx + break + + asset_schedules = [ + sch for sch in asset_doc.schedules if cint(sch.finance_book_id) == finance_book_id + ] + accumulated_depr_amount = asset_schedules[-1].accumulated_depreciation_amount + + return flt( + flt(asset_doc.gross_purchase_amount) - accumulated_depr_amount, + asset_doc.precision("gross_purchase_amount"), + ) + else: + return flt(asset_doc.value_after_depreciation) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 132840e38c2..e7af9bd5bc2 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -1425,6 +1425,16 @@ def create_asset_category(): "depreciation_expense_account": "_Test Depreciations - _TC", }, ) + asset_category.append( + "accounts", + { + "company_name": "_Test Company with perpetual inventory", + "fixed_asset_account": "_Test Fixed Asset - TCP1", + "accumulated_depreciation_account": "_Test Accumulated Depreciations - TCP1", + "depreciation_expense_account": "_Test Depreciations - TCP1", + }, + ) + asset_category.insert() diff --git a/erpnext/assets/doctype/asset_capitalization/__init__.py b/erpnext/assets/doctype/asset_capitalization/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js new file mode 100644 index 00000000000..9c7f70b0e57 --- /dev/null +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js @@ -0,0 +1,417 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.provide("erpnext.assets"); + + +erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.stock.StockController { + setup() { + this.setup_posting_date_time_check(); + } + + onload() { + this.setup_queries(); + } + + refresh() { + erpnext.hide_company(); + this.show_general_ledger(); + if ((this.frm.doc.stock_items && this.frm.doc.stock_items.length) || !this.frm.doc.target_is_fixed_asset) { + this.show_stock_ledger(); + } + } + + setup_queries() { + var me = this; + + me.setup_warehouse_query(); + + me.frm.set_query("target_item_code", function() { + if (me.frm.doc.entry_type == "Capitalization") { + return erpnext.queries.item({"is_stock_item": 0, "is_fixed_asset": 1}); + } else { + return erpnext.queries.item({"is_stock_item": 1, "is_fixed_asset": 0}); + } + }); + + me.frm.set_query("target_asset", function() { + var filters = {}; + + if (me.frm.doc.target_item_code) { + filters['item_code'] = me.frm.doc.target_item_code; + } + + filters['status'] = ["not in", ["Draft", "Scrapped", "Sold", "Capitalized", "Decapitalized"]]; + filters['docstatus'] = 1; + + return { + filters: filters + }; + }); + + me.frm.set_query("asset", "asset_items", function() { + var filters = { + 'status': ["not in", ["Draft", "Scrapped", "Sold", "Capitalized", "Decapitalized"]], + 'docstatus': 1 + }; + + if (me.frm.doc.target_asset) { + filters['name'] = ['!=', me.frm.doc.target_asset]; + } + + return { + filters: filters + }; + }); + + me.frm.set_query("item_code", "stock_items", function() { + return erpnext.queries.item({"is_stock_item": 1}); + }); + + me.frm.set_query("item_code", "service_items", function() { + return erpnext.queries.item({"is_stock_item": 0, "is_fixed_asset": 0}); + }); + + me.frm.set_query('batch_no', 'stock_items', function(doc, cdt, cdn) { + var item = locals[cdt][cdn]; + if (!item.item_code) { + frappe.throw(__("Please enter Item Code to get Batch Number")); + } else { + var filters = { + 'item_code': item.item_code, + 'posting_date': me.frm.doc.posting_date || frappe.datetime.nowdate(), + 'warehouse': item.warehouse + }; + + return { + query: "erpnext.controllers.queries.get_batch_no", + filters: filters + }; + } + }); + + me.frm.set_query('expense_account', 'service_items', function() { + return { + filters: { + "account_type": ['in', ["Tax", "Expense Account", "Income Account", "Expenses Included In Valuation", "Expenses Included In Asset Valuation"]], + "is_group": 0, + "company": me.frm.doc.company + } + }; + }); + } + + target_item_code() { + return this.get_target_item_details(); + } + + target_asset() { + return this.get_target_asset_details(); + } + + item_code(doc, cdt, cdn) { + var row = frappe.get_doc(cdt, cdn); + if (cdt === "Asset Capitalization Stock Item") { + this.get_consumed_stock_item_details(row); + } else if (cdt == "Asset Capitalization Service Item") { + this.get_service_item_details(row); + } + } + + warehouse(doc, cdt, cdn) { + var row = frappe.get_doc(cdt, cdn); + if (cdt === "Asset Capitalization Stock Item") { + this.get_warehouse_details(row); + } + } + + asset(doc, cdt, cdn) { + var row = frappe.get_doc(cdt, cdn); + if (cdt === "Asset Capitalization Asset Item") { + this.get_consumed_asset_details(row); + } + } + + posting_date() { + if (this.frm.doc.posting_date) { + frappe.run_serially([ + () => this.get_all_item_warehouse_details(), + () => this.get_all_asset_values() + ]); + } + } + + posting_time() { + if (this.frm.doc.posting_time) { + this.get_all_item_warehouse_details(); + } + } + + finance_book(doc, cdt, cdn) { + if (cdt === "Asset Capitalization Asset Item") { + var row = frappe.get_doc(cdt, cdn); + this.get_consumed_asset_details(row); + } else { + this.get_all_asset_values(); + } + } + + stock_qty() { + this.calculate_totals(); + } + + qty() { + this.calculate_totals(); + } + + target_qty() { + this.calculate_totals(); + } + + rate() { + this.calculate_totals(); + } + + company() { + var me = this; + + if (me.frm.doc.company) { + frappe.model.set_value(me.frm.doc.doctype, me.frm.doc.name, "cost_center", null); + $.each(me.frm.doc.stock_items || [], function (i, d) { + frappe.model.set_value(d.doctype, d.name, "cost_center", null); + }); + $.each(me.frm.doc.asset_items || [], function (i, d) { + frappe.model.set_value(d.doctype, d.name, "cost_center", null); + }); + $.each(me.frm.doc.service_items || [], function (i, d) { + frappe.model.set_value(d.doctype, d.name, "cost_center", null); + }); + } + + erpnext.accounts.dimensions.update_dimension(me.frm, me.frm.doctype); + } + + stock_items_add(doc, cdt, cdn) { + erpnext.accounts.dimensions.copy_dimension_from_first_row(this.frm, cdt, cdn, 'stock_items'); + } + + asset_items_add(doc, cdt, cdn) { + erpnext.accounts.dimensions.copy_dimension_from_first_row(this.frm, cdt, cdn, 'asset_items'); + } + + serivce_items_add(doc, cdt, cdn) { + erpnext.accounts.dimensions.copy_dimension_from_first_row(this.frm, cdt, cdn, 'service_items'); + } + + get_target_item_details() { + var me = this; + + if (me.frm.doc.target_item_code) { + return me.frm.call({ + method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_target_item_details", + child: me.frm.doc, + args: { + item_code: me.frm.doc.target_item_code, + company: me.frm.doc.company, + }, + callback: function (r) { + if (!r.exc) { + me.frm.refresh_fields(); + } + } + }); + } + } + + get_target_asset_details() { + var me = this; + + if (me.frm.doc.target_asset) { + return me.frm.call({ + method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_target_asset_details", + child: me.frm.doc, + args: { + asset: me.frm.doc.target_asset, + company: me.frm.doc.company, + }, + callback: function (r) { + if (!r.exc) { + me.frm.refresh_fields(); + } + } + }); + } + } + + get_consumed_stock_item_details(row) { + var me = this; + + if (row && row.item_code) { + return me.frm.call({ + method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_consumed_stock_item_details", + child: row, + args: { + args: { + item_code: row.item_code, + warehouse: row.warehouse, + stock_qty: flt(row.stock_qty), + doctype: me.frm.doc.doctype, + name: me.frm.doc.name, + company: me.frm.doc.company, + posting_date: me.frm.doc.posting_date, + posting_time: me.frm.doc.posting_time, + } + }, + callback: function (r) { + if (!r.exc) { + me.calculate_totals(); + } + } + }); + } + } + + get_consumed_asset_details(row) { + var me = this; + + if (row && row.asset) { + return me.frm.call({ + method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_consumed_asset_details", + child: row, + args: { + args: { + asset: row.asset, + doctype: me.frm.doc.doctype, + name: me.frm.doc.name, + company: me.frm.doc.company, + finance_book: row.finance_book || me.frm.doc.finance_book, + posting_date: me.frm.doc.posting_date, + posting_time: me.frm.doc.posting_time, + } + }, + callback: function (r) { + if (!r.exc) { + me.calculate_totals(); + } + } + }); + } + } + + get_service_item_details(row) { + var me = this; + + if (row && row.item_code) { + return me.frm.call({ + method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_service_item_details", + child: row, + args: { + args: { + item_code: row.item_code, + qty: flt(row.qty), + expense_account: row.expense_account, + company: me.frm.doc.company, + } + }, + callback: function (r) { + if (!r.exc) { + me.calculate_totals(); + } + } + }); + } + } + + get_warehouse_details(item) { + var me = this; + if (item.item_code && item.warehouse) { + me.frm.call({ + method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_warehouse_details", + child: item, + args: { + args: { + 'item_code': item.item_code, + 'warehouse': cstr(item.warehouse), + 'qty': flt(item.stock_qty), + 'serial_no': item.serial_no, + 'posting_date': me.frm.doc.posting_date, + 'posting_time': me.frm.doc.posting_time, + 'company': me.frm.doc.company, + 'voucher_type': me.frm.doc.doctype, + 'voucher_no': me.frm.doc.name, + 'allow_zero_valuation': 1 + } + }, + callback: function(r) { + if (!r.exc) { + me.calculate_totals(); + } + } + }); + } + } + + get_all_item_warehouse_details() { + var me = this; + return me.frm.call({ + method: "set_warehouse_details", + doc: me.frm.doc, + callback: function(r) { + if (!r.exc) { + me.calculate_totals(); + } + } + }); + } + + get_all_asset_values() { + var me = this; + return me.frm.call({ + method: "set_asset_values", + doc: me.frm.doc, + callback: function(r) { + if (!r.exc) { + me.calculate_totals(); + } + } + }); + } + + calculate_totals() { + var me = this; + + me.frm.doc.stock_items_total = 0; + me.frm.doc.asset_items_total = 0; + me.frm.doc.service_items_total = 0; + + $.each(me.frm.doc.stock_items || [], function (i, d) { + d.amount = flt(flt(d.stock_qty) * flt(d.valuation_rate), precision('amount', d)); + me.frm.doc.stock_items_total += d.amount; + }); + + $.each(me.frm.doc.asset_items || [], function (i, d) { + d.asset_value = flt(flt(d.asset_value), precision('asset_value', d)); + me.frm.doc.asset_items_total += d.asset_value; + }); + + $.each(me.frm.doc.service_items || [], function (i, d) { + d.amount = flt(flt(d.qty) * flt(d.rate), precision('amount', d)); + me.frm.doc.service_items_total += d.amount; + }); + + me.frm.doc.stock_items_total = flt(me.frm.doc.stock_items_total, precision('stock_items_total')); + me.frm.doc.asset_items_total = flt(me.frm.doc.asset_items_total, precision('asset_items_total')); + me.frm.doc.service_items_total = flt(me.frm.doc.service_items_total, precision('service_items_total')); + + me.frm.doc.total_value = me.frm.doc.stock_items_total + me.frm.doc.asset_items_total + me.frm.doc.service_items_total; + me.frm.doc.total_value = flt(me.frm.doc.total_value, precision('total_value')); + + me.frm.doc.target_qty = flt(me.frm.doc.target_qty, precision('target_qty')); + me.frm.doc.target_incoming_rate = me.frm.doc.target_qty ? me.frm.doc.total_value / flt(me.frm.doc.target_qty) + : me.frm.doc.total_value; + + me.frm.refresh_fields(); + } +}; + +cur_frm.cscript = new erpnext.assets.AssetCapitalization({frm: cur_frm}); diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.json b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.json new file mode 100644 index 00000000000..d1be5752d61 --- /dev/null +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.json @@ -0,0 +1,381 @@ +{ + "actions": [], + "autoname": "naming_series:", + "creation": "2021-09-04 13:38:04.217187", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "naming_series", + "entry_type", + "target_item_code", + "target_item_name", + "target_is_fixed_asset", + "target_has_batch_no", + "target_has_serial_no", + "column_break_9", + "target_asset", + "target_asset_name", + "target_warehouse", + "target_qty", + "target_stock_uom", + "target_batch_no", + "target_serial_no", + "column_break_5", + "company", + "finance_book", + "posting_date", + "posting_time", + "set_posting_time", + "amended_from", + "section_break_16", + "stock_items", + "stock_items_total", + "section_break_26", + "asset_items", + "asset_items_total", + "service_expenses_section", + "service_items", + "service_items_total", + "totals_section", + "total_value", + "column_break_36", + "target_incoming_rate", + "accounting_dimensions_section", + "cost_center", + "dimension_col_break", + "target_fixed_asset_account" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "hidden": 1, + "label": "Title" + }, + { + "fieldname": "target_item_code", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Target Item Code", + "options": "Item", + "reqd": 1 + }, + { + "depends_on": "eval:doc.target_item_code && doc.target_item_name != doc.target_item_code", + "fetch_from": "target_item_code.item_name", + "fieldname": "target_item_name", + "fieldtype": "Data", + "label": "Target Item Name", + "read_only": 1 + }, + { + "default": "0", + "fetch_from": "target_item_code.is_fixed_asset", + "fieldname": "target_is_fixed_asset", + "fieldtype": "Check", + "hidden": 1, + "label": "Target Is Fixed Asset", + "read_only": 1 + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.entry_type=='Capitalization'", + "fieldname": "target_asset", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Target Asset", + "mandatory_depends_on": "eval:doc.entry_type=='Capitalization'", + "no_copy": 1, + "options": "Asset" + }, + { + "depends_on": "eval:doc.entry_type=='Capitalization'", + "fetch_from": "target_asset.asset_name", + "fieldname": "target_asset_name", + "fieldtype": "Data", + "label": "Asset Name", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, + { + "fetch_from": "asset.company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "default": "Today", + "fieldname": "posting_date", + "fieldtype": "Date", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Posting Date", + "no_copy": 1, + "reqd": 1, + "search_index": 1 + }, + { + "default": "Now", + "fieldname": "posting_time", + "fieldtype": "Time", + "label": "Posting Time", + "no_copy": 1, + "reqd": 1 + }, + { + "default": "0", + "depends_on": "eval:doc.docstatus==0", + "fieldname": "set_posting_time", + "fieldtype": "Check", + "label": "Edit Posting Date and Time" + }, + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Series", + "options": "ACC-ASC-.YYYY.-", + "reqd": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Asset Capitalization", + "print_hide": 1, + "read_only": 1 + }, + { + "depends_on": "eval:doc.docstatus == 0 || (doc.stock_items && doc.stock_items.length)", + "fieldname": "section_break_16", + "fieldtype": "Section Break", + "label": "Consumed Stock Items" + }, + { + "fieldname": "stock_items", + "fieldtype": "Table", + "label": "Stock Items", + "options": "Asset Capitalization Stock Item" + }, + { + "depends_on": "eval:doc.entry_type=='Decapitalization'", + "fieldname": "target_warehouse", + "fieldtype": "Link", + "label": "Target Warehouse", + "mandatory_depends_on": "eval:doc.entry_type=='Decapitalization'", + "options": "Warehouse" + }, + { + "depends_on": "target_has_batch_no", + "fieldname": "target_batch_no", + "fieldtype": "Link", + "label": "Target Batch No", + "options": "Batch" + }, + { + "default": "1", + "fieldname": "target_qty", + "fieldtype": "Float", + "label": "Target Qty", + "read_only_depends_on": "target_is_fixed_asset" + }, + { + "fetch_from": "target_item_code.stock_uom", + "fieldname": "target_stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM", + "read_only": 1 + }, + { + "default": "0", + "fetch_from": "target_item_code.has_batch_no", + "fieldname": "target_has_batch_no", + "fieldtype": "Check", + "hidden": 1, + "label": "Target Has Batch No", + "read_only": 1 + }, + { + "default": "0", + "fetch_from": "target_item_code.has_serial_no", + "fieldname": "target_has_serial_no", + "fieldtype": "Check", + "hidden": 1, + "label": "Target Has Serial No", + "read_only": 1 + }, + { + "depends_on": "target_has_serial_no", + "fieldname": "target_serial_no", + "fieldtype": "Small Text", + "label": "Target Serial No" + }, + { + "depends_on": "eval:doc.docstatus == 0 || (doc.asset_items && doc.asset_items.length)", + "fieldname": "section_break_26", + "fieldtype": "Section Break", + "label": "Consumed Asset Items" + }, + { + "fieldname": "asset_items", + "fieldtype": "Table", + "label": "Assets", + "options": "Asset Capitalization Asset Item" + }, + { + "default": "Capitalization", + "fieldname": "entry_type", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Entry Type", + "options": "Capitalization\nDecapitalization", + "reqd": 1 + }, + { + "fieldname": "stock_items_total", + "fieldtype": "Currency", + "label": "Consumed Stock Total Value", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "asset_items_total", + "fieldtype": "Currency", + "label": "Consumed Asset Total Value", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "finance_book", + "fieldtype": "Link", + "label": "Finance Book", + "options": "Finance Book" + }, + { + "depends_on": "eval:doc.docstatus == 0 || (doc.service_items && doc.service_items.length)", + "fieldname": "service_expenses_section", + "fieldtype": "Section Break", + "label": "Service Expenses" + }, + { + "fieldname": "service_items", + "fieldtype": "Table", + "label": "Services", + "options": "Asset Capitalization Service Item" + }, + { + "fieldname": "service_items_total", + "fieldtype": "Currency", + "label": "Service Expense Total Amount", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "totals_section", + "fieldtype": "Section Break", + "label": "Totals" + }, + { + "fieldname": "total_value", + "fieldtype": "Currency", + "label": "Total Value", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "column_break_36", + "fieldtype": "Column Break" + }, + { + "fieldname": "target_incoming_rate", + "fieldtype": "Currency", + "label": "Target Incoming Rate", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "fieldname": "dimension_col_break", + "fieldtype": "Column Break" + }, + { + "fieldname": "target_fixed_asset_account", + "fieldtype": "Link", + "label": "Target Fixed Asset Account", + "options": "Account", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2022-09-12 15:09:40.771332", + "modified_by": "Administrator", + "module": "Assets", + "name": "Asset Capitalization", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Manufacturing Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Quality Manager", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "title", + "track_changes": 1, + "track_seen": 1 +} \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py new file mode 100644 index 00000000000..2e6f0ad7b02 --- /dev/null +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py @@ -0,0 +1,749 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json + +import frappe + +# import erpnext +from frappe import _ +from frappe.utils import cint, flt +from six import string_types + +import erpnext +from erpnext.assets.doctype.asset.depreciation import ( + get_gl_entries_on_asset_disposal, + get_value_after_depreciation_on_disposal_date, +) +from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account +from erpnext.assets.doctype.asset_value_adjustment.asset_value_adjustment import ( + get_current_asset_value, +) +from erpnext.controllers.stock_controller import StockController +from erpnext.setup.doctype.brand.brand import get_brand_defaults +from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.stock import get_warehouse_account_map +from erpnext.stock.doctype.item.item import get_item_defaults +from erpnext.stock.get_item_details import ( + get_default_cost_center, + get_default_expense_account, + get_item_warehouse, +) +from erpnext.stock.stock_ledger import get_previous_sle +from erpnext.stock.utils import get_incoming_rate + +force_fields = [ + "target_item_name", + "target_asset_name", + "item_name", + "asset_name", + "target_is_fixed_asset", + "target_has_serial_no", + "target_has_batch_no", + "target_stock_uom", + "stock_uom", + "target_fixed_asset_account", + "fixed_asset_account", + "valuation_rate", +] + + +class AssetCapitalization(StockController): + def validate(self): + self.validate_posting_time() + self.set_missing_values(for_validate=True) + self.validate_target_item() + self.validate_target_asset() + self.validate_consumed_stock_item() + self.validate_consumed_asset_item() + self.validate_service_item() + self.set_warehouse_details() + self.set_asset_values() + self.calculate_totals() + self.set_title() + + def before_submit(self): + self.validate_source_mandatory() + + def on_submit(self): + self.update_stock_ledger() + self.make_gl_entries() + self.update_target_asset() + + def on_cancel(self): + self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation") + self.update_stock_ledger() + self.make_gl_entries() + self.update_target_asset() + + def set_title(self): + self.title = self.target_asset_name or self.target_item_name or self.target_item_code + + def set_missing_values(self, for_validate=False): + target_item_details = get_target_item_details(self.target_item_code, self.company) + for k, v in target_item_details.items(): + if self.meta.has_field(k) and (not self.get(k) or k in force_fields): + self.set(k, v) + + # Remove asset if item not a fixed asset + if not self.target_is_fixed_asset: + self.target_asset = None + + target_asset_details = get_target_asset_details(self.target_asset, self.company) + for k, v in target_asset_details.items(): + if self.meta.has_field(k) and (not self.get(k) or k in force_fields): + self.set(k, v) + + for d in self.stock_items: + args = self.as_dict() + args.update(d.as_dict()) + args.doctype = self.doctype + args.name = self.name + consumed_stock_item_details = get_consumed_stock_item_details(args) + for k, v in consumed_stock_item_details.items(): + if d.meta.has_field(k) and (not d.get(k) or k in force_fields): + d.set(k, v) + + for d in self.asset_items: + args = self.as_dict() + args.update(d.as_dict()) + args.doctype = self.doctype + args.name = self.name + args.finance_book = d.get("finance_book") or self.get("finance_book") + consumed_asset_details = get_consumed_asset_details(args) + for k, v in consumed_asset_details.items(): + if d.meta.has_field(k) and (not d.get(k) or k in force_fields): + d.set(k, v) + + for d in self.service_items: + args = self.as_dict() + args.update(d.as_dict()) + args.doctype = self.doctype + args.name = self.name + service_item_details = get_service_item_details(args) + for k, v in service_item_details.items(): + if d.meta.has_field(k) and (not d.get(k) or k in force_fields): + d.set(k, v) + + def validate_target_item(self): + target_item = frappe.get_cached_doc("Item", self.target_item_code) + + if not target_item.is_fixed_asset and not target_item.is_stock_item: + frappe.throw( + _("Target Item {0} is neither a Fixed Asset nor a Stock Item").format(target_item.name) + ) + + if self.entry_type == "Capitalization" and not target_item.is_fixed_asset: + frappe.throw(_("Target Item {0} must be a Fixed Asset item").format(target_item.name)) + elif self.entry_type == "Decapitalization" and not target_item.is_stock_item: + frappe.throw(_("Target Item {0} must be a Stock Item").format(target_item.name)) + + if target_item.is_fixed_asset: + self.target_qty = 1 + if flt(self.target_qty) <= 0: + frappe.throw(_("Target Qty must be a positive number")) + + if not target_item.is_stock_item: + self.target_warehouse = None + if not target_item.is_fixed_asset: + self.target_asset = None + self.target_fixed_asset_account = None + if not target_item.has_batch_no: + self.target_batch_no = None + if not target_item.has_serial_no: + self.target_serial_no = "" + + if target_item.is_stock_item and not self.target_warehouse: + frappe.throw(_("Target Warehouse is mandatory for Decapitalization")) + + self.validate_item(target_item) + + def validate_target_asset(self): + if self.target_asset: + target_asset = self.get_asset_for_validation(self.target_asset) + + if target_asset.item_code != self.target_item_code: + frappe.throw( + _("Asset {0} does not belong to Item {1}").format(self.target_asset, self.target_item_code) + ) + + self.validate_asset(target_asset) + + def validate_consumed_stock_item(self): + for d in self.stock_items: + if d.item_code: + item = frappe.get_cached_doc("Item", d.item_code) + + if not item.is_stock_item: + frappe.throw(_("Row #{0}: Item {1} is not a stock item").format(d.idx, d.item_code)) + + if flt(d.stock_qty) <= 0: + frappe.throw(_("Row #{0}: Qty must be a positive number").format(d.idx)) + + self.validate_item(item) + + def validate_consumed_asset_item(self): + for d in self.asset_items: + if d.asset: + if d.asset == self.target_asset: + frappe.throw( + _("Row #{0}: Consumed Asset {1} cannot be the same as the Target Asset").format( + d.idx, d.asset + ) + ) + + asset = self.get_asset_for_validation(d.asset) + self.validate_asset(asset) + + def validate_service_item(self): + for d in self.service_items: + if d.item_code: + item = frappe.get_cached_doc("Item", d.item_code) + + if item.is_stock_item or item.is_fixed_asset: + frappe.throw(_("Row #{0}: Item {1} is not a service item").format(d.idx, d.item_code)) + + if flt(d.qty) <= 0: + frappe.throw(_("Row #{0}: Qty must be a positive number").format(d.idx)) + + if flt(d.rate) <= 0: + frappe.throw(_("Row #{0}: Amount must be a positive number").format(d.idx)) + + self.validate_item(item) + + if not d.cost_center: + d.cost_center = frappe.get_cached_value("Company", self.company, "cost_center") + + def validate_source_mandatory(self): + if not self.target_is_fixed_asset and not self.get("asset_items"): + frappe.throw(_("Consumed Asset Items is mandatory for Decapitalization")) + + if not self.get("stock_items") and not self.get("asset_items"): + frappe.throw(_("Consumed Stock Items or Consumed Asset Items is mandatory for Capitalization")) + + def validate_item(self, item): + from erpnext.stock.doctype.item.item import validate_end_of_life + + validate_end_of_life(item.name, item.end_of_life, item.disabled) + + def get_asset_for_validation(self, asset): + return frappe.db.get_value( + "Asset", asset, ["name", "item_code", "company", "status", "docstatus"], as_dict=1 + ) + + def validate_asset(self, asset): + if asset.status in ("Draft", "Scrapped", "Sold", "Capitalized", "Decapitalized"): + frappe.throw(_("Asset {0} is {1}").format(asset.name, asset.status)) + + if asset.docstatus == 0: + frappe.throw(_("Asset {0} is Draft").format(asset.name)) + if asset.docstatus == 2: + frappe.throw(_("Asset {0} is cancelled").format(asset.name)) + + if asset.company != self.company: + frappe.throw(_("Asset {0} does not belong to company {1}").format(asset.name, self.company)) + + @frappe.whitelist() + def set_warehouse_details(self): + for d in self.get("stock_items"): + if d.item_code and d.warehouse: + args = self.get_args_for_incoming_rate(d) + warehouse_details = get_warehouse_details(args) + d.update(warehouse_details) + + @frappe.whitelist() + def set_asset_values(self): + for d in self.get("asset_items"): + if d.asset: + finance_book = d.get("finance_book") or self.get("finance_book") + d.current_asset_value = flt(get_current_asset_value(d.asset, finance_book=finance_book)) + d.asset_value = get_value_after_depreciation_on_disposal_date( + d.asset, self.posting_date, finance_book=finance_book + ) + + def get_args_for_incoming_rate(self, item): + return frappe._dict( + { + "item_code": item.item_code, + "warehouse": item.warehouse, + "posting_date": self.posting_date, + "posting_time": self.posting_time, + "qty": -1 * flt(item.stock_qty), + "serial_no": item.serial_no, + "batch_no": item.batch_no, + "voucher_type": self.doctype, + "voucher_no": self.name, + "company": self.company, + "allow_zero_valuation": cint(item.get("allow_zero_valuation_rate")), + } + ) + + def calculate_totals(self): + self.stock_items_total = 0 + self.asset_items_total = 0 + self.service_items_total = 0 + + for d in self.stock_items: + d.amount = flt(flt(d.stock_qty) * flt(d.valuation_rate), d.precision("amount")) + self.stock_items_total += d.amount + + for d in self.asset_items: + d.asset_value = flt(flt(d.asset_value), d.precision("asset_value")) + self.asset_items_total += d.asset_value + + for d in self.service_items: + d.amount = flt(flt(d.qty) * flt(d.rate), d.precision("amount")) + self.service_items_total += d.amount + + self.stock_items_total = flt(self.stock_items_total, self.precision("stock_items_total")) + self.asset_items_total = flt(self.asset_items_total, self.precision("asset_items_total")) + self.service_items_total = flt(self.service_items_total, self.precision("service_items_total")) + + self.total_value = self.stock_items_total + self.asset_items_total + self.service_items_total + self.total_value = flt(self.total_value, self.precision("total_value")) + + self.target_qty = flt(self.target_qty, self.precision("target_qty")) + self.target_incoming_rate = self.total_value / self.target_qty + + def update_stock_ledger(self): + sl_entries = [] + + for d in self.stock_items: + sle = self.get_sl_entries( + d, + { + "actual_qty": -flt(d.stock_qty), + }, + ) + sl_entries.append(sle) + + if self.entry_type == "Decapitalization" and not self.target_is_fixed_asset: + sle = self.get_sl_entries( + self, + { + "item_code": self.target_item_code, + "warehouse": self.target_warehouse, + "batch_no": self.target_batch_no, + "serial_no": self.target_serial_no, + "actual_qty": flt(self.target_qty), + "incoming_rate": flt(self.target_incoming_rate), + }, + ) + sl_entries.append(sle) + + # reverse sl entries if cancel + if self.docstatus == 2: + sl_entries.reverse() + + if sl_entries: + self.make_sl_entries(sl_entries) + + def make_gl_entries(self, gl_entries=None, from_repost=False): + from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries + + if self.docstatus == 1: + if not gl_entries: + gl_entries = self.get_gl_entries() + + if gl_entries: + make_gl_entries(gl_entries, from_repost=from_repost) + elif self.docstatus == 2: + make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) + + def get_gl_entries( + self, warehouse_account=None, default_expense_account=None, default_cost_center=None + ): + # Stock GL Entries + gl_entries = [] + + self.warehouse_account = warehouse_account + if not self.warehouse_account: + self.warehouse_account = get_warehouse_account_map(self.company) + + precision = self.get_debit_field_precision() + self.sle_map = self.get_stock_ledger_details() + + target_account = self.get_target_account() + target_against = set() + + self.get_gl_entries_for_consumed_stock_items( + gl_entries, target_account, target_against, precision + ) + self.get_gl_entries_for_consumed_asset_items( + gl_entries, target_account, target_against, precision + ) + self.get_gl_entries_for_consumed_service_items( + gl_entries, target_account, target_against, precision + ) + + self.get_gl_entries_for_target_item(gl_entries, target_against, precision) + return gl_entries + + def get_target_account(self): + if self.target_is_fixed_asset: + return self.target_fixed_asset_account + else: + return self.warehouse_account[self.target_warehouse]["account"] + + def get_gl_entries_for_consumed_stock_items( + self, gl_entries, target_account, target_against, precision + ): + # Consumed Stock Items + for item_row in self.stock_items: + sle_list = self.sle_map.get(item_row.name) + if sle_list: + for sle in sle_list: + stock_value_difference = flt(sle.stock_value_difference, precision) + + if erpnext.is_perpetual_inventory_enabled(self.company): + account = self.warehouse_account[sle.warehouse]["account"] + else: + account = self.get_company_default("default_expense_account") + + target_against.add(account) + gl_entries.append( + self.get_gl_dict( + { + "account": account, + "against": target_account, + "cost_center": item_row.cost_center, + "project": item_row.get("project") or self.get("project"), + "remarks": self.get("remarks") or "Accounting Entry for Stock", + "credit": -1 * stock_value_difference, + }, + self.warehouse_account[sle.warehouse]["account_currency"], + item=item_row, + ) + ) + + def get_gl_entries_for_consumed_asset_items( + self, gl_entries, target_account, target_against, precision + ): + # Consumed Assets + for item in self.asset_items: + asset = self.get_asset(item) + + if asset.calculate_depreciation: + self.depreciate_asset(asset) + asset.reload() + + fixed_asset_gl_entries = get_gl_entries_on_asset_disposal( + asset, item.asset_value, item.get("finance_book") or self.get("finance_book") + ) + + asset.db_set("disposal_date", self.posting_date) + + self.set_consumed_asset_status(asset) + + for gle in fixed_asset_gl_entries: + gle["against"] = target_account + gl_entries.append(self.get_gl_dict(gle, item=item)) + target_against.add(gle["account"]) + + def get_gl_entries_for_consumed_service_items( + self, gl_entries, target_account, target_against, precision + ): + # Service Expenses + for item_row in self.service_items: + expense_amount = flt(item_row.amount, precision) + target_against.add(item_row.expense_account) + + gl_entries.append( + self.get_gl_dict( + { + "account": item_row.expense_account, + "against": target_account, + "cost_center": item_row.cost_center, + "project": item_row.get("project") or self.get("project"), + "remarks": self.get("remarks") or "Accounting Entry for Stock", + "credit": expense_amount, + }, + item=item_row, + ) + ) + + def get_gl_entries_for_target_item(self, gl_entries, target_against, precision): + if self.target_is_fixed_asset: + # Capitalization + gl_entries.append( + self.get_gl_dict( + { + "account": self.target_fixed_asset_account, + "against": ", ".join(target_against), + "remarks": self.get("remarks") or _("Accounting Entry for Asset"), + "debit": flt(self.total_value, precision), + "cost_center": self.get("cost_center"), + }, + item=self, + ) + ) + else: + # Target Stock Item + sle_list = self.sle_map.get(self.name) + for sle in sle_list: + stock_value_difference = flt(sle.stock_value_difference, precision) + account = self.warehouse_account[sle.warehouse]["account"] + + gl_entries.append( + self.get_gl_dict( + { + "account": account, + "against": ", ".join(target_against), + "cost_center": self.cost_center, + "project": self.get("project"), + "remarks": self.get("remarks") or "Accounting Entry for Stock", + "debit": stock_value_difference, + }, + self.warehouse_account[sle.warehouse]["account_currency"], + item=self, + ) + ) + + def update_target_asset(self): + total_target_asset_value = flt(self.total_value, self.precision("total_value")) + if self.docstatus == 1 and self.entry_type == "Capitalization": + asset_doc = frappe.get_doc("Asset", self.target_asset) + asset_doc.purchase_date = self.posting_date + asset_doc.gross_purchase_amount = total_target_asset_value + asset_doc.purchase_receipt_amount = total_target_asset_value + asset_doc.prepare_depreciation_data() + asset_doc.flags.ignore_validate_update_after_submit = True + asset_doc.save() + elif self.docstatus == 2: + for item in self.asset_items: + asset = self.get_asset(item) + asset.db_set("disposal_date", None) + self.set_consumed_asset_status(asset) + + if asset.calculate_depreciation: + self.reverse_depreciation_entry_made_after_disposal(asset) + self.reset_depreciation_schedule(asset) + + def get_asset(self, item): + asset = frappe.get_doc("Asset", item.asset) + self.check_finance_books(item, asset) + return asset + + def set_consumed_asset_status(self, asset): + if self.docstatus == 1: + asset.set_status("Capitalized" if self.target_is_fixed_asset else "Decapitalized") + else: + asset.set_status() + + +@frappe.whitelist() +def get_target_item_details(item_code=None, company=None): + out = frappe._dict() + + # Get Item Details + item = frappe._dict() + if item_code: + item = frappe.get_cached_doc("Item", item_code) + + # Set Item Details + out.target_item_name = item.item_name + out.target_stock_uom = item.stock_uom + out.target_is_fixed_asset = cint(item.is_fixed_asset) + out.target_has_batch_no = cint(item.has_batch_no) + out.target_has_serial_no = cint(item.has_serial_no) + + if out.target_is_fixed_asset: + out.target_qty = 1 + out.target_warehouse = None + else: + out.target_asset = None + + if not out.target_has_batch_no: + out.target_batch_no = None + if not out.target_has_serial_no: + out.target_serial_no = "" + + # Cost Center + item_defaults = get_item_defaults(item.name, company) + item_group_defaults = get_item_group_defaults(item.name, company) + brand_defaults = get_brand_defaults(item.name, company) + out.cost_center = get_default_cost_center( + frappe._dict({"item_code": item.name, "company": company}), + item_defaults, + item_group_defaults, + brand_defaults, + ) + + return out + + +@frappe.whitelist() +def get_target_asset_details(asset=None, company=None): + out = frappe._dict() + + # Get Asset Details + asset_details = frappe._dict() + if asset: + asset_details = frappe.db.get_value("Asset", asset, ["asset_name", "item_code"], as_dict=1) + if not asset_details: + frappe.throw(_("Asset {0} does not exist").format(asset)) + + # Re-set item code from Asset + out.target_item_code = asset_details.item_code + + # Set Asset Details + out.asset_name = asset_details.asset_name + + if asset_details.item_code: + out.target_fixed_asset_account = get_asset_category_account( + "fixed_asset_account", item=asset_details.item_code, company=company + ) + else: + out.target_fixed_asset_account = None + + return out + + +@frappe.whitelist() +def get_consumed_stock_item_details(args): + if isinstance(args, string_types): + args = json.loads(args) + + args = frappe._dict(args) + out = frappe._dict() + + item = frappe._dict() + if args.item_code: + item = frappe.get_cached_doc("Item", args.item_code) + + out.item_name = item.item_name + out.batch_no = None + out.serial_no = "" + + out.stock_qty = flt(args.stock_qty) or 1 + out.stock_uom = item.stock_uom + + out.warehouse = get_item_warehouse(item, args, overwrite_warehouse=True) if item else None + + # Cost Center + item_defaults = get_item_defaults(item.name, args.company) + item_group_defaults = get_item_group_defaults(item.name, args.company) + brand_defaults = get_brand_defaults(item.name, args.company) + out.cost_center = get_default_cost_center( + args, item_defaults, item_group_defaults, brand_defaults + ) + + if args.item_code and out.warehouse: + incoming_rate_args = frappe._dict( + { + "item_code": args.item_code, + "warehouse": out.warehouse, + "posting_date": args.posting_date, + "posting_time": args.posting_time, + "qty": -1 * flt(out.stock_qty), + "voucher_type": args.doctype, + "voucher_no": args.name, + "company": args.company, + "serial_no": args.serial_no, + "batch_no": args.batch_no, + } + ) + out.update(get_warehouse_details(incoming_rate_args)) + else: + out.valuation_rate = 0 + out.actual_qty = 0 + + return out + + +@frappe.whitelist() +def get_warehouse_details(args): + if isinstance(args, string_types): + args = json.loads(args) + + args = frappe._dict(args) + + out = {} + if args.warehouse and args.item_code: + out = { + "actual_qty": get_previous_sle(args).get("qty_after_transaction") or 0, + "valuation_rate": get_incoming_rate(args, raise_error_if_no_rate=False), + } + return out + + +@frappe.whitelist() +def get_consumed_asset_details(args): + if isinstance(args, string_types): + args = json.loads(args) + + args = frappe._dict(args) + out = frappe._dict() + + asset_details = frappe._dict() + if args.asset: + asset_details = frappe.db.get_value( + "Asset", args.asset, ["asset_name", "item_code", "item_name"], as_dict=1 + ) + if not asset_details: + frappe.throw(_("Asset {0} does not exist").format(args.asset)) + + out.item_code = asset_details.item_code + out.asset_name = asset_details.asset_name + out.item_name = asset_details.item_name + + if args.asset: + out.current_asset_value = flt( + get_current_asset_value(args.asset, finance_book=args.finance_book) + ) + out.asset_value = get_value_after_depreciation_on_disposal_date( + args.asset, args.posting_date, finance_book=args.finance_book + ) + else: + out.current_asset_value = 0 + out.asset_value = 0 + + # Account + if asset_details.item_code: + out.fixed_asset_account = get_asset_category_account( + "fixed_asset_account", item=asset_details.item_code, company=args.company + ) + else: + out.fixed_asset_account = None + + # Cost Center + if asset_details.item_code: + item = frappe.get_cached_doc("Item", asset_details.item_code) + item_defaults = get_item_defaults(item.name, args.company) + item_group_defaults = get_item_group_defaults(item.name, args.company) + brand_defaults = get_brand_defaults(item.name, args.company) + out.cost_center = get_default_cost_center( + args, item_defaults, item_group_defaults, brand_defaults + ) + + return out + + +@frappe.whitelist() +def get_service_item_details(args): + if isinstance(args, string_types): + args = json.loads(args) + + args = frappe._dict(args) + out = frappe._dict() + + item = frappe._dict() + if args.item_code: + item = frappe.get_cached_doc("Item", args.item_code) + + out.item_name = item.item_name + out.qty = flt(args.qty) or 1 + out.uom = item.purchase_uom or item.stock_uom + + item_defaults = get_item_defaults(item.name, args.company) + item_group_defaults = get_item_group_defaults(item.name, args.company) + brand_defaults = get_brand_defaults(item.name, args.company) + + out.expense_account = get_default_expense_account( + args, item_defaults, item_group_defaults, brand_defaults + ) + out.cost_center = get_default_cost_center( + args, item_defaults, item_group_defaults, brand_defaults + ) + + return out diff --git a/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py new file mode 100644 index 00000000000..86861f0b165 --- /dev/null +++ b/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py @@ -0,0 +1,494 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import unittest + +import frappe +from frappe.utils import cint, flt, getdate, now_datetime + +from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries +from erpnext.assets.doctype.asset.test_asset import ( + create_asset, + create_asset_data, + set_depreciation_settings_in_company, +) +from erpnext.stock.doctype.item.test_item import create_item + + +class TestAssetCapitalization(unittest.TestCase): + def setUp(self): + set_depreciation_settings_in_company() + create_asset_data() + create_asset_capitalization_data() + frappe.db.sql("delete from `tabTax Rule`") + + def test_capitalization_with_perpetual_inventory(self): + company = "_Test Company with perpetual inventory" + set_depreciation_settings_in_company(company=company) + + # Variables + consumed_asset_value = 100000 + + stock_rate = 1000 + stock_qty = 2 + stock_amount = 2000 + + service_rate = 500 + service_qty = 2 + service_amount = 1000 + + total_amount = 103000 + + # Create assets + target_asset = create_asset( + asset_name="Asset Capitalization Target Asset", + submit=1, + warehouse="Stores - TCP1", + company=company, + ) + consumed_asset = create_asset( + asset_name="Asset Capitalization Consumable Asset", + asset_value=consumed_asset_value, + submit=1, + warehouse="Stores - TCP1", + company=company, + ) + + # Create and submit Asset Captitalization + asset_capitalization = create_asset_capitalization( + entry_type="Capitalization", + target_asset=target_asset.name, + stock_qty=stock_qty, + stock_rate=stock_rate, + consumed_asset=consumed_asset.name, + service_qty=service_qty, + service_rate=service_rate, + service_expense_account="Expenses Included In Asset Valuation - TCP1", + company=company, + submit=1, + ) + + # Test Asset Capitalization values + self.assertEqual(asset_capitalization.entry_type, "Capitalization") + self.assertEqual(asset_capitalization.target_qty, 1) + + self.assertEqual(asset_capitalization.stock_items[0].valuation_rate, stock_rate) + self.assertEqual(asset_capitalization.stock_items[0].amount, stock_amount) + self.assertEqual(asset_capitalization.stock_items_total, stock_amount) + + self.assertEqual(asset_capitalization.asset_items[0].asset_value, consumed_asset_value) + self.assertEqual(asset_capitalization.asset_items_total, consumed_asset_value) + + self.assertEqual(asset_capitalization.service_items[0].amount, service_amount) + self.assertEqual(asset_capitalization.service_items_total, service_amount) + + self.assertEqual(asset_capitalization.total_value, total_amount) + self.assertEqual(asset_capitalization.target_incoming_rate, total_amount) + + # Test Target Asset values + target_asset.reload() + self.assertEqual(target_asset.gross_purchase_amount, total_amount) + self.assertEqual(target_asset.purchase_receipt_amount, total_amount) + + # Test Consumed Asset values + self.assertEqual(consumed_asset.db_get("status"), "Capitalized") + + # Test General Ledger Entries + expected_gle = { + "_Test Fixed Asset - TCP1": 3000, + "Expenses Included In Asset Valuation - TCP1": -1000, + "_Test Warehouse - TCP1": -2000, + } + actual_gle = get_actual_gle_dict(asset_capitalization.name) + + self.assertEqual(actual_gle, expected_gle) + + # Test Stock Ledger Entries + expected_sle = { + ("Capitalization Source Stock Item", "_Test Warehouse - TCP1"): { + "actual_qty": -stock_qty, + "stock_value_difference": -stock_amount, + } + } + actual_sle = get_actual_sle_dict(asset_capitalization.name) + self.assertEqual(actual_sle, expected_sle) + + # Cancel Asset Capitalization and make test entries and status are reversed + asset_capitalization.cancel() + self.assertEqual(consumed_asset.db_get("status"), "Submitted") + self.assertFalse(get_actual_gle_dict(asset_capitalization.name)) + self.assertFalse(get_actual_sle_dict(asset_capitalization.name)) + + def test_capitalization_with_periodical_inventory(self): + company = "_Test Company" + # Variables + consumed_asset_value = 100000 + + stock_rate = 1000 + stock_qty = 2 + stock_amount = 2000 + + service_rate = 500 + service_qty = 2 + service_amount = 1000 + + total_amount = 103000 + + # Create assets + target_asset = create_asset( + asset_name="Asset Capitalization Target Asset", + submit=1, + warehouse="Stores - _TC", + company=company, + ) + consumed_asset = create_asset( + asset_name="Asset Capitalization Consumable Asset", + asset_value=consumed_asset_value, + submit=1, + warehouse="Stores - _TC", + company=company, + ) + + # Create and submit Asset Captitalization + asset_capitalization = create_asset_capitalization( + entry_type="Capitalization", + target_asset=target_asset.name, + stock_qty=stock_qty, + stock_rate=stock_rate, + consumed_asset=consumed_asset.name, + service_qty=service_qty, + service_rate=service_rate, + service_expense_account="Expenses Included In Asset Valuation - _TC", + company=company, + submit=1, + ) + + # Test Asset Capitalization values + self.assertEqual(asset_capitalization.entry_type, "Capitalization") + self.assertEqual(asset_capitalization.target_qty, 1) + + self.assertEqual(asset_capitalization.stock_items[0].valuation_rate, stock_rate) + self.assertEqual(asset_capitalization.stock_items[0].amount, stock_amount) + self.assertEqual(asset_capitalization.stock_items_total, stock_amount) + + self.assertEqual(asset_capitalization.asset_items[0].asset_value, consumed_asset_value) + self.assertEqual(asset_capitalization.asset_items_total, consumed_asset_value) + + self.assertEqual(asset_capitalization.service_items[0].amount, service_amount) + self.assertEqual(asset_capitalization.service_items_total, service_amount) + + self.assertEqual(asset_capitalization.total_value, total_amount) + self.assertEqual(asset_capitalization.target_incoming_rate, total_amount) + + # Test Target Asset values + target_asset.reload() + self.assertEqual(target_asset.gross_purchase_amount, total_amount) + self.assertEqual(target_asset.purchase_receipt_amount, total_amount) + + # Test Consumed Asset values + self.assertEqual(consumed_asset.db_get("status"), "Capitalized") + + # Test General Ledger Entries + default_expense_account = frappe.db.get_value("Company", company, "default_expense_account") + expected_gle = { + "_Test Fixed Asset - _TC": 3000, + "Expenses Included In Asset Valuation - _TC": -1000, + default_expense_account: -2000, + } + actual_gle = get_actual_gle_dict(asset_capitalization.name) + + self.assertEqual(actual_gle, expected_gle) + + # Test Stock Ledger Entries + expected_sle = { + ("Capitalization Source Stock Item", "_Test Warehouse - _TC"): { + "actual_qty": -stock_qty, + "stock_value_difference": -stock_amount, + } + } + actual_sle = get_actual_sle_dict(asset_capitalization.name) + self.assertEqual(actual_sle, expected_sle) + + # Cancel Asset Capitalization and make test entries and status are reversed + asset_capitalization.cancel() + self.assertEqual(consumed_asset.db_get("status"), "Submitted") + self.assertFalse(get_actual_gle_dict(asset_capitalization.name)) + self.assertFalse(get_actual_sle_dict(asset_capitalization.name)) + + def test_decapitalization_with_depreciation(self): + # Variables + purchase_date = "2020-01-01" + depreciation_start_date = "2020-12-31" + capitalization_date = "2021-06-30" + + total_number_of_depreciations = 3 + expected_value_after_useful_life = 10_000 + consumed_asset_purchase_value = 100_000 + consumed_asset_current_value = 70_000 + consumed_asset_value_before_disposal = 55_000 + + target_qty = 10 + target_incoming_rate = 5500 + + depreciation_before_disposal_amount = 15_000 + accumulated_depreciation = 45_000 + + # to accomodate for depreciation on disposal calculation minor difference + consumed_asset_value_before_disposal = 55_123.29 + target_incoming_rate = 5512.329 + depreciation_before_disposal_amount = 14_876.71 + accumulated_depreciation = 44_876.71 + + # Create assets + consumed_asset = create_depreciation_asset( + asset_name="Asset Capitalization Consumable Asset", + asset_value=consumed_asset_purchase_value, + purchase_date=purchase_date, + depreciation_start_date=depreciation_start_date, + depreciation_method="Straight Line", + total_number_of_depreciations=total_number_of_depreciations, + frequency_of_depreciation=12, + expected_value_after_useful_life=expected_value_after_useful_life, + company="_Test Company with perpetual inventory", + submit=1, + ) + + # Create and submit Asset Captitalization + asset_capitalization = create_asset_capitalization( + entry_type="Decapitalization", + posting_date=capitalization_date, # half a year + target_item_code="Capitalization Target Stock Item", + target_qty=target_qty, + consumed_asset=consumed_asset.name, + company="_Test Company with perpetual inventory", + submit=1, + ) + + # Test Asset Capitalization values + self.assertEqual(asset_capitalization.entry_type, "Decapitalization") + + self.assertEqual( + asset_capitalization.asset_items[0].current_asset_value, consumed_asset_current_value + ) + self.assertEqual( + asset_capitalization.asset_items[0].asset_value, consumed_asset_value_before_disposal + ) + self.assertEqual(asset_capitalization.asset_items_total, consumed_asset_value_before_disposal) + + self.assertEqual(asset_capitalization.total_value, consumed_asset_value_before_disposal) + self.assertEqual(asset_capitalization.target_incoming_rate, target_incoming_rate) + + # Test Consumed Asset values + consumed_asset.reload() + self.assertEqual(consumed_asset.status, "Decapitalized") + + consumed_depreciation_schedule = [ + d for d in consumed_asset.schedules if getdate(d.schedule_date) == getdate(capitalization_date) + ] + self.assertTrue( + consumed_depreciation_schedule and consumed_depreciation_schedule[0].journal_entry + ) + self.assertEqual( + consumed_depreciation_schedule[0].depreciation_amount, depreciation_before_disposal_amount + ) + + # Test General Ledger Entries + expected_gle = { + "_Test Warehouse - TCP1": consumed_asset_value_before_disposal, + "_Test Accumulated Depreciations - TCP1": accumulated_depreciation, + "_Test Fixed Asset - TCP1": -consumed_asset_purchase_value, + } + actual_gle = get_actual_gle_dict(asset_capitalization.name) + self.assertEqual(actual_gle, expected_gle) + + # Cancel Asset Capitalization and make test entries and status are reversed + asset_capitalization.reload() + asset_capitalization.cancel() + self.assertEqual(consumed_asset.db_get("status"), "Partially Depreciated") + self.assertFalse(get_actual_gle_dict(asset_capitalization.name)) + self.assertFalse(get_actual_sle_dict(asset_capitalization.name)) + + +def create_asset_capitalization_data(): + create_item( + "Capitalization Target Stock Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=0 + ) + create_item( + "Capitalization Source Stock Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=0 + ) + create_item( + "Capitalization Source Service Item", is_stock_item=0, is_fixed_asset=0, is_purchase_item=0 + ) + + +def create_asset_capitalization(**args): + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + args = frappe._dict(args) + + now = now_datetime() + target_asset = frappe.get_doc("Asset", args.target_asset) if args.target_asset else frappe._dict() + target_item_code = target_asset.item_code or args.target_item_code + company = target_asset.company or args.company or "_Test Company" + warehouse = args.warehouse or create_warehouse("_Test Warehouse", company=company) + target_warehouse = args.target_warehouse or warehouse + source_warehouse = args.source_warehouse or warehouse + + asset_capitalization = frappe.new_doc("Asset Capitalization") + asset_capitalization.update( + { + "entry_type": args.entry_type or "Capitalization", + "company": company, + "posting_date": args.posting_date or now.strftime("%Y-%m-%d"), + "posting_time": args.posting_time or now.strftime("%H:%M:%S.%f"), + "target_item_code": target_item_code, + "target_asset": target_asset.name, + "target_warehouse": target_warehouse, + "target_qty": flt(args.target_qty) or 1, + "target_batch_no": args.target_batch_no, + "target_serial_no": args.target_serial_no, + "finance_book": args.finance_book, + } + ) + + if args.posting_date or args.posting_time: + asset_capitalization.set_posting_time = 1 + + if flt(args.stock_rate): + asset_capitalization.append( + "stock_items", + { + "item_code": args.stock_item or "Capitalization Source Stock Item", + "warehouse": source_warehouse, + "stock_qty": flt(args.stock_qty) or 1, + "batch_no": args.stock_batch_no, + "serial_no": args.stock_serial_no, + }, + ) + + if args.consumed_asset: + asset_capitalization.append( + "asset_items", + { + "asset": args.consumed_asset, + }, + ) + + if flt(args.service_rate): + asset_capitalization.append( + "service_items", + { + "item_code": args.service_item or "Capitalization Source Service Item", + "expense_account": args.service_expense_account, + "qty": flt(args.service_qty) or 1, + "rate": flt(args.service_rate), + }, + ) + + if args.submit: + create_stock_reconciliation(asset_capitalization, stock_rate=args.stock_rate) + + asset_capitalization.insert() + + if args.submit: + asset_capitalization.submit() + + return asset_capitalization + + +def create_stock_reconciliation(asset_capitalization, stock_rate=0): + from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + EmptyStockReconciliationItemsError, + create_stock_reconciliation, + ) + + if not asset_capitalization.get("stock_items"): + return + + try: + create_stock_reconciliation( + item_code=asset_capitalization.stock_items[0].item_code, + warehouse=asset_capitalization.stock_items[0].warehouse, + qty=flt(asset_capitalization.stock_items[0].stock_qty), + rate=flt(stock_rate), + company=asset_capitalization.company, + ) + except EmptyStockReconciliationItemsError: + pass + + +def create_depreciation_asset(**args): + args = frappe._dict(args) + + asset = frappe.new_doc("Asset") + asset.is_existing_asset = 1 + asset.calculate_depreciation = 1 + asset.asset_owner = "Company" + + asset.company = args.company or "_Test Company" + asset.item_code = args.item_code or "Macbook Pro" + asset.asset_name = args.asset_name or asset.item_code + asset.location = args.location or "Test Location" + + asset.purchase_date = args.purchase_date or "2020-01-01" + asset.available_for_use_date = args.available_for_use_date or asset.purchase_date + + asset.gross_purchase_amount = args.asset_value or 100000 + asset.purchase_receipt_amount = asset.gross_purchase_amount + + finance_book = asset.append("finance_books") + finance_book.depreciation_start_date = args.depreciation_start_date or "2020-12-31" + finance_book.depreciation_method = args.depreciation_method or "Straight Line" + finance_book.total_number_of_depreciations = cint(args.total_number_of_depreciations) or 3 + finance_book.frequency_of_depreciation = cint(args.frequency_of_depreciation) or 12 + finance_book.expected_value_after_useful_life = flt(args.expected_value_after_useful_life) + + if args.submit: + asset.submit() + + frappe.db.set_value("Company", "_Test Company", "series_for_depreciation_entry", "DEPR-") + post_depreciation_entries(date=finance_book.depreciation_start_date) + asset.load_from_db() + + return asset + + +def get_actual_gle_dict(name): + return dict( + frappe.db.sql( + """ + select account, sum(debit-credit) as diff + from `tabGL Entry` + where voucher_type = 'Asset Capitalization' and voucher_no = %s + group by account + having diff != 0 + """, + name, + ) + ) + + +def get_actual_sle_dict(name): + sles = frappe.db.sql( + """ + select + item_code, warehouse, + sum(actual_qty) as actual_qty, + sum(stock_value_difference) as stock_value_difference + from `tabStock Ledger Entry` + where voucher_type = 'Asset Capitalization' and voucher_no = %s + group by item_code, warehouse + having actual_qty != 0 + """, + name, + as_dict=1, + ) + + sle_dict = {} + for d in sles: + sle_dict[(d.item_code, d.warehouse)] = { + "actual_qty": d.actual_qty, + "stock_value_difference": d.stock_value_difference, + } + + return sle_dict diff --git a/erpnext/assets/doctype/asset_capitalization_asset_item/__init__.py b/erpnext/assets/doctype/asset_capitalization_asset_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/assets/doctype/asset_capitalization_asset_item/asset_capitalization_asset_item.json b/erpnext/assets/doctype/asset_capitalization_asset_item/asset_capitalization_asset_item.json new file mode 100644 index 00000000000..ebaaffbad15 --- /dev/null +++ b/erpnext/assets/doctype/asset_capitalization_asset_item/asset_capitalization_asset_item.json @@ -0,0 +1,128 @@ +{ + "actions": [], + "creation": "2021-09-05 15:52:10.124538", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "asset", + "asset_name", + "finance_book", + "column_break_3", + "item_code", + "item_name", + "section_break_6", + "current_asset_value", + "asset_value", + "column_break_9", + "accounting_dimensions_section", + "cost_center", + "dimension_col_break", + "fixed_asset_account" + ], + "fields": [ + { + "fieldname": "asset", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Asset", + "options": "Asset", + "reqd": 1 + }, + { + "fetch_from": "asset.asset_name", + "fieldname": "asset_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Asset Name", + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fetch_from": "asset.item_code", + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "read_only": 1 + }, + { + "fieldname": "section_break_6", + "fieldtype": "Section Break", + "label": "Value" + }, + { + "default": "0", + "fieldname": "asset_value", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Asset Value", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, + { + "fieldname": "fixed_asset_account", + "fieldtype": "Link", + "label": "Fixed Asset Account", + "options": "Account", + "read_only": 1 + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "collapsible": 1, + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" + }, + { + "fieldname": "dimension_col_break", + "fieldtype": "Column Break" + }, + { + "fieldname": "finance_book", + "fieldtype": "Link", + "label": "Finance Book", + "options": "Finance Book" + }, + { + "fieldname": "current_asset_value", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Current Asset Value", + "options": "Company:company:default_currency", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-09-12 14:30:02.915132", + "modified_by": "Administrator", + "module": "Assets", + "name": "Asset Capitalization Asset Item", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_capitalization_asset_item/asset_capitalization_asset_item.py b/erpnext/assets/doctype/asset_capitalization_asset_item/asset_capitalization_asset_item.py new file mode 100644 index 00000000000..ba356d6b9f0 --- /dev/null +++ b/erpnext/assets/doctype/asset_capitalization_asset_item/asset_capitalization_asset_item.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class AssetCapitalizationAssetItem(Document): + pass diff --git a/erpnext/assets/doctype/asset_capitalization_service_item/__init__.py b/erpnext/assets/doctype/asset_capitalization_service_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/assets/doctype/asset_capitalization_service_item/asset_capitalization_service_item.json b/erpnext/assets/doctype/asset_capitalization_service_item/asset_capitalization_service_item.json new file mode 100644 index 00000000000..0ae1c1428ee --- /dev/null +++ b/erpnext/assets/doctype/asset_capitalization_service_item/asset_capitalization_service_item.json @@ -0,0 +1,122 @@ +{ + "actions": [], + "creation": "2021-09-06 13:32:08.642060", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "item_name", + "column_break_3", + "expense_account", + "section_break_6", + "qty", + "uom", + "column_break_9", + "rate", + "amount", + "accounting_dimensions_section", + "cost_center", + "dimension_col_break" + ], + "fields": [ + { + "bold": 1, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item" + }, + { + "fetch_from": "item_code.item_name", + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "expense_account", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Expense Account", + "options": "Account", + "reqd": 1 + }, + { + "fieldname": "section_break_6", + "fieldtype": "Section Break", + "label": "Qty and Rate" + }, + { + "columns": 1, + "default": "1", + "fieldname": "qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Qty", + "non_negative": 1 + }, + { + "columns": 1, + "fetch_from": "stock_item_code.stock_uom", + "fieldname": "uom", + "fieldtype": "Link", + "in_list_view": 1, + "label": "UOM", + "options": "UOM" + }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, + { + "fieldname": "rate", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Rate", + "options": "Company:company:default_currency" + }, + { + "default": "0", + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "fieldname": "dimension_col_break", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-09-08 15:52:08.598100", + "modified_by": "Administrator", + "module": "Assets", + "name": "Asset Capitalization Service Item", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_capitalization_service_item/asset_capitalization_service_item.py b/erpnext/assets/doctype/asset_capitalization_service_item/asset_capitalization_service_item.py new file mode 100644 index 00000000000..28d018ee39a --- /dev/null +++ b/erpnext/assets/doctype/asset_capitalization_service_item/asset_capitalization_service_item.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class AssetCapitalizationServiceItem(Document): + pass diff --git a/erpnext/assets/doctype/asset_capitalization_stock_item/__init__.py b/erpnext/assets/doctype/asset_capitalization_stock_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json new file mode 100644 index 00000000000..14eb0f6ef20 --- /dev/null +++ b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.json @@ -0,0 +1,156 @@ +{ + "actions": [], + "creation": "2021-09-05 15:23:23.492310", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "item_name", + "column_break_3", + "warehouse", + "section_break_6", + "stock_qty", + "stock_uom", + "actual_qty", + "column_break_9", + "valuation_rate", + "amount", + "batch_and_serial_no_section", + "batch_no", + "column_break_13", + "serial_no", + "accounting_dimensions_section", + "cost_center", + "dimension_col_break" + ], + "fields": [ + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Warehouse", + "options": "Warehouse", + "reqd": 1 + }, + { + "fieldname": "batch_no", + "fieldtype": "Link", + "label": "Batch No", + "options": "Batch" + }, + { + "fieldname": "section_break_6", + "fieldtype": "Section Break", + "label": "Qty and Rate" + }, + { + "columns": 1, + "fieldname": "stock_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Qty", + "non_negative": 1 + }, + { + "columns": 1, + "fetch_from": "stock_item_code.stock_uom", + "fieldname": "stock_uom", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Stock UOM", + "options": "UOM", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, + { + "fieldname": "valuation_rate", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Valuation Rate", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "batch_and_serial_no_section", + "fieldtype": "Section Break", + "label": "Batch and Serial No" + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "fieldname": "serial_no", + "fieldtype": "Small Text", + "label": "Serial No" + }, + { + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "reqd": 1 + }, + { + "fetch_from": "item_code.item_name", + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "read_only": 1 + }, + { + "fieldname": "actual_qty", + "fieldtype": "Float", + "label": "Actual Qty in Warehouse", + "no_copy": 1, + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "fieldname": "dimension_col_break", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-09-08 15:56:20.230548", + "modified_by": "Administrator", + "module": "Assets", + "name": "Asset Capitalization Stock Item", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.py b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.py new file mode 100644 index 00000000000..5d6f98d5cf0 --- /dev/null +++ b/erpnext/assets/doctype/asset_capitalization_stock_item/asset_capitalization_stock_item.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class AssetCapitalizationStockItem(Document): + pass diff --git a/erpnext/assets/workspace/assets/assets.json b/erpnext/assets/workspace/assets/assets.json index 26a6609b310..c07155e48a8 100644 --- a/erpnext/assets/workspace/assets/assets.json +++ b/erpnext/assets/workspace/assets/assets.json @@ -130,6 +130,17 @@ "onboard": 0, "type": "Link" }, + { + "dependencies": "Asset", + "hidden": 0, + "is_query_report": 0, + "label": "Asset Capitalization", + "link_count": 0, + "link_to": "Asset Capitalization", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, { "hidden": 0, "is_query_report": 0, @@ -172,7 +183,7 @@ "type": "Link" } ], - "modified": "2022-01-13 17:25:41.730628", + "modified": "2022-01-13 18:25:41.730628", "modified_by": "Administrator", "module": "Assets", "name": "Assets", @@ -205,4 +216,4 @@ } ], "title": "Assets" -} \ No newline at end of file +} diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 6f321f47661..938de63f33a 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -38,6 +38,7 @@ from erpnext.accounts.party import ( validate_party_frozen_disabled, ) from erpnext.accounts.utils import get_account_currency, get_fiscal_years, validate_fiscal_year +from erpnext.assets.doctype.asset.depreciation import make_depreciation_entry from erpnext.buying.utils import update_last_purchase_rate from erpnext.controllers.print_settings import ( set_print_templates_for_item_table, @@ -205,6 +206,10 @@ class AccountsController(TransactionBase): def on_trash(self): # delete sl and gl entries on deletion of transaction if frappe.db.get_single_value("Accounts Settings", "delete_linked_ledger_entries"): + ple = frappe.qb.DocType("Payment Ledger Entry") + frappe.qb.from_(ple).delete().where( + (ple.voucher_type == self.doctype) & (ple.voucher_no == self.name) + ).run() frappe.db.sql( "delete from `tabGL Entry` where voucher_type=%s and voucher_no=%s", (self.doctype, self.name) ) @@ -1870,6 +1875,99 @@ class AccountsController(TransactionBase): ): throw(_("Conversion rate cannot be 0 or 1")) + def check_finance_books(self, item, asset): + if ( + len(asset.finance_books) > 1 + and not item.get("finance_book") + and not self.get("finance_book") + and asset.finance_books[0].finance_book + ): + frappe.throw( + _("Select finance book for the item {0} at row {1}").format(item.item_code, item.idx) + ) + + def depreciate_asset(self, asset): + asset.flags.ignore_validate_update_after_submit = True + asset.prepare_depreciation_data(date_of_disposal=self.posting_date) + asset.save() + + make_depreciation_entry(asset.name, self.posting_date) + + def reset_depreciation_schedule(self, asset): + asset.flags.ignore_validate_update_after_submit = True + + # recreate original depreciation schedule of the asset + asset.prepare_depreciation_data(date_of_return=self.posting_date) + + self.modify_depreciation_schedule_for_asset_repairs(asset) + asset.save() + + def modify_depreciation_schedule_for_asset_repairs(self, asset): + asset_repairs = frappe.get_all( + "Asset Repair", filters={"asset": asset.name}, fields=["name", "increase_in_asset_life"] + ) + + for repair in asset_repairs: + if repair.increase_in_asset_life: + asset_repair = frappe.get_doc("Asset Repair", repair.name) + asset_repair.modify_depreciation_schedule() + asset.prepare_depreciation_data() + + def reverse_depreciation_entry_made_after_disposal(self, asset): + from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry + + posting_date_of_original_disposal = self.get_posting_date_of_disposal_entry() + + row = -1 + finance_book = asset.get("schedules")[0].get("finance_book") + for schedule in asset.get("schedules"): + if schedule.finance_book != finance_book: + row = 0 + finance_book = schedule.finance_book + else: + row += 1 + + if schedule.schedule_date == posting_date_of_original_disposal: + if not self.disposal_was_made_on_original_schedule_date( + asset, schedule, row, posting_date_of_original_disposal + ) or self.disposal_happens_in_the_future(posting_date_of_original_disposal): + + reverse_journal_entry = make_reverse_journal_entry(schedule.journal_entry) + reverse_journal_entry.posting_date = nowdate() + frappe.flags.is_reverse_depr_entry = True + reverse_journal_entry.submit() + + frappe.flags.is_reverse_depr_entry = False + asset.flags.ignore_validate_update_after_submit = True + schedule.journal_entry = None + asset.save() + + def get_posting_date_of_disposal_entry(self): + if self.doctype == "Sales Invoice" and self.return_against: + return frappe.db.get_value("Sales Invoice", self.return_against, "posting_date") + else: + return self.posting_date + + # if the invoice had been posted on the date the depreciation was initially supposed to happen, the depreciation shouldn't be undone + def disposal_was_made_on_original_schedule_date( + self, asset, schedule, row, posting_date_of_disposal + ): + for finance_book in asset.get("finance_books"): + if schedule.finance_book == finance_book.finance_book: + orginal_schedule_date = add_months( + finance_book.depreciation_start_date, row * cint(finance_book.frequency_of_depreciation) + ) + + if orginal_schedule_date == posting_date_of_disposal: + return True + return False + + def disposal_happens_in_the_future(self, posting_date_of_disposal): + if posting_date_of_disposal > getdate(): + return True + + return False + @frappe.whitelist() def get_tax_rate(account_head): diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index dd1b1ab2c4d..5659ad0aa9b 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -314,7 +314,11 @@ class BuyingController(SubcontractingController): rate = flt(outgoing_rate * (d.conversion_factor or 1), d.precision("rate")) else: field = "incoming_rate" if self.get("is_internal_supplier") else "rate" - rate = frappe.db.get_value(ref_doctype, d.get(frappe.scrub(ref_doctype)), field) + rate = flt( + frappe.db.get_value(ref_doctype, d.get(frappe.scrub(ref_doctype)), field) + * (d.conversion_factor or 1), + d.precision("rate"), + ) if self.is_internal_transfer(): if rate != d.rate: diff --git a/erpnext/controllers/employee_boarding_controller.py b/erpnext/controllers/employee_boarding_controller.py deleted file mode 100644 index c06fb5930dc..00000000000 --- a/erpnext/controllers/employee_boarding_controller.py +++ /dev/null @@ -1,193 +0,0 @@ -# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -import frappe -from frappe import _ -from frappe.desk.form import assign_to -from frappe.model.document import Document -from frappe.utils import add_days, flt, unique - -from erpnext.setup.doctype.employee.employee import get_holiday_list_for_employee -from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday - - -class EmployeeBoardingController(Document): - """ - Create the project and the task for the boarding process - Assign to the concerned person and roles as per the onboarding/separation template - """ - - def validate(self): - # remove the task if linked before submitting the form - if self.amended_from: - for activity in self.activities: - activity.task = "" - - def on_submit(self): - # create the project for the given employee onboarding - project_name = _(self.doctype) + " : " - if self.doctype == "Employee Onboarding": - project_name += self.job_applicant - else: - project_name += self.employee - - project = frappe.get_doc( - { - "doctype": "Project", - "project_name": project_name, - "expected_start_date": self.date_of_joining - if self.doctype == "Employee Onboarding" - else self.resignation_letter_date, - "department": self.department, - "company": self.company, - } - ).insert(ignore_permissions=True, ignore_mandatory=True) - - self.db_set("project", project.name) - self.db_set("boarding_status", "Pending") - self.reload() - self.create_task_and_notify_user() - - def create_task_and_notify_user(self): - # create the task for the given project and assign to the concerned person - holiday_list = self.get_holiday_list() - - for activity in self.activities: - if activity.task: - continue - - dates = self.get_task_dates(activity, holiday_list) - - task = frappe.get_doc( - { - "doctype": "Task", - "project": self.project, - "subject": activity.activity_name + " : " + self.employee_name, - "description": activity.description, - "department": self.department, - "company": self.company, - "task_weight": activity.task_weight, - "exp_start_date": dates[0], - "exp_end_date": dates[1], - } - ).insert(ignore_permissions=True) - activity.db_set("task", task.name) - - users = [activity.user] if activity.user else [] - if activity.role: - user_list = frappe.db.sql_list( - """ - SELECT - DISTINCT(has_role.parent) - FROM - `tabHas Role` has_role - LEFT JOIN `tabUser` user - ON has_role.parent = user.name - WHERE - has_role.parenttype = 'User' - AND user.enabled = 1 - AND has_role.role = %s - """, - activity.role, - ) - users = unique(users + user_list) - - if "Administrator" in users: - users.remove("Administrator") - - # assign the task the users - if users: - self.assign_task_to_users(task, users) - - def get_holiday_list(self): - if self.doctype == "Employee Separation": - return get_holiday_list_for_employee(self.employee) - else: - if self.employee: - return get_holiday_list_for_employee(self.employee) - else: - if not self.holiday_list: - frappe.throw(_("Please set the Holiday List."), frappe.MandatoryError) - else: - return self.holiday_list - - def get_task_dates(self, activity, holiday_list): - start_date = end_date = None - - if activity.begin_on is not None: - start_date = add_days(self.boarding_begins_on, activity.begin_on) - start_date = self.update_if_holiday(start_date, holiday_list) - - if activity.duration is not None: - end_date = add_days(self.boarding_begins_on, activity.begin_on + activity.duration) - end_date = self.update_if_holiday(end_date, holiday_list) - - return [start_date, end_date] - - def update_if_holiday(self, date, holiday_list): - while is_holiday(holiday_list, date): - date = add_days(date, 1) - return date - - def assign_task_to_users(self, task, users): - for user in users: - args = { - "assign_to": [user], - "doctype": task.doctype, - "name": task.name, - "description": task.description or task.subject, - "notify": self.notify_users_by_email, - } - assign_to.add(args) - - def on_cancel(self): - # delete task project - project = self.project - for task in frappe.get_all("Task", filters={"project": project}): - frappe.delete_doc("Task", task.name, force=1) - frappe.delete_doc("Project", project, force=1) - self.db_set("project", "") - for activity in self.activities: - activity.db_set("task", "") - - frappe.msgprint( - _("Linked Project {} and Tasks deleted.").format(project), alert=True, indicator="blue" - ) - - -@frappe.whitelist() -def get_onboarding_details(parent, parenttype): - return frappe.get_all( - "Employee Boarding Activity", - fields=[ - "activity_name", - "role", - "user", - "required_for_employee_creation", - "description", - "task_weight", - "begin_on", - "duration", - ], - filters={"parent": parent, "parenttype": parenttype}, - order_by="idx", - ) - - -def update_employee_boarding_status(project): - employee_onboarding = frappe.db.exists("Employee Onboarding", {"project": project.name}) - employee_separation = frappe.db.exists("Employee Separation", {"project": project.name}) - - if not (employee_onboarding or employee_separation): - return - - status = "Pending" - if flt(project.percent_complete) > 0.0 and flt(project.percent_complete) < 100.0: - status = "In Process" - elif flt(project.percent_complete) == 100.0: - status = "Completed" - - if employee_onboarding: - frappe.db.set_value("Employee Onboarding", employee_onboarding, "boarding_status", status) - elif employee_separation: - frappe.db.set_value("Employee Separation", employee_separation, "boarding_status", status) diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 21504774f64..bbd950ed37a 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -877,6 +877,7 @@ def make_return_stock_entry_for_subcontract( { order_doctype: { "doctype": "Stock Entry", + "field_no_map": ["purchase_order", "subcontracting_order"], }, }, ignore_child_tables=True, diff --git a/erpnext/e_commerce/doctype/website_item/website_item.json b/erpnext/e_commerce/doctype/website_item/website_item.json index a416aac3a17..c5775ee9075 100644 --- a/erpnext/e_commerce/doctype/website_item/website_item.json +++ b/erpnext/e_commerce/doctype/website_item/website_item.json @@ -345,7 +345,8 @@ "image_field": "website_image", "index_web_pages_for_search": 1, "links": [], - "modified": "2022-06-28 17:10:30.613251", + "make_attachments_public": 1, + "modified": "2022-09-13 04:05:11.614087", "modified_by": "Administrator", "module": "E-commerce", "name": "Website Item", diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py index 018832c7d79..e9fa2adfdd9 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -519,6 +519,8 @@ def get_accrued_interest_entries(against_loan, posting_date=None): if not posting_date: posting_date = getdate() + precision = cint(frappe.db.get_default("currency_precision")) or 2 + unpaid_accrued_entries = frappe.db.sql( """ SELECT name, posting_date, interest_amount - paid_interest_amount as interest_amount, @@ -539,6 +541,13 @@ def get_accrued_interest_entries(against_loan, posting_date=None): as_dict=1, ) + # Skip entries with zero interest amount & payable principal amount + unpaid_accrued_entries = [ + d + for d in unpaid_accrued_entries + if flt(d.interest_amount, precision) > 0 or flt(d.payable_principal_amount, precision) > 0 + ] + return unpaid_accrued_entries diff --git a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.js b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.js index 0d5bfcbaf40..a0fd91e866f 100644 --- a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.js +++ b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.js @@ -11,17 +11,24 @@ frappe.query_reports["BOM Stock Calculated"] = { "options": "BOM", "reqd": 1 }, - { - "fieldname": "qty_to_make", - "label": __("Quantity to Make"), - "fieldtype": "Int", - "default": "1" - }, - - { + { + "fieldname": "warehouse", + "label": __("Warehouse"), + "fieldtype": "Link", + "options": "Warehouse", + }, + { + "fieldname": "qty_to_make", + "label": __("Quantity to Make"), + "fieldtype": "Float", + "default": "1.0", + "reqd": 1 + }, + { "fieldname": "show_exploded_view", "label": __("Show exploded view"), - "fieldtype": "Check" + "fieldtype": "Check", + "default": false, } ] } diff --git a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py index 933be3e0140..ec4b25c859f 100644 --- a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py +++ b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py @@ -4,29 +4,31 @@ import frappe from frappe import _ +from frappe.query_builder.functions import IfNull, Sum from frappe.utils.data import comma_and +from pypika.terms import ExistsCriterion def execute(filters=None): - # if not filters: filters = {} columns = get_columns() - summ_data = [] + data = [] - data = get_bom_stock(filters) + bom_data = get_bom_data(filters) qty_to_make = filters.get("qty_to_make") - manufacture_details = get_manufacturer_records() - for row in data: - reqd_qty = qty_to_make * row.actual_qty - last_pur_price = frappe.db.get_value("Item", row.item_code, "last_purchase_rate") - summ_data.append(get_report_data(last_pur_price, reqd_qty, row, manufacture_details)) - return columns, summ_data + for row in bom_data: + required_qty = qty_to_make * row.qty_per_unit + last_purchase_rate = frappe.db.get_value("Item", row.item_code, "last_purchase_rate") + + data.append(get_report_data(last_purchase_rate, required_qty, row, manufacture_details)) + + return columns, data -def get_report_data(last_pur_price, reqd_qty, row, manufacture_details): - to_build = row.to_build if row.to_build > 0 else 0 - diff_qty = to_build - reqd_qty +def get_report_data(last_purchase_rate, required_qty, row, manufacture_details): + qty_per_unit = row.qty_per_unit if row.qty_per_unit > 0 else 0 + difference_qty = row.actual_qty - required_qty return [ row.item_code, row.description, @@ -34,85 +36,126 @@ def get_report_data(last_pur_price, reqd_qty, row, manufacture_details): comma_and( manufacture_details.get(row.item_code, {}).get("manufacturer_part", []), add_quotes=False ), + qty_per_unit, row.actual_qty, - str(to_build), - reqd_qty, - diff_qty, - last_pur_price, + required_qty, + difference_qty, + last_purchase_rate, ] def get_columns(): - """return columns""" - columns = [ - _("Item") + ":Link/Item:100", - _("Description") + "::150", - _("Manufacturer") + "::250", - _("Manufacturer Part Number") + "::250", - _("Qty") + ":Float:50", - _("Stock Qty") + ":Float:100", - _("Reqd Qty") + ":Float:100", - _("Diff Qty") + ":Float:100", - _("Last Purchase Price") + ":Float:100", + return [ + { + "fieldname": "item", + "label": _("Item"), + "fieldtype": "Link", + "options": "Item", + "width": 120, + }, + { + "fieldname": "description", + "label": _("Description"), + "fieldtype": "Data", + "width": 150, + }, + { + "fieldname": "manufacturer", + "label": _("Manufacturer"), + "fieldtype": "Data", + "width": 120, + }, + { + "fieldname": "manufacturer_part_number", + "label": _("Manufacturer Part Number"), + "fieldtype": "Data", + "width": 150, + }, + { + "fieldname": "qty_per_unit", + "label": _("Qty Per Unit"), + "fieldtype": "Float", + "width": 110, + }, + { + "fieldname": "available_qty", + "label": _("Available Qty"), + "fieldtype": "Float", + "width": 120, + }, + { + "fieldname": "required_qty", + "label": _("Required Qty"), + "fieldtype": "Float", + "width": 120, + }, + { + "fieldname": "difference_qty", + "label": _("Difference Qty"), + "fieldtype": "Float", + "width": 130, + }, + { + "fieldname": "last_purchase_rate", + "label": _("Last Purchase Rate"), + "fieldtype": "Float", + "width": 160, + }, ] - return columns -def get_bom_stock(filters): - conditions = "" - bom = filters.get("bom") - - table = "`tabBOM Item`" - qty_field = "qty" - +def get_bom_data(filters): if filters.get("show_exploded_view"): - table = "`tabBOM Explosion Item`" - qty_field = "stock_qty" + bom_item_table = "BOM Explosion Item" + else: + bom_item_table = "BOM Item" + + bom_item = frappe.qb.DocType(bom_item_table) + bin = frappe.qb.DocType("Bin") + + query = ( + frappe.qb.from_(bom_item) + .left_join(bin) + .on(bom_item.item_code == bin.item_code) + .select( + bom_item.item_code, + bom_item.description, + bom_item.qty_consumed_per_unit.as_("qty_per_unit"), + IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"), + ) + .where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM")) + .groupby(bom_item.item_code) + ) if filters.get("warehouse"): warehouse_details = frappe.db.get_value( "Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1 ) + if warehouse_details: - conditions += ( - " and exists (select name from `tabWarehouse` wh \ - where wh.lft >= %s and wh.rgt <= %s and ledger.warehouse = wh.name)" - % (warehouse_details.lft, warehouse_details.rgt) + wh = frappe.qb.DocType("Warehouse") + query = query.where( + ExistsCriterion( + frappe.qb.from_(wh) + .select(wh.name) + .where( + (wh.lft >= warehouse_details.lft) + & (wh.rgt <= warehouse_details.rgt) + & (bin.warehouse == wh.name) + ) + ) ) else: - conditions += " and ledger.warehouse = %s" % frappe.db.escape(filters.get("warehouse")) + query = query.where(bin.warehouse == frappe.db.escape(filters.get("warehouse"))) - else: - conditions += "" - - return frappe.db.sql( - """ - SELECT - bom_item.item_code, - bom_item.description, - bom_item.{qty_field}, - ifnull(sum(ledger.actual_qty), 0) as actual_qty, - ifnull(sum(FLOOR(ledger.actual_qty / bom_item.{qty_field})), 0) as to_build - FROM - {table} AS bom_item - LEFT JOIN `tabBin` AS ledger - ON bom_item.item_code = ledger.item_code - {conditions} - - WHERE - bom_item.parent = '{bom}' and bom_item.parenttype='BOM' - - GROUP BY bom_item.item_code""".format( - qty_field=qty_field, table=table, conditions=conditions, bom=bom - ), - as_dict=1, - ) + return query.run(as_dict=True) def get_manufacturer_records(): details = frappe.get_all( "Item Manufacturer", fields=["manufacturer", "manufacturer_part_no", "item_code"] ) + manufacture_details = frappe._dict() for detail in details: dic = manufacture_details.setdefault(detail.get("item_code"), {}) diff --git a/erpnext/manufacturing/report/bom_stock_calculated/test_bom_stock_calculated.py b/erpnext/manufacturing/report/bom_stock_calculated/test_bom_stock_calculated.py new file mode 100644 index 00000000000..8ad980fa19a --- /dev/null +++ b/erpnext/manufacturing/report/bom_stock_calculated/test_bom_stock_calculated.py @@ -0,0 +1,115 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from frappe.tests.utils import FrappeTestCase + +from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom +from erpnext.manufacturing.report.bom_stock_calculated.bom_stock_calculated import ( + execute as bom_stock_calculated_report, +) +from erpnext.stock.doctype.item.test_item import make_item + + +class TestBOMStockCalculated(FrappeTestCase): + def setUp(self): + self.fg_item, self.rm_items = create_items() + self.boms = create_boms(self.fg_item, self.rm_items) + + def test_bom_stock_calculated(self): + qty_to_make = 10 + + # Case 1: When Item(s) Qty and Stock Qty are equal. + data = bom_stock_calculated_report( + filters={ + "qty_to_make": qty_to_make, + "bom": self.boms[0].name, + } + )[1] + expected_data = get_expected_data(self.boms[0], qty_to_make) + self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) + + # Case 2: When Item(s) Qty and Stock Qty are different and BOM Qty is 1. + data = bom_stock_calculated_report( + filters={ + "qty_to_make": qty_to_make, + "bom": self.boms[1].name, + } + )[1] + expected_data = get_expected_data(self.boms[1], qty_to_make) + self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) + + # Case 3: When Item(s) Qty and Stock Qty are different and BOM Qty is greater than 1. + data = bom_stock_calculated_report( + filters={ + "qty_to_make": qty_to_make, + "bom": self.boms[2].name, + } + )[1] + expected_data = get_expected_data(self.boms[2], qty_to_make) + self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) + + +def create_items(): + fg_item = make_item(properties={"is_stock_item": 1}).name + rm_item1 = make_item( + properties={ + "is_stock_item": 1, + "standard_rate": 100, + "opening_stock": 100, + "last_purchase_rate": 100, + } + ).name + rm_item2 = make_item( + properties={ + "is_stock_item": 1, + "standard_rate": 200, + "opening_stock": 200, + "last_purchase_rate": 200, + } + ).name + + return fg_item, [rm_item1, rm_item2] + + +def create_boms(fg_item, rm_items): + def update_bom_items(bom, uom, conversion_factor): + for item in bom.items: + item.uom = uom + item.conversion_factor = conversion_factor + + return bom + + bom1 = make_bom(item=fg_item, quantity=1, raw_materials=rm_items, rm_qty=10) + + bom2 = make_bom(item=fg_item, quantity=1, raw_materials=rm_items, rm_qty=10, do_not_submit=True) + bom2 = update_bom_items(bom2, "Box", 10) + bom2.save() + bom2.submit() + + bom3 = make_bom(item=fg_item, quantity=2, raw_materials=rm_items, rm_qty=10, do_not_submit=True) + bom3 = update_bom_items(bom3, "Box", 10) + bom3.save() + bom3.submit() + + return [bom1, bom2, bom3] + + +def get_expected_data(bom, qty_to_make): + expected_data = [] + + for idx in range(len(bom.items)): + expected_data.append( + [ + bom.items[idx].item_code, + bom.items[idx].item_code, + "", + "", + float(bom.items[idx].stock_qty / bom.quantity), + float(100 * (idx + 1)), + float(qty_to_make * (bom.items[idx].stock_qty / bom.quantity)), + float((100 * (idx + 1)) - (qty_to_make * (bom.items[idx].stock_qty / bom.quantity))), + float(100 * (idx + 1)), + ] + ) + + return expected_data diff --git a/erpnext/manufacturing/report/process_loss_report/process_loss_report.py b/erpnext/manufacturing/report/process_loss_report/process_loss_report.py index b10e6434223..ce8f4f35a3f 100644 --- a/erpnext/manufacturing/report/process_loss_report/process_loss_report.py +++ b/erpnext/manufacturing/report/process_loss_report/process_loss_report.py @@ -5,6 +5,7 @@ from typing import Dict, List, Tuple import frappe from frappe import _ +from frappe.query_builder.functions import Sum Filters = frappe._dict Row = frappe._dict @@ -14,15 +15,50 @@ QueryArgs = Dict[str, str] def execute(filters: Filters) -> Tuple[Columns, Data]: + filters = frappe._dict(filters or {}) columns = get_columns() data = get_data(filters) return columns, data def get_data(filters: Filters) -> Data: - query_args = get_query_args(filters) - data = run_query(query_args) + wo = frappe.qb.DocType("Work Order") + se = frappe.qb.DocType("Stock Entry") + + query = ( + frappe.qb.from_(wo) + .inner_join(se) + .on(wo.name == se.work_order) + .select( + wo.name, + wo.status, + wo.production_item, + wo.qty, + wo.produced_qty, + wo.process_loss_qty, + (wo.produced_qty - wo.process_loss_qty).as_("actual_produced_qty"), + Sum(se.total_incoming_value).as_("total_fg_value"), + Sum(se.total_outgoing_value).as_("total_rm_value"), + ) + .where( + (wo.process_loss_qty > 0) + & (wo.company == filters.company) + & (se.docstatus == 1) + & (se.posting_date.between(filters.from_date, filters.to_date)) + ) + .groupby(se.work_order) + ) + + if "item" in filters: + query.where(wo.production_item == filters.item) + + if "work_order" in filters: + query.where(wo.name == filters.work_order) + + data = query.run(as_dict=True) + update_data_with_total_pl_value(data) + return data @@ -67,54 +103,7 @@ def get_columns() -> Columns: ] -def get_query_args(filters: Filters) -> QueryArgs: - query_args = {} - query_args.update(filters) - query_args.update(get_filter_conditions(filters)) - return query_args - - -def run_query(query_args: QueryArgs) -> Data: - return frappe.db.sql( - """ - SELECT - wo.name, wo.status, wo.production_item, wo.qty, - wo.produced_qty, wo.process_loss_qty, - (wo.produced_qty - wo.process_loss_qty) as actual_produced_qty, - sum(se.total_incoming_value) as total_fg_value, - sum(se.total_outgoing_value) as total_rm_value - FROM - `tabWork Order` wo INNER JOIN `tabStock Entry` se - ON wo.name=se.work_order - WHERE - process_loss_qty > 0 - AND wo.company = %(company)s - AND se.docstatus = 1 - AND se.posting_date BETWEEN %(from_date)s AND %(to_date)s - {item_filter} - {work_order_filter} - GROUP BY - se.work_order - """.format( - **query_args - ), - query_args, - as_dict=1, - ) - - def update_data_with_total_pl_value(data: Data) -> None: for row in data: value_per_unit_fg = row["total_fg_value"] / row["actual_produced_qty"] row["total_pl_value"] = row["process_loss_qty"] * value_per_unit_fg - - -def get_filter_conditions(filters: Filters) -> QueryArgs: - filter_conditions = dict(item_filter="", work_order_filter="") - if "item" in filters: - production_item = filters.get("item") - filter_conditions.update({"item_filter": f"AND wo.production_item='{production_item}'"}) - if "work_order" in filters: - work_order_name = filters.get("work_order") - filter_conditions.update({"work_order_filter": f"AND wo.name='{work_order_name}'"}) - return filter_conditions diff --git a/erpnext/manufacturing/report/work_order_stock_report/work_order_stock_report.py b/erpnext/manufacturing/report/work_order_stock_report/work_order_stock_report.py index 063ebba0597..998b0e4bccd 100644 --- a/erpnext/manufacturing/report/work_order_stock_report/work_order_stock_report.py +++ b/erpnext/manufacturing/report/work_order_stock_report/work_order_stock_report.py @@ -4,6 +4,7 @@ import frappe from frappe import _ +from frappe.query_builder.functions import IfNull from frappe.utils import cint @@ -17,70 +18,70 @@ def execute(filters=None): def get_item_list(wo_list, filters): out = [] - # Add a row for each item/qty - for wo_details in wo_list: - desc = frappe.db.get_value("BOM", wo_details.bom_no, "description") + if wo_list: + bin = frappe.qb.DocType("Bin") + bom = frappe.qb.DocType("BOM") + bom_item = frappe.qb.DocType("BOM Item") - for wo_item_details in frappe.db.get_values( - "Work Order Item", {"parent": wo_details.name}, ["item_code", "source_warehouse"], as_dict=1 - ): + # Add a row for each item/qty + for wo_details in wo_list: + desc = frappe.db.get_value("BOM", wo_details.bom_no, "description") - item_list = frappe.db.sql( - """SELECT - bom_item.item_code as item_code, - ifnull(ledger.actual_qty*bom.quantity/bom_item.stock_qty,0) as build_qty - FROM - `tabBOM` as bom, `tabBOM Item` AS bom_item - LEFT JOIN `tabBin` AS ledger - ON bom_item.item_code = ledger.item_code - AND ledger.warehouse = ifnull(%(warehouse)s,%(filterhouse)s) - WHERE - bom.name = bom_item.parent - and bom_item.item_code = %(item_code)s - and bom.name = %(bom)s - GROUP BY - bom_item.item_code""", - { - "bom": wo_details.bom_no, - "warehouse": wo_item_details.source_warehouse, - "filterhouse": filters.warehouse, - "item_code": wo_item_details.item_code, - }, - as_dict=1, - ) + for wo_item_details in frappe.db.get_values( + "Work Order Item", {"parent": wo_details.name}, ["item_code", "source_warehouse"], as_dict=1 + ): + item_list = ( + frappe.qb.from_(bom) + .from_(bom_item) + .left_join(bin) + .on( + (bom_item.item_code == bin.item_code) + & (bin.warehouse == IfNull(wo_item_details.source_warehouse, filters.warehouse)) + ) + .select( + bom_item.item_code.as_("item_code"), + IfNull(bin.actual_qty * bom.quantity / bom_item.stock_qty, 0).as_("build_qty"), + ) + .where( + (bom.name == bom_item.parent) + & (bom_item.item_code == wo_item_details.item_code) + & (bom.name == wo_details.bom_no) + ) + .groupby(bom_item.item_code) + ).run(as_dict=1) - stock_qty = 0 - count = 0 - buildable_qty = wo_details.qty - for item in item_list: - count = count + 1 - if item.build_qty >= (wo_details.qty - wo_details.produced_qty): - stock_qty = stock_qty + 1 - elif buildable_qty >= item.build_qty: - buildable_qty = item.build_qty + stock_qty = 0 + count = 0 + buildable_qty = wo_details.qty + for item in item_list: + count = count + 1 + if item.build_qty >= (wo_details.qty - wo_details.produced_qty): + stock_qty = stock_qty + 1 + elif buildable_qty >= item.build_qty: + buildable_qty = item.build_qty - if count == stock_qty: - build = "Y" - else: - build = "N" + if count == stock_qty: + build = "Y" + else: + build = "N" - row = frappe._dict( - { - "work_order": wo_details.name, - "status": wo_details.status, - "req_items": cint(count), - "instock": stock_qty, - "description": desc, - "source_warehouse": wo_item_details.source_warehouse, - "item_code": wo_item_details.item_code, - "bom_no": wo_details.bom_no, - "qty": wo_details.qty, - "buildable_qty": buildable_qty, - "ready_to_build": build, - } - ) + row = frappe._dict( + { + "work_order": wo_details.name, + "status": wo_details.status, + "req_items": cint(count), + "instock": stock_qty, + "description": desc, + "source_warehouse": wo_item_details.source_warehouse, + "item_code": wo_item_details.item_code, + "bom_no": wo_details.bom_no, + "qty": wo_details.qty, + "buildable_qty": buildable_qty, + "ready_to_build": build, + } + ) - out.append(row) + out.append(row) return out diff --git a/erpnext/patches.txt b/erpnext/patches.txt index d780213209c..2a0ca8c4961 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -307,6 +307,7 @@ erpnext.patches.v13_0.job_card_status_on_hold erpnext.patches.v14_0.copy_is_subcontracted_value_to_is_old_subcontracting_flow erpnext.patches.v14_0.migrate_gl_to_payment_ledger erpnext.patches.v14_0.crm_ux_cleanup +erpnext.patches.v14_0.migrate_existing_lead_notes_as_per_the_new_format erpnext.patches.v14_0.remove_india_localisation # 14-07-2022 erpnext.patches.v13_0.fix_number_and_frequency_for_monthly_depreciation erpnext.patches.v14_0.remove_hr_and_payroll_modules # 20-07-2022 diff --git a/erpnext/patches/v14_0/migrate_existing_lead_notes_as_per_the_new_format.py b/erpnext/patches/v14_0/migrate_existing_lead_notes_as_per_the_new_format.py new file mode 100644 index 00000000000..ec72527552c --- /dev/null +++ b/erpnext/patches/v14_0/migrate_existing_lead_notes_as_per_the_new_format.py @@ -0,0 +1,23 @@ +import frappe +from frappe.utils import cstr, strip_html + + +def execute(): + for doctype in ("Lead", "Prospect", "Opportunity"): + if not frappe.db.has_column(doctype, "notes"): + continue + + dt = frappe.qb.DocType(doctype) + records = ( + frappe.qb.from_(dt) + .select(dt.name, dt.notes, dt.modified_by, dt.modified) + .where(dt.notes.isnotnull() & dt.notes != "") + ).run(as_dict=True) + + for d in records: + if strip_html(cstr(d.notes)).strip(): + doc = frappe.get_doc(doctype, d.name) + doc.append("notes", {"note": d.notes, "added_by": d.modified_by, "added_on": d.modified}) + doc.update_child_table("notes") + + frappe.db.sql_ddl(f"alter table `tab{doctype}` drop column `notes`") diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index a2be9367688..d80133c988a 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -10,7 +10,6 @@ from frappe.model.document import Document from frappe.utils import add_days, flt, get_datetime, get_time, get_url, nowtime, today from erpnext import get_default_company -from erpnext.controllers.employee_boarding_controller import update_employee_boarding_status from erpnext.controllers.queries import get_filters_cond from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday @@ -43,7 +42,6 @@ class Project(Document): self.send_welcome_email() self.update_costing() self.update_percent_complete() - update_employee_boarding_status(self) def copy_from_template(self): """ @@ -145,7 +143,6 @@ class Project(Document): def update_project(self): """Called externally by Task""" self.update_percent_complete() - update_employee_boarding_status(self) self.update_costing() self.db_update() diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index dc698886a01..490504a7c9d 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -389,6 +389,7 @@ class Company(NestedSet): "capital_work_in_progress_account": "Capital Work in Progress", "asset_received_but_not_billed": "Asset Received But Not Billed", "expenses_included_in_asset_valuation": "Expenses Included In Asset Valuation", + "default_expense_account": "Cost of Goods Sold", } if self.enable_perpetual_inventory: @@ -398,7 +399,6 @@ class Company(NestedSet): "default_inventory_account": "Stock", "stock_adjustment_account": "Stock Adjustment", "expenses_included_in_valuation": "Expenses Included In Valuation", - "default_expense_account": "Cost of Goods Sold", } ) diff --git a/erpnext/setup/doctype/employee/employee.json b/erpnext/setup/doctype/employee/employee.json index 39e0acd02aa..99693d90918 100644 --- a/erpnext/setup/doctype/employee/employee.json +++ b/erpnext/setup/doctype/employee/employee.json @@ -396,7 +396,7 @@ "collapsible": 1, "fieldname": "salary_information", "fieldtype": "Tab Break", - "label": "Salary Details", + "label": "Salary", "oldfieldtype": "Section Break", "width": "50%" }, @@ -428,7 +428,7 @@ "collapsible": 1, "fieldname": "contact_details", "fieldtype": "Tab Break", - "label": "Contact" + "label": "Address & Contacts" }, { "fieldname": "cell_number", @@ -507,7 +507,7 @@ "collapsible": 1, "fieldname": "personal_details", "fieldtype": "Tab Break", - "label": "Personal Details" + "label": "Personal" }, { "fieldname": "passport_number", @@ -701,7 +701,7 @@ "collapsible": 1, "fieldname": "attendance_and_leave_details", "fieldtype": "Tab Break", - "label": "Attendance and Leave Details" + "label": "Attendance & Leaves" }, { "fieldname": "column_break_44", @@ -726,7 +726,7 @@ { "fieldname": "basic_details_tab", "fieldtype": "Tab Break", - "label": "Basic Details" + "label": "Overview" }, { "fieldname": "company_details_section", @@ -810,7 +810,7 @@ "idx": 24, "image_field": "image", "links": [], - "modified": "2022-08-23 13:47:46.944993", + "modified": "2022-09-13 10:27:14.579197", "modified_by": "Administrator", "module": "Setup", "name": "Employee", diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 76cb31dc42e..d1d228dfdc6 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -795,7 +795,7 @@ }, { "fieldname": "customer_code", - "fieldtype": "Data", + "fieldtype": "Small Text", "hidden": 1, "label": "Customer Code", "no_copy": 1, @@ -910,7 +910,8 @@ "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2022-06-15 09:02:06.177691", + "make_attachments_public": 1, + "modified": "2022-09-13 04:08:17.431731", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 87fa72d74f0..143fe408c34 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -945,7 +945,12 @@ class Item(Document): if doctype == "Product Bundle": filters = {"new_item_code": self.name} - if doctype in ( + if linked_doc := frappe.db.get_value( + doctype, filters, ["new_item_code as docname"], as_dict=True + ): + return linked_doc.update({"doctype": doctype}) + + elif doctype in ( "Purchase Invoice Item", "Sales Invoice Item", ): diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 3366c737cbd..1cee553be5b 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -778,6 +778,44 @@ class TestItem(FrappeTestCase): item.has_batch_no = 1 item.save() + def test_customer_codes_length(self): + """Check if item code with special characters are allowed.""" + item = make_item(properties={"item_code": "Test Item Code With Special Characters"}) + for row in range(3): + item.append("customer_items", {"ref_code": frappe.generate_hash("", 120)}) + item.save() + self.assertTrue(len(item.customer_code) > 140) + + def test_update_is_stock_item(self): + # Step - 1: Create an Item with Maintain Stock enabled + item = make_item(properties={"is_stock_item": 1}) + + # Step - 2: Disable Maintain Stock + item.is_stock_item = 0 + item.save() + item.reload() + self.assertEqual(item.is_stock_item, 0) + + # Step - 3: Create Product Bundle + pb = frappe.new_doc("Product Bundle") + pb.new_item_code = item.name + pb.flags.ignore_mandatory = True + pb.save() + + # Step - 4: Try to enable Maintain Stock, should throw a validation error + item.is_stock_item = 1 + self.assertRaises(frappe.ValidationError, item.save) + item.reload() + + # Step - 5: Delete Product Bundle + pb.delete() + + # Step - 6: Again try to enable Maintain Stock + item.is_stock_item = 1 + item.save() + item.reload() + self.assertEqual(item.is_stock_item, 1) + def set_item_variant_settings(fields): doc = frappe.get_doc("Item Variant Settings") diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index d31d695c803..adddb413824 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -183,7 +183,7 @@ class PickList(Document): frappe.throw("Row #{0}: Item Code is Mandatory".format(item.idx)) item_code = item.item_code reference = item.sales_order_item or item.material_request_item - key = (item_code, item.uom, reference) + key = (item_code, item.uom, item.warehouse, reference) item.idx = None item.name = None diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 51d914dc62c..f85c478a72f 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -362,6 +362,12 @@ class PurchaseReceipt(BuyingController): if credit_currency == self.company_currency else flt(d.net_amount, d.precision("net_amount")) ) + + outgoing_amount = d.base_net_amount + if self.is_internal_supplier and d.valuation_rate: + outgoing_amount = d.valuation_rate * d.stock_qty + credit_amount = outgoing_amount + if credit_amount: account = warehouse_account[d.from_warehouse]["account"] if d.from_warehouse else stock_rbnb @@ -369,7 +375,7 @@ class PurchaseReceipt(BuyingController): gl_entries=gl_entries, account=account, cost_center=d.cost_center, - debit=-1 * flt(d.base_net_amount, d.precision("base_net_amount")), + debit=-1 * flt(outgoing_amount, d.precision("base_net_amount")), credit=0.0, remarks=remarks, against_account=warehouse_account_name, @@ -456,7 +462,7 @@ class PurchaseReceipt(BuyingController): # divisional loss adjustment valuation_amount_as_per_doc = ( - flt(d.base_net_amount, d.precision("base_net_amount")) + flt(outgoing_amount, d.precision("base_net_amount")) + flt(d.landed_cost_voucher_amount) + flt(d.rm_supp_cost) + flt(d.item_tax_amount) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index d0d115d96a1..b77c3a51348 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -5,6 +5,7 @@ import frappe from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, cint, cstr, flt, today +from pypika import functions as fn import erpnext from erpnext.accounts.doctype.account.test_account import get_inventory_account @@ -1156,6 +1157,125 @@ class TestPurchaseReceipt(FrappeTestCase): if gle.account == account: self.assertEqual(gle.credit, 50) + def test_backdated_transaction_for_internal_transfer(self): + from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + prepare_data_for_internal_transfer() + customer = "_Test Internal Customer 2" + company = "_Test Company with perpetual inventory" + + from_warehouse = create_warehouse("_Test Internal From Warehouse New", company=company) + to_warehouse = create_warehouse("_Test Internal To Warehouse New", company=company) + item_doc = create_item("Test Internal Transfer Item") + + target_warehouse = create_warehouse("_Test Internal GIT Warehouse New", company=company) + + make_purchase_receipt( + item_code=item_doc.name, + company=company, + posting_date=add_days(today(), -1), + warehouse=from_warehouse, + qty=1, + rate=100, + ) + + dn1 = create_delivery_note( + item_code=item_doc.name, + company=company, + customer=customer, + cost_center="Main - TCP1", + expense_account="Cost of Goods Sold - TCP1", + qty=1, + rate=500, + warehouse=from_warehouse, + target_warehouse=target_warehouse, + ) + + self.assertEqual(dn1.items[0].rate, 100) + + pr1 = make_inter_company_purchase_receipt(dn1.name) + pr1.items[0].warehouse = to_warehouse + self.assertEqual(pr1.items[0].rate, 100) + pr1.submit() + + # Backdated purchase receipt entry, the valuation rate should be updated for DN1 and PR1 + make_purchase_receipt( + item_code=item_doc.name, + company=company, + posting_date=add_days(today(), -2), + warehouse=from_warehouse, + qty=1, + rate=200, + ) + + dn_value = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_type": "Delivery Note", "voucher_no": dn1.name, "warehouse": target_warehouse}, + "stock_value_difference", + ) + + self.assertEqual(abs(dn_value), 200.00) + + pr_value = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_type": "Purchase Receipt", "voucher_no": pr1.name, "warehouse": to_warehouse}, + "stock_value_difference", + ) + + self.assertEqual(abs(pr_value), 200.00) + pr1.load_from_db() + + self.assertEqual(pr1.items[0].valuation_rate, 200) + self.assertEqual(pr1.items[0].rate, 100) + + Gl = frappe.qb.DocType("GL Entry") + + query = ( + frappe.qb.from_(Gl) + .select( + (fn.Sum(Gl.debit) - fn.Sum(Gl.credit)).as_("value"), + ) + .where((Gl.voucher_type == pr1.doctype) & (Gl.voucher_no == pr1.name)) + ).run(as_dict=True) + + self.assertEqual(query[0].value, 0) + + +def prepare_data_for_internal_transfer(): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier + from erpnext.selling.doctype.customer.test_customer import create_internal_customer + + company = "_Test Company with perpetual inventory" + + create_internal_customer( + "_Test Internal Customer 2", + company, + company, + ) + + create_internal_supplier( + "_Test Internal Supplier 2", + company, + company, + ) + + if not frappe.db.get_value("Company", company, "unrealized_profit_loss_account"): + account = "Unrealized Profit and Loss - TCP1" + if not frappe.db.exists("Account", account): + frappe.get_doc( + { + "doctype": "Account", + "account_name": "Unrealized Profit and Loss", + "parent_account": "Direct Income - TCP1", + "company": company, + "is_group": 0, + "account_type": "Income Account", + } + ).insert() + + frappe.db.set_value("Company", company, "unrealized_profit_loss_account", account) + def get_sl_entries(voucher_type, voucher_no): return frappe.db.sql( diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 1bbe5708071..a952a93ac72 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -815,7 +815,8 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle return { "filters": { "docstatus": 1, - "company": me.frm.doc.company + "company": me.frm.doc.company, + "status": ["not in", ["Completed", "Closed"]] } }; }); diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index d70952282d6..76bba8af646 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -117,6 +117,7 @@ class StockEntry(StockController): self.validate_work_order() self.validate_bom() self.validate_purchase_order() + self.validate_subcontracting_order() if self.purpose in ("Manufacture", "Repack"): self.mark_finished_and_scrap_items() @@ -875,25 +876,24 @@ class StockEntry(StockController): ) ) - parent = frappe.qb.DocType("Stock Entry") - child = frappe.qb.DocType("Stock Entry Detail") - - conditions = ( - (parent.docstatus == 1) - & (child.item_code == se_item.item_code) - & ( - (parent.purchase_order == self.purchase_order) - if self.subcontract_data.order_doctype == "Purchase Order" - else (parent.subcontracting_order == self.subcontracting_order) - ) - ) + se = frappe.qb.DocType("Stock Entry") + se_detail = frappe.qb.DocType("Stock Entry Detail") total_supplied = ( - frappe.qb.from_(parent) - .inner_join(child) - .on(parent.name == child.parent) - .select(Sum(child.transfer_qty)) - .where(conditions) + frappe.qb.from_(se) + .inner_join(se_detail) + .on(se.name == se_detail.parent) + .select(Sum(se_detail.transfer_qty)) + .where( + (se.purpose == "Send to Subcontractor") + & (se.docstatus == 1) + & (se_detail.item_code == se_item.item_code) + & ( + (se.purchase_order == self.purchase_order) + if self.subcontract_data.order_doctype == "Purchase Order" + else (se.subcontracting_order == self.subcontracting_order) + ) + ) ).run()[0][0] if flt(total_supplied, precision) > flt(total_allowed, precision): @@ -960,6 +960,20 @@ class StockEntry(StockController): ) ) + def validate_subcontracting_order(self): + if self.get("subcontracting_order") and self.purpose in [ + "Send to Subcontractor", + "Material Transfer", + ]: + sco_status = frappe.db.get_value("Subcontracting Order", self.subcontracting_order, "status") + + if sco_status == "Closed": + frappe.throw( + _("Cannot create Stock Entry against a closed Subcontracting Order {0}.").format( + self.subcontracting_order + ) + ) + def mark_finished_and_scrap_items(self): if any([d.item_code for d in self.items if (d.is_finished_item and d.t_warehouse)]): return diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 191c03f5f1c..7b984d38475 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -727,12 +727,21 @@ def create_stock_reconciliation(**args): sr.set_posting_time = 1 sr.company = args.company or "_Test Company" sr.expense_account = args.expense_account or ( - "Stock Adjustment - _TC" if frappe.get_all("Stock Ledger Entry") else "Temporary Opening - _TC" + ( + frappe.get_cached_value("Company", sr.company, "stock_adjustment_account") + or frappe.get_cached_value( + "Account", {"account_type": "Stock Adjustment", "company": sr.company}, "name" + ) + ) + if frappe.get_all("Stock Ledger Entry", {"company": sr.company}) + else frappe.get_cached_value( + "Account", {"account_type": "Temporary", "company": sr.company}, "name" + ) ) sr.cost_center = ( args.cost_center or frappe.get_cached_value("Company", sr.company, "cost_center") - or "_Test Cost Center - _TC" + or frappe.get_cached_value("Cost Center", filters={"is_group": 0, "company": sr.company}) ) sr.append( diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 3524a47c713..50309647de0 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -649,21 +649,25 @@ class update_entries_after(object): elif ( sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"] - and sle.actual_qty > 0 + and sle.voucher_detail_no and frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "is_internal_supplier") ): - sle_details = frappe.db.get_value( - "Stock Ledger Entry", - { - "voucher_type": sle.voucher_type, - "voucher_no": sle.voucher_no, - "dependant_sle_voucher_detail_no": sle.voucher_detail_no, - }, - ["stock_value_difference", "actual_qty"], - as_dict=1, + field = ( + "delivery_note_item" if sle.voucher_type == "Purchase Receipt" else "sales_invoice_item" + ) + doctype = ( + "Delivery Note Item" if sle.voucher_type == "Purchase Receipt" else "Sales Invoice Item" + ) + refernce_name = frappe.get_cached_value( + sle.voucher_type + " Item", sle.voucher_detail_no, field ) - rate = abs(sle_details.stock_value_difference / sle.actual_qty) + if refernce_name: + rate = frappe.get_cached_value( + doctype, + refernce_name, + "incoming_rate", + ) else: if sle.voucher_type in ("Purchase Receipt", "Purchase Invoice"): rate_field = "valuation_rate" @@ -745,7 +749,12 @@ class update_entries_after(object): def update_rate_on_purchase_receipt(self, sle, outgoing_rate): if frappe.db.exists(sle.voucher_type + " Item", sle.voucher_detail_no): frappe.db.set_value( - sle.voucher_type + " Item", sle.voucher_detail_no, "base_net_rate", outgoing_rate + sle.voucher_type + " Item", + sle.voucher_detail_no, + { + "base_net_rate": outgoing_rate, + "valuation_rate": outgoing_rate, + }, ) else: frappe.db.set_value( diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js index 065ef39db3e..40963f86373 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js @@ -107,7 +107,7 @@ frappe.ui.form.on('Subcontracting Order', { get_materials_from_supplier: function (frm) { let sco_rm_details = []; - if (frm.doc.supplied_items && frm.doc.per_received > 0) { + if (frm.doc.status != "Closed" && frm.doc.supplied_items && frm.doc.per_received > 0) { frm.doc.supplied_items.forEach(d => { if (d.total_supplied_qty > 0 && d.total_supplied_qty != d.consumed_qty) { sco_rm_details.push(d.name); diff --git a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py index 9385568c2b3..d054ce0f9d4 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py @@ -502,6 +502,35 @@ class TestSubcontractingOrder(FrappeTestCase): set_backflush_based_on("BOM") + def test_get_materials_from_supplier(self): + # Create SCO + sco = get_subcontracting_order() + + # Transfer RM + rm_items = get_rm_items(sco.supplied_items) + itemwise_details = make_stock_in_entry(rm_items=rm_items) + make_stock_transfer_entry( + sco_no=sco.name, + rm_items=rm_items, + itemwise_details=copy.deepcopy(itemwise_details), + ) + + # Create SCR (Partial) + scr = make_subcontracting_receipt(sco.name) + scr.items[0].qty -= 5 + scr.save() + scr.submit() + + # Get RM from Supplier + ste = get_materials_from_supplier(sco.name, [d.name for d in sco.supplied_items]) + ste.save() + ste.submit() + + sco.load_from_db() + + self.assertEqual(sco.status, "Closed") + self.assertEqual(sco.supplied_items[0].returned_qty, 5) + def create_subcontracting_order(**args): args = frappe._dict(args) diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index e49f212f10f..7f4e9efa948 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -776,6 +776,9 @@ def on_communication_update(doc, status): if not parent.meta.has_field("service_level_agreement"): return + if not parent.get("service_level_agreement"): + return + if ( doc.sent_or_received == "Received" # a reply is received and parent.get("status") == "Open" # issue status is set as open from communication.py diff --git a/license.txt b/license.txt index a238a97b060..f288702d2fa 100644 --- a/license.txt +++ b/license.txt @@ -1,205 +1,200 @@ -### GNU GENERAL PUBLIC LICENSE + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 -Version 3, 29 June 2007 + Copyright (C) 2007 Free Software Foundation, Inc.
|---|