Merge origin/master and resolve invoice_period_flag_service conflict

This commit is contained in:
copilot-swe-agent[bot]
2026-06-01 21:37:53 +00:00
committed by GitHub
26 changed files with 697 additions and 141 deletions
+2 -4
View File
@@ -46,8 +46,6 @@ jobs:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
QODANA_ENDPOINT: 'https://qodana.cloud'
- name: 'Qodana Scan (without cloud upload)'
- name: 'Skip Qodana Scan (missing cloud token)'
if: ${{ steps.qodana-token.outputs.present != 'true' }}
uses: JetBrains/qodana-action@v2026.1
with:
pr-mode: false
run: echo "Skipping Qodana because QODANA_TOKEN is not configured."
+6
View File
@@ -10,9 +10,15 @@
# CORS is handled at the edge by Traefik's headers middleware.
# Do not set or strip Access-Control-* headers here to avoid conflicts.
<<<<<<< HEAD
# Replication bootstrap snapshots contain sensitive failover credentials.
@replication_bootstrap path /storage/replication-bootstrap.json /storage/replication-bootstrap-*
respond @replication_bootstrap 404
=======
# Do not expose local replication bootstrap material from the public web root.
@replicationBootstrap path /storage/replication-bootstrap.json
respond @replicationBootstrap 404
>>>>>>> refs/remotes/origin/master
# PHP handling via FastCGI to php-fpm pool
php_fastcgi php1:9000 php2:9000 php3:9000 php4:9000 php5:9000
+6
View File
@@ -10,9 +10,15 @@
# CORS is handled at the edge by Traefik's headers middleware.
# Do not set or strip Access-Control-* headers here to avoid conflicts.
<<<<<<< HEAD
# Replication bootstrap snapshots contain sensitive failover credentials.
@replication_bootstrap path /storage/replication-bootstrap.json /storage/replication-bootstrap-*
respond @replication_bootstrap 404
=======
# Do not expose local replication bootstrap material from the public web root.
@replicationBootstrap path /storage/replication-bootstrap.json
respond @replicationBootstrap 404
>>>>>>> refs/remotes/origin/master
# PHP handling via FastCGI to php-fpm pool
php_fastcgi php-staging:9000
@@ -533,10 +533,7 @@ class invoice_period_flag_service
if (!is_array($flags)) {
$flags = $this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo);
try {
(new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags);
} catch (Throwable) {
}
$this->cacheAutomaticFlagsForPeriod($dateFrom, $dateTo, $flags);
}
if ($onlyCustomerNumbers === null) {
@@ -552,12 +549,11 @@ class invoice_period_flag_service
public function warmAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): void
{
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
$flags = $this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo);
try {
(new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags);
} catch (Throwable) {
}
$this->cacheAutomaticFlagsForPeriod(
$dateFrom,
$dateTo,
$this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo)
);
}
private function calculateAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): array
@@ -575,6 +571,14 @@ class invoice_period_flag_service
);
}
private function cacheAutomaticFlagsForPeriod(string $dateFrom, string $dateTo, array $flags): void
{
try {
(new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags);
} catch (Throwable) {
}
}
private function filterSuppressedAutomaticFlags(array $flags): array
{
global $db;
@@ -1760,18 +1764,7 @@ class invoice_period_flag_service
if ($currentVehicleType === '' || $expectedVehicleType === '') {
return false;
}
if ($currentVehicleType === $expectedVehicleType) {
return true;
}
// Allow a match if one normalized name's tokens are a subset of the other.
// E.g. "Indvendig vask Kassevogn" → "kassevogn" is a subset of
// "Kassevogn/varevogn" → "kassevogn varevogn", meaning the same vehicle type.
$currentTokens = explode(' ', $currentVehicleType);
$expectedTokens = explode(' ', $expectedVehicleType);
if (count($currentTokens) <= count($expectedTokens)) {
return array_diff($currentTokens, $expectedTokens) === [];
}
return array_diff($expectedTokens, $currentTokens) === [];
return $currentVehicleType === $expectedVehicleType;
}
private function normalizePrimaryVehicleProductName(string $productName): string
@@ -46,7 +46,7 @@ class order_reference_suggestions_service
$rows = [
...$this->fetchBookingRows($departmentId, $search),
...$this->fetchOrderRows($departmentId, $search),
...$this->fetchVehicleRows($customerId, $plates, $search),
...$this->fetchVehicleRows($departmentId, $customerId, $plates, $search),
];
$suggestions = $this->aggregateRows($rows, $search, $customerId, $plates);
@@ -137,7 +137,7 @@ class order_reference_suggestions_service
* @param array<int, string> $plates
* @return array<int, array<string, mixed>>
*/
private function fetchVehicleRows(?int $customerId, array $plates, string $search): array
private function fetchVehicleRows(int $departmentId, ?int $customerId, array $plates, string $search): array
{
$contextWhere = [];
$params = [];
@@ -171,6 +171,10 @@ class order_reference_suggestions_service
$params['search'] = '%' . $this->lower($search) . '%';
}
$where[] = $this->vehicleDepartmentAccessPredicate();
$params['orders_department_id'] = $departmentId;
$params['bookings_department_id'] = $departmentId;
$sql = "SELECT
'vehicle' AS source,
id AS origin_id,
@@ -190,6 +194,41 @@ class order_reference_suggestions_service
return $this->fetchRows($sql, $params);
}
private function vehicleDepartmentAccessPredicate(): string
{
$ordersWhere = [
'authorized_orders.department_id = :orders_department_id',
'(authorized_orders.customer_id = customer_vehicles.customer_id'
. " OR UPPER(REPLACE(authorized_orders.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
. " OR UPPER(REPLACE(authorized_orders.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
. " OR UPPER(REPLACE(authorized_orders.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))"
];
if ($this->tableHasColumn('orders', 'deleted_at')) {
$ordersWhere[] = 'authorized_orders.deleted_at IS NULL';
}
$bookingsWhere = [
'authorized_bookings.department = :bookings_department_id',
'(authorized_bookings.customer_number = customer_vehicles.customer_id'
. " OR UPPER(REPLACE(authorized_bookings.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
. " OR UPPER(REPLACE(authorized_bookings.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
. " OR UPPER(REPLACE(authorized_bookings.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))"
];
if ($this->tableHasColumn('order_bookings', 'deleted_at')) {
$bookingsWhere[] = 'authorized_bookings.deleted_at IS NULL';
}
return '(EXISTS (
SELECT 1
FROM orders authorized_orders
WHERE ' . implode(' AND ', $ordersWhere) . '
) OR EXISTS (
SELECT 1
FROM order_bookings authorized_bookings
WHERE ' . implode(' AND ', $bookingsWhere) . '
))';
}
/**
* @param array<string, mixed> $params
* @return array<int, array<string, mixed>>
@@ -58,6 +58,9 @@ class orders_schema_bootstrap
|| !self::columnExists($db, 'orders', 'booking_id')
|| !self::columnExists($db, 'orders', 'po')
|| !self::columnExists($db, 'order_bookings', 'po')
|| !self::columnExists($db, 'order_bookings', 'customer_number')
|| !self::columnExists($db, 'order_bookings', 'department')
|| !self::columnExists($db, 'order_bookings', 'deleted_at')
) {
return;
}
@@ -65,6 +68,9 @@ class orders_schema_bootstrap
$db->query(
"UPDATE orders o
INNER JOIN order_bookings b ON b.id = o.booking_id
AND b.customer_number = o.customer_id
AND b.department = o.department_id
AND b.deleted_at IS NULL
SET o.po = b.po
WHERE o.booking_id IS NOT NULL
AND o.booking_id > 0
+27 -6
View File
@@ -1021,6 +1021,7 @@ class release_manager
'channel_slug' => $channelSlug,
'route_slug' => $routeSlug,
'app' => $app,
'apps' => $this->releaseTestAppsFromInput($input),
'repository' => $repository,
'branch' => $branch,
'auto_sync' => $this->toBool($input['auto_sync'] ?? false),
@@ -4802,22 +4803,22 @@ class release_manager
throw new RuntimeException('Beta release channel uses production services and does not promote separate release bundles.');
}
$this->assertBetaProductionDataPolicy($channel, $serviceSet);
$frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null);
$apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
$this->assertReleaseGatePassedForPromotion(
$channelId,
(string)($bundle['frontend_commit_sha'] ?? ''),
null,
'frontend'
);
if (trim((string)($bundle['api_commit_sha'] ?? '')) !== '') {
if ($apiVersionId !== null) {
$this->assertReleaseGatePassedForPromotion(
$channelId,
(string)$bundle['api_commit_sha'],
(string)($bundle['api_commit_sha'] ?? ''),
null,
'api'
);
}
$frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null);
$apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
$deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null)
?? $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null);
@@ -9432,7 +9433,7 @@ class release_manager
'status' => 'draft',
'metadata' => [
'commit_mode' => $input['commit_mode'],
'github_access' => $input['github_access'],
'github_access' => self::releaseVersionGithubAccessMetadata($input['github_access'] ?? null),
'bundle_member' => true,
],
]);
@@ -9765,7 +9766,7 @@ class release_manager
return null;
}
$metadata = self::jsonDecode($version['metadata_json'] ?? null);
$metadata = self::publicReleaseVersionMetadata(self::jsonDecode($version['metadata_json'] ?? null));
$commit = $this->versionGithubCommit(['metadata' => $metadata]);
return [
@@ -9788,6 +9789,26 @@ class release_manager
];
}
private static function publicReleaseVersionMetadata(mixed $metadata): array
{
if (!is_array($metadata)) {
return [];
}
unset($metadata['github_access']);
return $metadata;
}
private static function releaseVersionGithubAccessMetadata(mixed $githubAccess): ?array
{
if (!is_array($githubAccess)) {
return null;
}
unset($githubAccess['commit'], $githubAccess['latest_commit'], $githubAccess['commit_authored_at']);
return $githubAccess;
}
private function publicAssignment(array $assignment): array
{
return [
@@ -207,25 +207,28 @@ class system_search_service
$activeTypes,
$this->associationEntityTypes()
));
foreach ($customerNumbers as $customerNumber) {
$associated = $this->executeLexicalSearch(
$associationTypes,
[(string)$customerNumber],
[],
$ownOnlyTypes,
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
[$customerNumber]
);
foreach ($associated as &$item) {
if (!isset($item['association_reason'])) {
$item['association_reason'] = 'customer:' . $customerNumber;
$associationTypes = array_values(array_diff($associationTypes, $ownOnlyTypes));
if (!empty($associationTypes)) {
foreach ($customerNumbers as $customerNumber) {
$associated = $this->executeLexicalSearch(
$associationTypes,
[(string)$customerNumber],
[],
$ownOnlyTypes,
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
[$customerNumber]
);
foreach ($associated as &$item) {
if (!isset($item['association_reason'])) {
$item['association_reason'] = 'customer:' . $customerNumber;
}
$item['score'] = max((int)$item['score'], 35);
}
$item['score'] = max((int)$item['score'], 35);
$initialResults = $this->mergeResults($initialResults, $associated);
}
$initialResults = $this->mergeResults($initialResults, $associated);
}
}
}
@@ -46,6 +46,11 @@ use objects\selfserve_config_versions_o;
class selfserve_studio_graph
{
private const DEFAULT_PATH_MAX_STATES = 2048;
private const MAX_PATH_MAX_STATES = 2048;
private const DEFAULT_PATH_SAMPLE_LIMIT = 200;
private const MAX_PATH_SAMPLE_LIMIT = 200;
/** @var array<string,array<int,string>> */
private array $columnCache = [];
@@ -678,7 +683,11 @@ class selfserve_studio_graph
$vehicleTypeIds[] = null;
}
$maxStates = $this->pathLimit($payload['max_states'] ?? null);
$maxStates = $this->pathLimit(
$payload['max_states'] ?? null,
self::DEFAULT_PATH_MAX_STATES,
self::MAX_PATH_MAX_STATES
);
$reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123'));
if ($reg === '') {
$reg = 'TEST123';
@@ -694,14 +703,18 @@ class selfserve_studio_graph
$stateCount = 0;
$terminalPathCount = 0;
$questionIds = [];
$pathSampleLimit = $this->pathLimit($payload['path_sample_limit'] ?? null);
$pathSampleLimit = $this->pathLimit(
$payload['path_sample_limit'] ?? null,
self::DEFAULT_PATH_SAMPLE_LIMIT,
self::MAX_PATH_SAMPLE_LIMIT
);
$paths = [];
$scenarioCount = max(1, count($vehicleTypeIds));
$confirmationRows = $this->loadPathConfirmationRows($departmentId, $versionId, $laneId, $vehicleTypeId, $configSource);
foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) {
$remainingStates = $maxStates === null ? null : $maxStates - $stateCount;
if ($remainingStates !== null && $remainingStates <= 0) {
$remainingStates = $maxStates - $stateCount;
if ($remainingStates <= 0) {
$truncated = true;
break;
}
@@ -759,7 +772,7 @@ class selfserve_studio_graph
$projectionOptions = [
'scope' => $scenarioScope,
'max_states' => $remainingStates,
'path_sample_limit' => $pathSampleLimit === null ? null : max(0, $pathSampleLimit - count($paths)),
'path_sample_limit' => max(0, $pathSampleLimit - count($paths)),
'progress_callback' => function (array $projection) use (
$progressCallback,
&$outcomes,
@@ -787,7 +800,7 @@ class selfserve_studio_graph
$partialOutcomes = array_merge($outcomes, array_values((array)($projection['outcomes'] ?? [])));
$partialPaths = array_merge($paths, array_values((array)($projection['paths'] ?? [])));
if ($pathSampleLimit !== null && count($partialPaths) > $pathSampleLimit) {
if (count($partialPaths) > $pathSampleLimit) {
$partialPaths = array_slice($partialPaths, 0, $pathSampleLimit);
}
@@ -839,12 +852,6 @@ class selfserve_studio_graph
},
'confirmation_rows' => $confirmationRows,
];
if ($remainingStates === null) {
unset($projectionOptions['max_states']);
}
if ($pathSampleLimit === null) {
unset($projectionOptions['path_sample_limit']);
}
$projection = $this->projectPathOutcomesFromSimulator($simulate, $projectionOptions);
foreach ((array)($projection['outcomes'] ?? []) as $outcome) {
if (is_array($outcome)) {
@@ -855,7 +862,7 @@ class selfserve_studio_graph
if (!is_array($path)) {
continue;
}
if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) {
if (count($paths) < $pathSampleLimit) {
$paths[] = $path;
}
}
@@ -918,9 +925,18 @@ class selfserve_studio_graph
*/
public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array
{
$maxStates = $this->pathLimit($options['max_states'] ?? null);
$maxStates = $this->pathLimit(
$options['max_states'] ?? null,
self::DEFAULT_PATH_MAX_STATES,
self::MAX_PATH_MAX_STATES
);
$sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5)));
$pathSampleLimit = $this->pathLimit($options['path_sample_limit'] ?? null);
$pathSampleLimit = $this->pathLimit(
$options['path_sample_limit'] ?? null,
self::DEFAULT_PATH_SAMPLE_LIMIT,
self::MAX_PATH_SAMPLE_LIMIT,
0
);
$progressCallback = is_callable($options['progress_callback'] ?? null) ? $options['progress_callback'] : null;
$progressIntervalStates = max(1, (int)($options['progress_interval_states'] ?? 128));
$scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : [];
@@ -938,7 +954,7 @@ class selfserve_studio_graph
$truncated = false;
while ($stack !== []) {
if ($maxStates !== null && $stateCount >= $maxStates) {
if ($stateCount >= $maxStates) {
$truncated = true;
break;
}
@@ -997,7 +1013,7 @@ class selfserve_studio_graph
$terminalPathCount++;
$chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : [];
$this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit);
if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) {
if (count($paths) < $pathSampleLimit) {
$paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope);
}
@@ -4273,14 +4289,18 @@ class selfserve_studio_graph
return null;
}
private function pathLimit(mixed $value): ?int
private function pathLimit(mixed $value, int $default, int $max, int $min = 1): int
{
if ($value === null || $value === '') {
return null;
return max($min, min($max, $default));
}
$parsed = (int)$value;
return $parsed > 0 ? $parsed : null;
if ($parsed < $min) {
return max($min, min($max, $default));
}
return min($max, $parsed);
}
/**
@@ -38,6 +38,49 @@ use objects\department_variables_o;
trait selfserve_lane_command_t
{
/**
* Acquire an atomic per-lane START lock before performing physical side effects.
*/
protected function acquireLaneStartCommandLock(): string
{
if (!defined('redis') || !method_exists(redis, 'set_if_absent_with_expiration')) {
throw new \RuntimeException('Cannot start lane: START lock is unavailable.');
}
$token = bin2hex(random_bytes(16));
$lock_key = $this->getLaneStartCommandLockKey();
if (!redis->set_if_absent_with_expiration($lock_key, $token, 30)) {
throw new \RuntimeException("Cannot start lane: Lane is not available.");
}
return $token;
}
protected function releaseLaneStartCommandLock(string $token): void
{
if (!defined('redis')) {
return;
}
$lock_key = $this->getLaneStartCommandLockKey();
try {
if (method_exists(redis, 'get') && redis->get($lock_key) !== $token) {
return;
}
if (method_exists(redis, 'delete')) {
redis->delete($lock_key);
}
} catch (\Throwable) {
// The lock has a short TTL, so release failures must not mask START results.
}
}
protected function getLaneStartCommandLockKey(): string
{
return 'selfserve_lane_start_command_lock_' . (int)$this->id;
}
/**
* Determine if the lane and its department have self-serve enabled.
* This method is intentionally protected to allow tests to override
@@ -509,38 +552,48 @@ trait selfserve_lane_command_t
// Validate customer number
if (!is_numeric($customer_number) || (int)$customer_number <= 0) throw new \InvalidArgumentException("Invalid customer number: " . $customer_number);
if (!(new users_o())->getUserByCustomerNumber((int)$customer_number)->exists()) throw new \InvalidArgumentException("Customer number does not exist: " . $customer_number);
$previous_customer_number = $this->getCustomerNumber();
$previous_license_plate = $this->getLicensePlate();
// Set the customer number and license plate
$this->setCustomerNumber($customer_number);
$this->setLicensePlate($license_plate);
$start_lock_token = $this->acquireLaneStartCommandLock();
try {
// Open the entrance port before marking the lane occupied. Gateway timeouts are
// ambiguous because the relay may already have received the pulse.
$this->openEntrancePortForWashStart();
} catch (\Throwable $e) {
$this->setCustomerNumber($previous_customer_number);
$this->setLicensePlate($previous_license_plate);
$this->setLaneState(selfserve_lane_state::IDLE);
throw $e;
// Re-check availability after taking the START lock so concurrent requests cannot
// both pass the preflight check and trigger the physical entrance relay.
if (!$this->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) throw new \RuntimeException("Cannot start lane: Lane is not available.");
$previous_customer_number = $this->getCustomerNumber();
$previous_license_plate = $this->getLicensePlate();
$previous_status = $this->getLaneStatus();
// Set the customer number and license plate
$this->setCustomerNumber($customer_number);
$this->setLicensePlate($license_plate);
// Mark the lane occupied before any physical entrance relay side effects.
$this->setLaneStatus(selfserve_lane_status::OCCUPIED);
try {
// Gateway timeouts are ambiguous because the relay may already have received
// the pulse, so openEntrancePortForWashStart() reports them and continues.
$this->openEntrancePortForWashStart();
} catch (\Throwable $e) {
$this->setCustomerNumber($previous_customer_number);
$this->setLicensePlate($previous_license_plate);
$this->setLaneStatus($previous_status);
$this->setLaneState(selfserve_lane_state::IDLE);
throw $e;
}
// Set the lane state to IN_WASH
$this->setLaneState(selfserve_lane_state::IN_WASH);
// Start the wash timer
$this->setWashStartTime(time());
$this->runPublishedStudioActions(
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
$this->resolveSelfServeActionWashModeForStart(),
[
'customer_number' => (int)$customer_number,
'reg' => $license_plate,
]
);
$this->runRelaySideEffectsForWashStart($arguments);
// Log the lane start event
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
} finally {
$this->releaseLaneStartCommandLock($start_lock_token);
}
// Set the lane status to OCCUPIED when started
$this->setLaneStatus(selfserve_lane_status::OCCUPIED);
// Set the lane state to IN_WASH
$this->setLaneState(selfserve_lane_state::IN_WASH);
// Start the wash timer
$this->setWashStartTime(time());
$this->runPublishedStudioActions(
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
$this->resolveSelfServeActionWashModeForStart(),
[
'customer_number' => (int)$customer_number,
'reg' => $license_plate,
]
);
$this->runRelaySideEffectsForWashStart($arguments);
// Log the lane start event
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
break;
case selfserve_lane_command::STOP:
// Require lane to be occupied before stopping
+6 -2
View File
@@ -18283,13 +18283,17 @@ components:
max_states:
type: integer
minimum: 1
maximum: 2048
default: 2048
nullable: true
description: Optional debug cap. Omit for complete path projection.
description: Optional debug cap for explored states. Omitted and larger values are capped at 2048.
path_sample_limit:
type: integer
minimum: 1
maximum: 200
default: 200
nullable: true
description: Optional debug cap for returned path rows. Omit to return every terminal path row.
description: Optional cap for returned path rows. Omitted and larger values are capped at 200.
SelfserveStudioPathOutcomesResponse:
type: object
@@ -835,8 +835,9 @@ class InvoicingPeriodRoute
$response->add_meta('customer_numbers', $customerNumbers);
}
$paginationOptions = self::getPeriodPaginationOptionsFromRequest();
$includeInvoicePeriodFlags = $this->hasPermission('list_invoice_period_flags');
// Get the invoicing period for the user
$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers);
$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers, $includeInvoicePeriodFlags);
if ($paginationOptions !== null) {
$paginated = self::applyPeriodPagination($period, $paginationOptions);
$period = $paginated['period'];
@@ -1841,7 +1842,12 @@ class InvoicingPeriodRoute
/**
* @throws Exception
*/
private static function getInvoicingPeriod(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array
private static function getInvoicingPeriod(
string $dateFrom,
string $dateTo,
?array $onlyCustomerNumbers = null,
bool $includeInvoicePeriodFlags = false
): array
{
//$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo)
$onlyCustomerNumbers = $onlyCustomerNumbers !== null
@@ -1888,14 +1894,16 @@ class InvoicingPeriodRoute
$draftOverlay['by_collection_id'] ?? [],
$draftOverlay['by_customer_number'] ?? [],
);
$types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) {
return (new invoice_period_flag_service())->applyFlagsToPeriodTypes(
$types,
$dateFrom,
$dateTo,
$onlyCustomerNumbers
);
}, 'invoice_period_flags');
if ($includeInvoicePeriodFlags) {
$types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) {
return (new invoice_period_flag_service())->applyFlagsToPeriodTypes(
$types,
$dateFrom,
$dateTo,
$onlyCustomerNumbers
);
}, 'invoice_period_flags');
}
return [
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
@@ -131,7 +131,7 @@ class departmentSelfserveVehicleConditionsRoute
$lane_id = (int)self::getParameter('lane_id');
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
$lane = $this->assertLaneAccess($user, $lane_id, $has_global);
$lane = $this->assertLaneAccess($user, $lane_id);
$customer_number = null;
if (!$has_global && $has_own) {
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
@@ -193,7 +193,7 @@ class departmentSelfserveVehicleConditionsRoute
$lane_id = (int)self::getParameter('lane_id');
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
$this->assertLaneAccess($user, $lane_id, $has_global);
$this->assertLaneAccess($user, $lane_id);
$customer_number = null;
if (!$has_global && $has_own) {
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
@@ -527,7 +527,7 @@ class departmentSelfserveVehicleConditionsRoute
return $default;
}
private function assertLaneAccess(object $user, int $laneId, bool $hasGlobalPermission): department_lanes_o
private function assertLaneAccess(object $user, int $laneId): department_lanes_o
{
global $response;
@@ -536,11 +536,14 @@ class departmentSelfserveVehicleConditionsRoute
$response->error('Department lane not found', 404);
}
if ($hasGlobalPermission) {
$authorized_department_ids = $user->getGroup()->getDepartments();
if (!in_array((int)$lane->department->value(), $authorized_department_ids, true)) {
$this->forbidDepartmentAccess((int)$lane->department->value());
}
$lane_department_id = (int)$lane->department->value();
$authorized_department_ids = array_values(array_filter(
array_map('intval', (array)$user->getGroup()->getDepartments()),
static fn(int $department_id): bool => $department_id > 0
));
if (!in_array($lane_department_id, $authorized_department_ids, true)) {
$this->forbidDepartmentAccess($lane_department_id);
}
return $lane;
@@ -17,7 +17,7 @@ class departmentsRoute
{
use route_t;
private function buildDepartmentListFilters(departments_o $departments, bool $canListArchived): string
private function buildDepartmentListFilters(departments_o $departments, bool $canListArchived): array
{
global $response;
@@ -41,7 +41,7 @@ class departmentsRoute
$filters['visible'] = 1;
$filters['archived'] = $archived;
return $departments->array_to_filters($filters);
return $filters;
}
private static function isTruthyBooleanValue(mixed $value): bool
+48 -6
View File
@@ -198,7 +198,9 @@ class ordersRoute
$po = $this->resolveOrderPoForBookingDefault(
array_key_exists('po', $data) ? $data['po'] : null,
array_key_exists('po', $data),
$bookingId
$bookingId,
(int)$data['customer_id'],
(int)$data['department_id']
);
$new_data = [
'customer_id' => (int)$data['customer_id'],
@@ -1251,14 +1253,20 @@ class ordersRoute
}
}
private function resolveOrderPoForBookingDefault(mixed $po, bool $poProvided, ?int $bookingId): ?string
private function resolveOrderPoForBookingDefault(
mixed $po,
bool $poProvided,
?int $bookingId,
int $customerNumber,
int $departmentId
): ?string
{
$currentPo = is_scalar($po) || $po === null ? trim((string)$po) : '';
if ($currentPo !== '') {
return $currentPo;
}
$bookingPo = $this->getBookingPoDefault($bookingId);
$bookingPo = $this->getBookingPoDefault($bookingId, $customerNumber, $departmentId);
if ($bookingPo !== null) {
return $bookingPo;
}
@@ -1273,7 +1281,11 @@ class ordersRoute
return;
}
$bookingPo = $this->getBookingPoDefault($bookingId ?? (int)($order->booking_id->value() ?? 0));
$bookingPo = $this->getBookingPoDefault(
$bookingId ?? (int)($order->booking_id->value() ?? 0),
(int)$order->customer_id->value(),
(int)$order->department_id->value()
);
if ($bookingPo === null) {
return;
}
@@ -1281,9 +1293,13 @@ class ordersRoute
$order->po->set($bookingPo);
}
private function getBookingPoDefault(?int $bookingId): ?string
private function getBookingPoDefault(?int $bookingId, int $customerNumber, int $departmentId): ?string
{
if ($bookingId === null || $bookingId <= 0) {
if ($bookingId === null || $bookingId <= 0 || $customerNumber <= 0 || $departmentId <= 0) {
return null;
}
if (!$this->canUseBookingPoDefault($customerNumber, $departmentId)) {
return null;
}
@@ -1293,6 +1309,18 @@ class ordersRoute
return null;
}
if ((int)$booking->customer_number->value() !== $customerNumber) {
return null;
}
if ((int)$booking->department->value() !== $departmentId) {
return null;
}
if (trim((string)($booking->deleted_at->value() ?? '')) !== '') {
return null;
}
$bookingPo = trim((string)($booking->po->value() ?? ''));
return $bookingPo !== '' ? $bookingPo : null;
} catch (\Throwable) {
@@ -1300,6 +1328,20 @@ class ordersRoute
}
}
private function canUseBookingPoDefault(int $customerNumber, int $departmentId): bool
{
try {
$user = (new authentication())->get_user();
if ($user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber) {
return true;
}
return $this->hasDepartmentAccess((string)$departmentId);
} catch (\Throwable) {
return false;
}
}
private function normalizeLegacyEditableFieldPayload(array $data, response $response): array
{
if (!array_key_exists('field', $data) && !array_key_exists('value', $data)) {
@@ -133,6 +133,20 @@ it('does not allow regular department listings to reveal archived departments th
expect($departmentIds)
->toContain($activeDepartment['id'])
->not->toContain($archivedDepartment['id']);
$response = api_client()->get('/departments?filters[name]=NOT%20NULL%2Carchived:1', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$departmentIds = array_map(
static fn(array $department): int => (int)($department['id'] ?? 0),
is_array($response->data()) ? $response->data() : []
);
expect($departmentIds)->not->toContain($archivedDepartment['id']);
});
it('rejects department listing when the permission is missing', function (): void {
@@ -102,6 +102,132 @@ it('creates orders through the orders endpoint', function (): void {
api_fixtures()->cleanupDeleteById('orders', $orderId);
});
it('defaults order PO only from a matching active booking', function (): void {
api_test_covers('POST /orders', 'security');
api_test_covers('PUT /orders', 'security');
$department = api_fixtures()->createDepartment(['name' => 'Order Booking PO Department']);
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Order Booking PO Other Department']);
$customer = api_fixtures()->createUser(['display_name' => 'Order Booking PO Customer']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Order Booking PO Other Customer']);
$cashier = api_fixtures()->createUser(['display_name' => 'Order Booking PO Cashier']);
$matchingBooking = api_fixtures()->createOrderBooking([
'customer_number' => $customer['customer_number'],
'department' => $department['id'],
'po' => 'MATCHING-BOOKING-PO',
]);
$foreignBooking = api_fixtures()->createOrderBooking([
'customer_number' => $otherCustomer['customer_number'],
'department' => $otherDepartment['id'],
'po' => 'FOREIGN-BOOKING-PO',
]);
$deletedBooking = api_fixtures()->createOrderBooking([
'customer_number' => $customer['customer_number'],
'department' => $department['id'],
'po' => 'DELETED-BOOKING-PO',
'deleted_at' => date('Y-m-d H:i:s'),
]);
$session = api_fixtures()->createUserSession(['add_order', 'edit_order'], [
'customer_number' => $customer['customer_number'],
]);
$createResponse = api_client()->post('/orders', [
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'ORDER-BOOKING-PO-MATCH',
'notes' => 'Created with matching booking',
'reg_1' => 'MATCHPO',
'booking_id' => $matchingBooking['id'],
], $session['headers']);
$createResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$matchingOrderId = (int)($createResponse->data()['id'] ?? 0);
expect($createResponse->data()['po'] ?? null)->toBe('MATCHING-BOOKING-PO');
$unauthorizedSession = api_fixtures()->createUserSession(['add_order'], [
'customer_number' => $otherCustomer['customer_number'],
]);
$unauthorizedResponse = api_client()->post('/orders', [
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'ORDER-BOOKING-PO-UNAUTHORIZED',
'notes' => 'Created without booking access',
'reg_1' => 'NOAUTHPO',
'booking_id' => $matchingBooking['id'],
], $unauthorizedSession['headers']);
$unauthorizedResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$unauthorizedOrderId = (int)($unauthorizedResponse->data()['id'] ?? 0);
expect($unauthorizedResponse->data()['po'] ?? null)->toBeNull();
$foreignResponse = api_client()->post('/orders', [
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'ORDER-BOOKING-PO-FOREIGN',
'notes' => 'Created with foreign booking',
'reg_1' => 'FOREIGNPO',
'booking_id' => $foreignBooking['id'],
], $session['headers']);
$foreignResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$foreignOrderId = (int)($foreignResponse->data()['id'] ?? 0);
expect($foreignResponse->data()['po'] ?? null)->toBeNull();
$deletedResponse = api_client()->post('/orders', [
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'ORDER-BOOKING-PO-DELETED',
'notes' => 'Created with deleted booking',
'reg_1' => 'DELETEPO',
'booking_id' => $deletedBooking['id'],
], $session['headers']);
$deletedResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$deletedOrderId = (int)($deletedResponse->data()['id'] ?? 0);
expect($deletedResponse->data()['po'] ?? null)->toBeNull();
$existingOrder = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'ORDER-BOOKING-PO-UPDATE',
'reg_1' => 'UPDATEPO',
]);
api_client()->put('/orders', [
'id' => $existingOrder['id'],
'booking_id' => $foreignBooking['id'],
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$updatedRow = api_fixtures()->fetchRowById('orders', (int)$existingOrder['id']);
expect($updatedRow['po'] ?? null)->toBeNull();
api_fixtures()->cleanupDeleteById('orders', $matchingOrderId);
api_fixtures()->cleanupDeleteById('orders', $unauthorizedOrderId);
api_fixtures()->cleanupDeleteById('orders', $foreignOrderId);
api_fixtures()->cleanupDeleteById('orders', $deletedOrderId);
});
it('rejects invalid order creation requests', function (): void {
api_test_covers('POST /orders', 'failure');
@@ -179,6 +179,59 @@ it('orders reference suggestions by match relevance before context and frequency
expect(array_slice($references, 0, 3))->toBe(['ABC', 'ABC-PREFIX', 'X-ABC-CONTAINS']);
});
it('does not return vehicle reference suggestions outside the authorized department context', function (): void {
api_test_covers('GET /orders/reference-suggestions', 'security');
$authorizedDepartment = api_fixtures()->createDepartment();
$otherDepartment = api_fixtures()->createDepartment();
$authorizedCustomer = api_fixtures()->createUser(['display_name' => 'Authorized Reference Customer']);
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Tenant Reference Customer']);
$cashier = api_fixtures()->createUser(['display_name' => 'Reference Security Cashier']);
api_fixtures()->createOrder([
'customer_id' => $authorizedCustomer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $authorizedDepartment['id'],
'reference' => 'SAFE-DEPARTMENT-REF',
'reg_1' => 'SAFE1',
]);
api_fixtures()->createOrder([
'customer_id' => $otherCustomer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $otherDepartment['id'],
'reference' => 'LEAK-ORDER-REF',
'reg_1' => 'LEAK1',
]);
api_fixtures()->createVehicle([
'customer_id' => $otherCustomer['customer_number'],
'type' => 53,
'reg' => 'LEAK1',
'reference' => 'LEAK-VEHICLE-REF',
]);
$session = api_fixtures()->createUserSession([
'list_orders',
'department_access_' . $authorizedDepartment['id'],
]);
$response = api_client()->get('/orders/reference-suggestions?' . http_build_query([
'search' => 'LEAK',
'department_id' => $authorizedDepartment['id'],
'customer_id' => $otherCustomer['customer_number'],
'reg_1' => 'LEAK1',
]), $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$suggestions = $response->data();
expect($suggestions)->toBeArray();
expect(reference_suggestion_by_reference($suggestions, 'LEAK-VEHICLE-REF'))->toBeNull();
expect(reference_suggestion_by_reference($suggestions, 'LEAK-ORDER-REF'))->toBeNull();
});
it('enforces authentication, list permission, and department access for reference suggestions', function (): void {
api_test_covers('GET /orders/reference-suggestions', 'auth');
@@ -653,6 +653,25 @@ it('does not report a price mismatch when a product-specific discount makes the
expect($flags)->toBe([]);
});
it('does not treat subset primary vehicle product names as equivalent', function (): void {
expect(invoice_period_flag_service_invoke('primaryVehicleProductsMatch', [5, 'Forvogn', 6, 'Forvogn med hænger']))
->toBeFalse()
->and(invoice_period_flag_service_invoke('primaryVehicleProductsMatch', [10, 'Indvendig vask Kassevogn', 11, 'Kassevogn']))
->toBeTrue();
});
it('rebuilds automatic invoice period data on cache misses instead of hiding warnings', function (): void {
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)->toContain('$flags = $this->buildAutomaticFlagsForPeriod($dateFrom, $dateTo);');
expect($content)->toContain('$rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo);');
expect($content)->toContain('cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows)');
expect($content)->not->toContain('enqueue_invoice_period_warming($dateFrom, $dateTo)');
});
it('preloads and caches missing e-conomic discounts before price mismatch detection', function (): void {
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
@@ -45,12 +45,26 @@ it('streams the main period response instead of encoding the full payload at onc
$content = (string)$content;
expect($content)->toContain('private static function streamInvoicingPeriodResponse(array $period): void')
->and($content)->toContain('$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers);')
->and($content)->toContain("$includeInvoicePeriodFlags = $this->hasPermission('list_invoice_period_flags');")
->and($content)->toContain('$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers, $includeInvoicePeriodFlags);')
->and($content)->toContain('self::streamInvoicingPeriodResponse($period);')
->and($content)->not->toContain('$response->success([' . PHP_EOL . ' ...self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers)')
->and($content)->toContain('echo self::jsonFragment($customer);');
});
it('only includes invoice period flags when the list permission is granted', function (): void {
$content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)
->toContain("$includeInvoicePeriodFlags = $this->hasPermission('list_invoice_period_flags');")
->and($content)->toContain('bool $includeInvoicePeriodFlags = false')
->and($content)->toContain('if ($includeInvoicePeriodFlags) {')
->and($content)->toContain('applyFlagsToPeriodTypes(');
});
it('maps batched period transaction rows to the legacy transaction response shape', function (): void {
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
$method = $reflection->getMethod('constructTransactionObjectFromPeriodRow');
@@ -83,6 +83,7 @@ it('normalizes app-specific release gate auto-sync metadata', function (): void
'channel_slug' => 'stable',
'route_slug' => 'master',
'app' => 'api',
'apps' => ['api'],
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
@@ -90,6 +91,11 @@ it('normalizes app-specific release gate auto-sync metadata', function (): void
'auto_sync' => true,
]);
$fullStackGate = $normalizeGate->invoke($manager, [
'channel_slug' => 'stable',
], ['slug' => 'stable']);
expect($fullStackGate['apps'])->toBe(['frontend', 'api']);
expect($appMatches->invoke($manager, ['app' => 'api'], 'api'))->toBeTrue();
expect($appMatches->invoke($manager, ['apps' => ['frontend', 'api']], 'api'))->toBeTrue();
expect($appMatches->invoke($manager, [], 'frontend'))->toBeTrue();
@@ -899,6 +905,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain('createBundle');
expect($manager)->toContain('deployBundle');
expect($manager)->toContain('promoteBundle');
expect($manager)->toContain('if ($apiVersionId !== null)');
expect($manager)->toContain('setChannelBundle');
expect($manager)->toContain('searchAssignmentSubjects');
expect($manager)->toContain('publicAssignmentSubjectSuggestion');
@@ -975,6 +982,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain('channelSlugForRoute');
expect($manager)->toContain('syncChannel');
expect($manager)->toContain('runReleaseTest');
expect($manager)->toContain("'apps' => \$this->releaseTestAppsFromInput(\$input)");
expect($manager)->toContain('releaseTestAppsFromInput');
expect($manager)->toContain('release_operation_runs');
expect($manager)->toContain('active_channel_app_key');
@@ -1349,7 +1357,7 @@ it('resolves release deployment endpoints from manual overrides, URLs, health ch
]))->toBe('https://gateway.example.test/beta/frontend');
});
it('exposes release version git commit metadata for runtime channel cards', function (): void {
it('redacts GitHub access metadata from public release versions', function (): void {
$manager = new release_manager();
$publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion');
$publicVersion->setAccessible(true);
@@ -1367,20 +1375,29 @@ it('exposes release version git commit metadata for runtime channel cards', func
'deployed_url' => null,
'status' => 'active',
'metadata_json' => json_encode([
'commit_mode' => 'specific',
'github_access' => [
'commit' => [
'sha' => 'c0ffee0000001111222233334444555566667777',
'message' => 'Private implementation detail',
'author_name' => 'Release Bot',
'authored_at' => '2026-05-19T10:15:00Z',
],
'commit_authored_at' => '2026-05-19T10:15:00Z',
],
'bundle_member' => true,
]),
'created_at' => '2026-05-19 10:10:00',
'deployed_at' => '2026-05-19 10:20:00',
]);
expect($version['commit_sha'])->toBe('c0ffee0000001111222233334444555566667777');
expect($version['commit']['sha'])->toBe('c0ffee0000001111222233334444555566667777');
expect($version['commit_authored_at'])->toBe('2026-05-19T10:15:00Z');
expect($version['commit'])->toBeNull();
expect($version['commit_authored_at'])->toBeNull();
expect($version['metadata'])->toBe([
'commit_mode' => 'specific',
'bundle_member' => true,
]);
expect($version['deployed_at'])->toBe('2026-05-19 10:20:00');
});
@@ -385,6 +385,30 @@ it('caps AI-driven expanded terms to prevent query amplification', function ():
expect($maxLen)->toBeLessThanOrEqual(64);
});
it('does not expand associations for own-only entity types', function (): void {
$parser = new FakeSystemSearchIntentParser();
$service = new TestableSystemSearchService($parser, [
[[
'entity_type' => 'customers',
'entity_id' => '10',
'title' => 'Acme',
'customer_number' => 1234,
'score' => 80,
]],
[],
]);
$service->search([
'query' => 'acme',
'allowed_types' => ['customers', 'orders'],
'own_only_types' => ['orders'],
'own_customer_number' => 4444,
'include_associations' => true,
]);
expect(count($service->lexicalCalls))->toBe(1);
});
it('expands danish discount wording into lexical discount synonyms', function (): void {
$parser = new FakeSystemSearchIntentParser();
$service = new TestableSystemSearchService($parser, [[
@@ -0,0 +1,58 @@
<?php
function selfserve_eligibility_route_source(): string
{
$route = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php'));
if ($route === false) {
throw new RuntimeException('departmentSelfserveVehicleConditionsRoute.php not found');
}
return $route;
}
function selfserve_eligibility_route_block(string $route, string $method, string $path): string
{
$start = strpos($route, "\$this->{$method}('{$path}'");
if ($start === false) {
throw new RuntimeException("Route block not found: {$method} {$path}");
}
$nextComment = strpos($route, "\n /**", $start + 1);
if ($nextComment === false) {
return substr($route, $start);
}
return substr($route, $start, $nextComment - $start);
}
function selfserve_eligibility_method_block(string $route, string $signature): string
{
$start = strpos($route, $signature);
if ($start === false) {
throw new RuntimeException("Method not found: {$signature}");
}
$nextMethod = strpos($route, "\n private function ", $start + strlen($signature));
if ($nextMethod === false) {
return substr($route, $start);
}
return substr($route, $start, $nextMethod - $start);
}
it('authorizes customer eligibility preview lanes before returning task attachments', function (): void {
$route = selfserve_eligibility_route_source();
$allowedBlock = selfserve_eligibility_route_block($route, 'get', '/department/selfserve/vehicle/allowed');
$assertBlock = selfserve_eligibility_method_block($route, 'private function assertLaneAccess(object $user, int $laneId): department_lanes_o');
expect($allowedBlock)->toContain('$lane = $this->assertLaneAccess($user, $lane_id);')
->and($allowedBlock)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)')
->and(strpos($allowedBlock, '$lane = $this->assertLaneAccess($user, $lane_id);'))
->toBeLessThan(strpos($allowedBlock, 'previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)'));
expect($assertBlock)->toContain('$authorized_department_ids = array_values(array_filter(')
->and($assertBlock)->toContain('array_map(\'intval\', (array)$user->getGroup()->getDepartments())')
->and($assertBlock)->toContain('if (!in_array($lane_department_id, $authorized_department_ids, true))')
->and($assertBlock)->toContain('$this->forbidDepartmentAccess($lane_department_id);')
->and($assertBlock)->not->toContain('if ($hasGlobalPermission)');
});
@@ -0,0 +1,28 @@
<?php
it('locks and marks a self-serve start occupied before opening the entrance relay', function (): void {
$commandTrait = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_command_t.php'));
expect($commandTrait)->not->toBeFalse();
expect($commandTrait)->toContain('set_if_absent_with_expiration');
$startCaseOffset = strpos($commandTrait, 'case selfserve_lane_command::START:');
expect($startCaseOffset)->not->toBeFalse();
$startCase = substr($commandTrait, (int)$startCaseOffset, 3500);
$lockOffset = strpos($startCase, '$start_lock_token = $this->acquireLaneStartCommandLock();');
$occupiedOffset = strpos($startCase, '$this->setLaneStatus(selfserve_lane_status::OCCUPIED);');
$openOffset = strpos($startCase, '$this->openEntrancePortForWashStart();');
$finallyOffset = strpos($startCase, '} finally {');
$releaseOffset = strpos($startCase, '$this->releaseLaneStartCommandLock($start_lock_token);');
expect($lockOffset)->not->toBeFalse()
->and($occupiedOffset)->not->toBeFalse()
->and($openOffset)->not->toBeFalse()
->and($finallyOffset)->not->toBeFalse()
->and($releaseOffset)->not->toBeFalse()
->and($lockOffset)->toBeLessThan($occupiedOffset)
->and($occupiedOffset)->toBeLessThan($openOffset)
->and($openOffset)->toBeLessThan($finallyOffset)
->and($finallyOffset)->toBeLessThan($releaseOffset);
});
@@ -1321,7 +1321,7 @@ it('truncates path outcome projection when the state cap is reached', function (
->and($projection['warnings'][0])->toContain('truncated at 2 explored state');
});
it('returns complete terminal path results for wide question trees and reports progress', function (): void {
it('applies default caps for wide question trees and reports progress', function (): void {
$service = selfserve_studio_graph_without_constructor();
$simulate = function (array $overrides): array {
$answers = [];
@@ -1380,17 +1380,18 @@ it('returns complete terminal path results for wide question trees and reports p
},
]);
expect($projection['truncated'])->toBeFalse()
->and($projection['summary']['state_count'])->toBe(8191)
expect($projection['truncated'])->toBeTrue()
->and($projection['summary']['state_count'])->toBe(2048)
->and($projection['summary']['question_count'])->toBe(12)
->and($projection['summary']['terminal_path_count'])->toBe(4096)
->and($projection['summary']['terminal_path_count'])->toBe(1023)
->and($projection['summary']['outcome_count'])->toBe(2)
->and($projection['summary']['path_sample_count'])->toBe(4096)
->and($projection['summary']['path_sample_count'])->toBe(200)
->and($projection['progress']['complete'])->toBeTrue()
->and($projection['progress']['percent'])->toBe(100)
->and($projection['paths'])->toHaveCount(4096)
->and($projection['paths'])->toHaveCount(200)
->and($projection['paths'][0]['answers'])->toHaveCount(12)
->and($projection['paths'][0]['result'])->toBe('Allowed')
->and($projection['warnings'][0])->toContain('truncated at 2048 explored state')
->and($progressEvents)->not->toBeEmpty()
->and($progressEvents[0]['terminal_path_count'])->toBeGreaterThan(0)
->and($progressEvents[0]['path_sample_count'])->toBeGreaterThan(0);
+2 -2
View File
@@ -27,7 +27,7 @@ Authorization: Bearer {{ADMIN_BEARER_TOKEN}}
POST https://api.truckwash.io:4433/collected-invoices/move-multiple
Accept: application/json
Content-Type: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
Authorization: Bearer {{ADMIN_BEARER_TOKEN}}
{
"order_ids": [54518, 48782, 48744],
@@ -39,7 +39,7 @@ Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb206
POST https://api.truckwash.io:4433/collected-invoices/move-multiple/registration-numbers
Accept: application/json
Content-Type: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
Authorization: Bearer {{ADMIN_BEARER_TOKEN}}
{
"registration_numbers": ["DC29870",