feat(sales-invoice): multi presented-certificate per item row

Add a "Sertifikatın seriyə / nömrəsi" multi-select cell to Sales Invoice
Item, mirroring the existing tax-article cell:

- new child DocType "Sales Invoice Certificate" (parent_row + certificate
  -> E-Taxes Presented Certificate), parent table custom_item_certificates,
  per-row display field custom_certificates_display; reuses the existing
  custom_si_row_key to link rows to certificates.
- sales_invoice_certificates.js: clicking an empty cell first asks
  "0% dərəcəsi ilə ƏDV-yə cəlb olunma haqqında sertifikatlar var?" (yes ->
  open the Table MultiSelect picker, no -> open nothing); rows that already
  have certificates open the picker directly.
- server hook sync_item_certificates: stable row keys, drop orphans,
  refresh the display label (not bound to the tax template).
- patch add_certificates_to_si_grid_views: inject the new column into saved
  per-user grid layouts.
- lock the tax-article cell (read-only) for any line that has a certificate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-06-16 15:58:50 +00:00
parent 4926d5c637
commit 575e0829b3
11 changed files with 395 additions and 1 deletions

View File

@ -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")

View File

@ -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": [

View File

@ -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",

View File

@ -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": []
}

View File

@ -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

View File

@ -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
jey_erp.patches.add_tax_articles_to_si_grid_views
jey_erp.patches.add_certificates_to_si_grid_views

View File

@ -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")

View File

@ -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();
}

View File

@ -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({

View File

@ -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")