<?php
class FinChainsController extends Controller
{
    public function myQueueAction()
    {
        $ac         = new ApprovalChain();
        $chainKey   = (string)($_GET['chain_key']   ?? '');
        $entityType = (string)($_GET['entity_type'] ?? '');
        $qSearch    = trim((string)($_GET['q']      ?? ''));
        $page       = max(1, (int)($_GET['page']    ?? 1));
        $q = $ac->myQueue(
            $this->currentUserId(),
            $_SESSION['user_group_name'] ?? '',
            $chainKey, $entityType, $page, 25, $qSearch
        );
        // Available filter values across THIS user's queue (so we don't offer filters that yield 0)
        $chainOptions  = $this->availableFiltersForQueue('chain',  $_SESSION['user_group_name'] ?? '');
        $entityOptions = $this->availableFiltersForQueue('entity', $_SESSION['user_group_name'] ?? '');

        // Slice 8Z-p · pre-compute the smart detail URL per row so the view stays presentational.
        foreach ($q['rows'] as &$r) $r['detail_url'] = $ac->entityDetailUrl($r);
        unset($r);

        $this->view->setLayout($this->roleLayout());
        $this->view->render('FinChains/myQueue', [
            'page_title'      => 'My Approval Queue',
            'items'           => $q['rows'],
            'total'           => $q['total'],
            'page'            => $q['page'],
            'per_page'        => $q['per_page'],
            'pages'           => $q['pages'],
            'chain_key'       => $chainKey,
            'entity_type'     => $entityType,
            'q'               => $qSearch,
            'chain_options'   => $chainOptions,
            'entity_options'  => $entityOptions,
        ]);
    }

    /** Slice 8Z-n · list of (chain_key,title) or entity_type currently sitting in this user's queue. */
    private function availableFiltersForQueue(string $type, string $userGroup): array
    {
        $ug = strtoupper(trim($userGroup));
        $db = new DB();
        if ($type === 'chain') {
            // Fall back to chain_key when title is blank so the filter chip is never empty.
            return $db->mySelect(
                "SELECT DISTINCT d.chain_key,
                        CASE WHEN d.title IS NULL OR TRIM(d.title) = '' THEN d.chain_key ELSE d.title END AS title
                   FROM fin_approvals a
                   JOIN fin_chain_defs  d ON d.c0d = a.chain_id
                   JOIN fin_chain_steps s ON s.c0d = a.current_step_id
                  WHERE a.status='IN_PROGRESS' AND a.deleted=0
                    AND (UPPER(s.role_required) = :ug OR s.role_required='*')
                  ORDER BY title",
                [':ug' => $ug]
            ) ?: [];
        }
        return $db->mySelect(
            "SELECT DISTINCT a.entity_type
               FROM fin_approvals a
               JOIN fin_chain_steps s ON s.c0d = a.current_step_id
              WHERE a.status='IN_PROGRESS' AND a.deleted=0
                AND (UPPER(s.role_required) = :ug OR s.role_required='*')
              ORDER BY a.entity_type",
            [':ug' => $ug]
        ) ?: [];
    }

    public function inFlightAction()
    {
        $ac    = new ApprovalChain();
        $items = $ac->allInFlight((string)($_GET['chain_key'] ?? ''));
        $this->view->setLayout($this->roleLayout());
        $this->view->render('FinChains/inFlight', [
            'page_title' => 'All In-Flight Approvals',
            'items'      => $items,
        ]);
    }

