brobada / CPQ ATO Model Punch-out Validator

// ==UserScript==
// @name        CPQ ATO Model Punch-out Validator
// @version     0.011
// @description Validates ATO Punch outs on the classic CPQ homepage and JetUI tiles
// @author      Obada Kadri
// @license     MIT
// @match       *://*.bigmachines.com/commerce/display_company_profile.jsp*
// @require     https://code.jquery.com/jquery-3.4.1.min.js
// @require     https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js
// @require     https://cdn.jsdelivr.net/npm/alasql@0.4
// @require     https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js
// @grant       GM_addStyle
// ==/UserScript==


let collapseIndex = 0;
function renderResultCategory(parentId, categoryDesc, data, columns) {
  const card = $('<div class="wrap-collabsible"></div>');
  // Collapsible Header
  card.append(`
  <input id="collapsible-${collapseIndex}" class="toggle" type="checkbox">
  <label for="collapsible-${collapseIndex}" class="lbl-toggle">${categoryDesc} <span class="badge badge-pill badge-dark">${data.length}</span></label>
  `);
  // Collapsible Content
  card.append(`
  <div class="collapsible-content">
    <div class="content-inner">
      <table class="table table-striped">
        <tr>${
          data.length
            ? columns.map(label => `<th>${label}</th>`).join("\n")
            : ""
        }</tr>
        ${data
          .map(row => {
            const cols = columns.map(
              col => `<td>${row[col] ? row[col] : ""}</td>`
            ).join("\n");
            return `<tr>${cols}</tr>`;
          })
          .join("\n")}
      </table>
    </div>
  </div>`);
  $(`#${parentId}`).append(card);
  collapseIndex++;
}

