Table of Contents
Background
Since 1987, Microsoft Excel has been and remains to this day an extremely useful spreadsheet application for the organization, analysis, storage, and visualization of data in tabular format. Automation of data became a key addition to Excel in 1993 with the introduction of the (V)isual (B)asic for (A)pplications (VBA) macro programming language, an implementation of Microsoft’s earlier 1991 event-driven Visual Basic (VB6).
Today, Excel proficiency is a common baseline requirement for many employment opportunities in various fields; in the modern digital age, the automation of data is paramount for companies and other organizations to remain aloft and perform routine business functions - especially if these functions require arithmetic calculation for statistical analysis of data.
In 2015, I was hired as a software engineer intern to automate workflow productivity for the design and planning phases of utility installation project timelines across multiple customer support teams at the company. I was also tasked to design a method of forecasting future employee workload based on statistical analysis of predictable project metrics made available from prior fiscal year data - this would help the customer support teams preemptively mitigate imbalances while moving forward into the current fiscal year.
Unsurprisingly, this data was automatically generated and stored in various Excel documents by SAP, the company’s backend data processing software. Upon inspection of this raw data, I formulated multiple ideas for automation and efficiency improvement after a few meetings with my internship supervisor, and I got to work.
Implementation
Save for a few select projects I encountered throughout my internship, the majority of them were written in VBA macros. I did have one or two “one-off” objectives which involved some web development and direct integration with SAP - nevertheless, all of them had me writing VBA code at one point or another due to the company’s reliance on Excel.
Employee Task Completion Productivity Report Tool
The first project I was assigned involved sorting, analyzing and categorizing anywhere from 18,000 to 35,000 rows of raw input data to produce generated charts and tables as a visualization aid to employee productivity. Based on a few reference tabs provided by my management, my original design included a linear search lookup table - this approach worked well, and despite meeting expectation, did not satisfy my need for speed and efficiency. Due to the scale of the input, the implementation of the linear search (i.e. O(n)), and the generation of the charts, the program execution time took anywhere from 18 to 25 minutes to complete.
After a meeting with my supervisor, I mentioned I had already thought of several enhancements I could incorporate into a second iteration of the macro - including dismissal of the linear search for a hashmap (i.e. O(1), key -> value) structure (these are referred to as “Dictionaries” in VBA) and caching the reference data into memory. After a brief explanation of these plans, I was actually able to make the changes during the meeting and demonstrate the improvements. As it turned out, the dictionary structures combined with the reference caches trimmed the execution time down to 3 minutes, equating to an approximate 86% improvement in efficiency.
An example of caching reference data into memory:
'Date Reference for getting due dates
Set datesMap = New Dictionary
With PROGRAM.Sheets("Date Reference")
For Each row In .Rows
If row.row <= .UsedRange.Rows.Count Then
If row.row > 1 Then
Dim k As String: k = Trim(.Cells(row.row, 1)) & "^"
datesMap(k & "apr") = Trim(.Cells(row.row, 3))
datesMap(k & "may") = Trim(.Cells(row.row, 4))
datesMap(k & "jun") = Trim(.Cells(row.row, 5))
datesMap(k & "jul") = Trim(.Cells(row.row, 6))
datesMap(k & "aug") = Trim(.Cells(row.row, 7))
datesMap(k & "sep") = Trim(.Cells(row.row, 8))
End If
Else
Exit For
End If
Next row
End With
An example of iterating through each row of raw input data:
For Each row In sheet.Rows
'If row contains data
If row.row <= sheet.UsedRange.Rows.Count Then
'Logic here
Else
Exit For
End If
Next row
An example of copying and pasting rows into separate sheets to handle sorting, based solely on a single column provided in the input data:
'Copy row
sheet.Range(sheet.Cells(row.row, 1), sheet.Cells(row.row, 31)).Copy
'Sort
Select Case Right(Trim(sheet.Cells(row.row, 16).Text), 4)
Case "DATA1"
For i = 1 To REPORT.Sheets.Count
If Left(REPORT.Sheets(i).Name, 2) = "D-" Then
With REPORT.Sheets(i)
.Range(.Cells(.UsedRange.Rows.Count + 1, 2), .Cells(.UsedRange.Rows.Count + 1, 32)).PasteSpecial
.Cells(.UsedRange.Rows.Count, 1) = Left(Trim(refMap(refNum)), 3)
End With
End If
Next i
Case "DATA2"
With D2
.Range(.Cells(.UsedRange.Rows.Count + 1, 2), .Cells(.UsedRange.Rows.Count + 1, 32)).PasteSpecial
.Cells(.UsedRange.Rows.Count, 1) = Left(Trim(refMap(refNum)), 3)
End With
...
End Select
A subroutine used to sort all columns between two specified columns based on a third column provided in a spreadsheet (this sub is used throughout all my macros):
'
' Sub: sortBasedOnColumn
' Params: sheet (Worksheet),
' firstColumn (String),
' secondColumn (String),
' sortColumn (String),
' startRow (String)
' This will sort all columns between 'firstColumn' and 'secondColumn' with respect to
' specified 'sortColumn' starting at row 'startRow' on worksheet 'sheet'.
'
Public Sub sortBasedOnColumn(ByVal sheet As Worksheet, firstColumn As String, secondColumn As String, sortColumn As String, startRow As String)
Dim lastRow As String
lastRow = sheet.Range("$" & secondColumn & "$" & sheet.Range("$" & secondColumn & "$" & sheet.Rows.Count).End(xlUp).row).row & ""
Dim lastColumn As String
lastColumn = sheet.Range("$" & secondColumn & "$" & sheet.Range("$" & secondColumn & "$" & sheet.Rows.Count).End(xlUp).row).column & ""
With sheet
.Activate
Range("$" & firstColumn & "$" & startRow, Range(secondColumn & lastRow)).Select
.Sort.SortFields.Clear
.Sort.SortFields.Add Key:=Range("$" & sortColumn & "$" & startRow), _
SortOn:=xlSortOnValues, Order:=xlAscending, DataOption:=xlSortNormal
With .Sort
.SetRange Range("$" & firstColumn & "$" & startRow, Range(secondColumn & lastRow))
.Header = xlNo
.MatchCase = False
.Orientation = xlTopToBottom
.SortMethod = xlPinYin
.Apply
End With
.Cells(1, 1).Select
End With
End Sub
An example of saving output:
'Save PROGRAM file
PROGRAM.Save
'Save output
outputLocation = Application.GetSaveAsFilename(PROGRAM.Path & "\" & reportName, filefilter:="Excel Files (*.xlsx), *.xlsx", title:="Save output")
REPORT.SaveAs fileName:=outputLocation, FileFormat:=xlOpenXMLWorkbook
Besides sorting the data into individual tabs, color-coded charts were produced as output and may have looked like the following:
These color-coded visual representations of data and related metrics helped the company’s customer support teams hone in on imbalances to meet project goal deadlines and improve customer experience by extension.
Employee Workload Forecasting Tool
Similar to the Employee Task Completion Productivity Report Tool, I was tasked with developing a preemptive workload calculator to forecast predictable workload prior to the beginning of the project season. This program would not rely on raw data as input, but rather a few numerical values which would be fed into several equations and plotted as a multi-bar chart across a future date range - in this way, the project manager would be able to mitigate imbalances before they could occur throughout the entire year.
One of these functions may have looked like this:
'
' Sub: doTIndex
' Params: worksheet (Worksheet), value (Integer), main (Integer), mon (String)
' This will run through the calculations for each T-milestone.
'
Private Sub doTIndex(ByVal worksheet As Worksheet, value As Integer, main As Integer, mon As String)
For Each k In totalsMap.Keys
Dim valueTX As Double
If CDbl(totalsMap(k)) > 0 Then
valueTX = CDbl(totalsMap(k)) * value
valueTX = valueTX / (CDate(dueDatesMap(k & "^" & LCase(mon))) - CDate(startDatesMap(k & "^" & LCase(mon))) + 1)
End If
Dim mainTX As Double
If CInt(mainMap(k)) > 0 Then
mainTX = CInt(mainMap(k)) * main
mainTX = mainTX / (CDate(dueDatesMap(k & "^" & LCase(mon))) - CDate(startDatesMap(k & "^" & LCase(mon))) + 1)
End If
Dim dateIndex As Date
For dateIndex = CDate(startDatesMap(k & "^" & LCase(mon))) To CDate(dueDatesMap(k & "^" & LCase(mon)))
With CES
.Cells(.UsedRange.Rows.Count + 1, 1) = dateIndex
.Cells(.UsedRange.Rows.Count, 2) = valueTX
.Cells(.UsedRange.Rows.Count, 3) = mainTX
End With
Next dateIndex
Next k
End Sub
An example of generating a graph:
'
' Sub: createGraph
' Params: book (Workbook), sheet (Worksheet), t (String), sourceRow1 (Integer), sourceCol1 (Integer), sourceRow2 (Integer), sourceCol2 (Integer)
' This will create a graph.
'
Private Sub createGraph(book As Workbook, ByVal sheet As Worksheet, t As String, sourceRow1 As Integer, sourceCol1 As Integer, sourceRow2 As Integer, sourceCol2 As Integer)
Dim g As Chart: Set g = book.Charts.Add
Set g = g.Location(Where:=xlLocationAsObject, name:=t)
With g
.SetSourceData sheet.Range(sheet.Cells(sourceRow1, sourceCol1), sheet.Cells(sourceRow2, sourceCol2)), xlRows
.ChartType = xlBarStacked
.HasTitle = True
.SetElement (msoElementChartTitleAboveChart)
.Parent.Top = 0
.Parent.Left = 0
.Parent.Height = 645
.Parent.Width = 925
.ChartTitle.Text = t
.SeriesCollection(1).Interior.ColorIndex = 44
.SeriesCollection(2).Interior.ColorIndex = 3
End With
addVerticalLineToGraph g, "Limit", CInt(limit), 102, 102, 153, True
End Sub
'
' Sub: addVerticalLineToGraph
' Params: c (Chart), title (String), n (Integer), red (Integer), blue (Integer), green (Integer), dashed (Boolean)
' This adds a vertical line to a chart 'c' at 'n' named 'title' with RGB color based on
' 'red', 'blue', and 'green', with boolean property 'dashed'.
'
Public Sub addVerticalLineToGraph(c As Chart, title As String, n As Integer, red As Integer, blue As Integer, green As Integer, dashed As Boolean)
Dim l As Series: Set l = c.SeriesCollection.NewSeries
With l
.ChartType = xlXYScatterLinesNoMarkers
.XValues = Array(n, n)
.Values = Array(0, 1)
.AxisGroup = xlSecondary
.name = title
.Format.Line.ForeColor.RGB = RGB(red, blue, green)
If dashed Then
.Format.Line.DashStyle = msoLineSysDash
End If
End With
c.HasAxis(XlAxisType.xlCategory, XlAxisGroup.xlSecondary) = True
Dim xAxis2 As Axis: Set xAxis2 = c.Axes(xlValue, xlSecondary)
With xAxis2
.MajorTickMark = xlNone
.TickLabelPosition = xlNone
.Format.Line.Visible = msoFalse
.MinimumScale = 0
.MaximumScale = 1
End With
c.HasAxis(XlAxisType.xlCategory, XlAxisGroup.xlSecondary) = False
End Sub
An example of a sub used to calculate start dates:
'
' Sub: calculateStartDates
' Params: N/A
' This calculates all start dates based on other milestone due dates.
'
Private Sub calculateStartDates()
Set startDatesMap = New Dictionary
For i = 0 To dueDatesMap.Count - 1
Dim k As String: k = dueDatesMap.Keys(i)
If i < 6 Then
'27 days representing 4 weeks + 1 day, which theoretically is (T-34 + 1) in this case
startDatesMap(k) = Trim(CDate(dueDatesMap(k)) - 27)
Else
'Handle special cases
If dueDatesMap(k) = dueDatesMap.Items(i - 6) Then
'If same due dates as previous, same start dates as previous
startDatesMap(k) = startDatesMap.Items(i - 6)
Else
'Look back 6 entries for previous milestone due date, add 1 to it
startDatesMap(k) = Trim(CDate(dueDatesMap.Items(i - 6)) + 1)
End If
End If
Next i
End Sub
An example of the simple calculator interface:
An example of graph output (36 hour horizontal line representing 1 work week):
Customer Project Phase Timeline
In an effort to provide visualization to the planning phases of customer project timelines, I was tasked in 2016 to create an interface capable of interacting directly with SAP. The original idea was to build this interface in Excel and hook into SAP and automate interaction with VBA macros - essentially, this project would be a sort of “frontend” for the company’s backend data processor. After meeting with the project manager to discuss options, I mentioned the possibility of creating a web-based frontend rather than one contained inside Excel - at the time, I was uncertain if this would even be feasible. I did not know if there was any way to hook into SAP directly nor if it would be possible to execute VBA macros from the browser.
Regardless, I accepted the challenge and told my project manager I would give it a shot (he really liked the idea of a web interface for ease of usability). I designed a simple web UI around the vis.js
dynamic visualization library (now deprecated to visjs
) by almende
to add an adjustable timeline (see example here). Specifically, my createTimeline()
function may have looked like the following:
/* Creates timeline when page loads */
function createTimeline() {
var container = document.getElementById('timeline');
var today = new Date(getToday() + ' 00:00:00');
items = new vis.DataSet([
{ id: 1, content: 'INITIATION', start: (new Date().setDate(today.getDate() - 3)), style: "background-color: #a589d9; border-color: #8c68cb;" },
{ id: 2, content: 'SCOPING', start: (new Date().setDate(today.getDate() - 2)), style: "background-color: #f16d64; border-color: #ec4339;" },
{ id: 3, content: 'DESIGN', start: (new Date().setDate(today.getDate() - 1)), style: "background-color: #f59640; border-color: #f47b16;" },
{ id: 4, content: 'PREREQUISITES', start: (new Date().setDate(today.getDate())), style: "background-color: #35bec1; border-color: #00aeb3;" },
{ id: 5, content: 'SCHEDULING', start: (new Date().setDate(today.getDate() + 1)), style: "background-color: #f371af; border-color: #ed4795;" },
{ id: 6, content: 'EXECUTION', start: (new Date().setDate(today.getDate() + 2)), style: "background-color: #95c753; border-color: #7cb82f;" }
]);
options = { stack: false, height: '200px', selectable: false, hiddenDates: {}, align: 'center' };
timeline = new vis.Timeline(container, items, options);
}
Each dataset ID in the items
parameter passed to new vis.Timeline()
corresponds to the initial value given to each project phase when the page loads in the browser. I added sliders to adjust these values, which are also controlled in JavaScript:
<div id="sliders">
<form>
<div id="scoping_wrapper">
<label for="scoping">SCOPING:<label>
<div class="scoping_popup" style="display: none;">
Initial site meeting or review, obtaining service characteristics and customer’s load information and all other information pertinent to design.
</div>
</div>
<span id="scoping_lock" style="color: green;">🔓</span>
<input type="range" name="scoping" id="scoping" data-highlight="true" data-show-value="true" value="0" min="0" max="50">
</form>
...
</div>
/* Scoping */
function getScoping() {
scoping = $('#scoping').val();
return scoping;
}
function setScoping(i) {
scoping = i;
$('#scoping').val(i).slider('refresh');
}
/* Updates timeline */
function updateTimeline() {
// Items
var initiationItem = items.get(1);
var scopingItem = items.get(2);
var designItem = items.get(3);
var prerequisitesItem = items.get(4);
var schedulingItem = items.get(5);
var executionItem = items.get(6);
// Base date
var base = new Date($('#date_picker').val() + ' 00:00:00');
// Backward
prerequisitesItem.end = dateOffset(base, 0);
prerequisitesItem.start = dateOffset(base, -getPrerequisites());
designItem.end = (new Date(prerequisitesItem.start));
designItem.start = dateOffset(designItem.end, -getDesign());
scopingItem.end = (new Date(designItem.start));
scopingItem.start = dateOffset(scopingItem.end, -getScoping());
initiationItem.end = (new Date(scopingItem.start));
initiationItem.start = dateOffset(initiationItem.end, -getInitiation());
// Forward
schedulingItem.start = (new Date(prerequisitesItem.end));
schedulingItem.end = dateOffset(schedulingItem.start, getScheduling());
executionItem.start = (new Date(schedulingItem.end));
executionItem.end = dateOffset(executionItem.start, getExecution());
// Update
items.update(initiationItem);
items.update(scopingItem);
items.update(designItem);
items.update(prerequisitesItem);
items.update(schedulingItem);
items.update(executionItem);
// Options
timeline.setOptions(options);
// Window
if(!(isVisibleWeekends())) {
timeline.setWindow(dateOffset(initiationItem.start, -1), dateOffset(executionItem.end, 1));
} else {
var ini = new Date().setTime(initiationItem.start.getTime() + (-1) * 86400000);
var exe = new Date().setTime(executionItem.end.getTime() + 1 * 86400000);
timeline.setWindow(ini, exe);
}
}
This interface allows the employee to adjust the various phases of a project based on an individual customer’s needs and deadlines. However, without some way to integrate with SAP, the user is only able to view the date deadlines for each phase but not automatically submit them into the data processor (i.e. this would still need to be completed manually). I figured the best idea would be to write the SAP form submission macros in VBA and then finally find a way to call them from the web UI (from the outside looking in, this sounds impossible).
Nevertheless I started writing the macros used to submit updated deadline information based on the “Notification” selector in the web UI. To do so, I added a common VBA module shared across the Internet for integrating with SAP and modified it to fit my needs. On top of this integration, I also wanted to implement multithreading, a practice normally non-existent in VBA but made possible by automatically cloning the workbook and calling the macros from the original as many times as needed to run tasks in parallel. If this sounds “hacky” that’s because it certainly is - but it works, and it worked for this project.
'
' Function: setup
' Params: ses (Variant)
' This sub will act as the driver for opening and preparing SAP for automation.
' It will return the session for interaction / automation.
'
Public Function setup(Optional ses As Variant) As SAPFEWSELib.GuiSession: On Error GoTo ErrHandler
'Open IE / SAP if needed
openSAP
'New session
Dim Session As SAPFEWSELib.GuiSession
'Initial setup and connection
If App Is Nothing Then
Set SapGuiAuto = GetObject("SAPGUI")
Set App = SapGuiAuto.GetScriptingEngine
End If
If Connection Is Nothing Then Set Connection = App.Children(0)
If Session Is Nothing Then
If Not IsMissing(ses) Then
Set Session = Connection.Children(CInt(ses))
Else
Set Session = Connection.Children(CInt(getSessionCount))
End If
End If
If IsObject(WScript) Then
WScript.ConnectObject Session, "on"
WScript.ConnectObject Application, "on"
End If
Set setup = Session
Exit Function
'Error handling
ErrHandler: errHandle Session, Err.Number, Err.Description
End Function
'
' Sub: openSAP
' Params: N/A
' This sub will open Internet Explorer and open SAP by visiting the link for SAP in the portal.
' It will then close Internet Explorer and wait 5 seconds before continuing automation.
'
Public Sub openSAP()
'Check if SAP is already open
If isExeRunning("saplogon.exe") Then Exit Sub
'Automate IE to open SAP
Dim IE As Object: Set IE = GetObject("new:{D5E8041D-920F-45e9-B8FB-B1DEB82C6E5E}") 'Set IE = CreateObject("InternetExplorer.Application")
With IE
ShowWindow .hwnd, SW_MAXIMIZE
.Visible = True
.Navigate "http://some.link.here/", 0
.Navigate "irj/servlet/prt/portal/prteventname/Navigate/prtroot/pcd!3aportal_content!2fevery_user!2fgeneral!2fcom.Z_Desktop_0!2fframeworkPages!2fcom.frameworkpage!2fcom.sap.portal.innerpage!2fcom.sap.portal.contentarea?NavigationTarget=navurl%3A%2F%2Fa280b87640b394a02c0f69de77efa77d&ExecuteLocally=true&CurrentWindowId=WID1370809905537&NavMode=1", 2048&
End With
'Wait until SAP has opened
While Not isExeRunning("saplogon.exe"): DoEvents: Wend
rest 40
IE.Quit
End Sub
'
' Function: isExeRunning
' Params: sExeName (String), sComputer (String)
' This will check to see if a certain process 'sExeName' is running in Windows.
' It is used in this project for instance to check whether or not SAP has loaded.
'
Public Function isExeRunning(sExeName As String, Optional sComputer As String = ".") As Boolean
On Error GoTo Error_Handler
Dim obj As Object: Set obj = GetObject("winmgmts:{impersonationLevel=impersonate}!\\" & sComputer & "\root\cimv2").ExecQuery("SELECT * FROM Win32_Process WHERE Name = '" & sExeName & "'")
If obj.Count <> 0 Then isExeRunning = True
Error_Handler_Exit:
On Error Resume Next
Set obj = Nothing
Exit Function
Error_Handler:
Resume Error_Handler_Exit
End Function
'
' Function: getSessionCount
' Params: N/A
' Returns the number of sessions.
'
Public Function getSessionCount() As Integer
getSessionCount = Connection.Children.Count - 1
End Function
'
' Function: elementExists
' Params: Session (SAPFEWSELib.GuiSession), id (String)
' Returns true if element with ID 'id' exists in Session 'Session', false otherwise.
'
Public Function elementExists(Session As SAPFEWSELib.GuiSession, id As String) As Boolean: On Error Resume Next
Session.FindById(id).SetFocus
If Err.Number = 0 Then
elementExists = True
Else
elementExists = False
End If
End Function
'
' Sub: errHandle
' Params: Session (SAPFEWSELib.GuiSession), (Optional) num (Integer), (Optional) message (String)
' Handle errors in SAP session.
'
Private Sub errHandle(Session As SAPFEWSELib.GuiSession, Optional num As Integer, Optional message As String)
If Not Session Is Nothing Then
Session.FindById("wnd[0]").Close
Set Session = Nothing
End If
'Kill SAP
killSAP
'Failure
writeReturn "3", message
End Sub
To handle killing processes in the multi-threaded environment, I wrote two subs named killSAP
(generates and executes a .bat
script using taskkill /F /IM sapgui.exe /T
to terminate the SAP process) and killExcel
(same logic as killSAP
but targeting excel.exe
):
'
' Sub: killSAP
' Params: N/A
' This sub kills any SAP processes.
'
Public Sub killSAP()
Dim fso As Object: Set fso = CreateObject("Scripting.FileSystemObject")
Dim killBat As Object: Set killBat = fso.CreateTextFile(ThisWorkbook.Path + "\kill_sap.bat")
killBat.WriteLine "taskkill /F /IM saplogon.exe /T"
killBat.WriteLine "taskkill /F /IM sapgui.exe /T"
killBat.Close: Set killBat = Nothing
Shell (ThisWorkbook.Path & "\kill_sap.bat"), vbHide
SetAttr (ThisWorkbook.Path & "\kill_sap.bat"), vbNormal: rest 3: Kill (ThisWorkbook.Path & "\kill_sap.bat")
End Sub
'
' Sub: killExcel
' Params: N/A
' This sub kills any Excel processes other than the one the sub is running from.
'
Public Sub killExcel()
Dim fso As Object: Set fso = CreateObject("Scripting.FileSystemObject")
Dim killBat As Object: Set killBat = fso.CreateTextFile(ThisWorkbook.Path + "\kill_excel.bat")
killBat.WriteLine "for /f ""skip=3 tokens=2 delims= "" %%a in ('tasklist /fi ""imagename eq excel.exe""') do ( if ""%%a"" neq """ & GetCurrentProcessId & """ ( TASKKILL /PID %%a /f >nul 2>nul ) )"
killBat.Close: Set killBat = Nothing
Shell (ThisWorkbook.Path & "\kill_excel.bat"), vbHide
SetAttr (ThisWorkbook.Path & "\kill_excel.bat"), vbNormal: rest 3: Kill (ThisWorkbook.Path & "\kill_excel.bat")
End Sub
For automating the actual specific interaction with SAP forms, SAP elements are identified using very long string paths, and are easiest to deduce using the “Script Recording and Playback…” functionality made available in Excel.
An example of automating a specific form in SAP:
'
' Sub: auto
' Params: N/A
' This is used for the customized / desired automation in SAP.
'
Private Sub auto(): On Error GoTo ErrHandler
With Session
'Enter IW52
.FindById("wnd[0]/tbar[0]/okcd").Text = "iw52"
.FindById("wnd[0]").SendVKey 0
'"Notification selection"
'Notification
.FindById("wnd[0]/usr/ctxtRIWO00-QMNUM").Text = priv_notif
'Enter
.FindById("wnd[0]").SendVKey 0
'If it fails here, notification was locked or no data was returned from SAP
On Error GoTo ErrHandlerLocked
'Switch to "Tasks" tab
.FindById("wnd[0]/usr/tabsTAB_GROUP_10/tabp10\TAB11").Select
'Return to normal error checking
On Error GoTo ErrHandler
'Iterate through the task list and update finish dates
Dim scroll As Integer: scroll = 0
For scroll = 0 To 100
.FindById("wnd[0]/usr/tabsTAB_GROUP_10/tabp10\TAB11/ssubSUB_GROUP_10:SAPLIQS0:7120/tblSAPLIQS0MASSNAHMEN_VIEWER").VerticalScrollbar.Position = scroll
If .FindById("wnd[0]/usr/tabsTAB_GROUP_10/tabp10\TAB11/ssubSUB_GROUP_10:SAPLIQS0:7120/tblSAPLIQS0MASSNAHMEN_VIEWER").VerticalScrollbar.Position <> scroll Then Exit For
Dim task As String: task = Trim(.FindById("wnd[0]/usr/tabsTAB_GROUP_10/tabp10\TAB11/ssubSUB_GROUP_10:SAPLIQS0:7120/tblSAPLIQS0MASSNAHMEN_VIEWER/ctxtVIQMSM-MNCOD[2,0]").Text)
Select Case task
'Scoping
Case "SCPE", "CSMC"
writeNewDate Session, getDateFormat(priv_scoping_date)
writeTask task
On Error GoTo ErrHandler
'Design
Case "REVW"
writeNewDate Session, getDateFormat(priv_design_date)
writeTask task
On Error GoTo ErrHandler
'Prerequisites
Case "ORFC", "PCST", "RCOM"
writeNewDate Session, getDateFormat(priv_prerequisites_date)
writeTask task
On Error GoTo ErrHandler
End Select
If elementExists(Session, "wnd[2]") Then
Do While elementExists(Session, "wnd[2]")
.FindById("wnd[2]/tbar[0]/btn[0]").press
Loop
End If
Next scroll
'Save
'.FindById("wnd[0]/tbar[0]/btn[11]").press
End With
'Close session
Session.FindById("wnd[0]").Close
At this point, I had automated SAP to update project phase deadline information in VBA but had not found a way to call the macro from the web UI. After a long day or two of research, I found a way to accomplish this task with PHP and AJAX (“Asynchronous JavaScript and XML”). This topology would appear as follows:
- HTML, CSS, JavaScript - frontend interface which the user sees and interacts with in their web browser; HTML is the layout of the page, CSS is the styling of the page, and JavaScript provides logic handling within the browser and sends AJAX requests to the server and waits for responses
- PHP - used on the server to handle incoming AJAX requests from clients and processes them; used in combination with Windows processes to automate Excel on the server
- VBA - used on the server to handle the automation and interaction with SAP; controlled via PHP, a macro
runIW52()
is called and is given the arguments passed in by the AJAX request from the browser
An example JavaScript function used to submit an AJAX request to the server:
/* Runs the VBA macro via PHP COM to update a notification in IW52 */
function runIW52(notif) {
$('#overlay').show();
$('.loader').show();
$('.checkmark').hide();
$('.errormark').hide();
$('#overlay_text').text('Updating task end dates for ' + notif + '...');
$('#overlay_text').css('color', 'rgba(0, 121, 193, 1)');
$('#overlay_text').show();
var scopingDate, designDate, prerequisitesDate;
for(var i = 1; i <= items.length; i++) {
var item = items.get(i);
switch(item.content) {
case 'SCOPING': scopingDate = getDateString(item.end); break;
case 'DESIGN': designDate = getDateString(item.end); break;
case 'PREREQUISITES': prerequisitesDate = getDateString(item.end); break;
}
}
// Send the information for the long running PHP script with COM
var id = 0;
$.ajax({
type: 'POST',
url: 'php/timeline.php',
data: {
notification: notif,
scoping_date: scopingDate,
design_date: designDate,
prerequisites_date: prerequisitesDate
},
success: function(returnJson) { id = JSON.parse(returnJson); }
})
// Check for when it finishes
var iteration = setInterval(function() {
$.ajax({
type: 'POST',
url: 'php/check.php',
data: { identifier: id },
success: function(returnJson) {
var returnData = JSON.parse(returnJson);
var result = returnData[0];
if(result) {
var returnCode = returnData[1];
var returnDesc = returnData[2];
var returnTask = returnData[3];
if(returnTask !== null && returnTask !== '') { returnTask = returnTask.split(';'); }
if(returnCode == 0) {
var tasks = '';
for(var i = 0; i < returnTask.length; i++) {
if(returnTask[i] !== '') {
tasks = tasks + returnTask[i];
if(i !== returnTask.length - 1) { tasks = tasks + ', '; }
}
}
$('#overlay_text').text('Success! Notification ' + notif + ' was updated!\nTasks: ' + tasks);
$('#overlay_text').css('color', 'lime');
$('.checkmark').show();
} else {
$('#overlay_text').text('Failure! ' + returnDesc);
$('#overlay_text').css('color', 'rgb(255, 25, 25)');
$('.errormark').show();
}
$('.loader').hide();
$('#overlay_text').show();
$('#overlay').one('click', function() {
$('#overlay').hide();
$('#overlay_text').hide();
});
clearInterval(iteration);
}
}
})
}, 3000);
}
The PHP functionality itself was split into 3 separate modules:
-
timeline.php
- receives AJAX requests from client web browsers; each parameter is checked for presence (notification number and phase end dates), and execution sleeps until it can process the long running PHP scriptlong.php
which actually handles the Excel manipulation in the background; this structure allows clients to load the page without freezing up given another request is being processed (timeline.php
should almost be considered to be a queueing system) -
long.php
- runs the Excel manipulation (and later, the macro) in the background; this script is backgrounded to allow other clients to queue in AJAX requests without page lockups and grabs the arguments saved in a temporary text file created bytimeline.php
, opens Excel silently, and runs therunIW52()
macro through Excel manipulation; the text file is then deleted to allow the next AJAX request through while another text file is temporarily created and assigned a name based on a randomly generated number using PHP’smt_rand()
function so that requests may be matched based on uniquely random temporary numbers to correctly return back the results to the correct client -
check.php
- continuously sent AJAX requests after the first AJAX request is sent totimeline.php
, returns result information to a client if a text file matching their generated request ID exists; once this text file does exist, it is deleted, the continuous requests cease, and the result is displayed on the client screen
timeline.php
<?php
try {
# Check for the presence of args from AJAX
if(isset($_POST["notification"]) && isset($_POST["scoping_date"]) && isset($_POST["design_date"]) && isset($_POST["prerequisites_date"])) {
# Change script max execution time
ini_set("max_execution_time", 600);
# Sleep while processing other request
while(file_exists("args_lock.txt")) { sleep(5); }
# Get random number to identify this request
$id = mt_rand();
# Create file for *very* inconvenient method of arg passing
$args = fopen("args_lock.txt", "w") or die("Unable to create new args file");
fwrite($args, $id."\n");
fwrite($args, $_POST["notification"]."\n");
fwrite($args, $_POST["scoping_date"]."\n");
fwrite($args, $_POST["design_date"]."\n");
fwrite($args, $_POST["prerequisites_date"]."\n");
fclose($args);
# Backgrounding for long-running PHP script (COM stuff)
shell_exec("background.vbs");
# Return the session ID so client knows which session results to wait for
echo json_encode($id);
}
} catch(Exception $e) {
# Print error information if caught
error_log($e);
}
?>
long.php
<?php
try {
# Change script max execution time
ini_set("max_execution_time", 600);
# Get passed args
$args = fopen("args_lock.txt", "r");
$id = intval(fgets($args));
$notification = fgets($args);
$scoping_date = fgets($args);
$design_date = fgets($args);
$prerequisites_date = fgets($args);
# Variable for workbook
$file = "../vba/IW52.xlsm";
# Starts the COM and opens workbook
$excel = new COM("Excel.Application") or die("Unable to start COM");
# Set some Excel flags
$excel->DisplayAlerts = false;
$excel->Application->Visible = 0;
# Get the workbook
$workbook = $excel->Application->Workbooks->Open($file) or die("Unable to open Excel");
# Run macro from workbook
$excel->Run("runIW52", $notification, $scoping_date, $design_date, $prerequisites_date);
# Handle all "return codes" as marked in Cells(1, 1) from macro
$return_code = $excel->ActiveWorkbook->Worksheets(1)->Cells(1, 1)->Value;
$return_desc = $excel->ActiveWorkbook->Worksheets(1)->Cells(1, 2)->Value;
$return_task = $excel->ActiveWorkbook->Worksheets(1)->Cells(1, 3)->Value;
# Close workbook and quit
$excel->ActiveWorkbook->Close(false);
$excel->Quit();
unset($excel);
# Close and delete args lock file
fclose($args);
chmod("args_lock.txt", 0777);
unlink("args_lock.txt");
# Create ID file with output
$result = fopen($id.".txt", "w") or die("Unable to create new ".$id.".txt file");
fwrite($result, $return_code."\n");
fwrite($result, $return_desc."\n");
fwrite($result, $return_task."\n");
fclose($result);
} catch(Exception $e) {
# Print error information if caught
error_log($e);
}
?>
check.php
<?php
try {
# Check for the presence of args from AJAX
if(isset($_POST["identifier"])) {
# Get the ID
$id = intval($_POST["identifier"]);
if(file_exists($id.".txt")) {
$result = fopen($id.".txt", "r");
$return_code = fgets($result);
$return_desc = fgets($result);
$return_task = fgets($result);
fclose($result);
chmod($id.".txt", 0777);
unlink($id.".txt");
echo json_encode(array(true, $return_code, $return_desc, $return_task));
} else {
echo json_encode(array(false));
}
}
} catch(Exception $e) {
# Print error information if caught
error_log($e);
}
?>
Results
While there are several other projects I could write about from my internship, I figured these three projects did well to provide examples of the power of Excel and automation with VBA. An understanding of data structures and efficiency programming provided me with additional resources in the design of the programs I produced for my team members - many of these programs and tools are still in use today. It is always rewarding and fulfilling to implement transformational change into any organization, and it remains a goal of mine going forward in all future roles and challenges I take on throughout my career.