From 7396db6ffc99137e969c79010f968da03b6139f8 Mon Sep 17 00:00:00 2001 From: Thanakorn S Date: Wed, 20 May 2026 10:17:02 +0700 Subject: [PATCH] accounting workflows --- .gitignore | 9 +- README.md | 173 ---- app/ac_dashboard/api/engine/by_source.php | 11 + app/ac_dashboard/api/engine/journals.php | 11 + app/ac_dashboard/api/engine/pl.php | 11 + .../api/engine/posting_window.php | 13 + app/ac_dashboard/api/engine/recent.php | 12 + app/ac_dashboard/api/engine/trend.php | 12 + app/ac_dashboard/index.php | 383 +++++++- app/accounting/account_formulas.php | 655 +++++++++++++ app/accounting/api/engine/account_formula.php | 61 ++ app/accounting/api/engine/acquire_op_lock.php | 17 + .../api/engine/get_balance_sheet.php | 13 + .../api/engine/get_gl_documents.php | 19 + app/accounting/api/engine/get_gl_movement.php | 20 + .../api/engine/get_journal_detail.php | 14 + .../api/engine/get_journal_listing.php | 18 + .../api/engine/get_pl_statement.php | 14 + .../api/engine/get_trial_balance.php | 14 + app/accounting/api/engine/get_vat_report.php | 20 + .../api/engine/log_batch_action.php | 15 + app/accounting/api/engine/manage_period.php | 40 - app/accounting/api/engine/period.php | 9 - app/accounting/api/engine/period_stats.php | 9 - app/accounting/api/engine/post_gl_entry.php | 62 ++ app/accounting/api/engine/posting_window.php | 54 ++ .../api/engine/product_account_mapping.php | 42 + app/accounting/api/engine/release_op_lock.php | 15 + .../api/engine/save_manual_journal.php | 79 ++ app/accounting/balance_sheet.php | 149 ++- app/accounting/chart_of_accounts.php | 25 +- app/accounting/departments.php | 10 +- app/accounting/gl_entries.php | 521 ++++++++++ app/accounting/gl_movement.php | 188 +++- app/accounting/journal_listing.php | 541 +++++++++++ app/accounting/manage_account.php | 13 + app/accounting/periods.php | 274 ------ app/accounting/pl_statement.php | 140 ++- app/accounting/posting_window.php | 189 ++++ app/accounting/trial_balance.php | 147 ++- app/accounting/vat_report.php | 186 +++- app/assets/css/custom.css | 2 + app/assets/js/custom.js | 822 ++++++++++++---- .../utils/classes/BatchActionManager.php | 56 ++ .../utils/classes/CompanyProfileManager.php | 152 +++ .../utils/classes/CompanySettingManager.php | 4 + app/assets/utils/classes/ContactManager.php | 12 +- app/assets/utils/classes/InvoiceManager.php | 907 +++++++++++++++++- .../utils/classes/OperationLockManager.php | 81 ++ app/assets/utils/classes/OrderManager.php | 601 ++++++++---- .../utils/classes/PaymentBillingManager.php | 395 ++++++++ app/assets/utils/classes/PaymentManager.php | 517 ++++++++++ app/assets/utils/classes/ProductManager.php | 56 +- .../utils/classes/PurchaseOrderManager.php | 460 +++++++-- .../utils/classes/PurchaseRequestManager.php | 350 +++++++ app/assets/utils/classes/QuotationManager.php | 341 +++++++ .../utils/classes/ReceiptBillingManager.php | 393 ++++++++ app/assets/utils/classes/ReceiptManager.php | 515 ++++++++++ app/assets/utils/classes/ReportManager.php | 86 +- app/assets/utils/classes/ReturnManager.php | 349 +++++-- app/assets/utils/classes/SmtpManager.php | 179 ++++ app/assets/utils/classes/StockManager.php | 34 +- .../utils/classes/StockSourceManager.php | 86 ++ .../utils/classes/SupplierReturnManager.php | 619 ++++++++++++ app/assets/utils/classes/UserManager.php | 49 + app/assets/utils/classes/WarehouseManager.php | 154 ++- .../classes_ac/AccountFormulaManager.php | 243 +++++ .../utils/classes_ac/ChartOfAccounts.php | 84 +- .../utils/classes_ac/DepartmentManager.php | 12 +- .../utils/classes_ac/FinancialReports.php | 480 ++++++++- app/assets/utils/classes_ac/GlManager.php | 295 ++++++ .../utils/classes_ac/GlQueryManager.php | 378 ++++++++ .../utils/classes_ac/JournalManager.php | 25 - app/assets/utils/classes_ac/PeriodManager.php | 120 --- .../utils/classes_ac/PostingWindowGuard.php | 73 ++ .../utils/classes_ac/TaxReportManager.php | 91 +- .../utils/classes_ac/posting/BasePosting.php | 183 ++++ .../classes_ac/posting/CreditNotePosting.php | 43 + .../classes_ac/posting/InvoicePosting.php | 51 +- .../classes_ac/posting/PaymentPosting.php | 118 ++- .../posting/PurchaseInvoicePosting.php | 42 + .../classes_ac/posting/PurchasePosting.php | 161 +++- .../classes_ac/posting/ReceiptPosting.php | 118 ++- .../posting/SupplierCreditNotePosting.php | 42 + app/contact/api/engine/manage_contact.php | 8 +- .../api/engine_report/reports_stats.php | 82 +- app/dashboard/index.php | 2 +- .../api/engine/convert_purchase_request.php | 140 +++ .../api/engine/manage_purchase_request.php | 34 + .../engine/manage_supplier_credit_note.php | 29 + .../api/engine/retrieve_purchase_request.php | 21 + .../engine/update_purchase_request_status.php | 28 + app/expense/manage_purchase_invoice.php | 356 +++++++ app/expense/manage_purchase_order.php | 364 +++++++ app/expense/manage_purchase_request.php | 365 +++++++ app/expense/manage_supplier_credit_note.php | 269 ++++++ app/expense/payment.php | 36 +- app/expense/purchase_invoice.php | 390 +++++++- app/expense/purchase_order.php | 224 ++++- app/expense/purchase_request.php | 190 +++- app/finance/api/engine/manage_payment.php | 69 ++ .../api/engine/manage_payment_billing.php | 68 ++ app/finance/api/engine/manage_receipt.php | 69 ++ .../api/engine/manage_receipt_billing.php | 68 ++ app/finance/manage_payment.php | 274 ++++++ app/finance/manage_payment_billing.php | 197 ++++ app/finance/manage_receipt.php | 274 ++++++ app/finance/manage_receipt_billing.php | 197 ++++ app/finance/payment.php | 464 ++++++++- app/finance/payment_billing.php | 357 +++++++ app/finance/receipt.php | 489 +++++++++- app/finance/receipt_billing.php | 357 +++++++ .../api/engine/retrieve_stock_by_source.php | 19 + app/ics/api/engine/validate_scan_location.php | 70 +- app/ics/manage_stock_in.php | 42 +- app/ics/manage_stock_out.php | 25 +- app/ics/manage_stock_transfer.php | 25 +- app/ics/stock_out.php | 38 +- app/include_ending.php | 31 + app/include_master_sidebar.php | 16 +- app/include_sidebar.php | 25 +- app/include_sidebar_ac.php | 65 +- app/include_topbar.php | 84 +- app/inventory/api/engine/manage_product.php | 6 +- app/inventory/api/engine/manager.php | 20 +- app/inventory/manage_product.php | 60 ++ app/journal/index.php | 190 +++- app/journal/new.php | 250 ++++- app/order/api/engine/confirm_return.php | 9 +- app/order/api/engine/issue_invoice.php | 9 +- app/order/api/engine/manage_invoice.php | 12 + app/order/api/engine/manage_order.php | 16 +- app/order/api/engine/manage_return.php | 12 + .../api/engine/proceed_to_credit_note.php | 36 + app/order/api/engine/retrieve_invoice.php | 36 +- app/order/api/engine/retrieve_order.php | 83 +- app/order/api/engine/retrieve_return.php | 7 +- .../api/engine/update_invoice_status.php | 41 +- app/order/api/engine/update_order_status.php | 5 +- .../api/engine/update_payment_status.php | 69 -- app/order/invoice.php | 284 +++--- app/order/manage_invoice.php | 222 +++-- app/order/manage_order.php | 376 ++++---- app/order/manage_return.php | 152 ++- app/order/order.php | 62 +- app/order/print_invoice.php | 2 +- app/order/return.php | 42 +- app/po/api/engine/cancel_supplier_return.php | 35 + app/po/api/engine/close_po.php | 34 + app/po/api/engine/confirm_supplier_return.php | 42 + app/po/api/engine/manage_supplier_return.php | 42 + .../engine/proceed_to_purchase_invoice.php | 35 + .../proceed_to_supplier_credit_note.php | 35 + app/po/api/engine/retrieve_po.php | 26 +- .../api/engine/retrieve_supplier_return.php | 29 + app/po/invoice.php | 279 ++++++ app/po/manage_po.php | 399 ++++---- app/po/manage_purchase_invoice.php | 394 ++++++++ app/po/manage_supplier_return.php | 631 ++++++++++++ app/po/po.php | 67 +- app/po/supplier_returns.php | 260 +++++ app/reports/occupy_rack.php | 17 +- app/reports/product_lot.php | 6 +- app/reports/stock_movement.php | 12 +- app/revenue/api/engine/convert_quotation.php | 136 ++- app/revenue/api/engine/manage_credit_note.php | 29 + app/revenue/api/engine/manage_order.php | 54 ++ app/revenue/api/engine/manage_quotation.php | 120 +-- app/revenue/api/engine/proceed_to_invoice.php | 40 + app/revenue/api/engine/quotation_stats.php | 21 +- app/revenue/api/engine/retrieve_quotation.php | 28 +- .../api/engine/update_quotation_status.php | 45 +- app/revenue/invoice.php | 406 +++++++- app/revenue/manage_credit_note.php | 269 ++++++ app/revenue/manage_invoice.php | 392 ++++++++ app/revenue/manage_order.php | 481 ++++++++++ app/revenue/manage_quotation.php | 138 +-- app/revenue/order.php | 241 ++++- app/revenue/quotation.php | 26 +- app/revenue/receipt.php | 36 +- app/revenue/view_invoice.php | 5 + app/setting/api/engine/manage_company.php | 141 +-- app/setting/api/engine/manage_profile.php | 33 +- app/setting/api/engine/manage_smtp.php | 126 +-- app/setting/api/engine/retrieve_company.php | 37 +- app/setting/api/engine/retrieve_smtp.php | 24 +- app/setting/api/engine/switch_branch.php | 11 +- app/setting/api/engine/test_smtp.php | 53 +- install.sql | 590 ------------ migrate.php | 109 --- .../20260512_001_create_schema_migrations.sql | 4 - .../20260513_001_add_app_access_to_user.sql | 4 - .../20260513_002_create_accounting_tables.sql | 87 -- .../20260513_003_seed_default_coa_parents.sql | 39 - migrations/20260513_005_seed_coa_demo.sql | 390 -------- .../20260513_006_create_quotation_table.sql | 24 - ...0260513_007_add_quotation_link_columns.sql | 7 - seed_company.sql | 339 ------- setup.php | 207 ---- 199 files changed, 23539 insertions(+), 5345 deletions(-) delete mode 100755 README.md create mode 100644 app/ac_dashboard/api/engine/by_source.php create mode 100644 app/ac_dashboard/api/engine/journals.php create mode 100644 app/ac_dashboard/api/engine/pl.php create mode 100644 app/ac_dashboard/api/engine/posting_window.php create mode 100644 app/ac_dashboard/api/engine/recent.php create mode 100644 app/ac_dashboard/api/engine/trend.php create mode 100644 app/accounting/account_formulas.php create mode 100644 app/accounting/api/engine/account_formula.php create mode 100644 app/accounting/api/engine/acquire_op_lock.php create mode 100644 app/accounting/api/engine/get_balance_sheet.php create mode 100644 app/accounting/api/engine/get_gl_documents.php create mode 100644 app/accounting/api/engine/get_gl_movement.php create mode 100644 app/accounting/api/engine/get_journal_detail.php create mode 100644 app/accounting/api/engine/get_journal_listing.php create mode 100644 app/accounting/api/engine/get_pl_statement.php create mode 100644 app/accounting/api/engine/get_trial_balance.php create mode 100644 app/accounting/api/engine/get_vat_report.php create mode 100644 app/accounting/api/engine/log_batch_action.php delete mode 100644 app/accounting/api/engine/manage_period.php delete mode 100644 app/accounting/api/engine/period.php delete mode 100644 app/accounting/api/engine/period_stats.php create mode 100644 app/accounting/api/engine/post_gl_entry.php create mode 100644 app/accounting/api/engine/posting_window.php create mode 100644 app/accounting/api/engine/product_account_mapping.php create mode 100644 app/accounting/api/engine/release_op_lock.php create mode 100644 app/accounting/api/engine/save_manual_journal.php create mode 100644 app/accounting/gl_entries.php create mode 100644 app/accounting/journal_listing.php delete mode 100644 app/accounting/periods.php create mode 100644 app/accounting/posting_window.php create mode 100644 app/assets/utils/classes/BatchActionManager.php create mode 100644 app/assets/utils/classes/CompanyProfileManager.php create mode 100644 app/assets/utils/classes/OperationLockManager.php create mode 100644 app/assets/utils/classes/PaymentBillingManager.php create mode 100644 app/assets/utils/classes/PaymentManager.php create mode 100644 app/assets/utils/classes/PurchaseRequestManager.php create mode 100644 app/assets/utils/classes/QuotationManager.php create mode 100644 app/assets/utils/classes/ReceiptBillingManager.php create mode 100644 app/assets/utils/classes/ReceiptManager.php create mode 100644 app/assets/utils/classes/SmtpManager.php create mode 100644 app/assets/utils/classes/StockSourceManager.php create mode 100644 app/assets/utils/classes/SupplierReturnManager.php create mode 100644 app/assets/utils/classes_ac/AccountFormulaManager.php create mode 100644 app/assets/utils/classes_ac/GlManager.php create mode 100644 app/assets/utils/classes_ac/GlQueryManager.php delete mode 100644 app/assets/utils/classes_ac/JournalManager.php delete mode 100644 app/assets/utils/classes_ac/PeriodManager.php create mode 100644 app/assets/utils/classes_ac/PostingWindowGuard.php create mode 100644 app/assets/utils/classes_ac/posting/BasePosting.php create mode 100644 app/assets/utils/classes_ac/posting/CreditNotePosting.php create mode 100644 app/assets/utils/classes_ac/posting/PurchaseInvoicePosting.php create mode 100644 app/assets/utils/classes_ac/posting/SupplierCreditNotePosting.php create mode 100644 app/expense/api/engine/convert_purchase_request.php create mode 100644 app/expense/api/engine/manage_purchase_request.php create mode 100644 app/expense/api/engine/manage_supplier_credit_note.php create mode 100644 app/expense/api/engine/retrieve_purchase_request.php create mode 100644 app/expense/api/engine/update_purchase_request_status.php create mode 100644 app/expense/manage_purchase_invoice.php create mode 100644 app/expense/manage_purchase_order.php create mode 100644 app/expense/manage_purchase_request.php create mode 100644 app/expense/manage_supplier_credit_note.php create mode 100644 app/finance/api/engine/manage_payment.php create mode 100644 app/finance/api/engine/manage_payment_billing.php create mode 100644 app/finance/api/engine/manage_receipt.php create mode 100644 app/finance/api/engine/manage_receipt_billing.php create mode 100644 app/finance/manage_payment.php create mode 100644 app/finance/manage_payment_billing.php create mode 100644 app/finance/manage_receipt.php create mode 100644 app/finance/manage_receipt_billing.php create mode 100644 app/finance/payment_billing.php create mode 100644 app/finance/receipt_billing.php create mode 100644 app/ics/api/engine/retrieve_stock_by_source.php create mode 100644 app/order/api/engine/proceed_to_credit_note.php delete mode 100644 app/order/api/engine/update_payment_status.php create mode 100644 app/po/api/engine/cancel_supplier_return.php create mode 100644 app/po/api/engine/close_po.php create mode 100644 app/po/api/engine/confirm_supplier_return.php create mode 100644 app/po/api/engine/manage_supplier_return.php create mode 100644 app/po/api/engine/proceed_to_purchase_invoice.php create mode 100644 app/po/api/engine/proceed_to_supplier_credit_note.php create mode 100644 app/po/api/engine/retrieve_supplier_return.php create mode 100644 app/po/invoice.php create mode 100644 app/po/manage_purchase_invoice.php create mode 100644 app/po/manage_supplier_return.php create mode 100644 app/po/supplier_returns.php create mode 100644 app/revenue/api/engine/manage_credit_note.php create mode 100644 app/revenue/api/engine/manage_order.php create mode 100644 app/revenue/api/engine/proceed_to_invoice.php create mode 100644 app/revenue/manage_credit_note.php create mode 100644 app/revenue/manage_invoice.php create mode 100644 app/revenue/manage_order.php create mode 100644 app/revenue/view_invoice.php delete mode 100644 install.sql delete mode 100644 migrate.php delete mode 100644 migrations/20260512_001_create_schema_migrations.sql delete mode 100644 migrations/20260513_001_add_app_access_to_user.sql delete mode 100644 migrations/20260513_002_create_accounting_tables.sql delete mode 100644 migrations/20260513_003_seed_default_coa_parents.sql delete mode 100644 migrations/20260513_005_seed_coa_demo.sql delete mode 100644 migrations/20260513_006_create_quotation_table.sql delete mode 100644 migrations/20260513_007_add_quotation_link_columns.sql delete mode 100644 seed_company.sql delete mode 100644 setup.php diff --git a/.gitignore b/.gitignore index 9996e9e..ea523a8 100755 --- a/.gitignore +++ b/.gitignore @@ -13,12 +13,7 @@ vendor/ node_modules/ # custom files -AGENTS.md -CLAUDE.md -SESSION.php +notes/ docs/ .claude/ - -# Testing — local only, not deployed to production -tests/ -phpunit.xml +SESSION.php diff --git a/README.md b/README.md deleted file mode 100755 index 521d2f4..0000000 --- a/README.md +++ /dev/null @@ -1,173 +0,0 @@ -# WMS - -A multi-tenant Warehouse Management System (WMS) built with PHP, MySQL, and vanilla JavaScript. Designed for small-to-medium operations that need barcode-driven stock control, lot/serial traceability, and multi-warehouse support — accessible from any browser without installing a native app. - ---- - -## Features - -### Dashboard -- Live stock overview with key metrics (total SKUs, low-stock count, near-expiry alerts) -- Low-stock product list with one-click restock shortcut to Stock In -- Dashboard widgets per warehouse - -### Inventory Management -- **Product master** — SKU, name, unit of measure, barcode, category, cost/price/margin tracking -- **Categories** — group products for filtering and reporting -- **Warehouse master** — multi-warehouse support; simple or advanced location mode (warehouse → rack, or warehouse → zone → aisle → rack) -- **Storage / rack master** — define physical rack locations per warehouse -- **Product lots** — lot number, expiry date, per-lot traceability across all warehouses - -### Stock Control (ICS) -- **Stock In** — receive stock into a specific location; supports lot, expiry, serial, unit price, and contact (supplier) -- **Stock Out** — remove stock from a location; cascaded lot/serial dropdowns filtered to available stock -- **Stock Transfer** — move stock between any two locations (same or different warehouse) -- **Stock Overview** — real-time balance per SKU across all warehouses and locations -- Two-step approval flow: transactions created as `pending` and approved separately - -### Barcode System -- **SKU barcode labels** — generate `SKU|{sku}|{lot}|{serial}` labels; print, disable, re-enable per serial -- **Location barcode labels** — generate `LOC|{warehouse}|{rack}` (simple) or `LOC|{warehouse}|{zone}|{aisle}|{rack}` (advanced) labels -- **Scanner support** — USB scanner, handheld scanner, phone camera (Html5Qrcode), and clipboard paste all handled by a unified `scanner.js` module -- Scan-driven stock flows: scan SKU label → scan location label → press F2 to save (no mouse required) -- Disabled barcodes are rejected at scan time with a clear error message - -### Orders -- **Sales orders** — create, confirm, and track customer orders -- **Returns** — manage product returns linked to original orders -- **Invoices** — generate and print invoices per order -- **Purchase orders (PO)** — create and track supplier purchase orders - -### Contacts -- Supplier and customer contact management -- Contact types (supplier, customer, other) -- Linked to stock-in, stock-out, orders, and POs - -### Reports -- **Stock Movement** — full transaction history with in/out/net summary; filterable by date, SKU, warehouse -- **Product Lots** — lot-level stock balance with expiry dates across all warehouses -- **Expired / Near Expiry** — products approaching or past their expiry date -- **Rack Occupancy** — visual overview of which racks are occupied, empty, or locked - -### Settings -- **Company profile** — name, logo, branch details -- **System config** — location mode (simple/advanced), custom zone/aisle/rack labels, stock uniqueness rules -- **User management** — invite users by email, assign roles (admin / staff / viewer), remove members -- **SMTP** — configure outbound email for notifications -- **Profile** — per-user name, username, password, profile picture - -### Multi-Tenant / Branch Support -- Each company is isolated; users can belong to multiple companies -- Branch switcher in the topbar for users with access to more than one company -- All data (products, stock, orders, contacts) is scoped to the active company - -### Security -- Session-based authentication with TOTP-style OTP validation on every API request -- CSRF token enforcement on all POST requests -- Role-based access control: `owner`, `admin`, `staff`, `viewer`; enforced in protected write APIs with `require_role()` and mirrored in page/sidebar UI (see `docs/ROLES.md`) -- Passwords hashed; profile picture uploads sandboxed to `uploads/profile/` - ---- - -## Tech Stack - -| Layer | Technology | -|-------|-----------| -| Backend | PHP 8.x, Apache, Composer | -| Databases | MySQL — `wms` (system/auth), `wms2` (operational data) | -| Frontend | Bootstrap 5, jQuery, Flatpickr, ApexCharts, JsBarcode, Html5Qrcode | -| Build | Vite (`npm run dev` / `npm run build`) | -| Auth | Session + HMAC-SHA1 OTP + CSRF tokens | -| Tests | PHPUnit 10 integration tests under `tests/` | - ---- - -## Module Layout - -``` -app/ -├── dashboard/ # Dashboard and low-stock widgets -├── ics/ # Stock In / Out / Transfer, barcode labels -├── inventory/ # Product, warehouse, rack, category masters -├── order/ # Sales orders, returns, invoices -├── po/ # Purchase orders -├── reports/ # Stock movement, lots, expiry, rack occupancy -├── contact/ # Supplier / customer contacts -├── setting/ # Company, users, SMTP, system config, profile -├── assets/ -│ ├── js/ # main.js, scanner.js, custom.js -│ ├── css/ # main.css, custom.css -│ └── utils/ # db_auth.php, db_helpers.php, shared classes -└── login/ # Login, OTP, onboarding -``` - ---- - -## Installation - -### Fresh server - -```bash -# 1. Create schema and both databases -mysql -uroot -p < install.sql - -# 2. Register baseline — must run once after install.sql -php migrate.php -``` - -### Updating an existing install - -```bash -# Apply any pending migrations -php migrate.php -``` - -Add new migrations as `migrations/YYYYMMDD_NNN_description.sql`. The first line must declare the target database: - -```sql --- db: wms2 -ALTER TABLE md_product ADD COLUMN weight decimal(10,2) NOT NULL DEFAULT 0.00; -``` - -See `docs/DEPLOYMENT.md` for full setup (Apache, PHP config, uploads directory, HTTPS checklist). - ---- - -## Local Development - -```bash -# PHP dependencies / integration tests -composer install -vendor/bin/phpunit - -# Front-end assets (Vite) -npm run dev -npm run build - -# Syntax-check a PHP file -php -l app/ics/manage_stock_in.php -``` - -App is served by Apache at `http://localhost/wms/app/`. - -The PHPUnit suite uses real local `wms` and `wms2` databases and fixture IDs defined in `tests/bootstrap.php`; run it only against a development database. - ---- - -## Documentation - -| File | Contents | -|------|----------| -| `docs/CHANGELOG.md` | Version history and notable changes | -| `docs/ROLES.md` | Role-based access control spec (admin / staff / viewer) | -| `docs/DATABASE.md` | Full schema for both databases — tables, columns, relationships | -| `docs/API.md` | All API endpoints — request fields, response format, error codes | -| `docs/DEPLOYMENT.md` | Installation, Apache/PHP/MySQL setup, environment checklist | -| `docs/TESTING.md` | Manual test checklists — barcode flow, stock ops, regression | -| `docs/SECURITY.md` | Auth model, OTP flow, CSRF, session management, XSS/SQL rules | -| `docs/STOCK.md` | Stock ledger, approval flow, rack lifecycle, StockManager/WarehouseManager | -| `docs/SCANNER.md` | scanner.js internals, device support, integrating scanning into new pages | -| `docs/CONTRIBUTING.md` | Patterns for adding new APIs, pages, modules, settings, and schema changes | -| `docs/V2PLAN.md` | Planned supervisor role and warehouse-scoped access design | - -Security hardening note: protected API engines must include `assets/utils/db_auth.php`, must reject unauthenticated sessions server-side, and must define role requirements with `require_role()` where the action is not viewer-safe. diff --git a/app/ac_dashboard/api/engine/by_source.php b/app/ac_dashboard/api/engine/by_source.php new file mode 100644 index 0000000..cfa3f1c --- /dev/null +++ b/app/ac_dashboard/api/engine/by_source.php @@ -0,0 +1,11 @@ + $reports->getDashboardBySource()]; + $answer['success'] = 1; + exit(json_encode($answer)); +?> diff --git a/app/ac_dashboard/api/engine/journals.php b/app/ac_dashboard/api/engine/journals.php new file mode 100644 index 0000000..c4af076 --- /dev/null +++ b/app/ac_dashboard/api/engine/journals.php @@ -0,0 +1,11 @@ + $reports->getDashboardJournalCount()]; + $answer['success'] = 1; + exit(json_encode($answer)); +?> diff --git a/app/ac_dashboard/api/engine/pl.php b/app/ac_dashboard/api/engine/pl.php new file mode 100644 index 0000000..ee36f28 --- /dev/null +++ b/app/ac_dashboard/api/engine/pl.php @@ -0,0 +1,11 @@ +getDashboardPL(); + $answer['success'] = 1; + exit(json_encode($answer)); +?> diff --git a/app/ac_dashboard/api/engine/posting_window.php b/app/ac_dashboard/api/engine/posting_window.php new file mode 100644 index 0000000..068b2c2 --- /dev/null +++ b/app/ac_dashboard/api/engine/posting_window.php @@ -0,0 +1,13 @@ +get('posting_open_from') ?: $settings->get('gl_open_from') ?: ''; + $open_to = $settings->get('posting_open_to') ?: $settings->get('gl_open_to') ?: ''; + + $answer['output'] = ['open_from' => $open_from, 'open_to' => $open_to]; + $answer['success'] = 1; + exit(json_encode($answer)); +?> diff --git a/app/ac_dashboard/api/engine/recent.php b/app/ac_dashboard/api/engine/recent.php new file mode 100644 index 0000000..55417a8 --- /dev/null +++ b/app/ac_dashboard/api/engine/recent.php @@ -0,0 +1,12 @@ + $reports->getDashboardRecentEntries($limit)]; + $answer['success'] = 1; + exit(json_encode($answer)); +?> diff --git a/app/ac_dashboard/api/engine/trend.php b/app/ac_dashboard/api/engine/trend.php new file mode 100644 index 0000000..34ed76a --- /dev/null +++ b/app/ac_dashboard/api/engine/trend.php @@ -0,0 +1,12 @@ + $reports->getMonthlyTrend($months)]; + $answer['success'] = 1; + exit(json_encode($answer)); +?> diff --git a/app/ac_dashboard/index.php b/app/ac_dashboard/index.php index 5bbd89d..ca5f276 100644 --- a/app/ac_dashboard/index.php +++ b/app/ac_dashboard/index.php @@ -8,24 +8,168 @@ -
-
+
-

Accounting Dashboard

-

Financial overview

+
+
+

Accounting Dashboard

+

Financial overview from posted journal entries

+
+
- + +
+
+
+
+
+ +
+
+

Revenue

+

—

+

Posted entries

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

Expenses

+

—

+

Posted entries

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

Net Profit

+

—

+

Revenue minus expenses

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

Journal Entries

+

—

+

Posted entries

+
+
+
+
+
+
+
+
+ + +
+
+
+
+ + Loading... + Manage Posting Window +
+
+
+
+
+
+
+ + +
+
+
+
+
Revenue vs Expenses (last 6 months)
+
+
+ +
+
+
+
+
+
+
+
+
+
Entries by Type
+
+
+
Loading...
+
+
+
+
+
+
+
+ +
-
- -

Accounting dashboard coming soon.

+
+
+
Recent Journal Entries
+ View All +
+
+ + + + + + + + + + + + + + + +
DatePeriodTypeReferenceDescriptionDebitCredit
Loading...
+
+
+
+
@@ -35,5 +179,228 @@ + + + diff --git a/app/accounting/account_formulas.php b/app/accounting/account_formulas.php new file mode 100644 index 0000000..fb270db --- /dev/null +++ b/app/accounting/account_formulas.php @@ -0,0 +1,655 @@ + + + + + + +
+
+ +
+
+
+
+

Account Formulas

+

Posting patterns for document journal entries

+
+ +
+
+
+ + + +
+
+
+
+ +

Formula Setup

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+ +
+
+
+

Formula Items

+ +
+
+ + + + + + + + + + + +
SideAccountValueDescription
+
+
+
+
+ +
+
+
+

Available Values

+
+
grand_total
Final document total
+
total
Subtotal less discount plus shipping
+
tax
Document tax amount
+
amount
Receipt or payment amount
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+
+
+
+
+ +
+
+ +
+
+
+ + + + + + + + + + + + + +
NameDocument TypeItemsDefaultStatusAction
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+ +
+
+
+ + + + + + + + + + + + + +
ProductSKUSales AccountPurchase AccountStatusAction
+
+
+
+
+
+ +
+
+ + + + + + + + + diff --git a/app/accounting/api/engine/account_formula.php b/app/accounting/api/engine/account_formula.php new file mode 100644 index 0000000..bb25a9f --- /dev/null +++ b/app/accounting/api/engine/account_formula.php @@ -0,0 +1,61 @@ +getByType($doc_type); + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($action === 'retrieve') { + $id = (int)($data['id'] ?? 0); + $row = $mgr->getById($id); + if (!$row) throw new Exception('Formula not found.'); + $answer['output'] = $row; + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($action === 'save') { + $new_id = null; + dbTransaction($pdo2, function($pdo) use ($data, $company_id, &$new_id) { + $mgr = new AccountFormulaManager($pdo, $company_id); + $new_id = $mgr->save($data); + }); + $answer['new_id'] = $new_id; + $answer['success'] = 1; + $answer['message'] = 'Formula saved.'; + exit(json_encode($answer)); + } + + if ($action === 'delete') { + $id = (int)($data['id'] ?? 0); + if ($id <= 0) throw new Exception('Missing formula id.'); + $mgr->delete($id); + $answer['success'] = 1; + $answer['message'] = 'Formula disabled.'; + exit(json_encode($answer)); + } + + $answer['output'] = $mgr->getAll(); + $answer['success'] = 1; + +} catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); +} + +exit(json_encode($answer)); +?> diff --git a/app/accounting/api/engine/acquire_op_lock.php b/app/accounting/api/engine/acquire_op_lock.php new file mode 100644 index 0000000..ee0a1f3 --- /dev/null +++ b/app/accounting/api/engine/acquire_op_lock.php @@ -0,0 +1,17 @@ +acquire( + trim((string)($data['operation_type'] ?? '')), + (int)($data['ttl_minutes'] ?? 120) + ); + $answer = array_merge($answer, $result); +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); +} + +exit(json_encode($answer)); diff --git a/app/accounting/api/engine/get_balance_sheet.php b/app/accounting/api/engine/get_balance_sheet.php new file mode 100644 index 0000000..dfe4394 --- /dev/null +++ b/app/accounting/api/engine/get_balance_sheet.php @@ -0,0 +1,13 @@ +getBalanceSheet($as_of_date, $dept_id); +exit(json_encode($answer)); diff --git a/app/accounting/api/engine/get_gl_documents.php b/app/accounting/api/engine/get_gl_documents.php new file mode 100644 index 0000000..027fa3b --- /dev/null +++ b/app/accounting/api/engine/get_gl_documents.php @@ -0,0 +1,19 @@ +getPostableDocuments( + trim((string)($data['doc_type'] ?? '')), + trim((string)($data['date_from'] ?? '')), + trim((string)($data['date_to'] ?? '')), + (int)($data['formula_id'] ?? 0) + ); + $answer['success'] = 1; +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); +} + +exit(json_encode($answer)); diff --git a/app/accounting/api/engine/get_gl_movement.php b/app/accounting/api/engine/get_gl_movement.php new file mode 100644 index 0000000..35ec4ae --- /dev/null +++ b/app/accounting/api/engine/get_gl_movement.php @@ -0,0 +1,20 @@ +getGLMovement($account_code, $from_date, $to_date, $dept_id); +exit(json_encode($answer)); diff --git a/app/accounting/api/engine/get_journal_detail.php b/app/accounting/api/engine/get_journal_detail.php new file mode 100644 index 0000000..90fb3b6 --- /dev/null +++ b/app/accounting/api/engine/get_journal_detail.php @@ -0,0 +1,14 @@ +getJournalDetail((int)($data['gl_id'] ?? 0)); + $answer['success'] = 1; +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); +} + +exit(json_encode($answer)); diff --git a/app/accounting/api/engine/get_journal_listing.php b/app/accounting/api/engine/get_journal_listing.php new file mode 100644 index 0000000..31860cd --- /dev/null +++ b/app/accounting/api/engine/get_journal_listing.php @@ -0,0 +1,18 @@ +getJournalListing( + trim((string)($data['source_type'] ?? '')), + trim((string)($data['date_from'] ?? '')), + trim((string)($data['date_to'] ?? '')) + ); + $answer['success'] = 1; +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); +} + +exit(json_encode($answer)); diff --git a/app/accounting/api/engine/get_pl_statement.php b/app/accounting/api/engine/get_pl_statement.php new file mode 100644 index 0000000..b3a921e --- /dev/null +++ b/app/accounting/api/engine/get_pl_statement.php @@ -0,0 +1,14 @@ +getProfitAndLoss($from_date, $to_date, $dept_id); +exit(json_encode($answer)); diff --git a/app/accounting/api/engine/get_trial_balance.php b/app/accounting/api/engine/get_trial_balance.php new file mode 100644 index 0000000..fff2795 --- /dev/null +++ b/app/accounting/api/engine/get_trial_balance.php @@ -0,0 +1,14 @@ +getTrialBalance($from_date, $to_date, $dept_id); +exit(json_encode($answer)); diff --git a/app/accounting/api/engine/get_vat_report.php b/app/accounting/api/engine/get_vat_report.php new file mode 100644 index 0000000..dea66f3 --- /dev/null +++ b/app/accounting/api/engine/get_vat_report.php @@ -0,0 +1,20 @@ +getVATReport($from_date, $to_date, $categories, $dept_id); +exit(json_encode($answer)); diff --git a/app/accounting/api/engine/log_batch_action.php b/app/accounting/api/engine/log_batch_action.php new file mode 100644 index 0000000..5dcd4bb --- /dev/null +++ b/app/accounting/api/engine/log_batch_action.php @@ -0,0 +1,15 @@ +log($data); + $answer['success'] = 1; + $answer['message'] = 'Batch action logged.'; +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); +} + +exit(json_encode($answer)); diff --git a/app/accounting/api/engine/manage_period.php b/app/accounting/api/engine/manage_period.php deleted file mode 100644 index 34e758a..0000000 --- a/app/accounting/api/engine/manage_period.php +++ /dev/null @@ -1,40 +0,0 @@ - 2100) exit(json_encode(['message' => 'Invalid year'])); - $pm->createYear($year); - $answer['message'] = "All 12 periods for {$year} created"; - -} elseif ($action === 'create_single') { - $period = $data['period'] ?? ''; - if (!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period)) { - exit(json_encode(['message' => 'Invalid period format (YYYY-MM)'])); - } - $pm->create($period); - $answer['message'] = "Period {$period} created"; - -} elseif ($action === 'open') { - $pm->open((int)($data['id'] ?? 0)); - $answer['message'] = 'Period reopened'; - -} elseif ($action === 'close') { - $pm->close((int)($data['id'] ?? 0)); - $answer['message'] = 'Period closed'; - -} elseif ($action === 'lock') { - $pm->lock((int)($data['id'] ?? 0)); - $answer['message'] = 'Period locked'; - -} else { - exit(json_encode(['message' => 'Unknown action'])); -} - -$answer['success'] = 1; -exit(json_encode($answer)); diff --git a/app/accounting/api/engine/period.php b/app/accounting/api/engine/period.php deleted file mode 100644 index 0480d92..0000000 --- a/app/accounting/api/engine/period.php +++ /dev/null @@ -1,9 +0,0 @@ -getAll(); -$answer['success'] = 1; -exit(json_encode($answer)); diff --git a/app/accounting/api/engine/period_stats.php b/app/accounting/api/engine/period_stats.php deleted file mode 100644 index 371a7c9..0000000 --- a/app/accounting/api/engine/period_stats.php +++ /dev/null @@ -1,9 +0,0 @@ -getStats(); -$answer = array_merge(['success' => 1], $stats); -exit(json_encode($answer)); diff --git a/app/accounting/api/engine/post_gl_entry.php b/app/accounting/api/engine/post_gl_entry.php new file mode 100644 index 0000000..bbbc70e --- /dev/null +++ b/app/accounting/api/engine/post_gl_entry.php @@ -0,0 +1,62 @@ + InvoicePosting::class, + 'credit_note' => CreditNotePosting::class, + 'purchase_invoice' => PurchaseInvoicePosting::class, + 'supplier_credit_note' => SupplierCreditNotePosting::class, + 'receipt' => ReceiptPosting::class, + 'payment' => PaymentPosting::class, + 'purchase_order' => PurchasePosting::class, +]; + +if (!isset($posting_map[$doc_type]) || $id <= 0) { + $answer['message'] = 'Invalid doc_type or id.'; + exit(json_encode($answer)); +} + +try { + $posting_class = $posting_map[$doc_type]; + $posting = new $posting_class($pdo2, $company_id); + $built = $posting->build($id, $formula_id); + + $guard = new PostingWindowGuard($pdo1, $company_id); + $gl = new GlManager($pdo2, $company_id, $guard); + $meta = ['journal_date' => $built['doc_date'] ?? null]; + + $pdo2->beginTransaction(); + + $existing = $gl->getBySource($doc_type, $id); + if ($existing) { + $gl->replace($doc_type, $id, $built['formula_id'], $built['period'], $built['lines'], $meta); + } else { + $gl->post($doc_type, $id, $built['formula_id'], $built['period'], $built['lines'], $meta); + } + + $pdo2->commit(); + + $answer['success'] = 1; + $answer['message'] = $existing ? 'GL entry replaced.' : 'GL entry posted.'; + +} catch (Exception $e) { + if ($pdo2->inTransaction()) $pdo2->rollBack(); + $answer['message'] = $e->getMessage(); +} + +exit(json_encode($answer)); diff --git a/app/accounting/api/engine/posting_window.php b/app/accounting/api/engine/posting_window.php new file mode 100644 index 0000000..52a6290 --- /dev/null +++ b/app/accounting/api/engine/posting_window.php @@ -0,0 +1,54 @@ +get('posting_open_from'); + if ($open_from === null || $open_from === '') { + $open_from = $csm->get('gl_open_from') ?: ''; + } + + $open_to = $csm->get('posting_open_to'); + if ($open_to === null || $open_to === '') { + $open_to = $csm->get('gl_open_to') ?: ''; + } + + $answer['output'] = [ + 'open_from' => $open_from ?: '', + 'open_to' => $open_to ?: '', + ]; + $answer['success'] = 1; + exit(json_encode($answer)); +} + +if ($data['action'] === 'save') { + $from = trim((string)($data['open_from'] ?? '')); + $to = trim((string)($data['open_to'] ?? '')); + + if ($from !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) { + $answer['message'] = 'Invalid open_from date. Use YYYY-MM-DD.'; + exit(json_encode($answer)); + } + if ($to !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) { + $answer['message'] = 'Invalid open_to date. Use YYYY-MM-DD.'; + exit(json_encode($answer)); + } + if ($from && $to && $from > $to) { + $answer['message'] = 'Open From must be on or before Open To.'; + exit(json_encode($answer)); + } + + $csm->set('posting_open_from', $from); + $csm->set('posting_open_to', $to); + + $answer['success'] = 1; + $answer['message'] = 'Posting window saved.'; + exit(json_encode($answer)); +} + +$answer['message'] = 'Invalid action.'; +exit(json_encode($answer)); diff --git a/app/accounting/api/engine/product_account_mapping.php b/app/accounting/api/engine/product_account_mapping.php new file mode 100644 index 0000000..c6e6511 --- /dev/null +++ b/app/accounting/api/engine/product_account_mapping.php @@ -0,0 +1,42 @@ + $sales_account_code, + 'Purchase account' => $purchase_account_code, + ] as $label => $account_code) { + if ($account_code !== null && !$coa->isPostingAccount($account_code)) { + throw new Exception($label . ' must be an active posting account.'); + } + } + + dbTransaction($pdo2, function($pdo) use ($company_id, $id, $sales_account_code, $purchase_account_code, $logging) { + $product = new ProductManager($pdo, $company_id); + $product->updateAccountMapping($id, $sales_account_code, $purchase_account_code, $logging); + }); + + $answer['success'] = 1; + +} catch (Exception $e) { + $answer['success'] = 0; + $answer['message'] = $e->getMessage(); + http_response_code(400); +} + +exit(json_encode($answer)); +?> diff --git a/app/accounting/api/engine/release_op_lock.php b/app/accounting/api/engine/release_op_lock.php new file mode 100644 index 0000000..96246db --- /dev/null +++ b/app/accounting/api/engine/release_op_lock.php @@ -0,0 +1,15 @@ +release(trim((string)($data['operation_type'] ?? ''))); + $answer['success'] = 1; + $answer['message'] = 'Lock released.'; +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); +} + +exit(json_encode($answer)); diff --git a/app/accounting/api/engine/save_manual_journal.php b/app/accounting/api/engine/save_manual_journal.php new file mode 100644 index 0000000..5114e21 --- /dev/null +++ b/app/accounting/api/engine/save_manual_journal.php @@ -0,0 +1,79 @@ + $account_code, 'department_id' => 0, 'debit' => $debit, 'credit' => $credit, 'description' => $desc]; + $total_debit += $debit; + $total_credit += $credit; +} + +if (count($lines) < 2) { + $answer['message'] = 'At least two journal lines are required.'; + exit(json_encode($answer)); +} + +if (abs($total_debit - $total_credit) > 0.005) { + $answer['message'] = 'Journal is not balanced. Debit ' . number_format($total_debit, 2) . ' ≠ Credit ' . number_format($total_credit, 2) . '.'; + exit(json_encode($answer)); +} + +try { + $pdo2->beginTransaction(); + $guard = new PostingWindowGuard($pdo1, $company_id); + $gl = new GlManager($pdo2, $company_id, $guard); + + if ($gl_id > 0) { + $gl->replaceManual($gl_id, $reference, $description, $journal_date, $period, $lines); + $answer['message'] = 'Journal entry updated.'; + } else { + $gl_id = $gl->postManual($reference, $description, $journal_date, $period, $lines); + $answer['message'] = 'Journal entry saved.'; + } + + $pdo2->commit(); + $answer['success'] = 1; + $answer['gl_id'] = $gl_id; +} catch (Exception $e) { + if ($pdo2->inTransaction()) $pdo2->rollBack(); + $answer['message'] = $e->getMessage(); +} + +exit(json_encode($answer)); diff --git a/app/accounting/balance_sheet.php b/app/accounting/balance_sheet.php index da666dc..7124b12 100644 --- a/app/accounting/balance_sheet.php +++ b/app/accounting/balance_sheet.php @@ -11,19 +11,42 @@
-
+

Balance Sheet

-

Assets, liabilities, and equity

+

Cumulative asset, liability, and equity positions as of a date

-
-
-
- -

Balance Sheet coming soon.

+ +
+
+
+ +
+
+ + +
+
+ +
+
+ +
+
+
+ +
+
+
Select a date and click Run.
@@ -32,5 +55,117 @@ + + diff --git a/app/accounting/chart_of_accounts.php b/app/accounting/chart_of_accounts.php index c2e943a..7f170b8 100644 --- a/app/accounting/chart_of_accounts.php +++ b/app/accounting/chart_of_accounts.php @@ -122,6 +122,7 @@ Code Name Type + Category Level Status Actions @@ -154,6 +155,19 @@ expense: 'Expense', }; + var category_labels = { + ar: 'Account Receivable', + ap: 'Account Payable', + sales_tax: 'Sales Tax', + purchase_tax: 'Purchase Tax', + }; + + function category_badge(cat) { + if (!cat) return '—'; + var label = category_labels[cat] || cat; + return '' + escape_html(label) + ''; + } + var level_badge = { item: 'Item', parent: 'Parent', @@ -166,8 +180,8 @@ checkRequired: 0, action: 'read', onSuccess: function(res) { - alasql('CREATE TABLE IF NOT EXISTS ac_account'); - alasql.tables.ac_account.data = res.output || []; + alasql('CREATE TABLE IF NOT EXISTS md_account'); + alasql.tables.md_account.data = res.output || []; change_page(1); } }); @@ -182,14 +196,14 @@ if (active_type) conditions.push(`account_type = '${active_type}'`); var where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''; - var filtered = alasql(`SELECT * FROM ac_account ${where} ORDER BY account_code`); + var filtered = alasql(`SELECT * FROM md_account ${where} ORDER BY account_code`); - var page = generate_pagination('ac_account', filtered.length); + var page = generate_pagination('account_table', filtered.length); $('table#account_table tfoot').html(page); if (filtered.length === 0) { $('table#account_table tbody').html( - 'No accounts found.' + 'No accounts found.' ); return; } @@ -204,6 +218,7 @@ ${prefix}${escape_html(item.account_code)} ${escape_html(item.account_name)} ${type_badge[item.account_type] || item.account_type} + ${category_badge(item.account_category)} ${is_item ? level_badge.item : level_badge.parent} ${item.status ? 'Active' : 'Inactive'} diff --git a/app/accounting/departments.php b/app/accounting/departments.php index cbd23be..033fd47 100644 --- a/app/accounting/departments.php +++ b/app/accounting/departments.php @@ -128,8 +128,8 @@ checkRequired: 0, action: 'read', onSuccess: function(res) { - alasql('CREATE TABLE IF NOT EXISTS ac_department'); - alasql.tables.ac_department.data = res.output || []; + alasql('CREATE TABLE IF NOT EXISTS md_department'); + alasql.tables.md_department.data = res.output || []; change_page(1); } }); @@ -140,10 +140,10 @@ var kw = search_term.toLowerCase(); var filtered = kw - ? alasql(`SELECT * FROM ac_department WHERE LOWER(dept_code) LIKE '%${kw}%' OR LOWER(dept_name) LIKE '%${kw}%' ORDER BY dept_code`) - : alasql(`SELECT * FROM ac_department ORDER BY dept_code`); + ? alasql(`SELECT * FROM md_department WHERE LOWER(dept_code) LIKE '%${kw}%' OR LOWER(dept_name) LIKE '%${kw}%' ORDER BY dept_code`) + : alasql(`SELECT * FROM md_department ORDER BY dept_code`); - $('table#dept_table tfoot').html(generate_pagination('ac_department', filtered.length)); + $('table#dept_table tfoot').html(generate_pagination('dept_table', filtered.length)); if (filtered.length === 0) { $('table#dept_table tbody').html('No departments found.'); diff --git a/app/accounting/gl_entries.php b/app/accounting/gl_entries.php new file mode 100644 index 0000000..18cf4ae --- /dev/null +++ b/app/accounting/gl_entries.php @@ -0,0 +1,521 @@ + + + + + + +
+
+ +
+
+

Batch GL Entries

+

Create or replace general ledger entries for a date range

+
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ + + + + + + + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+ + +
+
0 / 0
+
+ + +
+ + + + + + + + + + + + + + + + + +
Document No.ContactAmountDateProduct AccountsGL Status
Use the filters above and click Load.
+ +
+HTML; +} +?> diff --git a/app/accounting/gl_movement.php b/app/accounting/gl_movement.php index e94b549..ee7e736 100644 --- a/app/accounting/gl_movement.php +++ b/app/accounting/gl_movement.php @@ -11,19 +11,78 @@
-
+

GL Movement

-

General ledger movement report

+

Per-account ledger with opening balance and running total

-
-
-
- -

GL Movement coming soon.

+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ + +
+
+

Account

—
+

Date Range

—
+

Opening Balance

—
+
+
+ +
+
+ + + + + + + + + + + + + + + + + +
DatePeriodDepartmentReferenceDescriptionDebitCreditBalance
Select an account and date range, then click Run.
@@ -32,5 +91,120 @@ + + diff --git a/app/accounting/journal_listing.php b/app/accounting/journal_listing.php new file mode 100644 index 0000000..43626eb --- /dev/null +++ b/app/accounting/journal_listing.php @@ -0,0 +1,541 @@ + + + + + + +
+
+ +
+
+
+
+

GL Journal Listing

+

Posted general ledger entries from all document types

+
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + +
DocumentTypeContactPeriodFormulaDebitCreditPosted At
Use the filters above and click Load.
+
+
+ +
+
+ + + + + + + + + + + + + diff --git a/app/accounting/manage_account.php b/app/accounting/manage_account.php index 854ece3..704907b 100644 --- a/app/accounting/manage_account.php +++ b/app/accounting/manage_account.php @@ -72,6 +72,18 @@
Only parent-level accounts of the same type are shown.
+
+ + +
Used by reports and modules to filter by sub-type.
+
+
-
- -
-
-
- - - - - - - - - - diff --git a/app/accounting/pl_statement.php b/app/accounting/pl_statement.php index 87d9076..0597be3 100644 --- a/app/accounting/pl_statement.php +++ b/app/accounting/pl_statement.php @@ -11,26 +11,150 @@
-
+
-

P&L Statement

-

Profit and loss statement

+

Profit & Loss Statement

+

Revenue and expense accounts for a date range

-
-
-
- -

P&L Statement coming soon.

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
Select a date range and click Run.
+
+
+ + diff --git a/app/accounting/posting_window.php b/app/accounting/posting_window.php new file mode 100644 index 0000000..1afe4c7 --- /dev/null +++ b/app/accounting/posting_window.php @@ -0,0 +1,189 @@ + + + + + + +
+
+ +
+
+
+
+

Posting Window

+

Define the date range within which GL postings and approved inventory entries can be created, edited, voided, or reversed

+
+
+
+
+ +
+ + +
+
+
+ +
+
+ +
+
+
Open Posting Window
+

Dates outside this range are locked for GL and inventory movement

+
+
+ + +
Loading...
+ +
+ +
+ This window applies to both accounting GL postings and approved inventory entries. + Draft inventory rows can still be edited or cancelled before they affect stock balance. +
+
+ + +
+
+ + +
+
+ + +
+
+
Example: 01/01/2026 to 31/12/2026 opens all of 2026.
+
+
+ +
+ + +
+ +
+
+
+ + +
+
+
+
How it works
+
    +
  • Journal entries, GL postings, and approved inventory movements are only allowed if their date falls within the open window.
  • +
  • Dates before Open From or after Open To are rejected.
  • +
  • Inventory entries that already affected stock balance use the same window when they are voided, cancelled, or reversed.
  • +
  • To open a full year: set 01/01/YYYY → 31/12/YYYY.
  • +
  • To roll the window forward (e.g. close January): change Open From to 01/02/YYYY.
  • +
  • Remove Restrictions clears both dates — all dates become postable.
  • +
  • Existing posted GL and approved inventory entries are not changed automatically — the window is checked when a user creates, edits, voids, cancels, or reverses them.
  • +
+
+
+
+ +
+
+
+ + + + + + + diff --git a/app/accounting/trial_balance.php b/app/accounting/trial_balance.php index 11f7bdd..e678e16 100644 --- a/app/accounting/trial_balance.php +++ b/app/accounting/trial_balance.php @@ -11,19 +11,65 @@
-
+

Trial Balance

-

Period trial balance report

+

Brought-forward balances, date-range movement, and carry-forward balances for posting accounts

-
-
-
- -

Trial Balance coming soon.

+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + +
CodeAccount NameTypeB/F DebitB/F CreditPeriod DebitPeriod CreditC/F DebitC/F Credit
Select a date range and click Run.
@@ -32,5 +78,92 @@ + + diff --git a/app/accounting/vat_report.php b/app/accounting/vat_report.php index b90a6ae..7ab2b8c 100644 --- a/app/accounting/vat_report.php +++ b/app/accounting/vat_report.php @@ -11,19 +11,101 @@
-
+

VAT Report

-

Value added tax report

+

Output VAT (Sales Tax) and Input VAT (Purchase Tax) from posted journal entries

-
-
-
- -

VAT Report coming soon.

+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ + + + + +
+
+ + + + + + + + + + + + + + + + + + +
DatePeriodTypeDepartmentReferenceAccountCategoryDescriptionDebitCredit
Select a date range and click Run.
@@ -32,5 +114,95 @@ + + diff --git a/app/assets/css/custom.css b/app/assets/css/custom.css index 7371c17..24e82bc 100644 --- a/app/assets/css/custom.css +++ b/app/assets/css/custom.css @@ -1,3 +1,4 @@ + .flatpickr-day.selected { background: var(--bs-primary) !important; border-color: var(--bs-primary) !important; @@ -33,6 +34,7 @@ /* Ensure the container matches your theme's input styling */ .ui-autocomplete { background: #ffffff !important; + z-index: 9999 !important; } .ui-menu-item div { diff --git a/app/assets/js/custom.js b/app/assets/js/custom.js index 9e66a3f..f5eeee2 100644 --- a/app/assets/js/custom.js +++ b/app/assets/js/custom.js @@ -69,9 +69,19 @@ $(function () { }); // Initialize autocomplete for product search inputs - $('input.product_search').each(function (i, el) { + init_product_search_inputs(); + + window.init_product_search_inputs = init_product_search_inputs; + + function init_product_search_inputs(scope) { + var $scope = scope ? $(scope) : $(document); + + $scope.find('input.product_search').addBack('input.product_search').each(function (i, el) { var $el = $(el); + if ($el.data('product-search-ready')) return; + $el.data('product-search-ready', true); + $el.autocomplete({ // Fetch data from server @@ -145,9 +155,14 @@ $(function () { $(this) .val(product.sku) .attr("secondary", product.sku); - $('input#product_name') + + var target = $(this).data('product-name-target'); + var $productName = target ? $(this).closest('tr').find(target) : $('input#product_name'); + $productName .val(product.product_name) - .attr("secondary", product.product_name) + .attr("secondary", product.product_name); + + $(this).trigger('product:selected', [product]); console.log("Input updated with SKU:", product.sku); @@ -169,201 +184,309 @@ $(function () { var secondary = $(this).attr('secondary'); if (secondary) { $(this).val(secondary); - $('input#product_name').val($('input#product_name').attr('secondary')); + var target = $(this).data('product-name-target'); + var $productName = target ? $(this).closest('tr').find(target) : $('input#product_name'); + $productName.val($productName.attr('secondary')); } }); }); + } - $('input.contact_search').each(function (i, el) { + init_contact_search_inputs(); + window.init_contact_search_inputs = init_contact_search_inputs; - var $el = $(el); - $el.autocomplete({ + function init_contact_search_inputs(scope) { + var $scope = scope ? $(scope) : $(document); + $scope.find('input.contact_search').addBack('input.contact_search').each(function (i, el) { - // Fetch data from server - source: function (request, response) { + var $el = $(el); + if ($el.data('contact-search-ready')) return; + $el.data('contact-search-ready', true); - // Abort previous request (prevent multiple firing) - if ($el.data('xhr')) { - $el.data('xhr').abort(); - } + $el.autocomplete({ - // Prepare payload - let q = prepare_form_data(0, true); - q['keyword'] = request.term; - let json = JSON.stringify(q); + source: function (request, response) { - // Send AJAX request - var xhr = $.ajax({ - url: server_url + 'ics/api/engine/contact_search.php', - type: 'POST', - dataType: 'json', - data: { json: json }, - // for CSRF validation - headers: { - 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') - }, + if ($el.data('xhr')) { + $el.data('xhr').abort(); + } - success: function (res) { + let q = prepare_form_data(0, true); + q['keyword'] = request.term; + let json = JSON.stringify(q); - var aca = []; + var xhr = $.ajax({ + url: server_url + 'ics/api/engine/contact_search.php', + type: 'POST', + dataType: 'json', + data: { json: json }, + headers: { + 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') + }, - // Map result to autocomplete format - $.each(res.result, function (i, item) { + success: function (res) { - var temp = ""; - temp += "
"; - temp += "
"; - temp += "Tax ID: " + (item.tax_id || 'N/A'); - temp += "
"; - temp += "
"; - temp += item.contact_name; - temp += "
"; - temp += "
"; + var aca = []; + + $.each(res.result, function (i, item) { + + var temp = ""; + temp += "
"; + temp += "
"; + temp += "Tax ID: " + (item.tax_id || 'N/A'); + temp += "
"; + temp += "
"; + temp += item.contact_name; + temp += "
"; + temp += "
"; + + aca.push({ + label: temp, + value: item.contact_name, + data: item + }); - aca.push({ - label: temp, // HTML dropdown - value: item.contact_name, // Default value - data: item // Full object }); - }); + response(aca); + } + }); - response(aca); + $el.data('xhr', xhr); + }, + + minLength: 0, + delay: 300, + + select: function (e, ui) { + + if (ui.item && ui.item.data) { + + var contact = ui.item.data; + + $(this) + .val(contact.contact_name) + .attr("data-id", contact.id) + .attr("secondary", contact.contact_name); + + $(this).trigger('contact:selected', [contact]); + + return false; } - }); - - // Save request reference - $el.data('xhr', xhr); - }, - - minLength: 0, - delay: 300, - - // When selecting item - select: function (e, ui) { - - if (ui.item && ui.item.data) { - - var contact = ui.item.data; - - // Replace input value with SKU - $(this) - .val(contact.contact_name) - .attr("data-id", contact.id) - .attr("secondary", contact.contact_name); - - console.log("Input updated with contact name:", contact.contact_name); - - return false; // Prevent overwrite by default value } - } - }) + }) - // Allow HTML rendering in dropdown .data("ui-autocomplete")._renderItem = function (ul, item) { return $("
  • ") .append($("
    ").html(item.label)) .appendTo(ul); }; - // Add this ↓ - $el.on('blur', function () { - var secondary = $(this).attr('secondary'); - console.log($(this).attr('data-id')) - if (secondary) { - $('input#contact').val($('input#contact').attr('secondary')); - } - }); + $el.on('blur', function () { + var secondary = $(this).attr('secondary'); + if (secondary) { + $(this).val(secondary); + } + }); - }); + }); + } + + + /** ========================= + * ACCOUNT SEARCH AUTOCOMPLETE + * Reusable for accounting pages that already loaded md_account rows. + * ========================= */ + window.init_account_search_inputs = init_account_search_inputs; + + function init_account_search_inputs(scope, accounts) { + var $scope = scope ? $(scope) : $(document); + var accountRows = accounts || window.account_search_accounts || []; + + $scope.find('input.account_search').addBack('input.account_search').each(function (i, el) { + var $el = $(el); + if ($el.data('account-search-ready')) { + $el.autocomplete('option', 'source', build_account_source(accountRows)); + return; + } + $el.data('account-search-ready', true); + + $el.autocomplete({ + source: build_account_source(accountRows), + minLength: 0, + delay: 100, + select: function (e, ui) { + if (ui.item && ui.item.data) { + var account = ui.item.data; + var display = account.account_code + ' - ' + account.account_name; + + $(this) + .val(display) + .attr('secondary', display) + .attr('data-account-code', account.account_code); + + var target = $(this).data('account-code-target'); + if (target) { + $(this).closest('tr').find(target).val(account.account_code); + } + + $(this).trigger('account:selected', [account]); + return false; + } + } + }) + .data("ui-autocomplete")._renderItem = function (ul, item) { + return $("
  • ") + .append($("
    ").html(item.label)) + .appendTo(ul); + }; + + $el.on('focus', function () { + $(this).autocomplete('search', $(this).val()); + }); + + $el.on('blur', function () { + var secondary = $(this).attr('secondary'); + if (secondary) { + $(this).val(secondary); + } + }); + + $el.on('input', function () { + $(this).removeAttr('data-account-code secondary'); + var target = $(this).data('account-code-target'); + if (target) { + $(this).closest('tr').find(target).val(''); + } + $(this).trigger('account:cleared'); + }); + }); + } + + function build_account_source(accounts) { + return function(request, response) { + var term = String(request.term || '').toLowerCase(); + var rows = (accounts || []).filter(function(account) { + if (String(account.is_posting) !== '1' || String(account.status) !== '1') return false; + var code = String(account.account_code || '').toLowerCase(); + var name = String(account.account_name || '').toLowerCase(); + var type = String(account.account_type || '').toLowerCase(); + return !term || code.includes(term) || name.includes(term) || type.includes(term); + }).slice(0, 20); + + response(rows.map(function(account) { + var type = account.account_type ? String(account.account_type).toUpperCase() : 'ACCOUNT'; + var label = ""; + label += ""; + + return { + label: label, + value: account.account_code + ' - ' + account.account_name, + data: account + }; + })); + }; + } /** ========================= * LOT SEARCH AUTOCOMPLETE * ========================= */ - $('input.lot_search').each(function (i, el) { + init_lot_search_inputs(); + window.init_lot_search_inputs = init_lot_search_inputs; - var $el = $(el); - $el.autocomplete({ + function init_lot_search_inputs(scope) { + var $scope = scope ? $(scope) : $(document); + $scope.find('input.lot_search').addBack('input.lot_search').each(function (i, el) { - source: function (request, response) { + var $el = $(el); + if ($el.data('lot-search-ready')) return; + $el.data('lot-search-ready', true); - if ($el.data('xhr')) { - $el.data('xhr').abort(); - } + $el.autocomplete({ - let q = prepare_form_data(0, true); - q['keyword'] = request.term; - q['product_sku'] = $('input#product_sku').attr('secondary') || $('input#product_sku').val(); - let json = JSON.stringify(q); + source: function (request, response) { - var xhr = $.ajax({ - url: server_url + 'ics/api/engine/retrieve_lot.php', - type: 'POST', - dataType: 'json', - data: { json: json }, - headers: { - 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') - }, - success: function (res) { + if ($el.data('xhr')) { + $el.data('xhr').abort(); + } - var aca = []; + var sku = $el.closest('[data-sku]').data('sku') || + $('input#product_sku').attr('secondary') || + $('input#product_sku').val() || ''; - $.each(res.result, function (i, item) { - var temp = ""; - temp += "
    "; - temp += "
    "; - temp += "Lot: " + item.lot_number; - temp += "
    "; - temp += "
    "; - temp += "Expiry: " + (item.expiry_date || 'N/A'); - temp += "
    "; - temp += "
    "; + let q = prepare_form_data(0, true); + q['keyword'] = request.term; + q['product_sku'] = sku; + let json = JSON.stringify(q); - aca.push({ - label: temp, - value: item.lot_number, - data: item + var xhr = $.ajax({ + url: server_url + 'ics/api/engine/retrieve_lot.php', + type: 'POST', + dataType: 'json', + data: { json: json }, + headers: { + 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') + }, + success: function (res) { + + var aca = []; + + $.each(res.result, function (i, item) { + var temp = ""; + temp += "
    "; + temp += "
    "; + temp += "Lot: " + item.lot_number; + temp += "
    "; + temp += "
    "; + temp += "Expiry: " + (item.expiry_date || 'N/A'); + temp += "
    "; + temp += "
    "; + + aca.push({ + label: temp, + value: item.lot_number, + data: item + }); }); - }); - response(aca); + response(aca); + } + }); + + $el.data('xhr', xhr); + }, + + minLength: 0, + delay: 300, + + select: function (e, ui) { + if (ui.item && ui.item.data) { + + var lot = ui.item.data; + + $(this) + .val(lot.lot_number) + .attr('secondary', lot.lot_number) + .attr('data-existing', '1'); + + $(this).trigger('lot:selected', [lot]); + + return false; } - }); - - $el.data('xhr', xhr); - }, - - minLength: 0, - delay: 300, - - select: function (e, ui) { - if (ui.item && ui.item.data) { - - var lot = ui.item.data; - - // Set lot_number value - $(this) - .val(lot.lot_number) - .attr('secondary', lot.lot_number) - .attr('data-existing', '1'); // flag: this is an existing lot - - // Populate + lock expiry_date — existing lot, not editable - if (typeof expiryPicker !== 'undefined' && expiryPicker) { - expiryPicker.setDate(lot.expiry_date || '', true, 'Y-m-d'); - if (expiryPicker.altInput) { expiryPicker.altInput.disabled = true; } - } - $('#expiry_date').prop('disabled', true) - .attr('title', 'Expiry date is locked for existing lots'); - - return false; } - } - }) + }) .data("ui-autocomplete")._renderItem = function (ul, item) { return $("
  • ") @@ -371,32 +494,149 @@ $(function () { .appendTo(ul); }; - // On blur — restore value if user didn't pick from dropdown - $el.on('blur', function () { - var secondary = $(this).attr('secondary'); - if (secondary) { - $(this).val(secondary); - } - }); + $el.on('focus', function () { + $(this).autocomplete('search', $(this).val()); + }); - // On manual edit — clear existing flag and unlock expiry_date - $el.on('input', function () { - $(this).removeAttr('data-existing'); - $('#expiry_date').prop('disabled', false).removeAttr('title'); - if (typeof expiryPicker !== 'undefined' && expiryPicker) { - expiryPicker.clear(); - if (expiryPicker.altInput) { expiryPicker.altInput.disabled = false; } - } - }); + $el.on('blur', function () { + var secondary = $(this).attr('secondary'); + if (secondary) { + $(this).val(secondary); + } + }); - }); + $el.on('input', function () { + $(this).removeAttr('data-existing secondary'); + $(this).trigger('lot:cleared'); + }); + + }); + } }); +/** ========================= + * SHARED PAGE UTILITIES + * ========================= */ + +function remove_item(idx) { + $(`#item_row_${idx}`).remove(); + recalc_totals(); +} + +function set_select_value(selector, value, label) { + if (!value) return; + var $select = $(selector); + if (!$select.find(`option[value="${value}"]`).length) { + $select.append(``); + } + $select.val(value).prop('disabled', false); +} + +function load_location_config(callback) { + return ajax_request({ + url: server_url + 'setting/api/engine/company_setting.php', + autoPrepare: true, + checkRequired: 0, + action: 'read', + onSuccess: function(res) { + var s = res.output; + advanced = s.advanced_location == 1; + label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location'); + label_zone = s.location_label_zone || 'Zone'; + label_aisle = s.location_label_aisle || 'Aisle'; + if (callback) callback(); + } + }); +} + + /** ========================= * PREPARE FORM DATA * ========================= */ +function load_formula_options(select_id, document_type, selected_id) { + var ctx = document.getElementById('session-context'); + ajax_request({ + url: server_url + 'accounting/api/engine/account_formula.php', + action: 'get_by_type', + queueLock: false, + noLoading: true, + data: { + otp: ctx ? ctx.dataset.otp : '', + company_id: ctx ? ctx.dataset.companyId : '', + action: 'get_by_type', + document_type: document_type + }, + onSuccess: function(res) { + var $sel = $('#' + select_id); + $sel.empty().append(''); + $.each(res.output || [], function(i, f) { + var label = escape_html(f.formula_name) + (f.is_default == 1 ? ' (default)' : ''); + $sel.append(''); + }); + if (selected_id) $sel.val(selected_id); + } + }); +} + +function load_departments(select_id, selected_id) { + var ctx = document.getElementById('session-context'); + ajax_request({ + url: server_url + 'accounting/api/engine/department.php', + action: 'get', + queueLock: false, + noLoading: true, + data: { + otp: ctx ? ctx.dataset.otp : '', + company_id: ctx ? ctx.dataset.companyId : '', + action: 'get' + }, + onSuccess: function(res) { + var $sel = $('#' + select_id); + $sel.empty().append(''); + $.each(res.output || [], function(i, d) { + $sel.append(''); + }); + if (selected_id) $sel.val(selected_id); + } + }); +} + +var _dept_cache = {}; + +function load_dept_cache() { + return ajax_request({ + url: server_url + 'accounting/api/engine/department.php', + action: 'get', + queueLock: false, + noLoading: true, + autoPrepare: true, + checkRequired: 0, + onSuccess: function(res) { + _dept_cache = {}; + $.each(res.output || [], function(i, d) { + _dept_cache[String(d.id)] = escape_html(d.dept_code) + ' — ' + escape_html(d.dept_name); + }); + } + }); +} + +function get_dept_label(dept_id) { + if (!dept_id || String(dept_id) === '0') return '—'; + return _dept_cache[String(dept_id)] || ('Dept #' + dept_id); +} + +function populate_dept_filter(select_id) { + var $sel = $('#' + select_id); + $sel.empty().append(''); + $.each(_dept_cache, function(id, label) { + $sel.append(''); + }); +} + function prepare_form_data(check_required, raw_data) { var q = {}; @@ -561,7 +801,8 @@ function ajax_request(options) { } if (!res || res.success != 1) { - bootbox.alert(res?.message || "Unexpected error"); + if (options.noAlert !== true) bootbox.alert(res?.message || "Unexpected error"); + options.onError?.(null, res?.message || 'api_failed'); throw new Error(res?.message || "api_failed"); } @@ -592,13 +833,11 @@ function ajax_request(options) { } if (serverMessage) { - // Known server-reported error (HTTP 4xx/5xx with JSON body) - bootbox.alert(serverMessage); + if (options.noAlert !== true) bootbox.alert(serverMessage); options.onError?.(xhr, serverMessage); } else { - // Unknown error — network failure, PHP fatal, HTML error page console.error("AJAX Error:", xhr?.status, xhr?.responseText); - bootbox.alert("Server error occurred."); + if (options.noAlert !== true) bootbox.alert("Server error occurred."); options.onError?.(xhr, null); } @@ -647,7 +886,7 @@ function generate_pagination(table_id, total_records, records_per_page, current_ Showing ${records_per_page} per page
    Total: ${total_records} - + `; + pagination_html += `
  • `; return pagination_html; } @@ -727,11 +966,12 @@ document.addEventListener('DOMContentLoaded', () => { * Helper function to format date strings (assuming input is in ISO format) */ function format_date(iso_string) { + if (!iso_string) return '—'; var split = iso_string.split(" "); - if (split.length === 2) { - var datePart = split[0].split("-"); - } - return (datePart) ? `${datePart[2]}/${datePart[1]}/${datePart[0]} ${split[1]}` : iso_string; + var datePart = split[0].split("-"); + if (datePart.length !== 3) return iso_string; + var formatted = `${datePart[2]}/${datePart[1]}/${datePart[0]}`; + return split.length === 2 ? `${formatted} ${split[1]}` : formatted; } @@ -749,11 +989,56 @@ function to_iso_date(dateStr) { } +function round_dp(value, places) { + var factor = Math.pow(10, places); + return Math.round((Number(value) + Number.EPSILON) * factor) / factor; +} + +function expand_exponential_number(value) { + var str = String(value); + if (!/[eE]/.test(str)) return str; + + var sign = ''; + if (str[0] === '-') { + sign = '-'; + str = str.slice(1); + } + + var parts = str.toLowerCase().split('e'); + var mantissa = parts[0]; + var exponent = parseInt(parts[1], 10); + if (isNaN(exponent)) return sign + str; + + var mantissa_parts = mantissa.split('.'); + var integer = mantissa_parts[0]; + var fraction = mantissa_parts[1] || ''; + var digits = integer + fraction; + var point = integer.length + exponent; + + if (point <= 0) { + return sign + '0.' + '0'.repeat(Math.abs(point)) + digits; + } + if (point >= digits.length) { + return sign + digits + '0'.repeat(point - digits.length); + } + return sign + digits.slice(0, point) + '.' + digits.slice(point); +} + function format_number(value, decimal) { var n = Number(value); if (isNaN(n)) return '--'; - if (decimal !== undefined) n = n.toFixed(decimal); - var parts = n.toString().split('.'); + + if (Math.abs(n) < 0.000005) n = 0; + + if (decimal !== undefined) { + n = round_dp(n, decimal); + if (Math.abs(n) < 0.000005) n = 0; + n = n.toFixed(decimal); + } else { + n = expand_exponential_number(n); + } + + var parts = String(n).split('.'); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); return parts.join('.'); } @@ -814,3 +1099,180 @@ $(function() { roleObserver.observe(document.body, { childList: true, subtree: true }); } }); + +function show_stock_rows(source, source_id, label) { + $('#stock_rows_modal_title').text('Stock — ' + label); + $('#stock_rows_tbody').html('Loading...'); + var modal = new bootstrap.Modal(document.getElementById('stock_rows_modal')); + modal.show(); + + ajax_request({ + url: server_url + 'ics/api/engine/retrieve_stock_by_source.php', + autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, + action: 'read', + data: { source: source, source_id: source_id }, + onSuccess: function(res) { + var rows = res.output || []; + if (!rows.length) { + $('#stock_rows_tbody').html('No stock transactions found.'); + return; + } + var html = ''; + $.each(rows, function(i, r) { + var type_badge = r.type === 'in' + ? 'In' + : 'Out'; + var status_badge = {'-1':'Cancelled', + '0':'Draft', + '1':'Approved'}[String(r.status)] + || '—'; + var location = [r.zone, r.aisle, r.rack].filter(function(v) { + return v && v !== r.rack; + }).concat([r.rack]).filter(Boolean).join(' / '); + html += ` + ${type_badge} + ${escape_html(r.product_name || r.product_sku)}
    ${escape_html(r.product_sku)} + ${escape_html(r.warehouse_name)} + ${escape_html(location)} + ${escape_html(r.lot_number || '—')} + ${format_number(r.quantity, 2)} + ${status_badge} + ${format_date(r.date)} + `; + }); + $('#stock_rows_tbody').html(html); + } + }); +} + +// ─── Generic batch processor ───────────────────────────────────────────────── +// Submits items one at a time, waiting for each response before proceeding. +// One lock per key prevents two chains running concurrently for the same job. +// ── DB operation locks ──────────────────────────────────────────────────── +// acquire_op_lock / release_op_lock use md_lock_operation for cross-tab, +// cross-user locking. operation_type is the lock key (e.g. 'gl_post'). + +var _active_op_locks = []; // tracks locks held by this tab + +function acquire_op_lock(operation_type, onSuccess, onFail, ttl_minutes) { + var ctx = document.getElementById('session-context'); + ajax_request({ + url: server_url + 'accounting/api/engine/acquire_op_lock.php', + action: 'post', + queueLock: false, + noLoading: true, + data: { + otp: ctx ? ctx.dataset.otp : '', + company_id: ctx ? ctx.dataset.companyId : '', + action: 'post', + operation_type: operation_type, + ttl_minutes: ttl_minutes || 120 + }, + onSuccess: function(res) { + if (res.success) { + _active_op_locks.push(operation_type); + onSuccess(); + } else { + if (onFail) onFail(res.message, res); + } + } + }); +} + +function release_op_lock(operation_type) { + _active_op_locks = _active_op_locks.filter(function(t) { return t !== operation_type; }); + var ctx = document.getElementById('session-context'); + ajax_request({ + url: server_url + 'accounting/api/engine/release_op_lock.php', + action: 'post', + queueLock: false, + noLoading: true, + data: { + otp: ctx ? ctx.dataset.otp : '', + company_id: ctx ? ctx.dataset.companyId : '', + action: 'post', + operation_type: operation_type + }, + onSuccess: function() {} + }); +} + +// Release all held locks on tab/browser close via keepalive fetch +// (async XHR is killed on unload; keepalive fetch survives) +window.addEventListener('beforeunload', function() { + if (!_active_op_locks.length) return; + var ctx = document.getElementById('session-context'); + if (!ctx) return; + var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || ''; + _active_op_locks.forEach(function(operation_type) { + fetch(server_url + 'accounting/api/engine/release_op_lock.php', { + method: 'POST', + keepalive: true, + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf }, + body: JSON.stringify({ + otp: ctx.dataset.otp, + company_id: ctx.dataset.companyId, + action: 'post', + operation_type: operation_type + }) + }); + }); +}); + +// ── Batch processing ────────────────────────────────────────────────────── +// Each item is retried up to 3 times before being marked failed (covers +// transient errors such as MySQL lock-wait timeouts). +var _batch_locks = {}; + +// delay_ms: throttle interval between items (default 80ms) — back-pressure on MySQL +function batch_process(key, items, url, make_payload, onProgress, onDone, delay_ms) { + if (_batch_locks[key]) { + console.warn('batch_process: already running for key=' + key); + return; + } + _batch_locks[key] = true; + + var total = items.length; + var failed = []; + var interval = (typeof delay_ms === 'number' && delay_ms >= 0) ? delay_ms : 200; + + function release() { + _batch_locks[key] = false; + } + + function run_next(index, attempt) { + if (index >= total) { + release(); + onDone(failed); + return; + } + + var item = items[index]; + + ajax_request({ + url: url, + action: 'post', + noLoading: true, + queueLock: false, + noAlert: true, + data: make_payload(item), + onSuccess: function(res) { + onProgress(index + 1, total, item, res); + setTimeout(function() { run_next(index + 1, 0); }, interval); + }, + onError: function(xhr, msg) { + if (attempt < 2) { + // Retry — covers success:0 (e.g. lock-wait timeout) and HTTP 500 + setTimeout(function() { run_next(index, attempt + 1); }, interval); + } else { + // 3 attempts exhausted — mark failed, continue to next item + failed.push({ item: item, error: msg || 'unknown error' }); + onProgress(index + 1, total, item, null); + setTimeout(function() { run_next(index + 1, 0); }, interval); + } + } + }); + } + + run_next(0, 0); +} diff --git a/app/assets/utils/classes/BatchActionManager.php b/app/assets/utils/classes/BatchActionManager.php new file mode 100644 index 0000000..2b5f422 --- /dev/null +++ b/app/assets/utils/classes/BatchActionManager.php @@ -0,0 +1,56 @@ +pdo = $pdo; + $this->companyId = $company_id; + $this->userId = $user_id; + } + + public function log(array $data): void + { + $type = trim((string)($data['type'] ?? '')); + $doc_type = trim((string)($data['doc_type'] ?? '')); + $total = (int)($data['total'] ?? 0); + $succeeded = (int)($data['succeeded'] ?? 0); + $failed = (int)($data['failed'] ?? 0); + $failed_ids = $this->normalizeFailedIds($data['failed_ids'] ?? '[]'); + + if ($type === '') { + throw new Exception('type is required.'); + } + + $this->pdo->prepare( + "INSERT INTO td_batch_action + (company_id, type, doc_type, total, succeeded, failed, failed_ids, user_id, created_at) + VALUES + (:company_id, :type, :doc_type, :total, :succeeded, :failed, :failed_ids, :user_id, :created_at)" + )->execute([ + ':company_id' => $this->companyId, + ':type' => $type, + ':doc_type' => $doc_type, + ':total' => $total, + ':succeeded' => $succeeded, + ':failed' => $failed, + ':failed_ids' => $failed_ids, + ':user_id' => $this->userId, + ':created_at' => date('Y-m-d H:i:s'), + ]); + } + + private function normalizeFailedIds(mixed $failed_ids): string + { + if (!is_string($failed_ids)) { + return json_encode([]); + } + + $decoded = json_decode($failed_ids, true); + return json_encode(is_array($decoded) ? $decoded : []); + } +} +?> diff --git a/app/assets/utils/classes/CompanyProfileManager.php b/app/assets/utils/classes/CompanyProfileManager.php new file mode 100644 index 0000000..ef048f0 --- /dev/null +++ b/app/assets/utils/classes/CompanyProfileManager.php @@ -0,0 +1,152 @@ +pdo = $pdo; + $this->companyId = $company_id; + } + + public function getProfile(): array + { + $sth = $this->pdo->prepare( + "SELECT + company_id, channel_name, + company_name, company_name2, + company_logo, company_seal, + branch, branch_no, + fiscal_year, fx, + address, address2, + tax_id, prompt_pay, entrepreneur, + email, phone, fax, + website, facebook_page + FROM company_list + WHERE company_id = :company_id + LIMIT 1" + ); + $sth->execute([':company_id' => $this->companyId]); + $company = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$company) { + throw new Exception('Company not found.'); + } + + return $company; + } + + public function handleImageSlot( + string $slot, + string $action, + string $current, + string $upload_dir, + string $prefix + ): string { + if ($action === 'keep') { + return $current; + } + + if ($action === 'remove') { + if ($current && file_exists($upload_dir . $current)) { + unlink($upload_dir . $current); + } + return ''; + } + + if ($action === 'replace' && !empty($_FILES[$slot]['tmp_name'])) { + $file = $_FILES[$slot]; + + if ($file['error'] !== UPLOAD_ERR_OK) { + throw new RuntimeException("Upload error on {$slot}: code {$file['error']}."); + } + if ($file['size'] > self::MAX_SIZE) { + throw new RuntimeException('File too large. Maximum size is 2 MB.'); + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + if (!in_array($mime, self::ALLOWED_MIME, true)) { + throw new RuntimeException('Invalid file type. Only JPEG, PNG, GIF, WEBP allowed.'); + } + + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + if (!in_array($ext, self::ALLOWED_EXT, true)) { + throw new RuntimeException('Invalid file extension. Only jpg, png, gif, webp allowed.'); + } + + if ($current && file_exists($upload_dir . $current)) { + unlink($upload_dir . $current); + } + + $filename = $prefix . uniqid() . '.' . $ext; + if (!move_uploaded_file($file['tmp_name'], $upload_dir . $filename)) { + throw new RuntimeException("Failed to save {$slot}."); + } + + return $filename; + } + + return $current; + } + + public function saveProfile(array $data, string $company_logo, string $company_seal): void + { + $channel = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? '')); + + $sth = $this->pdo->prepare( + "UPDATE company_list SET + channel_name = :channel_name, + company_name = :company_name, + company_name2 = :company_name2, + company_logo = :company_logo, + company_seal = :company_seal, + branch = :branch, + branch_no = :branch_no, + fiscal_year = :fiscal_year, + fx = :fx, + address = :address, + address2 = :address2, + tax_id = :tax_id, + prompt_pay = :prompt_pay, + entrepreneur = :entrepreneur, + email = :email, + phone = :phone, + fax = :fax, + website = :website, + facebook_page = :facebook_page + WHERE company_id = :company_id" + ); + + $sth->execute([ + ':channel_name' => $channel, + ':company_name' => trim($data['company_name'] ?? ''), + ':company_name2' => trim($data['company_name2'] ?? ''), + ':company_logo' => $company_logo, + ':company_seal' => $company_seal, + ':branch' => trim($data['branch'] ?? 'สำนักงานใหญ่'), + ':branch_no' => trim($data['branch_no'] ?? ''), + ':fiscal_year' => trim($data['fiscal_year'] ?? ''), + ':fx' => trim($data['fx'] ?? 'thb'), + ':address' => trim($data['address'] ?? ''), + ':address2' => trim($data['address2'] ?? ''), + ':tax_id' => trim($data['tax_id'] ?? ''), + ':prompt_pay' => trim($data['prompt_pay'] ?? ''), + ':entrepreneur' => trim($data['entrepreneur'] ?? ''), + ':email' => trim($data['email'] ?? ''), + ':phone' => trim($data['phone'] ?? ''), + ':fax' => trim($data['fax'] ?? ''), + ':website' => trim($data['website'] ?? ''), + ':facebook_page' => trim($data['facebook_page'] ?? ''), + ':company_id' => $this->companyId, + ]); + } +} +?> diff --git a/app/assets/utils/classes/CompanySettingManager.php b/app/assets/utils/classes/CompanySettingManager.php index 57295b1..5b821d3 100644 --- a/app/assets/utils/classes/CompanySettingManager.php +++ b/app/assets/utils/classes/CompanySettingManager.php @@ -30,6 +30,10 @@ class CompanySettingManager 'location_label_rack' => 'Location', // single label default 'location_label_zone' => 'Zone', // advanced level 1 default 'location_label_aisle' => 'Aisle', // advanced level 2 default + 'posting_open_from' => '', // YYYY-MM-DD; empty = no lower bound + 'posting_open_to' => '', // YYYY-MM-DD; empty = no upper bound + 'gl_open_from' => '', // legacy alias; use posting_open_from + 'gl_open_to' => '', // legacy alias; use posting_open_to ]; public function __construct(PDO $pdo, int $company_id, ?PDO $transactionPdo = null) diff --git a/app/assets/utils/classes/ContactManager.php b/app/assets/utils/classes/ContactManager.php index 170d9cb..0b5e52a 100644 --- a/app/assets/utils/classes/ContactManager.php +++ b/app/assets/utils/classes/ContactManager.php @@ -287,6 +287,16 @@ class ContactManager { return $sth->fetch(PDO::FETCH_ASSOC); } + public function getContactImage(int $id): string + { + $sth = $this->pdo->prepare( + "SELECT contact_image FROM md_contact + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + return (string)($sth->fetchColumn() ?: ''); + } + /** * Search contacts by name keyword — for live autocomplete on stock forms. * @@ -437,4 +447,4 @@ class ContactManager { ':company_id' => $this->company_id, ]); } -} \ No newline at end of file +} diff --git a/app/assets/utils/classes/InvoiceManager.php b/app/assets/utils/classes/InvoiceManager.php index feac0bb..1399231 100644 --- a/app/assets/utils/classes/InvoiceManager.php +++ b/app/assets/utils/classes/InvoiceManager.php @@ -1,10 +1,12 @@ company_id); + $guard->assertOpenDate($date ?: date('Y-m-d'), $context); + } + /** * Generate next sequential document number. * - * @param string $doc_type 'invoice' | 'credit_note' | 'debit_note' + * @param string $doc_type 'invoice' | 'credit_note' | 'supplier_credit_note' * @return string e.g. "INV-20260502-0001" | "CN-20260502-0001" | "DN-20260502-0001" */ private function generateInvoiceNumber(string $doc_type): string { $prefix_map = [ - 'invoice' => 'INV', - 'credit_note' => 'CN', - 'debit_note' => 'DN', + 'invoice' => 'INV', + 'credit_note' => 'CN', + 'supplier_credit_note' => 'DN', + 'purchase_invoice' => 'PINV', ]; $prefix = ($prefix_map[$doc_type] ?? 'INV') . '-' . date('Ymd') . '-'; @@ -88,6 +103,61 @@ class InvoiceManager { return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); } + private function addSettlementFields(array $row): array + { + $total = abs((float)($row['grand_total'] ?? 0)); + $allocated = (float)($row['allocated_amount'] ?? 0); + $balance = max(0, $total - $allocated); + + $row['document_total'] = $total; + $row['allocated_amount'] = $allocated; + $row['balance_amount'] = $balance; + + if ((int)$row['status'] === 4) { + $row['payment_state'] = 'void'; + } elseif ((int)$row['status'] === 0) { + $row['payment_state'] = 'draft'; + } elseif ($total > 0 && $allocated + 0.0001 >= $total) { + $row['payment_state'] = 'paid'; + } elseif ($allocated > 0) { + $row['payment_state'] = 'partial'; + } else { + $row['payment_state'] = 'open'; + } + + return $row; + } + + private function syncInvoiceItems(int $invoice_id, array $items): void + { + $this->pdo->prepare( + "DELETE FROM td_invoice_item WHERE invoice_id = :invoice_id AND company_id = :company_id" + )->execute([':invoice_id' => $invoice_id, ':company_id' => $this->company_id]); + + $sth = $this->pdo->prepare( + "INSERT INTO td_invoice_item + (company_id, invoice_id, item_id, product_sku, product_name, + quantity, unit_price, total_price, tax_amount, tax_rate) + VALUES + (:company_id, :invoice_id, :item_id, :product_sku, :product_name, + :quantity, :unit_price, :total_price, :tax_amount, :tax_rate)" + ); + foreach ($items as $pos => $item) { + $sth->execute([ + ':company_id' => $this->company_id, + ':invoice_id' => $invoice_id, + ':item_id' => $pos + 1, + ':product_sku' => $item['product_sku'] ?? '', + ':product_name' => $item['product_name'] ?? $item['description'] ?? '', + ':quantity' => (float)($item['quantity'] ?? 0), + ':unit_price' => (float)($item['unit_price'] ?? $item['price'] ?? 0), + ':total_price' => (float)($item['total_price'] ?? 0), + ':tax_amount' => (float)($item['tax_amount'] ?? 0), + ':tax_rate' => (float)($item['tax_rate'] ?? 0), + ]); + } + } + // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Read // ───────────────────────────────────────────────────────────── @@ -116,9 +186,21 @@ class InvoiceManager { $sth = $this->pdo->prepare( "SELECT i.*, + COALESCE(ra.allocated_amount, 0) AS allocated_amount, COALESCE(c.contact_name, '') AS contact_name, o.order_number FROM td_invoice i + LEFT JOIN ( + SELECT a.company_id, a.invoice_id, SUM(a.amount) AS allocated_amount + FROM td_receipt_item a + JOIN td_receipt r + ON r.company_id = a.company_id + AND r.id = a.receipt_id + AND r.status = 1 + GROUP BY a.company_id, a.invoice_id + ) ra + ON ra.company_id = i.company_id + AND ra.invoice_id = i.id LEFT JOIN md_contact c ON c.company_id = i.company_id AND c.id = i.contact_id @@ -129,7 +211,12 @@ class InvoiceManager { ORDER BY i.id DESC" ); $sth->execute($params); - return $sth->fetchAll(PDO::FETCH_ASSOC); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as &$row) { + $row = $this->addSettlementFields($row); + } + unset($row); + return $rows; } /** @@ -142,9 +229,21 @@ class InvoiceManager { { $sth = $this->pdo->prepare( "SELECT i.*, + COALESCE(ra.allocated_amount, 0) AS allocated_amount, COALESCE(c.contact_name, '') AS contact_name, o.order_number FROM td_invoice i + LEFT JOIN ( + SELECT a.company_id, a.invoice_id, SUM(a.amount) AS allocated_amount + FROM td_receipt_item a + JOIN td_receipt r + ON r.company_id = a.company_id + AND r.id = a.receipt_id + AND r.status = 1 + GROUP BY a.company_id, a.invoice_id + ) ra + ON ra.company_id = i.company_id + AND ra.invoice_id = i.id LEFT JOIN md_contact c ON c.company_id = i.company_id AND c.id = i.contact_id @@ -159,10 +258,58 @@ class InvoiceManager { if (!$row) return false; - $row['items'] = json_decode($row['items'] ?? '[]', true) ?: []; + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_invoice_item + WHERE invoice_id = :invoice_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':invoice_id' => $id, ':company_id' => $this->company_id]); + $row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC); + + $row = $this->addSettlementFields($row); return $row; } + public function getLinkedCreditNotes(int $ref_invoice_id, string $doc_type): array + { + $sth = $this->pdo->prepare( + "SELECT * FROM td_invoice + WHERE company_id = :company_id + AND ref_invoice_id = :ref_id + AND doc_type = :doc_type + ORDER BY id DESC" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':ref_id' => $ref_invoice_id, + ':doc_type' => $doc_type, + ]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + public function addReferenceInvoiceNumbers(array $rows): array + { + $sth = $this->pdo->prepare( + "SELECT invoice_number FROM td_invoice + WHERE id = :id AND company_id = :company_id" + ); + + foreach ($rows as &$row) { + if ((int)($row['ref_invoice_id'] ?? 0) > 0) { + $sth->execute([ + ':id' => (int)$row['ref_invoice_id'], + ':company_id' => $this->company_id, + ]); + $row['ref_invoice_number'] = $sth->fetchColumn() ?: '—'; + } else { + $row['ref_invoice_number'] = '—'; + } + } + unset($row); + + return $rows; + } + // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Write // ───────────────────────────────────────────────────────────── @@ -187,6 +334,9 @@ class InvoiceManager { */ public function createFromOrder(int $order_id, array $logging): int { + $issued_date = date('Y-m-d'); + $this->assertPostingWindow($issued_date, 'Invoice'); + // Load order $sth = $this->pdo->prepare( "SELECT * FROM td_order @@ -217,34 +367,50 @@ class InvoiceManager { $log = [array_merge($logging, ['action' => 'create_from_order'])]; + $source = (string)($order['source'] ?? ''); + $source_id = (int)($order['source_id'] ?? 0); + $this->pdo->prepare( "INSERT INTO td_invoice - (company_id, uuid, doc_type, invoice_number, ref_invoice_id, - order_id, contact_id, issued_date, due_date, - subtotal, discount, tax, shipping_fee, grand_total, - items, status, notes, `log`) + (company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id, + order_id, contact_id, department_id, issued_date, due_date, + subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total, + status, notes, `log`) VALUES - (:company_id, :uuid, 'invoice', :invoice_number, 0, - :order_id, :contact_id, :issued_date, NULL, - :subtotal, :discount, :tax, :shipping_fee, :grand_total, - :items, 0, '', :log)" + (:company_id, :uuid, :source_id, :source, 'invoice', :invoice_number, 0, + :order_id, :contact_id, :department_id, :issued_date, NULL, + :subtotal, :discount, :tax, :tax_adjustment, :shipping_fee, :grand_total, + 0, '', :log)" )->execute([ - ':company_id' => $this->company_id, - ':uuid' => bin2hex(random_bytes(16)), - ':invoice_number' => $this->generateInvoiceNumber('invoice'), - ':order_id' => $order_id, - ':contact_id' => (int)$order['contact_id'], - ':issued_date' => date('Y-m-d'), - ':subtotal' => $order['subtotal'], - ':discount' => $order['discount'], - ':tax' => $order['tax'], - ':shipping_fee' => $order['shipping_fee'], - ':grand_total' => $order['grand_total'], - ':items' => $order['items'], // already JSON string - ':log' => json_encode($log), + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':source_id' => $source_id, + ':source' => $source, + ':invoice_number' => $this->generateInvoiceNumber('invoice'), + ':order_id' => $order_id, + ':contact_id' => (int)$order['contact_id'], + ':department_id' => (int)($order['department_id'] ?? 0), + ':issued_date' => $issued_date, + ':subtotal' => $order['subtotal'], + ':discount' => $order['discount'], + ':tax' => $order['tax'], + ':tax_adjustment' => (float)($order['tax_adjustment'] ?? 0), + ':shipping_fee' => $order['shipping_fee'], + ':grand_total' => $order['grand_total'], + ':log' => json_encode($log), ]); - return (int)$this->pdo->lastInsertId(); + $new_id = (int)$this->pdo->lastInsertId(); + // Read items from normalized table instead of JSON + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_order_item + WHERE order_id = :order_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':order_id' => $order_id, ':company_id' => $this->company_id]); + $order_items = $sth2->fetchAll(PDO::FETCH_ASSOC); + $this->syncInvoiceItems($new_id, $order_items); + return $new_id; } /** @@ -265,7 +431,7 @@ class InvoiceManager { $id = (int)($data['id'] ?? 0); $sth = $this->pdo->prepare( - "SELECT status, `log` FROM td_invoice + "SELECT status, doc_type, issued_date, `log` FROM td_invoice WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); @@ -277,22 +443,84 @@ class InvoiceManager { if ((int)$row['status'] !== 0) { throw new Exception("Only draft invoices can be edited."); } + $this->assertPostingWindow($row['issued_date'] ?: date('Y-m-d'), ucfirst(str_replace('_', ' ', $row['doc_type']))); $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'update']); - $this->pdo->prepare( - "UPDATE td_invoice SET - due_date = :due_date, - notes = :notes, - `log` = :log - WHERE id = :id AND company_id = :company_id" - )->execute([ + $formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0 + ? (int)$data['formula_id'] : null; + + $params = [ ':due_date' => $data['due_date'] ?: null, ':notes' => $data['notes'] ?? '', + ':formula_id' => $formula_id, ':log' => json_encode($log), ':id' => $id, ':company_id' => $this->company_id, + ]; + + $extra_set = ''; + if (array_key_exists('tax_adjustment', $data)) { + $tax_adjustment = (float)$data['tax_adjustment']; + if (abs($tax_adjustment) > 0.30) { + throw new Exception("Tax adjustment cannot exceed ±0.30."); + } + + $sth3 = $this->pdo->prepare( + "SELECT subtotal, discount, shipping_fee FROM td_invoice + WHERE company_id = :company_id AND id = :id" + ); + $sth3->execute([':company_id' => $this->company_id, ':id' => $id]); + $inv = $sth3->fetch(PDO::FETCH_ASSOC); + + $sth4 = $this->pdo->prepare( + "SELECT SUM(tax_amount) FROM td_invoice_item + WHERE invoice_id = :invoice_id AND company_id = :company_id" + ); + $sth4->execute([':invoice_id' => $id, ':company_id' => $this->company_id]); + $item_tax_sum = (float)$sth4->fetchColumn(); + + $new_tax = round($item_tax_sum, 2) + $tax_adjustment; + $new_grand_total = (float)$inv['subtotal'] - (float)$inv['discount'] + + $new_tax + (float)$inv['shipping_fee']; + + $extra_set = ', tax = :tax, tax_adjustment = :tax_adjustment, grand_total = :grand_total'; + $params[':tax'] = $new_tax; + $params[':tax_adjustment'] = $tax_adjustment; + $params[':grand_total'] = $new_grand_total; + } + + $this->pdo->prepare( + "UPDATE td_invoice SET + due_date = :due_date, + notes = :notes, + formula_id = :formula_id{$extra_set}, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute($params); + } + + public function saveFormula(int $id, ?int $formula_id): void + { + $sth = $this->pdo->prepare( + "SELECT status, doc_type, issued_date FROM td_invoice + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) throw new Exception("Invoice not found."); + if ((int)$row['status'] === 4) throw new Exception("Cannot update a void document."); + $this->assertPostingWindow($row['issued_date'] ?: date('Y-m-d'), ucfirst(str_replace('_', ' ', $row['doc_type']))); + + $this->pdo->prepare( + "UPDATE td_invoice SET formula_id = :formula_id + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':formula_id' => $formula_id, + ':id' => $id, + ':company_id' => $this->company_id, ]); } @@ -300,13 +528,16 @@ class InvoiceManager { * Issue a draft invoice (status 0 → 1). * * @param int $id td_invoice.id + * @param string|null $due_date Invoice due date, yyyy-mm-dd. * @param array $logging Audit entry. * @throws Exception If not found or not draft. */ - public function issueInvoice(int $id, array $logging): void + public function issueInvoice(int $id, array $logging, ?string $due_date = null): void { + $issued_date = date('Y-m-d'); + $sth = $this->pdo->prepare( - "SELECT status, `log` FROM td_invoice + "SELECT status, doc_type, `log` FROM td_invoice WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); @@ -314,24 +545,293 @@ class InvoiceManager { if (!$row) throw new Exception("Invoice not found."); if ((int)$row['status'] !== 0) throw new Exception("Only draft invoices can be issued."); + if (in_array($row['doc_type'], ['invoice', 'purchase_invoice']) && !$due_date) { + throw new Exception("Due date is required before issuing this document."); + } + $this->assertPostingWindow($issued_date, ucfirst(str_replace('_', ' ', $row['doc_type']))); $log = json_decode($row['log'] ?? '[]', true) ?: []; - $log[] = array_merge($logging, ['action' => 'issue']); + $log[] = array_merge($logging, ['action' => 'issue', 'due_date' => $due_date]); $this->pdo->prepare( "UPDATE td_invoice SET status = 1, issued_date = :issued_date, + due_date = :due_date, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ - ':issued_date' => date('Y-m-d'), + ':issued_date' => $issued_date, + ':due_date' => in_array($row['doc_type'], ['invoice', 'purchase_invoice']) ? $due_date : null, ':log' => json_encode($log), ':id' => $id, ':company_id' => $this->company_id, ]); } + /** + * Create a draft purchase invoice from a confirmed PO. + * + * Mirrors createFromOrder() for the purchase side. + * doc_type = 'purchase_invoice', source = 'po', source_id = po_id. + * Always creates status=0 (draft). + * + * @param int $po_id td_purchase_order.id + * @param array $logging Audit entry. + * @return int New td_invoice.id + * @throws Exception + */ + public function createFromPo(int $po_id, array $logging): int + { + $issued_date = date('Y-m-d'); + $this->assertPostingWindow($issued_date, 'Purchase invoice'); + + $sth = $this->pdo->prepare( + "SELECT * FROM td_purchase_order + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $po_id]); + $po = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$po) throw new Exception("Purchase order not found."); + if ((int)$po['status'] < 1) throw new Exception("Purchase invoice can only be created for confirmed POs."); + + // Block duplicate active purchase invoice for same PO + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_invoice + WHERE company_id = :company_id + AND source = 'po' + AND source_id = :source_id + AND doc_type = 'purchase_invoice' + AND status != 4" + ); + $sth->execute([':company_id' => $this->company_id, ':source_id' => $po_id]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception("An active purchase invoice already exists for this PO."); + } + + $log = [array_merge($logging, ['action' => 'create_from_po'])]; + + $this->pdo->prepare( + "INSERT INTO td_invoice + (company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id, + order_id, contact_id, department_id, issued_date, due_date, + subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total, + status, notes, `log`) + VALUES + (:company_id, :uuid, :source_id, 'po', 'purchase_invoice', :invoice_number, 0, + 0, :contact_id, :department_id, :issued_date, NULL, + :subtotal, :discount, :tax, :tax_adjustment, :shipping_fee, :grand_total, + 0, '', :log)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':source_id' => $po_id, + ':invoice_number' => $this->generateInvoiceNumber('purchase_invoice'), + ':contact_id' => (int)$po['contact_id'], + ':department_id' => (int)($po['department_id'] ?? 0), + ':issued_date' => $issued_date, + ':subtotal' => $po['subtotal'], + ':discount' => $po['discount'], + ':tax' => $po['tax'], + ':tax_adjustment' => (float)($po['tax_adjustment'] ?? 0), + ':shipping_fee' => $po['shipping_fee'], + ':grand_total' => $po['grand_total'], + ':log' => json_encode($log), + ]); + + $new_id = (int)$this->pdo->lastInsertId(); + // Read items from normalized table instead of JSON + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_purchase_order_item + WHERE order_id = :po_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':po_id' => $po_id, ':company_id' => $this->company_id]); + $po_items = $sth2->fetchAll(PDO::FETCH_ASSOC); + $this->syncInvoiceItems($new_id, $po_items); + return $new_id; + } + + /** + * Create a draft debit note from a confirmed supplier return. + * + * Mirrors createFromReturn() for the purchase side. + * doc_type = 'supplier_credit_note', source = 'supplier_return', source_id = return_id. + * Always creates status=0 (draft). + * + * @param int $return_id td_supplier_return.id + * @param array $logging Audit entry. + * @return int New td_invoice.id + * @throws Exception + */ + public function createFromSupplierReturn(int $return_id, array $logging): int + { + $issued_date = date('Y-m-d'); + $this->assertPostingWindow($issued_date, 'Supplier credit note'); + + $sth = $this->pdo->prepare( + "SELECT * FROM td_supplier_return + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $return_id]); + $ret = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$ret) throw new Exception("Supplier return not found."); + if ((int)$ret['status'] !== 1) throw new Exception("Supplier credit note can only be created for confirmed supplier returns."); + + // Block duplicate active debit note for same return + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_invoice + WHERE company_id = :company_id + AND source = 'supplier_return' + AND source_id = :source_id + AND doc_type = 'supplier_credit_note' + AND status != 4" + ); + $sth->execute([':company_id' => $this->company_id, ':source_id' => $return_id]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception("An active supplier credit note already exists for this return."); + } + + $tax = (float)$ret['tax']; + $tax_adj = (float)$ret['tax_adjustment']; + $subtotal = (float)$ret['refund_amount'] - $tax; + $grand_total = (float)$ret['refund_amount']; + $log = [array_merge($logging, ['action' => 'create_from_supplier_return'])]; + + $this->pdo->prepare( + "INSERT INTO td_invoice + (company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id, + order_id, contact_id, department_id, issued_date, due_date, + subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total, + status, notes, `log`) + VALUES + (:company_id, :uuid, :source_id, 'supplier_return', 'supplier_credit_note', :invoice_number, :ref_invoice_id, + 0, :contact_id, :department_id, :issued_date, NULL, + :subtotal, 0, :tax, :tax_adjustment, 0, :grand_total, + 0, '', :log)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':source_id' => $return_id, + ':invoice_number' => $this->generateInvoiceNumber('supplier_credit_note'), + ':ref_invoice_id' => (int)$ret['invoice_id'], + ':contact_id' => (int)$ret['contact_id'], + ':department_id' => (int)($ret['department_id'] ?? 0), + ':issued_date' => $issued_date, + ':subtotal' => $subtotal, + ':tax' => $tax, + ':tax_adjustment' => $tax_adj, + ':grand_total' => $grand_total, + ':log' => json_encode($log), + ]); + + $new_id = (int)$this->pdo->lastInsertId(); + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_supplier_return_item + WHERE return_id = :return_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':return_id' => $return_id, ':company_id' => $this->company_id]); + $this->syncInvoiceItems($new_id, $sth2->fetchAll(PDO::FETCH_ASSOC)); + return $new_id; + } + + /** + * Create a draft credit note from a confirmed return. + * + * Snapshots td_return.items into td_invoice.items. + * grand_total stored as negative for net-balance queries. + * Always creates status=0 (draft) — user must manually issue. + * + * Must be called inside dbTransaction() by the caller. + * + * @param int $return_id td_return.id + * @param array $logging Audit entry. + * @return int New td_invoice.id + * @throws Exception If return not found, not confirmed, or CN already exists. + */ + public function createFromReturn(int $return_id, array $logging): int + { + $issued_date = date('Y-m-d'); + $this->assertPostingWindow($issued_date, 'Credit note'); + + $sth = $this->pdo->prepare( + "SELECT * FROM td_return + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $return_id]); + $ret = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$ret) { + throw new Exception("Return not found."); + } + if ((int)$ret['status'] !== 1) { + throw new Exception("Credit note can only be created for confirmed returns."); + } + + // Block duplicate active CN for same return + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_invoice + WHERE company_id = :company_id + AND source = 'return' + AND source_id = :return_id + AND doc_type = 'credit_note' + AND status != 4" + ); + $sth->execute([':company_id' => $this->company_id, ':return_id' => $return_id]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception("An active credit note already exists for this return."); + } + + $tax = (float)$ret['tax']; + $tax_adj = (float)$ret['tax_adjustment']; + $subtotal = (float)$ret['refund_amount'] - $tax; + $grand_total = -abs((float)$ret['refund_amount']); + $log = [array_merge($logging, ['action' => 'create_from_return'])]; + + $this->pdo->prepare( + "INSERT INTO td_invoice + (company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id, + order_id, contact_id, department_id, issued_date, due_date, + subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total, + status, notes, `log`) + VALUES + (:company_id, :uuid, :source_id, 'return', 'credit_note', :invoice_number, :ref_invoice_id, + :order_id, :contact_id, :department_id, :issued_date, NULL, + :subtotal, 0, :tax, :tax_adjustment, 0, :grand_total, + 0, '', :log)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':source_id' => $return_id, + ':invoice_number' => $this->generateInvoiceNumber('credit_note'), + ':ref_invoice_id' => (int)$ret['invoice_id'], + ':order_id' => (int)$ret['order_id'], + ':contact_id' => (int)$ret['contact_id'], + ':department_id' => (int)($ret['department_id'] ?? 0), + ':issued_date' => $issued_date, + ':subtotal' => $subtotal, + ':tax' => $tax, + ':tax_adjustment' => $tax_adj, + ':grand_total' => $grand_total, + ':log' => json_encode($log), + ]); + + $new_id = (int)$this->pdo->lastInsertId(); + // Read items from normalized table instead of JSON + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_return_item + WHERE return_id = :return_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':return_id' => $return_id, ':company_id' => $this->company_id]); + $return_items = $sth2->fetchAll(PDO::FETCH_ASSOC); + $this->syncInvoiceItems($new_id, $return_items); + return $new_id; + } + /** * Create a credit note linked to a parent invoice. * @@ -347,8 +847,18 @@ class InvoiceManager { * @return int New td_invoice.id (credit note) * @throws Exception If parent invoice not found or not issued. */ - public function createCreditNote(int $ref_invoice_id, array $items, float $amount, array $logging): int + public function createCreditNote( + int $ref_invoice_id, + array $items, + float $amount, + array $logging, + string $source = '', + int $source_id = 0 + ): int { + $issued_date = date('Y-m-d'); + $this->assertPostingWindow($issued_date, 'Credit note'); + $sth = $this->pdo->prepare( "SELECT * FROM td_invoice WHERE company_id = :company_id AND id = :id AND doc_type = 'invoice'" @@ -367,30 +877,275 @@ class InvoiceManager { $this->pdo->prepare( "INSERT INTO td_invoice - (company_id, uuid, doc_type, invoice_number, ref_invoice_id, + (company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id, order_id, contact_id, issued_date, due_date, subtotal, discount, tax, shipping_fee, grand_total, - items, status, notes, `log`) + status, notes, `log`) VALUES - (:company_id, :uuid, 'credit_note', :invoice_number, :ref_invoice_id, + (:company_id, :uuid, :source_id, :source, 'credit_note', :invoice_number, :ref_invoice_id, :order_id, :contact_id, :issued_date, NULL, :amount, 0, 0, 0, :grand_total, - :items, 1, '', :log)" + 1, '', :log)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), + ':source_id' => $source_id, + ':source' => $source, ':invoice_number' => $this->generateInvoiceNumber('credit_note'), ':ref_invoice_id' => $ref_invoice_id, ':order_id' => (int)$parent['order_id'], ':contact_id' => (int)$parent['contact_id'], - ':issued_date' => date('Y-m-d'), + ':issued_date' => $issued_date, ':amount' => $amount, ':grand_total' => -abs($amount), // negative for net-balance queries - ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), ':log' => json_encode($log), ]); - return (int)$this->pdo->lastInsertId(); + $new_id = (int)$this->pdo->lastInsertId(); + $this->syncInvoiceItems($new_id, $items); + return $new_id; + } + + /** + * Create a draft accounting credit note without stock movement. + * + * ref_invoice_id is optional. When supplied, contact/order are copied from + * the original invoice unless explicitly provided. + */ + public function createManualCreditNote(array $data, array $logging): int + { + $this->assertPostingWindow(date('Y-m-d'), 'Credit note'); + + $ref_invoice_id = (int)($data['ref_invoice_id'] ?? 0); + $contact_id = (int)($data['contact_id'] ?? 0); + $order_id = (int)($data['order_id'] ?? 0); + $notes = trim((string)($data['notes'] ?? '')); + $items = $data['items'] ?? []; + $tax_adjustment = (float)($data['tax_adjustment'] ?? 0); + + if (is_string($items)) { + $items = json_decode($items, true) ?: []; + } + if (!$items) { + throw new Exception("Credit note requires at least one line item."); + } + if ($notes === '') { + throw new Exception("Credit note reason is required."); + } + if (abs($tax_adjustment) > 0.30) { + throw new Exception("Tax adjustment cannot exceed ±0.30."); + } + + if ($ref_invoice_id > 0) { + $sth = $this->pdo->prepare( + "SELECT * FROM td_invoice + WHERE company_id = :company_id + AND id = :id + AND doc_type = 'invoice'" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $ref_invoice_id]); + $parent = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$parent) { + throw new Exception("Reference invoice not found."); + } + if ((int)$parent['status'] === 0) { + throw new Exception("Cannot create a credit note for a draft invoice."); + } + + $contact_id = $contact_id ?: (int)$parent['contact_id']; + $order_id = $order_id ?: (int)$parent['order_id']; + } + + if ($contact_id <= 0) { + throw new Exception("Contact is required."); + } + + $normalized = []; + $subtotal = 0.0; + + foreach ($items as $i => $item) { + $description = trim((string)($item['product_name'] ?? $item['description'] ?? '')); + $sku = trim((string)($item['product_sku'] ?? '')); + $qty = (float)($item['quantity'] ?? 0); + $price = (float)($item['price'] ?? $item['unit_price'] ?? 0); + $tax_rate = (float)($item['tax_rate'] ?? 0); + + if ($description === '' && $sku === '') continue; + if ($qty <= 0 || $price <= 0) { + throw new Exception("Credit note line #" . ($i + 1) . " requires quantity and price."); + } + + $line_total = round($qty * $price, 4); + $subtotal += $line_total; + $normalized[] = [ + 'item_id' => count($normalized), + 'product_sku' => $sku, + 'product_name' => $description ?: $sku, + 'quantity' => $qty, + 'unit_price' => $price, + 'total_price' => $line_total, + 'tax_rate' => $tax_rate, + 'tax_amount' => round($qty * $price * $tax_rate / 100, 4), + ]; + } + + if (!$normalized || $subtotal <= 0) { + throw new Exception("Credit note amount must be greater than zero."); + } + + $tax = round(array_sum(array_column($normalized, 'tax_amount')), 2) + $tax_adjustment; + $grand_total = -abs($subtotal + $tax); + + $log = [array_merge($logging, ['action' => 'create_manual_credit_note'])]; + + $this->pdo->prepare( + "INSERT INTO td_invoice + (company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id, + order_id, contact_id, issued_date, due_date, + subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total, + status, notes, `log`) + VALUES + (:company_id, :uuid, 0, 'revenue', 'credit_note', :invoice_number, :ref_invoice_id, + :order_id, :contact_id, NULL, NULL, + :subtotal, 0, :tax, :tax_adjustment, 0, :grand_total, + 0, :notes, :log)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':invoice_number' => $this->generateInvoiceNumber('credit_note'), + ':ref_invoice_id' => $ref_invoice_id, + ':order_id' => $order_id, + ':contact_id' => $contact_id, + ':subtotal' => $subtotal, + ':tax' => $tax, + ':tax_adjustment' => $tax_adjustment, + ':grand_total' => $grand_total, + ':notes' => $notes, + ':log' => json_encode($log), + ]); + + $new_id = (int)$this->pdo->lastInsertId(); + $this->syncInvoiceItems($new_id, $normalized); + return $new_id; + } + + /** + * Create a draft accounting supplier credit note (supplier_credit_note) without stock movement. + */ + public function createManualDebitNote(array $data, array $logging): int + { + $this->assertPostingWindow(date('Y-m-d'), 'Supplier credit note'); + + $ref_invoice_id = (int)($data['ref_invoice_id'] ?? 0); + $contact_id = (int)($data['contact_id'] ?? 0); + $notes = trim((string)($data['notes'] ?? '')); + $items = $data['items'] ?? []; + $tax_adjustment = (float)($data['tax_adjustment'] ?? 0); + + if (is_string($items)) { + $items = json_decode($items, true) ?: []; + } + if (!$items) { + throw new Exception("Supplier credit note requires at least one line item."); + } + if ($notes === '') { + throw new Exception("Supplier credit note reason is required."); + } + if (abs($tax_adjustment) > 0.30) { + throw new Exception("Tax adjustment cannot exceed ±0.30."); + } + + if ($ref_invoice_id > 0) { + $sth = $this->pdo->prepare( + "SELECT * FROM td_invoice + WHERE company_id = :company_id + AND id = :id + AND doc_type = 'purchase_invoice'" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $ref_invoice_id]); + $parent = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$parent) { + throw new Exception("Reference purchase invoice not found."); + } + if ((int)$parent['status'] === 0) { + throw new Exception("Cannot create a supplier credit note for a draft purchase invoice."); + } + + $contact_id = $contact_id ?: (int)$parent['contact_id']; + } + + if ($contact_id <= 0) { + throw new Exception("Supplier contact is required."); + } + + $normalized = []; + $subtotal = 0.0; + + foreach ($items as $i => $item) { + $description = trim((string)($item['product_name'] ?? $item['description'] ?? '')); + $sku = trim((string)($item['product_sku'] ?? '')); + $qty = (float)($item['quantity'] ?? 0); + $price = (float)($item['price'] ?? $item['unit_price'] ?? 0); + $tax_rate = (float)($item['tax_rate'] ?? 0); + + if ($description === '' && $sku === '') continue; + if ($qty <= 0 || $price <= 0) { + throw new Exception("Line #" . ($i + 1) . " requires quantity and price."); + } + + $line_total = round($qty * $price, 4); + $subtotal += $line_total; + $normalized[] = [ + 'item_id' => count($normalized), + 'product_sku' => $sku, + 'product_name' => $description ?: $sku, + 'quantity' => $qty, + 'unit_price' => $price, + 'total_price' => $line_total, + 'tax_rate' => $tax_rate, + 'tax_amount' => round($qty * $price * $tax_rate / 100, 4), + ]; + } + + if (!$normalized || $subtotal <= 0) { + throw new Exception("Supplier credit note amount must be greater than zero."); + } + + $tax = round(array_sum(array_column($normalized, 'tax_amount')), 2) + $tax_adjustment; + $grand_total = $subtotal + $tax; + + $log = [array_merge($logging, ['action' => 'create_manual_supplier_credit_note'])]; + + $this->pdo->prepare( + "INSERT INTO td_invoice + (company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id, + order_id, contact_id, issued_date, due_date, + subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total, + status, notes, `log`) + VALUES + (:company_id, :uuid, 0, 'expense', 'supplier_credit_note', :invoice_number, :ref_invoice_id, + 0, :contact_id, NULL, NULL, + :subtotal, 0, :tax, :tax_adjustment, 0, :grand_total, + 0, :notes, :log)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':invoice_number' => $this->generateInvoiceNumber('supplier_credit_note'), + ':ref_invoice_id' => $ref_invoice_id, + ':contact_id' => $contact_id, + ':subtotal' => $subtotal, + ':tax' => $tax, + ':tax_adjustment' => $tax_adjustment, + ':grand_total' => $grand_total, + ':notes' => $notes, + ':log' => json_encode($log), + ]); + + $new_id = (int)$this->pdo->lastInsertId(); + $this->syncInvoiceItems($new_id, $normalized); + return $new_id; } /** @@ -413,10 +1168,53 @@ class InvoiceManager { $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) throw new Exception("Invoice not found."); + if (!in_array($row['doc_type'], ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note'], true)) { + throw new Exception("This document type cannot be voided from here."); + } if ((int)$row['status'] === 4) throw new Exception("Invoice is already void."); + if ((int)$row['status'] === 0) throw new Exception("Draft invoices cannot be voided from here."); + $this->assertPostingWindow($row['issued_date'] ?: date('Y-m-d'), ucfirst(str_replace('_', ' ', $row['doc_type']))); + + if (in_array($row['doc_type'], ['invoice', 'credit_note'], true)) { + $sth = $this->pdo->prepare( + "SELECT COUNT(*) + FROM td_receipt_item ri + JOIN td_receipt r + ON r.company_id = ri.company_id + AND r.id = ri.receipt_id + AND r.status = 1 + WHERE ri.company_id = :company_id + AND ri.invoice_id = :invoice_id" + ); + $sth->execute([':company_id' => $this->company_id, ':invoice_id' => $id]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception("Cannot void this document while posted receipts are allocated to it. Void the receipt first."); + } + } + + if (in_array($row['doc_type'], ['purchase_invoice', 'supplier_credit_note'], true)) { + $sth = $this->pdo->prepare( + "SELECT COUNT(*) + FROM td_payment_item pi + JOIN td_payment p + ON p.company_id = pi.company_id + AND p.id = pi.payment_id + AND p.status = 1 + WHERE pi.company_id = :company_id + AND pi.invoice_id = :invoice_id" + ); + $sth->execute([':company_id' => $this->company_id, ':invoice_id' => $id]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception("Cannot void this document while posted payments are allocated to it. Void the payment first."); + } + } $log = json_decode($row['log'] ?? '[]', true) ?: []; - $log[] = array_merge($logging, ['action' => 'void']); + $log[] = array_merge([ + 'action' => 'void', + 'status' => 4, + 'previous_status' => (int)$row['status'], + ], $logging); $this->pdo->prepare( "UPDATE td_invoice SET status = 4, `log` = :log @@ -426,5 +1224,10 @@ class InvoiceManager { ':id' => $id, ':company_id' => $this->company_id, ]); + + global $pdo1; + $guard = new PostingWindowGuard($pdo1, $this->company_id); + $gl = new GlManager($this->pdo, $this->company_id, $guard); + $gl->delete((string)$row['doc_type'], $id); } -} \ No newline at end of file +} diff --git a/app/assets/utils/classes/OperationLockManager.php b/app/assets/utils/classes/OperationLockManager.php new file mode 100644 index 0000000..ed4c416 --- /dev/null +++ b/app/assets/utils/classes/OperationLockManager.php @@ -0,0 +1,81 @@ +pdo = $pdo; + $this->companyId = $company_id; + $this->userId = $user_id; + } + + public function acquire(string $operation_type, int $ttl_minutes = 120): array + { + $operation_type = trim($operation_type); + $ttl_minutes = max(1, min(480, $ttl_minutes)); + + if ($operation_type === '') { + throw new Exception('operation_type is required.'); + } + + $now = date('Y-m-d H:i:s'); + $expires = date('Y-m-d H:i:s', strtotime("+{$ttl_minutes} minutes")); + + $this->pdo->beginTransaction(); + + try { + $sth = $this->pdo->prepare( + "SELECT * FROM md_lock_operation + WHERE company_id = :cid AND operation_type = :op_type + FOR UPDATE" + ); + $sth->execute([':cid' => $this->companyId, ':op_type' => $operation_type]); + $lock = $sth->fetch(PDO::FETCH_ASSOC); + + if ($lock) { + $this->pdo->rollBack(); + return [ + 'success' => 0, + 'message' => 'Operation "' . $operation_type . '" is already running. Please wait.', + 'expires_at' => $lock['expires_at'], + 'locked_at' => $lock['locked_at'], + ]; + } + + $this->pdo->prepare( + "INSERT INTO md_lock_operation (company_id, operation_type, user_id, locked_at, expires_at) + VALUES (:cid, :op_type, :uid, :locked_at, :expires_at)" + )->execute([ + ':cid' => $this->companyId, + ':op_type' => $operation_type, + ':uid' => $this->userId, + ':locked_at' => $now, + ':expires_at' => $expires, + ]); + + $this->pdo->commit(); + return ['success' => 1, 'message' => 'Lock acquired.']; + + } catch (Throwable $e) { + if ($this->pdo->inTransaction()) $this->pdo->rollBack(); + throw $e; + } + } + + public function release(string $operation_type): void + { + $operation_type = trim($operation_type); + if ($operation_type === '') { + throw new Exception('operation_type is required.'); + } + + $this->pdo->prepare( + "DELETE FROM md_lock_operation + WHERE company_id = :cid AND operation_type = :op_type" + )->execute([':cid' => $this->companyId, ':op_type' => $operation_type]); + } +} +?> diff --git a/app/assets/utils/classes/OrderManager.php b/app/assets/utils/classes/OrderManager.php index cf027f5..a058639 100644 --- a/app/assets/utils/classes/OrderManager.php +++ b/app/assets/utils/classes/OrderManager.php @@ -9,7 +9,7 @@ * Method order: * Transaction basis → getOrderList, getOrderById, generateOrderNumber, * saveOrder, confirmOrder, cancelOrder, - * updateFulfillmentStatus + * updateShippingTracking * * Key design decisions: * - Order items are stored as a JSON array in td_order.items. @@ -92,7 +92,7 @@ class OrderManager { $table = $this->stockTableNameFromWarehouseId($warehouse_id); $sth = $this->pdo->prepare( - "SELECT s.*, r.zone, r.aisle, r.rack, + "SELECT s.*, r.zone, r.aisle, r.rack, l.expiry_date, (s.`in` - COALESCE(( SELECT SUM(o.`out`) FROM `{$table}` o @@ -107,6 +107,11 @@ class OrderManager { AND r.warehouse = :warehouse_id AND r.td_stock_id = s.id AND r.product_sku IS NOT NULL + LEFT JOIN md_lot l + ON l.company_id = s.company_id + AND l.product_sku = s.product_sku + AND l.lot_number = s.lot_number + AND s.lot_number != '' WHERE s.company_id = :company_id AND s.product_sku = :product_sku AND s.`in` > 0 @@ -155,6 +160,96 @@ class OrderManager { return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); } + /** + * Derive fulfillment from linked stock-out rows and tracking. + * + * 0 = no fulfillment yet + * 1 = picking (at least one linked stock-out row is draft) + * 2 = packed (all linked stock-out rows are approved, no tracking) + * 3 = shipped (all linked stock-out rows are approved, tracking exists) + */ + private function deriveFulfillmentStatus(array $order): int + { + $status = (int)($order['status'] ?? 0); + if ($status < 1) return 0; + + $order_id = (int)($order['id'] ?? 0); + $sth = $this->pdo->prepare( + "SELECT warehouse_id, stock_out_id FROM td_order_item + WHERE order_id = :order_id AND company_id = :company_id" + ); + $sth->execute([':order_id' => $order_id, ':company_id' => $this->company_id]); + $items = $sth->fetchAll(PDO::FETCH_ASSOC); + + $linked_count = 0; + $draft_count = 0; + + foreach ($items as $item) { + $warehouse_id = (int)($item['warehouse_id'] ?? 0); + $stock_out_id = (int)($item['stock_out_id'] ?? 0); + if ($warehouse_id <= 0 || $stock_out_id <= 0) continue; + + $table = $this->stockTableNameFromWarehouseId($warehouse_id); + $sth = $this->pdo->prepare( + "SELECT status FROM `{$table}` + WHERE company_id = :company_id + AND id = :id + AND type = 'out' + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':id' => $stock_out_id, + ]); + + $stock_status = $sth->fetchColumn(); + if ($stock_status === false) continue; + + $linked_count++; + if ((int)$stock_status === 0) { + $draft_count++; + } + } + + if ($linked_count === 0) return 0; + if ($draft_count > 0) return 1; + + return trim((string)($order['shipping_tracking_number'] ?? '')) !== '' ? 3 : 2; + } + + private function syncOrderItems(int $order_id, array $items): void + { + $this->pdo->prepare( + "DELETE FROM td_order_item WHERE order_id = :order_id AND company_id = :company_id" + )->execute([':order_id' => $order_id, ':company_id' => $this->company_id]); + + $sth = $this->pdo->prepare( + "INSERT INTO td_order_item + (company_id, order_id, item_id, product_sku, product_name, + quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, lot_number, invoiced_qty) + VALUES + (:company_id, :order_id, :item_id, :product_sku, :product_name, + :quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_out_id, :lot_number, 0)" + ); + foreach ($items as $pos => $item) { + $sth->execute([ + ':company_id' => $this->company_id, + ':order_id' => $order_id, + ':item_id' => $pos + 1, + ':product_sku' => $item['product_sku'] ?? '', + ':product_name' => $item['product_name'] ?? '', + ':quantity' => (float)($item['quantity'] ?? 0), + ':unit_price' => (float)($item['unit_price'] ?? $item['price'] ?? 0), + ':total_price' => (float)($item['total_price'] ?? 0), + ':tax_amount' => (float)($item['tax_amount'] ?? 0), + ':tax_rate' => (float)($item['tax_rate'] ?? 0), + ':warehouse_id' => (int)($item['warehouse_id'] ?? 0), + ':stock_out_id' => (int)($item['stock_out_id'] ?? 0), + ':lot_number' => $item['lot_number'] ?? '', + ]); + } + } + // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Read // ───────────────────────────────────────────────────────────── @@ -179,7 +274,14 @@ class OrderManager { ORDER BY o.created_at DESC" ); $sth->execute([':company_id' => $this->company_id]); - return $sth->fetchAll(PDO::FETCH_ASSOC); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + + foreach ($rows as &$row) { + $row['fulfillment_status'] = $this->deriveFulfillmentStatus($row); + } + unset($row); + + return $rows; } /** @@ -207,10 +309,145 @@ class OrderManager { if (!$row) return false; - $row['items'] = json_decode($row['items'] ?? '[]', true) ?: []; + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_order_item + WHERE order_id = :order_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':order_id' => $id, ':company_id' => $this->company_id]); + $row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC); + + $row['fulfillment_status'] = $this->deriveFulfillmentStatus($row); return $row; } + public function getActiveInvoiceId(int $order_id): int + { + $sth = $this->pdo->prepare( + "SELECT id FROM td_invoice + WHERE company_id = :company_id + AND order_id = :order_id + AND doc_type = 'invoice' + AND status != 4 + ORDER BY id DESC + LIMIT 1" + ); + $sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]); + return (int)$sth->fetchColumn(); + } + + public function getReturnableItems(array $order): array + { + $order_id = (int)($order['id'] ?? 0); + $order_items = $order['items'] ?? []; + + $sth = $this->pdo->prepare( + "SELECT ri.item_id, ri.quantity + FROM td_return_item ri + JOIN td_return r ON r.id = ri.return_id AND r.company_id = ri.company_id + WHERE ri.company_id = :company_id + AND r.order_id = :order_id + AND r.status = 1" + ); + $sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]); + $returned_qty = []; + + foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $item) { + $item_id = (int)($item['item_id'] ?? -1); + if ($item_id < 0) continue; + $returned_qty[$item_id] = ($returned_qty[$item_id] ?? 0) + (float)$item['quantity']; + } + + $returnable = []; + foreach ($order_items as $i => $item) { + $already_returned = $returned_qty[$i] ?? 0; + $remaining = (float)$item['quantity'] - $already_returned; + if ($remaining <= 0) { + continue; + } + + $stock_out_warehouse_id = (int)($item['warehouse_id'] ?? 0); + $stock_out_id = (int)($item['stock_out_id'] ?? 0); + + if ($stock_out_warehouse_id > 0 && $stock_out_id > 0) { + $table = $this->stockTableNameFromWarehouseId($stock_out_warehouse_id); + $sth = $this->pdo->prepare( + "SELECT id, status, zone, aisle, rack, lot_number, serial_number + FROM `{$table}` + WHERE company_id = :company_id + AND id = :id + AND type = 'out' + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':id' => $stock_out_id, + ]); + $stock_out = $sth->fetch(PDO::FETCH_ASSOC); + + if ($stock_out) { + $item['zone'] = $stock_out['zone'] ?? ($item['zone'] ?? ''); + $item['aisle'] = $stock_out['aisle'] ?? ($item['aisle'] ?? ''); + $item['rack'] = $stock_out['rack'] ?? ($item['rack'] ?? ''); + $item['lot_number'] = $stock_out['lot_number'] ?? ($item['lot_number'] ?? ''); + $item['serial_number'] = $stock_out['serial_number'] ?? ($item['serial_number'] ?? ''); + $item['stock_out_status'] = (int)$stock_out['status']; + } + } + + $returnable[] = array_merge($item, [ + 'item_id' => $i, + 'original_qty' => (float)$item['quantity'], + 'returned_qty' => $already_returned, + 'returnable_qty' => $remaining, + 'stock_out_warehouse_id' => $stock_out_warehouse_id, + 'stock_out_status' => (int)($item['stock_out_status'] ?? 0), + ]); + } + + return $returnable; + } + + public function linkQuotationToOrder(int $quotation_id, int $order_id): void + { + $this->pdo->prepare( + "UPDATE td_quotation SET order_id = :order_id, status = 5 + WHERE id = :id AND company_id = :cid AND status = 2" + )->execute([':order_id' => $order_id, ':id' => $quotation_id, ':cid' => $this->company_id]); + } + + public function assertRevenueOrderEditable(int $order_id): void + { + $sth = $this->pdo->prepare( + "SELECT source, status FROM td_order + WHERE id = :id AND company_id = :cid + LIMIT 1" + ); + $sth->execute([':id' => $order_id, ':cid' => $this->company_id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row || !in_array($row['source'], ['revenue', 'quotation'], true)) { + throw new Exception('Revenue sales order not found.'); + } + if ((int)$row['status'] !== -2) { + throw new Exception('Only pending sales orders can be edited.'); + } + } + + public function assertRevenueOrder(int $order_id): void + { + $sth = $this->pdo->prepare( + "SELECT source FROM td_order + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $order_id]); + $source = (string)$sth->fetchColumn(); + + if (!in_array($source, ['revenue', 'quotation'], true)) { + throw new Exception("This is not a Revenue sales order."); + } + } + // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Write // ───────────────────────────────────────────────────────────── @@ -242,14 +479,20 @@ class OrderManager { $items = $data['items'] ?? []; // Calculate totals from items - $subtotal = array_reduce($items, fn($carry, $item) => + $subtotal = array_reduce($items, fn($carry, $item) => $carry + (float)($item['total_price'] ?? 0), 0.0 ); - $discount = (float)($data['discount'] ?? 0); - $tax = (float)($data['tax'] ?? 0); - $shipping_fee = (float)($data['shipping_fee'] ?? 0); - $tracking_no = trim((string)($data['shipping_tracking_number'] ?? '')); - $grand_total = $subtotal - $discount + $tax + $shipping_fee; + $discount = (float)($data['discount'] ?? 0); + $tax_adjustment = (float)($data['tax_adjustment'] ?? 0); + if (abs($tax_adjustment) > 0.30) { + throw new Exception("Tax adjustment cannot exceed ±0.30."); + } + $tax = round(array_reduce($items, fn($carry, $item) => + $carry + (float)($item['tax_amount'] ?? 0), 0.0 + ), 2) + $tax_adjustment; + $shipping_fee = (float)($data['shipping_fee'] ?? 0); + $tracking_no = trim((string)($data['shipping_tracking_number'] ?? '')); + $grand_total = $subtotal - $discount + $tax + $shipping_fee; if ($id > 0) { @@ -287,75 +530,84 @@ class OrderManager { $this->pdo->prepare( "UPDATE td_order SET - contact_id = :contact_id, - order_date = :order_date, - items = :items, - subtotal = :subtotal, - discount = :discount, - tax = :tax, - shipping_fee = :shipping_fee, + contact_id = :contact_id, + department_id = :department_id, + order_date = :order_date, + subtotal = :subtotal, + discount = :discount, + tax = :tax, + tax_adjustment = :tax_adjustment, + shipping_fee = :shipping_fee, shipping_tracking_number = :shipping_tracking_number, - grand_total = :grand_total, - notes = :notes, - status = :status, - `log` = :log + grand_total = :grand_total, + notes = :notes, + status = :status, + `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ - ':contact_id' => (int)($data['contact_id'] ?? 0), - ':order_date' => $data['order_date'] ?? date('Y-m-d'), - ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), - ':subtotal' => $subtotal, - ':discount' => $discount, - ':tax' => $tax, - ':shipping_fee' => $shipping_fee, + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':department_id' => (int)($data['department_id'] ?? 0), + ':order_date' => $data['order_date'] ?? date('Y-m-d'), + ':subtotal' => $subtotal, + ':discount' => $discount, + ':tax' => $tax, + ':tax_adjustment' => $tax_adjustment, + ':shipping_fee' => $shipping_fee, ':shipping_tracking_number' => $tracking_no, - ':grand_total' => $grand_total, - ':notes' => $data['notes'] ?? '', - ':status' => $new_status, - ':log' => json_encode($log), - ':id' => $id, - ':company_id' => $this->company_id, + ':grand_total' => $grand_total, + ':notes' => $data['notes'] ?? '', + ':status' => $new_status, + ':log' => json_encode($log), + ':id' => $id, + ':company_id' => $this->company_id, ]); + $this->syncOrderItems($id, $items); return 0; } else { $log = [$logging]; - $quotation_id = (int)($data['quotation_id'] ?? 0); - // Quotation-converted orders start as Pending (-2); direct orders as Draft (0) - $init_status = $quotation_id > 0 ? -2 : 0; + $source = trim((string)($data['source'] ?? '')); + $source_id = (int)($data['source_id'] ?? 0); + + // Direct WMS orders start as Draft; sourced orders wait for WMS assignment. + $init_status = $source !== '' ? -2 : 0; $this->pdo->prepare( "INSERT INTO td_order - (company_id, uuid, quotation_id, order_number, contact_id, order_date, - status, payment_status, subtotal, discount, tax, - shipping_fee, shipping_tracking_number, grand_total, items, notes, `log`, created_at) + (company_id, uuid, source_id, `source`, order_number, contact_id, department_id, order_date, + status, subtotal, discount, tax, tax_adjustment, + shipping_fee, shipping_tracking_number, grand_total, notes, `log`, created_at) VALUES - (:company_id, :uuid, :quotation_id, :order_number, :contact_id, :order_date, - :status, 0, :subtotal, :discount, :tax, - :shipping_fee, :shipping_tracking_number, :grand_total, :items, :notes, :log, :created_at)" + (:company_id, :uuid, :source_id, :source, :order_number, :contact_id, :department_id, :order_date, + :status, :subtotal, :discount, :tax, :tax_adjustment, + :shipping_fee, :shipping_tracking_number, :grand_total, :notes, :log, :created_at)" )->execute([ - ':company_id' => $this->company_id, - ':uuid' => bin2hex(random_bytes(16)), - ':quotation_id' => $quotation_id, - ':order_number' => $this->generateOrderNumber(), - ':contact_id' => (int)($data['contact_id'] ?? 0), - ':order_date' => $data['order_date'] ?? date('Y-m-d'), - ':status' => $init_status, - ':subtotal' => $subtotal, - ':discount' => $discount, - ':tax' => $tax, - ':shipping_fee' => $shipping_fee, + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':source_id' => $source_id, + ':source' => $source, + ':order_number' => $this->generateOrderNumber(), + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':department_id' => (int)($data['department_id'] ?? 0), + ':order_date' => $data['order_date'] ?? date('Y-m-d'), + ':status' => $init_status, + ':subtotal' => $subtotal, + ':discount' => $discount, + ':tax' => $tax, + ':tax_adjustment' => $tax_adjustment, + ':shipping_fee' => $shipping_fee, ':shipping_tracking_number' => $tracking_no, - ':grand_total' => $grand_total, - ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), - ':notes' => $data['notes'] ?? '', - ':log' => json_encode($log), - ':created_at' => date('Y-m-d H:i:s'), + ':grand_total' => $grand_total, + ':notes' => $data['notes'] ?? '', + ':log' => json_encode($log), + ':created_at' => date('Y-m-d H:i:s'), ]); - return (int)$this->pdo->lastInsertId(); + $new_id = (int)$this->pdo->lastInsertId(); + $this->syncOrderItems($new_id, $items); + return $new_id; } } @@ -401,45 +653,68 @@ class OrderManager { throw new Exception("Only draft orders can be confirmed."); } - $items = json_decode($order['items'] ?? '[]', true) ?: []; + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_order_item + WHERE order_id = :order_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':order_id' => $order_id, ':company_id' => $this->company_id]); + $items = $sth2->fetchAll(PDO::FETCH_ASSOC); if (empty($items)) { throw new Exception("Cannot confirm an order with no items."); } + // ── Pre-flight: validate ALL items before creating any rows ─────── + $preflight_errors = []; + foreach ($items as $i => $item) { + $warehouse_id = (int)($item['warehouse_id'] ?? 0); + $product_sku = $item['product_sku'] ?? ''; + $quantity = (float)($item['quantity'] ?? 0); + $name = $item['product_name'] ?? $product_sku; + + if (!$warehouse_id || !$product_sku) { + $preflight_errors[] = "Item #{$i}: missing warehouse or SKU."; + continue; + } + if ($quantity <= 0) { + $preflight_errors[] = "Item #{$i}: quantity must be greater than zero."; + continue; + } + + $rack_stock = $this->pickFifoRack($warehouse_id, $product_sku); + if (!$rack_stock) { + $preflight_errors[] = "No available stock for \"{$name}\" in the selected warehouse."; + continue; + } + + $available_qty = (float)($rack_stock['available_qty'] ?? $rack_stock['in'] ?? 0); + if ($quantity - $available_qty > 0.000001) { + $preflight_errors[] = + "Insufficient stock for \"{$name}\": " . + "{$available_qty} available, {$quantity} requested."; + } + + $expiry = $rack_stock['expiry_date'] ?? null; + if ($expiry && strtotime($expiry) < strtotime(date('Y-m-d'))) { + $lot = $rack_stock['lot_number'] ?? ''; + $preflight_errors[] = + "Lot {$lot} for \"{$name}\" expired on {$expiry} and cannot be picked."; + } + } + + if (!empty($preflight_errors)) { + throw new Exception(implode("\n", $preflight_errors)); + } + foreach ($items as $i => &$item) { $warehouse_id = (int)($item['warehouse_id'] ?? 0); $product_sku = $item['product_sku'] ?? ''; $quantity = (float)($item['quantity'] ?? 0); - if (!$warehouse_id || !$product_sku) { - throw new Exception( - "Item #{$i}: missing warehouse_id or product_sku." - ); - } - if ($quantity <= 0) { - throw new Exception("Item #{$i}: quantity must be greater than zero."); - } - - $rack_stock = $this->pickFifoRack($warehouse_id, $product_sku); - - if (!$rack_stock) { - $name = $item['product_name'] ?? $product_sku; - throw new Exception( - "No available stock for \"{$name}\" in the selected warehouse." - ); - } - + $rack_stock = $this->pickFifoRack($warehouse_id, $product_sku); $available_qty = (float)($rack_stock['available_qty'] ?? $rack_stock['in'] ?? 0); - if ($quantity - $available_qty > 0.000001) { - $name = $item['product_name'] ?? $product_sku; - throw new Exception( - "FIFO rack {$rack_stock['zone']}-{$rack_stock['aisle']}-{$rack_stock['rack']} " . - "for \"{$name}\" has only {$available_qty} available unit(s), " . - "but the order requests {$quantity}." - ); - } $table = $this->stockTableNameFromWarehouseId($warehouse_id); $item_uuid = $uuid . '_' . $i; @@ -474,7 +749,7 @@ class OrderManager { ':lot_number' => $rack_stock['lot_number'] ?? '', ':serial_number' => $rack_stock['serial_number'] ?? '', ':source_id' => $order_id, - ':price' => (float)($item['price'] ?? 0), + ':price' => (float)($item['unit_price'] ?? 0), ]); // Write rack context + stock_out_id back into the item object @@ -494,22 +769,38 @@ class OrderManager { } unset($item); // break reference - // ── Update order: items (with stock_out_id) + status=1 ─────────── + // ── Update order: status=1 ─────────────────────────────────────── $log = json_decode($order['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'confirm']); $this->pdo->prepare( "UPDATE td_order SET status = 1, - items = :items, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ - ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), ':log' => json_encode($log), ':id' => $order_id, ':company_id' => $this->company_id, ]); + + $upd = $this->pdo->prepare( + "UPDATE td_order_item SET + stock_out_id = :stock_out_id, + lot_number = :lot_number, + warehouse_id = :warehouse_id + WHERE order_id = :order_id AND item_id = :item_id AND company_id = :company_id" + ); + foreach ($items as $item) { + $upd->execute([ + ':stock_out_id' => (int)($item['stock_out_id'] ?? 0), + ':lot_number' => $item['lot_number'] ?? '', + ':warehouse_id' => (int)($item['warehouse_id'] ?? 0), + ':order_id' => $order_id, + ':item_id' => (int)($item['item_id'] ?? 0), + ':company_id' => $this->company_id, + ]); + } } /** @@ -555,11 +846,6 @@ class OrderManager { if ($status === -1) { throw new Exception("Order is already cancelled."); } - if ($status >= 2) { - throw new Exception( - "Cannot cancel an order that is processing or completed." - ); - } // Pending orders (-2) have no stock rows — skip the stock reversal loop below // ── Guard: no active invoice ────────────────────────────────────── @@ -610,7 +896,7 @@ class OrderManager { $warehouse_id = (int)$matches[1]; $row_sth = $this->pdo->prepare( - "SELECT id, product_sku, `out`, zone, aisle, rack, ref_id, status + "SELECT id, product_sku, `out`, zone, aisle, rack, ref_id, status, `date` FROM `{$table}` WHERE company_id = :company_id AND source = 'order' @@ -629,6 +915,8 @@ class OrderManager { continue; } + $whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Order stock cancellation'); + $whMgmt->occupyRack( $warehouse_id, (string)$row['zone'], @@ -638,11 +926,9 @@ class OrderManager { (int)$row['ref_id'] ); $whMgmt->adjustBalance( - 'out', - $warehouse_id, - (string)$row['product_sku'], - (float)$row['out'], - 0 + 'out', $warehouse_id, (string)$row['product_sku'], + (float)$row['out'], 0, + (int)$row['id'], 'order', $order_id ); } @@ -658,6 +944,38 @@ class OrderManager { ]); } + // ── Release converted_qty on source quotation ───────────────────────── + $source = (string)($order['source'] ?? ''); + $source_id = (int)($order['source_id'] ?? 0); + + if ($source === 'quotation' && $source_id > 0) { + $sth2 = $this->pdo->prepare( + "SELECT item_id, quantity FROM td_order_item + WHERE order_id = :order_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':order_id' => $order_id, ':company_id' => $this->company_id]); + $items = $sth2->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, + ]); + } + // Revert quotation from Converted back to Accepted if it was fully done + $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]); + } + // ── Cancel the order ────────────────────────────────────────────── $log = json_decode($order['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'cancel']); @@ -675,36 +993,14 @@ class OrderManager { } /** - * Update the fulfillment sub-status of a confirmed order. - * - * Fulfillment status values: - * 1 = Picking — warehouse staff are picking items - * 2 = Packed — items are packed, ready to ship - * 3 = Shipped — goods have left the warehouse - * - * Rules: - * - Only allowed on orders with status >= 1 (confirmed) and not cancelled. - * - Fulfillment must move forward only (no going backwards). - * - If auto_complete_on_ship = true AND fulfillment_status = 3 (Shipped), - * order status is automatically advanced to 3 (Completed). - * - If auto_complete_on_ship = false, order status is set to 2 (Processing) - * on first fulfillment update, and stays there until manually completed. - * - * Must be called inside dbTransaction() by the caller. - * - * @param int $order_id td_order.id - * @param int $fulfillment_status 1=Picking, 2=Packed, 3=Shipped - * @param string $tracking_number Shipping carrier tracking number. - * @param array $logging Audit entry. - * @param bool $auto_complete Whether Shipped auto-drives order to Completed. - * @throws Exception + * Update shipping tracking number. Fulfillment itself is derived from + * linked stock-out rows and tracking, not selected manually. */ - public function updateFulfillmentStatus( - int $order_id, - int $fulfillment_status, + public function updateShippingTracking( + int $order_id, string $tracking_number, array $logging, - bool $auto_complete = true + bool $auto_complete = true ): void { // ── Load order ──────────────────────────────────────────────────── $sth = $this->pdo->prepare( @@ -718,64 +1014,37 @@ class OrderManager { throw new Exception("Order not found."); } - $status = (int)$order['status']; - $current_fulfillment = (int)($order['fulfillment_status'] ?? 0); - $current_tracking = trim((string)($order['shipping_tracking_number'] ?? '')); - $tracking_number = trim($tracking_number); + $status = (int)$order['status']; + $current_tracking = trim((string)($order['shipping_tracking_number'] ?? '')); + $tracking_number = trim($tracking_number); if ($status === -1) { throw new Exception("Cannot update a cancelled order."); } if ($status < 1) { - throw new Exception("Confirm the order before updating fulfillment."); + throw new Exception("Confirm the order before adding a tracking number."); } - if ($status === 3) { - throw new Exception("Order is already completed."); - } - if (!in_array($fulfillment_status, [1, 2, 3], true)) { - throw new Exception("Invalid fulfillment status."); - } - if ($fulfillment_status < $current_fulfillment) { - throw new Exception("Fulfillment status can only move forward."); - } - if ($fulfillment_status === $current_fulfillment && $tracking_number === $current_tracking) { - throw new Exception("No fulfillment or tracking changes to save."); - } - - // ── Determine new order status ──────────────────────────────────── - $new_order_status = $status; - - if ($fulfillment_status > $current_fulfillment && $fulfillment_status === 3 && $auto_complete) { - // Shipped + auto-complete → Completed - $new_order_status = 3; - } elseif ($fulfillment_status > $current_fulfillment && $status === 1) { - // First fulfillment update → Processing - $new_order_status = 2; + if ($tracking_number === $current_tracking) { + throw new Exception("No tracking changes to save."); } // ── Persist ─────────────────────────────────────────────────────── $log = json_decode($order['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, [ - 'action' => 'update_fulfillment', - 'fulfillment_status' => $fulfillment_status, + 'action' => 'update_tracking', 'shipping_tracking_number' => $tracking_number, - 'order_status' => $new_order_status, ]); $this->pdo->prepare( "UPDATE td_order SET - fulfillment_status = :fulfillment_status, shipping_tracking_number = :shipping_tracking_number, - status = :status, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ - ':fulfillment_status' => $fulfillment_status, ':shipping_tracking_number' => $tracking_number, - ':status' => $new_order_status, - ':log' => json_encode($log), - ':id' => $order_id, - ':company_id' => $this->company_id, + ':log' => json_encode($log), + ':id' => $order_id, + ':company_id' => $this->company_id, ]); } } diff --git a/app/assets/utils/classes/PaymentBillingManager.php b/app/assets/utils/classes/PaymentBillingManager.php new file mode 100644 index 0000000..50db7f8 --- /dev/null +++ b/app/assets/utils/classes/PaymentBillingManager.php @@ -0,0 +1,395 @@ +pdo = $pdo; + $this->company_id = $company_id; + } + + private function generateBillingNumber(): string + { + $prefix = 'PB-' . date('Ymd') . '-'; + $sth = $this->pdo->prepare( + "SELECT billing_number FROM td_payment_billing + WHERE company_id = :company_id + AND billing_number LIKE :prefix + ORDER BY billing_number DESC + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':prefix' => $prefix . '%', + ]); + $last = $sth->fetchColumn(); + $seq = $last ? ((int)substr($last, -4) + 1) : 1; + return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + } + + private function unavailableDocumentAmount(int $invoice_id): float + { + $sth = $this->pdo->prepare( + "SELECT COALESCE(billed.billed_amount, 0) + COALESCE(direct.paid_amount, 0) + FROM ( + SELECT :company_id AS company_id, :invoice_id AS invoice_id + ) doc + LEFT JOIN ( + SELECT ba.company_id, ba.invoice_id, SUM(ba.amount) AS billed_amount + FROM td_payment_billing_item ba + JOIN td_payment_billing b + ON b.company_id = ba.company_id + AND b.id = ba.billing_id + WHERE ba.company_id = :company_id_billed + AND ba.invoice_id = :invoice_id_billed + AND b.status IN (1, 2, 3) + GROUP BY ba.company_id, ba.invoice_id + ) billed + ON billed.company_id = doc.company_id + AND billed.invoice_id = doc.invoice_id + LEFT JOIN ( + SELECT ra.company_id, ra.invoice_id, SUM(ra.amount) AS paid_amount + FROM td_payment_item ra + JOIN td_payment r + ON r.company_id = ra.company_id + AND r.id = ra.payment_id + WHERE ra.company_id = :company_id_direct + AND ra.invoice_id = :invoice_id_direct + AND r.status = 1 + AND r.payment_billing_id = 0 + GROUP BY ra.company_id, ra.invoice_id + ) direct + ON direct.company_id = doc.company_id + AND direct.invoice_id = doc.invoice_id" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':invoice_id' => $invoice_id, + ':company_id_billed' => $this->company_id, + ':invoice_id_billed' => $invoice_id, + ':company_id_direct' => $this->company_id, + ':invoice_id_direct' => $invoice_id, + ]); + return (float)$sth->fetchColumn(); + } + + public function getBillableDocuments(int $contact_id = 0): array + { + $where = [ + "i.company_id = :company_id", + "i.doc_type IN ('purchase_invoice', 'supplier_credit_note')", + "i.status = 1" + ]; + $params = [':company_id' => $this->company_id]; + + $where[] = 'i.contact_id = :contact_id'; + $params[':contact_id'] = $contact_id; + + $sth = $this->pdo->prepare( + "SELECT i.*, + COALESCE(c.contact_name, '') AS contact_name, + po.po_number AS order_number, + COALESCE(payment.paid_amount, 0) AS paid_amount, + COALESCE(billed.billed_amount, 0) AS billed_amount + FROM td_invoice i + LEFT JOIN md_contact c + ON c.company_id = i.company_id + AND c.id = i.contact_id + LEFT JOIN td_purchase_order po + ON po.company_id = i.company_id + AND po.id = i.source_id + AND i.source = 'po' + LEFT JOIN ( + SELECT ra.company_id, ra.invoice_id, SUM(ra.amount) AS paid_amount + FROM td_payment_item ra + JOIN td_payment r + ON r.company_id = ra.company_id + AND r.id = ra.payment_id + WHERE r.status = 1 + AND r.payment_billing_id = 0 + GROUP BY ra.company_id, ra.invoice_id + ) payment + ON payment.company_id = i.company_id + AND payment.invoice_id = i.id + LEFT JOIN ( + SELECT ba.company_id, ba.invoice_id, SUM(ba.amount) AS billed_amount + FROM td_payment_billing_item ba + JOIN td_payment_billing b + ON b.company_id = ba.company_id + AND b.id = ba.billing_id + WHERE b.status IN (1, 2, 3) + GROUP BY ba.company_id, ba.invoice_id + ) billed + ON billed.company_id = i.company_id + AND billed.invoice_id = i.id + WHERE " . implode(' AND ', $where) . " + ORDER BY i.issued_date ASC, i.id ASC" + ); + $sth->execute($params); + + $open = []; + foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { + $total = abs((float)$row['grand_total']); + $used = (float)$row['paid_amount'] + (float)$row['billed_amount']; + $balance = max(0, $total - $used); + if ($balance <= 0.0001) continue; + $row['document_total'] = $total; + $row['balance_amount'] = $balance; + $open[] = $row; + } + + return $open; + } + + public function getBillingList(): array + { + $sth = $this->pdo->prepare( + "SELECT b.*, + COALESCE(c.contact_name, '') AS contact_name, + COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS paid_amount + FROM td_payment_billing b + LEFT JOIN md_contact c + ON c.company_id = b.company_id + AND c.id = b.contact_id + LEFT JOIN td_payment r + ON r.company_id = b.company_id + AND r.payment_billing_id = b.id + WHERE b.company_id = :company_id + GROUP BY b.id + ORDER BY b.id DESC" + ); + $sth->execute([':company_id' => $this->company_id]); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + + foreach ($rows as &$row) { + $row['balance_amount'] = max(0, (float)$row['amount'] - (float)$row['paid_amount']); + } + return $rows; + } + + public function getOpenBillingList(int $contact_id = 0): array + { + $rows = $this->getBillingList(); + return array_values(array_filter($rows, function($row) use ($contact_id) { + if ((int)$row['status'] === 4) return false; + if ((int)$row['contact_id'] !== $contact_id) return false; + return (float)$row['balance_amount'] > 0.0001; + })); + } + + public function getBillingById(int $billing_id): array + { + $sth = $this->pdo->prepare( + "SELECT b.*, + COALESCE(c.contact_name, '') AS contact_name, + COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS paid_amount + FROM td_payment_billing b + LEFT JOIN md_contact c + ON c.company_id = b.company_id + AND c.id = b.contact_id + LEFT JOIN td_payment r + ON r.company_id = b.company_id + AND r.payment_billing_id = b.id + WHERE b.company_id = :company_id + AND b.id = :id + GROUP BY b.id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $billing_id]); + $billing = $sth->fetch(PDO::FETCH_ASSOC); + if (!$billing) throw new Exception("Payment billing not found."); + + $sth = $this->pdo->prepare( + "SELECT a.*, + i.invoice_number, + i.order_id, + i.department_id, + i.grand_total, + i.status AS invoice_status, + COALESCE(SUM(CASE WHEN r.status = 1 THEN ra.amount ELSE 0 END), 0) AS paid_line_amount, + po.po_number AS order_number + FROM td_payment_billing_item a + JOIN td_invoice i + ON i.company_id = a.company_id + AND i.id = a.invoice_id + LEFT JOIN td_payment_item ra + ON ra.company_id = a.company_id + AND ra.invoice_id = a.invoice_id + LEFT JOIN td_payment r + ON r.company_id = ra.company_id + AND r.id = ra.payment_id + AND r.payment_billing_id = a.billing_id + LEFT JOIN td_purchase_order po + ON po.company_id = i.company_id + AND po.id = i.source_id + AND i.source = 'po' + WHERE a.company_id = :company_id + AND a.billing_id = :billing_id + GROUP BY a.id + ORDER BY a.id ASC" + ); + $sth->execute([':company_id' => $this->company_id, ':billing_id' => $billing_id]); + $allocations = $sth->fetchAll(PDO::FETCH_ASSOC); + foreach ($allocations as &$line) { + $line['balance_amount'] = max(0, (float)$line['amount'] - (float)$line['paid_line_amount']); + } + $billing['allocations'] = $allocations; + $billing['balance_amount'] = max(0, (float)$billing['amount'] - (float)$billing['paid_amount']); + + return $billing; + } + + public function refreshBillingStatus(int $billing_id): void + { + $billing = $this->getBillingById($billing_id); + if ((int)$billing['status'] === 4) return; + if ((float)$billing['balance_amount'] <= 0.0001) { + $new_status = 2; + } elseif ((float)$billing['paid_amount'] > 0.0001) { + $new_status = 3; + } else { + $new_status = 1; + } + $this->pdo->prepare( + "UPDATE td_payment_billing + SET status = :status + WHERE company_id = :company_id + AND id = :id" + )->execute([ + ':status' => $new_status, + ':company_id' => $this->company_id, + ':id' => $billing_id, + ]); + } + + public function createBilling(array $data, array $logging): int + { + $contact_id = (int)($data['contact_id'] ?? 0); + $billing_date = $data['billing_date'] ?? date('Y-m-d'); + $notes = trim((string)($data['notes'] ?? '')); + $allocations = $data['allocations'] ?? []; + + if (is_string($allocations)) { + $allocations = json_decode($allocations, true) ?: []; + } + + if ($contact_id <= 0) throw new Exception("Supplier is required."); + if (!$allocations) throw new Exception("Payment billing requires at least one document."); + + $validated = []; + $purchase_invoice_total = 0.0; + $credit_total = 0.0; + + foreach ($allocations as $line) { + $invoice_id = (int)($line['invoice_id'] ?? 0); + $amount = (float)($line['amount'] ?? 0); + if ($invoice_id <= 0 || $amount <= 0) continue; + + $sth = $this->pdo->prepare( + "SELECT id, doc_type, invoice_number, contact_id, department_id, grand_total, status + FROM td_invoice + WHERE company_id = :company_id AND id = :id + FOR UPDATE" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $invoice_id]); + $doc = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$doc) throw new Exception("Document not found."); + if ((int)$doc['contact_id'] !== $contact_id) { + throw new Exception("All billing documents must belong to the selected contact."); + } + if (!in_array($doc['doc_type'], ['purchase_invoice', 'supplier_credit_note'], true)) { + throw new Exception("Payment billing can only include purchase invoices and supplier credit notes."); + } + if ((int)$doc['status'] !== 1) { + throw new Exception($doc['invoice_number'] . " is not open for billing."); + } + + $balance = abs((float)$doc['grand_total']) - $this->unavailableDocumentAmount($invoice_id); + if ($amount - $balance > 0.0001) { + throw new Exception($doc['invoice_number'] . " allocation exceeds remaining billable balance."); + } + + if ($doc['doc_type'] === 'supplier_credit_note') $credit_total += $amount; + else $purchase_invoice_total += $amount; + + $validated[] = [ + 'invoice_id' => $invoice_id, + 'doc_type' => $doc['doc_type'], + 'department_id' => (int)($doc['department_id'] ?? 0), + 'amount' => round($amount, 4), + ]; + } + + if (!$validated) throw new Exception("Payment billing requires at least one valid document."); + $net_amount = round($purchase_invoice_total - $credit_total, 4); + if ($net_amount <= 0.0001) { + throw new Exception("Payment billing amount must be greater than zero."); + } + + $log = [array_merge($logging, ['action' => 'create_payment_billing'])]; + $this->pdo->prepare( + "INSERT INTO td_payment_billing + (company_id, uuid, billing_number, contact_id, billing_date, + amount, status, notes, `log`, created_at) + VALUES + (:company_id, :uuid, :billing_number, :contact_id, :billing_date, + :amount, 1, :notes, :log, :created_at)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':billing_number' => $this->generateBillingNumber(), + ':contact_id' => $contact_id, + ':billing_date' => $billing_date, + ':amount' => $net_amount, + ':notes' => $notes, + ':log' => json_encode($log), + ':created_at' => date('Y-m-d H:i:s'), + ]); + + $billing_id = (int)$this->pdo->lastInsertId(); + + foreach ($validated as $line) { + $this->pdo->prepare( + "INSERT INTO td_payment_billing_item + (company_id, billing_id, invoice_id, doc_type, amount) + VALUES + (:company_id, :billing_id, :invoice_id, :doc_type, :amount)" + )->execute([ + ':company_id' => $this->company_id, + ':billing_id' => $billing_id, + ':invoice_id' => $line['invoice_id'], + ':doc_type' => $line['doc_type'], + ':amount' => $line['amount'], + ]); + } + + return $billing_id; + } + + public function voidBilling(int $billing_id, array $logging): void + { + $billing = $this->getBillingById($billing_id); + if ((int)$billing['status'] === 4) throw new Exception("Payment billing is already void."); + if ((float)$billing['paid_amount'] > 0.0001) { + throw new Exception("Cannot void a payment billing with posted payments."); + } + + $log = json_decode($billing['log'] ?: '[]', true); + if (!is_array($log)) $log = []; + $log[] = array_merge($logging, ['action' => 'void_payment_billing']); + + $this->pdo->prepare( + "UPDATE td_payment_billing + SET status = 4, `log` = :log + WHERE company_id = :company_id + AND id = :id" + )->execute([ + ':log' => json_encode($log), + ':company_id' => $this->company_id, + ':id' => $billing_id, + ]); + } +} +?> diff --git a/app/assets/utils/classes/PaymentManager.php b/app/assets/utils/classes/PaymentManager.php new file mode 100644 index 0000000..e6d2e13 --- /dev/null +++ b/app/assets/utils/classes/PaymentManager.php @@ -0,0 +1,517 @@ +pdo = $pdo; + $this->company_id = $company_id; + } + + private function generatePaymentNumber(): string + { + $prefix = 'PY-' . date('Ymd') . '-'; + $sth = $this->pdo->prepare( + "SELECT payment_number FROM td_payment + WHERE company_id = :company_id + AND payment_number LIKE :prefix + ORDER BY payment_number DESC + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':prefix' => $prefix . '%', + ]); + $last = $sth->fetchColumn(); + $seq = $last ? ((int)substr($last, -4) + 1) : 1; + return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + } + + private function assertPostingWindow(?string $date, string $context): void + { + global $pdo1; + + if (!isset($pdo1) || !($pdo1 instanceof PDO)) { + throw new Exception("Posting-window validation is unavailable."); + } + + $guard = new PostingWindowGuard($pdo1, $this->company_id); + $guard->assertOpenDate($date ?: date('Y-m-d'), $context); + } + + private function allocatedAmount(int $invoice_id): float + { + $sth = $this->pdo->prepare( + "SELECT COALESCE(SUM(a.amount), 0) + FROM td_payment_item a + JOIN td_payment r + ON r.company_id = a.company_id + AND r.id = a.payment_id + WHERE a.company_id = :company_id + AND a.invoice_id = :invoice_id + AND r.status = 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':invoice_id' => $invoice_id, + ]); + return (float)$sth->fetchColumn(); + } + + private function refreshInvoiceSettlementStatus(int $invoice_id): void + { + $sth = $this->pdo->prepare( + "SELECT grand_total, status FROM td_invoice + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $invoice_id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + if (!$row || (int)$row['status'] === 4 || (int)$row['status'] === 0) return; + + $target = abs((float)$row['grand_total']); + $paid = $this->allocatedAmount($invoice_id); + $new_status = ($target > 0 && $paid + 0.0001 >= $target) ? 2 : 1; + + $this->pdo->prepare( + "UPDATE td_invoice SET status = :status + WHERE company_id = :company_id AND id = :id" + )->execute([ + ':status' => $new_status, + ':company_id' => $this->company_id, + ':id' => $invoice_id, + ]); + } + + private function refreshPaymentBillingStatus(int $billing_id): void + { + if ($billing_id <= 0) return; + + $sth = $this->pdo->prepare( + "SELECT b.amount, b.status, + COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS paid_amount + FROM td_payment_billing b + LEFT JOIN td_payment r + ON r.company_id = b.company_id + AND r.payment_billing_id = b.id + WHERE b.company_id = :company_id + AND b.id = :id + GROUP BY b.id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $billing_id]); + $billing = $sth->fetch(PDO::FETCH_ASSOC); + if (!$billing || (int)$billing['status'] === 4) return; + + $amount = (float)$billing['amount']; + $payment = (float)$billing['paid_amount']; + if ($payment + 0.0001 >= $amount) { + $new_status = 2; + } elseif ($payment > 0.0001) { + $new_status = 3; + } else { + $new_status = 1; + } + $this->pdo->prepare( + "UPDATE td_payment_billing + SET status = :status + WHERE company_id = :company_id + AND id = :id" + )->execute([ + ':status' => $new_status, + ':company_id' => $this->company_id, + ':id' => $billing_id, + ]); + } + + public function getOpenDocuments(int $contact_id = 0): array + { + $where = [ + "i.company_id = :company_id", + "i.doc_type IN ('purchase_invoice', 'supplier_credit_note')", + "i.status IN (1, 2)" + ]; + $params = [':company_id' => $this->company_id]; + + if ($contact_id > 0) { + $where[] = 'i.contact_id = :contact_id'; + $params[':contact_id'] = $contact_id; + } + + $sth = $this->pdo->prepare( + "SELECT i.*, + COALESCE(c.contact_name, '') AS contact_name, + po.po_number AS order_number, + COALESCE(SUM(CASE WHEN r.status = 1 THEN a.amount ELSE 0 END), 0) AS allocated_amount + FROM td_invoice i + LEFT JOIN md_contact c + ON c.company_id = i.company_id + AND c.id = i.contact_id + LEFT JOIN td_purchase_order po + ON po.company_id = i.company_id + AND po.id = i.source_id + AND i.source = 'po' + LEFT JOIN td_payment_item a + ON a.company_id = i.company_id + AND a.invoice_id = i.id + LEFT JOIN td_payment r + ON r.company_id = a.company_id + AND r.id = a.payment_id + WHERE " . implode(' AND ', $where) . " + GROUP BY i.id + ORDER BY i.issued_date ASC, i.id ASC" + ); + $sth->execute($params); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + + $open = []; + foreach ($rows as $row) { + $total = abs((float)$row['grand_total']); + $allocated = (float)$row['allocated_amount']; + $balance = max(0, $total - $allocated); + if ($balance <= 0.0001) continue; + $row['document_total'] = $total; + $row['balance_amount'] = $balance; + $open[] = $row; + } + + return $open; + } + + public function getPaymentList(): array + { + $sth = $this->pdo->prepare( + "SELECT r.*, + COALESCE(c.contact_name, '') AS contact_name, + COALESCE(b.billing_number, '') AS billing_number + FROM td_payment r + LEFT JOIN md_contact c + ON c.company_id = r.company_id + AND c.id = r.contact_id + LEFT JOIN td_payment_billing b + ON b.company_id = r.company_id + AND b.id = r.payment_billing_id + WHERE r.company_id = :company_id + ORDER BY r.id DESC" + ); + $sth->execute([':company_id' => $this->company_id]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + public function getPaymentById(int $payment_id): array + { + $sth = $this->pdo->prepare( + "SELECT r.*, + COALESCE(c.contact_name, '') AS contact_name, + COALESCE(b.billing_number, '') AS billing_number + FROM td_payment r + LEFT JOIN md_contact c + ON c.company_id = r.company_id + AND c.id = r.contact_id + LEFT JOIN td_payment_billing b + ON b.company_id = r.company_id + AND b.id = r.payment_billing_id + WHERE r.company_id = :company_id + AND r.id = :id" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':id' => $payment_id, + ]); + $payment = $sth->fetch(PDO::FETCH_ASSOC); + if (!$payment) throw new Exception("Payment not found."); + + $sth = $this->pdo->prepare( + "SELECT a.*, + i.invoice_number, + i.order_id, + i.grand_total, + i.status AS invoice_status, + po.po_number AS order_number + FROM td_payment_item a + JOIN td_invoice i + ON i.company_id = a.company_id + AND i.id = a.invoice_id + LEFT JOIN td_purchase_order po + ON po.company_id = i.company_id + AND po.id = i.source_id + AND i.source = 'po' + WHERE a.company_id = :company_id + AND a.payment_id = :payment_id + ORDER BY a.id ASC" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':payment_id' => $payment_id, + ]); + + $payment['allocations'] = $sth->fetchAll(PDO::FETCH_ASSOC); + return $payment; + } + + public function voidPayment(int $payment_id, array $logging): void + { + $sth = $this->pdo->prepare( + "SELECT id, payment_billing_id, payment_date, status, `log` + FROM td_payment + WHERE company_id = :company_id + AND id = :id + FOR UPDATE" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':id' => $payment_id, + ]); + $payment = $sth->fetch(PDO::FETCH_ASSOC); + if (!$payment) throw new Exception("Payment not found."); + if ((int)$payment['status'] === 4) throw new Exception("Payment is already void."); + $this->assertPostingWindow($payment['payment_date'] ?: date('Y-m-d'), 'Payment'); + + $log = json_decode($payment['log'] ?: '[]', true); + if (!is_array($log)) $log = []; + $log[] = array_merge($logging, ['action' => 'void_payment']); + + $this->pdo->prepare( + "UPDATE td_payment + SET status = 4, `log` = :log + WHERE company_id = :company_id + AND id = :id" + )->execute([ + ':log' => json_encode($log), + ':company_id' => $this->company_id, + ':id' => $payment_id, + ]); + + global $pdo1; + $guard = new PostingWindowGuard($pdo1, $this->company_id); + $gl = new GlManager($this->pdo, $this->company_id, $guard); + $gl->delete('payment', $payment_id); + + $sth = $this->pdo->prepare( + "SELECT invoice_id + FROM td_payment_item + WHERE company_id = :company_id + AND payment_id = :payment_id" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':payment_id' => $payment_id, + ]); + foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $invoice_id) { + $this->refreshInvoiceSettlementStatus((int)$invoice_id); + } + $this->refreshPaymentBillingStatus((int)$payment['payment_billing_id']); + } + + public function createPayment(array $data, array $logging): int + { + $billing_id = (int)($data['payment_billing_id'] ?? $data['billing_id'] ?? 0); + $payment_date = $data['payment_date'] ?? date('Y-m-d'); + $this->assertPostingWindow($payment_date, 'Payment'); + $payment_method = trim((string)($data['payment_method'] ?? '')); + $notes = trim((string)($data['notes'] ?? '')); + $cash_amount = round((float)($data['amount'] ?? 0), 4); + $allocations = $data['allocations'] ?? []; + + if (is_string($allocations)) { + $allocations = json_decode($allocations, true) ?: []; + } + + if ($billing_id <= 0) throw new Exception("Payment billing is required."); + + $sth = $this->pdo->prepare( + "SELECT b.*, + COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS paid_amount + FROM td_payment_billing b + LEFT JOIN td_payment r + ON r.company_id = b.company_id + AND r.payment_billing_id = b.id + WHERE b.company_id = :company_id + AND b.id = :id + GROUP BY b.id + FOR UPDATE" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $billing_id]); + $billing = $sth->fetch(PDO::FETCH_ASSOC); + if (!$billing) throw new Exception("Payment billing not found."); + if ((int)$billing['status'] === 4) throw new Exception("Cannot pay against a void payment billing."); + + $contact_id = (int)$billing['contact_id']; + $billing_balance = round((float)$billing['amount'] - (float)$billing['paid_amount'], 4); + if ($billing_balance <= 0.0001) throw new Exception("Payment billing is already settled."); + if ($cash_amount - $billing_balance > 0.0001) { + throw new Exception("Payment amount exceeds payment billing balance."); + } + + $sth = $this->pdo->prepare( + "SELECT ba.invoice_id, ba.doc_type, COALESCE(i.department_id, 0) AS department_id, ba.amount, + COALESCE(SUM(CASE WHEN r.status = 1 THEN ra.amount ELSE 0 END), 0) AS paid_line_amount + FROM td_payment_billing_item ba + JOIN td_invoice i + ON i.company_id = ba.company_id + AND i.id = ba.invoice_id + LEFT JOIN td_payment_item ra + ON ra.company_id = ba.company_id + AND ra.invoice_id = ba.invoice_id + LEFT JOIN td_payment r + ON r.company_id = ra.company_id + AND r.id = ra.payment_id + AND r.payment_billing_id = ba.billing_id + WHERE ba.company_id = :company_id + AND ba.billing_id = :billing_id + GROUP BY ba.id + ORDER BY ba.id ASC" + ); + $sth->execute([':company_id' => $this->company_id, ':billing_id' => $billing_id]); + $billing_lines = $sth->fetchAll(PDO::FETCH_ASSOC); + if (!$billing_lines) throw new Exception("Payment billing has no documents."); + + $validated = []; + $billing_line_map = []; + foreach ($billing_lines as $line) { + $billing_line_map[(int)$line['invoice_id']] = [ + 'invoice_id' => (int)$line['invoice_id'], + 'doc_type' => $line['doc_type'], + 'department_id' => (int)($line['department_id'] ?? 0), + 'remaining' => max(0, (float)$line['amount'] - (float)$line['paid_line_amount']), + ]; + } + + if ($allocations) { + $purchase_invoice_total = 0.0; + $credit_total = 0.0; + + foreach ($allocations as $line) { + $invoice_id = (int)($line['invoice_id'] ?? 0); + $amount = abs((float)($line['amount'] ?? 0)); + if ($invoice_id <= 0 || $amount <= 0) continue; + if (!isset($billing_line_map[$invoice_id])) { + throw new Exception("Payment allocation document is not part of this billing."); + } + + $billing_line = $billing_line_map[$invoice_id]; + if ($amount - $billing_line['remaining'] > 0.0001) { + throw new Exception("Payment allocation exceeds billing line balance."); + } + + if ($billing_line['doc_type'] === 'supplier_credit_note') $credit_total += $amount; + else $purchase_invoice_total += $amount; + + $validated[] = [ + 'invoice_id' => $invoice_id, + 'doc_type' => $billing_line['doc_type'], + 'department_id' => (int)($line['department_id'] ?? $billing_line['department_id'] ?? 0), + 'amount' => round($amount, 4), + ]; + } + + $cash_amount = round($purchase_invoice_total - $credit_total, 4); + if ($cash_amount <= 0) throw new Exception("Cash payment amount must be greater than zero."); + if ($cash_amount - $billing_balance > 0.0001) { + throw new Exception("Payment amount exceeds payment billing balance."); + } + } else { + if ($cash_amount <= 0) throw new Exception("Payment amount is required."); + + $ratio = $cash_amount / max($billing_balance, 0.0001); + foreach ($billing_lines as $line) { + $remaining_line_amount = max(0, (float)$line['amount'] - (float)$line['paid_line_amount']); + if ($remaining_line_amount <= 0.0001) continue; + + $line_amount = round($remaining_line_amount * $ratio, 4); + if ($line_amount <= 0) continue; + if ($line_amount - $remaining_line_amount > 0.0001) $line_amount = $remaining_line_amount; + + $validated[] = [ + 'invoice_id' => (int)$line['invoice_id'], + 'doc_type' => $line['doc_type'], + 'department_id' => (int)($line['department_id'] ?? 0), + 'amount' => $line_amount, + ]; + } + } + + if (!$validated) throw new Exception("Payment billing has no remaining document balances."); + + $formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0 + ? (int)$data['formula_id'] : null; + + $log = [array_merge($logging, ['action' => 'create_payment'])]; + + $this->pdo->prepare( + "INSERT INTO td_payment + (company_id, uuid, payment_number, contact_id, department_id, payment_billing_id, payment_date, + payment_method, amount, formula_id, status, notes, `log`, created_at) + VALUES + (:company_id, :uuid, :payment_number, :contact_id, :department_id, :payment_billing_id, :payment_date, + :payment_method, :amount, :formula_id, 1, :notes, :log, :created_at)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':payment_number' => $this->generatePaymentNumber(), + ':contact_id' => $contact_id, + ':department_id' => (int)($data['department_id'] ?? 0), + ':payment_billing_id' => $billing_id, + ':payment_date' => $payment_date, + ':payment_method' => $payment_method, + ':amount' => $cash_amount, + ':formula_id' => $formula_id, + ':notes' => $notes, + ':log' => json_encode($log), + ':created_at' => date('Y-m-d H:i:s'), + ]); + + $payment_id = (int)$this->pdo->lastInsertId(); + + foreach ($validated as $line) { + $this->pdo->prepare( + "INSERT INTO td_payment_item + (company_id, payment_id, invoice_id, doc_type, department_id, amount) + VALUES + (:company_id, :payment_id, :invoice_id, :doc_type, :department_id, :amount)" + )->execute([ + ':company_id' => $this->company_id, + ':payment_id' => $payment_id, + ':invoice_id' => $line['invoice_id'], + ':doc_type' => $line['doc_type'], + ':department_id' => (int)($line['department_id'] ?? 0), + ':amount' => $line['amount'], + ]); + + $this->refreshInvoiceSettlementStatus($line['invoice_id']); + } + + $this->refreshPaymentBillingStatus($billing_id); + + return $payment_id; + } + + public function saveFormula(int $id, ?int $formula_id): void + { + $sth = $this->pdo->prepare( + "SELECT payment_date, status FROM td_payment + WHERE id = :id AND company_id = :company_id" + ); + $sth->execute([ + ':id' => $id, + ':company_id' => $this->company_id, + ]); + $payment = $sth->fetch(PDO::FETCH_ASSOC); + if (!$payment) throw new Exception("Payment not found."); + if ((int)$payment['status'] === 4) throw new Exception("Cannot update a void payment."); + $this->assertPostingWindow($payment['payment_date'] ?: date('Y-m-d'), 'Payment'); + + $sth = $this->pdo->prepare( + "UPDATE td_payment SET formula_id = :formula_id + WHERE id = :id AND company_id = :company_id AND status != 4" + ); + $sth->execute([ + ':formula_id' => $formula_id, + ':id' => $id, + ':company_id' => $this->company_id, + ]); + } +} +?> diff --git a/app/assets/utils/classes/ProductManager.php b/app/assets/utils/classes/ProductManager.php index 9550b6c..ff7f8e2 100644 --- a/app/assets/utils/classes/ProductManager.php +++ b/app/assets/utils/classes/ProductManager.php @@ -303,6 +303,16 @@ class ProductManager { return $sth->fetch(PDO::FETCH_ASSOC); } + public function getProductImage(int $id): string + { + $sth = $this->pdo->prepare( + "SELECT product_image FROM md_product + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + return (string)($sth->fetchColumn() ?: ''); + } + /** * Search products by SKU keyword — for live autocomplete on stock forms. * @@ -368,6 +378,8 @@ class ProductManager { ':category' => $data['category'], ':product_image' => $product_image, ':description' => $data['description'], + ':sales_account_code' => trim((string)($data['sales_account_code'] ?? '')) ?: null, + ':purchase_account_code' => trim((string)($data['purchase_account_code'] ?? '')) ?: null, ':status' => (int)$data['status'], ':log' => json_encode($table_log), ]; @@ -386,6 +398,8 @@ class ProductManager { reorder_point = :reorder_point, category = :category, product_image = :product_image, + sales_account_code = :sales_account_code, + purchase_account_code = :purchase_account_code, `description` = :description, `status` = :status, `log` = :log @@ -395,14 +409,52 @@ class ProductManager { $this->pdo->prepare( "INSERT INTO md_product (company_id, product_name, sku, barcode, uom, price, cost_price, min_stock, reorder_point, - category, product_image, `description`, `status`, `log`) + category, product_image, sales_account_code, purchase_account_code, `description`, `status`, `log`) VALUES (:company_id, :product_name, :sku, :barcode, :uom, :price, :cost_price, :min_stock, :reorder_point, - :category, :product_image, :description, :status, :log)" + :category, :product_image, :sales_account_code, :purchase_account_code, :description, :status, :log)" )->execute($params); } } + /** + * Update only the accounting account mapping fields for a product. + * + * This is used by Accounting so product master data can stay locked while + * finance users maintain GL routing for SKU-based posting. + */ + public function updateAccountMapping(int $id, ?string $sales_account_code, ?string $purchase_account_code, array $logging): void + { + $sth = $this->pdo->prepare( + "SELECT `log` FROM md_product + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $existing_log = $sth->fetchColumn(); + + if ($existing_log === false) { + throw new Exception('Product not found.'); + } + + $table_log = json_decode($existing_log ?: '[]', true) ?: []; + $table_log[] = $logging; + + $sth = $this->pdo->prepare( + "UPDATE md_product SET + sales_account_code = :sales_account_code, + purchase_account_code = :purchase_account_code, + `log` = :log + WHERE id = :id AND company_id = :company_id" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':id' => $id, + ':sales_account_code' => $sales_account_code, + ':purchase_account_code' => $purchase_account_code, + ':log' => json_encode($table_log), + ]); + } + /** * Soft-delete a product by negating its company_id. * diff --git a/app/assets/utils/classes/PurchaseOrderManager.php b/app/assets/utils/classes/PurchaseOrderManager.php index 37f0306..e62db71 100644 --- a/app/assets/utils/classes/PurchaseOrderManager.php +++ b/app/assets/utils/classes/PurchaseOrderManager.php @@ -18,8 +18,10 @@ * in the existing Stock In list automatically under source='po'. * - stock_in_id is written back into the items JSON after each receive call, * same pattern as stock_out_id in OrderManager::confirmOrder(). - * - Partial receipt is supported: receivePo() can be called multiple times - * until all items are fully received, advancing status 1→2 (partial) or 1/2→3 (completed). + * - Partial receipt is supported: receivePo() can be called multiple times. + * - PO stored statuses: -2 (pending warehouse), -1 (cancelled), 0 (draft), 1 (confirmed). + * Receipt progress (pending/partial/received) is derived via deriveReceiptStatus(), + * same pattern as fulfillment_status on SO. Never stored in td_purchase_order. * - cancelPo() is blocked if any linked stock-in rows have been approved (status=1), * because those have already modified rack and balance. * - Write methods do NOT manage their own DB transactions. @@ -78,6 +80,77 @@ class PurchaseOrderManager { return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); } + /** + * Derive receipt status from linked stock-in rows — never stored in DB. + * + * 0 = no stock-in rows linked yet + * 1 = pending (stock-in rows exist but none approved) + * 2 = partial (some approved, quantities not fully received) + * 3 = received (all approved, all quantities received) + */ + private function deriveReceiptStatus(array $po): int + { + if ((int)($po['status'] ?? 0) < 1) return 0; + + $po_id = (int)($po['id'] ?? 0); + + // Fetch items from normalized table + $sth = $this->pdo->prepare( + "SELECT item_id, quantity, received_qty, stock_in_id FROM td_purchase_order_item + WHERE order_id = :po_id AND company_id = :company_id" + ); + $sth->execute([':po_id' => $po_id, ':company_id' => $this->company_id]); + $items = $sth->fetchAll(PDO::FETCH_ASSOC); + + $has_any_stock_in = false; + foreach ($items as $item) { + if ((int)($item['stock_in_id'] ?? 0) > 0) { + $has_any_stock_in = true; + break; + } + } + if (!$has_any_stock_in) return 0; + + $sth = $this->pdo->prepare( + "SELECT table_name FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name LIKE 'td_stock_%'" + ); + $sth->execute(); + $tables = $sth->fetchAll(PDO::FETCH_COLUMN); + + $total = 0; + $pending = 0; + + foreach ($tables as $table) { + $sth = $this->pdo->prepare( + "SELECT status FROM `{$table}` + WHERE company_id = :company_id + AND source = 'po' + AND source_id = :po_id + AND type = 'in' + AND status != -1" + ); + $sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]); + foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $s) { + $total++; + if ((int)$s === 0) $pending++; + } + } + + if ($total === 0) return 0; + if ($pending > 0) return 1; + + $all_received = true; + foreach ($items as $item) { + if ((float)($item['received_qty'] ?? 0) < (float)($item['quantity'] ?? 0)) { + $all_received = false; + break; + } + } + return $all_received ? 3 : 2; + } + private function stockTableNameFromWarehouseId(int $warehouse_id): string { if ($warehouse_id <= 0) { @@ -87,6 +160,38 @@ class PurchaseOrderManager { return 'td_stock_' . $warehouse_id; } + private function syncPoItems(int $po_id, array $items): void + { + $this->pdo->prepare( + "DELETE FROM td_purchase_order_item WHERE order_id = :po_id AND company_id = :company_id" + )->execute([':po_id' => $po_id, ':company_id' => $this->company_id]); + + $sth = $this->pdo->prepare( + "INSERT INTO td_purchase_order_item + (company_id, order_id, item_id, product_sku, product_name, + quantity, unit_price, total_price, tax_amount, tax_rate, received_qty, stock_in_id, invoiced_qty) + VALUES + (:company_id, :order_id, :item_id, :product_sku, :product_name, + :quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :received_qty, :stock_in_id, 0)" + ); + foreach ($items as $pos => $item) { + $sth->execute([ + ':company_id' => $this->company_id, + ':order_id' => $po_id, + ':item_id' => $pos + 1, + ':product_sku' => $item['product_sku'] ?? '', + ':product_name' => $item['product_name'] ?? '', + ':quantity' => (float)($item['quantity'] ?? 0), + ':unit_price' => (float)($item['unit_price'] ?? 0), + ':total_price' => (float)($item['total_price'] ?? 0), + ':tax_amount' => (float)($item['tax_amount'] ?? 0), + ':tax_rate' => (float)($item['tax_rate'] ?? 0), + ':received_qty' => (float)($item['received_qty'] ?? 0), + ':stock_in_id' => (int)($item['stock_in_id'] ?? 0), + ]); + } + } + // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Read // ───────────────────────────────────────────────────────────── @@ -108,7 +213,11 @@ class PurchaseOrderManager { ORDER BY p.created_at DESC" ); $sth->execute([':company_id' => $this->company_id]); - return $sth->fetchAll(PDO::FETCH_ASSOC); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as &$row) { + $row['receipt_status'] = $this->deriveReceiptStatus($row); + } + return $rows; } /** @@ -132,19 +241,86 @@ class PurchaseOrderManager { if (!$row) return false; - $row['items'] = json_decode($row['items'] ?? '[]', true) ?: []; + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_purchase_order_item + WHERE order_id = :order_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':order_id' => $id, ':company_id' => $this->company_id]); + $row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC); + + $row['receipt_status'] = $this->deriveReceiptStatus($row); return $row; } + public function getReceivableItems(array $po): array + { + $receivable = []; + foreach (($po['items'] ?? []) as $i => $item) { + $ordered = (float)($item['quantity'] ?? 0); + $received = (float)($item['received_qty'] ?? 0); + $remaining = $ordered - $received; + if ($remaining > 0) { + $receivable[] = array_merge($item, [ + 'item_id' => (int)($item['item_id'] ?? $i), + 'ordered_qty' => $ordered, + 'received_qty' => $received, + 'remaining_qty' => $remaining, + ]); + } + } + + return $receivable; + } + + public function getReturnableItems(array $po): array + { + $returnable = []; + foreach (($po['items'] ?? []) as $i => $item) { + $received = (float)($item['received_qty'] ?? 0); + if ($received > 0) { + $ordered = (float)($item['quantity'] ?? 0); + $returnable[] = array_merge($item, [ + 'item_id' => (int)($item['item_id'] ?? $i), + 'ordered_qty' => $ordered, + 'received_qty' => $received, + 'returnable_qty' => $received, + 'warehouse_id' => (int)($item['receive_wh'] ?? 0), + 'zone' => $item['receive_zone'] ?? '', + 'aisle' => $item['receive_aisle'] ?? '', + 'rack' => $item['receive_rack'] ?? '', + ]); + } + } + + return $returnable; + } + + public function getActivePurchaseInvoiceId(int $po_id): int + { + $sth = $this->pdo->prepare( + "SELECT id FROM td_invoice + WHERE company_id = :company_id + AND source = 'po' + AND source_id = :po_id + AND doc_type = 'purchase_invoice' + AND status != 4 + LIMIT 1" + ); + $sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]); + return (int)$sth->fetchColumn(); + } + // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Write // ───────────────────────────────────────────────────────────── /** - * Insert a new PO (draft) or update metadata on an existing draft. + * Insert a new PO or update metadata on an existing pending/draft PO. * - * Insert (id=0): generates po_number, sets status=0, payment_status=0. - * Update (id>0): only allowed while status=0 (draft). + * Insert (id=0): generates po_number. Accounting-sourced POs start + * as pending warehouse (-2), direct WMS POs start as draft (0). + * Update (id>0): only allowed while status=-2 (pending) or 0 (draft). * * @param array $data Keys: id, contact_id, po_date, expected_date, * warehouse_id, items (array), discount, tax, @@ -157,13 +333,24 @@ class PurchaseOrderManager { $id = (int)($data['id'] ?? 0); $items = $data['items'] ?? []; - $subtotal = array_reduce($items, fn($carry, $item) => + $skus = array_filter(array_column($items, 'product_sku')); + if (count($skus) !== count(array_unique($skus))) { + throw new Exception("Duplicate products found — each product may only appear once per PO."); + } + + $subtotal = array_reduce($items, fn($carry, $item) => $carry + (float)($item['total_price'] ?? 0), 0.0 ); - $discount = (float)($data['discount'] ?? 0); - $tax = (float)($data['tax'] ?? 0); - $shipping_fee = (float)($data['shipping_fee'] ?? 0); - $grand_total = $subtotal - $discount + $tax + $shipping_fee; + $discount = (float)($data['discount'] ?? 0); + $tax_adjustment = (float)($data['tax_adjustment'] ?? 0); + if (abs($tax_adjustment) > 0.30) { + throw new Exception("Tax adjustment cannot exceed ±0.30."); + } + $tax = round(array_reduce($items, fn($carry, $item) => + $carry + (float)($item['tax_amount'] ?? 0), 0.0 + ), 2) + $tax_adjustment; + $shipping_fee = (float)($data['shipping_fee'] ?? 0); + $grand_total = $subtotal - $discount + $tax + $shipping_fee; if ($id > 0) { @@ -175,78 +362,97 @@ class PurchaseOrderManager { $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) throw new Exception("Purchase order not found."); - if ((int)$row['status'] !== 0) throw new Exception("Only draft POs can be edited."); + $cur_status = (int)$row['status']; + if (!in_array($cur_status, [-2, 0], true)) { + throw new Exception("Only pending or draft POs can be edited."); + } + $warehouse_id = (int)($data['warehouse_id'] ?? 0); + $new_status = ($cur_status === -2 && $warehouse_id > 0) ? 0 : $cur_status; $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = $logging; $this->pdo->prepare( "UPDATE td_purchase_order SET - contact_id = :contact_id, - po_date = :po_date, - expected_date = :expected_date, - warehouse_id = :warehouse_id, - items = :items, - subtotal = :subtotal, - discount = :discount, - tax = :tax, - shipping_fee = :shipping_fee, - grand_total = :grand_total, - notes = :notes, - `log` = :log + contact_id = :contact_id, + department_id = :department_id, + po_date = :po_date, + expected_date = :expected_date, + warehouse_id = :warehouse_id, + subtotal = :subtotal, + discount = :discount, + tax = :tax, + tax_adjustment = :tax_adjustment, + shipping_fee = :shipping_fee, + grand_total = :grand_total, + notes = :notes, + status = :status, + `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ - ':contact_id' => (int)($data['contact_id'] ?? 0), - ':po_date' => $data['po_date'] ?? date('Y-m-d'), + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':department_id' => (int)($data['department_id'] ?? 0), + ':po_date' => $data['po_date'] ?? date('Y-m-d'), ':expected_date' => $data['expected_date'] ?: null, - ':warehouse_id' => (int)($data['warehouse_id'] ?? 0), - ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), - ':subtotal' => $subtotal, - ':discount' => $discount, - ':tax' => $tax, - ':shipping_fee' => $shipping_fee, - ':grand_total' => $grand_total, - ':notes' => $data['notes'] ?? '', - ':log' => json_encode($log), - ':id' => $id, - ':company_id' => $this->company_id, + ':warehouse_id' => $warehouse_id, + ':subtotal' => $subtotal, + ':discount' => $discount, + ':tax' => $tax, + ':tax_adjustment' => $tax_adjustment, + ':shipping_fee' => $shipping_fee, + ':grand_total' => $grand_total, + ':notes' => $data['notes'] ?? '', + ':status' => $new_status, + ':log' => json_encode($log), + ':id' => $id, + ':company_id' => $this->company_id, ]); + $this->syncPoItems($id, $items); return 0; } else { $log = [$logging]; + $source = trim((string)($data['source'] ?? '')); + $source_id = (int)($data['source_id'] ?? 0); + $init_status = $source !== '' ? -2 : 0; $this->pdo->prepare( "INSERT INTO td_purchase_order - (company_id, uuid, po_number, contact_id, po_date, expected_date, - warehouse_id, status, payment_status, subtotal, discount, tax, - shipping_fee, grand_total, items, notes, `log`, created_at) + (company_id, uuid, source_id, `source`, po_number, contact_id, department_id, po_date, expected_date, + warehouse_id, status, subtotal, discount, tax, tax_adjustment, + shipping_fee, grand_total, notes, `log`, created_at) VALUES - (:company_id, :uuid, :po_number, :contact_id, :po_date, :expected_date, - :warehouse_id, 0, 0, :subtotal, :discount, :tax, - :shipping_fee, :grand_total, :items, :notes, :log, :created_at)" + (:company_id, :uuid, :source_id, :source, :po_number, :contact_id, :department_id, :po_date, :expected_date, + :warehouse_id, :status, :subtotal, :discount, :tax, :tax_adjustment, + :shipping_fee, :grand_total, :notes, :log, :created_at)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), + ':source_id' => $source_id, + ':source' => $source, ':po_number' => $this->generatePoNumber(), - ':contact_id' => (int)($data['contact_id'] ?? 0), - ':po_date' => $data['po_date'] ?? date('Y-m-d'), + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':department_id' => (int)($data['department_id'] ?? 0), + ':po_date' => $data['po_date'] ?? date('Y-m-d'), ':expected_date' => $data['expected_date'] ?: null, ':warehouse_id' => (int)($data['warehouse_id'] ?? 0), - ':subtotal' => $subtotal, - ':discount' => $discount, - ':tax' => $tax, - ':shipping_fee' => $shipping_fee, - ':grand_total' => $grand_total, - ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), - ':notes' => $data['notes'] ?? '', - ':log' => json_encode($log), - ':created_at' => date('Y-m-d H:i:s'), + ':status' => $init_status, + ':subtotal' => $subtotal, + ':discount' => $discount, + ':tax' => $tax, + ':tax_adjustment' => $tax_adjustment, + ':shipping_fee' => $shipping_fee, + ':grand_total' => $grand_total, + ':notes' => $data['notes'] ?? '', + ':log' => json_encode($log), + ':created_at' => date('Y-m-d H:i:s'), ]); - return (int)$this->pdo->lastInsertId(); + $new_id = (int)$this->pdo->lastInsertId(); + $this->syncPoItems($new_id, $items); + return $new_id; } } @@ -325,14 +531,26 @@ class PurchaseOrderManager { $status = (int)$po['status']; if ($status === -1) throw new Exception("Cannot receive against a cancelled PO."); if ($status === 0) throw new Exception("Confirm the PO before receiving goods."); - if ($status === 3) throw new Exception("This PO is already fully received."); + if (!empty($po['closed_at'])) throw new Exception("This PO has been closed — no further receipts are allowed."); + + // Load PO items from normalized table + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_purchase_order_item + WHERE order_id = :po_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':po_id' => $po_id, ':company_id' => $this->company_id]); + $po_items = $sth2->fetchAll(PDO::FETCH_ASSOC); + + $all_done = !empty($po_items) && array_reduce($po_items, fn($ok, $item) => + $ok && (float)($item['received_qty'] ?? 0) >= (float)($item['quantity'] ?? 0), true + ); + if ($all_done) throw new Exception("This PO is already fully received."); if (empty($receive_items)) { throw new Exception("No items provided to receive."); } - $po_items = json_decode($po['items'] ?? '[]', true) ?: []; - // Index PO items by item_id for quick lookup $po_items_by_id = []; foreach ($po_items as $i => $item) { @@ -352,6 +570,20 @@ class PurchaseOrderManager { if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku."); if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id."); + // Cap against PO quantity — prevent over-receiving + if ($item_id >= 0 && isset($po_items_by_id[$item_id])) { + $idx = $po_items_by_id[$item_id]; + $already_received = (float)($po_items[$idx]['received_qty'] ?? 0); + $max_receivable = (float)($po_items[$idx]['quantity'] ?? 0) - $already_received; + if ($quantity - $max_receivable > 0.000001) { + $name = $po_items[$idx]['product_name'] ?? $product_sku; + throw new Exception( + "Cannot receive {$quantity} for \"{$name}\": " . + "only {$max_receivable} unit(s) remaining on this PO." + ); + } + } + $item_uuid = $uuid . '_' . $j; $item_log = array_merge($logging, ['action' => 'receive_item']); @@ -414,43 +646,34 @@ class PurchaseOrderManager { } } - // Write stock_in_id + received qty back into PO item + // Write stock_in_id + received qty back into td_purchase_order_item if ($item_id >= 0 && isset($po_items_by_id[$item_id])) { $idx = $po_items_by_id[$item_id]; $already_received = (float)($po_items[$idx]['received_qty'] ?? 0); - $po_items[$idx]['received_qty'] = $already_received + $quantity; - $po_items[$idx]['stock_in_id'] = $new_stock_in_id; - $po_items[$idx]['receive_wh'] = $warehouse_id; - $po_items[$idx]['receive_zone'] = $zone; - $po_items[$idx]['receive_aisle'] = $aisle; - $po_items[$idx]['receive_rack'] = $rack; - } - } + $new_received_qty = $already_received + $quantity; - // Determine new PO status - $all_received = true; - foreach ($po_items as $item) { - $ordered = (float)($item['quantity'] ?? 0); - $received = (float)($item['received_qty'] ?? 0); - if ($received < $ordered) { - $all_received = false; - break; + $this->pdo->prepare( + "UPDATE td_purchase_order_item SET + stock_in_id = :stock_in_id, + received_qty = :received_qty + WHERE order_id = :po_id AND item_id = :item_id AND company_id = :company_id" + )->execute([ + ':stock_in_id' => $new_stock_in_id, + ':received_qty' => $new_received_qty, + ':po_id' => $po_id, + ':item_id' => $item_id, + ':company_id' => $this->company_id, + ]); } } - $new_status = $all_received ? 3 : 2; $log = json_decode($po['log'] ?? '[]', true) ?: []; - $log[] = array_merge($logging, ['action' => 'receive', 'new_status' => $new_status]); + $log[] = array_merge($logging, ['action' => 'receive']); $this->pdo->prepare( - "UPDATE td_purchase_order SET - status = :status, - items = :items, - `log` = :log + "UPDATE td_purchase_order SET `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ - ':status' => $new_status, - ':items' => json_encode($po_items, JSON_UNESCAPED_UNICODE), ':log' => json_encode($log), ':id' => $po_id, ':company_id' => $this->company_id, @@ -458,43 +681,40 @@ class PurchaseOrderManager { } /** - * Update payment status on a PO. - * Allowed for status >= 1 (confirmed, partial, completed). + * Close a confirmed PO — sets closed_at to prevent further receipts. * - * @param int $po_id td_purchase_order.id - * @param int $payment_status 0=unpaid, 1=paid, 2=partial - * @param array $logging Audit entry. + * Use when the supplier will send no more goods (short-shipped, agreement closed). + * Does not affect receipt_status — partial receipt is preserved. + * Blocked if PO is cancelled or not yet confirmed. + * + * @throws Exception */ - public function updatePaymentStatus(int $po_id, int $payment_status, array $logging): void + public function closePo(int $po_id, array $logging): void { $sth = $this->pdo->prepare( - "SELECT status, `log` FROM td_purchase_order + "SELECT status, closed_at, `log` FROM td_purchase_order WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $po_id]); $po = $sth->fetch(PDO::FETCH_ASSOC); if (!$po) throw new Exception("Purchase order not found."); - if ((int)$po['status'] < 1) throw new Exception("Confirm the PO before updating payment."); - if ((int)$po['status'] === -1) throw new Exception("Cannot update a cancelled PO."); - - if (!in_array($payment_status, [0, 1, 2], true)) { - throw new Exception("Invalid payment status."); - } + if ((int)$po['status'] === -1) throw new Exception("Cannot close a cancelled PO."); + if ((int)$po['status'] === 0) throw new Exception("Confirm the PO before closing it."); + if (!empty($po['closed_at'])) throw new Exception("This PO is already closed."); $log = json_decode($po['log'] ?? '[]', true) ?: []; - $log[] = array_merge($logging, ['action' => 'update_payment', 'payment_status' => $payment_status]); + $log[] = array_merge($logging, ['action' => 'close']); $this->pdo->prepare( "UPDATE td_purchase_order SET - payment_status = :payment_status, - `log` = :log + closed_at = NOW(), + `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ - ':payment_status' => $payment_status, - ':log' => json_encode($log), - ':id' => $po_id, - ':company_id' => $this->company_id, + ':log' => json_encode($log), + ':id' => $po_id, + ':company_id' => $this->company_id, ]); } @@ -521,7 +741,6 @@ class PurchaseOrderManager { $status = (int)$po['status']; if ($status === -1) throw new Exception("PO is already cancelled."); - if ($status === 3) throw new Exception("Cannot cancel a completed PO."); // Guard: block if any approved stock-in rows exist for this PO $sth = $this->pdo->prepare( @@ -600,5 +819,36 @@ class PurchaseOrderManager { ':id' => $po_id, ':company_id' => $this->company_id, ]); + + // Release converted_qty on the originating purchase request items + $source = (string)($po['source'] ?? ''); + $source_id = (int)($po['source_id'] ?? 0); + if ($source === 'purchase_request' && $source_id > 0) { + $sth2 = $this->pdo->prepare( + "SELECT item_id, quantity FROM td_purchase_order_item + WHERE order_id = :po_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':po_id' => $po_id, ':company_id' => $this->company_id]); + $po_items = $sth2->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, + ]); + } + // Revert PR from fully-converted (status=5) back to Approved (status=2) + $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]); + } } } diff --git a/app/assets/utils/classes/PurchaseRequestManager.php b/app/assets/utils/classes/PurchaseRequestManager.php new file mode 100644 index 0000000..3091ed0 --- /dev/null +++ b/app/assets/utils/classes/PurchaseRequestManager.php @@ -0,0 +1,350 @@ +pdo = $pdo; + $this->company_id = $company_id; + } + + // ───────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────── + + private function generateNumber(): string + { + $prefix = 'PR-' . date('Ym') . '-'; + $sth = $this->pdo->prepare( + "SELECT request_number FROM td_purchase_request + WHERE company_id = :cid AND request_number LIKE :prefix + ORDER BY request_number DESC LIMIT 1" + ); + $sth->execute([':cid' => $this->company_id, ':prefix' => $prefix . '%']); + $last = $sth->fetchColumn(); + $seq = $last ? ((int)substr($last, -4) + 1) : 1; + return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + } + + /** + * Derive document-level totals from line items. + * tax = ROUND(SUM(items.tax_amount), 2) — never accepted from caller. + */ + private function computeTotals(array $items, float $discount, float $shipping_fee): array + { + $subtotal = array_sum(array_map(fn($i) => (float)($i['total_price'] ?? 0), $items)); + $tax = round(array_sum(array_map(fn($i) => (float)($i['tax_amount'] ?? 0), $items)), 2); + $grand = max(0, $subtotal - $discount + $tax + $shipping_fee); + return [round($subtotal, 4), $tax, round($grand, 4)]; + } + + /** DELETE + INSERT all line items. converted_qty is always reset to 0 on save. */ + private function syncItems(int $request_id, array $items): void + { + $this->pdo->prepare( + "DELETE FROM td_purchase_request_item + WHERE request_id = :rid AND company_id = :cid" + )->execute([':rid' => $request_id, ':cid' => $this->company_id]); + + $sth = $this->pdo->prepare( + "INSERT INTO td_purchase_request_item + (company_id, request_id, item_id, product_sku, product_name, + quantity, unit_price, total_price, tax_amount, tax_rate, converted_qty) + VALUES + (:company_id, :request_id, :item_id, :product_sku, :product_name, + :quantity, :unit_price, :total_price, :tax_amount, :tax_rate, 0)" + ); + foreach ($items as $pos => $item) { + $sth->execute([ + ':company_id' => $this->company_id, + ':request_id' => $request_id, + ':item_id' => $pos + 1, + ':product_sku' => $item['product_sku'] ?? '', + ':product_name'=> $item['product_name'] ?? '', + ':quantity' => (float)($item['quantity'] ?? 0), + ':unit_price' => (float)($item['unit_price'] ?? 0), + ':total_price' => (float)($item['total_price'] ?? 0), + ':tax_amount' => (float)($item['tax_amount'] ?? 0), + ':tax_rate' => (float)($item['tax_rate'] ?? 0), + ]); + } + } + + // ───────────────────────────────────────────────────────────── + // Read + // ───────────────────────────────────────────────────────────── + + public function getList(): array + { + $sth = $this->pdo->prepare( + "SELECT r.*, COALESCE(c.contact_name, '') AS contact_name, + (SELECT COUNT(*) FROM td_purchase_order p + WHERE p.company_id = r.company_id + AND p.source = 'purchase_request' + AND p.source_id = r.id + AND p.status != -1) AS linked_po_count + FROM td_purchase_request r + LEFT JOIN md_contact c + ON c.company_id = r.company_id AND c.id = r.contact_id + WHERE r.company_id = :cid + ORDER BY r.id DESC" + ); + $sth->execute([':cid' => $this->company_id]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + public function getById(int $id): array|false + { + $sth = $this->pdo->prepare( + "SELECT r.*, COALESCE(c.contact_name, '') AS contact_name + FROM td_purchase_request r + LEFT JOIN md_contact c + ON c.company_id = r.company_id AND c.id = r.contact_id + WHERE r.company_id = :cid AND r.id = :id + LIMIT 1" + ); + $sth->execute([':cid' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + if (!$row) return false; + + $sth2 = $this->pdo->prepare( + "SELECT *, (quantity - converted_qty) AS remaining_qty + FROM td_purchase_request_item + WHERE request_id = :rid AND company_id = :cid + ORDER BY item_id" + ); + $sth2->execute([':rid' => $id, ':cid' => $this->company_id]); + $row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC); + $row['total_remaining'] = array_sum(array_column($row['items'], 'remaining_qty')); + + $sth3 = $this->pdo->prepare( + "SELECT id, po_number, status, grand_total, created_at + FROM td_purchase_order + WHERE company_id = :cid + AND source = 'purchase_request' + AND source_id = :rid + AND status != -1 + ORDER BY id" + ); + $sth3->execute([':cid' => $this->company_id, ':rid' => $id]); + $row['linked_pos'] = $sth3->fetchAll(PDO::FETCH_ASSOC); + + return $row; + } + + // ───────────────────────────────────────────────────────────── + // Write + // ───────────────────────────────────────────────────────────── + + /** + * Create (id=0) or update an existing draft purchase request. + * + * @param array $data Keys: id, contact_id, department_id, request_date, + * required_date, items (array), discount, + * shipping_fee, notes. + * Each item must include tax_amount. + * @return int The purchase request id (new or existing). + */ + public function save(array $data, array $logging): int + { + $id = (int)($data['id'] ?? 0); + $items = $data['items'] ?? []; + $discount = (float)($data['discount'] ?? 0); + $shipping_fee = (float)($data['shipping_fee'] ?? 0); + + if (empty($items)) throw new Exception('At least one item is required.'); + + [$subtotal, $tax, $grand] = $this->computeTotals($items, $discount, $shipping_fee); + + if ($id === 0) { + $number = $this->generateNumber(); + $log = [array_merge($logging, ['action' => 'create_purchase_request'])]; + + $this->pdo->prepare( + "INSERT INTO td_purchase_request + (company_id, uuid, request_number, contact_id, department_id, + request_date, required_date, status, + subtotal, discount, tax, shipping_fee, grand_total, + notes, `log`, created_at) + VALUES + (:cid, :uuid, :num, :contact_id, :department_id, + :request_date, :required_date, 0, + :sub, :disc, :tax, :ship, :grand, + :notes, :log, NOW())" + )->execute([ + ':cid' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':num' => $number, + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':department_id' => (int)($data['department_id'] ?? 0), + ':request_date' => $data['request_date'] ?: null, + ':required_date' => $data['required_date'] ?: null, + ':sub' => $subtotal, + ':disc' => $discount, + ':tax' => $tax, + ':ship' => $shipping_fee, + ':grand' => $grand, + ':notes' => $data['notes'] ?? '', + ':log' => json_encode($log), + ]); + + $id = (int)$this->pdo->lastInsertId(); + $this->syncItems($id, $items); + return $id; + } + + // Update — only allowed on draft (status = 0) + $sth = $this->pdo->prepare( + "SELECT status, `log` FROM td_purchase_request + 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('Purchase request not found.'); + if ((int)$row['status'] !== 0) throw new Exception('Only draft purchase requests can be edited.'); + + $log = json_decode($row['log'] ?: '[]', true); + $log[] = array_merge($logging, ['action' => 'update_purchase_request']); + + $this->pdo->prepare( + "UPDATE td_purchase_request SET + contact_id = :contact_id, + department_id = :department_id, + request_date = :request_date, + required_date = :required_date, + subtotal = :sub, + discount = :disc, + tax = :tax, + shipping_fee = :ship, + grand_total = :grand, + notes = :notes, + `log` = :log + WHERE company_id = :cid AND id = :id" + )->execute([ + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':department_id' => (int)($data['department_id'] ?? 0), + ':request_date' => $data['request_date'] ?: null, + ':required_date' => $data['required_date'] ?: null, + ':sub' => $subtotal, + ':disc' => $discount, + ':tax' => $tax, + ':ship' => $shipping_fee, + ':grand' => $grand, + ':notes' => $data['notes'] ?? '', + ':log' => json_encode($log), + ':cid' => $this->company_id, + ':id' => $id, + ]); + + $this->syncItems($id, $items); + return $id; + } + + /** + * Transition purchase request status. + * Valid actions: submit, approve, reject, reopen, cancel. + */ + public function updateStatus(int $id, string $action, array $logging): void + { + $transitions = [ + 'submit' => ['from' => [0], 'to' => 1, 'label' => 'Submitted'], + 'approve' => ['from' => [1], 'to' => 2, 'label' => 'Approved'], + 'reject' => ['from' => [1], 'to' => 3, 'label' => 'Rejected'], + 'reopen' => ['from' => [1, 2, 3], 'to' => 0, 'label' => 'Draft'], + 'cancel' => ['from' => [0, 1], 'to' => -1, 'label' => 'Cancelled'], + ]; + + if (!isset($transitions[$action])) throw new Exception('Invalid action.'); + + $sth = $this->pdo->prepare( + "SELECT status, `log` FROM td_purchase_request + 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('Purchase request not found.'); + + $t = $transitions[$action]; + if (!in_array((int)$row['status'], $t['from'], true)) { + throw new Exception('Transition not allowed from current status.'); + } + + if ($action === 'reopen') { + $sth2 = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_purchase_request_item + WHERE request_id = :rid AND company_id = :cid AND converted_qty > 0" + ); + $sth2->execute([':rid' => $id, ':cid' => $this->company_id]); + if ((int)$sth2->fetchColumn() > 0) { + throw new Exception('Cannot reopen — one or more items have already been converted to a purchase order.'); + } + } + + $log = json_decode($row['log'] ?: '[]', true); + $log[] = array_merge($logging, ['action' => 'purchase_request_' . $action]); + + $this->pdo->prepare( + "UPDATE td_purchase_request SET status = :status, `log` = :log + WHERE company_id = :cid AND id = :id" + )->execute([ + ':status' => $t['to'], + ':log' => json_encode($log), + ':cid' => $this->company_id, + ':id' => $id, + ]); + } + + /** + * Increment converted_qty per item after a successful PO conversion. + * Automatically sets PR status = 5 when all items are fully converted. + * + * @param array $validated Each entry: ['item_id' => int, 'quantity' => float] + */ + public function incrementConvertedQty(int $request_id, array $validated): void + { + $upd = $this->pdo->prepare( + "UPDATE td_purchase_request_item + SET converted_qty = converted_qty + :qty + WHERE request_id = :rid AND item_id = :item_id AND company_id = :cid" + ); + foreach ($validated as $v) { + $upd->execute([ + ':qty' => $v['quantity'], + ':rid' => $request_id, + ':item_id' => $v['item_id'], + ':cid' => $this->company_id, + ]); + } + + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_purchase_request_item + WHERE request_id = :rid AND company_id = :cid + AND converted_qty < quantity - 0.000001" + ); + $sth->execute([':rid' => $request_id, ':cid' => $this->company_id]); + if ((int)$sth->fetchColumn() === 0) { + $this->pdo->prepare( + "UPDATE td_purchase_request SET status = 5 + WHERE id = :id AND company_id = :cid" + )->execute([':id' => $request_id, ':cid' => $this->company_id]); + } + } +} diff --git a/app/assets/utils/classes/QuotationManager.php b/app/assets/utils/classes/QuotationManager.php new file mode 100644 index 0000000..0b68a04 --- /dev/null +++ b/app/assets/utils/classes/QuotationManager.php @@ -0,0 +1,341 @@ +pdo = $pdo; + $this->company_id = $company_id; + } + + // ───────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────── + + private function generateNumber(): string + { + $prefix = 'QT-' . date('Ym') . '-'; + $sth = $this->pdo->prepare( + "SELECT quotation_number FROM td_quotation + WHERE company_id = :cid AND quotation_number LIKE :prefix + ORDER BY id DESC LIMIT 1" + ); + $sth->execute([':cid' => $this->company_id, ':prefix' => $prefix . '%']); + $last = $sth->fetchColumn(); + $seq = $last ? ((int)substr($last, -4) + 1) : 1; + return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + } + + /** + * Derive document-level totals from line items. + * tax = ROUND(SUM(items.tax_amount), 2) — never accepted from caller. + */ + private function computeTotals(array $items, float $discount): array + { + $subtotal = array_sum(array_map(fn($i) => (float)($i['total_price'] ?? 0), $items)); + $tax = round(array_sum(array_map(fn($i) => (float)($i['tax_amount'] ?? 0), $items)), 2); + $grand = max(0, $subtotal - $discount + $tax); + return [round($subtotal, 4), $tax, round($grand, 4)]; + } + + /** DELETE + INSERT all line items. converted_qty is always preserved or reset to 0 on create. */ + private function syncItems(int $quotation_id, array $items): void + { + $this->pdo->prepare( + "DELETE FROM td_quotation_item + WHERE quotation_id = :qid AND company_id = :cid" + )->execute([':qid' => $quotation_id, ':cid' => $this->company_id]); + + $sth = $this->pdo->prepare( + "INSERT INTO td_quotation_item + (company_id, quotation_id, item_id, product_sku, product_name, + quantity, unit_price, total_price, tax_amount, tax_rate, converted_qty) + VALUES + (:company_id, :quotation_id, :item_id, :product_sku, :product_name, + :quantity, :unit_price, :total_price, :tax_amount, :tax_rate, 0)" + ); + foreach ($items as $pos => $item) { + $sth->execute([ + ':company_id' => $this->company_id, + ':quotation_id' => $quotation_id, + ':item_id' => $pos + 1, + ':product_sku' => $item['product_sku'] ?? '', + ':product_name' => $item['product_name'] ?? '', + ':quantity' => (float)($item['quantity'] ?? 0), + ':unit_price' => (float)($item['unit_price'] ?? 0), + ':total_price' => (float)($item['total_price'] ?? 0), + ':tax_amount' => (float)($item['tax_amount'] ?? 0), + ':tax_rate' => (float)($item['tax_rate'] ?? 0), + ]); + } + } + + // ───────────────────────────────────────────────────────────── + // Read + // ───────────────────────────────────────────────────────────── + + public function getList(): array + { + $sth = $this->pdo->prepare( + "SELECT q.*, COALESCE(c.contact_name, '') AS contact_name + FROM td_quotation q + LEFT JOIN md_contact c + ON c.id = q.contact_id AND c.company_id = q.company_id + WHERE q.company_id = :cid + ORDER BY q.id DESC" + ); + $sth->execute([':cid' => $this->company_id]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + public function getStats(): array + { + $sth = $this->pdo->prepare( + "SELECT + COUNT(*) AS total, + SUM(status = 0) AS draft, + SUM(status = 1) AS sent, + SUM(status = 2) AS accepted, + SUM(status = 3) AS rejected + FROM td_quotation + WHERE company_id = :cid" + ); + $sth->execute([':cid' => $this->company_id]); + return $sth->fetch(PDO::FETCH_ASSOC) ?: []; + } + + public function getById(int $id): array|false + { + $sth = $this->pdo->prepare( + "SELECT q.*, COALESCE(c.contact_name, '') AS contact_name + FROM td_quotation q + LEFT JOIN md_contact c + ON c.id = q.contact_id AND c.company_id = q.company_id + WHERE q.id = :id AND q.company_id = :cid + LIMIT 1" + ); + $sth->execute([':id' => $id, ':cid' => $this->company_id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + if (!$row) return false; + + $sth2 = $this->pdo->prepare( + "SELECT *, (quantity - converted_qty) AS remaining_qty + FROM td_quotation_item + WHERE quotation_id = :qid AND company_id = :cid + ORDER BY item_id" + ); + $sth2->execute([':qid' => $id, ':cid' => $this->company_id]); + $row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC); + $row['total_remaining'] = array_sum(array_column($row['items'], 'remaining_qty')); + + $sth3 = $this->pdo->prepare( + "SELECT id, order_number, status, grand_total, created_at + FROM td_order + WHERE company_id = :cid + AND source = 'quotation' + AND source_id = :qid + AND status != -1 + ORDER BY id" + ); + $sth3->execute([':cid' => $this->company_id, ':qid' => $id]); + $row['linked_orders'] = $sth3->fetchAll(PDO::FETCH_ASSOC); + + return $row; + } + + // ───────────────────────────────────────────────────────────── + // Write + // ───────────────────────────────────────────────────────────── + + /** + * Create (id=0) or update an existing draft quotation. + * + * @param array $data Keys: id, contact_id, department_id, quotation_date, + * valid_until, items (array), discount, notes. + * Each item must include tax_amount. + * @return int The quotation id (new or existing). + */ + public function save(array $data, array $logging): int + { + $id = (int)($data['id'] ?? 0); + $items = $data['items'] ?? []; + $discount = (float)($data['discount'] ?? 0); + + [$subtotal, $tax, $grand] = $this->computeTotals($items, $discount); + + if ($id === 0) { + $number = $this->generateNumber(); + $log = [array_merge($logging, ['action' => 'create_quotation'])]; + + $this->pdo->prepare( + "INSERT INTO td_quotation + (company_id, uuid, quotation_number, contact_id, department_id, + quotation_date, valid_until, status, + subtotal, discount, tax, grand_total, notes, `log`, created_at) + VALUES + (:cid, :uuid, :num, :contact_id, :department_id, + :qdate, :valid_until, 0, + :sub, :disc, :tax, :grand, :notes, :log, NOW())" + )->execute([ + ':cid' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':num' => $number, + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':department_id' => (int)($data['department_id'] ?? 0), + ':qdate' => $data['quotation_date'] ?: null, + ':valid_until' => $data['valid_until'] ?: null, + ':sub' => $subtotal, + ':disc' => $discount, + ':tax' => $tax, + ':grand' => $grand, + ':notes' => $data['notes'] ?? '', + ':log' => json_encode($log), + ]); + + $id = (int)$this->pdo->lastInsertId(); + $this->syncItems($id, $items); + return $id; + } + + // Update — only allowed on draft (status = 0) + $sth = $this->pdo->prepare( + "SELECT status, `log` 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.'); + if ((int)$row['status'] !== 0) throw new Exception('Only draft quotations can be edited.'); + + $log = json_decode($row['log'] ?: '[]', true); + $log[] = array_merge($logging, ['action' => 'update_quotation']); + + $this->pdo->prepare( + "UPDATE td_quotation SET + contact_id = :contact_id, + department_id = :department_id, + quotation_date = :qdate, + valid_until = :valid_until, + subtotal = :sub, + discount = :disc, + tax = :tax, + grand_total = :grand, + notes = :notes, + `log` = :log + WHERE id = :id AND company_id = :cid" + )->execute([ + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':department_id' => (int)($data['department_id'] ?? 0), + ':qdate' => $data['quotation_date'] ?: null, + ':valid_until' => $data['valid_until'] ?: null, + ':sub' => $subtotal, + ':disc' => $discount, + ':tax' => $tax, + ':grand' => $grand, + ':notes' => $data['notes'] ?? '', + ':log' => json_encode($log), + ':id' => $id, + ':cid' => $this->company_id, + ]); + + $this->syncItems($id, $items); + return $id; + } + + /** + * Transition quotation status. + * Valid actions: send, accept, reject, reopen, cancel. + */ + public function updateStatus(int $id, string $action, array $logging): void + { + $transitions = [ + 'send' => ['from' => [0], 'to' => 1, 'label' => 'Sent'], + 'accept' => ['from' => [1], 'to' => 2, 'label' => 'Accepted'], + 'reject' => ['from' => [1], 'to' => 3, 'label' => 'Rejected'], + 'reopen' => ['from' => [1, 2, 3], 'to' => 0, 'label' => 'Draft'], + 'cancel' => ['from' => [0, 1], 'to' => -1, 'label' => 'Cancelled'], + ]; + + if (!isset($transitions[$action])) throw new Exception('Invalid action.'); + + $sth = $this->pdo->prepare( + "SELECT status 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.'); + + $t = $transitions[$action]; + if (!in_array((int)$row['status'], $t['from'], true)) { + throw new Exception('Transition not allowed from current status.'); + } + + if ($action === 'reopen') { + $sth2 = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_quotation_item + WHERE quotation_id = :qid AND company_id = :cid AND converted_qty > 0" + ); + $sth2->execute([':qid' => $id, ':cid' => $this->company_id]); + if ((int)$sth2->fetchColumn() > 0) { + throw new Exception('Cannot reopen — one or more items have already been converted to an order.'); + } + } + + $this->pdo->prepare( + "UPDATE td_quotation SET status = :status WHERE id = :id AND company_id = :cid" + )->execute([':status' => $t['to'], ':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. + * + * @param array $validated Each entry: ['item_id' => int, 'quantity' => float] + */ + public function incrementConvertedQty(int $quotation_id, array $validated): void + { + $upd = $this->pdo->prepare( + "UPDATE td_quotation_item + SET converted_qty = converted_qty + :qty + WHERE quotation_id = :qid AND item_id = :item_id AND company_id = :cid" + ); + foreach ($validated as $v) { + $upd->execute([ + ':qty' => $v['quantity'], + ':qid' => $quotation_id, + ':item_id' => $v['item_id'], + ':cid' => $this->company_id, + ]); + } + + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_quotation_item + WHERE quotation_id = :qid AND company_id = :cid + AND converted_qty < quantity - 0.000001" + ); + $sth->execute([':qid' => $quotation_id, ':cid' => $this->company_id]); + if ((int)$sth->fetchColumn() === 0) { + $this->pdo->prepare( + "UPDATE td_quotation SET status = 5 WHERE id = :id AND company_id = :cid" + )->execute([':id' => $quotation_id, ':cid' => $this->company_id]); + } + } +} diff --git a/app/assets/utils/classes/ReceiptBillingManager.php b/app/assets/utils/classes/ReceiptBillingManager.php new file mode 100644 index 0000000..7296080 --- /dev/null +++ b/app/assets/utils/classes/ReceiptBillingManager.php @@ -0,0 +1,393 @@ +pdo = $pdo; + $this->company_id = $company_id; + } + + private function generateBillingNumber(): string + { + $prefix = 'RB-' . date('Ymd') . '-'; + $sth = $this->pdo->prepare( + "SELECT billing_number FROM td_receipt_billing + WHERE company_id = :company_id + AND billing_number LIKE :prefix + ORDER BY billing_number DESC + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':prefix' => $prefix . '%', + ]); + $last = $sth->fetchColumn(); + $seq = $last ? ((int)substr($last, -4) + 1) : 1; + return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + } + + private function unavailableDocumentAmount(int $invoice_id): float + { + $sth = $this->pdo->prepare( + "SELECT COALESCE(billed.billed_amount, 0) + COALESCE(direct.received_amount, 0) + FROM ( + SELECT :company_id AS company_id, :invoice_id AS invoice_id + ) doc + LEFT JOIN ( + SELECT ba.company_id, ba.invoice_id, SUM(ba.amount) AS billed_amount + FROM td_receipt_billing_item ba + JOIN td_receipt_billing b + ON b.company_id = ba.company_id + AND b.id = ba.billing_id + WHERE ba.company_id = :company_id_billed + AND ba.invoice_id = :invoice_id_billed + AND b.status IN (1, 2, 3) + GROUP BY ba.company_id, ba.invoice_id + ) billed + ON billed.company_id = doc.company_id + AND billed.invoice_id = doc.invoice_id + LEFT JOIN ( + SELECT ra.company_id, ra.invoice_id, SUM(ra.amount) AS received_amount + FROM td_receipt_item ra + JOIN td_receipt r + ON r.company_id = ra.company_id + AND r.id = ra.receipt_id + WHERE ra.company_id = :company_id_direct + AND ra.invoice_id = :invoice_id_direct + AND r.status = 1 + AND r.receipt_billing_id = 0 + GROUP BY ra.company_id, ra.invoice_id + ) direct + ON direct.company_id = doc.company_id + AND direct.invoice_id = doc.invoice_id" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':invoice_id' => $invoice_id, + ':company_id_billed' => $this->company_id, + ':invoice_id_billed' => $invoice_id, + ':company_id_direct' => $this->company_id, + ':invoice_id_direct' => $invoice_id, + ]); + return (float)$sth->fetchColumn(); + } + + public function getBillableDocuments(int $contact_id = 0): array + { + $where = [ + "i.company_id = :company_id", + "i.doc_type IN ('invoice', 'credit_note')", + "i.status = 1" + ]; + $params = [':company_id' => $this->company_id]; + + $where[] = 'i.contact_id = :contact_id'; + $params[':contact_id'] = $contact_id; + + $sth = $this->pdo->prepare( + "SELECT i.*, + COALESCE(c.contact_name, '') AS contact_name, + o.order_number, + COALESCE(received.received_amount, 0) AS received_amount, + COALESCE(billed.billed_amount, 0) AS billed_amount + FROM td_invoice i + LEFT JOIN md_contact c + ON c.company_id = i.company_id + AND c.id = i.contact_id + LEFT JOIN td_order o + ON o.company_id = i.company_id + AND o.id = i.order_id + LEFT JOIN ( + SELECT ra.company_id, ra.invoice_id, SUM(ra.amount) AS received_amount + FROM td_receipt_item ra + JOIN td_receipt r + ON r.company_id = ra.company_id + AND r.id = ra.receipt_id + WHERE r.status = 1 + AND r.receipt_billing_id = 0 + GROUP BY ra.company_id, ra.invoice_id + ) received + ON received.company_id = i.company_id + AND received.invoice_id = i.id + LEFT JOIN ( + SELECT ba.company_id, ba.invoice_id, SUM(ba.amount) AS billed_amount + FROM td_receipt_billing_item ba + JOIN td_receipt_billing b + ON b.company_id = ba.company_id + AND b.id = ba.billing_id + WHERE b.status IN (1, 2, 3) + GROUP BY ba.company_id, ba.invoice_id + ) billed + ON billed.company_id = i.company_id + AND billed.invoice_id = i.id + WHERE " . implode(' AND ', $where) . " + ORDER BY i.issued_date ASC, i.id ASC" + ); + $sth->execute($params); + + $open = []; + foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { + $total = abs((float)$row['grand_total']); + $used = (float)$row['received_amount'] + (float)$row['billed_amount']; + $balance = max(0, $total - $used); + if ($balance <= 0.0001) continue; + $row['document_total'] = $total; + $row['balance_amount'] = $balance; + $open[] = $row; + } + + return $open; + } + + public function getBillingList(): array + { + $sth = $this->pdo->prepare( + "SELECT b.*, + COALESCE(c.contact_name, '') AS contact_name, + COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS received_amount + FROM td_receipt_billing b + LEFT JOIN md_contact c + ON c.company_id = b.company_id + AND c.id = b.contact_id + LEFT JOIN td_receipt r + ON r.company_id = b.company_id + AND r.receipt_billing_id = b.id + WHERE b.company_id = :company_id + GROUP BY b.id + ORDER BY b.id DESC" + ); + $sth->execute([':company_id' => $this->company_id]); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + + foreach ($rows as &$row) { + $row['balance_amount'] = max(0, (float)$row['amount'] - (float)$row['received_amount']); + } + return $rows; + } + + public function getOpenBillingList(int $contact_id = 0): array + { + $rows = $this->getBillingList(); + return array_values(array_filter($rows, function($row) use ($contact_id) { + if ((int)$row['status'] === 4) return false; + if ((int)$row['contact_id'] !== $contact_id) return false; + return (float)$row['balance_amount'] > 0.0001; + })); + } + + public function getBillingById(int $billing_id): array + { + $sth = $this->pdo->prepare( + "SELECT b.*, + COALESCE(c.contact_name, '') AS contact_name, + COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS received_amount + FROM td_receipt_billing b + LEFT JOIN md_contact c + ON c.company_id = b.company_id + AND c.id = b.contact_id + LEFT JOIN td_receipt r + ON r.company_id = b.company_id + AND r.receipt_billing_id = b.id + WHERE b.company_id = :company_id + AND b.id = :id + GROUP BY b.id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $billing_id]); + $billing = $sth->fetch(PDO::FETCH_ASSOC); + if (!$billing) throw new Exception("Receipt billing not found."); + + $sth = $this->pdo->prepare( + "SELECT a.*, + i.invoice_number, + i.order_id, + i.department_id, + i.grand_total, + i.status AS invoice_status, + COALESCE(SUM(CASE WHEN r.status = 1 THEN ra.amount ELSE 0 END), 0) AS received_line_amount, + o.order_number + FROM td_receipt_billing_item a + JOIN td_invoice i + ON i.company_id = a.company_id + AND i.id = a.invoice_id + LEFT JOIN td_receipt_item ra + ON ra.company_id = a.company_id + AND ra.invoice_id = a.invoice_id + LEFT JOIN td_receipt r + ON r.company_id = ra.company_id + AND r.id = ra.receipt_id + AND r.receipt_billing_id = a.billing_id + LEFT JOIN td_order o + ON o.company_id = i.company_id + AND o.id = i.order_id + WHERE a.company_id = :company_id + AND a.billing_id = :billing_id + GROUP BY a.id + ORDER BY a.id ASC" + ); + $sth->execute([':company_id' => $this->company_id, ':billing_id' => $billing_id]); + $allocations = $sth->fetchAll(PDO::FETCH_ASSOC); + foreach ($allocations as &$line) { + $line['balance_amount'] = max(0, (float)$line['amount'] - (float)$line['received_line_amount']); + } + $billing['allocations'] = $allocations; + $billing['balance_amount'] = max(0, (float)$billing['amount'] - (float)$billing['received_amount']); + + return $billing; + } + + public function refreshBillingStatus(int $billing_id): void + { + $billing = $this->getBillingById($billing_id); + if ((int)$billing['status'] === 4) return; + if ((float)$billing['balance_amount'] <= 0.0001) { + $new_status = 2; + } elseif ((float)$billing['received_amount'] > 0.0001) { + $new_status = 3; + } else { + $new_status = 1; + } + $this->pdo->prepare( + "UPDATE td_receipt_billing + SET status = :status + WHERE company_id = :company_id + AND id = :id" + )->execute([ + ':status' => $new_status, + ':company_id' => $this->company_id, + ':id' => $billing_id, + ]); + } + + public function createBilling(array $data, array $logging): int + { + $contact_id = (int)($data['contact_id'] ?? 0); + $billing_date = $data['billing_date'] ?? date('Y-m-d'); + $notes = trim((string)($data['notes'] ?? '')); + $allocations = $data['allocations'] ?? []; + + if (is_string($allocations)) { + $allocations = json_decode($allocations, true) ?: []; + } + + if ($contact_id <= 0) throw new Exception("Customer is required."); + if (!$allocations) throw new Exception("Receipt billing requires at least one document."); + + $validated = []; + $invoice_total = 0.0; + $credit_total = 0.0; + + foreach ($allocations as $line) { + $invoice_id = (int)($line['invoice_id'] ?? 0); + $amount = (float)($line['amount'] ?? 0); + if ($invoice_id <= 0 || $amount <= 0) continue; + + $sth = $this->pdo->prepare( + "SELECT id, doc_type, invoice_number, contact_id, department_id, grand_total, status + FROM td_invoice + WHERE company_id = :company_id AND id = :id + FOR UPDATE" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $invoice_id]); + $doc = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$doc) throw new Exception("Document not found."); + if ((int)$doc['contact_id'] !== $contact_id) { + throw new Exception("All billing documents must belong to the selected contact."); + } + if (!in_array($doc['doc_type'], ['invoice', 'credit_note'], true)) { + throw new Exception("Receipt billing can only include invoices and credit notes."); + } + if ((int)$doc['status'] !== 1) { + throw new Exception($doc['invoice_number'] . " is not open for billing."); + } + + $balance = abs((float)$doc['grand_total']) - $this->unavailableDocumentAmount($invoice_id); + if ($amount - $balance > 0.0001) { + throw new Exception($doc['invoice_number'] . " allocation exceeds remaining billable balance."); + } + + if ($doc['doc_type'] === 'credit_note') $credit_total += $amount; + else $invoice_total += $amount; + + $validated[] = [ + 'invoice_id' => $invoice_id, + 'doc_type' => $doc['doc_type'], + 'department_id' => (int)($doc['department_id'] ?? 0), + 'amount' => round($amount, 4), + ]; + } + + if (!$validated) throw new Exception("Receipt billing requires at least one valid document."); + $net_amount = round($invoice_total - $credit_total, 4); + if ($net_amount <= 0.0001) { + throw new Exception("Receipt billing amount must be greater than zero."); + } + + $log = [array_merge($logging, ['action' => 'create_receipt_billing'])]; + $this->pdo->prepare( + "INSERT INTO td_receipt_billing + (company_id, uuid, billing_number, contact_id, billing_date, + amount, status, notes, `log`, created_at) + VALUES + (:company_id, :uuid, :billing_number, :contact_id, :billing_date, + :amount, 1, :notes, :log, :created_at)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':billing_number' => $this->generateBillingNumber(), + ':contact_id' => $contact_id, + ':billing_date' => $billing_date, + ':amount' => $net_amount, + ':notes' => $notes, + ':log' => json_encode($log), + ':created_at' => date('Y-m-d H:i:s'), + ]); + + $billing_id = (int)$this->pdo->lastInsertId(); + + foreach ($validated as $line) { + $this->pdo->prepare( + "INSERT INTO td_receipt_billing_item + (company_id, billing_id, invoice_id, doc_type, amount) + VALUES + (:company_id, :billing_id, :invoice_id, :doc_type, :amount)" + )->execute([ + ':company_id' => $this->company_id, + ':billing_id' => $billing_id, + ':invoice_id' => $line['invoice_id'], + ':doc_type' => $line['doc_type'], + ':amount' => $line['amount'], + ]); + } + + return $billing_id; + } + + public function voidBilling(int $billing_id, array $logging): void + { + $billing = $this->getBillingById($billing_id); + if ((int)$billing['status'] === 4) throw new Exception("Receipt billing is already void."); + if ((float)$billing['received_amount'] > 0.0001) { + throw new Exception("Cannot void a receipt billing with posted receipts."); + } + + $log = json_decode($billing['log'] ?: '[]', true); + if (!is_array($log)) $log = []; + $log[] = array_merge($logging, ['action' => 'void_receipt_billing']); + + $this->pdo->prepare( + "UPDATE td_receipt_billing + SET status = 4, `log` = :log + WHERE company_id = :company_id + AND id = :id" + )->execute([ + ':log' => json_encode($log), + ':company_id' => $this->company_id, + ':id' => $billing_id, + ]); + } +} +?> diff --git a/app/assets/utils/classes/ReceiptManager.php b/app/assets/utils/classes/ReceiptManager.php new file mode 100644 index 0000000..246eb79 --- /dev/null +++ b/app/assets/utils/classes/ReceiptManager.php @@ -0,0 +1,515 @@ +pdo = $pdo; + $this->company_id = $company_id; + } + + private function generateReceiptNumber(): string + { + $prefix = 'RC-' . date('Ymd') . '-'; + $sth = $this->pdo->prepare( + "SELECT receipt_number FROM td_receipt + WHERE company_id = :company_id + AND receipt_number LIKE :prefix + ORDER BY receipt_number DESC + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':prefix' => $prefix . '%', + ]); + $last = $sth->fetchColumn(); + $seq = $last ? ((int)substr($last, -4) + 1) : 1; + return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + } + + private function assertPostingWindow(?string $date, string $context): void + { + global $pdo1; + + if (!isset($pdo1) || !($pdo1 instanceof PDO)) { + throw new Exception("Posting-window validation is unavailable."); + } + + $guard = new PostingWindowGuard($pdo1, $this->company_id); + $guard->assertOpenDate($date ?: date('Y-m-d'), $context); + } + + private function allocatedAmount(int $invoice_id): float + { + $sth = $this->pdo->prepare( + "SELECT COALESCE(SUM(a.amount), 0) + FROM td_receipt_item a + JOIN td_receipt r + ON r.company_id = a.company_id + AND r.id = a.receipt_id + WHERE a.company_id = :company_id + AND a.invoice_id = :invoice_id + AND r.status = 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':invoice_id' => $invoice_id, + ]); + return (float)$sth->fetchColumn(); + } + + private function refreshInvoiceSettlementStatus(int $invoice_id): void + { + $sth = $this->pdo->prepare( + "SELECT grand_total, status FROM td_invoice + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $invoice_id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + if (!$row || (int)$row['status'] === 4 || (int)$row['status'] === 0) return; + + $target = abs((float)$row['grand_total']); + $paid = $this->allocatedAmount($invoice_id); + $new_status = ($target > 0 && $paid + 0.0001 >= $target) ? 2 : 1; + + $this->pdo->prepare( + "UPDATE td_invoice SET status = :status + WHERE company_id = :company_id AND id = :id" + )->execute([ + ':status' => $new_status, + ':company_id' => $this->company_id, + ':id' => $invoice_id, + ]); + } + + private function refreshReceiptBillingStatus(int $billing_id): void + { + if ($billing_id <= 0) return; + + $sth = $this->pdo->prepare( + "SELECT b.amount, b.status, + COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS received_amount + FROM td_receipt_billing b + LEFT JOIN td_receipt r + ON r.company_id = b.company_id + AND r.receipt_billing_id = b.id + WHERE b.company_id = :company_id + AND b.id = :id + GROUP BY b.id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $billing_id]); + $billing = $sth->fetch(PDO::FETCH_ASSOC); + if (!$billing || (int)$billing['status'] === 4) return; + + $amount = (float)$billing['amount']; + $received = (float)$billing['received_amount']; + if ($received + 0.0001 >= $amount) { + $new_status = 2; + } elseif ($received > 0.0001) { + $new_status = 3; + } else { + $new_status = 1; + } + $this->pdo->prepare( + "UPDATE td_receipt_billing + SET status = :status + WHERE company_id = :company_id + AND id = :id" + )->execute([ + ':status' => $new_status, + ':company_id' => $this->company_id, + ':id' => $billing_id, + ]); + } + + public function getOpenDocuments(int $contact_id = 0): array + { + $where = [ + "i.company_id = :company_id", + "i.doc_type IN ('invoice', 'credit_note')", + "i.status IN (1, 2)" + ]; + $params = [':company_id' => $this->company_id]; + + if ($contact_id > 0) { + $where[] = 'i.contact_id = :contact_id'; + $params[':contact_id'] = $contact_id; + } + + $sth = $this->pdo->prepare( + "SELECT i.*, + COALESCE(c.contact_name, '') AS contact_name, + o.order_number, + COALESCE(SUM(CASE WHEN r.status = 1 THEN a.amount ELSE 0 END), 0) AS allocated_amount + FROM td_invoice i + LEFT JOIN md_contact c + ON c.company_id = i.company_id + AND c.id = i.contact_id + LEFT JOIN td_order o + ON o.company_id = i.company_id + AND o.id = i.order_id + LEFT JOIN td_receipt_item a + ON a.company_id = i.company_id + AND a.invoice_id = i.id + LEFT JOIN td_receipt r + ON r.company_id = a.company_id + AND r.id = a.receipt_id + WHERE " . implode(' AND ', $where) . " + GROUP BY i.id + ORDER BY i.issued_date ASC, i.id ASC" + ); + $sth->execute($params); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + + $open = []; + foreach ($rows as $row) { + $total = abs((float)$row['grand_total']); + $allocated = (float)$row['allocated_amount']; + $balance = max(0, $total - $allocated); + if ($balance <= 0.0001) continue; + $row['document_total'] = $total; + $row['balance_amount'] = $balance; + $open[] = $row; + } + + return $open; + } + + public function getReceiptList(): array + { + $sth = $this->pdo->prepare( + "SELECT r.*, + COALESCE(c.contact_name, '') AS contact_name, + COALESCE(b.billing_number, '') AS billing_number + FROM td_receipt r + LEFT JOIN md_contact c + ON c.company_id = r.company_id + AND c.id = r.contact_id + LEFT JOIN td_receipt_billing b + ON b.company_id = r.company_id + AND b.id = r.receipt_billing_id + WHERE r.company_id = :company_id + ORDER BY r.id DESC" + ); + $sth->execute([':company_id' => $this->company_id]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + public function getReceiptById(int $receipt_id): array + { + $sth = $this->pdo->prepare( + "SELECT r.*, + COALESCE(c.contact_name, '') AS contact_name, + COALESCE(b.billing_number, '') AS billing_number + FROM td_receipt r + LEFT JOIN md_contact c + ON c.company_id = r.company_id + AND c.id = r.contact_id + LEFT JOIN td_receipt_billing b + ON b.company_id = r.company_id + AND b.id = r.receipt_billing_id + WHERE r.company_id = :company_id + AND r.id = :id" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':id' => $receipt_id, + ]); + $receipt = $sth->fetch(PDO::FETCH_ASSOC); + if (!$receipt) throw new Exception("Receipt not found."); + + $sth = $this->pdo->prepare( + "SELECT a.*, + i.invoice_number, + i.order_id, + i.grand_total, + i.status AS invoice_status, + o.order_number + FROM td_receipt_item a + JOIN td_invoice i + ON i.company_id = a.company_id + AND i.id = a.invoice_id + LEFT JOIN td_order o + ON o.company_id = i.company_id + AND o.id = i.order_id + WHERE a.company_id = :company_id + AND a.receipt_id = :receipt_id + ORDER BY a.id ASC" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':receipt_id' => $receipt_id, + ]); + + $receipt['allocations'] = $sth->fetchAll(PDO::FETCH_ASSOC); + return $receipt; + } + + public function voidReceipt(int $receipt_id, array $logging): void + { + $sth = $this->pdo->prepare( + "SELECT id, receipt_billing_id, receipt_date, status, `log` + FROM td_receipt + WHERE company_id = :company_id + AND id = :id + FOR UPDATE" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':id' => $receipt_id, + ]); + $receipt = $sth->fetch(PDO::FETCH_ASSOC); + if (!$receipt) throw new Exception("Receipt not found."); + if ((int)$receipt['status'] === 4) throw new Exception("Receipt is already void."); + $this->assertPostingWindow($receipt['receipt_date'] ?: date('Y-m-d'), 'Receipt'); + + $log = json_decode($receipt['log'] ?: '[]', true); + if (!is_array($log)) $log = []; + $log[] = array_merge($logging, ['action' => 'void_receipt']); + + $this->pdo->prepare( + "UPDATE td_receipt + SET status = 4, `log` = :log + WHERE company_id = :company_id + AND id = :id" + )->execute([ + ':log' => json_encode($log), + ':company_id' => $this->company_id, + ':id' => $receipt_id, + ]); + + global $pdo1; + $guard = new PostingWindowGuard($pdo1, $this->company_id); + $gl = new GlManager($this->pdo, $this->company_id, $guard); + $gl->delete('receipt', $receipt_id); + + $sth = $this->pdo->prepare( + "SELECT invoice_id + FROM td_receipt_item + WHERE company_id = :company_id + AND receipt_id = :receipt_id" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':receipt_id' => $receipt_id, + ]); + foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $invoice_id) { + $this->refreshInvoiceSettlementStatus((int)$invoice_id); + } + $this->refreshReceiptBillingStatus((int)$receipt['receipt_billing_id']); + } + + public function createReceipt(array $data, array $logging): int + { + $billing_id = (int)($data['receipt_billing_id'] ?? $data['billing_id'] ?? 0); + $receipt_date = $data['receipt_date'] ?? date('Y-m-d'); + $this->assertPostingWindow($receipt_date, 'Receipt'); + $payment_method = trim((string)($data['payment_method'] ?? '')); + $notes = trim((string)($data['notes'] ?? '')); + $cash_amount = round((float)($data['amount'] ?? 0), 4); + $allocations = $data['allocations'] ?? []; + + if (is_string($allocations)) { + $allocations = json_decode($allocations, true) ?: []; + } + + if ($billing_id <= 0) throw new Exception("Receipt billing is required."); + + $sth = $this->pdo->prepare( + "SELECT b.*, + COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS received_amount + FROM td_receipt_billing b + LEFT JOIN td_receipt r + ON r.company_id = b.company_id + AND r.receipt_billing_id = b.id + WHERE b.company_id = :company_id + AND b.id = :id + GROUP BY b.id + FOR UPDATE" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $billing_id]); + $billing = $sth->fetch(PDO::FETCH_ASSOC); + if (!$billing) throw new Exception("Receipt billing not found."); + if ((int)$billing['status'] === 4) throw new Exception("Cannot receive against a void receipt billing."); + + $contact_id = (int)$billing['contact_id']; + $billing_balance = round((float)$billing['amount'] - (float)$billing['received_amount'], 4); + if ($billing_balance <= 0.0001) throw new Exception("Receipt billing is already settled."); + if ($cash_amount - $billing_balance > 0.0001) { + throw new Exception("Receipt amount exceeds receipt billing balance."); + } + + $sth = $this->pdo->prepare( + "SELECT ba.invoice_id, ba.doc_type, COALESCE(i.department_id, 0) AS department_id, ba.amount, + COALESCE(SUM(CASE WHEN r.status = 1 THEN ra.amount ELSE 0 END), 0) AS received_line_amount + FROM td_receipt_billing_item ba + JOIN td_invoice i + ON i.company_id = ba.company_id + AND i.id = ba.invoice_id + LEFT JOIN td_receipt_item ra + ON ra.company_id = ba.company_id + AND ra.invoice_id = ba.invoice_id + LEFT JOIN td_receipt r + ON r.company_id = ra.company_id + AND r.id = ra.receipt_id + AND r.receipt_billing_id = ba.billing_id + WHERE ba.company_id = :company_id + AND ba.billing_id = :billing_id + GROUP BY ba.id + ORDER BY ba.id ASC" + ); + $sth->execute([':company_id' => $this->company_id, ':billing_id' => $billing_id]); + $billing_lines = $sth->fetchAll(PDO::FETCH_ASSOC); + if (!$billing_lines) throw new Exception("Receipt billing has no documents."); + + $validated = []; + $billing_line_map = []; + foreach ($billing_lines as $line) { + $billing_line_map[(int)$line['invoice_id']] = [ + 'invoice_id' => (int)$line['invoice_id'], + 'doc_type' => $line['doc_type'], + 'department_id' => (int)($line['department_id'] ?? 0), + 'remaining' => max(0, (float)$line['amount'] - (float)$line['received_line_amount']), + ]; + } + + if ($allocations) { + $invoice_total = 0.0; + $credit_total = 0.0; + + foreach ($allocations as $line) { + $invoice_id = (int)($line['invoice_id'] ?? 0); + $amount = abs((float)($line['amount'] ?? 0)); + if ($invoice_id <= 0 || $amount <= 0) continue; + if (!isset($billing_line_map[$invoice_id])) { + throw new Exception("Receipt allocation document is not part of this billing."); + } + + $billing_line = $billing_line_map[$invoice_id]; + if ($amount - $billing_line['remaining'] > 0.0001) { + throw new Exception("Receipt allocation exceeds billing line balance."); + } + + if ($billing_line['doc_type'] === 'credit_note') $credit_total += $amount; + else $invoice_total += $amount; + + $validated[] = [ + 'invoice_id' => $invoice_id, + 'doc_type' => $billing_line['doc_type'], + 'department_id' => (int)($line['department_id'] ?? $billing_line['department_id'] ?? 0), + 'amount' => round($amount, 4), + ]; + } + + $cash_amount = round($invoice_total - $credit_total, 4); + if ($cash_amount <= 0) throw new Exception("Cash receipt amount must be greater than zero."); + if ($cash_amount - $billing_balance > 0.0001) { + throw new Exception("Receipt amount exceeds receipt billing balance."); + } + } else { + if ($cash_amount <= 0) throw new Exception("Receipt amount is required."); + + $ratio = $cash_amount / max($billing_balance, 0.0001); + foreach ($billing_lines as $line) { + $remaining_line_amount = max(0, (float)$line['amount'] - (float)$line['received_line_amount']); + if ($remaining_line_amount <= 0.0001) continue; + + $line_amount = round($remaining_line_amount * $ratio, 4); + if ($line_amount <= 0) continue; + if ($line_amount - $remaining_line_amount > 0.0001) $line_amount = $remaining_line_amount; + + $validated[] = [ + 'invoice_id' => (int)$line['invoice_id'], + 'doc_type' => $line['doc_type'], + 'department_id' => (int)($line['department_id'] ?? 0), + 'amount' => $line_amount, + ]; + } + } + + if (!$validated) throw new Exception("Receipt billing has no remaining document balances."); + + $formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0 + ? (int)$data['formula_id'] : null; + + $log = [array_merge($logging, ['action' => 'create_receipt'])]; + + $this->pdo->prepare( + "INSERT INTO td_receipt + (company_id, uuid, receipt_number, contact_id, department_id, receipt_billing_id, receipt_date, + payment_method, amount, formula_id, status, notes, `log`, created_at) + VALUES + (:company_id, :uuid, :receipt_number, :contact_id, :department_id, :receipt_billing_id, :receipt_date, + :payment_method, :amount, :formula_id, 1, :notes, :log, :created_at)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':receipt_number' => $this->generateReceiptNumber(), + ':contact_id' => $contact_id, + ':department_id' => (int)($data['department_id'] ?? 0), + ':receipt_billing_id' => $billing_id, + ':receipt_date' => $receipt_date, + ':payment_method' => $payment_method, + ':amount' => $cash_amount, + ':formula_id' => $formula_id, + ':notes' => $notes, + ':log' => json_encode($log), + ':created_at' => date('Y-m-d H:i:s'), + ]); + + $receipt_id = (int)$this->pdo->lastInsertId(); + + foreach ($validated as $line) { + $this->pdo->prepare( + "INSERT INTO td_receipt_item + (company_id, receipt_id, invoice_id, doc_type, department_id, amount) + VALUES + (:company_id, :receipt_id, :invoice_id, :doc_type, :department_id, :amount)" + )->execute([ + ':company_id' => $this->company_id, + ':receipt_id' => $receipt_id, + ':invoice_id' => $line['invoice_id'], + ':doc_type' => $line['doc_type'], + ':department_id' => (int)($line['department_id'] ?? 0), + ':amount' => $line['amount'], + ]); + + $this->refreshInvoiceSettlementStatus($line['invoice_id']); + } + + $this->refreshReceiptBillingStatus($billing_id); + + return $receipt_id; + } + + public function saveFormula(int $id, ?int $formula_id): void + { + $sth = $this->pdo->prepare( + "SELECT receipt_date, status FROM td_receipt + WHERE id = :id AND company_id = :company_id" + ); + $sth->execute([ + ':id' => $id, + ':company_id' => $this->company_id, + ]); + $receipt = $sth->fetch(PDO::FETCH_ASSOC); + if (!$receipt) throw new Exception("Receipt not found."); + if ((int)$receipt['status'] === 4) throw new Exception("Cannot update a void receipt."); + $this->assertPostingWindow($receipt['receipt_date'] ?: date('Y-m-d'), 'Receipt'); + + $sth = $this->pdo->prepare( + "UPDATE td_receipt SET formula_id = :formula_id + WHERE id = :id AND company_id = :company_id AND status != 4" + ); + $sth->execute([ + ':formula_id' => $formula_id, + ':id' => $id, + ':company_id' => $this->company_id, + ]); + } +} +?> diff --git a/app/assets/utils/classes/ReportManager.php b/app/assets/utils/classes/ReportManager.php index e1b417c..9018adb 100644 --- a/app/assets/utils/classes/ReportManager.php +++ b/app/assets/utils/classes/ReportManager.php @@ -307,6 +307,90 @@ class ReportManager return $count; } + public function getDashboardStockTotals(): array + { + $sth = $this->pdo->prepare( + "SELECT + ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in, + ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out + FROM warehouse_balance + WHERE company_id = :company_id" + ); + $sth->execute([':company_id' => $this->companyId]); + return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0]; + } + + public function getDashboardOrderStats(): array + { + $sth = $this->pdo->prepare( + "SELECT COUNT(*), COALESCE(SUM(subtotal), 0) + FROM td_order + WHERE company_id = :company_id + AND status != -1" + ); + $sth->execute([':company_id' => $this->companyId]); + [$total_orders, $total_revenue] = $sth->fetch(PDO::FETCH_NUM) ?: [0, 0]; + + return [ + 'total_orders' => (int)$total_orders, + 'total_revenue' => (float)$total_revenue, + ]; + } + + public function getDashboardOpenDocumentCounts(): array + { + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_invoice + WHERE company_id = :company_id + AND doc_type = 'invoice' + AND status IN (0, 1, 3)" + ); + $sth->execute([':company_id' => $this->companyId]); + $unpaid_invoices = (int)$sth->fetchColumn(); + + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_return + WHERE company_id = :company_id + AND status = 0" + ); + $sth->execute([':company_id' => $this->companyId]); + $pending_returns = (int)$sth->fetchColumn(); + + return [ + 'unpaid_invoices' => $unpaid_invoices, + 'pending_returns' => $pending_returns, + ]; + } + + public function getDashboardStockHealth(): array + { + $low_stock_items = $this->getLowStockItems(); + $critical_count = count(array_filter($low_stock_items, fn($i) => $i['status'] === 'critical')); + $warning_count = count(array_filter($low_stock_items, fn($i) => $i['status'] === 'warning')); + $total_in_stock = (int)$this->getTotalProductInStock(); + $healthy_count = max(0, $total_in_stock - $critical_count - $warning_count); + + return [ + 'low_stock' => count($low_stock_items), + 'stock_health' => [ + 'healthy' => $healthy_count, + 'warning' => $warning_count, + 'critical' => $critical_count, + 'total' => $total_in_stock, + ], + 'low_stock_items' => $low_stock_items, + ]; + } + + public function getDashboardMasterCounts(): array + { + return [ + 'total_products' => (int)$this->getTotalProduct(), + 'total_contacts' => (int)$this->getTotalContact(), + 'total_warehouses' => (int)$this->getTotalWarehouse(), + ]; + } + /** * Return all SKUs at or below their reorder_point with full product and warehouse details. * @@ -1192,7 +1276,7 @@ class ReportManager rl.td_stock_id, rl.login, u.name AS user_name - FROM md_rack_log rl + FROM td_rack_log rl LEFT JOIN `{$main_db}`.user u ON u.user_id = rl.user_id WHERE rl.company_id = :company_id diff --git a/app/assets/utils/classes/ReturnManager.php b/app/assets/utils/classes/ReturnManager.php index 5610a23..432588f 100644 --- a/app/assets/utils/classes/ReturnManager.php +++ b/app/assets/utils/classes/ReturnManager.php @@ -81,6 +81,71 @@ class ReturnManager { return 'td_stock_' . $warehouse_id; } + /** + * Derive receipt status from td_stock_* rows linked to this return. + * 0 = no stock-in rows yet, 3 = goods restocked (stock-in rows exist). + * ReturnManager.confirmReturn() always inserts status=1 rows, so only 0 or 3 is possible. + */ + private function deriveReceiptStatus(array $ret): int + { + $sth = $this->pdo->prepare( + "SELECT table_name FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name LIKE 'td_stock_%'" + ); + $sth->execute(); + $tables = $sth->fetchAll(PDO::FETCH_COLUMN); + + foreach ($tables as $table) { + if (!preg_match('/^td_stock_\d+$/', $table)) continue; + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM `{$table}` + WHERE company_id = :company_id + AND source = 'return' + AND source_id = :return_id + AND type = 'in' + AND status != -1" + ); + $sth->execute([':company_id' => $this->company_id, ':return_id' => $ret['id']]); + if ((int)$sth->fetchColumn() > 0) return 3; + } + + return 0; + } + + private function syncReturnItems(int $return_id, array $items): void + { + $this->pdo->prepare( + "DELETE FROM td_return_item WHERE return_id = :return_id AND company_id = :company_id" + )->execute([':return_id' => $return_id, ':company_id' => $this->company_id]); + + $sth = $this->pdo->prepare( + "INSERT INTO td_return_item + (company_id, return_id, item_id, product_sku, product_name, + quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, stock_out_warehouse_id) + VALUES + (:company_id, :return_id, :item_id, :product_sku, :product_name, + :quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_out_id, :stock_out_warehouse_id)" + ); + foreach ($items as $pos => $item) { + $sth->execute([ + ':company_id' => $this->company_id, + ':return_id' => $return_id, + ':item_id' => $pos + 1, + ':product_sku' => $item['product_sku'] ?? '', + ':product_name' => $item['product_name'] ?? '', + ':quantity' => (float)($item['quantity'] ?? 0), + ':unit_price' => (float)($item['unit_price'] ?? $item['price'] ?? 0), + ':total_price' => (float)($item['total_price'] ?? 0), + ':tax_amount' => (float)($item['tax_amount'] ?? 0), + ':tax_rate' => (float)($item['tax_rate'] ?? 0), + ':warehouse_id' => (int)($item['warehouse_id'] ?? 0), + ':stock_out_id' => (int)($item['stock_out_id'] ?? 0), + ':stock_out_warehouse_id' => (int)($item['stock_out_warehouse_id'] ?? 0), + ]); + } + } + // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Read // ───────────────────────────────────────────────────────────── @@ -116,7 +181,12 @@ class ReturnManager { ORDER BY r.id DESC" ); $sth->execute($params); - return $sth->fetchAll(PDO::FETCH_ASSOC); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as &$row) { + $row['receipt_status'] = $this->deriveReceiptStatus($row); + } + unset($row); + return $rows; } /** @@ -145,10 +215,34 @@ class ReturnManager { $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) return false; - $row['items'] = json_decode($row['items'] ?? '[]', true) ?: []; + + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_return_item + WHERE return_id = :return_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':return_id' => $id, ':company_id' => $this->company_id]); + $row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC); + + $row['receipt_status'] = $this->deriveReceiptStatus($row); return $row; } + public function getActiveCreditNoteId(int $return_id): int + { + $sth = $this->pdo->prepare( + "SELECT id FROM td_invoice + WHERE company_id = :company_id + AND source = 'return' + AND source_id = :return_id + AND doc_type = 'credit_note' + AND status != 4 + LIMIT 1" + ); + $sth->execute([':company_id' => $this->company_id, ':return_id' => $return_id]); + return (int)$sth->fetchColumn(); + } + // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Write // ───────────────────────────────────────────────────────────── @@ -168,14 +262,39 @@ class ReturnManager { * @return int New td_return.id on insert, 0 on update. * @throws Exception If updating a non-draft return. */ + public function saveFormula(int $id, ?int $formula_id): void + { + $sth = $this->pdo->prepare( + "SELECT status FROM td_return + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) throw new Exception("Return not found."); + if ((int)$row['status'] === -1) throw new Exception("Cannot update a cancelled return."); + + $this->pdo->prepare( + "UPDATE td_return SET formula_id = :formula_id + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':formula_id' => $formula_id, + ':id' => $id, + ':company_id' => $this->company_id, + ]); + } + public function saveReturn(array $data, array $logging): int { $id = (int)($data['id'] ?? 0); $items = $data['items'] ?? []; - $refund_amount = array_reduce($items, fn($carry, $item) => - $carry + (float)($item['total_price'] ?? 0), 0.0 - ); + $tax_adjustment = (float)($data['tax_adjustment'] ?? 0); + if (abs($tax_adjustment) > 0.30) throw new Exception("Tax adjustment cannot exceed ±0.30."); + + $subtotal = array_reduce($items, fn($c, $i) => $c + (float)($i['total_price'] ?? 0), 0.0); + $tax = round(array_reduce($items, fn($c, $i) => $c + (float)($i['tax_amount'] ?? 0), 0.0), 2) + $tax_adjustment; + $refund_amount = $subtotal + $tax; if ($id > 0) { @@ -192,57 +311,85 @@ class ReturnManager { $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = $logging; + $formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0 + ? (int)$data['formula_id'] : null; + $this->pdo->prepare( "UPDATE td_return SET - contact_id = :contact_id, - return_date = :return_date, - reason = :reason, - items = :items, - refund_amount = :refund_amount, - notes = :notes, - `log` = :log + contact_id = :contact_id, + department_id = :department_id, + return_date = :return_date, + reason = :reason, + refund_amount = :refund_amount, + tax = :tax, + tax_adjustment = :tax_adjustment, + notes = :notes, + formula_id = :formula_id, + `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ - ':contact_id' => (int)($data['contact_id'] ?? 0), - ':return_date' => $data['return_date'] ?? date('Y-m-d'), - ':reason' => $data['reason'] ?? '', - ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), - ':refund_amount' => $refund_amount, - ':notes' => $data['notes'] ?? '', - ':log' => json_encode($log), - ':id' => $id, - ':company_id' => $this->company_id, + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':department_id' => (int)($data['department_id'] ?? 0), + ':return_date' => $data['return_date'] ?? date('Y-m-d'), + ':reason' => $data['reason'] ?? '', + ':refund_amount' => $refund_amount, + ':tax' => $tax, + ':tax_adjustment' => $tax_adjustment, + ':notes' => $data['notes'] ?? '', + ':formula_id' => $formula_id, + ':log' => json_encode($log), + ':id' => $id, + ':company_id' => $this->company_id, ]); + $this->syncReturnItems($id, $items); return 0; } else { + $formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0 + ? (int)$data['formula_id'] : null; + + // Inherit department from parent order if not explicitly provided + $dept_id = (int)($data['department_id'] ?? 0); + if ($dept_id === 0 && (int)($data['order_id'] ?? 0) > 0) { + $sth_dept = $this->pdo->prepare( + "SELECT department_id FROM td_order WHERE id = :id AND company_id = :cid" + ); + $sth_dept->execute([':id' => (int)$data['order_id'], ':cid' => $this->company_id]); + $dept_id = (int)($sth_dept->fetchColumn() ?: 0); + } + $this->pdo->prepare( "INSERT INTO td_return (company_id, uuid, return_number, order_id, invoice_id, - contact_id, return_date, status, - reason, refund_amount, items, notes, `log`) + contact_id, department_id, return_date, status, + reason, refund_amount, tax, tax_adjustment, notes, formula_id, `log`) VALUES (:company_id, :uuid, :return_number, :order_id, :invoice_id, - :contact_id, :return_date, 0, - :reason, :refund_amount, :items, :notes, :log)" + :contact_id, :department_id, :return_date, 0, + :reason, :refund_amount, :tax, :tax_adjustment, :notes, :formula_id, :log)" )->execute([ - ':company_id' => $this->company_id, - ':uuid' => bin2hex(random_bytes(16)), - ':return_number' => $this->generateReturnNumber(), - ':order_id' => (int)($data['order_id'] ?? 0), - ':invoice_id' => (int)($data['invoice_id'] ?? 0), - ':contact_id' => (int)($data['contact_id'] ?? 0), - ':return_date' => $data['return_date'] ?? date('Y-m-d'), - ':reason' => $data['reason'] ?? '', - ':refund_amount' => $refund_amount, - ':items' => json_encode($items, JSON_UNESCAPED_UNICODE), - ':notes' => $data['notes'] ?? '', - ':log' => json_encode([$logging]), + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':return_number' => $this->generateReturnNumber(), + ':order_id' => (int)($data['order_id'] ?? 0), + ':invoice_id' => (int)($data['invoice_id'] ?? 0), + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':department_id' => $dept_id, + ':return_date' => $data['return_date'] ?? date('Y-m-d'), + ':reason' => $data['reason'] ?? '', + ':refund_amount' => $refund_amount, + ':tax' => $tax, + ':tax_adjustment' => $tax_adjustment, + ':notes' => $data['notes'] ?? '', + ':formula_id' => $formula_id, + ':log' => json_encode([$logging]), ]); - return (int)$this->pdo->lastInsertId(); + $new_id = (int)$this->pdo->lastInsertId(); + $this->syncReturnItems($new_id, $items); + return $new_id; } } @@ -261,17 +408,19 @@ class ReturnManager { * InvoiceManager::createCreditNote() — auto CN, status=1 (issued). * If false: CN must be created manually from the return detail page. * - * Stock-in rows are ALWAYS status=1 regardless of default_stock_status config. - * Confirming a return is a final business decision — no warehouse re-approval needed. + * Stock-in rows respect $auto_approve (mirrors default_stock_status config). + * When false, rows are created as draft (status=0) and warehouse staff approve + * them via approve_stock.php; rack occupation and balance are deferred. * * Must be called inside dbTransaction() by the caller. * - * @param int $return_id td_return.id to confirm. - * @param string $uuid UUID prefix for stock-in rows. - * @param array $logging Audit entry. - * @param object $whMgmt WarehouseManager instance. - * @param object $invMgmt InvoiceManager instance. - * @param bool $auto_cn Whether to auto-create credit note. + * @param int $return_id td_return.id to confirm. + * @param string $uuid UUID prefix for stock-in rows. + * @param array $logging Audit entry. + * @param object $whMgmt WarehouseManager instance. + * @param object $invMgmt InvoiceManager instance. + * @param bool $auto_cn Whether to auto-create credit note. + * @param bool $auto_approve Whether to auto-approve stock-in rows (status=1) immediately. * @throws Exception */ public function confirmReturn( @@ -280,7 +429,8 @@ class ReturnManager { array $logging, $whMgmt, $invMgmt, - bool $auto_cn = false + bool $auto_cn = false, + bool $auto_approve = false ): void { $sth = $this->pdo->prepare( @@ -293,9 +443,19 @@ class ReturnManager { if (!$return) throw new Exception("Return request not found."); if ((int)$return['status'] !== 0) throw new Exception("Return is already confirmed or cancelled."); - $items = json_decode($return['items'] ?? '[]', true) ?: []; + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_return_item + WHERE return_id = :return_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':return_id' => $return_id, ':company_id' => $this->company_id]); + $items = $sth2->fetchAll(PDO::FETCH_ASSOC); if (empty($items)) throw new Exception("Cannot confirm a return with no items."); + if ($auto_approve) { + $whMgmt->assertStockMovementWindow($return['return_date'] ?? date('Y-m-d'), 'Return stock approval'); + } + // ── Per-item: restock ───────────────────────────────────────────── foreach ($items as $i => $item) { @@ -340,7 +500,6 @@ class ReturnManager { $item_log = [array_merge($logging, ['action' => 'confirm_return_item'])]; $quantity = (float)($item['quantity'] ?? 0); - // INSERT stock-in row — status=1 forced // price comes from the linked SO stock-out row (selling price), not the return form. // ref_id links back to the stock-out row so the report can trace the original sale. $this->pdo->prepare( @@ -355,7 +514,7 @@ class ReturnManager { :zone, :aisle, :rack, :contact_id, :description, :log, 'in', :lot_number, :serial_number, - 'return', :source_id, :ref_id, :price, 1)" + 'return', :source_id, :ref_id, :price, :status)" )->execute([ ':uuid' => $item_uuid, ':company_id' => $this->company_id, @@ -373,21 +532,23 @@ class ReturnManager { ':source_id' => $return_id, ':ref_id' => $stock_out_id, ':price' => (float)$stock_out['price'], + ':status' => $auto_approve ? 1 : 0, ]); $stock_in_id = (int)$this->pdo->lastInsertId(); - // Occupy rack + adjust balance — forced since status=1 - $whMgmt->occupyRack( - $warehouse_id, - $item['zone'] ?? '', - $item['aisle'] ?? '', - $item['rack'] ?? '', - $product_sku, - $stock_in_id, - true - ); - $whMgmt->adjustBalance('in', $warehouse_id, $product_sku, 0, $quantity); + if ($auto_approve) { + $whMgmt->occupyRack( + $warehouse_id, + $item['zone'] ?? '', + $item['aisle'] ?? '', + $item['rack'] ?? '', + $product_sku, + $stock_in_id, + true + ); + $whMgmt->adjustBalance('in', $warehouse_id, $product_sku, 0, $quantity); + } } // ── Update return status → 1 (confirmed) ───────────────────────── @@ -411,7 +572,9 @@ class ReturnManager { $invoice_id, $items, (float)$return['refund_amount'], - array_merge($logging, ['action' => 'auto_credit_note']) + array_merge($logging, ['action' => 'auto_credit_note']), + 'return', + $return_id ); } } @@ -421,11 +584,11 @@ class ReturnManager { * * Business rule: * A return can be cancelled as long as no active downstream document exists. - * Active means status != -1 (not voided). + * Active means status != 4 (not voided). * * Downstream document that blocks cancellation: * - td_invoice (credit note) where ref_invoice_id links back via - * order_id = return.order_id AND doc_type = 'credit_note' AND status != -1. + * order_id = return.order_id AND doc_type = 'credit_note' AND status != 4. * Credit notes store order_id (not return_id), so we match via order_id * and doc_type. * @@ -472,7 +635,7 @@ class ReturnManager { WHERE company_id = :company_id AND order_id = :order_id AND doc_type = 'credit_note' - AND status != -1" + AND status != 4" ); $sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]); if ((int)$sth->fetchColumn() > 0) { @@ -485,7 +648,13 @@ class ReturnManager { // ── If confirmed: reverse stock-in rows + rack/balance ──────────── if ($status === 1) { - $items = json_decode($return['items'] ?? '[]', true) ?: []; + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_return_item + WHERE return_id = :return_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':return_id' => $return_id, ':company_id' => $this->company_id]); + $items = $sth2->fetchAll(PDO::FETCH_ASSOC); foreach ($items as $item) { $warehouse_id = (int)($item['warehouse_id'] ?? 0); @@ -496,44 +665,36 @@ class ReturnManager { $table = $this->stockTableNameFromWarehouseId($warehouse_id); - // Find the stock-in row created by confirmReturn() + // Fetch ALL non-cancelled stock-in rows (status=1 approved or status=0 draft) $sth = $this->pdo->prepare( - "SELECT id, zone, aisle, rack FROM `{$table}` - WHERE company_id = :company_id - AND source = 'return' - AND source_id = :return_id + "SELECT id, status, zone, aisle, rack, `date` FROM `{$table}` + WHERE company_id = :company_id + AND source = 'return' + AND source_id = :return_id AND product_sku = :sku - AND status = 1 - LIMIT 1" + AND status != -1" ); $sth->execute([ ':company_id' => $this->company_id, ':return_id' => $return_id, ':sku' => $product_sku, ]); - $stock_row = $sth->fetch(PDO::FETCH_ASSOC); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); - if (!$stock_row) continue; + foreach ($rows as $row) { + $this->pdo->prepare( + "UPDATE `{$table}` SET status = -1 + WHERE id = :id AND company_id = :company_id" + )->execute([':id' => $row['id'], ':company_id' => $this->company_id]); - // Soft-delete the stock-in row - $this->pdo->prepare( - "UPDATE `{$table}` SET status = -1 - WHERE id = :id AND company_id = :company_id" - )->execute([ - ':id' => $stock_row['id'], - ':company_id' => $this->company_id, - ]); - - // Release rack occupation - $whMgmt->releaseRack( - $warehouse_id, - $stock_row['zone'], - $stock_row['aisle'], - $stock_row['rack'] - ); - - // Reverse balance - $whMgmt->adjustBalance('out', $warehouse_id, $product_sku, 0, $quantity); + if ((int)$row['status'] === 1) { + $whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Return stock cancellation'); + // Approved rows: reverse rack occupation + balance + $whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']); + $whMgmt->adjustBalance('out', $warehouse_id, $product_sku, 0, $quantity); + } + // Draft (status=0) rows: just soft-delete — rack/balance were never applied + } } } diff --git a/app/assets/utils/classes/SmtpManager.php b/app/assets/utils/classes/SmtpManager.php new file mode 100644 index 0000000..f01e28a --- /dev/null +++ b/app/assets/utils/classes/SmtpManager.php @@ -0,0 +1,179 @@ +pdo = $pdo; + $this->companyId = $company_id; + $this->method = $method; + $this->pinkey = $pinkey; + $this->iv = $iv; + } + + public function get(): ?array + { + $sth = $this->pdo->prepare( + "SELECT + server, + port, + username, + from_name, + from_email, + encryption + FROM company_smtp + WHERE company_id = :company_id + LIMIT 1" + ); + $sth->execute([':company_id' => $this->companyId]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + return $row ?: null; + } + + public function save(array $data): void + { + $server = trim((string)($data['smtp_host'] ?? '')); + $port = trim((string)($data['smtp_port'] ?? '587')); + $username = trim((string)($data['smtp_username'] ?? '')); + $raw_pass = (string)($data['smtp_password'] ?? ''); + $from_name = trim((string)($data['smtp_from_name'] ?? '')); + $from_email = trim((string)($data['smtp_from_email'] ?? '')); + $encryption = trim((string)($data['smtp_encryption'] ?? 'tls')); + + if ($server === '' || $username === '') { + throw new Exception('SMTP host and username are required.'); + } + + if (!in_array($port, ['25', '465', '587'], true)) $port = '587'; + if (!in_array($encryption, ['tls', 'ssl', 'none'], true)) $encryption = 'tls'; + + $sth = $this->pdo->prepare( + "SELECT smtp_id + FROM company_smtp + WHERE company_id = :company_id + LIMIT 1" + ); + $sth->execute([':company_id' => $this->companyId]); + $existing_id = $sth->fetchColumn(); + + if ($existing_id) { + $this->update((int)$existing_id, $server, $port, $username, $raw_pass, $from_name, $from_email, $encryption); + return; + } + + if ($raw_pass === '') { + throw new Exception('Password is required for a new SMTP configuration.'); + } + + $this->pdo->prepare( + "INSERT INTO company_smtp + (company_id, server, port, username, password, + from_name, from_email, encryption, updated_at) + VALUES + (:company_id, :server, :port, :username, :password, + :from_name, :from_email, :encryption, NOW())" + )->execute([ + ':company_id' => $this->companyId, + ':server' => $server, + ':port' => $port, + ':username' => $username, + ':password' => $this->encryptPassword($raw_pass), + ':from_name' => $from_name, + ':from_email' => $from_email, + ':encryption' => $encryption, + ]); + } + + public function sendTest(string $to): void + { + $to = trim($to); + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { + throw new Exception('Invalid recipient email address.'); + } + + $sth = $this->pdo->prepare( + "SELECT from_name + FROM company_smtp + WHERE company_id = :company_id + LIMIT 1" + ); + $sth->execute([':company_id' => $this->companyId]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + throw new Exception('No SMTP configuration found. Please save your settings first.'); + } + + $mailer = new mailer(['pdo1' => $this->pdo]); + $mailer->send_email([ + 'company_id' => $this->companyId, + 'smtp' => [], + 'to' => $to, + 'subject' => 'SMTP Test - WMS', + 'message' => "This is a test email from your WMS SMTP configuration.\n\nIf you received this, your SMTP settings are working correctly.", + 'channel_name' => $row['from_name'] ?: 'WMS System', + 'key' => $this->pinkey, + ]); + } + + private function update(int $smtp_id, string $server, string $port, string $username, string $raw_pass, string $from_name, string $from_email, string $encryption): void + { + if ($raw_pass !== '') { + $this->pdo->prepare( + "UPDATE company_smtp SET + server = :server, + port = :port, + username = :username, + password = :password, + from_name = :from_name, + from_email = :from_email, + encryption = :encryption, + updated_at = NOW() + WHERE smtp_id = :smtp_id" + )->execute([ + ':server' => $server, + ':port' => $port, + ':username' => $username, + ':password' => $this->encryptPassword($raw_pass), + ':from_name' => $from_name, + ':from_email' => $from_email, + ':encryption' => $encryption, + ':smtp_id' => $smtp_id, + ]); + return; + } + + $this->pdo->prepare( + "UPDATE company_smtp SET + server = :server, + port = :port, + username = :username, + from_name = :from_name, + from_email = :from_email, + encryption = :encryption, + updated_at = NOW() + WHERE smtp_id = :smtp_id" + )->execute([ + ':server' => $server, + ':port' => $port, + ':username' => $username, + ':from_name' => $from_name, + ':from_email' => $from_email, + ':encryption' => $encryption, + ':smtp_id' => $smtp_id, + ]); + } + + private function encryptPassword(string $plain): string + { + return openssl_encrypt($plain, $this->method, $this->pinkey, 0, $this->iv); + } +} +?> diff --git a/app/assets/utils/classes/StockManager.php b/app/assets/utils/classes/StockManager.php index 4fced9e..acb11b3 100644 --- a/app/assets/utils/classes/StockManager.php +++ b/app/assets/utils/classes/StockManager.php @@ -244,6 +244,9 @@ class StockManager { $table_log[] = $logging; if ($id > 0) { + if ((int)($row['status'] ?? 0) === 1) { + $whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Stock-in edit'); + } // Update: only metadata fields are editable after creation $this->pdo->prepare( @@ -357,6 +360,9 @@ class StockManager { $table_log[] = $logging; if ($id > 0) { + if ((int)($row['status'] ?? 0) === 1) { + $whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Stock-out edit'); + } // Update: only metadata fields are editable after creation $this->pdo->prepare( @@ -500,6 +506,11 @@ class StockManager { throw new Exception("Paired destination record missing — data integrity issue."); } + if ((int)($from_row['status'] ?? 0) === 1 || (int)($to_row['status'] ?? 0) === 1) { + $whMgmt->assertStockMovementWindow($from_row['date'] ?? null, 'Stock transfer edit'); + $whMgmt->assertStockMovementWindow($to_row['date'] ?? null, 'Stock transfer edit'); + } + $raw_from = $from_row['log'] ?? []; $from_log = is_array($raw_from) ? $raw_from : (json_decode($raw_from, true) ?: []); $from_log[] = $logging; @@ -695,6 +706,8 @@ class StockManager { throw new Exception('Already approved.'); } + $whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Stock approval'); + // ── Approve this row ────────────────────────────────────────────── $this->pdo->prepare( "UPDATE `{$table}` SET status = 1 WHERE id = :id AND company_id = :company_id" @@ -710,7 +723,8 @@ class StockManager { $row['product_sku'], $id ); - $whMgmt->adjustBalance('in', $warehouse_id, $row['product_sku'], 0, (float)$row['in']); + $whMgmt->adjustBalance('in', $warehouse_id, $row['product_sku'], 0, (float)$row['in'], + $id, $row['source'] ?? '', (int)($row['source_id'] ?? 0)); } elseif ($type === 'out') { @@ -723,7 +737,7 @@ class StockManager { ON out_rows.company_id = src.company_id AND out_rows.ref_id = src.id AND out_rows.`out` > 0 - AND out_rows.status != -1 + AND out_rows.status = 1 WHERE src.id = :ref_id AND src.company_id = :company_id GROUP BY src.id, src.`in`" @@ -739,7 +753,8 @@ class StockManager { if ($remaining_qty <= 0.000001) { $whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']); } - $whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out']); + $whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out'], + $id, $row['source'] ?? '', (int)($row['source_id'] ?? 0)); } elseif ($type === 'transfer') { @@ -791,6 +806,13 @@ class StockManager { $from_row = $row; } + if ($from_row) { + $whMgmt->assertStockMovementWindow($from_row['date'] ?? null, 'Stock transfer approval'); + } + if ($inbound_row) { + $whMgmt->assertStockMovementWindow($inbound_row['date'] ?? null, 'Stock transfer approval'); + } + // Approve outbound row if ($from_row && (int)$from_row['status'] === 0) { $this->pdo->prepare( @@ -813,7 +835,8 @@ class StockManager { $from_wh_id, $from_row['zone'], $from_row['aisle'], $from_row['rack'] ); - $whMgmt->adjustBalance('out', $from_wh_id, $from_row['product_sku'], 0, (float)$from_row['out']); + $whMgmt->adjustBalance('out', $from_wh_id, $from_row['product_sku'], 0, (float)$from_row['out'], + (int)$from_row['id'], $from_row['source'] ?? '', (int)($from_row['source_id'] ?? 0)); } if ($inbound_row && $paired_wh_id) { $whMgmt->occupyRack( @@ -822,7 +845,8 @@ class StockManager { $inbound_row['product_sku'], $inbound_row['id'] ); - $whMgmt->adjustBalance('in', $paired_wh_id, $inbound_row['product_sku'], 0, (float)$inbound_row['in']); + $whMgmt->adjustBalance('in', $paired_wh_id, $inbound_row['product_sku'], 0, (float)$inbound_row['in'], + (int)$inbound_row['id'], $inbound_row['source'] ?? '', (int)($inbound_row['source_id'] ?? 0)); } } } diff --git a/app/assets/utils/classes/StockSourceManager.php b/app/assets/utils/classes/StockSourceManager.php new file mode 100644 index 0000000..e07cff5 --- /dev/null +++ b/app/assets/utils/classes/StockSourceManager.php @@ -0,0 +1,86 @@ +pdo = $pdo; + $this->companyId = $company_id; + } + + public function getBySource(string $source, int $source_id): array + { + $source = trim($source); + if ($source === '' || $source_id <= 0) { + throw new Exception('source and source_id are required.'); + } + + $warehouses = $this->getWarehouseNames(); + $rows = []; + + foreach ($this->getStockTables() as $table) { + if (!preg_match('/^td_stock_(\d+)$/', $table, $match)) continue; + + $warehouse_id = (int)$match[1]; + $sth = $this->pdo->prepare( + "SELECT s.id, s.type, s.product_sku, s.`in`, s.`out`, + s.zone, s.aisle, s.rack, s.lot_number, s.serial_number, + s.status, s.date, s.price, + p.product_name + FROM `{$table}` s + LEFT JOIN md_product p + ON p.company_id = s.company_id + AND p.sku = s.product_sku + WHERE s.company_id = :company_id + AND s.source = :source + AND s.source_id = :source_id + ORDER BY s.date ASC" + ); + $sth->execute([ + ':company_id' => $this->companyId, + ':source' => $source, + ':source_id' => $source_id, + ]); + + foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { + $row['warehouse_id'] = $warehouse_id; + $row['warehouse_name'] = $warehouses[$warehouse_id] ?? "WH {$warehouse_id}"; + $row['quantity'] = $row['type'] === 'out' ? (float)$row['out'] : (float)$row['in']; + $rows[] = $row; + } + } + + return $rows; + } + + private function getWarehouseNames(): array + { + $sth = $this->pdo->prepare( + "SELECT id, warehouse_name + FROM md_warehouse + WHERE company_id = :company_id" + ); + $sth->execute([':company_id' => $this->companyId]); + + $warehouses = []; + foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $warehouse) { + $warehouses[(int)$warehouse['id']] = $warehouse['warehouse_name']; + } + return $warehouses; + } + + private function getStockTables(): array + { + $sth = $this->pdo->prepare( + "SELECT table_name + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name LIKE 'td_stock_%'" + ); + $sth->execute(); + return $sth->fetchAll(PDO::FETCH_COLUMN); + } +} +?> diff --git a/app/assets/utils/classes/SupplierReturnManager.php b/app/assets/utils/classes/SupplierReturnManager.php new file mode 100644 index 0000000..92136ed --- /dev/null +++ b/app/assets/utils/classes/SupplierReturnManager.php @@ -0,0 +1,619 @@ +pdo = $pdo; + $this->company_id = $company_id; + } + + // ───────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────── + + private function generateReturnNumber(): string + { + $prefix = 'SRN-' . date('Ymd') . '-'; + + $sth = $this->pdo->prepare( + "SELECT return_number FROM td_supplier_return + WHERE company_id = :company_id + AND return_number LIKE :prefix + ORDER BY return_number DESC + LIMIT 1" + ); + $sth->execute([':company_id' => $this->company_id, ':prefix' => $prefix . '%']); + + $last = $sth->fetchColumn(); + $seq = $last ? ((int)substr($last, -4) + 1) : 1; + return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + } + + private function stockTableName(int $warehouse_id): string + { + if ($warehouse_id <= 0) throw new Exception("Invalid warehouse id."); + return 'td_stock_' . $warehouse_id; + } + + /** + * Derive fulfillment status from td_stock_* rows + shipping tracking number. + * 0 = no stock-out rows + * 1 = stock-out rows exist but at least one is draft (Picking) + * 2 = all stock-out rows confirmed, no tracking number (Packed) + * 3 = all stock-out rows confirmed + tracking number present (Shipped) + */ + private function deriveFulfillmentStatus(array $ret): int + { + $sth = $this->pdo->prepare( + "SELECT table_name FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name LIKE 'td_stock_%'" + ); + $sth->execute(); + $tables = $sth->fetchAll(PDO::FETCH_COLUMN); + + $has_stock_out = false; + $has_draft = false; + foreach ($tables as $table) { + if (!preg_match('/^td_stock_\d+$/', $table)) continue; + $sth = $this->pdo->prepare( + "SELECT status FROM `{$table}` + WHERE company_id = :company_id + AND source = 'supplier_return' + AND source_id = :return_id + AND type = 'out' + AND status != -1" + ); + $sth->execute([':company_id' => $this->company_id, ':return_id' => $ret['id']]); + $rows = $sth->fetchAll(PDO::FETCH_COLUMN); + if (count($rows) > 0) { + $has_stock_out = true; + if (in_array(0, array_map('intval', $rows), true)) { + $has_draft = true; + } + } + } + + if (!$has_stock_out) return 0; + if ($has_draft) return 1; + return trim((string)($ret['shipping_tracking_number'] ?? '')) !== '' ? 3 : 2; + } + + private function syncSupplierReturnItems(int $return_id, array $items): void + { + $this->pdo->prepare( + "DELETE FROM td_supplier_return_item WHERE return_id = :return_id AND company_id = :company_id" + )->execute([':return_id' => $return_id, ':company_id' => $this->company_id]); + + $sth = $this->pdo->prepare( + "INSERT INTO td_supplier_return_item + (company_id, return_id, item_id, product_sku, product_name, + quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_in_id, zone, aisle, rack) + VALUES + (:company_id, :return_id, :item_id, :product_sku, :product_name, + :quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_in_id, :zone, :aisle, :rack)" + ); + foreach ($items as $pos => $item) { + $sth->execute([ + ':company_id' => $this->company_id, + ':return_id' => $return_id, + ':item_id' => $pos + 1, + ':product_sku' => $item['product_sku'] ?? '', + ':product_name' => $item['product_name'] ?? '', + ':quantity' => (float)($item['quantity'] ?? 0), + ':unit_price' => (float)($item['unit_price'] ?? $item['price'] ?? 0), + ':total_price' => (float)($item['total_price'] ?? 0), + ':tax_amount' => (float)($item['tax_amount'] ?? 0), + ':tax_rate' => (float)($item['tax_rate'] ?? 0), + ':warehouse_id' => (int)($item['warehouse_id'] ?? 0), + ':stock_in_id' => (int)($item['stock_in_id'] ?? 0), + ':zone' => $item['zone'] ?? '', + ':aisle' => $item['aisle'] ?? '', + ':rack' => $item['rack'] ?? '', + ]); + } + } + + // ───────────────────────────────────────────────────────────── + // Read + // ───────────────────────────────────────────────────────────── + + public function getReturnList(int $po_id = 0): array + { + $where = ['r.company_id = :company_id']; + $params = [':company_id' => $this->company_id]; + + if ($po_id > 0) { + $where[] = 'r.po_id = :po_id'; + $params[':po_id'] = $po_id; + } + + $sth = $this->pdo->prepare( + "SELECT r.*, + COALESCE(c.contact_name, '') AS contact_name, + p.po_number + FROM td_supplier_return r + LEFT JOIN md_contact c + ON c.company_id = r.company_id + AND c.id = r.contact_id + LEFT JOIN td_purchase_order p + ON p.company_id = r.company_id + AND p.id = r.po_id + WHERE " . implode(' AND ', $where) . " + ORDER BY r.id DESC" + ); + $sth->execute($params); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as &$row) { + $row['fulfillment_status'] = $this->deriveFulfillmentStatus($row); + } + unset($row); + return $rows; + } + + public function getReturnById(int $id): array|false + { + $sth = $this->pdo->prepare( + "SELECT r.*, + COALESCE(c.contact_name, '') AS contact_name, + p.po_number + FROM td_supplier_return r + LEFT JOIN md_contact c + ON c.company_id = r.company_id + AND c.id = r.contact_id + LEFT JOIN td_purchase_order p + ON p.company_id = r.company_id + AND p.id = r.po_id + WHERE r.company_id = :company_id + AND r.id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) return false; + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_supplier_return_item + WHERE return_id = :return_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':return_id' => $row['id'], ':company_id' => $this->company_id]); + $row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC); + $row['fulfillment_status'] = $this->deriveFulfillmentStatus($row); + return $row; + } + + public function getActiveDebitNoteId(int $return_id): int + { + $sth = $this->pdo->prepare( + "SELECT id FROM td_invoice + WHERE company_id = :company_id + AND source = 'supplier_return' + AND source_id = :source_id + AND doc_type = 'supplier_credit_note' + AND status != 4 + LIMIT 1" + ); + $sth->execute([':company_id' => $this->company_id, ':source_id' => $return_id]); + return (int)$sth->fetchColumn(); + } + + public function updateTracking(int $return_id, string $tracking_number): void + { + if ($return_id <= 0) { + throw new Exception("Invalid return id."); + } + + $this->pdo->prepare( + "UPDATE td_supplier_return + SET shipping_tracking_number = :tracking + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':tracking' => trim($tracking_number), + ':id' => $return_id, + ':company_id' => $this->company_id, + ]); + } + + // ───────────────────────────────────────────────────────────── + // Write + // ───────────────────────────────────────────────────────────── + + /** + * Create or update a draft supplier return. + * + * @param array $data Keys: id, po_id, invoice_id, contact_id, + * return_date, reason, items (array), notes. + * @param array $logging Audit entry. + * @return int New td_supplier_return.id on insert, 0 on update. + */ + public function saveReturn(array $data, array $logging): int + { + $id = (int)($data['id'] ?? 0); + $items = $data['items'] ?? []; + + $tax_adjustment = (float)($data['tax_adjustment'] ?? 0); + if (abs($tax_adjustment) > 0.30) throw new Exception("Tax adjustment cannot exceed ±0.30."); + + $subtotal = array_reduce($items, fn($c, $i) => $c + (float)($i['total_price'] ?? 0), 0.0); + $tax = round(array_reduce($items, fn($c, $i) => $c + (float)($i['tax_amount'] ?? 0), 0.0), 2) + $tax_adjustment; + $refund_amount = $subtotal + $tax; + + if ($id > 0) { + $sth = $this->pdo->prepare( + "SELECT status, `log` FROM td_supplier_return + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$row) throw new Exception("Supplier return not found."); + if ((int)$row['status'] !== 0) throw new Exception("Only draft returns can be edited."); + + $log = json_decode($row['log'] ?? '[]', true) ?: []; + $log[] = $logging; + + $this->pdo->prepare( + "UPDATE td_supplier_return SET + contact_id = :contact_id, + department_id = :department_id, + return_date = :return_date, + reason = :reason, + refund_amount = :refund_amount, + tax = :tax, + tax_adjustment = :tax_adjustment, + notes = :notes, + shipping_tracking_number = :tracking, + `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':department_id' => (int)($data['department_id'] ?? 0), + ':return_date' => $data['return_date'] ?? date('Y-m-d'), + ':reason' => $data['reason'] ?? '', + ':refund_amount' => $refund_amount, + ':tax' => $tax, + ':tax_adjustment' => $tax_adjustment, + ':notes' => $data['notes'] ?? '', + ':tracking' => trim($data['shipping_tracking_number'] ?? ''), + ':log' => json_encode($log), + ':id' => $id, + ':company_id' => $this->company_id, + ]); + + $this->syncSupplierReturnItems($id, $items); + return 0; + + } else { + // Inherit department from parent PO if not explicitly provided + $dept_id = (int)($data['department_id'] ?? 0); + if ($dept_id === 0 && (int)($data['po_id'] ?? 0) > 0) { + $sth_dept = $this->pdo->prepare( + "SELECT department_id FROM td_purchase_order WHERE id = :id AND company_id = :cid" + ); + $sth_dept->execute([':id' => (int)$data['po_id'], ':cid' => $this->company_id]); + $dept_id = (int)($sth_dept->fetchColumn() ?: 0); + } + + $this->pdo->prepare( + "INSERT INTO td_supplier_return + (company_id, uuid, return_number, po_id, invoice_id, + contact_id, department_id, return_date, status, + reason, refund_amount, tax, tax_adjustment, notes, `log`) + VALUES + (:company_id, :uuid, :return_number, :po_id, :invoice_id, + :contact_id, :department_id, :return_date, 0, + :reason, :refund_amount, :tax, :tax_adjustment, :notes, :log)" + )->execute([ + ':company_id' => $this->company_id, + ':uuid' => bin2hex(random_bytes(16)), + ':return_number' => $this->generateReturnNumber(), + ':po_id' => (int)($data['po_id'] ?? 0), + ':invoice_id' => (int)($data['invoice_id'] ?? 0), + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':department_id' => $dept_id, + ':return_date' => $data['return_date'] ?? date('Y-m-d'), + ':reason' => $data['reason'] ?? '', + ':refund_amount' => $refund_amount, + ':tax' => $tax, + ':tax_adjustment' => $tax_adjustment, + ':notes' => $data['notes'] ?? '', + ':log' => json_encode([$logging]), + ]); + + $new_id = (int)$this->pdo->lastInsertId(); + $this->syncSupplierReturnItems($new_id, $items); + return $new_id; + } + } + + /** + * Confirm a supplier return — create stock-out rows (goods leave warehouse). + * + * Flow per item: + * 1. Validate stock-in row (from PO receive) is approved. + * 2. INSERT td_stock_ out row, status respects $auto_approve. + * 3. If $auto_approve: releaseRack() + adjustBalance('out'). + * After all items: + * 4. UPDATE td_supplier_return.status = 1. + * + * Must be called inside dbTransaction() by the caller. + * + * @param int $return_id td_supplier_return.id + * @param string $uuid UUID prefix for stock-out rows. + * @param array $logging Audit entry. + * @param object $whMgmt WarehouseManager instance. + * @param bool $auto_approve Whether to auto-approve stock-out rows (status=1) immediately. + * @param object $invMgmt InvoiceManager instance (required if $auto_dn=true). + * @param bool $auto_dn Whether to auto-create and issue a debit note on confirm. + */ + public function confirmReturn( + int $return_id, + string $uuid, + array $logging, + $whMgmt, + bool $auto_approve = false, + $invMgmt = null, + bool $auto_dn = false + ): void { + $sth = $this->pdo->prepare( + "SELECT * FROM td_supplier_return + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $return_id]); + $return = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$return) throw new Exception("Supplier return not found."); + if ((int)$return['status'] !== 0) throw new Exception("Return is already confirmed or cancelled."); + + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_supplier_return_item + WHERE return_id = :return_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':return_id' => $return_id, ':company_id' => $this->company_id]); + $items = $sth2->fetchAll(PDO::FETCH_ASSOC); + if (empty($items)) throw new Exception("Cannot confirm a return with no items."); + + if ($auto_approve) { + $whMgmt->assertStockMovementWindow($return['return_date'] ?? date('Y-m-d'), 'Supplier return stock approval'); + } + + foreach ($items as $i => $item) { + $warehouse_id = (int)($item['warehouse_id'] ?? 0); + $product_sku = $item['product_sku'] ?? ''; + $quantity = (float)($item['quantity'] ?? 0); + $stock_in_id = (int)($item['stock_in_id'] ?? 0); + + if (!$warehouse_id || !$product_sku) { + throw new Exception("Item #{$i}: missing warehouse_id or product_sku."); + } + if ($quantity <= 0) { + throw new Exception("Item #{$i}: quantity must be greater than zero."); + } + + // Load original stock-in row for lot/serial context + $table = $this->stockTableName($warehouse_id); + if ($stock_in_id > 0) { + $sth = $this->pdo->prepare( + "SELECT status, lot_number, serial_number, price + FROM `{$table}` + WHERE company_id = :company_id + AND id = :id + AND product_sku = :sku + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':id' => $stock_in_id, + ':sku' => $product_sku, + ]); + $stock_in = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$stock_in) throw new Exception("Item #{$i}: original stock-in record not found."); + if ((int)$stock_in['status'] !== 1) throw new Exception("Item #{$i}: goods have not been approved into stock yet."); + } else { + $stock_in = ['lot_number' => '', 'serial_number' => '', 'price' => 0]; + } + + $zone = $item['zone'] ?? ''; + $aisle = $item['aisle'] ?? ''; + $rack = $item['rack'] ?? ''; + if ($zone === '' && $rack !== '') $zone = $rack; + if ($aisle === '' && $rack !== '') $aisle = $rack; + + $item_uuid = $uuid . '_srn_' . $i; + $item_log = [array_merge($logging, ['action' => 'confirm_return_item'])]; + + $this->pdo->prepare( + "INSERT INTO `{$table}` + (uuid, company_id, `date`, product_sku, `out`, + zone, aisle, rack, + contact_id, `description`, `log`, `type`, + lot_number, serial_number, + source, source_id, ref_id, price, status) + VALUES + (:uuid, :company_id, :date, :product_sku, :quantity, + :zone, :aisle, :rack, + :contact_id, :description, :log, 'out', + :lot_number, :serial_number, + 'supplier_return', :source_id, :ref_id, :price, :status)" + )->execute([ + ':uuid' => $item_uuid, + ':company_id' => $this->company_id, + ':date' => date('Y-m-d H:i:s'), + ':product_sku' => $product_sku, + ':quantity' => $quantity, + ':zone' => $zone, + ':aisle' => $aisle, + ':rack' => $rack, + ':contact_id' => (int)$return['contact_id'], + ':description' => $return['return_number'], + ':log' => json_encode($item_log), + ':lot_number' => $stock_in['lot_number'] ?? '', + ':serial_number' => $stock_in['serial_number'] ?? '', + ':source_id' => $return_id, + ':ref_id' => $stock_in_id, + ':price' => (float)($stock_in['price'] ?? $item['price'] ?? 0), + ':status' => $auto_approve ? 1 : 0, + ]); + + if ($auto_approve) { + $stock_out_id = (int)$this->pdo->lastInsertId(); + $whMgmt->releaseRack($warehouse_id, $zone, $aisle, $rack); + $whMgmt->adjustBalance('out', $warehouse_id, $product_sku, 0, $quantity); + } + } + + $log = json_decode($return['log'] ?? '[]', true) ?: []; + $log[] = array_merge($logging, ['action' => 'confirm']); + + $this->pdo->prepare( + "UPDATE td_supplier_return SET status = 1, `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':log' => json_encode($log), + ':id' => $return_id, + ':company_id' => $this->company_id, + ]); + + // ── Auto debit note ─────────────────────────────────────────────── + if ($auto_dn && $invMgmt) { + $dn_id = $invMgmt->createFromSupplierReturn( + $return_id, + array_merge($logging, ['action' => 'auto_supplier_credit_note']) + ); + $invMgmt->issueInvoice( + $dn_id, + array_merge($logging, ['action' => 'auto_issue_supplier_credit_note']) + ); + } + } + + /** + * Cancel a return (draft or confirmed). + * + * If confirmed: soft-delete stock-out rows and reverse balance. + * Blocked if an active debit note exists for this return. + * + * Must be called inside dbTransaction() by the caller. + */ + public function cancelReturn(int $return_id, array $logging, $whMgmt): void + { + $sth = $this->pdo->prepare( + "SELECT * FROM td_supplier_return + WHERE company_id = :company_id AND id = :id" + ); + $sth->execute([':company_id' => $this->company_id, ':id' => $return_id]); + $return = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$return) throw new Exception("Supplier return not found."); + if ((int)$return['status'] === -1) throw new Exception("Return is already cancelled."); + + // Guard: no active debit note + $sth = $this->pdo->prepare( + "SELECT COUNT(*) FROM td_invoice + WHERE company_id = :company_id + AND source = 'supplier_return' + AND source_id = :source_id + AND doc_type = 'supplier_credit_note' + AND status != 4" + ); + $sth->execute([':company_id' => $this->company_id, ':source_id' => $return_id]); + if ((int)$sth->fetchColumn() > 0) { + throw new Exception("Cannot cancel — an active supplier credit note exists. Void the supplier credit note first."); + } + + // If confirmed: reverse stock-out rows + if ((int)$return['status'] === 1) { + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_supplier_return_item + WHERE return_id = :return_id AND company_id = :company_id + ORDER BY item_id" + ); + $sth2->execute([':return_id' => $return_id, ':company_id' => $this->company_id]); + $items = $sth2->fetchAll(PDO::FETCH_ASSOC); + + foreach ($items as $item) { + $warehouse_id = (int)($item['warehouse_id'] ?? 0); + $product_sku = $item['product_sku'] ?? ''; + $quantity = (float)($item['quantity'] ?? 0); + + if (!$warehouse_id || !$product_sku) continue; + + $table = $this->stockTableName($warehouse_id); + + // Fetch ALL non-cancelled stock-out rows (status=1 approved or status=0 draft) + $sth = $this->pdo->prepare( + "SELECT id, status, zone, aisle, rack, ref_id, `date` FROM `{$table}` + WHERE company_id = :company_id + AND source = 'supplier_return' + AND source_id = :return_id + AND product_sku = :sku + AND status != -1" + ); + $sth->execute([ + ':company_id' => $this->company_id, + ':return_id' => $return_id, + ':sku' => $product_sku, + ]); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + + foreach ($rows as $row) { + $this->pdo->prepare( + "UPDATE `{$table}` SET status = -1 + WHERE id = :id AND company_id = :company_id" + )->execute([':id' => $row['id'], ':company_id' => $this->company_id]); + + if ((int)$row['status'] === 1) { + $whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Supplier return stock cancellation'); + // Approved rows: re-occupy rack (reverse the release) + reverse balance + $ref_id = (int)$row['ref_id']; + if ($ref_id > 0) { + $whMgmt->occupyRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack'], $product_sku, $ref_id, false); + } + $whMgmt->adjustBalance('in', $warehouse_id, $product_sku, 0, $quantity); + } + // Draft (status=0) rows: just soft-delete — rack/balance were never applied + } + } + } + + $log = json_decode($return['log'] ?? '[]', true) ?: []; + $log[] = array_merge($logging, ['action' => 'cancel']); + + $this->pdo->prepare( + "UPDATE td_supplier_return SET status = -1, `log` = :log + WHERE id = :id AND company_id = :company_id" + )->execute([ + ':log' => json_encode($log), + ':id' => $return_id, + ':company_id' => $this->company_id, + ]); + } +} diff --git a/app/assets/utils/classes/UserManager.php b/app/assets/utils/classes/UserManager.php index 0ab7916..584081e 100644 --- a/app/assets/utils/classes/UserManager.php +++ b/app/assets/utils/classes/UserManager.php @@ -59,6 +59,42 @@ class UserManager { return $user; } + public function getProfilePicture(): string + { + $sth = $this->pdo->prepare( + "SELECT profile_picture + FROM user + WHERE user_id = :user_id + LIMIT 1" + ); + $sth->execute([':user_id' => $this->user_id]); + return (string)($sth->fetchColumn() ?: ''); + } + + public function updateProfile(array $data, string $profile_picture): void + { + $this->pdo->prepare( + "UPDATE user SET + name = :name, + surname = :surname, + email = :email, + phone = :phone, + country = :country, + address = :address, + profile_picture = :profile_picture + WHERE user_id = :user_id" + )->execute([ + ':name' => trim($data['name'] ?? ''), + ':surname' => trim($data['surname'] ?? ''), + ':email' => trim($data['email'] ?? ''), + ':phone' => trim($data['phone'] ?? ''), + ':country' => trim($data['country'] ?? ''), + ':address' => trim($data['address'] ?? ''), + ':profile_picture' => $profile_picture, + ':user_id' => $this->user_id, + ]); + } + /** * Return all companies the current user belongs to, plus their role in each. * @@ -79,6 +115,19 @@ class UserManager { return $sth->fetchAll(PDO::FETCH_ASSOC); } + public function getCompanyAccess(int $company_id): ?array + { + $sth = $this->pdo->prepare( + "SELECT company_id, role + FROM company_map_user + WHERE company_id = :company_id AND user_id = :user_id + LIMIT 1" + ); + $sth->execute([':company_id' => $company_id, ':user_id' => $this->user_id]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + return $row ?: null; + } + // ───────────────────────────────────────────────────────────── // COMPANY MEMBER BASIS // ───────────────────────────────────────────────────────────── diff --git a/app/assets/utils/classes/WarehouseManager.php b/app/assets/utils/classes/WarehouseManager.php index d193531..14d7434 100644 --- a/app/assets/utils/classes/WarehouseManager.php +++ b/app/assets/utils/classes/WarehouseManager.php @@ -1,4 +1,5 @@ company_id); + $guard->assertOpenDate($date ?: date('Y-m-d'), $context); + } + private function resolveWarehouseTable(int $warehouse_id): ?string { $sth = $this->pdo->prepare( @@ -307,7 +320,7 @@ class WarehouseManager { */ private function insertRackLog(int $rack_id, string $action, array $extra = []): void { - $sql = "INSERT INTO md_rack_log + $sql = "INSERT INTO td_rack_log (company_id, md_rack_id, user_id, dt, login, action, product_sku, td_stock_id) VALUES (:company_id, :md_rack_id, :user_id, :dt, :login, :action, :product_sku, :td_stock_id)"; @@ -330,7 +343,7 @@ class WarehouseManager { /** * Soft-delete rack logs created by a stock movement and its reversal. * - * md_rack_log follows the same soft-delete convention as other tenant rows: + * td_rack_log follows the same soft-delete convention as other tenant rows: * negate company_id so normal company_id filters hide the records. */ private function softDeleteRackLogs( @@ -399,7 +412,7 @@ class WarehouseManager { $params[':since'] = $since; } - $sql = "UPDATE md_rack_log + $sql = "UPDATE td_rack_log SET company_id = company_id * -1 WHERE company_id = :company_id AND md_rack_id = :md_rack_id @@ -893,7 +906,7 @@ class WarehouseManager { * Soft-delete a storage record and its associated rack rows. * * Blocks deletion if any rack under this storage_id is occupied. - * Also hard-deletes md_rack_log entries for those racks before soft-deleting + * Also hard-deletes td_rack_log entries for those racks before soft-deleting * the md_rack rows themselves, then soft-deletes md_storage. * * Must be called inside dbTransaction() by the caller. @@ -948,11 +961,11 @@ class WarehouseManager { ]); $rack_ids = $sth->fetchAll(PDO::FETCH_COLUMN); - // Hard-delete md_rack_log for these racks (log records have no independent value) + // Hard-delete td_rack_log for these racks (log records have no independent value) if (!empty($rack_ids)) { $placeholders = implode(',', array_fill(0, count($rack_ids), '?')); $this->pdo->prepare( - "DELETE FROM md_rack_log WHERE md_rack_id IN ($placeholders)" + "DELETE FROM td_rack_log WHERE md_rack_id IN ($placeholders)" )->execute($rack_ids); } @@ -1328,6 +1341,88 @@ class WarehouseManager { $this->insertRacksInRange($storage_id, $range, $advanced_location); } + private function getRackRow(int $warehouse_id, string $zone, string $aisle, string $rack): array|false + { + $sql = "SELECT id, product_sku, td_stock_id + FROM md_rack + WHERE company_id = :company_id + AND warehouse = :warehouse + AND zone = :zone + AND aisle = :aisle + AND rack = :rack + LIMIT 1"; + + $sth = $this->pdo->prepare($sql); + $sth->execute([ + ':company_id' => $this->company_id, + ':warehouse' => $warehouse_id, + ':zone' => $zone, + ':aisle' => $aisle, + ':rack' => $rack, + ]); + + return $sth->fetch(PDO::FETCH_ASSOC); + } + + public function validateScanLocation( + string $mode, + int $warehouse_id, + string $zone, + string $aisle, + string $rack, + string $product_sku = '', + string $lot_number = '', + string $serial_number = '' + ): void { + if (!$warehouse_id || $zone === '' || $aisle === '' || $rack === '') { + throw new Exception('Location label is incomplete.'); + } + + $rack_row = $this->getRackRow($warehouse_id, $zone, $aisle, $rack); + if (!$rack_row) { + throw new Exception("Location {$zone}-{$aisle}-{$rack} does not exist."); + } + + if ($mode === 'in' || $mode === 'to') { + if ($rack_row['product_sku'] !== null) { + throw new Exception("Location {$zone}-{$aisle}-{$rack} is already occupied by {$rack_row['product_sku']}."); + } + + if ($mode === 'in' && $product_sku !== '') { + $this->validateStockUnique($product_sku, $lot_number ?: null, $serial_number ?: null); + } + + return; + } + + if ($mode === 'out' || $mode === 'from') { + if ($product_sku === '') { + throw new Exception('Scan the SKU label first.'); + } + + $stock = $this->getRackStock($warehouse_id, $zone, $aisle, $rack); + if (!$stock) { + throw new Exception("Location {$zone}-{$aisle}-{$rack} has no approved stock to move out."); + } + + if ($stock['product_sku'] !== $product_sku) { + throw new Exception("Location holds {$stock['product_sku']}, not {$product_sku}."); + } + + if ($lot_number !== '' && (string)$stock['lot_number'] !== $lot_number) { + throw new Exception("Location holds lot '{$stock['lot_number']}', not '{$lot_number}'."); + } + + if ($serial_number !== '' && (string)$stock['serial_number'] !== $serial_number) { + throw new Exception("Location holds serial '{$stock['serial_number']}', not '{$serial_number}'."); + } + + return; + } + + throw new Exception('Invalid scan validation mode.'); + } + /** * Fetch the stock record currently occupying a rack. * @@ -1754,27 +1849,29 @@ class WarehouseManager { * @param int|float $old_qty Previous quantity (0 for new records). * @param int|float $new_qty New quantity (0 to reverse/delete). */ - public function adjustBalance($type, $warehouse_id, $product_sku, $old_qty, $new_qty) { - + public function adjustBalance( + $type, $warehouse_id, $product_sku, $old_qty, $new_qty, + int $stock_id = 0, string $source = '', int $source_id = 0 + ): void { $column = $type === 'in' ? 'total_in' : 'total_out'; $delta = $new_qty - $old_qty; $month = date('Y-m'); - $sql = "INSERT INTO warehouse_balance - (company_id, warehouse_id, product_sku, month, `$column`) - VALUES - (:company_id, :warehouse_id, :product_sku, :month, :delta) - ON DUPLICATE KEY UPDATE - `$column` = `$column` + VALUES(`$column`)"; - - $sth = $this->pdo->prepare($sql); - $sth->execute([ - ":company_id" => $this->company_id, - ":warehouse_id" => $warehouse_id, - ":product_sku" => $product_sku, - ":month" => $month, - ":delta" => $delta + $this->pdo->prepare( + "INSERT INTO warehouse_balance + (company_id, warehouse_id, product_sku, month, `{$column}`) + VALUES + (:company_id, :warehouse_id, :product_sku, :month, :delta) + ON DUPLICATE KEY UPDATE + `{$column}` = `{$column}` + VALUES(`{$column}`)" + )->execute([ + ':company_id' => $this->company_id, + ':warehouse_id' => $warehouse_id, + ':product_sku' => $product_sku, + ':month' => $month, + ':delta' => $delta, ]); + } // ───────────────────────────────────────────────────────────── @@ -2050,6 +2147,10 @@ class WarehouseManager { $product_sku = $row['product_sku']; $product_name = $this->getProductName($product_sku); + if ((int)$row['status'] === 1) { + $this->assertStockMovementWindow($row['date'] ?? null, 'Stock-in deletion'); + } + $this->validateLatestTransaction($product_sku, $row['date'], $product_name); // Soft-delete: negate company_id so row is hidden but recoverable @@ -2104,6 +2205,10 @@ class WarehouseManager { $product_sku = $row['product_sku']; $product_name = $this->getProductName($product_sku); + if ((int)$row['status'] === 1) { + $this->assertStockMovementWindow($row['date'] ?? null, 'Stock-out deletion'); + } + $this->validateLatestTransaction($product_sku, $row['date'], $product_name); // Soft-delete: negate company_id so row is hidden but recoverable @@ -2174,6 +2279,11 @@ class WarehouseManager { throw new Exception("Paired destination record missing — data integrity issue."); } + if ((int)$from_row['status'] === 1 || (int)$to_row['status'] === 1) { + $this->assertStockMovementWindow($from_row['date'] ?? null, 'Stock transfer cancellation'); + $this->assertStockMovementWindow($to_row['date'] ?? null, 'Stock transfer cancellation'); + } + // Single global validation covers both warehouses (same SKU, same date) $this->validateLatestTransaction($product_sku, $from_row['date'], $product_name); diff --git a/app/assets/utils/classes_ac/AccountFormulaManager.php b/app/assets/utils/classes_ac/AccountFormulaManager.php new file mode 100644 index 0000000..46615d5 --- /dev/null +++ b/app/assets/utils/classes_ac/AccountFormulaManager.php @@ -0,0 +1,243 @@ +pdo = $pdo; + $this->companyId = $company_id; + } + + public function getAll(): array + { + $sth = $this->pdo->prepare( + "SELECT f.*, + COUNT(i.id) AS item_count + FROM md_account_formula f + LEFT JOIN md_account_formula_item i + ON i.company_id = f.company_id + AND i.formula_id = f.id + WHERE f.company_id = :cid + GROUP BY f.id + ORDER BY f.document_type ASC, f.is_default DESC, f.formula_name ASC" + ); + $sth->execute([':cid' => $this->companyId]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + public function getByType(string $document_type): array + { + $sth = $this->pdo->prepare( + "SELECT id, formula_name, is_default + FROM md_account_formula + WHERE company_id = :cid + AND document_type = :doc_type + AND status = 1 + ORDER BY is_default DESC, formula_name ASC" + ); + $sth->execute([':cid' => $this->companyId, ':doc_type' => $document_type]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + public function getById(int $id): ?array + { + $sth = $this->pdo->prepare( + "SELECT * FROM md_account_formula + WHERE company_id = :cid AND id = :id + LIMIT 1" + ); + $sth->execute([':cid' => $this->companyId, ':id' => $id]); + $formula = $sth->fetch(PDO::FETCH_ASSOC); + if (!$formula) return null; + + $sth = $this->pdo->prepare( + "SELECT i.*, a.account_name, a.account_type + FROM md_account_formula_item i + LEFT JOIN md_account a + ON a.company_id = i.company_id + AND a.account_code = i.account_code + WHERE i.company_id = :cid + AND i.formula_id = :formula_id + ORDER BY i.sort_order ASC, i.id ASC" + ); + $sth->execute([':cid' => $this->companyId, ':formula_id' => $id]); + $formula['items'] = $sth->fetchAll(PDO::FETCH_ASSOC); + + return $formula; + } + + public function save(array $data): int + { + $id = (int)($data['id'] ?? 0); + $name = trim((string)($data['formula_name'] ?? '')); + $documentType = trim((string)($data['document_type'] ?? '')); + $description = trim((string)($data['description'] ?? '')); + $isDefault = (int)($data['is_default'] ?? 0) === 1 ? 1 : 0; + $status = (int)($data['status'] ?? 1) === 1 ? 1 : 0; + $items = $data['items'] ?? []; + + if (is_string($items)) { + $items = json_decode($items, true) ?: []; + } + + if ($name === '') throw new Exception('Formula name is required.'); + if (!in_array($documentType, $this->documentTypes, true)) { + throw new Exception('Invalid document type.'); + } + + $validated = $this->validateItems($items); + if (!$validated) throw new Exception('Formula requires at least one debit and one credit item.'); + + $debitKeys = array_unique(array_column(array_filter($validated, fn($i) => $i['drcr'] === 'D'), 'amount_key')); + $creditKeys = array_unique(array_column(array_filter($validated, fn($i) => $i['drcr'] === 'C'), 'amount_key')); + if (!$debitKeys || !$creditKeys) { + throw new Exception('Formula requires at least one debit and one credit item.'); + } + + if ($isDefault) { + $this->pdo->prepare( + "UPDATE md_account_formula + SET is_default = 0 + WHERE company_id = :cid AND document_type = :document_type" + )->execute([':cid' => $this->companyId, ':document_type' => $documentType]); + } + + if ($id > 0) { + $sth = $this->pdo->prepare( + "UPDATE md_account_formula SET + formula_name = :formula_name, + document_type = :document_type, + description = :description, + is_default = :is_default, + status = :status, + updated_at = :updated_at + WHERE company_id = :cid AND id = :id" + ); + $sth->execute([ + ':cid' => $this->companyId, + ':id' => $id, + ':formula_name' => $name, + ':document_type' => $documentType, + ':description' => $description !== '' ? $description : null, + ':is_default' => $isDefault, + ':status' => $status, + ':updated_at' => date('Y-m-d H:i:s'), + ]); + } else { + $sth = $this->pdo->prepare( + "INSERT INTO md_account_formula + (company_id, formula_name, document_type, description, is_default, status, created_at) + VALUES + (:cid, :formula_name, :document_type, :description, :is_default, :status, :created_at)" + ); + $sth->execute([ + ':cid' => $this->companyId, + ':formula_name' => $name, + ':document_type' => $documentType, + ':description' => $description !== '' ? $description : null, + ':is_default' => $isDefault, + ':status' => $status, + ':created_at' => date('Y-m-d H:i:s'), + ]); + $id = (int)$this->pdo->lastInsertId(); + } + + $this->replaceItems($id, $validated); + return $id; + } + + public function delete(int $id): void + { + $this->pdo->prepare( + "UPDATE md_account_formula + SET status = 0, is_default = 0, updated_at = :updated_at + WHERE company_id = :cid AND id = :id" + )->execute([ + ':cid' => $this->companyId, + ':id' => $id, + ':updated_at' => date('Y-m-d H:i:s'), + ]); + } + + private function validateItems(array $items): array + { + $validated = []; + $sort = 1; + foreach ($items as $item) { + $drcr = strtoupper(trim((string)($item['drcr'] ?? ''))); + $accountCode = trim((string)($item['account_code'] ?? '')); + $amountKey = trim((string)($item['amount_key'] ?? '')); + $description = trim((string)($item['description'] ?? '')); + + if (!in_array($drcr, ['D', 'C'], true) || $accountCode === '' || $amountKey === '') continue; + if (!in_array($amountKey, $this->amountKeys, true)) throw new Exception('Invalid amount key.'); + if (!$this->isPostingAccount($accountCode)) { + throw new Exception($accountCode . ' is not an active posting account.'); + } + + $validated[] = [ + 'drcr' => $drcr, + 'account_code' => $accountCode, + 'amount_key' => $amountKey, + 'description' => $description, + 'sort_order' => $sort++, + ]; + } + return $validated; + } + + private function isPostingAccount(string $accountCode): bool + { + $sth = $this->pdo->prepare( + "SELECT COUNT(*) + FROM md_account + WHERE company_id = :cid + AND account_code = :account_code + AND is_posting = 1 + AND status = 1" + ); + $sth->execute([':cid' => $this->companyId, ':account_code' => $accountCode]); + return (int)$sth->fetchColumn() > 0; + } + + private function replaceItems(int $formulaId, array $items): void + { + $this->pdo->prepare( + "DELETE FROM md_account_formula_item + WHERE company_id = :cid AND formula_id = :formula_id" + )->execute([':cid' => $this->companyId, ':formula_id' => $formulaId]); + + $sth = $this->pdo->prepare( + "INSERT INTO md_account_formula_item + (company_id, formula_id, drcr, account_code, amount_key, description, sort_order) + VALUES + (:cid, :formula_id, :drcr, :account_code, :amount_key, :description, :sort_order)" + ); + + foreach ($items as $item) { + $sth->execute([ + ':cid' => $this->companyId, + ':formula_id' => $formulaId, + ':drcr' => $item['drcr'], + ':account_code' => $item['account_code'], + ':amount_key' => $item['amount_key'], + ':description' => $item['description'] !== '' ? $item['description'] : null, + ':sort_order' => $item['sort_order'], + ]); + } + } +} +?> diff --git a/app/assets/utils/classes_ac/ChartOfAccounts.php b/app/assets/utils/classes_ac/ChartOfAccounts.php index ee2a3ea..b34c55d 100644 --- a/app/assets/utils/classes_ac/ChartOfAccounts.php +++ b/app/assets/utils/classes_ac/ChartOfAccounts.php @@ -13,7 +13,7 @@ class ChartOfAccounts public function getAll(): array { $sth = $this->pdo->prepare( - "SELECT * FROM ac_account + "SELECT * FROM md_account WHERE company_id = :cid ORDER BY account_code ASC" ); @@ -24,7 +24,7 @@ class ChartOfAccounts public function getByCode(string $account_code): ?array { $sth = $this->pdo->prepare( - "SELECT * FROM ac_account + "SELECT * FROM md_account WHERE company_id = :cid AND account_code = :code LIMIT 1" ); @@ -36,7 +36,7 @@ class ChartOfAccounts public function getByType(string $account_type): array { $sth = $this->pdo->prepare( - "SELECT * FROM ac_account + "SELECT * FROM md_account WHERE company_id = :cid AND account_type = :type AND status = 1 ORDER BY account_code ASC" ); @@ -47,7 +47,7 @@ class ChartOfAccounts public function getById(int $id): ?array { $sth = $this->pdo->prepare( - "SELECT * FROM ac_account + "SELECT * FROM md_account WHERE company_id = :cid AND id = :id LIMIT 1" ); @@ -59,19 +59,20 @@ class ChartOfAccounts public function create(array $data): int { $sth = $this->pdo->prepare( - "INSERT INTO ac_account - (company_id, account_code, account_name, account_type, parent_code, is_posting, status) + "INSERT INTO md_account + (company_id, account_code, account_name, account_type, account_category, parent_code, is_posting, status) VALUES - (:cid, :code, :name, :type, :parent, :posting, :status)" + (:cid, :code, :name, :type, :category, :parent, :posting, :status)" ); $sth->execute([ - ':cid' => $this->companyId, - ':code' => trim($data['account_code']), - ':name' => trim($data['account_name']), - ':type' => $data['account_type'], - ':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null, - ':posting' => (int)($data['is_posting'] ?? 1), - ':status' => (int)($data['status'] ?? 1), + ':cid' => $this->companyId, + ':code' => trim($data['account_code']), + ':name' => trim($data['account_name']), + ':type' => $data['account_type'], + ':category' => !empty($data['account_category']) ? trim($data['account_category']) : '', + ':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null, + ':posting' => (int)($data['is_posting'] ?? 1), + ':status' => (int)($data['status'] ?? 1), ]); return (int)$this->pdo->lastInsertId(); } @@ -79,31 +80,33 @@ class ChartOfAccounts public function update(int $id, array $data): void { $sth = $this->pdo->prepare( - "UPDATE ac_account SET - account_code = :code, - account_name = :name, - account_type = :type, - parent_code = :parent, - is_posting = :posting, - status = :status + "UPDATE md_account SET + account_code = :code, + account_name = :name, + account_type = :type, + account_category = :category, + parent_code = :parent, + is_posting = :posting, + status = :status WHERE company_id = :cid AND id = :id" ); $sth->execute([ - ':cid' => $this->companyId, - ':id' => $id, - ':code' => trim($data['account_code']), - ':name' => trim($data['account_name']), - ':type' => $data['account_type'], - ':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null, - ':posting' => (int)($data['is_posting'] ?? 1), - ':status' => (int)($data['status'] ?? 1), + ':cid' => $this->companyId, + ':id' => $id, + ':code' => trim($data['account_code']), + ':name' => trim($data['account_name']), + ':type' => $data['account_type'], + ':category' => !empty($data['account_category']) ? trim($data['account_category']) : '', + ':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null, + ':posting' => (int)($data['is_posting'] ?? 1), + ':status' => (int)($data['status'] ?? 1), ]); } public function delete(int $id): void { $sth = $this->pdo->prepare( - "UPDATE ac_account SET status = 0 + "UPDATE md_account SET status = 0 WHERE company_id = :cid AND id = :id" ); $sth->execute([':cid' => $this->companyId, ':id' => $id]); @@ -112,7 +115,7 @@ class ChartOfAccounts public function isPostingAccount(string $account_code): bool { $sth = $this->pdo->prepare( - "SELECT is_posting FROM ac_account + "SELECT is_posting FROM md_account WHERE company_id = :cid AND account_code = :code AND status = 1 LIMIT 1" ); @@ -124,18 +127,29 @@ class ChartOfAccounts { $sth = $this->pdo->prepare( "SELECT - COUNT(*) AS total, - SUM(status = 1) AS active, - SUM(status = 0) AS inactive, + COUNT(*) AS total, + SUM(status = 1) AS active, + SUM(status = 0) AS inactive, SUM(account_type = 'asset') AS asset, SUM(account_type = 'liability') AS liability, SUM(account_type = 'equity') AS equity, SUM(account_type = 'revenue') AS revenue, SUM(account_type = 'expense') AS expense - FROM ac_account + FROM md_account WHERE company_id = :cid" ); $sth->execute([':cid' => $this->companyId]); return $sth->fetch(PDO::FETCH_ASSOC) ?: []; } + + public function getByCategory(string $category): array + { + $sth = $this->pdo->prepare( + "SELECT * FROM md_account + WHERE company_id = :cid AND account_category = :cat AND status = 1 + ORDER BY account_code ASC" + ); + $sth->execute([':cid' => $this->companyId, ':cat' => $category]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } } diff --git a/app/assets/utils/classes_ac/DepartmentManager.php b/app/assets/utils/classes_ac/DepartmentManager.php index 565b717..bc8924e 100644 --- a/app/assets/utils/classes_ac/DepartmentManager.php +++ b/app/assets/utils/classes_ac/DepartmentManager.php @@ -13,7 +13,7 @@ class DepartmentManager public function getAll(): array { $sth = $this->pdo->prepare( - "SELECT * FROM ac_department + "SELECT * FROM md_department WHERE company_id = :cid ORDER BY dept_code ASC" ); @@ -24,7 +24,7 @@ class DepartmentManager public function getById(int $id): ?array { $sth = $this->pdo->prepare( - "SELECT * FROM ac_department + "SELECT * FROM md_department WHERE company_id = :cid AND id = :id LIMIT 1" ); $sth->execute([':cid' => $this->companyId, ':id' => $id]); @@ -35,7 +35,7 @@ class DepartmentManager public function create(array $data): int { $sth = $this->pdo->prepare( - "INSERT INTO ac_department (company_id, dept_code, dept_name, description, status) + "INSERT INTO md_department (company_id, dept_code, dept_name, description, status) VALUES (:cid, :code, :name, :desc, :status)" ); $sth->execute([ @@ -51,7 +51,7 @@ class DepartmentManager public function update(int $id, array $data): void { $sth = $this->pdo->prepare( - "UPDATE ac_department SET + "UPDATE md_department SET dept_code = :code, dept_name = :name, description = :desc, @@ -71,7 +71,7 @@ class DepartmentManager public function delete(int $id): void { $sth = $this->pdo->prepare( - "UPDATE ac_department SET status = 0 + "UPDATE md_department SET status = 0 WHERE company_id = :cid AND id = :id" ); $sth->execute([':cid' => $this->companyId, ':id' => $id]); @@ -83,7 +83,7 @@ class DepartmentManager "SELECT COUNT(*) AS total, SUM(status = 1) AS active, SUM(status = 0) AS inactive - FROM ac_department WHERE company_id = :cid" + FROM md_department WHERE company_id = :cid" ); $sth->execute([':cid' => $this->companyId]); return $sth->fetch(PDO::FETCH_ASSOC) ?: []; diff --git a/app/assets/utils/classes_ac/FinancialReports.php b/app/assets/utils/classes_ac/FinancialReports.php index b3ebdff..8342df3 100644 --- a/app/assets/utils/classes_ac/FinancialReports.php +++ b/app/assets/utils/classes_ac/FinancialReports.php @@ -2,8 +2,8 @@ /** * FinancialReports * - * Generates all financial statements from ac_balance + ac_journal_line. - * Read-only — no writes. + * Generates all financial statements from td_gl + td_gl_item joined with md_account. + * Read-only — no writes. All results are reusable across API endpoints and future reports. */ class FinancialReports { @@ -16,8 +16,476 @@ class FinancialReports $this->companyId = $company_id; } - // TODO: getTrialBalance(string $from_period, string $to_period, ?int $dept_id): array - // TODO: getProfitAndLoss(string $from_period, string $to_period, ?int $dept_id): array - // TODO: getBalanceSheet(string $as_of_period, ?int $dept_id): array - // TODO: getGLMovement(string $account_code, string $from_period, string $to_period): array + // ── Trial Balance ───────────────────────────────────────────────────────── + // Returns every posting account with brought-forward balance, period movement, + // and carry-forward balance for the selected date range. + + private function normalizeDate(string $date, bool $end_of_month = false): string + { + $date = trim($date); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + return $date; + } + if (preg_match('/^\d{4}-\d{2}$/', $date)) { + return $end_of_month ? date('Y-m-t', strtotime($date . '-01')) : $date . '-01'; + } + return date('Y-m-d'); + } + + public function getTrialBalance(string $from_date, string $to_date, int $dept_id = 0): array + { + $from_date = $this->normalizeDate($from_date); + $to_date = $this->normalizeDate($to_date, true); + + $sth = $this->pdo->prepare(" + SELECT + a.account_code, + a.account_name, + a.account_type, + COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) < :from_date THEN i.debit ELSE 0 END), 0) AS opening_debit, + COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) < :from_date THEN i.credit ELSE 0 END), 0) AS opening_credit, + COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date THEN i.debit ELSE 0 END), 0) AS period_debit, + COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date THEN i.credit ELSE 0 END), 0) AS period_credit + FROM md_account a + LEFT JOIN td_gl_item i ON i.company_id = a.company_id + AND i.account_code = a.account_code + AND (:dept_id = 0 OR i.department_id = :dept_id) + LEFT JOIN td_gl g ON g.id = i.gl_id + AND g.company_id = a.company_id + AND COALESCE(g.journal_date, DATE(g.created_at)) <= :to_date + WHERE a.company_id = :cid AND a.is_posting = 1 AND a.status = 1 + GROUP BY a.account_code, a.account_name, a.account_type + HAVING opening_debit <> 0 + OR opening_credit <> 0 + OR period_debit <> 0 + OR period_credit <> 0 + ORDER BY a.account_code + "); + $sth->execute([ + ':cid' => $this->companyId, + ':from_date' => $from_date, + ':to_date' => $to_date, + ':dept_id' => $dept_id, + ]); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + + $total_opening_debit = 0.0; + $total_opening_credit = 0.0; + $total_period_debit = 0.0; + $total_period_credit = 0.0; + $total_closing_debit = 0.0; + $total_closing_credit = 0.0; + + foreach ($rows as &$row) { + $opening_balance = (float)$row['opening_debit'] - (float)$row['opening_credit']; + $period_debit = (float)$row['period_debit']; + $period_credit = (float)$row['period_credit']; + $closing_balance = $opening_balance + $period_debit - $period_credit; + + $row['opening_balance'] = $opening_balance; + $row['opening_debit'] = $opening_balance > 0 ? $opening_balance : 0.0; + $row['opening_credit'] = $opening_balance < 0 ? abs($opening_balance) : 0.0; + $row['period_debit'] = $period_debit; + $row['period_credit'] = $period_credit; + $row['closing_balance'] = $closing_balance; + $row['closing_debit'] = $closing_balance > 0 ? $closing_balance : 0.0; + $row['closing_credit'] = $closing_balance < 0 ? abs($closing_balance) : 0.0; + + $total_opening_debit += $row['opening_debit']; + $total_opening_credit += $row['opening_credit']; + $total_period_debit += $row['period_debit']; + $total_period_credit += $row['period_credit']; + $total_closing_debit += $row['closing_debit']; + $total_closing_credit += $row['closing_credit']; + } + unset($row); + + return [ + 'rows' => $rows, + 'total_opening_debit' => $total_opening_debit, + 'total_opening_credit' => $total_opening_credit, + 'total_period_debit' => $total_period_debit, + 'total_period_credit' => $total_period_credit, + 'total_closing_debit' => $total_closing_debit, + 'total_closing_credit' => $total_closing_credit, + 'total_debit' => $total_closing_debit, + 'total_credit' => $total_closing_credit, + 'from_date' => $from_date, + 'to_date' => $to_date, + 'from_period' => substr($from_date, 0, 7), + 'to_period' => substr($to_date, 0, 7), + ]; + } + + // ── Profit & Loss ───────────────────────────────────────────────────────── + // Returns revenue and expense accounts for the date range. + // revenue net = credit - debit; expense net = debit - credit (normal balances). + + public function getProfitAndLoss(string $from_date, string $to_date, int $dept_id = 0): array + { + $from_date = $this->normalizeDate($from_date); + $to_date = $this->normalizeDate($to_date, true); + + $sth = $this->pdo->prepare(" + SELECT + a.account_code, + a.account_name, + a.account_type, + COALESCE(SUM(i.debit), 0) AS total_debit, + COALESCE(SUM(i.credit), 0) AS total_credit + FROM md_account a + JOIN td_gl_item i ON i.company_id = a.company_id + AND i.account_code = a.account_code + AND (:dept_id = 0 OR i.department_id = :dept_id) + JOIN td_gl g ON g.id = i.gl_id + AND g.company_id = a.company_id + AND COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date + WHERE a.company_id = :cid + AND a.account_type IN ('revenue','expense') + AND a.is_posting = 1 + GROUP BY a.account_code, a.account_name, a.account_type + ORDER BY a.account_type DESC, a.account_code + "); + $sth->execute([ + ':cid' => $this->companyId, + ':from_date' => $from_date, + ':to_date' => $to_date, + ':dept_id' => $dept_id, + ]); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + + $total_revenue = 0.0; + $total_expense = 0.0; + foreach ($rows as $r) { + if ($r['account_type'] === 'revenue') $total_revenue += $r['total_credit'] - $r['total_debit']; + if ($r['account_type'] === 'expense') $total_expense += $r['total_debit'] - $r['total_credit']; + } + + return [ + 'rows' => $rows, + 'total_revenue' => $total_revenue, + 'total_expense' => $total_expense, + 'net_profit' => $total_revenue - $total_expense, + 'from_date' => $from_date, + 'to_date' => $to_date, + 'from_period' => substr($from_date, 0, 7), + 'to_period' => substr($to_date, 0, 7), + ]; + } + + // ── Balance Sheet ───────────────────────────────────────────────────────── + // Cumulative balances for asset/liability/equity accounts up to as_of_period. + // Retained earnings = cumulative net P&L (revenue - expense) through that period. + + public function getBalanceSheet(string $as_of_date, int $dept_id = 0): array + { + $as_of_date = $this->normalizeDate($as_of_date, true); + + $sth = $this->pdo->prepare(" + SELECT + a.account_code, + a.account_name, + a.account_type, + COALESCE(SUM(i.debit), 0) AS total_debit, + COALESCE(SUM(i.credit), 0) AS total_credit + FROM md_account a + JOIN td_gl_item i ON i.company_id = a.company_id + AND i.account_code = a.account_code + AND (:dept_id = 0 OR i.department_id = :dept_id) + JOIN td_gl g ON g.id = i.gl_id + AND g.company_id = a.company_id + AND COALESCE(g.journal_date, DATE(g.created_at)) <= :as_of + WHERE a.company_id = :cid + AND a.account_type IN ('asset','liability','equity') + AND a.is_posting = 1 AND a.status = 1 + GROUP BY a.account_code, a.account_name, a.account_type + ORDER BY a.account_type, a.account_code + "); + $sth->execute([':cid' => $this->companyId, ':as_of' => $as_of_date, ':dept_id' => $dept_id]); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + + $sth = $this->pdo->prepare(" + SELECT + COALESCE(SUM(CASE WHEN a.account_type = 'revenue' + THEN i.credit - i.debit ELSE 0 END), 0) AS net_revenue, + COALESCE(SUM(CASE WHEN a.account_type = 'expense' + THEN i.debit - i.credit ELSE 0 END), 0) AS net_expense + FROM md_account a + JOIN td_gl_item i ON i.company_id = a.company_id + AND i.account_code = a.account_code + AND (:dept_id = 0 OR i.department_id = :dept_id) + JOIN td_gl g ON g.id = i.gl_id + AND g.company_id = a.company_id + AND COALESCE(g.journal_date, DATE(g.created_at)) <= :as_of + WHERE a.company_id = :cid + AND a.account_type IN ('revenue','expense') + AND a.is_posting = 1 + "); + $sth->execute([':cid' => $this->companyId, ':as_of' => $as_of_date, ':dept_id' => $dept_id]); + $pl = $sth->fetch(PDO::FETCH_ASSOC); + + return [ + 'rows' => $rows, + 'retained_earnings' => (float)($pl['net_revenue'] ?? 0) - (float)($pl['net_expense'] ?? 0), + 'as_of_date' => $as_of_date, + 'as_of_period' => substr($as_of_date, 0, 7), + ]; + } + + // ── Monthly Trend ───────────────────────────────────────────────────────── + // Revenue vs expense per period for the last N months (defaults to 6). + // Returns rows: [{period, revenue, expense}] + + public function getMonthlyTrend(int $months = 6): array + { + $from = date('Y-m', strtotime('-' . ($months - 1) . ' months')); + $sth = $this->pdo->prepare(" + SELECT + g.period, + SUM(CASE WHEN a.account_type = 'revenue' THEN i.credit - i.debit ELSE 0 END) AS revenue, + SUM(CASE WHEN a.account_type = 'expense' THEN i.debit - i.credit ELSE 0 END) AS expense + FROM td_gl g + JOIN td_gl_item i ON i.gl_id = g.id AND i.company_id = g.company_id + JOIN md_account a ON a.company_id = i.company_id AND a.account_code = i.account_code + WHERE g.company_id = :cid AND g.period >= :from_period + GROUP BY g.period + ORDER BY g.period ASC + "); + $sth->execute([':cid' => $this->companyId, ':from_period' => $from]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + // ── Dashboard focused methods ───────────────────────────────────────────── + // One method per dashboard card/section. Used by separate REST endpoints. + + public function getDashboardPL(): array + { + $sth = $this->pdo->prepare(" + SELECT + a.account_type, + COALESCE(SUM(i.debit), 0) AS total_debit, + COALESCE(SUM(i.credit), 0) AS total_credit + FROM md_account a + JOIN td_gl_item i ON i.company_id = a.company_id AND i.account_code = a.account_code + JOIN td_gl g ON g.company_id = i.company_id AND g.id = i.gl_id + WHERE a.company_id = :cid + AND a.account_type IN ('revenue', 'expense') + AND a.is_posting = 1 + GROUP BY a.account_type + "); + $sth->execute([':cid' => $this->companyId]); + + $revenue = 0.0; + $expense = 0.0; + foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { + if ($row['account_type'] === 'revenue') $revenue += (float)$row['total_credit'] - (float)$row['total_debit']; + if ($row['account_type'] === 'expense') $expense += (float)$row['total_debit'] - (float)$row['total_credit']; + } + + return ['revenue' => $revenue, 'expense' => $expense, 'net_profit' => $revenue - $expense]; + } + + public function getDashboardJournalCount(): int + { + $sth = $this->pdo->prepare("SELECT COUNT(*) FROM td_gl WHERE company_id = :cid"); + $sth->execute([':cid' => $this->companyId]); + return (int)$sth->fetchColumn(); + } + + public function getDashboardBySource(): array + { + $sth = $this->pdo->prepare(" + SELECT source_type, COUNT(*) AS cnt + FROM td_gl + WHERE company_id = :cid + GROUP BY source_type + ORDER BY cnt DESC + "); + $sth->execute([':cid' => $this->companyId]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + public function getDashboardRecentEntries(int $limit = 10): array + { + $limit = max(1, min(100, $limit)); + $sth = $this->pdo->prepare(" + SELECT + g.id, g.source_type, + COALESCE(g.reference, '') AS reference, + COALESCE(g.description, '') AS description, + COALESCE(g.journal_date, DATE(g.created_at)) AS journal_date, + g.period, + SUM(i.debit) AS total_debit, + SUM(i.credit) AS total_credit + FROM td_gl g + JOIN td_gl_item i ON i.gl_id = g.id AND i.company_id = g.company_id + WHERE g.company_id = :cid + GROUP BY g.id, g.source_type, g.reference, g.description, g.journal_date, g.period, g.created_at + ORDER BY g.created_at DESC + LIMIT {$limit} + "); + $sth->execute([':cid' => $this->companyId]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + public function getDashboardSummary(int $trend_months = 6, int $recent_limit = 10): array + { + $sth = $this->pdo->prepare(" + SELECT + a.account_type, + COALESCE(SUM(i.debit), 0) AS total_debit, + COALESCE(SUM(i.credit), 0) AS total_credit + FROM md_account a + JOIN td_gl_item i + ON i.company_id = a.company_id + AND i.account_code = a.account_code + JOIN td_gl g + ON g.company_id = i.company_id + AND g.id = i.gl_id + WHERE a.company_id = :cid + AND a.account_type IN ('revenue', 'expense') + AND a.is_posting = 1 + GROUP BY a.account_type + "); + $sth->execute([':cid' => $this->companyId]); + + $total_revenue = 0.0; + $total_expense = 0.0; + foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { + if ($row['account_type'] === 'revenue') { + $total_revenue += (float)$row['total_credit'] - (float)$row['total_debit']; + } + if ($row['account_type'] === 'expense') { + $total_expense += (float)$row['total_debit'] - (float)$row['total_credit']; + } + } + + $sth = $this->pdo->prepare("SELECT COUNT(*) FROM td_gl WHERE company_id = :cid"); + $sth->execute([':cid' => $this->companyId]); + $journal_count = (int)$sth->fetchColumn(); + + $sth = $this->pdo->prepare(" + SELECT source_type, COUNT(*) AS cnt + FROM td_gl + WHERE company_id = :cid + GROUP BY source_type + ORDER BY cnt DESC + "); + $sth->execute([':cid' => $this->companyId]); + $by_source = $sth->fetchAll(PDO::FETCH_ASSOC); + + $sth = $this->pdo->prepare(" + SELECT + g.id, g.source_type, + COALESCE(g.reference, '') AS reference, + COALESCE(g.description, '') AS description, + COALESCE(g.journal_date, DATE(g.created_at)) AS journal_date, + g.period, + SUM(i.debit) AS total_debit, + SUM(i.credit) AS total_credit + FROM td_gl g + JOIN td_gl_item i ON i.gl_id = g.id AND i.company_id = g.company_id + WHERE g.company_id = :cid + GROUP BY g.id, g.source_type, g.reference, g.description, g.journal_date, g.period, g.created_at + ORDER BY g.created_at DESC + LIMIT {$recent_limit} + "); + $sth->execute([':cid' => $this->companyId]); + $recent = $sth->fetchAll(PDO::FETCH_ASSOC); + + return [ + 'revenue' => $total_revenue, + 'expense' => $total_expense, + 'net_profit' => $total_revenue - $total_expense, + 'journal_count' => $journal_count, + 'trend' => $this->getMonthlyTrend($trend_months), + 'by_source' => $by_source, + 'recent' => $recent, + ]; + } + + // ── GL Movement ─────────────────────────────────────────────────────────── + // Per-account ledger: opening balance + chronological lines with running balance. + + public function getGLMovement(string $account_code, string $from_date, string $to_date, int $dept_id = 0): array + { + $from_date = $this->normalizeDate($from_date); + $to_date = $this->normalizeDate($to_date, true); + + // Opening balance = all movements before from_date + $sth = $this->pdo->prepare(" + SELECT + COALESCE(SUM(i.debit), 0) AS opening_debit, + COALESCE(SUM(i.credit), 0) AS opening_credit + FROM td_gl_item i + JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id + WHERE i.company_id = :cid + AND i.account_code = :code + AND (:dept_id = 0 OR i.department_id = :dept_id) + AND COALESCE(g.journal_date, DATE(g.created_at)) < :from_date + "); + $sth->execute([ + ':cid' => $this->companyId, + ':code' => $account_code, + ':from_date' => $from_date, + ':dept_id' => $dept_id, + ]); + $ob = $sth->fetch(PDO::FETCH_ASSOC); + $opening_balance = (float)$ob['opening_debit'] - (float)$ob['opening_credit']; + + // Period lines + $sth = $this->pdo->prepare(" + SELECT + g.id AS gl_id, + g.source_type, + COALESCE(g.reference, '') AS reference, + COALESCE(g.description, '') AS gl_description, + g.period, + i.department_id, + COALESCE(d.dept_code, '') AS dept_code, + COALESCE(d.dept_name, '') AS dept_name, + COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date, + DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at, + i.debit, + i.credit, + COALESCE(i.description, '') AS line_description + FROM td_gl_item i + JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id + LEFT JOIN md_department d ON d.company_id = i.company_id AND d.id = i.department_id + WHERE i.company_id = :cid + AND i.account_code = :code + AND (:dept_id = 0 OR i.department_id = :dept_id) + AND COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date + ORDER BY g.journal_date ASC, g.id ASC, i.id ASC + "); + $sth->execute([ + ':cid' => $this->companyId, + ':code' => $account_code, + ':from_date' => $from_date, + ':to_date' => $to_date, + ':dept_id' => $dept_id, + ]); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + + // Account metadata + $sth = $this->pdo->prepare( + "SELECT account_name, account_type + FROM md_account + WHERE company_id = :cid AND account_code = :code LIMIT 1" + ); + $sth->execute([':cid' => $this->companyId, ':code' => $account_code]); + $acct = $sth->fetch(PDO::FETCH_ASSOC); + + return [ + 'account_code' => $account_code, + 'account_name' => $acct['account_name'] ?? '', + 'account_type' => $acct['account_type'] ?? '', + 'opening_balance' => $opening_balance, + 'rows' => $rows, + 'from_date' => $from_date, + 'to_date' => $to_date, + 'from_period' => substr($from_date, 0, 7), + 'to_period' => substr($to_date, 0, 7), + ]; + } } diff --git a/app/assets/utils/classes_ac/GlManager.php b/app/assets/utils/classes_ac/GlManager.php new file mode 100644 index 0000000..7043289 --- /dev/null +++ b/app/assets/utils/classes_ac/GlManager.php @@ -0,0 +1,295 @@ +pdo = $pdo; + $this->companyId = $company_id; + $this->postingWindowGuard = $postingWindowGuard; + } + + // ── public ─────────────────────────────────────────────────────────────── + + public function getBySource(string $source_type, int $source_id): ?array + { + $sth = $this->pdo->prepare( + "SELECT * FROM td_gl + WHERE company_id = :cid + AND source_type = :source_type + AND source_id = :source_id + LIMIT 1" + ); + $sth->execute([ + ':cid' => $this->companyId, + ':source_type' => $source_type, + ':source_id' => $source_id, + ]); + return $sth->fetch(PDO::FETCH_ASSOC) ?: null; + } + + public function post(string $source_type, int $source_id, int $formula_id, string $period, array $lines, array $meta = []): void + { + $journal_date = $this->resolveJournalDate($meta['journal_date'] ?? null, $period); + $this->assertPostingWindow($journal_date, 'GL posting'); + + $now = date('Y-m-d H:i:s'); + $sth = $this->pdo->prepare( + "INSERT INTO td_gl + (company_id, source_type, source_id, reference, description, journal_date, + formula_id, period, current_version, history, created_at, updated_at) + VALUES + (:cid, :source_type, :source_id, :reference, :description, :journal_date, + :formula_id, :period, 1, '[]', :created_at, :updated_at)" + ); + $sth->execute([ + ':cid' => $this->companyId, + ':source_type' => $source_type, + ':source_id' => $source_id, + ':reference' => $meta['reference'] ?? '', + ':description' => $meta['description'] ?? '', + ':journal_date' => $journal_date, + ':formula_id' => $formula_id, + ':period' => $period, + ':created_at' => $now, + ':updated_at' => $now, + ]); + $this->insertLines((int)$this->pdo->lastInsertId(), $lines); + } + + public function replaceManual(int $gl_id, string $reference, string $description, string $journal_date, string $period, array $lines): void + { + $this->assertPostingWindow($journal_date, 'Manual journal'); + + $now = date('Y-m-d H:i:s'); + + $sth = $this->pdo->prepare( + "SELECT id, current_version, formula_id, history + FROM td_gl + WHERE company_id = :cid AND id = :gl_id AND source_type = 'manual' + FOR UPDATE" + ); + $sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]); + $gl = $sth->fetch(PDO::FETCH_ASSOC); + if (!$gl) throw new Exception("Manual GL entry #$gl_id not found."); + + $version = (int)$gl['current_version']; + $history = json_decode($gl['history'], true) ?: []; + + $history[] = [ + 'version' => $version, + 'formula_id' => 0, + 'replaced_at' => $now, + 'lines' => $this->getLines($gl_id), + ]; + + $this->pdo->prepare( + "DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id" + )->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]); + + $this->pdo->prepare( + "UPDATE td_gl + SET reference = :reference, description = :description, + journal_date = :journal_date, period = :period, + current_version = :current_version, history = :history, + updated_at = :updated_at + WHERE id = :id" + )->execute([ + ':reference' => $reference, + ':description' => $description, + ':journal_date' => $journal_date, + ':period' => $period, + ':current_version' => $version + 1, + ':history' => json_encode($history), + ':updated_at' => $now, + ':id' => $gl_id, + ]); + + $this->insertLines($gl_id, $lines); + } + + public function postManual(string $reference, string $description, string $journal_date, string $period, array $lines): int + { + $this->assertPostingWindow($journal_date, 'Manual journal'); + + $now = date('Y-m-d H:i:s'); + $sth = $this->pdo->prepare( + "INSERT INTO td_gl + (company_id, source_type, source_id, reference, description, journal_date, + formula_id, period, current_version, history, created_at, updated_at) + VALUES + (:cid, 'manual', 0, :reference, :description, :journal_date, + 0, :period, 1, '[]', :created_at, :updated_at)" + ); + $sth->execute([ + ':cid' => $this->companyId, + ':reference' => $reference, + ':description' => $description, + ':journal_date' => $journal_date, + ':period' => $period, + ':created_at' => $now, + ':updated_at' => $now, + ]); + $gl_id = (int)$this->pdo->lastInsertId(); + $this->insertLines($gl_id, $lines); + return $gl_id; + } + + public function replace(string $source_type, int $source_id, int $formula_id, string $period, array $lines, array $meta = []): void + { + $now = date('Y-m-d H:i:s'); + + $sth = $this->pdo->prepare( + "SELECT id, current_version, formula_id, history, journal_date + FROM td_gl + WHERE company_id = :cid + AND source_type = :source_type + AND source_id = :source_id + FOR UPDATE" + ); + $sth->execute([ + ':cid' => $this->companyId, + ':source_type' => $source_type, + ':source_id' => $source_id, + ]); + $gl = $sth->fetch(PDO::FETCH_ASSOC); + if (!$gl) throw new Exception("GL record not found for replace."); + + $journal_date = $this->resolveJournalDate($meta['journal_date'] ?? ($gl['journal_date'] ?? null), $period); + $this->assertPostingWindow($journal_date, 'GL posting'); + + $gl_id = (int)$gl['id']; + $version = (int)$gl['current_version']; + $history = json_decode($gl['history'], true) ?: []; + + // Snapshot current lines into history + $history[] = [ + 'version' => $version, + 'formula_id' => (int)$gl['formula_id'], + 'replaced_at' => $now, + 'lines' => $this->getLines($gl_id), + ]; + + $this->pdo->prepare( + "DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id" + )->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]); + + $this->pdo->prepare( + "UPDATE td_gl + SET formula_id = :formula_id, + journal_date = :journal_date, + period = :period, + current_version = :current_version, + history = :history, + updated_at = :updated_at + WHERE id = :id" + )->execute([ + ':formula_id' => $formula_id, + ':journal_date' => $journal_date, + ':period' => $period, + ':current_version' => $version + 1, + ':history' => json_encode($history), + ':updated_at' => $now, + ':id' => $gl_id, + ]); + + $this->insertLines($gl_id, $lines); + } + + public function delete(string $source_type, int $source_id): void + { + $sth = $this->pdo->prepare( + "SELECT id, journal_date, period FROM td_gl + WHERE company_id = :cid + AND source_type = :source_type + AND source_id = :source_id + LIMIT 1" + ); + $sth->execute([ + ':cid' => $this->companyId, + ':source_type' => $source_type, + ':source_id' => $source_id, + ]); + $gl = $sth->fetch(PDO::FETCH_ASSOC); + $gl_id = (int)($gl['id'] ?? 0); + if (!$gl_id) return; + + $journal_date = $this->resolveJournalDate($gl['journal_date'] ?? null, (string)($gl['period'] ?? '')); + $this->assertPostingWindow($journal_date, 'GL posting deletion'); + + $this->pdo->prepare( + "DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id" + )->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]); + + $this->pdo->prepare( + "DELETE FROM td_gl WHERE id = :id AND company_id = :cid" + )->execute([':id' => $gl_id, ':cid' => $this->companyId]); + } + + // ── private ────────────────────────────────────────────────────────────── + + private function getLines(int $gl_id): array + { + $sth = $this->pdo->prepare( + "SELECT account_code, department_id, debit, credit, description + FROM td_gl_item + WHERE company_id = :cid AND gl_id = :gl_id + ORDER BY id ASC" + ); + $sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + private function assertPostingWindow(?string $journal_date, string $context): void + { + if ($this->postingWindowGuard) { + $this->postingWindowGuard->assertOpenDate($journal_date, $context); + } + } + + private function resolveJournalDate(?string $journal_date, string $period): string + { + $journal_date = trim((string)$journal_date); + if ($journal_date !== '') { + return $journal_date; + } + + $period = trim($period); + return preg_match('/^\d{4}-\d{2}$/', $period) ? "{$period}-01" : ''; + } + + private function insertLines(int $gl_id, array $lines): void + { + $sth = $this->pdo->prepare( + "INSERT INTO td_gl_item + (company_id, gl_id, account_code, department_id, debit, credit, description) + VALUES + (:cid, :gl_id, :account_code, :dept_id, :debit, :credit, :description)" + ); + foreach ($lines as $line) { + $sth->execute([ + ':cid' => $this->companyId, + ':gl_id' => $gl_id, + ':account_code' => $line['account_code'], + ':dept_id' => (int)($line['department_id'] ?? 0), + ':debit' => (float)($line['debit'] ?? 0), + ':credit' => (float)($line['credit'] ?? 0), + ':description' => (string)($line['description'] ?? ''), + ]); + } + } +} diff --git a/app/assets/utils/classes_ac/GlQueryManager.php b/app/assets/utils/classes_ac/GlQueryManager.php new file mode 100644 index 0000000..653a125 --- /dev/null +++ b/app/assets/utils/classes_ac/GlQueryManager.php @@ -0,0 +1,378 @@ +pdo = $pdo; + $this->companyId = $company_id; + } + + public function getPostableDocuments(string $doc_type, string $date_from = '', string $date_to = '', int $formula_id = 0): array + { + $doc_type = trim($doc_type); + $date_from = $this->parseDate($date_from); + $date_to = $this->parseDate($date_to); + + if (!in_array($doc_type, $this->postingTypes, true)) { + throw new Exception('Invalid doc_type.'); + } + + if (in_array($doc_type, $this->invoiceTypes, true)) { + $rows = $this->getInvoicePostableDocuments($doc_type, $date_from, $date_to, $formula_id); + } elseif ($doc_type === 'receipt') { + $rows = $this->getReceiptPostableDocuments($date_from, $date_to); + } elseif ($doc_type === 'purchase_order') { + $rows = $this->getPurchaseOrderPostableDocuments($date_from, $date_to, $formula_id); + } else { + $rows = $this->getPaymentPostableDocuments($date_from, $date_to); + } + + if ($formula_id > 0) { + $rows = array_values(array_filter($rows, function($row) use ($formula_id) { + return $row['gl_status'] == 0 || (int)$row['gl_formula_id'] === $formula_id; + })); + } + + return $rows; + } + + public function getJournalListing(string $source_type = '', string $date_from = '', string $date_to = ''): array + { + $source_type = trim($source_type); + $date_from = $this->parseDate($date_from); + $date_to = $this->parseDate($date_to); + + $where = ['g.company_id = :cid']; + $params = [':cid' => $this->companyId]; + + if ($source_type && in_array($source_type, $this->journalTypes, true)) { + $where[] = 'g.source_type = :source_type'; + $params[':source_type'] = $source_type; + } + if ($date_from) { + $where[] = 'g.journal_date >= :date_from'; + $params[':date_from'] = $date_from; + } + if ($date_to) { + $where[] = 'g.journal_date <= :date_to'; + $params[':date_to'] = $date_to; + } + + $sql = " + SELECT + g.id, + g.source_type, + g.source_id, + g.period, + g.current_version, + g.formula_id, + COALESCE(f.formula_name, '') AS formula_name, + DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at, + DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at, + CASE g.source_type + WHEN 'receipt' THEN r.receipt_number + WHEN 'payment' THEN p.payment_number + WHEN 'manual' THEN IF(g.reference != '', g.reference, CONCAT('MJE-', g.id)) + WHEN 'purchase_order' THEN po.po_number + ELSE i.invoice_number + END AS doc_number, + COALESCE( + CASE g.source_type + WHEN 'receipt' THEN cr.contact_name + WHEN 'payment' THEN cp.contact_name + WHEN 'manual' THEN g.description + WHEN 'purchase_order' THEN cpo.contact_name + ELSE ci.contact_name + END, '' + ) AS contact_name, + COALESCE(SUM(gi.debit), 0) AS total_debit, + COALESCE(SUM(gi.credit), 0) AS total_credit + FROM td_gl g + LEFT JOIN md_account_formula f + ON f.company_id = g.company_id AND f.id = g.formula_id + LEFT JOIN td_gl_item gi + ON gi.company_id = g.company_id AND gi.gl_id = g.id + LEFT JOIN td_invoice i + ON g.source_type NOT IN ('receipt','payment','manual','purchase_order') + AND i.company_id = g.company_id AND i.id = g.source_id + LEFT JOIN md_contact ci + ON ci.company_id = g.company_id AND ci.id = i.contact_id + LEFT JOIN td_receipt r + ON g.source_type = 'receipt' + AND r.company_id = g.company_id AND r.id = g.source_id + LEFT JOIN md_contact cr + ON cr.company_id = g.company_id AND cr.id = r.contact_id + LEFT JOIN td_payment p + ON g.source_type = 'payment' + AND p.company_id = g.company_id AND p.id = g.source_id + LEFT JOIN md_contact cp + ON cp.company_id = g.company_id AND cp.id = p.contact_id + LEFT JOIN td_purchase_order po + ON g.source_type = 'purchase_order' + AND po.company_id = g.company_id AND po.id = g.source_id + LEFT JOIN md_contact cpo + ON cpo.company_id = g.company_id AND cpo.id = po.contact_id + WHERE " . implode(' AND ', $where) . " + GROUP BY + g.id, g.source_type, g.source_id, g.reference, g.description, g.period, + g.current_version, g.formula_id, f.formula_name, g.created_at, g.updated_at, + i.invoice_number, r.receipt_number, p.payment_number, po.po_number, + ci.contact_name, cr.contact_name, cp.contact_name, cpo.contact_name + ORDER BY g.created_at DESC, g.id DESC + "; + + $sth = $this->pdo->prepare($sql); + $sth->execute($params); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + public function getJournalDetail(int $gl_id): array + { + if ($gl_id <= 0) { + throw new Exception('gl_id is required.'); + } + + $sth = $this->pdo->prepare( + "SELECT g.*, + DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at, + DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt, + DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt, + COALESCE(f.formula_name, '') AS formula_name + FROM td_gl g + LEFT JOIN md_account_formula f + ON f.company_id = g.company_id AND f.id = g.formula_id + WHERE g.company_id = :cid AND g.id = :gl_id + LIMIT 1" + ); + $sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]); + $header = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$header) { + throw new Exception('GL entry not found.'); + } + + $sth = $this->pdo->prepare( + "SELECT gi.account_code, gi.department_id, gi.debit, gi.credit, gi.description, + COALESCE(a.account_name, '') AS account_name, + COALESCE(d.dept_code, '') AS dept_code, + COALESCE(d.dept_name, '') AS dept_name + FROM td_gl_item gi + LEFT JOIN md_account a + ON a.company_id = gi.company_id AND a.account_code = gi.account_code + LEFT JOIN md_department d + ON d.company_id = gi.company_id AND d.id = gi.department_id + WHERE gi.company_id = :cid AND gi.gl_id = :gl_id + ORDER BY gi.id ASC" + ); + $sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]); + + return [ + 'header' => $header, + 'lines' => $sth->fetchAll(PDO::FETCH_ASSOC), + ]; + } + + private function getInvoicePostableDocuments(string $doc_type, string $date_from, string $date_to, int $formula_id): array + { + $requires_product_mapping = $this->formulaRequiresProductMapping($doc_type, $formula_id); + $mapping_column = in_array($doc_type, ['invoice', 'credit_note'], true) + ? 'sales_account_code' + : 'purchase_account_code'; + + $mapping_select = $requires_product_mapping + ? ", ( + SELECT COUNT(*) + FROM td_invoice_item ii + LEFT JOIN md_product p + ON p.company_id = ii.company_id + AND p.sku = ii.product_sku + WHERE ii.company_id = i.company_id + AND ii.invoice_id = i.id + AND ABS(ii.total_price) > 0.0000001 + AND COALESCE(NULLIF(p.{$mapping_column}, ''), '') = '' + ) AS product_mapping_missing" + : ", 0 AS product_mapping_missing"; + + $where = ['i.company_id = :cid', 'i.doc_type = :doc_type', 'i.status != 4']; + $params = [':cid' => $this->companyId, ':doc_type' => $doc_type, ':source_type' => $doc_type]; + if ($date_from) { $where[] = 'i.issued_date >= :date_from'; $params[':date_from'] = $date_from; } + if ($date_to) { $where[] = 'i.issued_date <= :date_to'; $params[':date_to'] = $date_to; } + + $sth = $this->pdo->prepare( + "SELECT i.id, + i.invoice_number AS doc_number, + COALESCE(c.contact_name, '') AS contact_name, + i.grand_total, + i.issued_date AS doc_date, + IF(g.id IS NULL, 0, 1) AS gl_status, + COALESCE(g.formula_id, 0) AS gl_formula_id, + " . ($requires_product_mapping ? '1' : '0') . " AS product_mapping_checked + {$mapping_select} + FROM td_invoice i + LEFT JOIN md_contact c + ON c.company_id = i.company_id AND c.id = i.contact_id + LEFT JOIN td_gl g + ON g.company_id = i.company_id + AND g.source_type = :source_type + AND g.source_id = i.id + WHERE " . implode(' AND ', $where) . " + ORDER BY i.issued_date DESC, i.id DESC" + ); + $sth->execute($params); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + private function getReceiptPostableDocuments(string $date_from, string $date_to): array + { + $where = ['r.company_id = :cid', 'r.status = 1']; + $params = [':cid' => $this->companyId]; + if ($date_from) { $where[] = 'r.receipt_date >= :date_from'; $params[':date_from'] = $date_from; } + if ($date_to) { $where[] = 'r.receipt_date <= :date_to'; $params[':date_to'] = $date_to; } + + $sth = $this->pdo->prepare( + "SELECT r.id, + r.receipt_number AS doc_number, + COALESCE(c.contact_name, '') AS contact_name, + r.amount AS grand_total, + r.receipt_date AS doc_date, + IF(g.id IS NULL, 0, 1) AS gl_status, + COALESCE(g.formula_id, 0) AS gl_formula_id, + 0 AS product_mapping_checked, + 0 AS product_mapping_missing + FROM td_receipt r + LEFT JOIN md_contact c + ON c.company_id = r.company_id AND c.id = r.contact_id + LEFT JOIN td_gl g + ON g.company_id = r.company_id + AND g.source_type = 'receipt' + AND g.source_id = r.id + WHERE " . implode(' AND ', $where) . " + ORDER BY r.receipt_date DESC, r.id DESC" + ); + $sth->execute($params); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + private function getPaymentPostableDocuments(string $date_from, string $date_to): array + { + $where = ['r.company_id = :cid', 'r.status = 1']; + $params = [':cid' => $this->companyId]; + if ($date_from) { $where[] = 'r.payment_date >= :date_from'; $params[':date_from'] = $date_from; } + if ($date_to) { $where[] = 'r.payment_date <= :date_to'; $params[':date_to'] = $date_to; } + + $sth = $this->pdo->prepare( + "SELECT r.id, + r.payment_number AS doc_number, + COALESCE(c.contact_name, '') AS contact_name, + r.amount AS grand_total, + r.payment_date AS doc_date, + IF(g.id IS NULL, 0, 1) AS gl_status, + COALESCE(g.formula_id, 0) AS gl_formula_id, + 0 AS product_mapping_checked, + 0 AS product_mapping_missing + FROM td_payment r + LEFT JOIN md_contact c + ON c.company_id = r.company_id AND c.id = r.contact_id + LEFT JOIN td_gl g + ON g.company_id = r.company_id + AND g.source_type = 'payment' + AND g.source_id = r.id + WHERE " . implode(' AND ', $where) . " + ORDER BY r.payment_date DESC, r.id DESC" + ); + $sth->execute($params); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + private function getPurchaseOrderPostableDocuments(string $date_from, string $date_to, int $formula_id): array + { + $requires_mapping = $this->formulaRequiresProductMapping('purchase_order', $formula_id); + + $mapping_select = $requires_mapping + ? ", ( + SELECT COUNT(*) + FROM td_purchase_order_item poi + LEFT JOIN md_product p + ON p.company_id = poi.company_id + AND p.sku = poi.product_sku + WHERE poi.company_id = po.company_id + AND poi.order_id = po.id + AND ABS(poi.total_price) > 0.0000001 + AND COALESCE(NULLIF(p.purchase_account_code, ''), '') = '' + ) AS product_mapping_missing" + : ", 0 AS product_mapping_missing"; + + $where = ['po.company_id = :cid', 'po.status = 1']; + $params = [':cid' => $this->companyId]; + if ($date_from) { $where[] = 'po.po_date >= :date_from'; $params[':date_from'] = $date_from; } + if ($date_to) { $where[] = 'po.po_date <= :date_to'; $params[':date_to'] = $date_to; } + + $sth = $this->pdo->prepare( + "SELECT po.id, + po.po_number AS doc_number, + COALESCE(c.contact_name, '') AS contact_name, + po.grand_total, + po.po_date AS doc_date, + IF(g.id IS NULL, 0, 1) AS gl_status, + COALESCE(g.formula_id, 0) AS gl_formula_id, + " . ($requires_mapping ? '1' : '0') . " AS product_mapping_checked + {$mapping_select} + FROM td_purchase_order po + LEFT JOIN md_contact c + ON c.company_id = po.company_id AND c.id = po.contact_id + LEFT JOIN td_gl g + ON g.company_id = po.company_id + AND g.source_type = 'purchase_order' + AND g.source_id = po.id + WHERE " . implode(' AND ', $where) . " + ORDER BY po.po_date DESC, po.id DESC" + ); + $sth->execute($params); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + private function formulaRequiresProductMapping(string $doc_type, int $formula_id): bool + { + if ($formula_id <= 0 || !in_array($doc_type, $this->mappingTypes, true)) { + return false; + } + + $sth = $this->pdo->prepare( + "SELECT COUNT(*) + FROM md_account_formula_item fi + JOIN md_account_formula f + ON f.company_id = fi.company_id + AND f.id = fi.formula_id + WHERE fi.company_id = :cid + AND fi.formula_id = :formula_id + AND f.document_type = :doc_type + AND fi.amount_key = 'total' + AND f.status = 1" + ); + $sth->execute([ + ':cid' => $this->companyId, + ':formula_id' => $formula_id, + ':doc_type' => $doc_type, + ]); + return ((int)$sth->fetchColumn()) > 0; + } + + private function parseDate(string $value): string + { + $value = trim($value); + if ($value === '') return ''; + if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $value, $m)) { + return "{$m[3]}-{$m[2]}-{$m[1]}"; + } + return $value; + } +} +?> diff --git a/app/assets/utils/classes_ac/JournalManager.php b/app/assets/utils/classes_ac/JournalManager.php deleted file mode 100644 index 7b3bb7e..0000000 --- a/app/assets/utils/classes_ac/JournalManager.php +++ /dev/null @@ -1,25 +0,0 @@ -pdo = $pdo; - $this->companyId = $company_id; - } - - // TODO: create(array $header, array $lines): int — returns journal_id - // TODO: void(int $journal_id): void - // TODO: getBySource(string $source_type, int $source_id): ?array - // TODO: getById(int $journal_id): ?array - // TODO: updateBalance(string $period, string $account_code, ?int $dept_id, float $debit, float $credit): void -} diff --git a/app/assets/utils/classes_ac/PeriodManager.php b/app/assets/utils/classes_ac/PeriodManager.php deleted file mode 100644 index d74a0d5..0000000 --- a/app/assets/utils/classes_ac/PeriodManager.php +++ /dev/null @@ -1,120 +0,0 @@ -pdo = $pdo; - $this->companyId = $company_id; - } - - public function getAll(): array - { - $sth = $this->pdo->prepare( - "SELECT * FROM ac_period - WHERE company_id = :cid - ORDER BY period DESC" - ); - $sth->execute([':cid' => $this->companyId]); - return $sth->fetchAll(PDO::FETCH_ASSOC); - } - - public function getById(int $id): ?array - { - $sth = $this->pdo->prepare( - "SELECT * FROM ac_period WHERE company_id = :cid AND id = :id LIMIT 1" - ); - $sth->execute([':cid' => $this->companyId, ':id' => $id]); - $row = $sth->fetch(PDO::FETCH_ASSOC); - return $row ?: null; - } - - public function isOpen(string $period): bool - { - $sth = $this->pdo->prepare( - "SELECT status FROM ac_period - WHERE company_id = :cid AND period = :period LIMIT 1" - ); - $sth->execute([':cid' => $this->companyId, ':period' => $period]); - $status = $sth->fetchColumn(); - return $status === 'open'; - } - - // Get existing period or auto-create it as open - public function getOrCreate(string $period): array - { - $sth = $this->pdo->prepare( - "SELECT * FROM ac_period WHERE company_id = :cid AND period = :period LIMIT 1" - ); - $sth->execute([':cid' => $this->companyId, ':period' => $period]); - $row = $sth->fetch(PDO::FETCH_ASSOC); - if ($row) return $row; - - $this->pdo->prepare( - "INSERT INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')" - )->execute([':cid' => $this->companyId, ':period' => $period]); - - return $this->getOrCreate($period); - } - - // Create a single period (manual) - public function create(string $period): void - { - $this->pdo->prepare( - "INSERT IGNORE INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')" - )->execute([':cid' => $this->companyId, ':period' => $period]); - } - - // Bulk-create all 12 months of a year - public function createYear(int $year): void - { - $sth = $this->pdo->prepare( - "INSERT IGNORE INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')" - ); - for ($m = 1; $m <= 12; $m++) { - $sth->execute([ - ':cid' => $this->companyId, - ':period' => sprintf('%04d-%02d', $year, $m), - ]); - } - } - - public function open(int $id): void - { - $this->pdo->prepare( - "UPDATE ac_period SET status = 'open', closed_at = NULL - WHERE company_id = :cid AND id = :id AND status = 'closed'" - )->execute([':cid' => $this->companyId, ':id' => $id]); - } - - public function close(int $id): void - { - $this->pdo->prepare( - "UPDATE ac_period SET status = 'closed', closed_at = NOW() - WHERE company_id = :cid AND id = :id AND status = 'open'" - )->execute([':cid' => $this->companyId, ':id' => $id]); - } - - public function lock(int $id): void - { - $this->pdo->prepare( - "UPDATE ac_period SET status = 'locked', locked_at = NOW() - WHERE company_id = :cid AND id = :id AND status = 'closed'" - )->execute([':cid' => $this->companyId, ':id' => $id]); - } - - public function getStats(): array - { - $sth = $this->pdo->prepare( - "SELECT COUNT(*) AS total, - SUM(status = 'open') AS open_count, - SUM(status = 'closed') AS closed_count, - SUM(status = 'locked') AS locked_count - FROM ac_period WHERE company_id = :cid" - ); - $sth->execute([':cid' => $this->companyId]); - return $sth->fetch(PDO::FETCH_ASSOC) ?: []; - } -} diff --git a/app/assets/utils/classes_ac/PostingWindowGuard.php b/app/assets/utils/classes_ac/PostingWindowGuard.php new file mode 100644 index 0000000..0f0d0cb --- /dev/null +++ b/app/assets/utils/classes_ac/PostingWindowGuard.php @@ -0,0 +1,73 @@ +settings = new CompanySettingManager($settingsPdo, $company_id); + } + + public function assertOpenDate(?string $posting_date, string $context = 'GL posting'): void + { + $date = $this->normalizeDate($posting_date); + if ($date === '') { + throw new Exception("{$context} date is required for posting-window validation."); + } + + $window = $this->getWindow(); + $from = $window['open_from']; + $to = $window['open_to']; + + if ($from !== '' && $date < $from) { + throw new Exception("{$context} date {$date} is before the open posting window ({$from} to " . ($to ?: 'no upper limit') . ")."); + } + + if ($to !== '' && $date > $to) { + throw new Exception("{$context} date {$date} is after the open posting window (" . ($from ?: 'no lower limit') . " to {$to})."); + } + } + + private function getWindow(): array + { + if ($this->window === null) { + $open_from = $this->settings->get('posting_open_from'); + if ($open_from === null || $open_from === '') { + $open_from = $this->settings->get('gl_open_from') ?: ''; + } + + $open_to = $this->settings->get('posting_open_to'); + if ($open_to === null || $open_to === '') { + $open_to = $this->settings->get('gl_open_to') ?: ''; + } + + $this->window = [ + 'open_from' => $this->normalizeDate($open_from ?: ''), + 'open_to' => $this->normalizeDate($open_to ?: ''), + ]; + } + + return $this->window; + } + + private function normalizeDate(?string $date): string + { + $date = trim((string)$date); + if ($date === '') { + return ''; + } + + if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $date, $m)) { + $date = "{$m[3]}-{$m[2]}-{$m[1]}"; + } + + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + throw new Exception("Invalid posting date {$date}. Use YYYY-MM-DD."); + } + + return $date; + } +} diff --git a/app/assets/utils/classes_ac/TaxReportManager.php b/app/assets/utils/classes_ac/TaxReportManager.php index b9c9c1e..19327db 100644 --- a/app/assets/utils/classes_ac/TaxReportManager.php +++ b/app/assets/utils/classes_ac/TaxReportManager.php @@ -3,7 +3,8 @@ * TaxReportManager * * Generates VAT and withholding tax reports from journal lines - * tagged to tax accounts (output VAT, input VAT, WHT). + * tagged to tax accounts (output VAT, input VAT, WHT) in md_account. + * Matches accounts where account_name contains 'vat' or 'tax' (case-insensitive). * Read-only — no writes. */ class TaxReportManager @@ -17,7 +18,89 @@ class TaxReportManager $this->companyId = $company_id; } - // TODO: getOutputVAT(string $from_period, string $to_period): array - // TODO: getInputVAT(string $from_period, string $to_period): array - // TODO: getWithholdingTax(string $from_period, string $to_period): array + private function normalizeDate(string $date, bool $end_of_month = false): string + { + $date = trim($date); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + return $date; + } + if (preg_match('/^\d{4}-\d{2}$/', $date)) { + return $end_of_month ? date('Y-m-t', strtotime($date . '-01')) : $date . '-01'; + } + return date('Y-m-d'); + } + + // ── VAT Report ──────────────────────────────────────────────────────────── + // Returns all GL lines for accounts whose account_category is 'sales_tax' + // or 'purchase_tax'. Pass $categories = [] to get both; pass one or more + // category strings to narrow (e.g. ['sales_tax']). + + public function getVATReport(string $from_date, string $to_date, array $categories = [], int $dept_id = 0): array + { + $from_date = $this->normalizeDate($from_date); + $to_date = $this->normalizeDate($to_date, true); + + $valid = ['sales_tax', 'purchase_tax']; + $cats = $categories ? array_intersect($categories, $valid) : $valid; + + if (empty($cats)) { + return ['rows' => [], 'total_debit' => 0.0, 'total_credit' => 0.0, + 'from_date' => $from_date, 'to_date' => $to_date, + 'from_period' => substr($from_date, 0, 7), 'to_period' => substr($to_date, 0, 7)]; + } + + $placeholders = implode(',', array_map(fn($i) => ":cat$i", array_keys($cats))); + $params = [':cid' => $this->companyId, ':from_date' => $from_date, ':to_date' => $to_date]; + foreach (array_values($cats) as $i => $c) { $params[":cat$i"] = $c; } + + $sth = $this->pdo->prepare(" + SELECT + g.id AS gl_id, + g.source_type, + COALESCE(g.reference, '') AS reference, + g.period, + COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date, + a.account_code, + a.account_name, + a.account_category, + i.department_id, + COALESCE(d.dept_code, '') AS dept_code, + COALESCE(d.dept_name, '') AS dept_name, + i.debit, + i.credit, + COALESCE(i.description, '') AS description + FROM td_gl_item i + JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id + JOIN md_account a ON a.company_id = i.company_id AND a.account_code = i.account_code + LEFT JOIN md_department d ON d.company_id = i.company_id AND d.id = i.department_id + WHERE i.company_id = :cid + AND COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date + AND a.account_category IN ($placeholders) + AND (:dept_id = 0 OR i.department_id = :dept_id) + ORDER BY g.period ASC, g.journal_date ASC, g.id ASC + "); + $params[':dept_id'] = $dept_id; + $sth->execute($params); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + + return [ + 'rows' => $rows, + 'total_debit' => (float)array_sum(array_column($rows, 'debit')), + 'total_credit' => (float)array_sum(array_column($rows, 'credit')), + 'from_date' => $from_date, + 'to_date' => $to_date, + 'from_period' => substr($from_date, 0, 7), + 'to_period' => substr($to_date, 0, 7), + ]; + } + + public function getOutputVAT(string $from_period, string $to_period, int $dept_id = 0): array + { + return $this->getVATReport($from_period, $to_period, ['sales_tax'], $dept_id); + } + + public function getInputVAT(string $from_period, string $to_period, int $dept_id = 0): array + { + return $this->getVATReport($from_period, $to_period, ['purchase_tax'], $dept_id); + } } diff --git a/app/assets/utils/classes_ac/posting/BasePosting.php b/app/assets/utils/classes_ac/posting/BasePosting.php new file mode 100644 index 0000000..b158a96 --- /dev/null +++ b/app/assets/utils/classes_ac/posting/BasePosting.php @@ -0,0 +1,183 @@ + int, 'period' => 'YYYY-MM', 'doc_date' => 'YYYY-MM-DD', 'lines' => [...]] + * + * Amount keys understood by formulas: + * grand_total — full document total (abs value used) + * total — pre-tax amount (grand_total - tax) + * tax — tax portion + * amount — used by receipt and payment + */ +abstract class BasePosting +{ + protected PDO $pdo; + protected int $companyId; + + public function __construct(PDO $pdo, int $company_id) + { + $this->pdo = $pdo; + $this->companyId = $company_id; + } + + abstract protected function fetchDocument(int $id): array; + abstract protected function getAmounts(array $doc): array; + abstract protected function getDocType(): string; + abstract protected function getDocDate(array $doc): string; + + public function build(int $id, ?int $formula_id = null): array + { + $doc = $this->fetchDocument($id); + $formula = $this->resolveFormula($this->getDocType(), $formula_id); + $amounts = $this->getAmounts($doc); + $doc_date = $this->getDocDate($doc); + $period = substr($doc_date, 0, 7); + $dept_id = (int)($doc['department_id'] ?? 0); + + $lines = []; + foreach ($formula['items'] as $item) { + $amount = abs((float)($amounts[$item['amount_key']] ?? 0)); + if ($amount == 0) continue; + + if ($item['amount_key'] === 'total') { + $split_lines = $this->buildSkuTotalLines($doc, $item, $amount, $dept_id); + if ($split_lines) { + $lines = array_merge($lines, $split_lines); + continue; + } + } + + $lines[] = [ + 'account_code' => $item['account_code'], + 'department_id' => $dept_id, + 'debit' => $item['drcr'] === 'D' ? $amount : 0.0, + 'credit' => $item['drcr'] === 'C' ? $amount : 0.0, + 'description' => (string)($item['description'] ?? ''), + ]; + } + + return [ + 'formula_id' => (int)$formula['id'], + 'period' => $period, + 'doc_date' => $doc_date, + 'lines' => $lines, + ]; + } + + protected function resolveFormula(string $doc_type, ?int $formula_id): array + { + if ($formula_id) { + $sth = $this->pdo->prepare( + "SELECT * FROM md_account_formula + WHERE company_id = :cid AND id = :id AND status = 1 + LIMIT 1" + ); + $sth->execute([':cid' => $this->companyId, ':id' => $formula_id]); + } else { + $sth = $this->pdo->prepare( + "SELECT * FROM md_account_formula + WHERE company_id = :cid + AND document_type = :doc_type + AND is_default = 1 + AND status = 1 + LIMIT 1" + ); + $sth->execute([':cid' => $this->companyId, ':doc_type' => $doc_type]); + } + + $formula = $sth->fetch(PDO::FETCH_ASSOC); + if (!$formula) throw new Exception("No active formula found for '{$doc_type}'."); + + $sth = $this->pdo->prepare( + "SELECT * FROM md_account_formula_item + WHERE company_id = :cid AND formula_id = :formula_id + ORDER BY sort_order ASC" + ); + $sth->execute([':cid' => $this->companyId, ':formula_id' => $formula['id']]); + $formula['items'] = $sth->fetchAll(PDO::FETCH_ASSOC); + + return $formula; + } + + protected function getSkuTotalAccountColumn(): string + { + return ''; + } + + private function buildSkuTotalLines(array $doc, array $formula_item, float $amount, int $dept_id): array + { + $column = $this->getSkuTotalAccountColumn(); + if ($column === '' || !in_array($column, ['sales_account_code', 'purchase_account_code'], true)) { + return []; + } + + $invoice_id = (int)($doc['id'] ?? 0); + if ($invoice_id <= 0) return []; + + $sth = $this->pdo->prepare(" + SELECT + i.product_sku, + i.product_name, + i.total_price, + COALESCE(NULLIF(p.{$column}, ''), '') AS mapped_account_code + FROM td_invoice_item i + LEFT JOIN md_product p + ON p.company_id = i.company_id + AND p.sku = i.product_sku + WHERE i.company_id = :cid + AND i.invoice_id = :invoice_id + ORDER BY i.item_id ASC, i.id ASC + "); + $sth->execute([':cid' => $this->companyId, ':invoice_id' => $invoice_id]); + $items = array_values(array_filter($sth->fetchAll(PDO::FETCH_ASSOC), function($row) { + return abs((float)($row['total_price'] ?? 0)) > 0.0000001; + })); + + if (!$items) return []; + + $base_total = array_reduce($items, function($carry, $row) { + return $carry + abs((float)($row['total_price'] ?? 0)); + }, 0.0); + if ($base_total <= 0) return []; + + $remaining = round($amount, 4); + $last = count($items) - 1; + $lines = []; + + foreach ($items as $idx => $row) { + $line_amount = $idx === $last + ? $remaining + : round($amount * (abs((float)$row['total_price']) / $base_total), 4); + $remaining = round($remaining - $line_amount, 4); + if (abs($line_amount) <= 0.0000001) continue; + + $account_code = trim((string)($row['mapped_account_code'] ?? '')); + if ($account_code === '') { + $account_code = (string)$formula_item['account_code']; + } + + $desc = trim((string)($formula_item['description'] ?? '')); + $sku = trim((string)($row['product_sku'] ?? '')); + $name = trim((string)($row['product_name'] ?? '')); + $item_desc = trim($sku . ($name !== '' ? ' - ' . $name : '')); + if ($item_desc !== '') { + $desc = $desc !== '' ? $desc . ': ' . $item_desc : $item_desc; + } + + $lines[] = [ + 'account_code' => $account_code, + 'department_id' => $dept_id, + 'debit' => $formula_item['drcr'] === 'D' ? $line_amount : 0.0, + 'credit' => $formula_item['drcr'] === 'C' ? $line_amount : 0.0, + 'description' => $desc, + ]; + } + + return $lines; + } +} diff --git a/app/assets/utils/classes_ac/posting/CreditNotePosting.php b/app/assets/utils/classes_ac/posting/CreditNotePosting.php new file mode 100644 index 0000000..ae605ec --- /dev/null +++ b/app/assets/utils/classes_ac/posting/CreditNotePosting.php @@ -0,0 +1,43 @@ +pdo->prepare( + "SELECT i.*, COALESCE(c.contact_name, '') AS contact_name + FROM td_invoice i + LEFT JOIN md_contact c + ON c.company_id = i.company_id AND c.id = i.contact_id + WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'credit_note' + LIMIT 1" + ); + $sth->execute([':cid' => $this->companyId, ':id' => $id]); + $doc = $sth->fetch(PDO::FETCH_ASSOC); + if (!$doc) throw new Exception("Credit note #{$id} not found."); + return $doc; + } + + protected function getAmounts(array $doc): array + { + // grand_total is negative in DB for credit notes — use abs() + $grand_total = abs((float)$doc['grand_total']); + $tax = abs((float)$doc['tax']); + return [ + 'grand_total' => $grand_total, + 'tax' => $tax, + 'total' => $grand_total - $tax, + 'amount' => $grand_total, + ]; + } + + protected function getDocDate(array $doc): string + { + return $doc['issued_date'] ?? date('Y-m-d'); + } +} diff --git a/app/assets/utils/classes_ac/posting/InvoicePosting.php b/app/assets/utils/classes_ac/posting/InvoicePosting.php index 7ddf32e..cec6e82 100644 --- a/app/assets/utils/classes_ac/posting/InvoicePosting.php +++ b/app/assets/utils/classes_ac/posting/InvoicePosting.php @@ -1,21 +1,42 @@ pdo = $pdo; - $this->companyId = $company_id; + $sth = $this->pdo->prepare( + "SELECT i.*, COALESCE(c.contact_name, '') AS contact_name + FROM td_invoice i + LEFT JOIN md_contact c + ON c.company_id = i.company_id AND c.id = i.contact_id + WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'invoice' + LIMIT 1" + ); + $sth->execute([':cid' => $this->companyId, ':id' => $id]); + $doc = $sth->fetch(PDO::FETCH_ASSOC); + if (!$doc) throw new Exception("Invoice #{$id} not found."); + return $doc; } - // TODO: build(int $invoice_id): array — returns [header, lines] ready for JournalManager + protected function getAmounts(array $doc): array + { + $grand_total = abs((float)$doc['grand_total']); + $tax = abs((float)$doc['tax']); + return [ + 'grand_total' => $grand_total, + 'tax' => $tax, + 'total' => $grand_total - $tax, + 'amount' => $grand_total, + ]; + } + + protected function getDocDate(array $doc): string + { + return $doc['issued_date'] ?? date('Y-m-d'); + } } diff --git a/app/assets/utils/classes_ac/posting/PaymentPosting.php b/app/assets/utils/classes_ac/posting/PaymentPosting.php index d72996a..09d5835 100644 --- a/app/assets/utils/classes_ac/posting/PaymentPosting.php +++ b/app/assets/utils/classes_ac/posting/PaymentPosting.php @@ -1,21 +1,109 @@ pdo = $pdo; - $this->companyId = $company_id; + $doc = $this->fetchDocument($id); + $formula = $this->resolveFormula($this->getDocType(), $formula_id); + $doc_date = $this->getDocDate($doc); + $period = substr($doc_date, 0, 7); + $dept_amounts = $this->getDepartmentAmounts($id); + + $lines = []; + foreach ($dept_amounts as $dept_id => $amount) { + if (abs($amount) <= 0.0001) continue; + $is_negative = $amount < 0; + $line_amount = abs($amount); + + foreach ($formula['items'] as $item) { + if ($item['amount_key'] === 'tax') continue; + + $drcr = $item['drcr']; + if ($is_negative) { + $drcr = $drcr === 'D' ? 'C' : 'D'; + } + + $lines[] = [ + 'account_code' => $item['account_code'], + 'department_id' => (int)$dept_id, + 'debit' => $drcr === 'D' ? $line_amount : 0.0, + 'credit' => $drcr === 'C' ? $line_amount : 0.0, + 'description' => (string)($item['description'] ?? ''), + ]; + } + } + + return [ + 'formula_id' => (int)$formula['id'], + 'period' => $period, + 'doc_date' => $doc_date, + 'lines' => $lines, + ]; } - // TODO: build(int $payment_id): array — returns [header, lines] ready for JournalManager + protected function fetchDocument(int $id): array + { + $sth = $this->pdo->prepare( + "SELECT r.*, COALESCE(c.contact_name, '') AS contact_name + FROM td_payment r + LEFT JOIN md_contact c + ON c.company_id = r.company_id AND c.id = r.contact_id + WHERE r.company_id = :cid AND r.id = :id + LIMIT 1" + ); + $sth->execute([':cid' => $this->companyId, ':id' => $id]); + $doc = $sth->fetch(PDO::FETCH_ASSOC); + if (!$doc) throw new Exception("Payment #{$id} not found."); + return $doc; + } + + protected function getAmounts(array $doc): array + { + $amount = abs((float)$doc['amount']); + return [ + 'amount' => $amount, + 'grand_total' => $amount, + 'total' => $amount, + 'tax' => 0.0, + ]; + } + + protected function getDocDate(array $doc): string + { + return $doc['payment_date'] ?? date('Y-m-d'); + } + + private function getDepartmentAmounts(int $payment_id): array + { + $sth = $this->pdo->prepare( + "SELECT pi.department_id, + pi.doc_type, + pi.amount + FROM td_payment_item pi + WHERE pi.company_id = :cid + AND pi.payment_id = :payment_id" + ); + $sth->execute([ + ':cid' => $this->companyId, + ':payment_id' => $payment_id, + ]); + + $dept_amounts = []; + foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $line) { + $dept_id = (int)($line['department_id'] ?? 0); + $amount = abs((float)($line['amount'] ?? 0)); + if ($line['doc_type'] === 'supplier_credit_note') { + $amount *= -1; + } + $dept_amounts[$dept_id] = ($dept_amounts[$dept_id] ?? 0) + $amount; + } + + if (!$dept_amounts) throw new Exception("Payment #{$payment_id} has no allocation lines for GL posting."); + + return $dept_amounts; + } } diff --git a/app/assets/utils/classes_ac/posting/PurchaseInvoicePosting.php b/app/assets/utils/classes_ac/posting/PurchaseInvoicePosting.php new file mode 100644 index 0000000..abad012 --- /dev/null +++ b/app/assets/utils/classes_ac/posting/PurchaseInvoicePosting.php @@ -0,0 +1,42 @@ +pdo->prepare( + "SELECT i.*, COALESCE(c.contact_name, '') AS contact_name + FROM td_invoice i + LEFT JOIN md_contact c + ON c.company_id = i.company_id AND c.id = i.contact_id + WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'purchase_invoice' + LIMIT 1" + ); + $sth->execute([':cid' => $this->companyId, ':id' => $id]); + $doc = $sth->fetch(PDO::FETCH_ASSOC); + if (!$doc) throw new Exception("Purchase invoice #{$id} not found."); + return $doc; + } + + protected function getAmounts(array $doc): array + { + $grand_total = abs((float)$doc['grand_total']); + $tax = abs((float)$doc['tax']); + return [ + 'grand_total' => $grand_total, + 'tax' => $tax, + 'total' => $grand_total - $tax, + 'amount' => $grand_total, + ]; + } + + protected function getDocDate(array $doc): string + { + return $doc['issued_date'] ?? date('Y-m-d'); + } +} diff --git a/app/assets/utils/classes_ac/posting/PurchasePosting.php b/app/assets/utils/classes_ac/posting/PurchasePosting.php index 268cd5f..e37a4ae 100644 --- a/app/assets/utils/classes_ac/posting/PurchasePosting.php +++ b/app/assets/utils/classes_ac/posting/PurchasePosting.php @@ -1,21 +1,162 @@ pdo = $pdo; - $this->companyId = $company_id; + $sth = $this->pdo->prepare( + "SELECT p.*, COALESCE(c.contact_name, '') AS contact_name + FROM td_purchase_order p + LEFT JOIN md_contact c + ON c.company_id = p.company_id AND c.id = p.contact_id + WHERE p.company_id = :cid AND p.id = :id + LIMIT 1" + ); + $sth->execute([':cid' => $this->companyId, ':id' => $id]); + $doc = $sth->fetch(PDO::FETCH_ASSOC); + if (!$doc) throw new Exception("Purchase order #{$id} not found."); + + $sth2 = $this->pdo->prepare( + "SELECT * FROM td_purchase_order_item + WHERE company_id = :cid AND order_id = :id + ORDER BY item_id ASC" + ); + $sth2->execute([':cid' => $this->companyId, ':id' => $id]); + $doc['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC); + + return $doc; } - // TODO: build(int $po_id): array — returns [header, lines] ready for JournalManager + protected function getAmounts(array $doc): array + { + $grand_total = abs((float)$doc['grand_total']); + $tax = abs((float)$doc['tax']); + return [ + 'grand_total' => $grand_total, + 'tax' => $tax, + 'total' => $grand_total - $tax, + 'amount' => $grand_total, + ]; + } + + protected function getDocDate(array $doc): string + { + return $doc['po_date'] ?? date('Y-m-d'); + } + + public function build(int $id, ?int $formula_id = null): array + { + $doc = $this->fetchDocument($id); + $formula = $this->resolveFormula($this->getDocType(), $formula_id); + $amounts = $this->getAmounts($doc); + $doc_date = $this->getDocDate($doc); + $period = substr($doc_date, 0, 7); + $dept_id = (int)($doc['department_id'] ?? 0); + + $lines = []; + foreach ($formula['items'] as $item) { + $amount = abs((float)($amounts[$item['amount_key']] ?? 0)); + if ($amount == 0) continue; + + if ($item['amount_key'] === 'total') { + $split_lines = $this->buildSkuSplitLines($doc['items'], $item, $amount, $dept_id); + if ($split_lines) { + $lines = array_merge($lines, $split_lines); + continue; + } + } + + $lines[] = [ + 'account_code' => $item['account_code'], + 'department_id' => $dept_id, + 'debit' => $item['drcr'] === 'D' ? $amount : 0.0, + 'credit' => $item['drcr'] === 'C' ? $amount : 0.0, + 'description' => (string)($item['description'] ?? ''), + ]; + } + + return [ + 'formula_id' => (int)$formula['id'], + 'period' => $period, + 'doc_date' => $doc_date, + 'lines' => $lines, + ]; + } + + private function buildSkuSplitLines(array $items, array $formula_item, float $amount, int $dept_id): array + { + $items = array_values(array_filter($items, function($row) { + return abs((float)($row['total_price'] ?? 0)) > 0.0000001; + })); + + if (!$items) return []; + + $skus = array_values(array_unique(array_filter(array_column($items, 'product_sku')))); + $account_map = []; + if ($skus) { + $placeholders = implode(',', array_fill(0, count($skus), '?')); + $sth = $this->pdo->prepare( + "SELECT sku, purchase_account_code FROM md_product + WHERE company_id = ? AND sku IN ({$placeholders})" + ); + $sth->execute(array_merge([$this->companyId], $skus)); + foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { + $account_map[$row['sku']] = trim((string)($row['purchase_account_code'] ?? '')); + } + } + + $base_total = array_reduce($items, function($carry, $row) { + return $carry + abs((float)($row['total_price'] ?? 0)); + }, 0.0); + if ($base_total <= 0) return []; + + $remaining = round($amount, 4); + $last = count($items) - 1; + $lines = []; + + foreach ($items as $idx => $row) { + $line_amount = $idx === $last + ? $remaining + : round($amount * (abs((float)$row['total_price']) / $base_total), 4); + $remaining = round($remaining - $line_amount, 4); + if (abs($line_amount) <= 0.0000001) continue; + + $sku = trim((string)($row['product_sku'] ?? '')); + $account_code = ($sku !== '' && ($account_map[$sku] ?? '') !== '') + ? $account_map[$sku] + : (string)$formula_item['account_code']; + + $name = trim((string)($row['product_name'] ?? '')); + $desc = trim((string)($formula_item['description'] ?? '')); + $item_desc = trim($sku . ($name !== '' ? ' - ' . $name : '')); + if ($item_desc !== '') { + $desc = $desc !== '' ? $desc . ': ' . $item_desc : $item_desc; + } + + $lines[] = [ + 'account_code' => $account_code, + 'department_id' => $dept_id, + 'debit' => $formula_item['drcr'] === 'D' ? $line_amount : 0.0, + 'credit' => $formula_item['drcr'] === 'C' ? $line_amount : 0.0, + 'description' => $desc, + ]; + } + + return $lines; + } } diff --git a/app/assets/utils/classes_ac/posting/ReceiptPosting.php b/app/assets/utils/classes_ac/posting/ReceiptPosting.php index a99834a..9e31b59 100644 --- a/app/assets/utils/classes_ac/posting/ReceiptPosting.php +++ b/app/assets/utils/classes_ac/posting/ReceiptPosting.php @@ -1,21 +1,109 @@ pdo = $pdo; - $this->companyId = $company_id; + $doc = $this->fetchDocument($id); + $formula = $this->resolveFormula($this->getDocType(), $formula_id); + $doc_date = $this->getDocDate($doc); + $period = substr($doc_date, 0, 7); + $dept_amounts = $this->getDepartmentAmounts($id); + + $lines = []; + foreach ($dept_amounts as $dept_id => $amount) { + if (abs($amount) <= 0.0001) continue; + $is_negative = $amount < 0; + $line_amount = abs($amount); + + foreach ($formula['items'] as $item) { + if ($item['amount_key'] === 'tax') continue; + + $drcr = $item['drcr']; + if ($is_negative) { + $drcr = $drcr === 'D' ? 'C' : 'D'; + } + + $lines[] = [ + 'account_code' => $item['account_code'], + 'department_id' => (int)$dept_id, + 'debit' => $drcr === 'D' ? $line_amount : 0.0, + 'credit' => $drcr === 'C' ? $line_amount : 0.0, + 'description' => (string)($item['description'] ?? ''), + ]; + } + } + + return [ + 'formula_id' => (int)$formula['id'], + 'period' => $period, + 'doc_date' => $doc_date, + 'lines' => $lines, + ]; } - // TODO: build(int $receipt_id): array — returns [header, lines] ready for JournalManager + protected function fetchDocument(int $id): array + { + $sth = $this->pdo->prepare( + "SELECT r.*, COALESCE(c.contact_name, '') AS contact_name + FROM td_receipt r + LEFT JOIN md_contact c + ON c.company_id = r.company_id AND c.id = r.contact_id + WHERE r.company_id = :cid AND r.id = :id + LIMIT 1" + ); + $sth->execute([':cid' => $this->companyId, ':id' => $id]); + $doc = $sth->fetch(PDO::FETCH_ASSOC); + if (!$doc) throw new Exception("Receipt #{$id} not found."); + return $doc; + } + + protected function getAmounts(array $doc): array + { + $amount = abs((float)$doc['amount']); + return [ + 'amount' => $amount, + 'grand_total' => $amount, + 'total' => $amount, + 'tax' => 0.0, + ]; + } + + protected function getDocDate(array $doc): string + { + return $doc['receipt_date'] ?? date('Y-m-d'); + } + + private function getDepartmentAmounts(int $receipt_id): array + { + $sth = $this->pdo->prepare( + "SELECT ri.department_id, + ri.doc_type, + ri.amount + FROM td_receipt_item ri + WHERE ri.company_id = :cid + AND ri.receipt_id = :receipt_id" + ); + $sth->execute([ + ':cid' => $this->companyId, + ':receipt_id' => $receipt_id, + ]); + + $dept_amounts = []; + foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $line) { + $dept_id = (int)($line['department_id'] ?? 0); + $amount = abs((float)($line['amount'] ?? 0)); + if ($line['doc_type'] === 'credit_note') { + $amount *= -1; + } + $dept_amounts[$dept_id] = ($dept_amounts[$dept_id] ?? 0) + $amount; + } + + if (!$dept_amounts) throw new Exception("Receipt #{$receipt_id} has no allocation lines for GL posting."); + + return $dept_amounts; + } } diff --git a/app/assets/utils/classes_ac/posting/SupplierCreditNotePosting.php b/app/assets/utils/classes_ac/posting/SupplierCreditNotePosting.php new file mode 100644 index 0000000..4b3b07e --- /dev/null +++ b/app/assets/utils/classes_ac/posting/SupplierCreditNotePosting.php @@ -0,0 +1,42 @@ +pdo->prepare( + "SELECT i.*, COALESCE(c.contact_name, '') AS contact_name + FROM td_invoice i + LEFT JOIN md_contact c + ON c.company_id = i.company_id AND c.id = i.contact_id + WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'supplier_credit_note' + LIMIT 1" + ); + $sth->execute([':cid' => $this->companyId, ':id' => $id]); + $doc = $sth->fetch(PDO::FETCH_ASSOC); + if (!$doc) throw new Exception("Supplier credit note #{$id} not found."); + return $doc; + } + + protected function getAmounts(array $doc): array + { + $grand_total = abs((float)$doc['grand_total']); + $tax = abs((float)$doc['tax']); + return [ + 'grand_total' => $grand_total, + 'tax' => $tax, + 'total' => $grand_total - $tax, + 'amount' => $grand_total, + ]; + } + + protected function getDocDate(array $doc): string + { + return $doc['issued_date'] ?? date('Y-m-d'); + } +} diff --git a/app/contact/api/engine/manage_contact.php b/app/contact/api/engine/manage_contact.php index 76a4f3a..bd5573f 100644 --- a/app/contact/api/engine/manage_contact.php +++ b/app/contact/api/engine/manage_contact.php @@ -13,10 +13,8 @@ $uploader = new FileUploader($include_url . 'uploads/contact/'); if ($id > 0) { - $sql = "SELECT `contact_image` FROM md_contact WHERE company_id = :company_id AND id = :id"; - $sth = $pdo2->prepare($sql); - $sth->execute([':company_id' => $company_id, ':id' => $id]); - $uploader->cleanup($sth->fetchColumn(), $data['keep_files'] ?? ''); + $contact = new ContactManager($pdo2, $company_id); + $uploader->cleanup($contact->getContactImage($id), $data['keep_files'] ?? ''); } $errors = $uploader->upload('contact_image'); @@ -50,4 +48,4 @@ } exit(json_encode($answer)); -?> \ No newline at end of file +?> diff --git a/app/dashboard/api/engine_report/reports_stats.php b/app/dashboard/api/engine_report/reports_stats.php index 8b15ee9..80067b1 100644 --- a/app/dashboard/api/engine_report/reports_stats.php +++ b/app/dashboard/api/engine_report/reports_stats.php @@ -3,78 +3,20 @@ require '../../../assets/utils/db_auth.php'; require '../../../assets/utils/classes/ReportManager.php'; - $month = date('Y-m'); $report = new ReportManager($pdo2, $company_id); - // All-time stock totals - $sth = $pdo2->prepare( - "SELECT - ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in, - ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out - FROM warehouse_balance - WHERE company_id = :company_id" - ); - $sth->execute([':company_id' => $company_id]); - $stock_stats = $sth->fetch(PDO::FETCH_ASSOC); - - // All-time orders (excluding cancelled) - $sth = $pdo2->prepare( - "SELECT COUNT(*), COALESCE(SUM(grand_total), 0) - FROM td_order - WHERE company_id = :company_id - AND status != -1" - ); - $sth->execute([':company_id' => $company_id]); - [$total_orders, $total_revenue] = $sth->fetch(PDO::FETCH_NUM); - - // Unpaid invoices (status=0 draft + status=1 issued + status=3 overdue) - $sth = $pdo2->prepare( - "SELECT COUNT(*) FROM td_invoice - WHERE company_id = :company_id - AND doc_type = 'invoice' - AND status IN (0, 1, 3)" - ); - $sth->execute([':company_id' => $company_id]); - $unpaid_invoices = (int)$sth->fetchColumn(); - - // Pending returns (status=0 draft) - $sth = $pdo2->prepare( - "SELECT COUNT(*) FROM td_return - WHERE company_id = :company_id - AND status = 0" - ); - $sth->execute([':company_id' => $company_id]); - $pending_returns = (int)$sth->fetchColumn(); - - // Stock health breakdown for the Overall Information donut chart - $low_stock_items = $report->getLowStockItems(); - $critical_count = count(array_filter($low_stock_items, fn($i) => $i['status'] === 'critical')); - $warning_count = count(array_filter($low_stock_items, fn($i) => $i['status'] === 'warning')); - $total_in_stock = (int) $report->getTotalProductInStock(); - $healthy_count = max(0, $total_in_stock - $critical_count - $warning_count); - - $answer['output'] = [ - 'total_in' => $stock_stats['total_in'], - 'total_out' => $stock_stats['total_out'], - 'low_stock' => count($low_stock_items), - 'total_orders' => (int)$total_orders, - 'total_revenue' => (float)$total_revenue, - 'unpaid_invoices' => $unpaid_invoices, - 'pending_returns' => $pending_returns, - 'stock_movement' => $report->getStockMovementChart(12), - 'most_moved' => $report->getMostMovedProducts($month), - 'low_stock_items' => $low_stock_items, - 'recent_activity' => $report->getRecentActivity(10), - 'stock_health' => [ - 'healthy' => $healthy_count, - 'warning' => $warning_count, - 'critical' => $critical_count, - 'total' => $total_in_stock, + $answer['output'] = array_merge( + $report->getDashboardStockTotals(), + $report->getDashboardOrderStats(), + $report->getDashboardOpenDocumentCounts(), + [ + 'stock_movement' => $report->getStockMovementChart(12), + 'most_moved' => $report->getMostMovedProducts(date('Y-m')), + 'recent_activity'=> $report->getRecentActivity(10), ], - 'total_products' => (int) $report->getTotalProduct(), - 'total_contacts' => (int) $report->getTotalContact(), - 'total_warehouses' => (int) $report->getTotalWarehouse(), - ]; + $report->getDashboardStockHealth(), + $report->getDashboardMasterCounts() + ); $answer['success'] = 1; exit(json_encode($answer)); -?> \ No newline at end of file +?> diff --git a/app/dashboard/index.php b/app/dashboard/index.php index b6e98ae..c46fb22 100644 --- a/app/dashboard/index.php +++ b/app/dashboard/index.php @@ -86,7 +86,7 @@

    Total Revenue

    —

    -

    Grand total to date

    +

    Sub-total to date (excl. VAT)

    diff --git a/app/expense/api/engine/convert_purchase_request.php b/app/expense/api/engine/convert_purchase_request.php new file mode 100644 index 0000000..35fa63e --- /dev/null +++ b/app/expense/api/engine/convert_purchase_request.php @@ -0,0 +1,140 @@ + 0, 'message' => 'Purchase request ID is required.'])); +} +if (!$contact_id) { + exit(json_encode(['success' => 0, 'message' => 'Supplier (contact_id) is required for the PO.'])); +} + +$prm = new PurchaseRequestManager($pdo2, $company_id); + +// Load PR — must be Approved +$pr = $prm->getById($request_id); +if (!$pr || (int)$pr['status'] !== 2) { + exit(json_encode(['success' => 0, 'message' => 'Purchase request not found or not in Approved status.'])); +} + +$pr_items = $pr['items']; +if (empty($pr_items)) { + exit(json_encode(['success' => 0, 'message' => 'Purchase request has no items.'])); +} + +if ((float)$pr['total_remaining'] <= 0.000001) { + exit(json_encode(['success' => 0, 'message' => 'Purchase request is already fully converted.'])); +} + +// Index by item_id for validation +$pr_by_id = []; +foreach ($pr_items as $pi) { + $pr_by_id[(int)$pi['item_id']] = $pi; +} + +// Default to all remaining +if (empty($convert_items)) { + $convert_items = []; + foreach ($pr_items as $pi) { + $remaining = (float)$pi['remaining_qty']; + if ($remaining > 0.000001) { + $convert_items[] = ['item_id' => (int)$pi['item_id'], 'quantity' => $remaining]; + } + } +} + +// Validate quantities and build PO items +$po_items = []; +$validated = []; + +foreach ($convert_items as $ci) { + $item_id = (int)($ci['item_id'] ?? 0); + $qty = (float)($ci['quantity'] ?? 0); + + if ($qty <= 0) continue; + if (!isset($pr_by_id[$item_id])) { + exit(json_encode(['success' => 0, 'message' => "Item #{$item_id} not found in this purchase request."])); + } + + $pi = $pr_by_id[$item_id]; + $remaining = (float)$pi['remaining_qty']; + + if ($qty - $remaining > 0.000001) { + $name = $pi['product_name'] ?: $pi['product_sku']; + exit(json_encode([ + 'success' => 0, + 'message' => "Cannot convert {$qty} for \"{$name}\": only {$remaining} remaining.", + ])); + } + + $unit_price = (float)$pi['unit_price']; + $total_price = round($qty * $unit_price, 4); + $tax_amount = round($total_price * (float)($pi['tax_amount'] / max((float)$pi['total_price'], 0.000001)), 4); + + $po_items[] = [ + 'item_id' => $item_id - 1, + 'product_sku' => $pi['product_sku'], + 'product_name' => $pi['product_name'], + 'quantity' => $qty, + 'unit_price' => $unit_price, + 'total_price' => $total_price, + 'tax_amount' => $tax_amount, + 'received_qty' => 0, + 'stock_in_id' => 0, + ]; + $validated[] = ['item_id' => $item_id, 'quantity' => $qty]; +} + +if (empty($po_items)) { + exit(json_encode(['success' => 0, 'message' => 'No valid items to convert.'])); +} + +$po_data = [ + 'source' => 'purchase_request', + 'source_id' => $request_id, + 'contact_id' => $contact_id, + 'department_id' => (int)($pr['department_id'] ?? 0), + 'po_date' => date('Y-m-d'), + 'expected_date' => $pr['required_date'] ?: '', + 'warehouse_id' => 0, + 'items' => $po_items, + 'discount' => $discount, + 'shipping_fee' => $shipping_fee, + 'notes' => $pr['notes'] ?? '', +]; + +try { + $new_po_id = null; + + dbTransaction($pdo2, function($pdo) use ( + $po_data, $company_id, $logging, $request_id, $validated, $prm, &$new_po_id + ) { + $po = new PurchaseOrderManager($pdo, $company_id); + $new_po_id = $po->savePo($po_data, $logging); + + $prm2 = new PurchaseRequestManager($pdo, $company_id); + $prm2->incrementConvertedQty($request_id, $validated); + }); + + $answer['success'] = 1; + $answer['message'] = 'Purchase order created.'; + $answer['po_id'] = $new_po_id; + +} catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); +} + +exit(json_encode($answer)); diff --git a/app/expense/api/engine/manage_purchase_request.php b/app/expense/api/engine/manage_purchase_request.php new file mode 100644 index 0000000..71ec5b7 --- /dev/null +++ b/app/expense/api/engine/manage_purchase_request.php @@ -0,0 +1,34 @@ +save(array_merge($data, ['id' => 0, 'items' => $items]), $logging); + $answer['success'] = 1; + $answer['message'] = 'Purchase request created.'; + $answer['new_id'] = $new_id; + } elseif ($action === 'update') { + $prm->save(array_merge($data, ['id' => $id, 'items' => $items]), $logging); + $answer['success'] = 1; + $answer['message'] = 'Purchase request updated.'; + } else { + throw new Exception('Unknown action.'); + } +} catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); +} + +exit(json_encode($answer)); diff --git a/app/expense/api/engine/manage_supplier_credit_note.php b/app/expense/api/engine/manage_supplier_credit_note.php new file mode 100644 index 0000000..2131481 --- /dev/null +++ b/app/expense/api/engine/manage_supplier_credit_note.php @@ -0,0 +1,29 @@ +createManualDebitNote($data, $logging); + }); + + $answer['success'] = 1; + $answer['new_id'] = $new_id; + $answer['message'] = 'Supplier credit note created.'; + + } catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); + + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> diff --git a/app/expense/api/engine/retrieve_purchase_request.php b/app/expense/api/engine/retrieve_purchase_request.php new file mode 100644 index 0000000..7bb4e43 --- /dev/null +++ b/app/expense/api/engine/retrieve_purchase_request.php @@ -0,0 +1,21 @@ + 0) { + $row = $prm->getById($id); + if (!$row) { + http_response_code(404); + exit(json_encode(['success' => 0, 'message' => 'Purchase request not found.'])); + } + $answer['output'] = $row; +} else { + $answer['output'] = $prm->getList(); +} + +$answer['success'] = 1; +exit(json_encode($answer)); diff --git a/app/expense/api/engine/update_purchase_request_status.php b/app/expense/api/engine/update_purchase_request_status.php new file mode 100644 index 0000000..f1b075e --- /dev/null +++ b/app/expense/api/engine/update_purchase_request_status.php @@ -0,0 +1,28 @@ + 0, 'message' => 'ID and action are required.'])); +} + +$prm = new PurchaseRequestManager($pdo2, $company_id); + +try { + $prm->updateStatus($id, $action, $logging); + $answer['success'] = 1; + $answer['message'] = 'Status updated.'; +} catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); +} + +exit(json_encode($answer)); diff --git a/app/expense/manage_purchase_invoice.php b/app/expense/manage_purchase_invoice.php new file mode 100644 index 0000000..324abed --- /dev/null +++ b/app/expense/manage_purchase_invoice.php @@ -0,0 +1,356 @@ + + + + + + +
    +
    + +
    +
    +
    +
    +

    Purchase Invoice

    +

    +
    + + Back + +
    +
    +
    + +
    + + +
    +
    +
    +

    Items

    +
    + + + + + + + + + + + + + +
    ProductQtyUnit PriceTax%Tax AmtTotal
    +
    +
    +
    + + +
    +
    +

    Supplier Credit Notes

    +
    + + + + + + + + + + + +
    DN #DateAmountStatus
    +
    +
    +
    +
    + + +
    + +
    +
    +

    Document Info

    +
    + + +
    +
    + Source: + +
    +
    + Supplier: + +
    +
    + Date: + +
    +
    + Due: + +
    +
    + Notes: + +
    +
    + GL Formula: +
    + + +
    +
    +
    +
    + +
    +
    + + + + + Create Supplier Credit Note + + + + +
    +
    + +
    +
    +
    +
    + + + + + + + diff --git a/app/expense/manage_purchase_order.php b/app/expense/manage_purchase_order.php new file mode 100644 index 0000000..8bb7011 --- /dev/null +++ b/app/expense/manage_purchase_order.php @@ -0,0 +1,364 @@ + + + + + + +
    +
    +
    +
    +
    +
    +

    +

    Create a supplier PO for WMS receiving

    +
    + + Back + +
    +
    +
    + +
    +
    +
    +
    +

    Order Information

    +
    +
    + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +

    Items to Order

    + +
    +
    + + + + + + + + + + + + +
    ProductQtyUnit PriceTax%Total
    +
    +
    +
    +
    + +
    +
    +
    +

    Status

    +
    +

    +
    +
    + +
    +
    + +
    +
    Pending Warehouse receiving setup
    +
    WMS staff assign the receiving warehouse, confirm the PO, and receive goods.
    +
    +
    +
    + +
    +
    +

    Summary

    +
    Subtotal0.00
    +
    Discount
    +
    Tax0.00
    +
    Tax Adj.
    +
    Shipping
    +
    +
    Grand Total0.00
    +
    +
    + +
    +
    + + + View Purchase Invoice + + +
    +
    +
    +
    +
    +
    + + + + + + + diff --git a/app/expense/manage_purchase_request.php b/app/expense/manage_purchase_request.php new file mode 100644 index 0000000..2773e53 --- /dev/null +++ b/app/expense/manage_purchase_request.php @@ -0,0 +1,365 @@ + + + + + + +
    +
    +
    +
    +
    +
    +

    +

    +
    + + Back + +
    +
    +
    + +
    +
    +
    +
    +

    Request Information

    +
    +
    + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +

    Requested Items

    + +
    +
    + + + + + + + + + + + + + +
    SKUDescriptionQtyUnit PriceTax%Total
    +
    +
    +
    +
    + +
    +
    +
    +

    Status

    +
    +

    +

    +
    +
    + +
    +
    +

    Summary

    +
    Subtotal0.00
    +
    Discount
    +
    Tax0.00
    +
    Tax Adj.
    +
    Shipping
    +
    +
    Grand Total0.00
    +
    +
    + +
    +
    + + + + + + +
    + + View PO +
    +
    +
    +
    +
    +
    + + + + + + + diff --git a/app/expense/manage_supplier_credit_note.php b/app/expense/manage_supplier_credit_note.php new file mode 100644 index 0000000..5129299 --- /dev/null +++ b/app/expense/manage_supplier_credit_note.php @@ -0,0 +1,269 @@ + + + + + + +
    +
    + +
    +
    +
    +
    +

    New Supplier Credit Note

    +

    Accounting supplier credit note without stock movement

    +
    + + Back + +
    +
    +
    + +
    +
    +
    +
    +

    Supplier Credit Note Information

    +
    +
    + + +
    +
    + + + +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +

    Credit Lines

    + +
    + +
    + + + + + + + + + + + + +
    DescriptionQtyUnit PriceTax%Total
    +
    +
    +
    +
    + +
    +
    +
    +

    Summary

    +
    + Subtotal + 0.00 +
    +
    + Tax + 0.00 +
    +
    + Tax Adj. + +
    +
    +
    + Credit Total + 0.00 +
    +
    +
    + +
    +
    + + Cancel +
    +
    +
    +
    + +
    +
    + + + + + + + diff --git a/app/expense/payment.php b/app/expense/payment.php index d6fde29..4298d6b 100644 --- a/app/expense/payment.php +++ b/app/expense/payment.php @@ -1,36 +1,4 @@ - - - - - -
    -
    - -
    -
    -

    Payments

    -

    Payments to suppliers

    -
    -
    - -
    -
    -
    - -

    Payments coming soon.

    -
    -
    -
    - -
    -
    - - - - - diff --git a/app/expense/purchase_invoice.php b/app/expense/purchase_invoice.php index f339371..46bec59 100644 --- a/app/expense/purchase_invoice.php +++ b/app/expense/purchase_invoice.php @@ -13,16 +13,174 @@
    -

    Purchase Invoices

    -

    Supplier invoices

    +
    +
    +

    Purchase Invoices

    +

    Supplier invoices and supplier credit notes

    +
    + + New Supplier Credit Note + +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +

    Invoices

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Paid

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Open

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Supplier Credit Notes

    +

    —

    +
    +
    +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    -
    - -

    Purchase Invoices coming soon.

    +
    +
    +
    + + + + + + + + + + + + + + + + + + +
    Invoice #SourceDateSupplierDepartmentGrand TotalDue DateGL FormulaStatusAction
    + + + + + + + + + + + + + + + + + +
    SCN #Ref Invoice #SupplierDepartmentDateAmountGL FormulaStatusAction
    + +
    +
    @@ -32,5 +190,227 @@ + + diff --git a/app/expense/purchase_order.php b/app/expense/purchase_order.php index eda3ad5..4e774bd 100644 --- a/app/expense/purchase_order.php +++ b/app/expense/purchase_order.php @@ -13,16 +13,128 @@
    -

    Purchase Orders

    -

    Purchase order management

    +
    +
    +

    Purchase Orders

    +

    Supplier orders handed off to WMS receiving

    +
    + + New Purchase Order + +
    -
    +
    +
    +
    +
    +
    + +
    +
    +

    Total POs

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Pending Warehouse

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Confirmed

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Received

    +

    —

    +
    +
    +
    +
    +
    + +
    -
    - -

    Purchase Orders coming soon.

    +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + + + + + + + + + + + + + + + +
    PO #SourceDateSupplierDepartmentGrand TotalStatusReceivingAction
    +
    +
    @@ -32,5 +144,105 @@ + + diff --git a/app/expense/purchase_request.php b/app/expense/purchase_request.php index b0782bb..83d1332 100644 --- a/app/expense/purchase_request.php +++ b/app/expense/purchase_request.php @@ -13,16 +13,111 @@
    -

    Purchase Requests

    -

    Internal purchase requests

    +
    +
    +

    Purchase Requests

    +

    Internal purchase requests before purchase order handoff

    +
    + + New Purchase Request + +
    -
    +
    +
    +
    +
    +
    +

    Total

    —

    +
    +
    +
    +
    +
    +
    +
    +

    Draft

    —

    +
    +
    +
    +
    +
    +
    +
    +

    Submitted

    —

    +
    +
    +
    +
    +
    +
    +
    +

    Approved

    —

    +
    +
    +
    +
    + +
    -
    - -

    Purchase Requests coming soon.

    +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + +
    Request #DateRequiredSupplierDepartmentItemsGrand TotalStatusPOAction
    +
    +
    @@ -32,5 +127,88 @@ + + diff --git a/app/finance/api/engine/manage_payment.php b/app/finance/api/engine/manage_payment.php new file mode 100644 index 0000000..2959ad2 --- /dev/null +++ b/app/finance/api/engine/manage_payment.php @@ -0,0 +1,69 @@ +getOpenDocuments($contact_id); + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($action === 'save_formula') { + $id = (int)($data['id'] ?? 0); + $formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0 ? (int)$data['formula_id'] : null; + $mgr->saveFormula($id, $formula_id); + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($action === 'retrieve') { + $payment_id = (int)($data['id'] ?? $data['payment_id'] ?? 0); + $answer['output'] = $mgr->getPaymentById($payment_id); + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($action === 'void') { + $payment_id = (int)($data['id'] ?? $data['payment_id'] ?? 0); + dbTransaction($pdo2, function($pdo) use ($payment_id, $company_id, $logging) { + $mgr = new PaymentManager($pdo, $company_id); + $mgr->voidPayment($payment_id, $logging); + }); + $answer['success'] = 1; + $answer['message'] = 'Payment voided.'; + exit(json_encode($answer)); + } + + if ($action === 'create') { + $new_id = null; + dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, &$new_id) { + $mgr = new PaymentManager($pdo, $company_id); + $new_id = $mgr->createPayment($data, $logging); + }); + $answer['success'] = 1; + $answer['new_id'] = $new_id; + $answer['message'] = 'Payment posted.'; + exit(json_encode($answer)); + } + + $answer['output'] = $mgr->getPaymentList(); + $answer['success'] = 1; + + } catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); + + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> diff --git a/app/finance/api/engine/manage_payment_billing.php b/app/finance/api/engine/manage_payment_billing.php new file mode 100644 index 0000000..fa174b4 --- /dev/null +++ b/app/finance/api/engine/manage_payment_billing.php @@ -0,0 +1,68 @@ +getBillableDocuments($contact_id); + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($action === 'open_billings') { + $contact_id = (int)($data['contact_id'] ?? 0); + $answer['output'] = $mgr->getOpenBillingList($contact_id); + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($action === 'retrieve') { + $billing_id = (int)($data['id'] ?? $data['billing_id'] ?? 0); + $answer['output'] = $mgr->getBillingById($billing_id); + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($action === 'void') { + $billing_id = (int)($data['id'] ?? $data['billing_id'] ?? 0); + dbTransaction($pdo2, function($pdo) use ($billing_id, $company_id, $logging) { + $mgr = new PaymentBillingManager($pdo, $company_id); + $mgr->voidBilling($billing_id, $logging); + }); + $answer['success'] = 1; + $answer['message'] = 'Payment billing voided.'; + exit(json_encode($answer)); + } + + if ($action === 'create') { + $new_id = null; + dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, &$new_id) { + $mgr = new PaymentBillingManager($pdo, $company_id); + $new_id = $mgr->createBilling($data, $logging); + }); + $answer['success'] = 1; + $answer['new_id'] = $new_id; + $answer['message'] = 'Payment billing created.'; + exit(json_encode($answer)); + } + + $answer['output'] = $mgr->getBillingList(); + $answer['success'] = 1; + + } catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); + + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> diff --git a/app/finance/api/engine/manage_receipt.php b/app/finance/api/engine/manage_receipt.php new file mode 100644 index 0000000..7ecf9d9 --- /dev/null +++ b/app/finance/api/engine/manage_receipt.php @@ -0,0 +1,69 @@ +getOpenDocuments($contact_id); + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($action === 'save_formula') { + $id = (int)($data['id'] ?? 0); + $formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0 ? (int)$data['formula_id'] : null; + $mgr->saveFormula($id, $formula_id); + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($action === 'retrieve') { + $receipt_id = (int)($data['id'] ?? $data['receipt_id'] ?? 0); + $answer['output'] = $mgr->getReceiptById($receipt_id); + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($action === 'void') { + $receipt_id = (int)($data['id'] ?? $data['receipt_id'] ?? 0); + dbTransaction($pdo2, function($pdo) use ($receipt_id, $company_id, $logging) { + $mgr = new ReceiptManager($pdo, $company_id); + $mgr->voidReceipt($receipt_id, $logging); + }); + $answer['success'] = 1; + $answer['message'] = 'Receipt voided.'; + exit(json_encode($answer)); + } + + if ($action === 'create') { + $new_id = null; + dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, &$new_id) { + $mgr = new ReceiptManager($pdo, $company_id); + $new_id = $mgr->createReceipt($data, $logging); + }); + $answer['success'] = 1; + $answer['new_id'] = $new_id; + $answer['message'] = 'Receipt posted.'; + exit(json_encode($answer)); + } + + $answer['output'] = $mgr->getReceiptList(); + $answer['success'] = 1; + + } catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); + + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> diff --git a/app/finance/api/engine/manage_receipt_billing.php b/app/finance/api/engine/manage_receipt_billing.php new file mode 100644 index 0000000..e605f74 --- /dev/null +++ b/app/finance/api/engine/manage_receipt_billing.php @@ -0,0 +1,68 @@ +getBillableDocuments($contact_id); + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($action === 'open_billings') { + $contact_id = (int)($data['contact_id'] ?? 0); + $answer['output'] = $mgr->getOpenBillingList($contact_id); + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($action === 'retrieve') { + $billing_id = (int)($data['id'] ?? $data['billing_id'] ?? 0); + $answer['output'] = $mgr->getBillingById($billing_id); + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($action === 'void') { + $billing_id = (int)($data['id'] ?? $data['billing_id'] ?? 0); + dbTransaction($pdo2, function($pdo) use ($billing_id, $company_id, $logging) { + $mgr = new ReceiptBillingManager($pdo, $company_id); + $mgr->voidBilling($billing_id, $logging); + }); + $answer['success'] = 1; + $answer['message'] = 'Receipt billing voided.'; + exit(json_encode($answer)); + } + + if ($action === 'create') { + $new_id = null; + dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, &$new_id) { + $mgr = new ReceiptBillingManager($pdo, $company_id); + $new_id = $mgr->createBilling($data, $logging); + }); + $answer['success'] = 1; + $answer['new_id'] = $new_id; + $answer['message'] = 'Receipt billing created.'; + exit(json_encode($answer)); + } + + $answer['output'] = $mgr->getBillingList(); + $answer['success'] = 1; + + } catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); + + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> diff --git a/app/finance/manage_payment.php b/app/finance/manage_payment.php new file mode 100644 index 0000000..c2bd7e3 --- /dev/null +++ b/app/finance/manage_payment.php @@ -0,0 +1,274 @@ + + + + + + +
    +
    + +
    +
    +
    +
    +

    Payment Detail

    +

    Supplier payment and document allocations

    +
    + + Back + +
    +
    +
    + +
    +
    +
    +
    +

    Allocations

    +
    + + + + + + + + + + + + + +
    DocumentTypePO #AllocatedDocument Total
    Loading payment...
    +
    +
    +
    +
    + +
    +
    +
    +

    Payment Info

    +
    +
    + Payment #: + — +
    +
    + Billing #: + — +
    +
    + Supplier: + — +
    +
    + Date: + — +
    +
    + Method: + — +
    +
    + Notes: +
    —
    +
    +
    + GL Formula: +
    + + +
    +
    +
    +
    + +
    +
    +

    Summary

    +
    + Purchase Invoice Allocations + 0.00 +
    +
    + Supplier Credit Notes Used + -0.00 +
    +
    +
    + Cash Payment + 0.00 +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    +
    +
    + + + + + + + diff --git a/app/finance/manage_payment_billing.php b/app/finance/manage_payment_billing.php new file mode 100644 index 0000000..8e1fd68 --- /dev/null +++ b/app/finance/manage_payment_billing.php @@ -0,0 +1,197 @@ + + + + + + +
    +
    + +
    +
    +
    +
    +

    Payment Billing Detail

    +

    Supplier payment billing and document allocations

    +
    + + Back + +
    +
    +
    + +
    +
    +
    +
    +

    Documents

    +
    + + + + + + + + + + + + + +
    DocumentTypePO #IncludedDocument Total
    Loading payment billing...
    +
    +
    +
    +
    + +
    +
    +
    +

    Billing Info

    +
    +
    Billing #:—
    +
    Supplier:—
    +
    Date:—
    +
    + Notes: +
    —
    +
    +
    +
    + +
    +
    +

    Summary

    +
    Amount Due0.00
    +
    Paid0.00
    +
    +
    Balance0.00
    +
    +
    + +
    +
    + + Create Payment + + + +
    +
    +
    +
    +
    +
    + + + + + + + diff --git a/app/finance/manage_receipt.php b/app/finance/manage_receipt.php new file mode 100644 index 0000000..2373399 --- /dev/null +++ b/app/finance/manage_receipt.php @@ -0,0 +1,274 @@ + + + + + + +
    +
    + +
    +
    +
    +
    +

    Receipt Detail

    +

    Customer receipt and document allocations

    +
    + + Back + +
    +
    +
    + +
    +
    +
    +
    +

    Allocations

    +
    + + + + + + + + + + + + + +
    DocumentTypeOrder #AllocatedDocument Total
    Loading receipt...
    +
    +
    +
    +
    + +
    +
    +
    +

    Receipt Info

    +
    +
    + Receipt #: + — +
    +
    + Billing #: + — +
    +
    + Customer: + — +
    +
    + Date: + — +
    +
    + Method: + — +
    +
    + Notes: +
    —
    +
    +
    + GL Formula: +
    + + +
    +
    +
    +
    + +
    +
    +

    Summary

    +
    + Invoice Allocations + 0.00 +
    +
    + Credit Notes Used + -0.00 +
    +
    +
    + Cash Receipt + 0.00 +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    +
    +
    + + + + + + + diff --git a/app/finance/manage_receipt_billing.php b/app/finance/manage_receipt_billing.php new file mode 100644 index 0000000..7157b60 --- /dev/null +++ b/app/finance/manage_receipt_billing.php @@ -0,0 +1,197 @@ + + + + + + +
    +
    + +
    +
    +
    +
    +

    Receipt Billing Detail

    +

    Customer collection bill and document allocations

    +
    + + Back + +
    +
    +
    + +
    +
    +
    +
    +

    Documents

    +
    + + + + + + + + + + + + + +
    DocumentTypeOrder #IncludedDocument Total
    Loading receipt billing...
    +
    +
    +
    +
    + +
    +
    +
    +

    Billing Info

    +
    +
    Billing #:—
    +
    Customer:—
    +
    Date:—
    +
    + Notes: +
    —
    +
    +
    +
    + +
    +
    +

    Summary

    +
    Amount Due0.00
    +
    Received0.00
    +
    +
    Balance0.00
    +
    +
    + +
    +
    + + Create Receipt + + + +
    +
    +
    +
    +
    +
    + + + + + + + diff --git a/app/finance/payment.php b/app/finance/payment.php index e89857f..f2dcc05 100644 --- a/app/finance/payment.php +++ b/app/finance/payment.php @@ -2,6 +2,8 @@ session_start(); require '../config.php'; require '../include_header.php'; + + $prefill_billing_id = (int)($_GET['billing_id'] ?? 0); ?> @@ -13,16 +15,141 @@
    -

    Payments

    -

    Cash and bank payments

    +
    +
    +

    Payments

    +

    Supplier payments against payment billings

    +
    + +
    -
    +
    +
    +
    +
    +

    Payment Information

    +
    +
    + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + +
    +
    +

    Payment Billings

    +
    + + + + + + + + + + + + + +
    Document / BillingType / DateDepartmentBalancePay / Use
    Select a supplier and load open billings.
    +
    +
    +
    +
    + +
    +
    +
    +

    Summary

    +
    + Selected Billings + 0.00 +
    +
    +
    + Cash Payment + 0.00 +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    + +
    -
    - -

    Payments coming soon.

    +
    +
    +
    + + + + + + + + + + + + + + + + +
    Payment #Billing #DateSupplierMethodAmountGL FormulaStatusAction
    +
    +
    @@ -32,5 +159,330 @@ + + diff --git a/app/finance/payment_billing.php b/app/finance/payment_billing.php new file mode 100644 index 0000000..d2d69c1 --- /dev/null +++ b/app/finance/payment_billing.php @@ -0,0 +1,357 @@ + + + + + + +
    +
    + +
    +
    +
    +
    +

    Payment Billings

    +

    Supplier payment billings from purchase invoices and supplier credit notes

    +
    + +
    +
    +
    + +
    +
    +
    +
    +

    Billing Information

    +
    +
    + + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + +
    +
    +

    Documents

    +
    + + + + + + + + + + + + +
    DocumentTypeBillableInclude
    Select a supplier and load billable documents.
    +
    +
    +
    +
    + +
    +
    +
    +

    Summary

    +
    + Purchase Invoices + 0.00 +
    +
    + Supplier Credit Notes + 0.00 +
    +
    +
    + Amount Due + 0.00 +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + +
    Billing #DateSupplierAmount DuePaidBalanceStatusAction
    +
    +
    +
    +
    +
    + +
    +
    + + + + + + + diff --git a/app/finance/receipt.php b/app/finance/receipt.php index 98b16cb..fa3f71c 100644 --- a/app/finance/receipt.php +++ b/app/finance/receipt.php @@ -2,6 +2,8 @@ session_start(); require '../config.php'; require '../include_header.php'; + + $prefill_billing_id = (int)($_GET['billing_id'] ?? 0); ?> @@ -13,16 +15,169 @@
    -

    Receipts

    -

    Cash and bank receipts

    +
    +
    +

    Receipts

    +

    Customer payments against receipt billings

    +
    + +
    -
    +
    +
    +
    +
    +

    Receipt Information

    +
    +
    + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + +
    +
    +

    Receipt Billings

    +
    + + + + + + + + + + + + + +
    Document / BillingType / DateDepartmentBalanceReceive / Use
    Select a customer and load open billings.
    +
    +
    +
    +
    + +
    +
    +
    +

    Summary

    +
    + Selected Billings + 0.00 +
    +
    +
    + Cash Receipt + 0.00 +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    -
    - -

    Receipts coming soon.

    +
    +
    +
    + + + + + + + + + + + + + + + + + +
    Receipt #Billing #DateCustomerDepartmentMethodAmountGL FormulaStatusAction
    +
    +
    @@ -32,5 +187,327 @@ + + diff --git a/app/finance/receipt_billing.php b/app/finance/receipt_billing.php new file mode 100644 index 0000000..be464b2 --- /dev/null +++ b/app/finance/receipt_billing.php @@ -0,0 +1,357 @@ + + + + + + +
    +
    + +
    +
    +
    +
    +

    Receipt Billings

    +

    Customer collection bills from invoices and credit notes

    +
    + +
    +
    +
    + +
    +
    +
    +
    +

    Billing Information

    +
    +
    + + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + +
    +
    +

    Documents

    +
    + + + + + + + + + + + + +
    DocumentTypeBillableInclude
    Select a customer and load billable documents.
    +
    +
    +
    +
    + +
    +
    +
    +

    Summary

    +
    + Invoices + 0.00 +
    +
    + Credit Notes + 0.00 +
    +
    +
    + Amount Due + 0.00 +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + +
    Billing #DateCustomerAmount DueReceivedBalanceStatusAction
    +
    +
    +
    +
    +
    + +
    +
    + + + + + + + diff --git a/app/ics/api/engine/retrieve_stock_by_source.php b/app/ics/api/engine/retrieve_stock_by_source.php new file mode 100644 index 0000000..1447161 --- /dev/null +++ b/app/ics/api/engine/retrieve_stock_by_source.php @@ -0,0 +1,19 @@ +getBySource( + trim((string)($data['source'] ?? '')), + (int)($data['source_id'] ?? 0) + ); + $answer['success'] = 1; + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> diff --git a/app/ics/api/engine/validate_scan_location.php b/app/ics/api/engine/validate_scan_location.php index 639a472..4ca465a 100644 --- a/app/ics/api/engine/validate_scan_location.php +++ b/app/ics/api/engine/validate_scan_location.php @@ -16,67 +16,17 @@ $lot_number = trim($data['lot_number'] ?? ''); $serial = trim($data['serial_number'] ?? ''); - if (!$warehouse_id || $zone === '' || $aisle === '' || $rack === '') { - $answer['message'] = 'Location label is incomplete.'; - http_response_code(400); - exit(json_encode($answer)); - } - - function rackRow(PDO $pdo, int $company_id, int $warehouse_id, string $zone, string $aisle, string $rack): array|false { - $sth = $pdo->prepare( - "SELECT id, product_sku, td_stock_id - FROM md_rack - WHERE company_id = :company_id - AND warehouse = :warehouse - AND zone = :zone - AND aisle = :aisle - AND rack = :rack - LIMIT 1" - ); - $sth->execute([ - ':company_id' => $company_id, - ':warehouse' => $warehouse_id, - ':zone' => $zone, - ':aisle' => $aisle, - ':rack' => $rack, - ]); - return $sth->fetch(PDO::FETCH_ASSOC); - } - try { - $rack_row = rackRow($pdo2, $company_id, $warehouse_id, $zone, $aisle, $rack); - if (!$rack_row) throw new Exception("Location {$zone}-{$aisle}-{$rack} does not exist."); - - if ($mode === 'in' || $mode === 'to') { - if ($rack_row['product_sku'] !== null) { - throw new Exception("Location {$zone}-{$aisle}-{$rack} is already occupied by {$rack_row['product_sku']}."); - } - - if ($mode === 'in' && $product_sku !== '') { - $whMgmt->validateStockUnique($product_sku, $lot_number ?: null, $serial ?: null); - } - } elseif ($mode === 'out' || $mode === 'from') { - if ($product_sku === '') throw new Exception('Scan the SKU label first.'); - - $stock = $whMgmt->getRackStock($warehouse_id, $zone, $aisle, $rack); - if (!$stock) { - throw new Exception("Location {$zone}-{$aisle}-{$rack} has no approved stock to move out."); - } - - if ($stock['product_sku'] !== $product_sku) { - throw new Exception("Location holds {$stock['product_sku']}, not {$product_sku}."); - } - - if ($lot_number !== '' && (string)$stock['lot_number'] !== $lot_number) { - throw new Exception("Location holds lot '{$stock['lot_number']}', not '{$lot_number}'."); - } - - if ($serial !== '' && (string)$stock['serial_number'] !== $serial) { - throw new Exception("Location holds serial '{$stock['serial_number']}', not '{$serial}'."); - } - } else { - throw new Exception('Invalid scan validation mode.'); - } + $whMgmt->validateScanLocation( + $mode, + $warehouse_id, + $zone, + $aisle, + $rack, + $product_sku, + $lot_number, + $serial + ); $answer['success'] = 1; exit(json_encode($answer)); diff --git a/app/ics/manage_stock_in.php b/app/ics/manage_stock_in.php index 48dd2b9..7d9ddec 100644 --- a/app/ics/manage_stock_in.php +++ b/app/ics/manage_stock_in.php @@ -134,6 +134,7 @@ + diff --git a/app/journal/new.php b/app/journal/new.php index c4ff68c..5ad4719 100644 --- a/app/journal/new.php +++ b/app/journal/new.php @@ -11,26 +11,254 @@
    -
    +
    -

    New Journal Entry

    -

    Create a manual journal entry

    -
    -
    - -
    -
    -
    - -

    New Journal Entry coming soon.

    +
    + + Back + +
    +

    New Manual Journal Entry

    +
    +
    + + +
    +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + + +
    +
    +
    +

    Debit Total

    +

    0.00

    +

    Credit Total

    +

    0.00

    +
    +

    Enter journal lines below.

    +
    +
    +
    + + +
    +
    +
    +
    +
    Journal Lines
    + +
    +
    + + + + + + + + + + + +
    AccountDescriptionDebitCredit
    +
    +
    +
    +
    + + +
    + + Cancel +
    + +
    + + diff --git a/app/order/api/engine/confirm_return.php b/app/order/api/engine/confirm_return.php index 4a2050a..bc755c0 100644 --- a/app/order/api/engine/confirm_return.php +++ b/app/order/api/engine/confirm_return.php @@ -15,15 +15,16 @@ exit(json_encode($answer)); } - $csm = new CompanySettingManager($pdo1, $company_id); - $auto_cn = (int)$csm->get('auto_invoice_and_credit_note') === 1; + $csm = new CompanySettingManager($pdo1, $company_id); + $auto_cn = (int)$csm->get('auto_invoice_and_credit_note') === 1; + $auto_approve = (int)$csm->get('default_stock_status') === 1; try { - dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging, $uuid, $auto_cn) { + dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging, $uuid, $auto_cn, $auto_approve) { $ret = new ReturnManager($pdo, $company_id); $whMgmt = new WarehouseManager($pdo, $company_id); $invMgmt = new InvoiceManager($pdo, $company_id); - $ret->confirmReturn($id, $uuid, $logging, $whMgmt, $invMgmt, $auto_cn); + $ret->confirmReturn($id, $uuid, $logging, $whMgmt, $invMgmt, $auto_cn, $auto_approve); }); $answer['success'] = 1; diff --git a/app/order/api/engine/issue_invoice.php b/app/order/api/engine/issue_invoice.php index a27f573..cbec71c 100644 --- a/app/order/api/engine/issue_invoice.php +++ b/app/order/api/engine/issue_invoice.php @@ -4,7 +4,8 @@ require_role($user_role, ['owner', 'admin', 'staff']); require '../../../assets/utils/classes/InvoiceManager.php'; - $id = (int)($data['id'] ?? 0); + $id = (int)($data['id'] ?? ($data['invoice_id'] ?? 0)); + $due_date = trim((string)($data['due_date'] ?? '')); if (!$id) { $answer['message'] = 'Invalid ID.'; @@ -13,9 +14,9 @@ } try { - dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging) { + dbTransaction($pdo2, function($pdo) use ($id, $due_date, $company_id, $logging) { $inv = new InvoiceManager($pdo, $company_id); - $inv->issueInvoice($id, $logging); + $inv->issueInvoice($id, $logging, $due_date ?: null); }); $answer['success'] = 1; @@ -31,4 +32,4 @@ } exit(json_encode($answer)); -?> \ No newline at end of file +?> diff --git a/app/order/api/engine/manage_invoice.php b/app/order/api/engine/manage_invoice.php index daac858..71f019b 100644 --- a/app/order/api/engine/manage_invoice.php +++ b/app/order/api/engine/manage_invoice.php @@ -5,6 +5,18 @@ require '../../../assets/utils/classes/InvoiceManager.php'; try { + $action = $data['action'] ?? 'update'; + + if ($action === 'save_formula') { + $id = (int)($data['id'] ?? 0); + $formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0 + ? (int)$data['formula_id'] : null; + $inv = new InvoiceManager($pdo2, $company_id); + $inv->saveFormula($id, $formula_id); + $answer['success'] = 1; + exit(json_encode($answer)); + } + dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging) { $inv = new InvoiceManager($pdo, $company_id); $inv->saveInvoice($data, $logging); diff --git a/app/order/api/engine/manage_order.php b/app/order/api/engine/manage_order.php index f8dffec..4be9093 100644 --- a/app/order/api/engine/manage_order.php +++ b/app/order/api/engine/manage_order.php @@ -8,19 +8,17 @@ $data['items'] = json_decode($data['items'] ?? '[]', true) ?: []; try { - $new_id = null; - $quotation_id = (int)($data['quotation_id'] ?? 0); + $new_id = null; + $source = trim((string)($data['source'] ?? '')); + $source_id = (int)($data['source_id'] ?? 0); - dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $quotation_id, &$new_id) { + dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $source, $source_id, &$new_id) { $order = new OrderManager($pdo, $company_id); $new_id = $order->saveOrder($data, $logging); // On new order created from a quotation: link both records - if ($new_id && $quotation_id > 0) { - $pdo->prepare( - "UPDATE ac_quotation SET order_id = :order_id, status = 5 - WHERE id = :id AND company_id = :cid AND status = 2" - )->execute([':order_id' => $new_id, ':id' => $quotation_id, ':cid' => $company_id]); + if ($new_id && $source === 'quotation' && $source_id > 0) { + $order->linkQuotationToOrder($source_id, $new_id); } }); @@ -39,4 +37,4 @@ } exit(json_encode($answer)); -?> \ No newline at end of file +?> diff --git a/app/order/api/engine/manage_return.php b/app/order/api/engine/manage_return.php index 7c91a81..d969056 100644 --- a/app/order/api/engine/manage_return.php +++ b/app/order/api/engine/manage_return.php @@ -4,9 +4,21 @@ require_role($user_role, ['owner', 'admin', 'staff']); require '../../../assets/utils/classes/ReturnManager.php'; + $action = $data['action'] ?? 'save'; $data['items'] = json_decode($data['items'] ?? '[]', true) ?: []; try { + if ($action === 'save_formula') { + $id = (int)($data['id'] ?? 0); + $formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0 + ? (int)$data['formula_id'] : null; + if (!$id) throw new Exception('Missing return id.'); + $ret = new ReturnManager($pdo2, $company_id); + $ret->saveFormula($id, $formula_id); + $answer['success'] = 1; + exit(json_encode($answer)); + } + $new_id = null; dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, &$new_id) { diff --git a/app/order/api/engine/proceed_to_credit_note.php b/app/order/api/engine/proceed_to_credit_note.php new file mode 100644 index 0000000..e85751b --- /dev/null +++ b/app/order/api/engine/proceed_to_credit_note.php @@ -0,0 +1,36 @@ +createFromReturn($return_id, $logging); + }); + + $answer['success'] = 1; + $answer['new_id'] = $new_id; + + } catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); + + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> diff --git a/app/order/api/engine/retrieve_invoice.php b/app/order/api/engine/retrieve_invoice.php index 3cbf4f7..1573701 100644 --- a/app/order/api/engine/retrieve_invoice.php +++ b/app/order/api/engine/retrieve_invoice.php @@ -17,41 +17,23 @@ // Fetch linked credit notes for invoice detail page if ($result['doc_type'] === 'invoice') { - $sth = $pdo2->prepare( - "SELECT * FROM td_invoice - WHERE company_id = :company_id - AND ref_invoice_id = :ref_id - AND doc_type = 'credit_note' - ORDER BY id DESC" - ); - $sth->execute([':company_id' => $company_id, ':ref_id' => $id]); - $answer['credit_notes'] = $sth->fetchAll(PDO::FETCH_ASSOC); + $answer['credit_notes'] = $inv->getLinkedCreditNotes($id, 'credit_note'); + } + + if ($result['doc_type'] === 'purchase_invoice') { + $answer['supplier_credit_notes'] = $inv->getLinkedCreditNotes($id, 'supplier_credit_note'); } } else { $order_id = (int)($data['order_id'] ?? 0); $doc_type = $data['doc_type'] ?? ''; $answer['output'] = $inv->getInvoiceList($order_id, $doc_type); - // For credit note tab — join ref invoice number - if ($doc_type === 'credit_note') { - $rows = $answer['output']; - foreach ($rows as &$row) { - if ((int)$row['ref_invoice_id'] > 0) { - $sth = $pdo2->prepare( - "SELECT invoice_number FROM td_invoice - WHERE id = :id AND company_id = :company_id" - ); - $sth->execute([':id' => $row['ref_invoice_id'], ':company_id' => $company_id]); - $row['ref_invoice_number'] = $sth->fetchColumn() ?: '—'; - } else { - $row['ref_invoice_number'] = '—'; - } - } - unset($row); - $answer['output'] = $rows; + // For credit note / debit note tabs — join ref invoice number + if ($doc_type === 'credit_note' || $doc_type === 'supplier_credit_note') { + $answer['output'] = $inv->addReferenceInvoiceNumbers($answer['output']); } } $answer['success'] = 1; exit(json_encode($answer)); -?> \ No newline at end of file +?> diff --git a/app/order/api/engine/retrieve_order.php b/app/order/api/engine/retrieve_order.php index 6a2db79..9b18857 100644 --- a/app/order/api/engine/retrieve_order.php +++ b/app/order/api/engine/retrieve_order.php @@ -16,85 +16,10 @@ } $answer['output'] = $result; - // Check if an active invoice already exists for this order - $sth = $pdo2->prepare( - "SELECT COUNT(*) FROM td_invoice - WHERE company_id = :company_id - AND order_id = :order_id - AND doc_type = 'invoice' - AND status != 4" - ); - $sth->execute([':company_id' => $company_id, ':order_id' => $id]); - $answer['has_active_invoice'] = (int)$sth->fetchColumn() > 0; - - // Calculate returnable items — order items minus already confirmed returns - $order_items = $result['items'] ?? '[]'; - - // Aggregate returned qty per item_id from confirmed returns - $sth = $pdo2->prepare( - "SELECT items FROM td_return - WHERE company_id = :company_id - AND order_id = :order_id - AND status = 1" - ); - $sth->execute([':company_id' => $company_id, ':order_id' => $id]); - $returned_qty = []; - - foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $items_json) { - $items = json_decode($items_json, true) ?: []; - foreach ($items as $item) { - $item_id = (int)($item['item_id'] ?? -1); - if ($item_id < 0) continue; - $returned_qty[$item_id] = ($returned_qty[$item_id] ?? 0) + (float)$item['quantity']; - } - } - - $returnable = []; - foreach ($order_items as $i => $item) { - $already_returned = $returned_qty[$i] ?? 0; - $remaining = (float)$item['quantity'] - $already_returned; - if ($remaining > 0) { - $stock_out_warehouse_id = (int)($item['warehouse_id'] ?? 0); - $stock_out_id = (int)($item['stock_out_id'] ?? 0); - - if ($stock_out_warehouse_id > 0 && $stock_out_id > 0) { - $table = 'td_stock_' . $stock_out_warehouse_id; - $sth = $pdo2->prepare( - "SELECT id, status, zone, aisle, rack, lot_number, serial_number - FROM `{$table}` - WHERE company_id = :company_id - AND id = :id - AND type = 'out' - LIMIT 1" - ); - $sth->execute([ - ':company_id' => $company_id, - ':id' => $stock_out_id, - ]); - $stock_out = $sth->fetch(PDO::FETCH_ASSOC); - - if ($stock_out) { - $item['zone'] = $stock_out['zone'] ?? ($item['zone'] ?? ''); - $item['aisle'] = $stock_out['aisle'] ?? ($item['aisle'] ?? ''); - $item['rack'] = $stock_out['rack'] ?? ($item['rack'] ?? ''); - $item['lot_number'] = $stock_out['lot_number'] ?? ($item['lot_number'] ?? ''); - $item['serial_number'] = $stock_out['serial_number'] ?? ($item['serial_number'] ?? ''); - $item['stock_out_status'] = (int)$stock_out['status']; - } - } - - $returnable[] = array_merge($item, [ - 'item_id' => $i, - 'original_qty' => (float)$item['quantity'], - 'returned_qty' => $already_returned, - 'returnable_qty' => $remaining, - 'stock_out_warehouse_id' => $stock_out_warehouse_id, - 'stock_out_status' => (int)($item['stock_out_status'] ?? 0), - ]); - } - } - - $answer['returnable_items'] = $returnable; + $active_invoice_id = $order->getActiveInvoiceId($id); + $answer['has_active_invoice'] = $active_invoice_id > 0; + $answer['active_invoice_id'] = $active_invoice_id; + $answer['returnable_items'] = $order->getReturnableItems($result); } else { $answer['output'] = $order->getOrderList(); } diff --git a/app/order/api/engine/retrieve_return.php b/app/order/api/engine/retrieve_return.php index 9c10a96..a976920 100644 --- a/app/order/api/engine/retrieve_return.php +++ b/app/order/api/engine/retrieve_return.php @@ -13,6 +13,11 @@ $answer['message'] = 'Return not found.'; exit(json_encode($answer)); } + + $active_cn_id = $ret->getActiveCreditNoteId($id); + $result['has_active_cn'] = $active_cn_id > 0 ? 1 : 0; + $result['active_cn_id'] = $active_cn_id; + $answer['output'] = $result; } else { $order_id = (int)($data['order_id'] ?? 0); @@ -21,4 +26,4 @@ $answer['success'] = 1; exit(json_encode($answer)); -?> \ No newline at end of file +?> diff --git a/app/order/api/engine/update_invoice_status.php b/app/order/api/engine/update_invoice_status.php index 9bb14a8..51214b6 100644 --- a/app/order/api/engine/update_invoice_status.php +++ b/app/order/api/engine/update_invoice_status.php @@ -13,8 +13,9 @@ exit(json_encode($answer)); } - // 2=paid 3=overdue 4=void — issue (0→1) handled by issue_invoice.php - if (!in_array($status, [2, 3, 4], true)) { + // Manual status changes are limited to void. Paid is handled by payments; + // overdue is calculated from due_date and is not stored as a status. + if ($status !== 4) { $answer['message'] = 'Invalid status value.'; http_response_code(400); exit(json_encode($answer)); @@ -22,38 +23,8 @@ try { dbTransaction($pdo2, function($pdo) use ($id, $status, $company_id, $logging) { - - $sth = $pdo->prepare( - "SELECT status, doc_type, `log` FROM td_invoice - WHERE company_id = :company_id AND id = :id" - ); - $sth->execute([':company_id' => $company_id, ':id' => $id]); - $row = $sth->fetch(PDO::FETCH_ASSOC); - - if (!$row) throw new Exception("Invoice not found."); - if ($row['doc_type'] !== 'invoice') throw new Exception("Only invoices can be updated this way."); - - $current = (int)$row['status']; - - if ($current === 4) throw new Exception("Invoice is already void."); - if ($current === 0) throw new Exception("Issue the invoice before changing payment status."); - - $log = json_decode($row['log'] ?? '[]', true) ?: []; - $log[] = array_merge($logging, [ - 'action' => 'update_status', - 'status' => $status, - 'previous_status' => $current, - ]); - - $pdo->prepare( - "UPDATE td_invoice SET status = :status, `log` = :log - WHERE id = :id AND company_id = :company_id" - )->execute([ - ':status' => $status, - ':log' => json_encode($log), - ':id' => $id, - ':company_id' => $company_id, - ]); + $inv = new InvoiceManager($pdo, $company_id); + $inv->voidInvoice($id, array_merge($logging, ['action' => 'update_status', 'status' => $status])); }); $answer['success'] = 1; @@ -69,4 +40,4 @@ } exit(json_encode($answer)); -?> \ No newline at end of file +?> diff --git a/app/order/api/engine/update_order_status.php b/app/order/api/engine/update_order_status.php index 8b50d37..28288ac 100644 --- a/app/order/api/engine/update_order_status.php +++ b/app/order/api/engine/update_order_status.php @@ -6,7 +6,6 @@ require '../../../assets/utils/classes/CompanySettingManager.php'; $id = (int)($data['id'] ?? 0); - $fulfillment_status = (int)($data['fulfillment_status'] ?? 0); $tracking_number = trim((string)($data['shipping_tracking_number'] ?? '')); if (!$id) { @@ -19,9 +18,9 @@ $auto_complete = (int)$csm->get('auto_complete_on_ship') === 1; try { - dbTransaction($pdo2, function($pdo) use ($id, $fulfillment_status, $tracking_number, $company_id, $logging, $auto_complete) { + dbTransaction($pdo2, function($pdo) use ($id, $tracking_number, $company_id, $logging, $auto_complete) { $order = new OrderManager($pdo, $company_id); - $order->updateFulfillmentStatus($id, $fulfillment_status, $tracking_number, $logging, $auto_complete); + $order->updateShippingTracking($id, $tracking_number, $logging, $auto_complete); }); $answer['success'] = 1; diff --git a/app/order/api/engine/update_payment_status.php b/app/order/api/engine/update_payment_status.php deleted file mode 100644 index 4cec2b7..0000000 --- a/app/order/api/engine/update_payment_status.php +++ /dev/null @@ -1,69 +0,0 @@ -prepare( - "SELECT status, payment_status, `log` FROM td_order - WHERE company_id = :company_id AND id = :id" - ); - $sth->execute([':company_id' => $company_id, ':id' => $id]); - $row = $sth->fetch(PDO::FETCH_ASSOC); - - if (!$row) throw new Exception("Order not found."); - if ((int)$row['status'] === -1) throw new Exception("Cannot update payment status of a cancelled order."); - - $log = json_decode($row['log'] ?? '[]', true) ?: []; - $log[] = array_merge($logging, [ - 'action' => 'update_payment_status', - 'payment_status' => $payment_status, - 'previous_status' => (int)$row['payment_status'], - ]); - - $pdo->prepare( - "UPDATE td_order SET - payment_status = :payment_status, - `log` = :log - WHERE id = :id AND company_id = :company_id" - )->execute([ - ':payment_status' => $payment_status, - ':log' => json_encode($log), - ':id' => $id, - ':company_id' => $company_id, - ]); - }); - - $answer['success'] = 1; - $answer['message'] = 'Payment status updated.'; - - } catch (PDOException $e) { - $answer['message'] = 'Database error, please try again.'; - http_response_code(500); - - } catch (Exception $e) { - $answer['message'] = $e->getMessage(); - http_response_code(400); - } - - exit(json_encode($answer)); -?> diff --git a/app/order/invoice.php b/app/order/invoice.php index d286863..3f342c4 100644 --- a/app/order/invoice.php +++ b/app/order/invoice.php @@ -11,61 +11,108 @@
    - -
    +
    -
    +

    Invoices

    -

    Invoices and credit notes

    +

    Invoices and credit notes

    - -
    -
    - +
    +
    +
    +
    +
    + +
    +
    +

    Total

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Draft

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Issued

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Overdue

    +

    —

    +
    +
    +
    - -
    +
    -
    +
    + placeholder="Search invoice number or contact...">
    -
    +
    + +
    +
    +
    + +
    -
    - -
    - - + + @@ -101,24 +145,6 @@
    Invoice #Type Order # ContactDepartment Issued Date Due Date Grand Total
    - - - - - - - - - - - - - - - - -
    CN #Order #Ref Invoice #ContactDateAmountAction
    -
    @@ -132,121 +158,108 @@ diff --git a/app/order/manage_invoice.php b/app/order/manage_invoice.php index 4e79d32..8a533ea 100644 --- a/app/order/manage_invoice.php +++ b/app/order/manage_invoice.php @@ -42,6 +42,8 @@ Product Qty Unit Price + Tax% + Tax Amt Total @@ -93,15 +95,31 @@ Contact:
    +
    + Department: + — +
    Issued:
    -
    +
    Due:
    +
    + GL Formula: +
    + + +
    +
    Notes: @@ -118,32 +136,15 @@ Save - - - -
    - -
    - - -
    -
    +
    @@ -157,6 +158,7 @@ - \ No newline at end of file + diff --git a/app/order/manage_order.php b/app/order/manage_order.php index 3adea29..c90caa4 100644 --- a/app/order/manage_order.php +++ b/app/order/manage_order.php @@ -5,6 +5,8 @@ $order_id = (int)($_GET['id'] ?? 0); $is_edit = isset($_GET['edit']) || $order_id === 0; + $source = $order_id ? '' : preg_replace('/[^a-z0-9_-]/i', '', $_GET['source'] ?? ''); + $source_id = $order_id ? 0 : (int)($_GET['source_id'] ?? 0); ?> @@ -61,9 +63,10 @@
    - - + +
    @@ -93,9 +96,10 @@ Product Warehouse - Qty - Unit Price - Total + Qty + Unit Price + Tax% + Total @@ -117,14 +121,26 @@

    Status

    -

    +

    +
    +
    + +
    +
    Warehouse assignment required
    +
    + Select a warehouse for every item, then save it. The order will move to Draft and can be confirmed. +
    +
    +
    +
    +
    @@ -140,8 +156,12 @@
    Tax - + 0.00 +
    +
    + Tax Adj. +
    Shipping @@ -180,36 +200,10 @@ Proceed to Invoice - -
    - -
    - - -
    -
    -
    - -
    - - -
    -
    - +
    - - New Return -
    @@ -32,7 +29,7 @@
    -
    +
    @@ -41,7 +38,7 @@
    -
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + +
    Invoice #TypeSourceDateSupplierDepartmentGrand TotalDue DateStatusAction
    +
    +
    +
    +
    +
    + +
    +
    + + + + + + + diff --git a/app/po/manage_po.php b/app/po/manage_po.php index cfad371..6ec6d9f 100644 --- a/app/po/manage_po.php +++ b/app/po/manage_po.php @@ -71,6 +71,13 @@
    +
    + + +
    +
    +
    +
    + GL Formula: +
    + + +
    +
    +
    +
    + +
    +
    + + + + + + + +
    +
    + +
    +
    +
    +
    + + + + + + + diff --git a/app/po/manage_supplier_return.php b/app/po/manage_supplier_return.php new file mode 100644 index 0000000..2d53c6e --- /dev/null +++ b/app/po/manage_supplier_return.php @@ -0,0 +1,631 @@ + + + + + + +
    +
    + + + +
    + + +
    + +
    +
    +

    Return Information

    +
    + +
    + + + +
    + +
    + + +
    + +
    + + + +
    + +
    + + +
    + +
    + +

    —

    +
    + +
    + + +
    + +
    +
    +
    + +
    +
    +

    Select Items to Return

    +

    Only received items with remaining returnable quantity are shown.

    + +
    + + + + + + + + + + + + + + + + + + + +
    ProductPO QtyReceivedReturnableReturn QtyUnit PriceTax%Total
    + Loading items... +
    +
    +
    +
    + +
    + + +
    + +
    +
    +

    Status

    + + +

    +

    +
    +
    + +
    +
    +

    Refund Summary

    +
    + Subtotal + 0.00 +
    +
    + Tax + 0.00 +
    +
    + Tax Adj. + +
    +
    +
    + Total Refund + 0.00 +
    +
    +
    + +
    +
    + + + + + + + + + + + View Supplier Credit Note + + + + +
    +
    + +
    +
    +
    +
    + + + + + + + diff --git a/app/po/po.php b/app/po/po.php index f41b50f..0efcfd8 100644 --- a/app/po/po.php +++ b/app/po/po.php @@ -75,8 +75,8 @@
    -

    Completed

    -

    —

    +

    Received

    +

    —

    @@ -90,7 +90,7 @@
    -
    +
    @@ -99,22 +99,18 @@
    -
    +
    - +
    @@ -137,10 +133,11 @@ PO # Date Supplier + Department Items Grand Total Status - Payment + Stock Expected Action @@ -164,26 +161,22 @@ function po_status_badge(status) { status = parseInt(status); const map = { + '-2': 'Pending Warehouse', '-1': 'Cancelled', '0': 'Draft', '1': 'Confirmed', - '2': 'Partial', - '3': 'Completed', }; return map[String(status)] ?? '—'; } - function po_payment_badge(status) { - status = parseInt(status); + function receipt_status_badge(status) { const map = { - '0': 'Unpaid', - '1': 'Paid', - '2': 'Partial', + '2': 'Partial', + '3': 'Received', }; - return map[String(status)] ?? '—'; + return map[String(status)] ?? '—'; } - function retrieve_pos() { return ajax_request({ url: 'po/api/engine/retrieve_po.php', @@ -193,23 +186,23 @@ onSuccess: function(res) { var all = res.output || []; - var keyword = $('#search_input').val().toLowerCase(); - var fil_status = $('#filter_status').val(); - var fil_payment = $('#filter_payment').val(); + var keyword = $('#search_input').val().toLowerCase(); + var fil_status = $('#filter_status').val(); + var fil_dept = $('#filter_dept').val(); var data = all.filter(function(o) { - var match_kw = !keyword || + var match_kw = !keyword || (o.po_number || '').toLowerCase().includes(keyword) || (o.contact_name || '').toLowerCase().includes(keyword); - var match_st = fil_status === '' || String(o.status) === fil_status; - var match_pm = fil_payment === '' || String(o.payment_status) === fil_payment; - return match_kw && match_st && match_pm; + var match_st = fil_status === '' || String(o.status) === fil_status; + var match_dept = !fil_dept || String(o.department_id) === fil_dept; + return match_kw && match_st && match_dept; }); $('#stat_total').text(format_number(all.length)); $('#stat_draft').text(format_number(all.filter(o => parseInt(o.status) === 0).length)); $('#stat_confirmed').text(format_number(all.filter(o => parseInt(o.status) === 1).length)); - $('#stat_completed').text(format_number(all.filter(o => parseInt(o.status) === 3).length)); + $('#stat_received').text(format_number(all.filter(o => parseInt(o.receipt_status) === 3).length)); alasql('CREATE TABLE IF NOT EXISTS po_list'); alasql.tables.po_list.data = data; @@ -233,21 +226,26 @@ $.each(page_data, function(i, o) { var items = JSON.parse(o.items || '[]'); var item_count = items.length; - var can_cancel = parseInt(o.status) >= 0 && parseInt(o.status) <= 1; + var can_cancel = parseInt(o.status) >= -2 && parseInt(o.status) <= 1; body += ` ${o.po_number} ${format_date(o.po_date)} ${o.contact_name || '—'} + ${get_dept_label(o.department_id)} ${item_count} item${item_count !== 1 ? 's' : ''} ${format_number(o.grand_total, 2)} ${po_status_badge(o.status)} - ${po_payment_badge(o.payment_status)} + ${receipt_status_badge(o.receipt_status)} ${o.expected_date ? format_date(o.expected_date) : '—'} + + + ${can_cancel ? ` @@ -285,14 +283,17 @@ } - $(function() { + $(async function() { + await load_dept_cache(); + populate_dept_filter('filter_dept'); retrieve_pos(); $('#search_input').on('keyup', function(e) { if (e.key === 'Enter') retrieve_pos(); }); + $('#filter_status, #filter_dept').on('change', retrieve_pos); }); - \ No newline at end of file + diff --git a/app/po/supplier_returns.php b/app/po/supplier_returns.php new file mode 100644 index 0000000..f7b08c2 --- /dev/null +++ b/app/po/supplier_returns.php @@ -0,0 +1,260 @@ + + + + + + +
    +
    + +
    +
    +
    +
    +

    Supplier Returns

    +

    Goods returned to suppliers

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +

    Total

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Draft

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Confirmed

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Cancelled

    +

    —

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + +
    Return #PO #DateSupplierDepartmentItemsRefund AmountStatusFulfillmentAction
    +
    +
    +
    +
    +
    + +
    +
    + + + + + + + diff --git a/app/reports/occupy_rack.php b/app/reports/occupy_rack.php index 2bb70ac..a20ac0f 100644 --- a/app/reports/occupy_rack.php +++ b/app/reports/occupy_rack.php @@ -263,6 +263,7 @@ - \ No newline at end of file + diff --git a/app/revenue/api/engine/convert_quotation.php b/app/revenue/api/engine/convert_quotation.php index 8215cb4..fa96bdf 100644 --- a/app/revenue/api/engine/convert_quotation.php +++ b/app/revenue/api/engine/convert_quotation.php @@ -2,60 +2,104 @@ session_start(); require '../../../assets/utils/db_auth.php'; require '../../../assets/utils/classes/OrderManager.php'; +require '../../../assets/utils/classes/QuotationManager.php'; + +$quotation_id = (int)($data['quotation_id'] ?? 0); +$convert_items = $data['convert_items'] ?? []; -$quotation_id = (int)($data['quotation_id'] ?? 0); if (!$quotation_id) { - exit(json_encode(['success' => 0, 'message' => 'Quotation ID required'])); + exit(json_encode(['success' => 0, 'message' => 'Quotation ID required.'])); } -// Load quotation — must be Accepted (status=2) -$sth = $pdo2->prepare( - "SELECT q.*, c.contact_name - FROM ac_quotation q - LEFT JOIN md_contact c ON c.id = q.contact_id - WHERE q.id = :id AND q.company_id = :cid AND q.status = 2 - LIMIT 1" -); -$sth->execute([':id' => $quotation_id, ':cid' => $company_id]); -$q = $sth->fetch(PDO::FETCH_ASSOC); +$qm = new QuotationManager($pdo2, $company_id); -if (!$q) { - exit(json_encode(['success' => 0, 'message' => 'Quotation not found or not in Accepted status'])); +// Load quotation — must be Accepted +$q = $qm->getById($quotation_id); +if (!$q || (int)$q['status'] !== 2) { + exit(json_encode(['success' => 0, 'message' => 'Quotation not found or not in Accepted status.'])); } -// Check not already converted -if ((int)$q['order_id'] > 0) { - exit(json_encode(['success' => 0, 'message' => 'Quotation has already been converted to an order'])); +$qt_items = $q['items']; +if (empty($qt_items)) { + exit(json_encode(['success' => 0, 'message' => 'Quotation has no items.'])); } -// Map quotation items → order items (warehouse_id left 0 = to be filled by WMS) -$qt_items = json_decode($q['items'] ?? '[]', true) ?: []; +if ((float)$q['total_remaining'] <= 0.000001) { + exit(json_encode(['success' => 0, 'message' => 'Quotation is already fully converted.'])); +} + +// Index by item_id for validation +$qt_by_id = []; +foreach ($qt_items as $qi) { + $qt_by_id[(int)$qi['item_id']] = $qi; +} + +// Default to all remaining when caller sends no selection +if (empty($convert_items)) { + $convert_items = []; + foreach ($qt_items as $qi) { + $remaining = (float)$qi['remaining_qty']; + if ($remaining > 0.000001) { + $convert_items[] = ['item_id' => (int)$qi['item_id'], 'quantity' => $remaining]; + } + } +} + +// Validate quantities and build order items $ord_items = []; -foreach ($qt_items as $i => $qi) { +$validated = []; + +foreach ($convert_items as $ci) { + $item_id = (int)($ci['item_id'] ?? 0); + $qty = (float)($ci['quantity'] ?? 0); + + if ($qty <= 0) continue; + if (!isset($qt_by_id[$item_id])) { + exit(json_encode(['success' => 0, 'message' => "Item #{$item_id} not found in this quotation."])); + } + + $qi = $qt_by_id[$item_id]; + $remaining = (float)$qi['remaining_qty']; + + if ($qty - $remaining > 0.000001) { + $name = $qi['product_name'] ?: $qi['product_sku']; + exit(json_encode([ + 'success' => 0, + 'message' => "Cannot convert {$qty} for \"{$name}\": only {$remaining} remaining.", + ])); + } + + $unit_price = (float)$qi['unit_price']; + $total_price = round($qty * $unit_price, 4); + $tax_amount = round($total_price * (float)($qi['tax_amount'] / max((float)$qi['total_price'], 0.000001)), 4); + $ord_items[] = [ - 'item_id' => $i, - 'product_sku' => $qi['product_sku'] ?? '', - 'product_name' => $qi['description'] ?? '', - 'warehouse_id' => 0, - 'zone' => '', - 'aisle' => '', - 'rack' => '', - 'lot_number' => '', - 'serial_number' => '', - 'quantity' => (float)($qi['quantity'] ?? 0), - 'price' => (float)($qi['unit_price'] ?? 0), - 'total_price' => (float)($qi['total_price'] ?? 0), - 'stock_out_id' => 0, + 'item_id' => $item_id - 1, + 'product_sku' => $qi['product_sku'], + 'product_name' => $qi['product_name'], + 'warehouse_id' => 0, + 'lot_number' => '', + 'quantity' => $qty, + 'unit_price' => $unit_price, + 'total_price' => $total_price, + 'tax_amount' => $tax_amount, + 'stock_out_id' => 0, ]; + $validated[] = ['item_id' => $item_id, 'quantity' => $qty]; +} + +if (empty($ord_items)) { + exit(json_encode(['success' => 0, 'message' => 'No valid items to convert.'])); } $order_data = [ - 'quotation_id' => $quotation_id, + 'source' => 'quotation', + 'source_id' => $quotation_id, 'contact_id' => (int)$q['contact_id'], + 'department_id'=> (int)($q['department_id'] ?? 0), 'order_date' => date('Y-m-d'), 'items' => $ord_items, - 'discount' => (float)$q['discount'], - 'tax' => (float)$q['tax'], + 'discount' => 0, 'shipping_fee' => 0, 'shipping_tracking_number' => '', 'notes' => $q['notes'] ?? '', @@ -64,21 +108,23 @@ $order_data = [ try { $new_order_id = null; - dbTransaction($pdo2, function($pdo) use ($order_data, $company_id, $logging, $quotation_id, &$new_order_id) { - $order = new OrderManager($pdo, $company_id); - $new_order_id = $order->saveOrder($order_data, $logging); + dbTransaction($pdo2, function($pdo) use ( + $order_data, $company_id, $logging, $quotation_id, $validated, $qm, &$new_order_id + ) { + $om = new OrderManager($pdo, $company_id); + $new_order_id = $om->saveOrder($order_data, $logging); - // Mark quotation as Converted and link order_id - $pdo->prepare( - "UPDATE ac_quotation SET status = 5, order_id = :oid - WHERE id = :id AND company_id = :cid" - )->execute([':oid' => $new_order_id, ':id' => $quotation_id, ':cid' => $company_id]); + $qm2 = new QuotationManager($pdo, $company_id); + $qm2->incrementConvertedQty($quotation_id, $validated); }); $answer['success'] = 1; - $answer['message'] = 'Order created successfully'; + $answer['message'] = 'Order created successfully.'; $answer['order_id'] = $new_order_id; +} catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); } catch (Exception $e) { $answer['message'] = $e->getMessage(); http_response_code(400); diff --git a/app/revenue/api/engine/manage_credit_note.php b/app/revenue/api/engine/manage_credit_note.php new file mode 100644 index 0000000..2937d22 --- /dev/null +++ b/app/revenue/api/engine/manage_credit_note.php @@ -0,0 +1,29 @@ +createManualCreditNote($data, $logging); + }); + + $answer['success'] = 1; + $answer['new_id'] = $new_id; + $answer['message'] = 'Credit note created.'; + + } catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); + + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> diff --git a/app/revenue/api/engine/manage_order.php b/app/revenue/api/engine/manage_order.php new file mode 100644 index 0000000..fcd6b63 --- /dev/null +++ b/app/revenue/api/engine/manage_order.php @@ -0,0 +1,54 @@ + 0, 'message' => 'Unknown action'])); +} + +if (empty($data['items'])) { + exit(json_encode(['success' => 0, 'message' => 'At least one item is required'])); +} + +try { + $new_id = null; + + dbTransaction($pdo2, function($pdo) use ($data, $action, $company_id, $logging, &$new_id) { + $order = new OrderManager($pdo, $company_id); + + if ($action === 'create') { + $data['source'] = 'revenue'; + $data['source_id'] = 0; + } else { + $id = (int)($data['id'] ?? 0); + if (!$id) { + throw new Exception('Order ID required.'); + } + + $order->assertRevenueOrderEditable($id); + } + + $new_id = $order->saveOrder($data, $logging); + }); + + $answer['success'] = 1; + $answer['message'] = $action === 'create' ? 'Sales order created' : 'Sales order updated'; + if ($new_id) { + $answer['new_id'] = $new_id; + } + +} catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); + +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); +} + +exit(json_encode($answer)); +?> diff --git a/app/revenue/api/engine/manage_quotation.php b/app/revenue/api/engine/manage_quotation.php index 9409bb3..24e5ec5 100644 --- a/app/revenue/api/engine/manage_quotation.php +++ b/app/revenue/api/engine/manage_quotation.php @@ -1,107 +1,35 @@ prepare($sql); - $sth->execute([':cid' => $cid, ':prefix' => $prefix . '%']); - $last = $sth->fetchColumn(); - $seq = $last ? ((int)substr($last, -4) + 1) : 1; - return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); -} +$qm = new QuotationManager($pdo2, $company_id); -// ── Compute totals from items ───────────────────────────────────────────────── -function compute_totals(array $items, float $discount, float $tax): array { - $subtotal = array_sum(array_map(fn($i) => (float)($i['total_price'] ?? 0), $items)); - $grand = $subtotal - $discount + $tax; - return [round($subtotal, 4), round(max(0, $grand), 4)]; -} - -if ($action === 'create') { - - $items = json_decode($data['items'] ?? '[]', true) ?: []; - $discount = (float)($data['discount'] ?? 0); - $tax = (float)($data['tax'] ?? 0); - [$subtotal, $grand] = compute_totals($items, $discount, $tax); - - $qnum = generate_quotation_number($pdo2, $company_id); - - $sql = "INSERT INTO ac_quotation - (company_id, uuid, quotation_number, contact_id, quotation_date, valid_until, - status, subtotal, discount, tax, grand_total, items, notes, log, created_at) - VALUES - (:cid, :uuid, :qnum, :contact_id, :qdate, :valid_until, - 0, :sub, :disc, :tax, :grand, :items, :notes, :log, NOW())"; - $sth = $pdo2->prepare($sql); - $sth->execute([ - ':cid' => $company_id, - ':uuid' => $uuid, - ':qnum' => $qnum, - ':contact_id' => (int)($data['contact_id'] ?? 0), - ':qdate' => $data['quotation_date'] ?: null, - ':valid_until'=> $data['valid_until'] ?: null, - ':sub' => $subtotal, - ':disc' => $discount, - ':tax' => $tax, - ':grand' => $grand, - ':items' => json_encode($items), - ':notes' => $data['notes'] ?? '', - ':log' => json_encode($logging), - ]); - - $answer['success'] = 1; - $answer['message'] = 'Quotation created'; - $answer['new_id'] = (int)$pdo2->lastInsertId(); - $answer['quotation_number'] = $qnum; - -} elseif ($action === 'update') { - - if (!$id) { exit(json_encode(['success' => 0, 'message' => 'ID required'])); } - - $items = json_decode($data['items'] ?? '[]', true) ?: []; - $discount = (float)($data['discount'] ?? 0); - $tax = (float)($data['tax'] ?? 0); - [$subtotal, $grand] = compute_totals($items, $discount, $tax); - - $sql = "UPDATE ac_quotation SET - contact_id = :contact_id, - quotation_date = :qdate, - valid_until = :valid_until, - subtotal = :sub, - discount = :disc, - tax = :tax, - grand_total = :grand, - items = :items, - notes = :notes - WHERE id = :id AND company_id = :cid AND status = 0"; - $sth = $pdo2->prepare($sql); - $sth->execute([ - ':contact_id' => (int)($data['contact_id'] ?? 0), - ':qdate' => $data['quotation_date'] ?: null, - ':valid_until' => $data['valid_until'] ?: null, - ':sub' => $subtotal, - ':disc' => $discount, - ':tax' => $tax, - ':grand' => $grand, - ':items' => json_encode($items), - ':notes' => $data['notes'] ?? '', - ':id' => $id, - ':cid' => $company_id, - ]); - - $answer['success'] = 1; - $answer['message'] = 'Quotation updated'; - -} else { - $answer['message'] = 'Unknown action'; +try { + if ($action === 'create') { + require_role($user_role, ['owner', 'admin', 'staff']); + $new_id = $qm->save(array_merge($data, ['id' => 0, 'items' => $items]), $logging); + $answer['success'] = 1; + $answer['message'] = 'Quotation created.'; + $answer['new_id'] = $new_id; + } elseif ($action === 'update') { + require_role($user_role, ['owner', 'admin', 'staff']); + $qm->save(array_merge($data, ['id' => $id, 'items' => $items]), $logging); + $answer['success'] = 1; + $answer['message'] = 'Quotation updated.'; + } else { + throw new Exception('Unknown action.'); + } +} catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); } exit(json_encode($answer)); diff --git a/app/revenue/api/engine/proceed_to_invoice.php b/app/revenue/api/engine/proceed_to_invoice.php new file mode 100644 index 0000000..4a9e8f3 --- /dev/null +++ b/app/revenue/api/engine/proceed_to_invoice.php @@ -0,0 +1,40 @@ +assertRevenueOrder($order_id); + + $inv = new InvoiceManager($pdo, $company_id); + $new_id = $inv->createFromOrder($order_id, $logging); + }); + + $answer['success'] = 1; + $answer['new_id'] = $new_id; + + } catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); + + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + } + + exit(json_encode($answer)); +?> diff --git a/app/revenue/api/engine/quotation_stats.php b/app/revenue/api/engine/quotation_stats.php index 5300703..6f6a0bd 100644 --- a/app/revenue/api/engine/quotation_stats.php +++ b/app/revenue/api/engine/quotation_stats.php @@ -1,19 +1,14 @@ prepare($sql); -$sth->execute([':cid' => $company_id]); -$row = $sth->fetch(PDO::FETCH_ASSOC); +try { + $quotation = new QuotationManager($pdo2, $company_id); + $answer['output'] = $quotation->getStats(); + $answer['success'] = 1; +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); +} -$answer['success'] = 1; -$answer['output'] = $row; exit(json_encode($answer)); diff --git a/app/revenue/api/engine/retrieve_quotation.php b/app/revenue/api/engine/retrieve_quotation.php index 1b882f1..b831c83 100644 --- a/app/revenue/api/engine/retrieve_quotation.php +++ b/app/revenue/api/engine/retrieve_quotation.php @@ -1,32 +1,20 @@ prepare($sql); - $sth->execute([':id' => $id, ':cid' => $company_id]); - $row = $sth->fetch(PDO::FETCH_ASSOC); - if (!$row) { exit(json_encode(['success' => 0, 'message' => 'Not found'])); } - $row['items'] = json_decode($row['items'] ?? '[]', true); + $row = $qm->getById($id); + if (!$row) { + http_response_code(404); + exit(json_encode(['success' => 0, 'message' => 'Not found.'])); + } $answer['output'] = $row; } else { - // List - $sql = "SELECT q.*, c.contact_name - FROM ac_quotation q - LEFT JOIN md_contact c ON c.id = q.contact_id - WHERE q.company_id = :cid - ORDER BY q.id DESC"; - $sth = $pdo2->prepare($sql); - $sth->execute([':cid' => $company_id]); - $answer['output'] = $sth->fetchAll(PDO::FETCH_ASSOC); + $answer['output'] = $qm->getList(); } $answer['success'] = 1; diff --git a/app/revenue/api/engine/update_quotation_status.php b/app/revenue/api/engine/update_quotation_status.php index 592ddff..d767717 100644 --- a/app/revenue/api/engine/update_quotation_status.php +++ b/app/revenue/api/engine/update_quotation_status.php @@ -1,42 +1,27 @@ 0, 'message' => 'ID and action required'])); + exit(json_encode(['success' => 0, 'message' => 'ID and action are required.'])); } -// Load current status -$sth = $pdo2->prepare("SELECT status FROM ac_quotation WHERE id = :id AND company_id = :cid LIMIT 1"); -$sth->execute([':id' => $id, ':cid' => $company_id]); -$row = $sth->fetch(PDO::FETCH_ASSOC); -if (!$row) { exit(json_encode(['success' => 0, 'message' => 'Not found'])); } +$qm = new QuotationManager($pdo2, $company_id); -$cur = (int)$row['status']; - -$transitions = [ - 'send' => ['from' => [0], 'to' => 1, 'label' => 'Sent'], - 'accept' => ['from' => [1], 'to' => 2, 'label' => 'Accepted'], - 'reject' => ['from' => [1], 'to' => 3, 'label' => 'Rejected'], - 'reopen' => ['from' => [1,2,3],'to' => 0, 'label' => 'Draft'], - 'cancel' => ['from' => [0,1], 'to' => -1,'label' => 'Cancelled'], -]; - -if (!isset($transitions[$action])) { - exit(json_encode(['success' => 0, 'message' => 'Invalid action'])); +try { + $qm->updateStatus($id, $action, $logging); + $answer['success'] = 1; + $answer['message'] = 'Status updated.'; +} catch (PDOException $e) { + $answer['message'] = 'Database error, please try again.'; + http_response_code(500); +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); } -$t = $transitions[$action]; -if (!in_array($cur, $t['from'], true)) { - exit(json_encode(['success' => 0, 'message' => 'Transition not allowed from current status'])); -} - -$sth = $pdo2->prepare("UPDATE ac_quotation SET status = :status WHERE id = :id AND company_id = :cid"); -$sth->execute([':status' => $t['to'], ':id' => $id, ':cid' => $company_id]); - -$answer['success'] = 1; -$answer['message'] = 'Status updated to ' . $t['label']; exit(json_encode($answer)); diff --git a/app/revenue/invoice.php b/app/revenue/invoice.php index a54c3aa..352aa03 100644 --- a/app/revenue/invoice.php +++ b/app/revenue/invoice.php @@ -11,18 +11,176 @@
    -
    + + +
    +
    +
    +
    +
    + +
    +
    +

    Invoices

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Paid

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Open

    +

    —

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Credit Notes

    +

    —

    +
    +
    +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    -
    - -

    Sales Invoices coming soon.

    +
    +
    +
    + + + + + + + + + + + + + + + + + +
    Invoice #Order #ContactDepartmentIssued DateDue DateGrand TotalGL FormulaStatusAction
    + + + + + + + + + + + + + + + + + + +
    CN #Order #Ref Invoice #ContactDepartmentDateAmountGL FormulaStatusAction
    +
    +
    @@ -32,5 +190,241 @@ + + diff --git a/app/revenue/manage_credit_note.php b/app/revenue/manage_credit_note.php new file mode 100644 index 0000000..a290f8e --- /dev/null +++ b/app/revenue/manage_credit_note.php @@ -0,0 +1,269 @@ + + + + + + +
    +
    + +
    +
    +
    +
    +

    New Credit Note

    +

    Accounting credit note without stock movement

    +
    + + Back + +
    +
    +
    + +
    +
    +
    +
    +

    Credit Note Information

    +
    +
    + + +
    +
    + + + +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +

    Credit Lines

    + +
    + +
    + + + + + + + + + + + + +
    DescriptionQtyUnit PriceTax%Total
    +
    +
    +
    +
    + +
    +
    +
    +

    Summary

    +
    + Subtotal + 0.00 +
    +
    + Tax + 0.00 +
    +
    + Tax Adj. + +
    +
    +
    + Credit Total + 0.00 +
    +
    +
    + +
    +
    + + Cancel +
    +
    +
    +
    + +
    +
    + + + + + + + diff --git a/app/revenue/manage_invoice.php b/app/revenue/manage_invoice.php new file mode 100644 index 0000000..afc2571 --- /dev/null +++ b/app/revenue/manage_invoice.php @@ -0,0 +1,392 @@ + + + + + + +
    +
    + +
    +
    +
    +
    +

    Invoice Detail

    +

    Revenue invoice management

    +
    + + Back + +
    +
    +
    + +
    +
    +
    +
    +

    Items

    +
    + + + + + + + + + + + + + + + +
    ProductQtyUnit PriceTax%Tax AmtTotal
    Loading invoice...
    +
    +
    +
    + +
    +
    +

    Credit Notes

    +
    + + + + + + + + + + + +
    CN #DateAmountStatus
    +
    +
    +
    +
    + +
    +
    +
    +

    Document Info

    +
    + + +
    +
    + Order: + — +
    +
    + Contact: + — +
    +
    + Issued: + — +
    +
    + Due: + — + +
    +
    + GL Formula: +
    + + +
    +
    +
    + Notes: +
    —
    +
    +
    +
    + +
    +
    + + + + + Create Credit Note + + +
    +
    +
    +
    +
    +
    + + + + + + + diff --git a/app/revenue/manage_order.php b/app/revenue/manage_order.php new file mode 100644 index 0000000..c4430ea --- /dev/null +++ b/app/revenue/manage_order.php @@ -0,0 +1,481 @@ + + + + + + +
    +
    + +
    +
    +
    +
    +

    + +

    +

    + +

    +
    + + Back + +
    +
    +
    + +
    +
    +
    +
    +

    Order Information

    +
    +
    + + + +
    + +
    + + +
    + +
    + + +
    +
    +
    +
    + +
    +
    +
    +

    Order Items

    + +
    + +
    + + + + + + + + + + + + + +
    SKUDescriptionQtyUnit PriceTax%Total
    +
    +
    +
    +
    + +
    +
    +
    +

    Status

    +
    +

    +
    +
    + +
    +
    + +
    +
    Pending WMS assignment
    +
    + This order will be sent to WMS as pending. WMS staff assign warehouses before confirmation. +
    +
    +
    +
    + +
    +
    +

    Summary

    +
    + Subtotal + 0.00 +
    +
    + Discount + +
    +
    + Tax + 0.00 +
    +
    + Tax Adj. + +
    +
    + Shipping + +
    +
    +
    + Grand Total + 0.00 +
    +
    +
    + +
    +
    + + + + View Invoice + + + + Back + +
    +
    +
    +
    + +
    +
    + + + + + + + diff --git a/app/revenue/manage_quotation.php b/app/revenue/manage_quotation.php index 6fddf15..50eb80b 100644 --- a/app/revenue/manage_quotation.php +++ b/app/revenue/manage_quotation.php @@ -45,7 +45,7 @@
    -
    @@ -62,6 +62,13 @@ placeholder="DD/MM/YYYY (optional)" autocomplete="off">
    +
    + + +
    +