<?php
/**
 * Slice 8b-4d · GL Close e2e (RETIRED → CLOSED with balanced journal entry).
 *
 *   1) Provision project · drive requisition through draft → chain → APPROVED → issue → retire → RETIRED.
 *   2) openCloseForAccountant() lists this requisition.
 *   3) Role guard · PC tries to close → BLOCKED.
 *   4) State guard · Accountant tries to close a non-RETIRED req → BLOCKED.
 *   5) Accountant closes RETIRED requisition · JE posted · req → CLOSED.
 *   6) Verify journal_entries row · source_type=SERVICE_REQUISITION · source_id=req · balanced (DR=CR).
 *   7) Verify journal_lines · one debit per category with spend > 0 · account codes correctly mapped.
 *   8) Verify project_id stamped on every journal_line.
 *   9) Verify credit line uses coa_id from the picked bank_account · bank_account_id set.
 *  10) Idempotency guard · second close attempt → BLOCKED.
 *  11) Requisition header · journal_entry_id + closed_by + closed_by_name + closed_on populated.
 *  12) PC notified · REQUISITION_CLOSED notification landed with JE reference in body.
 *  13) openCloseForAccountant() no longer includes the row.
 */
define('DS', DIRECTORY_SEPARATOR);
define('ROOT', dirname(__DIR__));
require_once ROOT . DS . 'config' . DS . 'config.php';
require_once ROOT . DS . 'core' . DS . 'Application.php';
require_once ROOT . DS . 'core' . DS . 'Model.php';
require_once ROOT . DS . 'core' . DS . 'DB.php';
$_SESSION = $_SESSION ?? [];
foreach ([
    'ApprovalChain.php','Tenders.php','TenderPricing.php','ProjectStarter.php','Notifications.php',
    'ServiceRequisitions.php','JournalPosting.php',
] as $m) require_once ROOT . DS . 'app' . DS . 'models' . DS . $m;

function h(string $t): void { echo "\n==== $t ====\n"; }
function p(string $t): void { echo "  $t\n"; }
function ok(bool $c, string $m): void { echo ($c ? "  ✓ " : "  ✗ ") . $m . "\n"; }
function boom(string $why) { echo "\nFATAL: $why\n"; exit(1); }

$db = new DB();
$ac = new ApprovalChain();
$tm = new Tenders();
$sr = new ServiceRequisitions();
$nm = new Notifications();

$uidCrm = 100; $unmCrm = 'CRM Test User';       $ugCrm  = 'CRM';
$uidFm  = 3;   $unmFm  = 'Finance Manager Test'; $ugFm  = 'Finance Manager';
$uidExec= 2;   $unmExec= 'Executive Test';      $ugExec = 'Executive';
$accId  = 4;   $accNm  = 'Accountant Test';    $ugAcc = 'Accountant';
$procId = 8;   $procNm = 'Procurement Test';
$pmId   = 5;   $pmNm   = 'Project Manager Test';
$pcRow  = $db->mySelect("SELECT c0d, full_name FROM users WHERE user_group_name='Projects Coordinator' AND status='ACTIVE' AND deleted=0 ORDER BY c0d LIMIT 1", []);
$pcId   = (int)($pcRow[0]['c0d'] ?? 0);
$pcNm   = (string)($pcRow[0]['full_name'] ?? '');
$ugPc   = 'Projects Coordinator';

$bankRow = $db->mySelect("SELECT c0d, coa_id, account_name FROM bank_accounts WHERE is_active = 1 ORDER BY c0d LIMIT 1", []);
if (empty($bankRow)) boom('No active bank_accounts row');
$bankId  = (int)$bankRow[0]['c0d'];
$bankCoa = (int)$bankRow[0]['coa_id'];

$stamp = date('YmdHis');

