feat: add administration reporting and correction services
This commit is contained in:
@@ -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');
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user