Intro
This document describes how DSP Contracts are represented and used to drive decisions in ServiceNow's incident module.
Background
DSP Contracts have been implemented as "Service Contracts" table (ast_service} that came with the asset management module. Each contract has a list of organizations which it serves; these lists are maintained in a Box spreadsheet and pulled into ServiceNow via a nightly import set (this could probably be improved by allowing in-tool editing; the trade-off is role/ACL management vs. import set/transform management).
Which DSP contract is used is not a simple matter of lookup; in the case of FASIT-tagged users, a different DSP group is used.
This combination of data and business rules is used to:
- credit time worked to appropriate contracts
- ensure correct dispatch of desktop support
Use Cases
From R1138, R1139, R1140, R1141:
- When processing an incident, an ITIL user should be able to determine which DSP group supports a particular user so that the incident can be routed to the appropriate DSP group.
- DSP managers need to be able to report at the end of the month the time worked for each support contract so they can accurately bill the orgs
- DSPs need to be able to filter tickets assigned to their groups by the contract of each client's org so they can constrain by the contracts they support
- DSP managers need counts of the users supported by a given contract so they can appropriately staff the contract support groups
Fields
To accomplish correct assignment, it was initially decided that the user table (sys_user) would have a visible field "DSP Group" (u_dsp_group) which would be populated by a business rule. The DSP contracts were loaded (one time) into the service contracts table (ast_service). The organization table was given a custom field to point each org back to exactly one contract. These relationships were initially loaded one time. Over time, the date in the user table became stale and a way was eventually established to update it regularly (see below). Currently, DSP group assignment is made reliable through a UI action that implements the business rule.
To deal with billing, a "bill to contract" field was added to task_time_worked. Upon insertion of each task time worked entry, business logic records a DSP contract to bill as appropriate.
Business Rules
Fruition originally called the rule "Update org data"... it originally worked something like this:
gs.log(current.department); setOrgData(); function setOrgData() { var org = new GlideRecord('u_cmn_organization'); org.get(current.u_organization); current.department = org.u_department.sys_id; current.u_division = org.u_division.sys_id; current.company = org.u_company.sys_id; current.u_dsp_group = org.u_dsp_group; current.u_service_contract = org.u_service_contract; }
...this rule populates the user's department, division, company, DSP Group, and Service Contract fields with those belonging to the org (instead of dot-walking?). Then, by examining the user field, a tech working an incident can see what the correct group is.
Data Source
Since there is no official data ensconced in an existing database, and we didn't feel like adding a role and ACLs at the time, it was desirable to delegate responsibility for the data source to something other than the contracts table. So a formatted spreadsheet was placed in Box.com and paired with a data source transform map in ServiceNow. Multiple people have edit access.
Data Contract
- The DSP organization is responsible for the source data content
- spreadsheet containing data will exist at https://yale.box.com/shared/static/k53bk7qymee6t5g7j85u.xls (don't delete the file, or this will break)
- must remain XLS (Excel 97/2000) format
- must remain publicly accessible
- headers must be "Contract Number", "Active?", "Short Description", "Assignment Group", "Organizations"
- Contract Number should be unique (enforced upon import)
- Active? must TRUE or FALSE
- Assignment Group should match an existing group (or it will be rendered as empty)
- Organizations should be a space-delimited list of valid orgs (invalid orgs will not register upon import)
- Each organization should map to one contract only (last match wins)
- The data will be loaded daily at midnight.
Load/Transform
Data Source (DEV) - https://yaledevelopment.service-now.com/nav_to.do?uri=sys_data_source.do?sys_id=deeaf2be800bfc847ac030825c2a51ed
Transform Map (DEV) - https://yaledevelopment.service-now.com/nav_to.do?uri=sys_transform_map.do?sys_id=66569736804bfc847ac030825c2a5196
...the above links to the latest copy in development. Most of the transform is a straight field mapping, coalescing on contract number. Below is the initial version of the org-mapping portion of the transform, for reference only.
// // 2013/01/24 - william.west@yale.edu - Defect 376 - DSP Query not showing contracts for requests. // // The scripted part of this transform parses the Organizations column from the input. // * regex validate, ignore any input that doesn't match. // * foreach org listed, get the gliderecord, set u_service_contract to point to current contract and update. // * ignore no match. // // MDN implementation of Array.indexOf // https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/indexOf if (!Array.prototype.indexOf) { Array.prototype.indexOf = function (searchElement /*, fromIndex */ ) { "use strict"; if (this == null) { throw new TypeError(); } var t = Object(this); var len = t.length >>> 0; if (len === 0) { return -1; } var n = 0; if (arguments.length > 1) { n = Number(arguments[1]); if (n != n) { // shortcut for verifying if it's NaN n = 0; } else if (n != 0 && n != Infinity && n != -Infinity) { n = (n > 0 || -1) * Math.floor(Math.abs(n)); } } if (n >= len) { return -1; } var k = n >= 0 ? n : Math.max(len - Math.abs(n), 0); for (; k < len; k++) { if (k in t && t[k] === searchElement) { return k; } } return -1; } } // // create array of latest list orgs that are supposed to have this contract, trim any spaces from each var newestorgs = source.u_organizations.split(' '); for(var i=0; i<newestorgs.length; i++) { newestorgs[i] = newestorgs[i].replace(/\s+/g, ''); } // // first, get the list of orgs which currently have this contract var contract_id = getContract(source.u_contract_number); var currentorgs = getOrgsWithContract(contract_id); // // next, get a list or orgs which have this contract but shouldn't var deleteorgs = deleteThese(currentorgs, newestorgs); // // finally, get a list of orgs which should have this contract but don't var addorgs = addThese(currentorgs, newestorgs); // // if the contract_id isn't 0, make the prescribed org changes if(contract_id != 0) { deleteOrgs(deleteorgs); addOrgs(addorgs, contract_id); } // // uncomment for debug output gs.log('********* TRANSFORM service_contracts - ' + source.u_contract_number + ', sys_id: ' + contract_id + '; will delete: ' + deleteorgs.toString() + '; will add: ' + addorgs.toString() + '; current orgs: ' + currentorgs.toString() ); //+ '; new org list: ' + newestorgs.toString() //); // // get a contract by number; return sys_id or 0 function getContract(number) { var gr = new GlideRecord('ast_service'); gr.addQuery('number',number); gr.query(); if(gr.next()) { return(gr.sys_id); } else { return 0; } } // // get orgs pointing to contract with sys_id == contract; return array of org sys_ids (could be empty) function getOrgsWithContract(contract) { var orgs = []; var gr = new GlideRecord('u_cmn_organization'); gr.addQuery('u_service_contract', contract); gr.query(); while(gr.next()) { orgs.push(gr.u_id.toString()); } return orgs; } // // return array of orgs in current but not in newest function deleteThese(current, newest) { var orgs = []; var i; for (i=0; i<current.length; i++) { if(newest.indexOf(current[i],0) == -1) { orgs.push(current[i]); } } return orgs; } // // return array of orgs in newest but not in current function addThese(current, newest) { var orgs = []; var i; for (i=0; i<newest.length; i++) { if(current.indexOf(newest[i],0) == -1) { orgs.push(newest[i]); } } return orgs; } // // disassociate service contract from orgs with matching names function deleteOrgs(orgs) { var i; for(i=0; i<orgs.length; i++) { var gr = new GlideRecord('u_cmn_organization'); gr.addQuery('u_id',orgs[i]); gr.query(); if(gr.next()) { gr.u_service_contract = ''; gr.update(); } } } // // associate service contract with orgs matching names function addOrgs(orgs, contract) { var i; for(i=0; i<orgs.length; i++) { var gr = new GlideRecord('u_cmn_organization'); gr.addQuery('u_id',orgs[i]); gr.query(); if(gr.next()) { gr.u_service_contract = contract; gr.update(); } } }
Data Model
Note that the DSP Group of a user is derived from this data via business logic that exists outside of the data model. The pseudocode is as follows:
if User->Fasit "CTS DSP Team FASIT" else User->Org->Contract->DSPGroup.Name
Here is example SNow JS for obtaining the correct DSP Group for a given user in the absence of a denormalized field reference or store proc:
// if user is FASIT, use FASIT group, else use the org's group // gr = user's GlideRecord if(gr.u_facit == true) { gs.addInfoMessage('Assigned to FASIT DSP Group'); current.assignment_group = sys_user_group_by_name('CTS DSP Team FASIT'); } else { current.assignment_group = gr.u_organization.u_service_contract.u_assignment_group.sys_id; }