/* ── 1 ── Full pipeline to RETIRED ────────────────────────────────── */
h('1. Pipeline: project → requisition → RETIRED');
$fb = $db->insertUpdate(
    "INSERT INTO tender_tracker
        (tender_no, tender_title, source_type, buyer_name, project_type,
         date_received, estimated_value, currency, tender_stage, tender_status,
         created_by, created_by_name, created_on)
     VALUES (:tn, :tt, 'PRIVATE_CLIENT', 'Vodacom TZ', 'services',
         CURDATE(), 20000000, 'TZS', 'PREP', 'OPEN', :u, :un, NOW())",
    [':tn' => 'T-8B4D-' . $stamp, ':tt' => 'SLICE 8b-4d GL close verify', ':u' => $uidCrm, ':un' => $unmCrm]
);
$tid = (int)($fb['last_id'] ?? 0);
foreach (['SUBMISSION','FOLLOW_UP','OPENING'] as $s) $tm->advanceStage($tid, $s, 'x', $uidCrm, $unmCrm);
$tm->recordResult($tid, 'WON', date('Y-m-d'), '', 20000000, '', '', $uidCrm, $unmCrm);
$tm->advanceStage($tid, 'AWARDED', 'a', $uidCrm, $unmCrm);
$tm->saveContractTerms($tid, ['contract_value'=>20000000], $uidFm, $ugFm, $unmFm);
$rC = $ac->submit(['chain_key'=>'TENDER_CONTRACT_APPROVAL','entity_type'=>'TENDER_CONTRACT','entity_id'=>$tid,'ref_label'=>'x','return_url'=>PROOT.'Tenders/view/'.$tid,'user_id'=>$uidCrm,'user_name'=>$unmCrm,'user_group'=>$ugCrm]);
$aidC = (int)$rC['approval_id'];
$ac->approve(['approval_id'=>$aidC,'user_id'=>$uidFm,'user_name'=>$unmFm,'user_group'=>$ugFm,'comment'=>'','assignments'=>['ACCOUNTANT'=>['user_id'=>$accId,'user_name'=>$accNm],'PROCUREMENT'=>['user_id'=>$procId,'user_name'=>$procNm]]]);
$ac->approve(['approval_id'=>$aidC,'user_id'=>$uidExec,'user_name'=>$unmExec,'user_group'=>$ugExec,'comment'=>'','assignments'=>['PROJECT_MANAGER'=>['user_id'=>$pmId,'user_name'=>$pmNm]]]);
$aRow = $ac->approvalById($aidC);
$stg  = $ac->decodePayload((string)($aRow['step_payload_json'] ?? ''));
$tm->onContractApproved($tid, $uidExec, $unmExec, $stg['assignments'] ?? []);
$t = $tm->find($tid); $pid = (int)$t['linked_project_id'];

$reqId = $sr->draft($pid, ['title' => 'Site 05 · GL close test'], $pcId, $pcNm);
$sr->addLine($reqId, 'FUEL',      ['description'=>'Diesel budget','qty'=>50, 'unit'=>'L',  'unit_cost'=>3200]);  // 160,000
$sr->addLine($reqId, 'MEALS',     ['description'=>'Techs meals',  'qty'=>4,  'unit'=>'day','unit_cost'=>15000]); //  60,000
$sr->addLine($reqId, 'TRANSPORT', ['description'=>'Boda hire',    'qty'=>2,  'unit'=>'day','unit_cost'=>25000]); //  50,000
$res = $sr->submitForApproval($reqId, $pcId, $pcNm, 'Projects Coordinator');
$aid = (int)$res['approval_id'];
foreach ([[$pcId,$pcNm,'Projects Coordinator'],[$pmId,$pmNm,'Project Manager'],[$uidFm,$unmFm,$ugFm],[$uidExec,$unmExec,$ugExec]] as $ap) {
    $ac->approve(['approval_id'=>$aid,'user_id'=>$ap[0],'user_name'=>$ap[1],'user_group'=>$ap[2],'comment'=>'OK']);
}
$sr->onChainDecision($reqId, 'APPROVED');
$sr->issue($reqId, ['issue_reference' => 'PV-2026-CL01'], $accId, $accNm, $ugAcc);
$sr->startRetirement($reqId, $pcId, $ugPc);
// Record 3 expenses across 3 categories (retired 260,000; issued was 270,000 → variance +10,000)
$sr->addExpense($reqId, 'FUEL',      ['description'=>'Diesel actual','amount'=>155000,'receipt_ref'=>'EFD-C001'], $pcId, $pcNm);
$sr->addExpense($reqId, 'MEALS',     ['description'=>'Meals actual','amount'=>55000, 'receipt_ref'=>'D-1'],       $pcId, $pcNm);
$sr->addExpense($reqId, 'TRANSPORT', ['description'=>'Boda actual', 'amount'=>50000],                              $pcId, $pcNm);
$sr->submitRetirement($reqId, $pcId, $pcNm);
$sr->approveRetirement($reqId, $accId, $accNm, $ugAcc, 'Reviewed OK; unspent 10k returned to till');
$req = $sr->findById($reqId);
p("Requisition {$req['req_no']} · status={$req['status']} · issued=" . number_format((float)$req['issue_amount']) . " · retired=" . number_format((float)$req['retired_amount']) . " · variance=" . number_format((float)$req['variance_amount']));
ok($req['status'] === 'RETIRED', 'Reached RETIRED');
ok((int)$req['journal_entry_id'] === 0, 'No JE yet');

