feat: add administration reporting and correction services

This commit is contained in:
Marco0300
2026-09-01 21:03:38 +02:00
parent de2bf277c4
commit b983f90dcb
24 changed files with 1392 additions and 31 deletions
+7 -3
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
require_once __DIR__ . '/ReportAudience.php';
final class ClientHistoryReport implements ReportQuery
{
@@ -10,9 +11,12 @@ final class ClientHistoryReport implements ReportQuery
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'client'): array
{
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $result = [];
foreach ($rows as $row) if ($filters->matches($row)) $result[] = $audience === 'internal' ? $mapper->internalHistory($row) : $mapper->clientHistory($row);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['changed_at'] ?? ''), (string)($b['changed_at'] ?? '')) ?: ((int)($a['id'] ?? $a['jobcard_id'] ?? 0) <=> (int)($b['id'] ?? $b['jobcard_id'] ?? 0)));
ReportAudience::validate($audience);
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $selected = [];
foreach ($rows as $row) if ($filters->matches($row)) $selected[] = $row;
usort($selected, static fn(array $a,array $b): int => strcmp((string)($a['changed_at'] ?? ''), (string)($b['changed_at'] ?? '')) ?: ((int)($a['id'] ?? $a['jobcard_id'] ?? 0) <=> (int)($b['id'] ?? $b['jobcard_id'] ?? 0)) ?: strcmp((string)($a['reference_no'] ?? ''), (string)($b['reference_no'] ?? '')));
$result = [];
foreach ($selected as $row) $result[] = $audience === ReportAudience::INTERNAL ? $mapper->internalHistory($row) : $mapper->clientHistory($row);
return $result;
}
public function query(array $rows, string $audience = 'client'): array { return $this->build($rows, $audience); }
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reporting {
require_once __DIR__ . '/ReportFilters.php';
/** Deterministic, storage-agnostic filtering for a client's status history. */
final class ClientHistoryService
{
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function filter(array $rows, array|\ReportFilters|null $criteria = null): array
{
$filters = $criteria instanceof \ReportFilters ? $criteria : \ReportFilters::fromArray($criteria ?? []);
$result = [];
foreach ($rows as $row) {
if (!is_array($row) || !$filters->matches($row)) continue;
$result[] = $row;
}
usort($result, static fn (array $a, array $b): int => strcmp((string)($a['changed_at'] ?? $a['created_at'] ?? ''), (string)($b['changed_at'] ?? $b['created_at'] ?? '')) ?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0)));
return $result;
}
/** @return list<array<string,mixed>> */
public function forClient(array $rows, int|string $clientId, array $criteria = []): array
{
if (!is_int($clientId) && (!is_string($clientId) || preg_match('/^[1-9]\d*$/', trim($clientId)) !== 1)) throw new \InvalidArgumentException('Client ID must be a positive integer.');
$criteria['client_id'] = (int)$clientId;
return $this->filter($rows, $criteria);
}
/** @return list<array<string,mixed>> */
public function history(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
/** @return list<array<string,mixed>> */
public function query(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
/** @return list<array<string,mixed>> */
public function getHistory(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
}
}
namespace {
if (!class_exists('ClientHistoryService', false)) class_alias('App\\Domain\\Reporting\\ClientHistoryService', 'ClientHistoryService');
}
+7 -3
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
require_once __DIR__ . '/ReportAudience.php';
final class ClientJobcardReport implements ReportQuery
{
@@ -10,9 +11,12 @@ final class ClientJobcardReport implements ReportQuery
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'client'): array
{
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $result = [];
foreach ($rows as $row) if ($filters->matches($row)) $result[] = $audience === 'internal' ? $mapper->internalJobcard($row) : $mapper->clientJobcard($row);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['created_at'] ?? ''), (string)($b['created_at'] ?? '')) ?: strcmp((string)($a['reference_no'] ?? ''), (string)($b['reference_no'] ?? '')));
ReportAudience::validate($audience);
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $selected = [];
foreach ($rows as $row) if ($filters->matches($row)) $selected[] = $row;
usort($selected, static fn(array $a,array $b): int => strcmp((string)($a['created_at'] ?? ''), (string)($b['created_at'] ?? '')) ?: strcmp((string)($a['reference_no'] ?? ''), (string)($b['reference_no'] ?? '')) ?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0)));
$result = [];
foreach ($selected as $row) $result[] = $audience === ReportAudience::INTERNAL ? $mapper->internalJobcard($row) : $mapper->clientJobcard($row);
return $result;
}
public function query(array $rows, string $audience = 'client'): array { return $this->build($rows, $audience); }
+15
View File
@@ -44,6 +44,21 @@ final class CsvExporter
return $withBom ? self::UTF8_BOM . $csv : $csv;
}
/** Serialize an allow-listed report projection without exposing associative keys. */
public function exportRecords(array $records, bool $withBom = false): string
{
if ($records === []) return $withBom ? self::UTF8_BOM : '';
$headers = array_keys($records[0]);
$rows = [];
foreach ($records as $record) {
if (!is_array($record) || array_keys($record) !== $headers) {
throw new InvalidArgumentException('CSV report records must share the same ordered fields.');
}
$rows[] = array_values($record);
}
return $this->export($headers, $rows, $withBom);
}
/**
* @param list<mixed> $fields
*/
@@ -7,6 +7,12 @@ final class PrintReportRenderer
/** @param list<string> $headers @param list<list<mixed>> $rows */
public function render(string $title, array $headers, array $rows): string
{
$headerCount = count($headers);
foreach ($rows as $number => $row) {
if (!is_array($row) || count($row) !== $headerCount) {
throw new InvalidArgumentException(sprintf('Print report row %d must contain %d fields.', $number + 1, $headerCount));
}
}
$head = implode('', array_map(fn(mixed $value): string => '<th>' . $this->escape($value) . '</th>', $headers));
$body = '';
foreach ($rows as $row) {
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
final class ReportAudience
{
public const CLIENT = 'client';
public const INTERNAL = 'internal';
public static function validate(string $audience): string
{
if (!in_array($audience, [self::CLIENT, self::INTERNAL], true)) {
throw new InvalidArgumentException('Report audience must be client or internal.');
}
return $audience;
}
}
+5 -1
View File
@@ -25,9 +25,13 @@ final class ReportDataMapper
/** @return array<string,mixed> */
public function internalHistory(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array<string,mixed> */
public function clientActivity(array $record): array { return $this->clientFacing($record); }
public function clientActivity(array $record): array { $safe = $this->clientFacing($record); $ordered = []; foreach (['client_id','client_name','hours','sla_hours'] as $field) if (array_key_exists($field, $safe)) $ordered[$field] = $safe[$field]; return $ordered; }
/** @return array<string,mixed> */
public function internalActivity(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array<string,mixed> */
public function clientSla(array $record): array { return $this->allow($record, ['client_id','client_name','allocated_hours','used_hours','remaining_hours','usage_percentage','status']); }
/** @return array<string,mixed> */
public function internalSla(array $record): array { return $this->clientSla($record); }
private function allow(array $record, array $fields): array
{
+6 -2
View File
@@ -36,7 +36,7 @@ final class ReportFilters
if ($this->status !== null && (string)($row['status'] ?? $row['to_status'] ?? '') !== $this->status) return false;
if ($this->priority !== null && (string)($row['priority'] ?? '') !== $this->priority) return false;
if ($this->sla !== null && (string)($row['sla_status'] ?? $row['sla'] ?? '') !== $this->sla) return false;
$date = (string)($row['work_date'] ?? $row['created_at'] ?? $row['changed_at'] ?? '');
$date = (string)($row['work_date'] ?? $row['created_at'] ?? $row['changed_at'] ?? $row['start_date'] ?? '');
if ($this->dateFrom !== null && ($date === '' || substr($date, 0, 10) < $this->dateFrom)) return false;
if ($this->dateTo !== null && ($date === '' || substr($date, 0, 10) > $this->dateTo)) return false;
return true;
@@ -55,7 +55,11 @@ final class ReportFilters
private static function positiveInt(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) return (int)$value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
$trimmed = trim($value);
$integer = filter_var($trimmed, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
return $integer === false || (string)$integer !== $trimmed ? null : $integer;
}
return null;
}
private static function text(mixed $value): ?string { return is_scalar($value) && trim((string)$value) !== '' ? trim((string)$value) : null; }
+30 -12
View File
@@ -1,29 +1,36 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportAudience.php';
final class SlaReport
{
public function __construct(
private readonly ?ReportFilters $filters = null,
private readonly ?ReportDataMapper $mapper = null,
) {}
/** @param list<array<string, mixed>> $agreements
* @return list<array<string, mixed>>
*/
public function rows(array $agreements): array
public function rows(array $agreements, string $audience = ReportAudience::INTERNAL): array
{
ReportAudience::validate($audience);
$filters = $this->filters ?? new ReportFilters();
$mapper = $this->mapper ?? new ReportDataMapper();
$rows = [];
foreach ($agreements as $agreement) {
if (!$filters->matches($agreement)) continue;
$allocated = max(0.0, (float)($agreement['allocated_hours'] ?? 0));
$used = 0.0;
foreach ((array)($agreement['hours'] ?? []) as $hours) {
$used += max(0.0, (float)$hours);
}
foreach ((array)($agreement['hours'] ?? []) as $hours) $used += max(0.0, (float)$hours);
$used = round($used, 2);
$remaining = round(max(0.0, $allocated - $used), 2);
$percentage = $allocated > 0
? round(($used / $allocated) * 100, 2)
: ($used > 0 ? 100.0 : 0.0);
$status = $used > $allocated
? 'exceeded'
: ($percentage >= 90 ? 'critical' : ($percentage >= 75 ? 'warning' : 'within_limit'));
$rows[] = [
$percentage = $allocated > 0 ? round(($used / $allocated) * 100, 2) : ($used > 0 ? 100.0 : 0.0);
$status = $used > $allocated ? 'exceeded' : ($percentage >= 90 ? 'critical' : ($percentage >= 75 ? 'warning' : 'within_limit'));
$record = [
'client_id' => (int)($agreement['client_id'] ?? 0),
'client_name' => (string)($agreement['client_name'] ?? ''),
'allocated_hours' => $allocated,
@@ -32,8 +39,19 @@ final class SlaReport
'usage_percentage' => $percentage,
'status' => $status,
];
$rows[] = $audience === ReportAudience::CLIENT ? $mapper->clientSla($record) : $mapper->internalSla($record);
}
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: $a['client_id'] <=> $b['client_id']);
usort($rows, static fn(array $a, array $b): int => strcmp((string)$a['client_name'], (string)$b['client_name']) ?: ((int)$a['client_id'] <=> (int)$b['client_id']));
return $rows;
}
public function build(array $agreements, string $audience = ReportAudience::INTERNAL): array
{
return $this->rows($agreements, $audience);
}
public function query(array $agreements, string $audience = ReportAudience::INTERNAL): array
{
return $this->rows($agreements, $audience);
}
}
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
require_once __DIR__ . '/ReportAudience.php';
final class TechnicianActivityReport implements ReportQuery
{
@@ -10,10 +11,13 @@ final class TechnicianActivityReport implements ReportQuery
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'internal'): array
{
ReportAudience::validate($audience);
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $totals = [];
foreach ($rows as $row) {
if (!$filters->matches($row)) continue;
$key = (string)(int)($row['technician_id'] ?? 0) . ':' . (string)(int)($row['client_id'] ?? 0);
$key = $audience === 'client'
? 'client:' . (string)(int)($row['client_id'] ?? 0)
: (string)(int)($row['technician_id'] ?? 0) . ':' . (string)(int)($row['client_id'] ?? 0);
if (!isset($totals[$key])) $totals[$key] = ['technician_id' => (int)($row['technician_id'] ?? 0), 'technician_name' => (string)($row['technician_name'] ?? ''), 'client_id' => (int)($row['client_id'] ?? 0), 'client_name' => (string)($row['client_name'] ?? ''), 'hours' => 0.0, 'sla_hours' => 0.0];
$hours = max(0.0, (float)($row['hours'] ?? 0)); $totals[$key]['hours'] += $hours;
if (!empty($row['counts_toward_sla'])) $totals[$key]['sla_hours'] += $hours;
@@ -21,7 +25,7 @@ final class TechnicianActivityReport implements ReportQuery
$result = array_values($totals);
foreach ($result as &$item) { $item['hours'] = round($item['hours'], 2); $item['sla_hours'] = round($item['sla_hours'], 2); if ($audience === 'client') $item = $mapper->clientActivity($item); }
unset($item);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['technician_name'] ?? ''), (string)($b['technician_name'] ?? '')) ?: ((int)($a['technician_id'] ?? 0) <=> (int)($b['technician_id'] ?? 0)));
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['technician_name'] ?? ''), (string)($b['technician_name'] ?? '')) ?: ((int)($a['technician_id'] ?? 0) <=> (int)($b['technician_id'] ?? 0)) ?: strcmp((string)($a['client_name'] ?? ''), (string)($b['client_name'] ?? '')) ?: ((int)($a['client_id'] ?? 0) <=> (int)($b['client_id'] ?? 0)));
return $result;
}
public function query(array $rows, string $audience = 'internal'): array { return $this->build($rows, $audience); }