    public function viewAction($id = 0)
    {
        $ac  = new ApprovalChain();
        $a   = $ac->approvalById((int)$id);
        if (!$a) {
            $this->view->setLayout($this->roleLayout());
            $this->view->render('generals/fb_page', ['fb_msg' => '<div class="alert alert-danger">Approval not found.</div>']);
            return;
        }
        $steps   = $ac->stepsForChain((int)$a['chain_id']);
        $history = $ac->historyFor((int)$id);
        $mayAct  = ($a['status'] === 'IN_PROGRESS')
                   && $ac->mayActOnStep((int)$a['current_step_id'], $_SESSION['user_group_name'] ?? '');

        // Assignment slots that the current step demands (if any) + candidate users per slot
        $curStep      = $ac->stepById((int)$a['current_step_id']);
        $requiredSlots = $curStep ? $ac->requiredSlotsForStep($curStep) : [];
        $slotOptions  = [];
        foreach ($requiredSlots as $slot) $slotOptions[$slot] = $ac->usersInSlot($slot);
        $stagedPayload = $ac->decodePayload((string)($a['step_payload_json'] ?? ''));

        // Load the underlying entity summary — for now, only crm_project_onboarding is wired
        $entity = [];
        if ($a['entity_type'] === 'crm_project_onboarding') {
            $db = new DB();
            $rs = $db->mySelect(
                "SELECT o.*, p.project_no, p.project_name, p.project_type, p.project_customer,
                        p.contract_value AS p_contract, p.currency AS p_currency, p.origin,
                        p.c0d AS project_id
                   FROM crm_project_onboarding o
                   JOIN c_projects p ON p.c0d = o.project_id
                  WHERE o.c0d = :id LIMIT 1",
                [':id' => (int)$a['entity_id']]
            );
            $entity = $rs[0] ?? [];
        }

        // Slice 8Z-l · Payment schedule context — FM/Exec see the batch inline (grouped by method)
        // when approving PAYMENT_SCHEDULE_APPROVAL. The heavy detail loads via /PaymentSchedules/view.
        $scheduleContext = [];
        if ($a['entity_type'] === 'payment_schedule') {
            if (!class_exists('PaymentSchedules')) require_once ROOT . '/app/models/PaymentSchedules.php';
            $ps = new PaymentSchedules();
            $sch = $ps->byId((int)$a['entity_id']);
            if (!empty($sch)) {
                $its = $ps->itemsFor((int)$a['entity_id']);
                $groups = [];
                foreach ($its as $it) {
                    $key = $it['method'] ?: 'UNSET';
                    if (!isset($groups[$key])) $groups[$key] = ['method' => $key, 'items' => [], 'total' => 0.0, 'count' => 0];
                    $groups[$key]['items'][] = $it;
                    $groups[$key]['total']   += (float)$it['amount'];
                    $groups[$key]['count']++;
                }
                $scheduleContext = [
                    'schedule' => $sch,
                    'items'    => $its,
                    'groups'   => array_values($groups),
                ];
                // Slice 8Z-m · at FM step, load the grouped proposal + source accounts (currency-filtered)
                if ($mayAct && ($a['current_step_key'] ?? '') === 'FM_APPROVE') {
                    if (!class_exists('CompanyBankAccounts')) require_once ROOT . '/app/models/CompanyBankAccounts.php';
                    $scheduleContext['proposal_groups']   = $ps->groupedItemsFor((int)$a['entity_id']);
                    $scheduleContext['source_accounts']   = (new CompanyBankAccounts())->all(true, (string)$sch['currency']);
                }
            }
        }

        // Slice 8Z-j Batch 4 — Cash-out context: for lpo approvals, expose LPO + Project + Team +
        // linked Client PO (with items + overheads inline) so approvers see everything they need
        // to decide without leaving the page. Extended in follow-up: project card + team + collapsible PO details.
        $lpoContext = [];
        if ($a['entity_type'] === 'lpo') {
            $db = new DB();
            $lpoRs = $db->mySelect(
                "SELECT l.c0d, l.lpo_no, l.lpo_type, l.amount, l.currency, l.purpose,
                        l.requested_by_name, l.requested_on, l.sourced_from_client_po_id, l.project_id,
                        p.project_no, p.project_name, p.project_type, p.project_customer,
                        p.contract_value, p.currency AS project_currency, p.origin, p.crm_status,
                        p.project_manager_id, p.project_manager_name,
                        p.pc_id, p.pc_name,
                        p.procurement_id, p.procurement_name,
                        p.accountant_id, p.accountant_name,
                        p.fs_id, p.fs_name
                   FROM lpos l
              LEFT JOIN c_projects p ON p.c0d = l.project_id
                  WHERE l.c0d = :id AND l.deleted = 0 LIMIT 1",
                [':id' => (int)$a['entity_id']]
            );
            if (!empty($lpoRs)) {
                $lpoContext['lpo'] = $lpoRs[0];
                // If the LPO is tagged to a Client PO (Discovery + future Supplier LPO paths), pull the PO summary + items + overheads.
                $cpoId = (int)$lpoRs[0]['sourced_from_client_po_id'];
                if ($cpoId > 0) {
                    $cpoRs = $db->mySelect(
                        "SELECT c0d, client_po_no, customer_po_ref, customer_name, po_date,
                                currency, subtotal_sell, subtotal_cog, vat_rate, total, status,
                                incoterm, delivery_deadline, primary_delivery_location,
                                (SELECT COUNT(*) FROM goods_client_po_items i WHERE i.client_po_id = cp.c0d) AS item_count
                           FROM goods_client_pos cp WHERE c0d = :id AND deleted = 0 LIMIT 1",
                        [':id' => $cpoId]
                    );
                    if (!empty($cpoRs)) {
                        $lpoContext['client_po'] = $cpoRs[0];
                        // Items + overheads (both shown in the collapsible pane).
                        if (!class_exists('GoodsClientPos')) require_once ROOT . '/app/models/GoodsClientPos.php';
                        $gcp = new GoodsClientPos();
                        $lpoContext['client_po_items']     = $gcp->itemsFor($cpoId);
                        $lpoContext['client_po_overheads'] = $gcp->overheadsForPo($cpoId, (int)$lpoRs[0]['project_id']);
                        // % of PO for the sanity-check chip.
                        $poTotal = (float)$cpoRs[0]['total'];
                        if ($poTotal > 0) $lpoContext['pct_of_po'] = round(((float)$lpoRs[0]['amount'] / $poTotal) * 100, 2);
                    }
                }
            }
        }

        // Slice 8Z-k · Accountant Prepare payload — providers, settings, existing plan.
        $accountantPrep = [];
        $onAccountantStep = ($mayAct && ($a['current_step_key'] ?? '') === 'ACCOUNTANT_PREPARE');
        if ($onAccountantStep && $a['entity_type'] === 'lpo' && !empty($lpoContext['lpo'])) {
            if (!class_exists('PaymentProviders')) require_once ROOT . '/app/models/PaymentProviders.php';
            if (!class_exists('PayeeAccounts'))    require_once ROOT . '/app/models/PayeeAccounts.php';
            if (!class_exists('FinanceSettings'))  require_once ROOT . '/app/models/FinanceSettings.php';
            $lpoRow = $lpoContext['lpo'];
            $fs = new FinanceSettings();
            // Load requester_by_id fresh (lpoContext only carries the name).
            $reqId = 0;
            if (!empty($lpoContext['lpo']['c0d'])) {
                $db = new DB();
                $r = $db->mySelect("SELECT requested_by FROM lpos WHERE c0d = :id", [':id' => (int)$lpoContext['lpo']['c0d']]);
                $reqId = (int)($r[0]['requested_by'] ?? 0);
            }
            $accountantPrep = [
                'providers'   => (new PaymentProviders())->all('', true),
                'accounts'    => (new PayeeAccounts())->accountsFor('STAFF', $reqId),
                'mobile_max'  => $fs->getFloat('mobile_money_max', 2000000),
                'petty_max'   => $fs->getFloat('petty_cash_max', 200000),
                'cash_needs_reason' => (int)$fs->getFloat('cash_handover_requires_reason', 1) === 1,
                'require_approved'  => (int)$fs->getFloat('require_approved_account', 0) === 1,
            ];
        }

        // Slice 8Z-n · pluck the most recent APPROVE decision (previous step) so the current
        // approver sees "Note from the previous approver" above the action bar.
        $priorNote = [];
        if ($mayAct && !empty($history)) {
            foreach ($history as $h) {
                if ($h['decision'] === 'APPROVE' && !empty(trim((string)$h['comment']))) {
                    $priorNote = $h;
                    break; // history is DESC — first APPROVE is the most recent
                }
            }
        }

        $this->view->setLayout($this->roleLayout());
        $this->view->render('FinChains/view', [
            'page_title'      => 'Approval · ' . ($a['ref_label'] ?? '#' . $id),
            'a'               => $a,
            'steps'           => $steps,
            'history'         => $history,
            'may_act'         => $mayAct,
            'entity'          => $entity,
            'lpo_context'     => $lpoContext,
            'schedule_context'=> $scheduleContext,
            'required_slots'  => $requiredSlots,
            'slot_options'    => $slotOptions,
            'staged_payload'  => $stagedPayload,
            'on_acct_prep'    => $onAccountantStep,
            'accountant_prep' => $accountantPrep,
            'prior_note'      => $priorNote,
        ]);
    }

