diff --git a/openapi.yaml b/openapi.yaml index a92c0d9e..651a693d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6165,6 +6165,34 @@ paths: '503': { $ref: '#/components/responses/ServiceUnavailable' } '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/monitor: + get: + tags: + - Invoices + summary: Monitor current-user visible collected-invoice e-conomic transfer queue jobs + operationId: monitorCollectedInvoiceEconomicQueueJobs + parameters: + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + responses: + '200': + description: Queue monitor state retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueMonitorResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/status: get: tags: @@ -6224,6 +6252,57 @@ paths: '503': { $ref: '#/components/responses/ServiceUnavailable' } '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/dismiss: + post: + tags: + - Invoices + summary: Clear one completed or failed collected-invoice queue job for the current user + operationId: dismissCollectedInvoiceEconomicQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job cleared + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueDismissResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/dismiss-terminal: + post: + tags: + - Invoices + summary: Clear all visible completed or failed collected-invoice queue jobs for the current user + operationId: dismissCollectedInvoiceEconomicTerminalQueueJobs + responses: + '200': + description: Terminal queue jobs cleared + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueDismissTerminalResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/run: post: tags: @@ -13881,6 +13960,140 @@ components: - meta - includes + EconomicTransferQueueMonitorResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + jobs: + type: array + items: + $ref: '#/components/schemas/EconomicTransferQueueJob' + counts: + type: object + properties: + queued: + type: integer + minimum: 0 + in_progress: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + completed: + type: integer + minimum: 0 + total: + type: integer + minimum: 0 + required: + - queued + - in_progress + - failed + - completed + - total + progress_percent: + type: integer + minimum: 0 + maximum: 100 + limit: + type: integer + minimum: 1 + maximum: 100 + required: + - jobs + - counts + - progress_percent + - limit + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueDismissResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + job: + $ref: '#/components/schemas/EconomicTransferQueueJob' + required: + - message + - job + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueDismissTerminalResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + dismissed_count: + type: integer + minimum: 0 + required: + - message + - dismissed_count + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + CollectedInvoiceEconomicCompareResponse: type: object description: Result of comparing a collected invoice with its E-conomic counterpart diff --git a/services/nginx/app/classes/economic_transfer_queue.php b/services/nginx/app/classes/economic_transfer_queue.php index 4ccc476a..48392921 100644 --- a/services/nginx/app/classes/economic_transfer_queue.php +++ b/services/nginx/app/classes/economic_transfer_queue.php @@ -154,6 +154,132 @@ class economic_transfer_queue return max(0, (int)$row['total']); } + public function listMonitorJobsForUser(int $user_id, int $limit = 50, ?string $transfer_type = null): array + { + global $db; + + $user_id = max(0, $user_id); + $limit = max(1, min(100, $limit)); + try { + $normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== '' + ? $this->validateTransferType($transfer_type) + : null; + } catch (Exception) { + return []; + } + + $transfer_condition = ''; + if ($normalized_transfer_type !== null) { + $transfer_condition = "AND q.transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'"; + } + + $sql = "SELECT q.* + FROM economic_transfer_queue_jobs q + LEFT JOIN economic_transfer_queue_job_dismissals d + ON d.queue_job_id = q.id + AND d.user_id = $user_id + AND d.dismissed_status = q.status + WHERE 1 = 1 + $transfer_condition + AND ( + q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') + OR d.queue_job_id IS NULL + ) + ORDER BY + CASE WHEN q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') THEN 0 ELSE 1 END, + q.id DESC + LIMIT $limit"; + $result = $db->query($sql); + if (!$result instanceof mysqli_result) { + return []; + } + + $jobs = []; + while ($row = $result->fetch_assoc()) { + $jobs[] = $this->normalizeJobRow($row); + } + return $jobs; + } + + /** + * @throws Exception + */ + public function dismissTerminalJobForUser(int $job_id, int $user_id): array + { + global $db; + + $job_id = max(0, $job_id); + $user_id = max(0, $user_id); + if ($job_id < 1 || $user_id < 1) { + throw new Exception('Queue job and user are required'); + } + + $job = $this->getJobById($job_id); + if ($job === null) { + throw new Exception('Queue job not found'); + } + + $status = strtoupper((string)($job['status'] ?? '')); + if (!in_array($status, [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) { + throw new Exception('Only completed or failed queue jobs can be dismissed'); + } + + $stmt = $db->prepare( + "INSERT INTO economic_transfer_queue_job_dismissals (queue_job_id, user_id, dismissed_status, dismissed_at) + VALUES (?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()" + ); + if (!$stmt) { + throw new Exception('Failed to prepare queue dismissal statement'); + } + + $stmt->bind_param('iis', $job_id, $user_id, $status); + if (!$stmt->execute()) { + $stmt->close(); + throw new Exception('Failed to dismiss queue job'); + } + $stmt->close(); + + return $job; + } + + public function dismissTerminalJobsForUser(int $user_id, ?string $transfer_type = null): int + { + global $db; + + $user_id = max(0, $user_id); + if ($user_id < 1) { + return 0; + } + + try { + $normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== '' + ? $this->validateTransferType($transfer_type) + : null; + } catch (Exception) { + return 0; + } + + $transfer_condition = ''; + if ($normalized_transfer_type !== null) { + $transfer_condition = "AND q.transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'"; + } + + $sql = "INSERT INTO economic_transfer_queue_job_dismissals (queue_job_id, user_id, dismissed_status, dismissed_at) + SELECT q.id, $user_id, q.status, NOW() + FROM economic_transfer_queue_jobs q + LEFT JOIN economic_transfer_queue_job_dismissals d + ON d.queue_job_id = q.id + AND d.user_id = $user_id + AND d.dismissed_status = q.status + WHERE q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "') + $transfer_condition + AND d.queue_job_id IS NULL + ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()"; + $db->query($sql); + return max(0, (int)($db->affected_rows ?? 0)); + } + /** * @throws Exception */ @@ -193,6 +319,8 @@ class economic_transfer_queue throw new Exception('Failed to retry queue job'); } + $this->clearDismissalsForJob($job_id); + $job = $this->getJobById($job_id); if ($job === null) { throw new Exception('Retry updated job could not be loaded'); @@ -480,6 +608,18 @@ class economic_transfer_queue ]; } + private function clearDismissalsForJob(int $job_id): void + { + global $db; + + $job_id = max(0, $job_id); + if ($job_id < 1) { + return; + } + + $db->query("DELETE FROM economic_transfer_queue_job_dismissals WHERE queue_job_id = $job_id"); + } + /** * Release jobs stuck in PROCESSING due to crashes or killed workers. */ diff --git a/services/nginx/app/classes/economic_transfer_queue_details_summary.php b/services/nginx/app/classes/economic_transfer_queue_details_summary.php index 67bf0947..f0fbaae6 100644 --- a/services/nginx/app/classes/economic_transfer_queue_details_summary.php +++ b/services/nginx/app/classes/economic_transfer_queue_details_summary.php @@ -27,12 +27,20 @@ class economic_transfer_queue_details_summary 'customer_number' => self::toPositiveInt( $result['customer_number'] ?? $result['user']['customer_number'] + ?? $payload['customer_number'] + ?? $payload['customer']['customer_number'] ?? null ), 'name' => self::toNonEmptyString( $result['customer_name'] + ?? $result['user']['customer_name'] + ?? $result['user']['display_name'] ?? $result['user']['name'] ?? $result['user']['company_name'] + ?? $payload['customer_name'] + ?? $payload['customer']['customer_name'] + ?? $payload['customer']['display_name'] + ?? $payload['customer']['name'] ?? null ), ], diff --git a/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php b/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php index 1c473a64..c0abfc56 100644 --- a/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php +++ b/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php @@ -42,6 +42,18 @@ class economic_transfer_queue_schema_bootstrap ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" ); + $db->query( + "CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_dismissals ( + queue_job_id BIGINT UNSIGNED NOT NULL, + user_id INT NOT NULL, + dismissed_status VARCHAR(32) NOT NULL, + dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (queue_job_id, user_id), + INDEX idx_economic_transfer_queue_job_dismissals_user_status (user_id, dismissed_status), + INDEX idx_economic_transfer_queue_job_dismissals_job (queue_job_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + self::$initialized = true; } } diff --git a/services/nginx/app/classes/order_reference_suggestions_service.php b/services/nginx/app/classes/order_reference_suggestions_service.php new file mode 100644 index 00000000..c0daf1a9 --- /dev/null +++ b/services/nginx/app/classes/order_reference_suggestions_service.php @@ -0,0 +1,517 @@ + + */ + private array $columnExistsCache = []; + + /** + * @param array{ + * search?: mixed, + * department_id?: mixed, + * customer_id?: mixed, + * reg_1?: mixed, + * reg_2?: mixed, + * reg_3?: mixed, + * limit?: mixed + * } $criteria + * @return array> + */ + public function suggest(array $criteria): array + { + $departmentId = $this->toPositiveInt($criteria['department_id'] ?? null); + if ($departmentId === null) { + return []; + } + + $search = $this->normalizeText($criteria['search'] ?? ''); + $customerId = $this->toPositiveInt($criteria['customer_id'] ?? null); + $plates = $this->normalizePlates([ + $criteria['reg_1'] ?? '', + $criteria['reg_2'] ?? '', + $criteria['reg_3'] ?? '', + ]); + $limit = $this->clampLimit($criteria['limit'] ?? self::DEFAULT_LIMIT); + + $rows = [ + ...$this->fetchBookingRows($departmentId, $search), + ...$this->fetchOrderRows($departmentId, $search), + ...$this->fetchVehicleRows($customerId, $plates, $search), + ]; + + $suggestions = $this->aggregateRows($rows, $search, $customerId, $plates); + usort($suggestions, [$this, 'sortSuggestions']); + + return array_slice($suggestions, 0, $limit); + } + + /** + * @return array> + */ + private function fetchBookingRows(int $departmentId, string $search): array + { + $where = [ + 'department = :department_id', + 'reference IS NOT NULL', + "TRIM(reference) <> ''", + ]; + if ($this->tableHasColumn('order_bookings', 'deleted_at')) { + array_unshift($where, 'deleted_at IS NULL'); + } + $params = ['department_id' => $departmentId]; + + if ($search !== '') { + $where[] = 'LOWER(reference) LIKE :search'; + $params['search'] = '%' . $this->lower($search) . '%'; + } + + $sql = "SELECT + 'booking' AS source, + id AS origin_id, + TRIM(reference) AS reference, + datetime AS source_created_at, + datetime AS used_at, + customer_number AS customer_id, + department AS department_id, + reg_1, + reg_2, + reg_3 + FROM order_bookings + WHERE " . implode(' AND ', $where) . " + ORDER BY datetime DESC, id DESC + LIMIT :source_limit"; + + return $this->fetchRows($sql, $params); + } + + /** + * @return array> + */ + private function fetchOrderRows(int $departmentId, string $search): array + { + $where = [ + 'department_id = :department_id', + 'reference IS NOT NULL', + "TRIM(reference) <> ''", + ]; + if ($this->tableHasColumn('orders', 'deleted_at')) { + array_unshift($where, 'deleted_at IS NULL'); + } + $params = ['department_id' => $departmentId]; + + if ($search !== '') { + $where[] = 'LOWER(reference) LIKE :search'; + $params['search'] = '%' . $this->lower($search) . '%'; + } + + $sql = "SELECT + 'order' AS source, + id AS origin_id, + TRIM(reference) AS reference, + created_at AS source_created_at, + created_at AS used_at, + customer_id, + department_id, + reg_1, + reg_2, + reg_3 + FROM orders + WHERE " . implode(' AND ', $where) . " + ORDER BY created_at DESC, id DESC + LIMIT :source_limit"; + + return $this->fetchRows($sql, $params); + } + + /** + * @param array $plates + * @return array> + */ + private function fetchVehicleRows(?int $customerId, array $plates, string $search): array + { + $contextWhere = []; + $params = []; + + if ($customerId !== null) { + $contextWhere[] = 'customer_id = :customer_id'; + $params['customer_id'] = $customerId; + } + + foreach ($plates as $index => $plate) { + $key = 'plate_' . $index; + $contextWhere[] = "UPPER(REPLACE(reg, ' ', '')) = :$key"; + $params[$key] = $plate; + } + + if ($contextWhere === []) { + return []; + } + + $where = [ + 'reference IS NOT NULL', + "TRIM(reference) <> ''", + '(' . implode(' OR ', $contextWhere) . ')', + ]; + if ($this->tableHasColumn('customer_vehicles', 'deleted_at')) { + array_unshift($where, 'deleted_at IS NULL'); + } + + if ($search !== '') { + $where[] = 'LOWER(reference) LIKE :search'; + $params['search'] = '%' . $this->lower($search) . '%'; + } + + $sql = "SELECT + 'vehicle' AS source, + id AS origin_id, + TRIM(reference) AS reference, + created_at AS source_created_at, + created_at AS used_at, + customer_id, + NULL AS department_id, + reg AS reg_1, + '' AS reg_2, + '' AS reg_3 + FROM customer_vehicles + WHERE " . implode(' AND ', $where) . " + ORDER BY created_at DESC, id DESC + LIMIT :source_limit"; + + return $this->fetchRows($sql, $params); + } + + /** + * @param array $params + * @return array> + */ + private function fetchRows(string $sql, array $params): array + { + $pdo = db::getPDO(); + $statement = $pdo->prepare($sql); + + foreach ($params as $key => $value) { + $statement->bindValue(':' . $key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $statement->bindValue(':source_limit', self::MAX_SOURCE_ROWS, PDO::PARAM_INT); + $statement->execute(); + + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + return is_array($rows) ? $rows : []; + } + + private function tableHasColumn(string $table, string $column): bool + { + $cacheKey = $table . '.' . $column; + if (array_key_exists($cacheKey, $this->columnExistsCache)) { + return $this->columnExistsCache[$cacheKey]; + } + + $pdo = db::getPDO(); + $statement = $pdo->prepare( + 'SELECT COUNT(*) AS total + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :table_name + AND COLUMN_NAME = :column_name' + ); + $statement->bindValue(':table_name', $table, PDO::PARAM_STR); + $statement->bindValue(':column_name', $column, PDO::PARAM_STR); + $statement->execute(); + + $this->columnExistsCache[$cacheKey] = ((int)$statement->fetchColumn()) > 0; + return $this->columnExistsCache[$cacheKey]; + } + + /** + * @param array> $rows + * @param array $plates + * @return array> + */ + private function aggregateRows(array $rows, string $search, ?int $customerId, array $plates): array + { + $groups = []; + + foreach ($rows as $row) { + $reference = $this->normalizeText($row['reference'] ?? ''); + if ($reference === '') { + continue; + } + + $key = $this->lower($reference); + if (!isset($groups[$key])) { + $groups[$key] = [ + 'reference' => $reference, + 'rows' => [], + 'usage_count' => 0, + 'last_used_at' => null, + 'context_boost' => 0, + 'section' => 'other', + ]; + } + + $section = $this->contextSection($row, $customerId, $plates); + $groups[$key]['usage_count']++; + $groups[$key]['rows'][] = $row; + $groups[$key]['last_used_at'] = $this->maxDate( + $groups[$key]['last_used_at'], + $this->normalizeDate($row['used_at'] ?? null) + ); + $groups[$key]['context_boost'] = max( + $groups[$key]['context_boost'], + $this->contextBoost($row, $customerId, $plates) + ); + $groups[$key]['section'] = $this->bestSection( + (string)$groups[$key]['section'], + $section + ); + } + + $suggestions = []; + foreach ($groups as $group) { + $bestRow = $this->bestOriginRow($group['rows']); + if ($bestRow === null) { + continue; + } + + $source = (string)($bestRow['source'] ?? 'order'); + $usageCount = (int)$group['usage_count']; + $score = $this->matchScore((string)$group['reference'], $search) + + (int)$group['context_boost'] + + $this->sectionScore((string)$group['section']) + + $this->sourceScore($source) + + min($usageCount, 20) * 5; + + $suggestions[] = [ + 'source' => $source, + 'section' => (string)$group['section'], + 'reference' => (string)$group['reference'], + 'source_created_at' => $this->normalizeDate($bestRow['source_created_at'] ?? null), + 'last_used_at' => $group['last_used_at'], + 'usage_count' => $usageCount, + 'origin_id' => (int)($bestRow['origin_id'] ?? 0), + 'score' => $score, + ]; + } + + return $suggestions; + } + + /** + * @param array> $rows + */ + private function bestOriginRow(array $rows): ?array + { + usort($rows, function (array $left, array $right): int { + $sourceCompare = $this->sourceScore((string)($right['source'] ?? '')) + <=> $this->sourceScore((string)($left['source'] ?? '')); + if ($sourceCompare !== 0) { + return $sourceCompare; + } + + $dateCompare = strcmp( + (string)$this->normalizeDate($right['source_created_at'] ?? null), + (string)$this->normalizeDate($left['source_created_at'] ?? null) + ); + if ($dateCompare !== 0) { + return $dateCompare; + } + + return ((int)($right['origin_id'] ?? 0)) <=> ((int)($left['origin_id'] ?? 0)); + }); + + return $rows[0] ?? null; + } + + private function sortSuggestions(array $left, array $right): int + { + $scoreCompare = ((int)($right['score'] ?? 0)) <=> ((int)($left['score'] ?? 0)); + if ($scoreCompare !== 0) { + return $scoreCompare; + } + + $usageCompare = ((int)($right['usage_count'] ?? 0)) <=> ((int)($left['usage_count'] ?? 0)); + if ($usageCompare !== 0) { + return $usageCompare; + } + + $sectionCompare = $this->sectionScore((string)($right['section'] ?? '')) + <=> $this->sectionScore((string)($left['section'] ?? '')); + if ($sectionCompare !== 0) { + return $sectionCompare; + } + + $dateCompare = strcmp((string)($right['last_used_at'] ?? ''), (string)($left['last_used_at'] ?? '')); + if ($dateCompare !== 0) { + return $dateCompare; + } + + $referenceCompare = strcmp((string)($left['reference'] ?? ''), (string)($right['reference'] ?? '')); + if ($referenceCompare !== 0) { + return $referenceCompare; + } + + return $this->sourceScore((string)($right['source'] ?? '')) <=> $this->sourceScore((string)($left['source'] ?? '')); + } + + /** + * @param array $plates + */ + private function contextBoost(array $row, ?int $customerId, array $plates): int + { + $score = 0; + if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) { + $score += 80; + } + + if ($this->rowMatchesAnyPlate($row, $plates)) { + $score += 90; + } + + return $score; + } + + /** + * @param array $plates + */ + private function contextSection(array $row, ?int $customerId, array $plates): string + { + if ($this->rowMatchesAnyPlate($row, $plates)) { + return 'this_vehicle'; + } + + if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) { + return 'other_customer_vehicle'; + } + + return 'other'; + } + + /** + * @param array $plates + */ + private function rowMatchesAnyPlate(array $row, array $plates): bool + { + $rowPlates = $this->normalizePlates([ + $row['reg_1'] ?? '', + $row['reg_2'] ?? '', + $row['reg_3'] ?? '', + ]); + + return $plates !== [] && array_intersect($plates, $rowPlates) !== []; + } + + private function matchScore(string $reference, string $search): int + { + if ($search === '') { + return 0; + } + + $referenceKey = $this->lower($reference); + $searchKey = $this->lower($search); + + if ($referenceKey === $searchKey) { + return 1000; + } + + if (str_starts_with($referenceKey, $searchKey)) { + return 600; + } + + if (str_contains($referenceKey, $searchKey)) { + return 300; + } + + return 0; + } + + private function sourceScore(string $source): int + { + return match ($source) { + 'booking' => 30, + 'order' => 20, + 'vehicle' => 10, + default => 0, + }; + } + + private function sectionScore(string $section): int + { + return match ($section) { + 'this_vehicle' => 40, + 'other_customer_vehicle' => 20, + default => 0, + }; + } + + private function bestSection(string $left, string $right): string + { + return $this->sectionScore($right) > $this->sectionScore($left) ? $right : $left; + } + + private function clampLimit(mixed $value): int + { + $limit = $this->toPositiveInt($value) ?? self::DEFAULT_LIMIT; + return max(1, min($limit, self::MAX_LIMIT)); + } + + private function toPositiveInt(mixed $value): ?int + { + $parsed = filter_var($value, FILTER_VALIDATE_INT); + return is_int($parsed) && $parsed > 0 ? $parsed : null; + } + + private function normalizeText(mixed $value): string + { + return trim((string)($value ?? '')); + } + + private function lower(string $value): string + { + return function_exists('mb_strtolower') ? mb_strtolower($value) : strtolower($value); + } + + /** + * @param array $values + * @return array + */ + private function normalizePlates(array $values): array + { + $plates = []; + foreach ($values as $value) { + $plate = strtoupper(preg_replace('/\s+/', '', (string)($value ?? ''))); + if ($plate !== '') { + $plates[] = $plate; + } + } + + return array_values(array_unique($plates)); + } + + private function normalizeDate(mixed $value): ?string + { + $date = trim((string)($value ?? '')); + return $date === '' || $date === '0000-00-00 00:00:00' ? null : $date; + } + + private function maxDate(?string $left, ?string $right): ?string + { + if ($left === null) { + return $right; + } + if ($right === null) { + return $left; + } + + return strcmp($right, $left) > 0 ? $right : $left; + } +} diff --git a/services/nginx/app/composer.json b/services/nginx/app/composer.json index 0b54246c..2f2cd5b2 100644 --- a/services/nginx/app/composer.json +++ b/services/nginx/app/composer.json @@ -5,7 +5,7 @@ "test:integration": "vendor/bin/pest --testsuite=Integration --colors=always", "test:api": [ "Composer\\Config::disableProcessTimeout", - "@php -r \"putenv('RUN_API_TESTS=1'); passthru('vendor/bin/pest --testsuite=Api --colors=always', $exitCode); exit($exitCode);\"" + "@php -r \"putenv('RUN_API_TESTS=1'); putenv('CONFIG_DB_TARGET=debug'); passthru('vendor/bin/pest --testsuite=Api --colors=always', $exitCode); exit($exitCode);\"" ], "test:api:edge": [ "Composer\\Config::disableProcessTimeout", diff --git a/services/nginx/app/objects/collected_order_invoices_o.php b/services/nginx/app/objects/collected_order_invoices_o.php index 4cf996c2..cac02b97 100644 --- a/services/nginx/app/objects/collected_order_invoices_o.php +++ b/services/nginx/app/objects/collected_order_invoices_o.php @@ -508,6 +508,7 @@ class collected_order_invoices_o extends db } // Set the processor to E-conomic, if it's not already set to Stripe. $this->processor->set(ECONOMIC_PROCESSOR); + $this->error_message->nullify(); // Object changed self::objectChanged(); return $this; diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 0a24d06c..9054fd24 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -6507,6 +6507,34 @@ paths: '503': { $ref: '#/components/responses/ServiceUnavailable' } '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/monitor: + get: + tags: + - Invoices + summary: Monitor current-user visible collected-invoice e-conomic transfer queue jobs + operationId: monitorCollectedInvoiceEconomicQueueJobs + parameters: + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + responses: + '200': + description: Queue monitor state retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueMonitorResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/status: get: tags: @@ -6566,6 +6594,57 @@ paths: '503': { $ref: '#/components/responses/ServiceUnavailable' } '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/dismiss: + post: + tags: + - Invoices + summary: Clear one completed or failed collected-invoice queue job for the current user + operationId: dismissCollectedInvoiceEconomicQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job cleared + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueDismissResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/dismiss-terminal: + post: + tags: + - Invoices + summary: Clear all visible completed or failed collected-invoice queue jobs for the current user + operationId: dismissCollectedInvoiceEconomicTerminalQueueJobs + responses: + '200': + description: Terminal queue jobs cleared + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueDismissTerminalResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/run: post: tags: @@ -14083,6 +14162,140 @@ components: - meta - includes + EconomicTransferQueueMonitorResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + jobs: + type: array + items: + $ref: '#/components/schemas/EconomicTransferQueueJob' + counts: + type: object + properties: + queued: + type: integer + minimum: 0 + in_progress: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + completed: + type: integer + minimum: 0 + total: + type: integer + minimum: 0 + required: + - queued + - in_progress + - failed + - completed + - total + progress_percent: + type: integer + minimum: 0 + maximum: 100 + limit: + type: integer + minimum: 1 + maximum: 100 + required: + - jobs + - counts + - progress_percent + - limit + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueDismissResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + job: + $ref: '#/components/schemas/EconomicTransferQueueJob' + required: + - message + - job + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueDismissTerminalResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + dismissed_count: + type: integer + minimum: 0 + required: + - message + - dismissed_count + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + CollectedInvoiceEconomicCompareResponse: type: object description: Result of comparing a collected invoice with its E-conomic counterpart diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 1cf90a8e..474d7471 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -34,6 +34,8 @@ class InvoicingPeriodRoute */ private static array $departmentExcludedFromInvoicingCache = []; + private static ?bool $collectedOrderInvoicesHasDeletedAtColumn = null; + /** * @throws Exception */ @@ -105,6 +107,64 @@ class InvoicingPeriodRoute ]; } + /** + * @return int[]|null + */ + private function getOptionalCustomerNumbersParameter(): ?array + { + if (!self::isParametersSet(['customerNumbers'])) { + return null; + } + + return self::normalizeCustomerNumbers(self::getParameter('customerNumbers')); + } + + /** + * @return int[] + */ + private static function normalizeCustomerNumbers(mixed $customerNumbers): array + { + if ($customerNumbers === null || $customerNumbers === '') { + return []; + } + + $rawValues = is_array($customerNumbers) + ? $customerNumbers + : explode(',', (string)$customerNumbers); + + $normalized = []; + foreach ($rawValues as $value) { + $parsed = (int)trim((string)$value); + if ($parsed < 1) { + continue; + } + $normalized[$parsed] = $parsed; + } + + return array_values($normalized); + } + + /** + * @param int[]|null $onlyCustomerNumbers + * @return int[] + */ + private static function filterCustomerNumbers(array $customerNumbers, ?array $onlyCustomerNumbers = null): array + { + $customerNumbers = self::normalizeCustomerNumbers($customerNumbers); + if ($onlyCustomerNumbers === null) { + return $customerNumbers; + } + + $allowed = array_fill_keys(self::normalizeCustomerNumbers($onlyCustomerNumbers), true); + if (empty($allowed)) { + return []; + } + + return array_values(array_filter($customerNumbers, static function (int $customerNumber) use ($allowed): bool { + return isset($allowed[$customerNumber]); + })); + } + /** * Response cache TTL (seconds) for v2 distribution endpoints. * Set `INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL` to override. @@ -196,11 +256,15 @@ class InvoicingPeriodRoute $dateRange = $this->requireAndNormalizeDateRange(); $dateFrom = $dateRange['dateFrom']; $dateTo = $dateRange['dateTo']; + $customerNumbers = $this->getOptionalCustomerNumbersParameter(); // Add date from and date to to the response meta $response->add_meta('date_from', $dateFrom); $response->add_meta('date_to', $dateTo); + if ($customerNumbers !== null) { + $response->add_meta('customer_numbers', $customerNumbers); + } // Get the invoicing period for the user - $response->success([...self::getInvoicingPeriod($dateFrom, $dateTo)]); + $response->success([...self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers)]); } else { // Log the incident (new logs_o())->add('vehicles', 'global', 1, 0, 'LIST_OWN_VEHICLES', 'No user found, or invalid session'); @@ -1103,33 +1167,36 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getInvoicingPeriod(string $dateFrom, string $dateTo): array + private static function getInvoicingPeriod(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array { //$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo) + $onlyCustomerNumbers = $onlyCustomerNumbers !== null + ? self::normalizeCustomerNumbers($onlyCustomerNumbers) + : null; - $customersWithTransactions = self::debugGetTime(function () use ($dateFrom, $dateTo) { - return self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::debugGetTime(function () use ($dateFrom, $dateTo, $onlyCustomerNumbers) { + return self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); }, 'customers_with_transactions'); $types = []; // Add the customers with transactions to the types array $types['all'] = $customersWithTransactions; - $types['vehicle_subscriptions'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getVehicleSubscriptions($dateFrom, $dateTo, $customersWithTransactions); + $types['vehicle_subscriptions'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getVehicleSubscriptions($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'vehicle_subscriptions'); - $types['fixed_pricing'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getFixedPricing($dateFrom, $dateTo, $customersWithTransactions); + $types['fixed_pricing'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getFixedPricing($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'fixed_pricing'); - $types['tank_cleaning'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getTankCleaning($dateFrom, $dateTo, $customersWithTransactions); + $types['tank_cleaning'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getTankCleaning($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'tank_cleaning'); - $types['special_arrangements'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getSpecialArrangements($dateFrom, $dateTo, $customersWithTransactions); + $types['special_arrangements'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getSpecialArrangements($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'special_arrangements'); - $types['invoice_per_order'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getInvoicingPerOrder($dateFrom, $dateTo, $customersWithTransactions); + $types['invoice_per_order'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getInvoicingPerOrder($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'invoice_per_order'); - $types['possible_duplicates'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions); + $types['possible_duplicates'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'possible_duplicates'); $queueOverlay = self::debugGetTime(function () use ($dateFrom, $dateTo) { return self::getActiveCollectedInvoiceQueueOverlay($dateFrom, $dateTo); @@ -1139,6 +1206,14 @@ class InvoicingPeriodRoute $queueOverlay['by_collection_id'] ?? [], $queueOverlay['by_customer_number'] ?? [], ); + $draftOverlay = self::debugGetTime(function () use ($types, $dateFrom, $dateTo) { + return self::getValidCollectedInvoiceDraftOverlay($types, $dateFrom, $dateTo); + }, 'valid_collected_invoice_draft_overlay'); + $types = self::applyCollectedInvoiceDraftOverlayToPeriodTypes( + $types, + $draftOverlay['by_collection_id'] ?? [], + $draftOverlay['by_customer_number'] ?? [], + ); return [ 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, @@ -1173,6 +1248,13 @@ class InvoicingPeriodRoute */ private static function getCustomersWithTransactions(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array { + $onlyCustomerNumbers = $onlyCustomerNumbers !== null + ? self::normalizeCustomerNumbers($onlyCustomerNumbers) + : null; + if ($onlyCustomerNumbers !== null && empty($onlyCustomerNumbers)) { + return []; + } + // Define the customers with orders in the specified date range $customers = self::debugGetTime(function () use ($dateFrom, $dateTo) { return (new orders_o())->getCustomersWithOrdersInDateRange($dateFrom, $dateTo); @@ -1311,6 +1393,7 @@ class InvoicingPeriodRoute 'requires_action' => self::checkRequiresAction($parsed_transactions, $requires_action), 'meta' => $meta ?? [], 'queue' => self::getDefaultQueueSummary(), + 'draft' => self::getDefaultDraftSummary(), ]; } @@ -1360,6 +1443,15 @@ class InvoicingPeriodRoute ]; } + private static function getDefaultDraftSummary(): array + { + return [ + 'has_valid_draft' => false, + 'invoice_collection_ids' => [], + 'is_action_blocked' => false, + ]; + } + private static function getActiveCollectedInvoiceQueueOverlay(string $dateFrom, string $dateTo): array { $overlay = [ @@ -1467,6 +1559,24 @@ class InvoicingPeriodRoute return $normalizedTimestamp >= $dateFrom && $normalizedTimestamp <= $dateTo; } + private static function collectedOrderInvoicesHasDeletedAtColumn(): bool + { + if (self::$collectedOrderInvoicesHasDeletedAtColumn !== null) { + return self::$collectedOrderInvoicesHasDeletedAtColumn; + } + + global $db; + + try { + $result = $db->query("SHOW COLUMNS FROM `collected_order_invoices` LIKE 'deleted_at'"); + self::$collectedOrderInvoicesHasDeletedAtColumn = $result !== false && (int)$result->num_rows > 0; + } catch (\Throwable) { + self::$collectedOrderInvoicesHasDeletedAtColumn = false; + } + + return self::$collectedOrderInvoicesHasDeletedAtColumn; + } + private static function applyCollectedInvoiceQueueOverlayToPeriodTypes( array $types, array $queueJobsByCollectionId, @@ -1582,6 +1692,235 @@ class InvoicingPeriodRoute return $customer; } + private static function getValidCollectedInvoiceDraftOverlay(array $types, string $dateFrom, string $dateTo): array + { + $overlay = [ + 'by_collection_id' => [], + 'by_customer_number' => [], + ]; + + $candidateInvoiceCollectionIds = []; + $customerLevelCandidateNumbers = []; + + foreach ($types as $customers) { + if (!is_array($customers)) { + continue; + } + + foreach ($customers as $customer) { + if (!is_array($customer)) { + continue; + } + + foreach (($customer['transactions'] ?? []) as $transaction) { + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $candidateInvoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; + } + } + + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber > 0 && self::customerSupportsCustomerLevelQueueBlocking($customer)) { + $customerLevelCandidateNumbers[$customerNumber] = $customerNumber; + } + } + } + + if (empty($candidateInvoiceCollectionIds) && empty($customerLevelCandidateNumbers)) { + return $overlay; + } + + global $db; + + try { + $whereCandidates = []; + if (!empty($candidateInvoiceCollectionIds)) { + $whereCandidates[] = 'id IN (' . implode(',', array_map('intval', array_values($candidateInvoiceCollectionIds))) . ')'; + } + if (!empty($customerLevelCandidateNumbers)) { + $dateFromEscaped = $db->escape_string($dateFrom); + $dateToEscaped = $db->escape_string($dateTo); + $whereCandidates[] = '(customer_number IN (' . implode(',', array_map('intval', array_values($customerLevelCandidateNumbers))) . ') + AND ( + DATE(closed_at) BETWEEN \'' . $dateFromEscaped . '\' AND \'' . $dateToEscaped . '\' + OR DATE(created_at) BETWEEN \'' . $dateFromEscaped . '\' AND \'' . $dateToEscaped . '\' + ))'; + } + + if (!defined('\objects\ECONOMIC_PROCESSOR')) { + class_exists(collected_order_invoices_o::class); + } + $processor = defined('\objects\ECONOMIC_PROCESSOR') + ? (int)constant('\objects\ECONOMIC_PROCESSOR') + : 1; + $deletedAtFilter = self::collectedOrderInvoicesHasDeletedAtColumn() + ? 'deleted_at IS NULL + AND ' + : ''; + $sql = "SELECT id, customer_number, created_at, closed_at + FROM collected_order_invoices + WHERE {$deletedAtFilter}processor = $processor + AND external_id IS NOT NULL + AND external_id <> '' + AND booked_invoice_id IS NULL + AND error_message IS NULL + AND (" . implode(' OR ', $whereCandidates) . ")"; + + $result = $db->query($sql); + if (!$result) { + return $overlay; + } + + while ($row = $result->fetch_assoc()) { + $invoiceCollectionId = (int)($row['id'] ?? 0); + $customerNumber = (int)($row['customer_number'] ?? 0); + if ($invoiceCollectionId < 1 || $customerNumber < 1) { + continue; + } + + $normalizedDraft = [ + 'invoice_collection_id' => $invoiceCollectionId, + 'customer_number' => $customerNumber, + 'created_at' => (string)($row['created_at'] ?? ''), + 'closed_at' => (string)($row['closed_at'] ?? ''), + 'is_period_relevant' => self::isInvoiceCollectionRelevantToPeriod( + (string)($row['created_at'] ?? ''), + (string)($row['closed_at'] ?? ''), + $dateFrom, + $dateTo + ), + ]; + + $overlay['by_collection_id'][$invoiceCollectionId] = $normalizedDraft; + $overlay['by_customer_number'][$customerNumber] = $overlay['by_customer_number'][$customerNumber] ?? []; + $overlay['by_customer_number'][$customerNumber][] = $normalizedDraft; + } + } catch (\Throwable) { + return $overlay; + } + + return $overlay; + } + + private static function applyCollectedInvoiceDraftOverlayToPeriodTypes( + array $types, + array $draftsByCollectionId, + array $draftsByCustomerNumber + ): array { + foreach ($types as $type => $customers) { + if (!is_array($customers)) { + continue; + } + + $types[$type] = array_map(function ($customer) use ($draftsByCollectionId, $draftsByCustomerNumber) { + if (!is_array($customer)) { + return $customer; + } + + return self::applyCollectedInvoiceDraftOverlayToCustomer( + $customer, + $draftsByCollectionId, + $draftsByCustomerNumber + ); + }, $customers); + } + + return $types; + } + + private static function applyCollectedInvoiceDraftOverlayToCustomer( + array $customer, + array $draftsByCollectionId, + array $draftsByCustomerNumber + ): array { + $customerNumber = (int)($customer['customer_number'] ?? 0); + $activeCustomerDrafts = array_values(array_filter( + $draftsByCustomerNumber[$customerNumber] ?? [], + static function ($draft): bool { + return !empty($draft['is_period_relevant']); + } + )); + + $transactions = []; + $actionableTransactionCount = 0; + $coveredActionableTransactionCount = 0; + $queuedActionableTransactionCount = 0; + $draftActionableTransactionCount = 0; + $invoiceCollectionIds = []; + + foreach (($customer['transactions'] ?? []) as $transaction) { + if (!is_array($transaction)) { + continue; + } + + $transaction['invoice_collection_id'] = isset($transaction['invoice_collection_id']) && (int)$transaction['invoice_collection_id'] > 0 + ? (int)$transaction['invoice_collection_id'] + : null; + + $isActionable = !($transaction['booked'] ?? false) && !($transaction['excluded'] ?? false); + if (!$isActionable) { + $transactions[] = $transaction; + continue; + } + + $actionableTransactionCount++; + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + $isQueued = !empty($transaction['queue_status']); + $isDraft = $invoiceCollectionId > 0 && isset($draftsByCollectionId[$invoiceCollectionId]); + + if ($isQueued || $isDraft) { + $coveredActionableTransactionCount++; + } + if ($isQueued) { + $queuedActionableTransactionCount++; + } + if ($isDraft) { + $draftActionableTransactionCount++; + $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; + } + + $transactions[] = $transaction; + } + + foreach ($activeCustomerDrafts as $draft) { + $invoiceCollectionId = (int)($draft['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; + } + } + + $isDraftActionBlocked = false; + $queue = is_array($customer['queue'] ?? null) ? $customer['queue'] : self::getDefaultQueueSummary(); + if ($actionableTransactionCount > 0 && $coveredActionableTransactionCount === $actionableTransactionCount) { + if ($queuedActionableTransactionCount > 0) { + $queue['is_action_blocked'] = true; + } elseif ($draftActionableTransactionCount > 0) { + $isDraftActionBlocked = true; + } + } elseif ( + $actionableTransactionCount === 0 + && self::customerSupportsCustomerLevelQueueBlocking($customer) + && !empty($activeCustomerDrafts) + && empty($queue['is_action_blocked']) + ) { + $isDraftActionBlocked = true; + } + + $customer['transactions'] = $transactions; + $customer['queue'] = $queue; + $customer['draft'] = [ + 'has_valid_draft' => !empty($invoiceCollectionIds), + 'invoice_collection_ids' => array_values($invoiceCollectionIds), + 'is_action_blocked' => $isDraftActionBlocked, + ]; + + if ($isDraftActionBlocked || !empty($queue['is_action_blocked'])) { + $customer['requires_action'] = false; + } + + return $customer; + } + private static function customerSupportsCustomerLevelQueueBlocking(array $customer): bool { $meta = $customer['meta'] ?? []; @@ -1594,14 +1933,17 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getVehicleSubscriptions(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getVehicleSubscriptions(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } // Since these are monthly subscriptions, we don't need to filter by transactions - $customer_numbers = (new \objects\users_o())->getCustomersWithVehicleSubscriptions(); + $customer_numbers = self::filterCustomerNumbers( + (new \objects\users_o())->getCustomersWithVehicleSubscriptions(), + $onlyCustomerNumbers + ); // Get all customers with vehicle subscriptions $subscriptions = []; /** @var int $customer_number */ @@ -1651,10 +1993,13 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getFixedPricing(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getFixedPricing(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // Get all customers with fixed pricing - $customer_numbers = (new \objects\users_o())->getCustomersWithFixedPricing(); + $customer_numbers = self::filterCustomerNumbers( + (new \objects\users_o())->getCustomersWithFixedPricing(), + $onlyCustomerNumbers + ); // If customersWithTransactions is not provided, only resolve transaction customers for fixed-pricing customers. if ($customersWithTransactions === null) { @@ -1727,14 +2072,17 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getTankCleaning(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getTankCleaning(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } // Filter out customers that do not have any transactions in the specified date range - $customer_numbers = (new \objects\users_o())->getCustomersWithTankCleaning(); + $customer_numbers = self::filterCustomerNumbers( + (new \objects\users_o())->getCustomersWithTankCleaning(), + $onlyCustomerNumbers + ); self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); // Get all customers with tank cleaning $tank_cleaning = []; @@ -1772,14 +2120,17 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getSpecialArrangements(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getSpecialArrangements(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } // Get all customers with tank cleaning - $customer_numbers = (new \objects\users_o())->getCustomersWithSpecialArrangements(); + $customer_numbers = self::filterCustomerNumbers( + (new \objects\users_o())->getCustomersWithSpecialArrangements(), + $onlyCustomerNumbers + ); // Filter out customers that do not have any transactions in the specified date range self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); $special_arrangements = []; @@ -1796,13 +2147,16 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getInvoicingPerOrder(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getInvoicingPerOrder(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // Get all customers with the invoicing per order attribute - $customer_numbers = (new users_o())->getCustomerNumbersWithAttributes(['invoiceAllOrdersIndividually']); + $customer_numbers = self::filterCustomerNumbers( + (new users_o())->getCustomerNumbersWithAttributes(['invoiceAllOrdersIndividually']), + $onlyCustomerNumbers + ); // Filter out customers that do not have any transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); @@ -1818,12 +2172,15 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getPossibleDuplicates(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getPossibleDuplicates(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } + $allowedCustomerNumbers = $onlyCustomerNumbers !== null + ? array_fill_keys(self::normalizeCustomerNumbers($onlyCustomerNumbers), true) + : null; // Get orders with the same reg_1, that has been created within 24 hours of each other $orders = (new orders_o())->getOrdersWithPossibleDuplicates($dateFrom, $dateTo); // Get the customer numbers from the orders @@ -1834,6 +2191,9 @@ class InvoicingPeriodRoute foreach ( $orders as $order ) { // Get the customer number from the order $customer_number = (int)$order[0]['object']->customer_id->value(); + if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customer_number])) { + continue; + } // Check if the customer number is already in the array if (isset($tmp_customer_arr[$customer_number])) { continue; diff --git a/services/nginx/app/routes/orderInvoicesRoute.php b/services/nginx/app/routes/orderInvoicesRoute.php index 6d744d0f..01da8864 100644 --- a/services/nginx/app/routes/orderInvoicesRoute.php +++ b/services/nginx/app/routes/orderInvoicesRoute.php @@ -794,6 +794,29 @@ class orderInvoicesRoute ] ); + $this->get('/collected-invoices/economic/queue/monitor', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $limit = $this->parseCollectedInvoiceQueueMonitorLimit(); + $queue = new economic_transfer_queue(); + + $response->success($this->buildCollectedInvoiceQueueMonitorPayload( + $queue, + (int)$user->id, + $limit + )); + }, + [ + 'add_collected_invoice_economic' => 'Monitor visible collected invoice transfer queue jobs.' + ] + ); + $this->post('/collected-invoices/economic/queue/retry', function () { global $response; self::requirePermission('add_collected_invoice_economic'); @@ -828,6 +851,67 @@ class orderInvoicesRoute ] ); + $this->post('/collected-invoices/economic/queue/dismiss', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $job_id = $this->requireCollectedInvoiceQueueJobId(); + $job = $this->requireCollectedInvoiceQueueJobById($job_id); + $status = strtoupper((string)($job['status'] ?? '')); + if (!in_array($status, [ + economic_transfer_queue::STATUS_COMPLETED, + economic_transfer_queue::STATUS_FAILED, + ], true)) { + $response->error('Only completed or failed collected invoice queue jobs can be cleared', 409); + } + + try { + $queue = new economic_transfer_queue(); + $dismissed = $queue->dismissTerminalJobForUser($job_id, (int)$user->id); + } catch (\Throwable $e) { + $response->error('Failed to clear collected invoice queue job: ' . $e->getMessage(), 400); + } + + $response->success([ + 'message' => 'Collected invoice queue job cleared', + 'job' => $this->withCollectedInvoiceQueueDetailsSummary($dismissed), + ]); + }, + [ + 'add_collected_invoice_economic' => 'Clear one completed or failed queued collected invoice transfer job for the current user.' + ] + ); + + $this->post('/collected-invoices/economic/queue/dismiss-terminal', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $queue = new economic_transfer_queue(); + $dismissed_count = $queue->dismissTerminalJobsForUser( + (int)$user->id, + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT + ); + + $response->success([ + 'message' => 'Completed and failed collected invoice queue jobs cleared', + 'dismissed_count' => $dismissed_count, + ]); + }, + [ + 'add_collected_invoice_economic' => 'Clear all visible completed or failed queued collected invoice transfer jobs for the current user.' + ] + ); + $this->post('/collected-invoices/economic/queue/run', function () { global $response; self::requirePermission('add_collected_invoice_economic'); @@ -2034,6 +2118,25 @@ class orderInvoicesRoute ]; } + private function parseCollectedInvoiceQueueMonitorLimit(): int + { + global $response; + + $limit = 50; + if (self::isParametersSet(['limit'])) { + $limit_raw = self::getParameter('limit'); + if (!is_numeric($limit_raw)) { + $response->error('limit must be between 1 and 100', 400); + } + $limit = (int)$limit_raw; + if ($limit < 1 || $limit > 100) { + $response->error('limit must be between 1 and 100', 400); + } + } + + return $limit; + } + private function requireCollectedInvoiceQueueJobId(): int { global $response; @@ -2104,6 +2207,62 @@ class orderInvoicesRoute ]; } + private function buildCollectedInvoiceQueueMonitorPayload(economic_transfer_queue $queue, int $user_id, int $limit): array + { + $jobs = $queue->listMonitorJobsForUser( + $user_id, + $limit, + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT + ); + $jobs = $this->withCollectedInvoiceQueueDetailsSummaryList($jobs); + + $counts = [ + 'queued' => 0, + 'in_progress' => 0, + 'failed' => 0, + 'completed' => 0, + 'total' => count($jobs), + ]; + $progress_sum = 0; + + foreach ($jobs as $job) { + $status = strtoupper((string)($job['status'] ?? '')); + $job_progress = max(0, min(100, (int)($job['progress_percent'] ?? 0))); + + if ($status === economic_transfer_queue::STATUS_QUEUED) { + $counts['queued']++; + $progress_sum += 0; + continue; + } + + if ($status === economic_transfer_queue::STATUS_PROCESSING) { + $counts['in_progress']++; + $progress_sum += $job_progress; + continue; + } + + if ($status === economic_transfer_queue::STATUS_FAILED) { + $counts['failed']++; + $progress_sum += 100; + continue; + } + + if ($status === economic_transfer_queue::STATUS_COMPLETED) { + $counts['completed']++; + $progress_sum += 100; + } + } + + return [ + 'jobs' => $jobs, + 'counts' => $counts, + 'progress_percent' => $counts['total'] > 0 + ? (int)round($progress_sum / $counts['total']) + : 0, + 'limit' => $limit, + ]; + } + private function countCollectedInvoiceQueueJobs(economic_transfer_queue $queue, array $statuses): int { global $db; @@ -2143,6 +2302,13 @@ class orderInvoicesRoute private function withCollectedInvoiceQueueDetailsSummary(array $job): array { $job['details_summary'] = economic_transfer_queue_details_summary::buildCollectedInvoiceSummary($job); + $collected_invoice_id = (int)($job['details_summary']['target']['collected_invoice_id'] ?? 0); + if ($collected_invoice_id > 0) { + $job['details_summary']['customer'] = $this->resolveCollectedInvoiceQueueCustomerSummary( + $job['details_summary']['customer'] ?? [], + $collected_invoice_id + ); + } return $job; } @@ -2153,6 +2319,72 @@ class orderInvoicesRoute }, $jobs)); } + private function resolveCollectedInvoiceQueueCustomerSummary(array $customer, int $collected_invoice_id): array + { + global $db; + + $customer_number = isset($customer['customer_number']) && is_numeric($customer['customer_number']) + ? (int)$customer['customer_number'] + : null; + $customer_name = is_string($customer['name'] ?? null) && trim((string)$customer['name']) !== '' + ? trim((string)$customer['name']) + : null; + + if ($customer_number !== null && $customer_name !== null) { + return [ + 'customer_number' => $customer_number, + 'name' => $customer_name, + ]; + } + + $collected_invoice_id = max(0, $collected_invoice_id); + if ($collected_invoice_id < 1) { + return [ + 'customer_number' => $customer_number, + 'name' => $customer_name, + ]; + } + + $sql = "SELECT coi.customer_number, coi.name AS invoice_name, u.display_name + FROM collected_order_invoices coi + LEFT JOIN users u ON u.customer_number = coi.customer_number + WHERE coi.id = $collected_invoice_id + LIMIT 1"; + $result = $db->query($sql); + if (!$result instanceof \mysqli_result) { + return [ + 'customer_number' => $customer_number, + 'name' => $customer_name, + ]; + } + + $row = $result->fetch_assoc(); + if (!is_array($row)) { + return [ + 'customer_number' => $customer_number, + 'name' => $customer_name, + ]; + } + + $resolved_customer_number = isset($row['customer_number']) && is_numeric($row['customer_number']) + ? (int)$row['customer_number'] + : $customer_number; + $display_name = trim((string)($row['display_name'] ?? '')); + $invoice_name = trim((string)($row['invoice_name'] ?? '')); + $resolved_name = $customer_name; + if ($resolved_name === null && $display_name !== '' && strtolower($display_name) !== 'unnamed') { + $resolved_name = $display_name; + } + if ($resolved_name === null && $invoice_name !== '') { + $resolved_name = $invoice_name; + } + + return [ + 'customer_number' => $resolved_customer_number, + 'name' => $resolved_name, + ]; + } + /** * @throws Exception */ diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index 8245c256..3aa33031 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -7,6 +7,7 @@ use classes\attachment_store; use classes\attachments; use classes\authentication; use classes\economic; +use classes\order_reference_suggestions_service; use classes\orders_input_normalizer; use classes\response; use classes\stripe; @@ -28,6 +29,52 @@ class ordersRoute public function run(): void { + $this->get('/orders/reference-suggestions', function () { + global $response; + $auth = new authentication(); + $user = $auth->get_user(); + if ($user === false) { + (new logs_o())->add('orders', 'global', 1, 0, 'LIST_ORDER_REFERENCE_SUGGESTIONS', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + + $this->requirePermission('list_orders'); + self::requireParameters(['department_id']); + $departmentId = (int)self::getParameter('department_id'); + self::requireParameterIntPositive($departmentId, 'department_id'); + self::requireDepartmentAccess((string)$departmentId); + + $search = trim((string)(self::getParameter('search') ?? '')); + if (strlen($search) > 255) { + $response->error('Parameter search must be at most 255 characters long', 400); + } + + foreach (['reg_1', 'reg_2', 'reg_3'] as $plateParameter) { + $plateValue = (string)(self::getParameter($plateParameter) ?? ''); + if (strlen($plateValue) > 32) { + $response->error('Parameter ' . $plateParameter . ' must be at most 32 characters long', 400); + } + } + + $suggestions = (new order_reference_suggestions_service())->suggest([ + 'search' => $search, + 'department_id' => $departmentId, + 'customer_id' => self::getParameter('customer_id') ?? null, + 'reg_1' => self::getParameter('reg_1') ?? '', + 'reg_2' => self::getParameter('reg_2') ?? '', + 'reg_3' => self::getParameter('reg_3') ?? '', + 'limit' => self::getParameter('limit') ?? null, + ]); + + (new logs_o())->add('orders', (string)$departmentId, 1, (int)$user->id, 'LIST_ORDER_REFERENCE_SUGGESTIONS', 'Successfully listed POS reference suggestions'); + $response->success($suggestions); + }, + [ + 'list_orders' => 'List POS order reference suggestions', + 'department_access_:id' => 'Access to the department used for reference suggestions', + ] + ); + $this->get('/orders', function () { // Require the user to be logged in global $response; diff --git a/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php b/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php index 00835267..233b21a8 100644 --- a/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php +++ b/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php @@ -70,6 +70,50 @@ it('removes generated customer traces during fixture cleanup', function (): void ]); }); +it('restores preserved module config rows during fixture cleanup', function (): void { + $db = api_test_runtime()->db(); + $module = 'fixture_cleanup'; + $variable = 'preserve_module_config_' . bin2hex(random_bytes(4)); + $moduleEscaped = $db->real_escape_string($module); + $variableEscaped = $db->real_escape_string($variable); + $ended = false; + + try { + $db->query( + "INSERT INTO `module_config` (`module`, `variable`, `value`, `type`, `created_at`, `updated_at`) " . + "VALUES ('{$moduleEscaped}', '{$variableEscaped}', '600100', 'int', '2026-04-14 12:00:00', '2026-04-14 12:00:00')" + ); + + api_fixtures()->preserveModuleConfig($module, $variable); + + $db->query( + "UPDATE `module_config` " . + "SET `value` = NULL, `type` = 'string', `updated_at` = '2026-04-14 12:05:00' " . + "WHERE `module` = '{$moduleEscaped}' AND `variable` = '{$variableEscaped}'" + ); + + api_test_runtime()->endTest(); + $ended = true; + + $row = api_fixture_cleanup_module_config_row($db, $module, $variable); + + expect($row) + ->not->toBeNull() + ->and($row['value'] ?? null)->toBe('600100') + ->and($row['type'] ?? null)->toBe('int') + ->and($row['created_at'] ?? null)->toBe('2026-04-14 12:00:00') + ->and($row['updated_at'] ?? null)->toBe('2026-04-14 12:00:00'); + } finally { + if (!$ended) { + api_test_runtime()->endTest(); + } + + $db->query( + "DELETE FROM `module_config` WHERE `module` = '{$moduleEscaped}' AND `variable` = '{$variableEscaped}'" + ); + } +}); + function api_fixture_cleanup_ensure_optional_trace_tables(mysqli $db): void { $statements = [ @@ -450,6 +494,24 @@ function api_fixture_cleanup_count(mysqli $db, string $sql): int return (int)($row['c'] ?? 0); } +function api_fixture_cleanup_module_config_row(mysqli $db, string $module, string $variable): ?array +{ + $moduleEscaped = $db->real_escape_string($module); + $variableEscaped = $db->real_escape_string($variable); + $result = $db->query( + "SELECT * FROM `module_config` WHERE `module` = '{$moduleEscaped}' AND `variable` = '{$variableEscaped}' LIMIT 1" + ); + + if ($result === false) { + throw new RuntimeException('Fixture cleanup module_config query failed.'); + } + + $row = $result->fetch_assoc(); + $result->free(); + + return $row ?: null; +} + /** * @param array $data */ diff --git a/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php b/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php index cafe763f..d23bcaff 100644 --- a/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php +++ b/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php @@ -41,6 +41,7 @@ it('lists the draft customer config entry in economic config responses', functio it('round-trips the draft customer config value through economic config updates', function (): void { api_test_covers('POST /economic/config', 'happy'); + api_fixtures()->preserveModuleConfig('economic', 'transactionDraftCustomerNumber'); $session = api_fixtures()->createUserSession(['economic_config']); api_client()->post('/economic/config', [ diff --git a/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php b/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php new file mode 100644 index 00000000..3ffd449e --- /dev/null +++ b/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php @@ -0,0 +1,208 @@ +createDepartment(); + $customer = api_fixtures()->createUser(['display_name' => 'Reference Suggestion Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Reference Suggestion Cashier']); + + api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'reference' => 'REF-BOOKING', + 'datetime' => '2026-05-13 09:00:00', + 'created_at' => '2026-05-01 08:00:00', + 'reg_1' => 'BOOK1', + ]); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'REF-HISTORY', + 'created_at' => '2026-05-10 10:00:00', + 'reg_1' => 'HIST1', + ]); + api_fixtures()->createVehicle([ + 'customer_id' => $customer['customer_number'], + 'type' => 53, + 'reg' => 'VEH1', + 'reference' => 'REF-VEHICLE', + 'created_at' => '2026-05-08 12:00:00', + ]); + + api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'reference' => 'REF-SHARED', + 'datetime' => '2026-05-15 11:00:00', + 'reg_1' => 'SHARED1', + ]); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'REF-SHARED', + 'created_at' => '2026-05-12 10:00:00', + 'reg_1' => 'SHARED1', + ]); + api_fixtures()->createVehicle([ + 'customer_id' => $customer['customer_number'], + 'type' => 53, + 'reg' => 'SHARED1', + 'reference' => 'REF-SHARED', + 'created_at' => '2026-05-09 12:00:00', + ]); + + $deletedOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'REF-DELETED', + ]); + api_test_runtime()->db() + ->query("UPDATE orders SET deleted_at = '2026-05-10 12:00:00' WHERE id = " . (int)$deletedOrder['id']); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => '', + ]); + + $session = api_fixtures()->createUserSession([ + 'list_orders', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->get('/orders/reference-suggestions?' . http_build_query([ + 'search' => 'REF', + 'department_id' => $department['id'], + 'customer_id' => $customer['customer_number'], + 'reg_1' => 'SHARED1', + 'limit' => 10, + ]), $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $suggestions = $response->data(); + expect($suggestions)->toBeArray(); + + $booking = reference_suggestion_by_reference($suggestions, 'REF-BOOKING'); + $history = reference_suggestion_by_reference($suggestions, 'REF-HISTORY'); + $vehicle = reference_suggestion_by_reference($suggestions, 'REF-VEHICLE'); + $shared = reference_suggestion_by_reference($suggestions, 'REF-SHARED'); + + expect($booking['source'] ?? null)->toBe('booking'); + expect($booking['source_created_at'] ?? null)->toBe('2026-05-13 09:00:00'); + expect($history['source'] ?? null)->toBe('order'); + expect($vehicle['source'] ?? null)->toBe('vehicle'); + expect($shared['source'] ?? null)->toBe('booking'); + expect($shared['section'] ?? null)->toBe('this_vehicle'); + expect($booking['section'] ?? null)->toBe('other_customer_vehicle'); + expect($history['section'] ?? null)->toBe('other_customer_vehicle'); + expect($vehicle['section'] ?? null)->toBe('other_customer_vehicle'); + expect($shared['usage_count'] ?? null)->toBe(3); + expect($shared['last_used_at'] ?? null)->toBe('2026-05-15 11:00:00'); + expect(reference_suggestion_by_reference($suggestions, 'REF-DELETED'))->toBeNull(); + expect(reference_suggestion_by_reference($suggestions, ''))->toBeNull(); +}); + +it('orders reference suggestions by match relevance before context and frequency', function (): void { + $department = api_fixtures()->createDepartment(); + $customer = api_fixtures()->createUser(['display_name' => 'Reference Ranking Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Reference Ranking Cashier']); + + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'ABC', + 'created_at' => '2026-05-01 08:00:00', + 'reg_1' => 'OTHER1', + ]); + api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'reference' => 'ABC-PREFIX', + 'datetime' => '2026-05-16 08:00:00', + 'reg_1' => 'MATCH1', + ]); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'X-ABC-CONTAINS', + 'created_at' => '2026-05-17 08:00:00', + 'reg_1' => 'MATCH1', + ]); + + $session = api_fixtures()->createUserSession([ + 'list_orders', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->get('/orders/reference-suggestions?' . http_build_query([ + 'search' => 'ABC', + 'department_id' => $department['id'], + 'customer_id' => $customer['customer_number'], + 'reg_1' => 'MATCH1', + ]), $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $references = array_map( + static fn(array $suggestion): string => (string)($suggestion['reference'] ?? ''), + is_array($response->data()) ? $response->data() : [] + ); + + expect(array_slice($references, 0, 3))->toBe(['ABC', 'ABC-PREFIX', 'X-ABC-CONTAINS']); +}); + +it('enforces authentication, list permission, and department access for reference suggestions', function (): void { + api_test_covers('GET /orders/reference-suggestions', 'auth'); + + $department = api_fixtures()->createDepartment(); + + api_client()->get('/orders/reference-suggestions?department_id=' . $department['id']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Invalid session'); + + $missingListPermission = api_fixtures()->createUserSession([ + 'department_access_' . $department['id'], + ]); + api_client()->get('/orders/reference-suggestions?department_id=' . $department['id'], $missingListPermission['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['list_orders']); + + $missingDepartmentAccess = api_fixtures()->createUserSession(['list_orders']); + api_client()->get('/orders/reference-suggestions?department_id=' . $department['id'], $missingDepartmentAccess['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['department_access_' . $department['id']]); +}); diff --git a/services/nginx/app/tests/Api/api_coverage_manifest.php b/services/nginx/app/tests/Api/api_coverage_manifest.php index 8af7d043..f6ffdd09 100644 --- a/services/nginx/app/tests/Api/api_coverage_manifest.php +++ b/services/nginx/app/tests/Api/api_coverage_manifest.php @@ -25,6 +25,7 @@ return [ 'manual_operations' => [ 'GET /ping', 'PUT /order', + 'GET /orders/reference-suggestions', ], 'happy_only_operations' => [ 'GET /ping', diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index 8af9ab18..5fec0787 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -935,13 +935,44 @@ final class ApiFixtures return $attributeId; } + public function preserveModuleConfig(string $module, string $variable): void + { + $existing = $this->fetchModuleConfig($module, $variable); + $conditions = [ + 'module' => $module, + 'variable' => $variable, + ]; + + $this->cleanup->add(function () use ($conditions, $existing): void { + if ($existing === null) { + $this->deleteWhereIfPossible('module_config', $conditions); + return; + } + + $current = $this->fetchModuleConfig((string)$conditions['module'], (string)$conditions['variable']); + $data = [ + 'value' => $existing['value'] ?? null, + 'type' => $existing['type'] ?? null, + 'created_at' => $existing['created_at'] ?? null, + 'updated_at' => $existing['updated_at'] ?? null, + ]; + + if ($current === null) { + $this->insertRow('module_config', [ + 'module' => $existing['module'] ?? $conditions['module'], + 'variable' => $existing['variable'] ?? $conditions['variable'], + ...$data, + ]); + return; + } + + $this->updateWhere('module_config', $conditions, $data); + }); + } + public function setModuleConfig(string $module, string $variable, string $value, string $type = 'bool'): void { - $moduleEscaped = $this->db->real_escape_string($module); - $variableEscaped = $this->db->real_escape_string($variable); - $existing = $this->queryOneBySql( - "SELECT * FROM module_config WHERE module = '{$moduleEscaped}' AND variable = '{$variableEscaped}' LIMIT 1" - ); + $existing = $this->fetchModuleConfig($module, $variable); if ($existing !== null) { $conditions = [ @@ -1876,6 +1907,16 @@ final class ApiFixtures return $row ?: null; } + private function fetchModuleConfig(string $module, string $variable): ?array + { + $moduleEscaped = $this->db->real_escape_string($module); + $variableEscaped = $this->db->real_escape_string($variable); + + return $this->queryOneBySql( + "SELECT * FROM module_config WHERE module = '{$moduleEscaped}' AND variable = '{$variableEscaped}' LIMIT 1" + ); + } + private function setRedisJson(string $key, array $payload): void { if ($this->redis === null) { diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 1d13da7f..62f8fb99 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -221,6 +221,32 @@ CREATE TABLE IF NOT EXISTS `orders` ( KEY `idx_orders_invoice_collection_id` (`invoice_collection_id`), KEY `idx_orders_reg_1` (`reg_1`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'order_bookings' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `order_bookings` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `customer_number` INT NOT NULL, + `department` INT NOT NULL, + `reg_1` VARCHAR(32) NULL, + `reg_2` VARCHAR(32) NULL, + `reg_3` VARCHAR(32) NULL, + `datetime` DATETIME NULL, + `note` TEXT NULL, + `reference` VARCHAR(255) NULL, + `po` VARCHAR(255) NULL, + `pickup` TINYINT(1) NOT NULL DEFAULT 0, + `items` LONGTEXT NULL, + `order_id` INT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_order_bookings_department` (`department`), + KEY `idx_order_bookings_customer_number` (`customer_number`), + KEY `idx_order_bookings_order_id` (`order_id`), + KEY `idx_order_bookings_reg_1` (`reg_1`), + KEY `idx_order_bookings_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'order_items' => <<<'SQL' CREATE TABLE IF NOT EXISTS `order_items` ( diff --git a/services/nginx/app/tests/Support/Api/ApiTestRuntime.php b/services/nginx/app/tests/Support/Api/ApiTestRuntime.php index 1cc04847..f5d7f940 100644 --- a/services/nginx/app/tests/Support/Api/ApiTestRuntime.php +++ b/services/nginx/app/tests/Support/Api/ApiTestRuntime.php @@ -38,6 +38,8 @@ final class ApiTestRuntime return 'API tests are disabled. Run with RUN_API_TESTS=1.'; } + $this->assertApiDatabaseTargetIsSafe(); + try { $this->bootstrapEnvironment(); $this->bootstrapSchemaIfRequested(); @@ -297,6 +299,8 @@ final class ApiTestRuntime $database = $this->readConfigValue('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target); $port = (int)($this->readConfigValue('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306'); + $this->assertApiDatabaseTargetIsSafe($target, $host, $user, $database, $port); + if ($host === '' || $user === '' || $database === '') { throw new RuntimeException('API tests require CONFIG_DB_HOST, CONFIG_DB_USER and CONFIG_DB_DATABASE to be set.'); } @@ -345,4 +349,47 @@ final class ApiTestRuntime return $liveValue; } + + private function assertApiDatabaseTargetIsSafe( + ?string $target = null, + ?string $host = null, + ?string $user = null, + ?string $database = null, + ?int $port = null + ): void { + if ((string)(getenv('API_TEST_ALLOW_LIVE_DB') ?: '') === '1') { + return; + } + + $target = strtolower(trim((string)($target ?? (getenv('CONFIG_DB_TARGET') ?: 'live')))); + if ($target !== 'debug') { + throw new RuntimeException( + 'Refusing to run API tests against CONFIG_DB_TARGET=live. Use CONFIG_DB_TARGET=debug, or set API_TEST_ALLOW_LIVE_DB=1 for an explicit override.' + ); + } + + $host ??= $this->readConfigValue('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target); + $user ??= $this->readConfigValue('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target); + $database ??= $this->readConfigValue('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target); + $port ??= (int)($this->readConfigValue('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306'); + + $liveHost = trim((string)(getenv('CONFIG_DB_HOST') ?: '')); + $liveUser = trim((string)(getenv('CONFIG_DB_USER') ?: '')); + $liveDatabase = trim((string)(getenv('CONFIG_DB_DATABASE') ?: '')); + $livePort = (int)(trim((string)(getenv('CONFIG_DB_PORT') ?: '3306')) ?: '3306'); + + if ( + $liveHost !== '' && + $liveUser !== '' && + $liveDatabase !== '' && + $host === $liveHost && + $user === $liveUser && + $database === $liveDatabase && + $port === $livePort + ) { + throw new RuntimeException( + 'Refusing to run API tests because CONFIG_DB_TARGET=debug resolves to the configured live database. Point CONFIG_DB_DEBUG_* at an isolated database, or set API_TEST_ALLOW_LIVE_DB=1 for an explicit override.' + ); + } + } } diff --git a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueDetailsSummaryBuilderTest.php b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueDetailsSummaryBuilderTest.php index 1e82654c..48abec37 100644 --- a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueDetailsSummaryBuilderTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueDetailsSummaryBuilderTest.php @@ -82,6 +82,25 @@ it('prefers queue error messages for failed jobs and keeps null-safe outcome fie ]); }); +it('uses queued job payload customer context before result data exists', function (): void { + $summary = economic_transfer_queue_details_summary::buildCollectedInvoiceSummary([ + 'status' => economic_transfer_queue::STATUS_QUEUED, + 'payload' => [ + 'collected_invoice_id' => 17389, + 'customer_number' => '778899', + 'customer_name' => 'Queued Customer A/S', + ], + 'result' => null, + 'progress_message' => 'Queued', + ]); + + expect($summary['target']['collected_invoice_id'])->toBe(17389); + expect($summary['customer'])->toBe([ + 'customer_number' => 778899, + 'name' => 'Queued Customer A/S', + ]); +}); + it('uses deterministic status message fallback when no explicit message exists', function (): void { $expectations = [ [economic_transfer_queue::STATUS_QUEUED, 'Queued'], diff --git a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueRouteHardeningTest.php b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueRouteHardeningTest.php index 55c85098..51cb9ab0 100644 --- a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueRouteHardeningTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueRouteHardeningTest.php @@ -60,11 +60,44 @@ it('enforces retry constraints for collected-invoice queue jobs before retry exe expect($content)->toContain("Collected invoice queue job can only be retried when status is FAILED', 409"); expect($content)->toContain('private function withCollectedInvoiceQueueDetailsSummary(array $job): array'); expect($content)->toContain('private function withCollectedInvoiceQueueDetailsSummaryList(array $jobs): array'); + expect($content)->toContain('private function resolveCollectedInvoiceQueueCustomerSummary(array $customer, int $collected_invoice_id): array'); + expect($content)->toContain('FROM collected_order_invoices coi'); + expect($content)->toContain('LEFT JOIN users u ON u.customer_number = coi.customer_number'); expect($content)->toContain('private function resolveCollectedInvoiceQueueRetryErrorStatus(string $message): int'); expect($content)->toContain('only failed jobs can be retried'); expect($content)->toContain('max retry attempts'); }); +it('exposes collected-invoice queue monitor and per-user terminal clear routes', function (): void { + $content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + $monitorStart = strpos($content, "\$this->get('/collected-invoices/economic/queue/monitor'"); + $dismissStart = strpos($content, "\$this->post('/collected-invoices/economic/queue/dismiss'"); + $dismissTerminalStart = strpos($content, "\$this->post('/collected-invoices/economic/queue/dismiss-terminal'"); + + expect($monitorStart)->not->toBeFalse(); + expect($dismissStart)->not->toBeFalse(); + expect($dismissTerminalStart)->not->toBeFalse(); + + expect($content)->toContain('$limit = $this->parseCollectedInvoiceQueueMonitorLimit();'); + expect($content)->toContain('$this->buildCollectedInvoiceQueueMonitorPayload('); + expect($content)->toContain('private function parseCollectedInvoiceQueueMonitorLimit(): int'); + expect($content)->toContain('limit must be between 1 and 100'); + expect($content)->toContain('private function buildCollectedInvoiceQueueMonitorPayload(economic_transfer_queue $queue, int $user_id, int $limit): array'); + expect($content)->toContain('$queue->listMonitorJobsForUser('); + expect($content)->toContain("'queued' => 0"); + expect($content)->toContain("'in_progress' => 0"); + expect($content)->toContain("'progress_percent' => \$counts['total'] > 0"); + + expect($content)->toContain('$queue->dismissTerminalJobForUser($job_id, (int)$user->id);'); + expect($content)->toContain('Only completed or failed collected invoice queue jobs can be cleared'); + expect($content)->toContain('$queue->dismissTerminalJobsForUser('); + expect($content)->toContain("'dismissed_count' => \$dismissed_count"); +}); + it('runs collected-invoice queue batches through an explicit manual endpoint', function (): void { $content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php index d64b8fc7..733752a0 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php @@ -61,12 +61,15 @@ it('keeps queue status endpoints guarded while collected invoice export routes s $guard_count = preg_match_all('/\$this->ensureEconomicTransferQueueIsAvailable\(\);/', (string)$content); $queue_init_count = preg_match_all('/new economic_transfer_queue\(\);/', (string)$content); - expect($guard_count)->toBe(4); - expect($queue_init_count)->toBe(6); + expect($guard_count)->toBe(7); + expect($queue_init_count)->toBe(9); $queue_endpoint_patterns = [ "/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue\\/monitor'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", "/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue\\/status'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/dismiss'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/dismiss-terminal'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", "/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/retry'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", "/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/run'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", ]; diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php index 46a28e95..f94bb759 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php @@ -18,6 +18,13 @@ it('hardens transfer queue with type validation retry caps and stale lock recove expect($content)->toContain('AND attempts < max_attempts'); expect($content)->toContain('public function listJobs(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null): array'); expect($content)->toContain('public function countJobs(array $statuses = [], ?string $transfer_type = null): int'); + expect($content)->toContain('public function listMonitorJobsForUser(int $user_id, int $limit = 50, ?string $transfer_type = null): array'); + expect($content)->toContain('public function dismissTerminalJobForUser(int $job_id, int $user_id): array'); + expect($content)->toContain('public function dismissTerminalJobsForUser(int $user_id, ?string $transfer_type = null): int'); + expect($content)->toContain('economic_transfer_queue_job_dismissals'); + expect($content)->toContain("Only completed or failed queue jobs can be dismissed"); + expect($content)->toContain('$this->clearDismissalsForJob($job_id);'); + expect($content)->toContain('private function clearDismissalsForJob(int $job_id): void'); expect($content)->toContain('public function processPendingByTransferType(string $transfer_type, int $limit = 10): array'); expect($content)->toContain('private function processPendingInternal(int $limit = 5, ?string $transfer_type = null): array'); expect($content)->toContain('private function claimNextJob(?string $transfer_type = null): ?array'); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php index 39b2a31e..8e68b0dc 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php @@ -43,8 +43,11 @@ it('documents economic transfer queue paths in openapi', function (): void { expect($content)->toContain('/collected-invoices/economic:'); expect($content)->toContain('/collected-invoices/stripe/book:'); expect($content)->toContain('/collected-invoices/economic/queue:'); + expect($content)->toContain('/collected-invoices/economic/queue/monitor:'); expect($content)->toContain('/collected-invoices/economic/queue/status:'); expect($content)->toContain('/collected-invoices/economic/queue/retry:'); + expect($content)->toContain('/collected-invoices/economic/queue/dismiss:'); + expect($content)->toContain('/collected-invoices/economic/queue/dismiss-terminal:'); expect($content)->toContain('/collected-invoices/economic/queue/run:'); expect($content)->toContain('/economic/invoice/draft/export:'); expect($content)->toContain('/economic/invoice/draft/export/status:'); @@ -65,6 +68,9 @@ it('documents economic transfer queue schemas in openapi', function (): void { expect($content)->toContain('EconomicTransferQueueRetryResponse:'); expect($content)->toContain('EconomicTransferQueueRunResponse:'); expect($content)->toContain('EconomicTransferQueueListResponse:'); + expect($content)->toContain('EconomicTransferQueueMonitorResponse:'); + expect($content)->toContain('EconomicTransferQueueDismissResponse:'); + expect($content)->toContain('EconomicTransferQueueDismissTerminalResponse:'); }); it('documents 200 fallback plus 202 queue contracts for export endpoints and keeps 503 on queue lifecycle endpoints', function (): void { @@ -101,9 +107,12 @@ it('documents 200 fallback plus 202 queue contracts for export endpoints and kee } $queue_blocks = [ - economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue:', '/collected-invoices/economic/queue/status:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue:', '/collected-invoices/economic/queue/monitor:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/monitor:', '/collected-invoices/economic/queue/status:'), economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/status:', '/collected-invoices/economic/queue/retry:'), - economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/retry:', '/collected-invoices/economic/queue/run:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/retry:', '/collected-invoices/economic/queue/dismiss:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/dismiss:', '/collected-invoices/economic/queue/dismiss-terminal:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/dismiss-terminal:', '/collected-invoices/economic/queue/run:'), economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/run:', '/collected-invoices/economic/compare:'), economic_transfer_queue_openapi_block($content, '/economic/invoice/draft/export/status:', '/economic/invoice/draft/export/retry:'), economic_transfer_queue_openapi_block($content, '/economic/invoice/export/status:', '/economic/invoice/export/retry:'), @@ -120,7 +129,7 @@ it('documents additive collected-invoice queue pagination metadata and retry con $queue_list_block = economic_transfer_queue_openapi_block( $content, '/collected-invoices/economic/queue:', - '/collected-invoices/economic/queue/status:' + '/collected-invoices/economic/queue/monitor:' ); expect($queue_list_block)->toContain('style: form'); expect($queue_list_block)->toContain('explode: false'); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php index 1cf753c9..b2bb7d0f 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php @@ -10,6 +10,11 @@ it('defines economic transfer queue jobs schema bootstrap table and tracking col expect($content)->toContain('error_message TEXT NULL'); expect($content)->toContain('payload_json JSON NOT NULL'); expect($content)->toContain('result_json JSON NULL'); + expect($content)->toContain('CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_dismissals'); + expect($content)->toContain('queue_job_id BIGINT UNSIGNED NOT NULL'); + expect($content)->toContain('user_id INT NOT NULL'); + expect($content)->toContain('dismissed_status VARCHAR(32) NOT NULL'); + expect($content)->toContain('PRIMARY KEY (queue_job_id, user_id)'); }); it('provides queue processor class constants and processing entrypoint', function (): void { diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodDraftOverlayTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodDraftOverlayTest.php new file mode 100644 index 00000000..5eae3b40 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodDraftOverlayTest.php @@ -0,0 +1,296 @@ +getMethod($method); + $target->setAccessible(true); + return $target->invokeArgs(null, $args); +} + +function invoicing_period_draft_overlay_draft( + int $invoice_collection_id, + int $customer_number, + bool $is_period_relevant = true +): array { + return [ + 'invoice_collection_id' => $invoice_collection_id, + 'customer_number' => $customer_number, + 'created_at' => '2026-04-01 00:00:01', + 'closed_at' => '2026-04-01 00:00:01', + 'is_period_relevant' => $is_period_relevant, + ]; +} + +function invoicing_period_draft_overlay_transaction( + int $id, + ?int $invoice_collection_id, + bool $booked = false, + bool $excluded = false, + ?string $queue_status = null +): array { + return [ + 'id' => $id, + 'booked' => $booked, + 'excluded' => $excluded, + 'invoice_collection_id' => $invoice_collection_id, + 'queue_status' => $queue_status, + 'queue_job_id' => $queue_status === null ? null : 99, + ]; +} + +function invoicing_period_draft_overlay_reset_deleted_at_column_cache(): void +{ + $reflection = new ReflectionClass(InvoicingPeriodRoute::class); + $property = $reflection->getProperty('collectedOrderInvoicesHasDeletedAtColumn'); + $property->setAccessible(true); + $property->setValue(null, null); +} + +class InvoicingPeriodDraftOverlayFakeDbResult +{ + public int $num_rows; + + public function __construct(private array $rows = []) + { + $this->num_rows = count($rows); + } + + public function fetch_assoc(): ?array + { + return array_shift($this->rows); + } +} + +class InvoicingPeriodDraftOverlayFakeDb +{ + public string $selectSql = ''; + + public function __construct(private bool $hasDeletedAtColumn) + { + } + + public function escape_string(string $value): string + { + return addslashes($value); + } + + public function query(string $sql): InvoicingPeriodDraftOverlayFakeDbResult|bool + { + if (str_starts_with($sql, 'SHOW COLUMNS')) { + return new InvoicingPeriodDraftOverlayFakeDbResult( + $this->hasDeletedAtColumn ? [['Field' => 'deleted_at']] : [] + ); + } + + $this->selectSql = $sql; + return false; + } +} + +it('blocks invoicing when all actionable transactions are backed by valid e-conomic drafts', function (): void { + $draft = invoicing_period_draft_overlay_draft(14578, 42424242); + + $customer = [ + 'customer_number' => 42424242, + 'customer_name' => 'Draft Customer', + 'requires_action' => true, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(501, 14578), + ], + ]; + + $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ + $customer, + [14578 => $draft], + [42424242 => [$draft]], + ]); + + expect($result['requires_action'])->toBeFalse(); + expect($result['queue'])->toBe([ + 'has_active_job' => false, + 'statuses' => [], + 'invoice_collection_ids' => [], + 'is_action_blocked' => false, + ]); + expect($result['draft'])->toBe([ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [14578], + 'is_action_blocked' => true, + ]); +}); + +it('keeps invoicing available when valid drafts only cover part of the actionable work', function (): void { + $draft = invoicing_period_draft_overlay_draft(2001, 43434343); + + $customer = [ + 'customer_number' => 43434343, + 'customer_name' => 'Mixed Draft Customer', + 'requires_action' => true, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(601, 2001), + invoicing_period_draft_overlay_transaction(602, null), + ], + ]; + + $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ + $customer, + [2001 => $draft], + [43434343 => [$draft]], + ]); + + expect($result['requires_action'])->toBeTrue(); + expect($result['draft'])->toBe([ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [2001], + 'is_action_blocked' => false, + ]); +}); + +it('excludes errored, booked, deleted, and missing-external-id invoice collections at query time', function (): void { + $hadDb = array_key_exists('db', $GLOBALS); + $originalDb = $GLOBALS['db'] ?? null; + $fakeDb = new InvoicingPeriodDraftOverlayFakeDb(true); + invoicing_period_draft_overlay_reset_deleted_at_column_cache(); + $GLOBALS['db'] = $fakeDb; + + try { + invoicing_period_draft_overlay_invoke('getValidCollectedInvoiceDraftOverlay', [ + [ + 'all' => [ + [ + 'customer_number' => 45454545, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(701, 3001), + ], + 'meta' => [ + 'fixed_pricing' => [ + 'price' => 1200, + ], + ], + ], + ], + ], + '2026-04-01', + '2026-04-30', + ]); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $originalDb; + } else { + unset($GLOBALS['db']); + } + invoicing_period_draft_overlay_reset_deleted_at_column_cache(); + } + + expect($fakeDb->selectSql)->toContain('deleted_at IS NULL'); + expect($fakeDb->selectSql)->toContain('processor = 1'); + expect($fakeDb->selectSql)->toContain('external_id IS NOT NULL'); + expect($fakeDb->selectSql)->toContain("external_id <> ''"); + expect($fakeDb->selectSql)->toContain('booked_invoice_id IS NULL'); + expect($fakeDb->selectSql)->toContain('error_message IS NULL'); +}); + +it('still checks valid drafts when the collection table has no deleted marker column', function (): void { + $hadDb = array_key_exists('db', $GLOBALS); + $originalDb = $GLOBALS['db'] ?? null; + $fakeDb = new InvoicingPeriodDraftOverlayFakeDb(false); + invoicing_period_draft_overlay_reset_deleted_at_column_cache(); + $GLOBALS['db'] = $fakeDb; + + try { + invoicing_period_draft_overlay_invoke('getValidCollectedInvoiceDraftOverlay', [ + [ + 'all' => [ + [ + 'customer_number' => 12345679, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(61415, 17389), + ], + ], + ], + ], + '2026-05-11', + '2026-05-11', + ]); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $originalDb; + } else { + unset($GLOBALS['db']); + } + invoicing_period_draft_overlay_reset_deleted_at_column_cache(); + } + + expect($fakeDb->selectSql)->not->toContain('deleted_at IS NULL'); + expect($fakeDb->selectSql)->toContain('id IN (17389)'); + expect($fakeDb->selectSql)->toContain('processor = 1'); + expect($fakeDb->selectSql)->toContain('error_message IS NULL'); +}); + +it('blocks fixed-pricing and subscription customer-level work when a relevant valid draft exists', function (): void { + $draft = invoicing_period_draft_overlay_draft(3001, 45454545, true); + + $customer = [ + 'customer_number' => 45454545, + 'customer_name' => 'Subscription Draft Customer', + 'requires_action' => true, + 'transactions' => [], + 'meta' => [ + 'fixed_pricing' => [ + 'price' => 1200, + ], + ], + ]; + + $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ + $customer, + [], + [45454545 => [$draft]], + ]); + + expect($result['requires_action'])->toBeFalse(); + expect($result['draft'])->toBe([ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [3001], + 'is_action_blocked' => true, + ]); +}); + +it('keeps queue blocking ahead of the draft label when all work is covered by queue or draft state', function (): void { + $draft = invoicing_period_draft_overlay_draft(5002, 46464646, true); + + $customer = [ + 'customer_number' => 46464646, + 'customer_name' => 'Queue And Draft Customer', + 'requires_action' => true, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(801, 5001, false, false, 'QUEUED'), + invoicing_period_draft_overlay_transaction(802, 5002), + ], + 'queue' => [ + 'has_active_job' => true, + 'statuses' => ['QUEUED'], + 'invoice_collection_ids' => [5001], + 'is_action_blocked' => false, + ], + ]; + + $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ + $customer, + [5002 => $draft], + [46464646 => [$draft]], + ]); + + expect($result['requires_action'])->toBeFalse(); + expect($result['queue']['is_action_blocked'])->toBeTrue(); + expect($result['draft'])->toBe([ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [5002], + 'is_action_blocked' => false, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodQueueOverlayTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodQueueOverlayTest.php index e009d724..2db9e7bf 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodQueueOverlayTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodQueueOverlayTest.php @@ -139,3 +139,22 @@ it('blocks fixed-pricing or subscription customers with no transactions when a r 'is_action_blocked' => true, ]); }); + +it('normalizes targeted customer number filters from comma-separated or repeated values', function (): void { + expect(invoicing_period_queue_overlay_invoke('normalizeCustomerNumbers', [ + '42424242, 43434343,42424242,0,not-a-number', + ]))->toBe([42424242, 43434343]); + + expect(invoicing_period_queue_overlay_invoke('normalizeCustomerNumbers', [ + ['45454545', 45454545, '46464646'], + ]))->toBe([45454545, 46464646]); +}); + +it('filters period customer number candidates to targeted customers only', function (): void { + $result = invoicing_period_queue_overlay_invoke('filterCustomerNumbers', [ + [42424242, '43434343', 45454545], + [43434343, 99999999], + ]); + + expect($result)->toBe([43434343]); +});