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|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; } } }