    public function approveAjAction()
    {
        // assignments[SLOT_NAME][user_id], assignments[SLOT_NAME][user_name]
        $assignments = [];
        if (is_array($_POST['assignments'] ?? null)) {
            foreach ($_POST['assignments'] as $slot => $vals) {
                $slot = strtoupper(trim((string)$slot));
                if ($slot === '' || !is_array($vals)) continue;
                $assignments[$slot] = [
                    'user_id'   => (int)($vals['user_id'] ?? 0),
                    'user_name' => (string)($vals['user_name'] ?? ''),
                ];
            }
        }
        // 8Y: entity-specific pre-transition gate. For TENDER_CONTRACT, FM cannot hand off to
        // Exec until every mandatory contract field is populated.
        $aid = (int)($_POST['approval_id'] ?? 0);
        $preErr = $this->preTransitionCheck($aid);
        if ($preErr !== null) { $this->jsonResponse(['ok' => false, 'msg' => $preErr]); return; }

        $ac = new ApprovalChain();
        $r  = $ac->approve([
            'approval_id' => $aid,
            'user_id'     => $this->currentUserId(),
            'user_name'   => $_SESSION['full_name']       ?? '',
            'user_group'  => $_SESSION['user_group_name'] ?? '',
            'comment'     => (string)($_POST['comment'] ?? ''),
            'assignments' => $assignments,
        ]);
        $this->postDecisionHook($r, $aid, 'APPROVE');
        $this->jsonResponse($r);
    }

