Files
wms-app/app/ics/manage_stock_out.php
T
Thanakorn 693c72f9ea Fix transfer quantity, unify date format, align demo seeds
Stock Transfer list showed 0.00 (read in instead of out); stock-out/transfer forms show the location quantity; dates display as YYYY-MM-DD HH:mm:ss. Demo seeds map product accounts and use product names and supplier batches.
2026-09-15 13:09:19 +07:00

674 lines
24 KiB
PHP

<?php
session_start();
require '../config.php';
if (!in_array($_SESSION['login_role'] ?? 'viewer', ['owner', 'admin', 'staff'], true)) {
http_response_code(403);
exit('Access denied.');
}
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php';?>
<?php require '../include_sidebar.php';?>
<!-- MAIN CONTENT -->
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="mb-6">
<h1 class="fs-3 mb-0">Stock Out</h1>
</div>
</div>
</div>
<div class="row g">
<div class="col-lg-8 col-12">
<div class="card">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Remove Stock</h2>
<form class="row gx-5">
<!-- Step 1: Product -->
<div class="mb-3 col-lg-3">
<label for="product_sku" class="form-label">SKU</label>
<div class="input-group">
<input type="text" id="product_sku" class="form-control product_search"
placeholder="Type product sku..." autocomplete="off" required>
<button class="btn btn-outline-secondary" type="button" onclick="scanner.open_camera()" title="Scan barcode label">
<i class="ti ti-scan"></i>
</button>
</div>
</div>
<div class="mb-3 col-lg-3">
<label for="product_name" class="form-label">Name</label>
<input type="text" id="product_name" class="form-control" autocomplete="off" disabled>
</div>
<!-- Step 2: Lot Number (active lots for selected SKU) -->
<div class="mb-3 col-lg-3">
<label for="lot_number" class="form-label">Lot Number <span class="text-muted fw-normal">(optional)</span></label>
<select id="lot_number" class="form-select">
<option value="">Select lot...</option>
</select>
</div>
<!-- Step 3: Serial Number (active serials for selected SKU + lot) -->
<div class="mb-3 col-lg-3">
<label for="serial_number" class="form-label">Serial Number <span class="text-muted fw-normal">(optional)</span></label>
<select id="serial_number" class="form-select">
<option value="">Select serial...</option>
</select>
</div>
<!-- Step 4: Warehouse -->
<div class="mb-3 col-lg-6">
<label for="warehouse" class="form-label">Warehouse</label>
<select name="warehouse" id="warehouse" class="form-select" required disabled>
<option value="">Select warehouse...</option>
</select>
</div>
<!-- Step 5: Zone -->
<div class="mb-3 col-lg-6" id="group_zone">
<label for="zone" class="form-label" id="label_zone">Zone</label>
<select name="zone" id="zone" class="form-select" disabled>
<option value="">Please select zone</option>
</select>
</div>
<!-- Step 6: Aisle -->
<div class="mb-3 col-lg-6" id="group_aisle">
<label for="aisle" class="form-label" id="label_aisle">Aisle</label>
<select name="aisle" id="aisle" class="form-select" disabled>
<option value="">Please select aisle</option>
</select>
</div>
<!-- Step 7: Bin -->
<div class="mb-3 col-lg-6" id="group_bin">
<label for="bin" class="form-label" id="label_bin">Bin</label>
<select name="bin" id="bin" class="form-select" required disabled>
<option value="">Please select bin</option>
</select>
</div>
<!-- Quantity: a stock-out always takes everything in the location -->
<div class="mb-3 col-lg-6">
<label for="location_quantity" class="form-label">Quantity</label>
<div class="input-group">
<input type="text" id="location_quantity" class="form-control text-end" placeholder="—" disabled>
<span class="input-group-text" id="location_uom" style="min-width:60px;">&nbsp;</span>
</div>
<div class="form-text">The whole quantity in the selected location is taken out.</div>
</div>
<!-- Contact -->
<div class="mb-3 col-lg-6">
<label for="contact" class="form-label">Contact</label>
<input type="text" class="form-control contact_search" id="contact" placeholder="Enter contact name">
</div>
<!-- Notes / Description -->
<div class="mb-3 col-lg-12">
<label for="description" class="form-label">Notes / Description</label>
<textarea class="form-control" id="description" rows="3" placeholder="Enter any notes"></textarea>
</div>
<!-- Submit Button -->
<div class="mb-3 col-lg-12">
<button type="submit" class="btn btn-primary" data-min-role="staff" onclick="manage_stock_out();">Remove Stock</button>
<button type="button" class="btn btn-success ms-2 d-none" id="approve_stock_btn" onclick="approve_current_stock();">Approve</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require "../include_ending.php";?>
<script>
// custom.js globals: set_select_value(), load_location_config()
var advanced = false;
var label_bin = '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 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('#bin', data.bin, data.bin);
scan_location_ready = true;
show_location_quantity();
});
}
// Quantity held in the chosen location — the amount this stock-out moves.
function show_location_quantity() {
var bin_val = $('#bin').val();
$('#location_quantity').val('');
$('#location_uom').html('&nbsp;');
if (!$('#warehouse').val() || !bin_val) return;
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_bin_stock.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: {
warehouse: $('#warehouse').val(),
zone: advanced ? $('#zone').val() : bin_val,
aisle: advanced ? $('#aisle').val() : bin_val,
bin: bin_val
},
onSuccess: function(res) {
if (!res.output) return;
$('#location_quantity').val(format_number(res.output.quantity, 2));
$('#location_uom').text(res.output.uom || '');
}
});
}
function validate_scanned_location(mode, data, onSuccess) {
ajax_request({
url: '<?php echo $server_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,
bin: data.bin,
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('<option value="">Select lot...</option>').prop('disabled', false);
$('#serial_number').html('<option value="">Select serial...</option>').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 && !$('#bin').val()) {
bootbox.alert('Scan the location label before saving.');
return;
}
manage_stock_out();
}
function apply_location_labels() {
if (advanced) {
$('#group_zone').show();
$('#group_aisle').show();
$('#label_zone').text(label_zone);
$('#label_aisle').text(label_aisle);
$('#label_bin').text(label_bin);
} else {
$('#group_zone').hide();
$('#group_aisle').hide();
$('#label_bin').text(label_bin);
}
}
function manage_stock_out() {
var bin_val = $('#bin').val();
var sku_val = $('#product_sku').attr('secondary') || $('#product_sku').val();
var wh_val = $('#warehouse').val();
if (!advanced) {
$('#zone').val(bin_val);
$('#aisle').val(bin_val);
}
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/manage_stock_out.php',
autoPrepare: true,
checkRequired: 1,
debugMode: 0,
data: {
'contact_id': $('#contact').attr('data-id'),
'zone': advanced ? $('#zone').val() : bin_val,
'aisle': advanced ? $('#aisle').val() : bin_val,
'bin': bin_val,
'product_sku': sku_val,
'warehouse': wh_val,
'lot_number': $('#lot_number').val(),
'serial_number': $('#serial_number').val(),
},
action: 'manage',
onSuccess: function() {
<?php if(empty($_GET["id"])){ ?>
reset_stock_out_inputs();
<?php } else { ?>
window.location.href = '<?php echo $server_url?>ics/stock_out.php';
<?php } ?>
}
});
}
function approve_current_stock() {
bootbox.confirm({
message: 'Approve this stock-out record?',
buttons: { confirm: { label: 'Approve', className: 'btn-success' }, cancel: { label: 'Cancel', className: 'btn-secondary' } },
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>ics/api/engine/approve_stock.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: {
id: '<?php echo $_GET["id"] ?? "";?>',
warehouse: '<?php echo $_GET["wh"] ?? "";?>',
type: 'out'
},
onSuccess: function() {
window.location.reload();
}
});
}
});
}
// ── Step 1: Product selected → load active lots ───────────────────────
function retrieve_active_lot() {
var sku = $('#product_sku').attr('secondary') || $('#product_sku').val();
if (!sku) return;
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_active_lot.php",
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: { "product_sku": sku },
onSuccess: function(res) {
var option = '<option value="">Select lot... (optional)</option>';
$.each(res.output, function(i, item) {
var label = item.lot_number;
if (item.expiry_date) label += ' — exp: ' + item.expiry_date;
option += `<option value="${item.lot_number}">${label}</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('<option value="">Select serial... (optional)</option>').prop('disabled', false);
// Reset downstream
if (!scan_location_ready) reset_location();
if (pending_scan_lot) {
retrieve_active_serial();
} else {
retrieve_warehouse();
}
}
});
}
// ── Step 2: Lot selected → load active serials ────────────────────────
function retrieve_active_serial() {
var sku = $('#product_sku').attr('secondary') || $('#product_sku').val();
if (!sku) return;
var lot = $('#lot_number').val();
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_active_serial.php",
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: { "product_sku": sku, "lot_number": lot },
onSuccess: function(res) {
var option = '<option value="">Select serial... (optional)</option>';
$.each(res.output, function(i, serial) {
option += `<option value="${serial}">${serial}</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
if (!scan_location_ready) reset_location();
retrieve_warehouse();
}
});
}
// ── Step 4: Load warehouse filtered by SKU ────────────────────────────
function retrieve_warehouse() {
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_warehouse.php",
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
data: {
"type": 'from',
"product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(),
"lot_number": $('#lot_number').val()
},
action: 'read',
onSuccess: function(res) {
var current = $('#warehouse').val();
var option = '<option value="">Select warehouse...</option>';
$.each(res.output, function(key, item) {
option += `<option value='${item.id}'>${item.warehouse_name}</option>`;
});
$('#warehouse').html(option).prop('disabled', false);
if (current) set_select_value('#warehouse', current, current);
}
});
}
// ── Steps 5-7: Zone → Aisle → Bin ───────────────────────────────────
function retrieve_zone(callback) {
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_zone.php",
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
data: {
"warehouse": $('#warehouse').val(),
"type": 'from',
"product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(),
"lot_number": $('#lot_number').val(),
"serial_number": $('#serial_number').val()
},
action: 'read',
onSuccess: function(res) {
var option = '<option value="">Please select ' + label_zone.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += `<option value='${item.zone}'>${item.zone}</option>`;
});
$('#zone').html(option).prop('disabled', false);
if (callback) callback();
}
});
}
function retrieve_aisle(callback) {
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_aisle.php",
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
data: {
"warehouse": $('#warehouse').val(),
"zone": $('#zone').val(),
"type": 'from',
"product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(),
"lot_number": $('#lot_number').val(),
"serial_number": $('#serial_number').val()
},
action: 'read',
onSuccess: function(res) {
var option = '<option value="">Please select ' + label_aisle.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += `<option value='${item}'>${item}</option>`;
});
$('#aisle').html(option).prop('disabled', false);
if (callback) callback();
}
});
}
function retrieve_bin(callback) {
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_bin.php",
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
data: {
"warehouse": $('#warehouse').val(),
"zone": $('#zone').val(),
"aisle": $('#aisle').val(),
"type": 'from',
"product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(),
"lot_number": $('#lot_number').val(),
"serial_number": $('#serial_number').val()
},
action: 'read',
onSuccess: function(res) {
var option = '<option value="">Please select ' + label_bin.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += `<option value='${item}'>${item}</option>`;
});
$('#bin').html(option).prop('disabled', false);
if (callback) callback();
}
});
}
// ── Reset location dropdowns ──────────────────────────────────────────
function reset_location() {
$('#warehouse').html('<option value="">Select warehouse...</option>').prop('disabled', true);
$('#zone').html('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
$('#aisle').html('<option value="">Please select ' + label_aisle.toLowerCase() + '</option>').prop('disabled', true);
$('#bin').html('<option value="">Please select ' + label_bin.toLowerCase() + '</option>').prop('disabled', true);
$('#location_quantity').val('');
$('#location_uom').html('&nbsp;');
}
// ── Edit mode ─────────────────────────────────────────────────────────
function retrieve_for_edit() {
<?php if(empty($_GET["id"])){ ?>
return false;
<?php } ?>
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_stock_out.php",
autoPrepare: true,
checkRequired: 0,
debugMode: 0,
action: 'read',
onSuccess: function(res) {
var data = res.output;
// Lock lot/serial — not editable in edit mode
$('#lot_number').html(`<option value="${data.lot_number || ''}">${data.lot_number || '—'}</option>`)
.val(data.lot_number || '').prop('disabled', true);
$('#serial_number').html(`<option value="${data.serial_number || ''}">${data.serial_number || '—'}</option>`)
.val(data.serial_number || '').prop('disabled', true);
$('#warehouse').val('<?php echo (int)($_GET["wh"] ?? 0);?>').prop('disabled', true);
function populate_fields() {
$.each(data, function(key, item) {
if (!['warehouse', 'zone', 'aisle', 'bin', 'lot_number', 'serial_number'].includes(key)) {
$('#' + key).val(item);
}
});
$('#contact').val(data.contact_name)
.attr('secondary', data.contact_name)
.attr('data-id', data.contact_id);
$('#product_name').val(data.product_name);
$('#product_sku').attr('secondary', data.product_sku).prop('disabled', true);
$('#location_quantity').val(format_number(data.quantity, 2));
$('#location_uom').text(data.uom || '');
$('button[type=submit]').text('Update');
$('button[type=reset]').hide();
if (data.status == 0) {
$('#approve_stock_btn').removeClass('d-none');
}
}
if (advanced) {
retrieve_zone(function() {
$('#zone').val(data.zone).prop('disabled', true);
retrieve_aisle(function() {
$('#aisle').val(data.aisle).prop('disabled', true);
retrieve_bin(function() {
$('#bin').val(data.bin).prop('disabled', true);
populate_fields();
});
});
});
} else {
// Simple mode — skip zone/aisle cascade
retrieve_bin(function() {
$('#bin').val(data.bin).prop('disabled', true);
populate_fields();
});
}
}
});
}
// ── Boot ─────────────────────────────────────────────────────────────
$(async function() {
try {
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);
}
});
// ── Change handlers (create mode only) ───────────────────────────────
<?php if(empty($_GET["id"])){ ?>
$(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() {
var sku = $(this).attr('secondary');
if (!sku) return;
retrieve_active_lot();
retrieve_warehouse();
});
// Lot selected → reload serials + location
$('#lot_number').on('change', function() {
retrieve_active_serial();
retrieve_warehouse();
});
// Serial selected → reload location
$('#serial_number').on('change', function() {
retrieve_warehouse();
});
// Warehouse selected → load zone (advanced) or bin directly (simple)
$('#warehouse').on('change', function() {
if (advanced) {
retrieve_zone();
} else {
retrieve_bin();
}
});
// Zone selected → load aisle (advanced only)
$('#zone').on('change', function() {
if (advanced) retrieve_aisle();
});
// Aisle selected → load bin (advanced only)
$('#aisle').on('change', function() {
if (advanced) retrieve_bin();
});
// Bin selected → show how much it holds
$('#bin').on('change', show_location_quantity);
<?php } ?>
</script>
</body>
</html>