<?php
/**
 * Slice 8b-4c · Requisition Retirement e2e (ISSUED → RETIRED with variance).
 *
 *   1) Provision services project. Drive requisition through chain → ISSUED (full amount).
 *   2) PC starts retirement → RETIRING.
 *   3) Guard: non-creator tries to start → BLOCKED. Non-creator tries to add expense → BLOCKED.
 *   4) PC adds 4 expenses across 3 categories → retired_amount + variance recompute.
 *   5) PC deletes one expense → recompute.
 *   6) Guard: PC tries to submit with 0 expenses (delete all, then re-add) → tested.
 *   7) PC submits retirement → retirement_submitted_on set, Accountant notification fires.
 *   8) Guard: PC tries to edit expense while submitted → BLOCKED.
 *   9) openRetirementForAccountant() includes this row.
 *  10) Guard: non-Accountant tries to approve → BLOCKED.
 *  11) Accountant sends back with reason → retirement_submitted_on cleared. PC notified.
 *  12) PC edits an expense (allowed again) → submits again.
 *  13) Accountant approves → RETIRED · retired_on stamped · notes stored · variance = issue - retired.
 *  14) Second call to approveRetirement → BLOCKED (already RETIRED).
 *  15) Overspend path: partial issue (60% = 384k) but PC records 400k of expenses → variance NEGATIVE.
 *  16) expensesByCategory returns per-category variance correctly.
 */
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',
] 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';

$stamp = date('YmdHis');

/* Helper: quickly build an ISSUED requisition on a project */
function buildIssued(array $ctx): int {
    global $db, $ac, $tm, $sr;
    $tid = $ctx['tid']; $pid = $ctx['pid'];
    $pcId = $ctx['pcId']; $pcNm = $ctx['pcNm'];
    $reqId = $sr->draft($pid, ['title' => $ctx['title'] ?? 'requisition', 'purpose' => $ctx['purpose'] ?? ''], $pcId, $pcNm);
    foreach ($ctx['lines'] as $ln) {
        $sr->addLine($reqId, $ln['cat'], ['description'=>$ln['desc'],'qty'=>$ln['qty'],'unit'=>$ln['unit'] ?? '','unit_cost'=>$ln['uc']]);
    }
    $res = $sr->submitForApproval($reqId, $pcId, $pcNm, 'Projects Coordinator');
    $aid = (int)$res['approval_id'];
    $ac->approve(['approval_id'=>$aid,'user_id'=>$pcId, 'user_name'=>$pcNm, 'user_group'=>'Projects Coordinator','comment'=>'OK']);
    $ac->approve(['approval_id'=>$aid,'user_id'=>$ctx['pmId'], 'user_name'=>$ctx['pmNm'], 'user_group'=>'Project Manager','comment'=>'OK']);
    $ac->approve(['approval_id'=>$aid,'user_id'=>$ctx['uidFm'],'user_name'=>$ctx['unmFm'],'user_group'=>$ctx['ugFm'],'comment'=>'OK']);
    $ac->approve(['approval_id'=>$aid,'user_id'=>$ctx['uidExec'],'user_name'=>$ctx['unmExec'],'user_group'=>$ctx['ugExec'],'comment'=>'OK']);
    $aRow = $ac->approvalById($aid);
    $sr->onChainDecision($reqId, (string)$aRow['status']);
    $sr->issue($reqId,
        ['issue_reference' => $ctx['issueRef'], 'issue_amount' => $ctx['issueAmount'] ?? '', 'issue_notes' => ''],
        $ctx['accId'], $ctx['accNm'], 'Accountant');
    return $reqId;
}

/* ── 1 ── Provision + build an ISSUED requisition ─────────────────── */
h('1. Provision project + build ISSUED requisition (full 545k)');
$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(), 25000000, 'TZS', 'PREP', 'OPEN', :u, :un, NOW())",
    [':tn' => 'T-8B4C-' . $stamp, ':tt' => 'SLICE 8b-4c retirement 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'), '', 25000000, '', '', $uidCrm, $unmCrm);