    /**
     * Slice 8Z-m · Payment Schedule FM proposal-review approve.
     * Persists source account, then approves the FM step (chain advances to Exec). Per-line
     * amount edits + exclusions are saved separately via updateItemAmountAj / excludeItemAj —
     * this endpoint just locks in the source + advances.
     */
    public function paymentScheduleApproveAjAction()
    {
        header('Content-Type: application/json');
        try {
            $aid = (int)($_POST['approval_id']  ?? 0);
            $src = (int)($_POST['source_account_id'] ?? 0);
            $cmt = (string)($_POST['comment']   ?? '');
            if ($aid <= 0) throw new InvalidArgumentException('approval_id required.');
            if ($src <= 0) throw new InvalidArgumentException('Pick a source account before approving.');

            $ac = new ApprovalChain();
            $a  = $ac->approvalById($aid);
            if (!$a)                                                     throw new RuntimeException('Approval not found.');
            if ($a['entity_type'] !== 'payment_schedule')                throw new RuntimeException('Only payment schedules use this endpoint.');
            if (($a['current_step_key'] ?? '') !== 'FM_APPROVE')         throw new RuntimeException('Not on FM step (current: ' . ($a['current_step_key'] ?? '—') . ').');

            if (!class_exists('PaymentSchedules')) require_once ROOT . '/app/models/PaymentSchedules.php';
            $ps = new PaymentSchedules();
            $srcRes = $ps->setSourceAccount((int)$a['entity_id'], $src);
            if (empty($srcRes['ok'])) { echo json_encode($srcRes); return; }

            // Slice 8Z-n · optional balance snapshot (FM's read of source account balance at approval)
            if (isset($_POST['fm_balance_snapshot']) && $_POST['fm_balance_snapshot'] !== '') {
                $bal = (float)$_POST['fm_balance_snapshot'];
                if ($bal >= 0) $ps->setBalanceSnapshot((int)$a['entity_id'], $bal);
            }

            $r = $ac->approve([
                'approval_id' => $aid,
                'user_id'     => $this->currentUserId(),
                'user_name'   => $_SESSION['full_name']       ?? '',
                'user_group'  => $_SESSION['user_group_name'] ?? '',
                'comment'     => trim($cmt) ?: 'Funded from selected source account · advances to Exec.',
            ]);
            $this->postDecisionHook($r, $aid, 'APPROVE');
            $this->jsonResponse($r);
        } catch (\Throwable $e) { $this->jsonResponse(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    /**
     * Slice 8Z-k · Accountant Prepare endpoint.
     * Applied at the ACCOUNTANT_PREPARE step of INTERNAL_CASH_LARGE_APPROVAL — saves the
     * disburse plan (method / account / scheduled date / notes / override reason if applicable)
     * on the LPO, then advances the chain (equivalent to approve).
     */
    public function accountantPrepAjAction()
    {
        header('Content-Type: application/json');
        try {
            $aid = (int)($_POST['approval_id'] ?? 0);
            if ($aid <= 0) throw new InvalidArgumentException('approval_id required.');
            $ac = new ApprovalChain();
            $a  = $ac->approvalById($aid);
            if (!$a)                                                       throw new RuntimeException('Approval not found.');
            if ($a['entity_type'] !== 'lpo')                              throw new RuntimeException('Only LPO approvals use this endpoint.');
            if (($a['current_step_key'] ?? '') !== 'ACCOUNTANT_PREPARE')  throw new RuntimeException('Not on the Accountant Prepare step (current: ' . ($a['current_step_key'] ?? '—') . ').');

            // Persist the disburse plan on the LPO first.
            if (!class_exists('Lpos')) require_once ROOT . '/app/models/Lpos.php';
            $prep = (new Lpos())->setAccountantPrep((int)$a['entity_id'], [
                'disburse_method'         => (string)($_POST['disburse_method']         ?? ''),
                'disburse_account_id'     => (int)   ($_POST['disburse_account_id']     ?? 0),
                'scheduled_disburse_date' => (string)($_POST['scheduled_disburse_date'] ?? ''),
                'accountant_prep_notes'   => (string)($_POST['accountant_prep_notes']   ?? ''),
                'override_reason'         => (string)($_POST['override_reason']         ?? ''),
            ]);
            if (empty($prep['ok'])) { echo json_encode($prep); return; }

            // If the picked account is PENDING (ad-hoc), log a USE_AD_HOC audit entry against it.
            $acctId = (int)($_POST['disburse_account_id'] ?? 0);
            if ($acctId > 0) {
                if (!class_exists('PayeeAccounts')) require_once ROOT . '/app/models/PayeeAccounts.php';
                $pa = new PayeeAccounts();
                $acct = $pa->byId($acctId);
                if (!empty($acct) && $acct['status'] === 'PENDING') {
                    $reason = trim((string)($_POST['override_reason'] ?? '')) ?: trim((string)($_POST['accountant_prep_notes'] ?? ''));
                    $pa->logAdHocUse($acctId, (int)$a['entity_id'], $this->currentUserId(), $_SESSION['full_name'] ?? '', $reason ?: 'Ad-hoc use during Accountant Prepare.');
                }
            }

            // Advance the chain (equivalent to approve).
            $r = $ac->approve([
                'approval_id' => $aid,
                'user_id'     => $this->currentUserId(),
                'user_name'   => $_SESSION['full_name']       ?? '',
                'user_group'  => $_SESSION['user_group_name'] ?? '',
                'comment'     => 'Prepared for disbursement · method: ' . (string)($_POST['disburse_method'] ?? '') . (($_POST['scheduled_disburse_date'] ?? '') ? ' · scheduled ' . $_POST['scheduled_disburse_date'] : ''),
            ]);
            $this->postDecisionHook($r, $aid, 'APPROVE');
            $this->jsonResponse($r);
        } catch (\Throwable $e) { $this->jsonResponse(['ok' => false, 'msg' => $e->getMessage()]); }
    }

    public function sendBackAjAction()
    {
        $ac = new ApprovalChain();
        $aid = (int)($_POST['approval_id'] ?? 0);
        $r  = $ac->sendBack([
            'approval_id' => $aid,
            'user_id'     => $this->currentUserId(),
            'user_name'   => $_SESSION['full_name']       ?? '',
            'user_group'  => $_SESSION['user_group_name'] ?? '',
            'comment'     => (string)($_POST['comment'] ?? ''),
        ]);
        $this->postDecisionHook($r, $aid, 'SEND_BACK');
        $this->jsonResponse($r);
    }

    public function declineAjAction()
    {
        $ac = new ApprovalChain();
        $aid = (int)($_POST['approval_id'] ?? 0);
        $r  = $ac->decline([
            'approval_id' => $aid,
            'user_id'     => $this->currentUserId(),
            'user_name'   => $_SESSION['full_name']       ?? '',
            'user_group'  => $_SESSION['user_group_name'] ?? '',
            'comment'     => (string)($_POST['comment'] ?? ''),
        ]);
        $this->postDecisionHook($r, $aid, 'DECLINE');
        $this->jsonResponse($r);
    }

    /**
     * 8Y: return the approval to its originator (CRM for TENDER_CONTRACT). Cancels the current
     * approval so CRM can edit terms + re-submit. Logged as SEND_BACK for a clean audit trail.
     */
    public function returnToOriginatorAjAction()
    {
        $ac  = new ApprovalChain();
        $aid = (int)($_POST['approval_id'] ?? 0);
        $r   = $ac->returnToOriginator([
            'approval_id' => $aid,
            'user_id'     => $this->currentUserId(),
            'user_name'   => $_SESSION['full_name']       ?? '',
            'user_group'  => $_SESSION['user_group_name'] ?? '',
            'comment'     => (string)($_POST['comment'] ?? ''),
        ]);
        $this->postDecisionHook($r, $aid, 'RETURN_TO_ORIGINATOR');
        $this->jsonResponse($r);
    }

    /**
     * 8Y: pre-transition validation. Returns an error message if the approve should be blocked,
     * null otherwise. Currently only enforces contract-completeness for TENDER_CONTRACT at
     * FM_VERIFY. Cheap to extend for other entity types.
     */
    private function preTransitionCheck(int $approval_id): ?string
    {
        if ($approval_id <= 0) return null;
        $ac = new ApprovalChain();
        $a  = $ac->approvalById($approval_id);
        if (empty($a)) return null;
        if ($a['entity_type'] === 'TENDER_CONTRACT' && ($a['current_step_key'] ?? '') === 'FM_VERIFY') {
            $missing = (new Tenders())->contractCompletenessMissing((int)$a['entity_id']);
            if (!empty($missing)) {
                return 'Complete the contract before handing over to Executive — missing: ' . implode(', ', $missing) . '.';
            }
        }
        return null;
    }

    /**
     * On terminal APPROVED / DECLINED, flip the underlying entity's status
     * so downstream Ops gates unlock (or reject) accordingly.
     * $decision is the engine verb (APPROVE / SEND_BACK / DECLINE / RETURN_TO_ORIGINATOR) —
     * used by entity-specific notification hooks to know which stakeholders to fan out to.
     */
    private function postDecisionHook(array $r, int $approval_id, string $decision = 'APPROVE'): void
    {
        if (!$r['ok'] || $approval_id <= 0) return;
        $ac = new ApprovalChain();
        $a  = $ac->approvalById($approval_id);
        if (empty($a)) return;

        // 8Y: entity-specific notifications on every transition (submit is handled by the
        // originating model directly since $a doesn't exist until submit returns).
        if ($a['entity_type'] === 'TENDER_CONTRACT') {
            try { (new Tenders())->notifyContractHandoff((int)$a['entity_id'], $decision, $a); }
            catch (\Throwable $e) { /* best-effort */ }
        }

        $db = new DB();

        if ($a['entity_type'] === 'crm_project_onboarding') {
            $onb = $db->mySelect("SELECT project_id FROM crm_project_onboarding WHERE c0d = :id", [':id' => (int)$a['entity_id']]);
            $pid = (int)($onb[0]['project_id'] ?? 0);
            if ($pid <= 0) return;

            if ($a['status'] === 'APPROVED') {
                $db->insertUpdate(
                    "UPDATE c_projects SET crm_status = 'APPROVED', edited_by = :uid WHERE c0d = :id",
                    [':uid' => $this->currentUserId(), ':id' => $pid]
                );
                $db->insertUpdate(
                    "UPDATE crm_project_onboarding SET approved_at = NOW(), approved_by = :uid WHERE c0d = :id",
                    [':uid' => $this->currentUserId(), ':id' => (int)$a['entity_id']]
                );
            } elseif ($a['status'] === 'DECLINED') {
                $db->insertUpdate(
                    "UPDATE c_projects SET crm_status = 'DECLINED', edited_by = :uid WHERE c0d = :id",
                    [':uid' => $this->currentUserId(), ':id' => $pid]
                );
            }
        }
        elseif ($a['entity_type'] === 'lpo') {
            $lm = new Lpos();
            $lm->markDecision((int)$a['entity_id'], $a['status']);
        }
        elseif ($a['entity_type'] === 'payment_schedule') {
            // Slice 8Z-l · terminal APPROVED flips every LPO in the batch to APPROVED (ready
            // for disburse). DECLINED detaches the LPOs so they stay BOOKED + can be re-scheduled.
            if (!class_exists('PaymentSchedules')) require_once ROOT . '/app/models/PaymentSchedules.php';
            $ps = new PaymentSchedules();
            if ($a['status'] === 'APPROVED')      $ps->onApproved((int)$a['entity_id']);
            elseif ($a['status'] === 'DECLINED')  $ps->onDeclined((int)$a['entity_id']);
        }
        elseif ($a['entity_type'] === 'payee_account') {
            // Slice 8Z-k: terminal APPROVE flips the account to APPROVED status + notifies creator.
            if ($a['status'] === 'APPROVED') {
                if (!class_exists('PayeeAccounts')) require_once ROOT . '/app/models/PayeeAccounts.php';
                (new PayeeAccounts())->markApproved((int)$a['entity_id'], $this->currentUserId(), $_SESSION['full_name'] ?? '');
            } elseif ($a['status'] === 'DECLINED') {
                $db->insertUpdate(
                    "UPDATE payee_accounts SET status = 'DISABLED', edited_by = :u, edited_on = NOW() WHERE c0d = :id",
                    [':u' => $this->currentUserId(), ':id' => (int)$a['entity_id']]
                );
            }
        }
        elseif ($a['entity_type'] === 'goods_project_onboarding') {
            // Slice 8g-GoodsCrm: CRM-authored Client PO chain terminal → stamp
            // accountant/procurement/PM/PC assignments + flip crm_status + confirm PO.
            if (!class_exists('GoodsOnboarding')) require_once ROOT . '/app/models/GoodsOnboarding.php';
            $ac2 = new ApprovalChain();
            $stagedPayload = $ac2->decodePayload((string)($a['step_payload_json'] ?? ''));
            (new GoodsOnboarding())->onChainDecision((int)$a['entity_id'], (string)$a['status'], $stagedPayload);
        }
        elseif ($a['entity_type'] === 'goods_rfq') {
            // Batch: flip every LPO in this approval's batch + close out the RFQ
            $lm = new Lpos();
            $lm->markDecisionForApprovalBatch((int)$a['c0d'], $a['status']);
            $newRfqStatus = ($a['status'] === 'APPROVED') ? 'AWARDED'
                          : (($a['status'] === 'DECLINED') ? 'CANCELLED' : 'EVALUATING');
            $db->insertUpdate(
                "UPDATE goods_rfqs SET status = :s, edited_by = :uid, edited_on = NOW() WHERE c0d = :id",
                [':s' => $newRfqStatus, ':uid' => $this->currentUserId(), ':id' => (int)$a['entity_id']]
            );
            // If approved, roll the parent client PO status → SOURCING (Procurement can now issue)
            if ($a['status'] === 'APPROVED') {
                $rfqRs = $db->mySelect("SELECT client_po_id FROM goods_rfqs WHERE c0d = :id", [':id' => (int)$a['entity_id']]);
                $cpoId = (int)($rfqRs[0]['client_po_id'] ?? 0);
                if ($cpoId > 0) {
                    $db->insertUpdate(
                        "UPDATE goods_client_pos SET status = 'SOURCING', edited_by = :uid, edited_on = NOW() WHERE c0d = :id AND status IN ('DRAFT','CONFIRMED')",
                        [':uid' => $this->currentUserId(), ':id' => $cpoId]
                    );
                }
            }
        }
        elseif ($a['entity_type'] === 'TENDER_CONTRACT') {
            // Slice 3: on Exec approval → auto-advance Awarded → Contract signing + auto-create project.
            // Slice 4: also snapshot the latest APPROVED pricing version's lines into the new project's goods_client_pos.
            // Slice 5: hydrate role seats (Accountant/Procurement/PM) staged across the FM_VERIFY + EXEC_APPROVE steps.
            // Slice 8Y: optional "record signing now" shortcut — Exec checkbox skips the ceremonial signing step.
            if ($a['status'] === 'APPROVED') {
                $tenderId = (int)$a['entity_id'];
                $uid  = $this->currentUserId();
                $unm  = $_SESSION['full_name'] ?? '';
                $ac2  = new ApprovalChain();
                $staged = $ac2->decodePayload((string)($a['step_payload_json'] ?? ''));
                $seats  = $staged['assignments'] ?? [];
                $tm   = new Tenders();
                $tm->onContractApproved($tenderId, $uid, $unm, $seats);
                // If a project was just created, snapshot the pricing baseline into its client PO
                $t = $tm->find($tenderId);
                if (!empty($t['linked_project_id'])) {
                    try { (new TenderPricing())->snapshotToProject($tenderId, (int)$t['linked_project_id'], $uid, $unm); }
                    catch (Throwable $e) { /* snapshot is best-effort */ }
                }
                // Slice 8Y: honour the Exec's "record signing now" shortcut. Best-effort — if the signing
                // pre-requisites fail we swallow so the approval itself still lands cleanly.
                if ((string)($_POST['record_signing_now'] ?? '') === '1') {
                    try {
                        $tm->recordContractSigning($tenderId, [
                            'signed_date'     => (string)($_POST['signed_date']     ?? date('Y-m-d')),
                            'signed_venue'    => (string)($_POST['signed_venue']    ?? ''),
                            'our_signatories' => (string)($_POST['our_signatories'] ?? ''),
                            'their_signatories' => '',
                            'notes'           => 'Signing recorded at Executive approval via shortcut.',
                        ], $uid, $unm);
                    } catch (Throwable $e) { /* best-effort */ }
                }
            }
        }
        elseif ($a['entity_type'] === 'TENDER_PRICING') {
            // Slice 4: chain terminates → mark the version APPROVED / DECLINED
            (new TenderPricing())->onPricingDecision((int)$a['entity_id'], (string)$a['status']);
        }
        elseif ($a['entity_type'] === 'WORKS_CR') {
            // Slice 8c-Spine: CR chain terminates → mark APPROVED → activate (supersedes prior ACTIVE)
            if (!class_exists('WorksCrs')) require_once ROOT . '/app/models/WorksCrs.php';
            (new WorksCrs())->onChainDecision((int)$a['entity_id'], (string)$a['status']);
        }
        elseif ($a['entity_type'] === 'WORKS_VARIATION') {
            // Slice 8c-Spine: Variation chain terminates → mark APPROVED → append items to works_boq_items
            if (!class_exists('WorksVariations')) require_once ROOT . '/app/models/WorksVariations.php';
            (new WorksVariations())->onChainDecision((int)$a['entity_id'], (string)$a['status']);
        }
        elseif ($a['entity_type'] === 'SERVICE_REQUISITION') {
            // Slice 8b-4a: Service Requisition chain terminates → mark APPROVED (awaits Accountant issue)
            if (!class_exists('ServiceRequisitions')) require_once ROOT . '/app/models/ServiceRequisitions.php';
            (new ServiceRequisitions())->onChainDecision((int)$a['entity_id'], (string)$a['status']);
        }
        elseif ($a['entity_type'] === 'sales') {
            $sid = (int)$a['entity_id'];
            if ($a['status'] === 'APPROVED') {
                // Accountant Ack terminal — post revenue JE then allocate sale_no
                $jp   = new JournalPosting();
                $jres = $jp->postForSaleAck($sid);
                $jeId = $jres['ok'] ? (int)($jres['id'] ?? 0) : 0;
                $sm   = new Sales();
                $sm->allocateAndAck($sid, $this->currentUserId(), $_SESSION['full_name'] ?? '', $jeId);
            } elseif ($a['status'] === 'DECLINED') {
                $db->insertUpdate(
                    "UPDATE sales SET invoice_workflow_status = 'CANCELLED', edited_by = :uid WHERE c0d = :id",
                    [':uid' => $this->currentUserId(), ':id' => $sid]
                );
            }
        }
    }
}
