accounting workflows

This commit is contained in:
Thanakorn S
2026-05-20 10:17:02 +07:00
parent 04a683bd02
commit 7396db6ffc
199 changed files with 23539 additions and 5345 deletions
+2 -7
View File
@@ -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
-173
View File
@@ -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.
+11
View File
@@ -0,0 +1,11 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/FinancialReports.php';
$reports = new FinancialReports($pdo2, $company_id);
$answer['output'] = ['by_source' => $reports->getDashboardBySource()];
$answer['success'] = 1;
exit(json_encode($answer));
?>
+11
View File
@@ -0,0 +1,11 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/FinancialReports.php';
$reports = new FinancialReports($pdo2, $company_id);
$answer['output'] = ['journal_count' => $reports->getDashboardJournalCount()];
$answer['success'] = 1;
exit(json_encode($answer));
?>
+11
View File
@@ -0,0 +1,11 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/FinancialReports.php';
$reports = new FinancialReports($pdo2, $company_id);
$answer['output'] = $reports->getDashboardPL();
$answer['success'] = 1;
exit(json_encode($answer));
?>
@@ -0,0 +1,13 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/CompanySettingManager.php';
$settings = new CompanySettingManager($pdo1, $company_id);
$open_from = $settings->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));
?>
+12
View File
@@ -0,0 +1,12 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/FinancialReports.php';
$limit = max(1, min(50, (int)($data['limit'] ?? 10)));
$reports = new FinancialReports($pdo2, $company_id);
$answer['output'] = ['recent' => $reports->getDashboardRecentEntries($limit)];
$answer['success'] = 1;
exit(json_encode($answer));
?>
+12
View File
@@ -0,0 +1,12 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/FinancialReports.php';
$months = max(1, min(24, (int)($data['months'] ?? 6)));
$reports = new FinancialReports($pdo2, $company_id);
$answer['output'] = ['trend' => $reports->getMonthlyTrend($months)];
$answer['success'] = 1;
exit(json_encode($answer));
?>
+375 -8
View File
@@ -8,24 +8,168 @@
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_ac.php'; ?>
<!-- MAIN CONTENT -->
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row mb-6">
<div class="row">
<div class="col-12">
<h1 class="fs-3 mb-1">Accounting Dashboard</h1>
<p class="mb-0 text-muted">Financial overview</p>
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-6 gap-3">
<div>
<h1 class="fs-3 mb-1">Accounting Dashboard</h1>
<p class="mb-0">Financial overview from posted journal entries</p>
</div>
</div>
</div>
</div>
<!-- Placeholder -->
<!-- Summary cards -->
<div class="row g-5 mb-5">
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-info bg-opacity-10 border border-info border-opacity-25 rounded-2" style="position:relative;">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-info text-white rounded-2">
<i class="ti ti-trending-up fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Revenue</h2>
<h3 class="fw-bold mb-0" id="stat_revenue">—</h3>
<p class="text-info mb-0 small">Posted entries</p>
</div>
</div>
<div class="card-loader-overlay" id="overlay_revenue">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-danger bg-opacity-10 border border-danger border-opacity-25 rounded-2" style="position:relative;">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-danger text-white rounded-2">
<i class="ti ti-trending-down fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Expenses</h2>
<h3 class="fw-bold mb-0" id="stat_expense">—</h3>
<p class="text-danger mb-0 small">Posted entries</p>
</div>
</div>
<div class="card-loader-overlay" id="overlay_expense">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-success bg-opacity-10 border border-success border-opacity-25 rounded-2" style="position:relative;">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-success text-white rounded-2">
<i class="ti ti-calculator fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Net Profit</h2>
<h3 class="fw-bold mb-0" id="stat_net">—</h3>
<p class="text-success mb-0 small">Revenue minus expenses</p>
</div>
</div>
<div class="card-loader-overlay" id="overlay_net">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-warning bg-opacity-10 border border-warning border-opacity-25 rounded-2" style="position:relative;">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-warning text-white rounded-2">
<i class="ti ti-file-description fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Journal Entries</h2>
<h3 class="fw-bold mb-0" id="stat_journals">—</h3>
<p class="text-warning mb-0 small">Posted entries</p>
</div>
</div>
<div class="card-loader-overlay" id="overlay_journals">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
<!-- Posting window banner -->
<div class="row mb-5">
<div class="col-12">
<div class="card" style="position:relative;">
<div class="card-body py-3 d-flex align-items-center gap-3 flex-wrap">
<i class="ti ti-lock text-muted"></i>
<span id="posting_window_banner" class="small text-muted">Loading...</span>
<a href="<?php echo $server_url?>accounting/posting_window.php" class="ms-auto small">Manage Posting Window</a>
</div>
<div class="card-loader-overlay" id="overlay_lock">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
<!-- Trend chart + Source breakdown -->
<div class="row g-5 mb-5">
<div class="col-lg-8">
<div class="card h-100" style="position:relative;">
<div class="card-header bg-white border-0 pb-0 pt-4 px-4">
<h6 class="mb-0 fw-semibold">Revenue vs Expenses <small class="text-muted fw-normal">(last 6 months)</small></h6>
</div>
<div class="card-body px-4 pb-4">
<canvas id="trend_chart" height="90"></canvas>
</div>
<div class="card-loader-overlay" id="overlay_trend">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100" style="position:relative;">
<div class="card-header bg-white border-0 pb-0 pt-4 px-4">
<h6 class="mb-0 fw-semibold">Entries by Type</h6>
</div>
<div class="card-body d-flex flex-column gap-3 px-4 pb-4" id="by_source_list">
<div class="text-center text-muted small py-4">Loading...</div>
</div>
<div class="card-loader-overlay" id="overlay_source">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
<!-- Recent journal entries -->
<div class="row">
<div class="col-12">
<div class="card p-5 text-center text-muted">
<i class="ti ti-calculator fs-1 mb-3 d-block"></i>
<p class="mb-0">Accounting dashboard coming soon.</p>
<div class="card overflow-hidden" style="position:relative;">
<div class="card-body p-4 d-flex align-items-center justify-content-between">
<h6 class="mb-0 fw-semibold">Recent Journal Entries</h6>
<a href="<?php echo $server_url?>journal/index.php" class="small">View All</a>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered">
<thead class="table-primary border-light">
<tr>
<th>Date</th>
<th>Period</th>
<th>Type</th>
<th>Reference</th>
<th>Description</th>
<th class="text-end">Debit</th>
<th class="text-end">Credit</th>
</tr>
</thead>
<tbody id="recent_tbody">
<tr><td colspan="7" class="text-center text-muted py-4 small">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="card-loader-overlay" id="overlay_recent">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
@@ -35,5 +179,228 @@
<?php require '../include_ending.php'; ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script>
var trend_chart_instance = null;
var source_labels = {
invoice: 'Invoice',
credit_note: 'Credit Note',
purchase_invoice: 'Purchase Invoice',
supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt',
payment: 'Payment',
manual: 'Manual'
};
var source_badges = {
invoice: 'bg-success-subtle text-success',
credit_note: 'bg-warning-subtle text-warning',
purchase_invoice: 'bg-primary-subtle text-primary',
supplier_credit_note: 'bg-secondary-subtle text-secondary',
receipt: 'bg-info-subtle text-info',
payment: 'bg-danger-subtle text-danger',
manual: 'bg-dark-subtle text-dark'
};
function fmt_num(n) {
return Number(n).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2});
}
function show_overlays() {
$('#overlay_revenue, #overlay_expense, #overlay_net, #overlay_journals, #overlay_lock, #overlay_trend, #overlay_source, #overlay_recent').show();
}
function set_dashboard_loading() {
show_overlays();
['stat_revenue', 'stat_expense', 'stat_net', 'stat_journals'].forEach(function(id) {
document.getElementById(id).textContent = '—';
});
document.getElementById('posting_window_banner').textContent = 'Loading...';
document.getElementById('by_source_list').innerHTML =
'<div class="text-center text-muted small py-4">Loading...</div>';
document.getElementById('recent_tbody').innerHTML =
'<tr><td colspan="7" class="text-center text-muted py-4 small">Loading...</td></tr>';
}
function load_dashboard() {
set_dashboard_loading();
// Revenue, Expenses, Net Profit
ajax_request({
url: server_url + 'ac_dashboard/api/engine/pl.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) {
var d = res.output;
document.getElementById('stat_revenue').textContent = fmt_num(d.revenue);
document.getElementById('stat_expense').textContent = fmt_num(d.expense);
$('#overlay_revenue, #overlay_expense').hide();
var net = d.net_profit;
var net_el = document.getElementById('stat_net');
net_el.textContent = fmt_num(net);
var net_card = net_el.closest('.card');
if (net_card) {
net_card.className = net >= 0
? 'card p-4 bg-success bg-opacity-10 border border-success border-opacity-25 rounded-2'
: 'card p-4 bg-danger bg-opacity-10 border border-danger border-opacity-25 rounded-2';
var icon = net_card.querySelector('.icon-shape');
if (icon) icon.className = net >= 0
? 'icon-shape icon-md bg-success text-white rounded-2'
: 'icon-shape icon-md bg-danger text-white rounded-2';
var sub = net_card.querySelector('p');
if (sub) sub.className = net >= 0 ? 'text-success mb-0 small' : 'text-danger mb-0 small';
}
$('#overlay_net').hide();
}
});
// Journal Entries count
ajax_request({
url: server_url + 'ac_dashboard/api/engine/journals.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) {
document.getElementById('stat_journals').textContent = res.output.journal_count;
$('#overlay_journals').hide();
}
});
// Posting window banner
ajax_request({
url: server_url + 'ac_dashboard/api/engine/posting_window.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) { render_posting_window(res.output); }
});
// Revenue vs Expenses trend chart
ajax_request({
url: server_url + 'ac_dashboard/api/engine/trend.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) { render_trend(res.output.trend); }
});
// Entries by type breakdown
ajax_request({
url: server_url + 'ac_dashboard/api/engine/by_source.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) { render_by_source(res.output.by_source); }
});
// Recent journal entries table
ajax_request({
url: server_url + 'ac_dashboard/api/engine/recent.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) { render_recent(res.output.recent); }
});
}
function render_posting_window(window_data) {
var el = document.getElementById('posting_window_banner');
var from = window_data.open_from || '';
var to = window_data.open_to || '';
if (!from && !to) {
el.innerHTML = '<span class="text-success fw-semibold">No restrictions</span> — all dates are open for posting and inventory movement.';
} else {
var f = from ? '<strong>' + escape_html(from) + '</strong>' : '<span class="text-muted">no lower limit</span>';
var t = to ? '<strong>' + escape_html(to) + '</strong>' : '<span class="text-muted">no upper limit</span>';
el.innerHTML = 'Posting open from ' + f + ' to ' + t;
}
$('#overlay_lock').hide();
}
function render_trend(trend) {
// Build exactly 6 month labels (last 6 months ending today), fill zeros for missing
var labels = [], revenues = [], expenses = [];
var idx = {};
trend.forEach(function(r){ idx[r.period] = r; });
for (var i = 5; i >= 0; i--) {
var d = new Date();
d.setDate(1);
d.setMonth(d.getMonth() - i);
var m = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
labels.push(m);
revenues.push(idx[m] ? parseFloat(idx[m].revenue) || 0 : 0);
expenses.push(idx[m] ? parseFloat(idx[m].expense) || 0 : 0);
}
if (trend_chart_instance) trend_chart_instance.destroy();
var ctx = document.getElementById('trend_chart').getContext('2d');
trend_chart_instance = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{ label: 'Revenue', data: revenues, backgroundColor: 'rgba(2,132,199,0.75)', borderRadius: 4 },
{ label: 'Expenses', data: expenses, backgroundColor: 'rgba(239,68,68,0.65)', borderRadius: 4 },
]
},
options: {
responsive: true,
plugins: { legend: { position: 'top' } },
scales: {
y: { beginAtZero: true, ticks: { callback: function(v){ return fmt_num(v); } } }
}
}
});
$('#overlay_trend').hide();
}
function render_by_source(rows) {
var el = document.getElementById('by_source_list');
if (!rows || rows.length === 0) {
el.innerHTML = '<div class="text-center text-muted small py-4">No entries found.</div>';
$('#overlay_source').hide();
return;
}
var total = rows.reduce(function(s, r){ return s + parseInt(r.cnt); }, 0);
var html = '';
rows.forEach(function(r) {
var pct = total > 0 ? Math.round(r.cnt / total * 100) : 0;
var label = source_labels[r.source_type] || r.source_type;
html += '<div>' +
'<div class="d-flex justify-content-between small mb-1">' +
'<span>' + escape_html(label) + '</span>' +
'<span class="text-muted">' + r.cnt + ' <span class="text-muted">(' + pct + '%)</span></span>' +
'</div>' +
'<div class="progress" style="height:6px;">' +
'<div class="progress-bar" style="width:' + pct + '%;background:#0284c7;"></div>' +
'</div>' +
'</div>';
});
el.innerHTML = html;
$('#overlay_source').hide();
}
function render_recent(rows) {
var tbody = document.getElementById('recent_tbody');
if (!rows || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4 small">No journal entries found.</td></tr>';
$('#overlay_recent').hide();
return;
}
var html = '';
rows.forEach(function(r) {
var badge_cls = source_badges[r.source_type] || 'bg-light text-dark';
var label = source_labels[r.source_type] || r.source_type;
var jdate = r.journal_date || '—';
html += '<tr>' +
'<td class="small">' + escape_html(jdate) + '</td>' +
'<td class="small">' + escape_html(r.period) + '</td>' +
'<td><span class="badge ' + badge_cls + '">' + escape_html(label) + '</span></td>' +
'<td class="small">' + escape_html(r.reference || '—') + '</td>' +
'<td class="small text-muted">' + escape_html(r.description || '—') + '</td>' +
'<td class="small text-end">' + fmt_num(r.total_debit) + '</td>' +
'<td class="small text-end">' + fmt_num(r.total_credit) + '</td>' +
'</tr>';
});
tbody.innerHTML = html;
$('#overlay_recent').hide();
}
load_dashboard();
</script>
</body>
</html>
+655
View File
@@ -0,0 +1,655 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_master_sidebar.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row mb-5">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3">
<div>
<h1 class="fs-3 mb-1">Account Formulas</h1>
<p class="mb-0 text-muted">Posting patterns for document journal entries</p>
</div>
<button class="btn btn-primary" onclick="show_formula_form()">
<i class="ti ti-plus me-1"></i>New Formula
</button>
</div>
</div>
</div>
<ul class="nav nav-tabs mb-4" id="account_formula_tabs">
<li class="nav-item">
<button class="nav-link active" id="tab_formulas" type="button" onclick="show_account_tab('formulas')">Formulas</button>
</li>
<li class="nav-item">
<button class="nav-link" id="tab_product_accounts" type="button" onclick="show_account_tab('product_accounts')">Product Accounts</button>
</li>
</ul>
<div class="row g-5 d-none" id="formula_form">
<div class="col-lg-8">
<div class="card mb-5">
<div class="card-body p-5">
<input type="hidden" id="id">
<h2 class="fs-5 mb-4">Formula Setup</h2>
<div class="row g-4">
<div class="col-lg-6">
<label class="form-label">Formula Name</label>
<input type="text" id="formula_name" class="form-control" placeholder="e.g. Standard Sales Invoice">
</div>
<div class="col-lg-4">
<label class="form-label">Document Type</label>
<select id="document_type" class="form-select">
<option value="sales_invoice">Sales Invoice</option>
<option value="sales_credit_note">Sales Credit Note</option>
<option value="receipt">Receipt</option>
<option value="purchase_invoice">Purchase Invoice</option>
<option value="supplier_credit_note">Supplier Credit Note</option>
<option value="payment">Payment</option>
</select>
</div>
<div class="col-lg-2">
<label class="form-label">Status</label>
<select id="status" class="form-select">
<option value="1">Active</option>
<option value="0">Inactive</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Description</label>
<textarea id="description" class="form-control" rows="2"></textarea>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="is_default" value="1">
<label class="form-check-label" for="is_default">Use as default for this document type</label>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fs-5 mb-0">Formula Items</h2>
<button class="btn btn-sm btn-outline-primary" onclick="add_item_row()">
<i class="ti ti-plus me-1"></i>Add Item
</button>
</div>
<div class="table-responsive">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th style="width:100px;">Side</th>
<th>Account</th>
<th style="width:150px;">Value</th>
<th>Description</th>
<th style="width:40px;"></th>
</tr>
</thead>
<tbody id="item_tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-5">
<div class="card-body p-4">
<h2 class="fs-5 mb-3">Available Values</h2>
<div class="small d-flex flex-column gap-2">
<div><span class="fw-semibold">grand_total</span><div class="text-muted">Final document total</div></div>
<div><span class="fw-semibold">total</span><div class="text-muted">Subtotal less discount plus shipping</div></div>
<div><span class="fw-semibold">tax</span><div class="text-muted">Document tax amount</div></div>
<div><span class="fw-semibold">amount</span><div class="text-muted">Receipt or payment amount</div></div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-4 d-flex flex-column gap-2">
<button class="btn btn-primary w-100" onclick="save_formula()">
<i class="ti ti-device-floppy me-1"></i>Save Formula
</button>
<button class="btn btn-light w-100" onclick="hide_formula_form()">Cancel</button>
</div>
</div>
</div>
</div>
<div class="row" id="formula_list_section">
<div class="col-12">
<div class="card">
<div class="card-body p-5">
<div class="row g-3 mb-4">
<div class="col-lg-8">
<input type="search" id="search_input" class="form-control" placeholder="Search formula or document type...">
</div>
<div class="col-lg-4">
<select id="filter_document_type" class="form-select">
<option value="">All Document Types</option>
<option value="sales_invoice">Sales Invoice</option>
<option value="sales_credit_note">Sales Credit Note</option>
<option value="receipt">Receipt</option>
<option value="purchase_invoice">Purchase Invoice</option>
<option value="supplier_credit_note">Supplier Credit Note</option>
<option value="payment">Payment</option>
</select>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered" id="formula_list">
<thead class="table-primary border-light">
<tr>
<th>Name</th>
<th>Document Type</th>
<th>Items</th>
<th>Default</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="row d-none" id="product_account_section">
<div class="col-12">
<div class="card">
<div class="card-body p-5">
<div class="row g-3 mb-4">
<div class="col-lg-8">
<input type="search" id="product_account_search" class="form-control" placeholder="Search product, SKU, or account...">
</div>
<div class="col-lg-4">
<select id="product_account_filter" class="form-select">
<option value="">All Products</option>
<option value="missing">Missing Mapping</option>
<option value="mapped">Mapped Sales & Purchase</option>
</select>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered" id="product_account_list">
<thead class="table-primary border-light">
<tr>
<th>Product</th>
<th>SKU</th>
<th>Sales Account</th>
<th>Purchase Account</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<div class="modal fade" id="product_account_modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title mb-0">Product Accounts</h5>
<div class="small text-muted" id="product_account_modal_product"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="product_account_product_id">
<input type="hidden" id="product_account_sales_code">
<input type="hidden" id="product_account_purchase_code">
<div class="row g-4">
<div class="col-md-6">
<label class="form-label">Sales Account</label>
<input type="text" class="form-control account_search" id="product_account_sales"
placeholder="Search account..." autocomplete="off">
</div>
<div class="col-md-6">
<label class="form-label">Purchase Account</label>
<input type="text" class="form-control account_search" id="product_account_purchase"
placeholder="Search account..." autocomplete="off">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="save_product_account_mapping()">
<i class="ti ti-device-floppy me-1"></i>Save Accounts
</button>
</div>
</div>
</div>
</div>
<?php require '../include_ending.php'; ?>
<script>
var all_accounts = [];
var all_formulas = [];
var all_products = [];
var item_index = 0;
var doc_labels = {
sales_invoice: 'Sales Invoice',
sales_credit_note: 'Sales Credit Note',
receipt: 'Receipt',
purchase_invoice: 'Purchase Invoice',
supplier_credit_note: 'Supplier Credit Note',
payment: 'Payment',
};
function account_display(account_code) {
var account = all_accounts.find(a => String(a.account_code) === String(account_code));
return account ? `${account.account_code} - ${account.account_name}` : (account_code || '');
}
function account_badge(account_code) {
var label = account_display(account_code);
return label ? escape_html(label) : '<span class="badge bg-warning text-dark">Not mapped</span>';
}
function product_status_badge(status) {
return String(status) === '1' ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>';
}
function product_account_row(product) {
var product_name = product.product_name || product.name || '';
return `<tr>
<td class="py-3 fw-semibold">${escape_html(product_name)}</td>
<td class="py-3">${escape_html(product.sku || '')}</td>
<td class="py-3">${account_badge(product.sales_account_code)}</td>
<td class="py-3">${account_badge(product.purchase_account_code)}</td>
<td class="py-3">${product_status_badge(product.status)}</td>
<td class="py-3">
<a href="javascript:;" onclick="open_product_account_modal(${product.id || 0})"><i class="ti ti-pencil fs-5"></i></a>
</td>
</tr>`;
}
function set_product_account_modal_field(input_selector, hidden_selector, account_code) {
var label = account_display(account_code || '');
$(input_selector)
.val(label)
.attr('data-account-code', account_code || '')
.attr('secondary', label);
$(hidden_selector).val(account_code || '');
}
function open_product_account_modal(product_id) {
var product = all_products.find(function(row) {
return String(row.id) === String(product_id);
});
if (!product) return;
$('#product_account_product_id').val(product.id || '');
$('#product_account_modal_product').text((product.product_name || product.name || '') + ' / ' + (product.sku || ''));
set_product_account_modal_field('#product_account_sales', '#product_account_sales_code', product.sales_account_code || '');
set_product_account_modal_field('#product_account_purchase', '#product_account_purchase_code', product.purchase_account_code || '');
if (window.init_account_search_inputs) window.init_account_search_inputs('#product_account_modal', all_accounts);
$('#product_account_modal').modal('show');
}
function save_product_account_mapping() {
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/product_account_mapping.php',
autoPrepare: true,
checkRequired: 0,
action: 'save',
data: {
id: $('#product_account_product_id').val(),
sales_account_code: $('#product_account_sales_code').val(),
purchase_account_code: $('#product_account_purchase_code').val(),
},
onSuccess: function() {
$('#product_account_modal').modal('hide');
retrieve_products();
}
});
}
function show_account_tab(tab) {
$('#formula_form').addClass('d-none');
$('#formula_list_section').toggleClass('d-none', tab !== 'formulas');
$('#product_account_section').toggleClass('d-none', tab !== 'product_accounts');
$('#tab_formulas').toggleClass('active', tab === 'formulas');
$('#tab_product_accounts').toggleClass('active', tab === 'product_accounts');
if (tab === 'product_accounts') render_product_account_list();
}
function amount_options(selected) {
var keys = ['grand_total', 'total', 'tax', 'amount'];
var opts = '';
keys.forEach(function(key) {
opts += `<option value="${key}" ${selected === key ? 'selected' : ''}>${key}</option>`;
});
return opts;
}
function add_item_row(item) {
item = item || {};
var idx = item_index++;
var row = `<tr id="formula_item_${idx}">
<td>
<select class="form-select form-select-sm item_drcr">
<option value="D" ${item.drcr === 'D' ? 'selected' : ''}>Debit</option>
<option value="C" ${item.drcr === 'C' ? 'selected' : ''}>Credit</option>
</select>
</td>
<td>
<input type="text" class="form-control form-control-sm item_account account_search"
value="${escape_html(account_display(item.account_code || ''))}"
data-account-code-target=".item_account_code"
data-account-code="${escape_html(item.account_code || '')}"
${item.account_code ? `secondary="${escape_html(account_display(item.account_code))}"` : ''}
placeholder="Search account..." autocomplete="off">
<input type="hidden" class="item_account_code" value="${escape_html(item.account_code || '')}">
</td>
<td><select class="form-select form-select-sm item_amount">${amount_options(item.amount_key || 'grand_total')}</select></td>
<td><input type="text" class="form-control form-control-sm item_description" value="${escape_html(item.description || '')}" placeholder="Line memo"></td>
<td><a href="javascript:;" class="link-danger" onclick="remove_item(${idx})"><i class="ti ti-trash fs-5"></i></a></td>
</tr>`;
$('#item_tbody').append(row);
if (window.init_account_search_inputs) window.init_account_search_inputs(`#formula_item_${idx}`, all_accounts);
}
function remove_item(idx) {
$('#formula_item_' + idx).remove();
}
function show_formula_form() {
$('#formula_form').removeClass('d-none');
$('#formula_list_section').addClass('d-none');
$('#product_account_section').addClass('d-none');
$('#tab_formulas').addClass('active');
$('#tab_product_accounts').removeClass('active');
reset_formula_form();
}
function hide_formula_form() {
$('#formula_form').addClass('d-none');
show_account_tab('formulas');
}
function reset_formula_form() {
$('#id, #formula_name, #description').val('');
$('#document_type').val('sales_invoice');
$('#status').val('1');
$('#is_default').prop('checked', false);
$('#item_tbody').empty();
item_index = 0;
add_item_row({ drcr: 'D', amount_key: 'grand_total' });
add_item_row({ drcr: 'C', amount_key: 'grand_total' });
}
function collect_items() {
var items = [];
$('#item_tbody tr').each(function() {
var row = {
drcr: $(this).find('.item_drcr').val(),
account_code: $(this).find('.item_account_code').val() || $(this).find('.item_account').attr('data-account-code') || '',
amount_key: $(this).find('.item_amount').val(),
description: $(this).find('.item_description').val(),
};
if (row.account_code) items.push(row);
});
return items;
}
function retrieve_accounts() {
return ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/account.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
all_accounts = res.output || [];
window.account_search_accounts = all_accounts;
if (window.init_account_search_inputs) window.init_account_search_inputs(document, all_accounts);
}
});
}
function retrieve_formulas() {
return ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/account_formula.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
all_formulas = res.output || [];
render_formula_list();
}
});
}
function retrieve_products() {
return ajax_request({
url: '<?php echo $server_url?>inventory/api/engine/product.php',
autoPrepare: true,
checkRequired: 0,
queueLock: false,
noLoading: true,
action: 'read',
onSuccess: function(res) {
all_products = res.output || [];
render_product_account_list();
}
});
}
function render_formula_list() {
var keyword = $('#search_input').val().toLowerCase();
var type = $('#filter_document_type').val();
var rows = all_formulas.filter(function(f) {
var match_kw = !keyword ||
String(f.formula_name || '').toLowerCase().includes(keyword) ||
String(doc_labels[f.document_type] || f.document_type || '').toLowerCase().includes(keyword);
return match_kw && (!type || f.document_type === type);
});
var body = '';
if (!rows.length) {
$('table#formula_list tbody').html('<tr><td colspan="6" class="text-center py-5 text-muted">No account formulas found.</td></tr>');
$('table#formula_list tfoot').html('');
return;
}
alasql('CREATE TABLE IF NOT EXISTS account_formula_list');
alasql.tables.account_formula_list.data = rows;
$('table#formula_list tfoot').html(generate_pagination('formula_list', rows.length));
$.each(rows.slice(0, prop_limit), function(i, f) {
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(f.formula_name || '')}</td>
<td class="py-3">${escape_html(doc_labels[f.document_type] || f.document_type || '')}</td>
<td class="py-3">${format_number(f.item_count || 0)}</td>
<td class="py-3">${String(f.is_default) === '1' ? '<span class="badge bg-primary">Default</span>' : '<span class="text-muted">—</span>'}</td>
<td class="py-3">${String(f.status) === '1' ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>'}</td>
<td class="py-3">
<a href="javascript:;" onclick="edit_formula(${f.id})"><i class="ti ti-eye fs-5"></i></a>
<a href="javascript:;" class="link-danger ms-2" onclick="delete_formula(${f.id})"><i class="ti ti-trash fs-5"></i></a>
</td>
</tr>`;
});
$('table#formula_list tbody').html(body);
}
function change_page_formula_list(page_num) {
var offset = (page_num - 1) * prop_limit;
var rows = alasql(`SELECT * FROM account_formula_list LIMIT ${prop_limit} OFFSET ${offset}`);
var body = '';
$.each(rows, function(i, f) {
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(f.formula_name || '')}</td>
<td class="py-3">${escape_html(doc_labels[f.document_type] || f.document_type || '')}</td>
<td class="py-3">${format_number(f.item_count || 0)}</td>
<td class="py-3">${String(f.is_default) === '1' ? '<span class="badge bg-primary">Default</span>' : '<span class="text-muted">—</span>'}</td>
<td class="py-3">${String(f.status) === '1' ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>'}</td>
<td class="py-3">
<a href="javascript:;" onclick="edit_formula(${f.id})"><i class="ti ti-eye fs-5"></i></a>
<a href="javascript:;" class="link-danger ms-2" onclick="delete_formula(${f.id})"><i class="ti ti-trash fs-5"></i></a>
</td>
</tr>`;
});
$('table#formula_list tbody').html(body);
}
function render_product_account_list() {
var keyword = $('#product_account_search').val().toLowerCase();
var filter = $('#product_account_filter').val();
var rows = all_products.filter(function(product) {
var sales_account = account_display(product.sales_account_code);
var purchase_account = account_display(product.purchase_account_code);
var has_sales = !!product.sales_account_code;
var has_purchase = !!product.purchase_account_code;
var match_kw = !keyword ||
String(product.product_name || product.name || '').toLowerCase().includes(keyword) ||
String(product.sku || '').toLowerCase().includes(keyword) ||
String(sales_account || '').toLowerCase().includes(keyword) ||
String(purchase_account || '').toLowerCase().includes(keyword);
if (!match_kw) return false;
if (filter === 'missing') return !has_sales || !has_purchase;
if (filter === 'mapped') return has_sales && has_purchase;
return true;
});
if (!rows.length) {
$('table#product_account_list tbody').html('<tr><td colspan="6" class="text-center py-5 text-muted">No product account mappings found.</td></tr>');
$('table#product_account_list tfoot').html('');
return;
}
alasql('CREATE TABLE IF NOT EXISTS product_account_list');
alasql.tables.product_account_list.data = rows;
$('table#product_account_list tfoot').html(generate_pagination('product_account_list', rows.length));
var body = '';
$.each(rows.slice(0, prop_limit), function(i, product) {
body += product_account_row(product);
});
$('table#product_account_list tbody').html(body);
}
function change_page_product_account_list(page_num) {
var offset = (page_num - 1) * prop_limit;
var rows = alasql(`SELECT * FROM product_account_list LIMIT ${prop_limit} OFFSET ${offset}`);
var body = '';
$.each(rows, function(i, product) {
body += product_account_row(product);
});
$('table#product_account_list tbody').html(body);
}
function edit_formula(id) {
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/account_formula.php',
autoPrepare: true,
checkRequired: 0,
action: 'retrieve',
data: { id: id },
onSuccess: function(res) {
var f = res.output || {};
$('#formula_form').removeClass('d-none');
$('#formula_list_section').addClass('d-none');
$('#product_account_section').addClass('d-none');
$('#tab_formulas').addClass('active');
$('#tab_product_accounts').removeClass('active');
$('#id').val(f.id || '');
$('#formula_name').val(f.formula_name || '');
$('#document_type').val(f.document_type || 'sales_invoice');
$('#description').val(f.description || '');
$('#status').val(String(f.status || 1));
$('#is_default').prop('checked', String(f.is_default) === '1');
$('#item_tbody').empty();
item_index = 0;
$.each(f.items || [], function(i, item) { add_item_row(item); });
}
});
}
function save_formula() {
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/account_formula.php',
autoPrepare: true,
checkRequired: 0,
action: 'save',
data: {
id: $('#id').val(),
formula_name: $('#formula_name').val(),
document_type: $('#document_type').val(),
description: $('#description').val(),
is_default: $('#is_default').is(':checked') ? 1 : 0,
status: $('#status').val(),
items: JSON.stringify(collect_items()),
},
onSuccess: function() {
hide_formula_form();
retrieve_formulas();
}
});
}
function delete_formula(id) {
bootbox.confirm('Disable this account formula?', function(ok) {
if (!ok) return;
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/account_formula.php',
autoPrepare: true,
checkRequired: 0,
action: 'delete',
data: { id: id },
onSuccess: retrieve_formulas
});
});
}
$(function() {
retrieve_accounts().then(function() {
retrieve_formulas();
retrieve_products();
});
$('#search_input').on('input', render_formula_list);
$('#filter_document_type').on('change', render_formula_list);
$('#product_account_search').on('input', render_product_account_list);
$('#product_account_filter').on('change', render_product_account_list);
$('#product_account_sales').on('account:selected', function(e, account) {
$('#product_account_sales_code').val(account.account_code || '');
}).on('account:cleared', function() {
$('#product_account_sales_code').val('');
});
$('#product_account_purchase').on('account:selected', function(e, account) {
$('#product_account_purchase_code').val(account.account_code || '');
}).on('account:cleared', function() {
$('#product_account_purchase_code').val('');
});
});
</script>
</body>
</html>
@@ -0,0 +1,61 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require_role($user_role, ['owner', 'admin', 'staff']);
require '../../../assets/utils/classes_ac/AccountFormulaManager.php';
try {
$mgr = new AccountFormulaManager($pdo2, $company_id);
$action = $data['action'] ?? ($_POST['action'] ?? 'read');
if ($action === 'get_by_type') {
$doc_type = trim((string)($data['document_type'] ?? ''));
if (!$doc_type) throw new Exception('document_type is required.');
$answer['output'] = $mgr->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));
?>
@@ -0,0 +1,17 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/OperationLockManager.php';
try {
$lock_manager = new OperationLockManager($pdo2, $company_id, $user_id);
$result = $lock_manager->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));
@@ -0,0 +1,13 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/FinancialReports.php';
$as_of_date = trim((string)($data['as_of_date'] ?? ($data['as_of_period'] ?? date('Y-m-d'))));
$dept_id = (int)($data['department_id'] ?? 0);
$reports = new FinancialReports($pdo2, $company_id);
$answer['success'] = 1;
$answer['output'] = $reports->getBalanceSheet($as_of_date, $dept_id);
exit(json_encode($answer));
@@ -0,0 +1,19 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/GlQueryManager.php';
try {
$gl_query = new GlQueryManager($pdo2, $company_id);
$answer['output'] = $gl_query->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));
@@ -0,0 +1,20 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/FinancialReports.php';
$account_code = trim((string)($data['account_code'] ?? ''));
$from_date = trim((string)($data['from_date'] ?? ($data['from_period'] ?? date('Y-m-d'))));
$to_date = trim((string)($data['to_date'] ?? ($data['to_period'] ?? $from_date)));
$dept_id = (int)($data['department_id'] ?? 0);
if ($account_code === '') {
$answer['message'] = 'account_code is required.';
exit(json_encode($answer));
}
$reports = new FinancialReports($pdo2, $company_id);
$answer['success'] = 1;
$answer['output'] = $reports->getGLMovement($account_code, $from_date, $to_date, $dept_id);
exit(json_encode($answer));
@@ -0,0 +1,14 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/GlQueryManager.php';
try {
$gl_query = new GlQueryManager($pdo2, $company_id);
$answer['output'] = $gl_query->getJournalDetail((int)($data['gl_id'] ?? 0));
$answer['success'] = 1;
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
}
exit(json_encode($answer));
@@ -0,0 +1,18 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/GlQueryManager.php';
try {
$gl_query = new GlQueryManager($pdo2, $company_id);
$answer['output'] = $gl_query->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));
@@ -0,0 +1,14 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/FinancialReports.php';
$from_date = trim((string)($data['from_date'] ?? ($data['from_period'] ?? date('Y-m-d'))));
$to_date = trim((string)($data['to_date'] ?? ($data['to_period'] ?? $from_date)));
$dept_id = (int)($data['department_id'] ?? 0);
$reports = new FinancialReports($pdo2, $company_id);
$answer['success'] = 1;
$answer['output'] = $reports->getProfitAndLoss($from_date, $to_date, $dept_id);
exit(json_encode($answer));
@@ -0,0 +1,14 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/FinancialReports.php';
$from_date = trim((string)($data['from_date'] ?? ($data['from_period'] ?? date('Y-m-d'))));
$to_date = trim((string)($data['to_date'] ?? ($data['to_period'] ?? $from_date)));
$dept_id = (int)($data['department_id'] ?? 0);
$reports = new FinancialReports($pdo2, $company_id);
$answer['success'] = 1;
$answer['output'] = $reports->getTrialBalance($from_date, $to_date, $dept_id);
exit(json_encode($answer));
@@ -0,0 +1,20 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/TaxReportManager.php';
$from_date = trim((string)($data['from_date'] ?? ($data['from_period'] ?? date('Y-m-d'))));
$to_date = trim((string)($data['to_date'] ?? ($data['to_period'] ?? $from_date)));
$dept_id = (int)($data['department_id'] ?? 0);
$categories = $data['categories'] ?? [];
if (is_string($categories)) {
$decoded = json_decode($categories, true);
$categories = is_array($decoded) ? $decoded : [$categories];
}
if (!is_array($categories)) $categories = [];
$tax = new TaxReportManager($pdo2, $company_id);
$answer['success'] = 1;
$answer['output'] = $tax->getVATReport($from_date, $to_date, $categories, $dept_id);
exit(json_encode($answer));
@@ -0,0 +1,15 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/BatchActionManager.php';
try {
$batch_action = new BatchActionManager($pdo2, $company_id, $user_id);
$batch_action->log($data);
$answer['success'] = 1;
$answer['message'] = 'Batch action logged.';
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
}
exit(json_encode($answer));
@@ -1,40 +0,0 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/PeriodManager.php';
$pm = new PeriodManager($pdo2, $company_id);
$action = $data['action_type'] ?? '';
if ($action === 'create_year') {
$year = (int)($data['year'] ?? date('Y'));
if ($year < 2000 || $year > 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));
-9
View File
@@ -1,9 +0,0 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/PeriodManager.php';
$pm = new PeriodManager($pdo2, $company_id);
$answer['output'] = $pm->getAll();
$answer['success'] = 1;
exit(json_encode($answer));
@@ -1,9 +0,0 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/PeriodManager.php';
$pm = new PeriodManager($pdo2, $company_id);
$stats = $pm->getStats();
$answer = array_merge(['success' => 1], $stats);
exit(json_encode($answer));
@@ -0,0 +1,62 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/PostingWindowGuard.php';
require '../../../assets/utils/classes_ac/GlManager.php';
require '../../../assets/utils/classes_ac/posting/BasePosting.php';
require '../../../assets/utils/classes_ac/posting/InvoicePosting.php';
require '../../../assets/utils/classes_ac/posting/CreditNotePosting.php';
require '../../../assets/utils/classes_ac/posting/PurchaseInvoicePosting.php';
require '../../../assets/utils/classes_ac/posting/SupplierCreditNotePosting.php';
require '../../../assets/utils/classes_ac/posting/ReceiptPosting.php';
require '../../../assets/utils/classes_ac/posting/PaymentPosting.php';
require '../../../assets/utils/classes_ac/posting/PurchasePosting.php';
$doc_type = trim((string)($data['doc_type'] ?? ''));
$id = (int)($data['id'] ?? 0);
$formula_id = (int)($data['formula_id'] ?? 0) ?: null;
$posting_map = [
'invoice' => 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));
@@ -0,0 +1,54 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/CompanySettingManager.php';
require_role($user_role, ['owner', 'admin']);
$csm = new CompanySettingManager($pdo1, $company_id);
if ($data['action'] === 'read') {
$open_from = $csm->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));
@@ -0,0 +1,42 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/ProductManager.php';
require '../../../assets/utils/classes_ac/ChartOfAccounts.php';
require_role($user_role, ['owner', 'admin']);
try {
$id = (int)($data['id'] ?? 0);
if ($id <= 0) {
throw new Exception('Product is required.');
}
$sales_account_code = trim((string)($data['sales_account_code'] ?? '')) ?: null;
$purchase_account_code = trim((string)($data['purchase_account_code'] ?? '')) ?: null;
$coa = new ChartOfAccounts($pdo2, $company_id);
foreach ([
'Sales account' => $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));
?>
@@ -0,0 +1,15 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/OperationLockManager.php';
try {
$lock_manager = new OperationLockManager($pdo2, $company_id, $user_id);
$lock_manager->release(trim((string)($data['operation_type'] ?? '')));
$answer['success'] = 1;
$answer['message'] = 'Lock released.';
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
}
exit(json_encode($answer));
@@ -0,0 +1,79 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/PostingWindowGuard.php';
require '../../../assets/utils/classes_ac/GlManager.php';
$gl_id = (int)($data['gl_id'] ?? 0);
$reference = trim((string)($data['reference'] ?? ''));
$description = trim((string)($data['description'] ?? ''));
$journal_date = trim((string)($data['journal_date'] ?? ''));
$lines_raw = $data['lines'] ?? [];
if (is_string($lines_raw)) {
$lines_raw = json_decode($lines_raw, true) ?: [];
}
// Convert d/m/Y → Y-m-d
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $journal_date, $m)) {
$journal_date = "{$m[3]}-{$m[2]}-{$m[1]}";
}
if (!$journal_date || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $journal_date)) {
$answer['message'] = 'Valid journal date is required.';
exit(json_encode($answer));
}
$period = substr($journal_date, 0, 7); // YYYY-MM
// Validate and normalise lines
$lines = [];
$total_debit = 0;
$total_credit = 0;
foreach ($lines_raw as $l) {
$account_code = trim((string)($l['account_code'] ?? ''));
$debit = (float)($l['debit'] ?? 0);
$credit = (float)($l['credit'] ?? 0);
$desc = trim((string)($l['description'] ?? ''));
if ($account_code === '') continue;
if ($debit == 0 && $credit == 0) continue;
$lines[] = ['account_code' => $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));
+142 -7
View File
@@ -11,19 +11,42 @@
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row mb-6">
<div class="row mb-5">
<div class="col-12">
<h1 class="fs-3 mb-1">Balance Sheet</h1>
<p class="mb-0 text-muted">Assets, liabilities, and equity</p>
<p class="mb-0 text-muted">Cumulative asset, liability, and equity positions as of a date</p>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card p-5 text-center text-muted">
<i class="ti ti-table fs-1 mb-3 d-block"></i>
<p class="mb-0">Balance Sheet coming soon.</p>
<!-- Filters -->
<div class="card p-4 mb-4">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label mb-1 small">As Of Date</label>
<input type="text" id="f_as_of" class="form-control form-control-sm" placeholder="DD/MM/YYYY" style="width:160px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">Department</label>
<select id="f_department" class="form-select form-select-sm" style="width:200px;">
<option value="0">All Departments</option>
</select>
</div>
<div class="col-auto">
<button class="btn btn-secondary btn-sm" onclick="load_report()">
<i class="ti ti-search me-1"></i>Run
</button>
</div>
<div class="col ms-auto d-flex align-items-end justify-content-end">
<button class="btn btn-outline-secondary btn-sm" onclick="window.print()">
<i class="ti ti-printer me-1"></i>Print
</button>
</div>
</div>
</div>
<div class="row g-4" id="bs_body">
<div class="col-12">
<div class="card p-5 text-center text-muted">Select a date and click Run.</div>
</div>
</div>
@@ -32,5 +55,117 @@
<?php require '../include_ending.php'; ?>
<script>
var now = new Date();
document.getElementById('f_as_of').value =
now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0');
flatpickr('#f_as_of', { dateFormat: 'Y-m-d', altInput: true, altFormat: 'd/m/Y', allowInput: true });
load_departments();
function load_departments() {
ajax_request({
url: server_url + 'accounting/api/engine/department.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
action: 'read',
onSuccess: function(res) {
var html = '<option value="0">All Departments</option>';
(res.output || []).filter(function(d){ return String(d.status) === '1'; }).forEach(function(d) {
html += '<option value="' + d.id + '">' + escape_html(d.dept_code + ' - ' + d.dept_name) + '</option>';
});
document.getElementById('f_department').innerHTML = html;
}
});
}
function load_report() {
var as_of = document.getElementById('f_as_of').value;
var dept = document.getElementById('f_department').value || '0';
if (!as_of) { alert('Select a date.'); return; }
document.getElementById('bs_body').innerHTML =
'<div class="col-12"><div class="card p-5 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Loading...</div></div>';
ajax_request({
url: server_url + 'accounting/api/engine/get_balance_sheet.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
action: 'read',
as_of_date: as_of,
department_id: dept,
onSuccess: function(res) {
var d = res.output;
var rows = d.rows || [];
var assets = rows.filter(function(r){ return r.account_type === 'asset'; });
var liabilities = rows.filter(function(r){ return r.account_type === 'liability'; });
var equity = rows.filter(function(r){ return r.account_type === 'equity'; });
function sum_net(items, type) {
return items.reduce(function(s, r) {
var net = type === 'asset'
? (parseFloat(r.total_debit) - parseFloat(r.total_credit))
: (parseFloat(r.total_credit) - parseFloat(r.total_debit));
return s + net;
}, 0);
}
function table_rows(items, type) {
if (items.length === 0) return '<tr><td colspan="2" class="text-muted small ps-4">No entries.</td></tr>';
return items.map(function(r) {
var net = type === 'asset'
? (parseFloat(r.total_debit) - parseFloat(r.total_credit))
: (parseFloat(r.total_credit) - parseFloat(r.total_debit));
return '<tr>' +
'<td class="ps-3"><span class="text-muted small me-2">' + escape_html(r.account_code) + '</span>' +
escape_html(r.account_name) + '</td>' +
'<td class="text-end">' + format_number(net) + '</td>' +
'</tr>';
}).join('');
}
var total_assets = sum_net(assets, 'asset');
var total_liab = sum_net(liabilities, 'liability');
var total_equity = sum_net(equity, 'equity') + (parseFloat(d.retained_earnings) || 0);
function section_card(title, items, type, total, extra_rows) {
return '<div class="card">' +
'<div class="card-body p-0">' +
'<table class="table mb-0">' +
'<thead class="table-primary border-light"><tr><th>' + title + '</th><th class="text-end">Amount</th></tr></thead>' +
'<tbody>' +
table_rows(items, type) +
(extra_rows || '') +
'<tr class="fw-semibold border-top">' +
'<td class="text-end py-2">Total ' + title + '</td>' +
'<td class="text-end py-2">' + format_number(total) + '</td>' +
'</tr>' +
'</tbody>' +
'</table>' +
'</div>' +
'</div>';
}
var retained_row = '<tr class="table-light">' +
'<td class="ps-3"><span class="text-muted small me-2">—</span>Retained Earnings (Cumulative P&L)</td>' +
'<td class="text-end">' + format_number(parseFloat(d.retained_earnings) || 0) + '</td>' +
'</tr>';
document.getElementById('bs_body').innerHTML =
'<div class="col-12">' + section_card('Assets', assets, 'asset', total_assets, '') + '</div>' +
'<div class="col-md-6">' + section_card('Liabilities', liabilities, 'liability', total_liab, '') + '</div>' +
'<div class="col-md-6">' + section_card('Equity', equity, 'equity', total_equity, retained_row) + '</div>' +
'<div class="col-12"><div class="card p-3 border-2 fw-bold d-flex flex-row justify-content-between align-items-center">' +
'<span>Total Liabilities + Equity</span>' +
'<span>' + format_number(total_liab + total_equity) + '</span>' +
'</div></div>';
}
});
}
</script>
</body>
</html>
+20 -5
View File
@@ -122,6 +122,7 @@
<th>Code</th>
<th>Name</th>
<th>Type</th>
<th>Category</th>
<th>Level</th>
<th>Status</th>
<th>Actions</th>
@@ -154,6 +155,19 @@
expense: '<span class="badge bg-warning bg-opacity-10 text-warning">Expense</span>',
};
var category_labels = {
ar: 'Account Receivable',
ap: 'Account Payable',
sales_tax: 'Sales Tax',
purchase_tax: 'Purchase Tax',
};
function category_badge(cat) {
if (!cat) return '<span class="text-muted small">—</span>';
var label = category_labels[cat] || cat;
return '<span class="badge bg-secondary bg-opacity-10 text-secondary">' + escape_html(label) + '</span>';
}
var level_badge = {
item: '<span class="badge bg-primary">Item</span>',
parent: '<span class="badge bg-secondary">Parent</span>',
@@ -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(
'<tr><td colspan="7" class="text-center py-5 text-muted">No accounts found.</td></tr>'
'<tr><td colspan="8" class="text-center py-5 text-muted">No accounts found.</td></tr>'
);
return;
}
@@ -204,6 +218,7 @@
<td class="py-3 fw-semibold ${indent}">${prefix}${escape_html(item.account_code)}</td>
<td class="py-3">${escape_html(item.account_name)}</td>
<td class="py-3">${type_badge[item.account_type] || item.account_type}</td>
<td class="py-3">${category_badge(item.account_category)}</td>
<td class="py-3">${is_item ? level_badge.item : level_badge.parent}</td>
<td class="py-3">${item.status ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>'}</td>
<td class="py-3">
+5 -5
View File
@@ -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('<tr><td colspan="5" class="text-center py-5 text-muted">No departments found.</td></tr>');
+521
View File
@@ -0,0 +1,521 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_ac.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row mb-6">
<div class="col-12">
<h1 class="fs-3 mb-1">Batch GL Entries</h1>
<p class="mb-0 text-muted">Create or replace general ledger entries for a date range</p>
</div>
</div>
<div class="row">
<div class="col-12">
<ul class="nav nav-tabs mb-4" id="glTabs">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#tab-invoice">Invoice</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-credit-note">Credit Note</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-purchase-invoice">Purchase Invoice</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-supplier-credit-note">Supplier Credit Note</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-receipt">Receipt</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-payment">Payment</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tab-invoice">
<?php echo gl_tab_html('invoice', 'Invoice'); ?>
</div>
<div class="tab-pane fade" id="tab-credit-note">
<?php echo gl_tab_html('credit_note', 'Credit Note'); ?>
</div>
<div class="tab-pane fade" id="tab-purchase-invoice">
<?php echo gl_tab_html('purchase_invoice', 'Purchase Invoice'); ?>
</div>
<div class="tab-pane fade" id="tab-supplier-credit-note">
<?php echo gl_tab_html('supplier_credit_note', 'Supplier Credit Note'); ?>
</div>
<div class="tab-pane fade" id="tab-receipt">
<?php echo gl_tab_html('receipt', 'Receipt'); ?>
</div>
<div class="tab-pane fade" id="tab-payment">
<?php echo gl_tab_html('payment', 'Payment'); ?>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<!-- Batch processing overlay -->
<div id="batch-overlay" style="display:none; position:fixed; inset:0; z-index:9999;
background:rgba(0,0,0,0.55); align-items:center; justify-content:center;">
<div class="card shadow-lg p-4 text-center" style="min-width:380px; max-width:480px; width:90%;">
<div class="spinner-border text-primary mx-auto mb-3" role="status" style="width:2.5rem; height:2.5rem;"></div>
<div class="fw-semibold fs-6 mb-1" id="batch-overlay-title">Processing…</div>
<div class="text-muted small mb-4" id="batch-overlay-sub"></div>
<!-- Large percentage number -->
<div id="batch-overlay-pct"
style="font-size:3rem; font-weight:700; line-height:1; letter-spacing:-1px; color:#0d6efd;">
0%
</div>
<div class="text-muted small mb-3" id="batch-overlay-count">0 / 0 documents</div>
<!-- Progress bar (pure visual fill, no text inside) -->
<div class="progress mb-2" style="height:10px; border-radius:6px;">
<div id="batch-overlay-bar"
class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width:0%; border-radius:6px;"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<div class="text-muted small" id="batch-overlay-eta">&nbsp;</div>
</div>
</div>
<script>
// (batch_process lives in custom.js)
var TAB_MAP = {
'invoice': 'invoice',
'credit_note': 'credit_note',
'purchase_invoice': 'purchase_invoice',
'supplier_credit_note': 'supplier_credit_note',
'receipt': 'receipt',
'payment': 'payment'
};
// Stores the last loaded IDs per tab so Run doesn't need to re-fetch
var _gl_loaded = {};
// Stores full row data per tab for client-side pagination
var _gl_rows = {};
// Stores failed items from the last batch run per tab (for Retry Failed)
var _gl_failed = {};
// ── init tab once ─────────────────────────────────────────────────────────
var _inited = {};
function init_gl_tab(doc_type) {
if (_inited[doc_type]) return;
_inited[doc_type] = true;
load_formula_options('gl-formula-' + doc_type, doc_type, null);
var today = new Date();
var firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
var fmt = function(d) {
return ('0' + d.getDate()).slice(-2) + '/' +
('0' + (d.getMonth() + 1)).slice(-2) + '/' +
d.getFullYear();
};
flatpickr('#gl-date-from-' + doc_type, {
dateFormat: 'd/m/Y', allowInput: true, defaultDate: firstDay
});
flatpickr('#gl-date-to-' + doc_type, {
dateFormat: 'd/m/Y', allowInput: true, defaultDate: today
});
$('#gl-date-from-' + doc_type).val(fmt(firstDay));
$('#gl-date-to-' + doc_type).val(fmt(today));
}
// ── load documents into table ─────────────────────────────────────────────
function load_gl_tab(doc_type) {
var formula_id = $('#gl-formula-' + doc_type).val();
if (!formula_id) { alert('Please select a GL formula first.'); return; }
var $tbody = $('#gl-tbody-' + doc_type);
var $badge = $('#gl-badge-' + doc_type);
_gl_loaded[doc_type] = [];
set_btn_state('#gl-btn-run-' + doc_type, false);
$badge.text('');
$tbody.html('<tr><td colspan="6" class="text-center text-muted py-4">'
+ '<i class="ti ti-loader ti-spin me-1"></i>Loading…</td></tr>');
var ctx = document.getElementById('session-context');
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/get_gl_documents.php',
action: 'get',
queueLock: false,
noLoading: true,
data: {
otp: ctx ? ctx.dataset.otp : '',
company_id: ctx ? ctx.dataset.companyId : '',
action: 'get',
doc_type: doc_type,
date_from: $('#gl-date-from-' + doc_type).val(),
date_to: $('#gl-date-to-' + doc_type).val(),
formula_id: formula_id
},
onSuccess: function(res) {
var rows = res.output || [];
var table_id = 'gl-table-' + doc_type; // HTML element id (hyphens ok)
var ala_id = 'gl_table_' + doc_type; // alasql identifier (underscores only)
if (rows.length === 0) {
$tbody.html('<tr><td colspan="6" class="text-center text-muted py-4">No documents found.</td></tr>');
$('#' + table_id + ' tfoot').empty();
$badge.text('0 documents');
return;
}
_gl_loaded[doc_type] = rows.map(function(r) { return r.id; });
_gl_rows[doc_type] = rows;
// Register with alasql for client-side pagination
alasql('CREATE TABLE IF NOT EXISTS ' + ala_id);
alasql.tables[ala_id].data = rows;
var unposted = rows.filter(function(r) { return r.gl_status == 0; }).length;
var posted = rows.length - unposted;
var missing_mapping = rows.filter(function(r) {
return Number(r.product_mapping_missing || 0) > 0;
}).length;
$badge.html(
rows.length + ' document(s) &nbsp;·&nbsp; '
+ '<span class="text-danger">' + unposted + ' unposted</span>'
+ ' &nbsp;·&nbsp; '
+ '<span class="text-success">' + posted + ' posted</span>'
+ (missing_mapping > 0
? ' &nbsp;·&nbsp; <span class="text-warning">' + missing_mapping + ' missing product account mapping</span>'
: '')
);
// Wire up dynamic change_page function keyed by HTML table id
window['change_page_' + table_id] = function(page_num) {
var offset = (page_num - 1) * prop_limit;
var page_rows = alasql('SELECT * FROM ' + ala_id + ' LIMIT ' + prop_limit + ' OFFSET ' + offset);
var html = '';
$.each(page_rows, function(i, r) {
var status_badge = r.gl_status == 1
? '<span class="badge bg-success-subtle text-success">Posted</span>'
: '<span class="badge bg-danger-subtle text-danger">Unposted</span>';
var mapping_badge = product_mapping_badge(r);
html +=
'<tr>' +
'<td>' + escape_html(r.doc_number) + '</td>' +
'<td>' + escape_html(r.contact_name || '—') + '</td>' +
'<td class="text-end">' + format_number(r.grand_total, 2) + '</td>' +
'<td>' + escape_html(r.doc_date || '—') + '</td>' +
'<td>' + mapping_badge + '</td>' +
'<td>' + status_badge + '</td>' +
'</tr>';
});
$tbody.html(html);
};
$('#' + table_id + ' tfoot').html(generate_pagination(table_id, rows.length));
window['change_page_' + table_id](1);
set_btn_state('#gl-btn-run-' + doc_type, missing_mapping === 0);
}
});
}
function product_mapping_badge(row) {
if (String(row.product_mapping_checked || '0') !== '1') {
return '<span class="text-muted">—</span>';
}
var missing = Number(row.product_mapping_missing || 0);
if (missing > 0) {
return '<span class="badge bg-warning text-dark">' + missing + ' missing</span>';
}
return '<span class="badge bg-success-subtle text-success">Mapped</span>';
}
// ── batch overlay helpers ─────────────────────────────────────────────────
var _batch_start_ms = 0;
function show_batch_overlay(label, total) {
_batch_start_ms = Date.now();
$('#batch-overlay-title').text('Batch GL Posting — ' + label);
$('#batch-overlay-sub').text('Starting…');
$('#batch-overlay-pct').text('0%');
$('#batch-overlay-bar').css('width', '0%').attr('aria-valuenow', 0);
$('#batch-overlay-count').text('0 / ' + total + ' documents');
$('#batch-overlay-eta').html('&nbsp;');
$('#batch-overlay').css('display', 'flex');
}
function update_batch_overlay(done, total) {
var pct = Math.round(done / total * 100);
var elapsed = (Date.now() - _batch_start_ms) / 1000;
var eta_txt = '&nbsp;';
if (done > 0) {
var remaining = Math.round((elapsed / done) * (total - done));
eta_txt = remaining > 0 ? ('~' + remaining + 's remaining') : 'almost done…';
}
$('#batch-overlay-pct').text(pct + '%');
$('#batch-overlay-bar').css('width', pct + '%').attr('aria-valuenow', pct);
$('#batch-overlay-count').text(done + ' / ' + total + ' documents');
$('#batch-overlay-sub').text('Processing document ' + done + ' of ' + total + '…');
$('#batch-overlay-eta').html(eta_txt);
}
function hide_batch_overlay() {
$('#batch-overlay').hide();
}
// ── run batch over all loaded IDs ─────────────────────────────────────────
var DOC_LABELS = {
invoice: 'Invoice',
credit_note: 'Credit Note',
purchase_invoice: 'Purchase Invoice',
supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt',
payment: 'Payment'
};
function run_gl_batch(doc_type) {
var ids = _gl_loaded[doc_type] || [];
if (!ids.length) return;
var missing_mapping = (_gl_rows[doc_type] || []).filter(function(r) {
return Number(r.product_mapping_missing || 0) > 0;
});
if (missing_mapping.length) {
alert('Product Account Mapping is incomplete for ' + missing_mapping.length + ' loaded document(s). Fill Sales/Purchase accounts on Account Formulas > Product Accounts, then reload this batch.');
return;
}
// Estimate TTL: ids × (throttle delay + avg PHP response) + 20% buffer, min 2 min
var per_item_ms = 200 + 150; // throttle + avg response
var estimated_ms = ids.length * per_item_ms;
var ttl_minutes = Math.max(2, Math.ceil(estimated_ms / 60000 * 1.2));
// Acquire DB lock before starting — blocks other tabs/users
acquire_op_lock('gl_post', function() {
_start_gl_batch(doc_type, ids);
}, function(msg, res) {
var wait_msg = msg || 'A GL posting batch is already running.';
if (res && res.expires_at) {
var remaining_s = Math.max(0, Math.round((new Date(res.expires_at) - new Date()) / 1000));
var remaining_m = Math.ceil(remaining_s / 60);
wait_msg += '\nEstimated wait: ~' + (remaining_s < 60 ? remaining_s + ' seconds' : remaining_m + ' minute(s)') + '.';
}
alert(wait_msg);
}, ttl_minutes);
}
function _start_gl_batch(doc_type, ids) {
var formula_id = $('#gl-formula-' + doc_type).val();
var $prog = $('#gl-progress-' + doc_type);
var $bar = $prog.find('.progress-bar');
var $msg = $('#gl-msg-' + doc_type);
set_btn_state('#gl-btn-load-' + doc_type, false);
set_btn_state('#gl-btn-run-' + doc_type, false);
$msg.addClass('d-none').empty();
$prog.removeClass('d-none');
$bar.css('width', '0%').text('0%');
show_batch_overlay(DOC_LABELS[doc_type] || doc_type, ids.length);
batch_process(
'gl_post',
ids,
'<?php echo $server_url?>accounting/api/engine/post_gl_entry.php',
function(id) {
var ctx = document.getElementById('session-context');
return {
otp: ctx ? ctx.dataset.otp : '',
company_id: ctx ? ctx.dataset.companyId : '',
action: 'post',
doc_type: doc_type,
id: id,
formula_id: formula_id
};
},
function(done, total) {
var pct = Math.round(done / total * 100);
$bar.css('width', pct + '%').attr('aria-valuenow', pct).text(pct + '%');
update_batch_overlay(done, total);
},
function(failed) {
// Always release DB lock — whether succeeded or failed
release_op_lock('gl_post');
hide_batch_overlay();
$prog.addClass('d-none');
var succeeded = ids.length - failed.length;
if (failed.length === 0) {
_gl_failed[doc_type] = [];
$msg.removeClass('d-none alert-danger').addClass('alert alert-success')
.text('Done. ' + succeeded + ' document(s) posted successfully.');
} else {
_gl_failed[doc_type] = failed;
var nums = failed.map(function(f) { return f.item; }).join(', ');
var detail = failed.map(function(f) {
return escape_html(f.item + (f.error ? ': ' + f.error : ''));
}).join('<br>');
$msg.removeClass('d-none alert-success').addClass('alert alert-danger')
.html('<strong>' + failed.length + ' failed</strong> after 3 retries (IDs: '
+ escape_html(nums) + '). ' + succeeded + ' succeeded.'
+ '<div class="small mt-2">' + detail + '</div>'
+ ' <button class="btn btn-sm btn-warning ms-2"'
+ ' onclick="retry_gl_batch(\'' + doc_type + '\')">'
+ '<i class="ti ti-refresh me-1"></i>Retry Failed</button>');
}
// Log the batch action
var ctx = document.getElementById('session-context');
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/log_batch_action.php',
queueLock: false,
noLoading: true,
data: {
otp: ctx ? ctx.dataset.otp : '',
company_id: ctx ? ctx.dataset.companyId : '',
action: 'post',
type: 'gl_post',
doc_type: doc_type,
total: ids.length,
succeeded: succeeded,
failed: failed.length,
failed_ids: JSON.stringify(failed.map(function(f) { return f.item; }))
},
onSuccess: function() {}
});
// Reload table to reflect updated GL status
set_btn_state('#gl-btn-load-' + doc_type, true);
load_gl_tab(doc_type);
}
);
}
// ── retry only failed items ───────────────────────────────────────────────
function retry_gl_batch(doc_type) {
var failed = _gl_failed[doc_type] || [];
if (!failed.length) return;
var ids = failed.map(function(f) { return f.item; });
_gl_failed[doc_type] = [];
var per_item_ms = 200 + 150;
var ttl_minutes = Math.max(2, Math.ceil(ids.length * per_item_ms / 60000 * 1.2));
acquire_op_lock('gl_post', function() {
_start_gl_batch(doc_type, ids);
}, function(msg, res) {
var wait_msg = msg || 'A GL posting batch is already running.';
if (res && res.expires_at) {
var remaining_s = Math.max(0, Math.round((new Date(res.expires_at) - new Date()) / 1000));
var remaining_m = Math.ceil(remaining_s / 60);
wait_msg += '\nEstimated wait: ~' + (remaining_s < 60 ? remaining_s + ' seconds' : remaining_m + ' minute(s)') + '.';
}
alert(wait_msg);
}, ttl_minutes);
}
// ── bootstrap ─────────────────────────────────────────────────────────────
$(function() {
init_gl_tab('invoice');
$('#glTabs a[data-bs-toggle="tab"]').on('shown.bs.tab', function(e) {
var slug = $(e.target).attr('href').replace('#tab-', '').replace(/-/g, '_');
var doc_type = TAB_MAP[slug] || slug;
init_gl_tab(doc_type);
});
});
</script>
</body>
</html>
<?php
function gl_tab_html(string $doc_type, string $label): string {
return <<<HTML
<div class="card p-4">
<!-- Filters + action buttons -->
<div class="row g-2 align-items-end mb-3">
<div class="col-auto">
<label class="form-label mb-1 small">Date From</label>
<input type="text" id="gl-date-from-{$doc_type}" class="form-control form-control-sm"
placeholder="d/m/Y" style="width:140px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">Date To</label>
<input type="text" id="gl-date-to-{$doc_type}" class="form-control form-control-sm"
placeholder="d/m/Y" style="width:140px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">GL Formula <span class="text-danger">*</span></label>
<select id="gl-formula-{$doc_type}" class="form-select form-select-sm" style="width:220px;">
<option value="">— Select formula —</option>
</select>
</div>
<div class="col-auto">
<button id="gl-btn-load-{$doc_type}" class="btn btn-secondary btn-sm"
onclick="load_gl_tab('{$doc_type}')">
<i class="ti ti-search me-1"></i>Load
</button>
</div>
<div class="col-auto">
<button id="gl-btn-run-{$doc_type}" class="btn btn-primary btn-sm" disabled
onclick="run_gl_batch('{$doc_type}')">
<i class="ti ti-send me-1"></i>Run
</button>
</div>
</div>
<!-- Summary badge -->
<div id="gl-badge-{$doc_type}" class="text-muted small mb-2"></div>
<!-- Progress bar -->
<div id="gl-progress-{$doc_type}" class="progress mb-3 d-none" style="height:22px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width:0%" aria-valuenow="0"
aria-valuemin="0" aria-valuemax="100">0 / 0</div>
</div>
<!-- Result message -->
<div id="gl-msg-{$doc_type}" class="d-none mb-3"></div>
<!-- Document listing (informational, no selection) -->
<table id="gl-table-{$doc_type}" class="table table-hover mb-0 table-centered">
<thead class="table-primary border-light">
<tr>
<th>Document No.</th>
<th>Contact</th>
<th class="text-end">Amount</th>
<th>Date</th>
<th>Product Accounts</th>
<th>GL Status</th>
</tr>
</thead>
<tbody id="gl-tbody-{$doc_type}">
<tr><td colspan="6" class="text-center text-muted py-4">Use the filters above and click Load.</td></tr>
</tbody>
<tfoot></tfoot>
</table>
</div>
HTML;
}
?>
+181 -7
View File
@@ -11,19 +11,78 @@
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row mb-6">
<div class="row mb-5">
<div class="col-12">
<h1 class="fs-3 mb-1">GL Movement</h1>
<p class="mb-0 text-muted">General ledger movement report</p>
<p class="mb-0 text-muted">Per-account ledger with opening balance and running total</p>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card p-5 text-center text-muted">
<i class="ti ti-arrows-exchange fs-1 mb-3 d-block"></i>
<p class="mb-0">GL Movement coming soon.</p>
<!-- Filters -->
<div class="card p-4 mb-4">
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label mb-1 small">Account</label>
<select id="f_account" class="form-select form-select-sm">
<option value="">— Select account —</option>
</select>
</div>
<div class="col-auto">
<label class="form-label mb-1 small">From Date</label>
<input type="text" id="f_from" class="form-control form-control-sm" placeholder="DD/MM/YYYY" style="width:160px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">To Date</label>
<input type="text" id="f_to" class="form-control form-control-sm" placeholder="DD/MM/YYYY" style="width:160px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">Department</label>
<select id="f_department" class="form-select form-select-sm" style="width:200px;">
<option value="0">All Departments</option>
</select>
</div>
<div class="col-auto">
<button class="btn btn-secondary btn-sm" onclick="load_report()">
<i class="ti ti-search me-1"></i>Run
</button>
</div>
<div class="col ms-auto d-flex align-items-end justify-content-end">
<button class="btn btn-outline-secondary btn-sm" onclick="window.print()">
<i class="ti ti-printer me-1"></i>Print
</button>
</div>
</div>
</div>
<!-- Report -->
<div id="glm_header_card" class="card p-4 mb-3" style="display:none!important;">
<div class="d-flex gap-5">
<div><p class="text-muted small mb-0">Account</p><strong id="glm_account_title">—</strong></div>
<div><p class="text-muted small mb-0">Date Range</p><strong id="glm_period_title">—</strong></div>
<div class="ms-auto text-end"><p class="text-muted small mb-0">Opening Balance</p><strong id="glm_opening">—</strong></div>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<table class="table table-hover mb-0 table-sm">
<thead class="table-primary border-light">
<tr>
<th>Date</th>
<th>Period</th>
<th>Department</th>
<th>Reference</th>
<th>Description</th>
<th class="text-end">Debit</th>
<th class="text-end">Credit</th>
<th class="text-end">Balance</th>
</tr>
</thead>
<tbody id="glm_tbody">
<tr><td colspan="8" class="text-center text-muted py-5">Select an account and date range, then click Run.</td></tr>
</tbody>
<tfoot id="glm_tfoot"></tfoot>
</table>
</div>
</div>
@@ -32,5 +91,120 @@
<?php require '../include_ending.php'; ?>
<script>
var now = new Date();
var curMonth = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0');
document.getElementById('f_from').value = curMonth + '-01';
document.getElementById('f_to').value = curMonth + '-' + String(new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()).padStart(2,'0');
flatpickr('#f_from, #f_to', { dateFormat: 'Y-m-d', altInput: true, altFormat: 'd/m/Y', allowInput: true });
// Load posting accounts into dropdown
ajax_request({
url: server_url + 'accounting/api/engine/account.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var opts = '<option value="">— Select account —</option>';
(res.output || []).filter(function(a){ return a.is_posting == 1 && a.status == 1; })
.forEach(function(a) {
opts += '<option value="' + escape_html(a.account_code) + '">' +
escape_html(a.account_code + ' — ' + a.account_name) + '</option>';
});
document.getElementById('f_account').innerHTML = opts;
}
});
load_departments();
function load_departments() {
ajax_request({
url: server_url + 'accounting/api/engine/department.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
action: 'read',
onSuccess: function(res) {
var html = '<option value="0">All Departments</option>';
(res.output || []).filter(function(d){ return String(d.status) === '1'; }).forEach(function(d) {
html += '<option value="' + d.id + '">' + escape_html(d.dept_code + ' - ' + d.dept_name) + '</option>';
});
document.getElementById('f_department').innerHTML = html;
}
});
}
function load_report() {
var code = document.getElementById('f_account').value;
var from = document.getElementById('f_from').value;
var to = document.getElementById('f_to').value;
var dept = document.getElementById('f_department').value || '0';
if (!code) { alert('Select an account.'); return; }
if (!from || !to) { alert('Select both dates.'); return; }
document.getElementById('glm_tbody').innerHTML =
'<tr><td colspan="8" class="text-center text-muted py-5"><span class="spinner-border spinner-border-sm me-2"></span>Loading...</td></tr>';
document.getElementById('glm_tfoot').innerHTML = '';
ajax_request({
url: server_url + 'accounting/api/engine/get_gl_movement.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
action: 'read',
account_code: code,
from_date: from,
to_date: to,
department_id: dept,
onSuccess: function(res) {
var d = res.output;
var rows = d.rows || [];
document.getElementById('glm_account_title').textContent = d.account_code + ' — ' + d.account_name;
document.getElementById('glm_period_title').textContent = d.from_date + ' → ' + d.to_date;
document.getElementById('glm_opening').textContent = format_number(d.opening_balance);
document.getElementById('glm_header_card').style.removeProperty('display');
if (rows.length === 0) {
document.getElementById('glm_tbody').innerHTML =
'<tr><td colspan="8" class="text-center text-muted py-5">No movement in this date range.</td></tr>';
document.getElementById('glm_tfoot').innerHTML = '';
return;
}
var running = parseFloat(d.opening_balance) || 0;
var total_dr = 0, total_cr = 0;
var html = '';
rows.forEach(function(r) {
var dr = parseFloat(r.debit) || 0;
var cr = parseFloat(r.credit) || 0;
running += dr - cr;
total_dr += dr;
total_cr += cr;
var bal_color = running >= 0 ? '' : 'text-danger';
html += '<tr>' +
'<td class="small">' + escape_html(r.entry_date || '—') + '</td>' +
'<td class="small">' + escape_html(r.period) + '</td>' +
'<td class="small">' + (r.dept_code ? escape_html(r.dept_code) : '<span class="text-muted">—</span>') + '</td>' +
'<td class="small">' + escape_html(r.reference || '—') + '</td>' +
'<td class="small text-muted">' + escape_html(r.line_description || r.gl_description || '—') + '</td>' +
'<td class="text-end small">' + (dr ? format_number(dr) : '—') + '</td>' +
'<td class="text-end small">' + (cr ? format_number(cr) : '—') + '</td>' +
'<td class="text-end small fw-semibold ' + bal_color + '">' + format_number(running) + '</td>' +
'</tr>';
});
document.getElementById('glm_tbody').innerHTML = html;
document.getElementById('glm_tfoot').innerHTML =
'<tr class="table-light fw-semibold">' +
'<td colspan="5" class="text-end">Closing Balance</td>' +
'<td class="text-end">' + format_number(total_dr) + '</td>' +
'<td class="text-end">' + format_number(total_cr) + '</td>' +
'<td class="text-end">' + format_number(running) + '</td>' +
'</tr>';
}
});
}
</script>
</body>
</html>
+541
View File
@@ -0,0 +1,541 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_ac.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row mb-6">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1">GL Journal Listing</h1>
<p class="mb-0 text-muted">Posted general ledger entries from all document types</p>
</div>
<button class="btn btn-primary" onclick="open_manual_entry()">
<i class="ti ti-plus me-1"></i>New Manual Entry
</button>
</div>
</div>
</div>
<!-- Filters -->
<div class="card p-4 mb-4">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label mb-1 small">Date From</label>
<input type="text" id="f-date-from" class="form-control form-control-sm" placeholder="d/m/Y" style="width:140px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">Date To</label>
<input type="text" id="f-date-to" class="form-control form-control-sm" placeholder="d/m/Y" style="width:140px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">Source Type</label>
<select id="f-source-type" class="form-select form-select-sm" style="width:200px;">
<option value="">All Types</option>
<option value="invoice">Invoice</option>
<option value="credit_note">Credit Note</option>
<option value="purchase_invoice">Purchase Invoice</option>
<option value="supplier_credit_note">Supplier Credit Note</option>
<option value="receipt">Receipt</option>
<option value="payment">Payment</option>
<option value="manual">Manual</option>
</select>
</div>
<div class="col-auto">
<button class="btn btn-secondary btn-sm" onclick="load_journal_listing()">
<i class="ti ti-search me-1"></i>Load
</button>
</div>
<div class="col ms-auto d-flex align-items-end justify-content-end">
<span id="jl-badge" class="text-muted small"></span>
</div>
</div>
</div>
<!-- Table -->
<div class="card">
<div class="card-body p-0">
<table id="jl-table" class="table table-hover mb-0 table-centered">
<thead class="table-primary border-light">
<tr>
<th>Document</th>
<th>Type</th>
<th>Contact</th>
<th>Period</th>
<th>Formula</th>
<th class="text-end">Debit</th>
<th class="text-end">Credit</th>
<th>Posted At</th>
<th></th>
</tr>
</thead>
<tbody id="jl-tbody">
<tr><td colspan="9" class="text-center text-muted py-5">Use the filters above and click Load.</td></tr>
</tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</main>
<!-- Manual entry modal -->
<div class="modal fade" id="me-modal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New Manual Journal Entry</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3 mb-4">
<div class="col-sm-3">
<label class="form-label small">Journal Date <span class="text-danger">*</span></label>
<input type="text" id="me-date" class="form-control form-control-sm" placeholder="d/m/Y">
</div>
<div class="col-sm-3">
<label class="form-label small">Reference</label>
<input type="text" id="me-reference" class="form-control form-control-sm" placeholder="e.g. PCF-001">
</div>
<div class="col-sm-6">
<label class="form-label small">Description</label>
<input type="text" id="me-description" class="form-control form-control-sm" placeholder="e.g. Petty cash reimbursement">
</div>
</div>
<!-- Balance indicator -->
<div class="d-flex align-items-center gap-3 mb-3">
<span class="text-muted small">Debit: <strong id="me-total-debit">0.00</strong></span>
<span class="text-muted small">Credit: <strong id="me-total-credit">0.00</strong></span>
<span id="me-balance-badge"></span>
</div>
<!-- Lines table -->
<table class="table table-sm table-hover table-centered mb-2">
<thead class="table-light">
<tr>
<th style="width:160px;">Account Code</th>
<th>Description</th>
<th style="width:160px;" class="text-end">Debit</th>
<th style="width:160px;" class="text-end">Credit</th>
<th style="width:40px;"></th>
</tr>
</thead>
<tbody id="me-lines">
<!-- rows injected by JS -->
</tbody>
</table>
<button class="btn btn-outline-secondary btn-sm" onclick="me_add_line()">
<i class="ti ti-plus me-1"></i>Add Line
</button>
</div>
<div class="modal-footer">
<div id="me-msg" class="text-danger small me-auto d-none"></div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary btn-sm" id="me-btn-save" onclick="save_manual_entry()">
<i class="ti ti-device-floppy me-1"></i>Save
</button>
</div>
</div>
</div>
</div>
<!-- Detail modal -->
<div class="modal fade" id="jl-modal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="jl-modal-title">Journal Entry</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="jl-modal-body">
<div class="text-center text-muted py-4"><i class="ti ti-loader ti-spin me-1"></i>Loading…</div>
</div>
<div class="modal-footer">
<button type="button" id="jl-btn-edit" class="btn btn-outline-primary btn-sm d-none"
onclick="edit_manual_entry()">
<i class="ti ti-pencil me-1"></i>Edit
</button>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<?php require '../include_ending.php'; ?>
<script>
var _jl_rows = [];
var SOURCE_LABELS = {
invoice: 'Invoice',
credit_note: 'Credit Note',
purchase_invoice: 'Purchase Invoice',
supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt',
payment: 'Payment',
manual: 'Manual'
};
var SOURCE_BADGE = {
invoice: 'bg-success-subtle text-success',
credit_note: 'bg-warning-subtle text-warning',
purchase_invoice: 'bg-primary-subtle text-primary',
supplier_credit_note: 'bg-secondary-subtle text-secondary',
receipt: 'bg-info-subtle text-info',
payment: 'bg-danger-subtle text-danger',
manual: 'bg-dark-subtle text-dark'
};
function source_badge(type) {
var cls = SOURCE_BADGE[type] || 'bg-light text-dark';
return '<span class="badge ' + cls + '">' + (SOURCE_LABELS[type] || type) + '</span>';
}
// ── init ─────────────────────────────────────────────────────────────────
$(function() {
var today = new Date();
var firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
var fmt = function(d) {
return ('0' + d.getDate()).slice(-2) + '/' +
('0' + (d.getMonth() + 1)).slice(-2) + '/' + d.getFullYear();
};
flatpickr('#f-date-from', { dateFormat: 'd/m/Y', allowInput: true, defaultDate: firstDay });
flatpickr('#f-date-to', { dateFormat: 'd/m/Y', allowInput: true, defaultDate: today });
$('#f-date-from').val(fmt(firstDay));
$('#f-date-to').val(fmt(today));
});
// ── load listing ─────────────────────────────────────────────────────────
function load_journal_listing() {
var $tbody = $('#jl-tbody');
$tbody.html('<tr><td colspan="9" class="text-center text-muted py-4">'
+ '<i class="ti ti-loader ti-spin me-1"></i>Loading…</td></tr>');
$('#jl-badge').text('');
$('#jl-table tfoot').empty();
var ctx = document.getElementById('session-context');
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/get_journal_listing.php',
action: 'get',
queueLock: false,
noLoading: true,
data: {
otp: ctx ? ctx.dataset.otp : '',
company_id: ctx ? ctx.dataset.companyId : '',
action: 'get',
date_from: $('#f-date-from').val(),
date_to: $('#f-date-to').val(),
source_type: $('#f-source-type').val()
},
onSuccess: function(res) {
_jl_rows = res.output || [];
if (!_jl_rows.length) {
$tbody.html('<tr><td colspan="9" class="text-center text-muted py-4">No GL entries found.</td></tr>');
$('#jl-badge').text('0 entries');
return;
}
$('#jl-badge').text(_jl_rows.length + ' entr' + (_jl_rows.length === 1 ? 'y' : 'ies'));
alasql('CREATE TABLE IF NOT EXISTS jl_table');
alasql.tables['jl_table'].data = _jl_rows;
window['change_page_jl_table'] = function(page_num) {
var offset = (page_num - 1) * prop_limit;
var page_rows = alasql('SELECT * FROM jl_table LIMIT ' + prop_limit + ' OFFSET ' + offset);
render_jl_rows(page_rows);
};
$('#jl-table tfoot').html(generate_pagination('jl-table', _jl_rows.length));
window['change_page_jl_table'](1);
}
});
}
function render_jl_rows(rows) {
var html = '';
$.each(rows, function(i, r) {
html +=
'<tr>' +
'<td class="fw-semibold">' + escape_html(r.doc_number || '—') + '</td>' +
'<td>' + source_badge(r.source_type) + '</td>' +
'<td>' + escape_html(r.contact_name || '—') + '</td>' +
'<td>' + escape_html(r.period) + '</td>' +
'<td class="text-muted small">' + escape_html(r.formula_name || '—') + '</td>' +
'<td class="text-end">' + format_number(r.total_debit, 2) + '</td>' +
'<td class="text-end">' + format_number(r.total_credit, 2) + '</td>' +
'<td class="text-muted small">' + escape_html(r.posted_at) + '</td>' +
'<td>' +
'<a href="javascript:;" onclick="show_journal_detail(' + r.id + ',\'' + escape_html(r.doc_number || '') + '\')" title="View lines">' +
'<i class="ti ti-eye fs-5"></i></a>' +
'</td>' +
'</tr>';
});
$('#jl-tbody').html(html);
}
// ── manual entry form ─────────────────────────────────────────────────────
var _me_line_idx = 0;
var _me_editing_gl_id = 0; // 0 = new, >0 = editing
function edit_manual_entry() {
if (!_jl_detail) return;
$('#jl-modal').modal('hide');
open_manual_entry(_jl_detail);
}
function open_manual_entry(existing) {
_me_line_idx = 0;
_me_editing_gl_id = existing ? (int_val(existing.header.id)) : 0;
$('#me-lines').empty();
$('#me-msg').addClass('d-none').text('');
$('#me-balance-badge').html('');
var title = _me_editing_gl_id > 0 ? 'Edit Manual Journal Entry' : 'New Manual Journal Entry';
$('#me-modal .modal-title').text(title);
if (existing) {
var h = existing.header;
$('#me-reference').val(h.reference || '');
$('#me-description').val(h.description || '');
flatpickr('#me-date', { dateFormat: 'd/m/Y', allowInput: true, defaultDate: h.journal_date || new Date() });
$('#me-date').val(h.journal_date_fmt || '');
$.each(existing.lines, function(i, l) { me_add_line(l); });
} else {
$('#me-reference').val('');
$('#me-description').val('');
flatpickr('#me-date', { dateFormat: 'd/m/Y', allowInput: true, defaultDate: new Date() });
me_add_line();
me_add_line();
}
me_update_totals();
// Load accounts if not yet fetched
if (!window.account_search_accounts || !window.account_search_accounts.length) {
var ctx = document.getElementById('session-context');
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/account.php',
action: 'get', queueLock: false, noLoading: true,
data: {
otp: ctx ? ctx.dataset.otp : '',
company_id: ctx ? ctx.dataset.companyId : '',
action: 'get'
},
onSuccess: function(res) {
window.account_search_accounts = res.output || [];
init_account_search_inputs('#me-modal', window.account_search_accounts);
}
});
} else {
init_account_search_inputs('#me-modal', window.account_search_accounts);
}
$('#me-modal').modal('show');
}
function int_val(v) { return parseInt(v, 10) || 0; }
function me_add_line(prefill) {
var idx = _me_line_idx++;
var code = prefill ? escape_html(prefill.account_code) : '';
var label = prefill ? escape_html(prefill.account_code + (prefill.account_name ? ' - ' + prefill.account_name : '')) : '';
var desc = prefill ? escape_html(prefill.description) : '';
var debit = prefill ? (parseFloat(prefill.debit) || '') : '';
var credit = prefill ? (parseFloat(prefill.credit) || '') : '';
var row =
'<tr id="me-line-' + idx + '">' +
'<td><input type="text" class="form-control form-control-sm account_search" placeholder="Search account…" autocomplete="off"' +
(prefill ? ' value="' + label + '" data-account-code="' + code + '" secondary="' + label + '"' : '') +
' oninput="me_update_totals()"></td>' +
'<td><input type="text" class="form-control form-control-sm" placeholder="Description" value="' + desc + '"></td>' +
'<td><input type="number" class="form-control form-control-sm text-end me-debit" min="0" step="0.01" placeholder="0.00" value="' + debit + '" oninput="me_update_totals()"></td>' +
'<td><input type="number" class="form-control form-control-sm text-end me-credit" min="0" step="0.01" placeholder="0.00" value="' + credit + '" oninput="me_update_totals()"></td>' +
'<td><a href="javascript:;" onclick="$(\'#me-line-' + idx + '\').remove(); me_update_totals();" class="text-danger"><i class="ti ti-x"></i></a></td>' +
'</tr>';
$('#me-lines').append(row);
var $newRow = $('#me-line-' + idx);
if (window.account_search_accounts && window.account_search_accounts.length) {
init_account_search_inputs($newRow[0], window.account_search_accounts);
}
}
function me_update_totals() {
var debit = 0, credit = 0;
$('#me-lines tr').each(function() {
debit += parseFloat($(this).find('.me-debit').val()) || 0;
credit += parseFloat($(this).find('.me-credit').val()) || 0;
});
$('#me-total-debit').text(format_number(debit, 2));
$('#me-total-credit').text(format_number(credit, 2));
var balanced = Math.abs(debit - credit) < 0.005 && debit > 0;
$('#me-balance-badge').html(
balanced
? '<span class="badge bg-success-subtle text-success">Balanced</span>'
: (debit > 0 || credit > 0
? '<span class="badge bg-danger-subtle text-danger">Unbalanced</span>'
: '')
);
}
function save_manual_entry() {
var lines = [];
$('#me-lines tr').each(function() {
var $row = $(this);
var account_code = $row.find('.account_search').attr('data-account-code') || '';
lines.push({
account_code: account_code.trim(),
description: $row.find('input').eq(1).val().trim(),
debit: parseFloat($row.find('.me-debit').val()) || 0,
credit: parseFloat($row.find('.me-credit').val()) || 0
});
});
set_btn_state('#me-btn-save', false);
$('#me-msg').addClass('d-none').text('');
var ctx = document.getElementById('session-context');
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/save_manual_journal.php',
action: 'post',
queueLock: false,
noLoading: true,
data: {
otp: ctx ? ctx.dataset.otp : '',
company_id: ctx ? ctx.dataset.companyId : '',
action: 'post',
gl_id: _me_editing_gl_id,
journal_date: $('#me-date').val(),
reference: $('#me-reference').val().trim(),
description: $('#me-description').val().trim(),
lines: JSON.stringify(lines)
},
onSuccess: function(res) {
set_btn_state('#me-btn-save', true);
if (res.success) {
$('#me-modal').modal('hide');
load_journal_listing();
} else {
$('#me-msg').removeClass('d-none').text(res.message || 'Failed to save.');
}
}
});
}
// ── detail modal ──────────────────────────────────────────────────────────
var _jl_detail = null; // cache last loaded detail for edit
function show_journal_detail(gl_id, doc_number) {
_jl_detail = null;
$('#jl-btn-edit').addClass('d-none');
$('#jl-modal-title').text('Journal Entry — ' + (doc_number || 'GL #' + gl_id));
$('#jl-modal-body').html('<div class="text-center text-muted py-4"><i class="ti ti-loader ti-spin me-1"></i>Loading…</div>');
$('#jl-modal').modal('show');
var ctx = document.getElementById('session-context');
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/get_journal_detail.php',
action: 'get',
queueLock: false,
noLoading: true,
data: {
otp: ctx ? ctx.dataset.otp : '',
company_id: ctx ? ctx.dataset.companyId : '',
action: 'get',
gl_id: gl_id
},
onSuccess: function(res) {
_jl_detail = res.output;
var h = res.output.header;
var lines = res.output.lines;
if (h.source_type === 'manual') {
$('#jl-btn-edit').removeClass('d-none');
}
var total_debit = 0;
var total_credit = 0;
var lines_html = '';
$.each(lines, function(i, l) {
total_debit += parseFloat(l.debit) || 0;
total_credit += parseFloat(l.credit) || 0;
var dept_label = l.dept_code
? escape_html(l.dept_code + (l.dept_name ? ' - ' + l.dept_name : ''))
: '<span class="text-muted">—</span>';
lines_html +=
'<tr>' +
'<td class="fw-semibold">' + escape_html(l.account_code) + '</td>' +
'<td>' + escape_html(l.account_name) + '</td>' +
'<td>' + dept_label + '</td>' +
'<td class="text-end">' + (parseFloat(l.debit) ? format_number(l.debit, 2) : '<span class="text-muted">—</span>') + '</td>' +
'<td class="text-end">' + (parseFloat(l.credit) ? format_number(l.credit, 2) : '<span class="text-muted">—</span>') + '</td>' +
'<td class="text-muted small">' + escape_html(l.description) + '</td>' +
'</tr>';
});
lines_html +=
'<tr class="table-light fw-semibold">' +
'<td colspan="3">Total</td>' +
'<td class="text-end">' + format_number(total_debit, 2) + '</td>' +
'<td class="text-end">' + format_number(total_credit, 2) + '</td>' +
'<td></td>' +
'</tr>';
var balanced = Math.abs(total_debit - total_credit) < 0.01;
var balance_indicator = balanced
? '<span class="badge bg-success-subtle text-success ms-2">Balanced</span>'
: '<span class="badge bg-danger-subtle text-danger ms-2">Unbalanced</span>';
var extra_fields = h.source_type === 'manual'
? '<div class="col-sm-4"><div class="text-muted small">Reference</div><div class="fw-semibold">' + escape_html(h.reference || ('MJE-' + h.id)) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(h.journal_date_fmt || '—') + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Description</div><div>' + escape_html(h.description || '—') + '</div></div>'
: '<div class="col-sm-4"><div class="text-muted small">Formula</div><div>' + escape_html(h.formula_name) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Version</div><div>v' + h.current_version + (h.current_version > 1 ? ' <span class="text-muted small">(replaced)</span>' : '') + '</div></div>' +
'<div class="col-sm-4"></div>';
var body =
'<div class="row g-3 mb-4 text-sm">' +
'<div class="col-sm-4"><div class="text-muted small">Source</div><div>' + source_badge(h.source_type) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Period</div><div class="fw-semibold">' + escape_html(h.period) + '</div></div>' +
extra_fields +
'<div class="col-sm-4"><div class="text-muted small">Posted At</div><div class="text-muted small">' + escape_html(h.posted_at) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Updated At</div><div class="text-muted small">' + escape_html(h.updated_at_fmt) + '</div></div>' +
'</div>' +
'<div class="d-flex align-items-center mb-2">' +
'<span class="fw-semibold">Journal Lines</span>' + balance_indicator +
'</div>' +
'<table class="table table-sm table-hover mb-0 table-centered">' +
'<thead class="table-light"><tr>' +
'<th>Account Code</th><th>Account Name</th><th>Department</th>' +
'<th class="text-end">Debit</th><th class="text-end">Credit</th><th>Description</th>' +
'</tr></thead>' +
'<tbody>' + lines_html + '</tbody>' +
'</table>';
$('#jl-modal-body').html(body);
}
});
}
</script>
</body>
</html>
+13
View File
@@ -72,6 +72,18 @@
<div class="form-text">Only parent-level accounts of the same type are shown.</div>
</div>
<div class="col-md-6">
<label class="form-label">Account Category</label>
<select class="form-select" id="account_category">
<option value="">— None —</option>
<option value="ar">Account Receivable</option>
<option value="ap">Account Payable</option>
<option value="sales_tax">Sales Tax (Output VAT)</option>
<option value="purchase_tax">Purchase Tax (Input VAT)</option>
</select>
<div class="form-text">Used by reports and modules to filter by sub-type.</div>
</div>
<div class="col-md-6">
<label class="form-label">Status</label>
<select class="form-select" id="status">
@@ -217,6 +229,7 @@
$('#account_code').val(item.account_code);
$('#account_name').val(item.account_name);
$('#account_type').val(item.account_type);
$('#account_category').val(item.account_category || '');
$('#is_posting').val(item.is_posting);
$('#status').val(item.status);
if (item.is_posting == 0) $('#parent_row').hide();
-274
View File
@@ -1,274 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_master_sidebar.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
<div>
<h1 class="fs-3 mb-1">Accounting Periods</h1>
<p class="mb-0">Open, close, and lock monthly accounting periods</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" onclick="show_create_year_modal();">
<i class="ti ti-calendar-plus me-1"></i>Generate Year
</button>
<button class="btn btn-primary" onclick="show_create_modal();">
<i class="ti ti-plus me-1"></i>Add Period
</button>
</div>
</div>
</div>
</div>
<!-- Stats -->
<div class="row g-5 mb-5">
<div class="col-xl-3 col-md-6 col-12">
<div class="card">
<div class="card-body p-6">
<div class="d-flex align-items-start gap-3">
<div class="icon-shape icon-lg bg-primary bg-opacity-10 text-primary rounded-2">
<i class="ti ti-calendar fs-4"></i>
</div>
<div><span>Total</span><h2 class="fw-bold mb-0" id="stat_total">—</h2></div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 col-12">
<div class="card">
<div class="card-body p-6">
<div class="d-flex align-items-start gap-3">
<div class="icon-shape icon-lg bg-success bg-opacity-10 text-success rounded-2">
<i class="ti ti-lock-open fs-4"></i>
</div>
<div><span>Open</span><h2 class="fw-bold mb-0" id="stat_open">—</h2></div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 col-12">
<div class="card">
<div class="card-body p-6">
<div class="d-flex align-items-start gap-3">
<div class="icon-shape icon-lg bg-warning bg-opacity-10 text-warning rounded-2">
<i class="ti ti-lock fs-4"></i>
</div>
<div><span>Closed</span><h2 class="fw-bold mb-0" id="stat_closed">—</h2></div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 col-12">
<div class="card">
<div class="card-body p-6">
<div class="d-flex align-items-start gap-3">
<div class="icon-shape icon-lg bg-danger bg-opacity-10 text-danger rounded-2">
<i class="ti ti-lock-filled fs-4"></i>
</div>
<div><span>Locked</span><h2 class="fw-bold mb-0" id="stat_locked">—</h2></div>
</div>
</div>
</div>
</div>
</div>
<!-- Table -->
<div class="row">
<div class="col-12">
<div class="card overflow-hidden">
<div class="card-body p-6">
<div class="table-responsive">
<table class="table mb-0 text-nowrap table-hover table-centered" id="period_table">
<thead class="table-primary border-light">
<tr>
<th>Period</th>
<th>Status</th>
<th>Closed At</th>
<th>Locked At</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Add Single Period Modal -->
<div class="modal fade" id="createPeriodModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" style="max-width:340px;">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Add Period</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label">Period (YYYY-MM)</label>
<input type="month" class="form-control" id="new_period">
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-primary" onclick="create_single();">Create</button>
</div>
</div>
</div>
</div>
<!-- Generate Year Modal -->
<div class="modal fade" id="createYearModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" style="max-width:340px;">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Generate Year Periods</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label">Year</label>
<input type="number" class="form-control" id="new_year" value="<?php echo date('Y'); ?>" min="2000" max="2100">
<div class="form-text">Creates all 12 months for the selected year (skips existing ones).</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-primary" onclick="create_year();">Generate</button>
</div>
</div>
</div>
</div>
<?php require '../include_ending.php'; ?>
<script>
var status_badge = {
open: '<span class="badge bg-success">Open</span>',
closed: '<span class="badge bg-warning text-dark">Closed</span>',
locked: '<span class="badge bg-danger">Locked</span>',
};
function retrieve_periods() {
return ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/period.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var rows = res.output || [];
if (rows.length === 0) {
$('table#period_table tbody').html('<tr><td colspan="5" class="text-center py-5 text-muted">No periods yet. Use Generate Year to create them.</td></tr>');
return;
}
var body = '';
$.each(rows, function(i, item) {
var actions = '';
if (item.status === 'open') {
actions = `<button class="btn btn-sm btn-outline-warning" onclick="do_action('close', ${item.id})">Close</button>`;
} else if (item.status === 'closed') {
actions = `<button class="btn btn-sm btn-outline-success me-1" onclick="do_action('open', ${item.id})">Reopen</button>
<button class="btn btn-sm btn-outline-danger" onclick="do_action('lock', ${item.id})">Lock</button>`;
} else {
actions = '<span class="text-muted small">Permanent</span>';
}
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(item.period)}</td>
<td class="py-3">${status_badge[item.status] || item.status}</td>
<td class="py-3 text-muted small">${item.closed_at || '—'}</td>
<td class="py-3 text-muted small">${item.locked_at || '—'}</td>
<td class="py-3">${actions}</td>
</tr>`;
});
$('table#period_table tbody').html(body);
}
});
}
function do_action(action_type, id) {
return ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/manage_period.php',
autoPrepare: true,
checkRequired: 0,
action: 'manage',
data: { action_type: action_type, id: id },
onSuccess: function() {
retrieve_periods();
retrieve_stats();
}
});
}
function create_single() {
var period = $('#new_period').val();
if (!period) return;
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/manage_period.php',
autoPrepare: true,
checkRequired: 0,
action: 'manage',
data: { action_type: 'create_single', period: period },
onSuccess: function() {
$('#createPeriodModal').modal('hide');
retrieve_periods();
retrieve_stats();
}
});
}
function create_year() {
var year = $('#new_year').val();
if (!year) return;
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/manage_period.php',
autoPrepare: true,
checkRequired: 0,
action: 'manage',
data: { action_type: 'create_year', year: year },
onSuccess: function() {
$('#createYearModal').modal('hide');
retrieve_periods();
retrieve_stats();
}
});
}
function retrieve_stats() {
return ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/period_stats.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
$('#stat_total').text(res.total || 0);
$('#stat_open').text(res.open_count || 0);
$('#stat_closed').text(res.closed_count || 0);
$('#stat_locked').text(res.locked_count || 0);
}
});
}
function show_create_modal() { $('#new_period').val(''); $('#createPeriodModal').modal('show'); }
function show_create_year_modal() { $('#createYearModal').modal('show'); }
$(async function() {
try {
await retrieve_stats();
await retrieve_periods();
} catch(e) { console.log(e); }
});
</script>
</body>
</html>
+132 -8
View File
@@ -11,26 +11,150 @@
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row mb-6">
<div class="row mb-5">
<div class="col-12">
<h1 class="fs-3 mb-1">P&amp;L Statement</h1>
<p class="mb-0 text-muted">Profit and loss statement</p>
<h1 class="fs-3 mb-1">Profit &amp; Loss Statement</h1>
<p class="mb-0 text-muted">Revenue and expense accounts for a date range</p>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card p-5 text-center text-muted">
<i class="ti ti-chart-line fs-1 mb-3 d-block"></i>
<p class="mb-0">P&amp;L Statement coming soon.</p>
<!-- Filters -->
<div class="card p-4 mb-4">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label mb-1 small">From Date</label>
<input type="text" id="f_from" class="form-control form-control-sm" placeholder="DD/MM/YYYY" style="width:160px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">To Date</label>
<input type="text" id="f_to" class="form-control form-control-sm" placeholder="DD/MM/YYYY" style="width:160px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">Department</label>
<select id="f_department" class="form-select form-select-sm" style="width:200px;">
<option value="0">All Departments</option>
</select>
</div>
<div class="col-auto">
<button class="btn btn-secondary btn-sm" onclick="load_report()">
<i class="ti ti-search me-1"></i>Run
</button>
</div>
<div class="col ms-auto d-flex align-items-end justify-content-end">
<button class="btn btn-outline-secondary btn-sm" onclick="window.print()">
<i class="ti ti-printer me-1"></i>Print
</button>
</div>
</div>
</div>
<!-- Report body -->
<div id="pl_body">
<div class="card p-5 text-center text-muted">Select a date range and click Run.</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script>
var now = new Date();
var curMonth = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0');
document.getElementById('f_from').value = curMonth + '-01';
document.getElementById('f_to').value = curMonth + '-' + String(new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()).padStart(2,'0');
flatpickr('#f_from, #f_to', { dateFormat: 'Y-m-d', altInput: true, altFormat: 'd/m/Y', allowInput: true });
load_departments();
function load_departments() {
ajax_request({
url: server_url + 'accounting/api/engine/department.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
action: 'read',
onSuccess: function(res) {
var html = '<option value="0">All Departments</option>';
(res.output || []).filter(function(d){ return String(d.status) === '1'; }).forEach(function(d) {
html += '<option value="' + d.id + '">' + escape_html(d.dept_code + ' - ' + d.dept_name) + '</option>';
});
document.getElementById('f_department').innerHTML = html;
}
});
}
function load_report() {
var from = document.getElementById('f_from').value;
var to = document.getElementById('f_to').value;
var dept = document.getElementById('f_department').value || '0';
if (!from || !to) { alert('Select both dates.'); return; }
document.getElementById('pl_body').innerHTML =
'<div class="card p-5 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Loading...</div>';
ajax_request({
url: server_url + 'accounting/api/engine/get_pl_statement.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
action: 'read',
from_date: from,
to_date: to,
department_id: dept,
onSuccess: function(res) {
var d = res.output;
var rows = d.rows || [];
var revenues = rows.filter(function(r){ return r.account_type === 'revenue'; });
var expenses = rows.filter(function(r){ return r.account_type === 'expense'; });
function section_rows(items, is_revenue) {
if (items.length === 0) return '<tr><td colspan="3" class="text-muted small ps-4">No entries.</td></tr>';
return items.map(function(r) {
var net = is_revenue ? (parseFloat(r.total_credit) - parseFloat(r.total_debit))
: (parseFloat(r.total_debit) - parseFloat(r.total_credit));
return '<tr>' +
'<td class="ps-4">' + escape_html(r.account_code) + '</td>' +
'<td>' + escape_html(r.account_name) + '</td>' +
'<td class="text-end">' + format_number(net) + '</td>' +
'</tr>';
}).join('');
}
var net_color = d.net_profit >= 0 ? 'text-success' : 'text-danger';
var html =
'<div class="card">' +
'<div class="card-body p-0">' +
'<table class="table mb-0">' +
'<thead class="table-primary border-light"><tr><th>Code</th><th>Account</th><th class="text-end">Amount</th></tr></thead>' +
'<tbody>' +
'<tr class="table-light"><td colspan="3" class="fw-semibold py-2">Revenue</td></tr>' +
section_rows(revenues, true) +
'<tr class="fw-semibold border-top">' +
'<td colspan="2" class="text-end py-2">Total Revenue</td>' +
'<td class="text-end text-success py-2">' + format_number(d.total_revenue) + '</td>' +
'</tr>' +
'<tr class="table-light"><td colspan="3" class="fw-semibold py-2">Expenses</td></tr>' +
section_rows(expenses, false) +
'<tr class="fw-semibold border-top">' +
'<td colspan="2" class="text-end py-2">Total Expenses</td>' +
'<td class="text-end text-danger py-2">' + format_number(d.total_expense) + '</td>' +
'</tr>' +
'<tr class="fw-bold border-top border-2">' +
'<td colspan="2" class="text-end py-3 fs-6">Net Profit / (Loss)</td>' +
'<td class="text-end py-3 fs-6 ' + net_color + '">' + format_number(d.net_profit) + '</td>' +
'</tr>' +
'</tbody>' +
'</table>' +
'</div>' +
'</div>';
document.getElementById('pl_body').innerHTML = html;
}
});
}
</script>
</body>
</html>
+189
View File
@@ -0,0 +1,189 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_master_sidebar.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-6 gap-3">
<div>
<h1 class="fs-3 mb-1">Posting Window</h1>
<p class="mb-0">Define the date range within which GL postings and approved inventory entries can be created, edited, voided, or reversed</p>
</div>
</div>
</div>
</div>
<div class="row g-5">
<!-- Window card -->
<div class="col-lg-6">
<div class="card">
<div class="card-body p-6">
<div class="d-flex align-items-center gap-3 mb-5">
<div class="icon-shape icon-lg bg-primary bg-opacity-10 text-primary rounded-2">
<i class="ti ti-calendar-event fs-4"></i>
</div>
<div>
<h5 class="mb-0 fw-semibold">Open Posting Window</h5>
<p class="mb-0 text-muted small">Dates outside this range are locked for GL and inventory movement</p>
</div>
</div>
<!-- Current status -->
<div id="window_display" class="alert alert-secondary mb-5">Loading...</div>
<div class="alert alert-info d-flex gap-2 mb-5">
<i class="ti ti-info-circle mt-1"></i>
<div>
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.
</div>
</div>
<!-- Form -->
<div class="row g-3 mb-4">
<div class="col-sm-6">
<label class="form-label">Open From <span class="text-danger">*</span></label>
<input type="text" id="open_from" class="form-control">
</div>
<div class="col-sm-6">
<label class="form-label">Open To <span class="text-danger">*</span></label>
<input type="text" id="open_to" class="form-control">
</div>
<div class="col-12">
<div class="form-text">Example: <code>01/01/2026</code> to <code>31/12/2026</code> opens all of 2026.</div>
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" onclick="save_window()">
<i class="ti ti-device-floppy me-1"></i>Save Window
</button>
<button class="btn btn-outline-secondary" onclick="clear_window()">
<i class="ti ti-lock-open me-1"></i>Remove Restrictions
</button>
</div>
</div>
</div>
</div>
<!-- Guide card -->
<div class="col-lg-6">
<div class="card border-0 bg-light h-100">
<div class="card-body p-5">
<h6 class="fw-semibold mb-3"><i class="ti ti-info-circle me-1"></i>How it works</h6>
<ul class="small mb-0 ps-3">
<li class="mb-2">Journal entries, GL postings, and approved inventory movements are only allowed if their date falls <strong>within</strong> the open window.</li>
<li class="mb-2">Dates <strong>before Open From</strong> or <strong>after Open To</strong> are rejected.</li>
<li class="mb-2">Inventory entries that already affected stock balance use the same window when they are voided, cancelled, or reversed.</li>
<li class="mb-2">To open a full year: set <strong>01/01/YYYY</strong> → <strong>31/12/YYYY</strong>.</li>
<li class="mb-2">To roll the window forward (e.g. close January): change Open From to <strong>01/02/YYYY</strong>.</li>
<li class="mb-2"><strong>Remove Restrictions</strong> clears both dates — all dates become postable.</li>
<li>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.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script>
var open_from_picker = null;
var open_to_picker = null;
function init_window_pickers() {
var config = {
dateFormat: 'Y-m-d',
altInput: true,
altFormat: 'd/m/Y',
allowInput: true,
onReady: function(_, __, fp) {
fp.altInput.classList.add('form-control');
}
};
open_from_picker = flatpickr('#open_from', config);
open_to_picker = flatpickr('#open_to', config);
}
function load_window() {
ajax_request({
url: server_url + 'accounting/api/engine/posting_window.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var d = res.output;
if (open_from_picker) open_from_picker.setDate(d.open_from || '', false, 'Y-m-d');
if (open_to_picker) open_to_picker.setDate(d.open_to || '', false, 'Y-m-d');
render_display(d.open_from, d.open_to);
}
});
}
function render_display(from, to) {
var el = document.getElementById('window_display');
if (!from && !to) {
el.className = 'alert alert-success mb-5';
el.innerHTML = '<i class="ti ti-lock-open me-2"></i><strong>No restrictions</strong> — all dates are open for posting.';
} else {
el.className = 'alert alert-primary mb-5';
var f = from ? '<strong>' + escape_html(from) + '</strong>' : '<span class="text-muted">no lower limit</span>';
var t = to ? '<strong>' + escape_html(to) + '</strong>' : '<span class="text-muted">no upper limit</span>';
el.innerHTML = '<i class="ti ti-calendar-event me-2"></i>Posting open from ' + f + ' to ' + t;
}
}
function save_window() {
var from = document.getElementById('open_from').value;
var to = document.getElementById('open_to').value;
ajax_request({
url: server_url + 'accounting/api/engine/posting_window.php',
autoPrepare: true,
checkRequired: 0,
action: 'save',
open_from: from,
open_to: to,
onSuccess: function() {
render_display(from, to);
}
});
}
function clear_window() {
if (open_from_picker) open_from_picker.clear();
if (open_to_picker) open_to_picker.clear();
ajax_request({
url: server_url + 'accounting/api/engine/posting_window.php',
autoPrepare: true,
checkRequired: 0,
action: 'save',
open_from: '',
open_to: '',
onSuccess: function() {
render_display('', '');
}
});
}
init_window_pickers();
load_window();
</script>
</body>
</html>
+140 -7
View File
@@ -11,19 +11,65 @@
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row mb-6">
<div class="row mb-5">
<div class="col-12">
<h1 class="fs-3 mb-1">Trial Balance</h1>
<p class="mb-0 text-muted">Period trial balance report</p>
<p class="mb-0 text-muted">Brought-forward balances, date-range movement, and carry-forward balances for posting accounts</p>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card p-5 text-center text-muted">
<i class="ti ti-scale fs-1 mb-3 d-block"></i>
<p class="mb-0">Trial Balance coming soon.</p>
<!-- Filters -->
<div class="card p-4 mb-4">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label mb-1 small">From Date</label>
<input type="text" id="f_from" class="form-control form-control-sm" placeholder="DD/MM/YYYY" style="width:160px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">To Date</label>
<input type="text" id="f_to" class="form-control form-control-sm" placeholder="DD/MM/YYYY" style="width:160px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">Department</label>
<select id="f_department" class="form-select form-select-sm" style="width:200px;">
<option value="0">All Departments</option>
</select>
</div>
<div class="col-auto">
<button class="btn btn-secondary btn-sm" onclick="load_report()">
<i class="ti ti-search me-1"></i>Run
</button>
</div>
<div class="col ms-auto d-flex align-items-end justify-content-end">
<button class="btn btn-outline-secondary btn-sm" onclick="window.print()">
<i class="ti ti-printer me-1"></i>Print
</button>
</div>
</div>
</div>
<!-- Table -->
<div class="card">
<div class="card-body p-0">
<table class="table table-hover mb-0 table-centered" id="tb_table">
<thead class="table-primary border-light">
<tr>
<th>Code</th>
<th>Account Name</th>
<th>Type</th>
<th class="text-end">B/F Debit</th>
<th class="text-end">B/F Credit</th>
<th class="text-end">Period Debit</th>
<th class="text-end">Period Credit</th>
<th class="text-end">C/F Debit</th>
<th class="text-end">C/F Credit</th>
</tr>
</thead>
<tbody id="tb_tbody">
<tr><td colspan="9" class="text-center text-muted py-5">Select a date range and click Run.</td></tr>
</tbody>
<tfoot id="tb_tfoot"></tfoot>
</table>
</div>
</div>
@@ -32,5 +78,92 @@
<?php require '../include_ending.php'; ?>
<script>
var type_labels = { asset:'Asset', liability:'Liability', equity:'Equity', revenue:'Revenue', expense:'Expense' };
// Default: current month date range
var now = new Date();
var curMonth = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0');
document.getElementById('f_from').value = curMonth + '-01';
document.getElementById('f_to').value = curMonth + '-' + String(new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()).padStart(2,'0');
flatpickr('#f_from, #f_to', { dateFormat: 'Y-m-d', altInput: true, altFormat: 'd/m/Y', allowInput: true });
load_departments();
function load_departments() {
ajax_request({
url: server_url + 'accounting/api/engine/department.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
action: 'read',
onSuccess: function(res) {
var html = '<option value="0">All Departments</option>';
(res.output || []).filter(function(d){ return String(d.status) === '1'; }).forEach(function(d) {
html += '<option value="' + d.id + '">' + escape_html(d.dept_code + ' - ' + d.dept_name) + '</option>';
});
document.getElementById('f_department').innerHTML = html;
}
});
}
function load_report() {
var from = document.getElementById('f_from').value;
var to = document.getElementById('f_to').value;
var dept = document.getElementById('f_department').value || '0';
if (!from || !to) { alert('Select both dates.'); return; }
document.getElementById('tb_tbody').innerHTML =
'<tr><td colspan="9" class="text-center text-muted py-5"><span class="spinner-border spinner-border-sm me-2"></span>Loading...</td></tr>';
document.getElementById('tb_tfoot').innerHTML = '';
ajax_request({
url: server_url + 'accounting/api/engine/get_trial_balance.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
action: 'read',
from_date: from,
to_date: to,
department_id: dept,
onSuccess: function(res) {
var d = res.output;
var rows = d.rows || [];
if (rows.length === 0) {
document.getElementById('tb_tbody').innerHTML =
'<tr><td colspan="9" class="text-center text-muted py-5">No data for this date range.</td></tr>';
document.getElementById('tb_tfoot').innerHTML = '';
return;
}
var html = '';
rows.forEach(function(r) {
html += '<tr>' +
'<td class="fw-semibold">' + escape_html(r.account_code) + '</td>' +
'<td>' + escape_html(r.account_name) + '</td>' +
'<td><span class="badge bg-secondary bg-opacity-10 text-secondary">' + escape_html(type_labels[r.account_type] || r.account_type) + '</span></td>' +
'<td class="text-end">' + format_number(r.opening_debit) + '</td>' +
'<td class="text-end">' + format_number(r.opening_credit) + '</td>' +
'<td class="text-end">' + format_number(r.period_debit) + '</td>' +
'<td class="text-end">' + format_number(r.period_credit) + '</td>' +
'<td class="text-end">' + format_number(r.closing_debit) + '</td>' +
'<td class="text-end">' + format_number(r.closing_credit) + '</td>' +
'</tr>';
});
document.getElementById('tb_tbody').innerHTML = html;
document.getElementById('tb_tfoot').innerHTML =
'<tr class="table-light fw-semibold">' +
'<td colspan="3">Total</td>' +
'<td class="text-end">' + format_number(d.total_opening_debit) + '</td>' +
'<td class="text-end">' + format_number(d.total_opening_credit) + '</td>' +
'<td class="text-end">' + format_number(d.total_period_debit) + '</td>' +
'<td class="text-end">' + format_number(d.total_period_credit) + '</td>' +
'<td class="text-end">' + format_number(d.total_closing_debit) + '</td>' +
'<td class="text-end">' + format_number(d.total_closing_credit) + '</td>' +
'</tr>';
}
});
}
</script>
</body>
</html>
+179 -7
View File
@@ -11,19 +11,101 @@
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row mb-6">
<div class="row mb-5">
<div class="col-12">
<h1 class="fs-3 mb-1">VAT Report</h1>
<p class="mb-0 text-muted">Value added tax report</p>
<p class="mb-0 text-muted">Output VAT (Sales Tax) and Input VAT (Purchase Tax) from posted journal entries</p>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card p-5 text-center text-muted">
<i class="ti ti-receipt-tax fs-1 mb-3 d-block"></i>
<p class="mb-0">VAT Report coming soon.</p>
<!-- Filters -->
<div class="card p-4 mb-4">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label mb-1 small">From Date</label>
<input type="text" id="f_from" class="form-control form-control-sm" placeholder="DD/MM/YYYY" style="width:160px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">To Date</label>
<input type="text" id="f_to" class="form-control form-control-sm" placeholder="DD/MM/YYYY" style="width:160px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">Category</label>
<select id="f_cat" class="form-select form-select-sm" style="width:180px;">
<option value="">All</option>
<option value="sales_tax">Output VAT (Sales)</option>
<option value="purchase_tax">Input VAT (Purchase)</option>
</select>
</div>
<div class="col-auto">
<label class="form-label mb-1 small">Department</label>
<select id="f_department" class="form-select form-select-sm" style="width:200px;">
<option value="0">All Departments</option>
</select>
</div>
<div class="col-auto">
<button class="btn btn-secondary btn-sm" onclick="load_report()">
<i class="ti ti-search me-1"></i>Run
</button>
</div>
<div class="col ms-auto d-flex align-items-end justify-content-end">
<button class="btn btn-outline-secondary btn-sm" onclick="window.print()">
<i class="ti ti-printer me-1"></i>Print
</button>
</div>
</div>
</div>
<!-- Summary cards -->
<div class="row g-3 mb-4" id="vat_summary" style="display:none;">
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<p class="text-muted small mb-1">Total Debit</p>
<h4 class="mb-0 fw-bold" id="vat_debit">—</h4>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<p class="text-muted small mb-1">Total Credit</p>
<h4 class="mb-0 fw-bold" id="vat_credit">—</h4>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<p class="text-muted small mb-1">Net VAT (Credit − Debit)</p>
<h4 class="mb-0 fw-bold" id="vat_net">—</h4>
</div>
</div>
</div>
</div>
<!-- Table -->
<div class="card">
<div class="card-body p-0">
<table class="table table-hover mb-0 table-sm">
<thead class="table-primary border-light">
<tr>
<th>Date</th>
<th>Period</th>
<th>Type</th>
<th>Department</th>
<th>Reference</th>
<th>Account</th>
<th>Category</th>
<th>Description</th>
<th class="text-end">Debit</th>
<th class="text-end">Credit</th>
</tr>
</thead>
<tbody id="vat_tbody">
<tr><td colspan="10" class="text-center text-muted py-5">Select a date range and click Run.</td></tr>
</tbody>
</table>
</div>
</div>
@@ -32,5 +114,95 @@
<?php require '../include_ending.php'; ?>
<script>
var now = new Date();
var curMonth = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0');
document.getElementById('f_from').value = curMonth + '-01';
document.getElementById('f_to').value = curMonth + '-' + String(new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()).padStart(2,'0');
flatpickr('#f_from, #f_to', { dateFormat: 'Y-m-d', altInput: true, altFormat: 'd/m/Y', allowInput: true });
var cat_labels = { sales_tax: 'Output VAT', purchase_tax: 'Input VAT' };
var src_labels = {
invoice: 'Invoice', credit_note: 'Credit Note',
purchase_invoice: 'Purchase Invoice', supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt', payment: 'Payment', manual: 'Manual',
};
load_departments();
function load_departments() {
ajax_request({
url: server_url + 'accounting/api/engine/department.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
action: 'read',
onSuccess: function(res) {
var html = '<option value="0">All Departments</option>';
(res.output || []).filter(function(d){ return String(d.status) === '1'; }).forEach(function(d) {
html += '<option value="' + d.id + '">' + escape_html(d.dept_code + ' - ' + d.dept_name) + '</option>';
});
document.getElementById('f_department').innerHTML = html;
}
});
}
function load_report() {
var from = document.getElementById('f_from').value;
var to = document.getElementById('f_to').value;
var cat = document.getElementById('f_cat').value;
var dept = document.getElementById('f_department').value || '0';
if (!from || !to) { alert('Select both dates.'); return; }
document.getElementById('vat_tbody').innerHTML =
'<tr><td colspan="10" class="text-center text-muted py-5"><span class="spinner-border spinner-border-sm me-2"></span>Loading...</td></tr>';
document.getElementById('vat_summary').style.display = 'none';
ajax_request({
url: server_url + 'accounting/api/engine/get_vat_report.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
action: 'read',
from_date: from,
to_date: to,
categories: cat ? [cat] : [],
department_id: dept,
onSuccess: function(res) {
var d = res.output;
var rows = d.rows || [];
document.getElementById('vat_debit').textContent = format_number(d.total_debit);
document.getElementById('vat_credit').textContent = format_number(d.total_credit);
document.getElementById('vat_net').textContent = format_number(parseFloat(d.total_credit) - parseFloat(d.total_debit));
document.getElementById('vat_summary').style.display = '';
if (rows.length === 0) {
document.getElementById('vat_tbody').innerHTML =
'<tr><td colspan="10" class="text-center text-muted py-5">No VAT entries found.</td></tr>';
return;
}
var html = '';
rows.forEach(function(r) {
html += '<tr>' +
'<td class="small">' + escape_html(r.entry_date || '—') + '</td>' +
'<td class="small">' + escape_html(r.period) + '</td>' +
'<td class="small"><span class="badge bg-secondary bg-opacity-10 text-secondary">' + escape_html(src_labels[r.source_type] || r.source_type) + '</span></td>' +
'<td class="small">' + (r.dept_code ? escape_html(r.dept_code) : '<span class="text-muted">—</span>') + '</td>' +
'<td class="small">' + escape_html(r.reference || '—') + '</td>' +
'<td class="small">' + escape_html(r.account_code + ' ' + r.account_name) + '</td>' +
'<td class="small"><span class="badge bg-info bg-opacity-10 text-info">' + escape_html(cat_labels[r.account_category] || r.account_category) + '</span></td>' +
'<td class="small text-muted">' + escape_html(r.description || '—') + '</td>' +
'<td class="text-end small">' + (parseFloat(r.debit) ? format_number(r.debit) : '—') + '</td>' +
'<td class="text-end small">' + (parseFloat(r.credit) ? format_number(r.credit) : '—') + '</td>' +
'</tr>';
});
document.getElementById('vat_tbody').innerHTML = html;
}
});
}
</script>
</body>
</html>
+2
View File
@@ -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 {
+642 -180
View File
@@ -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 += "<div class='contact-result-item' style='font-family:\"Poppins\",sans-serif;padding:4px 0;'>";
temp += "<div style='font-size:10px;text-transform:uppercase;color:#E66239;font-weight:600;line-height:1.2;'>";
temp += "Tax ID: " + (item.tax_id || 'N/A');
temp += "</div>";
temp += "<div style='font-size:13px;font-weight:500;color:#171717;margin-top:2px;'>";
temp += item.contact_name;
temp += "</div>";
temp += "</div>";
var aca = [];
$.each(res.result, function (i, item) {
var temp = "";
temp += "<div class='contact-result-item' style='font-family:\"Poppins\",sans-serif;padding:4px 0;'>";
temp += "<div style='font-size:10px;text-transform:uppercase;color:#E66239;font-weight:600;line-height:1.2;'>";
temp += "Tax ID: " + (item.tax_id || 'N/A');
temp += "</div>";
temp += "<div style='font-size:13px;font-weight:500;color:#171717;margin-top:2px;'>";
temp += item.contact_name;
temp += "</div>";
temp += "</div>";
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 $("<li>")
.append($("<div>").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 $("<li>")
.append($("<div>").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 += "<div class='account-result-item' style='font-family:\"Poppins\",sans-serif;padding:4px 0;'>";
label += "<div style='font-size:10px;text-transform:uppercase;color:#E66239;font-weight:600;line-height:1.2;'>";
label += type + " · " + escape_html(account.account_code || '');
label += "</div>";
label += "<div style='font-size:13px;font-weight:500;color:#171717;margin-top:2px;'>";
label += escape_html(account.account_name || '');
label += "</div>";
label += "</div>";
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 += "<div class='lot-result-item' style='font-family:\"Poppins\",sans-serif;padding:4px 0;'>";
temp += "<div style='font-size:10px;text-transform:uppercase;color:#E66239;font-weight:600;line-height:1.2;'>";
temp += "Lot: " + item.lot_number;
temp += "</div>";
temp += "<div style='font-size:13px;font-weight:500;color:#171717;margin-top:2px;'>";
temp += "Expiry: " + (item.expiry_date || 'N/A');
temp += "</div>";
temp += "</div>";
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 += "<div class='lot-result-item' style='font-family:\"Poppins\",sans-serif;padding:4px 0;'>";
temp += "<div style='font-size:10px;text-transform:uppercase;color:#E66239;font-weight:600;line-height:1.2;'>";
temp += "Lot: " + item.lot_number;
temp += "</div>";
temp += "<div style='font-size:13px;font-weight:500;color:#171717;margin-top:2px;'>";
temp += "Expiry: " + (item.expiry_date || 'N/A');
temp += "</div>";
temp += "</div>";
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 $("<li>")
@@ -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(`<option value="${value}">${label || value}</option>`);
}
$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('<option value="">— None —</option>');
$.each(res.output || [], function(i, f) {
var label = escape_html(f.formula_name) + (f.is_default == 1 ? ' (default)' : '');
$sel.append('<option value="' + f.id + '">' + label + '</option>');
});
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('<option value="0">— No Department —</option>');
$.each(res.output || [], function(i, d) {
$sel.append('<option value="' + d.id + '">'
+ escape_html(d.dept_code) + ' — ' + escape_html(d.dept_name)
+ '</option>');
});
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('<option value="">All Departments</option>');
$.each(_dept_cache, function(id, label) {
$sel.append('<option value="' + id + '">' + label + '</option>');
});
}
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_
<span class="text-muted">Showing ${records_per_page} per page</span><br>
<strong>Total: ${total_records}</strong>
</td>
<td colspan="${right_span}" class="border-bottom-0">
<td colspan="${right_span}" class="border-bottom-0" style="text-align:right;">
<nav aria-label="Page navigation" class="d-flex justify-content-end">
<ul class="pagination mb-0">`;
@@ -661,7 +900,7 @@ function generate_pagination(table_id, total_records, records_per_page, current_
</li>`;
}
pagination_html += `</ul></nav></td></tr>`;
pagination_html += `</ul></nav></div></td></tr>`;
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('<tr><td colspan="8" class="text-center py-4 text-muted">Loading...</td></tr>');
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('<tr><td colspan="8" class="text-center py-4 text-muted">No stock transactions found.</td></tr>');
return;
}
var html = '';
$.each(rows, function(i, r) {
var type_badge = r.type === 'in'
? '<span class="badge bg-success">In</span>'
: '<span class="badge bg-danger">Out</span>';
var status_badge = {'-1':'<span class="badge bg-secondary">Cancelled</span>',
'0':'<span class="badge bg-warning text-dark">Draft</span>',
'1':'<span class="badge bg-success">Approved</span>'}[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 += `<tr>
<td class="ps-4">${type_badge}</td>
<td>${escape_html(r.product_name || r.product_sku)}<br><small class="text-muted">${escape_html(r.product_sku)}</small></td>
<td>${escape_html(r.warehouse_name)}</td>
<td><small>${escape_html(location)}</small></td>
<td><small>${escape_html(r.lot_number || '—')}</small></td>
<td class="text-end fw-semibold">${format_number(r.quantity, 2)}</td>
<td>${status_badge}</td>
<td><small>${format_date(r.date)}</small></td>
</tr>`;
});
$('#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);
}
@@ -0,0 +1,56 @@
<?php
class BatchActionManager
{
private PDO $pdo;
private int $companyId;
private int $userId;
public function __construct(PDO $pdo, int $company_id, int $user_id)
{
$this->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 : []);
}
}
?>
@@ -0,0 +1,152 @@
<?php
class CompanyProfileManager
{
private const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
private const ALLOWED_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
private const MAX_SIZE = 2 * 1024 * 1024;
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->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,
]);
}
}
?>
@@ -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)
+11 -1
View File
@@ -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,
]);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
<?php
class OperationLockManager
{
private PDO $pdo;
private int $companyId;
private int $userId;
public function __construct(PDO $pdo, int $company_id, int $user_id)
{
$this->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]);
}
}
?>
+435 -166
View File
@@ -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,
]);
}
}
@@ -0,0 +1,395 @@
<?php
class PaymentBillingManager {
private PDO $pdo;
private int $company_id;
public function __construct(PDO $pdo, int $company_id) {
$this->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,
]);
}
}
?>
+517
View File
@@ -0,0 +1,517 @@
<?php
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
require_once __DIR__ . '/../classes_ac/GlManager.php';
class PaymentManager {
private PDO $pdo;
private int $company_id;
public function __construct(PDO $pdo, int $company_id) {
$this->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,
]);
}
}
?>
+54 -2
View File
@@ -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.
*
+355 -105
View File
@@ -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]);
}
}
}
@@ -0,0 +1,350 @@
<?php
/**
* PurchaseRequestManager
*
* All read/write operations for td_purchase_request and td_purchase_request_item.
*
* Status flow: 0=draft → 1=submitted → 2=approved → 5=converted
* 1/2/3 ← reopen 3=rejected
* 0/1 → -1=cancelled
*
* Tax: tax_amount is stored per line at full precision (decimal 18,4).
* document.tax = ROUND(SUM(items.tax_amount), 2) — never passed from caller.
*
* Write methods do NOT manage their own DB transactions.
* Callers must wrap multi-step operations inside dbTransaction().
*/
class PurchaseRequestManager
{
private PDO $pdo;
private int $company_id;
public function __construct(PDO $pdo, int $company_id)
{
$this->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]);
}
}
}
@@ -0,0 +1,341 @@
<?php
/**
* QuotationManager
*
* All read/write operations for td_quotation and td_quotation_item.
*
* Status flow: 0=draft → 1=sent → 2=accepted → 5=converted
* 1/2/3 ← reopen 3=rejected
* 0/1 → -1=cancelled
*
* Tax: tax_amount is stored per line at full precision (decimal 18,4).
* document.tax = ROUND(SUM(items.tax_amount), 2) — never passed from caller.
*
* Write methods do NOT manage their own DB transactions.
* Callers must wrap multi-step operations inside dbTransaction().
*/
class QuotationManager
{
private PDO $pdo;
private int $company_id;
public function __construct(PDO $pdo, int $company_id)
{
$this->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]);
}
}
}
@@ -0,0 +1,393 @@
<?php
class ReceiptBillingManager {
private PDO $pdo;
private int $company_id;
public function __construct(PDO $pdo, int $company_id) {
$this->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,
]);
}
}
?>
+515
View File
@@ -0,0 +1,515 @@
<?php
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
require_once __DIR__ . '/../classes_ac/GlManager.php';
class ReceiptManager {
private PDO $pdo;
private int $company_id;
public function __construct(PDO $pdo, int $company_id) {
$this->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,
]);
}
}
?>
+85 -1
View File
@@ -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
+255 -94
View File
@@ -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
}
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
require_once __DIR__ . '/../module/mailer.php';
class SmtpManager
{
private PDO $pdo;
private int $companyId;
private string $method;
private string $pinkey;
private string $iv;
public function __construct(PDO $pdo, int $company_id, string $method, string $pinkey, string $iv)
{
$this->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);
}
}
?>
+29 -5
View File
@@ -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));
}
}
}
@@ -0,0 +1,86 @@
<?php
class StockSourceManager
{
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->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);
}
}
?>
@@ -0,0 +1,619 @@
<?php
/**
* SupplierReturnManager
*
* Handles all read and write operations for td_supplier_return.
*
* Method order:
* Transaction basis → getReturnList, getReturnById, generateReturnNumber,
* saveReturn, confirmReturn, cancelReturn
*
* Key design decisions:
* - status: -1=cancelled, 0=draft, 1=confirmed (consistent with td_return)
* - confirmReturn() does stock-OUT (goods leave warehouse back to supplier).
* Mirrors ReturnManager.confirmReturn() which does stock-IN.
* - stock_in_id on each item references the original td_stock_* row created
* by PurchaseOrderManager.receivePo(). This gives us the lot/serial context.
* - Stock-out rows are inserted with status=1 (force-approved) — confirming a
* supplier return is a final decision, no warehouse re-approval needed.
* - cancelReturn() is blocked if an active debit note exists
* (source='supplier_return', source_id=return_id, status!=4).
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* Dynamic stock table names are derived only from md_warehouse.id.
*/
class SupplierReturnManager {
private PDO $pdo;
private int $company_id;
public function __construct(PDO $pdo, int $company_id) {
$this->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_<warehouse_id> 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,
]);
}
}
+49
View File
@@ -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
// ─────────────────────────────────────────────────────────────
+132 -22
View File
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* WarehouseManager
@@ -42,6 +43,18 @@ class WarehouseManager {
return 'td_stock_' . $warehouse_id;
}
public function assertStockMovementWindow(?string $date, string $context = 'Stock movement'): 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 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);
@@ -0,0 +1,243 @@
<?php
class AccountFormulaManager
{
private PDO $pdo;
private int $companyId;
private array $documentTypes = [
'invoice',
'credit_note',
'receipt',
'purchase_invoice',
'supplier_credit_note',
'payment',
];
private array $amountKeys = ['grand_total', 'total', 'tax', 'amount'];
public function __construct(PDO $pdo, int $company_id)
{
$this->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'],
]);
}
}
}
?>
+49 -35
View File
@@ -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);
}
}
@@ -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) ?: [];
@@ -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),
];
}
}
+295
View File
@@ -0,0 +1,295 @@
<?php
/**
* GlManager
*
* Owns all read/write operations on td_gl and td_gl_item.
* Called by PostingEngine after building the journal lines.
*
* Lifecycle:
* post() — first-time GL creation; throws if a record already exists.
* replace() — snapshot current lines into td_gl.history, then delete + re-insert.
* delete() — hard-delete both td_gl and all its td_gl_item rows (used on void).
*/
class GlManager
{
private PDO $pdo;
private int $companyId;
private ?PostingWindowGuard $postingWindowGuard;
public function __construct(PDO $pdo, int $company_id, ?PostingWindowGuard $postingWindowGuard = null)
{
$this->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'] ?? ''),
]);
}
}
}
@@ -0,0 +1,378 @@
<?php
class GlQueryManager
{
private PDO $pdo;
private int $companyId;
private array $invoiceTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note'];
private array $mappingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'purchase_order'];
private array $postingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'purchase_order'];
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order'];
public function __construct(PDO $pdo, int $company_id)
{
$this->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;
}
}
?>
@@ -1,25 +0,0 @@
<?php
/**
* JournalManager
*
* Create, read, and void journal entries in ac_journal + ac_journal_line.
* Updates ac_balance after each post.
* Never called directly by WMS — goes through PostingManager.
*/
class JournalManager
{
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
// TODO: create(array $header, array $lines): int — returns journal_id
// TODO: void(int $journal_id): void
// TODO: getBySource(string $source_type, int $source_id): ?array
// TODO: getById(int $journal_id): ?array
// TODO: updateBalance(string $period, string $account_code, ?int $dept_id, float $debit, float $credit): void
}
@@ -1,120 +0,0 @@
<?php
class PeriodManager
{
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
public function getAll(): array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_period
WHERE company_id = :cid
ORDER BY period DESC"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getById(int $id): ?array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_period WHERE company_id = :cid AND id = :id LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function isOpen(string $period): bool
{
$sth = $this->pdo->prepare(
"SELECT status FROM ac_period
WHERE company_id = :cid AND period = :period LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':period' => $period]);
$status = $sth->fetchColumn();
return $status === 'open';
}
// Get existing period or auto-create it as open
public function getOrCreate(string $period): array
{
$sth = $this->pdo->prepare(
"SELECT * FROM ac_period WHERE company_id = :cid AND period = :period LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':period' => $period]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if ($row) return $row;
$this->pdo->prepare(
"INSERT INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')"
)->execute([':cid' => $this->companyId, ':period' => $period]);
return $this->getOrCreate($period);
}
// Create a single period (manual)
public function create(string $period): void
{
$this->pdo->prepare(
"INSERT IGNORE INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')"
)->execute([':cid' => $this->companyId, ':period' => $period]);
}
// Bulk-create all 12 months of a year
public function createYear(int $year): void
{
$sth = $this->pdo->prepare(
"INSERT IGNORE INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')"
);
for ($m = 1; $m <= 12; $m++) {
$sth->execute([
':cid' => $this->companyId,
':period' => sprintf('%04d-%02d', $year, $m),
]);
}
}
public function open(int $id): void
{
$this->pdo->prepare(
"UPDATE ac_period SET status = 'open', closed_at = NULL
WHERE company_id = :cid AND id = :id AND status = 'closed'"
)->execute([':cid' => $this->companyId, ':id' => $id]);
}
public function close(int $id): void
{
$this->pdo->prepare(
"UPDATE ac_period SET status = 'closed', closed_at = NOW()
WHERE company_id = :cid AND id = :id AND status = 'open'"
)->execute([':cid' => $this->companyId, ':id' => $id]);
}
public function lock(int $id): void
{
$this->pdo->prepare(
"UPDATE ac_period SET status = 'locked', locked_at = NOW()
WHERE company_id = :cid AND id = :id AND status = 'closed'"
)->execute([':cid' => $this->companyId, ':id' => $id]);
}
public function getStats(): array
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) AS total,
SUM(status = 'open') AS open_count,
SUM(status = 'closed') AS closed_count,
SUM(status = 'locked') AS locked_count
FROM ac_period WHERE company_id = :cid"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
}
}
@@ -0,0 +1,73 @@
<?php
require_once __DIR__ . '/../classes/CompanySettingManager.php';
class PostingWindowGuard
{
private CompanySettingManager $settings;
private ?array $window = null;
public function __construct(PDO $settingsPdo, int $company_id)
{
$this->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;
}
}
@@ -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);
}
}
@@ -0,0 +1,183 @@
<?php
/**
* BasePosting
*
* Shared build() logic for all six posting types.
* Subclasses implement fetchDocument(), getAmounts(), getDocType(), getDocDate().
*
* build() returns:
* ['formula_id' => 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;
}
}
@@ -0,0 +1,43 @@
<?php
require_once __DIR__ . '/BasePosting.php';
class CreditNotePosting extends BasePosting
{
protected function getDocType(): string { return 'credit_note'; }
protected function getSkuTotalAccountColumn(): string { return 'sales_account_code'; }
protected function fetchDocument(int $id): array
{
$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 = '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');
}
}
@@ -1,21 +1,42 @@
<?php
/**
* InvoicePosting
*
* Builds the journal entry for a posted sales invoice.
* DR: Accounts Receivable
* CR: Revenue + Output VAT
*/
class InvoicePosting
{
private PDO $pdo;
private int $companyId;
require_once __DIR__ . '/BasePosting.php';
public function __construct(PDO $pdo, int $company_id)
class InvoicePosting extends BasePosting
{
protected function getDocType(): string { return 'invoice'; }
protected function getSkuTotalAccountColumn(): string { return 'sales_account_code'; }
protected function fetchDocument(int $id): array
{
$this->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');
}
}
@@ -1,21 +1,109 @@
<?php
/**
* PaymentPosting
*
* Builds the journal entry when a supplier payment is recorded.
* DR: Accounts Payable
* CR: Bank / Cash
*/
class PaymentPosting
{
private PDO $pdo;
private int $companyId;
require_once __DIR__ . '/BasePosting.php';
public function __construct(PDO $pdo, int $company_id)
class PaymentPosting extends BasePosting
{
protected function getDocType(): string { return 'payment'; }
public function build(int $id, ?int $formula_id = null): array
{
$this->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;
}
}
@@ -0,0 +1,42 @@
<?php
require_once __DIR__ . '/BasePosting.php';
class PurchaseInvoicePosting extends BasePosting
{
protected function getDocType(): string { return 'purchase_invoice'; }
protected function getSkuTotalAccountColumn(): string { return 'purchase_account_code'; }
protected function fetchDocument(int $id): array
{
$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 = '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');
}
}
@@ -1,21 +1,162 @@
<?php
require_once __DIR__ . '/BasePosting.php';
/**
* PurchasePosting
*
* Builds the journal entry when a Purchase Order is received (GRN).
* DR: Inventory
* CR: Accounts Payable
* Builds the GL journal entry for a Purchase Order (GRN).
* Typical formula: DR Inventory (per SKU) + DR Input VAT / CR Accounts Payable.
*
* Amount keys:
* grand_total — full PO total (used for AP credit line)
* tax — VAT portion
* total — pre-tax (grand_total - tax), split per SKU via purchase_account_code
* amount — alias of grand_total
*/
class PurchasePosting
class PurchasePosting extends BasePosting
{
private PDO $pdo;
private int $companyId;
protected function getDocType(): string { return 'purchase_order'; }
public function __construct(PDO $pdo, int $company_id)
protected function fetchDocument(int $id): array
{
$this->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;
}
}
@@ -1,21 +1,109 @@
<?php
/**
* ReceiptPosting
*
* Builds the journal entry when a customer receipt is recorded.
* DR: Bank / Cash
* CR: Accounts Receivable
*/
class ReceiptPosting
{
private PDO $pdo;
private int $companyId;
require_once __DIR__ . '/BasePosting.php';
public function __construct(PDO $pdo, int $company_id)
class ReceiptPosting extends BasePosting
{
protected function getDocType(): string { return 'receipt'; }
public function build(int $id, ?int $formula_id = null): array
{
$this->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;
}
}
@@ -0,0 +1,42 @@
<?php
require_once __DIR__ . '/BasePosting.php';
class SupplierCreditNotePosting extends BasePosting
{
protected function getDocType(): string { return 'supplier_credit_note'; }
protected function getSkuTotalAccountColumn(): string { return 'purchase_account_code'; }
protected function fetchDocument(int $id): array
{
$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 = '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');
}
}
+3 -5
View File
@@ -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));
?>
?>
@@ -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));
?>
?>
+1 -1
View File
@@ -86,7 +86,7 @@
<div>
<h2 class="mb-3 fs-6">Total Revenue</h2>
<h3 class="fw-bold mb-0" id="stat_total_revenue">—</h3>
<p class="text-info mb-0 small">Grand total to date</p>
<p class="text-info mb-0 small">Sub-total to date (excl. VAT)</p>
</div>
</div>
<div class="card-loader-overlay" id="overlay_revenue">
@@ -0,0 +1,140 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/PurchaseOrderManager.php';
require '../../../assets/utils/classes/PurchaseRequestManager.php';
require_role($user_role, ['owner', 'admin', 'staff']);
$request_id = (int)($data['request_id'] ?? 0);
$contact_id = (int)($data['contact_id'] ?? 0);
$convert_items = $data['convert_items'] ?? [];
$discount = (float)($data['discount'] ?? 0);
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
if (!$request_id) {
exit(json_encode(['success' => 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));
@@ -0,0 +1,34 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/PurchaseRequestManager.php';
require_role($user_role, ['owner', 'admin', 'staff']);
$action = $data['action'] ?? '';
$id = (int)($data['id'] ?? 0);
$items = json_decode($data['items'] ?? '[]', true) ?: [];
$prm = new PurchaseRequestManager($pdo2, $company_id);
try {
if ($action === 'create') {
$new_id = $prm->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));
@@ -0,0 +1,29 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require_role($user_role, ['owner', 'admin', 'staff']);
require '../../../assets/utils/classes/InvoiceManager.php';
try {
$new_id = null;
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, &$new_id) {
$inv = new InvoiceManager($pdo, $company_id);
$new_id = $inv->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));
?>
@@ -0,0 +1,21 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/PurchaseRequestManager.php';
$id = (int)($data['id'] ?? 0);
$prm = new PurchaseRequestManager($pdo2, $company_id);
if ($id > 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));
@@ -0,0 +1,28 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/PurchaseRequestManager.php';
require_role($user_role, ['owner', 'admin', 'staff']);
$id = (int)($data['id'] ?? 0);
$action = $data['action_type'] ?? '';
if (!$id || !$action) {
exit(json_encode(['success' => 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));
+356
View File
@@ -0,0 +1,356 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
$invoice_id = (int)($_GET['id'] ?? 0);
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_ac.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="mb-6 d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1" id="page_title">Purchase Invoice</h1>
<p class="mb-0 text-muted" id="page_subtitle"></p>
</div>
<a href="<?php echo $server_url?>expense/purchase_invoice.php" class="btn btn-light">
<i class="ti ti-arrow-left me-1"></i>Back
</a>
</div>
</div>
</div>
<div class="row g-5">
<!-- Left: Items -->
<div class="col-lg-8">
<div class="card mb-5">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Items</h2>
<div class="table-responsive">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th>Product</th>
<th class="text-end">Qty</th>
<th class="text-end">Unit Price</th>
<th class="text-end" style="width:60px;">Tax%</th>
<th class="text-end">Tax Amt</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody id="item_tbody"></tbody>
<tfoot id="item_tfoot"></tfoot>
</table>
</div>
</div>
</div>
<!-- Linked supplier credit notes (for purchase invoices) -->
<div class="card d-none" id="dn_card">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Supplier Credit Notes</h2>
<div class="table-responsive">
<table class="table mb-0 table-hover">
<thead class="table-light">
<tr>
<th>DN #</th>
<th>Date</th>
<th class="text-end">Amount</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="dn_tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Right: Info + actions -->
<div class="col-lg-4">
<div class="card mb-5">
<div class="card-body p-4">
<h2 class="fs-5 mb-3">Document Info</h2>
<div class="mb-2 d-flex gap-2">
<span id="badge_doc_type"></span>
<span id="badge_status"></span>
</div>
<div class="mb-2 small">
<span class="text-muted">Source:</span>
<a href="#" id="link_source" class="ms-1 fw-semibold"></a>
</div>
<div class="mb-2 small">
<span class="text-muted">Supplier:</span>
<span class="ms-1 fw-semibold" id="display_contact"></span>
</div>
<div class="mb-2 small">
<span class="text-muted">Date:</span>
<span class="ms-1" id="display_issued"></span>
</div>
<div class="mb-2 small" id="due_date_row">
<span class="text-muted">Due:</span>
<input type="text" id="due_date" class="form-control form-control-sm ms-1 d-inline-block"
style="width:140px;" placeholder="DD/MM/YYYY">
</div>
<div class="mt-3 small">
<span class="text-muted">Notes:</span>
<textarea id="notes" class="form-control form-control-sm mt-1" rows="2"></textarea>
</div>
<div class="mt-3 small">
<span class="text-muted">GL Formula:</span>
<div class="d-flex gap-1 mt-1">
<select id="formula_id" class="form-select form-select-sm">
<option value="">— None —</option>
</select>
<button class="btn btn-outline-secondary btn-sm px-2" id="btn_save_formula"
onclick="save_formula()" title="Save formula">
<i class="ti ti-device-floppy"></i>
</button>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-4 d-flex flex-column gap-2">
<button class="btn btn-primary w-100" id="btn_save" onclick="save_invoice()">
<i class="ti ti-device-floppy me-1"></i>Save
</button>
<a href="#" class="btn btn-outline-info w-100 d-none" id="btn_create_scn">
<i class="ti ti-file-minus me-1"></i>Create Supplier Credit Note
</a>
<button class="btn btn-outline-danger w-100" id="btn_void" onclick="void_invoice()">
<i class="ti ti-ban me-1"></i>Void
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script>
var invoice_id = <?php echo $invoice_id; ?>;
var invoice_data = null;
function doc_type_badge(doc_type) {
const map = {
'purchase_invoice': '<span class="badge bg-primary">Purchase Invoice</span>',
'supplier_credit_note': '<span class="badge bg-info text-white">Supplier Credit Note</span>',
};
return map[doc_type] ?? '—';
}
function invoice_status_badge(status, due_date) {
if (String(status) === '1' && due_date) {
var today = new Date().toISOString().slice(0, 10);
if (due_date < today) return '<span class="badge bg-danger">Overdue</span>';
}
const map = {
'0': '<span class="badge bg-secondary">Draft</span>',
'1': '<span class="badge bg-info text-white">Issued</span>',
'2': '<span class="badge bg-success">Paid</span>',
'4': '<span class="badge bg-light text-dark">Void</span>',
};
return map[String(status)] ?? '—';
}
function set_mode(inv) {
var status = parseInt(inv.status);
var doc_type = inv.doc_type;
var is_pi = doc_type === 'purchase_invoice';
var is_dn = doc_type === 'supplier_credit_note';
var is_draft = status === 0;
var is_editable = is_draft && (is_pi || is_dn);
$('#due_date_row').toggleClass('d-none', is_dn);
$('#due_date, #notes').prop('disabled', !is_editable);
var not_editable_hint = (!is_pi && !is_dn) ? 'Unsupported document type' : '';
set_btn_state('#btn_save', is_editable, not_editable_hint || 'Only draft documents can be edited');
$('#btn_create_scn')
.toggleClass('d-none', !(is_pi && status >= 1 && status !== 4))
.attr('href', '<?php echo $server_url?>expense/manage_supplier_credit_note.php?ref_invoice_id=' + invoice_id);
set_btn_state('#btn_void', is_pi && status >= 1 && status !== 4,
!is_pi ? 'Only purchase invoices can be voided'
: status === 0 ? 'Invoice has not been issued yet'
: status === 4 ? 'Already void' : '');
}
function retrieve_invoice() {
return ajax_request({
url: '<?php echo $server_url?>order/api/engine/retrieve_invoice.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { invoice_id: invoice_id },
onSuccess: function(res) {
invoice_data = res.output;
var inv = invoice_data;
$('#page_title').text(inv.invoice_number);
$('#badge_doc_type').html(doc_type_badge(inv.doc_type));
$('#badge_status').html(invoice_status_badge(inv.status, inv.due_date));
$('#display_contact').text(inv.contact_name || '—');
$('#display_issued').text(format_date(inv.issued_date) || '—');
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
$('#notes').val(inv.notes || '');
// Source link
if (inv.source === 'po' && inv.source_id) {
$('#link_source')
.attr('href', '<?php echo $server_url?>expense/manage_purchase_order.php?id=' + inv.source_id)
.text('PO #' + inv.source_id);
} else if (inv.source === 'supplier_return' && inv.source_id) {
$('#link_source')
.attr('href', '<?php echo $server_url?>expense/purchase_invoice.php?id=' + inv.source_id)
.text('Return #' + inv.source_id);
}
// Items
var tbody = '';
$.each(inv.items || [], function(i, item) {
tbody += `<tr>
<td>${escape_html(item.product_name || item.product_sku)}</td>
<td class="text-end">${format_number(item.quantity, 2)}</td>
<td class="text-end">${format_number(item.unit_price, 2)}</td>
<td class="text-end">${format_number(item.tax_rate, 2)}</td>
<td class="text-end">${format_number(item.tax_amount, 4)}</td>
<td class="text-end">${format_number(item.total_price, 2)}</td>
</tr>`;
});
$('#item_tbody').html(tbody);
var is_draft = parseInt(inv.status) === 0;
var is_editable_doc = inv.doc_type === 'purchase_invoice' || inv.doc_type === 'supplier_credit_note';
var tax_adj_val = parseFloat(inv.tax_adjustment) || 0;
var tax_adj_cell = (is_draft && is_editable_doc)
? `<input type="number" id="tax_adjustment" class="form-control form-control-sm text-end d-inline-block" style="width:90px;" min="-0.3" max="0.3" step="0.01" value="${tax_adj_val.toFixed(2)}">`
: `${format_number(tax_adj_val, 2)}`;
var tfoot = `
<tr><td colspan="5" class="text-muted border-bottom-0">Subtotal</td><td class="text-end border-bottom-0">${format_number(inv.subtotal, 2)}</td></tr>
<tr><td colspan="5" class="text-muted border-bottom-0">Discount</td><td class="text-end border-bottom-0">${format_number(inv.discount, 2)}</td></tr>
<tr><td colspan="5" class="text-muted border-bottom-0">Tax</td><td class="text-end border-bottom-0">${format_number(inv.tax, 2)}</td></tr>
<tr><td colspan="5" class="text-muted border-bottom-0">Tax Adj.</td><td class="text-end border-bottom-0">${tax_adj_cell}</td></tr>
<tr><td colspan="5" class="text-muted">Shipping</td><td class="text-end">${format_number(inv.shipping_fee, 2)}</td></tr>
<tr><td colspan="5" class="fw-bold">Grand Total</td><td class="text-end fw-bold">${format_number(inv.grand_total, 2)}</td></tr>`;
$('#item_tfoot').html(tfoot);
// Linked debit notes (for purchase invoices)
if (res.supplier_credit_notes && res.supplier_credit_notes.length) {
var dn_rows = '';
$.each(res.supplier_credit_notes, function(i, dn) {
dn_rows += `<tr>
<td>${escape_html(dn.invoice_number)}</td>
<td>${format_date(dn.issued_date)}</td>
<td class="text-end">${format_number(dn.grand_total, 2)}</td>
<td>${invoice_status_badge(dn.status, dn.due_date)}</td>
<td><a href="<?php echo $server_url?>expense/manage_purchase_invoice.php?id=${dn.id}"><i class="ti ti-eye fs-5"></i></a></td>
</tr>`;
});
$('#dn_tbody').html(dn_rows);
$('#dn_card').removeClass('d-none');
}
var gl_type = inv.doc_type === 'supplier_credit_note' ? 'supplier_credit_note' : 'purchase_invoice';
var selected_formula = inv.formula_id || '';
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/account_formula.php',
autoPrepare: true,
checkRequired: 0,
action: 'get_by_type',
data: { document_type: gl_type },
onSuccess: function(fres) {
var opts = '<option value="">— None —</option>';
$.each(fres.output || [], function(i, f) {
var sel = String(f.id) === String(selected_formula) ? ' selected' : '';
opts += '<option value="' + f.id + '"' + sel + '>' + escape_html(f.formula_name) + '</option>';
});
$('#formula_id').html(opts);
}
});
$('#btn_save_formula').prop('disabled', parseInt(inv.status) === 4);
set_mode(inv);
}
});
}
function save_invoice() {
ajax_request({
url: '<?php echo $server_url?>order/api/engine/manage_invoice.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: {
id: invoice_id,
due_date: $('#due_date').val().split('/').reverse().join('-'),
notes: $('#notes').val(),
tax_adjustment: parseFloat($('#tax_adjustment').val()) || 0,
},
onSuccess: function() { retrieve_invoice(); }
});
}
function save_formula() {
ajax_request({
url: '<?php echo $server_url?>order/api/engine/manage_invoice.php',
autoPrepare: true,
checkRequired: 0,
action: 'save_formula',
data: { id: invoice_id, formula_id: $('#formula_id').val() || null },
onSuccess: function() {
bootbox.alert({ message: 'GL formula saved.', size: 'small' });
}
});
}
function void_invoice() {
bootbox.confirm({
message: '<strong>Void this purchase invoice?</strong><br>This action cannot be undone.',
buttons: {
confirm: { label: 'Void', className: 'btn-danger' },
cancel: { label: 'Back', className: 'btn-secondary' }
},
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>order/api/engine/update_invoice_status.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: { invoice_id: invoice_id, status: 4 },
onSuccess: function() { retrieve_invoice(); }
});
}
});
}
$(function() {
if (invoice_id) retrieve_invoice();
flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
});
</script>
</body>
</html>
+364
View File
@@ -0,0 +1,364 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
$po_id = (int)($_GET['id'] ?? 0);
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_ac.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="mb-6 d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1" id="page_title"><?php echo $po_id ? 'Purchase Order Detail' : 'New Purchase Order'; ?></h1>
<p class="mb-0 text-muted" id="page_subtitle">Create a supplier PO for WMS receiving</p>
</div>
<a href="<?php echo $server_url?>expense/purchase_order.php" class="btn btn-light">
<i class="ti ti-arrow-left me-1"></i>Back
</a>
</div>
</div>
</div>
<div class="row g-5">
<div class="col-lg-8">
<div class="card mb-5">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Order Information</h2>
<div class="row gx-5">
<div class="mb-3 col-lg-6">
<label class="form-label">Supplier</label>
<input type="text" id="contact" class="form-control contact_search" placeholder="Search supplier..." autocomplete="off">
<input type="hidden" id="contact_id">
</div>
<div class="mb-3 col-lg-6">
<label class="form-label">PO Date</label>
<input type="text" id="po_date" class="form-control" placeholder="DD/MM/YYYY">
</div>
<div class="mb-3 col-lg-6">
<label class="form-label">Expected Delivery <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" id="expected_date" class="form-control" placeholder="DD/MM/YYYY">
</div>
<div class="mb-3 col-lg-6">
<label class="form-label">Department</label>
<select id="department_id" class="form-select">
<option value="0">— No Department —</option>
</select>
</div>
<div class="mb-3 col-lg-12">
<label class="form-label">Notes</label>
<textarea id="notes" class="form-control" rows="2"></textarea>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fs-5 mb-0">Items to Order</h2>
<button class="btn btn-sm btn-outline-primary" id="btn_add_item" onclick="add_item_row()">
<i class="ti ti-plus me-1"></i>Add Item
</button>
</div>
<div class="table-responsive">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th>Product</th>
<th style="width:90px;">Qty</th>
<th style="width:120px;">Unit Price</th>
<th style="width:80px;">Tax%</th>
<th style="width:120px;">Total</th>
<th style="width:40px;"></th>
</tr>
</thead>
<tbody id="item_tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-5 d-none" id="status_card">
<div class="card-body p-4">
<h2 class="fs-5 mb-3">Status</h2>
<div class="mb-2" id="badge_status"></div>
<p class="mb-0 small fw-semibold text-muted" id="po_number_display"></p>
</div>
</div>
<div class="alert alert-warning mb-5" id="wms_status_alert">
<div class="d-flex gap-2">
<i class="ti ti-building-warehouse mt-1"></i>
<div>
<div class="fw-semibold" id="wms_status_title">Pending Warehouse receiving setup</div>
<div class="small" id="wms_status_message">WMS staff assign the receiving warehouse, confirm the PO, and receive goods.</div>
</div>
</div>
</div>
<div class="card mb-5">
<div class="card-body p-4">
<h2 class="fs-5 mb-3">Summary</h2>
<div class="d-flex justify-content-between mb-2"><span class="text-muted">Subtotal</span><span id="summary_subtotal">0.00</span></div>
<div class="d-flex justify-content-between mb-2"><span class="text-muted">Discount</span><input type="number" id="discount" class="form-control form-control-sm text-end" style="width:110px;" value="0" min="0" step="any" oninput="recalc_totals()"></div>
<div class="d-flex justify-content-between mb-2"><span class="text-muted">Tax</span><span id="summary_tax" class="fw-semibold">0.00</span></div>
<div class="d-flex justify-content-between align-items-center mb-2"><span class="text-muted small">Tax Adj.</span><input type="number" id="tax_adjustment" class="form-control form-control-sm text-end" style="width:90px;" value="0" min="-0.3" max="0.3" step="0.01" oninput="recalc_totals()"></div>
<div class="d-flex justify-content-between mb-3"><span class="text-muted">Shipping</span><input type="number" id="shipping_fee" class="form-control form-control-sm text-end" style="width:110px;" value="0" min="0" step="any" oninput="recalc_totals()"></div>
<hr>
<div class="d-flex justify-content-between fw-bold fs-5"><span>Grand Total</span><span id="summary_grand_total">0.00</span></div>
</div>
</div>
<div class="card">
<div class="card-body p-4 d-flex flex-column gap-2">
<button class="btn btn-primary w-100" id="btn_save" onclick="save_po()">
<i class="ti ti-device-floppy me-1"></i>Save Pending PO
</button>
<a href="#" class="btn btn-outline-primary w-100 d-none" id="btn_view_pi">
<i class="ti ti-file-invoice me-1"></i>View Purchase Invoice
</a>
<button class="btn btn-outline-danger w-100 d-none" id="btn_cancel" onclick="cancel_po()">
<i class="ti ti-x me-1"></i>Cancel PO
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script>
var po_id = <?php echo $po_id; ?>;
var po_data = null;
var item_index = 0;
function po_status_badge(status) {
const map = {
'-2': '<span class="badge bg-danger">Pending Warehouse</span>',
'-1': '<span class="badge bg-secondary">Cancelled</span>',
'0': '<span class="badge bg-warning text-dark">Draft</span>',
'1': '<span class="badge bg-info text-white">Confirmed</span>',
};
return map[String(status)] || '—';
}
function recalc_totals() {
var subtotal = 0;
var tax_sum = 0;
$('#item_tbody tr').each(function() {
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
var price = parseFloat($(this).find('.item_price').val()) || 0;
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
var total = qty * price;
var tax_amt = round_dp(total * tax_rate / 100, 4);
$(this).find('.item_total').text(format_number(total, 2));
$(this).find('.item_tax_amount').val(tax_amt);
subtotal += total;
tax_sum += tax_amt;
});
var discount = parseFloat($('#discount').val()) || 0;
var tax_adjustment = parseFloat($('#tax_adjustment').val()) || 0;
var shipping = parseFloat($('#shipping_fee').val()) || 0;
var derived_tax = round_dp(tax_sum, 2);
$('#summary_subtotal').text(format_number(subtotal, 2));
$('#summary_tax').text(format_number(derived_tax, 2));
$('#summary_grand_total').text(format_number(subtotal - discount + derived_tax + tax_adjustment + shipping, 2));
}
function add_item_row(item) {
item = item || {};
var idx = item_index++;
var row = `<tr id="item_row_${idx}">
<td>
<input type="text" class="form-control form-control-sm item_sku product_search" placeholder="SKU..." value="${escape_html(item.product_sku || '')}" autocomplete="off" data-product-name-target=".item_name">
<input type="hidden" class="item_name" value="${escape_html(item.product_name || '')}">
<input type="hidden" class="item_id" value="${item.item_id ?? idx}">
<small class="text-muted item_name_display">${escape_html(item.product_name || '')}</small>
</td>
<td><input type="number" class="form-control form-control-sm item_qty" value="${item.quantity || 1}" min="0.0001" step="any" oninput="recalc_totals()"></td>
<td><input type="number" class="form-control form-control-sm item_price" value="${item.unit_price || item.price || 0}" min="0" step="any" oninput="recalc_totals()"></td>
<td>
<input type="number" class="form-control form-control-sm item_tax_rate" value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
</td>
<td class="item_total text-end">${format_number((item.quantity || 1) * (item.unit_price || item.price || 0), 2)}</td>
<td><a href="javascript:void(0);" class="link-danger remove_item_btn" onclick="remove_item(${idx})"><i class="ti ti-trash fs-5"></i></a></td>
</tr>`;
$('#item_tbody').append(row);
if (window.init_product_search_inputs) window.init_product_search_inputs(`#item_row_${idx}`);
recalc_totals();
}
function collect_items() {
var items = [];
var idx = 0;
$('#item_tbody tr').each(function() {
var sku = $(this).find('.item_sku').val().trim();
var name = $(this).find('.item_name').val().trim();
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
var price = parseFloat($(this).find('.item_price').val()) || 0;
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
var tax_amt = parseFloat($(this).find('.item_tax_amount').val()) || 0;
if (!sku || qty <= 0) { idx++; return; }
items.push({
item_id: idx,
product_sku: sku,
product_name: name,
quantity: qty,
unit_price: price,
total_price: round_dp(qty * price, 4),
tax_rate: tax_rate,
tax_amount: tax_amt,
received_qty: 0,
stock_in_id: 0,
});
idx++;
});
return items;
}
function set_mode(status, res) {
status = parseInt(status);
res = res || {};
var editable = status === -2;
$('#contact, #po_date, #expected_date, #department_id, #notes, #discount, #tax_adjustment, #shipping_fee, #btn_add_item')
.prop('disabled', !editable);
$('.item_sku, .item_qty, .item_price, .item_tax_rate').prop('disabled', !editable);
$('.remove_item_btn').toggleClass('d-none', !editable);
$('#btn_save').toggleClass('d-none', !editable);
$('#btn_cancel').toggleClass('d-none', !(status === -2 || status === 0));
if (status === -2) {
$('#wms_status_title').text('Pending Warehouse receiving setup');
$('#wms_status_message').text('WMS staff assign the receiving warehouse, confirm the PO, and receive goods.');
} else {
$('#wms_status_title').text('Managed on WMS side');
$('#wms_status_message').text('The PO has moved beyond accounting edits. Continue warehouse updates from the WMS PO screen.');
}
$('#btn_view_pi')
.toggleClass('d-none', !res.has_active_pi)
.attr('href', res.active_pi_id ? '<?php echo $server_url?>expense/manage_purchase_invoice.php?id=' + res.active_pi_id : '#');
}
function retrieve_po() {
if (!po_id) return Promise.resolve();
return ajax_request({
url: '<?php echo $server_url?>po/api/engine/retrieve_po.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: po_id },
onSuccess: function(res) {
po_data = res.output || {};
$('#page_title').text(po_data.po_number || 'Purchase Order Detail');
$('#status_card').removeClass('d-none');
$('#badge_status').html(po_status_badge(po_data.status));
$('#po_number_display').text(po_data.po_number || '');
$('#contact').val(po_data.contact_name || '');
$('#contact_id').val(po_data.contact_id || '');
$('#po_date').val(po_data.po_date ? format_date(po_data.po_date) : '');
$('#expected_date').val(po_data.expected_date ? format_date(po_data.expected_date) : '');
$('#department_id').val(po_data.department_id || 0);
$('#notes').val(po_data.notes || '');
$('#discount').val(po_data.discount || 0);
$('#tax_adjustment').val(po_data.tax_adjustment || 0);
$('#shipping_fee').val(po_data.shipping_fee || 0);
$('#item_tbody').empty();
item_index = 0;
(po_data.items || []).forEach(function(item) { add_item_row(item); });
recalc_totals();
set_mode(po_data.status, res);
}
});
}
function save_po() {
var items = collect_items();
if (!items.length) {
bootbox.alert('Please add at least one item.');
return;
}
ajax_request({
url: '<?php echo $server_url?>po/api/engine/manage_po.php',
autoPrepare: true,
checkRequired: 0,
action: po_id ? 'update' : 'create',
data: {
id: po_id,
source: po_id ? '' : 'purchase',
source_id: 0,
contact_id: $('#contact_id').val() || 0,
department_id: $('#department_id').val() || 0,
po_date: to_iso_date($('#po_date').val()),
expected_date: to_iso_date($('#expected_date').val()) || '',
warehouse_id: po_data ? (po_data.warehouse_id || 0) : 0,
items: JSON.stringify(items),
discount: $('#discount').val() || 0,
tax_adjustment: $('#tax_adjustment').val() || 0,
shipping_fee: $('#shipping_fee').val() || 0,
notes: $('#notes').val(),
},
onSuccess: function(res) {
if (!po_id && res.new_id) {
window.location.href = '<?php echo $server_url?>expense/manage_purchase_order.php?id=' + res.new_id;
} else {
retrieve_po();
}
}
});
}
function cancel_po() {
bootbox.confirm('Cancel this purchase order?', function(ok) {
if (!ok) return;
ajax_request({
url: '<?php echo $server_url?>po/api/engine/cancel_po.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: { id: po_id },
onSuccess: function() { retrieve_po(); }
});
});
}
$(document).on('contact:selected', '#contact', function(e, contact) {
$('#contact_id').val(contact.id);
});
$(document).on('product:selected', '.item_sku', function(e, product) {
var $row = $(this).closest('tr');
$row.find('.item_name').val(product.product_name || '');
$row.find('.item_name_display').text(product.product_name || '');
$row.find('.item_tax_rate').val(parseFloat(product.tax_rate || 0).toFixed(2));
recalc_totals();
});
$(function() {
load_departments('department_id');
flatpickr('#po_date', { dateFormat: 'd/m/Y', allowInput: true });
flatpickr('#expected_date', { dateFormat: 'd/m/Y', allowInput: true });
if (po_id) {
retrieve_po();
} else {
$('#po_date').val(flatpickr.formatDate(new Date(), 'd/m/Y'));
add_item_row();
set_mode(-2, {});
}
});
</script>
</body>
</html>
+365
View File
@@ -0,0 +1,365 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
$request_id = (int)($_GET['id'] ?? 0);
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_ac.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="mb-6 d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1" id="page_title"><?php echo $request_id ? 'Purchase Request Detail' : 'New Purchase Request'; ?></h1>
<p class="mb-0 text-muted" id="page_subtitle"><?php echo $request_id ? '' : 'Create an internal request before purchase order handoff'; ?></p>
</div>
<a href="<?php echo $server_url?>expense/purchase_request.php" class="btn btn-light">
<i class="ti ti-arrow-left me-1"></i>Back
</a>
</div>
</div>
</div>
<div class="row g-5">
<div class="col-lg-8">
<div class="card mb-5">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Request Information</h2>
<div class="row gx-5">
<div class="mb-3 col-lg-6">
<label class="form-label">Preferred Supplier</label>
<input type="text" id="contact" class="form-control contact_search" placeholder="Search supplier..." autocomplete="off">
<input type="hidden" id="contact_id">
</div>
<div class="mb-3 col-lg-3">
<label class="form-label">Request Date</label>
<input type="text" id="request_date" class="form-control" placeholder="DD/MM/YYYY">
</div>
<div class="mb-3 col-lg-3">
<label class="form-label">Required Date</label>
<input type="text" id="required_date" class="form-control" placeholder="DD/MM/YYYY">
</div>
<div class="mb-3 col-lg-6">
<label class="form-label">Department</label>
<select id="department_id" class="form-select">
<option value="0">— No Department —</option>
</select>
</div>
<div class="mb-3 col-12">
<label class="form-label">Notes</label>
<textarea id="notes" class="form-control" rows="2"></textarea>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fs-5 mb-0">Requested Items</h2>
<button class="btn btn-sm btn-outline-primary" id="btn_add_item" onclick="add_item_row()">
<i class="ti ti-plus me-1"></i>Add Item
</button>
</div>
<div class="table-responsive">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th style="width:130px;">SKU</th>
<th>Description</th>
<th style="width:90px;">Qty</th>
<th style="width:120px;">Unit Price</th>
<th style="width:80px;">Tax%</th>
<th style="width:120px;">Total</th>
<th style="width:40px;"></th>
</tr>
</thead>
<tbody id="item_tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-5 d-none" id="status_card">
<div class="card-body p-4">
<h2 class="fs-5 mb-3">Status</h2>
<div id="badge_status" class="mb-2"></div>
<p class="mb-0 small fw-semibold text-muted" id="request_number_display"></p>
<p class="mb-0 small text-muted d-none" id="po_display"></p>
</div>
</div>
<div class="card mb-5">
<div class="card-body p-4">
<h2 class="fs-5 mb-3">Summary</h2>
<div class="d-flex justify-content-between mb-2"><span class="text-muted">Subtotal</span><span id="summary_subtotal">0.00</span></div>
<div class="d-flex justify-content-between align-items-center mb-2"><span class="text-muted">Discount</span><input type="number" id="discount" class="form-control form-control-sm text-end" style="width:110px;" value="0" min="0" step="any" oninput="recalc_totals()"></div>
<div class="d-flex justify-content-between mb-2"><span class="text-muted">Tax</span><span id="summary_tax" class="fw-semibold">0.00</span></div>
<div class="d-flex justify-content-between align-items-center mb-2"><span class="text-muted small">Tax Adj.</span><input type="number" id="tax_adjustment" class="form-control form-control-sm text-end" style="width:90px;" value="0" min="-0.3" max="0.3" step="0.01" oninput="recalc_totals()"></div>
<div class="d-flex justify-content-between align-items-center mb-3"><span class="text-muted">Shipping</span><input type="number" id="shipping_fee" class="form-control form-control-sm text-end" style="width:110px;" value="0" min="0" step="any" oninput="recalc_totals()"></div>
<hr>
<div class="d-flex justify-content-between fw-bold fs-5"><span>Grand Total</span><span id="summary_grand_total">0.00</span></div>
</div>
</div>
<div class="card">
<div class="card-body p-4 d-flex flex-column gap-2">
<button class="btn btn-primary w-100" id="btn_save" onclick="save_request()"><i class="ti ti-device-floppy me-1"></i>Save Draft</button>
<button class="btn btn-info text-white w-100 d-none" id="btn_submit" onclick="change_status('submit')"><i class="ti ti-send me-1"></i>Submit Request</button>
<button class="btn btn-success w-100 d-none" id="btn_approve" onclick="change_status('approve')"><i class="ti ti-circle-check me-1"></i>Approve</button>
<button class="btn btn-outline-danger w-100 d-none" id="btn_reject" onclick="change_status('reject')"><i class="ti ti-x me-1"></i>Reject</button>
<button class="btn btn-outline-secondary w-100 d-none" id="btn_reopen" onclick="change_status('reopen')"><i class="ti ti-refresh me-1"></i>Reopen as Draft</button>
<button class="btn btn-outline-danger w-100 d-none" id="btn_cancel" onclick="change_status('cancel')"><i class="ti ti-ban me-1"></i>Cancel</button>
<hr class="d-none" id="hr_convert">
<button class="btn btn-success w-100 d-none" id="btn_convert" onclick="convert_to_po()"><i class="ti ti-transfer me-1"></i>Convert to PO</button>
<a href="#" class="btn btn-outline-primary w-100 d-none" id="btn_view_po"><i class="ti ti-clipboard-list me-1"></i>View PO</a>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script>
var request_id = <?php echo $request_id; ?>;
var request_data = null;
var item_index = 0;
var status_badge = {
'-1': '<span class="badge bg-secondary">Cancelled</span>',
'0': '<span class="badge bg-warning text-dark">Draft</span>',
'1': '<span class="badge bg-info text-white">Submitted</span>',
'2': '<span class="badge bg-success">Approved</span>',
'3': '<span class="badge bg-danger">Rejected</span>',
'5': '<span class="badge bg-primary">Converted</span>',
};
function recalc_totals() {
var subtotal = 0;
var tax_sum = 0;
$('#item_tbody tr').each(function() {
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
var price = parseFloat($(this).find('.item_price').val()) || 0;
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
var total = qty * price;
var tax_amt = round_dp(total * tax_rate / 100, 4);
$(this).find('.item_total').text(format_number(total, 2));
$(this).find('.item_tax_amount').val(tax_amt);
subtotal += total;
tax_sum += tax_amt;
});
var discount = parseFloat($('#discount').val()) || 0;
var tax_adjustment = parseFloat($('#tax_adjustment').val()) || 0;
var shipping = parseFloat($('#shipping_fee').val()) || 0;
var derived_tax = round_dp(tax_sum, 2);
$('#summary_subtotal').text(format_number(subtotal, 2));
$('#summary_tax').text(format_number(derived_tax, 2));
$('#summary_grand_total').text(format_number(subtotal - discount + derived_tax + tax_adjustment + shipping, 2));
}
function add_item_row(item) {
item = item || {};
var idx = item_index++;
var row = `<tr id="item_row_${idx}">
<td><input type="text" class="form-control form-control-sm item_sku product_search" value="${escape_html(item.product_sku || '')}" placeholder="SKU..." autocomplete="off" data-product-name-target=".item_desc"></td>
<td><input type="text" class="form-control form-control-sm item_desc" value="${escape_html(item.product_name || item.description || '')}" placeholder="Description..."></td>
<td><input type="number" class="form-control form-control-sm item_qty" value="${item.quantity || 1}" min="0.0001" step="any" oninput="recalc_totals()"></td>
<td><input type="number" class="form-control form-control-sm item_price" value="${item.unit_price || item.price || 0}" min="0" step="any" oninput="recalc_totals()"></td>
<td>
<input type="number" class="form-control form-control-sm item_tax_rate" value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
</td>
<td class="item_total text-end align-middle">${format_number((item.quantity || 1) * (item.unit_price || item.price || 0), 2)}</td>
<td class="align-middle"><a href="javascript:void(0);" class="link-danger remove_item_btn" onclick="remove_item(${idx})"><i class="ti ti-trash fs-5"></i></a></td>
</tr>`;
$('#item_tbody').append(row);
if (window.init_product_search_inputs) window.init_product_search_inputs(`#item_row_${idx}`);
recalc_totals();
}
function collect_items() {
var items = [];
$('#item_tbody tr').each(function() {
var sku = $(this).find('.item_sku').val().trim();
var desc = $(this).find('.item_desc').val().trim();
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
var price = parseFloat($(this).find('.item_price').val()) || 0;
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
var tax_amt = parseFloat($(this).find('.item_tax_amount').val()) || 0;
if (!sku && !desc && qty <= 0) return;
items.push({
product_sku: sku,
product_name: desc,
description: desc,
quantity: qty,
unit_price: price,
total_price: round_dp(qty * price, 4),
tax_rate: tax_rate,
tax_amount: tax_amt,
});
});
return items;
}
function set_mode(status) {
status = parseInt(status);
var is_draft = status === 0;
var is_submitted = status === 1;
var is_approved = status === 2;
var is_closed = status === -1 || status === 3 || status === 5;
$('#contact, #request_date, #required_date, #department_id, #notes, #discount, #tax_adjustment, #shipping_fee, #btn_add_item').prop('disabled', !is_draft);
$('.item_sku, .item_desc, .item_qty, .item_price, .item_tax_rate').prop('disabled', !is_draft);
$('.remove_item_btn').toggleClass('d-none', !is_draft);
$('#btn_save').toggleClass('d-none', !is_draft);
$('#btn_submit').toggleClass('d-none', !is_draft || !request_id);
$('#btn_approve, #btn_reject').toggleClass('d-none', !is_submitted);
$('#btn_reopen').toggleClass('d-none', is_draft || is_closed);
$('#btn_cancel').toggleClass('d-none', !(is_draft || is_submitted) || !request_id);
$('#hr_convert, #btn_convert').toggleClass('d-none', !is_approved);
$('#btn_view_po').toggleClass('d-none', !(request_data && parseInt(request_data.po_id) > 0))
.attr('href', request_data && request_data.po_id ? '<?php echo $server_url?>expense/manage_purchase_order.php?id=' + request_data.po_id : '#');
}
function retrieve_request() {
if (!request_id) return Promise.resolve();
return ajax_request({
url: '<?php echo $server_url?>expense/api/engine/retrieve_purchase_request.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: request_id },
onSuccess: function(res) {
request_data = res.output || {};
$('#page_title').text(request_data.request_number || 'Purchase Request Detail');
$('#status_card').removeClass('d-none');
$('#badge_status').html(status_badge[String(request_data.status)] || request_data.status);
$('#request_number_display').text(request_data.request_number || '');
$('#po_display')
.toggleClass('d-none', !(request_data.po_id > 0))
.html(request_data.po_id > 0 ? 'PO: <a href="<?php echo $server_url?>expense/manage_purchase_order.php?id=' + request_data.po_id + '">' + escape_html(request_data.po_number || ('PO #' + request_data.po_id)) + '</a>' : '');
$('#contact').val(request_data.contact_name || '');
$('#contact_id').val(request_data.contact_id || 0);
$('#request_date').val(request_data.request_date ? format_date(request_data.request_date) : '');
$('#required_date').val(request_data.required_date ? format_date(request_data.required_date) : '');
$('#department_id').val(request_data.department_id || 0);
$('#notes').val(request_data.notes || '');
$('#discount').val(request_data.discount || 0);
$('#tax_adjustment').val(request_data.tax_adjustment || 0);
$('#shipping_fee').val(request_data.shipping_fee || 0);
$('#item_tbody').empty();
item_index = 0;
(request_data.items || []).forEach(function(item) { add_item_row(item); });
recalc_totals();
set_mode(request_data.status);
}
});
}
function save_request() {
var items = collect_items();
if (!items.length) {
bootbox.alert('Please add at least one item.');
return;
}
ajax_request({
url: '<?php echo $server_url?>expense/api/engine/manage_purchase_request.php',
autoPrepare: true,
checkRequired: 0,
action: request_id ? 'update' : 'create',
data: {
id: request_id,
contact_id: $('#contact_id').val() || 0,
department_id: $('#department_id').val() || 0,
request_date: to_iso_date($('#request_date').val()),
required_date: to_iso_date($('#required_date').val()),
items: JSON.stringify(items),
discount: $('#discount').val() || 0,
tax_adjustment: $('#tax_adjustment').val() || 0,
shipping_fee: $('#shipping_fee').val() || 0,
notes: $('#notes').val(),
},
onSuccess: function(res) {
if (!request_id && res.new_id) window.location.href = '<?php echo $server_url?>expense/manage_purchase_request.php?id=' + res.new_id;
else retrieve_request();
}
});
}
function change_status(action_type) {
var labels = {
submit: 'Submit this purchase request?',
approve: 'Approve this purchase request?',
reject: 'Reject this purchase request?',
reopen: 'Reopen this purchase request as Draft?',
cancel: 'Cancel this purchase request?',
};
bootbox.confirm(labels[action_type] || 'Confirm?', function(ok) {
if (!ok) return;
ajax_request({
url: '<?php echo $server_url?>expense/api/engine/update_purchase_request_status.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: { id: request_id, action_type: action_type },
onSuccess: function() { retrieve_request(); }
});
});
}
function convert_to_po() {
bootbox.confirm('Convert this approved purchase request to a pending warehouse purchase order?', function(ok) {
if (!ok) return;
ajax_request({
url: '<?php echo $server_url?>expense/api/engine/convert_purchase_request.php',
autoPrepare: true,
checkRequired: 0,
action: 'create',
data: { request_id: request_id },
onSuccess: function(res) {
window.location.href = '<?php echo $server_url?>expense/manage_purchase_order.php?id=' + res.po_id;
}
});
});
}
$(document).on('contact:selected', '#contact', function(e, contact) {
$('#contact_id').val(contact.id);
});
$(document).on('product:selected', '.item_sku', function(e, product) {
var $row = $(this).closest('tr');
var price = parseFloat(product.price || 0);
$row.find('.item_desc').val(product.product_name || '');
if (!(parseFloat($row.find('.item_price').val()) > 0) && price > 0) {
$row.find('.item_price').val(price);
}
$row.find('.item_tax_rate').val(parseFloat(product.tax_rate || 0).toFixed(2));
recalc_totals();
});
$(function() {
load_departments('department_id');
flatpickr('#request_date', { dateFormat: 'd/m/Y', allowInput: true });
flatpickr('#required_date', { dateFormat: 'd/m/Y', allowInput: true });
if (request_id) {
retrieve_request();
} else {
$('#request_date').val(flatpickr.formatDate(new Date(), 'd/m/Y'));
add_item_row();
set_mode(0);
}
});
</script>
</body>
</html>
+269
View File
@@ -0,0 +1,269 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
$ref_invoice_id = (int)($_GET['ref_invoice_id'] ?? 0);
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_ac.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="mb-6 d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1">New Supplier Credit Note</h1>
<p class="mb-0 text-muted">Accounting supplier credit note without stock movement</p>
</div>
<a href="<?php echo $server_url?>expense/purchase_invoice.php" class="btn btn-light">
<i class="ti ti-arrow-left me-1"></i>Back
</a>
</div>
</div>
</div>
<div class="row g-5">
<div class="col-lg-8">
<div class="card mb-5">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Supplier Credit Note Information</h2>
<div class="row gx-5">
<div class="mb-3 col-lg-6">
<label class="form-label">Reference Purchase Invoice ID</label>
<input type="number" id="ref_invoice_id" class="form-control"
value="<?php echo $ref_invoice_id ?: ''; ?>" min="0" placeholder="Optional">
</div>
<div class="mb-3 col-lg-6">
<label class="form-label">Supplier</label>
<input type="text" id="contact" class="form-control contact_search"
placeholder="Search supplier..." autocomplete="off">
<input type="hidden" id="contact_id">
</div>
<div class="mb-3 col-lg-12">
<label class="form-label">Reason / Notes</label>
<textarea id="notes" class="form-control" rows="3"
placeholder="Billing correction, returns credit, price adjustment..."></textarea>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fs-5 mb-0">Credit Lines</h2>
<button class="btn btn-sm btn-outline-primary" onclick="add_item_row()">
<i class="ti ti-plus me-1"></i>Add Line
</button>
</div>
<div class="table-responsive">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th>Description</th>
<th style="width:100px;">Qty</th>
<th style="width:130px;">Unit Price</th>
<th style="width:80px;">Tax%</th>
<th style="width:120px;" class="text-end">Total</th>
<th style="width:40px;"></th>
</tr>
</thead>
<tbody id="item_tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-5">
<div class="card-body p-4">
<h2 class="fs-5 mb-3">Summary</h2>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Subtotal</span>
<span id="summary_subtotal">0.00</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Tax</span>
<span id="summary_tax">0.00</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted small">Tax Adj.</span>
<input type="number" id="tax_adjustment" class="form-control form-control-sm text-end"
style="width:90px;" min="-0.3" max="0.3" step="0.01" value="0" oninput="recalc_totals()">
</div>
<hr>
<div class="d-flex justify-content-between fw-bold fs-5">
<span>Credit Total</span>
<span id="summary_grand_total" class="text-info">0.00</span>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-4 d-flex flex-column gap-2">
<button class="btn btn-primary w-100" onclick="save_supplier_credit_note()">
<i class="ti ti-device-floppy me-1"></i>Save Draft
</button>
<a href="<?php echo $server_url?>expense/purchase_invoice.php" class="btn btn-light w-100">Cancel</a>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script>
var item_index = 0;
function add_item_row(item) {
item = item || {};
var idx = item_index++;
var row = `<tr id="item_row_${idx}">
<td>
<input type="text" class="form-control form-control-sm item_desc"
value="${escape_html(item.product_name || '')}" placeholder="Credit description">
</td>
<td>
<input type="number" class="form-control form-control-sm item_qty"
value="${item.quantity || 1}" min="0.0001" step="any" oninput="recalc_totals()">
</td>
<td>
<input type="number" class="form-control form-control-sm item_price"
value="${item.price || 0}" min="0" step="any" oninput="recalc_totals()">
</td>
<td>
<input type="number" class="form-control form-control-sm item_tax_rate"
value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
</td>
<td class="item_total text-end align-middle">0.00</td>
<td class="align-middle">
<a href="javascript:void(0);" class="link-danger" onclick="remove_item(${idx})">
<i class="ti ti-trash fs-5"></i>
</a>
</td>
</tr>`;
$('#item_tbody').append(row);
recalc_totals();
}
function remove_item(idx) {
$('#item_row_' + idx).remove();
recalc_totals();
}
function recalc_totals() {
var subtotal = 0, tax_sum = 0;
$('#item_tbody tr').each(function() {
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
var price = parseFloat($(this).find('.item_price').val()) || 0;
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
var total = qty * price;
subtotal += total;
tax_sum += total * tax_rate / 100;
$(this).find('.item_total').text(format_number(total, 2));
});
var tax_adj = parseFloat($('#tax_adjustment').val()) || 0;
var tax = round_dp(tax_sum, 2) + tax_adj;
$('#summary_subtotal').text(format_number(subtotal, 2));
$('#summary_tax').text(format_number(tax, 2));
$('#summary_grand_total').text(format_number(subtotal + tax, 2));
}
function collect_items() {
var items = [];
$('#item_tbody tr').each(function() {
var desc = $(this).find('.item_desc').val().trim();
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
var price = parseFloat($(this).find('.item_price').val()) || 0;
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
if (!desc && qty <= 0) return;
items.push({
product_sku: '',
product_name: desc,
quantity: qty,
price: price,
tax_rate: tax_rate,
tax_amount: round_dp(qty * price * tax_rate / 100, 4),
total_price: round_dp(qty * price, 4),
});
});
return items;
}
function load_ref_invoice() {
var ref_id = parseInt($('#ref_invoice_id').val()) || 0;
if (!ref_id) return;
ajax_request({
url: '<?php echo $server_url?>order/api/engine/retrieve_invoice.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { invoice_id: ref_id },
onSuccess: function(res) {
var inv = res.output || {};
if (inv.doc_type !== 'purchase_invoice') return;
$('#contact').val(inv.contact_name || '');
$('#contact_id').val(inv.contact_id || 0);
}
});
}
function save_supplier_credit_note() {
var items = collect_items();
if (!items.length) {
bootbox.alert('Please add at least one credit line.');
return;
}
if (!$('#contact_id').val()) {
bootbox.alert('Please select a supplier.');
return;
}
if (!$('#notes').val().trim()) {
bootbox.alert('Please enter the reason for this supplier credit note.');
return;
}
ajax_request({
url: '<?php echo $server_url?>expense/api/engine/manage_supplier_credit_note.php',
autoPrepare: true,
checkRequired: 0,
action: 'create',
data: {
ref_invoice_id: $('#ref_invoice_id').val() || 0,
contact_id: $('#contact_id').val() || 0,
items: JSON.stringify(items),
notes: $('#notes').val(),
tax_adjustment: parseFloat($('#tax_adjustment').val()) || 0,
},
onSuccess: function(res) {
if (res.new_id) {
window.location.href = '<?php echo $server_url?>expense/manage_purchase_invoice.php?id=' + res.new_id;
}
}
});
}
$(document).on('contact:selected', '#contact', function(e, contact) {
$('#contact_id').val(contact.id);
});
$(function() {
add_item_row();
load_ref_invoice();
$('#ref_invoice_id').on('change', load_ref_invoice);
});
</script>
</body>
</html>
+2 -34
View File
@@ -1,36 +1,4 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
header('Location: ../finance/payment.php');
exit;
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_ac.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row mb-6">
<div class="col-12">
<h1 class="fs-3 mb-1">Payments</h1>
<p class="mb-0 text-muted">Payments to suppliers</p>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card p-5 text-center text-muted">
<i class="ti ti-credit-card fs-1 mb-3 d-block"></i>
<p class="mb-0">Payments coming soon.</p>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
</body>
</html>
+385 -5
View File
@@ -13,16 +13,174 @@
<div class="row mb-6">
<div class="col-12">
<h1 class="fs-3 mb-1">Purchase Invoices</h1>
<p class="mb-0 text-muted">Supplier invoices</p>
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1">Purchase Invoices</h1>
<p class="mb-0 text-muted">Supplier invoices and supplier credit notes</p>
</div>
<a href="<?php echo $server_url?>expense/manage_supplier_credit_note.php" class="btn btn-primary">
<i class="ti ti-plus me-1"></i>New Supplier Credit Note
</a>
</div>
</div>
</div>
<div class="row g-5 mb-5">
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
<i class="ti ti-file-invoice fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Invoices</p>
<h3 class="fw-bold mb-0" id="stat_invoice">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-success bg-opacity-10 text-success rounded-2">
<i class="ti ti-circle-check fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Paid</p>
<h3 class="fw-bold mb-0" id="stat_paid">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
<i class="ti ti-clock fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Open</p>
<h3 class="fw-bold mb-0" id="stat_open">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
<i class="ti ti-file-minus fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Supplier Credit Notes</p>
<h3 class="fw-bold mb-0" id="stat_scn">—</h3>
</div>
</div>
</div>
</div>
</div>
<div class="row mb-5">
<div class="col-12">
<ul class="nav nav-tabs" id="invoice_tabs">
<li class="nav-item">
<a class="nav-link active" href="#" onclick="switch_tab('purchase_invoice'); return false;">
<i class="ti ti-file-invoice me-1"></i>Purchase Invoices
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" onclick="switch_tab('supplier_credit_note'); return false;">
<i class="ti ti-file-minus me-1"></i>Supplier Credit Notes
</a>
</li>
</ul>
</div>
</div>
<div class="row g-5 mb-5">
<div class="col-12">
<div class="card">
<div class="card-body p-4">
<div class="row g-3 align-items-end">
<div class="col-lg-4">
<div class="position-relative">
<input type="text" id="search_input" class="form-control ps-9"
placeholder="Search invoice number, PO, or supplier...">
<span class="position-absolute top-25 ms-4">
<i class="ti ti-search text-muted"></i>
</span>
</div>
</div>
<div class="col-lg-2">
<select id="filter_status" class="form-select">
<option value="">All Status</option>
<option value="0">Draft</option>
<option value="1">Issued</option>
<option value="2">Paid</option>
<option value="4">Void</option>
</select>
</div>
<div class="col-lg-2">
<select id="filter_dept" class="form-select">
<option value="">All Departments</option>
</select>
</div>
<div class="col-lg-2">
<button class="btn btn-secondary w-100" onclick="retrieve_invoices()">
<i class="ti ti-search me-1"></i>Search
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card p-5 text-center text-muted">
<i class="ti ti-file-invoice fs-1 mb-3 d-block"></i>
<p class="mb-0">Purchase Invoices coming soon.</p>
<div class="card">
<div class="card-body p-5">
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered" id="pi_list">
<thead class="table-primary border-light">
<tr>
<th>Invoice #</th>
<th>Source</th>
<th>Date</th>
<th>Supplier</th>
<th>Department</th>
<th>Grand Total</th>
<th>Due Date</th>
<th>GL Formula</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
<table class="table table-hover mb-0 table-centered d-none" id="scn_list">
<thead class="table-info border-light">
<tr>
<th>SCN #</th>
<th>Ref Invoice #</th>
<th>Supplier</th>
<th>Department</th>
<th>Date</th>
<th>Amount</th>
<th>GL Formula</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -32,5 +190,227 @@
<?php require '../include_ending.php'; ?>
<script>
var current_tab = <?php echo json_encode($_GET['tab'] ?? 'purchase_invoice'); ?>;
var all_invoices = [];
var formula_map = {};
function load_formula_map() {
return ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/account_formula.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: {},
onSuccess: function(res) {
formula_map = {};
$.each(res.output || [], function(i, f) { formula_map[f.id] = f.formula_name; });
}
});
}
function invoice_status_badge(status, due_date) {
if (String(status) === '1' && due_date) {
var today = new Date().toISOString().slice(0, 10);
if (due_date < today) return '<span class="badge bg-danger">Overdue</span>';
}
const map = {
'0': '<span class="badge bg-secondary">Draft</span>',
'1': '<span class="badge bg-info text-white">Issued</span>',
'2': '<span class="badge bg-success">Paid</span>',
'4': '<span class="badge bg-light text-dark">Void</span>',
};
return map[String(status)] || '—';
}
function switch_tab(tab) {
current_tab = tab;
$('#invoice_tabs .nav-link').each(function() {
$(this).toggleClass('active', $(this).attr('onclick').includes("'" + tab + "'"));
});
$('#pi_list').toggleClass('d-none', tab !== 'purchase_invoice');
$('#scn_list').toggleClass('d-none', tab !== 'supplier_credit_note');
if (tab === 'supplier_credit_note') {
$('#filter_status').html(`
<option value="">All Status</option>
<option value="0">Draft</option>
<option value="1">Issued</option>
<option value="4">Void</option>
`);
} else {
$('#filter_status').html(`
<option value="">All Status</option>
<option value="0">Draft</option>
<option value="1">Issued</option>
<option value="2">Paid</option>
<option value="4">Void</option>
`);
}
retrieve_invoices();
}
function update_stats() {
var pis = all_invoices.filter(i => i.doc_type === 'purchase_invoice');
$('#stat_invoice').text(format_number(pis.length));
$('#stat_paid').text(format_number(pis.filter(i => String(i.status) === '2').length));
$('#stat_open').text(format_number(pis.filter(i => ['0','1'].includes(String(i.status))).length));
$('#stat_scn').text(format_number(all_invoices.filter(i => i.doc_type === 'supplier_credit_note').length));
}
function retrieve_stats_source() {
return ajax_request({
url: '<?php echo $server_url?>order/api/engine/retrieve_invoice.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { doc_type: '' },
onSuccess: function(res) {
all_invoices = (res.output || []).filter(function(inv) {
return inv.doc_type === 'purchase_invoice' || inv.doc_type === 'supplier_credit_note';
});
update_stats();
}
});
}
function retrieve_invoices() {
return ajax_request({
url: '<?php echo $server_url?>order/api/engine/retrieve_invoice.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { doc_type: current_tab },
onSuccess: function(res) {
var all = res.output || [];
var keyword = $('#search_input').val().toLowerCase();
var status = $('#filter_status').val();
var fil_dept = $('#filter_dept').val();
var data = all.filter(function(inv) {
var match_kw = !keyword ||
(inv.invoice_number || '').toLowerCase().includes(keyword) ||
(inv.contact_name || '').toLowerCase().includes(keyword);
var match_st = !status || String(inv.status) === status;
var match_dept = !fil_dept || String(inv.department_id) === fil_dept;
return match_kw && match_st && match_dept;
});
if (current_tab === 'purchase_invoice') {
render_pi_table(data);
} else {
render_scn_table(data);
}
}
});
}
// ── Purchase Invoice table ────────────────────────────────────────────────
function render_pi_table(data) {
alasql('DROP TABLE IF EXISTS pi_list');
alasql('CREATE TABLE pi_list');
alasql.tables.pi_list.data = data;
$('table#pi_list tfoot').html(generate_pagination('pi_list', data.length));
change_page_pi_list(1);
}
function change_page_pi_list(page_num) {
var offset = (page_num - 1) * prop_limit;
var rows = alasql(`SELECT * FROM pi_list LIMIT ${prop_limit} OFFSET ${offset}`);
var body = '';
if (!rows.length) {
$('table#pi_list tbody').html('<tr><td colspan="10" class="text-center py-5 text-muted">No purchase invoices found.</td></tr>');
return;
}
$.each(rows, function(i, inv) {
var source_label = '<span class="text-muted">—</span>';
if (inv.source === 'po' && inv.source_id) {
source_label = `<a href="<?php echo $server_url?>expense/manage_purchase_order.php?id=${inv.source_id}">PO #${inv.source_id}</a>`;
} else if (inv.source === 'supplier_return' && inv.source_id) {
source_label = `<a href="<?php echo $server_url?>po/manage_supplier_return.php?id=${inv.source_id}">SRN #${inv.source_id}</a>`;
}
var formula_name = inv.formula_id && formula_map[inv.formula_id]
? escape_html(formula_map[inv.formula_id])
: '<span class="text-muted">—</span>';
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(inv.invoice_number || '')}</td>
<td class="py-3">${source_label}</td>
<td class="py-3">${inv.issued_date ? format_date(inv.issued_date) : '<span class="text-muted">—</span>'}</td>
<td class="py-3">${escape_html(inv.contact_name || '—')}</td>
<td class="py-3">${get_dept_label(inv.department_id)}</td>
<td class="py-3 fw-semibold">${format_number(inv.grand_total, 2)}</td>
<td class="py-3">${inv.due_date ? format_date(inv.due_date) : '<span class="text-muted">—</span>'}</td>
<td class="py-3 small">${formula_name}</td>
<td class="py-3">${invoice_status_badge(inv.status, inv.due_date)}</td>
<td class="py-3">
<a href="<?php echo $server_url?>expense/manage_purchase_invoice.php?id=${inv.id}" title="View">
<i class="ti ti-eye fs-5"></i>
</a>
</td>
</tr>`;
});
$('table#pi_list tbody').html(body);
}
// ── Supplier Credit Note table ────────────────────────────────────────────
function render_scn_table(data) {
alasql('DROP TABLE IF EXISTS scn_list');
alasql('CREATE TABLE scn_list');
alasql.tables.scn_list.data = data;
$('table#scn_list tfoot').html(generate_pagination('scn_list', data.length));
change_page_scn_list(1);
}
function change_page_scn_list(page_num) {
var offset = (page_num - 1) * prop_limit;
var rows = alasql(`SELECT * FROM scn_list LIMIT ${prop_limit} OFFSET ${offset}`);
var body = '';
if (!rows.length) {
$('table#scn_list tbody').html('<tr><td colspan="9" class="text-center py-5 text-muted">No supplier credit notes found.</td></tr>');
return;
}
$.each(rows, function(i, scn) {
var formula_name = scn.formula_id && formula_map[scn.formula_id]
? escape_html(formula_map[scn.formula_id])
: '<span class="text-muted">—</span>';
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(scn.invoice_number || '')}</td>
<td class="py-3">${scn.ref_invoice_number ? escape_html(scn.ref_invoice_number) : '<span class="text-muted">—</span>'}</td>
<td class="py-3">${escape_html(scn.contact_name || '—')}</td>
<td class="py-3">${get_dept_label(scn.department_id)}</td>
<td class="py-3">${scn.issued_date ? format_date(scn.issued_date) : '<span class="text-muted">—</span>'}</td>
<td class="py-3 fw-semibold text-info">${format_number(scn.grand_total, 2)}</td>
<td class="py-3 small">${formula_name}</td>
<td class="py-3">${invoice_status_badge(scn.status, null)}</td>
<td class="py-3">
<a href="<?php echo $server_url?>expense/manage_purchase_invoice.php?id=${scn.id}" title="View">
<i class="ti ti-eye fs-5"></i>
</a>
</td>
</tr>`;
});
$('table#scn_list tbody').html(body);
}
$(async function() {
await load_dept_cache();
populate_dept_filter('filter_dept');
await retrieve_stats_source();
await load_formula_map();
$('#search_input').on('keyup', function(e) { if (e.key === 'Enter') retrieve_invoices(); });
$('#filter_status, #filter_dept').on('change', retrieve_invoices);
if (current_tab !== 'purchase_invoice') {
switch_tab(current_tab);
} else {
await retrieve_invoices();
}
});
</script>
</body>
</html>
+218 -6
View File
@@ -13,16 +13,128 @@
<div class="row mb-6">
<div class="col-12">
<h1 class="fs-3 mb-1">Purchase Orders</h1>
<p class="mb-0 text-muted">Purchase order management</p>
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1">Purchase Orders</h1>
<p class="mb-0 text-muted">Supplier orders handed off to WMS receiving</p>
</div>
<a href="<?php echo $server_url?>expense/manage_purchase_order.php" class="btn btn-primary">
<i class="ti ti-plus me-1"></i>New Purchase Order
</a>
</div>
</div>
</div>
<div class="row">
<div class="row g-5 mb-5">
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
<i class="ti ti-clipboard-list fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Total POs</p>
<h3 class="fw-bold mb-0" id="stat_total">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
<i class="ti ti-building-warehouse fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Pending Warehouse</p>
<h3 class="fw-bold mb-0" id="stat_pending">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
<i class="ti ti-send fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Confirmed</p>
<h3 class="fw-bold mb-0" id="stat_confirmed">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-success bg-opacity-10 text-success rounded-2">
<i class="ti ti-circle-check fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Received</p>
<h3 class="fw-bold mb-0" id="stat_received">—</h3>
</div>
</div>
</div>
</div>
</div>
<div class="row g-5">
<div class="col-12">
<div class="card p-5 text-center text-muted">
<i class="ti ti-clipboard-list fs-1 mb-3 d-block"></i>
<p class="mb-0">Purchase Orders coming soon.</p>
<div class="card">
<div class="card-body p-4">
<div class="row g-3 align-items-end">
<div class="col-lg-4">
<input type="text" id="search_input" class="form-control" placeholder="Search PO number or supplier...">
</div>
<div class="col-lg-2">
<select id="filter_status" class="form-select">
<option value="">All Status</option>
<option value="-2">Pending Warehouse</option>
<option value="0">Draft</option>
<option value="1">Confirmed</option>
<option value="-1">Cancelled</option>
</select>
</div>
<div class="col-lg-2">
<select id="filter_dept" class="form-select">
<option value="">All Departments</option>
</select>
</div>
<div class="col-lg-2">
<button class="btn btn-secondary w-100" onclick="retrieve_pos()">
<i class="ti ti-search me-1"></i>Search
</button>
</div>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-body p-5">
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered" id="po_list">
<thead class="table-primary border-light">
<tr>
<th>PO #</th>
<th>Source</th>
<th>Date</th>
<th>Supplier</th>
<th>Department</th>
<th>Grand Total</th>
<th>Status</th>
<th>Receiving</th>
<th>Action</th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -32,5 +144,105 @@
<?php require '../include_ending.php'; ?>
<script>
function po_status_badge(status) {
const map = {
'-2': '<span class="badge bg-danger">Pending Warehouse</span>',
'-1': '<span class="badge bg-secondary">Cancelled</span>',
'0': '<span class="badge bg-warning text-dark">Draft</span>',
'1': '<span class="badge bg-info text-white">Confirmed</span>',
};
return map[String(status)] || '—';
}
function receipt_status_badge(status) {
const map = {
'2': '<span class="badge bg-primary">Partial</span>',
'3': '<span class="badge bg-success">Received</span>',
};
return map[String(status)] || '<span class="text-muted">—</span>';
}
function retrieve_pos() {
return ajax_request({
url: '<?php echo $server_url?>po/api/engine/retrieve_po.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var all = (res.output || []).filter(function(po) {
return ['purchase', 'purchase_request'].includes(String(po.source || ''));
});
var keyword = $('#search_input').val().toLowerCase();
var status = $('#filter_status').val();
var fil_dept = $('#filter_dept').val();
var data = all.filter(function(po) {
var source_id = String(po.source_id || '');
var source = String(po.source || '');
var source_label = source === 'purchase' ? 'direct' : 'purchase request';
var match_kw = !keyword ||
String(po.po_number || '').toLowerCase().includes(keyword) ||
String(po.contact_name || '').toLowerCase().includes(keyword) ||
source_label.includes(keyword) ||
source_id.includes(keyword);
var match_status = status === '' || String(po.status) === status;
var match_dept = !fil_dept || String(po.department_id) === fil_dept;
return match_kw && match_status && match_dept;
});
$('#stat_total').text(format_number(all.length));
$('#stat_pending').text(format_number(all.filter(po => String(po.status) === '-2').length));
$('#stat_confirmed').text(format_number(all.filter(po => String(po.status) === '1').length));
$('#stat_received').text(format_number(all.filter(po => String(po.receipt_status) === '3').length));
alasql('CREATE TABLE IF NOT EXISTS purchase_po_list');
alasql.tables.purchase_po_list.data = data;
$('table#po_list tfoot').html(generate_pagination('po_list', data.length));
change_page_po_list(1);
}
});
}
function change_page_po_list(page_num) {
var offset = (page_num - 1) * prop_limit;
var rows = alasql(`SELECT * FROM purchase_po_list LIMIT ${prop_limit} OFFSET ${offset}`);
var body = '';
if (!rows.length) {
$('table#po_list tbody').html('<tr><td colspan="8" class="text-center py-5 text-muted">No purchase orders found.</td></tr>');
return;
}
$.each(rows, function(i, po) {
var source = String(po.source || '');
var source_display = source === 'purchase_request' && parseInt(po.source_id) > 0
? `<a href="<?php echo $server_url?>expense/manage_purchase_request.php?id=${po.source_id}">Request #${po.source_id}</a>`
: (source === 'purchase' ? 'Direct' : '<span class="text-muted">—</span>');
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(po.po_number || '')}</td>
<td class="py-3">${source_display}</td>
<td class="py-3">${po.po_date ? format_date(po.po_date) : '<span class="text-muted">—</span>'}</td>
<td class="py-3">${escape_html(po.contact_name || '—')}</td>
<td class="py-3">${get_dept_label(po.department_id)}</td>
<td class="py-3 fw-semibold">${format_number(po.grand_total, 2)}</td>
<td class="py-3">${po_status_badge(po.status)}</td>
<td class="py-3">${receipt_status_badge(po.receipt_status)}</td>
<td class="py-3">
<a href="<?php echo $server_url?>expense/manage_purchase_order.php?id=${po.id}" title="View">
<i class="ti ti-eye fs-5"></i>
</a>
</td>
</tr>`;
});
$('table#po_list tbody').html(body);
}
$(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);
});
</script>
</body>
</html>
+184 -6
View File
@@ -13,16 +13,111 @@
<div class="row mb-6">
<div class="col-12">
<h1 class="fs-3 mb-1">Purchase Requests</h1>
<p class="mb-0 text-muted">Internal purchase requests</p>
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1">Purchase Requests</h1>
<p class="mb-0 text-muted">Internal purchase requests before purchase order handoff</p>
</div>
<a href="<?php echo $server_url?>expense/manage_purchase_request.php" class="btn btn-primary">
<i class="ti ti-plus me-1"></i>New Purchase Request
</a>
</div>
</div>
</div>
<div class="row">
<div class="row g-5 mb-5">
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2"><i class="ti ti-notes fs-4"></i></div>
<div><p class="mb-0 text-muted small">Total</p><h3 class="fw-bold mb-0" id="stat_total">—</h3></div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2"><i class="ti ti-pencil fs-4"></i></div>
<div><p class="mb-0 text-muted small">Draft</p><h3 class="fw-bold mb-0" id="stat_draft">—</h3></div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2"><i class="ti ti-send fs-4"></i></div>
<div><p class="mb-0 text-muted small">Submitted</p><h3 class="fw-bold mb-0" id="stat_submitted">—</h3></div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-success bg-opacity-10 text-success rounded-2"><i class="ti ti-circle-check fs-4"></i></div>
<div><p class="mb-0 text-muted small">Approved</p><h3 class="fw-bold mb-0" id="stat_approved">—</h3></div>
</div>
</div>
</div>
</div>
<div class="row g-5">
<div class="col-12">
<div class="card p-5 text-center text-muted">
<i class="ti ti-notes fs-1 mb-3 d-block"></i>
<p class="mb-0">Purchase Requests coming soon.</p>
<div class="card">
<div class="card-body p-4">
<div class="row g-3 align-items-end">
<div class="col-lg-4">
<input type="text" id="search_input" class="form-control" placeholder="Search request number or supplier...">
</div>
<div class="col-lg-2">
<select id="filter_status" class="form-select">
<option value="">All Status</option>
<option value="0">Draft</option>
<option value="1">Submitted</option>
<option value="2">Approved</option>
<option value="3">Rejected</option>
<option value="5">Converted</option>
<option value="-1">Cancelled</option>
</select>
</div>
<div class="col-lg-2">
<select id="filter_dept" class="form-select">
<option value="">All Departments</option>
</select>
</div>
<div class="col-lg-2">
<button class="btn btn-secondary w-100" onclick="retrieve_requests()">
<i class="ti ti-search me-1"></i>Search
</button>
</div>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-body p-5">
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered" id="request_list">
<thead class="table-primary border-light">
<tr>
<th>Request #</th>
<th>Date</th>
<th>Required</th>
<th>Supplier</th>
<th>Department</th>
<th>Items</th>
<th>Grand Total</th>
<th>Status</th>
<th>PO</th>
<th>Action</th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -32,5 +127,88 @@
<?php require '../include_ending.php'; ?>
<script>
var status_badge = {
'-1': '<span class="badge bg-secondary">Cancelled</span>',
'0': '<span class="badge bg-warning text-dark">Draft</span>',
'1': '<span class="badge bg-info text-white">Submitted</span>',
'2': '<span class="badge bg-success">Approved</span>',
'3': '<span class="badge bg-danger">Rejected</span>',
'5': '<span class="badge bg-primary">Converted</span>',
};
function retrieve_requests() {
return ajax_request({
url: '<?php echo $server_url?>expense/api/engine/retrieve_purchase_request.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var all = res.output || [];
var keyword = $('#search_input').val().toLowerCase();
var status = $('#filter_status').val();
var fil_dept = $('#filter_dept').val();
var data = all.filter(function(r) {
var match_kw = !keyword ||
String(r.request_number || '').toLowerCase().includes(keyword) ||
String(r.contact_name || '').toLowerCase().includes(keyword);
var match_st = status === '' || String(r.status) === status;
var match_dept = !fil_dept || String(r.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(r => String(r.status) === '0').length));
$('#stat_submitted').text(format_number(all.filter(r => String(r.status) === '1').length));
$('#stat_approved').text(format_number(all.filter(r => String(r.status) === '2').length));
alasql('CREATE TABLE IF NOT EXISTS purchase_request_list');
alasql.tables.purchase_request_list.data = data;
$('table#request_list tfoot').html(generate_pagination('request_list', data.length));
change_page_request_list(1);
}
});
}
function change_page_request_list(page_num) {
var offset = (page_num - 1) * prop_limit;
var rows = alasql(`SELECT * FROM purchase_request_list LIMIT ${prop_limit} OFFSET ${offset}`);
var body = '';
if (!rows.length) {
$('table#request_list tbody').html('<tr><td colspan="9" class="text-center py-5 text-muted">No purchase requests found.</td></tr>');
return;
}
$.each(rows, function(i, r) {
var items = typeof r.items === 'string' ? JSON.parse(r.items || '[]') : (r.items || []);
var po_link = r.po_id > 0
? `<a href="<?php echo $server_url?>expense/manage_purchase_order.php?id=${r.po_id}">${escape_html(r.po_number || ('PO #' + r.po_id))}</a>`
: '<span class="text-muted">—</span>';
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(r.request_number || '')}</td>
<td class="py-3">${r.request_date ? format_date(r.request_date) : '<span class="text-muted">—</span>'}</td>
<td class="py-3">${r.required_date ? format_date(r.required_date) : '<span class="text-muted">—</span>'}</td>
<td class="py-3">${escape_html(r.contact_name || '—')}</td>
<td class="py-3">${get_dept_label(r.department_id)}</td>
<td class="py-3">${items.length} item${items.length !== 1 ? 's' : ''}</td>
<td class="py-3 fw-semibold">${format_number(r.grand_total, 2)}</td>
<td class="py-3">${status_badge[String(r.status)] || r.status}</td>
<td class="py-3">${po_link}</td>
<td class="py-3">
<a href="<?php echo $server_url?>expense/manage_purchase_request.php?id=${r.id}" title="View">
<i class="ti ti-eye fs-5"></i>
</a>
</td>
</tr>`;
});
$('table#request_list tbody').html(body);
}
$(async function() {
await load_dept_cache();
populate_dept_filter('filter_dept');
retrieve_requests();
$('#search_input').on('keyup', function(e) { if (e.key === 'Enter') retrieve_requests(); });
$('#filter_status, #filter_dept').on('change', retrieve_requests);
});
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More