diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/hu_chart_of_accounts_for_microenterprises_with_account_number.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/hu_chart_of_accounts_for_microenterprises_with_account_number.json index 2cd6c0fc61a..4013bb09265 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/hu_chart_of_accounts_for_microenterprises_with_account_number.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/hu_chart_of_accounts_for_microenterprises_with_account_number.json @@ -1,4 +1,6 @@ { + "country_code": "hu", + "name": "Hungary - Chart of Accounts for Microenterprises", "tree": { "SZ\u00c1MLAOSZT\u00c1LY BEFEKTETETT ESZK\u00d6Z\u00d6K": { "account_number": 1, @@ -1651,4 +1653,4 @@ } } } -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py index 7e2f7631377..c2ddb399649 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -424,7 +424,9 @@ def reconcile_vouchers(bank_transaction_name, vouchers): vouchers = json.loads(vouchers) transaction = frappe.get_doc("Bank Transaction", bank_transaction_name) transaction.add_payment_entries(vouchers) - return frappe.get_doc("Bank Transaction", bank_transaction_name) + transaction.save() + + return transaction @frappe.whitelist() diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json index b32022e6fd8..0328d51b892 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json @@ -13,6 +13,7 @@ "status", "bank_account", "company", + "amended_from", "section_break_4", "deposit", "withdrawal", @@ -25,10 +26,10 @@ "transaction_id", "transaction_type", "section_break_14", + "column_break_oufv", "payment_entries", "section_break_18", "allocated_amount", - "amended_from", "column_break_17", "unallocated_amount", "party_section", @@ -138,10 +139,12 @@ "fieldtype": "Section Break" }, { + "allow_on_submit": 1, "fieldname": "allocated_amount", "fieldtype": "Currency", "label": "Allocated Amount", - "options": "currency" + "options": "currency", + "read_only": 1 }, { "fieldname": "amended_from", @@ -157,10 +160,12 @@ "fieldtype": "Column Break" }, { + "allow_on_submit": 1, "fieldname": "unallocated_amount", "fieldtype": "Currency", "label": "Unallocated Amount", - "options": "currency" + "options": "currency", + "read_only": 1 }, { "fieldname": "party_section", @@ -225,11 +230,15 @@ "fieldname": "bank_party_account_number", "fieldtype": "Data", "label": "Party Account No. (Bank Statement)" + }, + { + "fieldname": "column_break_oufv", + "fieldtype": "Column Break" } ], "is_submittable": 1, "links": [], - "modified": "2023-06-06 13:58:12.821411", + "modified": "2023-11-18 18:32:47.203694", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Transaction", diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index 4649d231628..51c823a4592 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -2,78 +2,73 @@ # For license information, please see license.txt import frappe +from frappe import _ from frappe.utils import flt from erpnext.controllers.status_updater import StatusUpdater class BankTransaction(StatusUpdater): - def after_insert(self): - self.unallocated_amount = abs(flt(self.withdrawal) - flt(self.deposit)) + def before_validate(self): + self.update_allocated_amount() - def on_submit(self): - self.clear_linked_payment_entries() + def validate(self): + self.validate_duplicate_references() + + def validate_duplicate_references(self): + """Make sure the same voucher is not allocated twice within the same Bank Transaction""" + if not self.payment_entries: + return + + pe = [] + for row in self.payment_entries: + reference = (row.payment_document, row.payment_entry) + if reference in pe: + frappe.throw( + _("{0} {1} is allocated twice in this Bank Transaction").format( + row.payment_document, row.payment_entry + ) + ) + pe.append(reference) + + def update_allocated_amount(self): + self.allocated_amount = ( + sum(p.allocated_amount for p in self.payment_entries) if self.payment_entries else 0.0 + ) + self.unallocated_amount = abs(flt(self.withdrawal) - flt(self.deposit)) - self.allocated_amount + + def before_submit(self): + self.allocate_payment_entries() self.set_status() if frappe.db.get_single_value("Accounts Settings", "enable_party_matching"): self.auto_set_party() - _saving_flag = False - - # nosemgrep: frappe-semgrep-rules.rules.frappe-modifying-but-not-comitting - def on_update_after_submit(self): - "Run on save(). Avoid recursion caused by multiple saves" - if not self._saving_flag: - self._saving_flag = True - self.clear_linked_payment_entries() - self.update_allocations() - self._saving_flag = False + def before_update_after_submit(self): + self.validate_duplicate_references() + self.allocate_payment_entries() + self.update_allocated_amount() def on_cancel(self): - self.clear_linked_payment_entries(for_cancel=True) - self.set_status(update=True) + for payment_entry in self.payment_entries: + self.clear_linked_payment_entry(payment_entry, for_cancel=True) - def update_allocations(self): - "The doctype does not allow modifications after submission, so write to the db direct" - if self.payment_entries: - allocated_amount = sum(p.allocated_amount for p in self.payment_entries) - else: - allocated_amount = 0.0 - - amount = abs(flt(self.withdrawal) - flt(self.deposit)) - self.db_set("allocated_amount", flt(allocated_amount)) - self.db_set("unallocated_amount", amount - flt(allocated_amount)) - self.reload() self.set_status(update=True) def add_payment_entries(self, vouchers): "Add the vouchers with zero allocation. Save() will perform the allocations and clearance" if 0.0 >= self.unallocated_amount: - frappe.throw(frappe._("Bank Transaction {0} is already fully reconciled").format(self.name)) + frappe.throw(_("Bank Transaction {0} is already fully reconciled").format(self.name)) - added = False for voucher in vouchers: - # Can't add same voucher twice - found = False - for pe in self.payment_entries: - if ( - pe.payment_document == voucher["payment_doctype"] - and pe.payment_entry == voucher["payment_name"] - ): - found = True - - if not found: - pe = { + self.append( + "payment_entries", + { "payment_document": voucher["payment_doctype"], "payment_entry": voucher["payment_name"], "allocated_amount": 0.0, # Temporary - } - child = self.append("payment_entries", pe) - added = True - - # runs on_update_after_submit - if added: - self.save() + }, + ) def allocate_payment_entries(self): """Refactored from bank reconciliation tool. @@ -90,6 +85,7 @@ class BankTransaction(StatusUpdater): - clear means: set the latest transaction date as clearance date """ remaining_amount = self.unallocated_amount + to_remove = [] for payment_entry in self.payment_entries: if payment_entry.allocated_amount == 0.0: unallocated_amount, should_clear, latest_transaction = get_clearance_details( @@ -99,49 +95,39 @@ class BankTransaction(StatusUpdater): if 0.0 == unallocated_amount: if should_clear: latest_transaction.clear_linked_payment_entry(payment_entry) - self.db_delete_payment_entry(payment_entry) + to_remove.append(payment_entry) elif remaining_amount <= 0.0: - self.db_delete_payment_entry(payment_entry) + to_remove.append(payment_entry) - elif 0.0 < unallocated_amount and unallocated_amount <= remaining_amount: - payment_entry.db_set("allocated_amount", unallocated_amount) + elif 0.0 < unallocated_amount <= remaining_amount: + payment_entry.allocated_amount = unallocated_amount remaining_amount -= unallocated_amount if should_clear: latest_transaction.clear_linked_payment_entry(payment_entry) - elif 0.0 < unallocated_amount and unallocated_amount > remaining_amount: - payment_entry.db_set("allocated_amount", remaining_amount) + elif 0.0 < unallocated_amount: + payment_entry.allocated_amount = remaining_amount remaining_amount = 0.0 elif 0.0 > unallocated_amount: - self.db_delete_payment_entry(payment_entry) - frappe.throw(frappe._("Voucher {0} is over-allocated by {1}").format(unallocated_amount)) + frappe.throw(_("Voucher {0} is over-allocated by {1}").format(unallocated_amount)) - self.reload() - - def db_delete_payment_entry(self, payment_entry): - frappe.db.delete("Bank Transaction Payments", {"name": payment_entry.name}) + for payment_entry in to_remove: + self.remove(to_remove) @frappe.whitelist() def remove_payment_entries(self): for payment_entry in self.payment_entries: self.remove_payment_entry(payment_entry) - # runs on_update_after_submit - self.save() + + self.save() # runs before_update_after_submit def remove_payment_entry(self, payment_entry): "Clear payment entry and clearance" self.clear_linked_payment_entry(payment_entry, for_cancel=True) self.remove(payment_entry) - def clear_linked_payment_entries(self, for_cancel=False): - if for_cancel: - for payment_entry in self.payment_entries: - self.clear_linked_payment_entry(payment_entry, for_cancel) - else: - self.allocate_payment_entries() - def clear_linked_payment_entry(self, payment_entry, for_cancel=False): clearance_date = None if for_cancel else self.date set_voucher_clearance( @@ -162,11 +148,10 @@ class BankTransaction(StatusUpdater): deposit=self.deposit, ).match() - if result: - party_type, party = result - frappe.db.set_value( - "Bank Transaction", self.name, field={"party_type": party_type, "party": party} - ) + if not result: + return + + self.party_type, self.party = result @frappe.whitelist() @@ -198,9 +183,7 @@ def get_clearance_details(transaction, payment_entry): if gle["gl_account"] == gl_bank_account: if gle["amount"] <= 0.0: frappe.throw( - frappe._("Voucher {0} value is broken: {1}").format( - payment_entry.payment_entry, gle["amount"] - ) + _("Voucher {0} value is broken: {1}").format(payment_entry.payment_entry, gle["amount"]) ) unmatched_gles -= 1 @@ -221,7 +204,7 @@ def get_clearance_details(transaction, payment_entry): def get_related_bank_gl_entries(doctype, docname): # nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql - result = frappe.db.sql( + return frappe.db.sql( """ SELECT ABS(gle.credit_in_account_currency - gle.debit_in_account_currency) AS amount, @@ -239,7 +222,6 @@ def get_related_bank_gl_entries(doctype, docname): dict(doctype=doctype, docname=docname), as_dict=True, ) - return result def get_total_allocated_amount(doctype, docname): @@ -365,6 +347,7 @@ def set_voucher_clearance(doctype, docname, clearance_date, self): if clearance_date: vouchers = [{"payment_doctype": "Bank Transaction", "payment_name": self.name}] bt.add_payment_entries(vouchers) + bt.save() else: for pe in bt.payment_entries: if pe.payment_document == self.doctype and pe.payment_entry == self.name: diff --git a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py index 5a1c139bdef..1e64eeeae63 100644 --- a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py +++ b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py @@ -113,7 +113,7 @@ def generate_data_from_csv(file_doc, as_dict=False): if as_dict: data.append({frappe.scrub(header): row[index] for index, header in enumerate(headers)}) else: - if not row[1]: + if not row[1] and len(row) > 1: row[1] = row[0] row[3] = row[2] data.append(row) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 0b5a37f2069..fb7ede3e891 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -509,7 +509,7 @@ class JournalEntry(AccountsController): ).format(d.reference_name, d.account) ) else: - dr_or_cr = "debit" if d.credit > 0 else "credit" + dr_or_cr = "debit" if flt(d.credit) > 0 else "credit" valid = False for jvd in against_entries: if flt(jvd[dr_or_cr]) > 0: diff --git a/erpnext/accounts/doctype/process_subscription/process_subscription.py b/erpnext/accounts/doctype/process_subscription/process_subscription.py index 99269d6a7d5..0aa9970cb80 100644 --- a/erpnext/accounts/doctype/process_subscription/process_subscription.py +++ b/erpnext/accounts/doctype/process_subscription/process_subscription.py @@ -17,11 +17,10 @@ class ProcessSubscription(Document): def create_subscription_process( - subscription: str | None, posting_date: Union[str, datetime.date] | None + subscription: str | None = None, posting_date: Union[str, datetime.date] | None = None ): """Create a new Process Subscription document""" doc = frappe.new_doc("Process Subscription") doc.subscription = subscription doc.posting_date = getdate(posting_date) - doc.insert(ignore_permissions=True) doc.submit() diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py index 3cf7d284bbb..a3d8c234180 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -676,7 +676,7 @@ def get_prorata_factor( def process_all( - subscription: str | None, posting_date: Optional["DateTimeLikeObject"] = None + subscription: str | None = None, posting_date: Optional["DateTimeLikeObject"] = None ) -> None: """ Task to updates the status of all `Subscription` apart from those that are cancelled diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 0e62ad61cc6..7948e5f4654 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -285,8 +285,8 @@ class ReceivablePayableReport(object): must_consider = False if self.filters.get("for_revaluation_journals"): - if (abs(row.outstanding) > 1.0 / 10**self.currency_precision) or ( - (abs(row.outstanding_in_account_currency) > 1.0 / 10**self.currency_precision) + if (abs(row.outstanding) > 0.0 / 10**self.currency_precision) or ( + (abs(row.outstanding_in_account_currency) > 0.0 / 10**self.currency_precision) ): must_consider = True else: diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py index 82f97f18941..2b5566fb2ff 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -1,7 +1,7 @@ import frappe from frappe import _ -from erpnext.accounts.report.tds_payable_monthly.tds_payable_monthly import ( +from erpnext.accounts.report.tax_withholding_details.tax_withholding_details import ( get_result, get_tds_docs, ) diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index d378fbd26ad..58fd6d4ef8a 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -214,30 +214,43 @@ frappe.ui.form.on('Asset', { }) }, - render_depreciation_schedule_view: function(frm, depr_schedule) { + render_depreciation_schedule_view: function(frm, asset_depr_schedule_doc) { let wrapper = $(frm.fields_dict["depreciation_schedule_view"].wrapper).empty(); let data = []; - depr_schedule.forEach((sch) => { + asset_depr_schedule_doc.depreciation_schedule.forEach((sch) => { const row = [ sch['idx'], frappe.format(sch['schedule_date'], { fieldtype: 'Date' }), frappe.format(sch['depreciation_amount'], { fieldtype: 'Currency' }), frappe.format(sch['accumulated_depreciation_amount'], { fieldtype: 'Currency' }), - sch['journal_entry'] || '' + sch['journal_entry'] || '', ]; + + if (asset_depr_schedule_doc.shift_based) { + row.push(sch['shift']); + } + data.push(row); }); + let columns = [ + {name: __("No."), editable: false, resizable: false, format: value => value, width: 60}, + {name: __("Schedule Date"), editable: false, resizable: false, width: 270}, + {name: __("Depreciation Amount"), editable: false, resizable: false, width: 164}, + {name: __("Accumulated Depreciation Amount"), editable: false, resizable: false, width: 164}, + ]; + + if (asset_depr_schedule_doc.shift_based) { + columns.push({name: __("Journal Entry"), editable: false, resizable: false, format: value => `${value}`, width: 245}); + columns.push({name: __("Shift"), editable: false, resizable: false, width: 59}); + } else { + columns.push({name: __("Journal Entry"), editable: false, resizable: false, format: value => `${value}`, width: 304}); + } + let datatable = new frappe.DataTable(wrapper.get(0), { - columns: [ - {name: __("No."), editable: false, resizable: false, format: value => value, width: 60}, - {name: __("Schedule Date"), editable: false, resizable: false, width: 270}, - {name: __("Depreciation Amount"), editable: false, resizable: false, width: 164}, - {name: __("Accumulated Depreciation Amount"), editable: false, resizable: false, width: 164}, - {name: __("Journal Entry"), editable: false, resizable: false, format: value => `${value}`, width: 304} - ], + columns: columns, data: data, layout: "fluid", serialNoColumn: false, @@ -272,8 +285,8 @@ frappe.ui.form.on('Asset', { asset_values.push(flt(frm.doc.gross_purchase_amount - frm.doc.opening_accumulated_depreciation, precision('gross_purchase_amount'))); } - let depr_schedule = (await frappe.call( - "erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule.get_depr_schedule", + let asset_depr_schedule_doc = (await frappe.call( + "erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule.get_asset_depr_schedule_doc", { asset_name: frm.doc.name, status: "Active", @@ -281,7 +294,7 @@ frappe.ui.form.on('Asset', { } )).message; - $.each(depr_schedule || [], function(i, v) { + $.each(asset_depr_schedule_doc.depreciation_schedule || [], function(i, v) { x_intervals.push(frappe.format(v.schedule_date, { fieldtype: 'Date' })); var asset_value = flt(frm.doc.gross_purchase_amount - v.accumulated_depreciation_amount, precision('gross_purchase_amount')); if(v.journal_entry) { @@ -296,7 +309,7 @@ frappe.ui.form.on('Asset', { }); frm.toggle_display(["depreciation_schedule_view"], 1); - frm.events.render_depreciation_schedule_view(frm, depr_schedule); + frm.events.render_depreciation_schedule_view(frm, asset_depr_schedule_doc); } else { if(frm.doc.opening_accumulated_depreciation) { x_intervals.push(frappe.format(frm.doc.creation.split(" ")[0], { fieldtype: 'Date' })); diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 12dcc5bcf33..22b45ece086 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -829,6 +829,7 @@ def get_item_details(item_code, asset_category, gross_purchase_amount): "total_number_of_depreciations": d.total_number_of_depreciations, "frequency_of_depreciation": d.frequency_of_depreciation, "daily_prorata_based": d.daily_prorata_based, + "shift_based": d.shift_based, "salvage_value_percentage": d.salvage_value_percentage, "expected_value_after_useful_life": flt(gross_purchase_amount) * flt(d.salvage_value_percentage / 100), diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 536845ed26d..07736984206 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -149,12 +149,7 @@ class TestAsset(AssetSetup): ("Creditors - _TC", 0.0, 100000.0), ) - gle = frappe.db.sql( - """select account, debit, credit from `tabGL Entry` - where voucher_type='Purchase Invoice' and voucher_no = %s - order by account""", - pi.name, - ) + gle = get_gl_entries("Purchase Invoice", pi.name) self.assertSequenceEqual(gle, expected_gle) pi.cancel() @@ -273,12 +268,7 @@ class TestAsset(AssetSetup): ), ) - gle = frappe.db.sql( - """select account, debit, credit from `tabGL Entry` - where voucher_type='Journal Entry' and voucher_no = %s - order by account, credit""", - asset.journal_entry_for_scrap, - ) + gle = get_gl_entries("Journal Entry", asset.journal_entry_for_scrap) self.assertSequenceEqual(gle, expected_gle) restore_asset(asset.name) @@ -354,13 +344,7 @@ class TestAsset(AssetSetup): ("Debtors - _TC", 25000.0, 0.0), ) - gle = frappe.db.sql( - """select account, debit, credit from `tabGL Entry` - where voucher_type='Sales Invoice' and voucher_no = %s - order by account""", - si.name, - ) - + gle = get_gl_entries("Sales Invoice", si.name) self.assertSequenceEqual(gle, expected_gle) si.cancel() @@ -434,13 +418,7 @@ class TestAsset(AssetSetup): ("Debtors - _TC", 40000.0, 0.0), ) - gle = frappe.db.sql( - """select account, debit, credit from `tabGL Entry` - where voucher_type='Sales Invoice' and voucher_no = %s - order by account""", - si.name, - ) - + gle = get_gl_entries("Sales Invoice", si.name) self.assertSequenceEqual(gle, expected_gle) def test_asset_with_maintenance_required_status_after_sale(self): @@ -581,13 +559,7 @@ class TestAsset(AssetSetup): ("CWIP Account - _TC", 5250.0, 0.0), ) - pr_gle = frappe.db.sql( - """select account, debit, credit from `tabGL Entry` - where voucher_type='Purchase Receipt' and voucher_no = %s - order by account""", - pr.name, - ) - + pr_gle = get_gl_entries("Purchase Receipt", pr.name) self.assertSequenceEqual(pr_gle, expected_gle) pi = make_invoice(pr.name) @@ -600,13 +572,7 @@ class TestAsset(AssetSetup): ("Creditors - _TC", 0.0, 5500.0), ) - pi_gle = frappe.db.sql( - """select account, debit, credit from `tabGL Entry` - where voucher_type='Purchase Invoice' and voucher_no = %s - order by account""", - pi.name, - ) - + pi_gle = get_gl_entries("Purchase Invoice", pi.name) self.assertSequenceEqual(pi_gle, expected_gle) asset = frappe.db.get_value("Asset", {"purchase_receipt": pr.name, "docstatus": 0}, "name") @@ -633,13 +599,7 @@ class TestAsset(AssetSetup): expected_gle = (("_Test Fixed Asset - _TC", 5250.0, 0.0), ("CWIP Account - _TC", 0.0, 5250.0)) - gle = frappe.db.sql( - """select account, debit, credit from `tabGL Entry` - where voucher_type='Asset' and voucher_no = %s - order by account""", - asset_doc.name, - ) - + gle = get_gl_entries("Asset", asset_doc.name) self.assertSequenceEqual(gle, expected_gle) def test_asset_cwip_toggling_cases(self): @@ -662,10 +622,7 @@ class TestAsset(AssetSetup): asset_doc.available_for_use_date = nowdate() asset_doc.calculate_depreciation = 0 asset_doc.submit() - gle = frappe.db.sql( - """select name from `tabGL Entry` where voucher_type='Asset' and voucher_no = %s""", - asset_doc.name, - ) + gle = get_gl_entries("Asset", asset_doc.name) self.assertFalse(gle) # case 1 -- PR with cwip disabled, Asset with cwip enabled @@ -679,10 +636,7 @@ class TestAsset(AssetSetup): asset_doc.available_for_use_date = nowdate() asset_doc.calculate_depreciation = 0 asset_doc.submit() - gle = frappe.db.sql( - """select name from `tabGL Entry` where voucher_type='Asset' and voucher_no = %s""", - asset_doc.name, - ) + gle = get_gl_entries("Asset", asset_doc.name) self.assertFalse(gle) # case 2 -- PR with cwip enabled, Asset with cwip disabled @@ -695,10 +649,7 @@ class TestAsset(AssetSetup): asset_doc.available_for_use_date = nowdate() asset_doc.calculate_depreciation = 0 asset_doc.submit() - gle = frappe.db.sql( - """select name from `tabGL Entry` where voucher_type='Asset' and voucher_no = %s""", - asset_doc.name, - ) + gle = get_gl_entries("Asset", asset_doc.name) self.assertTrue(gle) # case 3 -- PI with cwip disabled, Asset with cwip enabled @@ -711,10 +662,7 @@ class TestAsset(AssetSetup): asset_doc.available_for_use_date = nowdate() asset_doc.calculate_depreciation = 0 asset_doc.submit() - gle = frappe.db.sql( - """select name from `tabGL Entry` where voucher_type='Asset' and voucher_no = %s""", - asset_doc.name, - ) + gle = get_gl_entries("Asset", asset_doc.name) self.assertFalse(gle) # case 4 -- PI with cwip enabled, Asset with cwip disabled @@ -727,10 +675,7 @@ class TestAsset(AssetSetup): asset_doc.available_for_use_date = nowdate() asset_doc.calculate_depreciation = 0 asset_doc.submit() - gle = frappe.db.sql( - """select name from `tabGL Entry` where voucher_type='Asset' and voucher_no = %s""", - asset_doc.name, - ) + gle = get_gl_entries("Asset", asset_doc.name) self.assertTrue(gle) frappe.db.set_value("Asset Category", "Computers", "enable_cwip_accounting", cwip) @@ -1064,7 +1009,11 @@ class TestDepreciationBasics(AssetSetup): }, ) - depreciation_amount = get_depreciation_amount(asset, 100000, asset.finance_books[0]) + asset_depr_schedule_doc = get_asset_depr_schedule_doc(asset.name, "Active") + + depreciation_amount = get_depreciation_amount( + asset_depr_schedule_doc, asset, 100000, asset.finance_books[0] + ) self.assertEqual(depreciation_amount, 30000) def test_make_depr_schedule(self): @@ -1710,6 +1659,30 @@ class TestDepreciationBasics(AssetSetup): self.assertRaises(frappe.ValidationError, jv.insert) + def test_multi_currency_asset_pr_creation(self): + pr = make_purchase_receipt( + item_code="Macbook Pro", + qty=1, + rate=100.0, + location="Test Location", + supplier="_Test Supplier USD", + currency="USD", + ) + + pr.submit() + self.assertTrue(get_gl_entries("Purchase Receipt", pr.name)) + + +def get_gl_entries(doctype, docname): + gl_entry = frappe.qb.DocType("GL Entry") + return ( + frappe.qb.from_(gl_entry) + .select(gl_entry.account, gl_entry.debit, gl_entry.credit) + .where((gl_entry.voucher_type == doctype) & (gl_entry.voucher_no == docname)) + .orderby(gl_entry.account) + .run() + ) + def create_asset_data(): if not frappe.db.exists("Asset Category", "Computers"): @@ -1772,6 +1745,7 @@ def create_asset(**args): "expected_value_after_useful_life": args.expected_value_after_useful_life or 0, "depreciation_start_date": args.depreciation_start_date, "daily_prorata_based": args.daily_prorata_based or 0, + "shift_based": args.shift_based or 0, }, ) diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.js b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.js index 3d2dff179aa..c99297d6266 100644 --- a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.js +++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.js @@ -8,11 +8,13 @@ frappe.ui.form.on('Asset Depreciation Schedule', { }, make_schedules_editable: function(frm) { - var is_editable = frm.doc.depreciation_method == "Manual" ? true : false; + var is_manual_hence_editable = frm.doc.depreciation_method === "Manual" ? true : false; + var is_shift_hence_editable = frm.doc.shift_based ? true : false; - frm.toggle_enable("depreciation_schedule", is_editable); - frm.fields_dict["depreciation_schedule"].grid.toggle_enable("schedule_date", is_editable); - frm.fields_dict["depreciation_schedule"].grid.toggle_enable("depreciation_amount", is_editable); + frm.toggle_enable("depreciation_schedule", is_manual_hence_editable || is_shift_hence_editable); + frm.fields_dict["depreciation_schedule"].grid.toggle_enable("schedule_date", is_manual_hence_editable); + frm.fields_dict["depreciation_schedule"].grid.toggle_enable("depreciation_amount", is_manual_hence_editable); + frm.fields_dict["depreciation_schedule"].grid.toggle_enable("shift", is_shift_hence_editable); } }); diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.json b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.json index 8d8b46321fc..be35914251d 100644 --- a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.json +++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.json @@ -20,6 +20,7 @@ "total_number_of_depreciations", "rate_of_depreciation", "daily_prorata_based", + "shift_based", "column_break_8", "frequency_of_depreciation", "expected_value_after_useful_life", @@ -184,12 +185,20 @@ "label": "Depreciate based on daily pro-rata", "print_hide": 1, "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:doc.depreciation_method == \"Straight Line\"", + "fieldname": "shift_based", + "fieldtype": "Check", + "label": "Depreciate based on shifts", + "read_only": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-11-03 21:32:15.021796", + "modified": "2023-11-29 00:57:00.461998", "modified_by": "Administrator", "module": "Assets", "name": "Asset Depreciation Schedule", diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py index 7305691f97c..6e390ce6f81 100644 --- a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py +++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py @@ -26,6 +26,7 @@ class AssetDepreciationSchedule(Document): self.prepare_draft_asset_depr_schedule_data_from_asset_name_and_fb_name( self.asset, self.finance_book ) + self.update_shift_depr_schedule() def validate(self): self.validate_another_asset_depr_schedule_does_not_exist() @@ -73,6 +74,16 @@ class AssetDepreciationSchedule(Document): def on_cancel(self): self.db_set("status", "Cancelled") + def update_shift_depr_schedule(self): + if not self.shift_based or self.docstatus != 0: + return + + asset_doc = frappe.get_doc("Asset", self.asset) + fb_row = asset_doc.finance_books[self.finance_book_id - 1] + + self.make_depr_schedule(asset_doc, fb_row) + self.set_accumulated_depreciation(asset_doc, fb_row) + def prepare_draft_asset_depr_schedule_data_from_asset_name_and_fb_name(self, asset_name, fb_name): asset_doc = frappe.get_doc("Asset", asset_name) @@ -154,13 +165,14 @@ class AssetDepreciationSchedule(Document): self.rate_of_depreciation = row.rate_of_depreciation self.expected_value_after_useful_life = row.expected_value_after_useful_life self.daily_prorata_based = row.daily_prorata_based + self.shift_based = row.shift_based self.status = "Draft" def make_depr_schedule( self, asset_doc, row, - date_of_disposal, + date_of_disposal=None, update_asset_finance_book_row=True, value_after_depreciation=None, ): @@ -181,6 +193,8 @@ class AssetDepreciationSchedule(Document): num_of_depreciations_completed = 0 depr_schedule = [] + self.schedules_before_clearing = self.get("depreciation_schedule") + for schedule in self.get("depreciation_schedule"): if schedule.journal_entry: num_of_depreciations_completed += 1 @@ -246,6 +260,7 @@ class AssetDepreciationSchedule(Document): prev_depreciation_amount = 0 depreciation_amount = get_depreciation_amount( + self, asset_doc, value_after_depreciation, row, @@ -282,10 +297,7 @@ class AssetDepreciationSchedule(Document): ) if depreciation_amount > 0: - self.add_depr_schedule_row( - date_of_disposal, - depreciation_amount, - ) + self.add_depr_schedule_row(date_of_disposal, depreciation_amount, n) break @@ -369,10 +381,7 @@ class AssetDepreciationSchedule(Document): skip_row = True if flt(depreciation_amount, asset_doc.precision("gross_purchase_amount")) > 0: - self.add_depr_schedule_row( - schedule_date, - depreciation_amount, - ) + self.add_depr_schedule_row(schedule_date, depreciation_amount, n) # to ensure that final accumulated depreciation amount is accurate def get_adjusted_depreciation_amount( @@ -394,16 +403,22 @@ class AssetDepreciationSchedule(Document): def get_depreciation_amount_for_first_row(self): return self.get("depreciation_schedule")[0].depreciation_amount - def add_depr_schedule_row( - self, - schedule_date, - depreciation_amount, - ): + def add_depr_schedule_row(self, schedule_date, depreciation_amount, schedule_idx): + if self.shift_based: + shift = ( + self.schedules_before_clearing[schedule_idx].shift + if self.schedules_before_clearing and len(self.schedules_before_clearing) > schedule_idx + else frappe.get_cached_value("Asset Shift Factor", {"default": 1}, "shift_name") + ) + else: + shift = None + self.append( "depreciation_schedule", { "schedule_date": schedule_date, "depreciation_amount": depreciation_amount, + "shift": shift, }, ) @@ -445,6 +460,7 @@ class AssetDepreciationSchedule(Document): and i == max(straight_line_idx) - 1 and not date_of_disposal and not date_of_return + and not row.shift_based ): depreciation_amount += flt( value_after_depreciation - flt(row.expected_value_after_useful_life), @@ -527,6 +543,7 @@ def get_total_days(date, frequency): def get_depreciation_amount( + asset_depr_schedule, asset, depreciable_value, fb_row, @@ -537,7 +554,7 @@ def get_depreciation_amount( ): if fb_row.depreciation_method in ("Straight Line", "Manual"): return get_straight_line_or_manual_depr_amount( - asset, fb_row, schedule_idx, number_of_pending_depreciations + asset_depr_schedule, asset, fb_row, schedule_idx, number_of_pending_depreciations ) else: rate_of_depreciation = get_updated_rate_of_depreciation_for_wdv_and_dd( @@ -559,8 +576,11 @@ def get_updated_rate_of_depreciation_for_wdv_and_dd(asset, depreciable_value, fb def get_straight_line_or_manual_depr_amount( - asset, row, schedule_idx, number_of_pending_depreciations + asset_depr_schedule, asset, row, schedule_idx, number_of_pending_depreciations ): + if row.shift_based: + return get_shift_depr_amount(asset_depr_schedule, asset, row, schedule_idx) + # if the Depreciation Schedule is being modified after Asset Repair due to increase in asset life and value if asset.flags.increase_in_asset_life: return (flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life)) / ( @@ -655,6 +675,41 @@ def get_straight_line_or_manual_depr_amount( ) / flt(row.total_number_of_depreciations - asset.number_of_depreciations_booked) +def get_shift_depr_amount(asset_depr_schedule, asset, row, schedule_idx): + if asset_depr_schedule.get("__islocal") and not asset.flags.shift_allocation: + return ( + flt(asset.gross_purchase_amount) + - flt(asset.opening_accumulated_depreciation) + - flt(row.expected_value_after_useful_life) + ) / flt(row.total_number_of_depreciations - asset.number_of_depreciations_booked) + + asset_shift_factors_map = get_asset_shift_factors_map() + shift = ( + asset_depr_schedule.schedules_before_clearing[schedule_idx].shift + if len(asset_depr_schedule.schedules_before_clearing) > schedule_idx + else None + ) + shift_factor = asset_shift_factors_map.get(shift) if shift else 0 + + shift_factors_sum = sum( + flt(asset_shift_factors_map.get(schedule.shift)) + for schedule in asset_depr_schedule.schedules_before_clearing + ) + + return ( + ( + flt(asset.gross_purchase_amount) + - flt(asset.opening_accumulated_depreciation) + - flt(row.expected_value_after_useful_life) + ) + / flt(shift_factors_sum) + ) * shift_factor + + +def get_asset_shift_factors_map(): + return dict(frappe.db.get_all("Asset Shift Factor", ["shift_name", "shift_factor"], as_list=True)) + + def get_wdv_or_dd_depr_amount( depreciable_value, rate_of_depreciation, @@ -803,7 +858,12 @@ def make_new_active_asset_depr_schedules_and_cancel_current_ones( def get_temp_asset_depr_schedule_doc( - asset_doc, row, date_of_disposal=None, date_of_return=None, update_asset_finance_book_row=False + asset_doc, + row, + date_of_disposal=None, + date_of_return=None, + update_asset_finance_book_row=False, + new_depr_schedule=None, ): current_asset_depr_schedule_doc = get_asset_depr_schedule_doc( asset_doc.name, "Active", row.finance_book @@ -818,6 +878,21 @@ def get_temp_asset_depr_schedule_doc( temp_asset_depr_schedule_doc = frappe.copy_doc(current_asset_depr_schedule_doc) + if new_depr_schedule: + temp_asset_depr_schedule_doc.depreciation_schedule = [] + + for schedule in new_depr_schedule: + temp_asset_depr_schedule_doc.append( + "depreciation_schedule", + { + "schedule_date": schedule.schedule_date, + "depreciation_amount": schedule.depreciation_amount, + "accumulated_depreciation_amount": schedule.accumulated_depreciation_amount, + "journal_entry": schedule.journal_entry, + "shift": schedule.shift, + }, + ) + temp_asset_depr_schedule_doc.prepare_draft_asset_depr_schedule_data( asset_doc, row, @@ -839,6 +914,7 @@ def get_depr_schedule(asset_name, status, finance_book=None): return asset_depr_schedule_doc.get("depreciation_schedule") +@frappe.whitelist() def get_asset_depr_schedule_doc(asset_name, status, finance_book=None): asset_depr_schedule_name = get_asset_depr_schedule_name(asset_name, status, finance_book) diff --git a/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json b/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json index e597d5fe31e..25ae7a492c8 100644 --- a/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json +++ b/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json @@ -9,6 +9,7 @@ "depreciation_method", "total_number_of_depreciations", "daily_prorata_based", + "shift_based", "column_break_5", "frequency_of_depreciation", "depreciation_start_date", @@ -97,12 +98,19 @@ "fieldname": "daily_prorata_based", "fieldtype": "Check", "label": "Depreciate based on daily pro-rata" + }, + { + "default": "0", + "depends_on": "eval:doc.depreciation_method == \"Straight Line\"", + "fieldname": "shift_based", + "fieldtype": "Check", + "label": "Depreciate based on shifts" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-11-03 21:30:24.266601", + "modified": "2023-11-29 00:57:07.579777", "modified_by": "Administrator", "module": "Assets", "name": "Asset Finance Book", diff --git a/erpnext/assets/doctype/asset_shift_allocation/__init__.py b/erpnext/assets/doctype/asset_shift_allocation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/assets/doctype/asset_shift_allocation/asset_shift_allocation.js b/erpnext/assets/doctype/asset_shift_allocation/asset_shift_allocation.js new file mode 100644 index 00000000000..54df69339b5 --- /dev/null +++ b/erpnext/assets/doctype/asset_shift_allocation/asset_shift_allocation.js @@ -0,0 +1,14 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +frappe.ui.form.on('Asset Shift Allocation', { + onload: function(frm) { + frm.events.make_schedules_editable(frm); + }, + + make_schedules_editable: function(frm) { + frm.toggle_enable("depreciation_schedule", true); + frm.fields_dict["depreciation_schedule"].grid.toggle_enable("schedule_date", false); + frm.fields_dict["depreciation_schedule"].grid.toggle_enable("depreciation_amount", false); + frm.fields_dict["depreciation_schedule"].grid.toggle_enable("shift", true); + } +}); diff --git a/erpnext/assets/doctype/asset_shift_allocation/asset_shift_allocation.json b/erpnext/assets/doctype/asset_shift_allocation/asset_shift_allocation.json new file mode 100644 index 00000000000..89fa298a74a --- /dev/null +++ b/erpnext/assets/doctype/asset_shift_allocation/asset_shift_allocation.json @@ -0,0 +1,111 @@ +{ + "actions": [], + "autoname": "naming_series:", + "creation": "2023-11-24 15:07:44.652133", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "section_break_esaa", + "asset", + "naming_series", + "column_break_tdae", + "finance_book", + "amended_from", + "depreciation_schedule_section", + "depreciation_schedule" + ], + "fields": [ + { + "fieldname": "section_break_esaa", + "fieldtype": "Section Break" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Asset Shift Allocation", + "print_hide": 1, + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "asset", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Asset", + "options": "Asset", + "reqd": 1 + }, + { + "fieldname": "column_break_tdae", + "fieldtype": "Column Break" + }, + { + "fieldname": "finance_book", + "fieldtype": "Link", + "label": "Finance Book", + "options": "Finance Book" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "depreciation_schedule_section", + "fieldtype": "Section Break", + "label": "Depreciation Schedule" + }, + { + "fieldname": "depreciation_schedule", + "fieldtype": "Table", + "label": "Depreciation Schedule", + "options": "Depreciation Schedule" + }, + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Naming Series", + "options": "ACC-ASA-.YYYY.-", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2023-11-29 04:05:04.683518", + "modified_by": "Administrator", + "module": "Assets", + "name": "Asset Shift Allocation", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_shift_allocation/asset_shift_allocation.py b/erpnext/assets/doctype/asset_shift_allocation/asset_shift_allocation.py new file mode 100644 index 00000000000..d419ef4c841 --- /dev/null +++ b/erpnext/assets/doctype/asset_shift_allocation/asset_shift_allocation.py @@ -0,0 +1,262 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import ( + add_months, + cint, + flt, + get_last_day, + get_link_to_form, + is_last_day_of_the_month, +) + +from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_asset_depr_schedule_doc, + get_asset_shift_factors_map, + get_temp_asset_depr_schedule_doc, +) + + +class AssetShiftAllocation(Document): + def after_insert(self): + self.fetch_and_set_depr_schedule() + + def validate(self): + self.asset_depr_schedule_doc = get_asset_depr_schedule_doc( + self.asset, "Active", self.finance_book + ) + + self.validate_invalid_shift_change() + self.update_depr_schedule() + + def on_submit(self): + self.create_new_asset_depr_schedule() + + def fetch_and_set_depr_schedule(self): + if self.asset_depr_schedule_doc: + if self.asset_depr_schedule_doc.shift_based: + for schedule in self.asset_depr_schedule_doc.get("depreciation_schedule"): + self.append( + "depreciation_schedule", + { + "schedule_date": schedule.schedule_date, + "depreciation_amount": schedule.depreciation_amount, + "accumulated_depreciation_amount": schedule.accumulated_depreciation_amount, + "journal_entry": schedule.journal_entry, + "shift": schedule.shift, + }, + ) + + self.flags.ignore_validate = True + self.save() + else: + frappe.throw( + _( + "Asset Depreciation Schedule for Asset {0} and Finance Book {1} is not using shift based depreciation" + ).format(self.asset, self.finance_book) + ) + else: + frappe.throw( + _("Asset Depreciation Schedule not found for Asset {0} and Finance Book {1}").format( + self.asset, self.finance_book + ) + ) + + def validate_invalid_shift_change(self): + if not self.get("depreciation_schedule") or self.docstatus == 1: + return + + for i, sch in enumerate(self.depreciation_schedule): + if ( + sch.journal_entry and self.asset_depr_schedule_doc.depreciation_schedule[i].shift != sch.shift + ): + frappe.throw( + _( + "Row {0}: Shift cannot be changed since the depreciation has already been processed" + ).format(i) + ) + + def update_depr_schedule(self): + if not self.get("depreciation_schedule") or self.docstatus == 1: + return + + self.allocate_shift_diff_in_depr_schedule() + + asset_doc = frappe.get_doc("Asset", self.asset) + fb_row = asset_doc.finance_books[self.asset_depr_schedule_doc.finance_book_id - 1] + + asset_doc.flags.shift_allocation = True + + temp_depr_schedule = get_temp_asset_depr_schedule_doc( + asset_doc, fb_row, new_depr_schedule=self.depreciation_schedule + ).get("depreciation_schedule") + + self.depreciation_schedule = [] + + for schedule in temp_depr_schedule: + self.append( + "depreciation_schedule", + { + "schedule_date": schedule.schedule_date, + "depreciation_amount": schedule.depreciation_amount, + "accumulated_depreciation_amount": schedule.accumulated_depreciation_amount, + "journal_entry": schedule.journal_entry, + "shift": schedule.shift, + }, + ) + + def allocate_shift_diff_in_depr_schedule(self): + asset_shift_factors_map = get_asset_shift_factors_map() + reverse_asset_shift_factors_map = { + asset_shift_factors_map[k]: k for k in asset_shift_factors_map + } + + original_shift_factors_sum = sum( + flt(asset_shift_factors_map.get(schedule.shift)) + for schedule in self.asset_depr_schedule_doc.depreciation_schedule + ) + + new_shift_factors_sum = sum( + flt(asset_shift_factors_map.get(schedule.shift)) for schedule in self.depreciation_schedule + ) + + diff = new_shift_factors_sum - original_shift_factors_sum + + if diff > 0: + for i, schedule in reversed(list(enumerate(self.depreciation_schedule))): + if diff <= 0: + break + + shift_factor = flt(asset_shift_factors_map.get(schedule.shift)) + + if shift_factor <= diff: + self.depreciation_schedule.pop() + diff -= shift_factor + else: + try: + self.depreciation_schedule[i].shift = reverse_asset_shift_factors_map.get( + shift_factor - diff + ) + diff = 0 + except Exception: + frappe.throw(_("Could not auto update shifts. Shift with shift factor {0} needed.")).format( + shift_factor - diff + ) + elif diff < 0: + shift_factors = list(asset_shift_factors_map.values()) + desc_shift_factors = sorted(shift_factors, reverse=True) + depr_schedule_len_diff = self.asset_depr_schedule_doc.total_number_of_depreciations - len( + self.depreciation_schedule + ) + subsets_result = [] + + if depr_schedule_len_diff > 0: + num_rows_to_add = depr_schedule_len_diff + + while not subsets_result and num_rows_to_add > 0: + find_subsets_with_sum(shift_factors, num_rows_to_add, abs(diff), [], subsets_result) + if subsets_result: + break + num_rows_to_add -= 1 + + if subsets_result: + for i in range(num_rows_to_add): + schedule_date = add_months( + self.depreciation_schedule[-1].schedule_date, + cint(self.asset_depr_schedule_doc.frequency_of_depreciation), + ) + + if is_last_day_of_the_month(self.depreciation_schedule[-1].schedule_date): + schedule_date = get_last_day(schedule_date) + + self.append( + "depreciation_schedule", + { + "schedule_date": schedule_date, + "shift": reverse_asset_shift_factors_map.get(subsets_result[0][i]), + }, + ) + + if depr_schedule_len_diff <= 0 or not subsets_result: + for i, schedule in reversed(list(enumerate(self.depreciation_schedule))): + diff = abs(diff) + + if diff <= 0: + break + + shift_factor = flt(asset_shift_factors_map.get(schedule.shift)) + + if shift_factor <= diff: + for sf in desc_shift_factors: + if sf - shift_factor <= diff: + self.depreciation_schedule[i].shift = reverse_asset_shift_factors_map.get(sf) + diff -= sf - shift_factor + break + else: + try: + self.depreciation_schedule[i].shift = reverse_asset_shift_factors_map.get( + shift_factor + diff + ) + diff = 0 + except Exception: + frappe.throw(_("Could not auto update shifts. Shift with shift factor {0} needed.")).format( + shift_factor + diff + ) + + def create_new_asset_depr_schedule(self): + new_asset_depr_schedule_doc = frappe.copy_doc(self.asset_depr_schedule_doc) + + new_asset_depr_schedule_doc.depreciation_schedule = [] + + for schedule in self.depreciation_schedule: + new_asset_depr_schedule_doc.append( + "depreciation_schedule", + { + "schedule_date": schedule.schedule_date, + "depreciation_amount": schedule.depreciation_amount, + "accumulated_depreciation_amount": schedule.accumulated_depreciation_amount, + "journal_entry": schedule.journal_entry, + "shift": schedule.shift, + }, + ) + + notes = _( + "This schedule was created when Asset {0}'s shifts were adjusted through Asset Shift Allocation {1}." + ).format( + get_link_to_form("Asset", self.asset), + get_link_to_form(self.doctype, self.name), + ) + + new_asset_depr_schedule_doc.notes = notes + + self.asset_depr_schedule_doc.flags.should_not_cancel_depreciation_entries = True + self.asset_depr_schedule_doc.cancel() + + new_asset_depr_schedule_doc.submit() + + add_asset_activity( + self.asset, + _("Asset's depreciation schedule updated after Asset Shift Allocation {0}").format( + get_link_to_form(self.doctype, self.name) + ), + ) + + +def find_subsets_with_sum(numbers, k, target_sum, current_subset, result): + if k == 0 and target_sum == 0: + result.append(current_subset.copy()) + return + if k <= 0 or target_sum <= 0 or not numbers: + return + + # Include the current number in the subset + find_subsets_with_sum( + numbers, k - 1, target_sum - numbers[0], current_subset + [numbers[0]], result + ) + + # Exclude the current number from the subset + find_subsets_with_sum(numbers[1:], k, target_sum, current_subset, result) diff --git a/erpnext/assets/doctype/asset_shift_allocation/test_asset_shift_allocation.py b/erpnext/assets/doctype/asset_shift_allocation/test_asset_shift_allocation.py new file mode 100644 index 00000000000..8d00a24f6b2 --- /dev/null +++ b/erpnext/assets/doctype/asset_shift_allocation/test_asset_shift_allocation.py @@ -0,0 +1,113 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import cstr + +from erpnext.assets.doctype.asset.test_asset import create_asset +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_depr_schedule, +) + + +class TestAssetShiftAllocation(FrappeTestCase): + @classmethod + def setUpClass(cls): + create_asset_shift_factors() + + @classmethod + def tearDownClass(cls): + frappe.db.rollback() + + def test_asset_shift_allocation(self): + asset = create_asset( + calculate_depreciation=1, + available_for_use_date="2023-01-01", + purchase_date="2023-01-01", + gross_purchase_amount=120000, + depreciation_start_date="2023-01-31", + total_number_of_depreciations=12, + frequency_of_depreciation=1, + shift_based=1, + submit=1, + ) + + expected_schedules = [ + ["2023-01-31", 10000.0, 10000.0, "Single"], + ["2023-02-28", 10000.0, 20000.0, "Single"], + ["2023-03-31", 10000.0, 30000.0, "Single"], + ["2023-04-30", 10000.0, 40000.0, "Single"], + ["2023-05-31", 10000.0, 50000.0, "Single"], + ["2023-06-30", 10000.0, 60000.0, "Single"], + ["2023-07-31", 10000.0, 70000.0, "Single"], + ["2023-08-31", 10000.0, 80000.0, "Single"], + ["2023-09-30", 10000.0, 90000.0, "Single"], + ["2023-10-31", 10000.0, 100000.0, "Single"], + ["2023-11-30", 10000.0, 110000.0, "Single"], + ["2023-12-31", 10000.0, 120000.0, "Single"], + ] + + schedules = [ + [cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount, d.shift] + for d in get_depr_schedule(asset.name, "Active") + ] + + self.assertEqual(schedules, expected_schedules) + + asset_shift_allocation = frappe.get_doc( + {"doctype": "Asset Shift Allocation", "asset": asset.name} + ).insert() + + schedules = [ + [cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount, d.shift] + for d in asset_shift_allocation.get("depreciation_schedule") + ] + + self.assertEqual(schedules, expected_schedules) + + asset_shift_allocation = frappe.get_doc("Asset Shift Allocation", asset_shift_allocation.name) + asset_shift_allocation.depreciation_schedule[4].shift = "Triple" + asset_shift_allocation.save() + + schedules = [ + [cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount, d.shift] + for d in asset_shift_allocation.get("depreciation_schedule") + ] + + expected_schedules = [ + ["2023-01-31", 10000.0, 10000.0, "Single"], + ["2023-02-28", 10000.0, 20000.0, "Single"], + ["2023-03-31", 10000.0, 30000.0, "Single"], + ["2023-04-30", 10000.0, 40000.0, "Single"], + ["2023-05-31", 20000.0, 60000.0, "Triple"], + ["2023-06-30", 10000.0, 70000.0, "Single"], + ["2023-07-31", 10000.0, 80000.0, "Single"], + ["2023-08-31", 10000.0, 90000.0, "Single"], + ["2023-09-30", 10000.0, 100000.0, "Single"], + ["2023-10-31", 10000.0, 110000.0, "Single"], + ["2023-11-30", 10000.0, 120000.0, "Single"], + ] + + self.assertEqual(schedules, expected_schedules) + + asset_shift_allocation.submit() + + schedules = [ + [cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount, d.shift] + for d in get_depr_schedule(asset.name, "Active") + ] + + self.assertEqual(schedules, expected_schedules) + + +def create_asset_shift_factors(): + shifts = [ + {"doctype": "Asset Shift Factor", "shift_name": "Half", "shift_factor": 0.5, "default": 0}, + {"doctype": "Asset Shift Factor", "shift_name": "Single", "shift_factor": 1, "default": 1}, + {"doctype": "Asset Shift Factor", "shift_name": "Double", "shift_factor": 1.5, "default": 0}, + {"doctype": "Asset Shift Factor", "shift_name": "Triple", "shift_factor": 2, "default": 0}, + ] + + for s in shifts: + frappe.get_doc(s).insert() diff --git a/erpnext/assets/doctype/asset_shift_factor/__init__.py b/erpnext/assets/doctype/asset_shift_factor/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/assets/doctype/asset_shift_factor/asset_shift_factor.js b/erpnext/assets/doctype/asset_shift_factor/asset_shift_factor.js new file mode 100644 index 00000000000..88887fea877 --- /dev/null +++ b/erpnext/assets/doctype/asset_shift_factor/asset_shift_factor.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Asset Shift Factor", { +// refresh(frm) { + +// }, +// }); diff --git a/erpnext/assets/doctype/asset_shift_factor/asset_shift_factor.json b/erpnext/assets/doctype/asset_shift_factor/asset_shift_factor.json new file mode 100644 index 00000000000..fd04ffc5d44 --- /dev/null +++ b/erpnext/assets/doctype/asset_shift_factor/asset_shift_factor.json @@ -0,0 +1,74 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:shift_name", + "creation": "2023-11-27 18:16:03.980086", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "shift_name", + "shift_factor", + "default" + ], + "fields": [ + { + "fieldname": "shift_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Shift Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "shift_factor", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Shift Factor", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "default", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Default" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2023-11-29 04:04:24.272872", + "modified_by": "Administrator", + "module": "Assets", + "name": "Asset Shift Factor", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_shift_factor/asset_shift_factor.py b/erpnext/assets/doctype/asset_shift_factor/asset_shift_factor.py new file mode 100644 index 00000000000..4c275ce092c --- /dev/null +++ b/erpnext/assets/doctype/asset_shift_factor/asset_shift_factor.py @@ -0,0 +1,24 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + + +class AssetShiftFactor(Document): + def validate(self): + self.validate_default() + + def validate_default(self): + if self.default: + existing_default_shift_factor = frappe.db.get_value( + "Asset Shift Factor", {"default": 1}, "name" + ) + + if existing_default_shift_factor: + frappe.throw( + _("Asset Shift Factor {0} is set as default currently. Please change it first.").format( + frappe.bold(existing_default_shift_factor) + ) + ) diff --git a/erpnext/assets/doctype/asset_shift_factor/test_asset_shift_factor.py b/erpnext/assets/doctype/asset_shift_factor/test_asset_shift_factor.py new file mode 100644 index 00000000000..75073673c0c --- /dev/null +++ b/erpnext/assets/doctype/asset_shift_factor/test_asset_shift_factor.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestAssetShiftFactor(FrappeTestCase): + pass diff --git a/erpnext/assets/doctype/depreciation_schedule/depreciation_schedule.json b/erpnext/assets/doctype/depreciation_schedule/depreciation_schedule.json index 884e0c6cb2b..ef706e8f25a 100644 --- a/erpnext/assets/doctype/depreciation_schedule/depreciation_schedule.json +++ b/erpnext/assets/doctype/depreciation_schedule/depreciation_schedule.json @@ -12,6 +12,7 @@ "column_break_3", "accumulated_depreciation_amount", "journal_entry", + "shift", "make_depreciation_entry" ], "fields": [ @@ -57,11 +58,17 @@ "fieldname": "make_depreciation_entry", "fieldtype": "Button", "label": "Make Depreciation Entry" + }, + { + "fieldname": "shift", + "fieldtype": "Link", + "label": "Shift", + "options": "Asset Shift Factor" } ], "istable": 1, "links": [], - "modified": "2023-07-26 12:56:48.718736", + "modified": "2023-11-27 18:28:35.325376", "modified_by": "Administrator", "module": "Assets", "name": "Depreciation Schedule", diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 55c01e85137..0f8574c84df 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -16,7 +16,7 @@ from erpnext.buying.doctype.purchase_order.purchase_order import ( make_purchase_invoice as make_pi_from_po, ) from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt -from erpnext.controllers.accounts_controller import update_child_qty_rate +from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_blanket_order from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.material_request.material_request import make_purchase_order @@ -27,6 +27,21 @@ from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( class TestPurchaseOrder(FrappeTestCase): + def test_purchase_order_qty(self): + po = create_purchase_order(qty=1, do_not_save=True) + po.append( + "items", + { + "item_code": "_Test Item", + "qty": -1, + "rate": 10, + }, + ) + self.assertRaises(frappe.NonNegativeError, po.save) + + po.items[1].qty = 0 + self.assertRaises(InvalidQtyError, po.save) + def test_make_purchase_receipt(self): po = create_purchase_order(do_not_submit=True) self.assertRaises(frappe.ValidationError, make_purchase_receipt, po.name) diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index 2d706f41e5e..98c1b388c14 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -214,6 +214,7 @@ "fieldtype": "Float", "in_list_view": 1, "label": "Quantity", + "non_negative": 1, "oldfieldname": "qty", "oldfieldtype": "Currency", "print_width": "60px", @@ -917,7 +918,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-11-14 18:34:27.267382", + "modified": "2023-11-24 13:24:41.298416", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 154d4906462..a61680f9b34 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -71,6 +71,10 @@ class AccountMissingError(frappe.ValidationError): pass +class InvalidQtyError(frappe.ValidationError): + pass + + force_item_fields = ( "item_group", "brand", @@ -625,6 +629,7 @@ class AccountsController(TransactionBase): args["doctype"] = self.doctype args["name"] = self.name + args["child_doctype"] = item.doctype args["child_docname"] = item.name args["ignore_pricing_rule"] = ( self.ignore_pricing_rule if hasattr(self, "ignore_pricing_rule") else 0 @@ -910,10 +915,16 @@ class AccountsController(TransactionBase): return flt(args.get(field, 0) / self.get("conversion_rate", 1)) def validate_qty_is_not_zero(self): - if self.doctype != "Purchase Receipt": - for item in self.items: - if not item.qty: - frappe.throw(_("Item quantity can not be zero")) + if self.doctype == "Purchase Receipt": + return + + for item in self.items: + if not flt(item.qty): + frappe.throw( + msg=_("Row #{0}: Item quantity cannot be zero").format(item.idx), + title=_("Invalid Quantity"), + exc=InvalidQtyError, + ) def validate_account_currency(self, account, account_currency=None): valid_currency = [self.company_currency] @@ -3152,16 +3163,19 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil conv_fac_precision = child_item.precision("conversion_factor") or 2 qty_precision = child_item.precision("qty") or 2 - if flt(child_item.billed_amt, rate_precision) > flt( - flt(d.get("rate"), rate_precision) * flt(d.get("qty"), qty_precision), rate_precision - ): + # Amount cannot be lesser than billed amount, except for negative amounts + row_rate = flt(d.get("rate"), rate_precision) + amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( + row_rate * flt(d.get("qty"), qty_precision), rate_precision + ) + if amount_below_billed_amt and row_rate > 0.0: frappe.throw( _("Row #{0}: Cannot set Rate if amount is greater than billed amount for Item {1}.").format( child_item.idx, child_item.item_code ) ) else: - child_item.rate = flt(d.get("rate"), rate_precision) + child_item.rate = row_rate if d.get("conversion_factor"): if child_item.stock_uom == child_item.uom: diff --git a/erpnext/controllers/tests/test_subcontracting_controller.py b/erpnext/controllers/tests/test_subcontracting_controller.py index 6b61ae949da..47762ac4cfd 100644 --- a/erpnext/controllers/tests/test_subcontracting_controller.py +++ b/erpnext/controllers/tests/test_subcontracting_controller.py @@ -1001,6 +1001,7 @@ def make_subcontracted_items(): "Subcontracted Item SA5": {}, "Subcontracted Item SA6": {}, "Subcontracted Item SA7": {}, + "Subcontracted Item SA8": {}, } for item, properties in sub_contracted_items.items(): @@ -1020,6 +1021,7 @@ def make_raw_materials(): }, "Subcontracted SRM Item 4": {"has_serial_no": 1, "serial_no_series": "SRII.####"}, "Subcontracted SRM Item 5": {"has_serial_no": 1, "serial_no_series": "SRIID.####"}, + "Subcontracted SRM Item 8": {}, } for item, properties in raw_materials.items(): @@ -1043,6 +1045,7 @@ def make_service_items(): "Subcontracted Service Item 5": {}, "Subcontracted Service Item 6": {}, "Subcontracted Service Item 7": {}, + "Subcontracted Service Item 8": {}, } for item, properties in service_items.items(): @@ -1066,6 +1069,7 @@ def make_bom_for_subcontracted_items(): "Subcontracted Item SA5": ["Subcontracted SRM Item 5"], "Subcontracted Item SA6": ["Subcontracted SRM Item 3"], "Subcontracted Item SA7": ["Subcontracted SRM Item 1"], + "Subcontracted Item SA8": ["Subcontracted SRM Item 8"], } for item_code, raw_materials in boms.items(): diff --git a/erpnext/crm/doctype/competitor/competitor.json b/erpnext/crm/doctype/competitor/competitor.json index 280441f16fd..fd6da239212 100644 --- a/erpnext/crm/doctype/competitor/competitor.json +++ b/erpnext/crm/doctype/competitor/competitor.json @@ -29,8 +29,16 @@ } ], "index_web_pages_for_search": 1, - "links": [], - "modified": "2021-10-21 12:43:59.106807", + "links": [ + { + "is_child_table": 1, + "link_doctype": "Competitor Detail", + "link_fieldname": "competitor", + "parent_doctype": "Quotation", + "table_fieldname": "competitors" + } + ], + "modified": "2023-11-23 19:33:54.284279", "modified_by": "Administrator", "module": "CRM", "name": "Competitor", @@ -64,5 +72,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index fdec88d70d3..d22cc5548a6 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -36,6 +36,15 @@ class Lead(SellingController, CRMNote): def before_insert(self): self.contact_doc = None if frappe.db.get_single_value("CRM Settings", "auto_creation_of_contact"): + if self.source == "Existing Customer" and self.customer: + contact = frappe.db.get_value( + "Dynamic Link", + {"link_doctype": "Customer", "link_name": self.customer}, + "parent", + ) + if contact: + self.contact_doc = frappe.get_doc("Contact", contact) + return self.contact_doc = self.create_contact() def after_insert(self): diff --git a/erpnext/hooks.py b/erpnext/hooks.py index c6ab6f12f67..857471f1fda 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -419,7 +419,6 @@ scheduler_events = { "erpnext.projects.doctype.project.project.collect_project_status", ], "hourly_long": [ - "erpnext.accounts.doctype.process_subscription.process_subscription.create_subscription_process", "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries", "erpnext.utilities.bulk_transaction.retry", ], @@ -450,6 +449,7 @@ scheduler_events = { "erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly", ], "daily_long": [ + "erpnext.accounts.doctype.process_subscription.process_subscription.create_subscription_process", "erpnext.setup.doctype.email_digest.email_digest.send", "erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.auto_update_latest_price_in_all_boms", "erpnext.crm.utils.open_leads_opportunities_based_on_todays_event", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index db6bc80838f..f303531aee1 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -185,7 +185,8 @@ class JobCard(Document): # override capacity for employee production_capacity = 1 - if time_logs and production_capacity > len(time_logs): + overlap_count = self.get_overlap_count(time_logs) + if time_logs and production_capacity > overlap_count: return {} if self.workstation_type and time_logs: @@ -195,6 +196,37 @@ class JobCard(Document): return time_logs[-1] + @staticmethod + def get_overlap_count(time_logs): + count = 1 + + # Check overlap exists or not between the overlapping time logs with the current Job Card + for idx, row in enumerate(time_logs): + next_idx = idx + if idx + 1 < len(time_logs): + next_idx = idx + 1 + next_row = time_logs[next_idx] + if row.name == next_row.name: + continue + + if ( + ( + get_datetime(next_row.from_time) >= get_datetime(row.from_time) + and get_datetime(next_row.from_time) <= get_datetime(row.to_time) + ) + or ( + get_datetime(next_row.to_time) >= get_datetime(row.from_time) + and get_datetime(next_row.to_time) <= get_datetime(row.to_time) + ) + or ( + get_datetime(next_row.from_time) <= get_datetime(row.from_time) + and get_datetime(next_row.to_time) >= get_datetime(row.to_time) + ) + ): + count += 1 + + return count + def get_time_logs(self, args, doctype, check_next_available_slot=False): jc = frappe.qb.DocType("Job Card") jctl = frappe.qb.DocType(doctype) @@ -211,7 +243,14 @@ class JobCard(Document): query = ( frappe.qb.from_(jctl) .from_(jc) - .select(jc.name.as_("name"), jctl.from_time, jctl.to_time, jc.workstation, jc.workstation_type) + .select( + jc.name.as_("name"), + jctl.name.as_("row_name"), + jctl.from_time, + jctl.to_time, + jc.workstation, + jc.workstation_type, + ) .where( (jctl.parent == jc.name) & (Criterion.any(time_conditions)) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 0ae7657c422..e2c8f079805 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -921,6 +921,20 @@ class TestWorkOrder(FrappeTestCase): "Test RM Item 2 for Scrap Item Test", ] + from_time = add_days(now(), -1) + job_cards = frappe.get_all( + "Job Card Time Log", + fields=["distinct parent as name", "docstatus"], + filters={"from_time": (">", from_time)}, + order_by="creation asc", + ) + + for job_card in job_cards: + if job_card.docstatus == 1: + frappe.get_doc("Job Card", job_card.name).cancel() + + frappe.delete_doc("Job Card Time Log", job_card.name) + company = "_Test Company with perpetual inventory" for item_code in items: create_item( diff --git a/erpnext/patches/v15_0/create_asset_depreciation_schedules_from_assets.py b/erpnext/patches/v15_0/create_asset_depreciation_schedules_from_assets.py index 9a2a39fb78c..793497b766e 100644 --- a/erpnext/patches/v15_0/create_asset_depreciation_schedules_from_assets.py +++ b/erpnext/patches/v15_0/create_asset_depreciation_schedules_from_assets.py @@ -86,6 +86,7 @@ def get_asset_finance_books_map(): afb.frequency_of_depreciation, afb.rate_of_depreciation, afb.expected_value_after_useful_life, + afb.shift_based, ) .where(asset.docstatus < 2) .orderby(afb.idx) diff --git a/erpnext/public/js/communication.js b/erpnext/public/js/communication.js index 7ce8b0913c3..f205d889658 100644 --- a/erpnext/public/js/communication.js +++ b/erpnext/public/js/communication.js @@ -13,7 +13,7 @@ frappe.ui.form.on("Communication", { frappe.confirm(__(confirm_msg, [__("Issue")]), () => { frm.trigger('make_issue_from_communication'); }) - }, "Create"); + }, __("Create")); } if(!in_list(["Lead", "Opportunity"], frm.doc.reference_doctype)) { diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 2c40f4964be..6dc24faa967 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -512,6 +512,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe cost_center: item.cost_center, tax_category: me.frm.doc.tax_category, item_tax_template: item.item_tax_template, + child_doctype: item.doctype, child_docname: item.name, is_old_subcontracting_flow: me.frm.doc.is_old_subcontracting_flow, } diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.py b/erpnext/regional/report/uae_vat_201/uae_vat_201.py index 59ef58bfde3..6ef21e52ca1 100644 --- a/erpnext/regional/report/uae_vat_201/uae_vat_201.py +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.py @@ -141,7 +141,7 @@ def get_total_emiratewise(filters): return frappe.db.sql( """ select - s.vat_emirate as emirate, sum(i.base_amount) as total, sum(i.tax_amount) + s.vat_emirate as emirate, sum(i.base_net_amount) as total, sum(i.tax_amount) from `tabSales Invoice Item` i inner join `tabSales Invoice` s on @@ -356,7 +356,7 @@ def get_zero_rated_total(filters): frappe.db.sql( """ select - sum(i.base_amount) as total + sum(i.base_net_amount) as total from `tabSales Invoice Item` i inner join `tabSales Invoice` s on @@ -383,7 +383,7 @@ def get_exempt_total(filters): frappe.db.sql( """ select - sum(i.base_amount) as total + sum(i.base_net_amount) as total from `tabSales Invoice Item` i inner join `tabSales Invoice` s on diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.py b/erpnext/selling/doctype/product_bundle/product_bundle.py index 2fd9cc13012..3d4ffebbfb4 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/product_bundle.py @@ -76,16 +76,19 @@ class ProductBundle(Document): @frappe.validate_and_sanitize_search_inputs def get_new_item_code(doctype, txt, searchfield, start, page_len, filters): product_bundles = frappe.db.get_list("Product Bundle", {"disabled": 0}, pluck="name") + item = frappe.qb.DocType("Item") - return ( + query = ( frappe.qb.from_(item) - .select("*") + .select(item.item_code, item.item_name) .where( - (item.is_stock_item == 0) - & (item.is_fixed_asset == 0) - & (item.name.notin(product_bundles)) - & (item[searchfield].like(f"%{txt}%")) + (item.is_stock_item == 0) & (item.is_fixed_asset == 0) & (item[searchfield].like(f"%{txt}%")) ) .limit(page_len) .offset(start) - ).run() + ) + + if product_bundles: + query = query.where(item.name.notin(product_bundles)) + + return query.run() diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 3ad18daf193..97b214e33e5 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -214,13 +214,12 @@ frappe.ui.form.on("Sales Order", { label: __("Items to Reserve"), allow_bulk_edit: false, cannot_add_rows: true, - cannot_delete_rows: true, data: [], fields: [ { - fieldname: "name", + fieldname: "sales_order_item", fieldtype: "Data", - label: __("Name"), + label: __("Sales Order Item"), reqd: 1, read_only: 1, }, @@ -260,7 +259,7 @@ frappe.ui.form.on("Sales Order", { ], primary_action_label: __("Reserve Stock"), primary_action: () => { - var data = {items: dialog.fields_dict.items.grid.get_selected_children()}; + var data = {items: dialog.fields_dict.items.grid.data}; if (data.items && data.items.length > 0) { frappe.call({ @@ -278,9 +277,6 @@ frappe.ui.form.on("Sales Order", { } }); } - else { - frappe.msgprint(__("Please select items to reserve.")); - } dialog.hide(); }, @@ -292,7 +288,7 @@ frappe.ui.form.on("Sales Order", { if (unreserved_qty > 0) { dialog.fields_dict.items.df.data.push({ - 'name': item.name, + 'sales_order_item': item.name, 'item_code': item.item_code, 'warehouse': item.warehouse, 'qty_to_reserve': (unreserved_qty / flt(item.conversion_factor)) @@ -308,7 +304,7 @@ frappe.ui.form.on("Sales Order", { cancel_stock_reservation_entries(frm) { const dialog = new frappe.ui.Dialog({ title: __("Stock Unreservation"), - size: "large", + size: "extra-large", fields: [ { fieldname: "sr_entries", @@ -316,14 +312,13 @@ frappe.ui.form.on("Sales Order", { label: __("Reserved Stock"), allow_bulk_edit: false, cannot_add_rows: true, - cannot_delete_rows: true, in_place_edit: true, data: [], fields: [ { - fieldname: "name", + fieldname: "sre", fieldtype: "Link", - label: __("SRE"), + label: __("Stock Reservation Entry"), options: "Stock Reservation Entry", reqd: 1, read_only: 1, @@ -360,14 +355,14 @@ frappe.ui.form.on("Sales Order", { ], primary_action_label: __("Unreserve Stock"), primary_action: () => { - var data = {sr_entries: dialog.fields_dict.sr_entries.grid.get_selected_children()}; + var data = {sr_entries: dialog.fields_dict.sr_entries.grid.data}; if (data.sr_entries && data.sr_entries.length > 0) { frappe.call({ doc: frm.doc, method: "cancel_stock_reservation_entries", args: { - sre_list: data.sr_entries, + sre_list: data.sr_entries.map(item => item.sre), }, freeze: true, freeze_message: __('Unreserving Stock...'), @@ -377,9 +372,6 @@ frappe.ui.form.on("Sales Order", { } }); } - else { - frappe.msgprint(__("Please select items to unreserve.")); - } dialog.hide(); }, @@ -396,7 +388,7 @@ frappe.ui.form.on("Sales Order", { r.message.forEach(sre => { if (flt(sre.reserved_qty) > flt(sre.delivered_qty)) { dialog.fields_dict.sr_entries.df.data.push({ - 'name': sre.name, + 'sre': sre.name, 'item_code': sre.item_code, 'warehouse': sre.warehouse, 'qty': (flt(sre.reserved_qty) - flt(sre.delivered_qty)) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index d8b5878aa30..a518597aa6f 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -51,6 +51,35 @@ class TestSalesOrder(FrappeTestCase): def tearDown(self): frappe.set_user("Administrator") + def test_sales_order_with_negative_rate(self): + """ + Test if negative rate is allowed in Sales Order via doc submission and update items + """ + so = make_sales_order(qty=1, rate=100, do_not_save=True) + so.append("items", {"item_code": "_Test Item", "qty": 1, "rate": -10}) + so.save() + so.submit() + + first_item = so.get("items")[0] + second_item = so.get("items")[1] + trans_item = json.dumps( + [ + { + "item_code": first_item.item_code, + "rate": first_item.rate, + "qty": first_item.qty, + "docname": first_item.name, + }, + { + "item_code": second_item.item_code, + "rate": -20, + "qty": second_item.qty, + "docname": second_item.name, + }, + ] + ) + update_child_qty_rate("Sales Order", trans_item, so.name) + def test_make_material_request(self): so = make_sales_order(do_not_submit=True) diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index b4f73003aef..d4ccfc4753d 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -200,6 +200,7 @@ "fieldtype": "Float", "in_list_view": 1, "label": "Quantity", + "non_negative": 1, "oldfieldname": "qty", "oldfieldtype": "Currency", "print_width": "100px", @@ -895,7 +896,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2023-11-14 18:37:12.787893", + "modified": "2023-11-24 13:24:55.756320", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/selling/report/lost_quotations/__init__.py b/erpnext/selling/report/lost_quotations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/selling/report/lost_quotations/lost_quotations.js b/erpnext/selling/report/lost_quotations/lost_quotations.js new file mode 100644 index 00000000000..78e76cbf02a --- /dev/null +++ b/erpnext/selling/report/lost_quotations/lost_quotations.js @@ -0,0 +1,40 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.query_reports["Lost Quotations"] = { + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + }, + { + label: "Timespan", + fieldtype: "Select", + fieldname: "timespan", + options: [ + "Last Week", + "Last Month", + "Last Quarter", + "Last 6 months", + "Last Year", + "This Week", + "This Month", + "This Quarter", + "This Year", + ], + default: "This Year", + reqd: 1, + }, + { + fieldname: "group_by", + label: __("Group By"), + fieldtype: "Select", + options: ["Lost Reason", "Competitor"], + default: "Lost Reason", + reqd: 1, + }, + ], +}; diff --git a/erpnext/selling/report/lost_quotations/lost_quotations.json b/erpnext/selling/report/lost_quotations/lost_quotations.json new file mode 100644 index 00000000000..8915bab595e --- /dev/null +++ b/erpnext/selling/report/lost_quotations/lost_quotations.json @@ -0,0 +1,30 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2023-11-23 18:00:19.141922", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letter_head": null, + "letterhead": null, + "modified": "2023-11-23 19:27:28.854108", + "modified_by": "Administrator", + "module": "Selling", + "name": "Lost Quotations", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Quotation", + "report_name": "Lost Quotations", + "report_type": "Script Report", + "roles": [ + { + "role": "Sales User" + }, + { + "role": "Sales Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/selling/report/lost_quotations/lost_quotations.py b/erpnext/selling/report/lost_quotations/lost_quotations.py new file mode 100644 index 00000000000..7c0bfbdd525 --- /dev/null +++ b/erpnext/selling/report/lost_quotations/lost_quotations.py @@ -0,0 +1,98 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from typing import Literal + +import frappe +from frappe import _ +from frappe.model.docstatus import DocStatus +from frappe.query_builder.functions import Coalesce, Count, Round, Sum +from frappe.utils.data import get_timespan_date_range + + +def execute(filters=None): + columns = get_columns(filters.get("group_by")) + from_date, to_date = get_timespan_date_range(filters.get("timespan").lower()) + data = get_data(filters.get("company"), from_date, to_date, filters.get("group_by")) + return columns, data + + +def get_columns(group_by: Literal["Lost Reason", "Competitor"]): + return [ + { + "fieldname": "lost_reason" if group_by == "Lost Reason" else "competitor", + "label": _("Lost Reason") if group_by == "Lost Reason" else _("Competitor"), + "fieldtype": "Link", + "options": "Quotation Lost Reason" if group_by == "Lost Reason" else "Competitor", + "width": 200, + }, + { + "filedname": "lost_quotations", + "label": _("Lost Quotations"), + "fieldtype": "Int", + "width": 150, + }, + { + "filedname": "lost_quotations_pct", + "label": _("Lost Quotations %"), + "fieldtype": "Percent", + "width": 200, + }, + { + "fieldname": "lost_value", + "label": _("Lost Value"), + "fieldtype": "Currency", + "width": 150, + }, + { + "filedname": "lost_value_pct", + "label": _("Lost Value %"), + "fieldtype": "Percent", + "width": 200, + }, + ] + + +def get_data( + company: str, from_date: str, to_date: str, group_by: Literal["Lost Reason", "Competitor"] +): + """Return quotation value grouped by lost reason or competitor""" + if group_by == "Lost Reason": + fieldname = "lost_reason" + dimension = frappe.qb.DocType("Quotation Lost Reason Detail") + elif group_by == "Competitor": + fieldname = "competitor" + dimension = frappe.qb.DocType("Competitor Detail") + else: + frappe.throw(_("Invalid Group By")) + + q = frappe.qb.DocType("Quotation") + + lost_quotation_condition = ( + (q.status == "Lost") + & (q.docstatus == DocStatus.submitted()) + & (q.transaction_date >= from_date) + & (q.transaction_date <= to_date) + & (q.company == company) + ) + + from_lost_quotations = frappe.qb.from_(q).where(lost_quotation_condition) + total_quotations = from_lost_quotations.select(Count(q.name)) + total_value = from_lost_quotations.select(Sum(q.base_net_total)) + + query = ( + frappe.qb.from_(q) + .select( + Coalesce(dimension[fieldname], _("Not Specified")), + Count(q.name).distinct(), + Round((Count(q.name).distinct() / total_quotations * 100), 2), + Sum(q.base_net_total), + Round((Sum(q.base_net_total) / total_value * 100), 2), + ) + .left_join(dimension) + .on(dimension.parent == q.name) + .where(lost_quotation_condition) + .groupby(dimension[fieldname]) + ) + + return query.run() diff --git a/erpnext/setup/doctype/quotation_lost_reason/quotation_lost_reason.json b/erpnext/setup/doctype/quotation_lost_reason/quotation_lost_reason.json index 5d778eec0b4..0eae08e8707 100644 --- a/erpnext/setup/doctype/quotation_lost_reason/quotation_lost_reason.json +++ b/erpnext/setup/doctype/quotation_lost_reason/quotation_lost_reason.json @@ -1,83 +1,58 @@ { - "allow_copy": 0, - "allow_import": 1, - "allow_rename": 0, - "autoname": "field:order_lost_reason", - "beta": 0, - "creation": "2013-01-10 16:34:24", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 0, + "actions": [], + "allow_import": 1, + "autoname": "field:order_lost_reason", + "creation": "2013-01-10 16:34:24", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "order_lost_reason" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "order_lost_reason", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Quotation Lost Reason", - "length": 0, - "no_copy": 0, - "oldfieldname": "order_lost_reason", - "oldfieldtype": "Data", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "order_lost_reason", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Quotation Lost Reason", + "oldfieldname": "order_lost_reason", + "oldfieldtype": "Data", + "reqd": 1, + "unique": 1 } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-flag", - "idx": 1, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2016-07-25 05:24:25.533953", - "modified_by": "Administrator", - "module": "Setup", - "name": "Quotation Lost Reason", - "owner": "Administrator", + ], + "icon": "fa fa-flag", + "idx": 1, + "links": [ + { + "is_child_table": 1, + "link_doctype": "Quotation Lost Reason Detail", + "link_fieldname": "lost_reason", + "parent_doctype": "Quotation", + "table_fieldname": "lost_reasons" + } + ], + "modified": "2023-11-23 19:31:02.743353", + "modified_by": "Administrator", + "module": "Setup", + "name": "Quotation Lost Reason", + "naming_rule": "By fieldname", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Sales Master Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Master Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "track_seen": 0 + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 137c352e99a..94655747e43 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -1247,6 +1247,25 @@ class TestDeliveryNote(FrappeTestCase): dn.reload() self.assertFalse(dn.items[0].target_warehouse) + def test_serial_no_status(self): + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + item = make_item( + "Test Serial Item For Status", + {"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TESTSERIAL.#####"}, + ) + + item_code = item.name + pi = make_purchase_receipt(qty=1, item_code=item.name) + pi.reload() + serial_no = get_serial_nos_from_bundle(pi.items[0].serial_and_batch_bundle) + + self.assertEqual(frappe.db.get_value("Serial No", serial_no, "status"), "Active") + + dn = create_delivery_note(qty=1, item_code=item_code, serial_no=serial_no) + dn.reload() + self.assertEqual(frappe.db.get_value("Serial No", serial_no, "status"), "Delivered") + def create_delivery_note(**args): dn = frappe.new_doc("Delivery Note") diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 644df3d29a3..e7f620496cf 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -233,7 +233,7 @@ class PickList(Document): for location in self.locations: if location.warehouse and location.sales_order and location.sales_order_item: item_details = { - "name": location.sales_order_item, + "sales_order_item": location.sales_order_item, "item_code": location.item_code, "warehouse": location.warehouse, "qty_to_reserve": (flt(location.picked_qty) - flt(location.stock_reserved_qty)), diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index e02dfedc7dc..4d8519d4c29 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -573,7 +573,7 @@ class PurchaseReceipt(BuyingController): ) stock_value_diff = ( - flt(d.net_amount) + flt(d.base_net_amount) + flt(d.item_tax_amount / self.conversion_rate) + flt(d.landed_cost_voucher_amount) ) @@ -784,7 +784,7 @@ class PurchaseReceipt(BuyingController): for item in self.items: if item.sales_order and item.sales_order_item: item_details = { - "name": item.sales_order_item, + "sales_order_item": item.sales_order_item, "item_code": item.item_code, "warehouse": item.warehouse, "qty_to_reserve": item.stock_qty, diff --git a/erpnext/stock/doctype/serial_no/serial_no.js b/erpnext/stock/doctype/serial_no/serial_no.js index 9d5555ed631..1cb9fd1800e 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.js +++ b/erpnext/stock/doctype/serial_no/serial_no.js @@ -18,3 +18,22 @@ cur_frm.cscript.onload = function() { frappe.ui.form.on("Serial No", "refresh", function(frm) { frm.toggle_enable("item_code", frm.doc.__islocal); }); + + +frappe.ui.form.on("Serial No", { + refresh(frm) { + frm.trigger("view_ledgers") + }, + + view_ledgers(frm) { + frm.add_custom_button(__("View Ledgers"), () => { + frappe.route_options = { + "item_code": frm.doc.item_code, + "serial_no": frm.doc.name, + "posting_date": frappe.datetime.now_date(), + "posting_time": frappe.datetime.now_time() + }; + frappe.set_route("query-report", "Serial No Ledger"); + }).addClass('btn-primary'); + } +}) \ No newline at end of file diff --git a/erpnext/stock/doctype/serial_no/serial_no.json b/erpnext/stock/doctype/serial_no/serial_no.json index ed1b0af30a6..b4ece00fe64 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.json +++ b/erpnext/stock/doctype/serial_no/serial_no.json @@ -269,7 +269,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Status", - "options": "\nActive\nInactive\nExpired", + "options": "\nActive\nInactive\nDelivered\nExpired", "read_only": 1 }, { @@ -280,7 +280,7 @@ "icon": "fa fa-barcode", "idx": 1, "links": [], - "modified": "2023-04-16 15:58:46.139887", + "modified": "2023-11-28 15:37:59.489945", "modified_by": "Administrator", "module": "Stock", "name": "Serial No", diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index 09542826f3c..cbfa4e0a432 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -869,7 +869,7 @@ def create_stock_reservation_entries_for_so_items( items = [] if items_details: for item in items_details: - so_item = frappe.get_doc("Sales Order Item", item.get("name")) + so_item = frappe.get_doc("Sales Order Item", item.get("sales_order_item")) so_item.warehouse = item.get("warehouse") so_item.qty_to_reserve = ( flt(item.get("qty_to_reserve")) @@ -1053,12 +1053,14 @@ def cancel_stock_reservation_entries( from_voucher_type: Literal["Pick List", "Purchase Receipt"] = None, from_voucher_no: str = None, from_voucher_detail_no: str = None, - sre_list: list[dict] = None, + sre_list: list = None, notify: bool = True, ) -> None: """Cancel Stock Reservation Entries.""" if not sre_list: + sre_list = {} + if voucher_type and voucher_no: sre_list = get_stock_reservation_entries_for_voucher( voucher_type, voucher_no, voucher_detail_no, fields=["name"] @@ -1082,9 +1084,11 @@ def cancel_stock_reservation_entries( sre_list = query.run(as_dict=True) + sre_list = [d.name for d in sre_list] + if sre_list: for sre in sre_list: - frappe.get_doc("Stock Reservation Entry", sre["name"]).cancel() + frappe.get_doc("Stock Reservation Entry", sre).cancel() if notify: msg = _("Stock Reservation Entries Cancelled") diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index d1a9cf26acc..dfeb1ee7fb1 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -8,6 +8,7 @@ import frappe from frappe import _, throw from frappe.model import child_table_fields, default_fields from frappe.model.meta import get_field_precision +from frappe.model.utils import get_fetch_values from frappe.query_builder.functions import IfNull, Sum from frappe.utils import add_days, add_months, cint, cstr, flt, getdate @@ -571,6 +572,9 @@ def get_item_tax_template(args, item, out): item_tax_template = _get_item_tax_template(args, item_group_doc.taxes, out) item_group = item_group_doc.parent_item_group + if args.child_doctype and item_tax_template: + out.update(get_fetch_values(args.child_doctype, "item_tax_template", item_tax_template)) + def _get_item_tax_template(args, taxes, out=None, for_validate=False): if out is None: diff --git a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py index 7212b92bb31..ae12fbb3e4f 100644 --- a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py +++ b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py @@ -36,21 +36,27 @@ def get_columns(filters): "fieldtype": "Link", "fieldname": "company", "options": "Company", - "width": 150, + "width": 120, }, { "label": _("Warehouse"), "fieldtype": "Link", "fieldname": "warehouse", "options": "Warehouse", - "width": 150, + "width": 120, + }, + { + "label": _("Status"), + "fieldtype": "Data", + "fieldname": "status", + "width": 120, }, { "label": _("Serial No"), "fieldtype": "Link", "fieldname": "serial_no", "options": "Serial No", - "width": 150, + "width": 130, }, { "label": _("Valuation Rate"), @@ -58,6 +64,12 @@ def get_columns(filters): "fieldname": "valuation_rate", "width": 150, }, + { + "label": _("Qty"), + "fieldtype": "Float", + "fieldname": "qty", + "width": 150, + }, ] return columns @@ -83,12 +95,16 @@ def get_data(filters): "posting_time": row.posting_time, "voucher_type": row.voucher_type, "voucher_no": row.voucher_no, + "status": "Active" if row.actual_qty > 0 else "Delivered", "company": row.company, "warehouse": row.warehouse, + "qty": 1 if row.actual_qty > 0 else -1, } ) - serial_nos = bundle_wise_serial_nos.get(row.serial_and_batch_bundle, []) + serial_nos = [{"serial_no": row.serial_no, "valuation_rate": row.valuation_rate}] + if row.serial_and_batch_bundle: + serial_nos = bundle_wise_serial_nos.get(row.serial_and_batch_bundle, []) for index, bundle_data in enumerate(serial_nos): if index == 0: diff --git a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py index b1da3ec1bd1..416cf48871a 100644 --- a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py +++ b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py @@ -166,4 +166,4 @@ def create_reposting_entries(rows, company): if entries: entries = ", ".join(entries) - frappe.msgprint(_(f"Reposting entries created: {entries}")) + frappe.msgprint(_("Reposting entries created: {0}").format(entries)) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index da98455b5cb..de28be1c357 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -255,11 +255,15 @@ class SerialBatchBundle: if not serial_nos: return + status = "Inactive" + if self.sle.actual_qty < 0: + status = "Delivered" + sn_table = frappe.qb.DocType("Serial No") ( frappe.qb.update(sn_table) .set(sn_table.warehouse, warehouse) - .set(sn_table.status, "Active" if warehouse else "Inactive") + .set(sn_table.status, "Active" if warehouse else status) .where(sn_table.name.isin(serial_nos)) ).run() diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index faf0cadb755..70ca1c31f5c 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -8,7 +8,7 @@ from frappe.utils import flt from erpnext.buying.doctype.purchase_order.purchase_order import is_subcontracting_order_created from erpnext.controllers.subcontracting_controller import SubcontractingController -from erpnext.stock.stock_balance import get_ordered_qty, update_bin_qty +from erpnext.stock.stock_balance import update_bin_qty from erpnext.stock.utils import get_bin @@ -114,7 +114,32 @@ class SubcontractingOrder(SubcontractingController): ): item_wh_list.append([item.item_code, item.warehouse]) for item_code, warehouse in item_wh_list: - update_bin_qty(item_code, warehouse, {"ordered_qty": get_ordered_qty(item_code, warehouse)}) + update_bin_qty( + item_code, warehouse, {"ordered_qty": self.get_ordered_qty(item_code, warehouse)} + ) + + @staticmethod + def get_ordered_qty(item_code, warehouse): + table = frappe.qb.DocType("Subcontracting Order") + child = frappe.qb.DocType("Subcontracting Order Item") + + query = ( + frappe.qb.from_(table) + .inner_join(child) + .on(table.name == child.parent) + .select((child.qty - child.received_qty) * child.conversion_factor) + .where( + (table.docstatus == 1) + & (child.item_code == item_code) + & (child.warehouse == warehouse) + & (child.qty > child.received_qty) + & (table.status != "Completed") + ) + ) + + query = query.run() + + return flt(query[0][0]) if query else 0 def update_reserved_qty_for_subcontracting(self): for item in self.supplied_items: diff --git a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py index 22fdc13cc1d..35578589351 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py @@ -6,6 +6,7 @@ from collections import defaultdict import frappe from frappe.tests.utils import FrappeTestCase +from frappe.utils import flt from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_subcontracting_order from erpnext.controllers.subcontracting_controller import ( @@ -566,6 +567,67 @@ class TestSubcontractingOrder(FrappeTestCase): self.assertEqual(sco.status, "Closed") self.assertEqual(sco.supplied_items[0].returned_qty, 5) + def test_ordered_qty_for_subcontracting_order(self): + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 8", + "qty": 10, + "rate": 100, + "fg_item": "Subcontracted Item SA8", + "fg_item_qty": 10, + }, + ] + + ordered_qty = frappe.db.get_value( + "Bin", + filters={"warehouse": "_Test Warehouse - _TC", "item_code": "Subcontracted Item SA8"}, + fieldname="ordered_qty", + ) + ordered_qty = flt(ordered_qty) + + sco = get_subcontracting_order(service_items=service_items) + sco.reload() + + new_ordered_qty = frappe.db.get_value( + "Bin", + filters={"warehouse": "_Test Warehouse - _TC", "item_code": "Subcontracted Item SA8"}, + fieldname="ordered_qty", + ) + new_ordered_qty = flt(new_ordered_qty) + + self.assertEqual(ordered_qty + 10, new_ordered_qty) + + for row in sco.supplied_items: + make_stock_entry( + target="_Test Warehouse 1 - _TC", + item_code=row.rm_item_code, + qty=row.required_qty, + basic_rate=100, + ) + + scr = make_subcontracting_receipt(sco.name) + scr.submit() + + new_ordered_qty = frappe.db.get_value( + "Bin", + filters={"warehouse": "_Test Warehouse - _TC", "item_code": "Subcontracted Item SA8"}, + fieldname="ordered_qty", + ) + + self.assertEqual(ordered_qty, new_ordered_qty) + + scr.reload() + scr.cancel() + + new_ordered_qty = frappe.db.get_value( + "Bin", + filters={"warehouse": "_Test Warehouse - _TC", "item_code": "Subcontracted Item SA8"}, + fieldname="ordered_qty", + ) + + self.assertEqual(ordered_qty + 10, new_ordered_qty) + def create_subcontracting_order(**args): args = frappe._dict(args) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 8d705aa97df..ae64cc632aa 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -95,12 +95,12 @@ class SubcontractingReceipt(SubcontractingController): ) self.update_status_updater_args() self.update_prevdoc_status() - self.update_stock_ledger() - self.make_gl_entries_on_cancel() - self.repost_future_sle_and_gle() self.delete_auto_created_batches() self.set_consumed_qty_in_subcontract_order() self.set_subcontracting_order_status() + self.update_stock_ledger() + self.make_gl_entries_on_cancel() + self.repost_future_sle_and_gle() self.update_status() def validate_items_qty(self): diff --git a/erpnext/support/doctype/warranty_claim/warranty_claim.js b/erpnext/support/doctype/warranty_claim/warranty_claim.js index 358768eb46c..10cb37f5124 100644 --- a/erpnext/support/doctype/warranty_claim/warranty_claim.js +++ b/erpnext/support/doctype/warranty_claim/warranty_claim.js @@ -4,93 +4,67 @@ frappe.provide("erpnext.support"); frappe.ui.form.on("Warranty Claim", { - setup: function(frm) { - frm.set_query('contact_person', erpnext.queries.contact_query); - frm.set_query('customer_address', erpnext.queries.address_query); - frm.set_query('customer', erpnext.queries.customer); + setup: (frm) => { + frm.set_query("contact_person", erpnext.queries.contact_query); + frm.set_query("customer_address", erpnext.queries.address_query); + frm.set_query("customer", erpnext.queries.customer); - frm.add_fetch('serial_no', 'item_code', 'item_code'); - frm.add_fetch('serial_no', 'item_name', 'item_name'); - frm.add_fetch('serial_no', 'description', 'description'); - frm.add_fetch('serial_no', 'maintenance_status', 'warranty_amc_status'); - frm.add_fetch('serial_no', 'warranty_expiry_date', 'warranty_expiry_date'); - frm.add_fetch('serial_no', 'amc_expiry_date', 'amc_expiry_date'); - frm.add_fetch('serial_no', 'customer', 'customer'); - frm.add_fetch('serial_no', 'customer_name', 'customer_name'); - frm.add_fetch('item_code', 'item_name', 'item_name'); - frm.add_fetch('item_code', 'description', 'description'); + frm.set_query("serial_no", () => { + let filters = { + company: frm.doc.company, + }; + + if (frm.doc.item_code) { + filters["item_code"] = frm.doc.item_code; + } + + return { filters: filters }; + }); + + frm.set_query("item_code", () => { + return { + filters: { + disabled: 0, + }, + }; + }); }, - onload: function(frm) { - if(!frm.doc.status) { - frm.set_value('status', 'Open'); + + onload: (frm) => { + if (!frm.doc.status) { + frm.set_value("status", "Open"); } }, - customer: function(frm) { + + refresh: (frm) => { + frappe.dynamic_link = { + doc: frm.doc, + fieldname: "customer", + doctype: "Customer", + }; + + if ( + !frm.doc.__islocal && + ["Open", "Work In Progress"].includes(frm.doc.status) + ) { + frm.add_custom_button(__("Maintenance Visit"), () => { + frappe.model.open_mapped_doc({ + method: "erpnext.support.doctype.warranty_claim.warranty_claim.make_maintenance_visit", + frm: frm, + }); + }); + } + }, + + customer: (frm) => { erpnext.utils.get_party_details(frm); }, - customer_address: function(frm) { + + customer_address: (frm) => { erpnext.utils.get_address_display(frm); }, - contact_person: function(frm) { + + contact_person: (frm) => { erpnext.utils.get_contact_details(frm); - } + }, }); - -erpnext.support.WarrantyClaim = class WarrantyClaim extends frappe.ui.form.Controller { - refresh() { - frappe.dynamic_link = {doc: this.frm.doc, fieldname: 'customer', doctype: 'Customer'} - - if(!cur_frm.doc.__islocal && - (cur_frm.doc.status=='Open' || cur_frm.doc.status == 'Work In Progress')) { - cur_frm.add_custom_button(__('Maintenance Visit'), - this.make_maintenance_visit); - } - } - - make_maintenance_visit() { - frappe.model.open_mapped_doc({ - method: "erpnext.support.doctype.warranty_claim.warranty_claim.make_maintenance_visit", - frm: cur_frm - }) - } -}; - -extend_cscript(cur_frm.cscript, new erpnext.support.WarrantyClaim({frm: cur_frm})); - -cur_frm.fields_dict['serial_no'].get_query = function(doc, cdt, cdn) { - var cond = []; - var filter = [ - ['Serial No', 'docstatus', '!=', 2] - ]; - if(doc.item_code) { - cond = ['Serial No', 'item_code', '=', doc.item_code]; - filter.push(cond); - } - if(doc.customer) { - cond = ['Serial No', 'customer', '=', doc.customer]; - filter.push(cond); - } - return{ - filters:filter - } -} - -cur_frm.fields_dict['item_code'].get_query = function(doc, cdt, cdn) { - if(doc.serial_no) { - return{ - doctype: "Serial No", - fields: "item_code", - filters:{ - name: doc.serial_no - } - } - } - else{ - return{ - filters:[ - ['Item', 'docstatus', '!=', 2], - ['Item', 'disabled', '=', 0] - ] - } - } -}; diff --git a/erpnext/support/doctype/warranty_claim/warranty_claim.json b/erpnext/support/doctype/warranty_claim/warranty_claim.json index 01d9b013906..9af2b4606c9 100644 --- a/erpnext/support/doctype/warranty_claim/warranty_claim.json +++ b/erpnext/support/doctype/warranty_claim/warranty_claim.json @@ -92,7 +92,8 @@ "fieldname": "serial_no", "fieldtype": "Link", "label": "Serial No", - "options": "Serial No" + "options": "Serial No", + "search_index": 1 }, { "fieldname": "customer", @@ -128,6 +129,8 @@ "options": "fa fa-ticket" }, { + "fetch_from": "serial_no.item_code", + "fetch_if_empty": 1, "fieldname": "item_code", "fieldtype": "Link", "in_list_view": 1, @@ -140,6 +143,7 @@ }, { "depends_on": "eval:doc.item_code", + "fetch_from": "item_code.item_name", "fieldname": "item_name", "fieldtype": "Data", "label": "Item Name", @@ -149,6 +153,7 @@ }, { "depends_on": "eval:doc.item_code", + "fetch_from": "item_code.description", "fieldname": "description", "fieldtype": "Small Text", "label": "Description", @@ -164,17 +169,24 @@ "width": "50%" }, { + "fetch_from": "serial_no.maintenance_status", + "fetch_if_empty": 1, "fieldname": "warranty_amc_status", "fieldtype": "Select", "label": "Warranty / AMC Status", - "options": "\nUnder Warranty\nOut of Warranty\nUnder AMC\nOut of AMC" + "options": "\nUnder Warranty\nOut of Warranty\nUnder AMC\nOut of AMC", + "search_index": 1 }, { + "fetch_from": "serial_no.warranty_expiry_date", + "fetch_if_empty": 1, "fieldname": "warranty_expiry_date", "fieldtype": "Date", "label": "Warranty Expiry Date" }, { + "fetch_from": "serial_no.amc_expiry_date", + "fetch_if_empty": 1, "fieldname": "amc_expiry_date", "fieldtype": "Date", "label": "AMC Expiry Date" @@ -225,6 +237,7 @@ { "bold": 1, "depends_on": "customer", + "fetch_from": "customer.customer_name", "fieldname": "customer_name", "fieldtype": "Data", "in_global_search": 1, @@ -366,7 +379,7 @@ "icon": "fa fa-bug", "idx": 1, "links": [], - "modified": "2023-06-03 16:17:07.694449", + "modified": "2023-11-28 17:30:35.676410", "modified_by": "Administrator", "module": "Support", "name": "Warranty Claim", diff --git a/erpnext/www/all-products/__init__.py b/erpnext/www/all-products/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/www/shop-by-category/__init__.py b/erpnext/www/shop-by-category/__init__.py new file mode 100644 index 00000000000..e69de29bb2d