1

I have sheets that autocalculate the P&L for each project. At the moment they're using a fixed salary, but I'd like to change it to incorporate any future salary changes. The project duration is Sequenced from L1 based on a duration parameter in E2 using =EDATE($C$2, SEQUENCE(1, $E$2, 0))

Each cell in column L sequences through the duration, calculating the relevant values.

P&L

The formula for the salary costs refences the dataset below which is also automatically generated from data in another sheet using =FILTER(FILTER(AllStaffProjectAllocationTbl,AllStaffProjectAllocationTbl[Project Code Name]=B2), {1,0,0,1,1,1,0,0,0,1,0,0,0,1,0,0,0,0}) and can have a variable number of entries:

Project

The current formua for calculating the salary costs in L7 (from the first image) that someone on here kindly helped me with is =MMULT(SEQUENCE(1,ROWS($A$18#),1,0),($L$1#>=OFFSET($A$18#,0,3,,1))*($L$1#<=OFFSET($A$18#,0,4,,1))*OFFSET($A$18#,0,5,,1)) and uses the column F (which I'm hoping to remove) from the second image .

I've got a new set of data in the table EmployeeSalaryTbl for the salaries here (I had to add in the made up end date of 31/12/9999 for now to ensure calculations work below. Ideally, this would be blank, and I'd check for that in the below calculation):

salaries

I've begun modifying the MMult function in L7 to do the following:

  1. Sequence for the duration of the project
  2. Check if the employees from A18 onwards are on the project for the given month starting from L1
  3. If they are then find the salary that falls within the date range from the EmployeeSalaryTbl and add them all together for the month.

This is what I have so far but unfortunately it's giving me an error:

=MMULT(SEQUENCE(1,ROWS($A$18#),1,0),IF(AND(($L$1#>=OFFSET($A$18#,0,3,,1))*($L$1#<=OFFSET($A$18#,0,4,,1))),SUMIFS(EmployeeSalaryTbl[Salary Monthly], EmployeeSalaryTbl[Employee],$A$18#, EmployeeSalaryTbl[Salary Start Date],"<="&$L$1#, EmployeeSalaryTbl[Salary End Date],">="&$L$1#),0))

The data for the Project defined by image 2 is:

Employee Role Discipline Start Date End Date Salary Monthly
Bob Senior Programmer Programming 12/01/2020 06/05/2020 £4,333
Dave Mid Level Programmer Programming 01/02/2020 30/05/2020 £3,167
Peter Senior Programmer Programming 01/01/2020 31/01/2020 £4,583
Jack Junior Programmer Programming 01/02/2020 30/06/2020 £2,083
Richard Senior Artist Art 01/03/2020 30/04/2020 £3,750
Rodney Lead QA QA 01/03/2020 30/06/2020 £4,333
Proj 1 - Hire 1 Senior Producer Production 01/02/2020 30/05/2020 £3,458
Roger QA QA 01/01/2020 30/04/2020 £1,667
Wesley Mid Level Programmer Programming 01/02/2020 31/05/2020 £3,750
Rachel Senior Artist Art 01/01/2020 30/06/2020 £3,333
Proj 1 - Hire 2 Lead Programmer Programming 01/01/2020 31/07/2020 £4,417

And the data in for the EmployeeSalaryTbl is:

Employee Salary Start Date Salary End Date Salary Salary Monthly Salary Daily
Bob 01/01/2020 31/03/2021 £52,000 £4,333 £199
Bob 01/04/2021 31/03/2022 £55,000 £4,583 £211
Bob 01/04/2022 31/12/9999 £58,000 £4,833 £222
Dave 01/01/2020 31/03/2021 £38,000 £3,167 £146
Dave 01/04/2021 31/12/9999 £42,000 £3,500 £161
Wesley 01/01/2020 31/12/9999 £45,000 £3,750 £173
Jack 01/01/2020 31/12/9999 £25,000 £2,083 £96
Richard 01/01/2020 31/12/9999 £45,000 £3,750 £173
Rodney 01/01/2020 31/12/9999 £52,000 £4,333 £199
Proj 1 - Hire 1 01/01/2020 31/12/9999 £41,500 £3,458 £159
Roger 01/01/2020 31/12/9999 £20,000 £1,667 £77
Steve 01/01/2020 31/12/9999 £27,000 £2,250 £104
Rachel 01/01/2020 31/12/9999 £40,000 £3,333 £153
Peter 01/01/2020 31/12/9999 £34,000 £2,833 £130
Sarah 01/01/2020 31/12/9999 £22,000 £1,833 £84
Chloe 01/01/2020 31/12/9999 £33,000 £2,750 £127
Matthew 01/01/2020 31/03/2021 £23,000 £1,917 £88
Matthew 01/04/2021 31/12/9999 £28,000 £2,333 £107
Proj 1 - Hire 2 01/01/2020 31/12/9999 £36,000 £3,000 £138
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
  • I assume per tags used you don't have an excel version constraint. – David Leal Oct 30 '22 at 21:49
  • If I got the sense of your request, you could get the expected result with a formula similar to this: `=MMULT(SEQUENCE(1,ROWS(EmployeeSalaryTbl[Employee]),1,0),($L$1#>=EmployeeSalaryTbl[Salary Start Date])*($L$1#<=EmployeeSalaryTbl[Salary End Date])*EmployeeSalaryTbl[Salary Monthly])`. This formula is very similar to what you used with the previous table, but mainly I replaced the `OFFSET` parts with spill ranges, with table notations. My simulation with your data works fine, but there may be problems if salaries are changed in a date which is not either first or last day of the month. – Michele Oct 31 '22 at 01:15
  • Hi @Michele I need the offset parts as they determine whether the staff member is allocated to the project that month – Automation Monkey Oct 31 '22 at 08:58
  • 1
    Hi @DavidLeal I am on Excel 365 and I don't believe I have any constraints – Automation Monkey Oct 31 '22 at 08:59
  • Hi @AutomationMonkey, in order to determine whether staff member is allocated in the month this could be the formula: `=MMULT(SEQUENCE(1,ROWS(EmployeeSalaryTbl[Employee]),1,0),($L$1#>=EmployeeSalaryTbl[Salary Start Date])*($L$1#<=EmployeeSalaryTbl[Salary End Date])*EmployeeSalaryTbl[Salary Monthly]*($L$1#>=IFERROR(XLOOKUP(EmployeeSalaryTbl[Employee],CHOOSECOLS($A$18#,1),CHOOSECOLS($A$18#,4)),0))*($L$1#<=IFERROR(XLOOKUP(EmployeeSalaryTbl[Employee],CHOOSECOLS($A$18#,1),CHOOSECOLS($A$18#,5)),0)))`. It works in my simulation, but my previous consideration on salaries is still applicable. – Michele Oct 31 '22 at 19:14

1 Answers1

1

With your input data, and assuming no Excel version constraints (not specified in the question), in cell H2 put the following formula:

=LET(namePrj, TB_Prj[Employee], startPrj, TB_Prj[Start Date], endPrj,
 TB_Prj[End Date],name, TB_Roster[Employee],start, TB_Roster[Salary Start Date],
 end, TB_Roster[Salary End Date],salary, TB_Roster[Salary Monthly],
 SOMs, H1:S1, EOMs, EOMONTH(SOM,0),
 BYCOL(SOMs, LAMBDA(SOM, LET(EOM, EOMONTH(SOM,0),
 namesActive, FILTER(namePrj, (startPrj <= SOM) * (endPrj >= EOM)),
  cost, FILTER(salary, (start <= SOM) * (IF(end > 0, end, EOM) >= EOM) *
    (ISNUMBER(XMATCH(name,namesActive))),0), sum(cost)
  )))
 )

Two Excel Tables were defined to have dynamic ranges: TB_Prj for project information and TB_Roster for salary information. The month information is generated horizontally as follows in H1:

=EDATE(DATE(2020,1,1), SEQUENCE(1, 12, 0))

It generates the first day of the month. Represented in the formula as SOMs (Start Of the Month). Similarly, I use EOMs to represent each corresponding End Of the Month.

The output generates a 1x12 array as result.

Here is the output:

sample output in Excel

Showing only partially the salary table (TB_Roster)

Notes:

Please check the result, your data needs to be cleaned, for example, Peter in Salary Table has two entries overlapping the date interval.

If you cannot use tables (for example the input is based on SPILL formula like FILTER), then ranges can be used instead. You can define the following two data set ranges: prjSet, rosterSet, then define each corresponding column like this: INDEX(rng,,x), where x represents the corresponding column number on each range where rng is prjSet or rosterSet. For example name variable could be defined like this:

namePrj, INDEX(prjSet,,1)

Explanation

Using Excel Tables to have dynamic ranges, so the formula doesn't need to change when additional information is added. We use LET function to make the formula easier to maintain, defining the required inputs and intermediate results.

BYCOL is used to iterate over all SOMs values. For each start of the month (SOM), we do the following:

Find the namesActive from active projects checking that the start of the project is before or equal to SOM and the end of the project is greater or equal to EOM. In case no names from the project table satisfy the condition, FILTER returns an error (#CALC!) because empty arrays are not permitted in Excel. If this situation wants to be treated the formula needs to be adjusted (using a third input argument of FILTER and IF condition for computing cost) or better to clean the data. I would not expect for a given month not having any active project at a company level.

Once we have the list of names, we go to the second Excel Table (TB_Roster) to find a valid salary based on start, end dates.

We treat empty values in end, assuming that if there is no value (data is formatted as date, so empty means 0). The following IF condition ensures it (in the second FILTER call):

(IF(end > 0, end, EOM) >= EOM)

so if end array is positive (dates are represented as positive integers starting from 0, i.e. whole numbers), then a value was assigned, so we use this value, otherwise (0) it is replaced by EOM. When there is no end date the condition it is always true (all end values are considered). IF works with arrays, it evaluates the condition on each element of the array and returns the corresponding value on each element. By doing this we fixed the case of empty value to a valid situation, so there is no need to use a fictitious date.

The condition related to XMATCH, ensures we filter name by only the names on the previous FILTER call (valid names from the project table, i.e. namesActive). The result of this filter (cost) satisfies all the conditions and returns all matching monthly salaries.

In case there is no match based on the filter conditions, we return 0 (but another value can be used, for example NA()). In order to do that we use the third input argument of FILTER function.

Finally, we sum all the salaries that match the condition.

Bonus: Checking Interval Consistency

Based on the answer to the question: Excel: #CALC! error (Nested Array) when using MAP functions for counting interval overlaps provided by me. It can be adapted to check if date intervals are incorrect (end date before start date) or if intervals overlap for Salary Table:

=LET(names, TB_Roster[Employee], namesUx, UNIQUE(names),
 dates, TB_Roster[Salary Start Date]:TB_Roster[Salary End Date],
 maxDate, DATE(3000,1,1),
  byRowResult, BYROW(namesUx, LAMBDA(name,
    LET(set, FILTER(dates, names=name),
      startDates, INDEX(set,,1), endDates, INDEX(set,,2),
      calc, MAP(startDates, endDates, LAMBDA(start,end,
        LET(endAdj, IF(end > 0, end, maxDate),
        endDatesAdj, IF(endDates > 0, endDates, maxDate),
        startAfterEnd, IF(start <= endAdj, 0, 1),
        overlaps, IF(startAfterEnd > 0, 0,
          SUMPRODUCT((startDates <= endAdj) * (endDatesAdj >= start))-1),
        startAfterEnd &","& overlaps))),
        totals, TEXTSPLIT(TEXTJOIN(";",,calc),",",";"), intervals,
          1*INDEX(totals,,1), laps, 1*INDEX(totals,,2),
        SUM(intervals) + SUM(laps)/2
      )
    )), FILTER(HSTACK(namesUx, byRowResult), byRowResult<>0,
          "NO Overlaps or Wrong Ranges defined")
)

and the output will be for your input data:

Sample Excel spreadsheet/cells for checking inconsistencies

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
David Leal
  • 6,373
  • 4
  • 29
  • 56
  • Thanks for the detailed answer and explanation, I'm going to work through this now. One thing I forgot to mention in my question was I'd like to support part months. So if the employee starts mid month or ends mid month then it calculates the remaining working days and multiplies this by the 'Salary Daily' from the EmployeeSalaryTbl. Is this possible with the above solution? – Automation Monkey Oct 31 '22 at 09:02
  • Hi @David The data defined by TB_Prj in the solution above is actually defined by a `filter =FILTER(FILTER(AllStaffProjectAllocationTbl,AllStaffProjectAllocationTbl[Project Code Name]=B2), {1,0,0,1,1,1,0,0,0,1,0,0,0,1,0,0,0,0})` that pulls from another set of data in mine. I don't believe it can be a table, or can I make it one somehow? If not, how do I incorporate the data without a table? – Automation Monkey Oct 31 '22 at 09:19
  • In an attempt to replace the TB_prj and also make the SOMs dynamic I adjusted the first part of the forumla to the following but this just gives me a sequence of 0's: `namePrj, $A$18#, startPrj, $D$18#, endPrj, $E$18#, name, EmployeeSalaryTbl[Employee],start, EmployeeSalaryTbl[Salary Start Date], end, EmployeeSalaryTbl[Salary End Date],salary, EmployeeSalaryTbl[Salary Monthly], SOMs, $L$1#,` – Automation Monkey Oct 31 '22 at 09:41
  • Fixing the datasets makes it work but obviously doesn't support the dynamic nature of the data from L18 onwards. Leaving SOMs as $L$1# works for sequencing the duration though `namePrj, A18:A28, startPrj, D18:D28, endPrj, E18:E28, name, EmployeeSalaryTbl[Employee],start, EmployeeSalaryTbl[Salary Start Date], end, EmployeeSalaryTbl[Salary End Date],salary, EmployeeSalaryTbl[Salary Monthly], SOMs, $L$1#` – Automation Monkey Oct 31 '22 at 09:52
  • thanks for the spot in the test salary data too. I've removed the first salary entry for Peter in the test salary data in the question above. – Automation Monkey Oct 31 '22 at 11:51
  • I've also noticed an issue with the results. So leaving in the incorrect entry for Peter I get the same result as yourself for January. However, if I add up the salary amounts for the staff on the project in january I get a different result. It looks like it's not adding in the salary for Bob because if I remove Bob I get the same result as your example. Is this because Bob is only on it for a part month? If so, is there a way to solve the part months to use the per day salary for the working dyas they're on it that month? I'd like it to be as accurate as possible. – Automation Monkey Oct 31 '22 at 12:09
  • @AutomationMonkey I removed the line you removed in your question for Peter, please let's check the result without any changes. Once it is validated for the input data, then check other scenarios changing the input data, and finally modifying or adapting the formula. I will update the picture in my answer. Now I am getting for Jan the salary cost of: `£10,833.00`would you confirm that? – David Leal Oct 31 '22 at 13:09
  • I can confirm that I have £10,833 for the first month of January. Regards setting up a named range prjSet for A18 onwards, I then attempted to switch in the named range suggestion, defining Project1Dataset as `='Project 1 Test'!$A$18#` and then adjusting the function to `namePrj, INDEX(Project1Dataset,,1), startPrj, INDEX(Project1Dataset,,4), endPrj, INDEX(Project1Dataset,,5)`, unfortunately this gave 0's. I then switched the range to `='Project 1 Test'!$A$18:A28` for testing and the result was also 0's. I can't use a fixed dataset as it's alsways changing. – Automation Monkey Oct 31 '22 at 13:40
  • Additionally, I create the project sheet dynamically so using a named range isn't ideal as the user would have to add a new named range and inject it in to the formula for the salary costs. I could create the named range using vba on sheet creation and somehow inject the named range change in to the formula for salary costs. Ideally there'd be a solution without having to do this though. – Automation Monkey Oct 31 '22 at 13:41
  • It should work, without looking at your file it is difficult to say something. `Project1Dataset` can be defined like that and this is the way to create the variable `namePrj`. Use the following trick for testing purposes **only**: replace `sum(cost)` with: `result, sum(cost), namePrj`. This will return this name variable instead, just to check the variable was properly defined. You can use the same idea for checking any other name defined in the first `LET` call – David Leal Oct 31 '22 at 13:56
  • That is giving me a calc error now – Automation Monkey Oct 31 '22 at 14:12
  • func `=LET(namePrj, INDEX(Project1Dataset,,1), startPrj, INDEX(Project1Dataset,,4), endPrj, INDEX(Project1Dataset,,5), name, EmployeeSalaryTbl[Employee],start, EmployeeSalaryTbl[Salary Start Date], end, EmployeeSalaryTbl[Salary End Date],salary, EmployeeSalaryTbl[Salary Monthly], SOMs, $L$1#, EOMs, EOMONTH(SOM,0), BYCOL(SOMs, LAMBDA(SOM, LET(EOM, EOMONTH(SOM,0), namesActive, FILTER(namePrj, (startPrj <= SOM) * (endPrj >= EOM)), cost, FILTER(salary, (start <= SOM) * (IF(end > 0, end, EOM) >= EOM) * (ISNUMBER(XMATCH(name,namesActive))),0), result, SUM(cost), namePrj ))) )` – Automation Monkey Oct 31 '22 at 14:15
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/249197/discussion-between-david-leal-and-automation-monkey). – David Leal Oct 31 '22 at 14:23
  • 1
    Using =OFFSET('Project 1'!A18,0,0,COUNTA('Project 1'!$A$18:$A$10000),COUNTA('Project 1'!$A$17:$F$17)) for the named range allows it to be dynamic. This solution works for me since the data defined by the Filter is dynamic. It works when I copy the template sheet too, I just need to create a new name for the duplicated named range Project1Dataset when prompted which automatically corrects the entries in the Let function for the new sheet. – Automation Monkey Oct 31 '22 at 17:00