diff --git a/ram_custom/api/address_arabic.py b/ram_custom/api/address_arabic.py new file mode 100644 index 0000000..6b3c739 --- /dev/null +++ b/ram_custom/api/address_arabic.py @@ -0,0 +1,30 @@ +import frappe + +# (visible field, older field that print formats still read) +ARABIC_FIELD_PAIRS = ( + ("custom_address_line1_arabic", "custom_address_line_1_arabic"), + ("custom_area_arabic", "custom_areadistrict_arabic"), + ("custom_city_arabic", "custom_citytown_arabic"), +) + + +def sync_arabic_fields(doc, method=None): + """Keep the two parallel sets of Arabic address fields identical. + + The form only shows the first field of each pair; print formats written + earlier still read the second. Whatever the user (or the translate button / + auto-translation) puts in the visible field is mirrored into the older one. + If only the older field has text (legacy rows), it is copied up so nothing + is lost on the next save. + """ + for visible, older in ARABIC_FIELD_PAIRS: + if not (doc.meta.has_field(visible) and doc.meta.has_field(older)): + continue + + visible_value = (doc.get(visible) or "").strip() + older_value = (doc.get(older) or "").strip() + + if visible_value: + doc.set(older, visible_value) + elif older_value: + doc.set(visible, older_value) diff --git a/ram_custom/fixtures/print_format.json b/ram_custom/fixtures/print_format.json new file mode 100644 index 0000000..27cca65 --- /dev/null +++ b/ram_custom/fixtures/print_format.json @@ -0,0 +1,28 @@ +[ + { + "custom_format": 1, + "default_print_language": null, + "disabled": 0, + "doc_type": "Quotation", + "docstatus": 0, + "doctype": "Print Format", + "html": "\n\n\n \n Quotation\n\n\n\n\n
\n\n\n\n\n\n\n
\n\n {% if doc.company == \"Rukn Al Mashreq International Trading Company\" %}\n\n \n\n {% elif doc.company == \"ABB\" %}\n\n \n\n {% elif doc.company == \"Voltbuilds\" %}\n\n \n\n {% elif doc.company == \"JCB\" %}\n\n \n\n {% endif %}\n\n
\n\n\n\n\n\n\n \n\n \n\n \n\n \n\n \n\n\n \n\n \n\n \n\n \n\n \n\n\n \n\n \n\n \n\n \n\n \n\n \n\n
\n\n
 
\n\n
\n\n
\n\n\n\n\n
\n\n QUOTATION
عرض سعر\n\n
\n\n\n\n\n\n\n\n\n\n \n\n \n\n\n \n\n \n\n\n\n
\n\n To / إلى:
\n\n {{ doc.customer_name }}
\n\n {% set customer_id = doc.party_name if doc.quotation_to == \"Customer\" else \"\" %}\n {% set customer_name_ar = frappe.db.get_value(\"Customer\", customer_id, \"custom_customer_name_arabic\") if customer_id else \"\" %}\n {% if customer_name_ar %}\n {{ customer_name_ar }}
\n {% endif %}\n\n {% if doc.customer_address %}\n\n {% set addr = frappe.get_doc(\n \"Address\",\n doc.customer_address\n ) %}\n\n {{ addr.address_line1 or \"\" }}
\n {{ addr.address_line2 or \"\" }}
\n {{ addr.city or \"\" }}
\n {{ addr.pincode or \"\" }}
\n {{ addr.country or \"\" }}\n\n {% set addr_ar = [addr.get(\"custom_address_line1_arabic\"), addr.get(\"custom_area_arabic\"), addr.get(\"custom_city_arabic\")] | select | list %}\n {% if addr_ar %}\n
{{ addr_ar | join(\"، \") }}\n {% endif %}\n\n {% endif %}\n\n
\n\n {% if doc.custom_project %}{% endif %}
Quotation No{{ doc.name }}رقم العرض
Date{{ frappe.utils.formatdate(doc.transaction_date) }}التاريخ
Customer ID{{ customer_id or '' }}رقم العميل
Project{{ frappe.db.get_value(\"Project\", doc.custom_project, \"project_name\") or doc.custom_project }}المشروع
\n\n
\n\n\n\n\n

Dear sir

\n\n

\n Thank You very much to inviting us to providing this quotation\n for the following items.Please find our offer as follow:\n

\n\n\n\n\n\n\n\n\n\n\n \n\n \n\n\n \n\n \n\n\n \n\n \n\n\n \n\n \n\n\n \n\n \n\n\n \n\n \n\n\n \n\n \n\n\n \n\n \n\n\n \n\n \n\n\n\n\n\n\n\n\n{% for item in doc.items %}\n\n {% set currency = item.currency or doc.currency %}\n\n \n\n\n \n\n \n\n\n \n\n \n\n\n \n\n \n\n\n \n\n \n\n\n \n\n \n\n\n \n\n \n\n\n \n\n \n\n\n \n\n \n\n\n \n\n \n\n \n\n{% endfor %}\n\n\n\n
\n\n رقم
SL No.\n\n
\n\n رمز الصنف
Item Code\n\n
\n\n الوصف
Description\n\n
\n\n الوحدة
Unit\n\n
\n\n الكمية
Qty\n\n
\n\n السعر
Rate\n\n
\n\n المجموع الفرعي
Sub Total\n\n
\n\n الضريبة
VAT\n\n
\n\n الإجمالي
Total\n\n
\n\n {{ item.idx }}\n\n \n\n {{ item.item_code }}\n\n \n\n {{ item.description }}\n\n \n\n {{ item.uom }}\n\n \n\n {{ item.qty|int }}\n\n \n\n {% if currency == \"SAR\" %}\n\n \n\n \n {{ frappe.utils.fmt_money(\n item.rate,\n currency=\"\",\n precision=2\n ) }}\n \n\n {% else %}\n\n \n {{ frappe.utils.fmt_money(\n item.rate,\n currency=currency,\n precision=2\n ) }}\n \n\n {% endif %}\n\n \n\n {% if currency == \"SAR\" %}\n\n \n\n \n {{ frappe.utils.fmt_money(\n item.amount,\n currency=\"\",\n precision=2\n ) }}\n \n\n {% else %}\n\n \n {{ frappe.utils.fmt_money(\n item.amount,\n currency=currency,\n precision=2\n ) }}\n \n\n {% endif %}\n\n \n\n {% if currency == \"SAR\" %}\n\n \n\n \n {{ frappe.utils.fmt_money(\n item.amount * 0.15,\n currency=\"\",\n precision=2\n ) }}\n \n\n {% else %}\n\n \n {{ frappe.utils.fmt_money(\n item.amount * 0.15,\n currency=currency,\n precision=2\n ) }}\n \n\n {% endif %}\n\n \n\n {% if currency == \"SAR\" %}\n\n \n\n \n {{ frappe.utils.fmt_money(\n item.amount * 1.15,\n currency=\"\",\n precision=2\n ) }}\n \n\n {% else %}\n\n \n {{ frappe.utils.fmt_money(\n item.amount * 1.15,\n currency=currency,\n precision=2\n ) }}\n \n\n {% endif %}\n\n
\n\n\n\n\n
\n\n\n \n\n
\n\n
\n Sub Total / المجموع الفرعي\n
\n\n
\n\n {% if doc.currency == \"SAR\" %}\n\n \n\n {{ frappe.utils.fmt_money(\n doc.total,\n currency=\"\",\n precision=2\n ) }}\n\n {% else %}\n\n {{ frappe.utils.fmt_money(\n doc.total,\n currency=doc.currency,\n precision=2\n ) }}\n\n {% endif %}\n\n
\n\n
\n\n\n \n\n
\n\n
\n VAT 15% / ضريبة القيمة المضافة\n
\n\n
\n\n {% if doc.currency == \"SAR\" %}\n\n \n\n {{ frappe.utils.fmt_money(\n doc.total_taxes_and_charges,\n currency=\"\",\n precision=2\n ) }}\n\n {% else %}\n\n {{ frappe.utils.fmt_money(\n doc.total_taxes_and_charges,\n currency=doc.currency,\n precision=2\n ) }}\n\n {% endif %}\n\n
\n\n
\n\n\n \n\n
\n\n
\n Grand Total / الإجمالي الكلي\n
\n\n
\n\n {% if doc.currency == \"SAR\" %}\n\n \n\n {{ frappe.utils.fmt_money(\n doc.grand_total,\n currency=\"\",\n precision=2\n ) }}\n\n {% else %}\n\n {{ frappe.utils.fmt_money(\n doc.grand_total,\n currency=doc.currency,\n precision=2\n ) }}\n\n {% endif %}\n\n
\n\n
\n\n\n \n\n
\n\n
\n Grand Total in Words / الإجمالي كتابةً\n
\n\n
\n {{ doc.in_words }}\n
\n\n
\n\n
\n\n\n\n\n\n\n \n\n \n\n \n\n\n \n\n \n\n \n\n
\n\n TERMS & CONDITIONS / الشروط والأحكام\n\n
\n\n {{ doc.terms or '' }}\n\n
\n\n\n\n\n

\n We will be happy to supply any further information you may need\n and trust that you call on us to fill your order,which will receive\n our prompt and carefull attention.\n

\n\n

Best Regards,

\n\n
\n\n\n\n\n{% if doc.company == \"Rukn Al Mashreq International Trading Company\" %}\n\n
\n\n\n \n\n
\n\n \n SA1210000025300000185504\n \n\n \n\n
\n\n\n \n\n
\n\n \n SA8930400108095579540010\n \n\n \n\n
\n\n
\n\n\n{% elif doc.company == \"ABB\" %}\n\n
\n\n
\n\n \n SA7180000390608017654110\n \n\n \n\n
\n\n
\n\n{% endif %}\n\n\n
\n\n
\n\n
 
\n\n
\n\n\n\n\n{% if doc.company == \"Rukn Al Mashreq International Trading Company\" %}\n\n
\n\n \n\n
\n\n\n{% elif doc.company == \"ABB\" %}\n\n
\n\n \n\n
\n\n\n{% elif doc.company == \"JCB\" %}\n\n
\n\n \n\n
\n\n\n{% elif doc.company == \"Voltbuilds\" %}\n\n
\n\n \n\n
\n\n{% endif %}\n\n\n
\n\n\n", + "idx": 0, + "line_breaks": 0, + "margin_bottom": 15.0, + "margin_left": 15.0, + "margin_right": 15.0, + "margin_top": 15.0, + "modified": "2026-10-13 12:30:00.000000", + "module": "Ram Custom", + "name": "Quotation Bilingual", + "owner": "Administrator", + "page_number": "Hide", + "print_format_builder": 0, + "print_format_builder_beta": 0, + "print_format_type": "Jinja", + "raw_printing": 0, + "show_section_headings": 0, + "standard": "No" + } +] \ No newline at end of file diff --git a/ram_custom/fixtures/property_setter.json b/ram_custom/fixtures/property_setter.json index 3c6ddd4..67f2a07 100644 --- a/ram_custom/fixtures/property_setter.json +++ b/ram_custom/fixtures/property_setter.json @@ -103,13 +103,13 @@ "doctype_or_field": "DocField", "field_name": "phone", "is_system_generated": 1, - "modified": "2026-09-15 09:04:49.229767", + "modified": "2026-10-13 12:00:00.000000", "module": "Ram Custom", "name": "Address-phone-hidden", "property": "hidden", "property_type": "Check", "row_name": null, - "value": "1" + "value": "0" }, { "default_value": null, @@ -254,5 +254,53 @@ "property_type": "Check", "row_name": null, "value": "1" + }, + { + "default_value": null, + "doc_type": "Address", + "docstatus": 0, + "doctype": "Property Setter", + "doctype_or_field": "DocField", + "field_name": "custom_address_line_1_arabic", + "is_system_generated": 1, + "modified": "2026-10-13 12:00:00.000000", + "module": "Ram Custom", + "name": "Address-custom_address_line_1_arabic-hidden", + "property": "hidden", + "property_type": "Check", + "row_name": null, + "value": "1" + }, + { + "default_value": null, + "doc_type": "Address", + "docstatus": 0, + "doctype": "Property Setter", + "doctype_or_field": "DocField", + "field_name": "custom_areadistrict_arabic", + "is_system_generated": 1, + "modified": "2026-10-13 12:00:00.000000", + "module": "Ram Custom", + "name": "Address-custom_areadistrict_arabic-hidden", + "property": "hidden", + "property_type": "Check", + "row_name": null, + "value": "1" + }, + { + "default_value": null, + "doc_type": "Address", + "docstatus": 0, + "doctype": "Property Setter", + "doctype_or_field": "DocField", + "field_name": "custom_citytown_arabic", + "is_system_generated": 1, + "modified": "2026-10-13 12:00:00.000000", + "module": "Ram Custom", + "name": "Address-custom_citytown_arabic-hidden", + "property": "hidden", + "property_type": "Check", + "row_name": null, + "value": "1" } ] \ No newline at end of file diff --git a/ram_custom/hooks.py b/ram_custom/hooks.py index d349cf8..9b7029d 100644 --- a/ram_custom/hooks.py +++ b/ram_custom/hooks.py @@ -89,6 +89,10 @@ def _asset_version(): "dt": "Property Setter", "filters": [["module", "=", "Ram Custom"]], }, + { + "dt": "Print Format", + "filters": [["name", "in", ["Quotation Bilingual"]]], + }, ] # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} @@ -197,6 +201,11 @@ def _asset_version(): "Purchase Invoice": { "validate": "ram_custom.api.inter_company_transfer.block_inter_company_invoices", }, + "Address": { + # Mirrors the visible Arabic address fields into the older duplicates that + # print formats still read. Runs after arabic_translation's own before_save. + "before_save": "ram_custom.api.address_arabic.sync_arabic_fields", + }, "Customer": { # on_update, NOT after_insert: the primary Address is created inside # Customer.on_update, which runs after after_insert. See the docstring in diff --git a/ram_custom/patches.txt b/ram_custom/patches.txt index bdccea5..0c3c3b5 100644 --- a/ram_custom/patches.txt +++ b/ram_custom/patches.txt @@ -5,4 +5,6 @@ [post_model_sync] # Patches added in this section will be executed after doctypes are migrated ram_custom.patches.v0_0_2.seed_ict_reconciled_cost_value -ram_custom.patches.v0_0_2.seed_ict_uom_split_rates \ No newline at end of file +ram_custom.patches.v0_0_2.seed_ict_uom_split_rates +ram_custom.patches.v0_0_3.fix_delivery_note_return_status +ram_custom.patches.v0_0_3.merge_duplicate_address_arabic_fields diff --git a/ram_custom/patches/v0_0_3/__init__.py b/ram_custom/patches/v0_0_3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ram_custom/patches/v0_0_3/fix_delivery_note_return_status.py b/ram_custom/patches/v0_0_3/fix_delivery_note_return_status.py new file mode 100644 index 0000000..d1338d2 --- /dev/null +++ b/ram_custom/patches/v0_0_3/fix_delivery_note_return_status.py @@ -0,0 +1,26 @@ +# Ticket 5: submitted return Delivery Notes created before ERPNext started +# stamping the "Return" status still show "To Bill" in the list view. +# +# ERPNext's own rule (status_updater.py, "Delivery Note") is: +# Return = is_return == 1 and per_billed == 0 and docstatus == 1 +# so this only brings old rows in line with that rule. Nothing is touched when +# per_billed > 0 (a credit note was actually raised) or when the user has +# already closed the document. `modified` is left alone so the lists don't +# reshuffle and nobody sees a phantom edit. + +import frappe + + +def execute(): + dn = frappe.qb.DocType("Delivery Note") + rows = ( + frappe.qb.from_(dn) + .select(dn.name) + .where((dn.docstatus == 1) & (dn.is_return == 1) & (dn.per_billed == 0) & (dn.status == "To Bill")) + ).run(pluck="name") + + if not rows: + return + + (frappe.qb.update(dn).set(dn.status, "Return").where(dn.name.isin(rows))).run() + frappe.logger("ram_custom").info(f"Delivery Note returns re-labelled 'Return': {len(rows)}") diff --git a/ram_custom/patches/v0_0_3/merge_duplicate_address_arabic_fields.py b/ram_custom/patches/v0_0_3/merge_duplicate_address_arabic_fields.py new file mode 100644 index 0000000..7e3eaa1 --- /dev/null +++ b/ram_custom/patches/v0_0_3/merge_duplicate_address_arabic_fields.py @@ -0,0 +1,44 @@ +# Ticket 3: Address carries two parallel sets of Arabic fields. +# +# shown going forward (arabic_translation app) kept in sync (older, used by print formats) +# custom_address_line1_arabic custom_address_line_1_arabic +# custom_area_arabic custom_areadistrict_arabic +# custom_city_arabic custom_citytown_arabic +# +# The older fields are still read by live print formats, so we do not delete +# them; they are hidden on the form (Property Setters in fixtures) and kept in +# step by `ram_custom.api.address_arabic.sync_arabic_fields`. This patch makes +# the two sets agree for existing rows: whichever side is empty is filled from +# the other. Rows where both sides hold *different* text are left untouched and +# listed in the log for a person to decide. + +import frappe + +PAIRS = ( + ("custom_address_line1_arabic", "custom_address_line_1_arabic"), + ("custom_area_arabic", "custom_areadistrict_arabic"), + ("custom_city_arabic", "custom_citytown_arabic"), +) + + +def execute(): + address = frappe.qb.DocType("Address") + log = frappe.logger("ram_custom") + + for new, old in PAIRS: + if not (frappe.db.has_column("Address", new) and frappe.db.has_column("Address", old)): + continue + + new_col, old_col = address[new], address[old] + is_empty = lambda col: (col.isnull()) | (col == "") # noqa: E731 + + (frappe.qb.update(address).set(new_col, old_col).where(is_empty(new_col) & ~is_empty(old_col))).run() + (frappe.qb.update(address).set(old_col, new_col).where(is_empty(old_col) & ~is_empty(new_col))).run() + + differing = ( + frappe.qb.from_(address) + .select(address.name) + .where(~is_empty(new_col) & ~is_empty(old_col) & (new_col != old_col)) + ).run(pluck="name") + if differing: + log.warning(f"Address Arabic fields differ ({new} vs {old}): {differing}") diff --git a/ram_custom/ram_custom/report/stock_ledger_with_supplier_details/__init__.py b/ram_custom/ram_custom/report/stock_ledger_with_supplier_details/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ram_custom/ram_custom/report/stock_ledger_with_supplier_details/stock_ledger_with_supplier_details.js b/ram_custom/ram_custom/report/stock_ledger_with_supplier_details/stock_ledger_with_supplier_details.js new file mode 100644 index 0000000..77835c0 --- /dev/null +++ b/ram_custom/ram_custom/report/stock_ledger_with_supplier_details/stock_ledger_with_supplier_details.js @@ -0,0 +1,112 @@ +// Copyright (c) 2026, Enfono Technologies and contributors +// For license information, please see license.txt + +// Same filters as the standard Stock Ledger so people use it exactly the same +// way; the extra Supplier columns come from the server side. +frappe.query_reports["Stock Ledger with Supplier Details"] = { + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1, + }, + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.datetime.add_months(frappe.datetime.get_today(), -1), + reqd: 1, + }, + { + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.datetime.get_today(), + reqd: 1, + }, + { + fieldname: "warehouse", + label: __("Warehouses"), + fieldtype: "MultiSelectList", + options: "Warehouse", + get_data: function (txt) { + const company = frappe.query_report.get_filter_value("company"); + return frappe.db.get_link_options("Warehouse", txt, { company: company }); + }, + }, + { + fieldname: "item_code", + label: __("Items"), + fieldtype: "MultiSelectList", + options: "Item", + get_data: async function (txt) { + let { message: data } = await frappe.call({ + method: "erpnext.controllers.queries.item_query", + args: { + doctype: "Item", + txt: txt, + searchfield: "name", + start: 0, + page_len: 10, + filters: {}, + as_dict: 1, + }, + }); + data = data.map(({ name, ...rest }) => ({ + value: name, + description: Object.values(rest), + })); + return data || []; + }, + }, + { + fieldname: "item_group", + label: __("Item Group"), + fieldtype: "Link", + options: "Item Group", + }, + { + fieldname: "batch_no", + label: __("Batch No"), + fieldtype: "Link", + options: "Batch", + on_change() { + const batch_no = frappe.query_report.get_filter_value("batch_no"); + frappe.query_report.set_filter_value( + "segregate_serial_batch_bundle", + batch_no ? 1 : 0 + ); + }, + }, + { fieldname: "brand", label: __("Brand"), fieldtype: "Link", options: "Brand" }, + { fieldname: "voucher_no", label: __("Voucher #"), fieldtype: "Data" }, + { fieldname: "project", label: __("Project"), fieldtype: "Link", options: "Project" }, + { fieldname: "include_uom", label: __("Include UOM"), fieldtype: "Link", options: "UOM" }, + { + fieldname: "valuation_field_type", + label: __("Valuation Field Type"), + fieldtype: "Select", + width: "80", + options: "Currency\nFloat", + default: "Currency", + }, + { + fieldname: "segregate_serial_batch_bundle", + label: __("Segregate Serial / Batch Bundle"), + fieldtype: "Check", + default: 0, + }, + ], + formatter: function (value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data); + if (column.fieldname == "out_qty" && data && data.out_qty < 0) { + value = "" + value + ""; + } else if (column.fieldname == "in_qty" && data && data.in_qty > 0) { + value = "" + value + ""; + } + return value; + }, +}; diff --git a/ram_custom/ram_custom/report/stock_ledger_with_supplier_details/stock_ledger_with_supplier_details.json b/ram_custom/ram_custom/report/stock_ledger_with_supplier_details/stock_ledger_with_supplier_details.json new file mode 100644 index 0000000..c834916 --- /dev/null +++ b/ram_custom/ram_custom/report/stock_ledger_with_supplier_details/stock_ledger_with_supplier_details.json @@ -0,0 +1,28 @@ +{ + "add_total_row": 0, + "columns": [], + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2026-10-13 12:00:00.000000", + "module": "Ram Custom", + "name": "Stock Ledger with Supplier Details", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Stock Ledger Entry", + "report_name": "Stock Ledger with Supplier Details", + "report_type": "Script Report", + "roles": [ + { + "role": "Stock User" + }, + { + "role": "Accounts Manager" + } + ] +} diff --git a/ram_custom/ram_custom/report/stock_ledger_with_supplier_details/stock_ledger_with_supplier_details.py b/ram_custom/ram_custom/report/stock_ledger_with_supplier_details/stock_ledger_with_supplier_details.py new file mode 100644 index 0000000..eef882d --- /dev/null +++ b/ram_custom/ram_custom/report/stock_ledger_with_supplier_details/stock_ledger_with_supplier_details.py @@ -0,0 +1,163 @@ +# Copyright (c) 2026, Enfono Technologies and contributors +# For license information, please see license.txt + +"""Stock Ledger with Supplier, Supplier Invoice No and Supplier Invoice Date. + +The standard ERPNext Stock Ledger is run untouched; this report only adds three +columns right after "Voucher #". Because the columns are part of the report +itself, the layout is the same for every user and every visit - nothing to +re-arrange per user. + +Where the three values come from (looked up in bulk, one query per document +type, never one query per row): + +* Purchase Invoice - supplier, bill_no, bill_date on the invoice itself. +* Purchase Receipt - the "Supplier Invoice No / Date" typed on the receipt + itself. If that is empty, the supplier invoice that later billed this receipt + (through Purchase Invoice Item.purchase_receipt). If neither exists, the + supplier's own delivery note number is shown and the date is left empty. +* Any other voucher (Delivery Note, Stock Entry, ...) - left empty. +""" + +from collections import defaultdict + +import frappe +from erpnext.stock.report.stock_ledger.stock_ledger import execute as stock_ledger_execute +from frappe import _ + +CHUNK = 1000 + + +def execute(filters=None): + result = stock_ledger_execute(filters) + columns, data = result[0], result[1] + + insert_supplier_columns(columns) + add_supplier_details(data) + + return (columns, data, *result[2:]) + + +def insert_supplier_columns(columns): + new_columns = [ + { + "label": _("Supplier"), + "fieldname": "supplier", + "fieldtype": "Link", + "options": "Supplier", + "width": 160, + }, + { + "label": _("Supplier Invoice No"), + "fieldname": "supplier_invoice_no", + "fieldtype": "Data", + "width": 140, + }, + { + "label": _("Supplier Invoice Date"), + "fieldname": "supplier_invoice_date", + "fieldtype": "Date", + "width": 120, + }, + ] + + position = next( + (idx + 1 for idx, col in enumerate(columns) if col.get("fieldname") == "voucher_no"), + len(columns), + ) + columns[position:position] = new_columns + + +def add_supplier_details(data): + vouchers = defaultdict(set) + for row in data: + voucher_type, voucher_no = row.get("voucher_type"), row.get("voucher_no") + if voucher_type in ("Purchase Invoice", "Purchase Receipt") and voucher_no: + vouchers[voucher_type].add(voucher_no) + + if not vouchers: + return + + invoices = get_purchase_invoice_details(vouchers.get("Purchase Invoice", ())) + receipts = get_purchase_receipt_details(vouchers.get("Purchase Receipt", ())) + + for row in data: + details = None + if row.get("voucher_type") == "Purchase Invoice": + details = invoices.get(row.get("voucher_no")) + elif row.get("voucher_type") == "Purchase Receipt": + details = receipts.get(row.get("voucher_no")) + + if details: + row.update(details) + + +def get_purchase_invoice_details(names): + out = {} + pi = frappe.qb.DocType("Purchase Invoice") + for batch in _chunks(names): + rows = ( + frappe.qb.from_(pi) + .select(pi.name, pi.supplier, pi.bill_no, pi.bill_date) + .where(pi.name.isin(batch)) + ).run(as_dict=True) + for r in rows: + out[r.name] = { + "supplier": r.supplier, + "supplier_invoice_no": r.bill_no, + "supplier_invoice_date": r.bill_date, + } + return out + + +def get_purchase_receipt_details(names): + out = {} + meta = frappe.get_meta("Purchase Receipt") + number_field = "custom_supplier_invoice_number" + date_field = "custom_supplier_invoice_date" + has_receipt_invoice = meta.has_field(number_field) and meta.has_field(date_field) + + pr = frappe.qb.DocType("Purchase Receipt") + fields = [pr.name, pr.supplier, pr.supplier_delivery_note] + if has_receipt_invoice: + fields += [pr[number_field], pr[date_field]] + + for batch in _chunks(names): + for r in frappe.qb.from_(pr).select(*fields).where(pr.name.isin(batch)).run(as_dict=True): + invoice_no = r.get(number_field) if has_receipt_invoice else None + out[r.name] = { + "supplier": r.supplier, + "supplier_invoice_no": invoice_no or r.supplier_delivery_note, + "supplier_invoice_date": r.get(date_field) if invoice_no else None, + "_from_receipt": bool(invoice_no), + } + + # Receipts with no invoice number typed on them: use the invoice that billed them. + missing = [name for name, d in out.items() if not d.pop("_from_receipt")] + pii = frappe.qb.DocType("Purchase Invoice Item") + pi = frappe.qb.DocType("Purchase Invoice") + for batch in _chunks(missing): + billed = ( + frappe.qb.from_(pii) + .join(pi) + .on(pi.name == pii.parent) + .select(pii.purchase_receipt, pi.bill_no, pi.bill_date) + .where(pii.purchase_receipt.isin(batch) & (pi.docstatus == 1) & (pi.bill_no != "")) + .orderby(pi.posting_date) + .orderby(pi.name) + ).run(as_dict=True) + seen = set() + for r in billed: + if r.purchase_receipt in seen: + continue + seen.add(r.purchase_receipt) + out[r.purchase_receipt].update( + {"supplier_invoice_no": r.bill_no, "supplier_invoice_date": r.bill_date} + ) + return out + + +def _chunks(names): + names = sorted(names) + for i in range(0, len(names), CHUNK): + yield names[i : i + CHUNK] diff --git a/ram_custom/tests/__init__.py b/ram_custom/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ram_custom/tests/test_ticket_fixes_oct_2026.py b/ram_custom/tests/test_ticket_fixes_oct_2026.py new file mode 100644 index 0000000..a5e561a --- /dev/null +++ b/ram_custom/tests/test_ticket_fixes_oct_2026.py @@ -0,0 +1,275 @@ +# Copyright (c) 2026, Enfono Technologies and contributors +# For license information, please see license.txt + +import re + +import frappe +from frappe.tests.utils import FrappeTestCase + +from ram_custom.api.address_arabic import ARABIC_FIELD_PAIRS, sync_arabic_fields + + +class TestAddressArabicSync(FrappeTestCase): + """Ticket 3: the visible Arabic fields and the older duplicates stay identical.""" + + def _address(self, **values): + doc = frappe.new_doc("Address") + doc.update(values) + return doc + + def test_visible_value_is_mirrored_into_older_field(self): + for visible, older in ARABIC_FIELD_PAIRS: + doc = self._address(**{visible: "المحمدية"}) + sync_arabic_fields(doc) + self.assertEqual(doc.get(older), "المحمدية", older) + + def test_older_only_value_is_copied_up(self): + for visible, older in ARABIC_FIELD_PAIRS: + doc = self._address(**{older: "الرياض"}) + sync_arabic_fields(doc) + self.assertEqual(doc.get(visible), "الرياض", visible) + + def test_visible_wins_when_both_differ(self): + visible, older = ARABIC_FIELD_PAIRS[0] + doc = self._address(**{visible: "جديد", older: "قديم"}) + sync_arabic_fields(doc) + self.assertEqual(doc.get(older), "جديد") + + def test_both_empty_stay_empty(self): + doc = self._address() + sync_arabic_fields(doc) + for visible, older in ARABIC_FIELD_PAIRS: + self.assertFalse(doc.get(visible)) + self.assertFalse(doc.get(older)) + + def test_old_fields_hidden_and_phone_visible(self): + meta = frappe.get_meta("Address") + self.assertFalse(meta.get_field("phone").hidden, "phone must be visible") + for _visible, older in ARABIC_FIELD_PAIRS: + self.assertTrue(meta.get_field(older).hidden, f"{older} must be hidden") + for visible, _older in ARABIC_FIELD_PAIRS: + self.assertFalse(meta.get_field(visible).hidden, f"{visible} must be visible") + + +class TestDeliveryNoteReturnStatusPatch(FrappeTestCase): + """Ticket 5: old return Delivery Notes labelled 'To Bill' become 'Return'.""" + + def test_patch_relabels_only_unbilled_returns(self): + from ram_custom.patches.v0_0_3.fix_delivery_note_return_status import execute + + returns = frappe.get_all( + "Delivery Note", + filters={"docstatus": 1, "is_return": 1, "status": "Return", "per_billed": 0}, + fields=["name", "modified"], + limit=1, + ) + if not returns: + self.skipTest("no submitted return Delivery Note in this database") + + name, modified = returns[0].name, returns[0].modified + frappe.db.set_value("Delivery Note", name, "status", "To Bill", update_modified=False) + + # a normal (non-return) delivery that really is waiting to be billed must not change + normal = frappe.get_all( + "Delivery Note", + filters={"docstatus": 1, "is_return": 0, "status": "To Bill", "per_billed": 0}, + pluck="name", + limit=1, + ) + if not normal: + self.skipTest("no normal Delivery Note waiting to be billed in this database") + + execute() + + self.assertEqual(frappe.db.get_value("Delivery Note", name, "status"), "Return") + self.assertEqual(frappe.db.get_value("Delivery Note", name, "modified"), modified) + if normal: + self.assertEqual(frappe.db.get_value("Delivery Note", normal[0], "status"), "To Bill") + + # idempotent: a second run changes nothing + execute() + self.assertEqual(frappe.db.get_value("Delivery Note", name, "status"), "Return") + + def test_returns_that_were_billed_are_left_alone(self): + from ram_custom.patches.v0_0_3.fix_delivery_note_return_status import execute + + billed = frappe.get_all( + "Delivery Note", + filters={"docstatus": 1, "is_return": 1, "per_billed": [">", 0]}, + fields=["name", "status"], + limit=1, + ) + if not billed: + self.skipTest("no billed return Delivery Note in this database") + + name = billed[0].name + frappe.db.set_value("Delivery Note", name, "status", "To Bill", update_modified=False) + execute() + self.assertEqual(frappe.db.get_value("Delivery Note", name, "status"), "To Bill") + frappe.db.set_value("Delivery Note", name, "status", billed[0].status, update_modified=False) + + def test_no_returns_left_showing_to_bill(self): + from ram_custom.patches.v0_0_3.fix_delivery_note_return_status import execute + + normal_before = frappe.db.count( + "Delivery Note", {"docstatus": 1, "is_return": 0, "status": "To Bill"} + ) + execute() + self.assertEqual( + frappe.db.count("Delivery Note", {"docstatus": 1, "is_return": 0, "status": "To Bill"}), + normal_before, + "normal deliveries waiting to be billed must keep the 'To Bill' status", + ) + leftovers = frappe.db.count( + "Delivery Note", + {"docstatus": 1, "is_return": 1, "per_billed": 0, "status": "To Bill"}, + ) + self.assertEqual(leftovers, 0) + + +class TestStockLedgerWithSupplierDetails(FrappeTestCase): + """Ticket 1: Supplier, Supplier Invoice No and Date columns on the Stock Ledger.""" + + MODULE = ( + "ram_custom.ram_custom.report.stock_ledger_with_supplier_details.stock_ledger_with_supplier_details" + ) + + def test_columns_are_inserted_right_after_voucher_no(self): + report = frappe.get_attr(f"{self.MODULE}.insert_supplier_columns") + columns = [ + {"fieldname": "voucher_type"}, + {"fieldname": "voucher_no"}, + {"fieldname": "batch_no"}, + ] + report(columns) + self.assertEqual( + [c["fieldname"] for c in columns], + [ + "voucher_type", + "voucher_no", + "supplier", + "supplier_invoice_no", + "supplier_invoice_date", + "batch_no", + ], + ) + + def test_purchase_invoice_row_gets_supplier_details(self): + add_supplier_details = frappe.get_attr(f"{self.MODULE}.add_supplier_details") + pi = frappe.get_all( + "Purchase Invoice", + filters={"docstatus": 1, "bill_no": ["!=", ""]}, + fields=["name", "supplier", "bill_no", "bill_date"], + limit=1, + ) + if not pi: + self.skipTest("no submitted Purchase Invoice with a supplier invoice number") + + row = frappe._dict(voucher_type="Purchase Invoice", voucher_no=pi[0].name) + other = frappe._dict(voucher_type="Delivery Note", voucher_no="DN-NOT-A-PURCHASE") + add_supplier_details([row, other]) + + self.assertEqual(row.supplier, pi[0].supplier) + self.assertEqual(row.supplier_invoice_no, pi[0].bill_no) + self.assertEqual(row.supplier_invoice_date, pi[0].bill_date) + self.assertIsNone(other.get("supplier")) + + def test_purchase_receipt_shows_the_invoice_typed_on_the_receipt(self): + add_supplier_details = frappe.get_attr(f"{self.MODULE}.add_supplier_details") + pr = frappe.get_all( + "Purchase Receipt", + filters={"docstatus": 1, "custom_supplier_invoice_number": ["!=", ""]}, + fields=["name", "supplier", "custom_supplier_invoice_number", "custom_supplier_invoice_date"], + limit=1, + ) + if not pr: + self.skipTest("no submitted Purchase Receipt with a supplier invoice number") + + row = frappe._dict(voucher_type="Purchase Receipt", voucher_no=pr[0].name) + add_supplier_details([row]) + self.assertEqual(row.supplier, pr[0].supplier) + self.assertEqual(row.supplier_invoice_no, pr[0].custom_supplier_invoice_number) + self.assertEqual(row.supplier_invoice_date, pr[0].custom_supplier_invoice_date) + + def test_receipt_without_an_invoice_number_falls_back_to_the_billing_invoice(self): + add_supplier_details = frappe.get_attr(f"{self.MODULE}.add_supplier_details") + link = frappe.db.sql( + """ + select pr.name as receipt, pi.bill_no, pi.bill_date + from `tabPurchase Receipt` pr + join `tabPurchase Invoice Item` pii on pii.purchase_receipt = pr.name + join `tabPurchase Invoice` pi on pi.name = pii.parent + where pr.docstatus = 1 and ifnull(pr.custom_supplier_invoice_number, '') = '' + and pi.docstatus = 1 and ifnull(pi.bill_no, '') != '' + limit 1 + """, + as_dict=True, + ) + if not link: + self.skipTest("no un-numbered Purchase Receipt that was billed through a Purchase Invoice") + + row = frappe._dict(voucher_type="Purchase Receipt", voucher_no=link[0].receipt) + add_supplier_details([row]) + self.assertEqual(row.supplier_invoice_no, link[0].bill_no) + self.assertEqual(row.supplier_invoice_date, link[0].bill_date) + + def test_report_runs_and_keeps_standard_columns(self): + execute = frappe.get_attr(f"{self.MODULE}.execute") + company = frappe.db.get_value("Stock Ledger Entry", {"is_cancelled": 0}, "company") + if not company: + self.skipTest("no stock ledger entries") + + filters = frappe._dict( + company=company, + from_date=frappe.utils.add_months(frappe.utils.today(), -1), + to_date=frappe.utils.today(), + valuation_field_type="Currency", + ) + columns, data = execute(filters)[:2] + fieldnames = [c["fieldname"] for c in columns] + self.assertIn("voucher_no", fieldnames) + self.assertEqual(fieldnames.index("supplier"), fieldnames.index("voucher_no") + 1) + self.assertIn("supplier_invoice_date", fieldnames) + + +class TestQuotationBilingualPrint(FrappeTestCase): + """Ticket 8: the Quotation print carries Arabic next to the English labels.""" + + def _html(self): + if not frappe.db.exists("Print Format", "Quotation Bilingual"): + self.skipTest("print format 'Quotation Bilingual' is not installed on this site") + name = frappe.db.get_value("Quotation", {"quotation_to": "Customer", "docstatus": ["<", 2]}, "name") + if not name: + self.skipTest("no Quotation in this database") + return name, frappe.get_print("Quotation", name, print_format="Quotation Bilingual") + + def test_labels_are_bilingual(self): + _name, html = self._html() + for arabic in ( + "عرض سعر", + "رقم العرض", + "التاريخ", + "رقم العميل", + "المجموع الفرعي", + "الإجمالي الكلي", + "الشروط والأحكام", + ): + self.assertIn(arabic, html, arabic) + for english in ("QUOTATION", "Quotation No", "Sub Total", "Grand Total", "TERMS"): + self.assertIn(english, html, english) + + def test_customer_arabic_name_and_id_are_printed(self): + name, html = self._html() + doc = frappe.get_doc("Quotation", name) + self.assertRegex( + html, rf"(?s)Customer ID.*?{re.escape(doc.party_name)}", "Customer ID must show party_name" + ) + arabic = frappe.db.get_value("Customer", doc.party_name, "custom_customer_name_arabic") + if arabic: + self.assertIn(arabic, html) + + def test_current_default_format_is_untouched(self): + default = frappe.db.get_value( + "Property Setter", {"doc_type": "Quotation", "property": "default_print_format"}, "value" + ) + self.assertNotEqual(default, "Quotation Bilingual", "the default must only change after sign-off")