Merge origin/master and resolve invoice_period_flag_service conflict
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user