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:
+29
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user