From 648efee991e4d932b0fc07d2caaf672b66996d0a Mon Sep 17 00:00:00 2001 From: Thanakorn S Date: Fri, 22 May 2026 14:07:22 +0700 Subject: [PATCH] softDelete features --- .../api/engine/posting_window.php | 2 +- app/accounting/api/engine/posting_window.php | 2 +- app/accounting/journal_listing.php | 7 +- app/assets/utils/classes/InvoiceManager.php | 106 ++++++++++++++++ app/assets/utils/classes/OrderManager.php | 106 ++++++++++++++++ .../utils/classes/PaymentBillingManager.php | 40 ++++++ app/assets/utils/classes/PaymentManager.php | 48 +++++++ .../utils/classes/PurchaseOrderManager.php | 120 ++++++++++++++++++ .../utils/classes/PurchaseRequestManager.php | 40 ++++++ app/assets/utils/classes/QuotationManager.php | 40 ++++++ .../utils/classes/ReceiptBillingManager.php | 40 ++++++ app/assets/utils/classes/ReceiptManager.php | 48 +++++++ app/assets/utils/classes/ReturnManager.php | 71 +++++++++++ .../utils/classes/SupplierReturnManager.php | 69 ++++++++++ .../utils/classes_ac/FinancialReports.php | 2 +- app/assets/utils/classes_ac/GlManager.php | 12 +- .../utils/classes_ac/GlQueryManager.php | 4 +- app/expense/manage_purchase_order.php | 25 ++++ app/expense/manage_purchase_request.php | 23 ++++ app/expense/purchase_order.php | 22 ++++ app/expense/purchase_request.php | 22 ++++ app/finance/api/engine/delete_payment.php | 33 +++++ .../api/engine/delete_payment_billing.php | 33 +++++ app/finance/api/engine/delete_receipt.php | 33 +++++ .../api/engine/delete_receipt_billing.php | 33 +++++ app/finance/manage_payment.php | 26 ++++ app/finance/manage_payment_billing.php | 26 ++++ app/finance/manage_receipt.php | 26 ++++ app/finance/manage_receipt_billing.php | 26 ++++ app/finance/payment.php | 22 ++++ app/finance/payment_billing.php | 22 ++++ app/finance/receipt.php | 22 ++++ app/finance/receipt_billing.php | 22 ++++ app/ics/api/engine/barcode_lookup.php | 2 +- app/order/api/engine/delete_invoice.php | 33 +++++ app/order/api/engine/delete_order.php | 34 +++++ app/order/api/engine/delete_return.php | 34 +++++ app/order/api/engine/update_order_status.php | 2 +- app/order/invoice.php | 22 ++++ app/order/manage_invoice.php | 26 ++++ app/order/manage_order.php | 27 ++++ app/order/manage_return.php | 27 ++++ app/order/order.php | 22 ++++ app/order/return.php | 21 +++ app/po/api/engine/delete_po.php | 34 +++++ app/po/api/engine/delete_purchase_request.php | 33 +++++ app/po/api/engine/delete_supplier_return.php | 34 +++++ app/po/invoice.php | 22 ++++ app/po/manage_po.php | 26 ++++ app/po/manage_purchase_invoice.php | 27 ++++ app/po/manage_supplier_return.php | 26 ++++ app/po/po.php | 22 ++++ app/po/supplier_returns.php | 22 ++++ app/revenue/api/engine/delete_quotation.php | 33 +++++ app/revenue/manage_order.php | 25 ++++ app/revenue/manage_quotation.php | 27 ++++ app/revenue/order.php | 22 ++++ app/revenue/quotation.php | 21 +++ app/setting/api/engine/company_setting.php | 2 +- 59 files changed, 1783 insertions(+), 16 deletions(-) create mode 100644 app/finance/api/engine/delete_payment.php create mode 100644 app/finance/api/engine/delete_payment_billing.php create mode 100644 app/finance/api/engine/delete_receipt.php create mode 100644 app/finance/api/engine/delete_receipt_billing.php create mode 100644 app/order/api/engine/delete_invoice.php create mode 100644 app/order/api/engine/delete_order.php create mode 100644 app/order/api/engine/delete_return.php create mode 100644 app/po/api/engine/delete_po.php create mode 100644 app/po/api/engine/delete_purchase_request.php create mode 100644 app/po/api/engine/delete_supplier_return.php create mode 100644 app/revenue/api/engine/delete_quotation.php diff --git a/app/ac_dashboard/api/engine/posting_window.php b/app/ac_dashboard/api/engine/posting_window.php index 068b2c2..664b768 100644 --- a/app/ac_dashboard/api/engine/posting_window.php +++ b/app/ac_dashboard/api/engine/posting_window.php @@ -1,7 +1,7 @@ get('posting_open_from') ?: $settings->get('gl_open_from') ?: ''; diff --git a/app/accounting/api/engine/posting_window.php b/app/accounting/api/engine/posting_window.php index 52a6290..899e340 100644 --- a/app/accounting/api/engine/posting_window.php +++ b/app/accounting/api/engine/posting_window.php @@ -1,7 +1,7 @@ Receipt +
@@ -186,7 +187,8 @@ supplier_credit_note: 'Supplier Credit Note', receipt: 'Receipt', payment: 'Payment', - manual: 'Manual' + manual: 'Manual', + reversal: 'Reversal' }; var SOURCE_BADGE = { @@ -196,7 +198,8 @@ supplier_credit_note: 'bg-secondary-subtle text-secondary', receipt: 'bg-info-subtle text-info', payment: 'bg-danger-subtle text-danger', - manual: 'bg-dark-subtle text-dark' + manual: 'bg-dark-subtle text-dark', + reversal: 'bg-danger-subtle text-danger' }; function source_badge(type) { diff --git a/app/assets/utils/classes/InvoiceManager.php b/app/assets/utils/classes/InvoiceManager.php index 1399231..f2df951 100644 --- a/app/assets/utils/classes/InvoiceManager.php +++ b/app/assets/utils/classes/InvoiceManager.php @@ -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( diff --git a/app/assets/utils/classes/OrderManager.php b/app/assets/utils/classes/OrderManager.php index a058639..0adfdb7 100644 --- a/app/assets/utils/classes/OrderManager.php +++ b/app/assets/utils/classes/OrderManager.php @@ -1,4 +1,5 @@ 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. diff --git a/app/assets/utils/classes/PaymentBillingManager.php b/app/assets/utils/classes/PaymentBillingManager.php index 50db7f8..e0c2cfc 100644 --- a/app/assets/utils/classes/PaymentBillingManager.php +++ b/app/assets/utils/classes/PaymentBillingManager.php @@ -1,4 +1,5 @@ $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]); + } } ?> diff --git a/app/assets/utils/classes/PaymentManager.php b/app/assets/utils/classes/PaymentManager.php index e6d2e13..6eb2abc 100644 --- a/app/assets/utils/classes/PaymentManager.php +++ b/app/assets/utils/classes/PaymentManager.php @@ -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']); + } } ?> diff --git a/app/assets/utils/classes/PurchaseOrderManager.php b/app/assets/utils/classes/PurchaseOrderManager.php index e62db71..a840e70 100644 --- a/app/assets/utils/classes/PurchaseOrderManager.php +++ b/app/assets/utils/classes/PurchaseOrderManager.php @@ -1,4 +1,5 @@ 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]); + } } diff --git a/app/assets/utils/classes/PurchaseRequestManager.php b/app/assets/utils/classes/PurchaseRequestManager.php index 3091ed0..2067fe0 100644 --- a/app/assets/utils/classes/PurchaseRequestManager.php +++ b/app/assets/utils/classes/PurchaseRequestManager.php @@ -1,4 +1,5 @@ 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]); + } } diff --git a/app/assets/utils/classes/QuotationManager.php b/app/assets/utils/classes/QuotationManager.php index 0b68a04..e4e3e58 100644 --- a/app/assets/utils/classes/QuotationManager.php +++ b/app/assets/utils/classes/QuotationManager.php @@ -1,4 +1,5 @@ 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. diff --git a/app/assets/utils/classes/ReceiptBillingManager.php b/app/assets/utils/classes/ReceiptBillingManager.php index 7296080..d102f75 100644 --- a/app/assets/utils/classes/ReceiptBillingManager.php +++ b/app/assets/utils/classes/ReceiptBillingManager.php @@ -1,4 +1,5 @@ $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]); + } } ?> diff --git a/app/assets/utils/classes/ReceiptManager.php b/app/assets/utils/classes/ReceiptManager.php index 246eb79..d8a3fe0 100644 --- a/app/assets/utils/classes/ReceiptManager.php +++ b/app/assets/utils/classes/ReceiptManager.php @@ -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']); + } } ?> diff --git a/app/assets/utils/classes/ReturnManager.php b/app/assets/utils/classes/ReturnManager.php index 432588f..1a03cad 100644 --- a/app/assets/utils/classes/ReturnManager.php +++ b/app/assets/utils/classes/ReturnManager.php @@ -1,4 +1,5 @@ $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]); + } } diff --git a/app/assets/utils/classes/SupplierReturnManager.php b/app/assets/utils/classes/SupplierReturnManager.php index 92136ed..bd43275 100644 --- a/app/assets/utils/classes/SupplierReturnManager.php +++ b/app/assets/utils/classes/SupplierReturnManager.php @@ -1,4 +1,5 @@ $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]); + } } diff --git a/app/assets/utils/classes_ac/FinancialReports.php b/app/assets/utils/classes_ac/FinancialReports.php index 8342df3..f5d3921 100644 --- a/app/assets/utils/classes_ac/FinancialReports.php +++ b/app/assets/utils/classes_ac/FinancialReports.php @@ -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} diff --git a/app/assets/utils/classes_ac/GlManager.php b/app/assets/utils/classes_ac/GlManager.php index 9c47523..68198d6 100644 --- a/app/assets/utils/classes_ac/GlManager.php +++ b/app/assets/utils/classes_ac/GlManager.php @@ -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]); } diff --git a/app/assets/utils/classes_ac/GlQueryManager.php b/app/assets/utils/classes_ac/GlQueryManager.php index 653a125..3510a10 100644 --- a/app/assets/utils/classes_ac/GlQueryManager.php +++ b/app/assets/utils/classes_ac/GlQueryManager.php @@ -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)) { diff --git a/app/expense/manage_purchase_order.php b/app/expense/manage_purchase_order.php index 8bb7011..258be84 100644 --- a/app/expense/manage_purchase_order.php +++ b/app/expense/manage_purchase_order.php @@ -129,6 +129,9 @@ +
@@ -139,6 +142,8 @@