Files
wms-app/app/accounting/gl_entries.php
T
2026-05-20 10:17:02 +07:00

522 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
?>