feat: bootstrap JOBcard CRM foundation

This commit is contained in:
Marco0300
2026-09-01 18:54:47 +02:00
commit 480494c5ed
31 changed files with 1300 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Client/ClientContactValidator.php';
$invalid = validate_client_contact([
'name' => ' ',
'email' => 'not-an-email',
'phone' => str_repeat('1', 61),
'is_primary' => 'maybe',
]);
foreach (['name', 'email', 'phone', 'is_primary'] as $key) {
if (!isset($invalid['errors'][$key])) {
throw new RuntimeException("Expected validation error for {$key}");
}
}
$valid = validate_client_contact([
'name' => ' Jane Doe ',
'email' => ' JANE@example.com ',
'phone' => ' +27 11 555 0100 ',
'is_primary' => '1',
]);
if ($valid['errors'] !== []
|| $valid['name'] !== 'Jane Doe'
|| $valid['email'] !== 'jane@example.com'
|| $valid['phone'] !== '+27 11 555 0100'
|| $valid['is_primary'] !== true
) {
throw new RuntimeException('Expected contact input to be normalized');
}
$optional = normalize_client_contact(['name' => 'Sam']);
if ($optional !== ['name' => 'Sam', 'email' => null, 'phone' => null, 'is_primary' => false]) {
throw new RuntimeException('Expected optional contact fields to normalize to null/defaults');
}
printf("Client contact validator tests: 3 passed\n");
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php';
require_once __DIR__ . '/../app/Domain/Client/ClientHelpers.php';
$cases = [
[[], 'name'],
[['name' => str_repeat('A', 191)], 'name'],
[['name' => 'Acme', 'status' => 'paused'], 'status'],
];
foreach ($cases as [$input, $errorKey]) {
$result = validate_client($input);
if (!isset($result['errors'][$errorKey])) {
throw new RuntimeException("Expected validation error for {$errorKey}");
}
}
$valid = validate_client(['name' => ' Acme IT ', 'status' => 'active']);
if ($valid['errors'] !== [] || $valid['name'] !== 'Acme IT') {
throw new RuntimeException('Expected valid client input to be normalized');
}
if (!client_name_is_duplicate(' acme IT ', ['Acme IT', 'Other'])) {
throw new RuntimeException('Expected duplicate client names to be detected case-insensitively');
}
if (client_name_is_duplicate('New Client', ['Acme IT'])) {
throw new RuntimeException('Expected unique client name to be accepted');
}
printf("Client validator tests: %d passed\n", count($cases) + 2);
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Jobcard/TimeCalculator.php';
require_once __DIR__ . '/../app/Domain/SLA/SlaCalculator.php';
if (calculate_duration_hours('09:00', '11:30') !== 2.5) {
throw new RuntimeException('Expected 2.5 hours from start/end');
}
if (calculate_duration_hours(null, null, 1.25) !== 1.25) {
throw new RuntimeException('Expected manual duration');
}
if (calculate_duration_hours('11:30', '09:00') !== null) {
throw new RuntimeException('Expected invalid reverse time to be rejected');
}
$sla = calculate_sla_usage(20.0, [2.0, 1.5, 1.0]);
if ($sla !== ['used' => 4.5, 'remaining' => 15.5, 'percentage' => 22.5, 'status' => 'within_limit']) {
throw new RuntimeException('Unexpected SLA calculation: ' . json_encode($sla));
}
$over = calculate_sla_usage(10.0, [8.0, 4.0]);
if ($over['status'] !== 'exceeded' || $over['remaining'] !== 0.0) {
throw new RuntimeException('Expected exceeded SLA to clamp remaining hours to zero');
}
printf("Jobcard calculation tests: 5 passed\n");
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php';
require_once __DIR__ . '/../app/Domain/Jobcard/StatusTransitionValidator.php';
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryValidator.php';
require_once __DIR__ . '/../app/Domain/Jobcard/TimeAggregator.php';
use App\Domain\Jobcard\JobcardReference;
use App\Domain\Jobcard\StatusTransitionValidator;
use App\Domain\Jobcard\TimeEntryValidator;
use App\Domain\Jobcard\TimeAggregator;
$reference = JobcardReference::generate(42, 2026);
if ($reference !== 'JC-2026-000042') throw new RuntimeException('Expected generated jobcard reference.');
if (!JobcardReference::isValid($reference) || JobcardReference::isValid('bad-reference')) throw new RuntimeException('Expected reference format validation.');
$transitions = new StatusTransitionValidator();
if (!$transitions->canTransition('new', 'assigned')) throw new RuntimeException('new should transition to assigned.');
if ($transitions->canTransition('new', 'completed')) throw new RuntimeException('new should not skip to completed.');
if (!$transitions->canTransition('in_progress', 'in_progress')) throw new RuntimeException('Same status should be allowed.');
$validEntry = (new TimeEntryValidator())->validate(['work_date' => '2026-09-01', 'start_time' => '09:00', 'end_time' => '11:30']);
if (!$validEntry['valid'] || $validEntry['hours'] !== 2.5 || $validEntry['errors'] !== []) throw new RuntimeException('Expected valid time entry.');
$invalidEntry = (new TimeEntryValidator())->validate(['work_date' => 'not-a-date', 'start_time' => '11:00', 'end_time' => '10:00']);
if ($invalidEntry['valid'] || count($invalidEntry['errors']) !== 2) throw new RuntimeException('Expected invalid time entry errors.');
$nonNumericEntry = (new TimeEntryValidator())->validate(['work_date' => '2026-09-01', 'hours' => 'not-a-number']);
if ($nonNumericEntry['valid'] || !isset($nonNumericEntry['errors']['hours'])) throw new RuntimeException('Expected non-numeric hours to be rejected.');
$aggregator = new TimeAggregator();
if ($aggregator->total([['hours' => 2.25], ['hours' => 1.5], ['hours' => -4]]) !== 3.75) throw new RuntimeException('Expected positive time aggregation.');
if ($aggregator->slaTotal([['hours' => 2, 'counts_toward_sla' => true], ['hours' => 3, 'counts_toward_sla' => false]]) !== 2.0) throw new RuntimeException('Expected SLA-filtered aggregation.');
printf("Jobcard domain tests: 12 passed\n");
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Reporting/ReportDataMapper.php';
require_once __DIR__ . '/../app/Domain/Reporting/HoursPerClientReport.php';
require_once __DIR__ . '/../app/Domain/Reporting/SlaReport.php';
function reporting_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$record = [
'id' => 7,
'name' => 'Acme IT',
'status' => 'active',
'support_email' => 'support@example.test',
'internal_notes' => 'never disclose',
'password' => 'secret',
'credentials' => 'token',
'technical_ip' => '10.0.0.1',
'unknown_field' => 'not approved',
];
$mapper = new ReportDataMapper();
reporting_assert_same(
['id' => 7, 'name' => 'Acme IT', 'status' => 'active', 'support_email' => 'support@example.test'],
$mapper->clientFacing($record),
'Client-facing report data must be allow-listed.'
);
reporting_assert_same(
['internal_notes' => 'never disclose', 'password' => 'secret', 'credentials' => 'token', 'technical_ip' => '10.0.0.1', 'unknown_field' => 'not approved'],
$mapper->internal($record),
'Internal report data must remain separate from client-facing data.'
);
$hours = new HoursPerClientReport();
reporting_assert_same(
[
['client_id' => 7, 'client_name' => 'Acme IT', 'hours' => 3.0],
['client_id' => 2, 'client_name' => 'Beta', 'hours' => 3.5],
],
$hours->aggregate([
['client_id' => 7, 'client_name' => 'Acme IT', 'hours' => 1.25],
['client_id' => 2, 'client_name' => 'Beta', 'hours' => 3.5, 'internal_notes' => 'omit'],
['client_id' => 7, 'client_name' => 'Acme IT', 'hours' => 1.75, 'password' => 'omit'],
]),
'Hours must aggregate deterministically per client.'
);
$sla = new SlaReport();
reporting_assert_same(
[
['client_id' => 7, 'client_name' => 'Acme IT', 'allocated_hours' => 10.0, 'used_hours' => 9.0, 'remaining_hours' => 1.0, 'usage_percentage' => 90.0, 'status' => 'critical'],
],
$sla->rows([[
'client_id' => 7,
'client_name' => 'Acme IT',
'allocated_hours' => 10,
'hours' => [4, 5, -2],
'internal_notes' => 'omit',
'credentials' => 'omit',
]]),
'SLA rows must expose only safe, deterministic report fields.'
);
printf("Reporting contract tests: 3 passed\n");
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/SLA/SlaThresholdClassifier.php';
use App\Domain\SLA\SlaThresholdClassifier;
$classifier = new SlaThresholdClassifier();
if ($classifier->classify(7.5, 10.0) !== 'warning') throw new RuntimeException('75% should be warning.');
if ($classifier->classify(9.0, 10.0) !== 'critical') throw new RuntimeException('90% should be critical.');
if ($classifier->classify(10.01, 10.0) !== 'exceeded') throw new RuntimeException('Over allocation should be exceeded.');
if ($classifier->classify(0.0, 0.0) !== 'within_limit') throw new RuntimeException('No allocation and no usage should be within limit.');
printf("SLA domain tests: 4 passed\n");
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
putenv('JOBcard_TEST_VALUE=present');
require_once __DIR__ . '/../config/bootstrap.php';
$checks = 0;
assert(env_required('JOBcard_TEST_VALUE') === 'present');
$checks++;
assert(e('<script>alert("x")</script>') === '&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;');
$checks++;
$missingRaised = false;
try {
env_required('JOBcard_MISSING_VALUE');
} catch (RuntimeException $exception) {
$missingRaised = str_contains($exception->getMessage(), 'JOBcard_MISSING_VALUE');
}
assert($missingRaised === true);
$checks++;
printf("Foundation smoke tests: %d passed\n", $checks);