@@ -40,7 +40,17 @@ public function currentYear(): int {
4040 * @return array<int,array<int,array{used:float,pending:float}>> [typeId][year] => buckets
4141 */
4242 private function computeUsage (string $ employeeUid ): array {
43- $ requests = $ this ->requestMapper ->findAllForEmployee ($ employeeUid );
43+ return $ this ->usageFromRequests ($ this ->requestMapper ->findAllForEmployee ($ employeeUid ));
44+ }
45+
46+ /**
47+ * The usage half of {@see computeUsage()}, over requests already in memory, so
48+ * a batch report can load every employee's requests in one query.
49+ *
50+ * @param LeaveRequest[] $requests
51+ * @return array<int,array<int,array{used:float,pending:float}>> [typeId][year] => buckets
52+ */
53+ private function usageFromRequests (array $ requests ): array {
4454 $ byId = [];
4555 foreach ($ requests as $ request ) {
4656 $ byId [$ request ->getId ()] = $ request ;
@@ -84,19 +94,82 @@ private function computeUsage(string $employeeUid): array {
8494 */
8595 public function getBalance (string $ employeeUid , ?int $ year = null ): array {
8696 $ usage = $ this ->computeUsage ($ employeeUid );
97+ $ types = $ this ->typesById ();
98+
99+ // Entitlements for this employee: restricted to the reported year when there
100+ // is one, otherwise all of them, since they also decide which years to show.
101+ $ entitlements = [];
102+ foreach ($ this ->entitlementMapper ->findForEmployee ($ employeeUid , $ year ) as $ ent ) {
103+ $ entitlements [$ ent ->getYear ()][$ ent ->getTypeId ()] = $ ent ;
104+ }
105+
106+ return [
107+ 'employeeUid ' => $ employeeUid ,
108+ 'balances ' => $ this ->assembleRows ($ employeeUid , $ year , $ usage , $ types , $ entitlements ),
109+ ];
110+ }
111+
112+ /**
113+ * Balances for many employees in one year, in a fixed number of queries
114+ * regardless of headcount.
115+ *
116+ * The obvious loop over {@see getBalance()} costs one request query, one leave
117+ * type query and one entitlement query *per leave type* for every employee —
118+ * several thousand queries for a mid-sized company, which is what made the HR
119+ * balances report unusable at scale.
120+ *
121+ * @param string[] $employeeUids
122+ * @return array<string,list<array<string,mixed>>> balance rows keyed by employee uid
123+ */
124+ public function getBalancesForEmployees (array $ employeeUids , int $ year ): array {
125+ if ($ employeeUids === []) {
126+ return [];
127+ }
128+ $ types = $ this ->typesById ();
129+ $ requestsByEmployee = $ this ->requestMapper ->findAllForEmployees ($ employeeUids );
130+
131+ $ entitlementsByEmployee = [];
132+ foreach ($ this ->entitlementMapper ->findForYear ($ year ) as $ ent ) {
133+ $ entitlementsByEmployee [$ ent ->getEmployeeUid ()][$ year ][$ ent ->getTypeId ()] = $ ent ;
134+ }
135+
136+ $ result = [];
137+ foreach ($ employeeUids as $ uid ) {
138+ $ usage = $ this ->usageFromRequests ($ requestsByEmployee [$ uid ] ?? []);
139+ $ result [$ uid ] = $ this ->assembleRows ($ uid , $ year , $ usage , $ types , $ entitlementsByEmployee [$ uid ] ?? []);
140+ }
141+ return $ result ;
142+ }
143+
144+ /**
145+ * @return array<int,LeaveType>
146+ */
147+ private function typesById (): array {
87148 $ types = [];
88149 foreach ($ this ->leaveTypeMapper ->findAll () as $ type ) {
89150 $ types [$ type ->getId ()] = $ type ;
90151 }
152+ return $ types ;
153+ }
91154
155+ /**
156+ * Turn precomputed usage, types and entitlements into balance rows. Shared by
157+ * the single-employee and batch paths so both produce identical output.
158+ *
159+ * @param array<int,array<int,array{used:float,pending:float}>> $usage
160+ * @param array<int,LeaveType> $types
161+ * @param array<int,array<int,Entitlement>> $entitlements [year][typeId]
162+ * @return list<array<string,mixed>>
163+ */
164+ private function assembleRows (string $ employeeUid , ?int $ year , array $ usage , array $ types , array $ entitlements ): array {
92165 // Determine which years to report.
93166 $ years = [];
94167 if ($ year !== null ) {
95168 $ years [$ year ] = true ;
96169 } else {
97170 $ years [$ this ->currentYear ()] = true ;
98- foreach ($ this -> entitlementMapper -> findForEmployee ( $ employeeUid ) as $ ent ) {
99- $ years [$ ent -> getYear () ] = true ;
171+ foreach (array_keys ( $ entitlements ) as $ entYear ) {
172+ $ years [$ entYear ] = true ;
100173 }
101174 foreach ($ usage as $ perYear ) {
102175 foreach (array_keys ($ perYear ) as $ y ) {
@@ -114,38 +187,39 @@ public function getBalance(string $employeeUid, ?int $year = null): array {
114187 if (!$ type ->getCountsAgainstBalance () && $ used === 0.0 && $ pending === 0.0 ) {
115188 continue ;
116189 }
117- $ rows [] = $ this ->buildRow ($ employeeUid , $ reportYear , $ type , $ used , $ pending );
190+ $ rows [] = $ this ->buildRow ($ employeeUid , $ reportYear , $ type , $ used , $ pending, $ entitlements [ $ reportYear ][ $ typeId ] ?? null );
118191 }
119192 }
120193 // Newest year first, then sort_order.
121194 usort ($ rows , static function (array $ a , array $ b ): int {
122195 return [$ b ['year ' ], $ a ['sortOrder ' ]] <=> [$ a ['year ' ], $ b ['sortOrder ' ]];
123196 });
124-
125- return [
126- 'employeeUid ' => $ employeeUid ,
127- 'balances ' => $ rows ,
128- ];
197+ return $ rows ;
129198 }
130199
131200 /**
132201 * @return array<string,mixed>
133202 */
134- private function buildRow (string $ employeeUid , int $ year , LeaveType $ type , float $ used , float $ pending ): array {
203+ /**
204+ * @param ?Entitlement $ent the stored entitlement, or null when none exists.
205+ * Passed in rather than looked up here so a batch
206+ * report can resolve every employee's rows from one
207+ * preloaded index instead of a query per row.
208+ */
209+ private function buildRow (string $ employeeUid , int $ year , LeaveType $ type , float $ used , float $ pending , ?Entitlement $ ent ): array {
135210 $ entitlement = null ;
136211 $ base = 0.0 ;
137212 $ carry = 0.0 ;
138213 $ adjust = 0.0 ;
139214 $ entitlementId = null ;
140215 if ($ type ->getCountsAgainstBalance ()) {
141- try {
142- $ ent = $ this ->entitlementMapper ->findFor ($ employeeUid , $ year , $ type ->getId ());
216+ if ($ ent !== null ) {
143217 $ base = $ ent ->getBaseDays ();
144218 $ carry = $ ent ->getCarryOverDays ();
145219 $ adjust = $ ent ->getManualAdjustment ();
146220 $ entitlement = $ ent ->getEntitlement ();
147221 $ entitlementId = $ ent ->getId ();
148- } catch ( DoesNotExistException ) {
222+ } else {
149223 // No row yet: only the primary annual type inherits the configured
150224 // default allotment; other counting types start at zero until HR
151225 // grants an entitlement (avoids fabricating balances, §6.1).
0 commit comments