Add Bird gate call flow unit tests, self-serve machine wash minutes config, relay sync improvements, and OpenAPI updates. Refactor Bird call handling with terminal status detection and timeout normalization.

This commit is contained in:
Jeppe Bundgaard
2026-03-26 13:16:18 +01:00
parent a4654398d0
commit 95063d2a70
20 changed files with 731 additions and 77 deletions
@@ -0,0 +1,29 @@
<?php
namespace modules\selfserve\config;
use Exception;
use traits\module_config_variable;
class selfserve_machine_wash_minutes_included_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'selfserve',
'machine_wash_minutes_included',
'int',
true,
null,
'The number of machine wash minutes included before minute-based billing starts',
'20',
false,
20
);
}
}
@@ -3,8 +3,10 @@
namespace modules\selfserve;
require_once WD . '/modules/selfserve/config/selfserve_enabled_c.php';
require_once WD . '/modules/selfserve/config/selfserve_minute_product_c.php';
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php';
use modules\selfserve\config\selfserve_enabled_c;
use modules\selfserve\config\selfserve_machine_wash_minutes_included_c;
use modules\selfserve\config\selfserve_minute_product_c;
use traits\module_config_t;
@@ -22,15 +24,22 @@ class selfserve_c
* @var selfserve_minute_product_c $minute_product
*/
public selfserve_minute_product_c $minute_product;
/**
* Included machine wash minutes before minute-based self-serve billing starts
* @var selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included
*/
public selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included;
public function __construct()
{
$this->setupConfig('selfserve');
$this->allowUpdate([
selfserve_enabled_c::class,
selfserve_minute_product_c::class
selfserve_minute_product_c::class,
selfserve_machine_wash_minutes_included_c::class
]);
$this->enabled = new selfserve_enabled_c();
$this->minute_product = new selfserve_minute_product_c();
$this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c();
}
}
}
@@ -73,6 +73,9 @@ trait selfserve_lane_command_t
}
try {
if (method_exists($this, 'ensureInvoiceOrderContextForVehicleProduct')) {
$this->ensureInvoiceOrderContextForVehicleProduct();
}
$this->addVehicleTypeProductToLastInvoiceOrder();
} catch (\Throwable) {
// Never block STOP on optional order-line enrichment.
@@ -206,7 +209,8 @@ trait selfserve_lane_command_t
try {
$this->openDepartmentGateForCommand($gate);
} catch (\Throwable $e) {
throw new \RuntimeException('Failed to open property ' . $commandLabel . ' gate: ' . $e->getMessage(), 0, $e);
$this->reportPropertyGateCommandFailure($commandLabel, $e);
throw new \RuntimeException($this->propertyGateCommandFailureMessage($isAccessGate), 0, $e);
}
}
@@ -223,6 +227,23 @@ trait selfserve_lane_command_t
$gate->openGate();
}
protected function propertyGateCommandFailureMessage(bool $isAccessGate): string
{
return $isAccessGate
? 'Failed to open property access gate.'
: 'Failed to open property exit gate.';
}
protected function reportPropertyGateCommandFailure(string $commandLabel, \Throwable $e): void
{
try {
$laneId = isset($this->id) ? (string)$this->id : 'unknown';
error_log('Self-serve property ' . $commandLabel . ' gate open failed for lane ' . $laneId . ': ' . $e->getMessage());
} catch (\Throwable) {
// Never block API flow on diagnostics logging.
}
}
/**
* Execute a command on a self-serve lane
* @param selfserve_lane_command $command The command to execute
@@ -22,6 +22,11 @@ trait selfserve_lane_invoice_t
* @var int|null $minute_billing_product_id
*/
public ?int $minute_billing_product_id = null;
/**
* Included machine wash minutes before minute billing starts.
* @var int|null $machine_wash_minutes_included
*/
public ?int $machine_wash_minutes_included = null;
/**
* Get the minute billing product ID
* @return int|null The product ID for minute-based billing, or null if not set
@@ -38,11 +43,56 @@ trait selfserve_lane_invoice_t
return $this->minute_billing_product_id;
}
/**
* Get included machine wash minutes before minute billing starts.
*/
public function getMachineWashMinutesIncluded(): int
{
$included_minutes = selfserve::getInstance()
->config
->machine_wash_minutes_included
->getVariableValue();
if (is_numeric($included_minutes)) {
$this->machine_wash_minutes_included = (int)$included_minutes;
}
if ($this->machine_wash_minutes_included === null || $this->machine_wash_minutes_included < 0) {
$this->machine_wash_minutes_included = 0;
}
return $this->machine_wash_minutes_included;
}
public function getLastInvoiceOrderId(): ?int
{
return $this->last_invoice_order_id;
}
/**
* Ensure there is an invoice order context for optional STOP follow-up lines
* (for example vehicle-type product) even when minute billing quantity is zero.
*/
public function ensureInvoiceOrderContextForVehicleProduct(): bool
{
if (!empty($this->last_invoice_order_id)) {
return true;
}
if (empty($this->id)) {
return false;
}
if ($this->getLaneStatus() !== selfserve_lane_status::OCCUPIED) {
return false;
}
if (empty($this->getCustomerNumber()) || empty($this->getLicensePlate())) {
return false;
}
$this->createInvoiceOrderContext();
return !empty($this->last_invoice_order_id);
}
/**
* Invoice for minute-based billing
* @return bool True on success, false on failure
@@ -50,36 +100,23 @@ trait selfserve_lane_invoice_t
*/
public function invoice(): bool
{
$this->last_invoice_order_id = null;
if (empty($this->id)) throw new \Exception("Lane ID is not set.");
if ($this->getLaneStatus() !== selfserve_lane_status::OCCUPIED) throw new \Exception("Lane ID {$this->id} is not occupied; cannot invoice.");
if (empty($this->getCustomerNumber())) throw new \Exception("Customer number is not set for lane ID {$this->id}.");
if (empty($this->getLicensePlate())) throw new \Exception("License plate is not set for lane ID {$this->id}.");
if (empty($product_id = $this->getMinuteBillingProductId())) throw new \Exception("Minute billing product ID is not set.");
// Calculate minutes used
$minutes = $this->getElapsedWashTime() / 60; // Convert seconds to minutes
$minutes = (int)ceil($minutes); // Round up to nearest whole minute
if ($minutes <= 0) throw new \Exception("No minutes to bill for lane ID {$this->id}.");
$amount = $minutes; // Assuming 1 unit per minute, adjust as needed
// Subtract any free minutes if applicable (If the machine was triggered - If the customer pays for the primary vehicle product type) TODO: Implement free minute logic if needed
// Create invoice order
$order = (new orders_o())->add(
$this->getCustomerNumber(),
self::INVOICE_SYSTEM_USER_ID,
'',
'',
(int)$this->department_lane->department->value(),
(string)$this->getLicensePlate()
);
$order->lane->set($this->id);
$this->last_invoice_order_id = (int)$order->id;
// Add product to order
$order_items = new order_items_o();
$order_items->addItemToOrder(
$order->id,
$product_id,
self::INVOICE_SYSTEM_USER_ID,
$amount,
);
$elapsed_minutes = $this->calculateElapsedMinutesForBilling($this->getElapsedWashTime());
$included_minutes = $this->getMachineWashMinutesIncluded();
$billable_minutes = $this->calculateBillableMinutes($elapsed_minutes, $included_minutes);
if ($billable_minutes <= 0) {
return true;
}
$order = $this->createInvoiceOrderContext();
$this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
return true;
}
@@ -123,4 +160,49 @@ trait selfserve_lane_invoice_t
$product_id = (int)$vehicle->type->value();
return $product_id > 0 ? $product_id : null;
}
protected function calculateElapsedMinutesForBilling(?int $elapsed_wash_time_seconds): int
{
if ($elapsed_wash_time_seconds === null || $elapsed_wash_time_seconds <= 0) {
return 0;
}
return (int)ceil($elapsed_wash_time_seconds / 60);
}
protected function calculateBillableMinutes(int $elapsed_minutes, int $included_minutes): int
{
if ($elapsed_minutes <= 0) {
return 0;
}
$included_minutes = max(0, $included_minutes);
return max(0, $elapsed_minutes - $included_minutes);
}
protected function createInvoiceOrderContext(): orders_o
{
$order = (new orders_o())->add(
$this->getCustomerNumber(),
self::INVOICE_SYSTEM_USER_ID,
'',
'',
(int)$this->department_lane->department->value(),
(string)$this->getLicensePlate()
);
$order->lane->set($this->id);
$this->last_invoice_order_id = (int)$order->id;
return $order;
}
protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): void
{
(new order_items_o())->addItemToOrder(
$order_id,
$product_id,
self::INVOICE_SYSTEM_USER_ID,
$quantity,
);
}
}
@@ -182,6 +182,14 @@ trait selfserve_lane_relay_controller_t
$machineVisible = in_array(selfserve_lane_services::MACHINE->name, $normalizedServices, true);
$relayAction = 'noop';
if (!$this->isDepartmentSelfServeRelayMutationsEnabled()) {
return [
'machine_visible' => $machineVisible,
'relay_action' => 'noop_selfserve_disabled',
'relay_target_on' => $machineVisible,
];
}
if (!$this->hasConfiguredRelay(selfserve_lane_relay::MACHINE)) {
return [
'machine_visible' => $machineVisible,
@@ -238,6 +246,19 @@ trait selfserve_lane_relay_controller_t
}
}
private function isDepartmentSelfServeRelayMutationsEnabled(): bool
{
if (!method_exists($this, 'isDepartmentSelfServeEnabled')) {
return true;
}
try {
return $this->isDepartmentSelfServeEnabled() === true;
} catch (\Throwable) {
return false;
}
}
/**
* Resolve relay ID for the current lane.
* @param selfserve_lane_relay $relay
@@ -854,6 +875,10 @@ trait selfserve_lane_relay_controller_t
*/
private function sendRelaySwitchCommand(selfserve_lane_relay $relay, bool $on, ?int $duration = null): bool
{
if (!$this->isDepartmentSelfServeRelayMutationsEnabled()) {
throw new \Exception("Cannot change relay state: Self-serve is not enabled for this lane's department.");
}
$relay_id = $this->getRelayId($relay);
$payload = [
'id' => $relay_id,