accounting workflows
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
class AccountFormulaManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
private array $documentTypes = [
|
||||
'invoice',
|
||||
'credit_note',
|
||||
'receipt',
|
||||
'purchase_invoice',
|
||||
'supplier_credit_note',
|
||||
'payment',
|
||||
];
|
||||
|
||||
private array $amountKeys = ['grand_total', 'total', 'tax', 'amount'];
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
public function getAll(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT f.*,
|
||||
COUNT(i.id) AS item_count
|
||||
FROM md_account_formula f
|
||||
LEFT JOIN md_account_formula_item i
|
||||
ON i.company_id = f.company_id
|
||||
AND i.formula_id = f.id
|
||||
WHERE f.company_id = :cid
|
||||
GROUP BY f.id
|
||||
ORDER BY f.document_type ASC, f.is_default DESC, f.formula_name ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getByType(string $document_type): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, formula_name, is_default
|
||||
FROM md_account_formula
|
||||
WHERE company_id = :cid
|
||||
AND document_type = :doc_type
|
||||
AND status = 1
|
||||
ORDER BY is_default DESC, formula_name ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':doc_type' => $document_type]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getById(int $id): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_account_formula
|
||||
WHERE company_id = :cid AND id = :id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$formula = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$formula) return null;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*, a.account_name, a.account_type
|
||||
FROM md_account_formula_item i
|
||||
LEFT JOIN md_account a
|
||||
ON a.company_id = i.company_id
|
||||
AND a.account_code = i.account_code
|
||||
WHERE i.company_id = :cid
|
||||
AND i.formula_id = :formula_id
|
||||
ORDER BY i.sort_order ASC, i.id ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':formula_id' => $id]);
|
||||
$formula['items'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return $formula;
|
||||
}
|
||||
|
||||
public function save(array $data): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$name = trim((string)($data['formula_name'] ?? ''));
|
||||
$documentType = trim((string)($data['document_type'] ?? ''));
|
||||
$description = trim((string)($data['description'] ?? ''));
|
||||
$isDefault = (int)($data['is_default'] ?? 0) === 1 ? 1 : 0;
|
||||
$status = (int)($data['status'] ?? 1) === 1 ? 1 : 0;
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
if (is_string($items)) {
|
||||
$items = json_decode($items, true) ?: [];
|
||||
}
|
||||
|
||||
if ($name === '') throw new Exception('Formula name is required.');
|
||||
if (!in_array($documentType, $this->documentTypes, true)) {
|
||||
throw new Exception('Invalid document type.');
|
||||
}
|
||||
|
||||
$validated = $this->validateItems($items);
|
||||
if (!$validated) throw new Exception('Formula requires at least one debit and one credit item.');
|
||||
|
||||
$debitKeys = array_unique(array_column(array_filter($validated, fn($i) => $i['drcr'] === 'D'), 'amount_key'));
|
||||
$creditKeys = array_unique(array_column(array_filter($validated, fn($i) => $i['drcr'] === 'C'), 'amount_key'));
|
||||
if (!$debitKeys || !$creditKeys) {
|
||||
throw new Exception('Formula requires at least one debit and one credit item.');
|
||||
}
|
||||
|
||||
if ($isDefault) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_account_formula
|
||||
SET is_default = 0
|
||||
WHERE company_id = :cid AND document_type = :document_type"
|
||||
)->execute([':cid' => $this->companyId, ':document_type' => $documentType]);
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE md_account_formula SET
|
||||
formula_name = :formula_name,
|
||||
document_type = :document_type,
|
||||
description = :description,
|
||||
is_default = :is_default,
|
||||
status = :status,
|
||||
updated_at = :updated_at
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':id' => $id,
|
||||
':formula_name' => $name,
|
||||
':document_type' => $documentType,
|
||||
':description' => $description !== '' ? $description : null,
|
||||
':is_default' => $isDefault,
|
||||
':status' => $status,
|
||||
':updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
} else {
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO md_account_formula
|
||||
(company_id, formula_name, document_type, description, is_default, status, created_at)
|
||||
VALUES
|
||||
(:cid, :formula_name, :document_type, :description, :is_default, :status, :created_at)"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':formula_name' => $name,
|
||||
':document_type' => $documentType,
|
||||
':description' => $description !== '' ? $description : null,
|
||||
':is_default' => $isDefault,
|
||||
':status' => $status,
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$id = (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
$this->replaceItems($id, $validated);
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_account_formula
|
||||
SET status = 0, is_default = 0, updated_at = :updated_at
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
)->execute([
|
||||
':cid' => $this->companyId,
|
||||
':id' => $id,
|
||||
':updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateItems(array $items): array
|
||||
{
|
||||
$validated = [];
|
||||
$sort = 1;
|
||||
foreach ($items as $item) {
|
||||
$drcr = strtoupper(trim((string)($item['drcr'] ?? '')));
|
||||
$accountCode = trim((string)($item['account_code'] ?? ''));
|
||||
$amountKey = trim((string)($item['amount_key'] ?? ''));
|
||||
$description = trim((string)($item['description'] ?? ''));
|
||||
|
||||
if (!in_array($drcr, ['D', 'C'], true) || $accountCode === '' || $amountKey === '') continue;
|
||||
if (!in_array($amountKey, $this->amountKeys, true)) throw new Exception('Invalid amount key.');
|
||||
if (!$this->isPostingAccount($accountCode)) {
|
||||
throw new Exception($accountCode . ' is not an active posting account.');
|
||||
}
|
||||
|
||||
$validated[] = [
|
||||
'drcr' => $drcr,
|
||||
'account_code' => $accountCode,
|
||||
'amount_key' => $amountKey,
|
||||
'description' => $description,
|
||||
'sort_order' => $sort++,
|
||||
];
|
||||
}
|
||||
return $validated;
|
||||
}
|
||||
|
||||
private function isPostingAccount(string $accountCode): bool
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM md_account
|
||||
WHERE company_id = :cid
|
||||
AND account_code = :account_code
|
||||
AND is_posting = 1
|
||||
AND status = 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':account_code' => $accountCode]);
|
||||
return (int)$sth->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
private function replaceItems(int $formulaId, array $items): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM md_account_formula_item
|
||||
WHERE company_id = :cid AND formula_id = :formula_id"
|
||||
)->execute([':cid' => $this->companyId, ':formula_id' => $formulaId]);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO md_account_formula_item
|
||||
(company_id, formula_id, drcr, account_code, amount_key, description, sort_order)
|
||||
VALUES
|
||||
(:cid, :formula_id, :drcr, :account_code, :amount_key, :description, :sort_order)"
|
||||
);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':formula_id' => $formulaId,
|
||||
':drcr' => $item['drcr'],
|
||||
':account_code' => $item['account_code'],
|
||||
':amount_key' => $item['amount_key'],
|
||||
':description' => $item['description'] !== '' ? $item['description'] : null,
|
||||
':sort_order' => $item['sort_order'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -13,7 +13,7 @@ class ChartOfAccounts
|
||||
public function getAll(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_account
|
||||
"SELECT * FROM md_account
|
||||
WHERE company_id = :cid
|
||||
ORDER BY account_code ASC"
|
||||
);
|
||||
@@ -24,7 +24,7 @@ class ChartOfAccounts
|
||||
public function getByCode(string $account_code): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_account
|
||||
"SELECT * FROM md_account
|
||||
WHERE company_id = :cid AND account_code = :code
|
||||
LIMIT 1"
|
||||
);
|
||||
@@ -36,7 +36,7 @@ class ChartOfAccounts
|
||||
public function getByType(string $account_type): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_account
|
||||
"SELECT * FROM md_account
|
||||
WHERE company_id = :cid AND account_type = :type AND status = 1
|
||||
ORDER BY account_code ASC"
|
||||
);
|
||||
@@ -47,7 +47,7 @@ class ChartOfAccounts
|
||||
public function getById(int $id): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_account
|
||||
"SELECT * FROM md_account
|
||||
WHERE company_id = :cid AND id = :id
|
||||
LIMIT 1"
|
||||
);
|
||||
@@ -59,19 +59,20 @@ class ChartOfAccounts
|
||||
public function create(array $data): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO ac_account
|
||||
(company_id, account_code, account_name, account_type, parent_code, is_posting, status)
|
||||
"INSERT INTO md_account
|
||||
(company_id, account_code, account_name, account_type, account_category, parent_code, is_posting, status)
|
||||
VALUES
|
||||
(:cid, :code, :name, :type, :parent, :posting, :status)"
|
||||
(:cid, :code, :name, :type, :category, :parent, :posting, :status)"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':code' => trim($data['account_code']),
|
||||
':name' => trim($data['account_name']),
|
||||
':type' => $data['account_type'],
|
||||
':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null,
|
||||
':posting' => (int)($data['is_posting'] ?? 1),
|
||||
':status' => (int)($data['status'] ?? 1),
|
||||
':cid' => $this->companyId,
|
||||
':code' => trim($data['account_code']),
|
||||
':name' => trim($data['account_name']),
|
||||
':type' => $data['account_type'],
|
||||
':category' => !empty($data['account_category']) ? trim($data['account_category']) : '',
|
||||
':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null,
|
||||
':posting' => (int)($data['is_posting'] ?? 1),
|
||||
':status' => (int)($data['status'] ?? 1),
|
||||
]);
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
@@ -79,31 +80,33 @@ class ChartOfAccounts
|
||||
public function update(int $id, array $data): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE ac_account SET
|
||||
account_code = :code,
|
||||
account_name = :name,
|
||||
account_type = :type,
|
||||
parent_code = :parent,
|
||||
is_posting = :posting,
|
||||
status = :status
|
||||
"UPDATE md_account SET
|
||||
account_code = :code,
|
||||
account_name = :name,
|
||||
account_type = :type,
|
||||
account_category = :category,
|
||||
parent_code = :parent,
|
||||
is_posting = :posting,
|
||||
status = :status
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':id' => $id,
|
||||
':code' => trim($data['account_code']),
|
||||
':name' => trim($data['account_name']),
|
||||
':type' => $data['account_type'],
|
||||
':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null,
|
||||
':posting' => (int)($data['is_posting'] ?? 1),
|
||||
':status' => (int)($data['status'] ?? 1),
|
||||
':cid' => $this->companyId,
|
||||
':id' => $id,
|
||||
':code' => trim($data['account_code']),
|
||||
':name' => trim($data['account_name']),
|
||||
':type' => $data['account_type'],
|
||||
':category' => !empty($data['account_category']) ? trim($data['account_category']) : '',
|
||||
':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null,
|
||||
':posting' => (int)($data['is_posting'] ?? 1),
|
||||
':status' => (int)($data['status'] ?? 1),
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE ac_account SET status = 0
|
||||
"UPDATE md_account SET status = 0
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
@@ -112,7 +115,7 @@ class ChartOfAccounts
|
||||
public function isPostingAccount(string $account_code): bool
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT is_posting FROM ac_account
|
||||
"SELECT is_posting FROM md_account
|
||||
WHERE company_id = :cid AND account_code = :code AND status = 1
|
||||
LIMIT 1"
|
||||
);
|
||||
@@ -124,18 +127,29 @@ class ChartOfAccounts
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(status = 1) AS active,
|
||||
SUM(status = 0) AS inactive,
|
||||
COUNT(*) AS total,
|
||||
SUM(status = 1) AS active,
|
||||
SUM(status = 0) AS inactive,
|
||||
SUM(account_type = 'asset') AS asset,
|
||||
SUM(account_type = 'liability') AS liability,
|
||||
SUM(account_type = 'equity') AS equity,
|
||||
SUM(account_type = 'revenue') AS revenue,
|
||||
SUM(account_type = 'expense') AS expense
|
||||
FROM ac_account
|
||||
FROM md_account
|
||||
WHERE company_id = :cid"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
public function getByCategory(string $category): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_account
|
||||
WHERE company_id = :cid AND account_category = :cat AND status = 1
|
||||
ORDER BY account_code ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':cat' => $category]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class DepartmentManager
|
||||
public function getAll(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_department
|
||||
"SELECT * FROM md_department
|
||||
WHERE company_id = :cid
|
||||
ORDER BY dept_code ASC"
|
||||
);
|
||||
@@ -24,7 +24,7 @@ class DepartmentManager
|
||||
public function getById(int $id): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_department
|
||||
"SELECT * FROM md_department
|
||||
WHERE company_id = :cid AND id = :id LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
@@ -35,7 +35,7 @@ class DepartmentManager
|
||||
public function create(array $data): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO ac_department (company_id, dept_code, dept_name, description, status)
|
||||
"INSERT INTO md_department (company_id, dept_code, dept_name, description, status)
|
||||
VALUES (:cid, :code, :name, :desc, :status)"
|
||||
);
|
||||
$sth->execute([
|
||||
@@ -51,7 +51,7 @@ class DepartmentManager
|
||||
public function update(int $id, array $data): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE ac_department SET
|
||||
"UPDATE md_department SET
|
||||
dept_code = :code,
|
||||
dept_name = :name,
|
||||
description = :desc,
|
||||
@@ -71,7 +71,7 @@ class DepartmentManager
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE ac_department SET status = 0
|
||||
"UPDATE md_department SET status = 0
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
@@ -83,7 +83,7 @@ class DepartmentManager
|
||||
"SELECT COUNT(*) AS total,
|
||||
SUM(status = 1) AS active,
|
||||
SUM(status = 0) AS inactive
|
||||
FROM ac_department WHERE company_id = :cid"
|
||||
FROM md_department WHERE company_id = :cid"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
/**
|
||||
* FinancialReports
|
||||
*
|
||||
* Generates all financial statements from ac_balance + ac_journal_line.
|
||||
* Read-only — no writes.
|
||||
* Generates all financial statements from td_gl + td_gl_item joined with md_account.
|
||||
* Read-only — no writes. All results are reusable across API endpoints and future reports.
|
||||
*/
|
||||
class FinancialReports
|
||||
{
|
||||
@@ -16,8 +16,476 @@ class FinancialReports
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
// TODO: getTrialBalance(string $from_period, string $to_period, ?int $dept_id): array
|
||||
// TODO: getProfitAndLoss(string $from_period, string $to_period, ?int $dept_id): array
|
||||
// TODO: getBalanceSheet(string $as_of_period, ?int $dept_id): array
|
||||
// TODO: getGLMovement(string $account_code, string $from_period, string $to_period): array
|
||||
// ── Trial Balance ─────────────────────────────────────────────────────────
|
||||
// Returns every posting account with brought-forward balance, period movement,
|
||||
// and carry-forward balance for the selected date range.
|
||||
|
||||
private function normalizeDate(string $date, bool $end_of_month = false): string
|
||||
{
|
||||
$date = trim($date);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $date;
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $date)) {
|
||||
return $end_of_month ? date('Y-m-t', strtotime($date . '-01')) : $date . '-01';
|
||||
}
|
||||
return date('Y-m-d');
|
||||
}
|
||||
|
||||
public function getTrialBalance(string $from_date, string $to_date, int $dept_id = 0): array
|
||||
{
|
||||
$from_date = $this->normalizeDate($from_date);
|
||||
$to_date = $this->normalizeDate($to_date, true);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_type,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) < :from_date THEN i.debit ELSE 0 END), 0) AS opening_debit,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) < :from_date THEN i.credit ELSE 0 END), 0) AS opening_credit,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date THEN i.debit ELSE 0 END), 0) AS period_debit,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date THEN i.credit ELSE 0 END), 0) AS period_credit
|
||||
FROM md_account a
|
||||
LEFT JOIN td_gl_item i ON i.company_id = a.company_id
|
||||
AND i.account_code = a.account_code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
LEFT JOIN td_gl g ON g.id = i.gl_id
|
||||
AND g.company_id = a.company_id
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :to_date
|
||||
WHERE a.company_id = :cid AND a.is_posting = 1 AND a.status = 1
|
||||
GROUP BY a.account_code, a.account_name, a.account_type
|
||||
HAVING opening_debit <> 0
|
||||
OR opening_credit <> 0
|
||||
OR period_debit <> 0
|
||||
OR period_credit <> 0
|
||||
ORDER BY a.account_code
|
||||
");
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':from_date' => $from_date,
|
||||
':to_date' => $to_date,
|
||||
':dept_id' => $dept_id,
|
||||
]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$total_opening_debit = 0.0;
|
||||
$total_opening_credit = 0.0;
|
||||
$total_period_debit = 0.0;
|
||||
$total_period_credit = 0.0;
|
||||
$total_closing_debit = 0.0;
|
||||
$total_closing_credit = 0.0;
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$opening_balance = (float)$row['opening_debit'] - (float)$row['opening_credit'];
|
||||
$period_debit = (float)$row['period_debit'];
|
||||
$period_credit = (float)$row['period_credit'];
|
||||
$closing_balance = $opening_balance + $period_debit - $period_credit;
|
||||
|
||||
$row['opening_balance'] = $opening_balance;
|
||||
$row['opening_debit'] = $opening_balance > 0 ? $opening_balance : 0.0;
|
||||
$row['opening_credit'] = $opening_balance < 0 ? abs($opening_balance) : 0.0;
|
||||
$row['period_debit'] = $period_debit;
|
||||
$row['period_credit'] = $period_credit;
|
||||
$row['closing_balance'] = $closing_balance;
|
||||
$row['closing_debit'] = $closing_balance > 0 ? $closing_balance : 0.0;
|
||||
$row['closing_credit'] = $closing_balance < 0 ? abs($closing_balance) : 0.0;
|
||||
|
||||
$total_opening_debit += $row['opening_debit'];
|
||||
$total_opening_credit += $row['opening_credit'];
|
||||
$total_period_debit += $row['period_debit'];
|
||||
$total_period_credit += $row['period_credit'];
|
||||
$total_closing_debit += $row['closing_debit'];
|
||||
$total_closing_credit += $row['closing_credit'];
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'total_opening_debit' => $total_opening_debit,
|
||||
'total_opening_credit' => $total_opening_credit,
|
||||
'total_period_debit' => $total_period_debit,
|
||||
'total_period_credit' => $total_period_credit,
|
||||
'total_closing_debit' => $total_closing_debit,
|
||||
'total_closing_credit' => $total_closing_credit,
|
||||
'total_debit' => $total_closing_debit,
|
||||
'total_credit' => $total_closing_credit,
|
||||
'from_date' => $from_date,
|
||||
'to_date' => $to_date,
|
||||
'from_period' => substr($from_date, 0, 7),
|
||||
'to_period' => substr($to_date, 0, 7),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Profit & Loss ─────────────────────────────────────────────────────────
|
||||
// Returns revenue and expense accounts for the date range.
|
||||
// revenue net = credit - debit; expense net = debit - credit (normal balances).
|
||||
|
||||
public function getProfitAndLoss(string $from_date, string $to_date, int $dept_id = 0): array
|
||||
{
|
||||
$from_date = $this->normalizeDate($from_date);
|
||||
$to_date = $this->normalizeDate($to_date, true);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_type,
|
||||
COALESCE(SUM(i.debit), 0) AS total_debit,
|
||||
COALESCE(SUM(i.credit), 0) AS total_credit
|
||||
FROM md_account a
|
||||
JOIN td_gl_item i ON i.company_id = a.company_id
|
||||
AND i.account_code = a.account_code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
JOIN td_gl g ON g.id = i.gl_id
|
||||
AND g.company_id = a.company_id
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date
|
||||
WHERE a.company_id = :cid
|
||||
AND a.account_type IN ('revenue','expense')
|
||||
AND a.is_posting = 1
|
||||
GROUP BY a.account_code, a.account_name, a.account_type
|
||||
ORDER BY a.account_type DESC, a.account_code
|
||||
");
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':from_date' => $from_date,
|
||||
':to_date' => $to_date,
|
||||
':dept_id' => $dept_id,
|
||||
]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$total_revenue = 0.0;
|
||||
$total_expense = 0.0;
|
||||
foreach ($rows as $r) {
|
||||
if ($r['account_type'] === 'revenue') $total_revenue += $r['total_credit'] - $r['total_debit'];
|
||||
if ($r['account_type'] === 'expense') $total_expense += $r['total_debit'] - $r['total_credit'];
|
||||
}
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'total_revenue' => $total_revenue,
|
||||
'total_expense' => $total_expense,
|
||||
'net_profit' => $total_revenue - $total_expense,
|
||||
'from_date' => $from_date,
|
||||
'to_date' => $to_date,
|
||||
'from_period' => substr($from_date, 0, 7),
|
||||
'to_period' => substr($to_date, 0, 7),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Balance Sheet ─────────────────────────────────────────────────────────
|
||||
// Cumulative balances for asset/liability/equity accounts up to as_of_period.
|
||||
// Retained earnings = cumulative net P&L (revenue - expense) through that period.
|
||||
|
||||
public function getBalanceSheet(string $as_of_date, int $dept_id = 0): array
|
||||
{
|
||||
$as_of_date = $this->normalizeDate($as_of_date, true);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_type,
|
||||
COALESCE(SUM(i.debit), 0) AS total_debit,
|
||||
COALESCE(SUM(i.credit), 0) AS total_credit
|
||||
FROM md_account a
|
||||
JOIN td_gl_item i ON i.company_id = a.company_id
|
||||
AND i.account_code = a.account_code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
JOIN td_gl g ON g.id = i.gl_id
|
||||
AND g.company_id = a.company_id
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :as_of
|
||||
WHERE a.company_id = :cid
|
||||
AND a.account_type IN ('asset','liability','equity')
|
||||
AND a.is_posting = 1 AND a.status = 1
|
||||
GROUP BY a.account_code, a.account_name, a.account_type
|
||||
ORDER BY a.account_type, a.account_code
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':as_of' => $as_of_date, ':dept_id' => $dept_id]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN a.account_type = 'revenue'
|
||||
THEN i.credit - i.debit ELSE 0 END), 0) AS net_revenue,
|
||||
COALESCE(SUM(CASE WHEN a.account_type = 'expense'
|
||||
THEN i.debit - i.credit ELSE 0 END), 0) AS net_expense
|
||||
FROM md_account a
|
||||
JOIN td_gl_item i ON i.company_id = a.company_id
|
||||
AND i.account_code = a.account_code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
JOIN td_gl g ON g.id = i.gl_id
|
||||
AND g.company_id = a.company_id
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :as_of
|
||||
WHERE a.company_id = :cid
|
||||
AND a.account_type IN ('revenue','expense')
|
||||
AND a.is_posting = 1
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':as_of' => $as_of_date, ':dept_id' => $dept_id]);
|
||||
$pl = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'retained_earnings' => (float)($pl['net_revenue'] ?? 0) - (float)($pl['net_expense'] ?? 0),
|
||||
'as_of_date' => $as_of_date,
|
||||
'as_of_period' => substr($as_of_date, 0, 7),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Monthly Trend ─────────────────────────────────────────────────────────
|
||||
// Revenue vs expense per period for the last N months (defaults to 6).
|
||||
// Returns rows: [{period, revenue, expense}]
|
||||
|
||||
public function getMonthlyTrend(int $months = 6): array
|
||||
{
|
||||
$from = date('Y-m', strtotime('-' . ($months - 1) . ' months'));
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
g.period,
|
||||
SUM(CASE WHEN a.account_type = 'revenue' THEN i.credit - i.debit ELSE 0 END) AS revenue,
|
||||
SUM(CASE WHEN a.account_type = 'expense' THEN i.debit - i.credit ELSE 0 END) AS expense
|
||||
FROM td_gl g
|
||||
JOIN td_gl_item i ON i.gl_id = g.id AND i.company_id = g.company_id
|
||||
JOIN md_account a ON a.company_id = i.company_id AND a.account_code = i.account_code
|
||||
WHERE g.company_id = :cid AND g.period >= :from_period
|
||||
GROUP BY g.period
|
||||
ORDER BY g.period ASC
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':from_period' => $from]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// ── Dashboard focused methods ─────────────────────────────────────────────
|
||||
// One method per dashboard card/section. Used by separate REST endpoints.
|
||||
|
||||
public function getDashboardPL(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
a.account_type,
|
||||
COALESCE(SUM(i.debit), 0) AS total_debit,
|
||||
COALESCE(SUM(i.credit), 0) AS total_credit
|
||||
FROM md_account a
|
||||
JOIN td_gl_item i ON i.company_id = a.company_id AND i.account_code = a.account_code
|
||||
JOIN td_gl g ON g.company_id = i.company_id AND g.id = i.gl_id
|
||||
WHERE a.company_id = :cid
|
||||
AND a.account_type IN ('revenue', 'expense')
|
||||
AND a.is_posting = 1
|
||||
GROUP BY a.account_type
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
|
||||
$revenue = 0.0;
|
||||
$expense = 0.0;
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
if ($row['account_type'] === 'revenue') $revenue += (float)$row['total_credit'] - (float)$row['total_debit'];
|
||||
if ($row['account_type'] === 'expense') $expense += (float)$row['total_debit'] - (float)$row['total_credit'];
|
||||
}
|
||||
|
||||
return ['revenue' => $revenue, 'expense' => $expense, 'net_profit' => $revenue - $expense];
|
||||
}
|
||||
|
||||
public function getDashboardJournalCount(): int
|
||||
{
|
||||
$sth = $this->pdo->prepare("SELECT COUNT(*) FROM td_gl WHERE company_id = :cid");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return (int)$sth->fetchColumn();
|
||||
}
|
||||
|
||||
public function getDashboardBySource(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT source_type, COUNT(*) AS cnt
|
||||
FROM td_gl
|
||||
WHERE company_id = :cid
|
||||
GROUP BY source_type
|
||||
ORDER BY cnt DESC
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getDashboardRecentEntries(int $limit = 10): array
|
||||
{
|
||||
$limit = max(1, min(100, $limit));
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
g.id, g.source_type,
|
||||
COALESCE(g.reference, '') AS reference,
|
||||
COALESCE(g.description, '') AS description,
|
||||
COALESCE(g.journal_date, DATE(g.created_at)) AS journal_date,
|
||||
g.period,
|
||||
SUM(i.debit) AS total_debit,
|
||||
SUM(i.credit) AS total_credit
|
||||
FROM td_gl g
|
||||
JOIN td_gl_item i ON i.gl_id = g.id AND i.company_id = g.company_id
|
||||
WHERE g.company_id = :cid
|
||||
GROUP BY g.id, g.source_type, g.reference, g.description, g.journal_date, g.period, g.created_at
|
||||
ORDER BY g.created_at DESC
|
||||
LIMIT {$limit}
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getDashboardSummary(int $trend_months = 6, int $recent_limit = 10): array
|
||||
{
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
a.account_type,
|
||||
COALESCE(SUM(i.debit), 0) AS total_debit,
|
||||
COALESCE(SUM(i.credit), 0) AS total_credit
|
||||
FROM md_account a
|
||||
JOIN td_gl_item i
|
||||
ON i.company_id = a.company_id
|
||||
AND i.account_code = a.account_code
|
||||
JOIN td_gl g
|
||||
ON g.company_id = i.company_id
|
||||
AND g.id = i.gl_id
|
||||
WHERE a.company_id = :cid
|
||||
AND a.account_type IN ('revenue', 'expense')
|
||||
AND a.is_posting = 1
|
||||
GROUP BY a.account_type
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
|
||||
$total_revenue = 0.0;
|
||||
$total_expense = 0.0;
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
if ($row['account_type'] === 'revenue') {
|
||||
$total_revenue += (float)$row['total_credit'] - (float)$row['total_debit'];
|
||||
}
|
||||
if ($row['account_type'] === 'expense') {
|
||||
$total_expense += (float)$row['total_debit'] - (float)$row['total_credit'];
|
||||
}
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT COUNT(*) FROM td_gl WHERE company_id = :cid");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
$journal_count = (int)$sth->fetchColumn();
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT source_type, COUNT(*) AS cnt
|
||||
FROM td_gl
|
||||
WHERE company_id = :cid
|
||||
GROUP BY source_type
|
||||
ORDER BY cnt DESC
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
$by_source = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
g.id, g.source_type,
|
||||
COALESCE(g.reference, '') AS reference,
|
||||
COALESCE(g.description, '') AS description,
|
||||
COALESCE(g.journal_date, DATE(g.created_at)) AS journal_date,
|
||||
g.period,
|
||||
SUM(i.debit) AS total_debit,
|
||||
SUM(i.credit) AS total_credit
|
||||
FROM td_gl g
|
||||
JOIN td_gl_item i ON i.gl_id = g.id AND i.company_id = g.company_id
|
||||
WHERE g.company_id = :cid
|
||||
GROUP BY g.id, g.source_type, g.reference, g.description, g.journal_date, g.period, g.created_at
|
||||
ORDER BY g.created_at DESC
|
||||
LIMIT {$recent_limit}
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
$recent = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return [
|
||||
'revenue' => $total_revenue,
|
||||
'expense' => $total_expense,
|
||||
'net_profit' => $total_revenue - $total_expense,
|
||||
'journal_count' => $journal_count,
|
||||
'trend' => $this->getMonthlyTrend($trend_months),
|
||||
'by_source' => $by_source,
|
||||
'recent' => $recent,
|
||||
];
|
||||
}
|
||||
|
||||
// ── GL Movement ───────────────────────────────────────────────────────────
|
||||
// Per-account ledger: opening balance + chronological lines with running balance.
|
||||
|
||||
public function getGLMovement(string $account_code, string $from_date, string $to_date, int $dept_id = 0): array
|
||||
{
|
||||
$from_date = $this->normalizeDate($from_date);
|
||||
$to_date = $this->normalizeDate($to_date, true);
|
||||
|
||||
// Opening balance = all movements before from_date
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
COALESCE(SUM(i.debit), 0) AS opening_debit,
|
||||
COALESCE(SUM(i.credit), 0) AS opening_credit
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
WHERE i.company_id = :cid
|
||||
AND i.account_code = :code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) < :from_date
|
||||
");
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':code' => $account_code,
|
||||
':from_date' => $from_date,
|
||||
':dept_id' => $dept_id,
|
||||
]);
|
||||
$ob = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
$opening_balance = (float)$ob['opening_debit'] - (float)$ob['opening_credit'];
|
||||
|
||||
// Period lines
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
g.id AS gl_id,
|
||||
g.source_type,
|
||||
COALESCE(g.reference, '') AS reference,
|
||||
COALESCE(g.description, '') AS gl_description,
|
||||
g.period,
|
||||
i.department_id,
|
||||
COALESCE(d.dept_code, '') AS dept_code,
|
||||
COALESCE(d.dept_name, '') AS dept_name,
|
||||
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
i.debit,
|
||||
i.credit,
|
||||
COALESCE(i.description, '') AS line_description
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
LEFT JOIN md_department d ON d.company_id = i.company_id AND d.id = i.department_id
|
||||
WHERE i.company_id = :cid
|
||||
AND i.account_code = :code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date
|
||||
ORDER BY g.journal_date ASC, g.id ASC, i.id ASC
|
||||
");
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':code' => $account_code,
|
||||
':from_date' => $from_date,
|
||||
':to_date' => $to_date,
|
||||
':dept_id' => $dept_id,
|
||||
]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Account metadata
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT account_name, account_type
|
||||
FROM md_account
|
||||
WHERE company_id = :cid AND account_code = :code LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':code' => $account_code]);
|
||||
$acct = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return [
|
||||
'account_code' => $account_code,
|
||||
'account_name' => $acct['account_name'] ?? '',
|
||||
'account_type' => $acct['account_type'] ?? '',
|
||||
'opening_balance' => $opening_balance,
|
||||
'rows' => $rows,
|
||||
'from_date' => $from_date,
|
||||
'to_date' => $to_date,
|
||||
'from_period' => substr($from_date, 0, 7),
|
||||
'to_period' => substr($to_date, 0, 7),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
/**
|
||||
* GlManager
|
||||
*
|
||||
* Owns all read/write operations on td_gl and td_gl_item.
|
||||
* Called by PostingEngine after building the journal lines.
|
||||
*
|
||||
* Lifecycle:
|
||||
* post() — first-time GL creation; throws if a record already exists.
|
||||
* replace() — snapshot current lines into td_gl.history, then delete + re-insert.
|
||||
* delete() — hard-delete both td_gl and all its td_gl_item rows (used on void).
|
||||
*/
|
||||
class GlManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
private ?PostingWindowGuard $postingWindowGuard;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id, ?PostingWindowGuard $postingWindowGuard = null)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
$this->postingWindowGuard = $postingWindowGuard;
|
||||
}
|
||||
|
||||
// ── public ───────────────────────────────────────────────────────────────
|
||||
|
||||
public function getBySource(string $source_type, int $source_id): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_gl
|
||||
WHERE company_id = :cid
|
||||
AND source_type = :source_type
|
||||
AND source_id = :source_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':source_type' => $source_type,
|
||||
':source_id' => $source_id,
|
||||
]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
public function post(string $source_type, int $source_id, int $formula_id, string $period, array $lines, array $meta = []): void
|
||||
{
|
||||
$journal_date = $this->resolveJournalDate($meta['journal_date'] ?? null, $period);
|
||||
$this->assertPostingWindow($journal_date, 'GL posting');
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO td_gl
|
||||
(company_id, source_type, source_id, reference, description, journal_date,
|
||||
formula_id, period, current_version, history, created_at, updated_at)
|
||||
VALUES
|
||||
(:cid, :source_type, :source_id, :reference, :description, :journal_date,
|
||||
:formula_id, :period, 1, '[]', :created_at, :updated_at)"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':source_type' => $source_type,
|
||||
':source_id' => $source_id,
|
||||
':reference' => $meta['reference'] ?? '',
|
||||
':description' => $meta['description'] ?? '',
|
||||
':journal_date' => $journal_date,
|
||||
':formula_id' => $formula_id,
|
||||
':period' => $period,
|
||||
':created_at' => $now,
|
||||
':updated_at' => $now,
|
||||
]);
|
||||
$this->insertLines((int)$this->pdo->lastInsertId(), $lines);
|
||||
}
|
||||
|
||||
public function replaceManual(int $gl_id, string $reference, string $description, string $journal_date, string $period, array $lines): void
|
||||
{
|
||||
$this->assertPostingWindow($journal_date, 'Manual journal');
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, current_version, formula_id, history
|
||||
FROM td_gl
|
||||
WHERE company_id = :cid AND id = :gl_id AND source_type = 'manual'
|
||||
FOR UPDATE"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||
$gl = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$gl) throw new Exception("Manual GL entry #$gl_id not found.");
|
||||
|
||||
$version = (int)$gl['current_version'];
|
||||
$history = json_decode($gl['history'], true) ?: [];
|
||||
|
||||
$history[] = [
|
||||
'version' => $version,
|
||||
'formula_id' => 0,
|
||||
'replaced_at' => $now,
|
||||
'lines' => $this->getLines($gl_id),
|
||||
];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id"
|
||||
)->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_gl
|
||||
SET reference = :reference, description = :description,
|
||||
journal_date = :journal_date, period = :period,
|
||||
current_version = :current_version, history = :history,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id"
|
||||
)->execute([
|
||||
':reference' => $reference,
|
||||
':description' => $description,
|
||||
':journal_date' => $journal_date,
|
||||
':period' => $period,
|
||||
':current_version' => $version + 1,
|
||||
':history' => json_encode($history),
|
||||
':updated_at' => $now,
|
||||
':id' => $gl_id,
|
||||
]);
|
||||
|
||||
$this->insertLines($gl_id, $lines);
|
||||
}
|
||||
|
||||
public function postManual(string $reference, string $description, string $journal_date, string $period, array $lines): int
|
||||
{
|
||||
$this->assertPostingWindow($journal_date, 'Manual journal');
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO td_gl
|
||||
(company_id, source_type, source_id, reference, description, journal_date,
|
||||
formula_id, period, current_version, history, created_at, updated_at)
|
||||
VALUES
|
||||
(:cid, 'manual', 0, :reference, :description, :journal_date,
|
||||
0, :period, 1, '[]', :created_at, :updated_at)"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':reference' => $reference,
|
||||
':description' => $description,
|
||||
':journal_date' => $journal_date,
|
||||
':period' => $period,
|
||||
':created_at' => $now,
|
||||
':updated_at' => $now,
|
||||
]);
|
||||
$gl_id = (int)$this->pdo->lastInsertId();
|
||||
$this->insertLines($gl_id, $lines);
|
||||
return $gl_id;
|
||||
}
|
||||
|
||||
public function replace(string $source_type, int $source_id, int $formula_id, string $period, array $lines, array $meta = []): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, current_version, formula_id, history, journal_date
|
||||
FROM td_gl
|
||||
WHERE company_id = :cid
|
||||
AND source_type = :source_type
|
||||
AND source_id = :source_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':source_type' => $source_type,
|
||||
':source_id' => $source_id,
|
||||
]);
|
||||
$gl = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$gl) throw new Exception("GL record not found for replace.");
|
||||
|
||||
$journal_date = $this->resolveJournalDate($meta['journal_date'] ?? ($gl['journal_date'] ?? null), $period);
|
||||
$this->assertPostingWindow($journal_date, 'GL posting');
|
||||
|
||||
$gl_id = (int)$gl['id'];
|
||||
$version = (int)$gl['current_version'];
|
||||
$history = json_decode($gl['history'], true) ?: [];
|
||||
|
||||
// Snapshot current lines into history
|
||||
$history[] = [
|
||||
'version' => $version,
|
||||
'formula_id' => (int)$gl['formula_id'],
|
||||
'replaced_at' => $now,
|
||||
'lines' => $this->getLines($gl_id),
|
||||
];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id"
|
||||
)->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_gl
|
||||
SET formula_id = :formula_id,
|
||||
journal_date = :journal_date,
|
||||
period = :period,
|
||||
current_version = :current_version,
|
||||
history = :history,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id"
|
||||
)->execute([
|
||||
':formula_id' => $formula_id,
|
||||
':journal_date' => $journal_date,
|
||||
':period' => $period,
|
||||
':current_version' => $version + 1,
|
||||
':history' => json_encode($history),
|
||||
':updated_at' => $now,
|
||||
':id' => $gl_id,
|
||||
]);
|
||||
|
||||
$this->insertLines($gl_id, $lines);
|
||||
}
|
||||
|
||||
public function delete(string $source_type, int $source_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, journal_date, period FROM td_gl
|
||||
WHERE company_id = :cid
|
||||
AND source_type = :source_type
|
||||
AND source_id = :source_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':source_type' => $source_type,
|
||||
':source_id' => $source_id,
|
||||
]);
|
||||
$gl = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
$gl_id = (int)($gl['id'] ?? 0);
|
||||
if (!$gl_id) return;
|
||||
|
||||
$journal_date = $this->resolveJournalDate($gl['journal_date'] ?? null, (string)($gl['period'] ?? ''));
|
||||
$this->assertPostingWindow($journal_date, 'GL posting deletion');
|
||||
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id"
|
||||
)->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_gl WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $gl_id, ':cid' => $this->companyId]);
|
||||
}
|
||||
|
||||
// ── private ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function getLines(int $gl_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT account_code, department_id, debit, credit, description
|
||||
FROM td_gl_item
|
||||
WHERE company_id = :cid AND gl_id = :gl_id
|
||||
ORDER BY id ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private function assertPostingWindow(?string $journal_date, string $context): void
|
||||
{
|
||||
if ($this->postingWindowGuard) {
|
||||
$this->postingWindowGuard->assertOpenDate($journal_date, $context);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveJournalDate(?string $journal_date, string $period): string
|
||||
{
|
||||
$journal_date = trim((string)$journal_date);
|
||||
if ($journal_date !== '') {
|
||||
return $journal_date;
|
||||
}
|
||||
|
||||
$period = trim($period);
|
||||
return preg_match('/^\d{4}-\d{2}$/', $period) ? "{$period}-01" : '';
|
||||
}
|
||||
|
||||
private function insertLines(int $gl_id, array $lines): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO td_gl_item
|
||||
(company_id, gl_id, account_code, department_id, debit, credit, description)
|
||||
VALUES
|
||||
(:cid, :gl_id, :account_code, :dept_id, :debit, :credit, :description)"
|
||||
);
|
||||
foreach ($lines as $line) {
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':gl_id' => $gl_id,
|
||||
':account_code' => $line['account_code'],
|
||||
':dept_id' => (int)($line['department_id'] ?? 0),
|
||||
':debit' => (float)($line['debit'] ?? 0),
|
||||
':credit' => (float)($line['credit'] ?? 0),
|
||||
':description' => (string)($line['description'] ?? ''),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
class GlQueryManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
private array $invoiceTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note'];
|
||||
private array $mappingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'purchase_order'];
|
||||
private array $postingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'purchase_order'];
|
||||
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order'];
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
public function getPostableDocuments(string $doc_type, string $date_from = '', string $date_to = '', int $formula_id = 0): array
|
||||
{
|
||||
$doc_type = trim($doc_type);
|
||||
$date_from = $this->parseDate($date_from);
|
||||
$date_to = $this->parseDate($date_to);
|
||||
|
||||
if (!in_array($doc_type, $this->postingTypes, true)) {
|
||||
throw new Exception('Invalid doc_type.');
|
||||
}
|
||||
|
||||
if (in_array($doc_type, $this->invoiceTypes, true)) {
|
||||
$rows = $this->getInvoicePostableDocuments($doc_type, $date_from, $date_to, $formula_id);
|
||||
} elseif ($doc_type === 'receipt') {
|
||||
$rows = $this->getReceiptPostableDocuments($date_from, $date_to);
|
||||
} elseif ($doc_type === 'purchase_order') {
|
||||
$rows = $this->getPurchaseOrderPostableDocuments($date_from, $date_to, $formula_id);
|
||||
} else {
|
||||
$rows = $this->getPaymentPostableDocuments($date_from, $date_to);
|
||||
}
|
||||
|
||||
if ($formula_id > 0) {
|
||||
$rows = array_values(array_filter($rows, function($row) use ($formula_id) {
|
||||
return $row['gl_status'] == 0 || (int)$row['gl_formula_id'] === $formula_id;
|
||||
}));
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function getJournalListing(string $source_type = '', string $date_from = '', string $date_to = ''): array
|
||||
{
|
||||
$source_type = trim($source_type);
|
||||
$date_from = $this->parseDate($date_from);
|
||||
$date_to = $this->parseDate($date_to);
|
||||
|
||||
$where = ['g.company_id = :cid'];
|
||||
$params = [':cid' => $this->companyId];
|
||||
|
||||
if ($source_type && in_array($source_type, $this->journalTypes, true)) {
|
||||
$where[] = 'g.source_type = :source_type';
|
||||
$params[':source_type'] = $source_type;
|
||||
}
|
||||
if ($date_from) {
|
||||
$where[] = 'g.journal_date >= :date_from';
|
||||
$params[':date_from'] = $date_from;
|
||||
}
|
||||
if ($date_to) {
|
||||
$where[] = 'g.journal_date <= :date_to';
|
||||
$params[':date_to'] = $date_to;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
g.id,
|
||||
g.source_type,
|
||||
g.source_id,
|
||||
g.period,
|
||||
g.current_version,
|
||||
g.formula_id,
|
||||
COALESCE(f.formula_name, '') AS formula_name,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at,
|
||||
CASE g.source_type
|
||||
WHEN 'receipt' THEN r.receipt_number
|
||||
WHEN 'payment' THEN p.payment_number
|
||||
WHEN 'manual' THEN IF(g.reference != '', g.reference, CONCAT('MJE-', g.id))
|
||||
WHEN 'purchase_order' THEN po.po_number
|
||||
ELSE i.invoice_number
|
||||
END AS doc_number,
|
||||
COALESCE(
|
||||
CASE g.source_type
|
||||
WHEN 'receipt' THEN cr.contact_name
|
||||
WHEN 'payment' THEN cp.contact_name
|
||||
WHEN 'manual' THEN g.description
|
||||
WHEN 'purchase_order' THEN cpo.contact_name
|
||||
ELSE ci.contact_name
|
||||
END, ''
|
||||
) AS contact_name,
|
||||
COALESCE(SUM(gi.debit), 0) AS total_debit,
|
||||
COALESCE(SUM(gi.credit), 0) AS total_credit
|
||||
FROM td_gl g
|
||||
LEFT JOIN md_account_formula f
|
||||
ON f.company_id = g.company_id AND f.id = g.formula_id
|
||||
LEFT JOIN td_gl_item gi
|
||||
ON gi.company_id = g.company_id AND gi.gl_id = g.id
|
||||
LEFT JOIN td_invoice i
|
||||
ON g.source_type NOT IN ('receipt','payment','manual','purchase_order')
|
||||
AND i.company_id = g.company_id AND i.id = g.source_id
|
||||
LEFT JOIN md_contact ci
|
||||
ON ci.company_id = g.company_id AND ci.id = i.contact_id
|
||||
LEFT JOIN td_receipt r
|
||||
ON g.source_type = 'receipt'
|
||||
AND r.company_id = g.company_id AND r.id = g.source_id
|
||||
LEFT JOIN md_contact cr
|
||||
ON cr.company_id = g.company_id AND cr.id = r.contact_id
|
||||
LEFT JOIN td_payment p
|
||||
ON g.source_type = 'payment'
|
||||
AND p.company_id = g.company_id AND p.id = g.source_id
|
||||
LEFT JOIN md_contact cp
|
||||
ON cp.company_id = g.company_id AND cp.id = p.contact_id
|
||||
LEFT JOIN td_purchase_order po
|
||||
ON g.source_type = 'purchase_order'
|
||||
AND po.company_id = g.company_id AND po.id = g.source_id
|
||||
LEFT JOIN md_contact cpo
|
||||
ON cpo.company_id = g.company_id AND cpo.id = po.contact_id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
GROUP BY
|
||||
g.id, g.source_type, g.source_id, g.reference, g.description, g.period,
|
||||
g.current_version, g.formula_id, f.formula_name, g.created_at, g.updated_at,
|
||||
i.invoice_number, r.receipt_number, p.payment_number, po.po_number,
|
||||
ci.contact_name, cr.contact_name, cp.contact_name, cpo.contact_name
|
||||
ORDER BY g.created_at DESC, g.id DESC
|
||||
";
|
||||
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getJournalDetail(int $gl_id): array
|
||||
{
|
||||
if ($gl_id <= 0) {
|
||||
throw new Exception('gl_id is required.');
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT g.*,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt,
|
||||
DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt,
|
||||
COALESCE(f.formula_name, '') AS formula_name
|
||||
FROM td_gl g
|
||||
LEFT JOIN md_account_formula f
|
||||
ON f.company_id = g.company_id AND f.id = g.formula_id
|
||||
WHERE g.company_id = :cid AND g.id = :gl_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||
$header = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$header) {
|
||||
throw new Exception('GL entry not found.');
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT gi.account_code, gi.department_id, gi.debit, gi.credit, gi.description,
|
||||
COALESCE(a.account_name, '') AS account_name,
|
||||
COALESCE(d.dept_code, '') AS dept_code,
|
||||
COALESCE(d.dept_name, '') AS dept_name
|
||||
FROM td_gl_item gi
|
||||
LEFT JOIN md_account a
|
||||
ON a.company_id = gi.company_id AND a.account_code = gi.account_code
|
||||
LEFT JOIN md_department d
|
||||
ON d.company_id = gi.company_id AND d.id = gi.department_id
|
||||
WHERE gi.company_id = :cid AND gi.gl_id = :gl_id
|
||||
ORDER BY gi.id ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||
|
||||
return [
|
||||
'header' => $header,
|
||||
'lines' => $sth->fetchAll(PDO::FETCH_ASSOC),
|
||||
];
|
||||
}
|
||||
|
||||
private function getInvoicePostableDocuments(string $doc_type, string $date_from, string $date_to, int $formula_id): array
|
||||
{
|
||||
$requires_product_mapping = $this->formulaRequiresProductMapping($doc_type, $formula_id);
|
||||
$mapping_column = in_array($doc_type, ['invoice', 'credit_note'], true)
|
||||
? 'sales_account_code'
|
||||
: 'purchase_account_code';
|
||||
|
||||
$mapping_select = $requires_product_mapping
|
||||
? ", (
|
||||
SELECT COUNT(*)
|
||||
FROM td_invoice_item ii
|
||||
LEFT JOIN md_product p
|
||||
ON p.company_id = ii.company_id
|
||||
AND p.sku = ii.product_sku
|
||||
WHERE ii.company_id = i.company_id
|
||||
AND ii.invoice_id = i.id
|
||||
AND ABS(ii.total_price) > 0.0000001
|
||||
AND COALESCE(NULLIF(p.{$mapping_column}, ''), '') = ''
|
||||
) AS product_mapping_missing"
|
||||
: ", 0 AS product_mapping_missing";
|
||||
|
||||
$where = ['i.company_id = :cid', 'i.doc_type = :doc_type', 'i.status != 4'];
|
||||
$params = [':cid' => $this->companyId, ':doc_type' => $doc_type, ':source_type' => $doc_type];
|
||||
if ($date_from) { $where[] = 'i.issued_date >= :date_from'; $params[':date_from'] = $date_from; }
|
||||
if ($date_to) { $where[] = 'i.issued_date <= :date_to'; $params[':date_to'] = $date_to; }
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.id,
|
||||
i.invoice_number AS doc_number,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
i.grand_total,
|
||||
i.issued_date AS doc_date,
|
||||
IF(g.id IS NULL, 0, 1) AS gl_status,
|
||||
COALESCE(g.formula_id, 0) AS gl_formula_id,
|
||||
" . ($requires_product_mapping ? '1' : '0') . " AS product_mapping_checked
|
||||
{$mapping_select}
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id AND c.id = i.contact_id
|
||||
LEFT JOIN td_gl g
|
||||
ON g.company_id = i.company_id
|
||||
AND g.source_type = :source_type
|
||||
AND g.source_id = i.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY i.issued_date DESC, i.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private function getReceiptPostableDocuments(string $date_from, string $date_to): array
|
||||
{
|
||||
$where = ['r.company_id = :cid', 'r.status = 1'];
|
||||
$params = [':cid' => $this->companyId];
|
||||
if ($date_from) { $where[] = 'r.receipt_date >= :date_from'; $params[':date_from'] = $date_from; }
|
||||
if ($date_to) { $where[] = 'r.receipt_date <= :date_to'; $params[':date_to'] = $date_to; }
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.id,
|
||||
r.receipt_number AS doc_number,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
r.amount AS grand_total,
|
||||
r.receipt_date AS doc_date,
|
||||
IF(g.id IS NULL, 0, 1) AS gl_status,
|
||||
COALESCE(g.formula_id, 0) AS gl_formula_id,
|
||||
0 AS product_mapping_checked,
|
||||
0 AS product_mapping_missing
|
||||
FROM td_receipt r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id AND c.id = r.contact_id
|
||||
LEFT JOIN td_gl g
|
||||
ON g.company_id = r.company_id
|
||||
AND g.source_type = 'receipt'
|
||||
AND g.source_id = r.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY r.receipt_date DESC, r.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private function getPaymentPostableDocuments(string $date_from, string $date_to): array
|
||||
{
|
||||
$where = ['r.company_id = :cid', 'r.status = 1'];
|
||||
$params = [':cid' => $this->companyId];
|
||||
if ($date_from) { $where[] = 'r.payment_date >= :date_from'; $params[':date_from'] = $date_from; }
|
||||
if ($date_to) { $where[] = 'r.payment_date <= :date_to'; $params[':date_to'] = $date_to; }
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.id,
|
||||
r.payment_number AS doc_number,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
r.amount AS grand_total,
|
||||
r.payment_date AS doc_date,
|
||||
IF(g.id IS NULL, 0, 1) AS gl_status,
|
||||
COALESCE(g.formula_id, 0) AS gl_formula_id,
|
||||
0 AS product_mapping_checked,
|
||||
0 AS product_mapping_missing
|
||||
FROM td_payment r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id AND c.id = r.contact_id
|
||||
LEFT JOIN td_gl g
|
||||
ON g.company_id = r.company_id
|
||||
AND g.source_type = 'payment'
|
||||
AND g.source_id = r.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY r.payment_date DESC, r.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private function getPurchaseOrderPostableDocuments(string $date_from, string $date_to, int $formula_id): array
|
||||
{
|
||||
$requires_mapping = $this->formulaRequiresProductMapping('purchase_order', $formula_id);
|
||||
|
||||
$mapping_select = $requires_mapping
|
||||
? ", (
|
||||
SELECT COUNT(*)
|
||||
FROM td_purchase_order_item poi
|
||||
LEFT JOIN md_product p
|
||||
ON p.company_id = poi.company_id
|
||||
AND p.sku = poi.product_sku
|
||||
WHERE poi.company_id = po.company_id
|
||||
AND poi.order_id = po.id
|
||||
AND ABS(poi.total_price) > 0.0000001
|
||||
AND COALESCE(NULLIF(p.purchase_account_code, ''), '') = ''
|
||||
) AS product_mapping_missing"
|
||||
: ", 0 AS product_mapping_missing";
|
||||
|
||||
$where = ['po.company_id = :cid', 'po.status = 1'];
|
||||
$params = [':cid' => $this->companyId];
|
||||
if ($date_from) { $where[] = 'po.po_date >= :date_from'; $params[':date_from'] = $date_from; }
|
||||
if ($date_to) { $where[] = 'po.po_date <= :date_to'; $params[':date_to'] = $date_to; }
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT po.id,
|
||||
po.po_number AS doc_number,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
po.grand_total,
|
||||
po.po_date AS doc_date,
|
||||
IF(g.id IS NULL, 0, 1) AS gl_status,
|
||||
COALESCE(g.formula_id, 0) AS gl_formula_id,
|
||||
" . ($requires_mapping ? '1' : '0') . " AS product_mapping_checked
|
||||
{$mapping_select}
|
||||
FROM td_purchase_order po
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = po.company_id AND c.id = po.contact_id
|
||||
LEFT JOIN td_gl g
|
||||
ON g.company_id = po.company_id
|
||||
AND g.source_type = 'purchase_order'
|
||||
AND g.source_id = po.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY po.po_date DESC, po.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private function formulaRequiresProductMapping(string $doc_type, int $formula_id): bool
|
||||
{
|
||||
if ($formula_id <= 0 || !in_array($doc_type, $this->mappingTypes, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM md_account_formula_item fi
|
||||
JOIN md_account_formula f
|
||||
ON f.company_id = fi.company_id
|
||||
AND f.id = fi.formula_id
|
||||
WHERE fi.company_id = :cid
|
||||
AND fi.formula_id = :formula_id
|
||||
AND f.document_type = :doc_type
|
||||
AND fi.amount_key = 'total'
|
||||
AND f.status = 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':formula_id' => $formula_id,
|
||||
':doc_type' => $doc_type,
|
||||
]);
|
||||
return ((int)$sth->fetchColumn()) > 0;
|
||||
}
|
||||
|
||||
private function parseDate(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') return '';
|
||||
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $value, $m)) {
|
||||
return "{$m[3]}-{$m[2]}-{$m[1]}";
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* JournalManager
|
||||
*
|
||||
* Create, read, and void journal entries in ac_journal + ac_journal_line.
|
||||
* Updates ac_balance after each post.
|
||||
* Never called directly by WMS — goes through PostingManager.
|
||||
*/
|
||||
class JournalManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
// TODO: create(array $header, array $lines): int — returns journal_id
|
||||
// TODO: void(int $journal_id): void
|
||||
// TODO: getBySource(string $source_type, int $source_id): ?array
|
||||
// TODO: getById(int $journal_id): ?array
|
||||
// TODO: updateBalance(string $period, string $account_code, ?int $dept_id, float $debit, float $credit): void
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
<?php
|
||||
class PeriodManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
public function getAll(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_period
|
||||
WHERE company_id = :cid
|
||||
ORDER BY period DESC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getById(int $id): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_period WHERE company_id = :cid AND id = :id LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public function isOpen(string $period): bool
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status FROM ac_period
|
||||
WHERE company_id = :cid AND period = :period LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':period' => $period]);
|
||||
$status = $sth->fetchColumn();
|
||||
return $status === 'open';
|
||||
}
|
||||
|
||||
// Get existing period or auto-create it as open
|
||||
public function getOrCreate(string $period): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_period WHERE company_id = :cid AND period = :period LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':period' => $period]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row) return $row;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')"
|
||||
)->execute([':cid' => $this->companyId, ':period' => $period]);
|
||||
|
||||
return $this->getOrCreate($period);
|
||||
}
|
||||
|
||||
// Create a single period (manual)
|
||||
public function create(string $period): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"INSERT IGNORE INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')"
|
||||
)->execute([':cid' => $this->companyId, ':period' => $period]);
|
||||
}
|
||||
|
||||
// Bulk-create all 12 months of a year
|
||||
public function createYear(int $year): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT IGNORE INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')"
|
||||
);
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':period' => sprintf('%04d-%02d', $year, $m),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function open(int $id): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"UPDATE ac_period SET status = 'open', closed_at = NULL
|
||||
WHERE company_id = :cid AND id = :id AND status = 'closed'"
|
||||
)->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
}
|
||||
|
||||
public function close(int $id): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"UPDATE ac_period SET status = 'closed', closed_at = NOW()
|
||||
WHERE company_id = :cid AND id = :id AND status = 'open'"
|
||||
)->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
}
|
||||
|
||||
public function lock(int $id): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"UPDATE ac_period SET status = 'locked', locked_at = NOW()
|
||||
WHERE company_id = :cid AND id = :id AND status = 'closed'"
|
||||
)->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
}
|
||||
|
||||
public function getStats(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) AS total,
|
||||
SUM(status = 'open') AS open_count,
|
||||
SUM(status = 'closed') AS closed_count,
|
||||
SUM(status = 'locked') AS locked_count
|
||||
FROM ac_period WHERE company_id = :cid"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../classes/CompanySettingManager.php';
|
||||
|
||||
class PostingWindowGuard
|
||||
{
|
||||
private CompanySettingManager $settings;
|
||||
private ?array $window = null;
|
||||
|
||||
public function __construct(PDO $settingsPdo, int $company_id)
|
||||
{
|
||||
$this->settings = new CompanySettingManager($settingsPdo, $company_id);
|
||||
}
|
||||
|
||||
public function assertOpenDate(?string $posting_date, string $context = 'GL posting'): void
|
||||
{
|
||||
$date = $this->normalizeDate($posting_date);
|
||||
if ($date === '') {
|
||||
throw new Exception("{$context} date is required for posting-window validation.");
|
||||
}
|
||||
|
||||
$window = $this->getWindow();
|
||||
$from = $window['open_from'];
|
||||
$to = $window['open_to'];
|
||||
|
||||
if ($from !== '' && $date < $from) {
|
||||
throw new Exception("{$context} date {$date} is before the open posting window ({$from} to " . ($to ?: 'no upper limit') . ").");
|
||||
}
|
||||
|
||||
if ($to !== '' && $date > $to) {
|
||||
throw new Exception("{$context} date {$date} is after the open posting window (" . ($from ?: 'no lower limit') . " to {$to}).");
|
||||
}
|
||||
}
|
||||
|
||||
private function getWindow(): array
|
||||
{
|
||||
if ($this->window === null) {
|
||||
$open_from = $this->settings->get('posting_open_from');
|
||||
if ($open_from === null || $open_from === '') {
|
||||
$open_from = $this->settings->get('gl_open_from') ?: '';
|
||||
}
|
||||
|
||||
$open_to = $this->settings->get('posting_open_to');
|
||||
if ($open_to === null || $open_to === '') {
|
||||
$open_to = $this->settings->get('gl_open_to') ?: '';
|
||||
}
|
||||
|
||||
$this->window = [
|
||||
'open_from' => $this->normalizeDate($open_from ?: ''),
|
||||
'open_to' => $this->normalizeDate($open_to ?: ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->window;
|
||||
}
|
||||
|
||||
private function normalizeDate(?string $date): string
|
||||
{
|
||||
$date = trim((string)$date);
|
||||
if ($date === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $date, $m)) {
|
||||
$date = "{$m[3]}-{$m[2]}-{$m[1]}";
|
||||
}
|
||||
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
throw new Exception("Invalid posting date {$date}. Use YYYY-MM-DD.");
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
* TaxReportManager
|
||||
*
|
||||
* Generates VAT and withholding tax reports from journal lines
|
||||
* tagged to tax accounts (output VAT, input VAT, WHT).
|
||||
* tagged to tax accounts (output VAT, input VAT, WHT) in md_account.
|
||||
* Matches accounts where account_name contains 'vat' or 'tax' (case-insensitive).
|
||||
* Read-only — no writes.
|
||||
*/
|
||||
class TaxReportManager
|
||||
@@ -17,7 +18,89 @@ class TaxReportManager
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
// TODO: getOutputVAT(string $from_period, string $to_period): array
|
||||
// TODO: getInputVAT(string $from_period, string $to_period): array
|
||||
// TODO: getWithholdingTax(string $from_period, string $to_period): array
|
||||
private function normalizeDate(string $date, bool $end_of_month = false): string
|
||||
{
|
||||
$date = trim($date);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $date;
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $date)) {
|
||||
return $end_of_month ? date('Y-m-t', strtotime($date . '-01')) : $date . '-01';
|
||||
}
|
||||
return date('Y-m-d');
|
||||
}
|
||||
|
||||
// ── VAT Report ────────────────────────────────────────────────────────────
|
||||
// Returns all GL lines for accounts whose account_category is 'sales_tax'
|
||||
// or 'purchase_tax'. Pass $categories = [] to get both; pass one or more
|
||||
// category strings to narrow (e.g. ['sales_tax']).
|
||||
|
||||
public function getVATReport(string $from_date, string $to_date, array $categories = [], int $dept_id = 0): array
|
||||
{
|
||||
$from_date = $this->normalizeDate($from_date);
|
||||
$to_date = $this->normalizeDate($to_date, true);
|
||||
|
||||
$valid = ['sales_tax', 'purchase_tax'];
|
||||
$cats = $categories ? array_intersect($categories, $valid) : $valid;
|
||||
|
||||
if (empty($cats)) {
|
||||
return ['rows' => [], 'total_debit' => 0.0, 'total_credit' => 0.0,
|
||||
'from_date' => $from_date, 'to_date' => $to_date,
|
||||
'from_period' => substr($from_date, 0, 7), 'to_period' => substr($to_date, 0, 7)];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_map(fn($i) => ":cat$i", array_keys($cats)));
|
||||
$params = [':cid' => $this->companyId, ':from_date' => $from_date, ':to_date' => $to_date];
|
||||
foreach (array_values($cats) as $i => $c) { $params[":cat$i"] = $c; }
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
g.id AS gl_id,
|
||||
g.source_type,
|
||||
COALESCE(g.reference, '') AS reference,
|
||||
g.period,
|
||||
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_category,
|
||||
i.department_id,
|
||||
COALESCE(d.dept_code, '') AS dept_code,
|
||||
COALESCE(d.dept_name, '') AS dept_name,
|
||||
i.debit,
|
||||
i.credit,
|
||||
COALESCE(i.description, '') AS description
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
JOIN md_account a ON a.company_id = i.company_id AND a.account_code = i.account_code
|
||||
LEFT JOIN md_department d ON d.company_id = i.company_id AND d.id = i.department_id
|
||||
WHERE i.company_id = :cid
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date
|
||||
AND a.account_category IN ($placeholders)
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
ORDER BY g.period ASC, g.journal_date ASC, g.id ASC
|
||||
");
|
||||
$params[':dept_id'] = $dept_id;
|
||||
$sth->execute($params);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'total_debit' => (float)array_sum(array_column($rows, 'debit')),
|
||||
'total_credit' => (float)array_sum(array_column($rows, 'credit')),
|
||||
'from_date' => $from_date,
|
||||
'to_date' => $to_date,
|
||||
'from_period' => substr($from_date, 0, 7),
|
||||
'to_period' => substr($to_date, 0, 7),
|
||||
];
|
||||
}
|
||||
|
||||
public function getOutputVAT(string $from_period, string $to_period, int $dept_id = 0): array
|
||||
{
|
||||
return $this->getVATReport($from_period, $to_period, ['sales_tax'], $dept_id);
|
||||
}
|
||||
|
||||
public function getInputVAT(string $from_period, string $to_period, int $dept_id = 0): array
|
||||
{
|
||||
return $this->getVATReport($from_period, $to_period, ['purchase_tax'], $dept_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
/**
|
||||
* BasePosting
|
||||
*
|
||||
* Shared build() logic for all six posting types.
|
||||
* Subclasses implement fetchDocument(), getAmounts(), getDocType(), getDocDate().
|
||||
*
|
||||
* build() returns:
|
||||
* ['formula_id' => int, 'period' => 'YYYY-MM', 'doc_date' => 'YYYY-MM-DD', 'lines' => [...]]
|
||||
*
|
||||
* Amount keys understood by formulas:
|
||||
* grand_total — full document total (abs value used)
|
||||
* total — pre-tax amount (grand_total - tax)
|
||||
* tax — tax portion
|
||||
* amount — used by receipt and payment
|
||||
*/
|
||||
abstract class BasePosting
|
||||
{
|
||||
protected PDO $pdo;
|
||||
protected int $companyId;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
abstract protected function fetchDocument(int $id): array;
|
||||
abstract protected function getAmounts(array $doc): array;
|
||||
abstract protected function getDocType(): string;
|
||||
abstract protected function getDocDate(array $doc): string;
|
||||
|
||||
public function build(int $id, ?int $formula_id = null): array
|
||||
{
|
||||
$doc = $this->fetchDocument($id);
|
||||
$formula = $this->resolveFormula($this->getDocType(), $formula_id);
|
||||
$amounts = $this->getAmounts($doc);
|
||||
$doc_date = $this->getDocDate($doc);
|
||||
$period = substr($doc_date, 0, 7);
|
||||
$dept_id = (int)($doc['department_id'] ?? 0);
|
||||
|
||||
$lines = [];
|
||||
foreach ($formula['items'] as $item) {
|
||||
$amount = abs((float)($amounts[$item['amount_key']] ?? 0));
|
||||
if ($amount == 0) continue;
|
||||
|
||||
if ($item['amount_key'] === 'total') {
|
||||
$split_lines = $this->buildSkuTotalLines($doc, $item, $amount, $dept_id);
|
||||
if ($split_lines) {
|
||||
$lines = array_merge($lines, $split_lines);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$lines[] = [
|
||||
'account_code' => $item['account_code'],
|
||||
'department_id' => $dept_id,
|
||||
'debit' => $item['drcr'] === 'D' ? $amount : 0.0,
|
||||
'credit' => $item['drcr'] === 'C' ? $amount : 0.0,
|
||||
'description' => (string)($item['description'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'formula_id' => (int)$formula['id'],
|
||||
'period' => $period,
|
||||
'doc_date' => $doc_date,
|
||||
'lines' => $lines,
|
||||
];
|
||||
}
|
||||
|
||||
protected function resolveFormula(string $doc_type, ?int $formula_id): array
|
||||
{
|
||||
if ($formula_id) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_account_formula
|
||||
WHERE company_id = :cid AND id = :id AND status = 1
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $formula_id]);
|
||||
} else {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_account_formula
|
||||
WHERE company_id = :cid
|
||||
AND document_type = :doc_type
|
||||
AND is_default = 1
|
||||
AND status = 1
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':doc_type' => $doc_type]);
|
||||
}
|
||||
|
||||
$formula = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$formula) throw new Exception("No active formula found for '{$doc_type}'.");
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_account_formula_item
|
||||
WHERE company_id = :cid AND formula_id = :formula_id
|
||||
ORDER BY sort_order ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':formula_id' => $formula['id']]);
|
||||
$formula['items'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return $formula;
|
||||
}
|
||||
|
||||
protected function getSkuTotalAccountColumn(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
private function buildSkuTotalLines(array $doc, array $formula_item, float $amount, int $dept_id): array
|
||||
{
|
||||
$column = $this->getSkuTotalAccountColumn();
|
||||
if ($column === '' || !in_array($column, ['sales_account_code', 'purchase_account_code'], true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$invoice_id = (int)($doc['id'] ?? 0);
|
||||
if ($invoice_id <= 0) return [];
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
i.product_sku,
|
||||
i.product_name,
|
||||
i.total_price,
|
||||
COALESCE(NULLIF(p.{$column}, ''), '') AS mapped_account_code
|
||||
FROM td_invoice_item i
|
||||
LEFT JOIN md_product p
|
||||
ON p.company_id = i.company_id
|
||||
AND p.sku = i.product_sku
|
||||
WHERE i.company_id = :cid
|
||||
AND i.invoice_id = :invoice_id
|
||||
ORDER BY i.item_id ASC, i.id ASC
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':invoice_id' => $invoice_id]);
|
||||
$items = array_values(array_filter($sth->fetchAll(PDO::FETCH_ASSOC), function($row) {
|
||||
return abs((float)($row['total_price'] ?? 0)) > 0.0000001;
|
||||
}));
|
||||
|
||||
if (!$items) return [];
|
||||
|
||||
$base_total = array_reduce($items, function($carry, $row) {
|
||||
return $carry + abs((float)($row['total_price'] ?? 0));
|
||||
}, 0.0);
|
||||
if ($base_total <= 0) return [];
|
||||
|
||||
$remaining = round($amount, 4);
|
||||
$last = count($items) - 1;
|
||||
$lines = [];
|
||||
|
||||
foreach ($items as $idx => $row) {
|
||||
$line_amount = $idx === $last
|
||||
? $remaining
|
||||
: round($amount * (abs((float)$row['total_price']) / $base_total), 4);
|
||||
$remaining = round($remaining - $line_amount, 4);
|
||||
if (abs($line_amount) <= 0.0000001) continue;
|
||||
|
||||
$account_code = trim((string)($row['mapped_account_code'] ?? ''));
|
||||
if ($account_code === '') {
|
||||
$account_code = (string)$formula_item['account_code'];
|
||||
}
|
||||
|
||||
$desc = trim((string)($formula_item['description'] ?? ''));
|
||||
$sku = trim((string)($row['product_sku'] ?? ''));
|
||||
$name = trim((string)($row['product_name'] ?? ''));
|
||||
$item_desc = trim($sku . ($name !== '' ? ' - ' . $name : ''));
|
||||
if ($item_desc !== '') {
|
||||
$desc = $desc !== '' ? $desc . ': ' . $item_desc : $item_desc;
|
||||
}
|
||||
|
||||
$lines[] = [
|
||||
'account_code' => $account_code,
|
||||
'department_id' => $dept_id,
|
||||
'debit' => $formula_item['drcr'] === 'D' ? $line_amount : 0.0,
|
||||
'credit' => $formula_item['drcr'] === 'C' ? $line_amount : 0.0,
|
||||
'description' => $desc,
|
||||
];
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/BasePosting.php';
|
||||
|
||||
class CreditNotePosting extends BasePosting
|
||||
{
|
||||
protected function getDocType(): string { return 'credit_note'; }
|
||||
|
||||
protected function getSkuTotalAccountColumn(): string { return 'sales_account_code'; }
|
||||
|
||||
protected function fetchDocument(int $id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id AND c.id = i.contact_id
|
||||
WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'credit_note'
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$doc) throw new Exception("Credit note #{$id} not found.");
|
||||
return $doc;
|
||||
}
|
||||
|
||||
protected function getAmounts(array $doc): array
|
||||
{
|
||||
// grand_total is negative in DB for credit notes — use abs()
|
||||
$grand_total = abs((float)$doc['grand_total']);
|
||||
$tax = abs((float)$doc['tax']);
|
||||
return [
|
||||
'grand_total' => $grand_total,
|
||||
'tax' => $tax,
|
||||
'total' => $grand_total - $tax,
|
||||
'amount' => $grand_total,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDocDate(array $doc): string
|
||||
{
|
||||
return $doc['issued_date'] ?? date('Y-m-d');
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,42 @@
|
||||
<?php
|
||||
/**
|
||||
* InvoicePosting
|
||||
*
|
||||
* Builds the journal entry for a posted sales invoice.
|
||||
* DR: Accounts Receivable
|
||||
* CR: Revenue + Output VAT
|
||||
*/
|
||||
class InvoicePosting
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
require_once __DIR__ . '/BasePosting.php';
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
class InvoicePosting extends BasePosting
|
||||
{
|
||||
protected function getDocType(): string { return 'invoice'; }
|
||||
|
||||
protected function getSkuTotalAccountColumn(): string { return 'sales_account_code'; }
|
||||
|
||||
protected function fetchDocument(int $id): array
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id AND c.id = i.contact_id
|
||||
WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'invoice'
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$doc) throw new Exception("Invoice #{$id} not found.");
|
||||
return $doc;
|
||||
}
|
||||
|
||||
// TODO: build(int $invoice_id): array — returns [header, lines] ready for JournalManager
|
||||
protected function getAmounts(array $doc): array
|
||||
{
|
||||
$grand_total = abs((float)$doc['grand_total']);
|
||||
$tax = abs((float)$doc['tax']);
|
||||
return [
|
||||
'grand_total' => $grand_total,
|
||||
'tax' => $tax,
|
||||
'total' => $grand_total - $tax,
|
||||
'amount' => $grand_total,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDocDate(array $doc): string
|
||||
{
|
||||
return $doc['issued_date'] ?? date('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
* PaymentPosting
|
||||
*
|
||||
* Builds the journal entry when a supplier payment is recorded.
|
||||
* DR: Accounts Payable
|
||||
* CR: Bank / Cash
|
||||
*/
|
||||
class PaymentPosting
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
require_once __DIR__ . '/BasePosting.php';
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
class PaymentPosting extends BasePosting
|
||||
{
|
||||
protected function getDocType(): string { return 'payment'; }
|
||||
|
||||
public function build(int $id, ?int $formula_id = null): array
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
$doc = $this->fetchDocument($id);
|
||||
$formula = $this->resolveFormula($this->getDocType(), $formula_id);
|
||||
$doc_date = $this->getDocDate($doc);
|
||||
$period = substr($doc_date, 0, 7);
|
||||
$dept_amounts = $this->getDepartmentAmounts($id);
|
||||
|
||||
$lines = [];
|
||||
foreach ($dept_amounts as $dept_id => $amount) {
|
||||
if (abs($amount) <= 0.0001) continue;
|
||||
$is_negative = $amount < 0;
|
||||
$line_amount = abs($amount);
|
||||
|
||||
foreach ($formula['items'] as $item) {
|
||||
if ($item['amount_key'] === 'tax') continue;
|
||||
|
||||
$drcr = $item['drcr'];
|
||||
if ($is_negative) {
|
||||
$drcr = $drcr === 'D' ? 'C' : 'D';
|
||||
}
|
||||
|
||||
$lines[] = [
|
||||
'account_code' => $item['account_code'],
|
||||
'department_id' => (int)$dept_id,
|
||||
'debit' => $drcr === 'D' ? $line_amount : 0.0,
|
||||
'credit' => $drcr === 'C' ? $line_amount : 0.0,
|
||||
'description' => (string)($item['description'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'formula_id' => (int)$formula['id'],
|
||||
'period' => $period,
|
||||
'doc_date' => $doc_date,
|
||||
'lines' => $lines,
|
||||
];
|
||||
}
|
||||
|
||||
// TODO: build(int $payment_id): array — returns [header, lines] ready for JournalManager
|
||||
protected function fetchDocument(int $id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_payment r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id AND c.id = r.contact_id
|
||||
WHERE r.company_id = :cid AND r.id = :id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$doc) throw new Exception("Payment #{$id} not found.");
|
||||
return $doc;
|
||||
}
|
||||
|
||||
protected function getAmounts(array $doc): array
|
||||
{
|
||||
$amount = abs((float)$doc['amount']);
|
||||
return [
|
||||
'amount' => $amount,
|
||||
'grand_total' => $amount,
|
||||
'total' => $amount,
|
||||
'tax' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDocDate(array $doc): string
|
||||
{
|
||||
return $doc['payment_date'] ?? date('Y-m-d');
|
||||
}
|
||||
|
||||
private function getDepartmentAmounts(int $payment_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT pi.department_id,
|
||||
pi.doc_type,
|
||||
pi.amount
|
||||
FROM td_payment_item pi
|
||||
WHERE pi.company_id = :cid
|
||||
AND pi.payment_id = :payment_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':payment_id' => $payment_id,
|
||||
]);
|
||||
|
||||
$dept_amounts = [];
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $line) {
|
||||
$dept_id = (int)($line['department_id'] ?? 0);
|
||||
$amount = abs((float)($line['amount'] ?? 0));
|
||||
if ($line['doc_type'] === 'supplier_credit_note') {
|
||||
$amount *= -1;
|
||||
}
|
||||
$dept_amounts[$dept_id] = ($dept_amounts[$dept_id] ?? 0) + $amount;
|
||||
}
|
||||
|
||||
if (!$dept_amounts) throw new Exception("Payment #{$payment_id} has no allocation lines for GL posting.");
|
||||
|
||||
return $dept_amounts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/BasePosting.php';
|
||||
|
||||
class PurchaseInvoicePosting extends BasePosting
|
||||
{
|
||||
protected function getDocType(): string { return 'purchase_invoice'; }
|
||||
|
||||
protected function getSkuTotalAccountColumn(): string { return 'purchase_account_code'; }
|
||||
|
||||
protected function fetchDocument(int $id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id AND c.id = i.contact_id
|
||||
WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'purchase_invoice'
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$doc) throw new Exception("Purchase invoice #{$id} not found.");
|
||||
return $doc;
|
||||
}
|
||||
|
||||
protected function getAmounts(array $doc): array
|
||||
{
|
||||
$grand_total = abs((float)$doc['grand_total']);
|
||||
$tax = abs((float)$doc['tax']);
|
||||
return [
|
||||
'grand_total' => $grand_total,
|
||||
'tax' => $tax,
|
||||
'total' => $grand_total - $tax,
|
||||
'amount' => $grand_total,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDocDate(array $doc): string
|
||||
{
|
||||
return $doc['issued_date'] ?? date('Y-m-d');
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,162 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/BasePosting.php';
|
||||
|
||||
/**
|
||||
* PurchasePosting
|
||||
*
|
||||
* Builds the journal entry when a Purchase Order is received (GRN).
|
||||
* DR: Inventory
|
||||
* CR: Accounts Payable
|
||||
* Builds the GL journal entry for a Purchase Order (GRN).
|
||||
* Typical formula: DR Inventory (per SKU) + DR Input VAT / CR Accounts Payable.
|
||||
*
|
||||
* Amount keys:
|
||||
* grand_total — full PO total (used for AP credit line)
|
||||
* tax — VAT portion
|
||||
* total — pre-tax (grand_total - tax), split per SKU via purchase_account_code
|
||||
* amount — alias of grand_total
|
||||
*/
|
||||
class PurchasePosting
|
||||
class PurchasePosting extends BasePosting
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
protected function getDocType(): string { return 'purchase_order'; }
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
protected function fetchDocument(int $id): array
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT p.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_purchase_order p
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = p.company_id AND c.id = p.contact_id
|
||||
WHERE p.company_id = :cid AND p.id = :id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$doc) throw new Exception("Purchase order #{$id} not found.");
|
||||
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_purchase_order_item
|
||||
WHERE company_id = :cid AND order_id = :id
|
||||
ORDER BY item_id ASC"
|
||||
);
|
||||
$sth2->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return $doc;
|
||||
}
|
||||
|
||||
// TODO: build(int $po_id): array — returns [header, lines] ready for JournalManager
|
||||
protected function getAmounts(array $doc): array
|
||||
{
|
||||
$grand_total = abs((float)$doc['grand_total']);
|
||||
$tax = abs((float)$doc['tax']);
|
||||
return [
|
||||
'grand_total' => $grand_total,
|
||||
'tax' => $tax,
|
||||
'total' => $grand_total - $tax,
|
||||
'amount' => $grand_total,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDocDate(array $doc): string
|
||||
{
|
||||
return $doc['po_date'] ?? date('Y-m-d');
|
||||
}
|
||||
|
||||
public function build(int $id, ?int $formula_id = null): array
|
||||
{
|
||||
$doc = $this->fetchDocument($id);
|
||||
$formula = $this->resolveFormula($this->getDocType(), $formula_id);
|
||||
$amounts = $this->getAmounts($doc);
|
||||
$doc_date = $this->getDocDate($doc);
|
||||
$period = substr($doc_date, 0, 7);
|
||||
$dept_id = (int)($doc['department_id'] ?? 0);
|
||||
|
||||
$lines = [];
|
||||
foreach ($formula['items'] as $item) {
|
||||
$amount = abs((float)($amounts[$item['amount_key']] ?? 0));
|
||||
if ($amount == 0) continue;
|
||||
|
||||
if ($item['amount_key'] === 'total') {
|
||||
$split_lines = $this->buildSkuSplitLines($doc['items'], $item, $amount, $dept_id);
|
||||
if ($split_lines) {
|
||||
$lines = array_merge($lines, $split_lines);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$lines[] = [
|
||||
'account_code' => $item['account_code'],
|
||||
'department_id' => $dept_id,
|
||||
'debit' => $item['drcr'] === 'D' ? $amount : 0.0,
|
||||
'credit' => $item['drcr'] === 'C' ? $amount : 0.0,
|
||||
'description' => (string)($item['description'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'formula_id' => (int)$formula['id'],
|
||||
'period' => $period,
|
||||
'doc_date' => $doc_date,
|
||||
'lines' => $lines,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildSkuSplitLines(array $items, array $formula_item, float $amount, int $dept_id): array
|
||||
{
|
||||
$items = array_values(array_filter($items, function($row) {
|
||||
return abs((float)($row['total_price'] ?? 0)) > 0.0000001;
|
||||
}));
|
||||
|
||||
if (!$items) return [];
|
||||
|
||||
$skus = array_values(array_unique(array_filter(array_column($items, 'product_sku'))));
|
||||
$account_map = [];
|
||||
if ($skus) {
|
||||
$placeholders = implode(',', array_fill(0, count($skus), '?'));
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT sku, purchase_account_code FROM md_product
|
||||
WHERE company_id = ? AND sku IN ({$placeholders})"
|
||||
);
|
||||
$sth->execute(array_merge([$this->companyId], $skus));
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$account_map[$row['sku']] = trim((string)($row['purchase_account_code'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
$base_total = array_reduce($items, function($carry, $row) {
|
||||
return $carry + abs((float)($row['total_price'] ?? 0));
|
||||
}, 0.0);
|
||||
if ($base_total <= 0) return [];
|
||||
|
||||
$remaining = round($amount, 4);
|
||||
$last = count($items) - 1;
|
||||
$lines = [];
|
||||
|
||||
foreach ($items as $idx => $row) {
|
||||
$line_amount = $idx === $last
|
||||
? $remaining
|
||||
: round($amount * (abs((float)$row['total_price']) / $base_total), 4);
|
||||
$remaining = round($remaining - $line_amount, 4);
|
||||
if (abs($line_amount) <= 0.0000001) continue;
|
||||
|
||||
$sku = trim((string)($row['product_sku'] ?? ''));
|
||||
$account_code = ($sku !== '' && ($account_map[$sku] ?? '') !== '')
|
||||
? $account_map[$sku]
|
||||
: (string)$formula_item['account_code'];
|
||||
|
||||
$name = trim((string)($row['product_name'] ?? ''));
|
||||
$desc = trim((string)($formula_item['description'] ?? ''));
|
||||
$item_desc = trim($sku . ($name !== '' ? ' - ' . $name : ''));
|
||||
if ($item_desc !== '') {
|
||||
$desc = $desc !== '' ? $desc . ': ' . $item_desc : $item_desc;
|
||||
}
|
||||
|
||||
$lines[] = [
|
||||
'account_code' => $account_code,
|
||||
'department_id' => $dept_id,
|
||||
'debit' => $formula_item['drcr'] === 'D' ? $line_amount : 0.0,
|
||||
'credit' => $formula_item['drcr'] === 'C' ? $line_amount : 0.0,
|
||||
'description' => $desc,
|
||||
];
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
* ReceiptPosting
|
||||
*
|
||||
* Builds the journal entry when a customer receipt is recorded.
|
||||
* DR: Bank / Cash
|
||||
* CR: Accounts Receivable
|
||||
*/
|
||||
class ReceiptPosting
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
require_once __DIR__ . '/BasePosting.php';
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
class ReceiptPosting extends BasePosting
|
||||
{
|
||||
protected function getDocType(): string { return 'receipt'; }
|
||||
|
||||
public function build(int $id, ?int $formula_id = null): array
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
$doc = $this->fetchDocument($id);
|
||||
$formula = $this->resolveFormula($this->getDocType(), $formula_id);
|
||||
$doc_date = $this->getDocDate($doc);
|
||||
$period = substr($doc_date, 0, 7);
|
||||
$dept_amounts = $this->getDepartmentAmounts($id);
|
||||
|
||||
$lines = [];
|
||||
foreach ($dept_amounts as $dept_id => $amount) {
|
||||
if (abs($amount) <= 0.0001) continue;
|
||||
$is_negative = $amount < 0;
|
||||
$line_amount = abs($amount);
|
||||
|
||||
foreach ($formula['items'] as $item) {
|
||||
if ($item['amount_key'] === 'tax') continue;
|
||||
|
||||
$drcr = $item['drcr'];
|
||||
if ($is_negative) {
|
||||
$drcr = $drcr === 'D' ? 'C' : 'D';
|
||||
}
|
||||
|
||||
$lines[] = [
|
||||
'account_code' => $item['account_code'],
|
||||
'department_id' => (int)$dept_id,
|
||||
'debit' => $drcr === 'D' ? $line_amount : 0.0,
|
||||
'credit' => $drcr === 'C' ? $line_amount : 0.0,
|
||||
'description' => (string)($item['description'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'formula_id' => (int)$formula['id'],
|
||||
'period' => $period,
|
||||
'doc_date' => $doc_date,
|
||||
'lines' => $lines,
|
||||
];
|
||||
}
|
||||
|
||||
// TODO: build(int $receipt_id): array — returns [header, lines] ready for JournalManager
|
||||
protected function fetchDocument(int $id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_receipt r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id AND c.id = r.contact_id
|
||||
WHERE r.company_id = :cid AND r.id = :id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$doc) throw new Exception("Receipt #{$id} not found.");
|
||||
return $doc;
|
||||
}
|
||||
|
||||
protected function getAmounts(array $doc): array
|
||||
{
|
||||
$amount = abs((float)$doc['amount']);
|
||||
return [
|
||||
'amount' => $amount,
|
||||
'grand_total' => $amount,
|
||||
'total' => $amount,
|
||||
'tax' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDocDate(array $doc): string
|
||||
{
|
||||
return $doc['receipt_date'] ?? date('Y-m-d');
|
||||
}
|
||||
|
||||
private function getDepartmentAmounts(int $receipt_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT ri.department_id,
|
||||
ri.doc_type,
|
||||
ri.amount
|
||||
FROM td_receipt_item ri
|
||||
WHERE ri.company_id = :cid
|
||||
AND ri.receipt_id = :receipt_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':receipt_id' => $receipt_id,
|
||||
]);
|
||||
|
||||
$dept_amounts = [];
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $line) {
|
||||
$dept_id = (int)($line['department_id'] ?? 0);
|
||||
$amount = abs((float)($line['amount'] ?? 0));
|
||||
if ($line['doc_type'] === 'credit_note') {
|
||||
$amount *= -1;
|
||||
}
|
||||
$dept_amounts[$dept_id] = ($dept_amounts[$dept_id] ?? 0) + $amount;
|
||||
}
|
||||
|
||||
if (!$dept_amounts) throw new Exception("Receipt #{$receipt_id} has no allocation lines for GL posting.");
|
||||
|
||||
return $dept_amounts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/BasePosting.php';
|
||||
|
||||
class SupplierCreditNotePosting extends BasePosting
|
||||
{
|
||||
protected function getDocType(): string { return 'supplier_credit_note'; }
|
||||
|
||||
protected function getSkuTotalAccountColumn(): string { return 'purchase_account_code'; }
|
||||
|
||||
protected function fetchDocument(int $id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id AND c.id = i.contact_id
|
||||
WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'supplier_credit_note'
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$doc) throw new Exception("Supplier credit note #{$id} not found.");
|
||||
return $doc;
|
||||
}
|
||||
|
||||
protected function getAmounts(array $doc): array
|
||||
{
|
||||
$grand_total = abs((float)$doc['grand_total']);
|
||||
$tax = abs((float)$doc['tax']);
|
||||
return [
|
||||
'grand_total' => $grand_total,
|
||||
'tax' => $tax,
|
||||
'total' => $grand_total - $tax,
|
||||
'amount' => $grand_total,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDocDate(array $doc): string
|
||||
{
|
||||
return $doc['issued_date'] ?? date('Y-m-d');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user