accounting modules

This commit is contained in:
Thanakorn S
2026-05-13 15:11:24 +07:00
parent 511360faa2
commit 04a683bd02
70 changed files with 4451 additions and 42 deletions
+28 -6
View File
@@ -264,13 +264,27 @@ class OrderManager {
if (!$row) {
throw new Exception("Order not found.");
}
if ((int)$row['status'] !== 0) {
throw new Exception("Only draft orders can be edited.");
$cur_status = (int)$row['status'];
if ($cur_status !== 0 && $cur_status !== -2) {
throw new Exception("Only draft or pending orders can be edited.");
}
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $logging;
// Auto-promote Pending → Draft when all items have a warehouse assigned
$new_status = $cur_status;
if ($cur_status === -2) {
$all_have_warehouse = !empty($items) && array_reduce(
$items,
fn($ok, $item) => $ok && (int)($item['warehouse_id'] ?? 0) > 0,
true
);
if ($all_have_warehouse) {
$new_status = 0;
}
}
$this->pdo->prepare(
"UPDATE td_order SET
contact_id = :contact_id,
@@ -283,6 +297,7 @@ class OrderManager {
shipping_tracking_number = :shipping_tracking_number,
grand_total = :grand_total,
notes = :notes,
status = :status,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
@@ -296,6 +311,7 @@ class OrderManager {
':shipping_tracking_number' => $tracking_no,
':grand_total' => $grand_total,
':notes' => $data['notes'] ?? '',
':status' => $new_status,
':log' => json_encode($log),
':id' => $id,
':company_id' => $this->company_id,
@@ -305,23 +321,28 @@ class OrderManager {
} else {
$log = [$logging];
$log = [$logging];
$quotation_id = (int)($data['quotation_id'] ?? 0);
// Quotation-converted orders start as Pending (-2); direct orders as Draft (0)
$init_status = $quotation_id > 0 ? -2 : 0;
$this->pdo->prepare(
"INSERT INTO td_order
(company_id, uuid, order_number, contact_id, order_date,
(company_id, uuid, quotation_id, order_number, contact_id, order_date,
status, payment_status, subtotal, discount, tax,
shipping_fee, shipping_tracking_number, grand_total, items, notes, `log`, created_at)
VALUES
(:company_id, :uuid, :order_number, :contact_id, :order_date,
0, 0, :subtotal, :discount, :tax,
(:company_id, :uuid, :quotation_id, :order_number, :contact_id, :order_date,
:status, 0, :subtotal, :discount, :tax,
:shipping_fee, :shipping_tracking_number, :grand_total, :items, :notes, :log, :created_at)"
)->execute([
':company_id' => $this->company_id,
':uuid' => bin2hex(random_bytes(16)),
':quotation_id' => $quotation_id,
':order_number' => $this->generateOrderNumber(),
':contact_id' => (int)($data['contact_id'] ?? 0),
':order_date' => $data['order_date'] ?? date('Y-m-d'),
':status' => $init_status,
':subtotal' => $subtotal,
':discount' => $discount,
':tax' => $tax,
@@ -539,6 +560,7 @@ class OrderManager {
"Cannot cancel an order that is processing or completed."
);
}
// Pending orders (-2) have no stock rows — skip the stock reversal loop below
// ── Guard: no active invoice ──────────────────────────────────────
$sth = $this->pdo->prepare(
@@ -0,0 +1,37 @@
<?php
/**
* PostingManager
*
* The ONLY bridge between WMS and the accounting engine.
* WMS API endpoints call methods here — nothing in WMS imports
* accounting classes directly.
*
* Usage in a WMS API endpoint (add at the end, after business doc saves):
* require_once '../../../assets/utils/classes/PostingManager.php';
* (new PostingManager($pdo2, $company_id))->postInvoice($invoice_id);
*
* If posting fails it logs silently — the business document is already saved.
*/
class PostingManager
{
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
// TODO: postInvoice(int $invoice_id): void
// TODO: voidInvoice(int $invoice_id): void
// TODO: postPurchaseReceive(int $po_id): void
// TODO: voidPurchaseReceive(int $po_id): void
// TODO: postReceipt(int $receipt_id): void
// TODO: voidReceipt(int $receipt_id): void
// TODO: postPayment(int $payment_id): void
// TODO: voidPayment(int $payment_id): void
}
@@ -0,0 +1,23 @@
<?php
/**
* AccountingEngine
*
* Core double-entry validation and rules.
* Ensures every journal entry balances (total debit = total credit).
* All other accounting classes depend on this for validation.
*/
class AccountingEngine
{
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
// TODO: validate journal lines balance (sum debit = sum credit)
// TODO: validate account codes exist and are posting accounts
// TODO: validate period is open
}
@@ -0,0 +1,141 @@
<?php
class ChartOfAccounts
{
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_account
WHERE company_id = :cid
ORDER BY account_code ASC"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getByCode(string $account_code): ?array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_account
WHERE company_id = :cid AND account_code = :code
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':code' => $account_code]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function getByType(string $account_type): array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_account
WHERE company_id = :cid AND account_type = :type AND status = 1
ORDER BY account_code ASC"
);
$sth->execute([':cid' => $this->companyId, ':type' => $account_type]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getById(int $id): ?array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_account
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 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)
VALUES
(:cid, :code, :name, :type, :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),
]);
return (int)$this->pdo->lastInsertId();
}
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
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),
]);
}
public function delete(int $id): void
{
$sth = $this->pdo->prepare(
"UPDATE ac_account SET status = 0
WHERE company_id = :cid AND id = :id"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
}
public function isPostingAccount(string $account_code): bool
{
$sth = $this->pdo->prepare(
"SELECT is_posting FROM ac_account
WHERE company_id = :cid AND account_code = :code AND status = 1
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':code' => $account_code]);
return (bool)$sth->fetchColumn();
}
public function getStats(): array
{
$sth = $this->pdo->prepare(
"SELECT
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
WHERE company_id = :cid"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
}
}
@@ -0,0 +1,91 @@
<?php
class DepartmentManager
{
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_department
WHERE company_id = :cid
ORDER BY dept_code ASC"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getById(int $id): ?array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_department
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 create(array $data): int
{
$sth = $this->pdo->prepare(
"INSERT INTO ac_department (company_id, dept_code, dept_name, description, status)
VALUES (:cid, :code, :name, :desc, :status)"
);
$sth->execute([
':cid' => $this->companyId,
':code' => trim($data['dept_code']),
':name' => trim($data['dept_name']),
':desc' => $data['description'] ?? null,
':status' => (int)($data['status'] ?? 1),
]);
return (int)$this->pdo->lastInsertId();
}
public function update(int $id, array $data): void
{
$sth = $this->pdo->prepare(
"UPDATE ac_department SET
dept_code = :code,
dept_name = :name,
description = :desc,
status = :status
WHERE company_id = :cid AND id = :id"
);
$sth->execute([
':cid' => $this->companyId,
':id' => $id,
':code' => trim($data['dept_code']),
':name' => trim($data['dept_name']),
':desc' => $data['description'] ?? null,
':status' => (int)($data['status'] ?? 1),
]);
}
public function delete(int $id): void
{
$sth = $this->pdo->prepare(
"UPDATE ac_department SET status = 0
WHERE company_id = :cid AND id = :id"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
}
public function getStats(): array
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) AS total,
SUM(status = 1) AS active,
SUM(status = 0) AS inactive
FROM ac_department WHERE company_id = :cid"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
}
}
@@ -0,0 +1,23 @@
<?php
/**
* FinancialReports
*
* Generates all financial statements from ac_balance + ac_journal_line.
* Read-only — no writes.
*/
class FinancialReports
{
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$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
}
@@ -0,0 +1,25 @@
<?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
}
@@ -0,0 +1,120 @@
<?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,23 @@
<?php
/**
* TaxReportManager
*
* Generates VAT and withholding tax reports from journal lines
* tagged to tax accounts (output VAT, input VAT, WHT).
* Read-only — no writes.
*/
class TaxReportManager
{
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$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
}
@@ -0,0 +1,21 @@
<?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;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
// TODO: build(int $invoice_id): array — returns [header, lines] ready for JournalManager
}
@@ -0,0 +1,21 @@
<?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;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
// TODO: build(int $payment_id): array — returns [header, lines] ready for JournalManager
}
@@ -0,0 +1,21 @@
<?php
/**
* PurchasePosting
*
* Builds the journal entry when a Purchase Order is received (GRN).
* DR: Inventory
* CR: Accounts Payable
*/
class PurchasePosting
{
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
// TODO: build(int $po_id): array — returns [header, lines] ready for JournalManager
}
@@ -0,0 +1,21 @@
<?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;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
// TODO: build(int $receipt_id): array — returns [header, lines] ready for JournalManager
}