From b721c6492dde314d2ab123f4c0d28ae480aee4ec Mon Sep 17 00:00:00 2001 From: Mac Avenue Date: Tue, 30 Jun 2026 14:03:02 +0300 Subject: [PATCH 1/2] feat: Inter Branch Stock Transfer Created --- rmax_custom/fixtures/custom_field.json | 57 +++ rmax_custom/inter_branch.py | 201 ++++++++++ .../inter_branch_stock_transfer/__init__.py | 0 .../inter_branch_stock_transfer.js | 239 ++++++++++++ .../inter_branch_stock_transfer.json | 239 ++++++++++++ .../inter_branch_stock_transfer.py | 362 ++++++++++++++++++ .../inter_branch_stock_transfer_dashboard.py | 27 ++ .../test_inter_branch_stock_transfer.py | 9 + .../__init__.py | 0 .../inter_branch_stock_transfer_item.json | 208 ++++++++++ .../inter_branch_stock_transfer_item.py | 8 + 11 files changed, 1350 insertions(+) create mode 100644 rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/__init__.py create mode 100644 rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer.js create mode 100644 rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer.json create mode 100644 rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer.py create mode 100644 rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer_dashboard.py create mode 100644 rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/test_inter_branch_stock_transfer.py create mode 100644 rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer_item/__init__.py create mode 100644 rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer_item/inter_branch_stock_transfer_item.json create mode 100644 rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer_item/inter_branch_stock_transfer_item.py diff --git a/rmax_custom/fixtures/custom_field.json b/rmax_custom/fixtures/custom_field.json index 48175f6..0d98fef 100644 --- a/rmax_custom/fixtures/custom_field.json +++ b/rmax_custom/fixtures/custom_field.json @@ -3553,5 +3553,62 @@ "translatable": 0, "unique": 0, "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 1, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Delivery Note", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_ibst", + "fieldtype": "Link", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_inter_company_status", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Inter Branch Stock Transfer", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-06-30 00:00:00.000000", + "module": null, + "name": "Delivery Note-custom_ibst", + "no_copy": 1, + "non_negative": 0, + "options": "Inter Branch Stock Transfer", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 1, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null } ] \ No newline at end of file diff --git a/rmax_custom/inter_branch.py b/rmax_custom/inter_branch.py index 68f7094..d9de138 100644 --- a/rmax_custom/inter_branch.py +++ b/rmax_custom/inter_branch.py @@ -607,6 +607,117 @@ def _backfill_branch_columns_on_child_doctypes() -> None: ) +def create_companion_inter_branch_je_for_ibst(ibst) -> str | None: + """Create a 2-line Journal Entry for an Inter Branch Stock Transfer. + + Always creates the JE on submit — unlike the generic Stock Transfer flow, + IBST does not gate on the company cut-over date because it is a dedicated + inter-branch doctype and the user explicitly wants the JE every time. + + Returns the new JE name, or None when source == target branch or amount is zero. + """ + source_wh = getattr(ibst, "from_warehouse", None) + target_wh = getattr(ibst, "to_warehouse", None) + source_branch = resolve_warehouse_branch(source_wh) + target_branch = resolve_warehouse_branch(target_wh) + + if not source_branch or not target_branch: + frappe.log_error( + title="IBST companion JE skipped — branch not found", + message=( + f"Inter Branch Stock Transfer {getattr(ibst, 'name', '?')}: could not resolve " + f"source branch from warehouse '{source_wh}' or target branch from '{target_wh}'. " + f"Make sure both warehouses are mapped in Branch Configuration." + ), + ) + return None + if source_branch == target_branch: + return None + + # Prefer reading basic_amount from the already-submitted Stock Entry — ERPNext + # fills this from the actual valuation rate, so it is accurate even when the + # user left basic_rate blank on the IBST form. + amount = 0.0 + se_name = getattr(ibst, "stock_entry", None) + if se_name: + se_rows = frappe.get_all( + "Stock Entry Detail", + filters={"parent": se_name}, + fields=["basic_amount"], + ) + amount = round(sum(flt(r.basic_amount) for r in se_rows), 2) + + # Fall back to IBST item rates when the SE doesn't exist or has zero amounts. + if not amount: + amount = round( + sum( + flt(getattr(item, "basic_amount", 0)) + or (flt(getattr(item, "qty", 0)) * flt(getattr(item, "basic_rate", 0))) + for item in (ibst.items or []) + ), + 2, + ) + + if amount <= 0: + frappe.log_error( + title="IBST companion JE skipped — zero amount", + message=( + f"Inter Branch Stock Transfer {getattr(ibst, 'name', '?')} produced a zero " + f"transfer amount. Ensure items have a valuation rate in the source warehouse " + f"or set basic_rate on the IBST items before submitting." + ), + ) + return None + + company = ibst.company + src_receivable = get_or_create_inter_branch_account(company, target_branch, "receivable") + tgt_payable = get_or_create_inter_branch_account(company, source_branch, "payable") + + company_currency = frappe.db.get_value("Company", company, "default_currency") + src_currency = frappe.db.get_value("Account", src_receivable, "account_currency") or company_currency + tgt_currency = frappe.db.get_value("Account", tgt_payable, "account_currency") or company_currency + + je = frappe.new_doc("Journal Entry") + je.posting_date = ibst.posting_date + je.company = company + je.voucher_type = "Journal Entry" + je.custom_source_doctype = "Inter Branch Stock Transfer" + je.custom_source_docname = ibst.name + je.user_remark = _("Inter-Branch obligation from Inter Branch Stock Transfer {0}").format(ibst.name) + je.append( + "accounts", + { + "account": src_receivable, + "account_currency": src_currency, + "exchange_rate": 1, + "debit_in_account_currency": amount, + "debit": amount, + "branch": source_branch, + "custom_auto_inserted": 1, + "custom_source_doctype": "Inter Branch Stock Transfer", + "custom_source_docname": ibst.name, + }, + ) + je.append( + "accounts", + { + "account": tgt_payable, + "account_currency": tgt_currency, + "exchange_rate": 1, + "credit_in_account_currency": amount, + "credit": amount, + "branch": target_branch, + "custom_auto_inserted": 1, + "custom_source_doctype": "Inter Branch Stock Transfer", + "custom_source_docname": ibst.name, + }, + ) + je.flags.skip_inter_branch_injection = True + je.insert(ignore_permissions=True) + je.submit() + return je.name + + def setup_inter_branch_foundation() -> None: """Idempotent entrypoint called from setup.after_migrate. @@ -742,6 +853,96 @@ def create_companion_inter_branch_je_for_stock_transfer(stock_transfer) -> str | return je.name +def create_companion_inter_branch_je_for_ibst(ibst) -> str | None: + """Create a 2-line Journal Entry for a submitted Inter Branch Stock Transfer. + + Same accounting as the Stock Transfer companion JE: + Dr Inter-Branch Receivable — Due from (branch = source) + Cr Inter-Branch Payable — Due to (branch = target) + + Amount is read from the already-submitted Stock Entry's basic_amount so + valuation is consistent with the stock ledger. + Returns the new JE name, or None when no JE is needed. + """ + source_wh = getattr(ibst, "from_warehouse", None) + target_wh = getattr(ibst, "to_warehouse", None) + source_branch = resolve_warehouse_branch(source_wh) + target_branch = resolve_warehouse_branch(target_wh) + + if not source_branch or not target_branch: + return None + if source_branch == target_branch: + return None + + # Read amount from the SE that was just submitted; fall back to item rows. + amount = 0.0 + if getattr(ibst, "stock_entry", None): + se_rows = frappe.get_all( + "Stock Entry Detail", + filters={"parent": ibst.stock_entry}, + fields=["basic_amount"], + ) + amount = round(sum(flt(r.basic_amount) for r in se_rows), 2) + if not amount: + amount = round( + sum( + flt(getattr(item, "basic_amount", None)) or (flt(item.qty) * flt(item.basic_rate)) + for item in (ibst.items or []) + ), + 2, + ) + if amount <= 0: + return None + + company = ibst.company + src_receivable = get_or_create_inter_branch_account(company, target_branch, "receivable") + tgt_payable = get_or_create_inter_branch_account(company, source_branch, "payable") + + company_currency = frappe.db.get_value("Company", company, "default_currency") + src_currency = frappe.db.get_value("Account", src_receivable, "account_currency") or company_currency + tgt_currency = frappe.db.get_value("Account", tgt_payable, "account_currency") or company_currency + + je = frappe.new_doc("Journal Entry") + je.posting_date = ibst.posting_date + je.company = company + je.voucher_type = "Journal Entry" + je.custom_source_doctype = "Inter Branch Stock Transfer" + je.custom_source_docname = ibst.name + je.user_remark = _("Inter-Branch obligation from Inter Branch Stock Transfer {0}").format(ibst.name) + je.append( + "accounts", + { + "account": src_receivable, + "account_currency": src_currency, + "exchange_rate": 1, + "debit_in_account_currency": amount, + "debit": amount, + "branch": source_branch, + "custom_auto_inserted": 1, + "custom_source_doctype": "Inter Branch Stock Transfer", + "custom_source_docname": ibst.name, + }, + ) + je.append( + "accounts", + { + "account": tgt_payable, + "account_currency": tgt_currency, + "exchange_rate": 1, + "credit_in_account_currency": amount, + "credit": amount, + "branch": target_branch, + "custom_auto_inserted": 1, + "custom_source_doctype": "Inter Branch Stock Transfer", + "custom_source_docname": ibst.name, + }, + ) + je.flags.skip_inter_branch_injection = True + je.insert(ignore_permissions=True) + je.submit() + return je.name + + # ---------------------------------------------------------------------------- # Direct Stock Entry hooks (no Stock Transfer wrapper) # ---------------------------------------------------------------------------- diff --git a/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/__init__.py b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer.js b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer.js new file mode 100644 index 0000000..00632d6 --- /dev/null +++ b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer.js @@ -0,0 +1,239 @@ +// Copyright (c) 2026, Enfono and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Inter Branch Stock Transfer', { + + onload: function (frm) { + _setup_warehouse_queries(frm); + if (frm.is_new()) { + _set_default_source_warehouse(frm); + } + }, + + refresh: function (frm) { + _setup_warehouse_queries(frm); + _setup_buttons(frm); + _toggle_posting_time(frm); + }, + + set_posting_time: function (frm) { + _toggle_posting_time(frm); + }, + + company: function (frm) { + _setup_warehouse_queries(frm); + }, + + from_warehouse: function (frm) { + (frm.doc.items || []).forEach(function (item) { + if (!item.s_warehouse || item.s_warehouse === frm.doc._prev_from_wh) { + frappe.model.set_value(item.doctype, item.name, 's_warehouse', frm.doc.from_warehouse); + } + }); + frm.doc._prev_from_wh = frm.doc.from_warehouse; + _fetch_valuation_rates_for_all_items(frm); + }, + + to_warehouse: function (frm) { + (frm.doc.items || []).forEach(function (item) { + if (!item.t_warehouse || item.t_warehouse === frm.doc._prev_to_wh) { + frappe.model.set_value(item.doctype, item.name, 't_warehouse', frm.doc.to_warehouse); + } + }); + frm.doc._prev_to_wh = frm.doc.to_warehouse; + }, + + customer: function (frm) { + if (frm.doc.customer) { + frappe.db.get_value('Customer', frm.doc.customer, 'customer_name', function (r) { + if (r && r.customer_name) { + frm.set_value('customer_name', r.customer_name); + } + }); + } else { + frm.set_value('customer_name', ''); + } + }, +}); + + +// ─── Item Handlers ──────────────────────────────────────────────────────────── + +frappe.ui.form.on('Inter Branch Stock Transfer Item', { + + item_code: function (frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (!row.item_code) return; + + frappe.db.get_value('Item', row.item_code, ['item_name', 'stock_uom', 'description'], function (r) { + if (!r) return; + frappe.model.set_value(cdt, cdn, 'item_name', r.item_name); + frappe.model.set_value(cdt, cdn, 'stock_uom', r.stock_uom); + frappe.model.set_value(cdt, cdn, 'uom', r.stock_uom); + frappe.model.set_value(cdt, cdn, 'conversion_factor', 1); + if (!row.description && r.description) { + frappe.model.set_value(cdt, cdn, 'description', r.description); + } + _set_uom_query(frm, cdt, cdn); + }); + + if (frm.doc.from_warehouse && !row.s_warehouse) { + frappe.model.set_value(cdt, cdn, 's_warehouse', frm.doc.from_warehouse); + } + if (frm.doc.to_warehouse && !row.t_warehouse) { + frappe.model.set_value(cdt, cdn, 't_warehouse', frm.doc.to_warehouse); + } + + _fetch_valuation_rate(frm, cdt, cdn); + }, + + s_warehouse: function (frm, cdt, cdn) { + _fetch_valuation_rate(frm, cdt, cdn); + }, + + uom: function (_frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (!row.item_code || !row.uom) return; + frappe.call({ + method: 'rmax_custom.rmax_custom.doctype.stock_transfer.stock_transfer.get_item_uom_conversion', + args: { item_code: row.item_code, uom: row.uom }, + callback: function (r) { + if (r.message !== undefined) { + frappe.model.set_value(cdt, cdn, 'conversion_factor', r.message); + frappe.model.set_value(cdt, cdn, 'transfer_qty', flt(locals[cdt][cdn].qty) * flt(r.message)); + } + } + }); + }, + + qty: function (_frm, cdt, cdn) { + _recalculate_row_amount(cdt, cdn); + }, + + basic_rate: function (_frm, cdt, cdn) { + _recalculate_row_amount(cdt, cdn); + }, +}); + + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function _toggle_posting_time(frm) { + frm.set_df_property('posting_time', 'read_only', frm.doc.set_posting_time ? 0 : 1); + frm.set_df_property('posting_date', 'read_only', frm.doc.set_posting_time ? 0 : 1); +} + +function _setup_buttons(frm) { + frm.remove_custom_button(__('Create Delivery Note')); + + if (frm.doc.docstatus !== 1) return; + + // Delivery Note button + if (frm.doc.customer && !frm.doc.delivery_note) { + frm.add_custom_button(__('Create Delivery Note'), function () { + frappe.confirm( + __('Create a Delivery Note for customer {0}?', [frm.doc.customer]), + function () { + frappe.call({ + method: 'rmax_custom.rmax_custom.doctype.inter_branch_stock_transfer.inter_branch_stock_transfer.create_delivery_note', + args: { ibst_name: frm.doc.name }, + callback: function (r) { + if (r.message) { + frm.reload_doc(); + } + } + }); + } + ); + }, __('Create')); + } +} + +function _setup_warehouse_queries(frm) { + frm.set_query('from_warehouse', function () { + return { filters: { company: frm.doc.company, is_group: 0 } }; + }); + frm.set_query('to_warehouse', function () { + return { + ignore_user_permissions: 1, + filters: { + company: frm.doc.company, + is_group: 0, + name: ['!=', frm.doc.from_warehouse || ''] + } + }; + }); + frm.fields_dict['items'].grid.get_field('s_warehouse').get_query = function (doc) { + return { filters: { company: doc.company, is_group: 0 } }; + }; + frm.fields_dict['items'].grid.get_field('t_warehouse').get_query = function (doc) { + return { ignore_user_permissions: 1, filters: { company: doc.company, is_group: 0 } }; + }; +} + +function _set_default_source_warehouse(frm) { + frappe.call({ + method: 'frappe.client.get_list', + args: { + doctype: 'User Permission', + filters: { user: frappe.session.user, allow: 'Warehouse', is_default: 1 }, + fields: ['for_value'], + limit: 1 + }, + callback: function (r) { + if (r.message && r.message.length > 0 && !frm.doc.from_warehouse) { + frm.set_value('from_warehouse', r.message[0].for_value); + } + } + }); +} + +function _set_uom_query(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (!row.item_code) return; + frappe.call({ + method: 'frappe.client.get', + args: { doctype: 'Item', name: row.item_code }, + callback: function (r) { + if (r.message && r.message.uoms) { + let uom_list = r.message.uoms.map(function (d) { return d.uom; }); + frm.fields_dict['items'].grid.get_field('uom').get_query = function () { + return { filters: { name: ['in', uom_list] } }; + }; + } + } + }); +} + +function _fetch_valuation_rate(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (!row.item_code) return; + let warehouse = row.s_warehouse || frm.doc.from_warehouse; + if (!warehouse) return; + + frappe.call({ + method: 'rmax_custom.rmax_custom.doctype.inter_branch_stock_transfer.inter_branch_stock_transfer.get_item_valuation_rate', + args: { item_code: row.item_code, warehouse: warehouse }, + callback: function (r) { + if (r.message !== undefined && !flt(locals[cdt][cdn].basic_rate)) { + frappe.model.set_value(cdt, cdn, 'basic_rate', r.message); + frappe.model.set_value(cdt, cdn, 'valuation_rate', r.message); + _recalculate_row_amount(cdt, cdn); + } + } + }); +} + +function _fetch_valuation_rates_for_all_items(frm) { + (frm.doc.items || []).forEach(function (item) { + if (item.item_code) { + _fetch_valuation_rate(frm, item.doctype, item.name); + } + }); +} + +function _recalculate_row_amount(cdt, cdn) { + let row = locals[cdt][cdn]; + frappe.model.set_value(cdt, cdn, 'basic_amount', flt(row.qty) * flt(row.basic_rate)); + frappe.model.set_value(cdt, cdn, 'transfer_qty', flt(row.qty) * flt(row.conversion_factor || 1)); +} diff --git a/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer.json b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer.json new file mode 100644 index 0000000..a128888 --- /dev/null +++ b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer.json @@ -0,0 +1,239 @@ +{ + "actions": [], + "autoname": "naming_series:", + "creation": "2026-06-30 00:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "section_break_header", + "naming_series", + "company", + "amended_from", + "column_break_header", + "posting_date", + "posting_time", + "set_posting_time", + "section_break_wh", + "from_warehouse", + "column_break_wh", + "to_warehouse", + "section_break_customer", + "customer", + "customer_name", + "section_break_items", + "items", + "section_break_refs", + "stock_entry", + "journal_entry", + "column_break_refs", + "delivery_note" + ], + "fields": [ + { + "fieldname": "section_break_header", + "fieldtype": "Section Break" + }, + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Series", + "no_copy": 1, + "options": "IBST-.YYYY.-", + "print_hide": 1, + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "remember_last_selected_value": 1, + "reqd": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Inter Branch Stock Transfer", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_header", + "fieldtype": "Column Break" + }, + { + "default": "Today", + "fieldname": "posting_date", + "fieldtype": "Date", + "label": "Transaction Date", + "no_copy": 1, + "reqd": 1 + }, + { + "default": "Now", + "fieldname": "posting_time", + "fieldtype": "Time", + "label": "Posting Time", + "no_copy": 1, + "print_hide": 1 + }, + { + "default": "0", + "depends_on": "eval:doc.docstatus==0", + "fieldname": "set_posting_time", + "fieldtype": "Check", + "label": "Edit Posting Date and Time", + "print_hide": 1 + }, + { + "fieldname": "section_break_wh", + "fieldtype": "Section Break" + }, + { + "fieldname": "from_warehouse", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Set Source Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "column_break_wh", + "fieldtype": "Column Break" + }, + { + "fieldname": "to_warehouse", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Set Target Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "section_break_customer", + "fieldtype": "Section Break", + "label": "Customer (for Delivery Note)" + }, + { + "fieldname": "customer", + "fieldtype": "Link", + "label": "Customer", + "options": "Customer" + }, + { + "fetch_from": "customer.customer_name", + "fieldname": "customer_name", + "fieldtype": "Data", + "label": "Customer Name", + "read_only": 1 + }, + { + "fieldname": "section_break_items", + "fieldtype": "Section Break" + }, + { + "fieldname": "items", + "fieldtype": "Table", + "label": "Items", + "options": "Inter Branch Stock Transfer Item", + "reqd": 1 + }, + { + "collapsible": 1, + "fieldname": "section_break_refs", + "fieldtype": "Section Break", + "label": "Reference" + }, + { + "fieldname": "stock_entry", + "fieldtype": "Link", + "label": "Stock Entry", + "no_copy": 1, + "options": "Stock Entry", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "journal_entry", + "fieldtype": "Link", + "label": "Journal Entry", + "no_copy": 1, + "options": "Journal Entry", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_refs", + "fieldtype": "Column Break" + }, + { + "fieldname": "delivery_note", + "fieldtype": "Link", + "label": "Delivery Note", + "no_copy": 1, + "options": "Delivery Note", + "print_hide": 1, + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2026-06-30 09:50:55.269409", + "modified_by": "Administrator", + "module": "Rmax Custom", + "name": "Inter Branch Stock Transfer", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "amend": 1, + "cancel": 1, + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer.py b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer.py new file mode 100644 index 0000000..3231122 --- /dev/null +++ b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer.py @@ -0,0 +1,362 @@ +# Copyright (c) 2026, Enfono and contributors +# For license information, please see license.txt + +from __future__ import annotations +from collections import defaultdict + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import flt + + +class InterBranchStockTransfer(Document): + + def validate(self): + self._set_item_warehouses_from_header() + + def _set_item_warehouses_from_header(self): + """Push header warehouse defaults to item rows that have no row-level value.""" + for item in self.items or []: + if self.from_warehouse and not item.s_warehouse: + item.s_warehouse = self.from_warehouse + if self.to_warehouse and not item.t_warehouse: + item.t_warehouse = self.to_warehouse + + def before_submit(self): + self._validate_warehouses() + self._validate_stock_availability() + + def _validate_warehouses(self): + for item in self.items or []: + if not item.s_warehouse: + frappe.throw( + _("Row {0}: Source Warehouse is required for item {1}").format( + item.idx, item.item_code + ) + ) + if not item.t_warehouse: + frappe.throw( + _("Row {0}: Target Warehouse is required for item {1}").format( + item.idx, item.item_code + ) + ) + if item.s_warehouse == item.t_warehouse: + frappe.throw( + _("Row {0}: Source and Target Warehouse cannot be the same for item {1}").format( + item.idx, item.item_code + ) + ) + + def _validate_stock_availability(self): + wh_item_qty: dict[tuple[str, str], float] = defaultdict(float) + for item in self.items or []: + if item.item_code and item.s_warehouse: + wh_item_qty[(item.s_warehouse, item.item_code)] += flt(item.qty) + + shortages = [] + for (warehouse, item_code), needed in wh_item_qty.items(): + actual = flt( + frappe.db.get_value( + "Bin", + {"item_code": item_code, "warehouse": warehouse}, + "actual_qty", + ) + ) + if actual < needed: + shortages.append({ + "item_code": item_code, + "warehouse": warehouse, + "needed": needed, + "available": actual, + }) + + if shortages: + msg = _("Insufficient stock in source warehouse for the following items:") + msg += "