async function validatePunchOuts() {
  // Datatable API
  const getApiTableName = function(tableName) {
    let result = tableName;
    if (tableName === "_parts") {
      result = "parts";
    } else if (tableName.length) {
      result = "custom" + tableName[0].toUpperCase() + tableName.slice(1);
    }
    return result;
  };

  const getTableMetaData = async function(tableName) {
    const metaData = {};
    const apiTableName = getApiTableName(tableName);
    const base_url = `${location.protocol}//${location.hostname}`;
    const url = `${base_url}/rest/v8/metadata-catalog/${apiTableName}`;
    const data = await fetch(url)
      .then(r => r.json(), console.log)
      .catch(console.log);
    metaData.columns = Object.keys(data.definitions[apiTableName].properties)
      .filter(c => c !== "id")
      .map(col => data.definitions[apiTableName].properties[col]);
    return metaData;
  };

  const getTableData = function(tableName, query, offset, allData, deferred) {
    const dTableData = Object.is(deferred, undefined)
      ? new $.Deferred()
      : deferred;
    offset = offset || 0;

    query = query || "";
    const apiTableName = getApiTableName(tableName);
    const url = `/rest/v8/${apiTableName}?q=${query}&totalResults=true&offset=${offset}`;

    allData = allData || [];

    $.get(url, function(data) {
      if (data.hasMore) {
        const nextOffset = offset + 1000;
        allData = allData.concat(data.items);

        getTableData(tableName, query, nextOffset, allData, dTableData);
      } else {
        dTableData.resolve(allData.concat(data.items));
      }
    }).fail(function() {
      dTableData.reject();
    });

    return dTableData.promise();
  };

  const getTableInfo = async function(tableName, query) {
    const tableInfo = {};
    tableInfo.isValid = false;
    if (tableName) {
      tableInfo.isValid = true;
      tableInfo.name = tableName;
      tableInfo.metaData = await getTableMetaData(tableName);
      tableInfo.rawData = await getTableData(tableName, query);
    }
    return tableInfo;
  };

  // Item Type is either "ATO" or "PTO HW MODEL": {$or:[{"_part_custom_field4":{$eq:"ATO"}},{"_part_custom_field4":{$eq:"PTO HW MODEL"}}
  const fromItemMaster = await getTableInfo(
    "_parts",
    '{$and:[{$or:[{"_part_custom_field4":{$eq:"ATO"}},{"_part_custom_field4":{$eq:"PTO HW MODEL"}}]},{"_part_custom_field22":{$regex:".*HWCPQ.*"}}]}'
  );

  // hwPartAttributes
  const fromHwPartAttributes = await getTableInfo("hwPartAttributes");

  // hwATOconfigModel
  const fromHwAtoConfigModel = await getTableInfo("hwATOconfigModel");

  // partToSMH
  const fromPartToSmh = await getTableInfo('partToSMH');

  // partToSMH
  const fromWwSMHierarchy = await getTableInfo('hwSMHierarchy');

  let results;

  results = alasql(
    `SELECT * FROM ? AS l INNER JOIN ? AS r ON l.partNumber = r.part_number`,
    [fromItemMaster.rawData, fromHwPartAttributes.rawData]
  );
  results = alasql(
    `SELECT * FROM ? AS l FULL OUTER JOIN ? AS r ON l.partNumber = r.configuration`,
    [results, fromHwAtoConfigModel.rawData]
  );
  results = alasql(
      `SELECT * FROM ? AS l LEFT JOIN ? AS r ON l.partNumber = r.part_number`,
      [results, fromPartToSmh.rawData]
  );
  results = alasql(
      `SELECT * FROM ? AS l LEFT JOIN ? AS r ON l.smh_code_child = r.smh_code_child`,
      [results, fromWwSMHierarchy.rawData]
  );
  results = alasql(
      `SELECT l.*, r.smh_desc_child as smh_desc_parent FROM ? AS l LEFT JOIN ? AS r ON l.smh_code_parent = r.smh_code_child`,
      [results, fromWwSMHierarchy.rawData]
  );
  console.log("Result", results);


  const activeModels = results.filter(
    (r, i) =>
      r.partNumber &&
      r._part_custom_field12.match(/active|restricted/i) &&
      (r.is_active && r.is_active == "Y") &&
      results.map(mapObj => mapObj.partNumber).indexOf(r.partNumber) === i // Removes duplicates due to multi SMH
  ).sort((m1,m2) => {
      return m1.product_line < m2.product_line ? -1 :
      m1.product_line > m2.product_line ? 1 :
      m1.hw_config_position < m2.hw_config_position ? -1 :
      m1.hw_config_position > m2.hw_config_position ? 1 : 0;
  });
  const inItemMasterNotJet = results.filter(
    (r, i) =>
      r.partNumber &&
      r._part_custom_field12.match(/active|restricted/i) &&
      (!r.is_active || r.is_active == "N") &&
      results.map(mapObj => mapObj.partNumber).indexOf(r.partNumber) === i // Removes duplicates due to multi SMH
  );
  const inJetNotItemMaster = results.filter(
    (r, i) =>
      (!r.partNumber || !r._part_custom_field12.match(/active|restricted/i)) &&
      r.is_active && r.is_active == "Y" &&
      results.map(mapObj => mapObj.partNumber).indexOf(r.partNumber) === i // Removes duplicates due to multi SMH
  );

  $("#ato-punch-outs-validation-results").empty();
  renderResultCategory(
    "ato-punch-outs-validation-results",
    "Active Models",
    activeModels,
    ["partNumber", "description", "_part_custom_field4", "_part_custom_field12", "last_order_date", "smh_code_child", "smh_desc_child", "smh_code_intermediat", "smh_code_parent", "smh_desc_parent", "product_line_label", "hw_config_position"]
  );
  renderResultCategory(
    "ato-punch-outs-validation-results",
    "Records active\/restricted in Item Master but not active in Jet UI",
    // Filter out models that expired based on LOD
    inItemMasterNotJet.filter(r => r.last_order_date == null || moment().isSameOrBefore(moment(r.last_order_date, "DD-MMM-YY").format("YYYY-MM-DD"))),
    ["partNumber", "description", "_part_custom_field4", "_part_custom_field12", "last_order_date", "smh_code_child", "smh_desc_child", "smh_code_intermediat", "smh_code_parent", "smh_desc_parent"]
  );
  renderResultCategory(
    "ato-punch-outs-validation-results",
    "Records active in Jet UI but not active\/restricted in Item Master",
    inJetNotItemMaster,
    ["configuration", "product_line", "hw_config_position", "smh_code_child", "smh_desc_child", "smh_code_intermediat", "smh_code_parent", "smh_desc_parent"]
  );
}

function showValidator() {
  "use strict";
  var modalHtml = `
    <!-- Modal -->
    <div class="modal fade" id="atoModelPunchoutModal" tabindex="-1" role="dialog" aria-labelledby="atoModelPunchoutModalLabel">
      <div class="modal-dialog modal-xl" role="document">
        <div class="modal-content d-none">
          <div class="modal-header">
            <h5 class="modal-title" id="exampleModalLabel">CPQ ATO Model Punch-out Validator</h5>
            <button type="button" class="close" data-dismiss="modal" aria-label="Close">
              <span aria-hidden="true">&times;</span>
            </button>
          </div>
          <div class="modal-body">
            <div id="ato-punch-outs-validation-results" class="accordion">Validating...</div>
          </div>
          <div class="modal-footer">
            <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
          </div>
        </div>
      </div>
    </div>
  `;

  var okToolBar = document.querySelector("ul.ok-tool-bar");
  if (!okToolBar) {
    okToolBar = document.createElement("ul");
    okToolBar.className = "ok-tool-bar";
    $("body").append(okToolBar);
  }

  var showPerfStatsBtn = document.createElement("li");
  var text = document.createTextNode("Validate ATO Punch-outs");
  showPerfStatsBtn.appendChild(text);
  showPerfStatsBtn.id = "btn-btn-ato-validator";
  showPerfStatsBtn.className = "btn-ato-validator";
  showPerfStatsBtn.dataset.toggle = "modal";
  showPerfStatsBtn.dataset.target = "#atoModelPunchoutModal";

  //--- Add nodes to page
  okToolBar.append(showPerfStatsBtn);
  $("body").append(modalHtml);

  $("#btn-btn-ato-validator").on("click", function() {
    $(".modal-content").removeClass("d-none");
    validatePunchOuts();
  });
}

// Don't run on login page
const notLoginPage = !document.querySelector("#login-form .login-links");

if (notLoginPage) {
  $("head").append(
    '<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css">'
  );
  showValidator();
}

// Styling Stuff
var bm_css_src = `
ul.ok-tool-bar {
  position: fixed;
  bottom: 0;
  right: 10%;
  margin: 0;
}
ul.ok-tool-bar li {
  display: inline-block;
  padding: 3px 6px;
  margin: 0 3px;
  font-size: 11px;
  white-space: nowrap;
  vertical-align: middle;
  -ms-touch-action: manipulation;
  cursor: pointer;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
  background-image: none;
  border: 1px solid transparent;
  border-radius: 5px 5px 0 0;
}
li.btn-ato-validator {
  color: #fff;
  background-color: #A64123;
  border-color: #A64123;
}

body {
  margin: auto;
}

#atoModelPunchoutModal .modal-xl {
  margin: 0;
  width: 100%;
  max-width:100%;
}

.wrap-collabsible {
  margin-bottom: 1.2rem 0;
}

input.toggle[type='checkbox'] {
  display: none;
}

.lbl-toggle {
  display: block;
  padding: 5px 10px;
  color: #333;
  background: #ddd;
  cursor: pointer;
  transition: all 0.25s ease-out;
}

.lbl-toggle:hover {
  color: #7C5A0B;
}

.lbl-toggle::before {
  content: ' ';
  display: inline-block;

  border-top: 5px solid transparent;
  border-bottom: 5px solid transparent;
  border-left: 5px solid currentColor;
  vertical-align: middle;
  margin-right: .7rem;
  transform: translateY(-2px);

  transition: transform .2s ease-out;
}

.toggle:checked + .lbl-toggle::before {
  transform: rotate(90deg) translateX(-3px);
}

.collapsible-content {
  max-height: 0px;
  overflow: hidden;
  transition: max-height .25s ease-in-out;
}

.toggle:checked + .lbl-toggle + .collapsible-content {
  max-height: none;
}

.toggle:checked + .lbl-toggle {
  border-bottom-right-radius: 0;
  border-bottom-left-radius: 0;
}

.collapsible-content .content-inner {
  padding: .5rem 1rem;
}
`;
// eslint-disable-next-line no-undef
GM_addStyle(bm_css_src);