diff --git a/README.md b/README.md index a31fa00..486d160 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ [![Frappe](https://img.shields.io/badge/Frappe-v15-0089FF.svg)](https://frappeframework.com/) [![ERPNext](https://img.shields.io/badge/ERPNext-v15-2490EF.svg)](https://erpnext.com/) -![AmPower Visualize demo](assets/demo.gif) +![AmPower Visualize demo](assets/document_traceability_demo.gif) --- -AmPower Visualize is a powerful tool designed to enhance product traceability and visualize document relationships within your Frappe/ERPNext system. This application provides an interactive, zoomable, and draggable graph view of linked documents, allowing users to explore and understand complex document hierarchies with ease. +AmPower Visualize is a powerful tool designed to enhance document traceability and visualize document relationships within your Frappe/ERPNext system. This application provides an interactive, zoomable, and draggable graph view of linked documents, allowing users to explore and understand complex document hierarchies with ease. Empower your business with clear, interactive document traceability using AmPower Visualize! @@ -32,18 +32,16 @@ It can be used for the following use-cases: AmPower Visualize offers powerful visualization modes to cater to different traceability needs: #### Document Level Visualization -Creates a hierarchical tree view of sales orders and their connected documents -Shows the complete document flow with child tables containing item information -Perfect for understanding overall document relationships and tracking order processing +Creates a hierarchical radial graph of a root document (e.g. Sales Order) and every linked document connected to it — Delivery Notes, Sales Invoices, Purchase Orders, Purchase Receipts and more — grouped by Sales Order Item. Click any node to open a side panel showing its status, items, and quantities, with a direct link to open the document in Frappe. ![Document level visualization](assets/document-traceability.png) -#### Item Level Visualization -Generates detailed graphs showing parent-child relationships between items -Displays how items are linked across different documents -Ideal for tracking specific items through your business processes +#### Document Traceability Demo +Generates detailed graphs showing parent-child relationships between documents +Displays how documents are linked across different transactions +Ideal for tracking specific documents through your business processes -![Item level visualization](assets/product_traceability.png) +![Document traceability demo](assets/document_traceability_demo.gif) ## Batch Traceability @@ -105,7 +103,7 @@ If you encounter issues during installation, try running the following series of supervisorctl update bench restart ``` -3. **Navigate**: Access the "Product / Batch Traceability" page in your system. +3. **Navigate**: Access the "Document / Batch Traceability" page in your system. 4. **Select and Visualize**: - Select a DocType (e.g., Sales Order) from the dropdown menu. - Choose a specific document to begin visualization. @@ -121,22 +119,31 @@ If you encounter issues during installation, try running the following series of ### Key functions: 1. **`get_graph_data`**: - Fetches linked documents from the backend using `frappe.call`. -- Constructs a JSON structure with `nodes` and `links` for D3.js. +- Constructs a JSON structure with `nodes` and `edges` for the graph renderer. -2. **`visualize_graph`**: +2. **`render_graph` (Document Traceability)**: +- Builds the graph using a custom SVG radial layout: + - **Nodes**: Represent documents (e.g., Sales Orders, Sales Invoices, Delivery Notes). + - **Edges**: Define relationships between documents, labelled with quantities. +- Supports zoom, pan, drag, and a side panel for node details. +- Uses an adaptive radial fan layout for dynamic, collision-free positioning. + +3. **`visualize_graph` (Item & Batch Traceability)**: - Builds the graph using D3.js ([LICENSE](https://github.com/d3/d3/blob/main/LICENSE)): - - **Nodes**: Represent documents (e.g., Sales Orders, Sales Invoices). - - **Links**: Define relationships between documents. + - **Nodes**: Represent items or batches. + - **Links**: Define relationships between nodes. - Adds zoom, pan, and drag functionality for intuitive navigation. - Uses force-directed layout for dynamic positioning. ### Customizations: -AmPower Visualize uses **D3.js**, a powerful JavaScript library, to render the interactive document graph. The library consumes JSON data from the backend and creates a graphical representation of the basis of this input. +**Document Traceability** uses a custom SVG engine with an adaptive radial layout — no external graph library required. Node positions, fan angles, and radii are computed from the document hierarchy returned by the backend. + +**Item and Batch Traceability** use **D3.js**, a powerful JavaScript library, to render force-directed graphs. The library consumes JSON data from the backend and creates a graphical representation on that basis. - Node colors and sizes vary by document type. - A legend helps users identify node types and their significance. -- Links include labels indicating document status and quantities for respective items. +- Edges include labels indicating document status and quantities for respective items. ## Future Scope @@ -160,4 +167,4 @@ Encountered an issue? File a ticket on the GitHub repository. ## License -MIT +MIT \ No newline at end of file diff --git a/ampower_visualize/__init__.py b/ampower_visualize/__init__.py index 1aad10b..3de21c4 100644 --- a/ampower_visualize/__init__.py +++ b/ampower_visualize/__init__.py @@ -1 +1 @@ -__version__ = "v04062026.1" +__version__ = "v13072026.1" diff --git a/ampower_visualize/ampower_visualize/page/document_traceability/__init__.py b/ampower_visualize/ampower_visualize/page/document_traceability/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ampower_visualize/ampower_visualize/page/document_traceability/document_traceability.js b/ampower_visualize/ampower_visualize/page/document_traceability/document_traceability.js new file mode 100644 index 0000000..2d76b44 --- /dev/null +++ b/ampower_visualize/ampower_visualize/page/document_traceability/document_traceability.js @@ -0,0 +1,1000 @@ +// Copyright (c) 2026, Ambibuzz Technologies LLP and contributors +// For license information, please see license.txt + +// Doctype display config + +const DOCTYPE_CFG = { + "Sales Order": { abbrev: "SO", color: "#2490EF" }, + "Delivery Note": { abbrev: "DN", color: "#36B37E" }, + "Sales Invoice": { abbrev: "SI", color: "#FF8B00" }, + "Material Request": { abbrev: "MR", color: "#FF5630" }, + "Purchase Order": { abbrev: "PO", color: "#36B37E" }, + "Purchase Receipt": { abbrev: "PR", color: "#00B8D9" }, + "Purchase Invoice": { abbrev: "PI", color: "#6554C0" }, + "Production Plan": { abbrev: "PP", color: "#8993A4" }, + "Work Order": { abbrev: "WO", color: "#42526E" }, + "Job Card": { abbrev: "JC", color: "#B8ACF6" }, + "Stock Entry": { abbrev: "SE", color: "#F4A261" }, +}; + +/** Deep blue for the root node — must be distinct from every doctype colour. */ +const ROOT_NODE_COLOR = "#1565C0"; + +/** Hot pink for item-row nodes (SOI, MRI, POI …). */ +const ITEM_ROLE_COLOR = "#e91e8c"; + +/** Legend entries rendered in the top-left overlay. */ +const LEGEND_CFG = [ + { label: "Root Document", color: ROOT_NODE_COLOR }, + { label: "Sales Order", color: DOCTYPE_CFG["Sales Order"].color }, + { label: "Sales Order Item", color: ITEM_ROLE_COLOR }, + { label: "Sales Invoice", color: DOCTYPE_CFG["Sales Invoice"].color }, + { label: "Delivery Note", color: DOCTYPE_CFG["Delivery Note"].color }, + { label: "Material Request", color: DOCTYPE_CFG["Material Request"].color }, + { label: "Purchase Order", color: DOCTYPE_CFG["Purchase Order"].color }, + { label: "Purchase Invoice", color: DOCTYPE_CFG["Purchase Invoice"].color }, + { label: "Purchase Receipt", color: DOCTYPE_CFG["Purchase Receipt"].color }, +]; + +/** Return the colour for a doctype, falling back to grey. */ +function dt_color(doctype) { + return (DOCTYPE_CFG[doctype] || {}).color || "#8993A4"; +} + +/** Return the two/three-letter abbreviation for a doctype. */ +function dt_abbrev(doctype) { + return (DOCTYPE_CFG[doctype] || {}).abbrev || doctype.substring(0, 2).toUpperCase(); +} + +// Frappe page lifecycle + +frappe.pages["document-traceability"].on_page_load = function (wrapper) { + const page = frappe.ui.make_app_page({ + parent: wrapper, + title: "Document Traceability", + single_column: true, + }); + wrapper._dt_view = new DocTraceView(page); +}; + +frappe.pages["document-traceability"].on_page_show = function (wrapper) { + const opts = frappe.route_options || {}; + const view = wrapper._dt_view; + if (!view) return; + if (opts.doctype && opts.docname) { + frappe.route_options = {}; + view.set_doc(opts.doctype, opts.docname); + } +}; + +frappe.pages["document-traceability"].on_page_leave = function () { + $(window).off("mousemove.dt mouseup.dt"); +}; + +// Main view class + +class DocTraceView { + constructor(page) { + this.page = page; + this.doctype = null; + this.docname = null; + this._build_toolbar(); + this._build_canvas(); + } + + // Toolbar + + _build_toolbar() { + this.page.add_button(__("Re-trace"), () => this.run_trace(), + { icon: "es-line-reload" }); + } + + // Canvas + interaction setup + + _build_canvas() { + this.$wrap = $(` +
+ + +
+ + + +
+
+ `).appendTo($(this.page.body)); + + this.$svg = this.$wrap.find("#dt-svg"); + this.$panel = this.$wrap.find("#dt-panel"); + + // Zoom button helpers — zoom centred on canvas midpoint + const _zoom_btn = (factor) => { + const cw = this.$wrap[0].offsetWidth; + const ch = this.$wrap[0].offsetHeight; + const cx = cw / 2, cy = ch / 2; + this._vp.x = cx - factor * (cx - this._vp.x); + this._vp.y = cy - factor * (cy - this._vp.y); + this._vp.scale *= factor; + this._apply_vp(); + }; + this.$wrap.find("#dt-zoom-in" ).on("click", (e) => { e.stopPropagation(); _zoom_btn(1.25); }); + this.$wrap.find("#dt-zoom-out").on("click", (e) => { e.stopPropagation(); _zoom_btn(1 / 1.25); }); + this.$wrap.find("#dt-zoom-fit").on("click", (e) => { + e.stopPropagation(); + if (this._fit_vp) { this._vp = { ...this._fit_vp }; this._apply_vp(); } + }); + + // Highlight zoom buttons on hover + this.$wrap.find("#dt-zoom-ctrl button").on("mouseenter", function() { + $(this).css("background", "#f1f5f9"); + }).on("mouseleave", function() { + $(this).css("background", "transparent"); + }); + + /** + * Viewport state — the SVG root group is transformed as: + * translate(vp.x, vp.y) scale(vp.scale) + * so a node at SVG (nx, ny) appears at screen (vp.x + nx*scale, vp.y + ny*scale). + */ + this._vp = { x: 0, y: 0, scale: 1 }; + this._drag = null; + this._node_drag = null; + + // Zoom centred on cursor position + this.$wrap.on("wheel", (e) => { + e.preventDefault(); + const delta = e.originalEvent.deltaY < 0 ? 1.1 : 0.9; + const rect = this.$wrap[0].getBoundingClientRect(); + const mx = e.originalEvent.clientX - rect.left; + const my = e.originalEvent.clientY - rect.top; + this._vp.x = mx - delta * (mx - this._vp.x); + this._vp.y = my - delta * (my - this._vp.y); + this._vp.scale *= delta; + this._apply_vp(); + }).on("mousedown", (e) => { + // Start canvas pan only when the click lands on the background, not a node + if ($(e.target).closest("[data-nid]").length) return; + this.$wrap.css("cursor", "grabbing"); + this._drag = { sx: e.clientX - this._vp.x, sy: e.clientY - this._vp.y }; + }); + + $(window).on("mousemove.dt", (e) => { + if (this._node_drag) { + // Node drag — move node group + label group + update connected edge endpoints + const nd = this._node_drag; + const scale = this._vp.scale || 1; + const dx = (e.clientX - nd.ox) / scale; + const dy = (e.clientY - nd.oy) / scale; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) nd.moved = true; + const nx = nd.sx + dx; + const ny = nd.sy + dy; + nd.g_el.setAttribute("transform", `translate(${nx},${ny})`); + nd.lbl_g.setAttribute("transform", `translate(${nx},${ny})`); + nd.edges.forEach(({ line, ep }) => { + if (ep === 1) { line.setAttribute("x1", nx); line.setAttribute("y1", ny); } + else { line.setAttribute("x2", nx); line.setAttribute("y2", ny); } + }); + // Reposition ALL qty labels — midpoint of live line + perpendicular offset + this.$svg[0].querySelectorAll(".dt-qty-label").forEach(t => { + const l = t._line_ref; + if (!l) return; + const ax1 = parseFloat(l.getAttribute("x1")), ay1 = parseFloat(l.getAttribute("y1")); + const ax2 = parseFloat(l.getAttribute("x2")), ay2 = parseFloat(l.getAttribute("y2")); + const mx = (ax1 + ax2) / 2, my = (ay1 + ay2) / 2; + const llen = Math.hypot(ax2 - ax1, ay2 - ay1) || 1; + let px = -(ay2 - ay1) / llen, py = (ax2 - ax1) / llen; + if (py > 0) { px = -px; py = -py; } + const off = t._loff || 0; + t.setAttribute("x", String(mx + px * off)); + t.setAttribute("y", String(my + py * off)); + }); + } else if (this._drag) { + // Canvas pan + this._vp.x = e.clientX - this._drag.sx; + this._vp.y = e.clientY - this._drag.sy; + this._apply_vp(); + } + }).on("mouseup.dt", () => { + if (this._node_drag) { + const nd = this._node_drag; + // A mousedown + mouseup with no significant movement is treated as a click + if (!nd.moved) this.show_panel(nd.node); + this._node_drag = null; + } + this._drag = null; + this.$wrap.css("cursor", "grab"); + }); + } + + /** Apply the current viewport state to the SVG root group. */ + _apply_vp() { + const { x, y, scale } = this._vp; + this.$svg.find("#dt-root").attr("transform", `translate(${x},${y}) scale(${scale})`); + } + + /** + * Post-render collision resolver. + * + * Iteratively nudges item nodes outward along the root→node direction + * whenever a qty-on-arrow label overlaps any node ID / name label. + * Uses getBoundingClientRect() so all viewport transforms are accounted for. + * + * @param {Map} node_dom nid → { g_el, lbl_g, nx, ny } + * @param {Map} node_edges nid → [{ line, ep }] + */ + _resolve_overlaps(node_dom, node_edges) { + const svg = this.$svg[0]; + const qty_els = [...svg.querySelectorAll(".dt-qty-label")]; + const name_els = [...svg.querySelectorAll(".dt-node-lbl")]; + if (!qty_els.length || !name_els.length) return; + + const PAD = 5; // extra breathing room around qty label (px, screen space) + const STEP = 22; // px to nudge the node outward per collision (SVG space) + const MAX_ITER = 8; + + const hit = (a, b) => + a.left - PAD < b.right && + b.left - PAD < a.right && + a.top - PAD < b.bottom && + b.top - PAD < a.bottom; + + /** Reposition every qty label to the midpoint of its live line + perpendicular offset. */ + const _reposition_qty = () => { + for (const qt of qty_els) { + const l = qt._line_ref; + if (!l) continue; + const ax1 = parseFloat(l.getAttribute("x1")), ay1 = parseFloat(l.getAttribute("y1")); + const ax2 = parseFloat(l.getAttribute("x2")), ay2 = parseFloat(l.getAttribute("y2")); + const mx = (ax1 + ax2) / 2, my = (ay1 + ay2) / 2; + const ll = Math.hypot(ax2 - ax1, ay2 - ay1) || 1; + let px = -(ay2 - ay1) / ll, py = (ax2 - ax1) / ll; + if (py > 0) { px = -px; py = -py; } + qt.setAttribute("x", String(mx + px * (qt._loff || 10))); + qt.setAttribute("y", String(my + py * (qt._loff || 10))); + } + }; + + for (let iter = 0; iter < MAX_ITER; iter++) { + let any_moved = false; + + for (const qt of qty_els) { + const nid = qt._node_to_id; + const nd = nid ? node_dom.get(nid) : null; + if (!nd) continue; + + // Check this qty label against every node ID / name label + const qr = qt.getBoundingClientRect(); + let clash = false; + for (const ne of name_els) { + if (hit(qr, ne.getBoundingClientRect())) { clash = true; break; } + } + if (!clash) continue; + + // Nudge the item node outward from the SVG origin (= root at 0,0) + const rl = Math.hypot(nd.nx, nd.ny) || 1; + nd.nx += (nd.nx / rl) * STEP; + nd.ny += (nd.ny / rl) * STEP; + nd.g_el.setAttribute("transform", `translate(${nd.nx},${nd.ny})`); + nd.lbl_g.setAttribute("transform", `translate(${nd.nx},${nd.ny})`); + + // Update all edges connected to the nudged node + (node_edges.get(nid) || []).forEach(({ line, ep }) => { + if (ep === 1) { + line.setAttribute("x1", String(nd.nx)); + line.setAttribute("y1", String(nd.ny)); + } else { + line.setAttribute("x2", String(nd.nx)); + line.setAttribute("y2", String(nd.ny)); + } + }); + + any_moved = true; + } + + if (!any_moved) break; + + // Reposition qty labels to reflect updated line endpoints + _reposition_qty(); + } + } + + // Public API + + /** Switch to a new document and immediately run the trace. */ + set_doc(doctype, docname) { + this.doctype = doctype; + this.docname = docname; + this.page.set_title(`${doctype}: ${docname}`); + this.run_trace(); + } + + /** Call the Python backend and render the result. */ + run_trace() { + if (!this.doctype || !this.docname) return; + this.$svg.html( + `Tracing…` + ); + this.$panel.hide(); + + frappe.call({ + method: "ampower_visualize.ampower_visualize.page.document_traceability.document_traceability.get_document_trace", + args: { doctype: this.doctype, docname: this.docname, include_items: 1 }, + callback: (r) => { + if (r.exc) { + this.$svg.html( + `${r.exc}` + ); + return; + } + const data = r.message || { nodes: [], edges: [] }; + if (!data.nodes.length) { + this.$svg.html( + `No linked documents found.` + ); + return; + } + this._render_radial(data); + }, + }); + } + + // Radial layout + + _render_radial(data) { + const positions = this._compute_radial(data); + this._build_radial_svg(data, positions); + } + + /** + * Compute SVG (x, y) positions for every node. + * + * Supports two layouts depending on whether the backend emitted group nodes: + * + * THREE-TIER (root is the SO/MR/PO itself) + * root → item rows → leaf docs + * + * FOUR-TIER (root is a DN/SI/PR that links back to one or more SOs) + * root → SO/MR/PO group → item rows → leaf docs + * + * In the four-tier layout each group node is placed uniformly around the + * root. Its item rows fan 270° around it (away from the root), and each + * item's leaves spread 60° outward from the item — decoupling leaf spacing + * from the item/group angular allocation so leaves never become too cramped. + * + * @param {object} data { nodes, edges } from the Python backend + * @returns {Map} + */ + _compute_radial(data) { + const meta = {}; + for (const n of data.nodes) meta[n.id] = n; + + // Build adjacency maps for all tier transitions + const root_to_groups = new Map(); + const root_to_items = new Map(); + const group_to_items = new Map(); + const item_to_leaves = new Map(); + + for (const e of data.edges) { + const f = meta[e.from], t = meta[e.to]; + if (!f || !t) continue; + if (f.role === "root" && t.role === "group") { + if (!root_to_groups.has(e.from)) root_to_groups.set(e.from, []); + root_to_groups.get(e.from).push(e.to); + } + if (f.role === "root" && t.role === "item") { + if (!root_to_items.has(e.from)) root_to_items.set(e.from, []); + root_to_items.get(e.from).push(e.to); + } + if (f.role === "group" && t.role === "item") { + if (!group_to_items.has(e.from)) group_to_items.set(e.from, []); + group_to_items.get(e.from).push(e.to); + } + if (f.role === "item" && t.role === "leaf") { + if (!item_to_leaves.has(e.from)) item_to_leaves.set(e.from, []); + item_to_leaves.get(e.from).push(e.to); + } + } + + const positions = new Map(); + + // Single centre root — the document the user navigated from + const main_root = + data.nodes.find(n => n.role === "root" && + n.doctype === this.doctype && n.name === this.docname) || + data.nodes.find(n => n.role === "root" && + (root_to_groups.has(n.id) || root_to_items.has(n.id))) || + data.nodes.find(n => n.role === "root"); + + if (!main_root) return positions; + positions.set(main_root.id, { x: 0, y: 0 }); + + const has_groups = root_to_groups.size > 0; + + if (has_groups) { + /** + * FOUR-TIER layout: root → group → item → leaf + * + * Groups are placed uniformly around the root at GROUP_R. + * Items fan 270° around their group node (centred on the outward + * direction so the inward 90° stays clear for the root→group edge). + * Leaves spread a fixed 60° arc around each item's outward direction, + * keeping leaf spacing independent of how many items the group has. + */ + const all_groups = [...new Set([...root_to_groups.values()].flat())]; + const n_groups = all_groups.length || 1; + const max_items = Math.max(1, ...all_groups.map( + gid => (group_to_items.get(gid) || []).length)); + const max_leaves = Math.max(1, ...[...item_to_leaves.values()].map( + a => a.length)); + + // Adaptive radii for 4-tier + const LEAF_R = Math.min(220, Math.max(130, 65 * max_leaves)); + const ITEM_R = Math.min(300, Math.max(170, + Math.ceil(90 * max_items / (Math.PI * 1.5)))); + const GROUP_R = Math.max(260, ITEM_R + LEAF_R + 100); + + const ITEM_SPREAD = Math.PI * 1.5; + const LEAF_SPREAD = Math.PI / 3; + + all_groups.forEach((gid, gi) => { + const theta_g = (2 * Math.PI * gi / n_groups) - Math.PI / 2; + const gx = GROUP_R * Math.cos(theta_g); + const gy = GROUP_R * Math.sin(theta_g); + positions.set(gid, { x: gx, y: gy }); + + const items = group_to_items.get(gid) || []; + const n_items = items.length; + + items.forEach((iid, ii) => { + const theta_i = theta_g + (n_items > 1 + ? ITEM_SPREAD * (ii / (n_items - 1) - 0.5) + : 0); + const ix = gx + ITEM_R * Math.cos(theta_i); + const iy = gy + ITEM_R * Math.sin(theta_i); + positions.set(iid, { x: ix, y: iy }); + + const leaves = item_to_leaves.get(iid) || []; + const nl = leaves.length; + if (!nl) return; + + leaves.forEach((lid, li) => { + const theta_l = theta_i + (nl > 1 + ? LEAF_SPREAD * (li / (nl - 1) - 0.5) + : 0); + positions.set(lid, { + x: ix + LEAF_R * Math.cos(theta_l), + y: iy + LEAF_R * Math.sin(theta_l), + }); + }); + }); + }); + + } else { + /** + * THREE-TIER layout: root → item → leaf + * + * Uses the same fan algorithm as the four-tier layout: + * items fan 270° around the root (treating root as the implicit group), + * leaves spread a fixed 60° arc around each item's outward direction. + * This matches the visual quality of the DN/SI/PR four-tier view. + */ + const all_items = [...new Set([...root_to_items.values()].flat())]; + const n_items = all_items.length || 1; + const max_leaves = Math.max(1, ...[...item_to_leaves.values()].map( + a => a.length)); + + const LEAF_R = Math.min(220, Math.max(130, 65 * max_leaves)); + const ITEM_R = Math.min(300, Math.max(170, + Math.ceil(90 * n_items / (Math.PI * 1.5)))); + const ITEM_SPREAD = Math.PI * 1.5; + const LEAF_SPREAD = Math.PI / 3; + + all_items.forEach((iid, ii) => { + const theta_i = -Math.PI / 2 + (n_items > 1 + ? ITEM_SPREAD * (ii / (n_items - 1) - 0.5) + : 0); + const ix = ITEM_R * Math.cos(theta_i); + const iy = ITEM_R * Math.sin(theta_i); + positions.set(iid, { x: ix, y: iy }); + + const leaves = item_to_leaves.get(iid) || []; + const nl = leaves.length; + if (!nl) return; + + leaves.forEach((lid, li) => { + const theta_l = theta_i + (nl > 1 + ? LEAF_SPREAD * (li / (nl - 1) - 0.5) + : 0); + positions.set(lid, { + x: ix + LEAF_R * Math.cos(theta_l), + y: iy + LEAF_R * Math.sin(theta_l), + }); + }); + }); + } + + // Orphan handler — nodes not placed by the layout above (rare) + // Root-role nodes other than main_root are skipped intentionally. + let ox = 0; + for (const nd of data.nodes) { + if (positions.has(nd.id)) continue; + if (nd.role === "root") continue; + positions.set(nd.id, { x: ox, y: 800 }); + ox += 160; + } + + return positions; + } + + /** + * Build the SVG from computed positions. + * + * Three rendering layers (appended in order so z-order is correct): + * 1. edges_g — connector lines, pointer-events:none + * 2. labels_g — text labels, pointer-events:none + * 3. nodes_g — coloured squares, receives mouse events + * + * Separating labels into their own non-interactive layer prevents text + * from swallowing mouse events that should reach the canvas behind it. + * + * @param {object} data { nodes, edges } + * @param {Map} positions output of _compute_radial + */ + _build_radial_svg(data, positions) { + // Visual dimensions per role + const NODE_SZ = { root: 68, group: 52, item: 60, leaf: 40 }; + const TEXT_HEIGHT = { root: 38, group: 38, item: 52, leaf: 32 }; + const TEXT_HPAD = { root: 72, group: 64, item: 66, leaf: 52 }; + + // Compute the bounding box of all placed nodes (including labels below) + let min_x = Infinity, min_y = Infinity, max_x = -Infinity, max_y = -Infinity; + for (const n of data.nodes) { + const p = positions.get(n.id); + if (!p) continue; + const role = n.role || "leaf"; + const half = (NODE_SZ[role] || 40) / 2; + min_x = Math.min(min_x, p.x - TEXT_HPAD[role]); + min_y = Math.min(min_y, p.y - half - 12); + max_x = Math.max(max_x, p.x + TEXT_HPAD[role]); + max_y = Math.max(max_y, p.y + half + TEXT_HEIGHT[role] + 12); + } + + const SVG_NS = "http://www.w3.org/2000/svg"; + const svg = this.$svg[0]; + svg.innerHTML = ""; + + // Arrowhead marker — reused by every connector line via marker-end + const defs = document.createElementNS(SVG_NS, "defs"); + const marker = document.createElementNS(SVG_NS, "marker"); + marker.setAttribute("id", "dt-arrow"); + marker.setAttribute("markerWidth", "6"); + marker.setAttribute("markerHeight", "6"); + marker.setAttribute("refX", "5"); + marker.setAttribute("refY", "3"); + marker.setAttribute("orient", "auto"); + const arrow_path = document.createElementNS(SVG_NS, "path"); + arrow_path.setAttribute("d", "M0,0 L0,6 L6,3 z"); + arrow_path.setAttribute("fill", "#c8cdd6"); + marker.appendChild(arrow_path); + defs.appendChild(marker); + svg.appendChild(defs); + + // Single root group — _apply_vp sets its transform for pan/zoom + const root_g = document.createElementNS(SVG_NS, "g"); + root_g.setAttribute("id", "dt-root"); + svg.appendChild(root_g); + + this._build_legend(); + + /** Shorthand: create an SVG element with given attributes. */ + const mk = (tag, attrs = {}) => { + const el = document.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + return el; + }; + + // Three layers — order matters for z-index and pointer-events + const edges_g = mk("g", { "pointer-events": "none" }); + const labels_g = mk("g", { "pointer-events": "none" }); + const nodes_g = mk("g"); + root_g.appendChild(edges_g); + root_g.appendChild(labels_g); + root_g.appendChild(nodes_g); + + /** + * Identify the placed centre root nid. + * + * Python emits edges like SO-001 → SOI-001 (root → item). When the + * user visualises a DN, SO-001 is NOT placed in positions (only the DN + * is the centre root). Any edge whose "from" isn't placed is registered + * under the visible centre root so that dragging the centre node also + * moves the unplaced-root → item lines correctly. + */ + const _mr = data.nodes.find(n => n.role === "root" && + n.doctype === this.doctype && n.name === this.docname) || + data.nodes.find(n => n.role === "root"); + const main_root_nid = _mr ? _mr.id : null; + const main_root_pos = main_root_nid + ? (positions.get(main_root_nid) || { x: 0, y: 0 }) + : { x: 0, y: 0 }; + + /** + * Edge endpoint tracking. + * node_edges: nid → [{ line, ep }] + * ep = 1 → this node owns x1/y1 of the line + * ep = 2 → this node owns x2/y2 of the line + * During node drag, every registered line has its endpoint updated. + */ + const node_edges = new Map(); + const node_dom = new Map(); + + const _track = (nid, line, ep) => { + if (!nid) return; + if (!node_edges.has(nid)) node_edges.set(nid, []); + node_edges.get(nid).push({ line, ep }); + }; + + // Index nodes by id for role lookups during edge drawing + const node_meta = {}; + for (const n of data.nodes) node_meta[n.id] = n; + + /** + * Draw connector lines. + * Lines are trimmed to 62 % of the full node-to-node distance so + * arrows appear visually shorter while node positions (which govern + * the spacing between siblings) remain unchanged. + */ + for (const e of data.edges) { + const from_placed = positions.has(e.from); + const fp = from_placed ? positions.get(e.from) : main_root_pos; + const tp = positions.get(e.to); + if (!fp || !tp) continue; + + // Stop the line at the target node's border so there is no gap. + // half-size of the target square + 2 px breathing room. + let x2 = tp.x, y2 = tp.y; + const dx = tp.x - fp.x, dy = tp.y - fp.y; + const len = Math.hypot(dx, dy); + const to_meta = node_meta[e.to]; + const to_half = to_meta ? ((NODE_SZ[to_meta.role] || 40) / 2 + 2) : 22; + if (len > to_half + 10) { + x2 = fp.x + (dx / len) * (len - to_half); + y2 = fp.y + (dy / len) * (len - to_half); + } + + const line = mk("line", { + x1: fp.x, y1: fp.y, x2, y2, + stroke: "#c8cdd6", "stroke-width": "1.4", + "marker-end": "url(#dt-arrow)", + ...(e.dashed ? { "stroke-dasharray": "5,4" } : {}), + }); + edges_g.appendChild(line); + + // Quantity label on root/group → item edges — floated above the arrow line + const _to_nd = node_meta[e.to]; + if (_to_nd && _to_nd.role === "item" && _to_nd.qty != null) { + const mid_x = (fp.x + x2) / 2; + const mid_y = (fp.y + y2) / 2; + // Perpendicular unit vector — flip so it always points upward (negative SVG y) + const _ldx = x2 - fp.x, _ldy = y2 - fp.y; + const _llen = Math.hypot(_ldx, _ldy) || 1; + let _px = -_ldy / _llen, _py = _ldx / _llen; + if (_py > 0) { _px = -_px; _py = -_py; } + const LOFF = 10; + const qty_t = mk("text", { + x: mid_x + _px * LOFF, y: mid_y + _py * LOFF, + "class": "dt-qty-label", + "text-anchor": "middle", "dominant-baseline": "middle", + fill: "#374151", "font-size": "8", "font-weight": "700", + "font-family": "Inter,system-ui,sans-serif", + stroke: "white", "stroke-width": "2.5", "paint-order": "stroke", + }); + qty_t.textContent = String(_to_nd.qty); + // Store line ref + offset + target node id for drag + collision resolver + qty_t._line_ref = line; + qty_t._loff = LOFF; + qty_t._node_to_id = e.to; + labels_g.appendChild(qty_t); + } + + // Unplaced "from" nodes (e.g. SO when visualising a DN) are + // registered under the main root so their lines follow when the + // centre node is dragged. + _track(from_placed ? e.from : main_root_nid, line, 1); + if (positions.has(e.to)) _track(e.to, line, 2); + } + + /** + * Text helper — appends an SVG to parent. + * Coordinates are relative to the parent group's origin. + */ + const lbl = (parent, x, y, txt, opts = {}) => { + const el = mk("text", { + x, y, + "text-anchor": "middle", + "dominant-baseline": opts.baseline || "auto", + fill: opts.fill || "#1a1a1a", + "font-size": opts.size || "10", + "font-weight": opts.weight || "400", + "font-family": "Inter,system-ui,sans-serif", + ...(opts.opacity ? { opacity: opts.opacity } : {}), + ...(opts.cls ? { "class": opts.cls } : {}), + }); + el.textContent = txt; + parent.appendChild(el); + }; + + // Render each node + for (const n of data.nodes) { + const pos = positions.get(n.id); + if (!pos) continue; + + const role = n.role || "leaf"; + const sz = NODE_SZ[role] || 40; + const half = sz / 2; + const fill = role === "root" ? ROOT_NODE_COLOR + : role === "item" ? ITEM_ROLE_COLOR + : dt_color(n.doctype); + const is_origin = (n.doctype === this.doctype && n.name === this.docname); + + /** + * Node group — positioned with a transform so dragging only needs + * to update the transform, not every child element. + * All child geometry is relative to (0, 0) = square centre. + */ + const g_el = mk("g", { + "data-nid": n.id, + "transform": `translate(${pos.x},${pos.y})`, + }); + g_el.style.cursor = "grab"; + + // Subtle drop-shadow offset by (2, 2) + g_el.appendChild(mk("rect", { + x: -half + 2, y: -half + 2, width: sz, height: sz, + rx: role === "leaf" ? "3" : "5", + fill: "rgba(0,0,0,0.14)", + })); + + // Highlight ring around the document that was opened + if (is_origin) { + g_el.appendChild(mk("rect", { + x: -half - 4, y: -half - 4, width: sz + 8, height: sz + 8, + rx: "8", fill: "none", stroke: fill, "stroke-width": "3", + })); + } + + // Coloured square + const sq = mk("rect", { + x: -half, y: -half, width: sz, height: sz, + rx: role === "leaf" ? "3" : "5", fill, + }); + g_el.appendChild(sq); + + // Doctype abbreviation centred inside the square + // Item-role nodes carry their own abbrev (SOI, MRI, POI …) from the backend + lbl(g_el, 0, 1, n.abbrev || dt_abbrev(n.doctype), { + fill: "rgba(255,255,255,0.9)", + size: role === "leaf" ? "9" : "11", + weight: "700", baseline: "middle", + }); + + // Dim the square on hover to signal interactivity + g_el.addEventListener("mouseenter", () => { + sq.style.filter = "brightness(0.85)"; + g_el.style.cursor = "grab"; + }); + g_el.addEventListener("mouseleave", () => { sq.style.filter = ""; }); + + /** + * Mousedown starts a node drag. + * We read the CURRENT transform (not initial pos) so repeated drags + * accumulate correctly. The click/drag distinction is resolved on + * mouseup via the `moved` flag. + */ + g_el.addEventListener("mousedown", (ev) => { + ev.stopPropagation(); + this.$wrap.css("cursor", "grabbing"); + const t = g_el.getAttribute("transform").match(/translate\(([^,]+),([^)]+)\)/); + this._node_drag = { + node: n, + g_el, + lbl_g: g_el.__lbl_g, + edges: node_edges.get(n.id) || [], + ox: ev.clientX, + oy: ev.clientY, + sx: t ? parseFloat(t[1]) : pos.x, + sy: t ? parseFloat(t[2]) : pos.y, + moved: false, + }; + }); + + nodes_g.appendChild(g_el); + + /** + * Label group — same transform as the node group but lives in + * labels_g (pointer-events:none) so mouse events pass through + * to the canvas background beneath it. + * g_el.__lbl_g links the two groups for synchronised drag. + */ + const lbl_g = mk("g", { transform: `translate(${pos.x},${pos.y})` }); + g_el.__lbl_g = lbl_g; + + const below = half + 8; + + if (role === "root" || role === "group") { + lbl(lbl_g, 0, below + 11, n.name, { fill: "#111827", size: "10", weight: "700", cls: "dt-node-lbl" }); + if (n.status) + lbl(lbl_g, 0, below + 25, n.status, { fill: "#6b7280", size: "8.5" }); + + } else if (role === "item") { + lbl(lbl_g, 0, below + 11, n.item_name || n.item_code || "", + { fill: "#111827", size: "10", weight: "700", cls: "dt-node-lbl" }); + if (n.item_code && n.item_name && n.item_code !== n.item_name) + lbl(lbl_g, 0, below + 24, n.item_code, { fill: "#374151", size: "8.5", cls: "dt-node-lbl" }); + + } else { + lbl(lbl_g, 0, below + 11, n.name, { fill: "#111827", size: "9.5", weight: "600", cls: "dt-node-lbl" }); + if (n.status) + lbl(lbl_g, 0, below + 24, n.status, { fill: "#6b7280", size: "8" }); + } + + labels_g.appendChild(lbl_g); + // Register node DOM refs so _resolve_overlaps() can nudge positions + node_dom.set(n.id, { g_el, lbl_g, nx: pos.x, ny: pos.y }); + } + + /** + * Fit the graph to the viewport. + * Scale is chosen so the entire bounding box fits with `pad` px of margin. + * Translation centres the bounding box inside the canvas. + */ + const pad = 140; + const total_w = max_x - min_x + pad * 2; + const total_h = max_y - min_y + pad * 2; + const cw = this.$wrap.width(); + const ch = this.$wrap.height(); + const scale = Math.min(1, Math.min(cw / total_w, ch / total_h)); + + this._vp = { + x: (cw - total_w * scale) / 2 + (pad - min_x) * scale, + y: (ch - total_h * scale) / 2 + (pad - min_y) * scale, + scale, + }; + this._fit_vp = { ...this._vp }; + this._apply_vp(); + + // Collision resolver — runs after the browser has laid out SVG text + // so getBoundingClientRect() returns accurate values. + requestAnimationFrame(() => this._resolve_overlaps(node_dom, node_edges)); + + // Clicking the canvas background closes the side panel + this.$wrap.off("click.dt-panel").on("click.dt-panel", (e) => { + if (!$(e.target).closest("[data-nid]").length) this.$panel.hide(); + }); + } + + // Legend overlay + + _build_legend() { + this.$wrap.find(".dt-legend").remove(); + + const $legend = $(` +
+ `).appendTo(this.$wrap); + + for (const { label, color } of LEGEND_CFG) { + $legend.append(` + + + ${frappe.utils.escape_html(label)} + + `); + } + } + + // Side panel + + /** + * Show the detail panel for a clicked node. + * For document nodes, also fetches and displays the item list. + * + * @param {object} node node data object from the backend + */ + show_panel(node) { + const $p = this.$panel; + const color = node.role === "item" ? ITEM_ROLE_COLOR : dt_color(node.doctype); + const abbrev = node.role === "item" + ? (node.abbrev || "ITEM") + : dt_abbrev(node.doctype); + const title = node.role === "item" + ? frappe.utils.escape_html(node.item_name || node.item_code) + : frappe.utils.escape_html(node.name); + + $p.html(` +
+ ${abbrev} + ${title} + +
+ ${node.role === "item" ? ` +
+
Code: ${frappe.utils.escape_html(node.item_code || "")}
+
Qty: ${node.qty != null ? node.qty : "—"}
+
In: ${frappe.utils.escape_html(node.docname)}
+
+ ` : ` +
+ + ${frappe.utils.escape_html(node.status || "")} + +
+ `} + + Open ${frappe.utils.escape_html(node.doctype || "")} ↗ + + ${node.role !== "item" ? ` +
+
Items
+
Loading…
+ ` : ""} + `); + $p.find("#dt-panel-close").on("click", () => $p.hide()); + $p.show(); + + // Fetch item rows for document nodes (not needed for item-role nodes) + if (node.role !== "item") { + frappe.call({ + method: "ampower_visualize.ampower_visualize.page.document_traceability.document_traceability.get_document_items", + args: { doctype: node.doctype, docname: node.name }, + callback: (r) => { + const items = (r && r.message) || []; + const $tgt = $p.find("#dt-panel-items"); + if (!items.length) { + $tgt.html(`No items`); + return; + } + const rows = items.map(it => { + const qty = it.qty != null ? it.qty + : it.planned_qty != null ? it.planned_qty + : it.transferred_qty != null ? it.transferred_qty + : "—"; + return ` + + ${frappe.utils.escape_html(it.item_code || "")} + + + ${qty} + + `; + }).join(""); + $tgt.html(` + + + + + + ${rows} +
ItemQty
+ `); + }, + }); + } + } +} \ No newline at end of file diff --git a/ampower_visualize/ampower_visualize/page/document_traceability/document_traceability.json b/ampower_visualize/ampower_visualize/page/document_traceability/document_traceability.json new file mode 100644 index 0000000..67fb910 --- /dev/null +++ b/ampower_visualize/ampower_visualize/page/document_traceability/document_traceability.json @@ -0,0 +1,18 @@ +{ + "content": null, + "creation": "2026-07-12 11:36:16.980851", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2026-07-12 11:38:22.693297", + "modified_by": "Administrator", + "module": "Ampower Visualize", + "name": "document-traceability", + "owner": "Administrator", + "page_name": "document-traceability", + "roles": [], + "script": null, + "standard": "Yes", + "style": null, + "system_page": 0 +} \ No newline at end of file diff --git a/ampower_visualize/ampower_visualize/page/document_traceability/document_traceability.py b/ampower_visualize/ampower_visualize/page/document_traceability/document_traceability.py new file mode 100644 index 0000000..509f5f3 --- /dev/null +++ b/ampower_visualize/ampower_visualize/page/document_traceability/document_traceability.py @@ -0,0 +1,676 @@ +# Copyright (c) 2026, Ambibuzz Technologies LLP and contributors +# For license information, please see license.txt + +import frappe + +DOCTYPE_ABBREV = { + "Sales Order": "SO", + "Delivery Note": "DN", + "Sales Invoice": "SI", + "Material Request": "MR", + "Purchase Order": "PO", + "Purchase Receipt": "PR", + "Purchase Invoice": "PI", + "Production Plan": "PP", + "Work Order": "WO", + "Job Card": "JC", + "Stock Entry": "SE", +} + +ITEM_ABBREV = { + "Sales Order": "SOI", + "Delivery Note": "DNI", + "Sales Invoice": "SII", + "Material Request": "MRI", + "Purchase Order": "POI", + "Purchase Receipt": "PRI", + "Purchase Invoice": "PII", + "Production Plan": "PPI", + "Work Order": "WOI", + "Job Card": "JCI", + "Stock Entry": "SEI", +} + +ITEMS_CHILD = { + "Sales Order": ("Sales Order Item", ["item_code", "item_name", "qty", "uom", "rate", "amount"]), + "Delivery Note": ("Delivery Note Item", ["item_code", "item_name", "qty", "uom"]), + "Sales Invoice": ("Sales Invoice Item", ["item_code", "item_name", "qty", "uom", "rate", "amount"]), + "Material Request": ("Material Request Item", ["item_code", "item_name", "qty", "uom"]), + "Purchase Order": ("Purchase Order Item", ["item_code", "item_name", "qty", "uom", "rate", "amount"]), + "Purchase Receipt": ("Purchase Receipt Item", ["item_code", "item_name", "qty", "uom"]), + "Purchase Invoice": ("Purchase Invoice Item", ["item_code", "item_name", "qty", "uom", "rate", "amount"]), + "Production Plan": ("Production Plan Item", ["item_code", "item_name", "planned_qty", "uom"]), + "Job Card": ("Job Card Item", ["item_code", "transferred_qty"]), + "Stock Entry": ("Stock Entry Detail", ["item_code", "item_name", "qty", "s_warehouse", "t_warehouse"]), +} + +SUPPORTED_DOCTYPES = ( + "Sales Order", "Delivery Note", "Sales Invoice", + "Material Request", "Purchase Order", "Purchase Receipt", "Purchase Invoice", + "Production Plan", "Work Order", "Job Card", "Stock Entry", +) + + +@frappe.whitelist() +def get_document_trace(doctype, docname, include_items=0): + """ + Return a graph of nodes and edges for the given document. + + include_items=1 produces a radial item graph (root → item rows → linked docs). + include_items=0 is retained for API compatibility but the UI always uses 1. + """ + if not doctype or not docname: + frappe.throw("doctype and docname are required.") + if doctype not in SUPPORTED_DOCTYPES: + frappe.throw(f"Unsupported doctype: {doctype}") + if not frappe.db.exists(doctype, docname): + frappe.throw(f"{doctype} {docname!r} does not exist.") + + try: + frappe.has_permission(doctype, doc=docname, throw=True) + tracer = DocumentTracer(include_items=frappe.utils.cint(include_items)) + tracer.trace(doctype, docname) + nodes = list(tracer.nodes.values()) + edges = tracer.edges + if not nodes: + frappe.log_error( + f"No nodes returned for {doctype} {docname}", + "Document Traceability: empty result", + ) + return {"nodes": nodes, "edges": edges} + except frappe.PermissionError: + raise + except frappe.ValidationError: + raise + except Exception: + frappe.log_error(frappe.get_traceback(), f"Document Traceability: {doctype} {docname}") + frappe.throw( + "An error occurred while building the traceability graph. " + "Details have been logged to the Error Log." + ) + + +@frappe.whitelist() +def get_document_items(doctype, docname): + """Return the child item rows for a document, used to populate the side panel.""" + frappe.has_permission(doctype, doc=docname, throw=True) + if doctype == "Work Order": + wo = frappe.get_value("Work Order", docname, + ["production_item", "item_name", "qty", "uom"], as_dict=True) + if wo: + return [{"item_code": wo.production_item, "item_name": wo.get("item_name") or "", + "qty": wo.qty, "uom": wo.uom}] + return [] + cfg = ITEMS_CHILD.get(doctype) + if not cfg: + return [] + child_dt, fields = cfg + items = frappe.get_all(child_dt, filters={"parent": docname}, + fields=fields, order_by="idx asc", limit=200) + result = [] + for item in items: + row = {} + for k, v in item.items(): + if isinstance(v, float): + v = round(v, 3) if v != int(v) else int(v) + row[k] = v if v is not None else "" + result.append(row) + return result + + +class DocumentTracer: + def __init__(self, include_items=False): + """Initialise an empty tracer. Set include_items=True for the radial item graph.""" + self.include_items = bool(include_items) + self.nodes = {} + self.edges = [] + self._edge_set = set() + self._visited = set() + self._root_doctype = None + self._root_docname = None + + def _nid(self, doctype, name): + """Return a stable node ID string for a document.""" + return f"{doctype}||{name}" + + def _add_node(self, doctype, name, role="doc"): + """ + Add a document node if it does not already exist. + Returns the node ID. + """ + nid = self._nid(doctype, name) + if nid in self.nodes: + return nid + try: + values = frappe.get_value(doctype, name, ["status", "docstatus"], as_dict=True) or {} + docstatus = values.get("docstatus", 0) + status = values.get("status") or ( + "Submitted" if docstatus == 1 else + "Cancelled" if docstatus == 2 else "Draft" + ) + except Exception: + status = "" + self.nodes[nid] = { + "id": nid, + "type": "doc", + "role": role, + "doctype": doctype, + "name": name, + "abbrev": DOCTYPE_ABBREV.get(doctype, doctype[:2].upper()), + "status": status, + "url": f"/app/{frappe.scrub(doctype).replace('_', '-')}/{name}", + } + return nid + + def _add_leaf_node(self, doctype, name, item_nid=None): + """ + Add a leaf document node in the radial item graph. + + When item_nid is supplied a unique node is created per (item, document) + pair so the same document (e.g. DN-001) can appear separately for every + item row that links to it. + + Generic sibling-exclusion rule: if the leaf's doctype matches the root + document's doctype it is a sibling (e.g. another DN when visualising a DN) + and is excluded. Returns None in that case so the caller skips the edge. + """ + if doctype == self._root_doctype: + return None + nid = f"leaf||{item_nid}||{doctype}||{name}" if item_nid else self._nid(doctype, name) + if nid in self.nodes: + return nid + try: + values = frappe.get_value(doctype, name, ["status", "docstatus"], as_dict=True) or {} + docstatus = values.get("docstatus", 0) + status = values.get("status") or ( + "Submitted" if docstatus == 1 else + "Cancelled" if docstatus == 2 else "Draft" + ) + except Exception: + status = "" + self.nodes[nid] = { + "id": nid, + "type": "doc", + "role": "leaf", + "doctype": doctype, + "name": name, + "abbrev": DOCTYPE_ABBREV.get(doctype, doctype[:2].upper()), + "status": status, + "url": f"/app/{frappe.scrub(doctype).replace('_', '-')}/{name}", + } + return nid + + def _add_group_node(self, doctype, name): + """ + Add an intermediate group node representing a source document (SO, MR, PO) + that is not the root but groups a set of item rows beneath it. + Used to build the four-tier radial layout: root → group → item → leaf. + """ + nid = self._nid(doctype, name) + if nid in self.nodes: + return nid + try: + values = frappe.get_value(doctype, name, ["status", "docstatus"], as_dict=True) or {} + docstatus = values.get("docstatus", 0) + status = values.get("status") or ( + "Submitted" if docstatus == 1 else + "Cancelled" if docstatus == 2 else "Draft" + ) + except Exception: + status = "" + self.nodes[nid] = { + "id": nid, + "type": "doc", + "role": "group", + "doctype": doctype, + "name": name, + "abbrev": DOCTYPE_ABBREV.get(doctype, doctype[:2].upper()), + "status": status, + "url": f"/app/{frappe.scrub(doctype).replace('_', '-')}/{name}", + } + return nid + + def _add_item_node(self, doctype, docname, row_name, item_code, + item_name="", qty=0, uom=""): + """Add an item-row node (SOI, MRI, POI …) for the radial item graph.""" + nid = f"item||{doctype}||{docname}||{row_name}" + if nid in self.nodes: + return nid + self.nodes[nid] = { + "id": nid, + "type": "item", + "role": "item", + "doctype": doctype, + "docname": docname, + "name": row_name, + "item_code": item_code, + "item_name": item_name or item_code, + "qty": qty, + "uom": uom or "", + "abbrev": ITEM_ABBREV.get(doctype, doctype[:2].upper() + "I"), + "url": f"/app/{frappe.scrub(doctype).replace('_', '-')}/{docname}", + } + return nid + + def _add_edge(self, from_dt, from_name, to_dt, to_name, label="", dashed=False): + """Add a directed edge between two document nodes (deduplicates).""" + key = (from_dt, from_name, to_dt, to_name) + if key in self._edge_set: + return + self._edge_set.add(key) + self.edges.append({ + "from": self._nid(from_dt, from_name), + "to": self._nid(to_dt, to_name), + "label": label, + "dashed": dashed, + }) + + def _add_item_edge(self, from_nid, to_nid, dashed=False): + """Add a directed edge between two item-graph nodes (deduplicates). Ignores None nids.""" + if not from_nid or not to_nid: + return + key = (from_nid, to_nid) + if key in self._edge_set: + return + self._edge_set.add(key) + self.edges.append({"from": from_nid, "to": to_nid, "label": "", "dashed": dashed}) + + def _seen(self, key): + """Return True (and mark as visited) if key has been visited before.""" + if key in self._visited: + return True + self._visited.add(key) + return False + + def trace(self, doctype, docname): + """ + Entry point. Populates self.nodes and self.edges for the given document. + With include_items=True the result is a radial item graph; otherwise a + flat document-level graph is returned. + """ + self._root_doctype = doctype + self._root_docname = docname + if self.include_items: + doc_finder = DocumentTracer(include_items=False) + doc_finder.trace(doctype, docname) + self._build_radial_item_graph(doctype, docname, doc_finder.nodes) + else: + roots = self._find_roots(doctype, docname) + for root_dt, root_name in roots: + self._trace_forward(root_dt, root_name) + + def _build_radial_item_graph(self, root_doctype, root_docname, doc_nodes): + """ + Build a radial graph of either three or four tiers. + + Three-tier (root IS the SO/MR/PO): + root → item rows → linked docs + + Four-tier (root is a DN, SI, PR, etc. that links back to one or more SOs): + root → SO/MR/PO group → item rows → linked docs + + The extra group tier makes it clear which items belong to which source order + when a single DN (or SI/PR) consolidates items from multiple orders. + + Priority: Sales Order > Material Request > Purchase Order. + """ + docs = {} + for node in doc_nodes.values(): + docs.setdefault(node["doctype"], []).append(node["name"]) + + self._add_node(root_doctype, root_docname, role="root") + root_nid = self._nid(root_doctype, root_docname) + + if "Sales Order" in docs: + for so_name in docs["Sales Order"]: + if root_doctype == "Sales Order" and so_name == root_docname: + self._trace_so_items_radial(so_name, root_nid) + else: + so_nid = self._add_group_node("Sales Order", so_name) + self._add_item_edge(root_nid, so_nid) + self._trace_so_items_radial(so_name, so_nid) + + elif "Material Request" in docs: + for mr_name in docs["Material Request"]: + if root_doctype == "Material Request" and mr_name == root_docname: + self._trace_mr_items_radial(mr_name, root_nid) + else: + mr_nid = self._add_group_node("Material Request", mr_name) + self._add_item_edge(root_nid, mr_nid) + self._trace_mr_items_radial(mr_name, mr_nid) + + elif "Purchase Order" in docs: + for po_name in docs["Purchase Order"]: + if root_doctype == "Purchase Order" and po_name == root_docname: + self._trace_po_items_radial(po_name, root_nid) + else: + po_nid = self._add_group_node("Purchase Order", po_name) + self._add_item_edge(root_nid, po_nid) + self._trace_po_items_radial(po_name, po_nid) + + def _trace_so_items_radial(self, so_name, parent_nid): + """ + Trace Sales Order items and their linked downstream documents. + Creates item nodes for each SOI and leaf nodes for DN, SI, MR, PO, PR, PI. + """ + if self._seen(f"SO_RADIAL:{so_name}"): + return + for r in frappe.get_all("Sales Order Item", + filters={"parent": so_name}, + fields=["name", "item_code", "item_name", "qty", "uom"], + order_by="idx asc", limit=50): + + item_nid = self._add_item_node( + "Sales Order", so_name, r.name, + r.item_code, r.get("item_name") or "", r.qty, r.uom, + ) + self._add_item_edge(parent_nid, item_nid) + + for d in frappe.get_all("Delivery Note Item", + filters={"so_detail": r.name, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True, limit=20): + self._add_item_edge(item_nid, self._add_leaf_node("Delivery Note", d.parent, item_nid)) + + for d in frappe.get_all("Sales Invoice Item", + filters={"so_detail": r.name, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True, limit=20): + self._add_item_edge(item_nid, self._add_leaf_node("Sales Invoice", d.parent, item_nid)) + + for d in frappe.get_all("Material Request Item", + filters={"sales_order": so_name, "item_code": r.item_code, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True, limit=20): + self._add_item_edge(item_nid, self._add_leaf_node("Material Request", d.parent, item_nid)) + + for d in frappe.get_all("Purchase Order Item", + filters={"sales_order": so_name, "item_code": r.item_code, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True, limit=20): + self._add_item_edge(item_nid, self._add_leaf_node("Purchase Order", d.parent, item_nid)) + + po_names = list({ + d.parent for d in frappe.get_all("Purchase Order Item", + filters={"sales_order": so_name, "item_code": r.item_code, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True, limit=10) + }) + for po_name in po_names: + for d in frappe.get_all("Purchase Receipt Item", + filters={"purchase_order": po_name, "item_code": r.item_code, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True, limit=10): + self._add_item_edge(item_nid, self._add_leaf_node("Purchase Receipt", d.parent, item_nid)) + + for d in frappe.get_all("Purchase Invoice Item", + filters={"purchase_order": po_name, "item_code": r.item_code, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True, limit=10): + self._add_item_edge(item_nid, self._add_leaf_node("Purchase Invoice", d.parent, item_nid)) + + def _trace_mr_items_radial(self, mr_name, parent_nid): + """ + Trace Material Request items and their linked PO, PR, PI documents. + Used when no Sales Order is present in the discovered document set. + """ + if self._seen(f"MR_RADIAL:{mr_name}"): + return + for r in frappe.get_all("Material Request Item", + filters={"parent": mr_name}, + fields=["name", "item_code", "item_name", "qty", "uom"], + order_by="idx asc", limit=50): + + item_nid = self._add_item_node( + "Material Request", mr_name, r.name, + r.item_code, r.get("item_name") or "", r.qty, r.uom, + ) + self._add_item_edge(parent_nid, item_nid) + + for d in frappe.get_all("Purchase Order Item", + filters={"material_request_item": r.name, "docstatus": ["!=", 2]}, + fields=["name", "parent"], distinct=True, limit=20): + self._add_item_edge(item_nid, self._add_leaf_node("Purchase Order", d.parent, item_nid)) + for pr in frappe.get_all("Purchase Receipt Item", + filters={"purchase_order": d.parent, "item_code": r.item_code, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True, limit=10): + self._add_item_edge(item_nid, self._add_leaf_node("Purchase Receipt", pr.parent, item_nid)) + for pi in frappe.get_all("Purchase Invoice Item", + filters={"purchase_order": d.parent, "item_code": r.item_code, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True, limit=10): + self._add_item_edge(item_nid, self._add_leaf_node("Purchase Invoice", pi.parent, item_nid)) + + def _trace_po_items_radial(self, po_name, parent_nid): + """ + Trace Purchase Order items and their linked PR and PI documents. + Used as a fallback when neither SO nor MR is present. + """ + if self._seen(f"PO_RADIAL:{po_name}"): + return + for r in frappe.get_all("Purchase Order Item", + filters={"parent": po_name}, + fields=["name", "item_code", "item_name", "qty", "uom"], + order_by="idx asc", limit=50): + + item_nid = self._add_item_node( + "Purchase Order", po_name, r.name, + r.item_code, r.get("item_name") or "", r.qty, r.uom, + ) + self._add_item_edge(parent_nid, item_nid) + + for d in frappe.get_all("Purchase Receipt Item", + filters={"purchase_order_item": r.name, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True, limit=20): + self._add_item_edge(item_nid, self._add_leaf_node("Purchase Receipt", d.parent, item_nid)) + + for d in frappe.get_all("Purchase Invoice Item", + filters={"po_detail": r.name, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True, limit=20): + self._add_item_edge(item_nid, self._add_leaf_node("Purchase Invoice", d.parent, item_nid)) + + def _find_roots(self, doctype, docname): + """ + Walk up the document chain to find the root Sales Order (or equivalent) + for the flat document-graph mode. Returns a list of (doctype, name) tuples. + """ + if doctype == "Sales Order": + return [("Sales Order", docname)] + + if doctype == "Delivery Note": + sos = frappe.get_all("Delivery Note Item", + filters={"parent": docname, "against_sales_order": ["!=", ""]}, + fields=["against_sales_order"], distinct=True) + return [("Sales Order", r.against_sales_order) for r in sos] or [("Delivery Note", docname)] + + if doctype == "Sales Invoice": + sos = frappe.get_all("Sales Invoice Item", + filters={"parent": docname, "sales_order": ["!=", ""]}, + fields=["sales_order"], distinct=True) + if sos: + return [("Sales Order", r.sales_order) for r in sos] + dns = frappe.get_all("Sales Invoice Item", + filters={"parent": docname, "delivery_note": ["!=", ""]}, + fields=["delivery_note"], distinct=True) + roots = [] + for r in dns: + roots.extend(self._find_roots("Delivery Note", r.delivery_note)) + return roots or [("Sales Invoice", docname)] + + if doctype == "Material Request": + sos = frappe.get_all("Material Request Item", + filters={"parent": docname, "sales_order": ["!=", ""]}, + fields=["sales_order"], distinct=True) + return [("Sales Order", r.sales_order) for r in sos] or [("Material Request", docname)] + + if doctype == "Purchase Order": + sos = frappe.get_all("Purchase Order Item", + filters={"parent": docname, "sales_order": ["!=", ""]}, + fields=["sales_order"], distinct=True) + if sos: + return [("Sales Order", r.sales_order) for r in sos] + mrs = frappe.get_all("Purchase Order Item", + filters={"parent": docname, "material_request": ["!=", ""]}, + fields=["material_request"], distinct=True) + roots = [] + for r in mrs: + roots.extend(self._find_roots("Material Request", r.material_request)) + return roots or [("Purchase Order", docname)] + + if doctype == "Purchase Receipt": + pos = frappe.get_all("Purchase Receipt Item", + filters={"parent": docname, "purchase_order": ["!=", ""]}, + fields=["purchase_order"], distinct=True) + roots = [] + for r in pos: + roots.extend(self._find_roots("Purchase Order", r.purchase_order)) + return roots or [("Purchase Receipt", docname)] + + if doctype == "Purchase Invoice": + roots = [] + for row in frappe.get_all("Purchase Invoice Item", + filters={"parent": docname, "purchase_order": ["!=", ""]}, + fields=["purchase_order"], distinct=True): + roots.extend(self._find_roots("Purchase Order", row.purchase_order)) + if not roots: + for row in frappe.get_all("Purchase Invoice Item", + filters={"parent": docname, "purchase_receipt": ["!=", ""]}, + fields=["purchase_receipt"], distinct=True): + roots.extend(self._find_roots("Purchase Receipt", row.purchase_receipt)) + return roots or [("Purchase Invoice", docname)] + + if doctype == "Production Plan": + sos = frappe.get_all("Production Plan Sales Order", + filters={"parent": docname, "sales_order": ["!=", ""]}, + fields=["sales_order"], distinct=True) + return [("Sales Order", r.sales_order) for r in sos] or [("Production Plan", docname)] + + if doctype == "Work Order": + pp = frappe.get_value("Work Order", docname, "production_plan") + return self._find_roots("Production Plan", pp) if pp else [("Work Order", docname)] + + if doctype == "Job Card": + wo = frappe.get_value("Job Card", docname, "work_order") + return self._find_roots("Work Order", wo) if wo else [("Job Card", docname)] + + if doctype == "Stock Entry": + wo = frappe.get_value("Stock Entry", docname, "work_order") + return self._find_roots("Work Order", wo) if wo else [("Stock Entry", docname)] + + return [(doctype, docname)] + + def _trace_forward(self, doctype, name): + """Dispatch forward tracing to the appropriate method for the given doctype.""" + dispatch = { + "Sales Order": self._trace_so, + "Delivery Note": lambda n: (self._add_node("Delivery Note", n), self._trace_dn(n)), + "Sales Invoice": lambda n: self._add_node("Sales Invoice", n), + "Material Request": lambda n: (self._add_node("Material Request", n), self._trace_mr(n)), + "Purchase Order": lambda n: (self._add_node("Purchase Order", n), self._trace_po(n)), + "Purchase Receipt": lambda n: (self._add_node("Purchase Receipt", n), self._trace_pr(n)), + "Purchase Invoice": lambda n: self._add_node("Purchase Invoice", n), + "Production Plan": lambda n: (self._add_node("Production Plan", n), self._trace_pp(n)), + "Work Order": lambda n: (self._add_node("Work Order", n), self._trace_wo(n)), + "Job Card": lambda n: self._add_node("Job Card", n), + "Stock Entry": lambda n: self._add_node("Stock Entry", n), + } + fn = dispatch.get(doctype) + if fn: + fn(name) + + def _trace_so(self, name): + """Trace a Sales Order forward to DN, SI, MR, PO, and Production Plan.""" + if self._seen(f"SO:{name}"): + return + self._add_node("Sales Order", name) + for r in frappe.get_all("Delivery Note Item", + filters={"against_sales_order": name, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True): + self._add_node("Delivery Note", r.parent) + self._add_edge("Sales Order", name, "Delivery Note", r.parent) + self._trace_dn(r.parent) + for r in frappe.get_all("Sales Invoice Item", + filters={"sales_order": name, "delivery_note": ["in", ["", None]], "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True): + self._add_node("Sales Invoice", r.parent) + self._add_edge("Sales Order", name, "Sales Invoice", r.parent, dashed=True) + for r in frappe.get_all("Material Request Item", + filters={"sales_order": name, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True): + self._add_node("Material Request", r.parent) + self._add_edge("Sales Order", name, "Material Request", r.parent) + self._trace_mr(r.parent) + for r in frappe.get_all("Purchase Order Item", + filters={"sales_order": name, "material_request": ["in", ["", None]], "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True): + self._add_node("Purchase Order", r.parent) + self._add_edge("Sales Order", name, "Purchase Order", r.parent, dashed=True) + self._trace_po(r.parent) + for r in frappe.get_all("Production Plan Sales Order", + filters={"sales_order": name}, fields=["parent"], distinct=True): + if frappe.get_value("Production Plan", r.parent, "docstatus") != 2: + self._add_node("Production Plan", r.parent) + self._add_edge("Sales Order", name, "Production Plan", r.parent) + self._trace_pp(r.parent) + + def _trace_dn(self, name): + """Trace a Delivery Note forward to Sales Invoice.""" + if self._seen(f"DN:{name}"): + return + for r in frappe.get_all("Sales Invoice Item", + filters={"delivery_note": name, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True): + self._add_node("Sales Invoice", r.parent) + self._add_edge("Delivery Note", name, "Sales Invoice", r.parent) + + def _trace_mr(self, name): + """Trace a Material Request forward to Purchase Order.""" + if self._seen(f"MR:{name}"): + return + for r in frappe.get_all("Purchase Order Item", + filters={"material_request": name, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True): + self._add_node("Purchase Order", r.parent) + self._add_edge("Material Request", name, "Purchase Order", r.parent) + self._trace_po(r.parent) + + def _trace_po(self, name): + """Trace a Purchase Order forward to Purchase Receipt and Purchase Invoice.""" + if self._seen(f"PO:{name}"): + return + for r in frappe.get_all("Purchase Receipt Item", + filters={"purchase_order": name, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True): + self._add_node("Purchase Receipt", r.parent) + self._add_edge("Purchase Order", name, "Purchase Receipt", r.parent) + self._trace_pr(r.parent) + for r in frappe.get_all("Purchase Invoice Item", + filters={"purchase_order": name, "purchase_receipt": ["in", ["", None]], "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True): + self._add_node("Purchase Invoice", r.parent) + self._add_edge("Purchase Order", name, "Purchase Invoice", r.parent, dashed=True) + + def _trace_pr(self, name): + """Trace a Purchase Receipt forward to Purchase Invoice.""" + if self._seen(f"PR:{name}"): + return + for r in frappe.get_all("Purchase Invoice Item", + filters={"purchase_receipt": name, "docstatus": ["!=", 2]}, + fields=["parent"], distinct=True): + self._add_node("Purchase Invoice", r.parent) + self._add_edge("Purchase Receipt", name, "Purchase Invoice", r.parent) + + def _trace_pp(self, name): + """Trace a Production Plan forward to Work Orders.""" + if self._seen(f"PP:{name}"): + return + for r in frappe.get_all("Work Order", + filters={"production_plan": name, "docstatus": ["!=", 2]}, fields=["name"]): + self._add_node("Work Order", r.name) + self._add_edge("Production Plan", name, "Work Order", r.name) + self._trace_wo(r.name) + + def _trace_wo(self, name): + """Trace a Work Order forward to Job Cards and Stock Entries.""" + if self._seen(f"WO:{name}"): + return + for r in frappe.get_all("Job Card", + filters={"work_order": name, "docstatus": ["!=", 2]}, fields=["name"]): + self._add_node("Job Card", r.name) + self._add_edge("Work Order", name, "Job Card", r.name) + for r in frappe.get_all("Stock Entry", + filters={"work_order": name, "docstatus": ["!=", 2]}, fields=["name"]): + self._add_node("Stock Entry", r.name) + self._add_edge("Work Order", name, "Stock Entry", r.name) \ No newline at end of file diff --git a/ampower_visualize/hooks.py b/ampower_visualize/hooks.py index 24c7468..22a1cd2 100644 --- a/ampower_visualize/hooks.py +++ b/ampower_visualize/hooks.py @@ -28,7 +28,13 @@ # page_js = {"page" : "public/js/file.js"} # include js in doctype views -# doctype_js = {"doctype" : "public/js/doctype.js"} +doctype_js = {"Sales Order" : "public/js/sales_order.js", + "Sales Invoice" : "public/js/sales_invoice.js", + "Purchase Order" : "public/js/purchase_order.js", + "Purchase Invoice" : "public/js/purchase_invoice.js", + "Delivery Note" : "public/js/delivery_note.js", + "Purchase Receipt" : "public/js/purchase_receipt.js", + "Material Request" : "public/js/material_request.js"} # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} diff --git a/ampower_visualize/public/js/delivery_note.js b/ampower_visualize/public/js/delivery_note.js new file mode 100644 index 0000000..f68fcf6 --- /dev/null +++ b/ampower_visualize/public/js/delivery_note.js @@ -0,0 +1,15 @@ +// Copyright (c) 2026, Ambibuzz Technologies LLP and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Delivery Note", { + refresh(frm) { + if (frm.is_new()) return; + frm.add_custom_button(__("Visualize"), function () { + frappe.route_options = { + doctype: "Delivery Note", + docname: frm.docname, + }; + frappe.set_route("document-traceability"); + }); + }, +}); \ No newline at end of file diff --git a/ampower_visualize/public/js/material_request.js b/ampower_visualize/public/js/material_request.js new file mode 100644 index 0000000..2b13018 --- /dev/null +++ b/ampower_visualize/public/js/material_request.js @@ -0,0 +1,15 @@ +// Copyright (c) 2026, Ambibuzz Technologies LLP and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Material Request", { + refresh(frm) { + if (frm.is_new()) return; + frm.add_custom_button(__("Visualize"), function () { + frappe.route_options = { + doctype: "Material Request", + docname: frm.docname, + }; + frappe.set_route("document-traceability"); + }); + }, +}); \ No newline at end of file diff --git a/ampower_visualize/public/js/purchase_invoice.js b/ampower_visualize/public/js/purchase_invoice.js new file mode 100644 index 0000000..1f518f9 --- /dev/null +++ b/ampower_visualize/public/js/purchase_invoice.js @@ -0,0 +1,15 @@ +// Copyright (c) 2026, Ambibuzz Technologies LLP and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Purchase Invoice", { + refresh(frm) { + if (frm.is_new()) return; + frm.add_custom_button(__("Visualize"), function () { + frappe.route_options = { + doctype: "Purchase Invoice", + docname: frm.docname, + }; + frappe.set_route("document-traceability"); + }); + }, +}); \ No newline at end of file diff --git a/ampower_visualize/public/js/purchase_order.js b/ampower_visualize/public/js/purchase_order.js new file mode 100644 index 0000000..62c0a07 --- /dev/null +++ b/ampower_visualize/public/js/purchase_order.js @@ -0,0 +1,15 @@ +// Copyright (c) 2026, Ambibuzz Technologies LLP and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Purchase Order", { + refresh(frm) { + if (frm.is_new()) return; + frm.add_custom_button(__("Visualize"), function () { + frappe.route_options = { + doctype: "Purchase Order", + docname: frm.docname, + }; + frappe.set_route("document-traceability"); + }); + }, +}); \ No newline at end of file diff --git a/ampower_visualize/public/js/purchase_receipt.js b/ampower_visualize/public/js/purchase_receipt.js new file mode 100644 index 0000000..d89612c --- /dev/null +++ b/ampower_visualize/public/js/purchase_receipt.js @@ -0,0 +1,15 @@ +// Copyright (c) 2026, Ambibuzz Technologies LLP and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Purchase Reciept", { + refresh(frm) { + if (frm.is_new()) return; + frm.add_custom_button(__("Visualize"), function () { + frappe.route_options = { + doctype: "Purchase Reciept", + docname: frm.docname, + }; + frappe.set_route("document-traceability"); + }); + }, +}); \ No newline at end of file diff --git a/ampower_visualize/public/js/sales _invoice.js b/ampower_visualize/public/js/sales _invoice.js new file mode 100644 index 0000000..f009a28 --- /dev/null +++ b/ampower_visualize/public/js/sales _invoice.js @@ -0,0 +1,15 @@ +// Copyright (c) 2026, Ambibuzz Technologies LLP and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Sales Invoice", { + refresh(frm) { + if (frm.is_new()) return; + frm.add_custom_button(__("Visualize"), function () { + frappe.route_options = { + doctype: "Sales Invoice", + docname: frm.docname, + }; + frappe.set_route("document-traceability"); + }); + }, +}); \ No newline at end of file diff --git a/ampower_visualize/public/js/sales_order.js b/ampower_visualize/public/js/sales_order.js new file mode 100644 index 0000000..9a535d4 --- /dev/null +++ b/ampower_visualize/public/js/sales_order.js @@ -0,0 +1,15 @@ +// Copyright (c) 2026, Ambibuzz Technologies LLP and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Sales Order", { + refresh(frm) { + if (frm.is_new()) return; + frm.add_custom_button(__("Visualize"), function () { + frappe.route_options = { + doctype: "Sales Order", + docname: frm.docname, + }; + frappe.set_route("document-traceability"); + }); + }, +}); \ No newline at end of file diff --git a/assets/document_traceability.png b/assets/document_traceability.png new file mode 100644 index 0000000..ed7da50 Binary files /dev/null and b/assets/document_traceability.png differ diff --git a/assets/document_traceability_demo.gif b/assets/document_traceability_demo.gif new file mode 100644 index 0000000..07d553a Binary files /dev/null and b/assets/document_traceability_demo.gif differ