diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php index ae5cbfad..d0ab3daf 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php @@ -46,6 +46,90 @@ trait selfserve_lane_command_t return false; } } + + /** + * Resolve whether the program selector relay currently reports ON. + * Fail-closed to false when relay status cannot be read. + */ + protected function isProgramSelectorRelayOnForStop(): bool + { + try { + $status = $this->getMachineProgramPickerRelayStatus(); + return (bool)($status['on'] ?? false); + } catch (\Throwable) { + return false; + } + } + + /** + * Append the lane vehicle-type product to the current invoice order when requested. + */ + protected function addVehicleTypeProductToInvoiceIfNeeded(bool $should_add): void + { + if (!$should_add || !method_exists($this, 'addVehicleTypeProductToLastInvoiceOrder')) { + return; + } + + try { + $this->addVehicleTypeProductToLastInvoiceOrder(); + } catch (\Throwable) { + // Never block STOP on optional order-line enrichment. + } + } + + /** + * Disable relays after STOP in deterministic order: + * cleaner -> machine -> program selector. + */ + protected function turnOffRelaysAfterStop(): void + { + foreach ([ + selfserve_lane_relay::MACHINE_CLEANER, + selfserve_lane_relay::MACHINE, + selfserve_lane_relay::MACHINE_PROGRAM_PICKER, + ] as $relay) { + if (!$this->isRelayConfiguredForStop($relay)) { + continue; + } + try { + $this->turnOffRelay($relay); + } catch (\Throwable) { + // Best effort relay shutdown; never block STOP. + } + } + } + + protected function isRelayConfiguredForStop(selfserve_lane_relay $relay): bool + { + if (empty($this->department_lane)) { + return false; + } + + $relay_id = match ($relay) { + selfserve_lane_relay::MACHINE => (string)$this->department_lane->relay_machine_id->value(), + selfserve_lane_relay::MACHINE_PROGRAM_PICKER => (string)$this->department_lane->relay_machine_program_picker_id->value(), + selfserve_lane_relay::MACHINE_CLEANER => (string)$this->department_lane->relay_machine_cleaner_id->value(), + }; + + return trim($relay_id) !== ''; + } + + /** + * Finalize any active self-serve wash session before resetting lane state. + */ + protected function completeLatestSessionForStop(): void + { + try { + (new \modules\selfserve\classes\selfserve_wash_flow())->completeLatestSessionForLane( + $this->id, + $this->getLicensePlate() ?: null, + $this->getCustomerNumber() ?: null, + method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null + ); + } catch (\Throwable) { + // Session completion must not block STOP flow. + } + } /** * Execute a command on a self-serve lane * @param selfserve_lane_command $command The command to execute @@ -117,34 +201,20 @@ trait selfserve_lane_command_t if (($this->getCustomerNumber() !== $arguments->customer_number) && !$this->isBypassCustomerNumberValidation()) { throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number); } + // Snapshot program selector state before relay shutdown. + $program_selector_on = $this->isProgramSelectorRelayOnForStop(); // Invoice the customer $this->invoice(); + // If program selector was ON, add vehicle-type product to the self-serve order. + $this->addVehicleTypeProductToInvoiceIfNeeded($program_selector_on); // Open the exit port $this->open(selfserve_lane_port::EXIT); - // If department has enabled self-serve, turn off the machine relay when stopping - try { - if ($this->isDepartmentSelfServeEnabled()) { - // Only attempt if a machine relay is configured for this lane - if (!empty($this->department_lane) && !empty($this->department_lane->relay_machine_id) && !empty($this->department_lane->relay_machine_id->value())) { - $this->turnOffRelay(selfserve_lane_relay::MACHINE); - } - } - } catch (\Throwable $e) { - // Swallow relay control errors to not block STOP flow - } + // Turn off configured relays in deterministic order. + $this->turnOffRelaysAfterStop(); // Log the lane stop event $this->logLaneAction(selfserve_lane_log_action::STOP_WASH); // Finalize any active self-serve wash session before resetting lane state. - try { - (new \modules\selfserve\classes\selfserve_wash_flow())->completeLatestSessionForLane( - $this->id, - $this->getLicensePlate() ?: null, - $this->getCustomerNumber() ?: null, - method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null - ); - } catch (\Throwable $e) { - // Session completion must not block STOP flow. - } + $this->completeLatestSessionForStop(); // Reset the lane self::execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments()); break; diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php index d54ee565..33845d26 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php @@ -8,11 +8,13 @@ use classes\selfserve; use Exception; use modules\selfserve\classes\selfserve_lane; use modules\selfserve\helpers\selfserve_lane_status; +use objects\customer_vehicles_o; use objects\order_items_o; use objects\orders_o; trait selfserve_lane_invoice_t { + private const INVOICE_SYSTEM_USER_ID = 2285; public ?int $last_invoice_order_id = null; /** @@ -61,7 +63,7 @@ trait selfserve_lane_invoice_t // Create invoice order $order = (new orders_o())->add( $this->getCustomerNumber(), - 2285, // System User ID + self::INVOICE_SYSTEM_USER_ID, '', '', (int)$this->department_lane->department->value(), @@ -74,9 +76,50 @@ trait selfserve_lane_invoice_t $order_items->addItemToOrder( $order->id, $product_id, - 2285, // System User ID + self::INVOICE_SYSTEM_USER_ID, $amount, ); return true; } + + /** + * Add the current vehicle type product as a single order line on the latest invoice order. + * Returns true when the line was added, false when vehicle/product context was unavailable. + */ + public function addVehicleTypeProductToLastInvoiceOrder(): bool + { + if (empty($this->last_invoice_order_id)) { + return false; + } + + $vehicle_type_product_id = $this->resolveVehicleTypeProductIdForInvoice(); + if ($vehicle_type_product_id === null || $vehicle_type_product_id <= 0) { + return false; + } + + (new order_items_o())->addItemToOrder( + (int)$this->last_invoice_order_id, + $vehicle_type_product_id, + self::INVOICE_SYSTEM_USER_ID, + 1 + ); + + return true; + } + + protected function resolveVehicleTypeProductIdForInvoice(): ?int + { + $license_plate = trim((string)$this->getLicensePlate()); + if ($license_plate === '') { + return null; + } + + $vehicle = (new customer_vehicles_o())->selectByPlate(selfserve::standardize_registration($license_plate)); + if (!$vehicle->exists()) { + return null; + } + + $product_id = (int)$vehicle->type->value(); + return $product_id > 0 ? $product_id : null; + } } diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php new file mode 100644 index 00000000..960a418c --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php @@ -0,0 +1,215 @@ +value; + } +} + +class SelfserveLaneCommandDepartmentLaneFake +{ + public SelfserveLaneCommandValueFake $relay_machine_id; + public SelfserveLaneCommandValueFake $relay_machine_program_picker_id; + public SelfserveLaneCommandValueFake $relay_machine_cleaner_id; + + public function __construct(string $machineRelayId, string $programRelayId, string $cleanerRelayId) + { + $this->relay_machine_id = new SelfserveLaneCommandValueFake($machineRelayId); + $this->relay_machine_program_picker_id = new SelfserveLaneCommandValueFake($programRelayId); + $this->relay_machine_cleaner_id = new SelfserveLaneCommandValueFake($cleanerRelayId); + } +} + +class SelfserveLaneStopFlowHarness +{ + use selfserve_lane_command_t; + + public const DEFAULT_WASH_START_TIME = 0; + public const DEFAULT_CUSTOMER_NUMBER = 0; + public const DEFAULT_LICENSE_PLATE = ''; + + public int $id = 77; + public object $department_lane; + public int $invoiceCalls = 0; + public int $vehicleTypeProductAddCalls = 0; + /** @var selfserve_lane_port[] */ + public array $openedPorts = []; + /** @var selfserve_lane_relay[] */ + public array $turnedOffRelays = []; + + private selfserve_lane_status $laneStatus; + private selfserve_lane_mode $laneMode; + private selfserve_lane_state $laneState; + private int $customerNumber = 1234; + private string $licensePlate = 'AB12345'; + private int $washStartTime = 120; + private ?int $reservationStartTime = null; + private bool $bypassCustomerValidation = false; + private bool $programSelectorOn; + + public function __construct( + bool $programSelectorOn, + string $machineRelayId = 'relay-machine', + string $programRelayId = 'relay-program', + string $cleanerRelayId = 'relay-cleaner' + ) { + $this->programSelectorOn = $programSelectorOn; + $this->department_lane = new SelfserveLaneCommandDepartmentLaneFake( + $machineRelayId, + $programRelayId, + $cleanerRelayId + ); + $this->laneStatus = selfserve_lane_status::OCCUPIED; + $this->laneMode = selfserve_lane_mode::MANUAL; + $this->laneState = selfserve_lane_state::IN_WASH; + } + + public function getLaneStatus(): selfserve_lane_status + { + return $this->laneStatus; + } + + public function setLaneStatus(selfserve_lane_status $status): void + { + $this->laneStatus = $status; + } + + public function setLaneMode(selfserve_lane_mode $mode): void + { + $this->laneMode = $mode; + } + + public function setLaneState(selfserve_lane_state $state): void + { + $this->laneState = $state; + } + + public function setWashStartTime(?int $time): void + { + $this->washStartTime = $time ?? self::DEFAULT_WASH_START_TIME; + } + + public function setCustomerNumber(?int $customerNumber): void + { + $this->customerNumber = $customerNumber ?? self::DEFAULT_CUSTOMER_NUMBER; + } + + public function getCustomerNumber(): int + { + return $this->customerNumber; + } + + public function setLicensePlate(?string $licensePlate): void + { + $this->licensePlate = $licensePlate ?? self::DEFAULT_LICENSE_PLATE; + } + + public function getLicensePlate(): string + { + return $this->licensePlate; + } + + public function setReservationStartTime(?int $reservationStartTime): void + { + $this->reservationStartTime = $reservationStartTime; + } + + public function isBypassCustomerNumberValidation(): bool + { + return $this->bypassCustomerValidation; + } + + public function invoice(): bool + { + $this->invoiceCalls++; + return true; + } + + public function addVehicleTypeProductToLastInvoiceOrder(): bool + { + $this->vehicleTypeProductAddCalls++; + return true; + } + + public function getMachineProgramPickerRelayStatus(): array + { + return ['on' => $this->programSelectorOn]; + } + + public function open(selfserve_lane_port $port): bool + { + $this->openedPorts[] = $port; + return true; + } + + public function turnOffRelay(selfserve_lane_relay $relay): bool + { + $this->turnedOffRelays[] = $relay; + return true; + } + + public function logLaneAction(...$args): void {} + + protected function completeLatestSessionForStop(): void + { + // No-op in unit tests. + } +} + +it('adds vehicle type product on STOP when program selector is on, then turns off cleaner machine and selector relays', function (): void { + $lane = new SelfserveLaneStopFlowHarness(programSelectorOn: true); + $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); + + $lane->execute(selfserve_lane_command::STOP, $args); + + expect($lane->invoiceCalls)->toBe(1); + expect($lane->vehicleTypeProductAddCalls)->toBe(1); + expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]); + expect($lane->turnedOffRelays)->toBe([ + selfserve_lane_relay::MACHINE_CLEANER, + selfserve_lane_relay::MACHINE, + selfserve_lane_relay::MACHINE_PROGRAM_PICKER, + ]); + expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE); +}); + +it('skips vehicle type product add when program selector is off and only disables configured relays', function (): void { + $lane = new SelfserveLaneStopFlowHarness( + programSelectorOn: false, + machineRelayId: 'relay-machine', + programRelayId: 'relay-program', + cleanerRelayId: '' + ); + $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); + + $lane->execute(selfserve_lane_command::STOP, $args); + + expect($lane->invoiceCalls)->toBe(1); + expect($lane->vehicleTypeProductAddCalls)->toBe(0); + expect($lane->turnedOffRelays)->toBe([ + selfserve_lane_relay::MACHINE, + selfserve_lane_relay::MACHINE_PROGRAM_PICKER, + ]); +});