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:
parent
4926d5c637
commit
575e0829b3
|
|
@ -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")
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue