logo

Are you need IT Support Engineer? Free Consultant

Extend the Visit PDF Print Form and SAP Sales Clou…

  • By Sanjay
  • 08/05/2026
  • 5 Views


Goal

This blog is about extending the Visit Summary PDF/print form in SAP Sales Cloud Version 2 with the attendees'/contacts' Job Titles. Since this is not possible out of the box, the blog discusses the extension approach covering side-by-side extensibility with a webhook and a small Node.js app, running on SAP BTP.

Challenge and Solution

The print form data source for Visits doesn't contain the job titles of contact persons/attendees out of the box, which means you cannot simply “turn them on” (make them visible) in the print form with the Adobe LiveCycle Designer. They must be made available in the print form's data source.

Unfortunately, we can't simply extend the Attendees node with a JobTitle field in the system's Extensibility Administration, which would be the easiest approach. The Visit object (Business Entity/Service) can only be extended with custom fields on the header level.

Felix_Wyskocil_0-1778245362611.Png

➡️A workaround is to create a custom field on the header level and store multiple Attendee IDs and Job Titles in a structured way so we can parse them in the print form and display them accordingly. For this scenario, we go with a very simple pattern that divides records with | and the actual ID and Job Title with :
Example: 50:Developer|23:Designer|107:Manager

This is a simple and lightweight format, assuming the two delimiter characters ( | : ) are not part of anyone's Job Title.
Alternatively, we could use JSON as the internal field format. The Adobe LiveCycle Designer JavaScript engine allows for parsing JSON with var obj = eval("(" + jsonText + ")");

The next question is: How to fill the custom field on header level with the right information?
We can leverage a small side-by-side extension logic that is called by a webhook whenever a Visit is saved. The webhook sends the Visit record with the system-standard attendee information. We can use this to fetch the corresponding contact master data via the SSCv2 REST API and read the Job Titles. The extension logic can put this together in the structured format we need, respond to the webhook, and with that, fill our extension field.
Remark: Generating the Visit summary without changing a field while an attendee's job title has changed in the master data is an edge case that is not covered by this logic!

The print form can then parse the information from the field and display the job titles accordingly, e.g., in a new column or as an addition to the attendee name, for instance.

Procedure

  1. SSCv2: Extend Visit with Extension Field e.g.: PrintFormContactJobTitles
  2. BTP: Create Side-By-Side Extensibility App that handles the webhook
  3. SSCv2: Create Post-Webhook for Visit
  4. Edit the form template

Implementation

Visit – Extension Field

Since there's no attendee node that can be extended, we have to use a workaround and store structured information in a single field on the header level of the Visit.

Felix_Wyskocil_0-1778245429996.Png

Visit Post-Webhook

The webhook needs a Communication System with the right authentication and must point to the extension logic endpoint, e.g. a Node.js app on SAP BTP.

Felix_Wyskocil_1-1778245488470.Png

Node.JS App / Extension Logic

The following snippet shows the enrichment logic that can be placed into a Node.js express server.
It fits into Jens Limbach's code from here: https://github.com/jens-limbach/SSv2-webhook-sync-and-async/blob/main/README.md

const VISIT_ENTITY = 'sap.ssc.visitservice.entity.visit';
const ATTENDEE_TYPE = '147';

/**
 * Visit Attendees - Contacts Jobtitle Enrichment
 */
app.post('/webhooks/visit/posthook', async (req, res) => {

  try
  {
    // Validate payload
    const validationError = validateWebhookPayload(req.body, VISIT_ENTITY);
    if (validationError)
    {
      console.error(`❌ Validation failed: ${validationError.error}`)
      return res.status(400).json({ error: validationError.error })
    }

    // Support CloudEvents format (data wrapper) or direct format
    const data = req.body.data || req.body

    // sample payload uses displayId, real payload uses visitId...
    const visitDisplayId = data.currentImage.displayId || data.currentImage.visitId;
    console.log(`✅ Visit: ${visitDisplayId}`);

    const attendees = data.currentImage?.attendees ?? [];
    const attendeeIdArray = attendees
      .filter(a => String(a.type) === ATTENDEE_TYPE)
      .map(a => a.attendeeId)
      .filter(Boolean);

    if (!attendeeIdArray || attendeeIdArray.length === 0)
        return res.json({ noChanges: true });

    const jobTitles = await getContactPersonJobTitles(attendeeIdArray);
    console.log(`✅ JobTitles: ${jobTitles}`);

    const responseData = {
        ...data.currentImage,
        extensions: {
          ...data.currentImage.extensions,
          PrintFormContactJobTitles: jobTitles
        }
      }

      // Return response in CRM expected format
      const responseBody = { data: responseData };
      
      res.status(200).json(responseBody)
  }
  catch (error)
  {
    console.error('❌ Error in webhook-visit-post:', error)
    res.status(500).json({ error: 'Internal server error' })
  }
});

/**
 * Validates webhook payload structure
 * Returns null if everything is ok.
 */
function validateWebhookPayload(body, expectedEntityName)
{
  if (!body) return { error: 'Request body is empty', field: 'body' }

  // Support CloudEvents format (data wrapper) or direct format
  const data = body.data || body

  if (!data.currentImage) return { error: 'Missing currentImage object', field: 'currentImage' };
  if (!data.currentImage.id) return { error: 'Missing account ID', field: 'currentImage.id' };
  if (expectedEntityName && data.entity !== expectedEntityName) return { error: `Unexpected entity received: ${data.entity}, expected: ${expectedEntityName}`, field: 'entity' };

  return null;
}

async function getContactPersonJobTitles(attendeeIdArray)
{
  const filter = attendeeIdArray.map(id => `id eq '${id}'`).join(' or ');
  const url = `https://${process.env.SSCV2_HOST}/sap/c4c/api/v1/contact-person-service/contactPersons`;

    const response = await axios.get(url, {
      params: { $filter: filter },
      auth: {
        username: process.env.SSCV2_USERNAME,
        password: process.env.SSCV2_PASSWORD,
      },
    });

    const contacts = response.data.value ?? [];
    return contacts.map(c => `${c.displayId}:${c.functionalTitleName}`).join('|');    
}

Print Form Script to Enrich Table

If you don't have a custom print form yet, you must create a copy of the SAP's pre-delivered Visit print form.

Felix_Wyskocil_0-1778245677073.Png

Script Key Points:

  • The script is JavaScript
  • The table has the structure as shown in the screenshot. The rowTableItem is bound to the attendees[*] node and is repeated for each data item.
  • The rowTableSection is neither bound, nor repeated. (It could actually be omitted, but it's part of the pre-delivered form.)
  • The script accesses the bound data item via dataNode and hence can read the attendee id, which is not displayed in the form.

Felix_Wyskocil_1-1778245704402.Png

// visit.Page1.frmattendeeList.visitattendeeSubform.attendeeTable::initialize - (JavaScript, client)
var jobTitles = [];

function getJobTitleByAttendeeId(attendeeId)
{
	return jobTitles[attendeeId];
}

function getValueFromDataSource(object, somExpression)
{
	try
	{
		return object.resolveNode(somExpression).value;
	}
	catch (err) { return null; }
}

function initJobTitles()
{
	var jobTitlesNode = xfa.resolveNode("xfa.record.extensions.PrintFormContactJobTitles");
	if (!jobTitlesNode) return false;
	
	var jobTitlesString = jobTitlesNode.value;

	// jobTitlesString looks as follows: "50:Developer|23:Designer|107:Manager";
	// split it by | into pairs and then each pair into id and title by :

	var pairs = jobTitlesString.split('|');
	for (var i = 0; i < pairs.length; i++)
	{
	    var parts = pairs[i].split(':');
		var attendeeId = parts[0];
		var jobTitle = parts[1];
	    
	    jobTitles[attendeeId] = jobTitle;
	}
	
	return true;
}

function enrichJobTitleTable()
{
	var tableRows = this.resolveNodes("rowTableSection.rowTableItem[*]");
	for(var i = 0; i < tableRows.length; i++)
	{
		var row = tableRows.item(i);
		var attendeeId = getValueFromDataSource(row, "dataNode.attendeeDisplayId");
		var jobTitle = getJobTitleByAttendeeId(attendeeId);

		if (jobTitle)
			row.name.rawValue += "\n(" + jobTitle + ")";
	}
}

if (initJobTitles())
	enrichJobTitleTable();

 

 



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *