@cityssm/faster-unofficial-api
Version:
An unofficial API for the FASTER Web fleet management system relying on Puppeteer scripts, exported reports, and complex parsers.
451 lines (363 loc) • 13.2 kB
text/typescript
import fasterInventoryItemConstants from '@cityssm/faster-constants/inventory/items'
import {
type FasterReportExporterOptions,
FasterReportExporter
} from '@cityssm/faster-report-exporter'
import { csvReports, xlsxReports, xmlReports } from '@cityssm/faster-report-parser'
import { minutesToMillis } from '@cityssm/to-millis'
import Debug from 'debug'
import { DEBUG_NAMESPACE } from './debug.config.js'
import { delay, deleteFile } from './utilities.js'
const debug = Debug(`${DEBUG_NAMESPACE}:index`)
export type FasterUnofficialAPIOptions = Omit<
FasterReportExporterOptions,
'downloadFolderPath'
>
const timeoutMillis = minutesToMillis(1)
export class FasterUnofficialAPI {
readonly #fasterReportExporter: FasterReportExporter
/**
* Initialize the Faster Unofficial API
* @param fasterTenantOrBaseUrl - The subdomain of the FASTER Web URL before ".fasterwebcloud.com"
* or the full domain and path including "/FASTER"
* @param fasterUserName - The username to log in with
* @param fasterPassword - The password to log in with
* @param options - Options
*/
constructor(
fasterTenantOrBaseUrl: string,
fasterUserName: string,
fasterPassword: string,
options: Partial<FasterUnofficialAPIOptions> = {}
) {
this.#fasterReportExporter = new FasterReportExporter(
fasterTenantOrBaseUrl,
fasterUserName,
fasterPassword,
options
)
}
/**
* Retrieves a list of assets using the W114 report.
* @returns A list of assets
*/
async getAssets(): Promise<xlsxReports.W114AssetReportData[]> {
debug('Exporting asset list...')
const assetReportPath =
await this.#fasterReportExporter.exportAssetList('Excel')
debug(`Asset list exported: ${assetReportPath}`)
debug('Parsing asset report...')
const report = xlsxReports.parseW114ExcelReport(assetReportPath)
debug(`Asset report parsed with ${report.data.length} asset(s).`)
await deleteFile(assetReportPath)
return report.data
}
/**
* Retrieves a list of inventory items using the W200 report.
* @returns A list of inventory items, grouped by storeroom
*/
async getInventory(): Promise<xlsxReports.W200StoreroomReportData[]> {
debug('Exporting inventory report...')
const inventoryReportPath =
await this.#fasterReportExporter.exportInventory('Excel')
debug(`Inventory report exported: ${inventoryReportPath}`)
debug('Parsing inventory report...')
const report = xlsxReports.parseW200ExcelReport(inventoryReportPath)
debug(`Inventory report parsed with ${report.data.length} storeroom(s).`)
await deleteFile(inventoryReportPath)
return report.data
}
/**
* Retrieves a work order using the W399 report.
* @param workOrderNumber - The work order number to retrieve.
* @returns The work order details.
*/
async getWorkOrder(workOrderNumber: number): Promise<xmlReports.W399TechnicianWorkOrderXmlResults> {
debug(`Exporting work order ${workOrderNumber}...`)
const workOrderPath =
await this.#fasterReportExporter.exportWorkOrderTechnicianPrint(workOrderNumber, 'XML')
debug(`Work order exported: ${workOrderPath}`)
debug('Parsing work order report...')
const report = await xmlReports.parseW399TechnicianWorkOrder(workOrderPath)
debug(
`Work order report parsed.`
)
await deleteFile(workOrderPath)
return report
}
/**
* Updates an inventory item, truncating fields where necessary.
* @see {@link https://github.com/cityssm/node-faster-constants/blob/main/inventory/items.ts|@cityssm/faster-constants - Inventory Item Constants} for truncate lengths.
* @param itemNumber - The item number of the inventory item to update.
* @param storeroom - The storeroom of the inventory item to update.
* @param fieldsToUpdate - The fields to update.
* @param fieldsToUpdate.itemName - The updated item name.
* @param fieldsToUpdate.itemDescription - The updated item description.
* @param fieldsToUpdate.binLocation - The updated bin location.
* @param fieldsToUpdate.alternateLocation - The updated alternate location.
* @returns `true` if the inventory item was updated, `false` if not.
*/
async updateInventoryItem(
itemNumber: string,
storeroom: string,
fieldsToUpdate: {
itemName?: string
itemDescription?: string
binLocation?: string
alternateLocation?: string
}
): Promise<boolean> {
if (Object.keys(fieldsToUpdate).length === 0) {
debug('No fields to update.')
return false
}
if (
(fieldsToUpdate.itemName ?? '').length >
fasterInventoryItemConstants.itemName.maxLength
) {
debug(
`Item name exceeds maximum length of ${fasterInventoryItemConstants.itemName.maxLength} and will be truncated.`
)
}
if (
(fieldsToUpdate.binLocation ?? '').length >
fasterInventoryItemConstants.binLocation.maxLength
) {
debug(
`Bin location exceeds maximum length of ${fasterInventoryItemConstants.binLocation.maxLength} and will be truncated.`
)
}
if (
(fieldsToUpdate.alternateLocation ?? '').length >
fasterInventoryItemConstants.alternateLocation.maxLength
) {
debug(
`Alternate location exceeds maximum length of ${fasterInventoryItemConstants.alternateLocation.maxLength} and will be truncated.`
)
}
const { browser, page } =
await this.#fasterReportExporter._getLoggedInFasterPage()
try {
await page.goto(
this.#fasterReportExporter.fasterUrlBuilder.inventorySearchUrl(
itemNumber,
true
),
{
timeout: timeoutMillis
}
)
await page.waitForNetworkIdle({
timeout: timeoutMillis
})
// No results or more than one result.
// Not supported
if (
page
.url()
.startsWith(
this.#fasterReportExporter.fasterUrlBuilder.inventorySearchUrl()
)
) {
debug('Either no results or multiple results found. Not supported.')
return false
}
/*
* Verify the storeroom
*/
const headingText =
(await page.$eval(
// eslint-disable-next-line no-secrets/no-secrets
'#RAD_SPLITTER_PANE_CONTENT_ctl00_ContentPlaceHolder_Content_panetop h1',
(heading) => heading.textContent
)) ?? ''
if (!headingText.trim().endsWith(`[${storeroom}]`)) {
debug('Item not found in the specified storeroom.')
return false
}
/*
* Get the item id
*/
const pageUrlString = page.url()
const pageUrl = new URL(pageUrlString)
const itemId = pageUrl.searchParams.get('id') ?? ''
if (itemId === '') {
debug('Item ID not found.')
return false
}
/*
* Open the item identification update form
*/
const formUrl = `${this.#fasterReportExporter.fasterUrlBuilder.baseUrl}/Domains/Parts/PartDetail/IdentificationEdit.aspx?id=${itemId}`
await page.goto(formUrl, {
timeout: timeoutMillis
})
await page.waitForNetworkIdle({
timeout: timeoutMillis
})
/*
* Update the fields
*/
if (fieldsToUpdate.itemName !== undefined) {
await page.$eval(
`#${fasterInventoryItemConstants.itemName.inputId}`,
(itemNameTextBox: HTMLInputElement, itemName) => {
itemNameTextBox.value = itemName
},
fieldsToUpdate.itemName.slice(
0,
fasterInventoryItemConstants.itemName.maxLength
)
)
}
if (fieldsToUpdate.itemDescription !== undefined) {
await page.$eval(
'#PartDescriptionRadTextBox',
(itemDescriptionTextBox: HTMLTextAreaElement, itemDescription) => {
itemDescriptionTextBox.value = itemDescription
},
fieldsToUpdate.itemDescription
)
}
if (fieldsToUpdate.binLocation !== undefined) {
await page.$eval(
`#${fasterInventoryItemConstants.binLocation.inputId}`,
(binLocationTextBox: HTMLInputElement, binLocation) => {
binLocationTextBox.value = binLocation
},
fieldsToUpdate.binLocation.slice(
0,
fasterInventoryItemConstants.binLocation.maxLength
)
)
}
if (fieldsToUpdate.alternateLocation !== undefined) {
await page.$eval(
`#${fasterInventoryItemConstants.alternateLocation.inputId}`,
(alternateLocationTextBox: HTMLInputElement, alternateLocation) => {
alternateLocationTextBox.value = alternateLocation
},
fieldsToUpdate.alternateLocation.slice(
0,
fasterInventoryItemConstants.alternateLocation.maxLength
)
)
}
/*
* Save the form
*/
await page.$eval('#SaveTopButton', (saveButton: HTMLButtonElement) => {
saveButton.click()
})
await delay()
await page.waitForNetworkIdle({
timeout: timeoutMillis
})
return true
} finally {
try {
await browser.close()
} catch {
// Ignore errors
}
}
}
/**
* Retrieves the message log using the W603 report.
* @param startDate - The start date of the message log to retrieve.
* @param endDate - The end date of the message log to retrieve. Defaults to `startDate`.
* @returns The message log.
*/
async getMessageLog(
startDate: Date,
endDate?: Date
): Promise<csvReports.W603ReportRow[]> {
debug('Exporting message log...')
const messageLogPath = await this.#fasterReportExporter.exportMessageLogger(
startDate,
endDate ?? startDate,
'CSV'
)
debug(`Message log exported: ${messageLogPath}`)
debug('Parsing message log...')
const report = await csvReports.parseFasterCsvReport(
messageLogPath,
csvReports.fasterCsvReportOptions.w603
)
debug(`Message log parsed with ${report.data.length} message(s).`)
await deleteFile(messageLogPath)
return report.data
}
/**
* Executes an integration by name.
* @param integrationName - The name of the integration to execute.
* @returns `true` if the integration was executed, `false` if not.
*/
async executeIntegration(integrationName: string): Promise<boolean> {
const { browser, page } =
await this.#fasterReportExporter._getLoggedInFasterPage()
try {
await page.goto(
this.#fasterReportExporter.fasterUrlBuilder.integrationsUrl,
{
timeout: timeoutMillis
}
)
await page.waitForNetworkIdle({
timeout: timeoutMillis
})
// Find the integration row
const integrationTableRowElements = await page.$$(
// eslint-disable-next-line no-secrets/no-secrets
'#ctl00_ContentPlaceHolder_Content_IntegrationRadDock_C_IntegrationRadGrid_ctl00 tbody tr'
)
for (const integrationTableRowElement of integrationTableRowElements) {
const integrationNameElement =
await integrationTableRowElement.$('td:nth-child(1) a')
if (integrationNameElement === null) {
continue
}
const integrationNameText = await integrationNameElement.evaluate(
(cell) => cell.textContent
)
if (integrationNameText === integrationName) {
debug(`Integration found: ${integrationName}`)
const integrationActionLinkElements =
await integrationTableRowElement.$$('td:nth-child(3) a')
for (const integrationActionLinkElement of integrationActionLinkElements) {
const integrationActionLinkText =
await integrationActionLinkElement.evaluate(
(cell) => cell.textContent
)
if (integrationActionLinkText === 'Execute') {
debug(`Executing integration: ${integrationName}`)
await integrationActionLinkElement.click()
await delay()
await page.waitForNetworkIdle({
timeout: timeoutMillis
})
return true
}
}
}
}
} finally {
try {
await browser.close()
} catch {
// Ignore errors
}
}
return false
}
}
export const integrationNames = {
inventoryImportUtility: 'Inventory Import Utility'
}
export const parser = {
csvReports,
xlsxReports
}
export const exporter = {
FasterReportExporter
}