Our application is now capable of providing the basic management of the database entities. We can add, edit, and delete agents and departments, and our database is populated with a list of calls. Let us move on further and allow Aurelius to provide us with some useful information from the database.
Listings
In both Departments and Agents tabs, there is a grid at the bottom of the forms with information about the calls. You can find the code we use in this chapter in the Call Centre – Listings folder.
Departments
Nr: Row number
CallID: The ID of the call
Date: The date of the call
Entry Time: The time the call entered the queue
Waiting Time: The time the call stays in the queue
Duration: The duration of the call when an agent picks it up
Resolved: Indicated whether the issue in the call is resolved
Satisfaction Rate: The satisfaction rate the customer gave to the agent
- 1.Add a new private procedure in the form (updateDepartmentList) whereinterface...typeTFormMain = class(TForm)...private...procedure updateDepartmentList;...end;...implementation...
- 2.Add the following lines in updateDepartmentList:procedure TFormMain.updateDepartmentList;varsession: IDatabaseSession;callList: TObjectList<TCall>;call: TCall;begin...session:=TDatabaseSession.Create(SQLiteConnection.CreateConnection);callList:=session.objectManager.Find<TCall>.CreateAlias('DepartmentID', 'department').Where(Linq['department.ID'] =sgDepartments.Cells[2, sgDepartments.Selected]).List;...for call in callList dobeginsgDepartmentDetails.RowCount := sgDepartmentDetails.RowCount + 1;sgDepartmentDetails.Cells[0, sgDepartmentDetails.RowCount - 1]:= sgDepartmentDetails.RowCount.ToString;sgDepartmentDetails.Cells[1, sgDepartmentDetails.RowCount - 1]:= call.CallID;sgDepartmentDetails.Cells[2, sgDepartmentDetails.RowCount - 1]:= FormatDateTime('dd/mm/yyyy', call.Date);sgDepartmentDetails.Cells[3, sgDepartmentDetails.RowCount - 1]:= FormatDateTime('hh:mm:ss', call.QueueEntryTime);if call.Resolved = 0 thensgDepartmentDetails.Cells[6, sgDepartmentDetails.RowCount - 1]:= 'N'elsesgDepartmentDetails.Cells[6, sgDepartmentDetails.RowCount - 1]:= 'Y';if call.SatisfactionScore.HasValue thensgDepartmentDetails.Cells[7, sgDepartmentDetails.RowCount - 1]:= call.SatisfactionScore.Value.ToString;end;...callList.Free;...end;
Nothing new here in terms of Aurelius’ functionality. We retrieve the list of the calls based on the Department.ID from the first grid. Note that SatisfactionScore is a nullable field and, therefore, we check if there is a value in the field. If so, we use the Value property to TNullable<TCall> to extract the field.
The other columns of the grid require some calculations but this is straightforward. We check if nullable fields have values and we proceed with some calculations.procedure TFormMain.updateDepartmentList;varsession: IDatabaseSession;callList: TObjectList<TCall>;call: TCall;begin...for call in callList dobeginif call.QueueExitTime.HasValue thensgDepartmentDetails.Cells[4, sgDepartmentDetails.RowCount - 1]:=FormatDateTime('hh:mm:ss', call.QueueExitTime.Value - call.QueueEntryTime);if call.ServiceStartTime.HasValue and call.ServiceEndTime.HasValue thensgDepartmentDetails.Cells[5, sgDepartmentDetails.RowCount - 1]:=FormatDateTime('hh:mm:ss', call.ServiceEndTime.Value - call.ServiceStartTime.Value);end;...end; - 3.Call updateDepartmentList in the OnSelectCell event of the grid with the departments:procedure TFormMain.sgDepartmentsSelectCell(Sender: TObject; const ACol, ARow:Integer; var CanSelect: Boolean);begin...updateDepartmentList;end;
Agents
- 1.
In Entities.pas, we have already added a TList property (CallList) in TAgent class (see Chapter 3).
- 2.In MainForm.pas, add a new private procedure updateAgentList to update the grid with the calls per agent:interface...typeTFormMain = class(TForm)...private...procedure updateAgentList;...end;...implementation...
- 3.Add the following lines in updateAgentList :procedure TFormMain.updateAgentList;varsession: IDatabaseSession;agent: TAgent;call: TCall;begin...session:=TDatabaseSession.Create(SQLiteConnection.CreateConnection);tryagent:=session.objectManager.Find<TAgent>(StringToGUID(sgAgents.Cells[2, sgAgents.Selected]));if Assigned(agent) thenbegin...for call in agent.CallList dobeginsgAgentDetails.RowCount := sgAgentDetails.RowCount + 1;sgAgentDetails.Cells[0, sgAgentDetails.RowCount - 1]:= sgAgentDetails.RowCount.ToString;sgAgentDetails.Cells[1, sgAgentDetails.RowCount - 1]:= call.CallID;sgAgentDetails.Cells[2, sgAgentDetails.RowCount - 1]:=FormatDateTime('dd/mm/yyyy', call.Date);sgAgentDetails.Cells[3, sgAgentDetails.RowCount - 1]:=FormatDateTime('hh:mm:ss', call.QueueEntryTime);if call.QueueExitTime.HasValue thensgAgentDetails.Cells[4, sgAgentDetails.RowCount - 1]:=FormatDateTime('hh:mm:ss', call.QueueExitTime.Value - call.QueueEntryTime);if call.ServiceStartTime.HasValue and call.ServiceEndTime.HasValue thensgAgentDetails.Cells[5, sgAgentDetails.RowCount - 1]:=FormatDateTime('hh:mm:ss', call.ServiceEndTime.Value - call.ServiceStartTime.Value);if call.Resolved = 0 thensgAgentDetails.Cells[6, sgAgentDetails.RowCount - 1]:= 'N'elsesgAgentDetails.Cells[6, sgAgentDetails.RowCount - 1]:= 'Y';if call.SatisfactionScore.HasValue thensgAgentDetails.Cells[7, sgAgentDetails.RowCount - 1]:=call.SatisfactionScore.Value.ToString;end;end;finallysgAgentDetails.EndUpdate;...end;...end;
- 4.Call updateAgentList in the OnSelectCell event of the agents’ grid:procedure TFormMain.sgAgentsSelectCell(Sender: TObject; const ACol, ARow:Integer; var CanSelect: Boolean);begin...updateAgentList;end;
In this approach, we first load the agent based on the GUID that is selected when the user clicks the agent grid, and then we iterate through agent.CallList to get the calls associated to the particular agent. The rest of the code that populates the details of the calls in the grid is exactly the same as before.
In our application, the two approaches we used bring the same result. The code that uses the CallList is much simpler. The downside of this approach is that we cannot filter the results using Aurelius features as in the first approach. Instead, we need to run through the items of the TList and cut out whatever is not desirable. This may not pose a significant matter in CallCentre application but, in general, it is much preferable to do heavy tasks at the server side. Client-side processing may consume resources that can be valuable especially in mobile platforms.
Queries (Dashboard)
The dashboard holds lot of information of different nature and in different places. If you look at the data in the dashboard, you will notice that the entire data set consists of either aggregated (e.g., total calls) or calculated (e.g., calls/minute) values. In Chapter 2, we discussed that Aurelius is able to extract such values with the use of projections. The code files are located in the Call Centre – Queries folder.
Before we start filling the dashboard in, we need to consider the filtering we have introduced. The left sidebar allows the user to select the weeks, and it filters the data presented in the dashboard. We are going to use this filter in every calculation we make.
Average Satisfaction Score (%)
Total Calls
Answer Speed (min:sec)
Abandon Rate (%)
Calls/Minute
Calls answered in less than 180 seconds
Calls with satisfactory rate less than 3
In addition to the preceding metrics, there is a list which presents data per agent (total calls, calls answered, average speed, call resolution percentage, and the call resolution trend; that is whether the agent’s ability to resolve calls is improving or not or remains the same). The dashboard also holds two graphs, but we are going to deal with them in the next chapter.
Next, we calculate the indicators separately to demonstrate the use of different functions Aurelius offers. Many instances of the following code can be combined in one call to Aurelius as they are under the same conditions.
Average Satisfaction Score (%)
Because we are retrieving one and only one result from the projection (SatisfactionScore), we get a TCriteriaResult instead of a TObjectList<TCriteriaResult>, as shown in Chapter 2. We do this by using UniqueValue. In this case, we also use the Avg function from Aurelius toolbox, and we pass a boolean expression in the Where clause to run the calculation only on valid records.
Weeks
The preceding code snippet calculates the average satisfaction score for all the calls in the database. We need to make use of the aWeek parameter in calculateStatistics. We have already inserted in the database the week of each call when we imported the data from the csv file. The value is held in the Week property of the TCall entity .
You can click the buttons in the sidebar, and the satisfaction score is calculated for each week. However, if you look at the data in the database, you will notice that there are entries with week number 5. We calculated the week for each call using the WeekOfTheMonth function . Our data refers to January 2016, a month for which the first and last days fall in the middle of weeks. Therefore, the function, correctly, returns the calendar week number.
We define a TCriteria variable and we build the projection without passing it to the object manager because we need to populate it with the right filter. filter function accomplishes this, and then we call UniqueValue to retrieve the required information. For simplicity, the preceding code does not include the check for null content.
We could obviously add the lines from filter function directly in the code, but we are going to need it in more than one place in our code; therefore, a function looks for better solution. As a last note, we have to free the TCriteriaResult (projRes) but not the TCriteria (criteria). criteria is automatically destroyed when UniqueValue , List, or ListValues are called.
Total Calls
This time I introduce the variable totalCalls to get the result from the projection. The only reason I do this is because we will need this value for subsequent calculations.
Answer Speed
We have seen this pattern before; we use the Avg function of Aurelius and the IsNull condition to filter the entities. This time we pass a calculation (the subtraction) directly in a function and, finally, present the result in the right format. This snippet also shows that Aurelius is capable of managing different data types in a simple way.
Abandon Rate
Calls/Minute
Calls Answered in Less Than 180 Seconds
The only, perhaps, new element we have is that we can use computational conditions in a Where statement as the code shows. I encoded the 180 seconds limit as a time object.
Calls with Satisfactory Score Less Than 3
Agent Statistics List
The code groups the calls to Count by the ID of the agents. Note the way we access the ID property; the AgentID property in TCall is an entity itself, and therefore if we want to drill down to its properties, we need to create an alias pointing to TAgent. Then, we can use this alias inside projection calls in the same way we used it when we were dealing with criteria. We, also, want to show the name of the agent as appears in the Description field. As this is a simple use of a field from the main table, it can be easily access using the Prop function.
This snippet calculates the total calls for each agent. The other indicators in the agent list are determined in a similar manner. I do not show the code here, as it would be a repetition of what we have already seen. If you wish to see the full implementation, please refer to the code files that accompany this book.
Views
We implemented the dashboard screen by running several database queries in order to extract all the necessary data. In some instances, we had to do calculations in code before being able to present the right data. This is a very common approach but, in some instances, may not be ideal. For example, if the data set is huge or the resources are limited as it happens in mobile platforms, or if there are security concerns and, perhaps, legacy database schema, it is, strongly, preferable to allow the database engine to handle the queries.
- 1.Open Entities.pas unit and add the following class:...interface...type...[Entity][Table('OverallStatistics')][Id('FWeek', TIdGenerator.None)]TOverallStatistics = classprivate[Column('Week', [TColumnProp.Required])]FWeek: Integer;[Column('SatisfactionScore', [TColumnProp.Required])]FSatisfactionScore: Double;[Column('TotalCalls', [TColumnProp.Required])]FTotalCalls: Integer;[Column('AnswerSpeed', [TColumnProp.Required])]FAnswerSpeed: Double;[Column('AbandonRate', [TColumnProp.Required])]FAbandonRate: Double;[Column('CallsMinute', [TColumnProp.Required])]FCallsMinute: Double;[Column('CallsLess180', [TColumnProp.Required])]FCallsLess180: Integer;[Column('CallsLess3', [TColumnProp.Required])]FCallsLess3: Integer;[Column('CallsLess3Perc', [TColumnProp.Required])]FCallsLess3Perc: Double;publicproperty Week: Integer read FWeek write FWeek;property SatisfactionScore: Double read FSatisfactionScore write FSatisfactionScore;property TotalCalls: Integer read FTotalCalls write FTotalCalls;property AnswerSpeed: Double read FAnswerSpeed write FAnswerSpeed;property AbandonRate: Double read FAbandonRate write FAbandonRate;property CallsMinute: Double read FCallsMinute write FCallsMinute;property CallsLess180: Integer read FCallsLess180 write FCallsLess180;property CallsLess3: Integer read FCallsLess3 write FCallsLess3;property CallsLess3Perc: Double read FCallsLess3Perc write FCallsLess3Perc;end;...
There are a few points to mention about this entity that represents a view. First, the primary key (Week) is linked to one of the columns of the view. This is only to provide a unique identifier to the entity to keep Aurelius happy. The second point follows the first one. All the properties in this entity are flagged as required but again this is not important. I chose to do this because I do not want to check whether a property has content as we do when we deal with nullable fields. Unless the view is empty, I can, simply, use the property values. The last point to mention has to do with the naming and data type of the properties. This entity represents a view in the database and, therefore, the properties and the data types must be the same (or compatible) to the columns in the actual view.
- 2.
Run the CallCentre application. Aurelius will go on and create a table named OverallStatistics when updateDatabase is called. But this is not what we want to achieve. Aurelius may treat OverallStatistics as an entity (table) but, at database level, it is a view. This homogenous approach, also, leaves us unable to rely to Aurelius schema management to create or update a view. We rather need to do this manually.
- 3.
Delete the table, drop the database, or delete the database file. Let’s start clean.
- 4.
In order to prevent Aurelius checking for OverallStatistics table and from creating it, we need the help of models. The idea of models in Aurelius allows developers to create conceptual group entities (tables) to serve different tasks. A very common use of models is to separate the tables that hold security, licensing, or user management data from the main (default) group of tables. Models can go beyond conceptual level and be associated to different databases. Therefore, for instance, you can have two databases: one for the licensing and another for the application-specific data.
In our application, we are going to introduce the Database model to organize those entities that reflect views. Go back to TOverallStatistics class and decorate it with the attribute Model.type...[Entity][Table('OverallStatistics')][Model('Database')][Id('FWeek', TIdGenerator.None)]TOverallStatistics = class...end; - 5.
Run the application again. If you look at the database, you will see that Aurelius did not create the TOverallStatistics table because, the way we have set things up, the main (default) model is used. If you do not provide the Model attribute to an entity, it is considered part of the Default model.
- 6.We now need to create the view in the database. If you have the option and access, you can run the following SQL script manually to do this. The script can be found in the Misc folder in the code files under the name OverallStatisticsViewScript.sql.CREATE VIEW IF NOT EXISTS OverallStatistics AS selectWeek,Avg(SatisfactionScore) as SatisfactionScore,Count(*) as TotalCalls,(Avg( casewhen QueueExitTime is not null then QueueExitTime - QueueEntryTimeend )) as AnswerSpeed,(Count( casewhen QueueExitTime is null then 1end ) * 100.0 ) / count(*) as AbandonRate,(Count(*) / 9.00 / 60.00) as CallsMinute,(Count ( casewhen (QueueExitTime is not null) and ((QueueExitTime - QueueEntryTime) < 0.00208333333333333) then 1end )) as CallsLess180,(Count( casewhen SatisfactionScore < 3 then 1end )) as CallsLess3,(Count( casewhen SatisfactionScore < 3 then 1end ) * 100.0 / count(*) ) as CallsLess3PercfromCallGROUP BYWeek;
- 7.
In the case you cannot manage the database directly or in situations where you want your applications to update the database, you need to execute the script using Aurelius features.
- 8.Aurelius defines the IDBStatement interface , which allows us to pass plain SQL statements and execute them directly. This takes place at the TObjectManager level. Add the next private method in TFormMain in MainForm.pas. Note that you need to add Database.Session.Types in the interface section now.uses...,Database.Session.Types;typeTFormMain = class(TForm)...private...procedure createViews(const dbSession: IDatabaseSession);end;...implementation...procedure TFormMain.createViews(const dbSession: IDatabaseSession);varstatement: IDBStatement;sqlScript: string;beginsqlScript:='CREATE VIEW IF NOT EXISTS OverallStatistics AS select' +' Week,' +' avg(SatisfactionScore) as SatisfactionScore,' +' Count(*) as TotalCalls,' +' (Avg( case' +' when QueueExitTime is not null then QueueExitTime - QueueEntryTime' +' end )) as AnswerSpeed,' +' (Count( case' +' when QueueExitTime is null then 1' +' end ) * 100.0 ) / count(*) as AbandonRate,' +' (Count(*) / 9.00 / 60.00) as CallsMinute,' +' (Count ( case' +' when (QueueExitTime is not null) and ((QueueExitTime - QueueEntryTime) < 0.00208333333333333) then 1' +' end )) as CallsLess180,' +' (Count( case' +' when SatisfactionScore < 3 then 1' +' end )) as CallsLess3,' +' (Count( case' +' when SatisfactionScore < 3 then 1' +' end ) * 100.0 / count(*) ) as CallsLess3Perc ' +'FROM' +' Call ' +'GROUP BY' +' Week;';statement:=dbSession.objectManager.Connection.CreateStatement;statement.SetSQLCommand(sqlScript);statement.Execute;end;
We declare an IDBStatement variable, and we use the CreateStatement function to instantiate it. Then, we load the script to the interface using SetSQLCommand and finally execute it. In a full-scale application, most likely you want to wrap statement.Execute in a try-except branch.
In the preceding snippet, I have added some spaces to make the SQL script readable. Obviously, this is not necessary, as it is just a script. What matters, though, is the space at the beginning (or the end) of each line if you choose to create this concatenated string as I have done. Otherwise, you will end up with a script with adjacent words without any spaces.
- 9.Go to FormCreate event in MainForm.pas and call createViews :uses...Aurelius.Drivers.Interfaces;...procedure TFormMain.FormCreate(Sender: TObject);...begin...dbSession.databaseManager.UpdateDatabase;createViews(dbSession);setupGUI;end;
- 10.
If you check the code in the supplied files, I have added the same code in the procedure that executes the importing of the data. I will not mention it here as it is the same as the preceding snippet.
- 11.Time to get our data from the view and show it in the dashboard. Go to calculateStatistics in TFormMain and retrieve the list of entities:procedure TFormMain.calculateStatistics(const aWeek: TWeeks);varsession: IDatabaseSession;overallStatsList: TList<TOverallStatistics>;beginsession:=TDatabaseSession.Create(SQLiteConnection.CreateConnection);overallStatsList:=session.objectManager.Find<TOverallStatistics>.List;overallStatsList.Free;end;
- 12.
As you can see, accessing a view in Aurelius is done in the same way as accessing every other entity. However, the preceding code breaks. Try to execute it and you will see that Aurelius complains with the message of “Class TOverallStatistics is not a valid Entity. [Entity] attribute missing.” although we have used Entity attribute in the class.
- 13.
The reason for this error message is that we have made TOverallStatistics part of Database model. The call to Find in the preceding code accesses the Default model. We need to instruct the object manager to look at the Database model.
- 14.Open Database.Session.Types.pas and add the following overloading function to IDatabaseSession:typeIDatabaseSession = interface...function objectManager: TObjectManager; overload;function objectManager (const aModel: string): TObjectManager; overload;end;
- 15.In Database.Session.pas, add the implementation of the function. In this example, I have introduced a dictionary to hold the different object managers based on the model they serve. This can be handy when multiple models are present. The following code shows the full unit and highlights the differences from before:unit Database.Session;interfaceusesDatabase.Session.Types,Aurelius.Engine.DatabaseManager,Aurelius.Drivers.Interfaces,Aurelius.Engine.ObjectManager, System.Generics.Collections;typeTDatabaseSession = class (TInterfacedObject, IDatabaseSession)privatefConnection: IDBConnection;fDatabaseManager: TDatabaseManager;fObjectManagerDictionary: TObjectDictionary<string, TObjectManager>;publicconstructor Create(const aConnection: IDBConnection);destructor Destroy; override;{$REGION 'Interface'}function databaseManager: TDatabaseManager;function objectManager: TObjectManager; overload;function objectManager (const aModel: string): TObjectManager; overload;{$ENDREGION}end;implementationusesSystem.SysUtils, Aurelius.Mapping.Explorer;constructor TDatabaseSession.Create(const aConnection: IDBConnection);beginAssert(aConnection <> nil);inherited Create;fConnection:=aConnection;fObjectManagerDictionary:=TObjectDictionary<string, TObjectManager>.Create([doOwnsValues]);end;function TDatabaseSession.databaseManager: TDatabaseManager;beginif not Assigned(fDatabaseManager) thenfDatabaseManager:=TDatabaseManager.Create(fConnection);Result:=fDatabaseManager;end;destructor TDatabaseSession.Destroy;beginfDatabaseManager.Free;fObjectManagerDictionary.Free;inherited;end;function TDatabaseSession.objectManager(const aModel: string): TObjectManager;varcModel: string;begincModel:=Trim(UpperCase(aModel));if cModel=" thenResult:=objectManagerelsebeginif not fObjectManagerDictionary.ContainsKey(cModel) thenif cModel = 'DEFAULT' thenfObjectManagerDictionary.Add('DEFAULT', TObjectManager.Create(fConnection))elsefObjectManagerDictionary.Add(cModel,TObjectManager.Create(fConnection, TMappingExplorer.Get(cModel)));Result:=fObjectManagerDictionary.Items[cModel];end;end;function TDatabaseSession.objectManager: TObjectManager;beginresult:=objectManager('default');end;end.
We create a new instance of object manager for each model. A simple call to the constructor of TObjectManager passes the Default model. When we need an object manager for specific model other than the default, we use TMappingExplorer to provide the internal structure of the database representation we are interested in. TMappingExplorer is responsible for scanning through entities attached to a database model.
- 16.Now we are ready to access the content of the view. We also factor in the filtering according to the week.procedure TFormMain.calculateStatistics(const aWeek: TWeeks);var...,criteria: TCriteria;overallStatsList: TList<TOverallStatistics>;beginsession:=TDatabaseSession.Create(SQLiteConnection.CreateConnection);criteria:=session.objectManager('Database').Find<TOverallStatistics>;criteria:=filter(aWeek, criteria);overallStatsList:=criteria.List<TOverallStatistics>;overallStatsList.Free;end;
We pass the name of the model (Database) to the object manager, and we use filter to generate the correct call according to the chosen week. Then, we retrieve the content of the view and assign it to overallStatsList . Note that we need to provide the exact data type to List<> because we, now, do not retrieve a list of criteria results, as we did previously.
- 17.From this point onward, we can easily access the results and update the dashboard.procedure TFormMain.calculateStatistics(const aWeek: TWeeks);var...,overallStats: TOverallStatistics;beginsession:=TDatabaseSession.Create(SQLiteConnection.CreateConnection);criteria:=session.objectManager('Database').Find<TOverallStatistics>;criteria:=filter(aWeek, criteria);overallStatsList:=criteria.List<TOverallStatistics>;...for overallStats in overallStatsList dobeginlbSatisfactionValue.Text:=format('%2.2f', [overallStats.SatisfactionScore]);lbTotalCallsValue.Text:= overallStats.TotalCalls.ToString;lbAnswerSpeedValue.Text:=FormatDateTime('n:ss', overallStats.AnswerSpeed);lbAbandonRateValue.Text:=format('%3.2f%%', [overallStats.AbandonRate]);lbCallsMinuteValue.Text:=format('%3.2f', [overallStats.CallsMinute]);lbAnsweredLess180Value.Text:= overallStats.CallsLess180.ToString;lbSatisfactionScoreLess3Value.Text:= format('%d (%3.2f%%)',[overallStats.CallsLess3,overallStats.CallsLess3Perc * 100]);end;...overallStatsList.Free;end;
Note
The preceding code is different from the one you can find in the code files. In the actual code, we need to manage the fact that we present the results of week 4 and week 5 consolidated.
Summary
In this chapter, we covered lot of ground. We started with simple queries to get lists of agents and departments, and we moved on to explore how we can build complex requests and take advantage of Aurelius’ fluent interface. We also saw how the framework provides options for calculations and grouping. The last topic we discussed was database views – a way to move heavy calculations totally to the server side and, consequently, take advantage of the database engine.