456 lines
18 KiB
PHP
456 lines
18 KiB
PHP
<?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'; ?>
|
||
|
||
<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) · '
|
||
+ '<span class="text-danger">' + unposted + ' unposted</span>'
|
||
+ ' · '
|
||
+ '<span class="text-success">' + posted + ' posted</span>'
|
||
+ (missing_mapping > 0
|
||
? ' · <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 — delegated to shared BatchOverlay ─────────────
|
||
|
||
function show_batch_overlay(label, total) {
|
||
BatchOverlay.show('Batch GL Posting — ' + label, total, { unit: 'documents' });
|
||
}
|
||
|
||
function update_batch_overlay(done, total) {
|
||
BatchOverlay.update(done, total, 'document ' + done + ' of ' + total);
|
||
}
|
||
|
||
// ── 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 $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();
|
||
|
||
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) {
|
||
update_batch_overlay(done, total);
|
||
},
|
||
function(failed) {
|
||
// Always release DB lock — whether succeeded or failed
|
||
release_op_lock('gl_post');
|
||
|
||
var succeeded = ids.length - failed.length;
|
||
|
||
if (failed.length === 0) {
|
||
_gl_failed[doc_type] = [];
|
||
BatchOverlay.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>');
|
||
BatchOverlay.done(failed.length + ' failed · ' + succeeded + ' succeeded.', function() {
|
||
$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>
|
||
|
||
<!-- 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;
|
||
}
|
||
?>
|