" + msg += "".format( + _("Item Code"), _("Source Warehouse"), _("Required Qty"), _("Available Qty") + ) + for s in shortages: + msg += "".format( + s["item_code"], s["warehouse"], s["needed"], s["available"] + ) + msg += "
{}{}{}{}
{}{}{}{}
" + frappe.throw(msg, title=_("Insufficient Stock")) + + # ------------------------------------------------------------------ + # Submit + # ------------------------------------------------------------------ + + def on_submit(self): + self._create_stock_entry() + if self.customer: + self._create_delivery_note() + try: + self._create_journal_entry() + except Exception: + frappe.log_error( + title="Inter-Branch companion JE failed (IBST)", + message=frappe.get_traceback(), + ) + raise + + def _create_journal_entry(self) -> str | None: + """Create and submit the inter-branch JE for this IBST. + + Dr Due from Head Office - R Matrix - RM + Cr Due to Ghurab Office - R Matrix - RM + """ + # Amount from the submitted SE; fall back to item rows. + amount = 0.0 + if self.stock_entry: + se_rows = frappe.get_all( + "Stock Entry Detail", + filters={"parent": self.stock_entry}, + fields=["basic_amount"], + ) + amount = round(sum(flt(r.basic_amount) for r in se_rows), 2) + if not amount: + amount = round( + sum( + flt(getattr(item, "basic_amount", None)) or (flt(item.qty) * flt(item.basic_rate)) + for item in (self.items or []) + ), + 2, + ) + if not amount: + return None + + company_currency = frappe.db.get_value("Company", self.company, "default_currency") + + je = frappe.new_doc("Journal Entry") + je.posting_date = self.posting_date + je.company = self.company + je.voucher_type = "Journal Entry" + je.custom_source_doctype = "Inter Branch Stock Transfer" + je.custom_source_docname = self.name + je.user_remark = _("Inter-Branch Stock Transfer: {0}").format(self.name) + + je.append("accounts", { + "account": "Due from Head Office - R Matrix - RM", + "account_currency": company_currency, + "exchange_rate": 1, + "debit_in_account_currency": amount, + "debit": amount, + "custom_source_doctype": "Inter Branch Stock Transfer", + "custom_source_docname": self.name, + }) + je.append("accounts", { + "account": "Due to Ghurab Office - R Matrix - RM", + "account_currency": company_currency, + "exchange_rate": 1, + "credit_in_account_currency": amount, + "credit": amount, + "custom_source_doctype": "Inter Branch Stock Transfer", + "custom_source_docname": self.name, + }) + + je.flags.skip_inter_branch_injection = True + je.flags.ignore_permissions = True + je.insert(ignore_permissions=True) + je.submit() + + frappe.msgprint( + _('Journal Entry {0} created').format(je.name), + alert=True, + indicator="green", + ) + return je.name + + def _create_stock_entry(self): + se = frappe.new_doc("Stock Entry") + se.stock_entry_type = "Material Transfer" + se.company = self.company + se.posting_date = self.posting_date + se.posting_time = self.posting_time + se.from_warehouse = self.from_warehouse + se.to_warehouse = self.to_warehouse + se.remarks = _("Created from Inter Branch Stock Transfer: {0}").format(self.name) + + for item in self.items or []: + se.append("items", { + "item_code": item.item_code, + "qty": item.qty, + "uom": item.uom, + "conversion_factor": item.conversion_factor or 1, + "s_warehouse": item.s_warehouse or self.from_warehouse, + "t_warehouse": item.t_warehouse or self.to_warehouse, + "basic_rate": item.basic_rate, + "allow_zero_valuation_rate": item.allow_zero_valuation_rate, + "serial_no": item.serial_no, + "batch_no": item.batch_no, + }) + + # Prevent the SE on_submit hook from creating a second inter-branch JE. + # The IBST on_submit drives JE creation directly (same pattern as Stock Transfer). + se.flags.from_stock_transfer = True + se.flags.ignore_permissions = True + se.insert(ignore_permissions=True) + se.flags.from_stock_transfer = True # re-set after insert, consumed on submit + se.flags.ignore_permissions = True + se.submit() + + self.db_set("stock_entry", se.name, notify=True) + frappe.msgprint( + _('Stock Entry {0} created').format(se.name), + alert=True, + indicator="green", + ) + + def _create_delivery_note(self): + dn = frappe.new_doc("Delivery Note") + dn.customer = self.customer + dn.company = self.company + dn.posting_date = self.posting_date + dn.posting_time = self.posting_time + dn.set_warehouse = self.to_warehouse + dn.custom_ibst = self.name + dn.remarks = _("Created from Inter Branch Stock Transfer: {0}").format(self.name) + + for item in self.items or []: + dn.append("items", { + "item_code": item.item_code, + "qty": item.qty, + "uom": item.uom, + "warehouse": item.t_warehouse or self.to_warehouse, + "rate": item.basic_rate, + "description": item.description or item.item_name, + }) + + dn.flags.ignore_permissions = True + dn.insert(ignore_permissions=True) + + self.db_set("delivery_note", dn.name, notify=True) + frappe.msgprint( + _('Delivery Note {0} created').format(dn.name), + alert=True, + indicator="green", + ) + + + # ------------------------------------------------------------------ + # Cancel + # ------------------------------------------------------------------ + + def on_cancel(self): + self._cancel_companion_je() + self._cancel_linked_delivery_note() + self._cancel_linked_stock_entry() + + def _cancel_companion_je(self): + try: + je_names = frappe.db.sql_list( + """ + SELECT DISTINCT je.name + FROM `tabJournal Entry` je + INNER JOIN `tabJournal Entry Account` jea ON jea.parent = je.name + WHERE jea.custom_source_doctype = 'Inter Branch Stock Transfer' + AND jea.custom_source_docname = %s + AND je.docstatus = 1 + """, + (self.name,), + ) + for je_name in je_names: + je_doc = frappe.get_doc("Journal Entry", je_name) + je_doc.flags.skip_inter_branch_injection = True + je_doc.cancel() + except Exception: + frappe.log_error( + title="IBST companion JE cancel failed", + message=frappe.get_traceback(), + ) + raise + + def _cancel_linked_delivery_note(self): + if not self.delivery_note: + return + try: + dn = frappe.get_doc("Delivery Note", self.delivery_note) + if dn.docstatus == 1: + dn.flags.ignore_permissions = True + dn.cancel() + except Exception: + frappe.log_error(title="IBST Delivery Note cancel failed", message=frappe.get_traceback()) + raise + + def _cancel_linked_stock_entry(self): + if not self.stock_entry: + return + try: + se = frappe.get_doc("Stock Entry", self.stock_entry) + if se.docstatus == 1: + se.flags.ignore_permissions = True + se.cancel() + except Exception: + frappe.log_error(title="IBST Stock Entry cancel failed", message=frappe.get_traceback()) + raise + + +# ------------------------------------------------------------------ +# Whitelisted helpers +# ------------------------------------------------------------------ + +@frappe.whitelist() +def create_delivery_note(ibst_name: str) -> str: + """Create a Delivery Note from a submitted IBST that has a customer set. + + Called from the 'Create Delivery Note' button when the DN was not created + on submit (e.g. customer added after the fact via amendment). + """ + doc = frappe.get_doc("Inter Branch Stock Transfer", ibst_name) + if doc.docstatus != 1: + frappe.throw(_("Delivery Note can only be created from a submitted document.")) + if not doc.customer: + frappe.throw(_("Please set a Customer before creating a Delivery Note.")) + if doc.delivery_note and frappe.db.get_value("Delivery Note", doc.delivery_note, "docstatus") == 1: + frappe.throw(_("A Delivery Note {0} already exists for this document.").format(doc.delivery_note)) + doc._create_delivery_note() + return doc.delivery_note + + +@frappe.whitelist() +def make_journal_entry(ibst_name: str) -> str: + """Create and submit the inter-branch companion JE for a submitted IBST. + + Returns the new JE name. If a JE already exists for this IBST it is + returned as-is without creating a duplicate. + """ + doc = frappe.get_doc("Inter Branch Stock Transfer", ibst_name) + if doc.docstatus != 1: + frappe.throw(_("Journal Entry can only be created from a submitted document.")) + + # Return existing JE if already created + existing = frappe.db.sql_list( + """ + SELECT DISTINCT je.name + FROM `tabJournal Entry` je + INNER JOIN `tabJournal Entry Account` jea ON jea.parent = je.name + WHERE jea.custom_source_doctype = 'Inter Branch Stock Transfer' + AND jea.custom_source_docname = %s + AND je.docstatus = 1 + """, + (ibst_name,), + ) + if existing: + return existing[0] + + je_name = doc._create_journal_entry() + if not je_name: + frappe.throw(_("Could not create Journal Entry — no transfer value found.")) + return je_name + + +@frappe.whitelist() +def get_item_valuation_rate(item_code: str, warehouse: str) -> float: + """Return current valuation rate from Bin, falling back to Item master.""" + rate = flt( + frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "valuation_rate") + ) + if not rate: + rate = flt(frappe.db.get_value("Item", item_code, "valuation_rate")) + return rate diff --git a/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer_dashboard.py b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer_dashboard.py new file mode 100644 index 0000000..8e57e75 --- /dev/null +++ b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer_dashboard.py @@ -0,0 +1,27 @@ +# Copyright (c) 2026, Enfono and contributors +# For license information, please see license.txt + +from frappe import _ + + +def get_data(): + return { + # Journal Entry uses custom_source_docname pointing back to the IBST. + # Delivery Note uses custom_ibst (a hidden Link field set on creation). + # internal_links is intentionally omitted — it is only for child-table + # row lookups; using it with a scalar Link field causes AttributeError. + "fieldname": "custom_source_docname", + "non_standard_fieldnames": { + "Delivery Note": "custom_ibst", + }, + "transactions": [ + { + "label": _("Accounting"), + "items": ["Journal Entry"], + }, + { + "label": _("Delivery"), + "items": ["Delivery Note"], + }, + ], + } diff --git a/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/test_inter_branch_stock_transfer.py b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/test_inter_branch_stock_transfer.py new file mode 100644 index 0000000..618fab9 --- /dev/null +++ b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/test_inter_branch_stock_transfer.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, Enfono and contributors +# For license information, please see license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestInterBranchStockTransfer(FrappeTestCase): + pass diff --git a/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer_item/__init__.py b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer_item/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer_item/inter_branch_stock_transfer_item.json b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer_item/inter_branch_stock_transfer_item.json new file mode 100644 index 0000000..32f1d0a --- /dev/null +++ b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer_item/inter_branch_stock_transfer_item.json @@ -0,0 +1,208 @@ +{ + "actions": [], + "creation": "2026-06-30 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "item_code", + "item_name", + "description", + "column_break_qty", + "qty", + "stock_uom", + "uom", + "conversion_factor", + "transfer_qty", + "section_break_wh", + "s_warehouse", + "column_break_wh", + "t_warehouse", + "section_break_rate", + "basic_rate", + "basic_amount", + "column_break_rate", + "valuation_rate", + "allow_zero_valuation_rate", + "section_break_batch", + "serial_no", + "batch_no", + "column_break_acct", + "expense_account", + "cost_center" + ], + "fields": [ + { + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "reqd": 1, + "search_index": 1 + }, + { + "fetch_from": "item_code.item_name", + "fieldname": "item_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Item Name", + "read_only": 1 + }, + { + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description", + "print_hide": 1 + }, + { + "fieldname": "column_break_qty", + "fieldtype": "Column Break" + }, + { + "fieldname": "qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Qty", + "reqd": 1 + }, + { + "fetch_from": "item_code.stock_uom", + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM", + "read_only": 1 + }, + { + "fieldname": "uom", + "fieldtype": "Link", + "in_list_view": 1, + "label": "UOM", + "options": "UOM", + "reqd": 1 + }, + { + "default": "1", + "fieldname": "conversion_factor", + "fieldtype": "Float", + "label": "Conversion Factor", + "read_only": 1 + }, + { + "fieldname": "transfer_qty", + "fieldtype": "Float", + "label": "Qty in Stock UOM", + "read_only": 1, + "print_hide": 1 + }, + { + "fieldname": "section_break_wh", + "fieldtype": "Section Break", + "label": "Warehouses" + }, + { + "fieldname": "s_warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Source Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "column_break_wh", + "fieldtype": "Column Break" + }, + { + "fieldname": "t_warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Target Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "section_break_rate", + "fieldtype": "Section Break", + "label": "Rate and Amount" + }, + { + "fieldname": "basic_rate", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Basic Rate (As per Valuation Method)", + "options": "Company:company:default_currency" + }, + { + "fieldname": "basic_amount", + "fieldtype": "Currency", + "label": "Basic Amount", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "column_break_rate", + "fieldtype": "Column Break" + }, + { + "fieldname": "valuation_rate", + "fieldtype": "Currency", + "label": "Valuation Rate", + "options": "Company:company:default_currency", + "print_hide": 1, + "read_only": 1 + }, + { + "default": "0", + "fieldname": "allow_zero_valuation_rate", + "fieldtype": "Check", + "label": "Allow Zero Valuation Rate", + "print_hide": 1 + }, + { + "collapsible": 1, + "fieldname": "section_break_batch", + "fieldtype": "Section Break", + "label": "Serial No / Batch" + }, + { + "fieldname": "serial_no", + "fieldtype": "Text", + "label": "Serial No" + }, + { + "fieldname": "batch_no", + "fieldtype": "Link", + "label": "Batch No", + "options": "Batch" + }, + { + "fieldname": "column_break_acct", + "fieldtype": "Column Break" + }, + { + "fieldname": "expense_account", + "fieldtype": "Link", + "label": "Difference Account", + "options": "Account", + "print_hide": 1 + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center", + "print_hide": 1 + } + ], + "index_web_pages_for_search": 0, + "istable": 1, + "links": [], + "modified": "2026-06-30 00:00:00.000000", + "modified_by": "Administrator", + "module": "Rmax Custom", + "name": "Inter Branch Stock Transfer Item", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer_item/inter_branch_stock_transfer_item.py b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer_item/inter_branch_stock_transfer_item.py new file mode 100644 index 0000000..af75103 --- /dev/null +++ b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer_item/inter_branch_stock_transfer_item.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, Enfono and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class InterBranchStockTransferItem(Document): + pass From aae3db507b4ae1cc122b6b885640b128a1818628 Mon Sep 17 00:00:00 2001 From: Mac Avenue Date: Tue, 30 Jun 2026 19:02:02 +0300 Subject: [PATCH 2/2] fix: stock entry connection added --- .../inter_branch_stock_transfer_dashboard.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer_dashboard.py b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer_dashboard.py index 8e57e75..7cca2b9 100644 --- a/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer_dashboard.py +++ b/rmax_custom/rmax_custom/doctype/inter_branch_stock_transfer/inter_branch_stock_transfer_dashboard.py @@ -6,15 +6,22 @@ def get_data(): return { - # Journal Entry uses custom_source_docname pointing back to the IBST. - # Delivery Note uses custom_ibst (a hidden Link field set on creation). - # internal_links is intentionally omitted — it is only for child-table - # row lookups; using it with a scalar Link field causes AttributeError. "fieldname": "custom_source_docname", + "internal_links": { + # String format: reads doc.stock_entry on the IBST document directly. + "Stock Entry": "stock_entry", + }, "non_standard_fieldnames": { "Delivery Note": "custom_ibst", }, + "dynamic_links": { + "custom_source_docname": ["Inter Branch Stock Transfer", "custom_source_doctype"], + }, "transactions": [ + { + "label": _("Stock"), + "items": ["Stock Entry"], + }, { "label": _("Accounting"), "items": ["Journal Entry"],