Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f797bf6b8 | ||
|
|
d345db927f | ||
|
|
eca7a81f9d | ||
|
|
6f3d7e0f7d | ||
|
|
a8fba73d99 | ||
|
|
669759461d | ||
|
|
38814545c4 | ||
|
|
215c8d0fbb |
@@ -240,7 +240,13 @@ class economic_transfer_executor
|
|||||||
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
|
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
|
||||||
);
|
);
|
||||||
|
|
||||||
return $collected_order_invoices->asArray();
|
$result = $collected_order_invoices->asArray();
|
||||||
|
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
|
||||||
|
if ($transfer_metrics !== null) {
|
||||||
|
$result['economic_transfer_metrics'] = $transfer_metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -112,6 +112,134 @@ class limited_backoffice_service
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{group:string,capability:string}>
|
||||||
|
*/
|
||||||
|
private const ROLE_PERMISSION_CAPABILITIES = [
|
||||||
|
'user' => [
|
||||||
|
'group' => 'account',
|
||||||
|
'capability' => 'sign_in',
|
||||||
|
],
|
||||||
|
'permissions_list_own' => [
|
||||||
|
'group' => 'account',
|
||||||
|
'capability' => 'view_own_permissions',
|
||||||
|
],
|
||||||
|
'list_orders' => [
|
||||||
|
'group' => 'orders',
|
||||||
|
'capability' => 'view_orders',
|
||||||
|
],
|
||||||
|
'add_order' => [
|
||||||
|
'group' => 'orders',
|
||||||
|
'capability' => 'create_orders',
|
||||||
|
],
|
||||||
|
'edit_order' => [
|
||||||
|
'group' => 'orders',
|
||||||
|
'capability' => 'edit_orders',
|
||||||
|
],
|
||||||
|
'delete_order' => [
|
||||||
|
'group' => 'orders',
|
||||||
|
'capability' => 'delete_orders',
|
||||||
|
],
|
||||||
|
'list_order_items' => [
|
||||||
|
'group' => 'orders',
|
||||||
|
'capability' => 'view_order_items',
|
||||||
|
],
|
||||||
|
'add_order_items' => [
|
||||||
|
'group' => 'orders',
|
||||||
|
'capability' => 'create_order_items',
|
||||||
|
],
|
||||||
|
'edit_order_items' => [
|
||||||
|
'group' => 'orders',
|
||||||
|
'capability' => 'update_order_lines',
|
||||||
|
],
|
||||||
|
'delete_order_items' => [
|
||||||
|
'group' => 'orders',
|
||||||
|
'capability' => 'remove_order_lines',
|
||||||
|
],
|
||||||
|
'charge_order' => [
|
||||||
|
'group' => 'orders',
|
||||||
|
'capability' => 'charge_orders',
|
||||||
|
],
|
||||||
|
'list_bookings' => [
|
||||||
|
'group' => 'bookings',
|
||||||
|
'capability' => 'view_department_bookings',
|
||||||
|
],
|
||||||
|
'list_own_bookings' => [
|
||||||
|
'group' => 'bookings',
|
||||||
|
'capability' => 'view_own_bookings',
|
||||||
|
],
|
||||||
|
'edit_bookings' => [
|
||||||
|
'group' => 'bookings',
|
||||||
|
'capability' => 'update_bookings',
|
||||||
|
],
|
||||||
|
'add_booking' => [
|
||||||
|
'group' => 'bookings',
|
||||||
|
'capability' => 'create_bookings',
|
||||||
|
],
|
||||||
|
'complete_bookings' => [
|
||||||
|
'group' => 'bookings',
|
||||||
|
'capability' => 'mark_bookings_complete',
|
||||||
|
],
|
||||||
|
'resend_booking_confirmations' => [
|
||||||
|
'group' => 'bookings',
|
||||||
|
'capability' => 'send_booking_confirmations',
|
||||||
|
],
|
||||||
|
'department_timebookings_entries_get' => [
|
||||||
|
'group' => 'time_bookings',
|
||||||
|
'capability' => 'view_time_booking_entries',
|
||||||
|
],
|
||||||
|
'department_timebookings_entries_post' => [
|
||||||
|
'group' => 'time_bookings',
|
||||||
|
'capability' => 'create_time_booking_entries',
|
||||||
|
],
|
||||||
|
'department_timebookings_entries_put' => [
|
||||||
|
'group' => 'time_bookings',
|
||||||
|
'capability' => 'edit_time_booking_entries',
|
||||||
|
],
|
||||||
|
'statistics_orders_new' => [
|
||||||
|
'group' => 'reports',
|
||||||
|
'capability' => 'view_order_statistics',
|
||||||
|
],
|
||||||
|
'statistics_bookings_new' => [
|
||||||
|
'group' => 'reports',
|
||||||
|
'capability' => 'view_booking_statistics',
|
||||||
|
],
|
||||||
|
self::PERMISSION_ACCESS => [
|
||||||
|
'group' => 'limited_backoffice',
|
||||||
|
'capability' => 'open_limited_backoffice',
|
||||||
|
],
|
||||||
|
self::PERMISSION_MANAGE_PRICES => [
|
||||||
|
'group' => 'limited_backoffice',
|
||||||
|
'capability' => 'manage_department_prices',
|
||||||
|
],
|
||||||
|
self::PERMISSION_MANAGE_EMPLOYEES => [
|
||||||
|
'group' => 'limited_backoffice',
|
||||||
|
'capability' => 'manage_employee_access',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
private const ROLE_PERMISSION_GROUP_ORDER = [
|
||||||
|
'account',
|
||||||
|
'orders',
|
||||||
|
'bookings',
|
||||||
|
'time_bookings',
|
||||||
|
'reports',
|
||||||
|
'limited_backoffice',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, true>
|
||||||
|
*/
|
||||||
|
private const PHONE_COUNTRY_CODES = [
|
||||||
|
45 => true,
|
||||||
|
46 => true,
|
||||||
|
47 => true,
|
||||||
|
358 => true,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<string, bool>
|
* @var array<string, bool>
|
||||||
*/
|
*/
|
||||||
@@ -123,7 +251,7 @@ class limited_backoffice_service
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array{key:string,label:string,description:string}>
|
* @return array<int, array{key:string,label:string,description:string,permission_groups:array<int,array{key:string,capabilities:array<int,string>}>}>
|
||||||
*/
|
*/
|
||||||
public function rolePresets(): array
|
public function rolePresets(): array
|
||||||
{
|
{
|
||||||
@@ -133,11 +261,45 @@ class limited_backoffice_service
|
|||||||
'key' => $key,
|
'key' => $key,
|
||||||
'label' => $preset['label'],
|
'label' => $preset['label'],
|
||||||
'description' => $preset['description'],
|
'description' => $preset['description'],
|
||||||
|
'permission_groups' => $this->rolePermissionGroups($preset['permissions']),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return $roles;
|
return $roles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $permissions
|
||||||
|
* @return array<int, array{key:string,capabilities:array<int,string>}>
|
||||||
|
*/
|
||||||
|
private function rolePermissionGroups(array $permissions): array
|
||||||
|
{
|
||||||
|
$groups = [];
|
||||||
|
foreach ($permissions as $permission) {
|
||||||
|
$capability = self::ROLE_PERMISSION_CAPABILITIES[$permission] ?? null;
|
||||||
|
if ($capability === null) {
|
||||||
|
throw new \RuntimeException('Missing limited backoffice role capability for permission: ' . $permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
$group = $capability['group'];
|
||||||
|
$groups[$group] ??= [];
|
||||||
|
$groups[$group][] = $capability['capability'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [];
|
||||||
|
foreach (self::ROLE_PERMISSION_GROUP_ORDER as $group) {
|
||||||
|
if (!isset($groups[$group])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload[] = [
|
||||||
|
'key' => $group,
|
||||||
|
'capabilities' => array_values(array_unique($groups[$group])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, int>
|
* @return array<int, int>
|
||||||
*/
|
*/
|
||||||
@@ -382,7 +544,8 @@ class limited_backoffice_service
|
|||||||
$roleKey = $this->normalizeRoleKey($payload['role_key'] ?? null);
|
$roleKey = $this->normalizeRoleKey($payload['role_key'] ?? null);
|
||||||
$displayName = $this->normalizeRequiredString($payload['display_name'] ?? null, 'Display name is required.');
|
$displayName = $this->normalizeRequiredString($payload['display_name'] ?? null, 'Display name is required.');
|
||||||
$password = $this->normalizePassword($payload['password'] ?? null, true);
|
$password = $this->normalizePassword($payload['password'] ?? null, true);
|
||||||
$email = $this->normalizeOptionalString($payload['email'] ?? null);
|
$email = $this->normalizeEmail($payload['email'] ?? null, true);
|
||||||
|
$phone = $this->normalizeOptionalPhonePair($payload);
|
||||||
|
|
||||||
$mysqli = $this->mysqli();
|
$mysqli = $this->mysqli();
|
||||||
$mysqli->begin_transaction();
|
$mysqli->begin_transaction();
|
||||||
@@ -393,13 +556,23 @@ class limited_backoffice_service
|
|||||||
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
|
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
|
||||||
|
|
||||||
$statement = $mysqli->prepare(
|
$statement = $mysqli->prepare(
|
||||||
'INSERT INTO `users` (`customer_number`, `display_name`, `email`, `password`, `group_id`)
|
'INSERT INTO `users`
|
||||||
VALUES (?, ?, ?, ?, ?)'
|
(`customer_number`, `display_name`, `email`, `password`, `group_id`, `phone_country_code`, `phone`)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
if ($statement === false) {
|
if ($statement === false) {
|
||||||
throw new \RuntimeException('Unable to prepare employee insert.');
|
throw new \RuntimeException('Unable to prepare employee insert.');
|
||||||
}
|
}
|
||||||
$statement->bind_param('isssi', $customerNumber, $displayName, $email, $passwordHash, $groupId);
|
$statement->bind_param(
|
||||||
|
'isssiii',
|
||||||
|
$customerNumber,
|
||||||
|
$displayName,
|
||||||
|
$email,
|
||||||
|
$passwordHash,
|
||||||
|
$groupId,
|
||||||
|
$phone['phone_country_code'],
|
||||||
|
$phone['phone']
|
||||||
|
);
|
||||||
$statement->execute();
|
$statement->execute();
|
||||||
$employeeId = (int)$mysqli->insert_id;
|
$employeeId = (int)$mysqli->insert_id;
|
||||||
$statement->close();
|
$statement->close();
|
||||||
@@ -477,11 +650,12 @@ class limited_backoffice_service
|
|||||||
? $this->normalizeRequiredString($payload['display_name'], 'Display name is required.')
|
? $this->normalizeRequiredString($payload['display_name'], 'Display name is required.')
|
||||||
: null;
|
: null;
|
||||||
$email = array_key_exists('email', $payload)
|
$email = array_key_exists('email', $payload)
|
||||||
? $this->normalizeOptionalString($payload['email'])
|
? $this->normalizeEmail($payload['email'], true)
|
||||||
: null;
|
: null;
|
||||||
$password = array_key_exists('password', $payload)
|
$password = array_key_exists('password', $payload)
|
||||||
? $this->normalizePassword($payload['password'], false)
|
? $this->normalizePassword($payload['password'], false)
|
||||||
: null;
|
: null;
|
||||||
|
$phone = $this->normalizeOptionalPhonePair($payload, false);
|
||||||
$active = array_key_exists('active', $payload)
|
$active = array_key_exists('active', $payload)
|
||||||
? (bool)$payload['active']
|
? (bool)$payload['active']
|
||||||
: $this->isEmployeeRowActive($employee);
|
: $this->isEmployeeRowActive($employee);
|
||||||
@@ -511,6 +685,10 @@ class limited_backoffice_service
|
|||||||
if ($password !== null) {
|
if ($password !== null) {
|
||||||
$userUpdates['password'] = password_hash($password, PASSWORD_DEFAULT);
|
$userUpdates['password'] = password_hash($password, PASSWORD_DEFAULT);
|
||||||
}
|
}
|
||||||
|
if ($phone !== null) {
|
||||||
|
$userUpdates['phone_country_code'] = $phone['phone_country_code'];
|
||||||
|
$userUpdates['phone'] = $phone['phone'];
|
||||||
|
}
|
||||||
$usersHaveDeletedAt = $this->tableHasColumn('users', 'deleted_at');
|
$usersHaveDeletedAt = $this->tableHasColumn('users', 'deleted_at');
|
||||||
if ($active) {
|
if ($active) {
|
||||||
$userUpdates['group_id'] = $managedGroupId;
|
$userUpdates['group_id'] = $managedGroupId;
|
||||||
@@ -909,6 +1087,89 @@ class limited_backoffice_service
|
|||||||
return $value === '' ? null : $value;
|
return $value === '' ? null : $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeEmail(mixed $value, bool $required): ?string
|
||||||
|
{
|
||||||
|
$email = $this->normalizeOptionalString($value);
|
||||||
|
if ($email === null) {
|
||||||
|
if ($required) {
|
||||||
|
throw new limited_backoffice_exception('Email is required.', 400);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||||
|
throw new limited_backoffice_exception('Email must be a valid email address.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $email;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array{phone_country_code:int|null,phone:int|null}|null
|
||||||
|
*/
|
||||||
|
private function normalizeOptionalPhonePair(array $payload, bool $defaultWhenMissing = true): ?array
|
||||||
|
{
|
||||||
|
$hasCountryCode = array_key_exists('phone_country_code', $payload);
|
||||||
|
$hasPhone = array_key_exists('phone', $payload);
|
||||||
|
if (!$hasCountryCode && !$hasPhone) {
|
||||||
|
return $defaultWhenMissing
|
||||||
|
? ['phone_country_code' => null, 'phone' => null]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$hasCountryCode || !$hasPhone) {
|
||||||
|
throw new limited_backoffice_exception('Phone country code and phone number must be provided together.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$countryCode = $this->normalizeOptionalDigits($payload['phone_country_code']);
|
||||||
|
$phone = $this->normalizeOptionalDigits($payload['phone']);
|
||||||
|
if ($countryCode === null && $phone === null) {
|
||||||
|
return ['phone_country_code' => null, 'phone' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($countryCode === null || $phone === null) {
|
||||||
|
throw new limited_backoffice_exception('Phone country code and phone number must be provided together.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset(self::PHONE_COUNTRY_CODES[$countryCode])) {
|
||||||
|
throw new limited_backoffice_exception('Phone country code is not supported.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$phoneText = (string)$phone;
|
||||||
|
if (!preg_match('/^\d{4,15}$/', $phoneText)) {
|
||||||
|
throw new limited_backoffice_exception('Phone number must be 4-15 digits.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'phone_country_code' => $countryCode,
|
||||||
|
'phone' => $phone,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeOptionalDigits(mixed $value): ?int
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_int($value)) {
|
||||||
|
return $value > 0 ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
$value = trim($value);
|
||||||
|
if ($value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (ctype_digit($value)) {
|
||||||
|
return (int)$value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new limited_backoffice_exception('Phone values must contain digits only.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizePassword(mixed $value, bool $required): ?string
|
private function normalizePassword(mixed $value, bool $required): ?string
|
||||||
{
|
{
|
||||||
if ($value === null || $value === '') {
|
if ($value === null || $value === '') {
|
||||||
@@ -1084,6 +1345,8 @@ class limited_backoffice_service
|
|||||||
'customer_number' => (int)$row['customer_number'],
|
'customer_number' => (int)$row['customer_number'],
|
||||||
'display_name' => (string)($row['display_name'] ?? ''),
|
'display_name' => (string)($row['display_name'] ?? ''),
|
||||||
'email' => $row['email'] === null ? null : (string)$row['email'],
|
'email' => $row['email'] === null ? null : (string)$row['email'],
|
||||||
|
'phone_country_code' => $row['phone_country_code'] === null ? null : (int)$row['phone_country_code'],
|
||||||
|
'phone' => $row['phone'] === null ? null : (int)$row['phone'],
|
||||||
'active' => $active,
|
'active' => $active,
|
||||||
'role' => $this->rolePayload((string)$row['role_key']),
|
'role' => $this->rolePayload((string)$row['role_key']),
|
||||||
'departments' => $this->departmentSummaries($departmentIds),
|
'departments' => $this->departmentSummaries($departmentIds),
|
||||||
@@ -1204,7 +1467,7 @@ class limited_backoffice_service
|
|||||||
$types = '';
|
$types = '';
|
||||||
$values = [];
|
$values = [];
|
||||||
foreach ($fields as $field => $value) {
|
foreach ($fields as $field => $value) {
|
||||||
if (!in_array($field, ['display_name', 'email', 'password', 'group_id', 'deleted_at'], true)) {
|
if (!in_array($field, ['display_name', 'email', 'password', 'group_id', 'deleted_at', 'phone_country_code', 'phone'], true)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($value === null) {
|
if ($value === null) {
|
||||||
|
|||||||
+32
-4
@@ -61,19 +61,47 @@ class economic_invoices_draft_endpoint
|
|||||||
* @throws Exception If the request fails
|
* @throws Exception If the request fails
|
||||||
*/
|
*/
|
||||||
public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void
|
public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void
|
||||||
|
{
|
||||||
|
$this->add_orders($invoiceDraftId, [$order], $currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add many orders to a draft invoice and flush their lines in batches.
|
||||||
|
*
|
||||||
|
* @param orders_o[] $orders
|
||||||
|
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
|
||||||
|
* @throws Exception If the request fails
|
||||||
|
*/
|
||||||
|
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): array
|
||||||
{
|
{
|
||||||
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
|
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
|
||||||
// Check if the order includes any items that should be included in the invoice
|
$orders_with_invoice_lines = 0;
|
||||||
if ($order->getIncludeInInvoiceCount() > 0) {
|
|
||||||
|
foreach ( $orders as $order ) {
|
||||||
|
if (!$order instanceof orders_o) {
|
||||||
|
throw new Exception('Order payload must contain orders_o instances');
|
||||||
|
}
|
||||||
|
// Check if the order includes any items that should be included in the invoice
|
||||||
|
if ($order->getIncludeInInvoiceCount() <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orders_with_invoice_lines++;
|
||||||
// Add the transaction header (Timestamp, department, etc.)
|
// Add the transaction header (Timestamp, department, etc.)
|
||||||
$draftInvoice->addNewTransactionHeader($order);
|
$draftInvoice->addNewTransactionHeader($order);
|
||||||
// Add the order lines
|
// Add the order lines
|
||||||
$draftInvoice->addOrderItemLines($order);
|
$draftInvoice->addOrderItemLines($order);
|
||||||
// Add an empty line, so the invoice is not empty
|
// Add an empty line, so the invoice is not empty
|
||||||
$draftInvoice->addTextLine('');
|
$draftInvoice->addTextLine('');
|
||||||
// Save the draft invoice lines
|
|
||||||
$draftInvoice->addLines();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$metrics = $draftInvoice->flushLinesInBatches($line_batch_size);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'order_count' => count($orders),
|
||||||
|
'orders_with_invoice_lines' => $orders_with_invoice_lines,
|
||||||
|
...$metrics,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ use objects\orders_o;
|
|||||||
|
|
||||||
class economic_invoice_draft
|
class economic_invoice_draft
|
||||||
{
|
{
|
||||||
|
public const DEFAULT_LINE_BATCH_SIZE = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Economic draftInvoiceNumber
|
* The Economic draftInvoiceNumber
|
||||||
* @var int $draft_invoice_number
|
* @var int $draft_invoice_number
|
||||||
@@ -110,13 +112,55 @@ class economic_invoice_draft
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add the lines to the draft invoice
|
* Add the lines to the draft invoice.
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function addLines(): void
|
public function addLines(): void
|
||||||
|
{
|
||||||
|
$this->flushLinesInBatches();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add queued draft lines using chunked requests.
|
||||||
|
*
|
||||||
|
* @return array{line_count:int,batch_count:int,batch_sizes:array<int,int>}
|
||||||
|
*/
|
||||||
|
public function flushLinesInBatches(int $batch_size = self::DEFAULT_LINE_BATCH_SIZE): array
|
||||||
|
{
|
||||||
|
$lines = array_values($this->draft_lines);
|
||||||
|
$line_count = count($lines);
|
||||||
|
if ($line_count === 0) {
|
||||||
|
return [
|
||||||
|
'line_count' => 0,
|
||||||
|
'batch_count' => 0,
|
||||||
|
'batch_sizes' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch_size = max(1, $batch_size);
|
||||||
|
$batch_sizes = [];
|
||||||
|
foreach (array_chunk($lines, $batch_size) as $batch) {
|
||||||
|
$this->sendDraftLines($batch);
|
||||||
|
$batch_sizes[] = count($batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->draft_lines = [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'line_count' => $line_count,
|
||||||
|
'batch_count' => count($batch_sizes),
|
||||||
|
'batch_sizes' => $batch_sizes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pendingLineCount(): int
|
||||||
|
{
|
||||||
|
return count($this->draft_lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sendDraftLines(array $draft_lines): object
|
||||||
{
|
{
|
||||||
$economic = new economic();
|
$economic = new economic();
|
||||||
$economic->invoices->draft->add_lines($this->draft_invoice_number, $this->draft_lines);
|
return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class collected_order_invoices_o extends db
|
|||||||
public object_property $updated_at;
|
public object_property $updated_at;
|
||||||
public object_property $closed_at;
|
public object_property $closed_at;
|
||||||
public int $economic_wash_subscription_user_id = 1857;
|
public int $economic_wash_subscription_user_id = 1857;
|
||||||
|
private ?array $last_economic_transfer_metrics = null;
|
||||||
/**
|
/**
|
||||||
* The processor types
|
* The processor types
|
||||||
*
|
*
|
||||||
@@ -712,6 +713,7 @@ class collected_order_invoices_o extends db
|
|||||||
*/
|
*/
|
||||||
public function addInvoicesToDraft(bool $skip_check = false): self
|
public function addInvoicesToDraft(bool $skip_check = false): self
|
||||||
{
|
{
|
||||||
|
$this->last_economic_transfer_metrics = null;
|
||||||
// Require the invoice collection to be selected
|
// Require the invoice collection to be selected
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
// Require the invoice collection to be open
|
// Require the invoice collection to be open
|
||||||
@@ -736,10 +738,20 @@ class collected_order_invoices_o extends db
|
|||||||
usort($orders, function ($a, $b) {
|
usort($orders, function ($a, $b) {
|
||||||
return strtotime($a['created_at']) - strtotime($b['created_at']);
|
return strtotime($a['created_at']) - strtotime($b['created_at']);
|
||||||
});
|
});
|
||||||
// Add the invoices to the invoice draft
|
// Add the invoice lines to the draft in one accumulated batch path.
|
||||||
|
$order_objects = [];
|
||||||
foreach ( $orders as $order ) {
|
foreach ( $orders as $order ) {
|
||||||
self::addInvoiceToDraft($order['id'], true, $draft_id, $currency);
|
$order_object = new orders_o();
|
||||||
|
$order_object->select((int)$order['id']);
|
||||||
|
$order_object->requireSelected();
|
||||||
|
$order_objects[] = $order_object;
|
||||||
}
|
}
|
||||||
|
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);
|
||||||
|
$this->last_economic_transfer_metrics = [
|
||||||
|
'draft_invoice_id' => $draft_id,
|
||||||
|
'currency' => (string)$currency,
|
||||||
|
...$metrics,
|
||||||
|
];
|
||||||
// If the customer has the onlyTankCleaning attribute, add the environmental fee & oil fees to the invoice draft
|
// If the customer has the onlyTankCleaning attribute, add the environmental fee & oil fees to the invoice draft
|
||||||
self::addEnvironmentalAndOilFeesToDraft($draft_id, $currency);
|
self::addEnvironmentalAndOilFeesToDraft($draft_id, $currency);
|
||||||
// Object changed
|
// Object changed
|
||||||
@@ -748,6 +760,11 @@ class collected_order_invoices_o extends db
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getLastEconomicTransferMetrics(): ?array
|
||||||
|
{
|
||||||
|
return $this->last_economic_transfer_metrics;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add the environmental fee & oil fees to the invoice draft, if the customer has the onlyTankCleaning attribute
|
* Add the environmental fee & oil fees to the invoice draft, if the customer has the onlyTankCleaning attribute
|
||||||
* @param int $draft_id The invoice draft id
|
* @param int $draft_id The invoice draft id
|
||||||
|
|||||||
@@ -12523,6 +12523,40 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema: {}
|
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:
|
/superuser/department/branding:
|
||||||
put:
|
put:
|
||||||
tags:
|
tags:
|
||||||
@@ -21550,6 +21584,21 @@ components:
|
|||||||
data:
|
data:
|
||||||
$ref: '#/components/schemas/DepartmentDailyReportOverviewPayload'
|
$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:
|
DepartmentDailyReportTransactionCountPayload:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
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 () {
|
$this->get('/departments/daily-reports/overview', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$this->requirePermission('list_department_daily_reports');
|
$this->requirePermission('list_department_daily_reports');
|
||||||
|
|||||||
@@ -348,11 +348,60 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
|||||||
->assertSuccess();
|
->assertSuccess();
|
||||||
|
|
||||||
expect(array_column($roles->data(), 'key'))->toBe(['viewer', 'cashier', 'booking_coordinator', 'operations_lead', 'department_admin']);
|
expect(array_column($roles->data(), 'key'))->toBe(['viewer', 'cashier', 'booking_coordinator', 'operations_lead', 'department_admin']);
|
||||||
|
$rolesByKey = array_column($roles->data(), null, 'key');
|
||||||
|
expect($rolesByKey['viewer']['permission_groups'] ?? null)->toBe([
|
||||||
|
[
|
||||||
|
'key' => 'account',
|
||||||
|
'capabilities' => ['sign_in', 'view_own_permissions'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$departmentAdminGroups = array_column($rolesByKey['department_admin']['permission_groups'] ?? [], 'capabilities', 'key');
|
||||||
|
expect($departmentAdminGroups['limited_backoffice'] ?? null)->toBe([
|
||||||
|
'open_limited_backoffice',
|
||||||
|
'manage_department_prices',
|
||||||
|
'manage_employee_access',
|
||||||
|
]);
|
||||||
expect($roles->body)->not->toContain('department_access_');
|
expect($roles->body)->not->toContain('department_access_');
|
||||||
|
$rolePayload = $roles->data();
|
||||||
|
$rolePayloadStrings = [];
|
||||||
|
array_walk_recursive($rolePayload, static function ($value) use (&$rolePayloadStrings): void {
|
||||||
|
if (is_string($value)) {
|
||||||
|
$rolePayloadStrings[] = $value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
foreach ([
|
||||||
|
'list_orders',
|
||||||
|
'add_order',
|
||||||
|
'edit_order',
|
||||||
|
'delete_order',
|
||||||
|
'list_order_items',
|
||||||
|
'add_order_items',
|
||||||
|
'edit_order_items',
|
||||||
|
'delete_order_items',
|
||||||
|
'charge_order',
|
||||||
|
'list_bookings',
|
||||||
|
'list_own_bookings',
|
||||||
|
'edit_bookings',
|
||||||
|
'add_booking',
|
||||||
|
'complete_bookings',
|
||||||
|
'resend_booking_confirmations',
|
||||||
|
'department_timebookings_entries_get',
|
||||||
|
'department_timebookings_entries_post',
|
||||||
|
'department_timebookings_entries_put',
|
||||||
|
'statistics_orders_new',
|
||||||
|
'statistics_bookings_new',
|
||||||
|
'limited_backoffice_access',
|
||||||
|
'limited_backoffice_prices_manage',
|
||||||
|
'limited_backoffice_employees_manage',
|
||||||
|
] as $rawPermission) {
|
||||||
|
expect($rolePayloadStrings)->not->toContain($rawPermission);
|
||||||
|
}
|
||||||
|
|
||||||
$created = api_client()->post('/limited-backoffice/employees', [
|
$created = api_client()->post('/limited-backoffice/employees', [
|
||||||
'display_name' => 'Limited Cashier',
|
'display_name' => 'Limited Cashier',
|
||||||
'email' => 'limited-cashier@example.test',
|
'email' => 'limited-cashier@example.test',
|
||||||
|
'phone_country_code' => 45,
|
||||||
|
'phone' => 12345678,
|
||||||
'password' => 'Secret123!',
|
'password' => 'Secret123!',
|
||||||
'role_key' => 'cashier',
|
'role_key' => 'cashier',
|
||||||
'department_ids' => [(int)$department['id']],
|
'department_ids' => [(int)$department['id']],
|
||||||
@@ -366,6 +415,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
|||||||
$employeeId = (int)($created->data()['id'] ?? 0);
|
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||||
expect($employeeId)->toBeGreaterThan(0);
|
expect($employeeId)->toBeGreaterThan(0);
|
||||||
limited_backoffice_cleanup_created_employee($employeeId);
|
limited_backoffice_cleanup_created_employee($employeeId);
|
||||||
|
expect($created->data()['email'] ?? null)->toBe('limited-cashier@example.test');
|
||||||
|
expect($created->data()['phone_country_code'] ?? null)->toBe(45);
|
||||||
|
expect($created->data()['phone'] ?? null)->toBe(12345678);
|
||||||
expect($created->body)->not->toContain('department_access_');
|
expect($created->body)->not->toContain('department_access_');
|
||||||
expect($created->body)->not->toContain('permissions');
|
expect($created->body)->not->toContain('permissions');
|
||||||
|
|
||||||
@@ -384,6 +436,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
|||||||
|
|
||||||
$updated = api_client()->put('/limited-backoffice/employees/' . $employeeId, [
|
$updated = api_client()->put('/limited-backoffice/employees/' . $employeeId, [
|
||||||
'display_name' => 'Limited Lead',
|
'display_name' => 'Limited Lead',
|
||||||
|
'email' => 'limited-lead@example.test',
|
||||||
|
'phone_country_code' => 358,
|
||||||
|
'phone' => 87654321,
|
||||||
'role_key' => 'operations_lead',
|
'role_key' => 'operations_lead',
|
||||||
'department_ids' => [(int)$department['id']],
|
'department_ids' => [(int)$department['id']],
|
||||||
], $session['headers']);
|
], $session['headers']);
|
||||||
@@ -393,11 +448,25 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
|||||||
->assertSuccess();
|
->assertSuccess();
|
||||||
|
|
||||||
expect($updated->data()['display_name'] ?? null)->toBe('Limited Lead');
|
expect($updated->data()['display_name'] ?? null)->toBe('Limited Lead');
|
||||||
|
expect($updated->data()['email'] ?? null)->toBe('limited-lead@example.test');
|
||||||
|
expect($updated->data()['phone_country_code'] ?? null)->toBe(358);
|
||||||
|
expect($updated->data()['phone'] ?? null)->toBe(87654321);
|
||||||
expect($updated->data()['role']['key'] ?? null)->toBe('operations_lead');
|
expect($updated->data()['role']['key'] ?? null)->toBe('operations_lead');
|
||||||
|
|
||||||
$list = api_client()->get('/limited-backoffice/employees', $session['headers']);
|
$list = api_client()->get('/limited-backoffice/employees', $session['headers']);
|
||||||
$ids = array_column($list->data(), 'id');
|
$ids = array_column($list->data(), 'id');
|
||||||
expect($ids)->toContain($employeeId);
|
expect($ids)->toContain($employeeId);
|
||||||
|
$listedEmployee = null;
|
||||||
|
foreach ($list->data() as $employee) {
|
||||||
|
if ((int)($employee['id'] ?? 0) === $employeeId) {
|
||||||
|
$listedEmployee = $employee;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect($listedEmployee)->not->toBeNull();
|
||||||
|
expect($listedEmployee['email'] ?? null)->toBe('limited-lead@example.test');
|
||||||
|
expect($listedEmployee['phone_country_code'] ?? null)->toBe(358);
|
||||||
|
expect($listedEmployee['phone'] ?? null)->toBe(87654321);
|
||||||
expect($list->body)->not->toContain('department_access_');
|
expect($list->body)->not->toContain('department_access_');
|
||||||
|
|
||||||
$deactivated = api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers']);
|
$deactivated = api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers']);
|
||||||
@@ -419,6 +488,34 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts employees without optional phone details', function (): void {
|
||||||
|
api_test_covers('POST /limited-backoffice/employees', 'happy');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee No Phone']);
|
||||||
|
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||||
|
|
||||||
|
$created = api_client()->post('/limited-backoffice/employees', [
|
||||||
|
'display_name' => 'Limited No Phone',
|
||||||
|
'email' => 'limited-no-phone@example.test',
|
||||||
|
'password' => 'Secret123!',
|
||||||
|
'role_key' => 'viewer',
|
||||||
|
'department_ids' => [(int)$department['id']],
|
||||||
|
], $session['headers']);
|
||||||
|
|
||||||
|
$created
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||||
|
expect($employeeId)->toBeGreaterThan(0);
|
||||||
|
limited_backoffice_cleanup_created_employee($employeeId);
|
||||||
|
expect(array_key_exists('phone_country_code', $created->data()))->toBeTrue();
|
||||||
|
expect(array_key_exists('phone', $created->data()))->toBeTrue();
|
||||||
|
expect($created->data()['phone_country_code'])->toBeNull();
|
||||||
|
expect($created->data()['phone'])->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects employee scopes roles raw permissions self edits superusers and shared groups', function (): void {
|
it('rejects employee scopes roles raw permissions self edits superusers and shared groups', function (): void {
|
||||||
api_test_covers('POST /limited-backoffice/employees', 'validation');
|
api_test_covers('POST /limited-backoffice/employees', 'validation');
|
||||||
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation');
|
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation');
|
||||||
@@ -429,6 +526,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
|||||||
|
|
||||||
api_client()->post('/limited-backoffice/employees', [
|
api_client()->post('/limited-backoffice/employees', [
|
||||||
'display_name' => 'Outside Employee',
|
'display_name' => 'Outside Employee',
|
||||||
|
'email' => 'outside@example.test',
|
||||||
'password' => 'Secret123!',
|
'password' => 'Secret123!',
|
||||||
'role_key' => 'cashier',
|
'role_key' => 'cashier',
|
||||||
'department_ids' => [(int)$otherDepartment['id']],
|
'department_ids' => [(int)$otherDepartment['id']],
|
||||||
@@ -440,6 +538,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
|||||||
|
|
||||||
api_client()->post('/limited-backoffice/employees', [
|
api_client()->post('/limited-backoffice/employees', [
|
||||||
'display_name' => 'Raw Employee',
|
'display_name' => 'Raw Employee',
|
||||||
|
'email' => 'raw@example.test',
|
||||||
'password' => 'Secret123!',
|
'password' => 'Secret123!',
|
||||||
'role_key' => 'cashier',
|
'role_key' => 'cashier',
|
||||||
'department_ids' => [(int)$department['id']],
|
'department_ids' => [(int)$department['id']],
|
||||||
@@ -452,6 +551,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
|||||||
|
|
||||||
api_client()->post('/limited-backoffice/employees', [
|
api_client()->post('/limited-backoffice/employees', [
|
||||||
'display_name' => 'Unknown Role Employee',
|
'display_name' => 'Unknown Role Employee',
|
||||||
|
'email' => 'unknown-role@example.test',
|
||||||
'password' => 'Secret123!',
|
'password' => 'Secret123!',
|
||||||
'role_key' => 'superuser',
|
'role_key' => 'superuser',
|
||||||
'department_ids' => [(int)$department['id']],
|
'department_ids' => [(int)$department['id']],
|
||||||
@@ -504,3 +604,88 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
|||||||
->assertSuccess(false)
|
->assertSuccess(false)
|
||||||
->assertMessage('Cannot manage shared groups.');
|
->assertMessage('Cannot manage shared groups.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects invalid limited backoffice employee contact details', function (): void {
|
||||||
|
api_test_covers('POST /limited-backoffice/employees', 'validation');
|
||||||
|
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation');
|
||||||
|
|
||||||
|
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee Contact Validation']);
|
||||||
|
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||||
|
$basePayload = [
|
||||||
|
'display_name' => 'Contact Employee',
|
||||||
|
'email' => 'contact@example.test',
|
||||||
|
'password' => 'Secret123!',
|
||||||
|
'role_key' => 'viewer',
|
||||||
|
'department_ids' => [(int)$department['id']],
|
||||||
|
];
|
||||||
|
|
||||||
|
api_client()->post('/limited-backoffice/employees', array_diff_key($basePayload, ['email' => true]), $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Email is required.');
|
||||||
|
|
||||||
|
api_client()->post('/limited-backoffice/employees', [
|
||||||
|
...$basePayload,
|
||||||
|
'email' => 'not-an-email',
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Email must be a valid email address.');
|
||||||
|
|
||||||
|
api_client()->post('/limited-backoffice/employees', [
|
||||||
|
...$basePayload,
|
||||||
|
'phone_country_code' => 45,
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Phone country code and phone number must be provided together.');
|
||||||
|
|
||||||
|
api_client()->post('/limited-backoffice/employees', [
|
||||||
|
...$basePayload,
|
||||||
|
'phone_country_code' => 1,
|
||||||
|
'phone' => 12345678,
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Phone country code is not supported.');
|
||||||
|
|
||||||
|
api_client()->post('/limited-backoffice/employees', [
|
||||||
|
...$basePayload,
|
||||||
|
'phone_country_code' => 45,
|
||||||
|
'phone' => '12ab',
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Phone values must contain digits only.');
|
||||||
|
|
||||||
|
$created = api_client()->post('/limited-backoffice/employees', $basePayload, $session['headers'])
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess();
|
||||||
|
|
||||||
|
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||||
|
expect($employeeId)->toBeGreaterThan(0);
|
||||||
|
limited_backoffice_cleanup_created_employee($employeeId);
|
||||||
|
|
||||||
|
api_client()->put('/limited-backoffice/employees/' . $employeeId, [
|
||||||
|
'email' => '',
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Email is required.');
|
||||||
|
|
||||||
|
api_client()->put('/limited-backoffice/employees/' . $employeeId, [
|
||||||
|
'phone_country_code' => 45,
|
||||||
|
'phone' => '123',
|
||||||
|
], $session['headers'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertEnvelope()
|
||||||
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Phone number must be 4-15 digits.');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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',
|
'GET /branding',
|
||||||
'POST /branding',
|
'POST /branding',
|
||||||
'PUT /branding',
|
'PUT /branding',
|
||||||
|
'GET /superuser/departments/{id}/overview',
|
||||||
'PUT /superuser/department/branding',
|
'PUT /superuser/department/branding',
|
||||||
'POST /bird/voice/calls/webhook/inbound',
|
'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_department_id` (`department_id`),
|
||||||
KEY `idx_department_variables_variable` (`variable`)
|
KEY `idx_department_variables_variable` (`variable`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) 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,
|
SQL,
|
||||||
'department_gates' => <<<'SQL'
|
'department_gates' => <<<'SQL'
|
||||||
CREATE TABLE IF NOT EXISTS `department_gates` (
|
CREATE TABLE IF NOT EXISTS `department_gates` (
|
||||||
|
|||||||
+3
@@ -31,8 +31,11 @@ it('documents the daily report overview endpoint and reusable schemas in openapi
|
|||||||
$content = department_daily_reports_openapi_content_or_skip();
|
$content = department_daily_reports_openapi_content_or_skip();
|
||||||
|
|
||||||
expect($content)->toContain('/departments/daily-reports/overview:');
|
expect($content)->toContain('/departments/daily-reports/overview:');
|
||||||
|
expect($content)->toContain('/superuser/departments/{id}/overview:');
|
||||||
expect($content)->toContain('operationId: getDailyReportOverview');
|
expect($content)->toContain('operationId: getDailyReportOverview');
|
||||||
|
expect($content)->toContain('operationId: getSuperuserDepartmentOverview');
|
||||||
expect($content)->toContain('DepartmentDailyReportOverviewResponse:');
|
expect($content)->toContain('DepartmentDailyReportOverviewResponse:');
|
||||||
|
expect($content)->toContain('SuperuserDepartmentOverviewResponse:');
|
||||||
expect($content)->toContain('DepartmentDailyReportMetric:');
|
expect($content)->toContain('DepartmentDailyReportMetric:');
|
||||||
expect($content)->toContain('DepartmentDailyReportProductTile:');
|
expect($content)->toContain('DepartmentDailyReportProductTile:');
|
||||||
expect($content)->toContain('- name: department_ids');
|
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'));
|
$objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php'));
|
||||||
|
|
||||||
expect($routeContent)->toContain('/departments/daily-reports/overview');
|
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('/departments/daily-reports/complaints');
|
||||||
expect($routeContent)->toContain('outsideHoursStatisticsService');
|
expect($routeContent)->toContain('outsideHoursStatisticsService');
|
||||||
expect($routeContent)->toContain('dailyReportComplaintsRepository');
|
expect($routeContent)->toContain('dailyReportComplaintsRepository');
|
||||||
|
|||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('routes collected invoice draft line uploads through the multi-order batch endpoint', function (): void {
|
||||||
|
$content = file_get_contents(dirname(__DIR__, 3) . '/objects/collected_order_invoices_o.php');
|
||||||
|
|
||||||
|
expect($content)->not->toBeFalse();
|
||||||
|
$content = (string)$content;
|
||||||
|
|
||||||
|
$start = strpos($content, 'public function addInvoicesToDraft');
|
||||||
|
$end = strpos($content, 'public function getLastEconomicTransferMetrics');
|
||||||
|
expect($start)->not->toBeFalse();
|
||||||
|
expect($end)->not->toBeFalse();
|
||||||
|
expect($end)->toBeGreaterThan($start);
|
||||||
|
|
||||||
|
$methodBlock = substr($content, (int)$start, (int)$end - (int)$start);
|
||||||
|
expect($methodBlock)->toContain('$order_objects = [];')
|
||||||
|
->and($methodBlock)->toContain('$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);')
|
||||||
|
->and($methodBlock)->toContain('...$metrics')
|
||||||
|
->and($methodBlock)->not->toContain('self::addInvoiceToDraft($order[\'id\'], true, $draft_id, $currency);');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps single-order draft uploads as a wrapper around the batch endpoint', function (): void {
|
||||||
|
$content = file_get_contents(dirname(__DIR__, 3) . '/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php');
|
||||||
|
|
||||||
|
expect($content)->not->toBeFalse();
|
||||||
|
$content = (string)$content;
|
||||||
|
|
||||||
|
$singleStart = strpos($content, 'public function add_order');
|
||||||
|
$singleEnd = strpos($content, 'public function add_orders');
|
||||||
|
expect($singleStart)->not->toBeFalse();
|
||||||
|
expect($singleEnd)->not->toBeFalse();
|
||||||
|
expect($singleEnd)->toBeGreaterThan($singleStart);
|
||||||
|
|
||||||
|
$singleBlock = substr($content, (int)$singleStart, (int)$singleEnd - (int)$singleStart);
|
||||||
|
expect($singleBlock)->toContain('$this->add_orders($invoiceDraftId, [$order], $currency);');
|
||||||
|
|
||||||
|
$batchBlock = substr($content, (int)$singleEnd);
|
||||||
|
expect($batchBlock)->toContain('$draftInvoice->flushLinesInBatches($line_batch_size);')
|
||||||
|
->and($batchBlock)->toContain("'orders_with_invoice_lines' => \$orders_with_invoice_lines");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes collected invoice batch transfer metrics in queue results when available', function (): void {
|
||||||
|
$content = file_get_contents(dirname(__DIR__, 3) . '/classes/economic_transfer_executor.php');
|
||||||
|
|
||||||
|
expect($content)->not->toBeFalse();
|
||||||
|
$content = (string)$content;
|
||||||
|
|
||||||
|
expect($content)->toContain('$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();')
|
||||||
|
->and($content)->toContain("\$result['economic_transfer_metrics'] = \$transfer_metrics;");
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use helpers\economic_invoice_draft;
|
||||||
|
|
||||||
|
if (!class_exists('EconomicInvoiceDraftBatchingProbe')) {
|
||||||
|
class EconomicInvoiceDraftBatchingProbe extends economic_invoice_draft
|
||||||
|
{
|
||||||
|
public array $sentBatches = [];
|
||||||
|
|
||||||
|
protected function sendDraftLines(array $draft_lines): object
|
||||||
|
{
|
||||||
|
$this->sentBatches[] = $draft_lines;
|
||||||
|
return (object)['lines' => $draft_lines];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists('EconomicInvoiceDraftFailingBatchingProbe')) {
|
||||||
|
class EconomicInvoiceDraftFailingBatchingProbe extends EconomicInvoiceDraftBatchingProbe
|
||||||
|
{
|
||||||
|
public int $failOnBatch = 1;
|
||||||
|
|
||||||
|
protected function sendDraftLines(array $draft_lines): object
|
||||||
|
{
|
||||||
|
if (count($this->sentBatches) + 1 === $this->failOnBatch) {
|
||||||
|
throw new RuntimeException('Simulated e-conomic line batch failure');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::sendDraftLines($draft_lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not call e-conomic when flushing an empty draft line buffer', function (): void {
|
||||||
|
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
|
||||||
|
|
||||||
|
$metrics = $draft->flushLinesInBatches();
|
||||||
|
|
||||||
|
expect($metrics)->toBe([
|
||||||
|
'line_count' => 0,
|
||||||
|
'batch_count' => 0,
|
||||||
|
'batch_sizes' => [],
|
||||||
|
])->and($draft->sentBatches)->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes a small draft line buffer in one request and clears pending lines', function (): void {
|
||||||
|
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
|
||||||
|
$draft->addTextLine('line-0');
|
||||||
|
$draft->addTextLine('line-1');
|
||||||
|
$draft->addTextLine('line-2');
|
||||||
|
|
||||||
|
$metrics = $draft->flushLinesInBatches(500);
|
||||||
|
|
||||||
|
expect($metrics)->toBe([
|
||||||
|
'line_count' => 3,
|
||||||
|
'batch_count' => 1,
|
||||||
|
'batch_sizes' => [3],
|
||||||
|
])->and($draft->sentBatches)->toHaveCount(1)
|
||||||
|
->and($draft->sentBatches[0][0]['description'])->toBe('line-0')
|
||||||
|
->and($draft->sentBatches[0][2]['description'])->toBe('line-2')
|
||||||
|
->and($draft->pendingLineCount())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chunks large draft line buffers while preserving line order', function (): void {
|
||||||
|
$draft = new EconomicInvoiceDraftBatchingProbe(123, 'DKK', true);
|
||||||
|
for ($i = 0; $i < 1201; $i++) {
|
||||||
|
$draft->addTextLine('line-' . $i);
|
||||||
|
}
|
||||||
|
|
||||||
|
$metrics = $draft->flushLinesInBatches(500);
|
||||||
|
|
||||||
|
expect($metrics)->toBe([
|
||||||
|
'line_count' => 1201,
|
||||||
|
'batch_count' => 3,
|
||||||
|
'batch_sizes' => [500, 500, 201],
|
||||||
|
])->and($draft->sentBatches)->toHaveCount(3)
|
||||||
|
->and($draft->sentBatches[0][0]['description'])->toBe('line-0')
|
||||||
|
->and($draft->sentBatches[1][0]['description'])->toBe('line-500')
|
||||||
|
->and($draft->sentBatches[2][200]['description'])->toBe('line-1200')
|
||||||
|
->and($draft->pendingLineCount())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bubbles line batch failures and keeps pending lines available', function (): void {
|
||||||
|
$draft = new EconomicInvoiceDraftFailingBatchingProbe(123, 'DKK', true);
|
||||||
|
$draft->addTextLine('line-0');
|
||||||
|
|
||||||
|
expect(fn () => $draft->flushLinesInBatches(500))
|
||||||
|
->toThrow(RuntimeException::class, 'Simulated e-conomic line batch failure');
|
||||||
|
|
||||||
|
expect($draft->sentBatches)->toBe([])
|
||||||
|
->and($draft->pendingLineCount())->toBe(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user