diff --git a/jey_erp/custom/sales_invoice_certificates.py b/jey_erp/custom/sales_invoice_certificates.py new file mode 100644 index 0000000..d4d83e7 --- /dev/null +++ b/jey_erp/custom/sales_invoice_certificates.py @@ -0,0 +1,56 @@ +# Sales Invoice multi presented-certificate — server-side foundation. +# +# Source of truth for the (possibly multiple) presented certificates of an invoice +# line is the parent table `custom_item_certificates` (child DocType "Sales Invoice +# Certificate"), whose rows are linked to an item row by `custom_si_row_key` — the +# SAME stable row key used by the tax-article multi-select. +# +# This validate hook: +# 1. guarantees every item row has a stable `custom_si_row_key`, +# 2. drops ONLY orphaned certificate rows (whose parent_row matches no item row), +# 3. refreshes the `custom_certificates_display` text (the grid cell summary). +# +# Certificates are NOT bound to the line's tax template, so — unlike tax articles — +# the selection is never cleared on template change. + +import frappe + + +def sync_item_certificates(doc, method=None): + if doc.doctype != "Sales Invoice": + return + + # 1) ensure each item row has a unique, stable row key (links it to its certs) + seen = set() + for item in doc.get("items", []): + key = item.get("custom_si_row_key") + if not key or key in seen: + key = "r" + frappe.generate_hash(length=10) + item.custom_si_row_key = key + seen.add(key) + + # 2) keep every certificate row that points at a real item row; drop only orphans. + item_keys = {item.custom_si_row_key for item in doc.get("items", [])} + rows = [ + r + for r in (doc.get("custom_item_certificates") or []) + if r.get("parent_row") in item_keys and r.get("certificate") + ] + doc.custom_item_certificates = rows + + # 3) refresh the per-row "Sertifikatın seriyə / nömrəsi" display label + by_row = {} + for r in rows: + by_row.setdefault(r.parent_row, []).append(r.certificate) + + for item in doc.get("items", []): + certs = by_row.get(item.custom_si_row_key, []) + if hasattr(item, "custom_certificates_display"): + item.custom_certificates_display = _display_label(len(certs)) + + +def _display_label(n: int) -> str: + """Short summary shown in the grid cell (avoids the Data field length limit).""" + if n: + return frappe._("{0} selected").format(n) + return frappe._("Click to select") diff --git a/jey_erp/custom_fields.py b/jey_erp/custom_fields.py index ca57459..48e811a 100644 --- a/jey_erp/custom_fields.py +++ b/jey_erp/custom_fields.py @@ -748,6 +748,23 @@ def create_custom_fields(): options='Sales Invoice Tax Article', insert_after='custom_item_tax_articles_section' ), + # Multiple presented certificates per item row, stored on the parent and + # linked to each item row by custom_si_row_key (same row-key as the tax + # articles above; Frappe has no nested child tables). + dict( + fieldname='custom_item_certificates_section', + label='Item Certificates', + fieldtype='Section Break', + insert_after='custom_item_tax_articles', + collapsible=1 + ), + dict( + fieldname='custom_item_certificates', + label='Item Certificates', + fieldtype='Table', + options='Sales Invoice Certificate', + insert_after='custom_item_certificates_section' + ), # New comments section after time_sheet_list dict( fieldname='comments_section', @@ -935,6 +952,18 @@ def create_custom_fields(): hidden=0, columns=2, description='Click to select tax articles' + ), + # --- multi presented-certificate per item row (click the cell to edit) --- + dict( + fieldname='custom_certificates_display', + label='Sertifikatın seriyə / nömrəsi', + fieldtype='Data', + insert_after='custom_tax_articles_display', + read_only=1, + in_list_view=1, + hidden=0, + columns=2, + description='Click to select certificates' ) ], "Purchase Invoice Item": [ diff --git a/jey_erp/hooks.py b/jey_erp/hooks.py index 8294e12..2e9193e 100644 --- a/jey_erp/hooks.py +++ b/jey_erp/hooks.py @@ -13,6 +13,7 @@ app_include_js = [ "/assets/jey_erp/js/sales_invoice.js", "/assets/jey_erp/js/sales_invoice_vat.js", "/assets/jey_erp/js/sales_invoice_tax_articles.js", + "/assets/jey_erp/js/sales_invoice_certificates.js", "/assets/jey_erp/js/sales_order_vat.js", "/assets/jey_erp/js/asset.js", "/assets/jey_erp/js/link_field_fix.js", @@ -59,6 +60,7 @@ doc_events = { "validate": [ "jey_erp.custom.sales_invoice.calculate_tax_free_amounts", "jey_erp.custom.sales_invoice_tax_articles.sync_item_tax_articles", + "jey_erp.custom.sales_invoice_certificates.sync_item_certificates", ], "on_update": [ "jey_erp.custom.sales_invoice.calculate_tax_free_amounts", diff --git a/jey_erp/jey_erp/doctype/sales_invoice_certificate/__init__.py b/jey_erp/jey_erp/doctype/sales_invoice_certificate/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jey_erp/jey_erp/doctype/sales_invoice_certificate/sales_invoice_certificate.json b/jey_erp/jey_erp/doctype/sales_invoice_certificate/sales_invoice_certificate.json new file mode 100644 index 0000000..21599f1 --- /dev/null +++ b/jey_erp/jey_erp/doctype/sales_invoice_certificate/sales_invoice_certificate.json @@ -0,0 +1,40 @@ +{ + "actions": [], + "creation": "2026-06-16 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "parent_row", + "certificate" + ], + "fields": [ + { + "fieldname": "parent_row", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Item Row Key", + "read_only": 1 + }, + { + "fieldname": "certificate", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Certificate", + "options": "E-Taxes Presented Certificate", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-06-16 00:00:00.000000", + "modified_by": "Administrator", + "module": "Jey Erp", + "name": "Sales Invoice Certificate", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/jey_erp/jey_erp/doctype/sales_invoice_certificate/sales_invoice_certificate.py b/jey_erp/jey_erp/doctype/sales_invoice_certificate/sales_invoice_certificate.py new file mode 100644 index 0000000..7a16b8c --- /dev/null +++ b/jey_erp/jey_erp/doctype/sales_invoice_certificate/sales_invoice_certificate.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, Ali and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class SalesInvoiceCertificate(Document): + pass diff --git a/jey_erp/patches.txt b/jey_erp/patches.txt index e14350f..8e84eee 100644 --- a/jey_erp/patches.txt +++ b/jey_erp/patches.txt @@ -15,4 +15,5 @@ jey_erp.patches.remove_won_lost_checkboxes jey_erp.patches.drop_orphan_bank_doctypes jey_erp.patches.backfill_si_tax_article_row_keys jey_erp.patches.backfill_sales_invoice_tax_articles_history -jey_erp.patches.add_tax_articles_to_si_grid_views \ No newline at end of file +jey_erp.patches.add_tax_articles_to_si_grid_views +jey_erp.patches.add_certificates_to_si_grid_views \ No newline at end of file diff --git a/jey_erp/patches/add_certificates_to_si_grid_views.py b/jey_erp/patches/add_certificates_to_si_grid_views.py new file mode 100644 index 0000000..267654e --- /dev/null +++ b/jey_erp/patches/add_certificates_to_si_grid_views.py @@ -0,0 +1,53 @@ +import json + +import frappe + + +def execute(): + """Inject the new "Sertifikatın seriyə / nömrəsi" column into saved Sales Invoice + Item grid layouts. + + Per-user grid column layouts live in `__UserSettings` (GridView). A layout saved + before this feature does not list `custom_certificates_display`. Frappe renders the + saved list verbatim and ignores the new in_list_view field, so the certificate cell + never appears for those users until they add it manually via the grid gear. + + Add `custom_certificates_display` to every saved layout that doesn't already have it, + positioned right after the tax-articles cell. Purely additive and idempotent. + """ + rows = frappe.db.sql( + "select user, data from `__UserSettings` where doctype = 'Sales Invoice'", + as_dict=True, + ) + for r in rows: + try: + data = json.loads(r.data or "{}") + except (ValueError, TypeError): + continue + + grid_view = data.get("GridView") or {} + cols = grid_view.get("Sales Invoice Item") + if not cols: + continue + + names = [c.get("fieldname") for c in cols] + if "custom_certificates_display" in names: + continue + + new_col = {"fieldname": "custom_certificates_display", "columns": 2} + if "custom_tax_articles_display" in names: + cols.insert(names.index("custom_tax_articles_display") + 1, new_col) + elif "item_tax_template" in names: + cols.insert(names.index("item_tax_template") + 1, new_col) + else: + cols.append(new_col) + + grid_view["Sales Invoice Item"] = cols + data["GridView"] = grid_view + frappe.db.sql( + "update `__UserSettings` set data = %s where user = %s and doctype = 'Sales Invoice'", + (json.dumps(data), r.user), + ) + + frappe.db.commit() + frappe.cache().delete_value("_user_settings") diff --git a/jey_erp/public/js/sales_invoice_certificates.js b/jey_erp/public/js/sales_invoice_certificates.js new file mode 100644 index 0000000..5adfd07 --- /dev/null +++ b/jey_erp/public/js/sales_invoice_certificates.js @@ -0,0 +1,184 @@ +// Multiple presented certificates per Sales Invoice Item row. +// Click the read-only "Sertifikatın seriyə / nömrəsi" cell -> on a row that has no +// certificate yet, a yes/no question is asked first ("0% dərəcəsi ilə ƏDV-yə cəlb +// olunma haqqında sertifikatlar var?"). Answering "Hə" opens a dialog with a Table +// MultiSelect field (a Link control under the hood => native search + Advanced +// Search) pre-filled with the row's certificates as pills; answering "Yox" closes +// it without opening anything. Once the row already has certificates, the cell opens +// the picker directly. +// +// Source of truth = parent table `custom_item_certificates`, linked to each item row +// by `custom_si_row_key` — the SAME row key the tax-article multi-select uses. + +const CERT_DIALOG_CLASS = "certificates-dialog"; +const CERT_CHILD_DOCTYPE = "Sales Invoice Certificate"; +const CERT_DISPLAY_FIELD = "custom_certificates_display"; +const CERT_CONFIRM_MSG = "0% dərəcəsi ilə ƏDV-yə cəlb olunma haqqında sertifikatlar var?"; + +if (!document.getElementById("certmulti-style")) { + const style = document.createElement("style"); + style.id = "certmulti-style"; + const F = CERT_DISPLAY_FIELD; + const D = "." + CERT_DIALOG_CLASS; + style.textContent = [ + // grid cell: clickable, click passes through the read-only input + '.grid-row [data-fieldname="' + F + '"]{cursor:pointer;}', + '.grid-row [data-fieldname="' + F + '"] input,', + '.grid-row [data-fieldname="' + F + '"] textarea,', + '.grid-row [data-fieldname="' + F + '"] .like-disabled-input{pointer-events:none;}', + + // keep the dropdown (incl. native "Advanced Search") reachable, not clipped + D + " .modal-body," + D + " .frappe-control," + D + " .form-group," + + D + " .control-input-wrapper{overflow:visible!important;}", + D + " .awesomplete > ul{max-height:260px;overflow-y:auto;z-index:1080;}", + + // ONE seamless surface: keep only the single .modal-content gradient + D + " .modal-header," + D + " .modal-body," + D + " .modal-footer," + + D + " .form-layout," + D + " .form-page," + D + " .form-section," + + D + " .section-body," + D + " .form-column," + D + " .table-multiselect{" + + "background:transparent!important;background-image:none!important;" + + "box-shadow:none!important;border:none!important;}", + + // thin dividers under the title and above the footer + D + " .modal-header{border-bottom:1px solid rgba(0,0,0,.15)!important;}", + D + " .modal-footer{border-top:1px solid rgba(0,0,0,.12)!important;}", + + // trim the empty space above the field + D + " .modal-body{padding-top:14px!important;}", + + // the search input as its own bordered field, just below the pills + D + " .table-multiselect .awesomplete{display:block;width:100%;margin-top:8px;}", + D + " .table-multiselect .awesomplete input{display:block;width:100%;" + + "border:1px solid var(--border-color,#999)!important;" + + "border-radius:var(--border-radius,6px)!important;padding:4px 8px!important;min-height:28px;}", + ].join(""); + document.head.appendChild(style); +} + +frappe.ui.form.on("Sales Invoice", { + onload(frm) { + ensure_cert_setup(frm); + }, + refresh(frm) { + ensure_cert_setup(frm); + }, +}); + +function ensure_cert_setup(frm) { + if (frm.__certificates_bound) return; + frm.__certificates_bound = true; + + $(document) + .off("mousedown.certmulti") + .on("mousedown.certmulti", '[data-fieldname="' + CERT_DISPLAY_FIELD + '"]', function (ev) { + const grid_row = $(this).closest(".grid-row").data("grid_row"); + const cdn = grid_row && grid_row.doc && grid_row.doc.name; + if (cdn && frm.fields_dict.items && frm.fields_dict.items.grid) { + ev.stopPropagation(); + handle_certificate_cell_click(frm, cdn); + } + }); +} + +function handle_certificate_cell_click(frm, cdn) { + const row = locals["Sales Invoice Item"][cdn]; + if (!row) return; + + // Already has certificates on this row -> open the picker straight away. + const key = row.custom_si_row_key; + const has_certs = + key && + (frm.doc.custom_item_certificates || []).some( + (r) => r.parent_row === key && r.certificate + ); + + if (has_certs) { + open_certificate_dialog(frm, cdn); + return; + } + + // First time on this row -> ask whether such certificates exist at all. + frappe.confirm( + __(CERT_CONFIRM_MSG), + () => open_certificate_dialog(frm, cdn), // "Hə" / Yes + () => {} // "Yox" / No -> open nothing + ); +} + +function open_certificate_dialog(frm, cdn) { + const row = locals["Sales Invoice Item"][cdn]; + if (!row) return; + + if (!row.custom_si_row_key) { + row.custom_si_row_key = "r" + Math.random().toString(36).slice(2, 12); + } + const key = row.custom_si_row_key; + + // Table MultiSelect value = array of child-row objects keyed by the link field. + const current = (frm.doc.custom_item_certificates || []) + .filter((r) => r.parent_row === key && r.certificate) + .map((r) => ({ certificate: r.certificate })); + + const d = new frappe.ui.Dialog({ + title: __("Sertifikatın seriyə / nömrəsi") + " — " + (row.item_code || "#" + row.idx), + fields: [ + { + fieldname: "certificates", + fieldtype: "Table MultiSelect", + label: __("Select certificates"), + options: CERT_CHILD_DOCTYPE, + get_query() { + return frm.doc.company ? { filters: { company: frm.doc.company } } : {}; + }, + }, + ], + primary_action_label: __("Save"), + primary_action(values) { + const selected = Array.from( + new Set((values.certificates || []).map((r) => r.certificate).filter(Boolean)) + ); + frm.doc.custom_item_certificates = (frm.doc.custom_item_certificates || []).filter( + (r) => r.parent_row !== key + ); + selected.forEach((c) => { + const child = frm.add_child("custom_item_certificates"); + child.parent_row = key; + child.certificate = c; + }); + const display = selected.length + ? __("{0} selected", [selected.length]) + : __("Click to select"); + frappe.model.set_value(row.doctype, row.name, CERT_DISPLAY_FIELD, display); + frm.refresh_field("custom_item_certificates"); + frm.refresh_field("items"); + d.hide(); + }, + }); + + d.$wrapper.addClass(CERT_DIALOG_CLASS); + d.add_custom_action(__("Clear all"), () => d.set_value("certificates", [])); + + // Robust pill removal inside a Dialog (Table MultiSelect's own .btn-remove handler + // does not update the field value in a dialog). Intercept in the CAPTURE phase and + // rebuild the value as: current minus the clicked certificate. + const fld = d.fields_dict.certificates; + fld.$wrapper[0].addEventListener( + "click", + function (e) { + const btn = e.target.closest(".btn-remove"); + if (!btn) return; + e.stopPropagation(); + const pill = btn.closest(".tb-selected-value"); + const removed = pill ? decodeURIComponent(pill.getAttribute("data-value") || "") : ""; + const remaining = (fld.get_value() || []) + .map((r) => (typeof r === "string" ? r : r && r.certificate)) + .filter((n) => n && n !== removed) + .map((n) => ({ certificate: n })); + fld.set_value(remaining); + }, + true + ); + + d.set_value("certificates", current); + d.show(); +} diff --git a/jey_erp/public/js/sales_invoice_tax_articles.js b/jey_erp/public/js/sales_invoice_tax_articles.js index 865a343..d549dc2 100644 --- a/jey_erp/public/js/sales_invoice_tax_articles.js +++ b/jey_erp/public/js/sales_invoice_tax_articles.js @@ -127,6 +127,24 @@ function open_tax_article_dialog(frm, cdn) { const row = locals["Sales Invoice Item"][cdn]; if (!row) return; + // Tax articles are locked (read-only) for a line that already has a presented + // certificate — the certificate determines the 0% treatment for that line. + const cert_key = row.custom_si_row_key; + const has_cert = + cert_key && + (frm.doc.custom_item_certificates || []).some( + (r) => r.parent_row === cert_key && r.certificate + ); + if (has_cert) { + frappe.show_alert({ + message: __( + "Tax articles are read-only while a certificate is selected for this line. Remove the certificate to edit them." + ), + indicator: "orange", + }); + return; + } + const group = tax_article_group_for(row.item_tax_template); if (!group) { frappe.show_alert({ diff --git a/jey_erp/translation_markers.py b/jey_erp/translation_markers.py index 3d5051a..93ab6e5 100644 --- a/jey_erp/translation_markers.py +++ b/jey_erp/translation_markers.py @@ -41,6 +41,7 @@ _lt("Cash Registers") _lt("Chief Executive Officer") _lt("Citizenship Country") _lt("City (for print formats)") +_lt("Click to select certificates") _lt("Click to select tax articles") _lt("Code of the Cadastral Valuation District") _lt("Code of the Territorial Unit") @@ -96,6 +97,7 @@ _lt("Is Sub Account") _lt("Is Taxpayer in Cancellation Process") _lt("Is taxes document") _lt("Item") +_lt("Item Certificates") _lt("Item Tax Articles") _lt("Job Applicant") _lt("Kassa metodu — gəlir və xərclər yalnız ödəniş zamanı tanınır. ƏDV öhdəliyi yalnız ödəniş alındıqda yaranır.\nHesablama metodu — gəlir və xərclər faktura zamanı tanınır.") @@ -150,6 +152,7 @@ _lt("Row Key") _lt("SSN") _lt("Selected customer object from E-Taxes (auto-filled)") _lt("Seller") +_lt("Sertifikatın seriyə / nömrəsi") _lt("Settlements") _lt("Special Tax Regime") _lt("Sport Betting Operator")