option('limit')); $submissionId = trim((string) $this->option('submission-id')); $daysOption = $this->option('days'); if (empty($partition) || empty($formId)) { $this->error('neblogistic.partition and neblogistic.form_id must be configured.'); return 1; } $query = DB::table('neblogistic') ->select('id', 'submission_id', 'status') ->whereNotNull('submission_id') ->where('submission_id', '!=', ''); if ($submissionId !== '') { $query->where('submission_id', $submissionId); } else { $query->whereIn('status', self::APPROVED_LIKE); if ($daysOption !== null && $daysOption !== '') { $days = max(1, (int) $daysOption); $query->where('created_at', '>=', now()->subDays($days)->startOfDay()); } } $records = $query->orderByDesc('id')->limit($limit)->get(); if ($records->isEmpty()) { $this->info('No neblogistic rows to compare.'); Log::info('Neblogistic push-workflow-status compare: no rows'); return 0; } $this->info(sprintf('Comparing %d row(s), latest first. partition=%s form_id=%s', $records->count(), $partition, $formId)); Log::info('Neblogistic push-workflow-status compare started', [ 'rows' => $records->count(), 'limit' => $limit, 'days' => $daysOption, 'submission_id' => $submissionId !== '' ? $submissionId : null, 'partition' => $partition, 'form_id' => $formId, ]); $checked = 0; $inSync = 0; $mismatch = 0; $failed = 0; $reportRows = []; foreach ($records as $row) { $mysqlStatus = trim((string) ($row->status ?? '')); if (!in_array($mysqlStatus, self::APPROVED_LIKE, true)) { $this->warn(sprintf( 'Skipping neblogistic id=%s submission_id=%s status=%s (not approved-like).', $row->id, $row->submission_id, $mysqlStatus === '' ? '(empty)' : $mysqlStatus )); continue; } $checked++; $this->line(sprintf( '[%d/%d] Checking id=%s submission_id=%s mysql=%s ...', $checked, $records->count(), $row->id, $row->submission_id, $mysqlStatus )); $result = $this->compareRow($row, $partition, $formId, $timeout); $this->line(sprintf( ' -> %s reason=%s dynamo=%s history=%s next=%s', $result['outcome'], $result['reason'], $result['dynamo_workflow_status'] !== '' ? $result['dynamo_workflow_status'] : '(empty)', $result['dynamo_history_status'] !== '' ? $result['dynamo_history_status'] : '(empty)', $result['workflow_next_approver'] !== '' ? $result['workflow_next_approver'] : '(empty)' )); Log::info('Neblogistic push-workflow-status row', [ 'neblogistic_id' => $row->id, 'submission_id' => $row->submission_id, 'mysql_status' => $mysqlStatus, 'outcome' => $result['outcome'], 'reason' => $result['reason'], 'dynamo_workflow_status' => $result['dynamo_workflow_status'], 'dynamo_history_status' => $result['dynamo_history_status'], 'workflow_next_approver' => $result['workflow_next_approver'], ]); if ($result['outcome'] === 'in_sync') { $inSync++; continue; } if ($result['outcome'] === 'failed') { $failed++; } else { $mismatch++; } $reportRows[] = [ $row->id, $row->submission_id, (string) ($row->status ?? ''), $result['dynamo_workflow_status'], $result['dynamo_history_status'], $result['workflow_next_approver'], $result['reason'], ]; } if ($reportRows !== []) { $this->table( ['mysql_id', 'submission_id', 'mysql_status', 'dynamo_workflow_status', 'dynamo_history_status', 'workflow_next_approver', 'reason'], $reportRows ); } else { $this->info('No mismatches or failures.'); } $summary = sprintf( 'Done. checked: %d, in_sync: %d, mismatch: %d, failed: %d.', $checked, $inSync, $mismatch, $failed ); $this->info($summary); Log::info('Neblogistic push-workflow-status compare', [ 'checked' => $checked, 'in_sync' => $inSync, 'mismatch' => $mismatch, 'failed' => $failed, 'limit' => $limit, 'days' => $daysOption, 'submission_id' => $submissionId !== '' ? $submissionId : null, ]); return 0; } /** * @param object $row * @return array{outcome: string, reason: string, dynamo_workflow_status: string, dynamo_history_status: string, workflow_next_approver: string} */ private function compareRow($row, string $partition, string $formId, int $timeout): array { $empty = [ 'dynamo_workflow_status' => '', 'dynamo_history_status' => '', 'workflow_next_approver' => '', ]; try { $response = LambdaRequest::getrequest( 'form', 'formentity/getsubmissiondetails', [ 'partition' => $partition, 'form_id' => $formId, 'id' => $row->submission_id, ], '', $timeout > 0 ? $timeout : null ); if (!is_object($response) || ($response->status ?? '') !== 'success') { Log::warning('Neblogistic push-workflow-status: form API did not return success', [ 'neblogistic_id' => $row->id, 'submission_id' => $row->submission_id, ]); return $this->result('failed', 'api_failed', $empty); } $submission = $response->data->submission ?? null; if (!is_object($submission)) { Log::warning('Neblogistic push-workflow-status: missing submission', [ 'neblogistic_id' => $row->id, 'submission_id' => $row->submission_id, ]); return $this->result('failed', 'missing_submission', $empty); } $workflowStatus = strtolower(trim((string) ($submission->workflow_status ?? ''))); $historyStatus = $this->statusFromApprovalHistory($submission->approval_history ?? null) ?? ''; $nextApprover = (string) ($submission->workflow_next_approver ?? ''); $meta = [ 'dynamo_workflow_status' => $workflowStatus, 'dynamo_history_status' => $historyStatus, 'workflow_next_approver' => $nextApprover, ]; $mysqlStatus = trim((string) ($row->status ?? '')); if ($workflowStatus === 'rejected') { return $this->result('mismatch', 'dynamo_rejected', $meta); } if ($workflowStatus === 'approved') { return $this->result('in_sync', 'workflow_approved', $meta); } if ($this->historyMeetsMysqlLevel($historyStatus, $mysqlStatus)) { return $this->result('in_sync', 'sequential_pending', $meta); } if ($historyStatus === '') { return $this->result('mismatch', 'empty_history', $meta); } return $this->result('mismatch', 'behind_mysql', $meta); } catch (\Throwable $e) { Log::error('Neblogistic push-workflow-status: exception', [ 'neblogistic_id' => $row->id ?? null, 'submission_id' => $row->submission_id ?? null, 'message' => $e->getMessage(), ]); return $this->result('failed', 'api_failed', $empty); } } /** * @param array{dynamo_workflow_status: string, dynamo_history_status: string, workflow_next_approver: string} $meta * @return array{outcome: string, reason: string, dynamo_workflow_status: string, dynamo_history_status: string, workflow_next_approver: string} */ private function result(string $outcome, string $reason, array $meta): array { return [ 'outcome' => $outcome, 'reason' => $reason, 'dynamo_workflow_status' => $meta['dynamo_workflow_status'], 'dynamo_history_status' => $meta['dynamo_history_status'], 'workflow_next_approver' => $meta['workflow_next_approver'], ]; } /** * True when Dynamo history already reflects the MySQL approval level * (including real sequential L2/L3 still pending on workflow_status). */ private function historyMeetsMysqlLevel(string $historyStatus, string $mysqlStatus): bool { if ($historyStatus === '' || $mysqlStatus === '') { return false; } $historyLevel = $this->approvalLevel($historyStatus); $mysqlLevel = $this->approvalLevel($mysqlStatus); if ($historyLevel === null || $mysqlLevel === null) { return strcasecmp($historyStatus, $mysqlStatus) === 0; } return $historyLevel >= $mysqlLevel; } private function approvalLevel(string $status): ?int { if (preg_match('/^L([1-4])\s+Approved$/i', $status, $m)) { return (int) $m[1]; } if (in_array($status, ['Approved', 'Dispatched', 'Delivered'], true)) { return 5; } return null; } /** * Mirror metric.js neblogisticStatusFromApprovalHistory (newest first). * * @param mixed $approvalHistory */ private function statusFromApprovalHistory($approvalHistory): ?string { if (!is_array($approvalHistory) || count($approvalHistory) === 0) { return null; } $known = [ 'L1 Approved' => true, 'L2 Approved' => true, 'L3 Approved' => true, 'L4 Approved' => true, 'Rejected' => true, ]; $latest = $approvalHistory[0]; $latest = is_object($latest) ? get_object_vars($latest) : (array) $latest; $action = (string) ($latest['action'] ?? ''); $actionStatus = (string) ($latest['action_status'] ?? ''); if ($action === 'reject' || preg_match('/rejected/i', $actionStatus)) { return 'Rejected'; } if (isset($known[$actionStatus])) { return $actionStatus; } if (preg_match('/^L([1-4])\s+Approved$/i', $actionStatus, $m)) { return 'L' . $m[1] . ' Approved'; } $maxLevel = 0; foreach ($approvalHistory as $entry) { $entry = is_object($entry) ? get_object_vars($entry) : (array) $entry; $entryAction = (string) ($entry['action'] ?? ''); $entryStatus = (string) ($entry['action_status'] ?? ''); if ($entryAction === 'reject' || preg_match('/rejected/i', $entryStatus)) { return 'Rejected'; } if (preg_match('/^L([1-4])\s+Approved$/i', $entryStatus, $m)) { $maxLevel = max($maxLevel, (int) $m[1]); } } return $maxLevel > 0 ? ('L' . $maxLevel . ' Approved') : null; } }