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

598 lines
22 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Http\Requests\LambdaRequest;
use App\Services\NeblogisticAuditService;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class NeblogisticBackfillApprovalDates extends Command
{
protected $signature = 'neblogistic:backfill-approval-dates
{--days=30 : Look back this many days on created_at (request date)}
{--dry-run : Preview updates without writing}
{--limit=500 : Max rows to process in this run}';
protected $description = 'Backfill missing L1/L2/L3/final approval dates from history, approved_at, approval records, audit trail, or workflow_action_date';
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);
$days = max(1, (int) $this->option('days'));
$limit = max(1, (int) $this->option('limit'));
$dryRun = (bool) $this->option('dry-run');
if (empty($partition) || empty($formId)) {
$this->error('neblogistic.partition and neblogistic.form_id must be configured.');
return 1;
}
if (!Schema::hasColumn('neblogistic', 'l2_approved_at') || !Schema::hasColumn('neblogistic', 'l3_approved_at')) {
$this->error('Run migrations first: l2_approved_at / l3_approved_at columns are missing.');
return 1;
}
$since = now()->subDays($days)->startOfDay();
$statuses = [
'L1 Approved',
'L2 Approved',
'L3 Approved',
'L4 Approved',
'Approved',
'Dispatched',
'Delivered',
];
$records = DB::table('neblogistic')
->whereIn('status', $statuses)
->whereNotNull('submission_id')
->where('submission_id', '!=', '')
// --days applies to request created_at only (not updated_at), so old
// Delivered rows touched recently are not pulled in with last-year history dates.
->where('created_at', '>=', $since)
->where(function ($q) {
// Status-relevant missing dates only (avoid false L2/L3 skips on L1-only rows).
$q->where(function ($l1) {
$l1->where('status', 'L1 Approved')->whereNull('l1_approved_at');
})->orWhere(function ($l2) {
$l2->where('status', 'L2 Approved')
->where(function ($inner) {
$inner->whereNull('l1_approved_at')->orWhereNull('l2_approved_at');
});
})->orWhere(function ($l3) {
$l3->whereIn('status', ['L3 Approved', 'L4 Approved'])
->where(function ($inner) {
$inner->whereNull('l1_approved_at')
->orWhereNull('l2_approved_at')
->orWhereNull('l3_approved_at');
});
})->orWhere(function ($final) {
$final->whereIn('status', ['Approved', 'Dispatched', 'Delivered'])
->where(function ($inner) {
$inner->whereNull('l1_approved_at')
->orWhereNull('l2_approved_at')
->orWhereNull('l3_approved_at')
->orWhereNull('approved_at');
});
});
})
->orderBy('id')
->limit($limit)
->get();
if ($records->isEmpty()) {
$this->info('No neblogistic rows need approval-date backfill.');
return 0;
}
$this->info(sprintf(
'Processing %d row(s) (days=%d, dry-run=%s)...',
$records->count(),
$days,
$dryRun ? 'yes' : 'no'
));
$updated = 0;
$skipped = 0;
$failed = 0;
foreach ($records as $row) {
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 backfill: form API did not return success', [
'neblogistic_id' => $row->id,
'submission_id' => $row->submission_id,
'response' => $response,
]);
$failed++;
continue;
}
$submission = $response->data->submission ?? null;
if (!is_object($submission)) {
Log::warning('Neblogistic backfill: missing submission in response', [
'neblogistic_id' => $row->id,
'submission_id' => $row->submission_id,
]);
$failed++;
continue;
}
$approvals = $response->data->approvals ?? [];
$updates = $this->buildDateUpdates($row, $submission, $approvals);
if ($updates === null || count($updates) === 0) {
$skipped++;
$missing = $this->relevantMissingDateFields($row);
$this->line(sprintf(
'Skipped id=%s status=%s (still missing: %s; no recoverable dates)',
$row->id,
$row->status,
implode(', ', $missing)
));
continue;
}
$this->line(sprintf(
'%s id=%s submission_id=%s status=%s updates=%s',
$dryRun ? '[dry-run]' : 'Updated',
$row->id,
$row->submission_id,
$row->status,
json_encode($updates)
));
if (!$dryRun) {
$oldData = json_decode(json_encode($row), true);
DB::table('neblogistic')->where('id', $row->id)->update($updates);
NeblogisticAuditService::logUpdate(
$row->id,
$oldData,
$updates,
null,
null
);
}
$updated++;
} catch (\Throwable $e) {
$failed++;
Log::error('Neblogistic backfill: exception', [
'neblogistic_id' => $row->id ?? null,
'submission_id' => $row->submission_id ?? null,
'message' => $e->getMessage(),
]);
$this->error(sprintf('Failed id=%s: %s', $row->id ?? '?', $e->getMessage()));
}
}
$this->info(sprintf(
'Done. %s: %d, skipped: %d, failed: %d.',
$dryRun ? 'Would update' : 'Updated',
$updated,
$skipped,
$failed
));
return 0;
}
/**
* Build null-only date updates.
* Priority for L1: approval_history → neblogistic.approved_at → approval records → audit → workflow_action_date.
* Priority for L2/L3: approval_history → approval records → audit → workflow_action_date.
*
* @param object $row
* @param object $submission
* @param mixed $approvals
* @return array<string, string>|null
*/
private function buildDateUpdates($row, $submission, $approvals = []): ?array
{
$levelDates = $this->levelDatesFromApprovalHistory($submission->approval_history ?? null);
// Many L1 Approved rows historically wrote the timestamp into approved_at only.
if (empty($levelDates['l1_approved_at']) && !empty($row->approved_at)) {
$fromApprovedAt = $this->parseDate($row->approved_at);
if ($fromApprovedAt !== null) {
$levelDates['l1_approved_at'] = $fromApprovedAt->format('Y-m-d H:i:s');
}
}
$fromApprovals = $this->levelDatesFromApprovalRecords($submission, $approvals);
$fromAudit = $this->levelDatesFromAuditTrail($row->id);
$workflowActionDate = $this->parseDate($submission->workflow_action_date ?? null);
foreach (['l1_approved_at', 'l2_approved_at', 'l3_approved_at'] as $field) {
if (empty($levelDates[$field]) && !empty($fromApprovals[$field])) {
$levelDates[$field] = $fromApprovals[$field];
}
if (empty($levelDates[$field]) && !empty($fromAudit[$field])) {
$levelDates[$field] = $fromAudit[$field];
}
}
// If still missing the date for the row's current L-level, use workflow_action_date.
if ($workflowActionDate !== null) {
if (preg_match('/^L([1-3])\s+Approved$/i', (string) $row->status, $m)) {
$field = 'l' . $m[1] . '_approved_at';
if (empty($levelDates[$field])) {
$levelDates[$field] = $workflowActionDate->format('Y-m-d H:i:s');
}
}
}
$updates = [];
if (empty($row->l1_approved_at) && !empty($levelDates['l1_approved_at'])) {
$updates['l1_approved_at'] = $levelDates['l1_approved_at'];
}
if (empty($row->l2_approved_at) && !empty($levelDates['l2_approved_at'])) {
$updates['l2_approved_at'] = $levelDates['l2_approved_at'];
}
if (empty($row->l3_approved_at) && !empty($levelDates['l3_approved_at'])) {
$updates['l3_approved_at'] = $levelDates['l3_approved_at'];
}
$finalStatuses = ['Approved', 'Dispatched', 'Delivered'];
if (in_array($row->status, $finalStatuses, true) && empty($row->approved_at)) {
$approvedAt = $this->latestApproveDate($submission);
if ($approvedAt === null) {
$approvedAt = $this->latestApproveDateFromApprovals($approvals);
}
if ($approvedAt === null) {
$approvedAt = $workflowActionDate;
}
if ($approvedAt === null && !empty($fromAudit['approved_at'])) {
$approvedAt = $this->parseDate($fromAudit['approved_at']);
}
if ($approvedAt !== null) {
$updates['approved_at'] = $approvedAt->format('Y-m-d H:i:s');
}
}
if (count($updates) === 0) {
return null;
}
$updates['updated_at'] = now()->format('Y-m-d H:i:s');
return $updates;
}
/**
* @param mixed $approvalHistory
* @return array{l1_approved_at: ?string, l2_approved_at: ?string, l3_approved_at: ?string}
*/
private function levelDatesFromApprovalHistory($approvalHistory): array
{
$dates = [
'l1_approved_at' => null,
'l2_approved_at' => null,
'l3_approved_at' => null,
];
if (!is_array($approvalHistory)) {
return $dates;
}
foreach ($approvalHistory as $entry) {
$entry = is_object($entry) ? get_object_vars($entry) : (array) $entry;
$action = strtolower((string) ($entry['action'] ?? ''));
$actionStatus = (string) ($entry['action_status'] ?? '');
if ($action === 'reject' || preg_match('/rejected/i', $actionStatus)) {
continue;
}
if (!preg_match('/^L([1-3])\s+Approved$/i', $actionStatus, $m)) {
continue;
}
$field = 'l' . $m[1] . '_approved_at';
if (!empty($dates[$field])) {
continue;
}
$parsed = $this->parseDate($entry['action_date'] ?? null);
if ($parsed !== null) {
$dates[$field] = $parsed->format('Y-m-d H:i:s');
}
}
return $dates;
}
/**
* Map Dynamo approval rows to L1/L2/L3 using workflow_approvers order,
* falling back to chronological approve timestamps.
*
* @param object $submission
* @param mixed $approvals
* @return array{l1_approved_at: ?string, l2_approved_at: ?string, l3_approved_at: ?string}
*/
private function levelDatesFromApprovalRecords($submission, $approvals): array
{
$dates = [
'l1_approved_at' => null,
'l2_approved_at' => null,
'l3_approved_at' => null,
];
if (!is_array($approvals) || count($approvals) === 0) {
return $dates;
}
$approved = [];
foreach ($approvals as $approval) {
$approval = is_object($approval) ? get_object_vars($approval) : (array) $approval;
$status = strtolower((string) ($approval['approval_status'] ?? ''));
if ($status !== 'approve' && $status !== 'approved') {
continue;
}
$parsed = $this->parseDate($approval['updatedAt'] ?? $approval['updated_at'] ?? $approval['createdAt'] ?? $approval['created_at'] ?? null);
if ($parsed === null) {
continue;
}
$approved[] = [
'approver_id' => (string) ($approval['approver_id'] ?? ''),
'at' => $parsed,
];
}
if (count($approved) === 0) {
return $dates;
}
$usedApproverIds = [];
$workflowApprovers = $submission->workflow_approvers ?? [];
if (is_array($workflowApprovers) && count($workflowApprovers) > 0) {
foreach ($workflowApprovers as $index => $approverId) {
if ($index > 2) {
break;
}
$field = 'l' . ($index + 1) . '_approved_at';
foreach ($approved as $item) {
if ($item['approver_id'] !== '' && $item['approver_id'] === (string) $approverId) {
$dates[$field] = $item['at']->format('Y-m-d H:i:s');
$usedApproverIds[$item['approver_id']] = true;
break;
}
}
}
}
// Chronological fallback for empty levels using unused approve rows only.
$remaining = array_values(array_filter($approved, function ($item) use ($usedApproverIds) {
return $item['approver_id'] === '' || !isset($usedApproverIds[$item['approver_id']]);
}));
usort($remaining, function ($a, $b) {
return $a['at']->timestamp <=> $b['at']->timestamp;
});
$remainingIndex = 0;
foreach (['l1_approved_at', 'l2_approved_at', 'l3_approved_at'] as $field) {
if (!empty($dates[$field])) {
continue;
}
if (!isset($remaining[$remainingIndex])) {
break;
}
$dates[$field] = $remaining[$remainingIndex]['at']->format('Y-m-d H:i:s');
$remainingIndex++;
}
return $dates;
}
/**
* Read earliest audit trail timestamps when status became L1/L2/L3/Approved.
*
* @return array{l1_approved_at: ?string, l2_approved_at: ?string, l3_approved_at: ?string, approved_at: ?string}
*/
private function levelDatesFromAuditTrail($neblogisticId): array
{
$dates = [
'l1_approved_at' => null,
'l2_approved_at' => null,
'l3_approved_at' => null,
'approved_at' => null,
];
if (!Schema::hasTable('neblogistic_audit_trail')) {
return $dates;
}
$rows = DB::table('neblogistic_audit_trail')
->where('neblogistic_id', $neblogisticId)
->where('action', 'UPDATE')
->orderBy('created_at', 'asc')
->get(['changed_fields', 'new_data', 'created_at']);
foreach ($rows as $audit) {
$status = $this->extractStatusFromAudit($audit);
if ($status === null) {
continue;
}
$at = $this->parseDate($audit->created_at);
if ($at === null) {
continue;
}
$formatted = $at->format('Y-m-d H:i:s');
if ($status === 'L1 Approved' && empty($dates['l1_approved_at'])) {
$dates['l1_approved_at'] = $formatted;
} elseif ($status === 'L2 Approved' && empty($dates['l2_approved_at'])) {
$dates['l2_approved_at'] = $formatted;
} elseif ($status === 'L3 Approved' && empty($dates['l3_approved_at'])) {
$dates['l3_approved_at'] = $formatted;
} elseif ($status === 'Approved' && empty($dates['approved_at'])) {
$dates['approved_at'] = $formatted;
}
}
return $dates;
}
/**
* @param object $audit
*/
private function extractStatusFromAudit($audit): ?string
{
$changed = json_decode($audit->changed_fields ?? '', true);
if (is_array($changed) && isset($changed['status']['new'])) {
return (string) $changed['status']['new'];
}
$newData = json_decode($audit->new_data ?? '', true);
if (is_array($newData) && isset($newData['status'])) {
return (string) $newData['status'];
}
return null;
}
/**
* Newest approve action_date from approval_history.
*
* @param object $submission
*/
private function latestApproveDate($submission): ?Carbon
{
$history = $submission->approval_history ?? null;
if (!is_array($history)) {
return null;
}
foreach ($history as $entry) {
$entry = is_object($entry) ? get_object_vars($entry) : (array) $entry;
$action = strtolower((string) ($entry['action'] ?? ''));
$actionStatus = (string) ($entry['action_status'] ?? '');
if ($action === 'reject' || preg_match('/rejected/i', $actionStatus)) {
continue;
}
if ($action === 'approve' || preg_match('/approved/i', $actionStatus)) {
$parsed = $this->parseDate($entry['action_date'] ?? null);
if ($parsed !== null) {
return $parsed;
}
}
}
return null;
}
/**
* @param mixed $approvals
*/
private function latestApproveDateFromApprovals($approvals): ?Carbon
{
if (!is_array($approvals)) {
return null;
}
$latest = null;
foreach ($approvals as $approval) {
$approval = is_object($approval) ? get_object_vars($approval) : (array) $approval;
$status = strtolower((string) ($approval['approval_status'] ?? ''));
if ($status !== 'approve' && $status !== 'approved') {
continue;
}
$parsed = $this->parseDate($approval['updatedAt'] ?? $approval['updated_at'] ?? $approval['createdAt'] ?? $approval['created_at'] ?? null);
if ($parsed === null) {
continue;
}
if ($latest === null || $parsed->gt($latest)) {
$latest = $parsed;
}
}
return $latest;
}
/**
* Fields that are considered missing for the row's current status.
*
* @param object $row
* @return string[]
*/
private function relevantMissingDateFields($row): array
{
$missing = [];
$status = (string) ($row->status ?? '');
if ($status === 'L1 Approved') {
if (empty($row->l1_approved_at)) {
$missing[] = 'l1_approved_at';
}
return $missing;
}
if ($status === 'L2 Approved') {
if (empty($row->l1_approved_at)) {
$missing[] = 'l1_approved_at';
}
if (empty($row->l2_approved_at)) {
$missing[] = 'l2_approved_at';
}
return $missing;
}
if (in_array($status, ['L3 Approved', 'L4 Approved'], true)) {
if (empty($row->l1_approved_at)) {
$missing[] = 'l1_approved_at';
}
if (empty($row->l2_approved_at)) {
$missing[] = 'l2_approved_at';
}
if (empty($row->l3_approved_at)) {
$missing[] = 'l3_approved_at';
}
return $missing;
}
if (in_array($status, ['Approved', 'Dispatched', 'Delivered'], true)) {
if (empty($row->l1_approved_at)) {
$missing[] = 'l1_approved_at';
}
if (empty($row->l2_approved_at)) {
$missing[] = 'l2_approved_at';
}
if (empty($row->l3_approved_at)) {
$missing[] = 'l3_approved_at';
}
if (empty($row->approved_at)) {
$missing[] = 'approved_at';
}
}
return $missing;
}
/**
* @param mixed $raw
*/
private function parseDate($raw): ?Carbon
{
if ($raw === null || $raw === '') {
return null;
}
try {
return Carbon::parse($raw);
} catch (\Exception $e) {
return null;
}
}
}