<?php
namespace ApplicationBundle\Modules\ImportLc\Controller;
use ApplicationBundle\Controller\GenericController;
use ApplicationBundle\Interfaces\SessionCheckInterface;
use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
use ApplicationBundle\Constants\GeneralConstant;
use ApplicationBundle\Entity\BillOfEntry;
use ApplicationBundle\Entity\LandedCostSheet;
use ApplicationBundle\Modules\ImportLc\Service\BoeGlService;
use ApplicationBundle\Modules\ImportLc\Service\LcGlService;
use ApplicationBundle\Modules\ImportLc\Service\LandedCostService;
use Symfony\Component\HttpFoundation\Request;
/**
* Import & LC — cp-shell module for the import / Letter-of-Credit lifecycle:
* Proforma Invoice → Insurance/Pay-Req → LC Opening → LC Register → Bill of Lading
* → Bill of Entry → Landed Cost → (GRN). This is the modern front-end over the
* existing import chain; document forms are ported in phases. The dashboard reads
* the live import data so it is useful from day one.
*/
class ImportLcController extends GenericController implements SessionCheckInterface
{
/** Import & LC command centre. */
public function dashboardAction(Request $request)
{
$conn = $this->getDoctrine()->getManager()->getConnection();
$n = function ($sql, $p = []) use ($conn) { try { return $conn->fetchOne($sql, $p); } catch (\Throwable $e) { return 0; } };
$rows = function ($sql, $p = []) use ($conn) { try { return $conn->fetchAllAssociative($sql, $p); } catch (\Throwable $e) { return []; } };
// ── KPIs ────────────────────────────────────────────────────────────────
$openLcCount = (int) $n("SELECT COUNT(*) FROM lc_entry WHERE (full_delivered IS NULL OR full_delivered = 0) AND (delete_flag IS NULL OR delete_flag = 0)");
$totalLcValue = (float) $n("SELECT COALESCE(SUM(document_amount),0) FROM lc_entry WHERE (delete_flag IS NULL OR delete_flag = 0)");
$marginHeld = (float) $n("SELECT COALESCE(SUM(lc_margin_amount),0) FROM lc_entry WHERE (full_delivered IS NULL OR full_delivered = 0)");
$shipments = (int) $n("SELECT COUNT(*) FROM bill_of_lading WHERE (delete_flag IS NULL OR delete_flag = 0)");
$customs = (int) $n("SELECT COUNT(*) FROM bill_of_entry WHERE (delete_flag IS NULL OR delete_flag = 0)");
$pendingOpen = (int) $n("SELECT COUNT(*) FROM lc_opening_request o WHERE (o.delete_flag IS NULL OR o.delete_flag = 0) AND NOT EXISTS (SELECT 1 FROM lc_entry e WHERE e.lc_opening_request_ids LIKE CONCAT('%\"', o.document_id, '\"%') OR e.lc_opening_request_ids LIKE CONCAT('%', o.document_id, '%'))");
// ── Pipeline (counts per document type) ──────────────────────────────────
$pipeline = [
['key' => 'proforma', 'label' => 'Proforma', 'icon' => 'description', 'count' => (int) $n("SELECT COUNT(*) FROM proforma_invoice WHERE (delete_flag IS NULL OR delete_flag=0)")],
['key' => 'insurance', 'label' => 'Insurance / Pay', 'icon' => 'verified_user', 'count' => (int) $n("SELECT COUNT(*) FROM insurance_pay_request WHERE (delete_flag IS NULL OR delete_flag=0)")],
['key' => 'lcopening', 'label' => 'LC Opening', 'icon' => 'lock_open', 'count' => (int) $n("SELECT COUNT(*) FROM lc_opening_request WHERE (delete_flag IS NULL OR delete_flag=0)")],
['key' => 'lc', 'label' => 'LC Register', 'icon' => 'account_balance','count' => (int) $n("SELECT COUNT(*) FROM lc_entry WHERE (delete_flag IS NULL OR delete_flag=0)")],
['key' => 'bol', 'label' => 'Bill of Lading', 'icon' => 'directions_boat','count' => $shipments],
['key' => 'boe', 'label' => 'Bill of Entry', 'icon' => 'gavel', 'count' => $customs],
['key' => 'landed', 'label' => 'Landed Cost', 'icon' => 'functions', 'count' => (int) $n("SELECT COUNT(*) FROM landed_cost_sheet WHERE (delete_flag IS NULL OR delete_flag=0)")],
];
// ── Recent LCs ───────────────────────────────────────────────────────────
$recent = $rows(
"SELECT e.document_id AS id, e.lc_number, e.document_amount AS amount, e.document_date AS dt,
e.lc_margin_amount AS margin, e.full_delivered AS delivered, e.approved,
COALESCE(h.name, CONCAT('Supplier #', e.supplier_id)) AS supplier
FROM lc_entry e LEFT JOIN acc_accounts_head h ON h.accounts_head_id = e.supplier_head_id
WHERE (e.delete_flag IS NULL OR e.delete_flag = 0)
ORDER BY e.document_id DESC LIMIT 10"
);
return $this->render('@ImportLc/pages/dashboard.html.twig', [
'active' => 'dash',
'kpi' => [
'openLc' => $openLcCount, 'totalValue' => $totalLcValue, 'margin' => $marginHeld,
'shipments' => $shipments, 'customs' => $customs, 'pendingOpen' => $pendingOpen,
],
'pipeline' => $pipeline,
'recent' => $recent,
]);
}
/** LC detail / edit form — enrich an LC with the full real-world fields + status. */
public function lcFormAction(Request $request)
{
$em = $this->getDoctrine()->getManager();
$id = (int) $request->query->get('id', 0);
$lc = $em->getRepository('ApplicationBundle\\Entity\\LcEntry')->findOneBy(['documentId' => $id]);
if (!$lc) {
$request->getSession()->getFlashBag()->add('importlc', 'LC not found.');
return $this->redirect($this->generateUrl('import_lc_dashboard'));
}
$conn = $em->getConnection();
$supplier = $conn->fetchOne('SELECT COALESCE(h.name, CONCAT("Supplier #", ?)) FROM acc_accounts_head h WHERE h.accounts_head_id = ?',
[$lc->getSupplierId(), $lc->getSupplierHeadId()]);
$currencies = $conn->fetchAllAssociative('SELECT currency_id AS id, code FROM currencies ORDER BY code');
return $this->render('@ImportLc/pages/lc_form.html.twig', [
'active' => 'lc',
'lc' => $lc,
'supplier' => $supplier ?: ('Supplier #' . $lc->getSupplierId()),
'currencies' => $currencies,
'statuses' => ['DRAFT', 'OPENED', 'SHIPPED', 'DOCS_RECEIVED', 'ACCEPTED', 'RETIRED', 'CANCELLED'],
'incoterms' => ['EXW', 'FCA', 'FAS', 'FOB', 'CFR', 'CIF', 'CPT', 'CIP', 'DAP', 'DPU', 'DDP'],
'flash' => $request->getSession()->getFlashBag()->get('importlc')[0] ?? null,
]);
}
/** Save the LC real-world fields. */
public function lcSaveAction(Request $request)
{
$p = $request->request;
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$lc = $em->getRepository('ApplicationBundle\\Entity\\LcEntry')->findOneBy(['documentId' => (int) $p->get('id', 0)]);
if (!$lc) { $session->getFlashBag()->add('importlc', 'LC not found.'); return $this->redirect($this->generateUrl('import_lc_dashboard')); }
$d = function ($k) use ($p) { $v = trim((string) $p->get($k, '')); return $v === '' ? null : new \DateTime($v); };
$num = function ($k) use ($p) { $v = $p->get($k, ''); return ($v === '' || $v === null) ? null : (string) $v; };
$str = function ($k) use ($p) { $v = trim((string) $p->get($k, '')); return $v === '' ? null : $v; };
if ($num('lcAmount') !== null) { $lc->setDocumentAmount($num('lcAmount')); }
if ($str('lcNumber') !== null) { $lc->setLcNumber($str('lcNumber')); }
$lc->setLcTenorType($str('lcTenorType'));
$lc->setLcUsanceDays($num('lcUsanceDays'));
$lc->setLcIssueDate($d('lcIssueDate'));
$lc->setLcExpiryDate($d('lcExpiryDate'));
$lc->setLcLatestShipmentDate($d('lcLatestShipmentDate'));
$lc->setApplicantName($str('applicantName'));
$lc->setBeneficiaryName($str('beneficiaryName'));
$lc->setBeneficiaryBank($str('beneficiaryBank'));
$lc->setIssuingBankName($str('issuingBankName'));
$lc->setIssuingBankSwift($str('issuingBankSwift'));
$lc->setAdvisingBankName($str('advisingBankName'));
$lc->setAdvisingBankSwift($str('advisingBankSwift'));
$lc->setIncoterms($str('incoterms'));
$lc->setPortOfLoading($str('portOfLoading'));
$lc->setPortOfDischarge($str('portOfDischarge'));
$lc->setPartialShipment($p->has('partialShipment') ? 1 : 0);
$lc->setTranshipment($p->has('transhipment') ? 1 : 0);
$lc->setCommissionRate($num('commissionRate'));
// Auto-derive charge amounts from rate × LC amount where only a rate is given.
$amt = (float) ($num('lcAmount') ?? $lc->getDocumentAmount());
$lc->setCommissionAmount($num('commissionAmount') ?? ($num('commissionRate') !== null ? number_format($amt * (float) $num('commissionRate') / 100, 2, '.', '') : null));
$lc->setSwiftCharge($num('swiftCharge'));
$lc->setPostageCharge($num('postageCharge'));
$lc->setOtherCharges($num('otherCharges'));
if ($num('marginRate') !== null) { $lc->setLcMarginRate($num('marginRate')); $lc->setLcMarginAmount(number_format($amt * (float) $num('marginRate') / 100, 2, '.', '')); }
$lc->setLcStatus($str('lcStatus'));
$lc->setAcceptanceDate($d('acceptanceDate'));
$lc->setRetirementDate($d('retirementDate'));
$lc->setSettlementAmount($num('settlementAmount'));
$lc->setHsCode($str('hsCode'));
if (method_exists($lc, 'setEditedLoginId')) { $lc->setEditedLoginId((int) $session->get(UserConstants::USER_LOGIN_ID)); }
$em->flush();
$session->getFlashBag()->add('importlc', '✓ LC ' . ($lc->getLcNumber() ?: ('#' . $lc->getDocumentId())) . ' saved.');
return $this->redirect($this->generateUrl('import_lc_form', ['id' => $lc->getDocumentId()]));
}
/** Post the LC opening margin + bank charges to the GL. */
public function lcPostGlAction(Request $request)
{
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$id = (int) $request->request->get('id', 0);
$lc = $em->getRepository('ApplicationBundle\\Entity\\LcEntry')->findOneBy(['documentId' => $id]);
if (!$lc) { $session->getFlashBag()->add('importlc', 'LC not found.'); return $this->redirect($this->generateUrl('import_lc_dashboard')); }
if ((int) $lc->getLedgerHit() === 1) {
$session->getFlashBag()->add('importlc', 'Already posted to GL.');
} else {
try {
$txnId = LcGlService::postOpening($em, $lc, 1, (int) $session->get(UserConstants::USER_LOGIN_ID));
$session->getFlashBag()->add('importlc', $txnId
? '✓ Posted to GL (voucher #' . $txnId . '): Dr Margin + Dr Charges / Cr Bank.'
: 'Nothing posted — no margin/charges, or LC GL heads not configured (run inno:lc-accounts-setup).');
} catch (\Throwable $e) {
$session->getFlashBag()->add('importlc', '✕ GL post failed: ' . $e->getMessage());
}
}
return $this->redirect($this->generateUrl('import_lc_form', ['id' => $id]));
}
/** Retire / settle the LC — clears the supplier payable, applying the margin. */
public function lcRetireAction(Request $request)
{
$em = $this->getDoctrine()->getManager();
$session = $request->getSession();
$id = (int) $request->request->get('id', 0);
$lc = $em->getRepository('ApplicationBundle\\Entity\\LcEntry')->findOneBy(['documentId' => $id]);
if (!$lc) { $session->getFlashBag()->add('importlc', 'LC not found.'); return $this->redirect($this->generateUrl('import_lc_dashboard')); }
if ($lc->getRetirementVoucherId()) {
$session->getFlashBag()->add('importlc', 'LC already retired.');
} else {
try {
$txnId = LcGlService::postRetirement($em, $lc, 1, (int) $session->get(UserConstants::USER_LOGIN_ID));
$session->getFlashBag()->add('importlc', $txnId
? '✓ LC retired (voucher #' . $txnId . '): payable settled — margin applied + bank balance.'
: 'Cannot retire — set a settlement amount first (and ensure LC heads are configured).');
} catch (\Throwable $e) {
$session->getFlashBag()->add('importlc', '✕ Retirement failed: ' . $e->getMessage());
}
}
return $this->redirect($this->generateUrl('import_lc_form', ['id' => $id]));
}
/** Bill of Entry form (new/edit) — customs duty. */
public function boeFormAction(Request $request)
{
$em = $this->getDoctrine()->getManager(); $conn = $em->getConnection();
$id = (int) $request->query->get('id', 0);
$boe = $id ? $em->getRepository('ApplicationBundle\\Entity\\BillOfEntry')->findOneBy(['documentId' => $id]) : null;
$grns = $conn->fetchAllAssociative("SELECT g.grn_id AS id, g.document_hash AS hash FROM grn g WHERE (g.delete_flag IS NULL OR g.delete_flag=0) ORDER BY g.grn_id DESC LIMIT 200");
return $this->render('@ImportLc/pages/boe_form.html.twig', [
'active' => 'boe', 'boe' => $boe, 'grns' => $grns,
'flash' => $request->getSession()->getFlashBag()->get('importlc')[0] ?? null,
]);
}
/** Save Bill of Entry. */
public function boeSaveAction(Request $request)
{
$p = $request->request; $em = $this->getDoctrine()->getManager(); $session = $request->getSession();
$loginId = (int) $session->get(UserConstants::USER_LOGIN_ID);
$id = (int) $p->get('id', 0);
$b = $id ? $em->getRepository('ApplicationBundle\\Entity\\BillOfEntry')->findOneBy(['documentId' => $id]) : new BillOfEntry();
if (!$id) {
foreach ([['setCompanyId',1],['setLedgerHit',0],['setStatus',GeneralConstant::ACTIVE],['setStage',GeneralConstant::STAGE_INITIATED],
['setApproved',GeneralConstant::APPROVAL_STATUS_PENDING],['setCreatedLoginId',$loginId]] as $c) { if (method_exists($b, $c[0])) { $b->{$c[0]}($c[1]); } }
if (method_exists($b, 'setDocumentDate')) { $b->setDocumentDate(new \DateTime('today')); }
if (method_exists($b, 'setDocumentHash')) { $b->setDocumentHash('BOE/' . date('ymd') . '/' . substr(md5(uniqid('', true)), 0, 6)); }
}
$n = function ($k) use ($p) { $v = $p->get($k, ''); return ($v === '' || $v === null) ? 0 : (string) $v; };
$b->setBeNumber($p->get('beNumber') ?: null);
$b->setCustomsRef($p->get('customsRef') ?: null);
$b->setBeGrnId((int) $p->get('beGrnId') ?: null);
$b->setAssessableValue($n('assessableValue'));
$b->setDutyRate($n('dutyRate'));
// auto-derive duty from rate × assessable value when amount blank
$duty = $n('dutyAmount');
if (($duty === 0 || $duty === '0') && (float) $n('dutyRate') > 0) { $duty = number_format((float) $n('assessableValue') * (float) $n('dutyRate') / 100, 2, '.', ''); }
$b->setDutyAmount($duty);
$b->setImportVat($n('importVat'));
if (method_exists($b, 'setDocumentAmount')) { $b->setDocumentAmount($duty); }
$em->persist($b); $em->flush();
$session->getFlashBag()->add('importlc', '✓ Bill of Entry saved.');
return $this->redirect($this->generateUrl('import_lc_boe_form', ['id' => $b->getDocumentId()]));
}
/** Post BoE customs duty to GL (capitalize to inventory). */
public function boePostGlAction(Request $request)
{
$em = $this->getDoctrine()->getManager(); $session = $request->getSession();
$b = $em->getRepository('ApplicationBundle\\Entity\\BillOfEntry')->findOneBy(['documentId' => (int) $request->request->get('id', 0)]);
if (!$b) { $session->getFlashBag()->add('importlc', 'Not found.'); return $this->redirect($this->generateUrl('import_lc_dashboard')); }
if ((int) $b->getLedgerHit() === 1) { $session->getFlashBag()->add('importlc', 'Duty already posted.'); }
else {
try {
$txnId = BoeGlService::post($em, $b, 1, (int) $session->get(UserConstants::USER_LOGIN_ID));
$session->getFlashBag()->add('importlc', $txnId ? '✓ Duty posted (voucher #' . $txnId . '): Dr Inventory / Cr Customs Payable.' : 'Nothing posted — enter a duty amount, or configure LC heads.');
} catch (\Throwable $e) { $session->getFlashBag()->add('importlc', '✕ Failed: ' . $e->getMessage()); }
}
return $this->redirect($this->generateUrl('import_lc_boe_form', ['id' => $b->getDocumentId()]));
}
/** LC aging & status report — open exposure, days-to-expiry warnings, status mix. */
public function agingAction(Request $request)
{
$conn = $this->getDoctrine()->getManager()->getConnection();
$rows = $conn->fetchAllAssociative(
"SELECT e.document_id AS id, e.lc_number, e.document_amount AS amount, e.lc_margin_amount AS margin,
COALESCE(e.lc_status, IF(e.full_delivered=1,'RETIRED', IF(e.approved=1,'OPENED','DRAFT'))) AS status,
COALESCE(e.lc_issue_date, e.lc_date) AS opened, e.lc_expiry_date AS expiry,
DATEDIFF(CURDATE(), COALESCE(e.lc_issue_date, e.lc_date)) AS age_days,
DATEDIFF(e.lc_expiry_date, CURDATE()) AS to_expiry,
COALESCE(h.name, CONCAT('Supplier #', e.supplier_id)) AS supplier
FROM lc_entry e LEFT JOIN acc_accounts_head h ON h.accounts_head_id = e.supplier_head_id
WHERE (e.delete_flag IS NULL OR e.delete_flag = 0)
ORDER BY (e.full_delivered = 1), e.lc_expiry_date IS NULL, e.lc_expiry_date ASC, e.document_id DESC"
);
// status mix
$mix = [];
$openExposure = 0.0; $marginTied = 0.0; $expiringSoon = 0;
foreach ($rows as $r) {
$st = $r['status'] ?: 'DRAFT';
$mix[$st] = ($mix[$st] ?? 0) + 1;
if ($st !== 'RETIRED' && $st !== 'CANCELLED') {
$openExposure += (float) $r['amount'];
$marginTied += (float) $r['margin'];
if ($r['to_expiry'] !== null && (int) $r['to_expiry'] >= 0 && (int) $r['to_expiry'] <= 21) { $expiringSoon++; }
}
}
return $this->render('@ImportLc/pages/aging.html.twig', [
'active' => 'aging', 'rows' => $rows, 'mix' => $mix,
'openExposure' => $openExposure, 'marginTied' => $marginTied, 'expiringSoon' => $expiringSoon,
]);
}
/** Landed-cost sheet form (new or edit). */
public function landedFormAction(Request $request)
{
$em = $this->getDoctrine()->getManager();
$conn = $em->getConnection();
$id = (int) $request->query->get('id', 0);
$sheet = $id ? $em->getRepository('ApplicationBundle\\Entity\\LandedCostSheet')->findOneBy(['documentId' => $id]) : null;
$grns = $conn->fetchAllAssociative(
"SELECT g.grn_id AS id, g.document_hash AS hash, COALESCE(h.name, CONCAT('Supplier #', g.supplier_id)) AS supplier
FROM grn g LEFT JOIN acc_accounts_head h ON h.accounts_head_id = g.supplier_head_id
WHERE (g.delete_flag IS NULL OR g.delete_flag = 0) ORDER BY g.grn_id DESC LIMIT 200"
);
return $this->render('@ImportLc/pages/landed_form.html.twig', [
'active' => 'landed', 'sheet' => $sheet, 'grns' => $grns,
'flash' => $request->getSession()->getFlashBag()->get('importlc')[0] ?? null,
]);
}
/** Save the landed-cost sheet. */
public function landedSaveAction(Request $request)
{
$p = $request->request; $em = $this->getDoctrine()->getManager(); $session = $request->getSession();
$loginId = (int) $session->get(UserConstants::USER_LOGIN_ID);
$id = (int) $p->get('id', 0);
$s = $id ? $em->getRepository('ApplicationBundle\\Entity\\LandedCostSheet')->findOneBy(['documentId' => $id]) : new LandedCostSheet();
if (!$id) {
foreach ([['setCompanyId', 1], ['setLedgerHit', 0], ['setStatus', GeneralConstant::ACTIVE],
['setStage', GeneralConstant::STAGE_INITIATED], ['setApproved', GeneralConstant::APPROVAL_STATUS_PENDING],
['setCreatedLoginId', $loginId]] as $c) { if (method_exists($s, $c[0])) { $s->{$c[0]}($c[1]); } }
if (method_exists($s, 'setDocumentDate')) { $s->setDocumentDate(new \DateTime('today')); }
if (method_exists($s, 'setDocumentHash')) { $s->setDocumentHash('LCS/' . date('ymd') . '/' . substr(md5(uniqid('', true)), 0, 6)); }
}
$num = function ($k) use ($p) { $v = $p->get($k, ''); return ($v === '' || $v === null) ? 0 : (string) $v; };
$s->setFreightAmount($num('freightAmount'));
$s->setInsuranceAmount($num('insuranceAmount'));
$s->setCustomsDuty($num('customsDuty'));
$s->setOtherLandedCharges($num('otherLandedCharges'));
$s->setLandedGrnId((int) $p->get('landedGrnId') ?: null);
$s->setAllocationBasis($p->get('allocationBasis', 'value'));
$total = (float) $num('freightAmount') + (float) $num('insuranceAmount') + (float) $num('customsDuty') + (float) $num('otherLandedCharges');
if (method_exists($s, 'setDocumentAmount')) { $s->setDocumentAmount(number_format($total, 2, '.', '')); }
// Stamp the GRN's PO/PI/EI link now so the double-count guard is reliable + auditable.
try { LandedCostService::stampGrnLink($em, $s); } catch (\Throwable $e) {}
$em->persist($s); $em->flush();
$session->getFlashBag()->add('importlc', '✓ Landed-cost sheet saved.');
return $this->redirect($this->generateUrl('import_lc_landed_form', ['id' => $s->getDocumentId()]));
}
/** Post landed cost to GL (capitalize to inventory). */
public function landedPostGlAction(Request $request)
{
$em = $this->getDoctrine()->getManager(); $session = $request->getSession();
$s = $em->getRepository('ApplicationBundle\\Entity\\LandedCostSheet')->findOneBy(['documentId' => (int) $request->request->get('id', 0)]);
if (!$s) { $session->getFlashBag()->add('importlc', 'Sheet not found.'); return $this->redirect($this->generateUrl('import_lc_dashboard')); }
if ((int) $s->getLedgerHit() === 1) {
$session->getFlashBag()->add('importlc', 'Already capitalized.');
} else {
try {
$txnId = LandedCostService::post($em, $s, 1, (int) $session->get(UserConstants::USER_LOGIN_ID));
if ($txnId) {
$session->getFlashBag()->add('importlc', '✓ Capitalized to inventory (voucher #' . $txnId . '): Dr Inventory / Cr Clearing.');
} else {
// Distinguish the double-count refusal from "no costs / heads not configured".
$prior = LandedCostService::alreadyCapitalised($em, $s);
$session->getFlashBag()->add('importlc', $prior['blocked']
? '✕ Not posted — import costs for GRN #' . (int) $s->getLandedGrnId() . ' were already capitalised via the ' . $prior['reason'] . '. Enter import costs in ONE place (the purchase/expense invoice OR this landed-cost sheet, not both).'
: 'Nothing posted — no costs entered, no resolvable stock slot, or LC heads not configured.');
}
} catch (\Throwable $e) { $session->getFlashBag()->add('importlc', '✕ Failed: ' . $e->getMessage()); }
}
return $this->redirect($this->generateUrl('import_lc_landed_form', ['id' => $s->getDocumentId()]));
}
}