598 lines
22 KiB
PHP
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;
|
|
}
|
|
}
|
|
}
|