diff --git a/.github/stale.yml b/.github/stale.yml index da15d32680f..f0e384b497f 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -13,7 +13,7 @@ exemptProjects: true exemptMilestones: true pulls: - daysUntilStale: 15 + daysUntilStale: 14 daysUntilClose: 3 exemptLabels: - hotfix diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index ef2a7621f84..6cfd15d3ec8 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -125,14 +125,27 @@ def validate_expense_against_budget(args, expense_amount=0): if not args.account: return - for budget_against in ["project", "cost_center"] + get_accounting_dimensions(): + default_dimensions = [ + { + "fieldname": "project", + "document_type": "Project", + }, + { + "fieldname": "cost_center", + "document_type": "Cost Center", + }, + ] + + for dimension in default_dimensions + get_accounting_dimensions(as_list=False): + budget_against = dimension.get("fieldname") + if ( args.get(budget_against) and args.account and frappe.db.get_value("Account", {"name": args.account, "root_type": "Expense"}) ): - doctype = frappe.unscrub(budget_against) + doctype = dimension.get("document_type") if frappe.get_cached_value("DocType", doctype, "is_tree"): lft, rgt = frappe.db.get_value(doctype, args.get(budget_against), ["lft", "rgt"]) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 27e6f0e598f..aea801af9dd 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -4,6 +4,7 @@ import frappe from frappe import _ +from frappe.query_builder.functions import IfNull, Sum from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points @@ -673,18 +674,22 @@ def get_bin_qty(item_code, warehouse): def get_pos_reserved_qty(item_code, warehouse): - reserved_qty = frappe.db.sql( - """select sum(p_item.stock_qty) as qty - from `tabPOS Invoice` p, `tabPOS Invoice Item` p_item - where p.name = p_item.parent - and ifnull(p.consolidated_invoice, '') = '' - and p_item.docstatus = 1 - and p_item.item_code = %s - and p_item.warehouse = %s - """, - (item_code, warehouse), - as_dict=1, - ) + p_inv = frappe.qb.DocType("POS Invoice") + p_item = frappe.qb.DocType("POS Invoice Item") + + reserved_qty = ( + frappe.qb.from_(p_inv) + .from_(p_item) + .select(Sum(p_item.qty).as_("qty")) + .where( + (p_inv.name == p_item.parent) + & (IfNull(p_inv.consolidated_invoice, "") == "") + & (p_inv.is_return == 0) + & (p_item.docstatus == 1) + & (p_item.item_code == item_code) + & (p_item.warehouse == warehouse) + ) + ).run(as_dict=True) return reserved_qty[0].qty or 0 if reserved_qty else 0 diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 30d949b73b6..946170340ee 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -880,18 +880,21 @@ def get_party_shipping_address(doctype, name): def get_partywise_advanced_payment_amount( - party_type, posting_date=None, future_payment=0, company=None + party_type, posting_date=None, future_payment=0, company=None, party=None ): cond = "1=1" if posting_date: if future_payment: - cond = "posting_date <= '{0}' OR DATE(creation) <= '{0}' " "".format(posting_date) + cond = "(posting_date <= '{0}' OR DATE(creation) <= '{0}')" "".format(posting_date) else: cond = "posting_date <= '{0}'".format(posting_date) if company: cond += "and company = {0}".format(frappe.db.escape(company)) + if party: + cond += "and party = {0}".format(frappe.db.escape(party)) + data = frappe.db.sql( """ SELECT party, sum({0}) as amount FROM `tabGL Entry` @@ -903,7 +906,6 @@ def get_partywise_advanced_payment_amount( ), party_type, ) - if data: return frappe._dict(data) diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py index 29217b04be2..9c01b1a4980 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py @@ -31,7 +31,6 @@ class AccountsReceivableSummary(ReceivablePayableReport): def get_data(self, args): self.data = [] - self.receivables = ReceivablePayableReport(self.filters).run(args)[1] self.get_party_total(args) @@ -42,6 +41,7 @@ class AccountsReceivableSummary(ReceivablePayableReport): self.filters.report_date, self.filters.show_future_payments, self.filters.company, + party=self.filters.get(scrub(self.party_type)), ) or {} ) @@ -74,6 +74,9 @@ class AccountsReceivableSummary(ReceivablePayableReport): row.gl_balance = gl_balance_map.get(party) row.diff = flt(row.outstanding) - flt(row.gl_balance) + if self.filters.show_future_payments: + row.remaining_balance = flt(row.outstanding) - flt(row.future_amount) + self.data.append(row) def get_party_total(self, args): @@ -106,6 +109,7 @@ class AccountsReceivableSummary(ReceivablePayableReport): "range4": 0.0, "range5": 0.0, "total_due": 0.0, + "future_amount": 0.0, "sales_person": [], } ), @@ -151,6 +155,10 @@ class AccountsReceivableSummary(ReceivablePayableReport): self.setup_ageing_columns() + if self.filters.show_future_payments: + self.add_column(label=_("Future Payment Amount"), fieldname="future_amount") + self.add_column(label=_("Remaining Balance"), fieldname="remaining_balance") + if self.party_type == "Customer": self.add_column( label=_("Territory"), fieldname="territory", fieldtype="Link", options="Territory" diff --git a/erpnext/accounts/report/gross_and_net_profit_report/gross_and_net_profit_report.py b/erpnext/accounts/report/gross_and_net_profit_report/gross_and_net_profit_report.py index cd5f3667071..f0ca405401d 100644 --- a/erpnext/accounts/report/gross_and_net_profit_report/gross_and_net_profit_report.py +++ b/erpnext/accounts/report/gross_and_net_profit_report/gross_and_net_profit_report.py @@ -125,12 +125,14 @@ def get_revenue(data, period_list, include_in_gross=1): data_to_be_removed = True while data_to_be_removed: - revenue, data_to_be_removed = remove_parent_with_no_child(revenue, period_list) - revenue = adjust_account(revenue, period_list) + revenue, data_to_be_removed = remove_parent_with_no_child(revenue) + + adjust_account_totals(revenue, period_list) + return copy.deepcopy(revenue) -def remove_parent_with_no_child(data, period_list): +def remove_parent_with_no_child(data): data_to_be_removed = False for parent in data: if "is_group" in parent and parent.get("is_group") == 1: @@ -147,16 +149,19 @@ def remove_parent_with_no_child(data, period_list): return data, data_to_be_removed -def adjust_account(data, period_list, consolidated=False): - leaf_nodes = [item for item in data if item["is_group"] == 0] +def adjust_account_totals(data, period_list): totals = {} - for node in leaf_nodes: - set_total(node, node["total"], data, totals) - for d in data: - for period in period_list: - key = period if consolidated else period.key - d["total"] = totals[d["account"]] - return data + for d in reversed(data): + if d.get("is_group"): + for period in period_list: + # reset totals for group accounts as totals set by get_data doesn't consider include_in_gross check + d[period.key] = sum( + item[period.key] for item in data if item.get("parent_account") == d.get("account") + ) + else: + set_total(d, d["total"], data, totals) + + d["total"] = totals[d["account"]] def set_total(node, value, complete_list, totals): @@ -191,6 +196,9 @@ def get_profit( if profit_loss[key]: has_value = True + if not profit_loss.get("total"): + profit_loss["total"] = 0 + profit_loss["total"] += profit_loss[key] if has_value: return profit_loss @@ -229,6 +237,9 @@ def get_net_profit( if profit_loss[key]: has_value = True + if not profit_loss.get("total"): + profit_loss["total"] = 0 + profit_loss["total"] += profit_loss[key] if has_value: return profit_loss diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 224f79babad..2d56b597b0c 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -736,7 +736,7 @@ class GrossProfitGenerator(object): def load_invoice_items(self): conditions = "" if self.filters.company: - conditions += " and company = %(company)s" + conditions += " and `tabSales Invoice`.company = %(company)s" if self.filters.from_date: conditions += " and posting_date >= %(from_date)s" if self.filters.to_date: diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 8754bb7125a..ae77b9b94ce 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -337,10 +337,6 @@ class Asset(AccountsController): if should_get_last_day: schedule_date = get_last_day(schedule_date) - # schedule date will be a year later from start date - # so monthly schedule date is calculated by removing 11 months from it - monthly_schedule_date = add_months(schedule_date, -finance_book.frequency_of_depreciation + 1) - # if asset is being sold if date_of_disposal: from_date = self.get_from_date_for_disposal(finance_book) @@ -363,14 +359,20 @@ class Asset(AccountsController): break # For first row - if ( - (has_pro_rata or has_wdv_or_dd_non_yearly_pro_rata) - and not self.opening_accumulated_depreciation - and n == 0 - ): - from_date = add_days( - self.available_for_use_date, -1 - ) # needed to calc depr amount for available_for_use_date too + if n == 0 and has_pro_rata and not self.opening_accumulated_depreciation: + from_date = add_days(self.available_for_use_date, -1) + depreciation_amount, days, months = self.get_pro_rata_amt( + finance_book, + depreciation_amount, + from_date, + finance_book.depreciation_start_date, + has_wdv_or_dd_non_yearly_pro_rata, + ) + elif n == 0 and has_wdv_or_dd_non_yearly_pro_rata and self.opening_accumulated_depreciation: + from_date = add_months( + getdate(self.available_for_use_date), + (self.number_of_depreciations_booked * finance_book.frequency_of_depreciation), + ) depreciation_amount, days, months = self.get_pro_rata_amt( finance_book, depreciation_amount, @@ -378,10 +380,6 @@ class Asset(AccountsController): finance_book.depreciation_start_date, has_wdv_or_dd_non_yearly_pro_rata, ) - - # For first depr schedule date will be the start date - # so monthly schedule date is calculated by removing month difference between use date and start date - monthly_schedule_date = add_months(finance_book.depreciation_start_date, -months + 1) # For last row elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1: @@ -406,9 +404,7 @@ class Asset(AccountsController): depreciation_amount_without_pro_rata, depreciation_amount, finance_book.finance_book ) - monthly_schedule_date = add_months(schedule_date, 1) schedule_date = add_days(schedule_date, days) - last_schedule_date = schedule_date if not depreciation_amount: continue diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index c51c6edf1da..645abf25a8f 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -157,7 +157,7 @@ "party_account_currency", "inter_company_order_reference", "is_old_subcontracting_flow", - "dashboard" + "connections_tab" ], "fields": [ { @@ -1171,7 +1171,6 @@ "depends_on": "is_internal_supplier", "fieldname": "set_from_warehouse", "fieldtype": "Link", - "ignore_user_permissions": 1, "label": "Set From Warehouse", "options": "Warehouse" }, @@ -1185,12 +1184,6 @@ "fieldtype": "Tab Break", "label": "More Info" }, - { - "fieldname": "dashboard", - "fieldtype": "Tab Break", - "label": "Dashboard", - "show_dashboard": 1 - }, { "fieldname": "column_break_7", "fieldtype": "Column Break" @@ -1266,13 +1259,19 @@ "fieldname": "shipping_address_section", "fieldtype": "Section Break", "label": "Shipping Address" + }, + { + "fieldname": "connections_tab", + "fieldtype": "Tab Break", + "label": "Connections", + "show_dashboard": 1 } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2023-05-07 20:18:09.196799", + "modified": "2023-05-24 11:16:41.195340", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index ab29dd85829..fa566f62ef0 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -184,6 +184,7 @@ class BuyingController(SubcontractingController): address_dict = { "supplier_address": "address_display", "shipping_address": "shipping_address_display", + "billing_address": "billing_address_display", } for address_field, address_display_field in address_dict.items(): diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index a7d0b29f83c..fcaa3fd276f 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -653,23 +653,19 @@ class JobCard(Document): exc=JobCardOverTransferError, ) - job_card_items_transferred_qty = _get_job_card_items_transferred_qty(ste_doc) + job_card_items_transferred_qty = _get_job_card_items_transferred_qty(ste_doc) or {} + allow_excess = frappe.db.get_single_value("Manufacturing Settings", "job_card_excess_transfer") - if job_card_items_transferred_qty: - allow_excess = frappe.db.get_single_value("Manufacturing Settings", "job_card_excess_transfer") + for row in ste_doc.items: + if not row.job_card_item: + continue - for row in ste_doc.items: - if not row.job_card_item: - continue + transferred_qty = flt(job_card_items_transferred_qty.get(row.job_card_item, 0.0)) - transferred_qty = flt(job_card_items_transferred_qty.get(row.job_card_item)) + if not allow_excess: + _validate_over_transfer(row, transferred_qty) - if not allow_excess: - _validate_over_transfer(row, transferred_qty) - - frappe.db.set_value( - "Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty) - ) + frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty)) def set_transferred_qty(self, update_status=False): "Set total FG Qty in Job Card for which RM was transferred." diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 61766a67511..22177f44148 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -342,6 +342,12 @@ class TestJobCard(FrappeTestCase): job_card.reload() self.assertEqual(job_card.transferred_qty, 2) + transfer_entry_2.cancel() + transfer_entry.cancel() + + job_card.reload() + self.assertEqual(job_card.transferred_qty, 0.0) + def test_job_card_material_transfer_correctness(self): """ 1. Test if only current Job Card Items are pulled in a Stock Entry against a Job Card diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index ab7aa52bb7a..45a59cf7325 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -451,10 +451,14 @@ frappe.ui.form.on("Material Request Plan Item", { for_warehouse: row.warehouse }, callback: function(r) { - let {projected_qty, actual_qty} = r.message; + if (r.message) { + let {projected_qty, actual_qty} = r.message[0]; - frappe.model.set_value(cdt, cdn, 'projected_qty', projected_qty); - frappe.model.set_value(cdt, cdn, 'actual_qty', actual_qty); + frappe.model.set_value(cdt, cdn, { + 'projected_qty': projected_qty, + 'actual_qty': actual_qty + }); + } } }) } diff --git a/erpnext/patches.txt b/erpnext/patches.txt index f010f1a1be6..fbfb7d2094e 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -331,3 +331,4 @@ execute:frappe.db.set_single_value("Accounts Settings", "merge_similar_account_h # below migration patches should always run last erpnext.patches.v14_0.migrate_gl_to_payment_ledger erpnext.patches.v14_0.update_company_in_ldc +erpnext.patches.v14_0.set_packed_qty_in_draft_delivery_notes diff --git a/erpnext/patches/v14_0/set_packed_qty_in_draft_delivery_notes.py b/erpnext/patches/v14_0/set_packed_qty_in_draft_delivery_notes.py new file mode 100644 index 00000000000..1aeb2e6cc3d --- /dev/null +++ b/erpnext/patches/v14_0/set_packed_qty_in_draft_delivery_notes.py @@ -0,0 +1,60 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.query_builder.functions import Sum + + +def execute(): + ps = frappe.qb.DocType("Packing Slip") + dn = frappe.qb.DocType("Delivery Note") + ps_item = frappe.qb.DocType("Packing Slip Item") + + ps_details = ( + frappe.qb.from_(ps) + .join(ps_item) + .on(ps.name == ps_item.parent) + .join(dn) + .on(ps.delivery_note == dn.name) + .select( + dn.name.as_("delivery_note"), + ps_item.item_code.as_("item_code"), + Sum(ps_item.qty).as_("packed_qty"), + ) + .where((ps.docstatus == 1) & (dn.docstatus == 0)) + .groupby(dn.name, ps_item.item_code) + ).run(as_dict=True) + + if ps_details: + dn_list = set() + item_code_list = set() + for ps_detail in ps_details: + dn_list.add(ps_detail.delivery_note) + item_code_list.add(ps_detail.item_code) + + dn_item = frappe.qb.DocType("Delivery Note Item") + dn_item_query = ( + frappe.qb.from_(dn_item) + .select( + dn.parent.as_("delivery_note"), + dn_item.name, + dn_item.item_code, + dn_item.qty, + ) + .where((dn_item.parent.isin(dn_list)) & (dn_item.item_code.isin(item_code_list))) + ) + + dn_details = frappe._dict() + for r in dn_item_query.run(as_dict=True): + dn_details.setdefault((r.delivery_note, r.item_code), frappe._dict()).setdefault(r.name, r.qty) + + for ps_detail in ps_details: + dn_items = dn_details.get((ps_detail.delivery_note, ps_detail.item_code)) + + if dn_items: + remaining_qty = ps_detail.packed_qty + for name, qty in dn_items.items(): + if remaining_qty > 0: + row_packed_qty = min(qty, remaining_qty) + frappe.db.set_value("Delivery Note Item", name, "packed_qty", row_packed_qty) + remaining_qty -= row_packed_qty diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 29437beab10..83630df3a0b 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -494,7 +494,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }, () => { // for internal customer instead of pricing rule directly apply valuation rate on item - if (me.frm.doc.is_internal_customer || me.frm.doc.is_internal_supplier) { + if ((me.frm.doc.is_internal_customer || me.frm.doc.is_internal_supplier) && me.frm.doc.represents_company === me.frm.doc.company) { me.get_incoming_rate(item, me.frm.posting_date, me.frm.posting_time, me.frm.doc.doctype, me.frm.doc.company); } else { diff --git a/erpnext/stock/doctype/closing_stock_balance/__init__.py b/erpnext/stock/doctype/closing_stock_balance/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/closing_stock_balance/closing_stock_balance.js b/erpnext/stock/doctype/closing_stock_balance/closing_stock_balance.js new file mode 100644 index 00000000000..5c807a80a04 --- /dev/null +++ b/erpnext/stock/doctype/closing_stock_balance/closing_stock_balance.js @@ -0,0 +1,39 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Closing Stock Balance", { + refresh(frm) { + frm.trigger("generate_closing_balance"); + frm.trigger("regenerate_closing_balance"); + }, + + generate_closing_balance(frm) { + if (in_list(["Queued", "Failed"], frm.doc.status)) { + frm.add_custom_button(__("Generate Closing Stock Balance"), () => { + frm.call({ + method: "enqueue_job", + doc: frm.doc, + freeze: true, + callback: () => { + frm.reload_doc(); + } + }) + }) + } + }, + + regenerate_closing_balance(frm) { + if (frm.doc.status == "Completed") { + frm.add_custom_button(__("Regenerate Closing Stock Balance"), () => { + frm.call({ + method: "regenerate_closing_balance", + doc: frm.doc, + freeze: true, + callback: () => { + frm.reload_doc(); + } + }) + }) + } + } +}); diff --git a/erpnext/stock/doctype/closing_stock_balance/closing_stock_balance.json b/erpnext/stock/doctype/closing_stock_balance/closing_stock_balance.json new file mode 100644 index 00000000000..225da6d15ec --- /dev/null +++ b/erpnext/stock/doctype/closing_stock_balance/closing_stock_balance.json @@ -0,0 +1,148 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "naming_series:", + "creation": "2023-05-17 09:58:42.086911", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "naming_series", + "company", + "status", + "column_break_p0s0", + "from_date", + "to_date", + "filters_section", + "item_code", + "item_group", + "include_uom", + "column_break_rm5w", + "warehouse", + "warehouse_type", + "amended_from" + ], + "fields": [ + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Naming Series", + "options": "CBAL-.#####" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_preview": 1, + "label": "Status", + "options": "Draft\nQueued\nIn Progress\nCompleted\nFailed\nCanceled", + "read_only": 1 + }, + { + "fieldname": "column_break_p0s0", + "fieldtype": "Column Break" + }, + { + "fieldname": "from_date", + "fieldtype": "Date", + "label": "From Date" + }, + { + "fieldname": "to_date", + "fieldtype": "Date", + "label": "To Date" + }, + { + "collapsible": 1, + "fieldname": "filters_section", + "fieldtype": "Section Break", + "label": "Filters" + }, + { + "fieldname": "item_code", + "fieldtype": "Link", + "label": "Item Code", + "options": "Item" + }, + { + "fieldname": "item_group", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group" + }, + { + "fieldname": "column_break_rm5w", + "fieldtype": "Column Break" + }, + { + "fieldname": "warehouse", + "fieldtype": "Link", + "label": "Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "warehouse_type", + "fieldtype": "Link", + "label": "Warehouse Type", + "options": "Warehouse Type" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Closing Stock Balance", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Closing Stock Balance", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "include_uom", + "fieldtype": "Link", + "label": "Include UOM", + "options": "UOM" + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2023-05-17 11:46:04.448220", + "modified_by": "Administrator", + "module": "Stock", + "name": "Closing Stock Balance", + "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 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/stock/doctype/closing_stock_balance/closing_stock_balance.py b/erpnext/stock/doctype/closing_stock_balance/closing_stock_balance.py new file mode 100644 index 00000000000..a7963726ae3 --- /dev/null +++ b/erpnext/stock/doctype/closing_stock_balance/closing_stock_balance.py @@ -0,0 +1,133 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json + +import frappe +from frappe import _ +from frappe.core.doctype.prepared_report.prepared_report import create_json_gz_file +from frappe.desk.form.load import get_attachments +from frappe.model.document import Document +from frappe.utils import get_link_to_form, gzip_decompress, parse_json +from frappe.utils.background_jobs import enqueue + +from erpnext.stock.report.stock_balance.stock_balance import execute + + +class ClosingStockBalance(Document): + def before_save(self): + self.set_status() + + def set_status(self, save=False): + self.status = "Queued" + if self.docstatus == 2: + self.status = "Canceled" + + if self.docstatus == 0: + self.status = "Draft" + + if save: + self.db_set("status", self.status) + + def validate(self): + self.validate_duplicate() + + def validate_duplicate(self): + table = frappe.qb.DocType("Closing Stock Balance") + + query = ( + frappe.qb.from_(table) + .select(table.name) + .where( + (table.docstatus == 1) + & (table.company == self.company) + & ( + (table.from_date.between(self.from_date, self.to_date)) + | (table.to_date.between(self.from_date, self.to_date)) + | (table.from_date >= self.from_date and table.to_date <= self.to_date) + ) + ) + ) + + for fieldname in ["warehouse", "item_code", "item_group", "warehouse_type"]: + if self.get(fieldname): + query = query.where(table.get(fieldname) == self.get(fieldname)) + + query = query.run(as_dict=True) + + if query and query[0].name: + name = get_link_to_form("Closing Stock Balance", query[0].name) + msg = f"Closing Stock Balance {name} already exists for the selected date range" + frappe.throw(_(msg), title=_("Duplicate Closing Stock Balance")) + + def on_submit(self): + self.set_status(save=True) + self.enqueue_job() + + def on_cancel(self): + self.set_status(save=True) + self.clear_attachment() + + @frappe.whitelist() + def enqueue_job(self): + self.db_set("status", "In Progress") + self.clear_attachment() + enqueue(prepare_closing_stock_balance, name=self.name, queue="long", timeout=1500) + + @frappe.whitelist() + def regenerate_closing_balance(self): + self.enqueue_job() + + def clear_attachment(self): + if attachments := get_attachments(self.doctype, self.name): + attachment = attachments[0] + frappe.delete_doc("File", attachment.name) + + def create_closing_stock_balance_entries(self): + columns, data = execute( + filters=frappe._dict( + { + "company": self.company, + "from_date": self.from_date, + "to_date": self.to_date, + "warehouse": self.warehouse, + "item_code": self.item_code, + "item_group": self.item_group, + "warehouse_type": self.warehouse_type, + "include_uom": self.include_uom, + "ignore_closing_balance": 1, + "show_variant_attributes": 1, + "show_stock_ageing_data": 1, + } + ) + ) + + create_json_gz_file({"columns": columns, "data": data}, self.doctype, self.name) + + def get_prepared_data(self): + if attachments := get_attachments(self.doctype, self.name): + attachment = attachments[0] + attached_file = frappe.get_doc("File", attachment.name) + + data = gzip_decompress(attached_file.get_content()) + if data := json.loads(data.decode("utf-8")): + data = data + + return parse_json(data) + + return frappe._dict({}) + + +def prepare_closing_stock_balance(name): + doc = frappe.get_doc("Closing Stock Balance", name) + + doc.db_set("status", "In Progress") + + try: + doc.create_closing_stock_balance_entries() + doc.db_set("status", "Completed") + except Exception as e: + doc.db_set("status", "Failed") + traceback = frappe.get_traceback() + + frappe.log_error("Closing Stock Balance Failed", traceback, doc.doctype, doc.name) diff --git a/erpnext/stock/doctype/closing_stock_balance/test_closing_stock_balance.py b/erpnext/stock/doctype/closing_stock_balance/test_closing_stock_balance.py new file mode 100644 index 00000000000..7d61f5c9c63 --- /dev/null +++ b/erpnext/stock/doctype/closing_stock_balance/test_closing_stock_balance.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 TestClosingStockBalance(FrappeTestCase): + pass diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index ae56645b730..77545e0e1ad 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -185,11 +185,14 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends erpn } if(doc.docstatus==0 && !doc.__islocal) { - this.frm.add_custom_button(__('Packing Slip'), function() { - frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.delivery_note.delivery_note.make_packing_slip", - frm: me.frm - }) }, __('Create')); + if (doc.__onload && doc.__onload.has_unpacked_items) { + this.frm.add_custom_button(__('Packing Slip'), function() { + frappe.model.open_mapped_doc({ + method: "erpnext.stock.doctype.delivery_note.delivery_note.make_packing_slip", + frm: me.frm + }) }, __('Create') + ); + } } if (!doc.__islocal && doc.docstatus==1) { diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 16caceba09f..3a056500b54 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -86,6 +86,10 @@ class DeliveryNote(SellingController): ] ) + def onload(self): + if self.docstatus == 0: + self.set_onload("has_unpacked_items", self.has_unpacked_items()) + def before_print(self, settings=None): def toggle_print_hide(meta, fieldname): df = meta.get_field(fieldname) @@ -299,20 +303,21 @@ class DeliveryNote(SellingController): ) def validate_packed_qty(self): - """ - Validate that if packed qty exists, it should be equal to qty - """ - if not any(flt(d.get("packed_qty")) for d in self.get("items")): - return - has_error = False - for d in self.get("items"): - if flt(d.get("qty")) != flt(d.get("packed_qty")): - frappe.msgprint( - _("Packed quantity must equal quantity for Item {0} in row {1}").format(d.item_code, d.idx) - ) - has_error = True - if has_error: - raise frappe.ValidationError + """Validate that if packed qty exists, it should be equal to qty""" + + if frappe.db.exists("Packing Slip", {"docstatus": 1, "delivery_note": self.name}): + product_bundle_list = self.get_product_bundle_list() + for item in self.items + self.packed_items: + if ( + item.item_code not in product_bundle_list + and flt(item.packed_qty) + and flt(item.packed_qty) != flt(item.qty) + ): + frappe.throw( + _("Row {0}: Packed Qty must be equal to {1} Qty.").format( + item.idx, frappe.bold(item.doctype) + ) + ) def update_pick_list_status(self): from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status @@ -390,6 +395,23 @@ class DeliveryNote(SellingController): ) ) + def has_unpacked_items(self): + product_bundle_list = self.get_product_bundle_list() + + for item in self.items + self.packed_items: + if item.item_code not in product_bundle_list and flt(item.packed_qty) < flt(item.qty): + return True + + return False + + def get_product_bundle_list(self): + items_list = [item.item_code for item in self.items] + return frappe.db.get_all( + "Product Bundle", + filters={"new_item_code": ["in", items_list]}, + pluck="name", + ) + def update_billed_amount_based_on_so(so_detail, update_modified=True): from frappe.query_builder.functions import Sum @@ -681,6 +703,12 @@ def make_installation_note(source_name, target_doc=None): @frappe.whitelist() def make_packing_slip(source_name, target_doc=None): + def set_missing_values(source, target): + target.run_method("set_missing_values") + + def update_item(obj, target, source_parent): + target.qty = flt(obj.qty) - flt(obj.packed_qty) + doclist = get_mapped_doc( "Delivery Note", source_name, @@ -695,12 +723,34 @@ def make_packing_slip(source_name, target_doc=None): "field_map": { "item_code": "item_code", "item_name": "item_name", + "batch_no": "batch_no", "description": "description", "qty": "qty", + "stock_uom": "stock_uom", + "name": "dn_detail", }, + "postprocess": update_item, + "condition": lambda item: ( + not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code}) + and flt(item.packed_qty) < flt(item.qty) + ), + }, + "Packed Item": { + "doctype": "Packing Slip Item", + "field_map": { + "item_code": "item_code", + "item_name": "item_name", + "batch_no": "batch_no", + "description": "description", + "qty": "qty", + "name": "pi_detail", + }, + "postprocess": update_item, + "condition": lambda item: (flt(item.packed_qty) < flt(item.qty)), }, }, target_doc, + set_missing_values, ) return doclist diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index e46cab05762..b97e42c2468 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -84,6 +84,7 @@ "installed_qty", "item_tax_rate", "column_break_atna", + "packed_qty", "received_qty", "accounting_details_section", "expense_account", @@ -850,6 +851,16 @@ "print_hide": 1, "read_only": 1, "report_hide": 1 + }, + { + "default": "0", + "depends_on": "eval: doc.packed_qty", + "fieldname": "packed_qty", + "fieldtype": "Float", + "label": "Packed Qty", + "no_copy": 1, + "non_negative": 1, + "read_only": 1 } ], "idx": 1, @@ -866,4 +877,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index cb8eb30cb30..c5fb2411c28 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -27,6 +27,7 @@ "actual_qty", "projected_qty", "ordered_qty", + "packed_qty", "column_break_16", "incoming_rate", "picked_qty", @@ -242,13 +243,23 @@ "label": "Picked Qty", "no_copy": 1, "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval: doc.packed_qty", + "fieldname": "packed_qty", + "fieldtype": "Float", + "label": "Packed Qty", + "no_copy": 1, + "non_negative": 1, + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-04-27 05:23:08.683245", + "modified": "2023-04-28 13:16:38.460806", "modified_by": "Administrator", "module": "Stock", "name": "Packed Item", diff --git a/erpnext/stock/doctype/packing_slip/packing_slip.js b/erpnext/stock/doctype/packing_slip/packing_slip.js index 40d46852d03..95e5ea309f8 100644 --- a/erpnext/stock/doctype/packing_slip/packing_slip.js +++ b/erpnext/stock/doctype/packing_slip/packing_slip.js @@ -1,113 +1,46 @@ -// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -// License: GNU General Public License v3. See license.txt +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt -cur_frm.fields_dict['delivery_note'].get_query = function(doc, cdt, cdn) { - return{ - filters:{ 'docstatus': 0} - } -} +frappe.ui.form.on('Packing Slip', { + setup: (frm) => { + frm.set_query('delivery_note', () => { + return { + filters: { + docstatus: 0, + } + } + }); + frm.set_query('item_code', 'items', (doc, cdt, cdn) => { + if (!doc.delivery_note) { + frappe.throw(__('Please select a Delivery Note')); + } else { + let d = locals[cdt][cdn]; + return { + query: 'erpnext.stock.doctype.packing_slip.packing_slip.item_details', + filters: { + delivery_note: doc.delivery_note, + } + } + } + }); + }, -cur_frm.fields_dict['items'].grid.get_field('item_code').get_query = function(doc, cdt, cdn) { - if(!doc.delivery_note) { - frappe.throw(__("Please select a Delivery Note")); - } else { - return { - query: "erpnext.stock.doctype.packing_slip.packing_slip.item_details", - filters:{ 'delivery_note': doc.delivery_note} + refresh: (frm) => { + frm.toggle_display('misc_details', frm.doc.amended_from); + }, + + delivery_note: (frm) => { + frm.set_value('items', null); + + if (frm.doc.delivery_note) { + erpnext.utils.map_current_doc({ + method: 'erpnext.stock.doctype.delivery_note.delivery_note.make_packing_slip', + source_name: frm.doc.delivery_note, + target_doc: frm, + freeze: true, + freeze_message: __('Creating Packing Slip ...'), + }); } - } -} - -cur_frm.cscript.onload_post_render = function(doc, cdt, cdn) { - if(doc.delivery_note && doc.__islocal) { - cur_frm.cscript.get_items(doc, cdt, cdn); - } -} - -cur_frm.cscript.get_items = function(doc, cdt, cdn) { - return this.frm.call({ - doc: this.frm.doc, - method: "get_items", - callback: function(r) { - if(!r.exc) cur_frm.refresh(); - } - }); -} - -cur_frm.cscript.refresh = function(doc, dt, dn) { - cur_frm.toggle_display("misc_details", doc.amended_from); -} - -cur_frm.cscript.validate = function(doc, cdt, cdn) { - cur_frm.cscript.validate_case_nos(doc); - cur_frm.cscript.validate_calculate_item_details(doc); -} - -// To Case No. cannot be less than From Case No. -cur_frm.cscript.validate_case_nos = function(doc) { - doc = locals[doc.doctype][doc.name]; - if(cint(doc.from_case_no)==0) { - frappe.msgprint(__("The 'From Package No.' field must neither be empty nor it's value less than 1.")); - frappe.validated = false; - } else if(!cint(doc.to_case_no)) { - doc.to_case_no = doc.from_case_no; - refresh_field('to_case_no'); - } else if(cint(doc.to_case_no) < cint(doc.from_case_no)) { - frappe.msgprint(__("'To Case No.' cannot be less than 'From Case No.'")); - frappe.validated = false; - } -} - - -cur_frm.cscript.validate_calculate_item_details = function(doc) { - doc = locals[doc.doctype][doc.name]; - var ps_detail = doc.items || []; - - cur_frm.cscript.validate_duplicate_items(doc, ps_detail); - cur_frm.cscript.calc_net_total_pkg(doc, ps_detail); -} - - -// Do not allow duplicate items i.e. items with same item_code -// Also check for 0 qty -cur_frm.cscript.validate_duplicate_items = function(doc, ps_detail) { - for(var i=0; i None: + super(PackingSlip, self).__init__(*args, **kwargs) + self.status_updater = [ + { + "target_dt": "Delivery Note Item", + "join_field": "dn_detail", + "target_field": "packed_qty", + "target_parent_dt": "Delivery Note", + "target_ref_field": "qty", + "source_dt": "Packing Slip Item", + "source_field": "qty", + }, + { + "target_dt": "Packed Item", + "join_field": "pi_detail", + "target_field": "packed_qty", + "target_parent_dt": "Delivery Note", + "target_ref_field": "qty", + "source_dt": "Packing Slip Item", + "source_field": "qty", + }, + ] + def validate(self) -> None: from erpnext.utilities.transaction_base import validate_uom_is_integer + self.validate_delivery_note() + self.validate_case_nos() + self.validate_items() + validate_uom_is_integer(self, "stock_uom", "qty") validate_uom_is_integer(self, "weight_uom", "net_weight") - def validate_delivery_note(self): - """ - Validates if delivery note has status as draft - """ - if cint(frappe.db.get_value("Delivery Note", self.delivery_note, "docstatus")) != 0: - frappe.throw(_("Delivery Note {0} must not be submitted").format(self.delivery_note)) + self.set_missing_values() + self.calculate_net_total_pkg() - def validate_items_mandatory(self): - rows = [d.item_code for d in self.get("items")] - if not rows: - frappe.msgprint(_("No Items to pack"), raise_exception=1) + def on_submit(self): + self.update_prevdoc_status() + + def on_cancel(self): + self.update_prevdoc_status() + + def validate_delivery_note(self): + """Raises an exception if the `Delivery Note` status is not Draft""" + + if cint(frappe.db.get_value("Delivery Note", self.delivery_note, "docstatus")) != 0: + frappe.throw( + _("A Packing Slip can only be created for Draft Delivery Note.").format(self.delivery_note) + ) def validate_case_nos(self): - """ - Validate if case nos overlap. If they do, recommend next case no. - """ - if not cint(self.from_case_no): - frappe.msgprint(_("Please specify a valid 'From Case No.'"), raise_exception=1) + """Validate if case nos overlap. If they do, recommend next case no.""" + + if cint(self.from_case_no) <= 0: + frappe.throw( + _("The 'From Package No.' field must neither be empty nor it's value less than 1.") + ) elif not self.to_case_no: self.to_case_no = self.from_case_no - elif cint(self.from_case_no) > cint(self.to_case_no): - frappe.msgprint(_("'To Case No.' cannot be less than 'From Case No.'"), raise_exception=1) + elif cint(self.to_case_no) < cint(self.from_case_no): + frappe.throw(_("'To Package No.' cannot be less than 'From Package No.'")) + else: + ps = frappe.qb.DocType("Packing Slip") + res = ( + frappe.qb.from_(ps) + .select( + ps.name, + ) + .where( + (ps.delivery_note == self.delivery_note) + & (ps.docstatus == 1) + & ( + (ps.from_case_no.between(self.from_case_no, self.to_case_no)) + | (ps.to_case_no.between(self.from_case_no, self.to_case_no)) + | ((ps.from_case_no <= self.from_case_no) & (ps.to_case_no >= self.from_case_no)) + ) + ) + ).run() - res = frappe.db.sql( - """SELECT name FROM `tabPacking Slip` - WHERE delivery_note = %(delivery_note)s AND docstatus = 1 AND - ((from_case_no BETWEEN %(from_case_no)s AND %(to_case_no)s) - OR (to_case_no BETWEEN %(from_case_no)s AND %(to_case_no)s) - OR (%(from_case_no)s BETWEEN from_case_no AND to_case_no)) - """, - { - "delivery_note": self.delivery_note, - "from_case_no": self.from_case_no, - "to_case_no": self.to_case_no, - }, - ) + if res: + frappe.throw( + _("""Package No(s) already in use. Try from Package No {0}""").format( + self.get_recommended_case_no() + ) + ) - if res: - frappe.throw( - _("""Case No(s) already in use. Try from Case No {0}""").format(self.get_recommended_case_no()) + def validate_items(self): + for item in self.items: + if item.qty <= 0: + frappe.throw(_("Row {0}: Qty must be greater than 0.").format(item.idx)) + + if not item.dn_detail and not item.pi_detail: + frappe.throw( + _("Row {0}: Either Delivery Note Item or Packed Item reference is mandatory.").format( + item.idx + ) + ) + + remaining_qty = frappe.db.get_value( + "Delivery Note Item" if item.dn_detail else "Packed Item", + {"name": item.dn_detail or item.pi_detail, "docstatus": 0}, + ["sum(qty - packed_qty)"], ) - def validate_qty(self): - """Check packed qty across packing slips and delivery note""" - # Get Delivery Note Items, Item Quantity Dict and No. of Cases for this Packing slip - dn_details, ps_item_qty, no_of_cases = self.get_details_for_packing() + if remaining_qty is None: + frappe.throw( + _("Row {0}: Please provide a valid Delivery Note Item or Packed Item reference.").format( + item.idx + ) + ) + elif remaining_qty <= 0: + frappe.throw( + _("Row {0}: Packing Slip is already created for Item {1}.").format( + item.idx, frappe.bold(item.item_code) + ) + ) + elif item.qty > remaining_qty: + frappe.throw( + _("Row {0}: Qty cannot be greater than {1} for the Item {2}.").format( + item.idx, frappe.bold(remaining_qty), frappe.bold(item.item_code) + ) + ) - for item in dn_details: - new_packed_qty = (flt(ps_item_qty[item["item_code"]]) * no_of_cases) + flt(item["packed_qty"]) - if new_packed_qty > flt(item["qty"]) and no_of_cases: - self.recommend_new_qty(item, ps_item_qty, no_of_cases) - - def get_details_for_packing(self): - """ - Returns - * 'Delivery Note Items' query result as a list of dict - * Item Quantity dict of current packing slip doc - * No. of Cases of this packing slip - """ - - rows = [d.item_code for d in self.get("items")] - - # also pick custom fields from delivery note - custom_fields = ", ".join( - "dni.`{0}`".format(d.fieldname) - for d in frappe.get_meta("Delivery Note Item").get_custom_fields() - if d.fieldtype not in no_value_fields - ) - - if custom_fields: - custom_fields = ", " + custom_fields - - condition = "" - if rows: - condition = " and item_code in (%s)" % (", ".join(["%s"] * len(rows))) - - # gets item code, qty per item code, latest packed qty per item code and stock uom - res = frappe.db.sql( - """select item_code, sum(qty) as qty, - (select sum(psi.qty * (abs(ps.to_case_no - ps.from_case_no) + 1)) - from `tabPacking Slip` ps, `tabPacking Slip Item` psi - where ps.name = psi.parent and ps.docstatus = 1 - and ps.delivery_note = dni.parent and psi.item_code=dni.item_code) as packed_qty, - stock_uom, item_name, description, dni.batch_no {custom_fields} - from `tabDelivery Note Item` dni - where parent=%s {condition} - group by item_code""".format( - condition=condition, custom_fields=custom_fields - ), - tuple([self.delivery_note] + rows), - as_dict=1, - ) - - ps_item_qty = dict([[d.item_code, d.qty] for d in self.get("items")]) - no_of_cases = cint(self.to_case_no) - cint(self.from_case_no) + 1 - - return res, ps_item_qty, no_of_cases - - def recommend_new_qty(self, item, ps_item_qty, no_of_cases): - """ - Recommend a new quantity and raise a validation exception - """ - item["recommended_qty"] = (flt(item["qty"]) - flt(item["packed_qty"])) / no_of_cases - item["specified_qty"] = flt(ps_item_qty[item["item_code"]]) - if not item["packed_qty"]: - item["packed_qty"] = 0 - - frappe.throw( - _("Quantity for Item {0} must be less than {1}").format( - item.get("item_code"), item.get("recommended_qty") - ) - ) - - def update_item_details(self): - """ - Fill empty columns in Packing Slip Item - """ + def set_missing_values(self): if not self.from_case_no: self.from_case_no = self.get_recommended_case_no() - for d in self.get("items"): - res = frappe.db.get_value("Item", d.item_code, ["weight_per_unit", "weight_uom"], as_dict=True) + for item in self.items: + stock_uom, weight_per_unit, weight_uom = frappe.db.get_value( + "Item", item.item_code, ["stock_uom", "weight_per_unit", "weight_uom"] + ) - if res and len(res) > 0: - d.net_weight = res["weight_per_unit"] - d.weight_uom = res["weight_uom"] + item.stock_uom = stock_uom + if weight_per_unit and not item.net_weight: + item.net_weight = weight_per_unit + if weight_uom and not item.weight_uom: + item.weight_uom = weight_uom def get_recommended_case_no(self): - """ - Returns the next case no. for a new packing slip for a delivery - note - """ - recommended_case_no = frappe.db.sql( - """SELECT MAX(to_case_no) FROM `tabPacking Slip` - WHERE delivery_note = %s AND docstatus=1""", - self.delivery_note, + """Returns the next case no. for a new packing slip for a delivery note""" + + return ( + cint( + frappe.db.get_value( + "Packing Slip", {"delivery_note": self.delivery_note, "docstatus": 1}, ["max(to_case_no)"] + ) + ) + + 1 ) - return cint(recommended_case_no[0][0]) + 1 + def calculate_net_total_pkg(self): + self.net_weight_uom = self.items[0].weight_uom if self.items else None + self.gross_weight_uom = self.net_weight_uom - @frappe.whitelist() - def get_items(self): - self.set("items", []) + net_weight_pkg = 0 + for item in self.items: + if item.weight_uom != self.net_weight_uom: + frappe.throw( + _( + "Different UOM for items will lead to incorrect (Total) Net Weight value. Make sure that Net Weight of each item is in the same UOM." + ) + ) - custom_fields = frappe.get_meta("Delivery Note Item").get_custom_fields() + net_weight_pkg += flt(item.net_weight) * flt(item.qty) - dn_details = self.get_details_for_packing()[0] - for item in dn_details: - if flt(item.qty) > flt(item.packed_qty): - ch = self.append("items", {}) - ch.item_code = item.item_code - ch.item_name = item.item_name - ch.stock_uom = item.stock_uom - ch.description = item.description - ch.batch_no = item.batch_no - ch.qty = flt(item.qty) - flt(item.packed_qty) + self.net_weight_pkg = round(net_weight_pkg, 2) - # copy custom fields - for d in custom_fields: - if item.get(d.fieldname): - ch.set(d.fieldname, item.get(d.fieldname)) - - self.update_item_details() + if not flt(self.gross_weight_pkg): + self.gross_weight_pkg = self.net_weight_pkg @frappe.whitelist() diff --git a/erpnext/stock/doctype/packing_slip/test_packing_slip.py b/erpnext/stock/doctype/packing_slip/test_packing_slip.py index bc405b20995..96da23db4a8 100644 --- a/erpnext/stock/doctype/packing_slip/test_packing_slip.py +++ b/erpnext/stock/doctype/packing_slip/test_packing_slip.py @@ -3,9 +3,118 @@ import unittest -# test_records = frappe.get_test_records('Packing Slip') +import frappe from frappe.tests.utils import FrappeTestCase +from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle +from erpnext.stock.doctype.delivery_note.delivery_note import make_packing_slip +from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note +from erpnext.stock.doctype.item.test_item import make_item -class TestPackingSlip(unittest.TestCase): - pass + +class TestPackingSlip(FrappeTestCase): + def test_packing_slip(self): + # Step - 1: Create a Product Bundle + items = create_items() + make_product_bundle(items[0], items[1:], 5) + + # Step - 2: Create a Delivery Note (Draft) with Product Bundle + dn = create_delivery_note( + item_code=items[0], + qty=2, + do_not_save=True, + ) + dn.append( + "items", + { + "item_code": items[1], + "warehouse": "_Test Warehouse - _TC", + "qty": 10, + }, + ) + dn.save() + + # Step - 3: Make a Packing Slip from Delivery Note for 4 Qty + ps1 = make_packing_slip(dn.name) + for item in ps1.items: + item.qty = 4 + ps1.save() + ps1.submit() + + # Test - 1: `Packed Qty` should be updated to 4 in Delivery Note Items and Packed Items. + dn.load_from_db() + for item in dn.items: + if not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code}): + self.assertEqual(item.packed_qty, 4) + + for item in dn.packed_items: + self.assertEqual(item.packed_qty, 4) + + # Step - 4: Make another Packing Slip from Delivery Note for 6 Qty + ps2 = make_packing_slip(dn.name) + ps2.save() + ps2.submit() + + # Test - 2: `Packed Qty` should be updated to 10 in Delivery Note Items and Packed Items. + dn.load_from_db() + for item in dn.items: + if not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code}): + self.assertEqual(item.packed_qty, 10) + + for item in dn.packed_items: + self.assertEqual(item.packed_qty, 10) + + # Step - 5: Cancel Packing Slip [1] + ps1.cancel() + + # Test - 3: `Packed Qty` should be updated to 4 in Delivery Note Items and Packed Items. + dn.load_from_db() + for item in dn.items: + if not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code}): + self.assertEqual(item.packed_qty, 6) + + for item in dn.packed_items: + self.assertEqual(item.packed_qty, 6) + + # Step - 6: Cancel Packing Slip [2] + ps2.cancel() + + # Test - 4: `Packed Qty` should be updated to 0 in Delivery Note Items and Packed Items. + dn.load_from_db() + for item in dn.items: + if not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code}): + self.assertEqual(item.packed_qty, 0) + + for item in dn.packed_items: + self.assertEqual(item.packed_qty, 0) + + # Step - 7: Make Packing Slip for more Qty than Delivery Note + ps3 = make_packing_slip(dn.name) + ps3.items[0].qty = 20 + + # Test - 5: Should throw an ValidationError, as Packing Slip Qty is more than Delivery Note Qty + self.assertRaises(frappe.exceptions.ValidationError, ps3.save) + + # Step - 8: Make Packing Slip for less Qty than Delivery Note + ps4 = make_packing_slip(dn.name) + ps4.items[0].qty = 5 + ps4.save() + ps4.submit() + + # Test - 6: Delivery Note should throw a ValidationError on Submit, as Packed Qty and Delivery Note Qty are not the same + dn.load_from_db() + self.assertRaises(frappe.exceptions.ValidationError, dn.submit) + + +def create_items(): + items_properties = [ + {"is_stock_item": 0}, + {"is_stock_item": 1, "stock_uom": "Nos"}, + {"is_stock_item": 1, "stock_uom": "Box"}, + ] + + items = [] + for properties in items_properties: + items.append(make_item(properties=properties).name) + + return items diff --git a/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json b/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json index 4270839bfdb..4bd90355acb 100644 --- a/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json +++ b/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json @@ -20,7 +20,8 @@ "stock_uom", "weight_uom", "page_break", - "dn_detail" + "dn_detail", + "pi_detail" ], "fields": [ { @@ -121,13 +122,23 @@ "fieldtype": "Data", "hidden": 1, "in_list_view": 1, - "label": "DN Detail" + "label": "Delivery Note Item", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "pi_detail", + "fieldtype": "Data", + "hidden": 1, + "label": "Delivery Note Packed Item", + "no_copy": 1, + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2021-12-14 01:22:00.715935", + "modified": "2023-04-28 15:00:14.079306", "modified_by": "Administrator", "module": "Stock", "name": "Packing Slip Item", @@ -136,5 +147,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index d82fab8749a..efadf36199c 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2401,7 +2401,7 @@ def move_sample_to_retention_warehouse(company, items): "basic_rate": item.get("valuation_rate"), "uom": item.get("uom"), "stock_uom": item.get("stock_uom"), - "conversion_factor": 1.0, + "conversion_factor": item.get("conversion_factor") or 1.0, "serial_no": sample_serial_nos, "batch_no": item.get("batch_no"), }, diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index ec7fb0f4a28..cb2bc0a7f51 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -8,12 +8,12 @@ "defaults_tab", "item_defaults_section", "item_naming_by", + "valuation_method", "item_group", - "stock_uom", "column_break_4", "default_warehouse", "sample_retention_warehouse", - "valuation_method", + "stock_uom", "price_list_defaults_section", "auto_insert_price_list_rate_if_missing", "column_break_12", @@ -96,6 +96,7 @@ "fieldtype": "Column Break" }, { + "documentation_url": "https://docs.erpnext.com/docs/v14/user/manual/en/stock/articles/calculation-of-valuation-rate-in-fifo-and-moving-average", "fieldname": "valuation_method", "fieldtype": "Select", "label": "Default Valuation Method", @@ -346,7 +347,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2022-02-05 15:33:43.692736", + "modified": "2023-05-29 15:09:54.959411", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/doctype/warehouse/warehouse.js b/erpnext/stock/doctype/warehouse/warehouse.js index d69c624fba3..87a23efc590 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.js +++ b/erpnext/stock/doctype/warehouse/warehouse.js @@ -17,6 +17,7 @@ frappe.ui.form.on("Warehouse", { return { filters: { is_group: 1, + company: doc.company, }, }; }); @@ -39,26 +40,34 @@ frappe.ui.form.on("Warehouse", { !frm.doc.__islocal ); - if (!frm.doc.__islocal) { + if (!frm.is_new()) { frappe.contacts.render_address_and_contact(frm); + + let enable_toggle = frm.doc.disabled ? "Enable" : "Disable"; + frm.add_custom_button(__(enable_toggle), () => { + frm.set_value('disabled', 1 - frm.doc.disabled); + frm.save() + }); + + frm.add_custom_button(__("Stock Balance"), function () { + frappe.set_route("query-report", "Stock Balance", { + warehouse: frm.doc.name, + }); + }); + + frm.add_custom_button( + frm.doc.is_group + ? __("Convert to Ledger", null, "Warehouse") + : __("Convert to Group", null, "Warehouse"), + function () { + convert_to_group_or_ledger(frm); + }, + ); + } else { frappe.contacts.clear_address_and_contact(frm); } - frm.add_custom_button(__("Stock Balance"), function () { - frappe.set_route("query-report", "Stock Balance", { - warehouse: frm.doc.name, - }); - }); - - frm.add_custom_button( - frm.doc.is_group - ? __("Convert to Ledger", null, "Warehouse") - : __("Convert to Group", null, "Warehouse"), - function () { - convert_to_group_or_ledger(frm); - }, - ); if (!frm.doc.is_group && frm.doc.__onload && frm.doc.__onload.account) { frm.add_custom_button( diff --git a/erpnext/stock/doctype/warehouse/warehouse.json b/erpnext/stock/doctype/warehouse/warehouse.json index c695d541bf9..43b2ad2a69b 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.json +++ b/erpnext/stock/doctype/warehouse/warehouse.json @@ -1,23 +1,21 @@ { "actions": [], "allow_import": 1, - "creation": "2013-03-07 18:50:32", + "creation": "2023-05-29 13:02:17.121296", "description": "A logical Warehouse against which stock entries are made.", "doctype": "DocType", "document_type": "Setup", "engine": "InnoDB", "field_order": [ "warehouse_detail", + "disabled", "warehouse_name", "column_break_3", - "warehouse_type", - "parent_warehouse", - "default_in_transit_warehouse", "is_group", + "parent_warehouse", "column_break_4", "account", "company", - "disabled", "address_and_contact", "address_html", "column_break_10", @@ -32,6 +30,10 @@ "city", "state", "pin", + "transit_section", + "warehouse_type", + "column_break_qajx", + "default_in_transit_warehouse", "tree_details", "lft", "rgt", @@ -58,7 +60,7 @@ "fieldname": "is_group", "fieldtype": "Check", "in_list_view": 1, - "label": "Is Group" + "label": "Is Group Warehouse" }, { "fieldname": "company", @@ -78,7 +80,7 @@ "default": "0", "fieldname": "disabled", "fieldtype": "Check", - "in_list_view": 1, + "hidden": 1, "label": "Disabled" }, { @@ -164,7 +166,6 @@ { "fieldname": "city", "fieldtype": "Data", - "in_list_view": 1, "label": "City", "oldfieldname": "city", "oldfieldtype": "Data" @@ -238,13 +239,23 @@ "fieldtype": "Link", "label": "Default In-Transit Warehouse", "options": "Warehouse" + }, + { + "collapsible": 1, + "fieldname": "transit_section", + "fieldtype": "Section Break", + "label": "Transit" + }, + { + "fieldname": "column_break_qajx", + "fieldtype": "Column Break" } ], "icon": "fa fa-building", "idx": 1, "is_tree": 1, "links": [], - "modified": "2022-03-01 02:37:48.034944", + "modified": "2023-05-29 13:10:43.333160", "modified_by": "Administrator", "module": "Stock", "name": "Warehouse", @@ -261,7 +272,6 @@ "read": 1, "report": 1, "role": "Item Manager", - "set_user_permissions": 1, "share": 1, "write": 1 }, diff --git a/erpnext/stock/form_tour/stock_reconciliation/stock_reconciliation.json b/erpnext/stock/form_tour/stock_reconciliation/stock_reconciliation.json index 5b7fd72c082..07a511071f8 100644 --- a/erpnext/stock/form_tour/stock_reconciliation/stock_reconciliation.json +++ b/erpnext/stock/form_tour/stock_reconciliation/stock_reconciliation.json @@ -2,54 +2,75 @@ "creation": "2021-08-24 14:44:46.770952", "docstatus": 0, "doctype": "Form Tour", + "first_document": 0, "idx": 0, + "include_name_field": 0, "is_standard": 1, - "modified": "2021-08-25 16:26:11.718664", + "list_name": "List", + "modified": "2023-05-29 13:38:27.192177", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reconciliation", + "new_document_form": 0, "owner": "Administrator", "reference_doctype": "Stock Reconciliation", "save_on_complete": 1, "steps": [ { "description": "Set Purpose to Opening Stock to set the stock opening balance.", - "field": "", "fieldname": "purpose", "fieldtype": "Select", "has_next_condition": 1, + "hide_buttons": 0, "is_table_field": 0, "label": "Purpose", + "modal_trigger": 0, + "next_on_click": 0, "next_step_condition": "eval: doc.purpose === \"Opening Stock\"", - "parent_field": "", + "offset_x": 0, + "offset_y": 0, + "popover_element": 0, "position": "Top", - "title": "Purpose" - }, - { - "description": "Select the items for which the opening stock has to be set.", - "field": "", - "fieldname": "items", - "fieldtype": "Table", - "has_next_condition": 1, - "is_table_field": 0, - "label": "Items", - "next_step_condition": "eval: doc.items[0]?.item_code", - "parent_field": "", - "position": "Top", - "title": "Items" + "title": "Purpose", + "ui_tour": 0 }, { "description": "Edit the Posting Date by clicking on the Edit Posting Date and Time checkbox below.", - "field": "", "fieldname": "posting_date", "fieldtype": "Date", "has_next_condition": 0, + "hide_buttons": 0, "is_table_field": 0, "label": "Posting Date", - "parent_field": "", + "modal_trigger": 0, + "next_on_click": 0, + "offset_x": 0, + "offset_y": 0, + "popover_element": 0, "position": "Bottom", - "title": "Posting Date" + "title": "Posting Date", + "ui_tour": 0 + }, + { + "description": "Select the items for which the opening stock has to be set.", + "fieldname": "items", + "fieldtype": "Table", + "has_next_condition": 1, + "hide_buttons": 0, + "is_table_field": 0, + "label": "Items", + "modal_trigger": 0, + "next_on_click": 0, + "next_step_condition": "eval: doc.items[0]?.item_code", + "offset_x": 0, + "offset_y": 0, + "popover_element": 0, + "position": "Top", + "title": "Items", + "ui_tour": 0 } ], - "title": "Stock Reconciliation" + "title": "Stock Reconciliation", + "track_steps": 0, + "ui_tour": 0 } \ No newline at end of file diff --git a/erpnext/stock/form_tour/stock_settings/stock_settings.json b/erpnext/stock/form_tour/stock_settings/stock_settings.json index 3d164e33b3b..adbd1597f39 100644 --- a/erpnext/stock/form_tour/stock_settings/stock_settings.json +++ b/erpnext/stock/form_tour/stock_settings/stock_settings.json @@ -2,88 +2,73 @@ "creation": "2021-08-20 15:20:59.336585", "docstatus": 0, "doctype": "Form Tour", + "first_document": 0, "idx": 0, + "include_name_field": 0, "is_standard": 1, - "modified": "2021-08-25 16:19:37.699528", + "list_name": "List", + "modified": "2023-05-29 12:33:19.142202", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", + "new_document_form": 0, "owner": "Administrator", "reference_doctype": "Stock Settings", "save_on_complete": 1, "steps": [ { "description": "By default, the Item Name is set as per the Item Code entered. If you want Items to be named by a Naming Series choose the 'Naming Series' option.", - "field": "", "fieldname": "item_naming_by", "fieldtype": "Select", "has_next_condition": 0, + "hide_buttons": 0, "is_table_field": 0, "label": "Item Naming By", - "parent_field": "", + "modal_trigger": 0, + "next_on_click": 0, + "offset_x": 0, + "offset_y": 0, + "popover_element": 0, "position": "Bottom", - "title": "Item Naming By" + "title": "Item Naming By", + "ui_tour": 0 }, { "description": "Set a Default Warehouse for Inventory Transactions. This will be fetched into the Default Warehouse in the Item master.", - "field": "", "fieldname": "default_warehouse", "fieldtype": "Link", "has_next_condition": 0, + "hide_buttons": 0, "is_table_field": 0, "label": "Default Warehouse", - "parent_field": "", + "modal_trigger": 0, + "next_on_click": 0, + "offset_x": 0, + "offset_y": 0, + "popover_element": 0, "position": "Bottom", - "title": "Default Warehouse" - }, - { - "description": "Quality inspection is performed on the inward and outward movement of goods. Receipt and delivery transactions will be stopped or the user will be warned if the quality inspection is not performed.", - "field": "", - "fieldname": "action_if_quality_inspection_is_not_submitted", - "fieldtype": "Select", - "has_next_condition": 0, - "is_table_field": 0, - "label": "Action If Quality Inspection Is Not Submitted", - "parent_field": "", - "position": "Bottom", - "title": "Action if Quality Inspection Is Not Submitted" - }, - { - "description": "Serial numbers for stock will be set automatically based on the Items entered based on first in first out in transactions like Purchase/Sales Invoices, Delivery Notes, etc.", - "field": "", - "fieldname": "automatically_set_serial_nos_based_on_fifo", - "fieldtype": "Check", - "has_next_condition": 0, - "is_table_field": 0, - "label": "Automatically Set Serial Nos Based on FIFO", - "parent_field": "", - "position": "Bottom", - "title": "Automatically Set Serial Nos based on FIFO" - }, - { - "description": "Show 'Scan Barcode' field above every child table to insert Items with ease.", - "field": "", - "fieldname": "show_barcode_field", - "fieldtype": "Check", - "has_next_condition": 0, - "is_table_field": 0, - "label": "Show Barcode Field in Stock Transactions", - "parent_field": "", - "position": "Bottom", - "title": "Show Barcode Field" + "title": "Default Warehouse", + "ui_tour": 0 }, { "description": "Choose between FIFO and Moving Average Valuation Methods. Click here to know more about them.", - "field": "", "fieldname": "valuation_method", "fieldtype": "Select", "has_next_condition": 0, + "hide_buttons": 0, "is_table_field": 0, "label": "Default Valuation Method", - "parent_field": "", + "modal_trigger": 0, + "next_on_click": 0, + "offset_x": 0, + "offset_y": 0, + "popover_element": 0, "position": "Bottom", - "title": "Default Valuation Method" + "title": "Default Valuation Method", + "ui_tour": 0 } ], - "title": "Stock Settings" + "title": "Stock Settings", + "track_steps": 0, + "ui_tour": 0 } \ No newline at end of file diff --git a/erpnext/stock/form_tour/warehouse/warehouse.json b/erpnext/stock/form_tour/warehouse/warehouse.json index 23ff2aebbaa..5897357bc73 100644 --- a/erpnext/stock/form_tour/warehouse/warehouse.json +++ b/erpnext/stock/form_tour/warehouse/warehouse.json @@ -2,53 +2,57 @@ "creation": "2021-08-24 14:43:44.465237", "docstatus": 0, "doctype": "Form Tour", + "first_document": 0, "idx": 0, + "include_name_field": 0, "is_standard": 1, - "modified": "2021-08-24 14:50:31.988256", + "list_name": "List", + "modified": "2023-05-29 13:09:49.920796", "modified_by": "Administrator", "module": "Stock", "name": "Warehouse", + "new_document_form": 0, "owner": "Administrator", "reference_doctype": "Warehouse", "save_on_complete": 1, "steps": [ { "description": "Select a name for the warehouse. This should reflect its location or purpose.", - "field": "", "fieldname": "warehouse_name", "fieldtype": "Data", "has_next_condition": 1, + "hide_buttons": 0, "is_table_field": 0, "label": "Warehouse Name", + "modal_trigger": 0, + "next_on_click": 0, "next_step_condition": "eval: doc.warehouse_name", - "parent_field": "", + "offset_x": 0, + "offset_y": 0, + "popover_element": 0, "position": "Bottom", - "title": "Warehouse Name" - }, - { - "description": "Select a warehouse type to categorize the warehouse into a sub-group.", - "field": "", - "fieldname": "warehouse_type", - "fieldtype": "Link", - "has_next_condition": 0, - "is_table_field": 0, - "label": "Warehouse Type", - "parent_field": "", - "position": "Top", - "title": "Warehouse Type" + "title": "Warehouse Name", + "ui_tour": 0 }, { "description": "Select an account to set a default account for all transactions with this warehouse.", - "field": "", "fieldname": "account", "fieldtype": "Link", "has_next_condition": 0, + "hide_buttons": 0, "is_table_field": 0, "label": "Account", - "parent_field": "", + "modal_trigger": 0, + "next_on_click": 0, + "offset_x": 0, + "offset_y": 0, + "popover_element": 0, "position": "Top", - "title": "Account" + "title": "Account", + "ui_tour": 0 } ], - "title": "Warehouse" + "title": "Warehouse", + "track_steps": 0, + "ui_tour": 0 } \ No newline at end of file diff --git a/erpnext/stock/module_onboarding/stock/stock.json b/erpnext/stock/module_onboarding/stock/stock.json index c246747a5b3..864ac4be3b4 100644 --- a/erpnext/stock/module_onboarding/stock/stock.json +++ b/erpnext/stock/module_onboarding/stock/stock.json @@ -19,7 +19,7 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/stock", "idx": 0, "is_complete": 0, - "modified": "2021-08-20 14:38:55.570067", + "modified": "2023-05-29 14:43:36.223302", "modified_by": "Administrator", "module": "Stock", "name": "Stock", @@ -35,10 +35,10 @@ "step": "Create a Stock Entry" }, { - "step": "Stock Opening Balance" + "step": "Check Stock Ledger Report" }, { - "step": "View Stock Projected Qty" + "step": "Stock Opening Balance" } ], "subtitle": "Inventory, Warehouses, Analysis, and more.", diff --git a/erpnext/stock/onboarding_step/check_stock_ledger_report/check_stock_ledger_report.json b/erpnext/stock/onboarding_step/check_stock_ledger_report/check_stock_ledger_report.json new file mode 100644 index 00000000000..cdbc0b759ba --- /dev/null +++ b/erpnext/stock/onboarding_step/check_stock_ledger_report/check_stock_ledger_report.json @@ -0,0 +1,24 @@ +{ + "action": "View Report", + "action_label": "Check Stock Ledger", + "creation": "2023-05-29 13:46:04.174565", + "description": "# Check Stock Reports\nBased on the various stock transactions, you can get a host of one-click Stock Reports in ERPNext like Stock Ledger, Stock Balance, Projected Quantity, and Ageing analysis.", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2023-05-29 14:39:03.943244", + "modified_by": "Administrator", + "name": "Check Stock Ledger Report", + "owner": "Administrator", + "reference_report": "Stock Ledger", + "report_description": "Stock Ledger report contains every submitted stock transaction. You can use filter to narrow down ledger entries.", + "report_reference_doctype": "Stock Ledger Entry", + "report_type": "Script Report", + "show_form_tour": 0, + "show_full_form": 0, + "title": "Check Stock Ledger", + "validate_action": 1 +} \ No newline at end of file diff --git a/erpnext/stock/onboarding_step/create_a_stock_entry/create_a_stock_entry.json b/erpnext/stock/onboarding_step/create_a_stock_entry/create_a_stock_entry.json index 3cb522c893d..dea2aae9500 100644 --- a/erpnext/stock/onboarding_step/create_a_stock_entry/create_a_stock_entry.json +++ b/erpnext/stock/onboarding_step/create_a_stock_entry/create_a_stock_entry.json @@ -9,7 +9,7 @@ "is_complete": 0, "is_single": 0, "is_skipped": 0, - "modified": "2021-06-18 13:57:11.434063", + "modified": "2023-05-29 14:39:04.066547", "modified_by": "Administrator", "name": "Create a Stock Entry", "owner": "Administrator", diff --git a/erpnext/stock/onboarding_step/create_a_warehouse/create_a_warehouse.json b/erpnext/stock/onboarding_step/create_a_warehouse/create_a_warehouse.json index 22c88bf10ea..25926127a08 100644 --- a/erpnext/stock/onboarding_step/create_a_warehouse/create_a_warehouse.json +++ b/erpnext/stock/onboarding_step/create_a_warehouse/create_a_warehouse.json @@ -9,7 +9,7 @@ "is_complete": 0, "is_single": 0, "is_skipped": 0, - "modified": "2021-08-18 12:23:36.675572", + "modified": "2023-05-29 14:39:04.074907", "modified_by": "Administrator", "name": "Create a Warehouse", "owner": "Administrator", diff --git a/erpnext/stock/onboarding_step/stock_opening_balance/stock_opening_balance.json b/erpnext/stock/onboarding_step/stock_opening_balance/stock_opening_balance.json index 48fd1fddee0..18c95505d72 100644 --- a/erpnext/stock/onboarding_step/stock_opening_balance/stock_opening_balance.json +++ b/erpnext/stock/onboarding_step/stock_opening_balance/stock_opening_balance.json @@ -9,7 +9,7 @@ "is_complete": 0, "is_single": 0, "is_skipped": 0, - "modified": "2021-06-18 13:59:36.021097", + "modified": "2023-05-29 14:39:08.825699", "modified_by": "Administrator", "name": "Stock Opening Balance", "owner": "Administrator", diff --git a/erpnext/stock/onboarding_step/stock_settings/stock_settings.json b/erpnext/stock/onboarding_step/stock_settings/stock_settings.json index 2cf90e806cd..b48ac806a46 100644 --- a/erpnext/stock/onboarding_step/stock_settings/stock_settings.json +++ b/erpnext/stock/onboarding_step/stock_settings/stock_settings.json @@ -9,7 +9,7 @@ "is_complete": 0, "is_single": 1, "is_skipped": 0, - "modified": "2021-08-18 12:06:51.139387", + "modified": "2023-05-29 14:39:04.083360", "modified_by": "Administrator", "name": "Stock Settings", "owner": "Administrator", diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 2fa97ae3545..d3f1f31af48 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -281,7 +281,7 @@ class FIFOSlots: # consume transfer data and add stock to fifo queue self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row) else: - if not serial_nos: + if not serial_nos and not row.get("has_serial_no"): if fifo_queue and flt(fifo_queue[0][0]) <= 0: # neutralize 0/negative stock by adding positive stock fifo_queue[0][0] += flt(row.actual_qty) diff --git a/erpnext/stock/report/stock_analytics/stock_analytics.py b/erpnext/stock/report/stock_analytics/stock_analytics.py index 27b94ab3f96..6c5b58c6e45 100644 --- a/erpnext/stock/report/stock_analytics/stock_analytics.py +++ b/erpnext/stock/report/stock_analytics/stock_analytics.py @@ -5,15 +5,13 @@ from typing import List import frappe from frappe import _, scrub +from frappe.query_builder.functions import CombineDatetime from frappe.utils import get_first_day as get_first_day_of_month from frappe.utils import get_first_day_of_week, get_quarter_start, getdate +from frappe.utils.nestedset import get_descendants_of from erpnext.accounts.utils import get_fiscal_year -from erpnext.stock.report.stock_balance.stock_balance import ( - get_item_details, - get_items, - get_stock_ledger_entries, -) +from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter from erpnext.stock.utils import is_reposting_item_valuation_in_progress @@ -231,7 +229,7 @@ def get_data(filters): data = [] items = get_items(filters) sle = get_stock_ledger_entries(filters, items) - item_details = get_item_details(items, sle, filters) + item_details = get_item_details(items, sle) periodic_data = get_periodic_data(sle, filters) ranges = get_period_date_ranges(filters) @@ -265,3 +263,109 @@ def get_chart_data(columns): chart["type"] = "line" return chart + + +def get_items(filters): + "Get items based on item code, item group or brand." + if item_code := filters.get("item_code"): + return [item_code] + else: + item_filters = {} + if item_group := filters.get("item_group"): + children = get_descendants_of("Item Group", item_group, ignore_permissions=True) + item_filters["item_group"] = ("in", children + [item_group]) + if brand := filters.get("brand"): + item_filters["brand"] = brand + + return frappe.get_all("Item", filters=item_filters, pluck="name", order_by=None) + + +def get_stock_ledger_entries(filters, items): + sle = frappe.qb.DocType("Stock Ledger Entry") + + query = ( + frappe.qb.from_(sle) + .select( + sle.item_code, + sle.warehouse, + sle.posting_date, + sle.actual_qty, + sle.valuation_rate, + sle.company, + sle.voucher_type, + sle.qty_after_transaction, + sle.stock_value_difference, + sle.item_code.as_("name"), + sle.voucher_no, + sle.stock_value, + sle.batch_no, + ) + .where((sle.docstatus < 2) & (sle.is_cancelled == 0)) + .orderby(CombineDatetime(sle.posting_date, sle.posting_time)) + .orderby(sle.creation) + .orderby(sle.actual_qty) + ) + + if items: + query = query.where(sle.item_code.isin(items)) + + query = apply_conditions(query, filters) + return query.run(as_dict=True) + + +def apply_conditions(query, filters): + sle = frappe.qb.DocType("Stock Ledger Entry") + warehouse_table = frappe.qb.DocType("Warehouse") + + if not filters.get("from_date"): + frappe.throw(_("'From Date' is required")) + + if to_date := filters.get("to_date"): + query = query.where(sle.posting_date <= to_date) + else: + frappe.throw(_("'To Date' is required")) + + if company := filters.get("company"): + query = query.where(sle.company == company) + + if filters.get("warehouse"): + query = apply_warehouse_filter(query, sle, filters) + elif warehouse_type := filters.get("warehouse_type"): + query = ( + query.join(warehouse_table) + .on(warehouse_table.name == sle.warehouse) + .where(warehouse_table.warehouse_type == warehouse_type) + ) + + return query + + +def get_item_details(items, sle): + item_details = {} + if not items: + items = list(set(d.item_code for d in sle)) + + if not items: + return item_details + + item_table = frappe.qb.DocType("Item") + + query = ( + frappe.qb.from_(item_table) + .select( + item_table.name, + item_table.item_name, + item_table.description, + item_table.item_group, + item_table.brand, + item_table.stock_uom, + ) + .where(item_table.name.isin(items)) + ) + + result = query.run(as_dict=1) + + for item_table in result: + item_details.setdefault(item_table.name, item_table) + + return item_details diff --git a/erpnext/stock/report/stock_balance/stock_balance.js b/erpnext/stock/report/stock_balance/stock_balance.js index 9b3965d0d69..33ed955a5c4 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.js +++ b/erpnext/stock/report/stock_balance/stock_balance.js @@ -87,6 +87,12 @@ frappe.query_reports["Stock Balance"] = { "label": __('Show Stock Ageing Data'), "fieldtype": 'Check' }, + { + "fieldname": 'ignore_closing_balance', + "label": __('Ignore Closing Balance'), + "fieldtype": 'Check', + "default": 1 + }, ], "formatter": function (value, row, column, data, default_formatter) { diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 66991a907fd..7c821700df3 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -7,15 +7,16 @@ from typing import Any, Dict, List, Optional, TypedDict import frappe from frappe import _ +from frappe.query_builder import Order from frappe.query_builder.functions import Coalesce, CombineDatetime -from frappe.utils import cint, date_diff, flt, getdate +from frappe.utils import add_days, cint, date_diff, flt, getdate from frappe.utils.nestedset import get_descendants_of import erpnext from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age -from erpnext.stock.utils import add_additional_uom_columns, is_reposting_item_valuation_in_progress +from erpnext.stock.utils import add_additional_uom_columns class StockBalanceFilter(TypedDict): @@ -35,400 +36,548 @@ SLEntry = Dict[str, Any] def execute(filters: Optional[StockBalanceFilter] = None): - is_reposting_item_valuation_in_progress() - if not filters: - filters = {} + return StockBalanceReport(filters).run() - if filters.get("company"): - company_currency = erpnext.get_company_currency(filters.get("company")) - else: - company_currency = frappe.db.get_single_value("Global Defaults", "default_currency") - include_uom = filters.get("include_uom") - columns = get_columns(filters) - items = get_items(filters) - sle = get_stock_ledger_entries(filters, items) +class StockBalanceReport(object): + def __init__(self, filters: Optional[StockBalanceFilter]) -> None: + self.filters = filters + self.from_date = getdate(filters.get("from_date")) + self.to_date = getdate(filters.get("to_date")) - if filters.get("show_stock_ageing_data"): - filters["show_warehouse_wise_stock"] = True - item_wise_fifo_queue = FIFOSlots(filters, sle).generate() + self.start_from = None + self.data = [] + self.columns = [] + self.sle_entries: List[SLEntry] = [] + self.set_company_currency() - # if no stock ledger entry found return - if not sle: - return columns, [] + def set_company_currency(self) -> None: + if self.filters.get("company"): + self.company_currency = erpnext.get_company_currency(self.filters.get("company")) + else: + self.company_currency = frappe.db.get_single_value("Global Defaults", "default_currency") - iwb_map = get_item_warehouse_map(filters, sle) - item_map = get_item_details(items, sle, filters) - item_reorder_detail_map = get_item_reorder_details(item_map.keys()) + def run(self): + self.float_precision = cint(frappe.db.get_default("float_precision")) or 3 - data = [] - conversion_factors = {} + self.inventory_dimensions = self.get_inventory_dimension_fields() + self.prepare_opening_data_from_closing_balance() + self.prepare_stock_ledger_entries() + self.prepare_new_data() - _func = itemgetter(1) + if not self.columns: + self.columns = self.get_columns() - to_date = filters.get("to_date") + self.add_additional_uom_columns() - for group_by_key in iwb_map: - item = group_by_key[1] - warehouse = group_by_key[2] - company = group_by_key[0] + return self.columns, self.data - if item_map.get(item): - qty_dict = iwb_map[group_by_key] - item_reorder_level = 0 - item_reorder_qty = 0 - if item + warehouse in item_reorder_detail_map: - item_reorder_level = item_reorder_detail_map[item + warehouse]["warehouse_reorder_level"] - item_reorder_qty = item_reorder_detail_map[item + warehouse]["warehouse_reorder_qty"] + def prepare_opening_data_from_closing_balance(self) -> None: + self.opening_data = frappe._dict({}) - report_data = { - "currency": company_currency, - "item_code": item, - "warehouse": warehouse, - "company": company, - "reorder_level": item_reorder_level, - "reorder_qty": item_reorder_qty, - } - report_data.update(item_map[item]) - report_data.update(qty_dict) + closing_balance = self.get_closing_balance() + if not closing_balance: + return - if include_uom: - conversion_factors.setdefault(item, item_map[item].conversion_factor) + self.start_from = add_days(closing_balance[0].to_date, 1) + res = frappe.get_doc("Closing Stock Balance", closing_balance[0].name).get_prepared_data() - if filters.get("show_stock_ageing_data"): - fifo_queue = item_wise_fifo_queue[(item, warehouse)].get("fifo_queue") + for entry in res.data: + entry = frappe._dict(entry) + + group_by_key = self.get_group_by_key(entry) + if group_by_key not in self.opening_data: + self.opening_data.setdefault(group_by_key, entry) + + def prepare_new_data(self): + if not self.sle_entries: + return + + if self.filters.get("show_stock_ageing_data"): + self.filters["show_warehouse_wise_stock"] = True + item_wise_fifo_queue = FIFOSlots(self.filters, self.sle_entries).generate() + + _func = itemgetter(1) + + self.item_warehouse_map = self.get_item_warehouse_map() + + variant_values = {} + if self.filters.get("show_variant_attributes"): + variant_values = self.get_variant_values_for() + + for key, report_data in self.item_warehouse_map.items(): + if variant_data := variant_values.get(report_data.item_code): + report_data.update(variant_data) + + if self.filters.get("show_stock_ageing_data"): + opening_fifo_queue = self.get_opening_fifo_queue(report_data) or [] + + fifo_queue = [] + if fifo_queue := item_wise_fifo_queue.get((report_data.item_code, report_data.warehouse)): + fifo_queue = fifo_queue.get("fifo_queue") + + if fifo_queue: + opening_fifo_queue.extend(fifo_queue) stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0} - if fifo_queue: - fifo_queue = sorted(filter(_func, fifo_queue), key=_func) + if opening_fifo_queue: + fifo_queue = sorted(filter(_func, opening_fifo_queue), key=_func) if not fifo_queue: continue + to_date = self.to_date stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date) stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1]) stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1]) + stock_ageing_data["fifo_queue"] = fifo_queue report_data.update(stock_ageing_data) - data.append(report_data) + self.data.append(report_data) - add_additional_uom_columns(columns, data, include_uom, conversion_factors) - return columns, data + def get_item_warehouse_map(self): + item_warehouse_map = {} + self.opening_vouchers = self.get_opening_vouchers() + for entry in self.sle_entries: + group_by_key = self.get_group_by_key(entry) + if group_by_key not in item_warehouse_map: + self.initialize_data(item_warehouse_map, group_by_key, entry) -def get_columns(filters: StockBalanceFilter): - """return columns""" - columns = [ - { - "label": _("Item"), - "fieldname": "item_code", - "fieldtype": "Link", - "options": "Item", - "width": 100, - }, - {"label": _("Item Name"), "fieldname": "item_name", "width": 150}, - { - "label": _("Item Group"), - "fieldname": "item_group", - "fieldtype": "Link", - "options": "Item Group", - "width": 100, - }, - { - "label": _("Warehouse"), - "fieldname": "warehouse", - "fieldtype": "Link", - "options": "Warehouse", - "width": 100, - }, - ] + self.prepare_item_warehouse_map(item_warehouse_map, entry, group_by_key) - for dimension in get_inventory_dimensions(): - columns.append( - { - "label": _(dimension.doctype), - "fieldname": dimension.fieldname, - "fieldtype": "Link", - "options": dimension.doctype, - "width": 110, - } + if self.opening_data.get(group_by_key): + del self.opening_data[group_by_key] + + for group_by_key, entry in self.opening_data.items(): + if group_by_key not in item_warehouse_map: + self.initialize_data(item_warehouse_map, group_by_key, entry) + + item_warehouse_map = filter_items_with_no_transactions( + item_warehouse_map, self.float_precision, self.inventory_dimensions ) - columns.extend( - [ - { - "label": _("Stock UOM"), - "fieldname": "stock_uom", - "fieldtype": "Link", - "options": "UOM", - "width": 90, - }, - { - "label": _("Balance Qty"), - "fieldname": "bal_qty", - "fieldtype": "Float", - "width": 100, - "convertible": "qty", - }, - { - "label": _("Balance Value"), - "fieldname": "bal_val", - "fieldtype": "Currency", - "width": 100, - "options": "currency", - }, - { - "label": _("Opening Qty"), - "fieldname": "opening_qty", - "fieldtype": "Float", - "width": 100, - "convertible": "qty", - }, - { - "label": _("Opening Value"), - "fieldname": "opening_val", - "fieldtype": "Currency", - "width": 110, - "options": "currency", - }, - { - "label": _("In Qty"), - "fieldname": "in_qty", - "fieldtype": "Float", - "width": 80, - "convertible": "qty", - }, - {"label": _("In Value"), "fieldname": "in_val", "fieldtype": "Float", "width": 80}, - { - "label": _("Out Qty"), - "fieldname": "out_qty", - "fieldtype": "Float", - "width": 80, - "convertible": "qty", - }, - {"label": _("Out Value"), "fieldname": "out_val", "fieldtype": "Float", "width": 80}, - { - "label": _("Valuation Rate"), - "fieldname": "val_rate", - "fieldtype": "Currency", - "width": 90, - "convertible": "rate", - "options": "currency", - }, - { - "label": _("Reorder Level"), - "fieldname": "reorder_level", - "fieldtype": "Float", - "width": 80, - "convertible": "qty", - }, - { - "label": _("Reorder Qty"), - "fieldname": "reorder_qty", - "fieldtype": "Float", - "width": 80, - "convertible": "qty", - }, - { - "label": _("Company"), - "fieldname": "company", - "fieldtype": "Link", - "options": "Company", - "width": 100, - }, - ] - ) + return item_warehouse_map - if filters.get("show_stock_ageing_data"): - columns += [ - {"label": _("Average Age"), "fieldname": "average_age", "width": 100}, - {"label": _("Earliest Age"), "fieldname": "earliest_age", "width": 100}, - {"label": _("Latest Age"), "fieldname": "latest_age", "width": 100}, - ] + def prepare_item_warehouse_map(self, item_warehouse_map, entry, group_by_key): + qty_dict = item_warehouse_map[group_by_key] + for field in self.inventory_dimensions: + qty_dict[field] = entry.get(field) - if filters.get("show_variant_attributes"): - columns += [ - {"label": att_name, "fieldname": att_name, "width": 100} - for att_name in get_variants_attributes() - ] - - return columns - - -def apply_conditions(query, filters): - sle = frappe.qb.DocType("Stock Ledger Entry") - warehouse_table = frappe.qb.DocType("Warehouse") - - if not filters.get("from_date"): - frappe.throw(_("'From Date' is required")) - - if to_date := filters.get("to_date"): - query = query.where(sle.posting_date <= to_date) - else: - frappe.throw(_("'To Date' is required")) - - if company := filters.get("company"): - query = query.where(sle.company == company) - - if filters.get("warehouse"): - query = apply_warehouse_filter(query, sle, filters) - elif warehouse_type := filters.get("warehouse_type"): - query = ( - query.join(warehouse_table) - .on(warehouse_table.name == sle.warehouse) - .where(warehouse_table.warehouse_type == warehouse_type) - ) - - return query - - -def get_stock_ledger_entries(filters: StockBalanceFilter, items: List[str]) -> List[SLEntry]: - sle = frappe.qb.DocType("Stock Ledger Entry") - - query = ( - frappe.qb.from_(sle) - .select( - sle.item_code, - sle.warehouse, - sle.posting_date, - sle.actual_qty, - sle.valuation_rate, - sle.company, - sle.voucher_type, - sle.qty_after_transaction, - sle.stock_value_difference, - sle.item_code.as_("name"), - sle.voucher_no, - sle.stock_value, - sle.batch_no, - ) - .where((sle.docstatus < 2) & (sle.is_cancelled == 0)) - .orderby(CombineDatetime(sle.posting_date, sle.posting_time)) - .orderby(sle.creation) - .orderby(sle.actual_qty) - ) - - inventory_dimension_fields = get_inventory_dimension_fields() - if inventory_dimension_fields: - for fieldname in inventory_dimension_fields: - query = query.select(fieldname) - if fieldname in filters and filters.get(fieldname): - query = query.where(sle[fieldname].isin(filters.get(fieldname))) - - if items: - query = query.where(sle.item_code.isin(items)) - - query = apply_conditions(query, filters) - return query.run(as_dict=True) - - -def get_opening_vouchers(to_date): - opening_vouchers = {"Stock Entry": [], "Stock Reconciliation": []} - - se = frappe.qb.DocType("Stock Entry") - sr = frappe.qb.DocType("Stock Reconciliation") - - vouchers_data = ( - frappe.qb.from_( - ( - frappe.qb.from_(se) - .select(se.name, Coalesce("Stock Entry").as_("voucher_type")) - .where((se.docstatus == 1) & (se.posting_date <= to_date) & (se.is_opening == "Yes")) - ) - + ( - frappe.qb.from_(sr) - .select(sr.name, Coalesce("Stock Reconciliation").as_("voucher_type")) - .where((sr.docstatus == 1) & (sr.posting_date <= to_date) & (sr.purpose == "Opening Stock")) - ) - ).select("voucher_type", "name") - ).run(as_dict=True) - - if vouchers_data: - for d in vouchers_data: - opening_vouchers[d.voucher_type].append(d.name) - - return opening_vouchers - - -def get_inventory_dimension_fields(): - return [dimension.fieldname for dimension in get_inventory_dimensions()] - - -def get_item_warehouse_map(filters: StockBalanceFilter, sle: List[SLEntry]): - iwb_map = {} - from_date = getdate(filters.get("from_date")) - to_date = getdate(filters.get("to_date")) - opening_vouchers = get_opening_vouchers(to_date) - float_precision = cint(frappe.db.get_default("float_precision")) or 3 - inventory_dimensions = get_inventory_dimension_fields() - - for d in sle: - group_by_key = get_group_by_key(d, filters, inventory_dimensions) - if group_by_key not in iwb_map: - iwb_map[group_by_key] = frappe._dict( - { - "opening_qty": 0.0, - "opening_val": 0.0, - "in_qty": 0.0, - "in_val": 0.0, - "out_qty": 0.0, - "out_val": 0.0, - "bal_qty": 0.0, - "bal_val": 0.0, - "val_rate": 0.0, - } - ) - - qty_dict = iwb_map[group_by_key] - for field in inventory_dimensions: - qty_dict[field] = d.get(field) - - if d.voucher_type == "Stock Reconciliation" and not d.batch_no: - qty_diff = flt(d.qty_after_transaction) - flt(qty_dict.bal_qty) + if entry.voucher_type == "Stock Reconciliation" and (not entry.batch_no or entry.serial_no): + qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty) else: - qty_diff = flt(d.actual_qty) + qty_diff = flt(entry.actual_qty) - value_diff = flt(d.stock_value_difference) + value_diff = flt(entry.stock_value_difference) - if d.posting_date < from_date or d.voucher_no in opening_vouchers.get(d.voucher_type, []): + if entry.posting_date < self.from_date or entry.voucher_no in self.opening_vouchers.get( + entry.voucher_type, [] + ): qty_dict.opening_qty += qty_diff qty_dict.opening_val += value_diff - elif d.posting_date >= from_date and d.posting_date <= to_date: - if flt(qty_diff, float_precision) >= 0: + elif entry.posting_date >= self.from_date and entry.posting_date <= self.to_date: + + if flt(qty_diff, self.float_precision) >= 0: qty_dict.in_qty += qty_diff qty_dict.in_val += value_diff else: qty_dict.out_qty += abs(qty_diff) qty_dict.out_val += abs(value_diff) - qty_dict.val_rate = d.valuation_rate + qty_dict.val_rate = entry.valuation_rate qty_dict.bal_qty += qty_diff qty_dict.bal_val += value_diff - iwb_map = filter_items_with_no_transactions(iwb_map, float_precision, inventory_dimensions) + def initialize_data(self, item_warehouse_map, group_by_key, entry): + opening_data = self.opening_data.get(group_by_key, {}) - return iwb_map + item_warehouse_map[group_by_key] = frappe._dict( + { + "item_code": entry.item_code, + "warehouse": entry.warehouse, + "item_group": entry.item_group, + "company": entry.company, + "currency": self.company_currency, + "stock_uom": entry.stock_uom, + "item_name": entry.item_name, + "opening_qty": opening_data.get("bal_qty") or 0.0, + "opening_val": opening_data.get("bal_val") or 0.0, + "opening_fifo_queue": opening_data.get("fifo_queue") or [], + "in_qty": 0.0, + "in_val": 0.0, + "out_qty": 0.0, + "out_val": 0.0, + "bal_qty": opening_data.get("bal_qty") or 0.0, + "bal_val": opening_data.get("bal_val") or 0.0, + "val_rate": 0.0, + } + ) + + def get_group_by_key(self, row) -> tuple: + group_by_key = [row.company, row.item_code, row.warehouse] + + for fieldname in self.inventory_dimensions: + if self.filters.get(fieldname): + group_by_key.append(row.get(fieldname)) + + return tuple(group_by_key) + + def get_closing_balance(self) -> List[Dict[str, Any]]: + if self.filters.get("ignore_closing_balance"): + return [] + + table = frappe.qb.DocType("Closing Stock Balance") + + query = ( + frappe.qb.from_(table) + .select(table.name, table.to_date) + .where( + (table.docstatus == 1) + & (table.company == self.filters.company) + & ((table.to_date <= self.from_date)) + ) + .orderby(table.to_date, order=Order.desc) + .limit(1) + ) + + for fieldname in ["warehouse", "item_code", "item_group", "warehouse_type"]: + if self.filters.get(fieldname): + query = query.where(table[fieldname] == self.filters.get(fieldname)) + + return query.run(as_dict=True) + + def prepare_stock_ledger_entries(self): + sle = frappe.qb.DocType("Stock Ledger Entry") + item_table = frappe.qb.DocType("Item") + + query = ( + frappe.qb.from_(sle) + .inner_join(item_table) + .on(sle.item_code == item_table.name) + .select( + sle.item_code, + sle.warehouse, + sle.posting_date, + sle.actual_qty, + sle.valuation_rate, + sle.company, + sle.voucher_type, + sle.qty_after_transaction, + sle.stock_value_difference, + sle.item_code.as_("name"), + sle.voucher_no, + sle.stock_value, + sle.batch_no, + sle.serial_no, + item_table.item_group, + item_table.stock_uom, + item_table.item_name, + ) + .where((sle.docstatus < 2) & (sle.is_cancelled == 0)) + .orderby(CombineDatetime(sle.posting_date, sle.posting_time)) + .orderby(sle.creation) + .orderby(sle.actual_qty) + ) + + query = self.apply_inventory_dimensions_filters(query, sle) + query = self.apply_warehouse_filters(query, sle) + query = self.apply_items_filters(query, item_table) + query = self.apply_date_filters(query, sle) + + if self.filters.get("company"): + query = query.where(sle.company == self.filters.get("company")) + + self.sle_entries = query.run(as_dict=True) + + def apply_inventory_dimensions_filters(self, query, sle) -> str: + inventory_dimension_fields = self.get_inventory_dimension_fields() + if inventory_dimension_fields: + for fieldname in inventory_dimension_fields: + query = query.select(fieldname) + if self.filters.get(fieldname): + query = query.where(sle[fieldname].isin(self.filters.get(fieldname))) + + return query + + def apply_warehouse_filters(self, query, sle) -> str: + warehouse_table = frappe.qb.DocType("Warehouse") + + if self.filters.get("warehouse"): + query = apply_warehouse_filter(query, sle, self.filters) + elif warehouse_type := self.filters.get("warehouse_type"): + query = ( + query.join(warehouse_table) + .on(warehouse_table.name == sle.warehouse) + .where(warehouse_table.warehouse_type == warehouse_type) + ) + + return query + + def apply_items_filters(self, query, item_table) -> str: + if item_group := self.filters.get("item_group"): + children = get_descendants_of("Item Group", item_group, ignore_permissions=True) + query = query.where(item_table.item_group.isin(children + [item_group])) + + for field in ["item_code", "brand"]: + if not self.filters.get(field): + continue + + query = query.where(item_table[field] == self.filters.get(field)) + + return query + + def apply_date_filters(self, query, sle) -> str: + if not self.filters.ignore_closing_balance and self.start_from: + query = query.where(sle.posting_date >= self.start_from) + + if self.to_date: + query = query.where(sle.posting_date <= self.to_date) + + return query + + def get_columns(self): + columns = [ + { + "label": _("Item"), + "fieldname": "item_code", + "fieldtype": "Link", + "options": "Item", + "width": 100, + }, + {"label": _("Item Name"), "fieldname": "item_name", "width": 150}, + { + "label": _("Item Group"), + "fieldname": "item_group", + "fieldtype": "Link", + "options": "Item Group", + "width": 100, + }, + { + "label": _("Warehouse"), + "fieldname": "warehouse", + "fieldtype": "Link", + "options": "Warehouse", + "width": 100, + }, + ] + + for dimension in get_inventory_dimensions(): + columns.append( + { + "label": _(dimension.doctype), + "fieldname": dimension.fieldname, + "fieldtype": "Link", + "options": dimension.doctype, + "width": 110, + } + ) + + columns.extend( + [ + { + "label": _("Stock UOM"), + "fieldname": "stock_uom", + "fieldtype": "Link", + "options": "UOM", + "width": 90, + }, + { + "label": _("Balance Qty"), + "fieldname": "bal_qty", + "fieldtype": "Float", + "width": 100, + "convertible": "qty", + }, + { + "label": _("Balance Value"), + "fieldname": "bal_val", + "fieldtype": "Currency", + "width": 100, + "options": "currency", + }, + { + "label": _("Opening Qty"), + "fieldname": "opening_qty", + "fieldtype": "Float", + "width": 100, + "convertible": "qty", + }, + { + "label": _("Opening Value"), + "fieldname": "opening_val", + "fieldtype": "Currency", + "width": 110, + "options": "currency", + }, + { + "label": _("In Qty"), + "fieldname": "in_qty", + "fieldtype": "Float", + "width": 80, + "convertible": "qty", + }, + {"label": _("In Value"), "fieldname": "in_val", "fieldtype": "Float", "width": 80}, + { + "label": _("Out Qty"), + "fieldname": "out_qty", + "fieldtype": "Float", + "width": 80, + "convertible": "qty", + }, + {"label": _("Out Value"), "fieldname": "out_val", "fieldtype": "Float", "width": 80}, + { + "label": _("Valuation Rate"), + "fieldname": "val_rate", + "fieldtype": "Currency", + "width": 90, + "convertible": "rate", + "options": "currency", + }, + { + "label": _("Company"), + "fieldname": "company", + "fieldtype": "Link", + "options": "Company", + "width": 100, + }, + ] + ) + + if self.filters.get("show_stock_ageing_data"): + columns += [ + {"label": _("Average Age"), "fieldname": "average_age", "width": 100}, + {"label": _("Earliest Age"), "fieldname": "earliest_age", "width": 100}, + {"label": _("Latest Age"), "fieldname": "latest_age", "width": 100}, + ] + + if self.filters.get("show_variant_attributes"): + columns += [ + {"label": att_name, "fieldname": att_name, "width": 100} + for att_name in get_variants_attributes() + ] + + return columns + + def add_additional_uom_columns(self): + if not self.filters.get("include_uom"): + return + + conversion_factors = self.get_itemwise_conversion_factor() + add_additional_uom_columns(self.columns, self.data, self.filters.include_uom, conversion_factors) + + def get_itemwise_conversion_factor(self): + items = [] + if self.filters.item_code or self.filters.item_group: + items = [d.item_code for d in self.data] + + table = frappe.qb.DocType("UOM Conversion Detail") + query = ( + frappe.qb.from_(table) + .select( + table.conversion_factor, + table.parent, + ) + .where((table.parenttype == "Item") & (table.uom == self.filters.include_uom)) + ) + + if items: + query = query.where(table.parent.isin(items)) + + result = query.run(as_dict=1) + if not result: + return {} + + return {d.parent: d.conversion_factor for d in result} + + def get_variant_values_for(self): + """Returns variant values for items.""" + attribute_map = {} + items = [] + if self.filters.item_code or self.filters.item_group: + items = [d.item_code for d in self.data] + + filters = {} + if items: + filters = {"parent": ("in", items)} + + attribute_info = frappe.get_all( + "Item Variant Attribute", + fields=["parent", "attribute", "attribute_value"], + filters=filters, + ) + + for attr in attribute_info: + attribute_map.setdefault(attr["parent"], {}) + attribute_map[attr["parent"]].update({attr["attribute"]: attr["attribute_value"]}) + + return attribute_map + + def get_opening_vouchers(self): + opening_vouchers = {"Stock Entry": [], "Stock Reconciliation": []} + + se = frappe.qb.DocType("Stock Entry") + sr = frappe.qb.DocType("Stock Reconciliation") + + vouchers_data = ( + frappe.qb.from_( + ( + frappe.qb.from_(se) + .select(se.name, Coalesce("Stock Entry").as_("voucher_type")) + .where((se.docstatus == 1) & (se.posting_date <= self.to_date) & (se.is_opening == "Yes")) + ) + + ( + frappe.qb.from_(sr) + .select(sr.name, Coalesce("Stock Reconciliation").as_("voucher_type")) + .where( + (sr.docstatus == 1) & (sr.posting_date <= self.to_date) & (sr.purpose == "Opening Stock") + ) + ) + ).select("voucher_type", "name") + ).run(as_dict=True) + + if vouchers_data: + for d in vouchers_data: + opening_vouchers[d.voucher_type].append(d.name) + + return opening_vouchers + + @staticmethod + def get_inventory_dimension_fields(): + return [dimension.fieldname for dimension in get_inventory_dimensions()] + + @staticmethod + def get_opening_fifo_queue(report_data): + opening_fifo_queue = report_data.get("opening_fifo_queue") or [] + for row in opening_fifo_queue: + row[1] = getdate(row[1]) + + return opening_fifo_queue -def get_group_by_key(row, filters, inventory_dimension_fields) -> tuple: - group_by_key = [row.company, row.item_code, row.warehouse] - - for fieldname in inventory_dimension_fields: - if filters.get(fieldname): - group_by_key.append(row.get(fieldname)) - - return tuple(group_by_key) - - -def filter_items_with_no_transactions(iwb_map, float_precision: float, inventory_dimensions: list): +def filter_items_with_no_transactions( + iwb_map, float_precision: float, inventory_dimensions: list = None +): pop_keys = [] for group_by_key in iwb_map: qty_dict = iwb_map[group_by_key] no_transactions = True for key, val in qty_dict.items(): - if key in inventory_dimensions: + if inventory_dimensions and key in inventory_dimensions: + continue + + if key in [ + "item_code", + "warehouse", + "item_name", + "item_group", + "project", + "stock_uom", + "company", + "opening_fifo_queue", + ]: continue val = flt(val, float_precision) @@ -445,96 +594,6 @@ def filter_items_with_no_transactions(iwb_map, float_precision: float, inventory return iwb_map -def get_items(filters: StockBalanceFilter) -> List[str]: - "Get items based on item code, item group or brand." - if item_code := filters.get("item_code"): - return [item_code] - else: - item_filters = {} - if item_group := filters.get("item_group"): - children = get_descendants_of("Item Group", item_group, ignore_permissions=True) - item_filters["item_group"] = ("in", children + [item_group]) - if brand := filters.get("brand"): - item_filters["brand"] = brand - - return frappe.get_all("Item", filters=item_filters, pluck="name", order_by=None) - - -def get_item_details(items: List[str], sle: List[SLEntry], filters: StockBalanceFilter): - item_details = {} - if not items: - items = list(set(d.item_code for d in sle)) - - if not items: - return item_details - - item_table = frappe.qb.DocType("Item") - - query = ( - frappe.qb.from_(item_table) - .select( - item_table.name, - item_table.item_name, - item_table.description, - item_table.item_group, - item_table.brand, - item_table.stock_uom, - ) - .where(item_table.name.isin(items)) - ) - - if uom := filters.get("include_uom"): - uom_conv_detail = frappe.qb.DocType("UOM Conversion Detail") - query = ( - query.left_join(uom_conv_detail) - .on((uom_conv_detail.parent == item_table.name) & (uom_conv_detail.uom == uom)) - .select(uom_conv_detail.conversion_factor) - ) - - result = query.run(as_dict=1) - - for item_table in result: - item_details.setdefault(item_table.name, item_table) - - if filters.get("show_variant_attributes"): - variant_values = get_variant_values_for(list(item_details)) - item_details = {k: v.update(variant_values.get(k, {})) for k, v in item_details.items()} - - return item_details - - -def get_item_reorder_details(items): - item_reorder_details = frappe._dict() - - if items: - item_reorder_details = frappe.get_all( - "Item Reorder", - ["parent", "warehouse", "warehouse_reorder_qty", "warehouse_reorder_level"], - filters={"parent": ("in", items)}, - ) - - return dict((d.parent + d.warehouse, d) for d in item_reorder_details) - - def get_variants_attributes() -> List[str]: """Return all item variant attributes.""" return frappe.get_all("Item Attribute", pluck="name") - - -def get_variant_values_for(items): - """Returns variant values for items.""" - attribute_map = {} - - attribute_info = frappe.get_all( - "Item Variant Attribute", - ["parent", "attribute", "attribute_value"], - { - "parent": ("in", items), - }, - ) - - for attr in attribute_info: - attribute_map.setdefault(attr["parent"], {}) - attribute_map[attr["parent"]].update({attr["attribute"]: attr["attribute_value"]}) - - return attribute_map diff --git a/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py b/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py index abbb33b2f16..5dbdceff247 100644 --- a/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py +++ b/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py @@ -8,15 +8,15 @@ import frappe from frappe import _ from frappe.query_builder.functions import Count -from frappe.utils import flt +from frappe.utils import cint, flt, getdate from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age -from erpnext.stock.report.stock_balance.stock_balance import ( +from erpnext.stock.report.stock_analytics.stock_analytics import ( get_item_details, - get_item_warehouse_map, get_items, get_stock_ledger_entries, ) +from erpnext.stock.report.stock_balance.stock_balance import filter_items_with_no_transactions from erpnext.stock.utils import is_reposting_item_valuation_in_progress @@ -32,7 +32,7 @@ def execute(filters=None): items = get_items(filters) sle = get_stock_ledger_entries(filters, items) - item_map = get_item_details(items, sle, filters) + item_map = get_item_details(items, sle) iwb_map = get_item_warehouse_map(filters, sle) warehouse_list = get_warehouse_list(filters) item_ageing = FIFOSlots(filters).generate() @@ -128,3 +128,59 @@ def add_warehouse_column(columns, warehouse_list): for wh in warehouse_list: columns += [_(wh.name) + ":Int:100"] + + +def get_item_warehouse_map(filters, sle): + iwb_map = {} + from_date = getdate(filters.get("from_date")) + to_date = getdate(filters.get("to_date")) + float_precision = cint(frappe.db.get_default("float_precision")) or 3 + + for d in sle: + group_by_key = get_group_by_key(d) + if group_by_key not in iwb_map: + iwb_map[group_by_key] = frappe._dict( + { + "opening_qty": 0.0, + "opening_val": 0.0, + "in_qty": 0.0, + "in_val": 0.0, + "out_qty": 0.0, + "out_val": 0.0, + "bal_qty": 0.0, + "bal_val": 0.0, + "val_rate": 0.0, + } + ) + + qty_dict = iwb_map[group_by_key] + if d.voucher_type == "Stock Reconciliation" and not d.batch_no: + qty_diff = flt(d.qty_after_transaction) - flt(qty_dict.bal_qty) + else: + qty_diff = flt(d.actual_qty) + + value_diff = flt(d.stock_value_difference) + + if d.posting_date < from_date: + qty_dict.opening_qty += qty_diff + qty_dict.opening_val += value_diff + + elif d.posting_date >= from_date and d.posting_date <= to_date: + if flt(qty_diff, float_precision) >= 0: + qty_dict.in_qty += qty_diff + qty_dict.in_val += value_diff + else: + qty_dict.out_qty += abs(qty_diff) + qty_dict.out_val += abs(value_diff) + + qty_dict.val_rate = d.valuation_rate + qty_dict.bal_qty += qty_diff + qty_dict.bal_val += value_diff + + iwb_map = filter_items_with_no_transactions(iwb_map, float_precision) + + return iwb_map + + +def get_group_by_key(row) -> tuple: + return (row.company, row.item_code, row.warehouse) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 6106809273f..2f64eddb300 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -443,12 +443,11 @@ class update_entries_after(object): i += 1 self.process_sle(sle) + self.update_bin_data(sle) if sle.dependant_sle_voucher_detail_no: entries_to_fix = self.get_dependent_entries_to_fix(entries_to_fix, sle) - self.update_bin() - if self.exceptions: self.raise_exceptions() @@ -1065,6 +1064,18 @@ class update_entries_after(object): else: raise NegativeStockError(message) + def update_bin_data(self, sle): + bin_name = get_or_make_bin(sle.item_code, sle.warehouse) + values_to_update = { + "actual_qty": sle.qty_after_transaction, + "stock_value": sle.stock_value, + } + + if sle.valuation_rate is not None: + values_to_update["valuation_rate"] = sle.valuation_rate + + frappe.db.set_value("Bin", bin_name, values_to_update) + def update_bin(self): # update bin for each warehouse for warehouse, data in self.data.items(): diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index fb526971ede..67233dd44da 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -220,7 +220,7 @@ def get_bin(item_code, warehouse): def get_or_make_bin(item_code: str, warehouse: str) -> str: - bin_record = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}) + bin_record = frappe.get_cached_value("Bin", {"item_code": item_code, "warehouse": warehouse}) if not bin_record: bin_obj = _create_bin(item_code, warehouse)