Merge remote-tracking branch 'origin/master' into codex/custom-pricing-only-departments
# Conflicts: # services/nginx/app/tests/Api/OrderItemsApiTest.php
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use objects\orders_o;
|
||||
use objects\products_o;
|
||||
use objects\users_o;
|
||||
|
||||
class customer_product_rule_service
|
||||
{
|
||||
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
|
||||
|
||||
private const ADDON_CATEGORY_ID = 4;
|
||||
private const TANK_CLEANING_CATEGORY_ID = 5;
|
||||
|
||||
/**
|
||||
* @return array{rule:string,message:string}|null
|
||||
*/
|
||||
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
|
||||
{
|
||||
$order = (new orders_o())->getOrderById($orderId);
|
||||
if (!$order->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$product = (new products_o())->getProductById($productId);
|
||||
if (!$product->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value());
|
||||
if (!$customer->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$categoryId = (int)$product->category->value();
|
||||
$categoryName = $this->categoryName($categoryId);
|
||||
$searchableProduct = $this->searchableProductText($product, $categoryName);
|
||||
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
|
||||
&& $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) {
|
||||
return $this->violation('restrictAdditionalServices');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) {
|
||||
return $this->violation('restrictTankCleaning');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) {
|
||||
return $this->violation('onlyTankCleaning');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictSpotFree')
|
||||
&& $this->containsAny($searchableProduct, ['spot free', 'spotfree'])) {
|
||||
return $this->violation('restrictSpotFree');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictInteriorCleaning')
|
||||
&& $this->containsAny($searchableProduct, ['interior', 'indvendig'])) {
|
||||
return $this->violation('restrictInteriorCleaning');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{rule:string,message:string}
|
||||
*/
|
||||
private function violation(string $rule): array
|
||||
{
|
||||
return [
|
||||
'rule' => $rule,
|
||||
'message' => self::BLOCK_MESSAGE,
|
||||
];
|
||||
}
|
||||
|
||||
private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool
|
||||
{
|
||||
if ($relatedItemId !== null && $relatedItemId > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($categoryId === self::ADDON_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->countStandaloneOrderItems($orderId) > 0;
|
||||
}
|
||||
|
||||
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
|
||||
{
|
||||
if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
|
||||
}
|
||||
|
||||
private function searchableProductText(products_o $product, string $categoryName): string
|
||||
{
|
||||
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $terms
|
||||
*/
|
||||
private function containsAny(string $value, array $terms): bool
|
||||
{
|
||||
foreach ($terms as $term) {
|
||||
if ($term !== '' && str_contains($value, $term)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function categoryName(int $categoryId): string
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($categoryId <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1');
|
||||
if (!$result || $result->num_rows === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return strtolower((string)($row['name'] ?? ''));
|
||||
}
|
||||
|
||||
private function countStandaloneOrderItems(int $orderId): int
|
||||
{
|
||||
global $db;
|
||||
|
||||
$result = $db->query(
|
||||
'SELECT COUNT(*) AS item_count
|
||||
FROM order_items
|
||||
WHERE order_id = ' . $orderId . '
|
||||
AND deleted_at IS NULL
|
||||
AND (related_item_id IS NULL OR related_item_id = 0)'
|
||||
);
|
||||
if (!$result) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)($row['item_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,13 @@ class economic_transfer_executor
|
||||
'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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -92,12 +92,17 @@ class error_report_service
|
||||
throw new RuntimeException('Data collection acceptance is required.');
|
||||
}
|
||||
|
||||
$screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? ''));
|
||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
|
||||
$storedScreenshot = $this->storeOptionalScreenshot($payload['screenshot'] ?? null, $context);
|
||||
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
|
||||
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
|
||||
$runtimeContext = $this->runtimeContext($payload, $context);
|
||||
$runtimeContext['screenshot_attachment'] = [
|
||||
'status' => $storedScreenshot['status'],
|
||||
'attached' => $storedScreenshot['key'] !== '',
|
||||
'mime_type' => $storedScreenshot['mime_type'] !== '' ? $storedScreenshot['mime_type'] : null,
|
||||
'size_bytes' => (int)$storedScreenshot['size_bytes'],
|
||||
];
|
||||
|
||||
$this->execute(
|
||||
"INSERT INTO error_reports (
|
||||
@@ -295,6 +300,67 @@ class error_report_service
|
||||
return $value === true || $value === 1 || $value === '1' || $value === 'true';
|
||||
}
|
||||
|
||||
private function storeOptionalScreenshot(mixed $value, array $context): array
|
||||
{
|
||||
if (!is_scalar($value) && !$value instanceof \Stringable && $value !== null) {
|
||||
return $this->emptyScreenshotAttachment('invalid');
|
||||
}
|
||||
|
||||
$dataUri = trim((string)($value ?? ''));
|
||||
if ($dataUri === '') {
|
||||
return $this->emptyScreenshotAttachment($this->contextScreenshotStatus($context) ?? 'not_provided');
|
||||
}
|
||||
|
||||
try {
|
||||
$screenshot = self::decodeScreenshotDataUri($dataUri);
|
||||
} catch (RuntimeException $exception) {
|
||||
$message = strtolower($exception->getMessage());
|
||||
return $this->emptyScreenshotAttachment(str_contains($message, 'too large') ? 'too_large' : 'invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||
} catch (Throwable) {
|
||||
return $this->emptyScreenshotAttachment('storage_failed');
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => (string)($storedScreenshot['key'] ?? ''),
|
||||
'mime_type' => (string)($storedScreenshot['mime_type'] ?? $screenshot['mime_type']),
|
||||
'size_bytes' => (int)($storedScreenshot['size_bytes'] ?? $screenshot['size_bytes']),
|
||||
'status' => 'stored',
|
||||
];
|
||||
}
|
||||
|
||||
private function emptyScreenshotAttachment(string $status): array
|
||||
{
|
||||
return [
|
||||
'key' => '',
|
||||
'mime_type' => '',
|
||||
'size_bytes' => 0,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
private function contextScreenshotStatus(array $context): ?string
|
||||
{
|
||||
$attachment = $context['screenshot_attachment'] ?? null;
|
||||
$status = is_array($attachment) ? ($attachment['status'] ?? null) : null;
|
||||
$status ??= $context['screenshot_capture_status'] ?? $context['screenshot_status'] ?? null;
|
||||
|
||||
return $this->normalizeEmptyScreenshotStatus($status);
|
||||
}
|
||||
|
||||
private function normalizeEmptyScreenshotStatus(mixed $status): ?string
|
||||
{
|
||||
$status = strtolower(trim((string)$status));
|
||||
if (in_array($status, ['capture_failed', 'not_provided'], true)) {
|
||||
return $status;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function runtimeContext(array $payload, array $context): array
|
||||
{
|
||||
return [
|
||||
@@ -432,6 +498,10 @@ class error_report_service
|
||||
|
||||
private function publicReport(array $row, bool $includeDetail): array
|
||||
{
|
||||
$screenshotMimeType = trim((string)($row['screenshot_mime_type'] ?? ''));
|
||||
$screenshotSizeBytes = isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0;
|
||||
$hasScreenshot = $screenshotMimeType !== '' && $screenshotSizeBytes > 0;
|
||||
|
||||
$report = [
|
||||
'id' => (int)$row['id'],
|
||||
'status' => (string)$row['status'],
|
||||
@@ -449,10 +519,10 @@ class error_report_service
|
||||
'release_trace_id' => $row['release_trace_id'] ?? null,
|
||||
'frontend_version' => $row['frontend_version'] ?? null,
|
||||
'api_version' => $row['api_version'] ?? null,
|
||||
'screenshot' => [
|
||||
'mime_type' => $row['screenshot_mime_type'] ?? null,
|
||||
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
|
||||
],
|
||||
'screenshot' => $hasScreenshot ? [
|
||||
'mime_type' => $screenshotMimeType,
|
||||
'size_bytes' => $screenshotSizeBytes,
|
||||
] : null,
|
||||
'answers' => [
|
||||
'before_error' => $row['before_error'] ?? '',
|
||||
'expected' => $row['expected'] ?? '',
|
||||
@@ -467,8 +537,11 @@ class error_report_service
|
||||
];
|
||||
|
||||
if ($includeDetail) {
|
||||
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
|
||||
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
|
||||
if ($hasScreenshot) {
|
||||
$objectKey = trim((string)($row['screenshot_object_key'] ?? ''));
|
||||
$report['screenshot']['url'] = $this->store->screenshotUrl($objectKey);
|
||||
$report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : null;
|
||||
}
|
||||
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
|
||||
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
|
||||
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
|
||||
|
||||
@@ -113,6 +113,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>
|
||||
*/
|
||||
@@ -125,7 +253,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
|
||||
{
|
||||
@@ -135,11 +263,45 @@ class limited_backoffice_service
|
||||
'key' => $key,
|
||||
'label' => $preset['label'],
|
||||
'description' => $preset['description'],
|
||||
'permission_groups' => $this->rolePermissionGroups($preset['permissions']),
|
||||
];
|
||||
}
|
||||
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>
|
||||
*/
|
||||
@@ -296,10 +458,15 @@ class limited_backoffice_service
|
||||
$mysqli->begin_transaction();
|
||||
|
||||
try {
|
||||
$priceUpdateAssignments = ['`price` = VALUES(`price`)'];
|
||||
if ($this->tableHasColumn('product_department_prices', 'updated_at')) {
|
||||
$priceUpdateAssignments[] = '`updated_at` = CURRENT_TIMESTAMP';
|
||||
}
|
||||
|
||||
$statement = $mysqli->prepare(
|
||||
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`), `updated_at` = CURRENT_TIMESTAMP'
|
||||
ON DUPLICATE KEY UPDATE ' . implode(', ', $priceUpdateAssignments)
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare department price update.');
|
||||
@@ -382,7 +549,8 @@ class limited_backoffice_service
|
||||
$roleKey = $this->normalizeRoleKey($payload['role_key'] ?? null);
|
||||
$displayName = $this->normalizeRequiredString($payload['display_name'] ?? null, 'Display name is required.');
|
||||
$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->begin_transaction();
|
||||
@@ -393,13 +561,23 @@ class limited_backoffice_service
|
||||
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
$statement = $mysqli->prepare(
|
||||
'INSERT INTO `users` (`customer_number`, `display_name`, `email`, `password`, `group_id`)
|
||||
VALUES (?, ?, ?, ?, ?)'
|
||||
'INSERT INTO `users`
|
||||
(`customer_number`, `display_name`, `email`, `password`, `group_id`, `phone_country_code`, `phone`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
if ($statement === false) {
|
||||
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();
|
||||
$employeeId = (int)$mysqli->insert_id;
|
||||
$statement->close();
|
||||
@@ -477,11 +655,12 @@ class limited_backoffice_service
|
||||
? $this->normalizeRequiredString($payload['display_name'], 'Display name is required.')
|
||||
: null;
|
||||
$email = array_key_exists('email', $payload)
|
||||
? $this->normalizeOptionalString($payload['email'])
|
||||
? $this->normalizeEmail($payload['email'], true)
|
||||
: null;
|
||||
$password = array_key_exists('password', $payload)
|
||||
? $this->normalizePassword($payload['password'], false)
|
||||
: null;
|
||||
$phone = $this->normalizeOptionalPhonePair($payload, false);
|
||||
$active = array_key_exists('active', $payload)
|
||||
? (bool)$payload['active']
|
||||
: $this->isEmployeeRowActive($employee);
|
||||
@@ -511,6 +690,10 @@ class limited_backoffice_service
|
||||
if ($password !== null) {
|
||||
$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');
|
||||
if ($active) {
|
||||
$userUpdates['group_id'] = $managedGroupId;
|
||||
@@ -912,6 +1095,89 @@ class limited_backoffice_service
|
||||
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
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
@@ -1087,6 +1353,8 @@ class limited_backoffice_service
|
||||
'customer_number' => (int)$row['customer_number'],
|
||||
'display_name' => (string)($row['display_name'] ?? ''),
|
||||
'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,
|
||||
'role' => $this->rolePayload((string)$row['role_key']),
|
||||
'departments' => $this->departmentSummaries($departmentIds),
|
||||
@@ -1207,7 +1475,7 @@ class limited_backoffice_service
|
||||
$types = '';
|
||||
$values = [];
|
||||
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;
|
||||
}
|
||||
if ($value === null) {
|
||||
|
||||
+33
-5
@@ -61,19 +61,47 @@ class economic_invoices_draft_endpoint
|
||||
* @throws Exception If the request fails
|
||||
*/
|
||||
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);
|
||||
// Check if the order includes any items that should be included in the invoice
|
||||
if ($order->getIncludeInInvoiceCount() > 0) {
|
||||
$orders_with_invoice_lines = 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.)
|
||||
$draftInvoice->addNewTransactionHeader($order);
|
||||
// Add the order lines
|
||||
$draftInvoice->addOrderItemLines($order);
|
||||
// Add an empty line, so the invoice is not empty
|
||||
$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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,4 +179,4 @@ class economic_invoices_draft_endpoint
|
||||
$draft_invoice->addLines();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ use objects\orders_o;
|
||||
|
||||
class economic_invoice_draft
|
||||
{
|
||||
public const DEFAULT_LINE_BATCH_SIZE = 500;
|
||||
|
||||
/**
|
||||
* The Economic draftInvoiceNumber
|
||||
* @var int $draft_invoice_number
|
||||
@@ -110,13 +112,55 @@ class economic_invoice_draft
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the lines to the draft invoice
|
||||
* @return void
|
||||
* Add the lines to the draft invoice.
|
||||
*/
|
||||
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->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 $closed_at;
|
||||
public int $economic_wash_subscription_user_id = 1857;
|
||||
private ?array $last_economic_transfer_metrics = null;
|
||||
/**
|
||||
* The processor types
|
||||
*
|
||||
@@ -712,6 +713,7 @@ class collected_order_invoices_o extends db
|
||||
*/
|
||||
public function addInvoicesToDraft(bool $skip_check = false): self
|
||||
{
|
||||
$this->last_economic_transfer_metrics = null;
|
||||
// Require the invoice collection to be selected
|
||||
self::requireSelected();
|
||||
// Require the invoice collection to be open
|
||||
@@ -736,10 +738,20 @@ class collected_order_invoices_o extends db
|
||||
usort($orders, function ($a, $b) {
|
||||
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 ) {
|
||||
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
|
||||
self::addEnvironmentalAndOilFeesToDraft($draft_id, $currency);
|
||||
// Object changed
|
||||
@@ -748,6 +760,11 @@ class collected_order_invoices_o extends db
|
||||
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
|
||||
* @param int $draft_id The invoice draft id
|
||||
|
||||
@@ -13388,7 +13388,6 @@ components:
|
||||
- expected
|
||||
- actual
|
||||
- data_collection_accepted
|
||||
- screenshot
|
||||
properties:
|
||||
before_error:
|
||||
type: string
|
||||
@@ -13404,10 +13403,11 @@ components:
|
||||
description: What actually happened
|
||||
data_collection_accepted:
|
||||
type: boolean
|
||||
description: Required acceptance of collecting screenshot and diagnostic error data
|
||||
description: Required acceptance of collecting diagnostic error data and a screenshot when one can be attached
|
||||
screenshot:
|
||||
type: string
|
||||
description: PNG, JPEG, or WebP data URI of the current app viewport
|
||||
nullable: true
|
||||
description: Optional PNG, JPEG, or WebP data URI of the current app viewport. Reports are accepted without an attachment when capture or upload fails.
|
||||
route_path:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -13518,6 +13518,7 @@ components:
|
||||
nullable: true
|
||||
screenshot:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
answers:
|
||||
type: object
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\customer_product_rule_service;
|
||||
use objects\logs_o;
|
||||
use objects\order_items_o;
|
||||
use objects\orders_o;
|
||||
@@ -70,6 +71,10 @@ class orderItemsRoute
|
||||
$price = (int)self::getParameter('price');
|
||||
}
|
||||
}
|
||||
$order = (new orders_o())->getOrderById((int)$data['order_id']);
|
||||
if (!$order->exists()) {
|
||||
$response->error('Order not found', 404);
|
||||
}
|
||||
$product = (new products_o())->getProductById((int)$data['product_id']);
|
||||
if (!$product->exists()) {
|
||||
$response->error('Product not found', 404);
|
||||
@@ -77,6 +82,19 @@ class orderItemsRoute
|
||||
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
|
||||
$response->error('Notes is required for this product', 400);
|
||||
}
|
||||
$customerRuleViolation = (new customer_product_rule_service())
|
||||
->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id);
|
||||
if ($customerRuleViolation !== null) {
|
||||
(new logs_o())->add(
|
||||
'order_items',
|
||||
'global',
|
||||
1,
|
||||
$user->id,
|
||||
'ORDER_ITEM_RESTRICTED_BY_CUSTOMER_RULE',
|
||||
'Blocked product ' . (int)$data['product_id'] . ' on order ' . (int)$data['order_id'] . ' by rule ' . $customerRuleViolation['rule']
|
||||
);
|
||||
$response->error($customerRuleViolation['message'], 400);
|
||||
}
|
||||
|
||||
// Add the order item to the order This is done individually, to make the notes to the individual order items possible
|
||||
$order_items = (new order_items_o());
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function error_report_api_payload(array $overrides = []): array
|
||||
{
|
||||
return array_replace_recursive([
|
||||
'before_error' => 'Opening the orders page',
|
||||
'expected' => 'The orders should load',
|
||||
'actual' => 'The page showed an error',
|
||||
'data_collection_accepted' => true,
|
||||
'data_collection_policy_version' => 'error-report-v1',
|
||||
'route_path' => '/admin/orders',
|
||||
'page_url' => 'https://app.example.test/admin/orders',
|
||||
'release_trace_id' => 'trace-error-report-test',
|
||||
'frontend_version' => 'frontend-test',
|
||||
'api_version' => 'api-test',
|
||||
'request_errors' => [
|
||||
['method' => 'GET', 'url' => '/orders', 'statusCode' => 500],
|
||||
],
|
||||
'vue_errors' => [
|
||||
['type' => 'vue_component_error', 'payload' => ['message' => 'Render failed']],
|
||||
],
|
||||
'context' => [
|
||||
'viewport' => ['width' => 1280, 'height' => 720],
|
||||
'user_agent' => 'ErrorReportsApiTest',
|
||||
'captured_at' => '2026-07-06T10:00:00.000Z',
|
||||
'data_collection_policy_version' => 'error-report-v1',
|
||||
],
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
function error_report_api_cleanup(array $report): void
|
||||
{
|
||||
$id = (int)($report['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
api_fixtures()->cleanupDeleteById('error_reports', $id);
|
||||
}
|
||||
}
|
||||
|
||||
it('creates error reports when screenshot capture failed', function (): void {
|
||||
api_test_covers('POST /error-reports', 'happy');
|
||||
|
||||
$session = api_fixtures()->createUserSession();
|
||||
$response = api_client()->post('/error-reports', error_report_api_payload([
|
||||
'screenshot' => null,
|
||||
'context' => [
|
||||
'screenshot_attachment' => ['status' => 'capture_failed'],
|
||||
],
|
||||
]), $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$report = $response->data();
|
||||
expect($report['screenshot'])->toBeNull();
|
||||
expect($report['answers']['before_error'])->toBe('Opening the orders page');
|
||||
expect($report['request_error_count'])->toBe(1);
|
||||
expect($report['vue_error_count'])->toBe(1);
|
||||
expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([
|
||||
'status' => 'capture_failed',
|
||||
'attached' => false,
|
||||
'mime_type' => null,
|
||||
'size_bytes' => 0,
|
||||
]);
|
||||
|
||||
error_report_api_cleanup($report);
|
||||
});
|
||||
|
||||
it('creates error reports when an optional screenshot payload is invalid', function (): void {
|
||||
api_test_covers('POST /error-reports', 'invalid optional screenshot');
|
||||
|
||||
$session = api_fixtures()->createUserSession();
|
||||
$response = api_client()->post('/error-reports', error_report_api_payload([
|
||||
'screenshot' => 'data:text/plain;base64,' . base64_encode('not an image'),
|
||||
]), $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$report = $response->data();
|
||||
expect($report['screenshot'])->toBeNull();
|
||||
expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([
|
||||
'status' => 'invalid',
|
||||
'attached' => false,
|
||||
'mime_type' => null,
|
||||
'size_bytes' => 0,
|
||||
]);
|
||||
|
||||
error_report_api_cleanup($report);
|
||||
});
|
||||
@@ -85,6 +85,32 @@ function limited_backoffice_without_users_deleted_at(callable $callback): void
|
||||
}
|
||||
}
|
||||
|
||||
function limited_backoffice_without_department_prices_updated_at(callable $callback): void
|
||||
{
|
||||
$db = api_test_runtime()->db();
|
||||
$column = $db->query("SHOW COLUMNS FROM `product_department_prices` LIKE 'updated_at'");
|
||||
if ($column === false) {
|
||||
throw new RuntimeException('Unable to inspect product_department_prices.updated_at test column.');
|
||||
}
|
||||
|
||||
$hadColumn = (int)$column->num_rows > 0;
|
||||
if ($hadColumn) {
|
||||
$db->query('ALTER TABLE `product_department_prices` DROP COLUMN `updated_at`');
|
||||
}
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} finally {
|
||||
if ($hadColumn) {
|
||||
$db->query(
|
||||
'ALTER TABLE `product_department_prices`
|
||||
ADD COLUMN `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
AFTER `created_at`'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('lists and updates explicit prices only for assigned departments', function (): void {
|
||||
api_test_covers('GET /limited-backoffice/departments', 'happy');
|
||||
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'happy');
|
||||
@@ -148,6 +174,43 @@ it('lists and updates explicit prices only for assigned departments', function (
|
||||
expect(limited_backoffice_price_value((int)$otherDepartment['id'], (int)$product['id']))->toBe(4321);
|
||||
});
|
||||
|
||||
it('updates department prices when the price table has no updated_at column', function (): void {
|
||||
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'schema compatibility');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Prices Legacy Schema']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Limited Prices Legacy Category']);
|
||||
$products = [
|
||||
api_fixtures()->createProduct(['name' => 'Legacy Price One', 'category' => $category['id']]),
|
||||
api_fixtures()->createProduct(['name' => 'Legacy Price Two', 'category' => $category['id']]),
|
||||
api_fixtures()->createProduct(['name' => 'Legacy Price Three', 'category' => $category['id']]),
|
||||
];
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
|
||||
foreach ($products as $index => $product) {
|
||||
limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 100 + $index);
|
||||
}
|
||||
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
limited_backoffice_without_department_prices_updated_at(function () use ($department, $products, $session): void {
|
||||
$updated = api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||
'prices' => [
|
||||
['product_id' => (int)$products[0]['id'], 'price' => '999999'],
|
||||
['product_id' => (int)$products[1]['id'], 'price' => '999999'],
|
||||
['product_id' => (int)$products[2]['id'], 'price' => '99999'],
|
||||
],
|
||||
], $session['headers']);
|
||||
|
||||
$updated
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect(limited_backoffice_price_value((int)$department['id'], (int)$products[0]['id']))->toBe(999999);
|
||||
expect(limited_backoffice_price_value((int)$department['id'], (int)$products[1]['id']))->toBe(999999);
|
||||
expect(limited_backoffice_price_value((int)$department['id'], (int)$products[2]['id']))->toBe(99999);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects cross-department price access, body spoofing, and outside products', function (): void {
|
||||
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'auth');
|
||||
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'auth');
|
||||
@@ -364,11 +427,60 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
->assertSuccess();
|
||||
|
||||
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_');
|
||||
$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', [
|
||||
'display_name' => 'Limited Cashier',
|
||||
'email' => 'limited-cashier@example.test',
|
||||
'phone_country_code' => 45,
|
||||
'phone' => 12345678,
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'cashier',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
@@ -382,6 +494,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||
expect($employeeId)->toBeGreaterThan(0);
|
||||
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('permissions');
|
||||
|
||||
@@ -400,6 +515,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
|
||||
$updated = api_client()->put('/limited-backoffice/employees/' . $employeeId, [
|
||||
'display_name' => 'Limited Lead',
|
||||
'email' => 'limited-lead@example.test',
|
||||
'phone_country_code' => 358,
|
||||
'phone' => 87654321,
|
||||
'role_key' => 'operations_lead',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
], $session['headers']);
|
||||
@@ -409,11 +527,25 @@ it('creates updates lists and deactivates scoped employees without exposing raw
|
||||
->assertSuccess();
|
||||
|
||||
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');
|
||||
|
||||
$list = api_client()->get('/limited-backoffice/employees', $session['headers']);
|
||||
$ids = array_column($list->data(), 'id');
|
||||
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_');
|
||||
|
||||
$deactivated = api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers']);
|
||||
@@ -435,6 +567,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 {
|
||||
api_test_covers('POST /limited-backoffice/employees', 'validation');
|
||||
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation');
|
||||
@@ -445,6 +605,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Outside Employee',
|
||||
'email' => 'outside@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'cashier',
|
||||
'department_ids' => [(int)$otherDepartment['id']],
|
||||
@@ -456,6 +617,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Raw Employee',
|
||||
'email' => 'raw@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'cashier',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
@@ -468,6 +630,7 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Unknown Role Employee',
|
||||
'email' => 'unknown-role@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'superuser',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
@@ -520,3 +683,88 @@ it('rejects employee scopes roles raw permissions self edits superusers and shar
|
||||
->assertSuccess(false)
|
||||
->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.');
|
||||
});
|
||||
|
||||
@@ -4,6 +4,38 @@ declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function create_order_item_rule_fixture(array $customerAttributes = []): array
|
||||
{
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Rule Customer']);
|
||||
foreach ($customerAttributes as $attribute) {
|
||||
api_fixtures()->addCustomerAttribute((int)$customer['id'], (string)$attribute);
|
||||
}
|
||||
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'reference' => 'RULE-CHECK',
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||
|
||||
return [
|
||||
'customer' => $customer,
|
||||
'department' => $department,
|
||||
'order' => $order,
|
||||
'session' => $session,
|
||||
];
|
||||
}
|
||||
|
||||
function post_order_item(array $order, array $product, array $headers, array $overrides = []): \Tests\Support\Api\ApiResponse
|
||||
{
|
||||
return api_client()->post('/order/items', array_merge([
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $product['id'],
|
||||
'quantity' => 1,
|
||||
], $overrides), $headers);
|
||||
}
|
||||
|
||||
function custom_pricing_only_price_override(int $userId, int $productId, int $percentage): void
|
||||
{
|
||||
$statement = api_test_runtime()->db()->prepare(
|
||||
@@ -147,6 +179,129 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
|
||||
expect($response->data()['requires_note'] ?? null)->toBeTrue();
|
||||
});
|
||||
|
||||
it('blocks addon products added as standalone additional order items for customers restricted from additional services', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||
$primaryProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Primary truck wash',
|
||||
'price' => 200,
|
||||
]);
|
||||
$addonProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Drying add-on',
|
||||
'category' => 4,
|
||||
'price' => 50,
|
||||
]);
|
||||
|
||||
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
});
|
||||
|
||||
it('allows standalone additional order items when the customer is not restricted from additional services', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture();
|
||||
$primaryProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Primary unrestricted truck wash',
|
||||
'price' => 200,
|
||||
]);
|
||||
$addonProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Unrestricted add-on',
|
||||
'category' => 4,
|
||||
'price' => 50,
|
||||
]);
|
||||
|
||||
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('blocks related addon order items for customers restricted from additional services', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||
$cashier = api_fixtures()->createUser(['display_name' => 'Order Item Rule Cashier']);
|
||||
$primaryProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Primary related truck wash',
|
||||
'price' => 200,
|
||||
]);
|
||||
$addonProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Related extra brush',
|
||||
'price' => 35,
|
||||
]);
|
||||
$primaryItem = api_fixtures()->createOrderItem([
|
||||
'order_id' => $fixture['order']['id'],
|
||||
'product_id' => $primaryProduct['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'price' => 200,
|
||||
]);
|
||||
|
||||
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
|
||||
'related_item_id' => $primaryItem['id'],
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
});
|
||||
|
||||
it('blocks named restricted service products for the selected customer', function (string $attribute, array $productAttributes): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture([$attribute]);
|
||||
$product = api_fixtures()->createProduct($productAttributes);
|
||||
|
||||
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
})->with([
|
||||
'spot free' => ['restrictSpotFree', ['name' => 'Spot Free rinse', 'price' => 80]],
|
||||
'interior cleaning' => ['restrictInteriorCleaning', ['name' => 'Indvendig vask', 'price' => 125]],
|
||||
'tank cleaning' => ['restrictTankCleaning', ['name' => 'Tankrens', 'category' => 5, 'price' => 300]],
|
||||
]);
|
||||
|
||||
it('only allows tank cleaning products when the customer has the only tank cleaning rule', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['onlyTankCleaning']);
|
||||
$nonTankProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Exterior truck wash',
|
||||
'price' => 180,
|
||||
]);
|
||||
$tankProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Tank cleaning',
|
||||
'category' => 5,
|
||||
'price' => 300,
|
||||
]);
|
||||
|
||||
post_order_item($fixture['order'], $nonTankProduct, $fixture['session']['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
|
||||
post_order_item($fixture['order'], $tankProduct, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('uses the sentinel for missing custom-only department prices without discounts or cross-department prices', function (): void {
|
||||
api_test_covers('GET /products', 'happy');
|
||||
api_test_covers('POST /order/items', 'happy');
|
||||
|
||||
@@ -76,4 +76,6 @@ it('defines error report schema, routes, permissions, storage, and OpenAPI docs'
|
||||
expect($openapi)->toContain('/error-reports:');
|
||||
expect($openapi)->toContain('ErrorReportSubmissionRequest');
|
||||
expect($openapi)->toContain('ErrorReportStatusUpdateRequest');
|
||||
expect($openapi)->not->toContain(" - screenshot\n");
|
||||
expect($openapi)->toContain('Reports are accepted without an attachment when capture or upload fails.');
|
||||
});
|
||||
|
||||
+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