/* ── 2 ── openCloseForAccountant() ────────────────────────────────── */
h('2. openCloseForAccountant() includes this requisition');
$queue = $sr->openCloseForAccountant();
$here = array_filter($queue, fn($r) => (int)$r['c0d'] === $reqId);
ok(!empty($here), 'Requisition in close queue');

/* ── 3 ── Role guard ──────────────────────────────────────────────── */
h('3. PC tries to close → BLOCKED');
try { $sr->close($reqId, $bankId, $pcId, $pcNm, $ugPc); ok(false, 'Allowed'); }
catch (\Throwable $e) { ok(strpos($e->getMessage(), 'Only Accountant/FM/Admin') !== false, 'Blocked · ' . $e->getMessage()); }

/* ── 4 ── State guard (build a non-RETIRED req and try to close it) ─ */
h('4. State guard · close non-RETIRED requisition → BLOCKED');
$draftId = $sr->draft($pid, ['title' => 'Fresh draft (state-guard test)'], $pcId, $pcNm);
try { $sr->close($draftId, $bankId, $accId, $accNm, $ugAcc); ok(false, 'Allowed'); }
catch (\Throwable $e) { ok(strpos($e->getMessage(), 'RETIRED') !== false || strpos($e->getMessage(), 'only RETIRED') !== false, 'Blocked · ' . $e->getMessage()); }

/* ── 5 ── Accountant closes ───────────────────────────────────────── */
h('5. Accountant closes RETIRED requisition');
$pcUnreadBefore = $nm->unreadCountForUser($pcId);
$jeId = $sr->close($reqId, $bankId, $accId, $accNm, $ugAcc);
$req = $sr->findById($reqId);
p("Close returned je_id=$jeId · req.status={$req['status']} · req.journal_entry_id={$req['journal_entry_id']}");
ok($req['status'] === 'CLOSED', 'Status → CLOSED');
ok((int)$req['journal_entry_id'] === $jeId && $jeId > 0, 'journal_entry_id stamped');
ok(!empty($req['closed_on']), 'closed_on stamped');
ok((int)$req['closed_by'] === $accId && $req['closed_by_name'] === $accNm, 'closed_by = Accountant');

/* ── 6 ── Verify journal_entries row ──────────────────────────────── */
h('6. JE header sanity');
$je = $db->mySelect("SELECT * FROM journal_entries WHERE c0d = :id", [':id' => $jeId])[0];
p("JE {$je['je_no']} · source_type={$je['source_type']} · source_id={$je['source_id']} · Dr=" . number_format((float)$je['total_debit']) . " Cr=" . number_format((float)$je['total_credit']));
ok($je['source_type'] === 'SERVICE_REQUISITION',    'source_type = SERVICE_REQUISITION');
ok((int)$je['source_id'] === $reqId,                 'source_id links back to requisition');
ok($je['source_ref'] === $req['req_no'],             'source_ref = req_no');
ok(abs((float)$je['total_debit'] - (float)$je['total_credit']) < 0.01, 'Balanced (DR = CR)');
ok(abs((float)$je['total_debit'] - (float)$req['retired_amount']) < 0.01, 'Totals = retired_amount (260,000)');

