Compare commits

..
Author SHA1 Message Date
Jeppe Bundgaard 1cda2a81aa Merge remote-tracking branch 'origin/master' into codex/custom-pricing-only-departments
# Conflicts:
#	services/nginx/app/tests/Api/OrderItemsApiTest.php
2026-07-06 15:21:31 +02:00
Jeppe BandJeppe Bundgaard 8e46ce1b04 [codex] Allow error reports without screenshots (#299)
* Allow error reports without screenshots

* Stabilize edge gateway shell transcript smoke

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 14:27:47 +02:00
Jeppe BandJeppe Bundgaard 11c2a1b72e Block restricted customer order items (#296)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 14:06:29 +02:00
Jeppe BandJeppe Bundgaard 6f3d7e0f7d Add limited backoffice employee contact fields (#294)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 13:14:06 +02:00
Jeppe B a8fba73d99 Add limited backoffice role permission details (#291)
Adds grouped safe permission metadata for limited backoffice role presets.
2026-07-06 12:24:54 +02:00
Jeppe B 669759461d Merge pull request #293 from copenhagentruckwash/codex/economic-collected-invoice-transfer-speed
Optimize collected e-conomic invoice transfers
2026-07-06 11:49:47 +02:00
Jeppe Bundgaard 38814545c4 Optimize collected e-conomic invoice transfers 2026-07-06 11:31:22 +02:00
Jeppe Bundgaard 215c8d0fbb Add limited backoffice role permission details 2026-07-06 10:38:51 +02:00
Jeppe Bundgaard 84dec4c0a2 Stabilize custom pricing API fixture 2026-07-06 10:34:57 +02:00
Jeppe Bundgaard d47ea1d659 Add custom-only department pricing enforcement 2026-07-06 10:15:11 +02:00
28 changed files with 1309 additions and 81 deletions
+4 -3
View File
@@ -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
+13 -15
View File
@@ -928,22 +928,20 @@ async function main() {
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
);
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
: [];
await waitForCondition(
async () => {
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
: [];
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
assert.ok(
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
"Gateway logs page did not persist the shell transcript."
);
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
assert.ok(
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
"Gateway logs page did not include the shell close audit event."
return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell"))
&& timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED");
},
{ timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." }
);
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
@@ -34,6 +34,14 @@ class departments_schema_bootstrap
);
}
if (!self::columnExists($db, 'departments', 'custom_pricing_only')) {
$db->query(
"ALTER TABLE departments
ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0
AFTER archived"
);
}
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
$db->query(
"ALTER TABLE departments
@@ -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);
@@ -688,6 +688,7 @@ class invoice_period_flag_service
o.po AS order_po,
o.notes AS order_notes,
o.department_id,
d.custom_pricing_only AS department_custom_pricing_only,
o.reg_1,
o.invoice_collection_id,
o.wash_id,
@@ -720,6 +721,7 @@ class invoice_period_flag_service
GROUP BY customer_number
) u ON u.customer_number = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
LEFT JOIN departments d ON d.id = o.department_id
LEFT JOIN products p ON p.id = oi.product_id
LEFT JOIN categories c ON c.id = p.category
LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
@@ -1921,19 +1923,26 @@ class invoice_period_flag_service
private function calculateExpectedPrice(array $row): int
{
$base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0);
$discount = $this->discountBreakdown($row)['applied_discount_percentage'];
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
$base = $row['department_price'] !== null
? (int)$row['department_price']
: ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
$discount = $customMissingPrice ? 0 : $this->discountBreakdown($row)['applied_discount_percentage'];
return (int)round($base * (1 - ($discount / 100)));
}
private function priceBreakdown(array $row, int $expected): array
{
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
$base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0);
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
$base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
$discount = $this->discountBreakdown($row);
if ($customMissingPrice) {
$discount['applied_discount_percentage'] = 0;
}
return [
'product_price' => (int)($row['product_base_price'] ?? 0),
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0),
'department_price' => $departmentPrice,
'effective_base_price' => $base,
'product_discount_percentage' => $discount['product_discount_percentage'],
@@ -1944,6 +1953,11 @@ class invoice_period_flag_service
];
}
private function isCustomMissingDepartmentPrice(array $row): bool
{
return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0);
}
private function discountBreakdown(array $row): array
{
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
@@ -3,6 +3,7 @@
namespace classes;
use mysqli;
use objects\products_o;
use objects\users_o;
class limited_backoffice_service
@@ -112,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>
*/
@@ -119,11 +248,12 @@ class limited_backoffice_service
public function __construct()
{
departments_schema_bootstrap::ensureTables();
limited_backoffice_schema_bootstrap::ensureTables();
}
/**
* @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
{
@@ -133,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>
*/
@@ -194,7 +358,7 @@ class limited_backoffice_service
$in = implode(',', array_map('intval', $departmentIds));
$sql = "
SELECT `id`, `name`, `description`, `visible`, `archived`
SELECT `id`, `name`, `description`, `visible`, `archived`, `custom_pricing_only`
FROM `departments`
WHERE `id` IN ($in)
ORDER BY `order_priority` ASC, `name` ASC, `id` ASC
@@ -209,6 +373,7 @@ class limited_backoffice_service
'description' => (string)($row['description'] ?? ''),
'visible' => (bool)($row['visible'] ?? false),
'archived' => (bool)($row['archived'] ?? false),
'custom_pricing_only' => (bool)(int)($row['custom_pricing_only'] ?? 0),
], $rows);
}
@@ -224,8 +389,9 @@ class limited_backoffice_service
throw new limited_backoffice_exception('Department not found', 404);
}
$catalog = $this->departmentProductCatalog($departmentId);
if ($catalog['missing_products'] !== []) {
$customPricingOnly = (bool)($department['custom_pricing_only'] ?? false);
$catalog = $this->departmentProductCatalog($departmentId, $customPricingOnly);
if (!$customPricingOnly && $catalog['missing_products'] !== []) {
throw new limited_backoffice_exception('Department price setup is incomplete.', 409, [
'message' => 'Department price setup is incomplete.',
'code' => 'department_price_setup_required',
@@ -257,7 +423,8 @@ class limited_backoffice_service
throw new limited_backoffice_exception('Department not found', 404);
}
$catalog = $this->departmentProductCatalog($departmentId);
$customPricingOnly = (bool)($department['custom_pricing_only'] ?? false);
$catalog = $this->departmentProductCatalog($departmentId, $customPricingOnly);
if ($catalog['required_product_ids'] === []) {
throw new limited_backoffice_exception('Department has no products configured.', 409);
}
@@ -274,7 +441,7 @@ class limited_backoffice_service
sort($providedProductIds);
$missingProductIds = array_values(array_diff($requiredProductIds, $providedProductIds));
if ($missingProductIds !== []) {
if (!$customPricingOnly && $missingProductIds !== []) {
throw new limited_backoffice_exception('Price is required for every department product.', 400, [
'message' => 'Price is required for every department product.',
'missing_product_ids' => $missingProductIds,
@@ -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;
@@ -611,13 +794,13 @@ class limited_backoffice_service
}
/**
* @return array{id:int,name:string,description:string}
* @return array{id:int,name:string,description:string,custom_pricing_only:bool}
*/
private function fetchDepartment(int $departmentId): ?array
{
global $db;
$statement = $this->mysqli()->prepare(
'SELECT `id`, `name`, `description` FROM `departments` WHERE `id` = ? LIMIT 1'
'SELECT `id`, `name`, `description`, `custom_pricing_only` FROM `departments` WHERE `id` = ? LIMIT 1'
);
if ($statement === false) {
throw new limited_backoffice_exception('Unable to load department.', 500);
@@ -636,13 +819,14 @@ class limited_backoffice_service
'id' => (int)$row['id'],
'name' => (string)$row['name'],
'description' => (string)($row['description'] ?? ''),
'custom_pricing_only' => (bool)(int)($row['custom_pricing_only'] ?? 0),
];
}
/**
* @return array{categories:array<int,array<string,mixed>>,missing_products:array<int,array<string,mixed>>,required_product_ids:array<int,int>}
*/
private function departmentProductCatalog(int $departmentId): array
private function departmentProductCatalog(int $departmentId, bool $customPricingOnly = false): array
{
global $db;
@@ -703,10 +887,12 @@ class limited_backoffice_service
'id' => $productId,
'name' => (string)$row['product_name'],
'description' => (string)($row['product_description'] ?? ''),
'price' => $row['department_price'] === null ? null : (int)$row['department_price'],
'price' => $row['department_price'] === null
? ($customPricingOnly ? products_o::CUSTOM_PRICING_MISSING_PRICE : null)
: (int)$row['department_price'],
];
if ($row['department_price_id'] === null) {
if ($row['department_price_id'] === null && !$customPricingOnly) {
$missing[] = [
'id' => $productId,
'name' => (string)$row['product_name'],
@@ -909,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 === '') {
@@ -1084,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),
@@ -1204,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) {
@@ -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
@@ -22,6 +22,7 @@ class departments_o extends db
public object_property $dimension; // The dimension of the department
public object_property $visible; // The visibility of the department
public object_property $archived; // Whether the department is archived
public object_property $custom_pricing_only; // Whether missing department prices must not fall back to defaults
public object_property $branding; // The branding of the department
public object_property $longitude; // The longitude of the department (Can be null)
public object_property $latitude; // The latitude of the department (Can be null)
@@ -107,6 +108,7 @@ class departments_o extends db
$this->branding = new object_property($this->table, $this->id, 'branding', 'int', false);
$this->visible = new object_property($this->table, $this->id, 'visible', 'int', false);
$this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false);
$this->custom_pricing_only = new object_property($this->table, $this->id, 'custom_pricing_only', 'boolean', false);
$this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false);
$this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false);
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
@@ -185,6 +187,12 @@ class departments_o extends db
return $department;
}
public function isCustomPricingOnly(int $department_id): bool
{
$department = $this->getDepartmentById($department_id);
return (bool)(int)($department['custom_pricing_only'] ?? 0);
}
/**
* Get the price of a product in a department
* @param int $department_id
+5 -3
View File
@@ -168,12 +168,14 @@ class order_items_o extends db
// Get the order
$order = (new orders_o())->getOrderById($order_id);
// Get the product price
$price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value());
$product = (new products_o())->getProductById($product_id);
$priceResolution = $product->getDepartmentPriceResolution((int)$order->department_id->value());
$price = $priceResolution['price'];
// Check if the user has a discount on the product, or category
$customer = (new orders_o())->getOrderCustomer($order_id);
$discount = $customer->getCustomPrice($product_id, false);
if ($discount) {
if ($discount && !products_o::priceResolutionIsCustomMissing($priceResolution)) {
$price = $price - ($price * $discount / 100);
}
@@ -354,4 +356,4 @@ class order_items_o extends db
{
return (new products_o())->select((int)$this->product_id->value());
}
}
}
+16 -5
View File
@@ -1428,7 +1428,8 @@ class orders_o extends db
$order_item->product_id->set((int)$product->id); // Set the product ID to the product ID from the wash item
$order_item->reference->set('');
// Get the product price based on the department
$product_price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
$product_price = (int)$priceResolution['price']; // Get the department price for the product
// Get the customers custom price discount percentage
$user = $xlvask_usage_log->getUser(); // Get the user from the usage log
if (!$user->exists()) {
@@ -1436,7 +1437,9 @@ class orders_o extends db
}
$product_price_discount_percentage = (int)$user->getProductDiscountPercentage((int)$order_item->product_id->value()); // Get the custom price discount percentage for the product
// Apply the discount percentage to the product price
$product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
$product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price
}
$order_item->notes->set(null); // Set notes for the simulated order item
$order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage
$order_item->quantity->set((int)$washItem->Count); // Set the quantity based on the wash item
@@ -1503,10 +1506,14 @@ class orders_o extends db
if (!$current_user->exists()) {
throw new Exception('No current user found');
}
$price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
$price = (int)$priceResolution['price']; // Get the department price for the product
$discount_percentage = (int)$current_user->getProductDiscountPercentage((int)$product->id); // Get the custom price discount percentage for the product
// Apply the discount percentage to the product price
// Apply the discount percentage to the product price
if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
return $price;
}
return (int)round($price * (1 - ($discount_percentage / 100)));
}
@@ -1589,13 +1596,17 @@ class orders_o extends db
$product_id = (int)$item['product_id'];
if (!isset($department_price_cache[$product_id])) {
$product = (new products_o())->select($product_id);
$department_price_cache[$product_id] = (int)$product->getDepartmentPrice($department_id);
$department_price_cache[$product_id] = $product->getDepartmentPriceResolution($department_id);
}
if ($tmp_user === null) {
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
}
$discount = $tmp_user->getCustomPrice($product_id, false);
$post_discount = (int)round($department_price_cache[$product_id] * (1 - ($discount / 100))) * $quantity;
$unitPrice = (int)$department_price_cache[$product_id]['price'];
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) {
$unitPrice = (int)round($unitPrice * (1 - ($discount / 100)));
}
$post_discount = $unitPrice * $quantity;
$total += $post_discount;
}
+62 -9
View File
@@ -13,6 +13,11 @@ class products_o extends db
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
public const CUSTOM_PRICING_MISSING_PRICE = 999999;
public const PRICE_SOURCE_DEPARTMENT = 'department';
public const PRICE_SOURCE_DEFAULT = 'default';
public const PRICE_SOURCE_CUSTOM_MISSING = 'custom_missing';
public const PRICE_SOURCE_KEY = '_department_price_source';
/**
* The name of the product
@@ -255,24 +260,41 @@ class products_o extends db
* @param int $department_id
* @return array
*/
public function applyDepartmentPricing(array $products, int $department_id): array
public function applyDepartmentPricing(array $products, int $department_id, bool $includePriceSource = false): array
{
global $db;
$department_id = $db->escape_string($department_id);
$sql = "SELECT * FROM product_department_prices WHERE department_id = $department_id";
$result = $db->query($sql);
$prices = $db->fetch_all($result);
$priceLookup = [];
foreach ($prices as $price) {
$priceLookup[(int)$price['product_id']] = (int)$price['price'];
}
$customPricingOnly = (new departments_o())->isCustomPricingOnly((int)$department_id);
foreach ( $products as $key => $product ) {
foreach ( $prices as $price ) {
if ((int)$product['id'] === (int)$price['product_id']) {
$products[$key]['price'] = $price['price'];
}
$productId = (int)($product['id'] ?? 0);
$source = self::PRICE_SOURCE_DEFAULT;
if (array_key_exists($productId, $priceLookup)) {
$products[$key]['price'] = $priceLookup[$productId];
$source = self::PRICE_SOURCE_DEPARTMENT;
} elseif ($customPricingOnly) {
$products[$key]['price'] = self::CUSTOM_PRICING_MISSING_PRICE;
$source = self::PRICE_SOURCE_CUSTOM_MISSING;
}
if ($includePriceSource) {
$products[$key][self::PRICE_SOURCE_KEY] = $source;
}
}
return $products;
}
public function getDepartmentPrice(int $department_id): int
/**
* @return array{price:int,source:string}
*/
public function getDepartmentPriceResolution(int $department_id): array
{
global $db;
$department_id = $db->escape_string($department_id);
@@ -281,10 +303,27 @@ class products_o extends db
$prices = $db->fetch_all($result);
// Check if the product has a department price
if (count($prices) > 0) {
return $prices[0]['price'];
return [
'price' => (int)$prices[0]['price'],
'source' => self::PRICE_SOURCE_DEPARTMENT,
];
}
if ((new departments_o())->isCustomPricingOnly((int)$department_id)) {
return [
'price' => self::CUSTOM_PRICING_MISSING_PRICE,
'source' => self::PRICE_SOURCE_CUSTOM_MISSING,
];
}
// Return the default price
return $this->price->value();
return [
'price' => (int)$this->price->value(),
'source' => self::PRICE_SOURCE_DEFAULT,
];
}
public function getDepartmentPrice(int $department_id): int
{
return $this->getDepartmentPriceResolution($department_id)['price'];
}
public function applyCustomerDiscounts(array $products, users_o $customer): array
@@ -303,12 +342,26 @@ class products_o extends db
// Get the customer's discount percentage
$discount_percentage = $customer->getProductDiscountPercentage($product['id']);
// Apply the discount to the product price
if ($discount_percentage > 0) {
if ($discount_percentage > 0 && ($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
$product['price'] = (int)(round($product['price'] * (1 - ($discount_percentage / 100))));
}
unset($product[self::PRICE_SOURCE_KEY]);
return $product;
}
public static function stripDepartmentPriceSources(array $products): array
{
return array_map(static function (array $product): array {
unset($product[self::PRICE_SOURCE_KEY]);
return $product;
}, $products);
}
public static function priceResolutionIsCustomMissing(array $resolution): bool
{
return ($resolution['source'] ?? null) === self::PRICE_SOURCE_CUSTOM_MISSING;
}
public function getSubscriptionMonthlyPrice(): int
{
// Subscription price (for 2 washes per month) is 1.2 times the normal price
+4 -3
View File
@@ -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
@@ -1730,12 +1730,16 @@ class InvoicingPeriodRoute
$product_cache[$product_id] = (new products_o())->select($product_id);
}
if (!isset($department_price_cache[$department_id][$product_id])) {
$department_price_cache[$department_id][$product_id] = (int)$product_cache[$product_id]->getDepartmentPrice($department_id);
$department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id);
}
if (!array_key_exists($product_id, $discount_cache)) {
$discount_cache[$product_id] = $user->getCustomPrice($product_id, false);
}
$post_discount = (int)round($department_price_cache[$department_id][$product_id] * (1 - ($discount_cache[$product_id] / 100))) * $quantity;
$unit_price = (int)$department_price_cache[$department_id][$product_id]['price'];
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) {
$unit_price = (int)round($unit_price * (1 - ($discount_cache[$product_id] / 100)));
}
$post_discount = $unit_price * $quantity;
$transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount);
}
@@ -103,6 +103,7 @@ class departmentsRoute
'economic_department_id',
'visible',
'archived',
'custom_pricing_only',
'longitude',
'latitude',
])
@@ -123,6 +124,12 @@ class departmentsRoute
'latitude' => (float)$department['latitude'],
'order_priority' => (int)$department['order_priority'],
];
if (
$user->hasPermission('superuser_fetch_department')
|| $user->hasPermission('edit_department')
) {
$tmp_department['custom_pricing_only'] = (bool)(int)($department['custom_pricing_only'] ?? 0);
}
// If the user has the permission to view the slack webhook, add it to the response
if ($user->hasPermission('view_slack_webhook')) {
$tmp_department['slack_webhook'] = $department['slack_webhook'];
@@ -220,6 +227,9 @@ class departmentsRoute
if (self::isParametersSet(['archived'])) {
$department->archived->set(self::isTruthyBooleanValue(self::getParameter('archived')));
}
if (self::isParametersSet(['custom_pricing_only'])) {
$department->custom_pricing_only->set(self::isTruthyBooleanValue(self::getParameter('custom_pricing_only')));
}
$department->objectChanged();
// Log the incident
(new logs_o())->add('departments', (int)self::getParameter('id'), 1, $user->id, 'EDIT_DEPARTMENT', 'Successfully edited a department');
+17 -1
View File
@@ -53,6 +53,19 @@ class productsRoute
return null;
}
private function assertCanUseDepartmentPricing(mixed $user, ?int $departmentId): void
{
if (!$user instanceof users_o || $departmentId === null) {
return;
}
if ($this->hasPermission('superuser_fetch_department')) {
return;
}
$this->requirePermission('department_access_' . $departmentId);
}
/**
* Get the category (ID) if the category parameter is provided (In the request 'category')
* @return int|null
@@ -91,12 +104,14 @@ class productsRoute
// Check if the departmentId is set
if ($departmentId) {
// Apply the departments unique pricing
$products = (new products_o())->applyDepartmentPricing($products, $departmentId);
$products = (new products_o())->applyDepartmentPricing($products, $departmentId, true);
}
// Check if the customer is set
if ($customer !== null) {
// Apply the customers unique discounts
$products = (new products_o())->applyCustomerDiscounts($products, $customer);
} else {
$products = products_o::stripDepartmentPriceSources($products);
}
return $products;
}
@@ -197,6 +212,7 @@ class productsRoute
// Define the variables
$customer = self::getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
$departmentId = self::getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
$this->assertCanUseDepartmentPricing($user, $departmentId);
$category = self::getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
$productId = self::getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
// Check if the "final_price" parameter is set, and true.
@@ -231,6 +231,7 @@ it('updates departments through the real endpoint', function (): void {
'description' => 'Updated description',
'order_priority' => 5,
'archived' => true,
'custom_pricing_only' => true,
], $session['headers']);
$response
@@ -246,6 +247,7 @@ it('updates departments through the real endpoint', function (): void {
expect($row['description'] ?? null)->toBe('Updated description');
expect((int)($row['order_priority'] ?? 0))->toBe(5);
expect((int)($row['archived'] ?? 0))->toBe(1);
expect((int)($row['custom_pricing_only'] ?? 0))->toBe(1);
});
it('rejects invalid department update requests', function (): void {
@@ -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);
});
@@ -285,6 +285,85 @@ it('fails price setup gaps without exposing product defaults', function (): void
expect($response->data()['missing_products'][0]['id'] ?? null)->toBe((int)$product['id']);
});
it('defaults missing custom-only department prices to sentinel without exposing fallback prices', function (): void {
api_test_covers('GET /limited-backoffice/departments', 'happy');
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'happy');
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'happy');
$department = api_fixtures()->createDepartment([
'name' => 'Limited Custom Pricing Only',
'custom_pricing_only' => 1,
]);
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Other Pricing']);
$category = api_fixtures()->createCategory(['name' => 'Limited Custom Pricing Category']);
$product = api_fixtures()->createProduct([
'name' => 'Custom Missing Product',
'category' => $category['id'],
'price' => 87654,
]);
$otherProduct = api_fixtures()->createProduct([
'name' => 'Custom Missing Other Product',
'category' => $category['id'],
'price' => 76543,
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$product['id'], 4321);
limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$otherProduct['id'], 5432);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$departments = api_client()->get('/limited-backoffice/departments', $session['headers']);
$departments
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($departments->data()[0]['custom_pricing_only'] ?? null)->toBeTrue();
$response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->body)->not->toContain('87654');
expect($response->body)->not->toContain('76543');
expect($response->body)->not->toContain('4321');
expect($response->body)->not->toContain('5432');
expect($response->data()['department']['custom_pricing_only'] ?? null)->toBeTrue();
$products = [];
foreach ($response->data()['categories'] as $departmentCategory) {
foreach ($departmentCategory['products'] as $departmentProduct) {
$products[(int)$departmentProduct['id']] = $departmentProduct;
}
}
expect($products[(int)$product['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
expect($products[(int)$otherProduct['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
$updated = api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
'prices' => [
['product_id' => (int)$product['id'], 'price' => 2222],
],
], $session['headers']);
$updated
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$updatedProducts = [];
foreach ($updated->data()['categories'] as $departmentCategory) {
foreach ($departmentCategory['products'] as $departmentProduct) {
$updatedProducts[(int)$departmentProduct['id']] = $departmentProduct;
}
}
expect($updated->body)->not->toContain('87654');
expect($updated->body)->not->toContain('76543');
expect($updated->body)->not->toContain('4321');
expect($updated->body)->not->toContain('5432');
expect($updatedProducts[(int)$product['id']]['price'] ?? null)->toBe(2222);
expect($updatedProducts[(int)$otherProduct['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
});
it('rejects invalid price batches and leaves existing prices unchanged', function (): void {
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'validation');
@@ -348,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']],
@@ -366,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');
@@ -384,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']);
@@ -393,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']);
@@ -419,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');
@@ -429,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']],
@@ -440,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']],
@@ -452,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']],
@@ -504,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.');
});
@@ -36,6 +36,41 @@ function post_order_item(array $order, array $product, array $headers, array $ov
], $overrides), $headers);
}
function custom_pricing_only_price_override(int $userId, int $productId, int $percentage): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `price_overrides` (`user_id`, `is_category`, `product_or_category_id`, `percentage`)
VALUES (?, 0, ?, ?)'
);
$productIdText = (string)$productId;
$statement->bind_param('isi', $userId, $productIdText, $percentage);
$statement->execute();
$statement->close();
api_fixtures()->cleanupDeleteWhere('price_overrides', [
'user_id' => $userId,
'is_category' => 0,
'product_or_category_id' => $productIdText,
]);
}
function custom_pricing_only_department_price(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
);
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$statement->close();
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
'department_id' => $departmentId,
'product_id' => $productId,
]);
}
it('requires notes when adding the extraordinary chemistry product to an order', function (): void {
api_test_covers('POST /order/items', 'validation');
@@ -266,3 +301,77 @@ it('only allows tank cleaning products when the customer has the only tank clean
->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');
$department = api_fixtures()->createDepartment([
'name' => 'Custom Pricing Products',
'custom_pricing_only' => 1,
]);
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Custom Pricing Other']);
$category = api_fixtures()->createCategory(['name' => 'Custom Pricing Products Category']);
$product = api_fixtures()->createProduct([
'name' => 'Custom Pricing Missing Product',
'category' => $category['id'],
'price' => 12345,
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
custom_pricing_only_department_price((int)$otherDepartment['id'], (int)$product['id'], 3333);
$customer = api_fixtures()->createUser(['display_name' => 'Custom Pricing Customer']);
api_fixtures()->cacheEconomicCustomerDiscountPercentage((int)$customer['id'], 0);
custom_pricing_only_price_override((int)$customer['id'], (int)$product['id'], 50);
$session = api_fixtures()->createUserSession([
'list_products',
'add_order_items',
'department_access_' . (int)$department['id'],
]);
$productResponse = api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$department['id']
. '&customer_id=' . (int)$customer['customer_number'],
$session['headers']
);
$productResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($productResponse->body)->not->toContain('12345');
expect($productResponse->body)->not->toContain('3333');
expect($productResponse->data()['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
api_client()->get(
'/products?final_price=true&id=' . (int)$product['id']
. '&department_id=' . (int)$otherDepartment['id'],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'CUSTOM-ONLY-ORDER',
]);
$orderItem = api_client()->post('/order/items', [
'order_id' => $order['id'],
'product_id' => $product['id'],
'quantity' => 1,
], $session['headers']);
$orderItem
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect((int)($orderItem->data()['price'] ?? 0))->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
});
@@ -132,6 +132,7 @@ final class ApiFixtures
'branding' => (int)($attributes['branding'] ?? 0),
'visible' => (int)($attributes['visible'] ?? 1),
'archived' => (int)($attributes['archived'] ?? 0),
'custom_pricing_only' => (int)($attributes['custom_pricing_only'] ?? 0),
'latitude' => $attributes['latitude'] ?? 0.0,
'longitude' => $attributes['longitude'] ?? 0.0,
'order_priority' => (int)($attributes['order_priority'] ?? 0),
@@ -1709,6 +1710,17 @@ final class ApiFixtures
$this->cleanup->add(fn() => $this->deleteWhere($table, $conditions));
}
public function cacheEconomicCustomerDiscountPercentage(int $userId, int $discountPercentage): void
{
if ($this->redis === null) {
throw new RuntimeException('API tests require Redis for cache-backed endpoint flows.');
}
$key = 'users_' . $userId . '_economic_customer_discount_percentage';
$this->redis->set($key, (string)$discountPercentage);
$this->cleanup->add(fn() => $this->deleteRedisKey($key));
}
private function purgeCustomerTraceData(int $userId, int $customerNumber): void
{
$invoiceCollectionIds = $this->fetchIntColumnWhere('collected_order_invoices', 'id', [
@@ -89,6 +89,7 @@ CREATE TABLE IF NOT EXISTS `departments` (
`branding` INT NULL DEFAULT NULL,
`visible` TINYINT(1) NOT NULL DEFAULT 1,
`archived` TINYINT(1) NOT NULL DEFAULT 0,
`custom_pricing_only` TINYINT(1) NOT NULL DEFAULT 0,
`latitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
`longitude` DECIMAL(10,7) NOT NULL DEFAULT 0,
`order_priority` INT NOT NULL DEFAULT 0,
@@ -892,6 +893,13 @@ SQL,
'ALTER TABLE `departments` ADD INDEX `idx_departments_archived` (`archived`)'
);
}
if (!$this->columnExists('departments', 'custom_pricing_only')) {
$this->execute(
'departments.custom_pricing_only',
'ALTER TABLE `departments` ADD COLUMN `custom_pricing_only` TINYINT(1) NOT NULL DEFAULT 0 AFTER `archived`'
);
}
}
private function ensureOrderInvoiceCollectionSchema(): void
@@ -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.');
});
@@ -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);
});
@@ -618,6 +618,33 @@ it('uses a preloaded e-conomic global discount in expected price breakdowns', fu
]);
});
it('uses the custom-only sentinel without discounts when department price is missing', function (): void {
$row = [
'customer_number' => 35131752,
'user_id' => 411,
'product_base_price' => 100,
'department_price' => null,
'department_custom_pricing_only' => 1,
'product_discount_percentage' => 50,
'category_discount_percentage' => 25,
'apply_category_discount' => 0,
];
$expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]);
$breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]);
expect($expected)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
expect($breakdown)->toMatchArray([
'product_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
'department_price' => null,
'effective_base_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
'product_discount_percentage' => 50,
'category_discount_percentage' => 0,
'applied_discount_percentage' => 0,
'expected_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
]);
});
it('does not report a price mismatch when a product-specific discount makes the expected price zero', function (): void {
$row = [
'customer_number' => 35131752,