diff --git a/app/assets/images/apple-touch-icon.png b/app/assets/images/apple-touch-icon.png deleted file mode 100644 index dc37230..0000000 Binary files a/app/assets/images/apple-touch-icon.png and /dev/null differ diff --git a/app/assets/images/favicon-16x16.png b/app/assets/images/favicon-16x16.png deleted file mode 100644 index e01e137..0000000 Binary files a/app/assets/images/favicon-16x16.png and /dev/null differ diff --git a/app/assets/images/favicon-32x32.png b/app/assets/images/favicon-32x32.png deleted file mode 100644 index 6639fab..0000000 Binary files a/app/assets/images/favicon-32x32.png and /dev/null differ diff --git a/app/assets/images/favicon.png b/app/assets/images/favicon.png new file mode 100644 index 0000000..bea5eb5 Binary files /dev/null and b/app/assets/images/favicon.png differ diff --git a/app/assets/js/scanner.js b/app/assets/js/scanner.js new file mode 100644 index 0000000..54a96e7 --- /dev/null +++ b/app/assets/js/scanner.js @@ -0,0 +1,173 @@ +var scanner = (function() { + var _callback = null; + var _allow_raw = true; + var _allowed_types = null; + var _camera = null; + var _modal = null; + var _buffer = ''; + var _last_key_ts = 0; + var _max_key_gap = 0; + var _HW_THRESHOLD = 50; + + function _on_paste(e) { + if (!_callback) return; + var el = document.activeElement; + if (el && el.tagName === 'TEXTAREA') return; + var text = ((e.originalEvent || e).clipboardData || window.clipboardData).getData('text'); + if (!text) return; + text = text.trim(); + if (text.length < 3) return; + e.preventDefault(); + _resolve(text); + } + + function _on_keydown(e) { + var now = Date.now(); + var gap = now - _last_key_ts; + + if (gap > 500) { + _buffer = ''; + _max_key_gap = 0; + } else if (_buffer.length > 0) { + _max_key_gap = Math.max(_max_key_gap, gap); + } + _last_key_ts = now; + + if (e.key === 'Enter' && _buffer.length >= 3) { + var barcode = _buffer; + var is_scanner_input = _max_key_gap <= _HW_THRESHOLD; + _buffer = ''; + _max_key_gap = 0; + if (is_scanner_input) { + e.preventDefault(); + _remove_typed_barcode(barcode); + _resolve(barcode); + } + return; + } + + if (e.key.length === 1) { + _buffer += e.key; + } + } + + function _remove_typed_barcode(barcode) { + var el = document.activeElement; + if (!el || !('value' in el)) return; + if (el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA') return; + + var value = String(el.value || ''); + if (!value.endsWith(barcode)) return; + el.value = value.slice(0, value.length - barcode.length); + $(el).trigger('input'); + } + + function _resolve(barcode) { + if (!barcode || !_callback) return; + + $.ajax({ + url: server_url + 'ics/api/engine/barcode_lookup.php', + type: 'POST', + dataType: 'json', + data: { json: JSON.stringify({ barcode: barcode, action: 'read' }) }, + headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') }, + success: function(res) { + if (res && res.success) { + if ((!_allow_raw && res.type === 'raw') || (_allowed_types && _allowed_types.indexOf(res.type) === -1)) { + _show_error('This page only accepts generated barcode labels.'); + return; + } + _callback(res.sku || '', res.product_name || '', res.uom || '', res); + return; + } + _show_error('Barcode not found: ' + barcode); + }, + error: function(xhr) { + var msg = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : 'Scanner error. Please try again.'; + _show_error(msg === 'Barcode not found.' ? 'Barcode not found: ' + barcode : msg); + } + }); + } + + function _build_modal() { + if (_modal) return; + $('body').append(` + `); + _modal = new bootstrap.Modal(document.getElementById('scanner_modal')); + document.getElementById('scanner_modal').addEventListener('hidden.bs.modal', _stop_camera); + } + + function _start_camera() { + if (_camera) return; + if (typeof Html5Qrcode === 'undefined') { + _show_error('Camera scanner library is not available.'); + return; + } + _camera = new Html5Qrcode('scanner_qr_reader'); + _camera.start( + { facingMode: 'environment' }, + { fps: 10, qrbox: { width: 250, height: 120 } }, + function(decodedText) { + _modal.hide(); + _resolve(decodedText); + }, + function() {} + ).catch(function(err) { + _show_error('Camera error: ' + err); + }); + } + + function _stop_camera() { + if (!_camera) return; + _camera.stop().catch(function() {}).finally(function() { + _camera = null; + }); + } + + function _show_error(msg) { + var $err = $('#scanner_error'); + if ($err.length) { + $err.text(msg).removeClass('d-none'); + setTimeout(function() { $err.addClass('d-none'); }, 3000); + return; + } + if (typeof bootbox !== 'undefined') bootbox.alert(msg); + } + + return { + init: function(options) { + options = options || {}; + _callback = options.onScan || null; + _allow_raw = options.allowRaw !== false; + _allowed_types = Array.isArray(options.allowedTypes) ? options.allowedTypes : null; + document.removeEventListener('keydown', _on_keydown); + document.removeEventListener('paste', _on_paste); + document.addEventListener('keydown', _on_keydown); + document.addEventListener('paste', _on_paste); + _build_modal(); + }, + open_camera: function() { + if (!_modal) _build_modal(); + _modal.show(); + setTimeout(_start_camera, 400); + }, + destroy: function() { + document.removeEventListener('keydown', _on_keydown); + document.removeEventListener('paste', _on_paste); + _stop_camera(); + } + }; +})(); diff --git a/app/assets/utils/classes/ProductManager.php b/app/assets/utils/classes/ProductManager.php index a3ba21c..2467eb3 100644 --- a/app/assets/utils/classes/ProductManager.php +++ b/app/assets/utils/classes/ProductManager.php @@ -317,7 +317,8 @@ class ProductManager { $sth = $this->pdo->prepare( "SELECT * FROM md_product WHERE company_id = :company_id - AND sku LIKE :keyword + AND (sku LIKE :keyword OR barcode LIKE :keyword) + AND status > 0 LIMIT 50" ); $sth->execute([ @@ -358,6 +359,7 @@ class ProductManager { ':company_id' => $this->company_id, ':product_name' => $data['product_name'], ':sku' => $data['sku'], + ':barcode' => trim($data['barcode'] ?? '') ?: null, ':uom' => $data['uom'] ?? 'pcs', ':price' => $data['price'], ':cost_price' => (float)($data['cost_price'] ?? 0), @@ -376,6 +378,7 @@ class ProductManager { "UPDATE md_product SET product_name = :product_name, sku = :sku, + barcode = :barcode, uom = :uom, price = :price, cost_price = :cost_price, @@ -391,10 +394,10 @@ class ProductManager { } else { $this->pdo->prepare( "INSERT INTO md_product - (company_id, product_name, sku, uom, price, cost_price, min_stock, reorder_point, + (company_id, product_name, sku, barcode, uom, price, cost_price, min_stock, reorder_point, category, product_image, `description`, `status`, `log`) VALUES - (:company_id, :product_name, :sku, :uom, :price, :cost_price, :min_stock, :reorder_point, + (:company_id, :product_name, :sku, :barcode, :uom, :price, :cost_price, :min_stock, :reorder_point, :category, :product_image, :description, :status, :log)" )->execute($params); } diff --git a/app/ics/api/engine/barcode_lookup.php b/app/ics/api/engine/barcode_lookup.php new file mode 100644 index 0000000..089faae --- /dev/null +++ b/app/ics/api/engine/barcode_lookup.php @@ -0,0 +1,205 @@ +prepare( + "SELECT sku, product_name, uom + FROM md_product + WHERE company_id = :company_id + AND sku = :sku + AND status > 0 + LIMIT 1" + ); + $sth->execute([':company_id' => $company_id, ':sku' => $sku]); + return $sth->fetch(PDO::FETCH_ASSOC); + } + + function validateLotSerial(PDO $pdo, int $company_id, string $sku, string $lot, string $serial): void { + if ($serial === '') return; + + $warehouses = $pdo->prepare( + "SELECT id FROM md_warehouse + WHERE company_id = :company_id AND status = 1" + ); + $warehouses->execute([':company_id' => $company_id]); + + foreach ($warehouses->fetchAll(PDO::FETCH_COLUMN) as $warehouse_id) { + $table = 'td_stock_' . (int)$warehouse_id; + $sth = $pdo->prepare( + "SELECT lot_number + FROM `{$table}` + WHERE company_id = :company_id + AND product_sku = :sku + AND serial_number = :serial + AND status != -1 + LIMIT 1" + ); + $sth->execute([':company_id' => $company_id, ':sku' => $sku, ':serial' => $serial]); + $existing_lot = $sth->fetchColumn(); + if ($existing_lot !== false && (string)$existing_lot !== $lot) { + throw new Exception('Serial number belongs to a different lot.'); + } + } + } + + function findPreparedLabel(PDO $pdo, int $company_id, string $barcode, string $type): array|false { + $sth = $pdo->prepare( + "SELECT * + FROM md_barcode + WHERE company_id = :company_id + AND barcode = :barcode + AND barcode_type = :barcode_type + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $company_id, + ':barcode' => $barcode, + ':barcode_type' => $type, + ]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + if ($row && (int)$row['status'] !== 1) { + throw new Exception('Barcode is disabled.'); + } + return $row ?: false; + } + + function advancedLocation(PDO $pdo, int $company_id): bool { + $sth = $pdo->prepare( + "SELECT value FROM company_setting + WHERE company_id = :company_id AND setting_key = 'advanced_location' + LIMIT 1" + ); + $sth->execute([':company_id' => $company_id]); + $value = $sth->fetchColumn(); + return (int)($value === false ? 0 : $value) === 1; + } + + function validateLocation(PDO $pdo, int $company_id, int $warehouse_id, string $zone, string $aisle, string $rack): void { + $sth = $pdo->prepare( + "SELECT 1 FROM md_rack + WHERE company_id = :company_id + AND warehouse = :warehouse + AND zone = :zone + AND aisle = :aisle + AND rack = :rack + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $company_id, + ':warehouse' => $warehouse_id, + ':zone' => $zone, + ':aisle' => $aisle, + ':rack' => $rack, + ]); + if (!$sth->fetchColumn()) throw new Exception('Location not found.'); + } + + $parts = explode('|', $barcode); + $label_type = strtoupper($parts[0] ?? ''); + + try { + if ($label_type === 'SKU') { + if (count($parts) !== 4) throw new Exception('Invalid SKU label format.'); + $sku = trim($parts[1]); + $lot = trim($parts[2]); + $serial = trim($parts[3]); + if ($sku === '' || $lot === '' || $serial === '') throw new Exception('SKU, lot, and serial are required.'); + + $label = findPreparedLabel($pdo2, $company_id, $barcode, 'sku'); + if (!$label) throw new Exception('SKU barcode label was not prepared.'); + if ($label['product_sku'] !== $sku || $label['lot_number'] !== $lot || $label['serial_number'] !== $serial) { + throw new Exception('SKU barcode label does not match its registry.'); + } + + $product = findProduct($pdo2, $company_id, $sku); + if (!$product) throw new Exception('SKU not found.'); + validateLotSerial($pdo2, $company_id, $sku, $lot, $serial); + + $answer['success'] = 1; + $answer['type'] = 'sku'; + $answer['sku'] = $product['sku']; + $answer['product_name'] = $product['product_name']; + $answer['uom'] = $product['uom'] ?? 'pcs'; + $answer['lot_number'] = $lot; + $answer['serial_number'] = $serial; + exit(json_encode($answer)); + } + + if ($label_type === 'LOC') { + $advanced = advancedLocation($pdo1, $company_id); + if ($advanced && count($parts) !== 5) throw new Exception('Invalid advanced location label format.'); + if (!$advanced && count($parts) !== 3) throw new Exception('Invalid location label format.'); + + $warehouse_id = (int)($parts[1] ?? 0); + if ($advanced) { + $zone = trim($parts[2]); + $aisle = trim($parts[3]); + $rack = trim($parts[4]); + } else { + $rack = trim($parts[2]); + $zone = $rack; + $aisle = $rack; + } + if (!$warehouse_id || $zone === '' || $aisle === '' || $rack === '') throw new Exception('Location label is incomplete.'); + + $label = findPreparedLabel($pdo2, $company_id, $barcode, 'loc'); + if (!$label) throw new Exception('Location barcode label was not prepared.'); + if ((int)$label['warehouse_id'] !== $warehouse_id || $label['zone'] !== $zone || $label['aisle'] !== $aisle || $label['rack'] !== $rack) { + throw new Exception('Location barcode label does not match its registry.'); + } + + validateLocation($pdo2, $company_id, $warehouse_id, $zone, $aisle, $rack); + + $answer['success'] = 1; + $answer['type'] = 'loc'; + $answer['warehouse_id'] = $warehouse_id; + $answer['zone'] = $zone; + $answer['aisle'] = $aisle; + $answer['rack'] = $rack; + exit(json_encode($answer)); + } + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + exit(json_encode($answer)); + } + + $sth = $pdo2->prepare( + "SELECT sku, product_name, uom + FROM md_product + WHERE company_id = :company_id + AND (barcode = :barcode OR sku = :sku) + AND status > 0 + ORDER BY (barcode = :barcode_order) DESC + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $company_id, + ':barcode' => $barcode, + ':sku' => $barcode, + ':barcode_order' => $barcode, + ]); + $product = $sth->fetch(PDO::FETCH_ASSOC); + + if (!$product) { + $answer['message'] = 'Barcode not found.'; + http_response_code(404); + exit(json_encode($answer)); + } + + $answer['success'] = 1; + $answer['type'] = 'raw'; + $answer['sku'] = $product['sku']; + $answer['product_name'] = $product['product_name']; + $answer['uom'] = $product['uom'] ?? 'pcs'; + exit(json_encode($answer)); +?> diff --git a/app/ics/api/engine/location_barcode_label.php b/app/ics/api/engine/location_barcode_label.php new file mode 100644 index 0000000..9d4c577 --- /dev/null +++ b/app/ics/api/engine/location_barcode_label.php @@ -0,0 +1,153 @@ +prepare( + "SELECT 1 FROM md_rack + WHERE company_id = :company_id + AND warehouse = :warehouse + AND zone = :zone + AND aisle = :aisle + AND rack = :rack + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $company_id, + ':warehouse' => $warehouse_id, + ':zone' => $zone, + ':aisle' => $aisle, + ':rack' => $rack, + ]); + if (!$sth->fetchColumn()) { + $answer = ['success' => 0, 'message' => 'Location not found.']; + http_response_code(404); + exit(json_encode($answer)); + } + } + + $label_action = $data['label_action'] ?? 'create'; + $barcode = trim($data['barcode'] ?? ''); + $warehouse_id = (int)($data['warehouse_id'] ?? 0); + $zone = trim($data['zone'] ?? ''); + $aisle = trim($data['aisle'] ?? ''); + $rack = trim($data['rack'] ?? ''); + + if ($label_action === 'list') { + $sth = $pdo2->prepare( + "SELECT barcode, warehouse_id, zone, aisle, rack, status, print_count, last_printed_dt + FROM md_barcode + WHERE company_id = :company_id + AND barcode_type = 'loc'" + ); + $sth->execute([':company_id' => $company_id]); + + $answer['success'] = 1; + $answer['output'] = $sth->fetchAll(PDO::FETCH_ASSOC); + exit(json_encode($answer)); + } + + if ($label_action === 'print') { + if ($barcode === '') { + $answer['message'] = 'No barcode provided.'; + http_response_code(400); + exit(json_encode($answer)); + } + + $sth = $pdo2->prepare( + "UPDATE md_barcode + SET print_count = print_count + 1, + last_printed_dt = NOW() + WHERE company_id = :company_id + AND barcode = :barcode + AND barcode_type = 'loc' + AND status = 1" + ); + $sth->execute([':company_id' => $company_id, ':barcode' => $barcode]); + + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($label_action === 'disable') { + if ($barcode === '') { + $answer['message'] = 'No barcode provided.'; + http_response_code(400); + exit(json_encode($answer)); + } + + $sth = $pdo2->prepare( + "UPDATE md_barcode + SET status = 0 + WHERE company_id = :company_id + AND barcode = :barcode + AND barcode_type = 'loc' + AND status = 1" + ); + $sth->execute([':company_id' => $company_id, ':barcode' => $barcode]); + + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($label_action === 'enable') { + if ($barcode === '') { + $answer['message'] = 'No barcode provided.'; + http_response_code(400); + exit(json_encode($answer)); + } + + $sth = $pdo2->prepare( + "UPDATE md_barcode + SET status = 1 + WHERE company_id = :company_id + AND barcode = :barcode + AND barcode_type = 'loc' + AND status <> 1" + ); + $sth->execute([':company_id' => $company_id, ':barcode' => $barcode]); + + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($barcode === '' || !$warehouse_id || $zone === '' || $aisle === '' || $rack === '') { + $answer['message'] = 'Location barcode data is incomplete.'; + http_response_code(400); + exit(json_encode($answer)); + } + + validateLocation($pdo2, $company_id, $warehouse_id, $zone, $aisle, $rack); + + $sth = $pdo2->prepare( + "INSERT INTO md_barcode + (company_id, barcode_type, barcode, warehouse_id, zone, aisle, rack) + VALUES + (:company_id, 'loc', :barcode, :warehouse_id, :zone, :aisle, :rack) + ON DUPLICATE KEY UPDATE + barcode_type = VALUES(barcode_type), + warehouse_id = VALUES(warehouse_id), + zone = VALUES(zone), + aisle = VALUES(aisle), + rack = VALUES(rack), + status = 1" + ); + $sth->execute([ + ':company_id' => $company_id, + ':barcode' => $barcode, + ':warehouse_id' => $warehouse_id, + ':zone' => $zone, + ':aisle' => $aisle, + ':rack' => $rack, + ]); + + $answer['success'] = 1; + $answer['output'] = [ + 'barcode' => $barcode, + 'warehouse_id' => $warehouse_id, + 'zone' => $zone, + 'aisle' => $aisle, + 'rack' => $rack, + ]; + exit(json_encode($answer)); +?> diff --git a/app/ics/api/engine/sku_barcode_label.php b/app/ics/api/engine/sku_barcode_label.php new file mode 100644 index 0000000..a0bc5d1 --- /dev/null +++ b/app/ics/api/engine/sku_barcode_label.php @@ -0,0 +1,245 @@ +prepare( + "SELECT sku, product_name, uom + FROM md_product + WHERE company_id = :company_id + AND sku = :sku + AND status > 0 + LIMIT 1" + ); + $sth->execute([':company_id' => $company_id, ':sku' => $sku]); + return $sth->fetch(PDO::FETCH_ASSOC); + } + + function activeStockLotExists(PDO $pdo, int $company_id, string $sku, string $lot): bool { + $warehouses = $pdo->prepare( + "SELECT id FROM md_warehouse + WHERE company_id = :company_id AND status = 1" + ); + $warehouses->execute([':company_id' => $company_id]); + + foreach ($warehouses->fetchAll(PDO::FETCH_COLUMN) as $warehouse_id) { + $table = stockTableNameFromWarehouseId((int)$warehouse_id); + $sth = $pdo->prepare( + "SELECT ROUND(SUM(COALESCE(`in`, 0)) - SUM(COALESCE(`out`, 0)), 2) AS balance + FROM `{$table}` + WHERE company_id = :company_id + AND product_sku = :product_sku + AND lot_number = :lot_number + AND status = 1" + ); + $sth->execute([ + ':company_id' => $company_id, + ':product_sku' => $sku, + ':lot_number' => $lot, + ]); + if ((float)$sth->fetchColumn() > 0) return true; + } + + return false; + } + + function findLot(PDO $pdo, int $company_id, string $sku, string $lot): array|false { + $sth = $pdo->prepare( + "SELECT + l.product_sku, + l.lot_number, + l.expiry_date, + p.product_name, + p.uom + FROM md_lot l + INNER JOIN md_product p + ON p.company_id = l.company_id + AND p.sku = l.product_sku + WHERE l.company_id = :company_id + AND l.product_sku = :product_sku + AND l.lot_number = :lot_number + AND p.status > 0 + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $company_id, + ':product_sku' => $sku, + ':lot_number' => $lot, + ]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + if ($row) return $row; + + $product = findProduct($pdo, $company_id, $sku); + if (!$product || !activeStockLotExists($pdo, $company_id, $sku, $lot)) return false; + + return [ + 'product_sku' => $product['sku'], + 'lot_number' => $lot, + 'expiry_date' => null, + 'product_name' => $product['product_name'], + 'uom' => $product['uom'], + ]; + } + + function listLabels(PDO $pdo, int $company_id, string $sku, string $lot): array { + $params = [':company_id' => $company_id]; + $where = "WHERE b.company_id = :company_id"; + + if ($sku !== '') { + $where .= " AND b.product_sku = :product_sku"; + $params[':product_sku'] = $sku; + } + + if ($lot !== '') { + $where .= " AND b.lot_number = :lot_number"; + $params[':lot_number'] = $lot; + } + + $sth = $pdo->prepare( + "SELECT + b.id, + b.product_sku, + b.lot_number, + b.serial_number, + b.barcode, + b.status, + b.print_count, + b.last_printed_dt, + b.created_dt, + p.product_name + FROM md_barcode b + LEFT JOIN md_product p + ON p.company_id = b.company_id + AND p.sku = b.product_sku + {$where} + AND b.barcode_type = 'sku' + ORDER BY b.created_dt DESC, b.id DESC" + ); + $sth->execute($params); + return $sth->fetchAll(PDO::FETCH_ASSOC); + } + + $label_action = $data['label_action'] ?? 'read'; + $product_sku = trim($data['product_sku'] ?? ''); + $lot_number = trim($data['lot_number'] ?? ''); + + if ($label_action === 'print') { + $barcode = trim($data['barcode'] ?? ''); + if ($barcode === '') { + $answer['message'] = 'No barcode provided.'; + http_response_code(400); + exit(json_encode($answer)); + } + + $sth = $pdo2->prepare( + "UPDATE md_barcode + SET print_count = print_count + 1, + last_printed_dt = NOW() + WHERE company_id = :company_id + AND barcode = :barcode + AND barcode_type = 'sku' + AND status = 1" + ); + $sth->execute([':company_id' => $company_id, ':barcode' => $barcode]); + + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($label_action === 'disable') { + $barcode = trim($data['barcode'] ?? ''); + if ($barcode === '') { + $answer['message'] = 'No barcode provided.'; + http_response_code(400); + exit(json_encode($answer)); + } + + $sth = $pdo2->prepare( + "UPDATE md_barcode + SET status = 0 + WHERE company_id = :company_id + AND barcode = :barcode + AND barcode_type = 'sku' + AND status = 1" + ); + $sth->execute([':company_id' => $company_id, ':barcode' => $barcode]); + + $answer['success'] = 1; + exit(json_encode($answer)); + } + + if ($label_action === 'create') { + $serial_number = trim($data['serial_number'] ?? ''); + $allow_unlisted = !empty($data['allow_unlisted']); + + if ($product_sku === '' || $lot_number === '' || $serial_number === '') { + $answer['message'] = 'SKU, lot no, and serial no are required.'; + http_response_code(400); + exit(json_encode($answer)); + } + + $lot = findLot($pdo2, $company_id, $product_sku, $lot_number); + if (!$lot) { + if (!$allow_unlisted) { + $answer['message'] = 'Lot not found.'; + http_response_code(404); + exit(json_encode($answer)); + } + + $product = findProduct($pdo2, $company_id, $product_sku); + if (!$product) { + $answer['message'] = 'SKU not found.'; + http_response_code(404); + exit(json_encode($answer)); + } + + $lot = [ + 'product_sku' => $product['sku'], + 'lot_number' => $lot_number, + 'expiry_date' => null, + 'product_name' => $product['product_name'], + 'uom' => $product['uom'], + ]; + } + + $barcode = "SKU|{$product_sku}|{$lot_number}|{$serial_number}"; + + $sth = $pdo2->prepare( + "INSERT INTO md_barcode + (company_id, barcode_type, barcode, product_sku, lot_number, serial_number) + VALUES + (:company_id, 'sku', :barcode, :product_sku, :lot_number, :serial_number) + ON DUPLICATE KEY UPDATE + barcode_type = VALUES(barcode_type), + product_sku = VALUES(product_sku), + lot_number = VALUES(lot_number), + serial_number = VALUES(serial_number), + status = 1" + ); + $sth->execute([ + ':company_id' => $company_id, + ':product_sku' => $product_sku, + ':lot_number' => $lot_number, + ':serial_number' => $serial_number, + ':barcode' => $barcode, + ]); + + $answer['success'] = 1; + $answer['output'] = [ + 'product_sku' => $product_sku, + 'product_name' => $lot['product_name'], + 'lot_number' => $lot_number, + 'serial_number' => $serial_number, + 'barcode' => $barcode, + ]; + exit(json_encode($answer)); + } + + $answer['success'] = 1; + $answer['output'] = listLabels($pdo2, $company_id, $product_sku, $lot_number); + exit(json_encode($answer)); +?> diff --git a/app/ics/api/engine/sku_label_lots.php b/app/ics/api/engine/sku_label_lots.php new file mode 100644 index 0000000..ba56836 --- /dev/null +++ b/app/ics/api/engine/sku_label_lots.php @@ -0,0 +1,159 @@ +prepare( + "SELECT + l.id, + l.product_sku, + l.lot_number, + l.expiry_date, + l.description, + p.product_name, + p.uom, + 'md_lot' AS source + FROM md_lot l + INNER JOIN md_product p + ON p.company_id = l.company_id + AND p.sku = l.product_sku + WHERE l.company_id = :company_id + AND p.status > 0 + ORDER BY l.product_sku ASC, l.lot_number ASC" + ); + $sth->execute([':company_id' => $company_id]); + $rows = $sth->fetchAll(PDO::FETCH_ASSOC); + $seen = []; + + foreach ($rows as $row) { + $seen[$row['product_sku'] . '|' . $row['lot_number']] = true; + } + + $warehouses = $pdo2->prepare( + "SELECT id FROM md_warehouse + WHERE company_id = :company_id AND status = 1" + ); + $warehouses->execute([':company_id' => $company_id]); + + foreach ($warehouses->fetchAll(PDO::FETCH_COLUMN) as $warehouse_id) { + $table = stockTableNameFromWarehouseId((int)$warehouse_id); + $stock = $pdo2->query( + "SELECT + s.product_sku, + s.lot_number, + p.product_name, + p.uom, + ROUND(SUM(COALESCE(s.`in`, 0)) - SUM(COALESCE(s.`out`, 0)), 2) AS balance + FROM `{$table}` s + INNER JOIN md_product p + ON p.company_id = s.company_id + AND p.sku = s.product_sku + WHERE s.company_id = " . (int)$company_id . " + AND s.status = 1 + AND s.lot_number IS NOT NULL + AND s.lot_number <> '' + AND p.status > 0 + GROUP BY s.product_sku, s.lot_number, p.product_name, p.uom + HAVING balance > 0" + ); + + foreach ($stock->fetchAll(PDO::FETCH_ASSOC) as $row) { + $key = $row['product_sku'] . '|' . $row['lot_number']; + if (isset($seen[$key])) continue; + $seen[$key] = true; + $rows[] = [ + 'id' => null, + 'product_sku' => $row['product_sku'], + 'lot_number' => $row['lot_number'], + 'expiry_date' => null, + 'description' => 'Found in active stock; missing md_lot row.', + 'product_name'=> $row['product_name'], + 'uom' => $row['uom'], + 'source' => 'active_stock', + ]; + } + } + + $barcodes = $pdo2->prepare( + "SELECT + b.product_sku, + b.lot_number, + b.serial_number, + b.barcode, + b.status, + b.created_dt, + p.product_name, + p.uom + FROM md_barcode b + INNER JOIN md_product p + ON p.company_id = b.company_id + AND p.sku = b.product_sku + WHERE b.company_id = :company_id + AND b.barcode_type = 'sku' + AND b.product_sku IS NOT NULL + AND b.product_sku <> '' + AND b.lot_number IS NOT NULL + AND b.lot_number <> '' + AND p.status > 0 + ORDER BY b.created_dt DESC, b.id DESC" + ); + $barcodes->execute([':company_id' => $company_id]); + + foreach ($barcodes->fetchAll(PDO::FETCH_ASSOC) as $row) { + $key = $row['product_sku'] . '|' . $row['lot_number']; + if (isset($seen[$key])) continue; + $seen[$key] = true; + $rows[] = [ + 'id' => null, + 'product_sku' => $row['product_sku'], + 'lot_number' => $row['lot_number'], + 'expiry_date' => null, + 'description' => 'Prepared barcode exists; missing md_lot row.', + 'product_name' => $row['product_name'], + 'uom' => $row['uom'], + 'source' => 'md_barcode', + 'serial_number' => $row['serial_number'], + 'barcode' => $row['barcode'], + ]; + } + + $status_rows = $pdo2->prepare( + "SELECT + product_sku, + lot_number, + SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS active_count, + SUM(CASE WHEN status <> 1 THEN 1 ELSE 0 END) AS disabled_count + FROM md_barcode + WHERE company_id = :company_id + AND barcode_type = 'sku' + AND product_sku IS NOT NULL + AND product_sku <> '' + AND lot_number IS NOT NULL + AND lot_number <> '' + GROUP BY product_sku, lot_number" + ); + $status_rows->execute([':company_id' => $company_id]); + $label_status = []; + foreach ($status_rows->fetchAll(PDO::FETCH_ASSOC) as $row) { + $label_status[$row['product_sku'] . '|' . $row['lot_number']] = [ + 'active_count' => (int)$row['active_count'], + 'disabled_count' => (int)$row['disabled_count'], + ]; + } + + foreach ($rows as &$row) { + $status = $label_status[$row['product_sku'] . '|' . $row['lot_number']] ?? null; + $row['barcode_active_count'] = $status['active_count'] ?? 0; + $row['barcode_disabled_count'] = $status['disabled_count'] ?? 0; + } + unset($row); + + usort($rows, fn($a, $b) => [$a['product_sku'], $a['lot_number']] <=> [$b['product_sku'], $b['lot_number']]); + + $answer['success'] = 1; + $answer['output'] = $rows; + exit(json_encode($answer)); +?> diff --git a/app/ics/api/engine/sku_label_products.php b/app/ics/api/engine/sku_label_products.php new file mode 100644 index 0000000..4b0b1a0 --- /dev/null +++ b/app/ics/api/engine/sku_label_products.php @@ -0,0 +1,17 @@ +prepare( + "SELECT sku, product_name, uom + FROM md_product + WHERE company_id = :company_id + AND status > 0 + ORDER BY sku ASC" + ); + $sth->execute([':company_id' => $company_id]); + + $answer['success'] = 1; + $answer['output'] = $sth->fetchAll(PDO::FETCH_ASSOC); + exit(json_encode($answer)); +?> diff --git a/app/ics/api/engine/validate_scan_location.php b/app/ics/api/engine/validate_scan_location.php new file mode 100644 index 0000000..0a710dc --- /dev/null +++ b/app/ics/api/engine/validate_scan_location.php @@ -0,0 +1,86 @@ +prepare( + "SELECT id, product_sku, td_stock_id + FROM md_rack + WHERE company_id = :company_id + AND warehouse = :warehouse + AND zone = :zone + AND aisle = :aisle + AND rack = :rack + LIMIT 1" + ); + $sth->execute([ + ':company_id' => $company_id, + ':warehouse' => $warehouse_id, + ':zone' => $zone, + ':aisle' => $aisle, + ':rack' => $rack, + ]); + return $sth->fetch(PDO::FETCH_ASSOC); + } + + try { + $rack_row = rackRow($pdo2, $company_id, $warehouse_id, $zone, $aisle, $rack); + if (!$rack_row) throw new Exception("Location {$zone}-{$aisle}-{$rack} does not exist."); + + if ($mode === 'in' || $mode === 'to') { + if ($rack_row['product_sku'] !== null) { + throw new Exception("Location {$zone}-{$aisle}-{$rack} is already occupied by {$rack_row['product_sku']}."); + } + + if ($mode === 'in' && $product_sku !== '') { + $whMgmt->validateStockUnique($product_sku, $lot_number ?: null, $serial ?: null); + } + } elseif ($mode === 'out' || $mode === 'from') { + if ($product_sku === '') throw new Exception('Scan the SKU label first.'); + + $stock = $whMgmt->getRackStock($warehouse_id, $zone, $aisle, $rack); + if (!$stock) { + throw new Exception("Location {$zone}-{$aisle}-{$rack} has no approved stock to move out."); + } + + if ($stock['product_sku'] !== $product_sku) { + throw new Exception("Location holds {$stock['product_sku']}, not {$product_sku}."); + } + + if ($lot_number !== '' && (string)$stock['lot_number'] !== $lot_number) { + throw new Exception("Location holds lot '{$stock['lot_number']}', not '{$lot_number}'."); + } + + if ($serial !== '' && (string)$stock['serial_number'] !== $serial) { + throw new Exception("Location holds serial '{$stock['serial_number']}', not '{$serial}'."); + } + } else { + throw new Exception('Invalid scan validation mode.'); + } + + $answer['success'] = 1; + exit(json_encode($answer)); + } catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); + exit(json_encode($answer)); + } +?> diff --git a/app/ics/location_barcode_label.php b/app/ics/location_barcode_label.php new file mode 100644 index 0000000..0051a0d --- /dev/null +++ b/app/ics/location_barcode_label.php @@ -0,0 +1,388 @@ + + + + + + +
+
+
+
+
+

Location Barcode Labels

+

Print or reprint labels from the rack master list.

+
+
+ + + +
+
+
+ +
+
+
+
+ + + + +
+
+ + + + + + + + + + + + + + + +
WarehouseZoneAisleRackStatusLabel StatusAction
Loading locations...
+
+
+
+
+
+
+ Select a location to preview its label. +
+
+
+
+
+
+ + + + + + + diff --git a/app/ics/manage_stock_in.php b/app/ics/manage_stock_in.php index 854aeb8..d6263ee 100644 --- a/app/ics/manage_stock_in.php +++ b/app/ics/manage_stock_in.php @@ -30,8 +30,13 @@
- +
+ + +
@@ -130,6 +135,8 @@ var label_rack = 'Location'; var label_zone = 'Zone'; var label_aisle = 'Aisle'; + var scan_sku_ready = false; + var scan_location_ready = false; function load_location_config(callback) { return ajax_request({ @@ -166,6 +173,100 @@ } } + function set_select_value(selector, value, label) { + if (!value) return; + var $select = $(selector); + if (!$select.find(`option[value="${value}"]`).length) { + $select.append(``); + } + $select.val(value); + } + + function apply_sku_label_scan(data) { + $('#product_sku').val(data.sku).attr('secondary', data.sku); + $('#product_name').val(data.product_name || '').attr('secondary', data.product_name || ''); + $('#lot_number').val(data.lot_number || ''); + $('#serial_number').val(data.serial_number || ''); + $('#quantity').val($('#quantity').val() || 1); + scan_sku_ready = true; + scan_location_ready = false; + $('#warehouse').val(''); + $('#zone').val(''); + $('#aisle').val(''); + $('#rack').val(''); + $('#product_sku').trigger('blur'); + } + + function apply_location_label_scan(data) { + if (!scan_sku_ready && !($('#product_sku').attr('secondary') || $('#product_sku').val())) { + bootbox.alert('Scan the SKU label first, then scan the location label.'); + return; + } + validate_scanned_location('in', data, function() { + set_select_value('#warehouse', data.warehouse_id, data.warehouse_id); + if (advanced) { + set_select_value('#zone', data.zone, data.zone); + set_select_value('#aisle', data.aisle, data.aisle); + } + set_select_value('#rack', data.rack, data.rack); + scan_location_ready = true; + }); + } + + function validate_scanned_location(mode, data, onSuccess) { + ajax_request({ + url: 'ics/api/engine/validate_scan_location.php', + autoPrepare: true, + checkRequired: 0, + action: 'read', + queueLock: false, + noLoading: true, + data: { + mode: mode, + warehouse_id: data.warehouse_id, + zone: data.zone, + aisle: data.aisle, + rack: data.rack, + product_sku: $('#product_sku').attr('secondary') || $('#product_sku').val(), + lot_number: $('#lot_number').val(), + serial_number: $('#serial_number').val() + }, + onSuccess: onSuccess + }); + } + + function reset_stock_in_inputs() { + $('form')[0].reset(); + $('#product_sku').removeAttr('secondary').prop('disabled', false); + $('#product_name').removeAttr('secondary').val(''); + $('#lot_number, #serial_number, #expiry_date').prop('disabled', false); + $('#contact').removeAttr('secondary data-id'); + if (expiryPicker) expiryPicker.clear(); + if (!advanced) { + $('#zone').val(''); + $('#aisle').val(''); + } + $('#warehouse').val(''); + $('#zone').val(''); + $('#aisle').val(''); + $('#rack').val(''); + scan_sku_ready = false; + scan_location_ready = false; + $('#product_sku').focus(); + } + + function save_scanned_stock_in() { + if (!scan_sku_ready && !($('#product_sku').attr('secondary') || $('#product_sku').val())) { + bootbox.alert('Scan the SKU label first.'); + return; + } + if (!scan_location_ready && !$('#rack').val()) { + bootbox.alert('Scan the location label before saving.'); + return; + } + manage_stock_in(); + } + function manage_stock_in() { var rack_val = $('#rack').val(); @@ -200,7 +301,11 @@ }, action: 'manage', onSuccess: function() { + + reset_stock_in_inputs(); + window.location.href = 'ics/stock_in.php'; + } }); @@ -238,6 +343,8 @@ url: "ics/api/engine/retrieve_warehouse.php", autoPrepare: true, checkRequired: 0, + noLoading: true, + queueLock: false, data: { "type": type }, @@ -262,6 +369,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, data : { "type": type }, @@ -289,6 +397,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, data: { "type": type }, @@ -315,6 +424,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, data: { "type": type }, @@ -462,6 +572,14 @@ await load_location_config(apply_location_labels); await retrieve_warehouse('to'); await retrieve_for_edit(); + scanner.init({ + allowRaw: false, + allowedTypes: ['sku', 'loc'], + onScan: function(sku, product_name, uom, data) { + if (data.type === 'sku') apply_sku_label_scan(data); + if (data.type === 'loc') apply_location_label_scan(data); + } + }); prefill_from_restock(); @@ -488,6 +606,13 @@ + $(document).on('keydown', function(e) { + if (e.key === 'F2') { + e.preventDefault(); + save_scanned_stock_in(); + } + }); + $("select#warehouse").change(function() { if (advanced) { retrieve_zone('to'); diff --git a/app/ics/manage_stock_out.php b/app/ics/manage_stock_out.php index 4f82482..ca4deb9 100644 --- a/app/ics/manage_stock_out.php +++ b/app/ics/manage_stock_out.php @@ -28,8 +28,13 @@
- +
+ + +
@@ -117,6 +122,97 @@ var label_rack = 'Location'; var label_zone = 'Zone'; var label_aisle = 'Aisle'; + var scan_sku_ready = false; + var scan_location_ready = false; + var pending_scan_lot = ''; + var pending_scan_serial = ''; + + function set_select_value(selector, value, label) { + if (!value) return; + var $select = $(selector); + if (!$select.find(`option[value="${value}"]`).length) { + $select.append(``); + } + $select.val(value).prop('disabled', false); + } + + function apply_sku_label_scan(data) { + $('#product_sku').val(data.sku).attr('secondary', data.sku); + $('#product_name').val(data.product_name || '').attr('secondary', data.product_name || ''); + pending_scan_lot = data.lot_number || ''; + pending_scan_serial = data.serial_number || ''; + set_select_value('#lot_number', data.lot_number, data.lot_number); + set_select_value('#serial_number', data.serial_number, data.serial_number); + scan_sku_ready = true; + scan_location_ready = false; + reset_location(); + retrieve_active_lot(); + } + + function apply_location_label_scan(data) { + if (!scan_sku_ready && !($('#product_sku').attr('secondary') || $('#product_sku').val())) { + bootbox.alert('Scan the SKU label first, then scan the location label.'); + return; + } + validate_scanned_location('out', data, function() { + set_select_value('#warehouse', data.warehouse_id, data.warehouse_id); + if (advanced) { + set_select_value('#zone', data.zone, data.zone); + set_select_value('#aisle', data.aisle, data.aisle); + } + set_select_value('#rack', data.rack, data.rack); + scan_location_ready = true; + }); + } + + function validate_scanned_location(mode, data, onSuccess) { + ajax_request({ + url: 'ics/api/engine/validate_scan_location.php', + autoPrepare: true, + checkRequired: 0, + action: 'read', + queueLock: false, + noLoading: true, + data: { + mode: mode, + warehouse_id: data.warehouse_id, + zone: data.zone, + aisle: data.aisle, + rack: data.rack, + product_sku: $('#product_sku').attr('secondary') || $('#product_sku').val(), + lot_number: $('#lot_number').val(), + serial_number: $('#serial_number').val() + }, + onSuccess: onSuccess + }); + } + + function reset_stock_out_inputs() { + $('form')[0].reset(); + $('#product_sku').removeAttr('secondary').prop('disabled', false); + $('#product_name').removeAttr('secondary').val(''); + $('#lot_number').html('').prop('disabled', false); + $('#serial_number').html('').prop('disabled', false); + $('#contact').removeAttr('secondary data-id'); + reset_location(); + scan_sku_ready = false; + scan_location_ready = false; + pending_scan_lot = ''; + pending_scan_serial = ''; + $('#product_sku').focus(); + } + + function save_scanned_stock_out() { + if (!scan_sku_ready && !($('#product_sku').attr('secondary') || $('#product_sku').val())) { + bootbox.alert('Scan the SKU label first.'); + return; + } + if (!scan_location_ready && !$('#rack').val()) { + bootbox.alert('Scan the location label before saving.'); + return; + } + manage_stock_out(); + } function load_location_config(callback) { return ajax_request({ @@ -171,10 +267,16 @@ 'rack': rack_val, 'product_sku': sku_val, 'warehouse': wh_val, + 'lot_number': $('#lot_number').val(), + 'serial_number': $('#serial_number').val(), }, action: 'manage', onSuccess: function() { + + reset_stock_out_inputs(); + window.location.href = 'ics/stock_out.php'; + } }); } @@ -215,6 +317,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, action: 'read', data: { "product_sku": sku }, onSuccess: function(res) { @@ -226,10 +329,18 @@ option += ``; }); $('#lot_number').html(option).prop('disabled', false); + if (pending_scan_lot) { + set_select_value('#lot_number', pending_scan_lot, pending_scan_lot); + } $('#serial_number').html('').prop('disabled', false); // Reset downstream - reset_location(); + if (!scan_location_ready) reset_location(); + if (pending_scan_lot) { + retrieve_active_serial(); + } else { + retrieve_warehouse(); + } } }); } @@ -248,6 +359,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, action: 'read', data: { "product_sku": sku, "lot_number": lot }, onSuccess: function(res) { @@ -257,9 +369,14 @@ option += ``; }); $('#serial_number').html(option).prop('disabled', false); + if (pending_scan_serial) { + set_select_value('#serial_number', pending_scan_serial, pending_scan_serial); + pending_scan_serial = ''; + } // Reset downstream - reset_location(); + if (!scan_location_ready) reset_location(); + retrieve_warehouse(); } }); } @@ -273,6 +390,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, data: { "type": 'from', "product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(), @@ -280,11 +398,13 @@ }, action: 'read', onSuccess: function(res) { + var current = $('#warehouse').val(); var option = ''; $.each(res.output, function(key, item) { option += ``; }); $('#warehouse').html(option).prop('disabled', false); + if (current) set_select_value('#warehouse', current, current); } }); } @@ -298,6 +418,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, data: { "warehouse": $('#warehouse').val(), "type": 'from', @@ -324,6 +445,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, data: { "warehouse": $('#warehouse').val(), "zone": $('#zone').val(), @@ -351,6 +473,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, data: { "warehouse": $('#warehouse').val(), "zone": $('#zone').val(), @@ -454,6 +577,14 @@ await load_location_config(apply_location_labels); await retrieve_warehouse(); await retrieve_for_edit(); + scanner.init({ + allowRaw: false, + allowedTypes: ['sku', 'loc'], + onScan: function(sku, product_name, uom, data) { + if (data.type === 'sku') apply_sku_label_scan(data); + if (data.type === 'loc') apply_location_label_scan(data); + } + }); } catch(e) { console.log(e); } @@ -462,6 +593,12 @@ // ── Change handlers (create mode only) ─────────────────────────────── + $(document).on('keydown', function(e) { + if (e.key === 'F2') { + e.preventDefault(); + save_scanned_stock_out(); + } + }); // SKU selected → load lots $('input#product_sku').on('blur', function() { diff --git a/app/ics/manage_stock_transfer.php b/app/ics/manage_stock_transfer.php index f09d664..f4e7c4a 100644 --- a/app/ics/manage_stock_transfer.php +++ b/app/ics/manage_stock_transfer.php @@ -28,8 +28,13 @@
- +
+ + +
@@ -150,6 +155,133 @@ var label_rack = 'Location'; var label_zone = 'Zone'; var label_aisle = 'Aisle'; + var scan_sku_ready = false; + var scan_from_ready = false; + var scan_to_ready = false; + var pending_scan_lot = ''; + var pending_scan_serial = ''; + + function set_select_value(selector, value, label) { + if (!value) return; + var $select = $(selector); + if (!$select.find(`option[value="${value}"]`).length) { + $select.append(``); + } + $select.val(value).prop('disabled', false); + } + + function apply_sku_label_scan(data) { + $('#product_sku').val(data.sku).attr('secondary', data.sku); + $('#product_name').val(data.product_name || '').attr('secondary', data.product_name || ''); + pending_scan_lot = data.lot_number || ''; + pending_scan_serial = data.serial_number || ''; + set_select_value('#lot_number', data.lot_number, data.lot_number); + set_select_value('#serial_number', data.serial_number, data.serial_number); + scan_sku_ready = true; + scan_from_ready = false; + scan_to_ready = false; + reset_from_location(); + reset_to_location(); + retrieve_active_lot(); + } + + function apply_location_label_scan(data) { + if (!scan_sku_ready && !($('#product_sku').attr('secondary') || $('#product_sku').val())) { + bootbox.alert('Scan the SKU label first, then scan the from-location label.'); + return; + } + + var role = scan_from_ready ? 'to' : 'from'; + if (role === 'to' && same_location_as_from(data)) { + bootbox.alert('To-location must be different from from-location.'); + return; + } + + validate_scanned_location(role, data, function() { + set_select_value('#warehouse_' + role, data.warehouse_id, data.warehouse_id); + if (advanced) { + set_select_value('#zone_' + role, data.zone, data.zone); + set_select_value('#aisle_' + role, data.aisle, data.aisle); + } + set_select_value('#rack_' + role, data.rack, data.rack); + + if (role === 'from') { + scan_from_ready = true; + } else { + scan_to_ready = true; + } + }); + } + + function same_location_as_from(data) { + return String($('#warehouse_from').val()) === String(data.warehouse_id) + && String($('#zone_from').val()) === String(data.zone) + && String($('#aisle_from').val()) === String(data.aisle) + && String($('#rack_from').val()) === String(data.rack); + } + + function validate_scanned_location(role, data, onSuccess) { + ajax_request({ + url: 'ics/api/engine/validate_scan_location.php', + autoPrepare: true, + checkRequired: 0, + action: 'read', + queueLock: false, + noLoading: true, + data: { + mode: role, + warehouse_id: data.warehouse_id, + zone: data.zone, + aisle: data.aisle, + rack: data.rack, + product_sku: $('#product_sku').attr('secondary') || $('#product_sku').val(), + lot_number: $('#lot_number').val(), + serial_number: $('#serial_number').val() + }, + onSuccess: onSuccess + }); + } + + function reset_to_location() { + $('#warehouse_to').val(''); + $('#zone_to').html('').val(''); + $('#aisle_to').html('').val(''); + $('#rack_to').html('').val(''); + } + + function reset_stock_transfer_inputs() { + $('form')[0].reset(); + $('#product_sku').removeAttr('secondary').prop('disabled', false); + $('#product_name').removeAttr('secondary').val(''); + $('#lot_number').html('').prop('disabled', false); + $('#serial_number').html('').prop('disabled', false); + $('#contact').removeAttr('secondary data-id'); + reset_from_location(); + reset_to_location(); + retrieve_warehouse('to'); + scan_sku_ready = false; + scan_from_ready = false; + scan_to_ready = false; + pending_scan_lot = ''; + pending_scan_serial = ''; + $('#product_sku').focus(); + } + + function save_scanned_stock_transfer() { + if (!scan_sku_ready && !($('#product_sku').attr('secondary') || $('#product_sku').val())) { + bootbox.alert('Scan the SKU label first.'); + return; + } + if (!scan_from_ready && !$('#rack_from').val()) { + bootbox.alert('Scan the from-location label before saving.'); + return; + } + if (!scan_to_ready && !$('#rack_to').val()) { + bootbox.alert('Scan the to-location label before saving.'); + return; + } + manage_stock_transfer(); + } function load_location_config(callback) { return ajax_request({ @@ -224,10 +356,16 @@ 'aisle_to': advanced ? $('#aisle_to').val() : rack_to_val, 'rack_to': rack_to_val, 'product_sku': sku_val, + 'lot_number': $('#lot_number').val(), + 'serial_number': $('#serial_number').val(), }, action: 'manage', onSuccess: function() { + + reset_stock_transfer_inputs(); + window.location.href = 'ics/stock_transfer.php'; + } }); } @@ -266,6 +404,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, action: 'read', data: { 'product_sku': sku }, onSuccess: function(res) { @@ -276,8 +415,16 @@ option += ''; }); $('#lot_number').html(option).prop('disabled', false); + if (pending_scan_lot) { + set_select_value('#lot_number', pending_scan_lot, pending_scan_lot); + } $('#serial_number').html('').prop('disabled', false); - reset_from_location(); + if (!scan_from_ready) reset_from_location(); + if (pending_scan_lot) { + retrieve_active_serial(); + } else { + retrieve_warehouse('from'); + } } }); } @@ -292,6 +439,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, action: 'read', data: { 'product_sku': sku, 'lot_number': $('#lot_number').val() }, onSuccess: function(res) { @@ -300,7 +448,12 @@ option += ''; }); $('#serial_number').html(option).prop('disabled', false); - reset_from_location(); + if (pending_scan_serial) { + set_select_value('#serial_number', pending_scan_serial, pending_scan_serial); + pending_scan_serial = ''; + } + if (!scan_from_ready) reset_from_location(); + retrieve_warehouse('from'); } }); } @@ -313,6 +466,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, data: { 'type': type, 'product_sku': type === 'from' ? ($('#product_sku').attr('secondary') || $('#product_sku').val()) : '', @@ -320,11 +474,13 @@ }, action: 'read', onSuccess: function(res) { + var current = $('#warehouse_' + type).val(); var option = ''; $.each(res.output, function(key, item) { option += ''; }); $('select[name="warehouse"][role="' + type + '"]').html(option).prop('disabled', false); + if (current) set_select_value('#warehouse_' + type, current, current); } }); } @@ -348,6 +504,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, data: Object.assign({ 'type': type }, extra), action: 'read', onSuccess: function(res) { @@ -380,6 +537,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, data: Object.assign({ 'type': type }, extra), action: 'read', onSuccess: function(res) { @@ -414,6 +572,7 @@ autoPrepare: true, checkRequired: 0, noLoading: true, + queueLock: false, data: Object.assign({ 'type': type }, extra), action: 'read', onSuccess: function(res) { @@ -535,6 +694,14 @@ await retrieve_warehouse('to'); await retrieve_for_edit(); + scanner.init({ + allowRaw: false, + allowedTypes: ['sku', 'loc'], + onScan: function(sku, product_name, uom, data) { + if (data.type === 'sku') apply_sku_label_scan(data); + if (data.type === 'loc') apply_location_label_scan(data); + } + }); } catch(e) { console.log(e); } @@ -544,6 +711,13 @@ // ── Change handlers (create mode only) ─────────────────────────────────── + $(document).on('keydown', function(e) { + if (e.key === 'F2') { + e.preventDefault(); + save_scanned_stock_transfer(); + } + }); + $('input#product_sku').on('blur', function() { var sku = $(this).attr('secondary'); if (!sku) return; diff --git a/app/ics/sku_barcode_label.php b/app/ics/sku_barcode_label.php new file mode 100644 index 0000000..a3aa7dd --- /dev/null +++ b/app/ics/sku_barcode_label.php @@ -0,0 +1,538 @@ + + + + + + +
+
+
+
+
+

SKU Barcode Labels

+

Print or reprint product identity labels from the lot master list.

+
+
+ + + +
+
+
+ +
+
+
+
+
+ + + + +
+
+ + + + + + + + + + + + + + +
SKUProductLot NoExpirySourceAction
Loading lots...
+
+
+
+
+ +
+
+
+
+ Select a lot to preview its label. +
+
+
+
+
+
+
+ + + + + + + + + + + + + diff --git a/app/include_header.php b/app/include_header.php index d66fb79..3ae00bb 100644 --- a/app/include_header.php +++ b/app/include_header.php @@ -6,9 +6,9 @@ ThreeWMS - - - + + + @@ -58,4 +58,8 @@ - \ No newline at end of file + + + + diff --git a/app/include_master_sidebar.php b/app/include_master_sidebar.php index bd0b24f..2501d12 100644 --- a/app/include_master_sidebar.php +++ b/app/include_master_sidebar.php @@ -6,11 +6,7 @@