/* ── 7 ── Verify journal_lines · account codes correctly mapped ── */
h('7. Journal lines · debits split by category');
$lines = $db->mySelect("SELECT * FROM journal_lines WHERE journal_entry_id = :je ORDER BY line_no", [':je' => $jeId]);
p("Total lines: " . count($lines));
foreach ($lines as $ln) p(sprintf("  #%d %s · %-40s Dr=%s Cr=%s", $ln['line_no'], $ln['account_code'], $ln['account_name'], number_format((float)$ln['debit']), number_format((float)$ln['credit'])));
$debitLines = array_filter($lines, fn($l) => (float)$l['debit'] > 0);
$creditLines = array_filter($lines, fn($l) => (float)$l['credit'] > 0);
ok(count($debitLines) === 3,  '3 debit lines (one per spent category)');
ok(count($creditLines) === 1, '1 credit line (bank)');
$byCode = [];
foreach ($debitLines as $l) $byCode[$l['account_code']] = ($byCode[$l['account_code']] ?? 0) + (float)$l['debit'];
// FUEL(155k)+TRANSPORT(50k) → 6210 = 205k; MEALS(55k) → 6290 = 55k
ok(abs(($byCode['6210'] ?? 0) - 205000) < 0.01, 'Debit 6210 · FUEL + TRANSPORT = 205,000');
ok(abs(($byCode['6290'] ?? 0) - 55000) < 0.01,  'Debit 6290 · MEALS = 55,000');

/* ── 8 ── project_id stamped ─────────────────────────────────────── */
h('8. project_id stamped on every journal line');
$badLines = array_filter($lines, fn($l) => (int)$l['project_id'] !== $pid);
ok(empty($badLines), "All " . count($lines) . " lines carry project_id = $pid");

/* ── 9 ── Credit line uses bank coa_id + bank_account_id set ────── */
h('9. Credit line binds to bank');
$creditLine = reset($creditLines);
p("Credit line · code={$creditLine['account_code']} · account_id={$creditLine['account_id']} · bank_account_id={$creditLine['bank_account_id']}");
ok((int)$creditLine['bank_account_id'] === $bankId, 'bank_account_id = picked bank');
ok((int)$creditLine['account_id'] === $bankCoa, 'account_id = bank_accounts.coa_id');

/* ── 10 ── Idempotency guard ─────────────────────────────────────── */
h('10. Second close attempt → BLOCKED');
try { $sr->close($reqId, $bankId, $accId, $accNm, $ugAcc); ok(false, 'Allowed'); }
catch (\Throwable $e) { ok(strpos($e->getMessage(), 'CLOSED') !== false || strpos($e->getMessage(), 'Already posted') !== false, 'Blocked · ' . $e->getMessage()); }

/* ── 11 ── Header fully populated ────────────────────────────────── */
h('11. Header fields · sanity');
$reqAfter = $sr->findById($reqId);
$goodHeader = (int)$reqAfter['journal_entry_id'] === $jeId
              && (int)$reqAfter['closed_by'] === $accId
              && $reqAfter['closed_by_name'] === $accNm
              && !empty($reqAfter['closed_on']);
ok($goodHeader, "je=$jeId · by=$accId · closed_on={$reqAfter['closed_on']}");

/* ── 12 ── PC notification ───────────────────────────────────────── */
h('12. PC got REQUISITION_CLOSED notification');
$pcUnreadAfter = $nm->unreadCountForUser($pcId);
$notif = $db->mySelect("SELECT * FROM notifications WHERE recipient_user_id = :u AND kind = 'REQUISITION_CLOSED' AND entity_id = :e ORDER BY c0d DESC LIMIT 1", [':u' => $pcId, ':e' => $reqId])[0] ?? null;
p("PC unread: $pcUnreadBefore → $pcUnreadAfter · body: " . ($notif['body'] ?? '?'));
ok($notif !== null, 'REQUISITION_CLOSED row exists');
ok(strpos((string)$notif['body'], $je['je_no']) !== false, 'Body carries JE reference');

/* ── 13 ── Queue no longer includes it ────────────────────────────── */
h('13. Close queue no longer includes this requisition');
$queueAfter = $sr->openCloseForAccountant();
$stillHere = array_filter($queueAfter, fn($r) => (int)$r['c0d'] === $reqId);
ok(empty($stillHere), 'Dropped from close queue');

echo "\n=== END ===\n";
