Rapport-Laravel/app/Console/Commands/NeblogisticPushWorkflowStat...

356 lines
13 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Http\Requests\LambdaRequest;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class NeblogisticPushWorkflowStatus extends Command
{
protected $signature = 'neblogistic:push-workflow-status
{--days= : Optional created_at lookback in days}
{--limit=200 : Max MySQL rows this run}
{--submission-id= : Check one submission only}';
protected $description = 'Compare MySQL neblogistic approved-like statuses to Dynamo form workflow_status (read-only; no approvereject)';
private const APPROVED_LIKE = [
'L1 Approved',
'L2 Approved',
'L3 Approved',
'L4 Approved',
'Approved',
'Dispatched',
'Delivered',
];
public function handle(): int
{
$partition = config('neblogistic.partition', 'rapport-team3');
$formId = config('neblogistic.form_id', '1750914801-153443b9-94d0-4dcb-b3f3-4fa11588a0a9');
$timeout = (int) config('neblogistic.http_timeout', 120);
$limit = max(1, (int) $this->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;
}
}