$tm->advanceStage($tid, 'AWARDED', 'a', $uidCrm, $unmCrm);
$tm->saveContractTerms($tid, ['contract_value'=>25000000], $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'];

$ctx = compact('tid','pid','pcId','pcNm','pmId','pmNm','uidFm','unmFm','ugFm','uidExec','unmExec','ugExec','accId','accNm');
$ctx['title']    = 'Site 03 · biweekly float';
$ctx['issueRef'] = 'PV-2026-0100';
$ctx['lines']    = [
    ['cat'=>'FUEL','desc'=>'Diesel','qty'=>100,'unit'=>'L','uc'=>3200], // 320k
    ['cat'=>'MEALS','desc'=>'2 techs × 5 days','qty'=>10,'unit'=>'day','uc'=>15000], // 150k
    ['cat'=>'TRANSPORT','desc'=>'Boda hire','qty'=>3,'unit'=>'day','uc'=>25000], // 75k
];
$reqId = buildIssued($ctx);
$req = $sr->findById($reqId);
p("Requisition {$req['req_no']} · status={$req['status']} · issued=" . number_format((float)$req['issue_amount']));
ok($req['status'] === 'ISSUED' && (float)$req['issue_amount'] == 545000, "ISSUED at 545,000");

/* ── 2 ── PC starts retirement ────────────────────────────────────── */
h('2. PC starts retirement → RETIRING');
$sr->startRetirement($reqId, $pcId, $ugPc);
$req = $sr->findById($reqId);
ok($req['status'] === 'RETIRING' && empty($req['retirement_submitted_on']), 'Status → RETIRING · submitted_on clear');

/* ── 3 ── Non-creator guards ─────────────────────────────────────── */
h('3. Non-creator role guards');
try { $sr->startRetirement($reqId, $accId, $ugAcc); ok(false, 'Non-creator allowed to start'); }
catch (\Throwable $e) { ok(strpos($e->getMessage(), 'requisitioner') !== false || strpos($e->getMessage(), 'RETIRING') !== false, 'Guard on non-creator start · ' . $e->getMessage()); }
try { $sr->addExpense($reqId, 'FUEL', ['description'=>'try','amount'=>1000], $pmId, $pmNm); ok(false, 'PM allowed to add'); }
catch (\Throwable $e) { ok(strpos($e->getMessage(), 'requisitioner') !== false, 'Guard on non-creator expense · ' . $e->getMessage()); }

/* ── 4 ── PC adds expenses ────────────────────────────────────────── */
h('4. PC adds 4 expenses across 3 categories');
$e1 = $sr->addExpense($reqId, 'FUEL',      ['description'=>'Diesel · Puma station','amount'=>300000,'receipt_ref'=>'EFD-A001','receipt_date'=>date('Y-m-d')], $pcId, $pcNm);
$e2 = $sr->addExpense($reqId, 'FUEL',      ['description'=>'Diesel top-up','amount'=>15000,'receipt_ref'=>'EFD-A002'], $pcId, $pcNm);
$e3 = $sr->addExpense($reqId, 'MEALS',     ['description'=>'2 techs × 5 days','amount'=>140000,'receipt_ref'=>'DIARY-1'], $pcId, $pcNm);
$e4 = $sr->addExpense($reqId, 'TRANSPORT', ['description'=>'Boda hire (2 days only)','amount'=>50000], $pcId, $pcNm); // no receipt
$req = $sr->findById($reqId);
$expected = 300000 + 15000 + 140000 + 50000; // 505,000
p("retired_amount=" . number_format((float)$req['retired_amount']) . " · variance=" . number_format((float)$req['variance_amount']));
ok(abs((float)$req['retired_amount'] - $expected) < 0.01, "retired_amount = $expected (505,000)");
ok(abs((float)$req['variance_amount'] - (545000 - $expected)) < 0.01, 'variance = 545,000 - 505,000 = 40,000 (under-spent)');

/* ── 5 ── PC deletes an expense ─────────────────────────────────── */
h('5. PC deletes one expense → recompute');
$sr->deleteExpense($e2, $pcId);
$req = $sr->findById($reqId);
$expected2 = $expected - 15000; // 490,000
ok(abs((float)$req['retired_amount'] - $expected2) < 0.01, "retired_amount = $expected2 (490,000)");

/* ── 6 ── PC re-adds an expense ──────────────────────────────────── */
h('6. PC re-adds a similar expense');
$sr->addExpense($reqId, 'ACCOMMODATION', ['description'=>'Overnight lodging','amount'=>55000,'receipt_ref'=>'INN-99'], $pcId, $pcNm);
$req = $sr->findById($reqId);
$expected3 = $expected2 + 55000; // 545,000 exact
p("retired_amount=" . number_format((float)$req['retired_amount']) . " · variance=" . number_format((float)$req['variance_amount']));
ok(abs((float)$req['retired_amount'] - 545000) < 0.01, 'retired = 545,000');
ok(abs((float)$req['variance_amount'] - 0) < 0.01, 'variance = 0 (exact)');

/* ── 7 ── PC submits retirement ──────────────────────────────────── */
h('7. PC submits retirement · Accountant notified');
$accBefore = $nm->unreadCountForUser($accId);
$sr->submitRetirement($reqId, $pcId, $pcNm);
$req = $sr->findById($reqId);
ok(!empty($req['retirement_submitted_on']), 'retirement_submitted_on stamped');
$accAfter = $nm->unreadCountForUser($accId);
p("Accountant unread: $accBefore → $accAfter");
$notif = $db->mySelect("SELECT COUNT(*) n FROM notifications WHERE recipient_user_id = :u AND kind = 'REQUISITION_RETIREMENT_SUBMITTED' AND entity_id = :e", [':u' => $accId, ':e' => $reqId])[0];
ok((int)$notif['n'] === 1, 'Accountant got REQUISITION_RETIREMENT_SUBMITTED');

/* ── 8 ── Cannot edit while submitted ────────────────────────────── */
h('8. PC tries to edit expense after submit → BLOCKED');
try { $sr->addExpense($reqId, 'FUEL', ['description'=>'more diesel','amount'=>5000], $pcId, $pcNm); ok(false, 'Allowed'); }
catch (\Throwable $e) { ok(strpos($e->getMessage(), 'submitted') !== false, 'Blocked · ' . $e->getMessage()); }

/* ── 9 ── openRetirementForAccountant() ─────────────────────────── */
h('9. openRetirementForAccountant() includes the row');
$queue = $sr->openRetirementForAccountant();
$here = array_filter($queue, fn($r) => (int)$r['c0d'] === $reqId);
ok(!empty($here), 'Requisition in retirement queue');

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

/* ── 11 ── Accountant sends back ────────────────────────────────── */
h('11. Accountant sends back with reason · PC notified');
$pcBefore = $nm->unreadCountForUser($pcId);
$sr->sendBackRetirement($reqId, $accId, $accNm, $ugAcc, 'Missing receipt for boda hire — please attach.');
$req = $sr->findById($reqId);
ok(empty($req['retirement_submitted_on']), 'retirement_submitted_on cleared');
ok($req['status'] === 'RETIRING', 'Status still RETIRING (not final decline)');
$pcAfter = $nm->unreadCountForUser($pcId);
p("PC unread: $pcBefore → $pcAfter");
$notif = $db->mySelect("SELECT COUNT(*) n FROM notifications WHERE recipient_user_id = :u AND kind = 'REQUISITION_RETIREMENT_SENT_BACK' AND entity_id = :e", [':u' => $pcId, ':e' => $reqId])[0];
ok((int)$notif['n'] === 1, 'PC got REQUISITION_RETIREMENT_SENT_BACK');

/* ── 12 ── PC edits, then re-submits ────────────────────────────── */
h('12. PC edits expense (allowed again) · resubmits');
// Add a note-only expense to prove edit works
$sr->addExpense($reqId, 'MISC', ['description'=>'Explanatory expense · replaces boda without receipt','amount'=>5000,'notes'=>'Client witness present'], $pcId, $pcNm);
$req = $sr->findById($reqId);
p("retired now = " . number_format((float)$req['retired_amount']));
$sr->submitRetirement($reqId, $pcId, $pcNm);
$req = $sr->findById($reqId);
ok(!empty($req['retirement_submitted_on']), 'Re-submitted successfully');

/* ── 13 ── Accountant approves ──────────────────────────────────── */
h('13. Accountant approves retirement · RETIRED');
$sr->approveRetirement($reqId, $accId, $accNm, $ugAcc, 'Reviewed with notes; approved.');
$req = $sr->findById($reqId);
p("status={$req['status']} · retired_on={$req['retired_on']} · retired_amount=" . number_format((float)$req['retired_amount']) . " · variance=" . number_format((float)$req['variance_amount']) . " · approver={$req['retirement_approved_by_name']}");
ok($req['status'] === 'RETIRED' && !empty($req['retired_on']), 'Status → RETIRED');
ok((int)$req['retirement_approved_by'] === $accId, 'approved_by = Accountant');
ok($req['retirement_notes'] === 'Reviewed with notes; approved.', 'Notes stored');
ok(abs((float)$req['issue_amount'] - (float)$req['retired_amount'] - (float)$req['variance_amount']) < 0.01, 'variance = issue - retired (algebraic sanity)');
$notif = $db->mySelect("SELECT COUNT(*) n FROM notifications WHERE recipient_user_id = :u AND kind = 'REQUISITION_RETIRED' AND entity_id = :e", [':u' => $pcId, ':e' => $reqId])[0];
ok((int)$notif['n'] === 1, 'PC got REQUISITION_RETIRED notification');

/* ── 14 ── Idempotency guard ────────────────────────────────────── */
h('14. Second approve → BLOCKED');
try { $sr->approveRetirement($reqId, $accId, $accNm, $ugAcc, ''); ok(false, 'Allowed'); }
catch (\Throwable $e) { ok(strpos($e->getMessage(), 'RETIRED') !== false || strpos($e->getMessage(), 'RETIRING') !== false, 'Blocked · ' . $e->getMessage()); }

/* ── 15 ── Overspend path (partial issue but spend more than issued) ─ */
h('15. Overspend path: partial issue 384k, spend 400k → variance NEGATIVE');
$ctx2 = $ctx;
$ctx2['title']    = 'Site 04 float · partial issue';
$ctx2['issueRef'] = 'PV-2026-0101';
$ctx2['issueAmount'] = 384000;
$ctx2['lines'] = [
    ['cat'=>'FUEL','desc'=>'Diesel budget','qty'=>200,'unit'=>'L','uc'=>3200], // 640,000
];
$req2Id = buildIssued($ctx2);
$sr->startRetirement($req2Id, $pcId, $ugPc);
$sr->addExpense($req2Id, 'FUEL', ['description'=>'Diesel actual · Puma','amount'=>400000,'receipt_ref'=>'EFD-B'], $pcId, $pcNm);
$req2 = $sr->findById($req2Id);
$expectedVar = 384000 - 400000; // -16,000
p("issue=" . number_format((float)$req2['issue_amount']) . " · retired=" . number_format((float)$req2['retired_amount']) . " · variance=" . number_format((float)$req2['variance_amount']));
ok(abs((float)$req2['variance_amount'] - $expectedVar) < 0.01, "variance = -16,000 (overspend)");

/* ── 16 ── expensesByCategory returns per-category variance ────── */
h('16. expensesByCategory returns per-category variance vs planned');
$byCat = $sr->expensesByCategory($reqId);
foreach ($byCat as $c => $b) {
    if (empty($b['expenses']) && $b['planned'] == 0) continue;
    p(sprintf("  %-14s planned=%-9s spent=%-9s var=%s", $c, number_format((float)$b['planned']), number_format((float)$b['spent']), number_format((float)$b['variance'])));
}
$fuelPlanned = (float)$byCat['FUEL']['planned'];    // 320,000 (100 × 3200)
$fuelSpent   = (float)$byCat['FUEL']['spent'];      // 300,000 (only e1 kept)
$fuelVar     = (float)$byCat['FUEL']['variance'];   // 20,000 under
ok(abs($fuelPlanned - 320000) < 0.01, 'FUEL planned 320,000');
ok(abs($fuelSpent - 300000) < 0.01,   'FUEL spent 300,000');
ok(abs($fuelVar - 20000) < 0.01,      'FUEL variance +20,000 (under)');
$accomVar = (float)$byCat['ACCOMMODATION']['variance']; // planned 0 · spent 55,000 → variance -55,000
ok(abs($accomVar + 55000) < 0.01, 'ACCOMMODATION variance -55,000 (over — no plan)');

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