Stock Transfer list showed 0.00 (read in instead of out); stock-out/transfer forms show the location quantity; dates display as YYYY-MM-DD HH:mm:ss. Demo seeds map product accounts, use product names and supplier batches, and add demo_seed_assets.php.
597 lines
29 KiB
PHP
597 lines
29 KiB
PHP
<?php
|
|
require_once __DIR__ . '/DepreciationCalculator.php';
|
|
require_once __DIR__ . '/AssetCategoryManager.php';
|
|
require_once __DIR__ . '/../classes/DocumentNumberManager.php';
|
|
|
|
/**
|
|
* AssetManager
|
|
*
|
|
* The asset register: create (manually or from a purchase invoice line),
|
|
* activate, void, dispose of, and undo a disposal.
|
|
*
|
|
* Status: 0 draft, 1 active, 2 fully depreciated, 3 disposed, 4 void.
|
|
* GL entries are not posted here — Accounting posts them from Batch GL Entries.
|
|
* Voiding reverses entries that were already posted.
|
|
*/
|
|
class AssetManager
|
|
{
|
|
public const STATUS_DRAFT = 0;
|
|
public const STATUS_ACTIVE = 1;
|
|
public const STATUS_FULLY = 2;
|
|
public const STATUS_DISPOSED = 3;
|
|
public const STATUS_VOID = 4;
|
|
|
|
private PDO $pdo;
|
|
private int $companyId;
|
|
|
|
public function __construct(PDO $pdo, int $company_id)
|
|
{
|
|
$this->pdo = $pdo;
|
|
$this->companyId = $company_id;
|
|
}
|
|
|
|
// ── read ─────────────────────────────────────────────────────────────────
|
|
|
|
public function getList(int $invoice_id = 0): array
|
|
{
|
|
$where = ['a.company_id = :cid'];
|
|
$params = [':cid' => $this->companyId, ':cid_d' => $this->companyId];
|
|
if ($invoice_id > 0) {
|
|
$where[] = 'a.invoice_id = :invoice_id';
|
|
$params[':invoice_id'] = $invoice_id;
|
|
}
|
|
|
|
$sth = $this->pdo->prepare($this->baseSelect() . ' WHERE ' . implode(' AND ', $where) . ' ORDER BY a.id DESC');
|
|
$sth->execute($params);
|
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
|
}
|
|
|
|
public function getById(int $id): ?array
|
|
{
|
|
$sth = $this->pdo->prepare($this->baseSelect() . ' WHERE a.company_id = :cid AND a.id = :id LIMIT 1');
|
|
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId, ':id' => $id]);
|
|
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
|
|
}
|
|
|
|
/** Asset, month-by-month schedule (recorded + projected) and its GL entries. */
|
|
public function getDetail(int $id): array
|
|
{
|
|
$asset = $this->getById($id);
|
|
if (!$asset) throw new Exception('Asset not found.');
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT d.period, d.amount, d.accumulated_after, d.book_value_after, d.run_id,
|
|
COALESCE(r.run_number, '') AS run_number
|
|
FROM td_asset_depreciation d
|
|
LEFT JOIN td_asset_run r ON r.company_id = d.company_id AND r.id = d.run_id
|
|
WHERE d.company_id = :cid AND d.asset_id = :id
|
|
ORDER BY d.period ASC"
|
|
);
|
|
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
|
$recorded = [];
|
|
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
|
$recorded[$row['period']] = $row;
|
|
}
|
|
|
|
$cost = (float)$asset['cost'];
|
|
$salvage = (float)$asset['salvage_value'];
|
|
$life = (int)$asset['useful_life_months'];
|
|
$disposal_period = $asset['disposal_date'] ? DepreciationCalculator::periodOf($asset['disposal_date']) : '';
|
|
|
|
$schedule = [];
|
|
if ($asset['in_service_date'] && $life > 0 && $cost > $salvage) {
|
|
foreach (DepreciationCalculator::schedule($cost, $salvage, $life, $asset['in_service_date']) as $row) {
|
|
if (isset($recorded[$row['period']])) {
|
|
$r = $recorded[$row['period']];
|
|
$row = [
|
|
'period' => $row['period'],
|
|
'amount' => (float)$r['amount'],
|
|
'accumulated' => (float)$r['accumulated_after'],
|
|
'book_value' => (float)$r['book_value_after'],
|
|
'state' => 'recorded',
|
|
'run_id' => (int)$r['run_id'],
|
|
'run_number' => $r['run_number'],
|
|
];
|
|
} else {
|
|
$stopped = $disposal_period !== '' && $row['period'] >= $disposal_period;
|
|
$row['state'] = $stopped || (int)$asset['status'] === self::STATUS_VOID ? 'stopped' : 'projected';
|
|
}
|
|
$schedule[] = $row;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'asset' => $asset,
|
|
'schedule' => $schedule,
|
|
'monthly_amount' => $life > 0 ? DepreciationCalculator::monthlyAmount($cost, $salvage, $life) : 0,
|
|
'gl' => [
|
|
'capitalization' => $this->glEntry('asset_capitalization', $id),
|
|
'disposal' => $this->glEntry('asset_disposal', $id),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* One purchase invoice line, the part of it already capitalized, and the
|
|
* account its purchase posting debited (the default clearing account).
|
|
*/
|
|
public function getInvoiceLine(int $invoice_id, int $item_id, int $exclude_asset_id = 0): array
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT i.id AS invoice_id, i.invoice_number, i.issued_date, i.contact_id, i.department_id,
|
|
i.status AS invoice_status, i.formula_id, i.grand_total, i.tax,
|
|
COALESCE(c.contact_name, '') AS contact_name,
|
|
it.id AS item_id, it.product_sku, it.product_name, it.quantity, it.unit_price, it.total_price,
|
|
COALESCE(NULLIF(p.purchase_account_code, ''), '') AS product_account_code
|
|
FROM td_invoice i
|
|
JOIN td_invoice_item it
|
|
ON it.company_id = i.company_id AND it.invoice_id = i.id
|
|
LEFT JOIN md_contact c
|
|
ON c.company_id = i.company_id AND c.id = i.contact_id
|
|
LEFT JOIN md_product p
|
|
ON p.company_id = it.company_id AND p.sku = it.product_sku
|
|
WHERE i.company_id = :cid AND i.id = :invoice_id AND it.id = :item_id
|
|
AND i.doc_type = 'purchase_invoice'
|
|
LIMIT 1"
|
|
);
|
|
$sth->execute([':cid' => $this->companyId, ':invoice_id' => $invoice_id, ':item_id' => $item_id]);
|
|
$line = $sth->fetch(PDO::FETCH_ASSOC);
|
|
if (!$line) throw new Exception('Purchase invoice line not found.');
|
|
if (!in_array((int)$line['invoice_status'], [1, 2], true)) {
|
|
throw new Exception('Only issued or paid purchase invoices can be turned into assets.');
|
|
}
|
|
|
|
$line['line_amount'] = $this->postedLineAmount($invoice_id, (float)$line['total_price'], (float)$line['grand_total'], (float)$line['tax']);
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT COALESCE(SUM(cost), 0) FROM td_asset
|
|
WHERE company_id = :cid AND invoice_item_id = :item_id AND status <> 4 AND id <> :exclude"
|
|
);
|
|
$sth->execute([':cid' => $this->companyId, ':item_id' => $item_id, ':exclude' => $exclude_asset_id]);
|
|
$line['capitalized'] = round((float)$sth->fetchColumn(), 2);
|
|
$line['remaining'] = round($line['line_amount'] - $line['capitalized'], 2);
|
|
|
|
$line['clearing_account_code'] = $line['product_account_code'] !== ''
|
|
? $line['product_account_code']
|
|
: $this->formulaTotalAccount((int)$line['formula_id']);
|
|
|
|
return $line;
|
|
}
|
|
|
|
/** Issued purchase invoice lines with how much of each has been capitalized. */
|
|
public function getCapitalizableLines(string $date_from = '', string $date_to = ''): array
|
|
{
|
|
$where = ['i.company_id = :cid', "i.doc_type = 'purchase_invoice'", 'i.status IN (1, 2)'];
|
|
$params = [':cid' => $this->companyId, ':cid_s' => $this->companyId, ':cid_x' => $this->companyId];
|
|
if ($date_from = $this->parseDate($date_from)) { $where[] = 'i.issued_date >= :date_from'; $params[':date_from'] = $date_from; }
|
|
if ($date_to = $this->parseDate($date_to)) { $where[] = 'i.issued_date <= :date_to'; $params[':date_to'] = $date_to; }
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT i.id AS invoice_id, i.invoice_number, i.issued_date, i.status AS invoice_status,
|
|
i.grand_total, i.tax, s.sum_total,
|
|
COALESCE(c.contact_name, '') AS contact_name,
|
|
it.id AS item_id, it.product_sku, it.product_name, it.quantity, it.total_price,
|
|
COALESCE(x.capitalized, 0) AS capitalized,
|
|
COALESCE(x.asset_count, 0) AS asset_count
|
|
FROM td_invoice i
|
|
JOIN td_invoice_item it
|
|
ON it.company_id = i.company_id AND it.invoice_id = i.id
|
|
JOIN (SELECT invoice_id, SUM(ABS(total_price)) AS sum_total
|
|
FROM td_invoice_item WHERE company_id = :cid_s GROUP BY invoice_id) s
|
|
ON s.invoice_id = i.id
|
|
LEFT JOIN md_contact c
|
|
ON c.company_id = i.company_id AND c.id = i.contact_id
|
|
LEFT JOIN (SELECT invoice_item_id, SUM(cost) AS capitalized, COUNT(*) AS asset_count
|
|
FROM td_asset WHERE company_id = :cid_x AND status <> 4 GROUP BY invoice_item_id) x
|
|
ON x.invoice_item_id = it.id
|
|
WHERE " . implode(' AND ', $where) . "
|
|
ORDER BY i.issued_date DESC, i.id DESC, it.item_id ASC"
|
|
);
|
|
$sth->execute($params);
|
|
|
|
$rows = [];
|
|
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
|
$sum = (float)$row['sum_total'];
|
|
$row['line_amount'] = $sum > 0
|
|
? round(((float)$row['grand_total'] - (float)$row['tax']) * abs((float)$row['total_price']) / $sum, 2)
|
|
: 0.0;
|
|
$row['capitalized'] = round((float)$row['capitalized'], 2);
|
|
$row['remaining'] = round($row['line_amount'] - $row['capitalized'], 2);
|
|
unset($row['grand_total'], $row['tax'], $row['sum_total']);
|
|
$rows[] = $row;
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
public function getStats(): array
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT COUNT(*) AS total,
|
|
COALESCE(SUM(a.status = 0), 0) AS draft,
|
|
COALESCE(SUM(a.status = 1), 0) AS active,
|
|
COALESCE(SUM(a.status = 2), 0) AS fully_depreciated,
|
|
COALESCE(SUM(a.status = 3), 0) AS disposed,
|
|
COALESCE(SUM(IF(a.status IN (1, 2), a.cost, 0)), 0) AS cost_in_use,
|
|
COALESCE(SUM(IF(a.status IN (1, 2), COALESCE(d.accumulated, 0), 0)), 0) AS accumulated_in_use
|
|
FROM td_asset a
|
|
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
|
|
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
|
|
ON d.asset_id = a.id
|
|
WHERE a.company_id = :cid AND a.status <> 4"
|
|
);
|
|
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
|
|
$stats = $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
|
$stats['book_value_in_use'] = round((float)($stats['cost_in_use'] ?? 0) - (float)($stats['accumulated_in_use'] ?? 0), 2);
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT c.id, c.category_code, c.category_name, c.asset_class,
|
|
COUNT(a.id) AS asset_count,
|
|
COALESCE(SUM(a.cost), 0) AS cost,
|
|
COALESCE(SUM(COALESCE(d.accumulated, 0)), 0) AS accumulated
|
|
FROM td_asset a
|
|
JOIN md_asset_category c
|
|
ON c.company_id = a.company_id AND c.id = a.category_id
|
|
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
|
|
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
|
|
ON d.asset_id = a.id
|
|
WHERE a.company_id = :cid AND a.status IN (1, 2)
|
|
GROUP BY c.id, c.category_code, c.category_name, c.asset_class
|
|
ORDER BY c.category_code ASC"
|
|
);
|
|
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
|
|
$stats['by_category'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
return $stats;
|
|
}
|
|
|
|
// ── write ────────────────────────────────────────────────────────────────
|
|
|
|
public function save(array $data, int $user_id): int
|
|
{
|
|
$id = (int)($data['id'] ?? 0);
|
|
$existing = $id ? $this->getById($id) : null;
|
|
if ($id && !$existing) throw new Exception('Asset not found.');
|
|
if ($existing && in_array((int)$existing['status'], [self::STATUS_DISPOSED, self::STATUS_VOID], true)) {
|
|
throw new Exception('Disposed or void assets cannot be edited.');
|
|
}
|
|
|
|
$name = trim((string)($data['asset_name'] ?? ''));
|
|
if ($name === '') throw new Exception('Asset name is required.');
|
|
$description = trim((string)($data['description'] ?? ''));
|
|
$department_id = (int)($data['department_id'] ?? 0);
|
|
|
|
// Once a month has been charged, the numbers behind the schedule are fixed.
|
|
if ($existing && $this->hasDepreciation($id)) {
|
|
$this->pdo->prepare(
|
|
"UPDATE td_asset SET asset_name = :name, description = :desc, department_id = :dept, updated_at = NOW()
|
|
WHERE company_id = :cid AND id = :id"
|
|
)->execute([':name' => $name, ':desc' => $description, ':dept' => $department_id, ':cid' => $this->companyId, ':id' => $id]);
|
|
return $id;
|
|
}
|
|
|
|
$category = (new AssetCategoryManager($this->pdo, $this->companyId))->getById((int)($data['category_id'] ?? 0));
|
|
if (!$category) throw new Exception('Select an asset category.');
|
|
$same_category = $existing && (int)$existing['category_id'] === (int)$category['id'];
|
|
if ((int)$category['status'] !== 1 && !$same_category) throw new Exception('The selected category is inactive.');
|
|
|
|
$cost = round((float)($data['cost'] ?? 0), 2);
|
|
$salvage = round((float)($data['salvage_value'] ?? 0), 2);
|
|
$life = (int)($data['useful_life_months'] ?? 0);
|
|
DepreciationCalculator::assertInputs($cost, $salvage, $life);
|
|
|
|
$acquisition = $this->parseDate((string)($data['acquisition_date'] ?? ''));
|
|
$in_service = $this->parseDate((string)($data['in_service_date'] ?? '')) ?: $acquisition;
|
|
if ($acquisition === '') throw new Exception('Acquisition date is required.');
|
|
if ($in_service < $acquisition) throw new Exception('The in-service date cannot be before the acquisition date.');
|
|
|
|
$clearing = trim((string)($data['clearing_account_code'] ?? ''));
|
|
if ($clearing !== '' && !AssetCategoryManager::isPostingAccount($this->pdo, $this->companyId, $clearing)) {
|
|
throw new Exception("Clearing account {$clearing} is not an active posting account.");
|
|
}
|
|
|
|
$invoice_id = $existing ? (int)$existing['invoice_id'] : (int)($data['invoice_id'] ?? 0);
|
|
$item_id = $existing ? (int)$existing['invoice_item_id'] : (int)($data['invoice_item_id'] ?? 0);
|
|
$contact_id = $existing ? (int)$existing['contact_id'] : 0;
|
|
$source = 'manual';
|
|
|
|
if ($item_id > 0) {
|
|
$line = $this->getInvoiceLine($invoice_id, $item_id, $id);
|
|
if ($cost > $line['remaining'] + 0.004) {
|
|
throw new Exception('Cost is more than the part of this invoice line not yet capitalized (' . number_format($line['remaining'], 2) . ').');
|
|
}
|
|
if ($clearing === '') {
|
|
throw new Exception('Assets from a purchase invoice need the clearing account the invoice was posted to.');
|
|
}
|
|
$contact_id = (int)$line['contact_id'];
|
|
$source = 'purchase_invoice';
|
|
}
|
|
|
|
$params = [
|
|
':cid' => $this->companyId,
|
|
':name' => $name,
|
|
':category_id' => (int)$category['id'],
|
|
':class' => $category['asset_class'],
|
|
':desc' => $description,
|
|
':dept' => $department_id,
|
|
':acquisition' => $acquisition,
|
|
':in_service' => $in_service,
|
|
':cost' => $cost,
|
|
':salvage' => $salvage,
|
|
':life' => $life,
|
|
':clearing' => $clearing,
|
|
];
|
|
|
|
if ($existing) {
|
|
$params[':id'] = $id;
|
|
$this->pdo->prepare(
|
|
"UPDATE td_asset SET
|
|
asset_name = :name, category_id = :category_id, asset_class = :class, description = :desc,
|
|
department_id = :dept, acquisition_date = :acquisition, in_service_date = :in_service,
|
|
cost = :cost, salvage_value = :salvage, useful_life_months = :life,
|
|
clearing_account_code = :clearing, updated_at = NOW()
|
|
WHERE company_id = :cid AND id = :id"
|
|
)->execute($params);
|
|
return $id;
|
|
}
|
|
|
|
$params += [
|
|
':number' => (new DocumentNumberManager($this->pdo, $this->companyId))->generate('asset'),
|
|
':source' => $source,
|
|
':invoice_id' => $invoice_id,
|
|
':item_id' => $item_id,
|
|
':contact_id' => $contact_id,
|
|
':user_id' => $user_id,
|
|
];
|
|
$this->pdo->prepare(
|
|
"INSERT INTO td_asset
|
|
(company_id, asset_number, asset_name, category_id, asset_class, description, source,
|
|
invoice_id, invoice_item_id, contact_id, department_id, acquisition_date, in_service_date,
|
|
cost, salvage_value, useful_life_months, clearing_account_code, status, created_by, created_at)
|
|
VALUES
|
|
(:cid, :number, :name, :category_id, :class, :desc, :source,
|
|
:invoice_id, :item_id, :contact_id, :dept, :acquisition, :in_service,
|
|
:cost, :salvage, :life, :clearing, 0, :user_id, NOW())"
|
|
)->execute($params);
|
|
return (int)$this->pdo->lastInsertId();
|
|
}
|
|
|
|
public function activate(int $id): void
|
|
{
|
|
$asset = $this->requireAsset($id);
|
|
if ((int)$asset['status'] !== self::STATUS_DRAFT) throw new Exception('Only draft assets can be activated.');
|
|
|
|
$category = (new AssetCategoryManager($this->pdo, $this->companyId))->getById((int)$asset['category_id']);
|
|
if (!$category || (int)$category['status'] !== 1) throw new Exception('The asset category is missing or inactive.');
|
|
DepreciationCalculator::assertInputs((float)$asset['cost'], (float)$asset['salvage_value'], (int)$asset['useful_life_months']);
|
|
|
|
$this->setStatus($id, self::STATUS_ACTIVE);
|
|
}
|
|
|
|
/** Drafts are removed outright (soft delete, like other documents). */
|
|
public function delete(int $id): void
|
|
{
|
|
$asset = $this->requireAsset($id);
|
|
if ((int)$asset['status'] !== self::STATUS_DRAFT) throw new Exception('Only draft assets can be deleted. Void an active asset instead.');
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE td_asset SET company_id = company_id * -1, updated_at = NOW() WHERE company_id = :cid AND id = :id"
|
|
)->execute([':cid' => $this->companyId, ':id' => $id]);
|
|
}
|
|
|
|
/** Void an active asset recorded in error. Reverses its capitalization entry if posted. */
|
|
public function void(int $id, GlManager $gl): void
|
|
{
|
|
$asset = $this->requireAsset($id);
|
|
if (!in_array((int)$asset['status'], [self::STATUS_ACTIVE, self::STATUS_FULLY], true)) {
|
|
throw new Exception('Only active assets can be voided.');
|
|
}
|
|
if ($this->hasDepreciation($id)) {
|
|
throw new Exception('This asset has depreciation recorded. Void its depreciation runs first.');
|
|
}
|
|
|
|
$gl->delete('asset_capitalization', $id);
|
|
$this->setStatus($id, self::STATUS_VOID);
|
|
}
|
|
|
|
/**
|
|
* Record a sale or write-off. Every month before the disposal month must
|
|
* already be charged; the disposal month itself gets no charge.
|
|
*/
|
|
public function dispose(int $id, array $data): void
|
|
{
|
|
$asset = $this->requireAsset($id);
|
|
if (!in_array((int)$asset['status'], [self::STATUS_ACTIVE, self::STATUS_FULLY], true)) {
|
|
throw new Exception('Only active assets can be disposed of.');
|
|
}
|
|
|
|
$date = $this->parseDate((string)($data['disposal_date'] ?? ''));
|
|
if ($date === '') throw new Exception('Disposal date is required.');
|
|
if ($date < $asset['in_service_date']) throw new Exception('The disposal date cannot be before the in-service date.');
|
|
|
|
$sale_price = round((float)($data['sale_price'] ?? 0), 2);
|
|
if ($sale_price < 0) throw new Exception('Sale price cannot be negative.');
|
|
|
|
$proceeds = trim((string)($data['proceeds_account_code'] ?? ''));
|
|
if ($sale_price > 0 && $proceeds === '') throw new Exception('Select the account that received the sale proceeds.');
|
|
if ($proceeds !== '' && !AssetCategoryManager::isPostingAccount($this->pdo, $this->companyId, $proceeds)) {
|
|
throw new Exception("Proceeds account {$proceeds} is not an active posting account.");
|
|
}
|
|
|
|
$disposal_period = DepreciationCalculator::periodOf($date);
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id AND period >= :period"
|
|
);
|
|
$sth->execute([':cid' => $this->companyId, ':id' => $id, ':period' => $disposal_period]);
|
|
if ((int)$sth->fetchColumn() > 0) {
|
|
throw new Exception("Depreciation is already recorded for {$disposal_period} or later. Void those runs before disposing of the asset on this date.");
|
|
}
|
|
|
|
$sth = $this->pdo->prepare("SELECT period FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id");
|
|
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
|
$recorded = array_flip($sth->fetchAll(PDO::FETCH_COLUMN));
|
|
|
|
$schedule = DepreciationCalculator::schedule(
|
|
(float)$asset['cost'], (float)$asset['salvage_value'], (int)$asset['useful_life_months'], $asset['in_service_date']
|
|
);
|
|
foreach ($schedule as $row) {
|
|
if ($row['period'] >= $disposal_period) break;
|
|
if ($row['amount'] > 0 && !isset($recorded[$row['period']])) {
|
|
$label = $asset['asset_class'] === 'intangible' ? 'amortization' : 'depreciation';
|
|
throw new Exception("Run {$label} through " . DepreciationCalculator::addMonths($disposal_period, -1)
|
|
. " before disposing of this asset (first month not charged: {$row['period']}).");
|
|
}
|
|
}
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE td_asset SET status = :status, disposal_date = :date, sale_price = :price,
|
|
proceeds_account_code = :proceeds, disposal_notes = :notes, updated_at = NOW()
|
|
WHERE company_id = :cid AND id = :id"
|
|
)->execute([
|
|
':status' => self::STATUS_DISPOSED,
|
|
':date' => $date,
|
|
':price' => $sale_price,
|
|
':proceeds' => $proceeds,
|
|
':notes' => trim((string)($data['disposal_notes'] ?? '')),
|
|
':cid' => $this->companyId,
|
|
':id' => $id,
|
|
]);
|
|
}
|
|
|
|
/** Reverse a disposal (and its GL entry if posted). */
|
|
public function undoDisposal(int $id, GlManager $gl): void
|
|
{
|
|
$asset = $this->requireAsset($id);
|
|
if ((int)$asset['status'] !== self::STATUS_DISPOSED) throw new Exception('This asset has not been disposed of.');
|
|
|
|
$gl->delete('asset_disposal', $id);
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE td_asset SET status = :status, disposal_date = NULL, sale_price = 0,
|
|
proceeds_account_code = '', disposal_notes = '', updated_at = NOW()
|
|
WHERE company_id = :cid AND id = :id"
|
|
)->execute([':status' => self::STATUS_ACTIVE, ':cid' => $this->companyId, ':id' => $id]);
|
|
$this->syncStatuses([$id]);
|
|
}
|
|
|
|
/** Set active / fully depreciated from what has been charged so far. */
|
|
public function syncStatuses(array $asset_ids): void
|
|
{
|
|
$ids = array_values(array_unique(array_filter(array_map('intval', $asset_ids))));
|
|
if (!$ids) return;
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE td_asset a
|
|
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
|
|
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
|
|
ON d.asset_id = a.id
|
|
SET a.status = IF(COALESCE(d.accumulated, 0) >= ROUND(a.cost - a.salvage_value, 2) - 0.004, 2, 1),
|
|
a.updated_at = NOW()
|
|
WHERE a.company_id = :cid AND a.status IN (1, 2) AND a.id IN (" . implode(',', $ids) . ")"
|
|
)->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
|
|
}
|
|
|
|
// ── private ──────────────────────────────────────────────────────────────
|
|
|
|
private function baseSelect(): string
|
|
{
|
|
return "SELECT a.*,
|
|
COALESCE(c.category_code, '') AS category_code,
|
|
COALESCE(c.category_name, '') AS category_name,
|
|
COALESCE(ct.contact_name, '') AS contact_name,
|
|
COALESCE(i.invoice_number, '') AS invoice_number,
|
|
COALESCE(d.accumulated, 0) AS accumulated,
|
|
COALESCE(d.last_period, '') AS last_period,
|
|
ROUND(a.cost - COALESCE(d.accumulated, 0), 4) AS book_value
|
|
FROM td_asset a
|
|
LEFT JOIN md_asset_category c
|
|
ON c.company_id = a.company_id AND c.id = a.category_id
|
|
LEFT JOIN md_contact ct
|
|
ON ct.company_id = a.company_id AND ct.id = a.contact_id
|
|
LEFT JOIN td_invoice i
|
|
ON i.company_id = a.company_id AND i.id = a.invoice_id
|
|
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated, MAX(period) AS last_period
|
|
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
|
|
ON d.asset_id = a.id";
|
|
}
|
|
|
|
private function requireAsset(int $id): array
|
|
{
|
|
$sth = $this->pdo->prepare("SELECT * FROM td_asset WHERE company_id = :cid AND id = :id FOR UPDATE");
|
|
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
|
$asset = $sth->fetch(PDO::FETCH_ASSOC);
|
|
if (!$asset) throw new Exception('Asset not found.');
|
|
return $asset;
|
|
}
|
|
|
|
private function setStatus(int $id, int $status): void
|
|
{
|
|
$this->pdo->prepare("UPDATE td_asset SET status = :status, updated_at = NOW() WHERE company_id = :cid AND id = :id")
|
|
->execute([':status' => $status, ':cid' => $this->companyId, ':id' => $id]);
|
|
}
|
|
|
|
private function hasDepreciation(int $id): bool
|
|
{
|
|
$sth = $this->pdo->prepare("SELECT COUNT(*) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id");
|
|
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
|
return (int)$sth->fetchColumn() > 0;
|
|
}
|
|
|
|
private function glEntry(string $source_type, int $source_id): ?array
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT id, reference, journal_date, period, current_version,
|
|
DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS posted_at
|
|
FROM td_gl WHERE company_id = :cid AND source_type = :type AND source_id = :id LIMIT 1"
|
|
);
|
|
$sth->execute([':cid' => $this->companyId, ':type' => $source_type, ':id' => $source_id]);
|
|
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
|
|
}
|
|
|
|
/**
|
|
* The part of the invoice's pre-tax total the purchase posting put on this
|
|
* line — PurchaseInvoicePosting splits (grand_total - tax) by line total.
|
|
*/
|
|
private function postedLineAmount(int $invoice_id, float $line_total, float $grand_total, float $tax): float
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT COALESCE(SUM(ABS(total_price)), 0) FROM td_invoice_item WHERE company_id = :cid AND invoice_id = :id"
|
|
);
|
|
$sth->execute([':cid' => $this->companyId, ':id' => $invoice_id]);
|
|
$sum = (float)$sth->fetchColumn();
|
|
if ($sum <= 0) return 0.0;
|
|
return round((abs($grand_total) - abs($tax)) * abs($line_total) / $sum, 2);
|
|
}
|
|
|
|
private function formulaTotalAccount(int $formula_id): string
|
|
{
|
|
if ($formula_id <= 0) {
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT id FROM md_account_formula
|
|
WHERE company_id = :cid AND document_type = 'purchase_invoice' AND is_default = 1 AND status = 1
|
|
LIMIT 1"
|
|
);
|
|
$sth->execute([':cid' => $this->companyId]);
|
|
$formula_id = (int)$sth->fetchColumn();
|
|
}
|
|
if ($formula_id <= 0) return '';
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT account_code FROM md_account_formula_item
|
|
WHERE company_id = :cid AND formula_id = :fid AND amount_key = 'total' AND drcr = 'D'
|
|
ORDER BY sort_order ASC LIMIT 1"
|
|
);
|
|
$sth->execute([':cid' => $this->companyId, ':fid' => $formula_id]);
|
|
return (string)($sth->fetchColumn() ?: '');
|
|
}
|
|
|
|
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]}";
|
|
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $value)) return $value;
|
|
throw new Exception("Invalid date '{$value}'.");
|
|
}
|
|
}
|