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
+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);
}