Compare commits

..
9 changed files with 254 additions and 188 deletions
+49
View File
@@ -12523,6 +12523,40 @@ paths:
application/json:
schema: {}
/superuser/departments/{id}/overview:
get:
tags:
- Departments
summary: Get superuser department overview
description: Returns the selected department metadata and operational overview metrics for a superuser without requiring scoped department access.
operationId: getSuperuserDepartmentOverview
parameters:
- name: id
in: path
required: true
schema: {type: integer}
- name: date
in: query
required: true
schema: {type: string}
- name: date_to
in: query
required: false
schema: {type: string}
responses:
'200':
description: Superuser department overview loaded successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SuperuserDepartmentOverviewResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/superuser/department/branding:
put:
tags:
@@ -21550,6 +21584,21 @@ components:
data:
$ref: '#/components/schemas/DepartmentDailyReportOverviewPayload'
SuperuserDepartmentOverviewPayload:
type: object
properties:
department:
$ref: '#/components/schemas/Department'
overview:
$ref: '#/components/schemas/DepartmentDailyReportOverviewPayload'
SuperuserDepartmentOverviewResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/SuperuserDepartmentOverviewPayload'
DepartmentDailyReportTransactionCountPayload:
type: object
properties:
@@ -812,6 +812,53 @@ class departmentDailyReportsRoute
]
);
$this->get('/superuser/departments/{id}/overview', function () {
global $response;
$this->requirePermission('superuser_fetch_department');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
$department_id_param = (string)($this->fromRoute('id') ?? '');
if (!ctype_digit($department_id_param) || (int)$department_id_param <= 0) {
$response->error('Parameter id must be a positive integer', 400);
return;
}
self::requireParameters([
'date',
]);
self::validateDateLocally();
$date_to = $this->getDate_to();
$department_id = (int)$department_id_param;
$department = (new departments_o())->select($department_id);
if (!$department->exists()) {
$response->error('Department not found', 404);
return;
}
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'Successfully loaded superuser department overview');
$response->success([
'department' => $department->asArray(['slack_webhook' => false]),
'overview' => $this->buildDailyReportOverview(
[$department_id],
(string)self::getParameter('date'),
$date_to
),
]);
},
[
'superuser_fetch_department' => 'Get the superuser department overview'
]
);
$this->get('/departments/daily-reports/overview', function () {
global $response;
$this->requirePermission('list_department_daily_reports');
@@ -612,46 +612,21 @@ class orderInvoicesRoute
$preview ? 'User previewed splitting collected order invoices by month' : 'User split collected order invoices by order month'
);
$date_from = $db->escape_string($date_range['dateFrom']);
$date_to = $db->escape_string($date_range['dateTo']);
$sql = "SELECT DISTINCT invoice_collection_id
FROM orders
WHERE created_at BETWEEN '$date_from' AND '$date_to'
AND invoice_collection_id IS NOT NULL
AND invoice_collection_id > 0
AND deleted_at IS NULL";
$query_result = $db->query($sql);
$invoice_collection_ids = [];
if (self::isParametersSet(['invoice_collection_ids'])) {
$invoice_collection_ids_raw = self::getParameter('invoice_collection_ids');
if (!is_array($invoice_collection_ids_raw)) {
$response->error('invoice_collection_ids must be an array', 400);
}
foreach ($invoice_collection_ids_raw as $invoice_collection_id_raw) {
if (is_array($invoice_collection_id_raw) || is_object($invoice_collection_id_raw) || !is_numeric($invoice_collection_id_raw)) {
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
}
$invoice_collection_id = (int)$invoice_collection_id_raw;
if ($invoice_collection_id < 1 || $invoice_collection_id > 999999999) {
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
}
while ($row = $query_result->fetch_assoc()) {
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
if ($invoice_collection_id > 0) {
$invoice_collection_ids[] = $invoice_collection_id;
}
$invoice_collection_ids = array_values(array_unique($invoice_collection_ids));
if (empty($invoice_collection_ids)) {
$response->error('invoice_collection_ids must contain at least one id', 400);
}
} else {
$date_from = $db->escape_string($date_range['dateFrom']);
$date_to = $db->escape_string($date_range['dateTo']);
$sql = "SELECT DISTINCT invoice_collection_id
FROM orders
WHERE created_at BETWEEN '$date_from' AND '$date_to'
AND invoice_collection_id IS NOT NULL
AND invoice_collection_id > 0
AND deleted_at IS NULL";
$query_result = $db->query($sql);
while ($row = $query_result->fetch_assoc()) {
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
if ($invoice_collection_id > 0) {
$invoice_collection_ids[] = $invoice_collection_id;
}
}
}
$items = [];
@@ -85,65 +85,6 @@ it('previews monthly split changes without moving orders or creating collections
->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']);
});
it('previews only explicit monthly split invoice collection ids', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'preview-scope');
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Preview Monthly Split Customer']);
$department = api_fixtures()->createDepartment();
$targetCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$ignoredCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
]);
$targetMarchOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
'created_at' => '2096-03-15 10:00:00',
]);
$targetAprilOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
'created_at' => '2096-04-02 10:00:00',
]);
$ignoredMarchOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $ignoredCollection['id'],
'created_at' => '2096-03-16 10:00:00',
]);
$ignoredAprilOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $ignoredCollection['id'],
'created_at' => '2096-04-03 10:00:00',
]);
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
$response = api_client()->post('/collected-invoices/split-by-month', [
'dateFrom' => '2096-03-01',
'dateTo' => '2096-04-30',
'invoice_collection_ids' => [$targetCollection['id']],
'preview' => true,
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $response->data();
expect($payload['processed_count'] ?? null)->toBe(1)
->and($payload['changed_count'] ?? null)->toBe(1)
->and($payload['changed'][0]['invoice_collection_id'] ?? null)->toBe((int)$targetCollection['id'])
->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id'])
->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe((int)$targetCollection['id'])
->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id'])
->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']);
});
it('splits a selected March and April collected invoice into monthly collections', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'happy');
@@ -198,74 +139,6 @@ it('splits a selected March and April collected invoice into monthly collections
}
});
it('splits only explicit monthly split invoice collection ids', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'scope');
$customer = api_fixtures()->createUser(['display_name' => 'Scoped Monthly Split Customer']);
$department = api_fixtures()->createDepartment();
$targetCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
'created_at' => '2096-03-01 00:00:01',
]);
$ignoredCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $customer['customer_number'],
'created_at' => '2096-03-01 00:00:01',
]);
$targetMarchOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
'created_at' => '2096-03-15 10:00:00',
]);
$targetAprilOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $targetCollection['id'],
'created_at' => '2096-04-02 10:00:00',
]);
$ignoredMarchOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $ignoredCollection['id'],
'created_at' => '2096-03-16 10:00:00',
]);
$ignoredAprilOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'invoice_collection_id' => $ignoredCollection['id'],
'created_at' => '2096-04-03 10:00:00',
]);
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
$createdCollectionIds = [];
try {
$response = api_client()->post('/collected-invoices/split-by-month', [
'dateFrom' => '2096-03-01',
'dateTo' => '2096-04-30',
'invoice_collection_ids' => [$targetCollection['id']],
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $response->data();
$createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []);
$aprilCollectionId = (int)($createdCollectionIds[0] ?? 0);
expect($payload['processed_count'] ?? null)->toBe(1)
->and($payload['changed_count'] ?? null)->toBe(1)
->and($aprilCollectionId)->toBeGreaterThan(0)
->and(monthly_split_order_collection_id((int)$targetMarchOrder['id']))->toBe((int)$targetCollection['id'])
->and(monthly_split_order_collection_id((int)$targetAprilOrder['id']))->toBe($aprilCollectionId)
->and(monthly_split_order_collection_id((int)$ignoredMarchOrder['id']))->toBe((int)$ignoredCollection['id'])
->and(monthly_split_order_collection_id((int)$ignoredAprilOrder['id']))->toBe((int)$ignoredCollection['id']);
} finally {
monthly_split_cleanup_collections($createdCollectionIds);
}
});
it('sets closed_at to month end when split month has ended', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'closed-at');
@@ -472,27 +345,3 @@ it('rejects invalid monthly split date ranges', function (): void {
->assertEnvelope()
->assertSuccess(false);
});
it('rejects invalid explicit monthly split invoice collection ids', function (): void {
api_test_covers('POST /collected-invoices/split-by-month', 'invalid-scope');
$session = api_fixtures()->createUserSession(['split_collected_invoice']);
api_client()->post('/collected-invoices/split-by-month', [
'dateFrom' => '2096-03-01',
'dateTo' => '2096-04-30',
'invoice_collection_ids' => ['not-a-number'],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
api_client()->post('/collected-invoices/split-by-month', [
'dateFrom' => '2096-03-01',
'dateTo' => '2096-04-30',
'invoice_collection_ids' => [],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
});
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('loads a single department overview for superusers without department scoped access', function (): void {
api_test_covers('GET /superuser/departments/{id}/overview', 'happy');
$department = api_fixtures()->createDepartment([
'name' => 'Overview Department ' . uniqid('', false),
'description' => 'Department overview fixture',
'economic_department_id' => 42,
'visible' => 1,
]);
$departmentRow = api_fixtures()->fetchRowById('departments', (int)$department['id']);
$session = api_fixtures()->createUserSession([
'superuser_fetch_department',
]);
$response = api_client()->get(
'/superuser/departments/' . $department['id'] . '/overview?date=2026-07-06&date_to=2026-07-06',
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $response->data();
expect($payload)->toBeArray();
expect($payload['department'])
->toBeArray()
->toHaveKey('id', (int)$department['id'])
->toHaveKey('name', $departmentRow['name'])
->toHaveKey('description', 'Department overview fixture')
->toHaveKey('economic_department_id', 42);
expect($payload['overview'])
->toBeArray()
->toHaveKey('department_ids', [(int)$department['id']])
->toHaveKey('date', '2026-07-06')
->toHaveKey('date_to', '2026-07-06');
expect($payload['overview']['metrics'])
->toBeArray()
->toHaveKeys([
'bookings',
'complaints',
'night_washes',
'revenue',
'washes',
'products_sold',
'transactions',
'water_usage',
'overtime',
]);
expect($payload['overview']['metrics']['revenue']['state'])->toBe('ready');
expect($payload['overview']['metrics']['revenue']['value'])->toBe(0);
expect($payload['overview']['products'])->toBeArray();
});
it('rejects superuser department overview requests without permission or valid input', function (): void {
api_test_covers('GET /superuser/departments/{id}/overview', 'auth');
api_test_covers('GET /superuser/departments/{id}/overview', 'failure');
$department = api_fixtures()->createDepartment();
$unauthorizedSession = api_fixtures()->createUserSession([]);
api_client()->get(
'/superuser/departments/' . $department['id'] . '/overview?date=2026-07-06',
$unauthorizedSession['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['superuser_fetch_department']);
$session = api_fixtures()->createUserSession(['superuser_fetch_department']);
api_client()->get('/superuser/departments/bad/overview?date=2026-07-06', $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Parameter id must be a positive integer');
api_client()->get('/superuser/departments/' . $department['id'] . '/overview', $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Missing required parameters: date');
api_client()->get('/superuser/departments/99999999/overview?date=2026-07-06', $session['headers'])
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Department not found');
});
@@ -19,6 +19,7 @@ return [
'GET /branding',
'POST /branding',
'PUT /branding',
'GET /superuser/departments/{id}/overview',
'PUT /superuser/department/branding',
'POST /bird/voice/calls/webhook/inbound',
],
@@ -111,6 +111,46 @@ CREATE TABLE IF NOT EXISTS `department_variables` (
KEY `idx_department_variables_department_id` (`department_id`),
KEY `idx_department_variables_variable` (`variable`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'department_daily_reports' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `department_daily_reports` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`department_id` INT NOT NULL,
`water_usage` INT NOT NULL DEFAULT 0,
`water_usage_morning` INT NOT NULL DEFAULT 0,
`notes` TEXT NULL,
`filled_by` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_department_daily_reports_department_id` (`department_id`),
KEY `idx_department_daily_reports_created_at` (`created_at`),
KEY `idx_department_daily_reports_department_created_at` (`department_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'department_time_bookings_opening_hours' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `department_time_bookings_opening_hours` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`department` INT NOT NULL,
`monday_start` TIME NULL,
`monday_end` TIME NULL,
`tuesday_start` TIME NULL,
`tuesday_end` TIME NULL,
`wednesday_start` TIME NULL,
`wednesday_end` TIME NULL,
`thursday_start` TIME NULL,
`thursday_end` TIME NULL,
`friday_start` TIME NULL,
`friday_end` TIME NULL,
`saturday_start` TIME NULL,
`saturday_end` TIME NULL,
`sunday_start` TIME NULL,
`sunday_end` TIME NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_department_time_bookings_opening_hours_department` (`department`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'department_gates' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `department_gates` (
@@ -31,8 +31,11 @@ it('documents the daily report overview endpoint and reusable schemas in openapi
$content = department_daily_reports_openapi_content_or_skip();
expect($content)->toContain('/departments/daily-reports/overview:');
expect($content)->toContain('/superuser/departments/{id}/overview:');
expect($content)->toContain('operationId: getDailyReportOverview');
expect($content)->toContain('operationId: getSuperuserDepartmentOverview');
expect($content)->toContain('DepartmentDailyReportOverviewResponse:');
expect($content)->toContain('SuperuserDepartmentOverviewResponse:');
expect($content)->toContain('DepartmentDailyReportMetric:');
expect($content)->toContain('DepartmentDailyReportProductTile:');
expect($content)->toContain('- name: department_ids');
@@ -342,6 +342,8 @@ it('wires the overview route to batched repository methods and overview path', f
$objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php'));
expect($routeContent)->toContain('/departments/daily-reports/overview');
expect($routeContent)->toContain('/superuser/departments/{id}/overview');
expect($routeContent)->toContain('superuser_fetch_department');
expect($routeContent)->toContain('/departments/daily-reports/complaints');
expect($routeContent)->toContain('outsideHoursStatisticsService');
expect($routeContent)->toContain('dailyReportComplaintsRepository');