softDelete features

This commit is contained in:
Thanakorn S
2026-05-22 14:07:22 +07:00
parent f04554793e
commit 2410f7bade
59 changed files with 1783 additions and 16 deletions
+106
View File
@@ -1158,6 +1158,112 @@ class InvoiceManager {
* @param array $logging Audit entry.
* @throws Exception
*/
/**
* Soft-delete an invoice/credit_note/purchase_invoice/supplier_credit_note by negating
* company_id. Blocked if any active (non-soft-deleted) downstream documents exist.
* Deletes the GL entry if one was posted (subject to posting window).
*/
public function softDelete(int $id): void
{
$sth = $this->pdo->prepare(
"SELECT id, doc_type, issued_date FROM td_invoice
WHERE company_id = :cid AND id = :id LIMIT 1"
);
$sth->execute([':cid' => $this->company_id, ':id' => $id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new Exception('Invoice not found.');
$doc_type = (string)$row['doc_type'];
$this->assertPostingWindow($row['issued_date'] ?: date('Y-m-d'), ucfirst(str_replace('_', ' ', $doc_type)) . ' deletion');
if (in_array($doc_type, ['invoice', 'credit_note'], true)) {
$sth2 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_receipt_item
WHERE company_id = :cid AND invoice_id = :id"
);
$sth2->execute([':cid' => $this->company_id, ':id' => $id]);
if ((int)$sth2->fetchColumn() > 0) {
throw new Exception('Cannot delete — receipts are allocated to this document. Delete the receipts first.');
}
$sth3 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_payment_item
WHERE company_id = :cid AND invoice_id = :id"
);
$sth3->execute([':cid' => $this->company_id, ':id' => $id]);
if ((int)$sth3->fetchColumn() > 0) {
throw new Exception('Cannot delete — payments are allocated to this document. Delete the payments first.');
}
$sth4 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_receipt_billing_item
WHERE company_id = :cid AND invoice_id = :id"
);
$sth4->execute([':cid' => $this->company_id, ':id' => $id]);
if ((int)$sth4->fetchColumn() > 0) {
throw new Exception('Cannot delete — receipt billings reference this document. Delete the receipt billings first.');
}
$sth5 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_payment_billing_item
WHERE company_id = :cid AND invoice_id = :id"
);
$sth5->execute([':cid' => $this->company_id, ':id' => $id]);
if ((int)$sth5->fetchColumn() > 0) {
throw new Exception('Cannot delete — payment billings reference this document. Delete the payment billings first.');
}
if ($doc_type === 'invoice') {
$sth6 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_invoice
WHERE company_id = :cid AND ref_invoice_id = :id AND doc_type = 'credit_note'"
);
$sth6->execute([':cid' => $this->company_id, ':id' => $id]);
if ((int)$sth6->fetchColumn() > 0) {
throw new Exception('Cannot delete — credit notes reference this invoice. Delete the credit notes first.');
}
}
}
if (in_array($doc_type, ['purchase_invoice', 'supplier_credit_note'], true)) {
$sth7 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_payment_item
WHERE company_id = :cid AND invoice_id = :id"
);
$sth7->execute([':cid' => $this->company_id, ':id' => $id]);
if ((int)$sth7->fetchColumn() > 0) {
throw new Exception('Cannot delete — payments are allocated to this document. Delete the payments first.');
}
$sth8 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_payment_billing_item
WHERE company_id = :cid AND invoice_id = :id"
);
$sth8->execute([':cid' => $this->company_id, ':id' => $id]);
if ((int)$sth8->fetchColumn() > 0) {
throw new Exception('Cannot delete — payment billings reference this document. Delete the payment billings first.');
}
}
// Delete GL entry if posted (no-op if none exists; throws if period is closed)
global $pdo1;
if (isset($pdo1) && $pdo1 instanceof PDO) {
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$gl = new GlManager($this->pdo, $this->company_id, $guard);
$gl->delete($doc_type, $id);
}
$this->pdo->prepare(
"UPDATE td_invoice_item SET company_id = company_id * -1
WHERE invoice_id = :id AND company_id = :cid"
)->execute([':id' => $id, ':cid' => $this->company_id]);
$this->pdo->prepare(
"UPDATE td_invoice SET company_id = company_id * -1
WHERE id = :id AND company_id = :cid"
)->execute([':id' => $id, ':cid' => $this->company_id]);
}
public function voidInvoice(int $id, array $logging): void
{
$sth = $this->pdo->prepare(
+106
View File
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* OrderManager
@@ -992,6 +993,111 @@ class OrderManager {
]);
}
/**
* Soft-delete an order by negating company_id on the header, items, and all
* linked stock-out rows. Approved stock-out side effects are reversed first.
* Blocked if any invoice or return (not yet soft-deleted) exists for this order.
*/
public function softDelete(int $id): void
{
$sth = $this->pdo->prepare(
"SELECT * FROM td_order WHERE company_id = :cid AND id = :id"
);
$sth->execute([':cid' => $this->company_id, ':id' => $id]);
$order = $sth->fetch(PDO::FETCH_ASSOC);
if (!$order) throw new Exception('Sales order not found.');
global $pdo1;
if (isset($pdo1) && $pdo1 instanceof PDO) {
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$guard->assertOpenDate($order['order_date'] ?: date('Y-m-d'), 'Order deletion');
}
$sth2 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_invoice
WHERE company_id = :cid AND order_id = :id"
);
$sth2->execute([':cid' => $this->company_id, ':id' => $id]);
if ((int)$sth2->fetchColumn() > 0) {
throw new Exception('Cannot delete — this order has linked invoices. Delete the invoices first.');
}
$sth3 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_return
WHERE company_id = :cid AND order_id = :id"
);
$sth3->execute([':cid' => $this->company_id, ':id' => $id]);
if ((int)$sth3->fetchColumn() > 0) {
throw new Exception('Cannot delete — this order has linked returns. Delete the returns first.');
}
// Block if any approved stock-out rows exist; user must reverse via ICS first
$sth4 = $this->pdo->prepare(
"SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name LIKE 'td_stock_%'"
);
$sth4->execute();
$tables = $sth4->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$chk = $this->pdo->prepare(
"SELECT COUNT(*) FROM `{$table}`
WHERE company_id = :cid AND source = 'order' AND source_id = :id AND type = 'out' AND status = 1"
);
$chk->execute([':cid' => $this->company_id, ':id' => $id]);
if ((int)$chk->fetchColumn() > 0) {
throw new Exception('Cannot delete — stock-out for this order has been approved. Reverse via ICS first.');
}
}
// Negate draft/pending stock-out rows (status != 1)
foreach ($tables as $table) {
$this->pdo->prepare(
"UPDATE `{$table}` SET company_id = company_id * -1
WHERE company_id = :cid AND source = 'order' AND source_id = :id AND type = 'out' AND status != 1"
)->execute([':cid' => $this->company_id, ':id' => $id]);
}
// Release converted_qty on source quotation
$source = (string)($order['source'] ?? '');
$source_id = (int)($order['source_id'] ?? 0);
if ($source === 'quotation' && $source_id > 0) {
$sth5 = $this->pdo->prepare(
"SELECT item_id, quantity FROM td_order_item
WHERE order_id = :id AND company_id = :cid ORDER BY item_id"
);
$sth5->execute([':id' => $id, ':cid' => $this->company_id]);
$items = $sth5->fetchAll(PDO::FETCH_ASSOC);
$dec = $this->pdo->prepare(
"UPDATE td_quotation_item
SET converted_qty = GREATEST(0, converted_qty - :qty)
WHERE quotation_id = :qid AND item_id = :item_id AND company_id = :cid"
);
foreach ($items as $item) {
$dec->execute([
':qty' => (float)($item['quantity'] ?? 0),
':qid' => $source_id,
':item_id' => (int)($item['item_id'] ?? 0),
':cid' => $this->company_id,
]);
}
$this->pdo->prepare(
"UPDATE td_quotation SET status = 2
WHERE id = :id AND company_id = :cid AND status = 5"
)->execute([':id' => $source_id, ':cid' => $this->company_id]);
}
$this->pdo->prepare(
"UPDATE td_order_item SET company_id = company_id * -1
WHERE order_id = :id AND company_id = :cid"
)->execute([':id' => $id, ':cid' => $this->company_id]);
$this->pdo->prepare(
"UPDATE td_order SET company_id = company_id * -1
WHERE id = :id AND company_id = :cid"
)->execute([':id' => $id, ':cid' => $this->company_id]);
}
/**
* Update shipping tracking number. Fulfillment itself is derived from
* linked stock-out rows and tracking, not selected manually.
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
class PaymentBillingManager {
@@ -391,5 +392,44 @@ class PaymentBillingManager {
':id' => $billing_id,
]);
}
/**
* Soft-delete a payment billing by negating company_id on the header and all items.
* Blocked if any payment (not yet soft-deleted) is linked to this billing.
*/
public function softDelete(int $billing_id): void
{
$sth = $this->pdo->prepare(
"SELECT id, billing_date FROM td_payment_billing WHERE id = :id AND company_id = :cid LIMIT 1"
);
$sth->execute([':id' => $billing_id, ':cid' => $this->company_id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new Exception('Payment billing not found.');
global $pdo1;
if (isset($pdo1) && $pdo1 instanceof PDO) {
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$guard->assertOpenDate($row['billing_date'] ?: date('Y-m-d'), 'Payment billing deletion');
}
$sth2 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_payment
WHERE company_id = :cid AND payment_billing_id = :id"
);
$sth2->execute([':cid' => $this->company_id, ':id' => $billing_id]);
if ((int)$sth2->fetchColumn() > 0) {
throw new Exception('Cannot delete — payments are linked to this billing. Delete the payments first.');
}
$this->pdo->prepare(
"UPDATE td_payment_billing_item SET company_id = company_id * -1
WHERE billing_id = :id AND company_id = :cid"
)->execute([':id' => $billing_id, ':cid' => $this->company_id]);
$this->pdo->prepare(
"UPDATE td_payment_billing SET company_id = company_id * -1
WHERE id = :id AND company_id = :cid"
)->execute([':id' => $billing_id, ':cid' => $this->company_id]);
}
}
?>
@@ -513,5 +513,53 @@ class PaymentManager {
':company_id' => $this->company_id,
]);
}
/**
* Soft-delete a payment by negating company_id on the header and all items.
* Deletes the GL entry if posted. Refreshes settlement and billing status.
*/
public function softDelete(int $payment_id): void
{
$sth = $this->pdo->prepare(
"SELECT id, payment_billing_id, payment_date FROM td_payment
WHERE company_id = :cid AND id = :id LIMIT 1"
);
$sth->execute([':cid' => $this->company_id, ':id' => $payment_id]);
$payment = $sth->fetch(PDO::FETCH_ASSOC);
if (!$payment) throw new Exception('Payment not found.');
$this->assertPostingWindow($payment['payment_date'] ?: date('Y-m-d'), 'Payment deletion');
// Collect linked invoice ids before negating
$sth2 = $this->pdo->prepare(
"SELECT invoice_id FROM td_payment_item
WHERE company_id = :cid AND payment_id = :id"
);
$sth2->execute([':cid' => $this->company_id, ':id' => $payment_id]);
$invoice_ids = $sth2->fetchAll(PDO::FETCH_COLUMN);
// Delete GL entry if posted (no-op if none; throws if period closed)
global $pdo1;
if (isset($pdo1) && $pdo1 instanceof PDO) {
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$gl = new GlManager($this->pdo, $this->company_id, $guard);
$gl->delete('payment', $payment_id);
}
$this->pdo->prepare(
"UPDATE td_payment_item SET company_id = company_id * -1
WHERE payment_id = :id AND company_id = :cid"
)->execute([':id' => $payment_id, ':cid' => $this->company_id]);
$this->pdo->prepare(
"UPDATE td_payment SET company_id = company_id * -1
WHERE id = :id AND company_id = :cid"
)->execute([':id' => $payment_id, ':cid' => $this->company_id]);
foreach ($invoice_ids as $invoice_id) {
$this->refreshInvoiceSettlementStatus((int)$invoice_id);
}
$this->refreshPaymentBillingStatus((int)$payment['payment_billing_id']);
}
}
?>
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* PurchaseOrderManager
@@ -851,4 +852,123 @@ class PurchaseOrderManager {
)->execute([':id' => $source_id, ':cid' => $this->company_id]);
}
}
/**
* Soft-delete a purchase order by negating company_id on the header, items, and all
* linked draft stock-in rows. Blocked if any purchase invoice, supplier return, or
* approved (received) stock-in rows exist.
*/
public function softDelete(int $po_id): void
{
$sth = $this->pdo->prepare(
"SELECT * FROM td_purchase_order WHERE company_id = :cid AND id = :id"
);
$sth->execute([':cid' => $this->company_id, ':id' => $po_id]);
$po = $sth->fetch(PDO::FETCH_ASSOC);
if (!$po) throw new Exception('Purchase order not found.');
global $pdo1;
if (isset($pdo1) && $pdo1 instanceof PDO) {
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$guard->assertOpenDate($po['po_date'] ?: date('Y-m-d'), 'Purchase order deletion');
}
$sth2 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_invoice
WHERE company_id = :cid AND source = 'po' AND source_id = :id AND doc_type = 'purchase_invoice'"
);
$sth2->execute([':cid' => $this->company_id, ':id' => $po_id]);
if ((int)$sth2->fetchColumn() > 0) {
throw new Exception('Cannot delete — this PO has linked purchase invoices. Delete the invoices first.');
}
$sth3 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_supplier_return
WHERE company_id = :cid AND po_id = :id AND status != -1"
);
$sth3->execute([':cid' => $this->company_id, ':id' => $po_id]);
if ((int)$sth3->fetchColumn() > 0) {
throw new Exception('Cannot delete — this PO has linked supplier returns. Delete or cancel the returns first.');
}
$sth4 = $this->pdo->prepare(
"SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name LIKE 'td_stock_%'"
);
$sth4->execute();
$tables = $sth4->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$sth5 = $this->pdo->prepare(
"SELECT COUNT(*) FROM `{$table}`
WHERE company_id = :cid AND source = 'po' AND source_id = :id AND status = 1"
);
$sth5->execute([':cid' => $this->company_id, ':id' => $po_id]);
if ((int)$sth5->fetchColumn() > 0) {
throw new Exception('Cannot delete — received stock from this PO must be reversed via ICS first.');
}
}
// Release racks from draft stock-in rows and negate them
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
foreach ($tables as $table) {
if (!preg_match('/^td_stock_(\d+)$/', (string)$table, $matches)) continue;
$warehouse_id = (int)$matches[1];
$row_sth = $this->pdo->prepare(
"SELECT id, zone, aisle, rack FROM `{$table}`
WHERE company_id = :cid AND source = 'po' AND source_id = :id AND status = 0"
);
$row_sth->execute([':cid' => $this->company_id, ':id' => $po_id]);
$draft_rows = $row_sth->fetchAll(PDO::FETCH_ASSOC);
foreach ($draft_rows as $row) {
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
}
$this->pdo->prepare(
"UPDATE `{$table}` SET company_id = company_id * -1
WHERE company_id = :cid AND source = 'po' AND source_id = :id"
)->execute([':cid' => $this->company_id, ':id' => $po_id]);
}
// Release converted_qty on source purchase request
$source = (string)($po['source'] ?? '');
$source_id = (int)($po['source_id'] ?? 0);
if ($source === 'purchase_request' && $source_id > 0) {
$sth6 = $this->pdo->prepare(
"SELECT item_id, quantity FROM td_purchase_order_item
WHERE order_id = :id AND company_id = :cid ORDER BY item_id"
);
$sth6->execute([':id' => $po_id, ':cid' => $this->company_id]);
$po_items = $sth6->fetchAll(PDO::FETCH_ASSOC);
$dec = $this->pdo->prepare(
"UPDATE td_purchase_request_item
SET converted_qty = GREATEST(0, converted_qty - :qty)
WHERE request_id = :rid AND item_id = :item_id AND company_id = :cid"
);
foreach ($po_items as $item) {
$dec->execute([
':qty' => (float)($item['quantity'] ?? 0),
':rid' => $source_id,
':item_id' => (int)($item['item_id'] ?? 0),
':cid' => $this->company_id,
]);
}
$this->pdo->prepare(
"UPDATE td_purchase_request SET status = 2
WHERE id = :id AND company_id = :cid AND status = 5"
)->execute([':id' => $source_id, ':cid' => $this->company_id]);
}
$this->pdo->prepare(
"UPDATE td_purchase_order_item SET company_id = company_id * -1
WHERE order_id = :id AND company_id = :cid"
)->execute([':id' => $po_id, ':cid' => $this->company_id]);
$this->pdo->prepare(
"UPDATE td_purchase_order SET company_id = company_id * -1
WHERE id = :id AND company_id = :cid"
)->execute([':id' => $po_id, ':cid' => $this->company_id]);
}
}
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* PurchaseRequestManager
@@ -347,4 +348,43 @@ class PurchaseRequestManager
)->execute([':id' => $request_id, ':cid' => $this->company_id]);
}
}
/**
* Soft-delete a purchase request by negating company_id on the header and all items.
* Blocked if any purchase order (not yet soft-deleted) was converted from this request.
*/
public function softDelete(int $id): void
{
$sth = $this->pdo->prepare(
"SELECT id, request_date FROM td_purchase_request WHERE id = :id AND company_id = :cid LIMIT 1"
);
$sth->execute([':id' => $id, ':cid' => $this->company_id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new Exception('Purchase request not found.');
global $pdo1;
if (isset($pdo1) && $pdo1 instanceof PDO) {
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$guard->assertOpenDate($row['request_date'] ?: date('Y-m-d'), 'Purchase request deletion');
}
$sth2 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_purchase_order
WHERE company_id = :cid AND source = 'purchase_request' AND source_id = :id"
);
$sth2->execute([':cid' => $this->company_id, ':id' => $id]);
if ((int)$sth2->fetchColumn() > 0) {
throw new Exception('Cannot delete — this request has linked purchase orders. Delete the POs first.');
}
$this->pdo->prepare(
"UPDATE td_purchase_request_item SET company_id = company_id * -1
WHERE request_id = :id AND company_id = :cid"
)->execute([':id' => $id, ':cid' => $this->company_id]);
$this->pdo->prepare(
"UPDATE td_purchase_request SET company_id = company_id * -1
WHERE id = :id AND company_id = :cid"
)->execute([':id' => $id, ':cid' => $this->company_id]);
}
}
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* QuotationManager
@@ -304,6 +305,45 @@ class QuotationManager
)->execute([':status' => $t['to'], ':id' => $id, ':cid' => $this->company_id]);
}
/**
* Soft-delete a quotation by negating company_id on the header and all items.
* Blocked if any sales order (not yet soft-deleted) was converted from this quotation.
*/
public function softDelete(int $id): void
{
$sth = $this->pdo->prepare(
"SELECT id, quotation_date FROM td_quotation WHERE id = :id AND company_id = :cid LIMIT 1"
);
$sth->execute([':id' => $id, ':cid' => $this->company_id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new Exception('Quotation not found.');
global $pdo1;
if (isset($pdo1) && $pdo1 instanceof PDO) {
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$guard->assertOpenDate($row['quotation_date'] ?: date('Y-m-d'), 'Quotation deletion');
}
$sth2 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_order
WHERE company_id = :cid AND source = 'quotation' AND source_id = :id"
);
$sth2->execute([':cid' => $this->company_id, ':id' => $id]);
if ((int)$sth2->fetchColumn() > 0) {
throw new Exception('Cannot delete — this quotation has linked sales orders. Delete the orders first.');
}
$this->pdo->prepare(
"UPDATE td_quotation_item SET company_id = company_id * -1
WHERE quotation_id = :id AND company_id = :cid"
)->execute([':id' => $id, ':cid' => $this->company_id]);
$this->pdo->prepare(
"UPDATE td_quotation SET company_id = company_id * -1
WHERE id = :id AND company_id = :cid"
)->execute([':id' => $id, ':cid' => $this->company_id]);
}
/**
* Increment converted_qty per item after a successful order conversion.
* Automatically sets quotation status = 5 when all items are fully converted.
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
class ReceiptBillingManager {
@@ -389,5 +390,44 @@ class ReceiptBillingManager {
':id' => $billing_id,
]);
}
/**
* Soft-delete a receipt billing by negating company_id on the header and all items.
* Blocked if any receipt (not yet soft-deleted) is linked to this billing.
*/
public function softDelete(int $billing_id): void
{
$sth = $this->pdo->prepare(
"SELECT id, billing_date FROM td_receipt_billing WHERE id = :id AND company_id = :cid LIMIT 1"
);
$sth->execute([':id' => $billing_id, ':cid' => $this->company_id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new Exception('Receipt billing not found.');
global $pdo1;
if (isset($pdo1) && $pdo1 instanceof PDO) {
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$guard->assertOpenDate($row['billing_date'] ?: date('Y-m-d'), 'Receipt billing deletion');
}
$sth2 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_receipt
WHERE company_id = :cid AND receipt_billing_id = :id"
);
$sth2->execute([':cid' => $this->company_id, ':id' => $billing_id]);
if ((int)$sth2->fetchColumn() > 0) {
throw new Exception('Cannot delete — receipts are linked to this billing. Delete the receipts first.');
}
$this->pdo->prepare(
"UPDATE td_receipt_billing_item SET company_id = company_id * -1
WHERE billing_id = :id AND company_id = :cid"
)->execute([':id' => $billing_id, ':cid' => $this->company_id]);
$this->pdo->prepare(
"UPDATE td_receipt_billing SET company_id = company_id * -1
WHERE id = :id AND company_id = :cid"
)->execute([':id' => $billing_id, ':cid' => $this->company_id]);
}
}
?>
@@ -511,5 +511,53 @@ class ReceiptManager {
':company_id' => $this->company_id,
]);
}
/**
* Soft-delete a receipt by negating company_id on the header and all items.
* Deletes the GL entry if posted. Refreshes settlement and billing status.
*/
public function softDelete(int $receipt_id): void
{
$sth = $this->pdo->prepare(
"SELECT id, receipt_billing_id, receipt_date FROM td_receipt
WHERE company_id = :cid AND id = :id LIMIT 1"
);
$sth->execute([':cid' => $this->company_id, ':id' => $receipt_id]);
$receipt = $sth->fetch(PDO::FETCH_ASSOC);
if (!$receipt) throw new Exception('Receipt not found.');
$this->assertPostingWindow($receipt['receipt_date'] ?: date('Y-m-d'), 'Receipt deletion');
// Collect linked invoice ids before negating
$sth2 = $this->pdo->prepare(
"SELECT invoice_id FROM td_receipt_item
WHERE company_id = :cid AND receipt_id = :id"
);
$sth2->execute([':cid' => $this->company_id, ':id' => $receipt_id]);
$invoice_ids = $sth2->fetchAll(PDO::FETCH_COLUMN);
// Delete GL entry if posted (no-op if none; throws if period closed)
global $pdo1;
if (isset($pdo1) && $pdo1 instanceof PDO) {
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$gl = new GlManager($this->pdo, $this->company_id, $guard);
$gl->delete('receipt', $receipt_id);
}
$this->pdo->prepare(
"UPDATE td_receipt_item SET company_id = company_id * -1
WHERE receipt_id = :id AND company_id = :cid"
)->execute([':id' => $receipt_id, ':cid' => $this->company_id]);
$this->pdo->prepare(
"UPDATE td_receipt SET company_id = company_id * -1
WHERE id = :id AND company_id = :cid"
)->execute([':id' => $receipt_id, ':cid' => $this->company_id]);
foreach ($invoice_ids as $invoice_id) {
$this->refreshInvoiceSettlementStatus((int)$invoice_id);
}
$this->refreshReceiptBillingStatus((int)$receipt['receipt_billing_id']);
}
}
?>
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* ReturnManager
@@ -713,4 +714,74 @@ class ReturnManager {
':company_id' => $this->company_id,
]);
}
/**
* Soft-delete a return by negating company_id on the header, items, and all linked
* stock-in rows. Approved stock-in side effects are reversed first.
* Blocked if any credit note (not yet soft-deleted) exists for the linked order.
*/
public function softDelete(int $return_id): void
{
$sth = $this->pdo->prepare(
"SELECT * FROM td_return WHERE company_id = :cid AND id = :id"
);
$sth->execute([':cid' => $this->company_id, ':id' => $return_id]);
$return = $sth->fetch(PDO::FETCH_ASSOC);
if (!$return) throw new Exception('Return not found.');
global $pdo1;
if (isset($pdo1) && $pdo1 instanceof PDO) {
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$guard->assertOpenDate($return['return_date'] ?: date('Y-m-d'), 'Return deletion');
}
$order_id = (int)$return['order_id'];
if ($order_id > 0) {
$sth2 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_invoice
WHERE company_id = :cid AND order_id = :order_id AND doc_type = 'credit_note'"
);
$sth2->execute([':cid' => $this->company_id, ':order_id' => $order_id]);
if ((int)$sth2->fetchColumn() > 0) {
throw new Exception('Cannot delete — a credit note exists for this return\'s order. Delete the credit note first.');
}
}
// Block if any approved stock-in rows exist; user must reverse via ICS first
$sth3 = $this->pdo->prepare(
"SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name LIKE 'td_stock_%'"
);
$sth3->execute();
$tables = $sth3->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$chk = $this->pdo->prepare(
"SELECT COUNT(*) FROM `{$table}`
WHERE company_id = :cid AND source = 'return' AND source_id = :id AND status = 1"
);
$chk->execute([':cid' => $this->company_id, ':id' => $return_id]);
if ((int)$chk->fetchColumn() > 0) {
throw new Exception('Cannot delete — stock-in for this return has been approved. Reverse via ICS first.');
}
}
// Negate draft/pending stock-in rows (status != 1)
foreach ($tables as $table) {
$this->pdo->prepare(
"UPDATE `{$table}` SET company_id = company_id * -1
WHERE company_id = :cid AND source = 'return' AND source_id = :id AND status != 1"
)->execute([':cid' => $this->company_id, ':id' => $return_id]);
}
$this->pdo->prepare(
"UPDATE td_return_item SET company_id = company_id * -1
WHERE return_id = :id AND company_id = :cid"
)->execute([':id' => $return_id, ':cid' => $this->company_id]);
$this->pdo->prepare(
"UPDATE td_return SET company_id = company_id * -1
WHERE id = :id AND company_id = :cid"
)->execute([':id' => $return_id, ':cid' => $this->company_id]);
}
}
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* SupplierReturnManager
@@ -616,4 +617,72 @@ class SupplierReturnManager {
':company_id' => $this->company_id,
]);
}
/**
* Soft-delete a supplier return by negating company_id on the header, items, and all
* linked stock-out rows. Approved stock-out side effects are reversed first.
* Blocked if any supplier credit note (not yet soft-deleted) exists for this return.
*/
public function softDelete(int $return_id): void
{
$sth = $this->pdo->prepare(
"SELECT * FROM td_supplier_return WHERE company_id = :cid AND id = :id"
);
$sth->execute([':cid' => $this->company_id, ':id' => $return_id]);
$return = $sth->fetch(PDO::FETCH_ASSOC);
if (!$return) throw new Exception('Supplier return not found.');
global $pdo1;
if (isset($pdo1) && $pdo1 instanceof PDO) {
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$guard->assertOpenDate($return['return_date'] ?: date('Y-m-d'), 'Supplier return deletion');
}
$sth2 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_invoice
WHERE company_id = :cid AND source = 'supplier_return' AND source_id = :id
AND doc_type = 'supplier_credit_note'"
);
$sth2->execute([':cid' => $this->company_id, ':id' => $return_id]);
if ((int)$sth2->fetchColumn() > 0) {
throw new Exception('Cannot delete — a supplier credit note exists for this return. Delete the credit note first.');
}
// Block if any approved stock-out rows exist; user must reverse via ICS first
$sth3 = $this->pdo->prepare(
"SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name LIKE 'td_stock_%'"
);
$sth3->execute();
$tables = $sth3->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$chk = $this->pdo->prepare(
"SELECT COUNT(*) FROM `{$table}`
WHERE company_id = :cid AND source = 'supplier_return' AND source_id = :id AND status = 1"
);
$chk->execute([':cid' => $this->company_id, ':id' => $return_id]);
if ((int)$chk->fetchColumn() > 0) {
throw new Exception('Cannot delete — stock-out for this supplier return has been approved. Reverse via ICS first.');
}
}
// Negate draft/pending stock-out rows (status != 1)
foreach ($tables as $table) {
$this->pdo->prepare(
"UPDATE `{$table}` SET company_id = company_id * -1
WHERE company_id = :cid AND source = 'supplier_return' AND source_id = :id AND status != 1"
)->execute([':cid' => $this->company_id, ':id' => $return_id]);
}
$this->pdo->prepare(
"UPDATE td_supplier_return_item SET company_id = company_id * -1
WHERE return_id = :id AND company_id = :cid"
)->execute([':id' => $return_id, ':cid' => $this->company_id]);
$this->pdo->prepare(
"UPDATE td_supplier_return SET company_id = company_id * -1
WHERE id = :id AND company_id = :cid"
)->execute([':id' => $return_id, ':cid' => $this->company_id]);
}
}
@@ -319,7 +319,7 @@ class FinancialReports
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
WHERE g.company_id = :cid AND g.source_type != 'voided'
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}
+6 -6
View File
@@ -8,7 +8,9 @@
* 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() — create a reversal journal entry (audit trail), then hard-delete the original.
* delete() — create a reversal journal entry (debit/credit swapped), then mark the original
* source_type = 'voided' so it stays in the table for audit trail but is invisible
* to getBySource() lookups.
*/
class GlManager
{
@@ -265,12 +267,10 @@ class GlManager
$this->insertLines((int)$this->pdo->lastInsertId(), $reversal_lines);
}
// Mark original as voided — keeps audit trail; getBySource() won't match 'voided' source_type
$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"
"UPDATE td_gl SET source_type = 'voided', updated_at = NOW()
WHERE id = :id AND company_id = :cid"
)->execute([':id' => $gl_id, ':cid' => $this->companyId]);
}
@@ -7,7 +7,7 @@ class GlQueryManager
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'];
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order', 'reversal'];
public function __construct(PDO $pdo, int $company_id)
{
@@ -50,7 +50,7 @@ class GlQueryManager
$date_from = $this->parseDate($date_from);
$date_to = $this->parseDate($date_to);
$where = ['g.company_id = :cid'];
$where = ['g.company_id = :cid', "g.source_type != 'voided'"];
$params = [':cid' => $this->companyId];
if ($source_type && in_array($source_type, $this->journalTypes, true)) {