Compare commits
22
Commits
sdlc
...
78d69d81df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78d69d81df | ||
|
|
693c72f9ea | ||
|
|
9512d440dd | ||
|
|
45331948c1 | ||
|
|
ba73456185 | ||
|
|
501c70050f | ||
|
|
f6909b08eb | ||
|
|
5846c8b282 | ||
|
|
6a271c1f7d | ||
|
|
2ef2f32107 | ||
|
|
1f72633cb6 | ||
|
|
4efab9f7c9 | ||
|
|
44c66c49a5 | ||
|
|
6b3a590aa9 | ||
|
|
21148bf50c | ||
|
|
cf8106771b | ||
|
|
d7203583b7 | ||
|
|
9afcf072b0 | ||
|
|
2f290ddb26 | ||
|
|
5d021d7683 | ||
|
|
5ee0c8d41b | ||
|
|
c3113bc70d |
@@ -13,5 +13,11 @@ EMIT_SECRET=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
|
||||
# Email OTP on sign-in. Off by default; only the exact value "true" turns it on,
|
||||
# and that needs working SMTP. While off, sign-in is password only (logged as
|
||||
# OTP_BYPASSED, shown on the login page and top bar).
|
||||
# Applied to app/config.php by the php container on every start.
|
||||
OTP_REQUIRED=false
|
||||
|
||||
# Port to expose the web app on (default 80)
|
||||
HTTP_PORT=80
|
||||
|
||||
@@ -6,6 +6,9 @@ app/uploads
|
||||
node_modules/
|
||||
nodejs/.env
|
||||
|
||||
# PM2 runtime logs (written by the node container; nodejs/logs/.gitkeep keeps the folder)
|
||||
nodejs/logs/*.log
|
||||
|
||||
# Docker deploy secrets
|
||||
/.env
|
||||
|
||||
|
||||
@@ -509,7 +509,7 @@
|
||||
|
||||
var extra_fields = h.source_type === 'manual'
|
||||
? '<div class="col-sm-4"><div class="text-muted small">Reference</div><div class="fw-semibold">' + escape_html(h.reference || ('MJE-' + h.id)) + '</div></div>' +
|
||||
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(h.journal_date_fmt || '—') + '</div></div>' +
|
||||
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(format_date(h.journal_date)) + '</div></div>' +
|
||||
'<div class="col-sm-4"><div class="text-muted small">Description</div><div>' + escape_html(h.description || '—') + '</div></div>'
|
||||
: '<div class="col-sm-4"><div class="text-muted small">Formula</div><div>' + escape_html(h.formula_name) + '</div></div>' +
|
||||
'<div class="col-sm-4"><div class="text-muted small">Version</div><div>v' + h.current_version + (h.current_version > 1 ? ' <span class="text-muted small">(replaced)</span>' : '') + '</div></div>' +
|
||||
|
||||
+11
-2
@@ -995,15 +995,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
/**
|
||||
* Helper function to format date strings (assuming input is in ISO format)
|
||||
*/
|
||||
// Display format used across the app: YYYY-MM-DD, or YYYY-MM-DD HH:mm:ss when the value has a time.
|
||||
function format_date(iso_string) {
|
||||
if (!iso_string) return '—';
|
||||
var split = iso_string.split(" ");
|
||||
var split = String(iso_string).split(" ");
|
||||
var datePart = split[0].split("-");
|
||||
if (datePart.length !== 3) return iso_string;
|
||||
var formatted = `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
|
||||
var formatted = `${datePart[0]}-${datePart[1]}-${datePart[2]}`;
|
||||
return split.length === 2 ? `${formatted} ${split[1]}` : formatted;
|
||||
}
|
||||
|
||||
// DD/MM/YYYY for filling date inputs (flatpickr dateFormat 'd/m/Y'); to_iso_date() reverses it.
|
||||
function format_date_input(iso_string) {
|
||||
if (!iso_string) return '';
|
||||
var datePart = String(iso_string).split(" ")[0].split("-");
|
||||
if (datePart.length !== 3) return iso_string;
|
||||
return `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
|
||||
}
|
||||
|
||||
|
||||
function to_iso_date(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
// app/assets/utils/app_registry.php
|
||||
//
|
||||
// The apps a user can be given access to (user.app_access and
|
||||
// company_map_user.app_access), with the label, icon and badge colour the
|
||||
// Users Access page shows for each.
|
||||
//
|
||||
// config.php may define its own $app_registry; this file only fills it in when
|
||||
// it is missing or empty — which is every Docker-generated config.php written
|
||||
// before the setting was documented. Without it the Add User dialog breaks
|
||||
// (Object.entries(null) in setting/users.php) and inviting a user fails
|
||||
// (array_keys(null) in setting/api/engine/manage_users.php).
|
||||
//
|
||||
// Keys must stay within the user.app_access enum: 'wms' and 'accounting'
|
||||
// ('all' is implied and never listed here).
|
||||
|
||||
if (!isset($app_registry) || !is_array($app_registry) || !$app_registry) {
|
||||
$app_registry = [
|
||||
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
|
||||
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
|
||||
];
|
||||
}
|
||||
@@ -99,7 +99,7 @@ class CompanyProfileManager
|
||||
|
||||
public function saveProfile(array $data, string $company_logo, string $company_seal): void
|
||||
{
|
||||
$channel = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
|
||||
$channel = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE company_list SET
|
||||
|
||||
@@ -79,7 +79,8 @@ class StockManager {
|
||||
public function getStockList(int $warehouse_id, string $type): array
|
||||
{
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
|
||||
// The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0).
|
||||
$column = in_array($type, ['out', 'transfer'], true) ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
|
||||
$stock_ref = $this->stockReferenceSql();
|
||||
|
||||
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
|
||||
@@ -439,8 +440,9 @@ class StockManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Quantity and identifiers come from the existing stock_in row (immutable)
|
||||
$quantity = (int)$source_stock['in'];
|
||||
// Quantity and identifiers come from the existing stock_in row (immutable).
|
||||
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
|
||||
$quantity = (float)$source_stock['in'];
|
||||
$ref_id = (int)$source_stock['id'];
|
||||
$lot_number = $source_stock['lot_number'] ?? null;
|
||||
$serial_number = $source_stock['serial_number'] ?? null;
|
||||
@@ -611,8 +613,9 @@ class StockManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Quantity and identifiers come from the source stock_in row (immutable)
|
||||
$quantity = (int)$source_stock['in'];
|
||||
// Quantity and identifiers come from the source stock_in row (immutable).
|
||||
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
|
||||
$quantity = (float)$source_stock['in'];
|
||||
$lot_number = $source_stock['lot_number'] ?? null;
|
||||
$serial_number = $source_stock['serial_number'] ?? null;
|
||||
|
||||
|
||||
@@ -697,7 +697,7 @@ class FinancialReports
|
||||
COALESCE(d.dept_code, '') AS dept_code,
|
||||
COALESCE(d.dept_name, '') AS dept_name,
|
||||
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
||||
i.debit,
|
||||
i.credit,
|
||||
COALESCE(i.description, '') AS line_description
|
||||
|
||||
@@ -75,8 +75,8 @@ class GlQueryManager
|
||||
g.current_version,
|
||||
g.formula_id,
|
||||
COALESCE(f.formula_name, '') AS formula_name,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at,
|
||||
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at,
|
||||
CASE g.source_type
|
||||
WHEN 'receipt' THEN r.receipt_number
|
||||
WHEN 'payment' THEN p.payment_number
|
||||
@@ -142,8 +142,8 @@ class GlQueryManager
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT g.*,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt,
|
||||
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at_fmt,
|
||||
DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt,
|
||||
COALESCE(f.formula_name, '') AS formula_name
|
||||
FROM td_gl g
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
// app/assets/utils/otp_policy.php
|
||||
//
|
||||
// Email OTP login policy, set by OTP_REQUIRED in config.php.
|
||||
//
|
||||
// OFF BY DEFAULT: the OTP step runs only when the constant is defined and is
|
||||
// exactly the boolean true. A missing constant (any config.php written before
|
||||
// this switch existed), 1, 'true' or a typo all leave it off, so sign-in is
|
||||
// password only and no SMTP is needed to log in.
|
||||
//
|
||||
// While it is off, every sign-in that skips the OTP because of it is logged as
|
||||
// OTP_BYPASSED, and the login page and top bar both say so on screen — a
|
||||
// password-only sign-in must never be invisible to whoever is using it.
|
||||
//
|
||||
// Only the login OTP is affected. When it is on, the staff/viewer and no-SMTP
|
||||
// skips in login_otp.php still apply; password-reset OTPs (PasswordResetManager)
|
||||
// are a separate flow that stays on regardless.
|
||||
|
||||
if (!function_exists('otp_required')) {
|
||||
function otp_required(): bool {
|
||||
return defined('OTP_REQUIRED') && OTP_REQUIRED === true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('otp_log_bypass')) {
|
||||
// There is no auth log table in this app, so bypasses go to the PHP error
|
||||
// log (the container's Apache log) under a fixed, greppable tag.
|
||||
function otp_log_bypass($user_id, string $where): void {
|
||||
error_log(sprintf(
|
||||
'[auth] OTP_BYPASSED user_id=%d ip=%s where=%s -- OTP_REQUIRED is not true in config.php',
|
||||
(int)$user_id,
|
||||
$_SERVER['REMOTE_ADDR'] ?? '-',
|
||||
$where
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,24 @@ if (!defined('NODE_EMIT_SECRET')) {
|
||||
define('NODE_EMIT_SECRET', 'YOUR_NODE_EMIT_SECRET'); // must match nodejs/.env EMIT_SECRET
|
||||
}
|
||||
|
||||
// ── Login OTP ────────────────────────────────────────────────────────────────
|
||||
// Email OTP on sign-in. OFF BY DEFAULT: only the boolean true turns it on —
|
||||
// anything else, the constant being absent included, leaves sign-in password
|
||||
// only (logged as OTP_BYPASSED, shown on the login page and top bar). Turn it
|
||||
// on only with working SMTP. Password-reset OTPs are not affected.
|
||||
if (!defined('OTP_REQUIRED')) {
|
||||
define('OTP_REQUIRED', false);
|
||||
}
|
||||
|
||||
// ── App registry ─────────────────────────────────────────────────────────────
|
||||
// Apps a user can be given access to, as shown on Setting → Users Access. Keys
|
||||
// must match the user.app_access enum ('wms', 'accounting'). If this is left
|
||||
// out, assets/utils/app_registry.php supplies the same default.
|
||||
$app_registry = [
|
||||
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
|
||||
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
|
||||
];
|
||||
|
||||
// ── Usage packages ───────────────────────────────────────────────────────────
|
||||
// Keyed by company_list.package (defaults to 'starter'). Read by UsageGuard to
|
||||
// enforce daily/weekly action limits and which features lock once exceeded.
|
||||
|
||||
@@ -27,6 +27,11 @@ class db_statement extends PDOStatement {
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
// PDOStatement::execute() is declared ?array $params = null : bool. This
|
||||
// override deliberately accepts a looser signature so callers may pass
|
||||
// positional arguments (see func_get_args() below), so the tightened return
|
||||
// type is opted out of rather than the call sites being changed.
|
||||
#[\ReturnTypeWillChange]
|
||||
public function execute($args = null) {
|
||||
// Perform logging here. PDO object is accessible
|
||||
// from $this->pdo.
|
||||
|
||||
@@ -230,7 +230,7 @@
|
||||
$('#badge_status').html(invoice_status_badge(inv.status, inv.due_date));
|
||||
$('#display_contact').text(inv.contact_name || '—');
|
||||
$('#display_issued').text(format_date(inv.issued_date) || '—');
|
||||
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
|
||||
$('#notes').val(inv.notes || '');
|
||||
|
||||
// Source link
|
||||
|
||||
@@ -274,8 +274,8 @@
|
||||
$('#po_number_display').text(po_data.po_number || '');
|
||||
$('#contact').val(po_data.contact_name || '');
|
||||
$('#contact_id').val(po_data.contact_id || '');
|
||||
$('#po_date').val(po_data.po_date ? format_date(po_data.po_date) : '');
|
||||
$('#expected_date').val(po_data.expected_date ? format_date(po_data.expected_date) : '');
|
||||
$('#po_date').val(po_data.po_date ? format_date_input(po_data.po_date) : '');
|
||||
$('#expected_date').val(po_data.expected_date ? format_date_input(po_data.expected_date) : '');
|
||||
$('#department_id').val(po_data.department_id || 0);
|
||||
$('#notes').val(po_data.notes || '');
|
||||
$('#discount').val(po_data.discount || 0);
|
||||
|
||||
@@ -253,8 +253,8 @@
|
||||
.html(request_data.po_id > 0 ? 'PO: <a href="<?php echo $server_url?>expense/manage_purchase_order.php?id=' + request_data.po_id + '">' + escape_html(request_data.po_number || ('PO #' + request_data.po_id)) + '</a>' : '');
|
||||
$('#contact').val(request_data.contact_name || '');
|
||||
$('#contact_id').val(request_data.contact_id || 0);
|
||||
$('#request_date').val(request_data.request_date ? format_date(request_data.request_date) : '');
|
||||
$('#required_date').val(request_data.required_date ? format_date(request_data.required_date) : '');
|
||||
$('#request_date').val(request_data.request_date ? format_date_input(request_data.request_date) : '');
|
||||
$('#required_date').val(request_data.required_date ? format_date_input(request_data.required_date) : '');
|
||||
$('#department_id').val(request_data.department_id || 0);
|
||||
$('#notes').val(request_data.notes || '');
|
||||
$('#discount').val(request_data.discount || 0);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/WarehouseManager.php';
|
||||
|
||||
// What one location holds — the quantity a stock-out or transfer from it moves
|
||||
// (StockManager takes the whole approved stock-in row in the bin).
|
||||
try {
|
||||
$whMgmt = new WarehouseManager($pdo2, $company_id);
|
||||
$row = $whMgmt->getBinStock(
|
||||
(int)($data['warehouse'] ?? 0),
|
||||
$data['zone'] ?? '',
|
||||
$data['aisle'] ?? '',
|
||||
$data['bin'] ?? ''
|
||||
);
|
||||
|
||||
$output = null;
|
||||
if ($row) {
|
||||
$sth = $pdo2->prepare("SELECT uom FROM md_product WHERE company_id = :c AND sku = :sku LIMIT 1");
|
||||
$sth->execute([':c' => $company_id, ':sku' => $row['product_sku']]);
|
||||
$output = [
|
||||
'product_sku' => $row['product_sku'],
|
||||
'lot_number' => $row['lot_number'],
|
||||
'serial_number' => $row['serial_number'],
|
||||
'quantity' => (float)$row['in'],
|
||||
'uom' => (string)($sth->fetchColumn() ?: ''),
|
||||
];
|
||||
}
|
||||
|
||||
$answer['output'] = $output;
|
||||
$answer['success'] = 1;
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -93,6 +93,16 @@
|
||||
</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;"> </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>
|
||||
@@ -159,6 +169,35 @@
|
||||
}
|
||||
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(' ');
|
||||
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 || '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -483,6 +522,8 @@
|
||||
$('#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(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -509,7 +550,7 @@
|
||||
$('#serial_number').html(`<option value="${data.serial_number || ''}">${data.serial_number || '—'}</option>`)
|
||||
.val(data.serial_number || '').prop('disabled', true);
|
||||
|
||||
$('#warehouse').val('<?php echo $_GET["wh"];?>').prop('disabled', true);
|
||||
$('#warehouse').val('<?php echo (int)($_GET["wh"] ?? 0);?>').prop('disabled', true);
|
||||
|
||||
function populate_fields() {
|
||||
$.each(data, function(key, item) {
|
||||
@@ -522,6 +563,8 @@
|
||||
.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) {
|
||||
@@ -619,6 +662,9 @@
|
||||
if (advanced) retrieve_bin();
|
||||
});
|
||||
|
||||
// Bin selected → show how much it holds
|
||||
$('#bin').on('change', show_location_quantity);
|
||||
|
||||
<?php } ?>
|
||||
</script>
|
||||
|
||||
|
||||
@@ -92,6 +92,15 @@
|
||||
<option value="">Please select bin</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Quantity: a transfer always moves everything in the from-location -->
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label for="transfer_quantity" class="form-label">Quantity</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="transfer_quantity" class="form-control text-end" placeholder="—" disabled>
|
||||
<span class="input-group-text" id="transfer_uom" style="min-width:52px;"> </span>
|
||||
</div>
|
||||
<div class="form-text">Whole location is moved.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12"><hr class="my-2"></div>
|
||||
|
||||
@@ -204,12 +213,41 @@
|
||||
|
||||
if (role === 'from') {
|
||||
scan_from_ready = true;
|
||||
show_from_quantity();
|
||||
} else {
|
||||
scan_to_ready = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Quantity held in the from-location — the amount this transfer moves.
|
||||
function show_from_quantity() {
|
||||
var bin_val = $('#bin_from').val();
|
||||
$('#transfer_quantity').val('');
|
||||
$('#transfer_uom').html(' ');
|
||||
if (!$('#warehouse_from').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_from').val(),
|
||||
zone: advanced ? $('#zone_from').val() : bin_val,
|
||||
aisle: advanced ? $('#aisle_from').val() : bin_val,
|
||||
bin: bin_val
|
||||
},
|
||||
onSuccess: function(res) {
|
||||
if (!res.output) return;
|
||||
$('#transfer_quantity').val(format_number(res.output.quantity, 2));
|
||||
$('#transfer_uom').text(res.output.uom || '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function same_location_as_from(data) {
|
||||
return String($('#warehouse_from').val()) === String(data.warehouse_id)
|
||||
&& String($('#zone_from').val()) === String(data.zone)
|
||||
@@ -574,6 +612,8 @@
|
||||
$('#zone_from').html('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
|
||||
$('#aisle_from').html('<option value="">Please select ' + label_aisle.toLowerCase() + '</option>').prop('disabled', true);
|
||||
$('#bin_from').html('<option value="">Please select ' + label_bin.toLowerCase() + '</option>').prop('disabled', true);
|
||||
$('#transfer_quantity').val('');
|
||||
$('#transfer_uom').html(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -640,6 +680,8 @@
|
||||
$('#contact').val(data.contact_name)
|
||||
.attr('secondary', data.contact_name)
|
||||
.attr('data-id', data.contact_id);
|
||||
$('#transfer_quantity').val(format_number(data.quantity, 2));
|
||||
$('#transfer_uom').text(data.uom || '');
|
||||
$('button[type=submit]').text('Update');
|
||||
$('button[type=reset]').hide();
|
||||
if (data.status == 0) {
|
||||
@@ -732,6 +774,9 @@
|
||||
if (advanced) retrieve_bin('from');
|
||||
});
|
||||
|
||||
// From bin → show how much it holds
|
||||
$('#bin_from').on('change', show_from_quantity);
|
||||
|
||||
// To warehouse → zone (advanced) or bin directly (simple)
|
||||
$('select[name="warehouse"][role="to"]').on('change', function() {
|
||||
if (advanced) { retrieve_zone('to'); } else { retrieve_bin('to'); }
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
<?php
|
||||
// Output buffering must be active before the first byte of HTML below, so that
|
||||
// header() calls made later in the page still work — notably the
|
||||
// not-logged-in redirect in include_topbar.php, which runs *after* this file
|
||||
// has already emitted <!DOCTYPE html>. Without a buffer that redirect depends
|
||||
// entirely on php.ini's output_buffering: it is on for the dev stack but off
|
||||
// in production, where every protected page answered 200 with a half-rendered
|
||||
// body instead of sending the browser to the login form. session.php starts a
|
||||
// buffer for the same reason.
|
||||
if (ob_get_level() === 0) {
|
||||
ob_start();
|
||||
}
|
||||
|
||||
// Never render PHP notices/warnings into the page: they leak absolute server
|
||||
// paths to anonymous visitors and corrupt the markup. Errors still reach the
|
||||
// server log. This mirrors the policy db_auth.php already applies to the JSON
|
||||
// API routes, and keeps the app safe even where php.ini has display_errors on.
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
// Security headers — emitted before any HTML output.
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: SAMEORIGIN');
|
||||
|
||||
+21
-1
@@ -3,11 +3,17 @@
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/dbconn.php';
|
||||
require_once __DIR__ . '/assets/utils/classes/UsageGuard.php';
|
||||
require_once __DIR__ . '/assets/utils/otp_policy.php';
|
||||
|
||||
// Redirect to login if the user has not completed full authentication.
|
||||
// login_company_id is only written by login_confirm.php after OTP is verified —
|
||||
// using it (not "otp") ensures half-logged-in sessions are also redirected.
|
||||
if(empty($_SESSION["login_company_id"])){
|
||||
// Discard the markup include_header.php has already buffered so the browser
|
||||
// receives a clean redirect rather than a partially rendered page body.
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
header('Location: '.$server_url.'login/index.php');
|
||||
exit;
|
||||
}
|
||||
@@ -193,6 +199,18 @@ $_usage_full = $_usage_max_pct >= 100;
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Email OTP off (the default): a password-only sign-in must never be invisible to whoever is using it -->
|
||||
<?php if (!otp_required()): ?>
|
||||
<li class="d-none d-md-block">
|
||||
<span class="badge bg-warning text-dark d-flex align-items-center gap-1 px-2 py-1"
|
||||
style="font-size:11px; cursor:default;"
|
||||
title="OTP_REQUIRED is not true in config.php">
|
||||
<i class="ti ti-shield-off"></i>
|
||||
OTP off
|
||||
</span>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Usage limit warning -->
|
||||
<?php if ($_usage_full || $_usage_warn): ?>
|
||||
<li>
|
||||
@@ -402,7 +420,9 @@ function do_switch_branch(company_id) {
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'update',
|
||||
company_id: company_id,
|
||||
// Sent under its own key: prepare_form_data() always fills company_id with
|
||||
// the CURRENT company, and only options.data reaches the payload.
|
||||
data: { target_company_id: company_id },
|
||||
onSuccess: function(res) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
|
||||
document.getElementById('gl_detail_body').innerHTML =
|
||||
'<div class="row g-3 mb-4">' +
|
||||
'<div class="col-6"><p class="text-muted small mb-0">Date</p><strong>' + escape_html(h.journal_date_fmt || h.journal_date || '—') + '</strong></div>' +
|
||||
'<div class="col-6"><p class="text-muted small mb-0">Date</p><strong>' + escape_html(format_date(h.journal_date)) + '</strong></div>' +
|
||||
'<div class="col-6"><p class="text-muted small mb-0">Period</p><strong>' + escape_html(h.period) + '</strong></div>' +
|
||||
'<div class="col-6"><p class="text-muted small mb-0">Reference</p><strong>' + escape_html(h.reference || '—') + '</strong></div>' +
|
||||
'<div class="col-6"><p class="text-muted small mb-0">Source</p><strong>' + escape_html(src_labels[h.source_type] || h.source_type) + '</strong></div>' +
|
||||
|
||||
@@ -58,6 +58,7 @@ require_once '../../../config.php';
|
||||
require_once '../../../preset.php';
|
||||
define('UNAUTHENTICATED_ROUTE', true);
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/otp_policy.php';
|
||||
|
||||
// ── Step 1: Load session state written by login_otp.php ───────────────────────
|
||||
$data["username"] = $_SESSION["login_data"]['username'];
|
||||
@@ -100,9 +101,15 @@ $_SESSION["diff"] = $otp_diff_minutes;
|
||||
|
||||
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
|
||||
// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session
|
||||
// so they never receive or enter an OTP. Admin/owner always go through this check.
|
||||
// so they never receive or enter an OTP. Admin/owner always go through this check,
|
||||
// unless OTP_REQUIRED=false in config.php: that also covers a user who was already
|
||||
// on the OTP screen when the switch was turned off.
|
||||
if (empty($_SESSION['skip_otp'])) {
|
||||
if ($data["otp"] != $otp || $otp_diff_minutes > 5) {
|
||||
if (!otp_required()) {
|
||||
if (!empty($user_id)) {
|
||||
otp_log_bypass($user_id, 'login_confirm');
|
||||
}
|
||||
} elseif ($data["otp"] != $otp || $otp_diff_minutes > 5) {
|
||||
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ require_once '../../../config.php';
|
||||
require_once '../../../preset.php';
|
||||
define('UNAUTHENTICATED_ROUTE', true);
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/otp_policy.php';
|
||||
|
||||
// ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
|
||||
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
|
||||
@@ -253,11 +254,15 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Step 5g: Role check — staff/viewer skip OTP entirely ─────────────────
|
||||
// Owners always require 2FA. Invited users (license='user') require 2FA only
|
||||
// ── Step 5g: OTP policy, then role check — staff/viewer skip OTP entirely ─
|
||||
// OTP_REQUIRED=false in config.php turns the email OTP off for everyone and
|
||||
// logs the sign-in as a bypass (see assets/utils/otp_policy.php).
|
||||
// Otherwise owners always require 2FA. Invited users (license='user') require 2FA only
|
||||
// if their role in this company is admin or owner; staff/viewer go straight in.
|
||||
$requires_otp = true;
|
||||
if (($r['license'] ?? 'owner') !== 'owner') {
|
||||
$requires_otp = otp_required();
|
||||
if (!$requires_otp) {
|
||||
otp_log_bypass($user_id, 'login_otp');
|
||||
} elseif (($r['license'] ?? 'owner') !== 'owner') {
|
||||
$sth_role = $pdo1->prepare(
|
||||
"SELECT role FROM company_map_user WHERE company_id = :cid AND user_id = :uid LIMIT 1"
|
||||
);
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
* 2. CSRF check — rejects requests missing a valid X-CSRF-Token header.
|
||||
* 3. Decode and sanitise input fields.
|
||||
* 4. Required field validation — company_name and channel_name must be non-empty.
|
||||
* 5. Required SMTP validation — smtp_host, smtp_username, smtp_password
|
||||
* must all be provided (company SMTP is mandatory for WMS email delivery).
|
||||
* 5. SMTP validation — smtp_host, smtp_username, smtp_password must all be
|
||||
* provided while email OTP is on (company SMTP delivers the OTP). With
|
||||
* OTP_REQUIRED=false they are optional but all-or-nothing: left blank,
|
||||
* steps 6-8 and 13 are skipped and the company is created without SMTP.
|
||||
* 6. Normalise smtp_port to one of ['25', '465', '587'] (default: 587).
|
||||
* Normalise smtp_encryption to one of ['tls', 'ssl', 'none'] (default: tls).
|
||||
* 7. Encrypt SMTP password with OpenSSL (same method/iv/key as rest of app).
|
||||
@@ -52,6 +54,7 @@ require_once '../../../session.php';
|
||||
require_once '../../../config.php';
|
||||
require_once '../../../dbconn.php';
|
||||
require_once '../../../assets/utils/db_helpers.php';
|
||||
require_once '../../../assets/utils/otp_policy.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
@@ -97,9 +100,9 @@ try {
|
||||
$company_name = trim($data['company_name'] ?? '');
|
||||
$company_name2 = trim($data['company_name2'] ?? '');
|
||||
|
||||
// channel_name is the URL slug / identifier — strip everything except
|
||||
// lowercase letters, digits, hyphens, and underscores.
|
||||
$channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
|
||||
// channel_name is the URL slug / identifier — lowercase first, then strip
|
||||
// everything except lowercase letters, digits, hyphens, and underscores.
|
||||
$channel_name = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
|
||||
|
||||
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
|
||||
$branch_no = trim($data['branch_no'] ?? '00000');
|
||||
@@ -114,59 +117,67 @@ try {
|
||||
}
|
||||
|
||||
// ── Step 5: SMTP field validation ────────────────────────────────────────
|
||||
// SMTP is mandatory because the company needs to send OTP emails to users.
|
||||
// An account without working SMTP would be unable to complete 2FA login.
|
||||
// While email OTP is on, SMTP is mandatory: the company needs it to send OTP
|
||||
// emails, and an account without working SMTP could not complete 2FA login.
|
||||
// With OTP_REQUIRED=false in config.php it is optional — all three fields
|
||||
// left blank means "no SMTP", and the test send (step 8) and the company_smtp
|
||||
// row (step 13) are skipped. Partly filled is an error either way.
|
||||
$smtp_host = trim($data['smtp_host'] ?? '');
|
||||
$smtp_username = trim($data['smtp_username'] ?? '');
|
||||
$smtp_password = $data['smtp_password'] ?? '';
|
||||
$smtp_given = ($smtp_host !== '' || $smtp_username !== '' || $smtp_password !== '');
|
||||
|
||||
if (!$smtp_host || !$smtp_username || !$smtp_password) {
|
||||
$answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.';
|
||||
if ((otp_required() || $smtp_given) && (!$smtp_host || !$smtp_username || !$smtp_password)) {
|
||||
$answer['message'] = otp_required()
|
||||
? 'SMTP configuration is required. Please fill in all SMTP fields.'
|
||||
: 'Fill in SMTP host, username and password, or leave all three blank.';
|
||||
http_response_code(422);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Step 6: Normalise SMTP port and encryption ────────────────────────────
|
||||
// Clamp to known-good values to prevent storing unsupported configuration.
|
||||
$smtp_port = trim($data['smtp_port'] ?? '587');
|
||||
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
|
||||
if ($smtp_given) {
|
||||
// ── Step 6: Normalise SMTP port and encryption ────────────────────────
|
||||
// Clamp to known-good values to prevent storing unsupported configuration.
|
||||
$smtp_port = trim($data['smtp_port'] ?? '587');
|
||||
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
|
||||
|
||||
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
|
||||
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
|
||||
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
|
||||
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
|
||||
|
||||
// ── Step 7: Encrypt SMTP password ────────────────────────────────────────
|
||||
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
|
||||
// so the stored password can be decrypted by the mailer module.
|
||||
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
|
||||
// ── Step 7: Encrypt SMTP password ────────────────────────────────────
|
||||
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
|
||||
// so the stored password can be decrypted by the mailer module.
|
||||
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
|
||||
|
||||
// Assemble a temporary SMTP config for the test send (step 8)
|
||||
$smtp_config = [
|
||||
'server' => $smtp_host,
|
||||
'port' => $smtp_port,
|
||||
'username' => $smtp_username,
|
||||
'password' => $encrypted_pass,
|
||||
'from_name' => $company_name ?: $smtp_username,
|
||||
'from_email' => $email ?: $smtp_username,
|
||||
'encryption' => $smtp_encryption,
|
||||
];
|
||||
// Assemble a temporary SMTP config for the test send (step 8)
|
||||
$smtp_config = [
|
||||
'server' => $smtp_host,
|
||||
'port' => $smtp_port,
|
||||
'username' => $smtp_username,
|
||||
'password' => $encrypted_pass,
|
||||
'from_name' => $company_name ?: $smtp_username,
|
||||
'from_email' => $email ?: $smtp_username,
|
||||
'encryption' => $smtp_encryption,
|
||||
];
|
||||
|
||||
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────────
|
||||
// Sends a test email to the onboarding user's registered address.
|
||||
// If the mailer throws or exits, no DB records have been created yet,
|
||||
// so the user can correct their SMTP settings and retry cleanly.
|
||||
require_once '../../../assets/utils/module/mailer.php';
|
||||
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────
|
||||
// Sends a test email to the onboarding user's registered address.
|
||||
// If the mailer throws or exits, no DB records have been created yet,
|
||||
// so the user can correct their SMTP settings and retry cleanly.
|
||||
require_once '../../../assets/utils/module/mailer.php';
|
||||
|
||||
$mailer = new mailer(['pdo1' => $pdo1]);
|
||||
$mailer->send_email([
|
||||
'company_id' => 0,
|
||||
'smtp' => $smtp_config,
|
||||
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
|
||||
'subject' => 'WMS — SMTP Verification',
|
||||
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
|
||||
'channel_name' => $company_name ?: 'WMS',
|
||||
'key' => $pinkey,
|
||||
]);
|
||||
// If mailer fails, it calls exit() internally — nothing below this line runs.
|
||||
$mailer = new mailer(['pdo1' => $pdo1]);
|
||||
$mailer->send_email([
|
||||
'company_id' => 0,
|
||||
'smtp' => $smtp_config,
|
||||
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
|
||||
'subject' => 'WMS — SMTP Verification',
|
||||
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
|
||||
'channel_name' => $company_name ?: 'WMS',
|
||||
'key' => $pinkey,
|
||||
]);
|
||||
// If mailer fails, it calls exit() internally — nothing below this line runs.
|
||||
}
|
||||
|
||||
// ── Step 9: Duplicate channel_name check ─────────────────────────────────
|
||||
// channel_name is the unique identifier used in URLs and API calls — must be globally unique.
|
||||
@@ -226,25 +237,29 @@ try {
|
||||
// ── Step 13: Save company SMTP settings ──────────────────────────────────
|
||||
// Stored with the encrypted password so the mailer module can decrypt and
|
||||
// use it for all outgoing email from this company (OTP, notifications, etc.).
|
||||
$sth = $pdo1->prepare("
|
||||
INSERT INTO company_smtp
|
||||
(company_id, server, port, username, password,
|
||||
from_name, from_email, encryption, updated_at)
|
||||
VALUES
|
||||
(:company_id, :server, :port, :username, :password,
|
||||
:from_name, :from_email, :encryption, NOW())
|
||||
");
|
||||
$sth->execute([
|
||||
':company_id' => $company_id,
|
||||
':server' => $smtp_host,
|
||||
':port' => $smtp_port,
|
||||
':username' => $smtp_username,
|
||||
':password' => $encrypted_pass,
|
||||
':from_name' => $company_name,
|
||||
':from_email' => $email ?: $smtp_username,
|
||||
':encryption' => $smtp_encryption,
|
||||
]);
|
||||
db_check($sth, $answer);
|
||||
// Skipped when no SMTP was given (only allowed with OTP_REQUIRED=false); it
|
||||
// can be added later under Settings → SMTP.
|
||||
if ($smtp_given) {
|
||||
$sth = $pdo1->prepare("
|
||||
INSERT INTO company_smtp
|
||||
(company_id, server, port, username, password,
|
||||
from_name, from_email, encryption, updated_at)
|
||||
VALUES
|
||||
(:company_id, :server, :port, :username, :password,
|
||||
:from_name, :from_email, :encryption, NOW())
|
||||
");
|
||||
$sth->execute([
|
||||
':company_id' => $company_id,
|
||||
':server' => $smtp_host,
|
||||
':port' => $smtp_port,
|
||||
':username' => $smtp_username,
|
||||
':password' => $encrypted_pass,
|
||||
':from_name' => $company_name,
|
||||
':from_email' => $email ?: $smtp_username,
|
||||
':encryption' => $smtp_encryption,
|
||||
]);
|
||||
db_check($sth, $answer);
|
||||
}
|
||||
|
||||
// ── Step 14: Clear onboarding session keys ───────────────────────────────
|
||||
// These keys are no longer needed and should not persist into the
|
||||
|
||||
+18
-2
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
require '../session.php';
|
||||
require '../config.php';
|
||||
require_once '../assets/utils/otp_policy.php';
|
||||
require '../include_header.php';
|
||||
// successful login — redirect based on app_access
|
||||
if(!empty($_SESSION["login_status"])){
|
||||
@@ -32,6 +33,13 @@
|
||||
</div>
|
||||
|
||||
<form class="needs-validation mt-3" novalidate id="login-form">
|
||||
<?php if (!otp_required()): ?>
|
||||
<!-- OTP_REQUIRED is not true in config.php (the default): a password-only sign-in must never be invisible -->
|
||||
<div class="alert alert-warning small py-2 mb-3" title="OTP_REQUIRED is not true in config.php">
|
||||
<i class="ti ti-alert-triangle me-1"></i>
|
||||
Email OTP is off — sign-in is password only.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<!-- first step login [OTP] -->
|
||||
<?php if(!isset($_SESSION['login_data'])){?>
|
||||
<div class="mb-3">
|
||||
@@ -51,7 +59,7 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<!-- "Remember me" is intentionally excluded.
|
||||
This login uses 2FA (OTP via email) on every session.
|
||||
This login uses 2FA (OTP via email) on every session when OTP_REQUIRED=true in config.php (off by default).
|
||||
A persistent login would bypass the OTP step and undermine the security model.
|
||||
Do not add this back. -->
|
||||
</div>
|
||||
@@ -62,6 +70,7 @@
|
||||
</p>
|
||||
<?php }else{ ?>
|
||||
<!-- second step login -->
|
||||
<?php if (otp_required()): ?>
|
||||
<div class="alert alert-warning small py-2 mb-3">
|
||||
<i class="ti ti-mail me-1"></i>
|
||||
OTP is sent via your company's SMTP setting.
|
||||
@@ -73,13 +82,20 @@
|
||||
<span>One Time Password</span>
|
||||
</label>
|
||||
<input id="otp" type="otp" class="form-control"
|
||||
placeholder="your otp for reference number <?php echo $_SESSION["reference"]?>" required minlength="6">
|
||||
placeholder="your otp for reference number <?php echo $_SESSION["reference"] ?? ''?>" required minlength="6">
|
||||
<div class="invalid-feedback">Please provide a otp (min 6 characters).</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<!-- OTP_REQUIRED was switched off while this session sat on the OTP step:
|
||||
login_confirm.php no longer checks the code, so there is nothing to type. -->
|
||||
<input id="otp" type="hidden" value="">
|
||||
<?php endif; ?>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label d-flex justify-content-between">
|
||||
<a href="javascript:;" class="small link-primary" onclick="back()">Back</a>
|
||||
<?php if (otp_required()): ?>
|
||||
<a href="javascript:;" class="small link-primary" onclick="request_new_otp();">Request New OTP</a>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100" onclick="login_confirm();">Sign in</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
require '../session.php';
|
||||
require '../config.php';
|
||||
require_once '../assets/utils/otp_policy.php';
|
||||
|
||||
// Must come from email verification
|
||||
if (empty($_SESSION['onboarding_user_id'])) {
|
||||
@@ -48,7 +49,8 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Channel Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop">
|
||||
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
|
||||
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
|
||||
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
@@ -77,11 +79,20 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<h2 class="fs-5 mb-0"><i class="ti ti-mail-cog me-2"></i>SMTP / Email Setting</h2>
|
||||
<?php if (otp_required()): ?>
|
||||
<span class="badge bg-label-danger">Required</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-label-secondary">Optional</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p class="text-muted small mb-3">
|
||||
<?php if (otp_required()): ?>
|
||||
SMTP is required to send OTP during login.
|
||||
A verification email will be sent when you finish setup.
|
||||
<?php else: ?>
|
||||
Email OTP is turned off, so SMTP is optional. Leave it blank to skip;
|
||||
you can add it later under Settings → SMTP.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
||||
<!-- SMTP User Guide (collapsible) -->
|
||||
@@ -174,11 +185,11 @@
|
||||
<!-- SMTP Form -->
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">SMTP Host <span class="text-danger">*</span></label>
|
||||
<label class="form-label">SMTP Host <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
||||
<input type="text" class="form-control" id="smtp_host" placeholder="e.g. smtp.gmail.com">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Port <span class="text-danger">*</span></label>
|
||||
<label class="form-label">Port <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
||||
<select class="form-select" id="smtp_port">
|
||||
<option value="587">587 — TLS</option>
|
||||
<option value="465">465 — SSL</option>
|
||||
@@ -186,11 +197,11 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Username / Email <span class="text-danger">*</span></label>
|
||||
<label class="form-label">Username / Email <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
||||
<input type="text" class="form-control" id="smtp_username" placeholder="your@email.com">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Password <span class="text-danger">*</span></label>
|
||||
<label class="form-label">Password <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="smtp_password" placeholder="SMTP password">
|
||||
<button class="btn btn-outline-secondary toggle-pw" type="button" data-target="smtp_password">
|
||||
@@ -251,13 +262,23 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$('#smtp_host').val().trim() || !$('#smtp_username').val().trim() || !$('#smtp_password').val()) {
|
||||
bootbox.alert('SMTP host, username and password are required.');
|
||||
// Mirrors api/engine/onboarding.php: SMTP is required while email OTP is on;
|
||||
// with OTP_REQUIRED=false it is optional, but the three fields go together.
|
||||
const smtp_required = <?php echo otp_required() ? 'true' : 'false'; ?>;
|
||||
const smtp_host = $('#smtp_host').val().trim();
|
||||
const smtp_user = $('#smtp_username').val().trim();
|
||||
const smtp_pass = $('#smtp_password').val();
|
||||
const smtp_given = !!(smtp_host || smtp_user || smtp_pass);
|
||||
|
||||
if ((smtp_required || smtp_given) && (!smtp_host || !smtp_user || !smtp_pass)) {
|
||||
bootbox.alert(smtp_required
|
||||
? 'SMTP host, username and password are required.'
|
||||
: 'Fill in SMTP host, username and password, or leave all three blank.');
|
||||
return;
|
||||
}
|
||||
|
||||
const $btn = $('#btn_finish');
|
||||
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>Verifying SMTP…');
|
||||
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>' + (smtp_given ? 'Verifying SMTP…' : 'Setting up…'));
|
||||
|
||||
const encryption = $('input[name="smtp_encryption"]:checked').val();
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
$('#display_contact').text(inv.contact_name || '—');
|
||||
$('#display_department').text(get_dept_label(inv.department_id));
|
||||
$('#display_issued').text(format_date(inv.issued_date) || '—');
|
||||
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
|
||||
$('#notes').val(inv.notes || '');
|
||||
|
||||
var gl_type = inv.doc_type === 'credit_note' ? 'sales_credit_note' : 'sales_invoice';
|
||||
|
||||
@@ -493,7 +493,7 @@
|
||||
// Fields
|
||||
$('#contact').val(o.contact_name || '');
|
||||
$('#contact_id').val(o.contact_id);
|
||||
$('#order_date').val(o.order_date ? format_date(o.order_date) : '');
|
||||
$('#order_date').val(o.order_date ? format_date_input(o.order_date) : '');
|
||||
$('#department_id').val(o.department_id || 0);
|
||||
$('#notes').val(o.notes || '');
|
||||
$('#discount').val(o.discount || 0);
|
||||
|
||||
@@ -795,7 +795,7 @@
|
||||
$('#badge_status').html(return_status_badge(r.status));
|
||||
$('#return_number_display').text(r.return_number);
|
||||
|
||||
$('#return_date').val(r.return_date ? format_date(r.return_date) : '');
|
||||
$('#return_date').val(r.return_date ? format_date_input(r.return_date) : '');
|
||||
$('#reason').val(r.reason || '');
|
||||
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
|
||||
$('#display_department').text(get_dept_label(r.department_id));
|
||||
|
||||
@@ -682,8 +682,8 @@
|
||||
|
||||
$('#contact').val(o.contact_name || '');
|
||||
$('#contact_id').val(o.contact_id || '');
|
||||
$('#po_date').val(o.po_date ? format_date(o.po_date) : '');
|
||||
$('#expected_date').val(o.expected_date ? format_date(o.expected_date) : '');
|
||||
$('#po_date').val(o.po_date ? format_date_input(o.po_date) : '');
|
||||
$('#expected_date').val(o.expected_date ? format_date_input(o.expected_date) : '');
|
||||
$('#warehouse_id').val(o.warehouse_id || '');
|
||||
$('#department_id').val(o.department_id || 0);
|
||||
$('#notes').val(o.notes || '');
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
$('#display_contact').text(inv.contact_name || '—');
|
||||
$('#display_department').text(get_dept_label(inv.department_id));
|
||||
$('#display_issued').text(format_date(inv.issued_date) || '—');
|
||||
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
|
||||
$('#notes').val(inv.notes || '');
|
||||
|
||||
// Source link
|
||||
|
||||
@@ -432,7 +432,7 @@
|
||||
$('#badge_status').html(return_status_badge(r.status));
|
||||
$('#badge_fulfillment').html(fulfillment_status_badge(r.fulfillment_status));
|
||||
$('#return_number_display').text(r.return_number);
|
||||
$('#return_date').val(r.return_date ? format_date(r.return_date) : '');
|
||||
$('#return_date').val(r.return_date ? format_date_input(r.return_date) : '');
|
||||
$('#reason').val(r.reason || '');
|
||||
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
|
||||
$('#display_department').text(get_dept_label(r.department_id));
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
$('#display_contact').html(display_text(inv.contact_name));
|
||||
$('#display_issued').html(inv.issued_date ? format_date(inv.issued_date) : '<span class="text-muted">—</span>');
|
||||
$('#display_due').html(inv.due_date ? format_date(inv.due_date) : '<span class="text-muted">—</span>');
|
||||
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
|
||||
$('#display_notes').html(inv.notes ? escape_html(inv.notes).replace(/\n/g, '<br>') : '<span class="text-muted">—</span>');
|
||||
|
||||
var rows = '';
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
$('#order_number_display').text(o.order_number);
|
||||
$('#contact').val(o.contact_name || '');
|
||||
$('#contact_id').val(o.contact_id || 0);
|
||||
$('#order_date').val(o.order_date ? format_date(o.order_date) : '');
|
||||
$('#order_date').val(o.order_date ? format_date_input(o.order_date) : '');
|
||||
$('#notes').val(o.notes || '');
|
||||
$('#discount').val(o.discount || 0);
|
||||
$('#tax_adjustment').val(o.tax_adjustment || 0);
|
||||
|
||||
@@ -379,8 +379,8 @@
|
||||
|
||||
$('#contact').val(q.contact_name || '');
|
||||
$('#contact_id').val(q.contact_id || 0);
|
||||
$('#quotation_date').val(q.quotation_date ? format_date(q.quotation_date) : '');
|
||||
$('#valid_until').val(q.valid_until ? format_date(q.valid_until) : '');
|
||||
$('#quotation_date').val(q.quotation_date ? format_date_input(q.quotation_date) : '');
|
||||
$('#valid_until').val(q.valid_until ? format_date_input(q.valid_until) : '');
|
||||
$('#department_id').val(q.department_id || 0);
|
||||
$('#notes').val(q.notes || '');
|
||||
$('#discount').val(parseFloat(q.discount) || 0);
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
// app/session.php
|
||||
ob_start(); // ensure output buffering is on regardless of php.ini — prevents stray output from corrupting JSON API responses
|
||||
|
||||
// The buffer is still flushed, so notices would still land in front of the JSON
|
||||
// body and break the client's parse ("Server error occurred."). The login API
|
||||
// engines load this file instead of db_auth.php, so apply the same policy here.
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
|
||||
// Derive cookie path dynamically from the current script location.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/app_registry.php';
|
||||
require_once '../../../assets/utils/classes/UserManager.php';
|
||||
|
||||
if ($user_role !== 'owner') { http_response_code(403); exit(json_encode(['success' => 0, 'message' => 'Only the owner can modify this setting.'])); }
|
||||
@@ -30,7 +31,11 @@
|
||||
$result = $um->inviteUser($email, $role, $app_access);
|
||||
|
||||
// Both new and existing users require explicit acceptance via email
|
||||
$invite_url = rtrim($server_url, '/') . ($result['new_user']
|
||||
// Absolute URL: the link is opened from a mail client, where a bare
|
||||
// /app/... path goes nowhere. Same construction as register.php.
|
||||
$invite_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
|
||||
. '://' . $_SERVER['HTTP_HOST']
|
||||
. rtrim($server_url, '/') . ($result['new_user']
|
||||
? '/login/invited_onboarding.php?token=' . $result['token']
|
||||
: '/login/accept_invite.php?token=' . $result['token']);
|
||||
|
||||
@@ -88,7 +93,11 @@
|
||||
$map_id = (int)($data['map_id'] ?? 0);
|
||||
$result = $um->resendInvite($map_id);
|
||||
|
||||
$invite_url = rtrim($server_url, '/') . ($result['is_new_user']
|
||||
// Absolute URL: the link is opened from a mail client, where a bare
|
||||
// /app/... path goes nowhere. Same construction as register.php.
|
||||
$invite_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
|
||||
. '://' . $_SERVER['HTTP_HOST']
|
||||
. rtrim($server_url, '/') . ($result['is_new_user']
|
||||
? '/login/invited_onboarding.php?token=' . $result['token']
|
||||
: '/login/accept_invite.php?token=' . $result['token']);
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
* switch_branch.php — Switch the active company for the current session.
|
||||
*
|
||||
* action: 'read' → return list of companies the user belongs to
|
||||
* action: 'update' → switch to the requested company_id
|
||||
* action: 'update' → switch to target_company_id
|
||||
*
|
||||
* The target is read from target_company_id, not company_id: every request
|
||||
* carries company_id = the CURRENT company (prepare_form_data in custom.js),
|
||||
* so reading it made a switch silently re-select the company already active.
|
||||
*/
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
@@ -20,7 +24,7 @@ if ($action === 'read') {
|
||||
}
|
||||
|
||||
if ($action === 'update') {
|
||||
$target_company_id = (int)($data['company_id'] ?? 0);
|
||||
$target_company_id = (int)($data['target_company_id'] ?? 0);
|
||||
|
||||
if (!$target_company_id) {
|
||||
$answer['message'] = 'Invalid company.';
|
||||
|
||||
@@ -121,7 +121,8 @@
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Nickname / Channel Name</label>
|
||||
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop">
|
||||
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
|
||||
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
|
||||
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require_once '../assets/utils/app_registry.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
@@ -256,9 +257,7 @@
|
||||
const license_badge = license_html(u.license);
|
||||
const access_badge = app_access_html(u.app_access);
|
||||
|
||||
const joined = u.created_at
|
||||
? new Date(u.created_at).toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'})
|
||||
: '—';
|
||||
const joined = u.created_at ? format_date(String(u.created_at).split(' ')[0]) : '—';
|
||||
|
||||
const is_pending = u.status === 'pending' && u.is_pending_invite == 1;
|
||||
|
||||
|
||||
+4
-2
@@ -532,9 +532,11 @@ foreach ($stockout_defs as $so) {
|
||||
$qty = $so['qty'];
|
||||
$cost = $p['cost'];
|
||||
$uom = $p['uom'];
|
||||
// The batch is bought in from a supplier; only the stock-out goes to the customer.
|
||||
$supplier_id = $supplier_ids[0];
|
||||
|
||||
dbTransaction($pdo2, function ($pdo2) use (
|
||||
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id,
|
||||
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id, $supplier_id,
|
||||
$logging, $dispatch_in_marker, $dispatch_out_marker
|
||||
) {
|
||||
$bin = findFreeBin($pdo2, $company_id, $main_wh_id);
|
||||
@@ -549,7 +551,7 @@ foreach ($stockout_defs as $so) {
|
||||
'zone' => $bin,
|
||||
'aisle' => $bin,
|
||||
'bin' => $bin,
|
||||
'contact_id' => $customer_id,
|
||||
'contact_id' => $supplier_id,
|
||||
'description' => $dispatch_in_marker,
|
||||
], $logging, $in_uuid);
|
||||
$stockMgmt->approveStock($stock_id, $main_wh_id, 'in', $whMgmt);
|
||||
|
||||
@@ -39,7 +39,7 @@ function buildLineItem(array $products, string $sku, float $qty): array {
|
||||
$tax_amount = round($total_price * $tax_rate / 100, 4);
|
||||
return [
|
||||
'product_sku' => $sku,
|
||||
'product_name' => $sku,
|
||||
'product_name' => $p['product_name'],
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unit_price,
|
||||
'total_price' => $total_price,
|
||||
@@ -69,11 +69,16 @@ $sth->execute([':c' => $company_id]);
|
||||
$sales_dept_id = (int)$sth->fetchColumn();
|
||||
if (!$sales_dept_id) { exit("ERROR: SALES department not found — run demo_seed_transactions.php first.\n"); }
|
||||
|
||||
$sth = $pdo2->prepare("SELECT sku, price FROM md_product WHERE company_id = :c");
|
||||
$sth = $pdo2->prepare("SELECT sku, product_name, price FROM md_product WHERE company_id = :c");
|
||||
$sth->execute([':c' => $company_id]);
|
||||
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
|
||||
|
||||
$sth = $pdo2->prepare("SELECT id, contact_name FROM md_contact WHERE company_id = :c AND contact_type = 1 ORDER BY id");
|
||||
// contact_type holds an md_contact_type id — resolve 'Customer' by name, not by a fixed id.
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT c.id, c.contact_name FROM md_contact c
|
||||
JOIN md_contact_type t ON t.company_id = c.company_id AND t.id = c.contact_type
|
||||
WHERE c.company_id = :c AND t.contact_type = 'Customer' ORDER BY c.id"
|
||||
);
|
||||
$sth->execute([':c' => $company_id]);
|
||||
$customers = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$customer_ids = array_column($customers, 'id');
|
||||
|
||||
+46
-173
@@ -5,18 +5,16 @@
|
||||
*
|
||||
* Extends the base demo data (demo_seed.php: company, warehouses, products,
|
||||
* contacts, opening stock) with transactional data covering the rest of the
|
||||
* app's features:
|
||||
* app's features (restock / dispatch / transfer movements are seeded by demo_seed.php):
|
||||
*
|
||||
* 1. Additional stock-in replenishment (restock events)
|
||||
* 2. Stock-out (direct dispatch) + stock transfer (Main -> Bangna)
|
||||
* 3. Chart of accounts, departments, GL posting formulas
|
||||
* 4. Sales cycle: quotation -> sales order -> invoice -> GL post
|
||||
* 5. AR: receipt billing -> receipt -> GL post
|
||||
* 6. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
|
||||
* 7. AP: payment billing -> payment -> GL post
|
||||
* 8. Supplier return (confirmed, restocks reversed)
|
||||
* 9. Customer return (draft only — see note below)
|
||||
* 10. Barcode labels for a handful of products
|
||||
* 4. Chart of accounts, departments, GL posting formulas, product account mapping
|
||||
* 5. Sales cycle: quotation -> sales order -> invoice -> GL post
|
||||
* 6. AR: receipt billing -> receipt -> GL post
|
||||
* 7. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
|
||||
* 8. AP: payment billing -> payment -> GL post
|
||||
* 9. Supplier return (confirmed, restocks reversed)
|
||||
* 10. Customer return (draft only — see note below)
|
||||
* 11. Barcode labels for a handful of products
|
||||
*
|
||||
* Every write goes through the same Manager classes + engine-file patterns
|
||||
* the app itself uses (dbTransaction wrapping, GlManager posting exactly as
|
||||
@@ -46,6 +44,7 @@ require_once __DIR__ . '/app/dbconn.php';
|
||||
require_once __DIR__ . '/app/assets/utils/db_helpers.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/WarehouseManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/StockManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/ProductManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/QuotationManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/OrderManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php';
|
||||
@@ -89,24 +88,29 @@ $sth->execute();
|
||||
$owner_user_id = (int)($sth->fetchColumn() ?: 0);
|
||||
if (!$owner_user_id) exit("ERROR: demo owner user not found — run demo_seed.php first.\n");
|
||||
|
||||
// Resolve by name: ids depend on what else exists in the database.
|
||||
$sth = $pdo2->prepare("SELECT id, warehouse_name FROM md_warehouse WHERE company_id = :c ORDER BY id");
|
||||
$sth->execute([':c' => $company_id]);
|
||||
$warehouses = $sth->fetchAll(PDO::FETCH_KEY_PAIR); // id => name
|
||||
if (count($warehouses) < 2) exit("ERROR: expected 2 warehouses — run demo_seed.php first.\n");
|
||||
$wh_ids = array_keys($warehouses);
|
||||
$main_wh = $wh_ids[0]; // Main Warehouse
|
||||
$bangna_wh = $wh_ids[1]; // Bangna Distribution Center
|
||||
$main_wh = (int)(array_search('Main Warehouse', $warehouses, true) ?: 0);
|
||||
$bangna_wh = (int)(array_search('Bangna Distribution Center', $warehouses, true) ?: 0);
|
||||
if (!$main_wh || !$bangna_wh) exit("ERROR: expected Main Warehouse and Bangna Distribution Center — run demo_seed.php first.\n");
|
||||
|
||||
$sth = $pdo2->prepare("SELECT id, contact_name, contact_type FROM md_contact WHERE company_id = :c ORDER BY id");
|
||||
// md_contact.contact_type is an md_contact_type id, so match on the type name.
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT c.id, c.contact_name, t.contact_type AS type_name
|
||||
FROM md_contact c
|
||||
JOIN md_contact_type t ON t.company_id = c.company_id AND t.id = c.contact_type
|
||||
WHERE c.company_id = :c
|
||||
ORDER BY c.id"
|
||||
);
|
||||
$sth->execute([':c' => $company_id]);
|
||||
$contacts = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (count($contacts) < 7) exit("ERROR: expected 7 contacts — run demo_seed.php first.\n");
|
||||
$customer_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 1), 'id');
|
||||
$supplier_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 2), 'id');
|
||||
$customer_ids = array_values($customer_ids);
|
||||
$supplier_ids = array_values($supplier_ids);
|
||||
$customer_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Customer'), 'id'));
|
||||
$supplier_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Supplier'), 'id'));
|
||||
if (count($customer_ids) < 4 || count($supplier_ids) < 3) exit("ERROR: expected 4 customers and 3 suppliers — run demo_seed.php first.\n");
|
||||
|
||||
$sth = $pdo2->prepare("SELECT sku, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id");
|
||||
$sth = $pdo2->prepare("SELECT sku, product_name, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id");
|
||||
$sth->execute([':c' => $company_id]);
|
||||
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
|
||||
if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.php first.\n");
|
||||
@@ -114,156 +118,6 @@ if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.p
|
||||
$logging = ['user_id' => $owner_user_id, 'dt' => date('Y-m-d H:i:s'), 'login' => null, 'action' => 'seed_transactions'];
|
||||
|
||||
$whMgmt = new WarehouseManager($pdo2, $company_id);
|
||||
$stockMgmt = new StockManager($pdo2, $company_id);
|
||||
|
||||
/** Find the next unused simple-location bin label "A-N" for a warehouse. */
|
||||
function nextFreeBin(PDO $pdo2, int $company_id, int $warehouse_id): string {
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT bin FROM md_bin WHERE company_id = :c AND warehouse = :w AND product_sku IS NULL
|
||||
ORDER BY CAST(SUBSTRING(bin, 3) AS UNSIGNED) ASC LIMIT 1"
|
||||
);
|
||||
$sth->execute([':c' => $company_id, ':w' => $warehouse_id]);
|
||||
$bin = $sth->fetchColumn();
|
||||
if (!$bin) throw new Exception("No free bin available in warehouse {$warehouse_id}.");
|
||||
return $bin;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Additional stock-in replenishment (restock events)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
echo "--- Restock (additional stock-in) ---\n";
|
||||
|
||||
$restock_defs = [
|
||||
['sku' => 'EL-001', 'warehouse' => $main_wh, 'qty' => 40, 'supplier_idx' => 0],
|
||||
['sku' => 'OF-001', 'warehouse' => $main_wh, 'qty' => 60, 'supplier_idx' => 1],
|
||||
['sku' => 'BV-002', 'warehouse' => $bangna_wh, 'qty' => 35, 'supplier_idx' => 2],
|
||||
];
|
||||
|
||||
foreach ($restock_defs as $r) {
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT COUNT(*) FROM `td_stock_{$r['warehouse']}`
|
||||
WHERE company_id = :c AND product_sku = :sku AND type = 'in' AND description = 'Restock (demo seed)'"
|
||||
);
|
||||
$sth->execute([':c' => $company_id, ':sku' => $r['sku']]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
skip("Restock for {$r['sku']} in warehouse {$r['warehouse']} already exists");
|
||||
continue;
|
||||
}
|
||||
|
||||
$bin = nextFreeBin($pdo2, $company_id, $r['warehouse']);
|
||||
$supplier_id = $supplier_ids[$r['supplier_idx']];
|
||||
$uuid = bin2hex(random_bytes(16));
|
||||
$p = $products[$r['sku']];
|
||||
|
||||
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $r, $bin, $supplier_id, $logging, $uuid, $p) {
|
||||
$stock_id = $stockMgmt->saveStockIn([
|
||||
'id' => 0, 'warehouse' => $r['warehouse'], 'product_sku' => $r['sku'],
|
||||
'quantity' => $r['qty'], 'price' => $p['cost_price'],
|
||||
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
|
||||
'contact_id' => $supplier_id, 'description' => 'Restock (demo seed)',
|
||||
], $logging, $uuid);
|
||||
$stockMgmt->approveStock($stock_id, $r['warehouse'], 'in', $whMgmt);
|
||||
});
|
||||
ok("Restocked {$r['qty']} {$p['uom']} of {$r['sku']} into {$warehouses[$r['warehouse']]} bin {$bin}");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Stock-out (direct dispatch) — dedicated batch in + full-bin out
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
echo "\n--- Stock-out (direct dispatch) ---\n";
|
||||
|
||||
$dispatch_defs = [
|
||||
['sku' => 'PK-003', 'warehouse' => $main_wh, 'qty' => 25, 'customer_idx' => 0],
|
||||
['sku' => 'OF-003', 'warehouse' => $main_wh, 'qty' => 15, 'customer_idx' => 1],
|
||||
];
|
||||
|
||||
foreach ($dispatch_defs as $d) {
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT COUNT(*) FROM `td_stock_{$d['warehouse']}`
|
||||
WHERE company_id = :c AND product_sku = :sku AND type = 'out' AND description = 'Direct dispatch (demo seed)'"
|
||||
);
|
||||
$sth->execute([':c' => $company_id, ':sku' => $d['sku']]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
skip("Dispatch for {$d['sku']} already exists");
|
||||
continue;
|
||||
}
|
||||
|
||||
$bin = nextFreeBin($pdo2, $company_id, $d['warehouse']);
|
||||
$customer_id = $customer_ids[$d['customer_idx']];
|
||||
$p = $products[$d['sku']];
|
||||
|
||||
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $d, $bin, $customer_id, $logging, $p) {
|
||||
// Receive a dedicated batch first (so we don't touch demo_seed.php's opening-stock bins)
|
||||
$in_uuid = bin2hex(random_bytes(16));
|
||||
$in_id = $stockMgmt->saveStockIn([
|
||||
'id' => 0, 'warehouse' => $d['warehouse'], 'product_sku' => $d['sku'],
|
||||
'quantity' => $d['qty'], 'price' => $p['cost_price'],
|
||||
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
|
||||
'contact_id' => $customer_id, 'description' => 'Batch for dispatch (demo seed)',
|
||||
], $logging, $in_uuid);
|
||||
$stockMgmt->approveStock($in_id, $d['warehouse'], 'in', $whMgmt);
|
||||
|
||||
// Dispatch it straight out (saveStockOut takes the whole bin)
|
||||
$out_uuid = bin2hex(random_bytes(16));
|
||||
$out_id = $stockMgmt->saveStockOut([
|
||||
'id' => 0, 'warehouse' => $d['warehouse'], 'product_sku' => $d['sku'],
|
||||
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
|
||||
'contact_id' => $customer_id, 'description' => 'Direct dispatch (demo seed)',
|
||||
], $logging, $out_uuid);
|
||||
$stockMgmt->approveStock($out_id, $d['warehouse'], 'out', $whMgmt);
|
||||
});
|
||||
ok("Dispatched {$d['qty']} {$p['uom']} of {$d['sku']} from {$warehouses[$d['warehouse']]}");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Stock transfer — Main Warehouse -> Bangna Distribution Center
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
echo "\n--- Stock transfer (Main -> Bangna) ---\n";
|
||||
|
||||
$transfer_defs = [
|
||||
['sku' => 'BV-001', 'qty' => 30],
|
||||
['sku' => 'PK-002', 'qty' => 12],
|
||||
];
|
||||
|
||||
foreach ($transfer_defs as $t) {
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT COUNT(*) FROM `td_stock_{$main_wh}`
|
||||
WHERE company_id = :c AND product_sku = :sku AND type = 'transfer' AND description = 'Transfer to Bangna (demo seed)'"
|
||||
);
|
||||
$sth->execute([':c' => $company_id, ':sku' => $t['sku']]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
skip("Transfer for {$t['sku']} already exists");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bin allocation is independent of the transaction below — resolve both up front.
|
||||
$from_bin = nextFreeBin($pdo2, $company_id, $main_wh);
|
||||
$to_bin = nextFreeBin($pdo2, $company_id, $bangna_wh);
|
||||
$p = $products[$t['sku']];
|
||||
|
||||
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $t, $from_bin, $to_bin, $main_wh, $bangna_wh, $logging, $p) {
|
||||
// Receive a dedicated batch first (so we don't touch demo_seed.php's opening-stock bins)
|
||||
$in_uuid = bin2hex(random_bytes(16));
|
||||
$in_id = $stockMgmt->saveStockIn([
|
||||
'id' => 0, 'warehouse' => $main_wh, 'product_sku' => $t['sku'],
|
||||
'quantity' => $t['qty'], 'price' => $p['cost_price'],
|
||||
'zone' => $from_bin, 'aisle' => $from_bin, 'bin' => $from_bin,
|
||||
'contact_id' => 0, 'description' => 'Batch for transfer (demo seed)',
|
||||
], $logging, $in_uuid);
|
||||
$stockMgmt->approveStock($in_id, $main_wh, 'in', $whMgmt);
|
||||
|
||||
$tr_uuid = bin2hex(random_bytes(16));
|
||||
$out_id = $stockMgmt->saveStockTransfer([
|
||||
'id' => 0, 'warehouse_from' => $main_wh, 'warehouse_to' => $bangna_wh,
|
||||
'product_sku' => $t['sku'],
|
||||
'zone_from' => $from_bin, 'aisle_from' => $from_bin, 'bin_from' => $from_bin,
|
||||
'zone_to' => $to_bin, 'aisle_to' => $to_bin, 'bin_to' => $to_bin,
|
||||
'contact_id' => 0, 'description' => 'Transfer to Bangna (demo seed)',
|
||||
], $logging, $tr_uuid);
|
||||
$stockMgmt->approveStock($out_id, $main_wh, 'transfer', $whMgmt);
|
||||
});
|
||||
ok("Transferred {$t['qty']} {$p['uom']} of {$t['sku']}: {$warehouses[$main_wh]} bin {$from_bin} -> {$warehouses[$bangna_wh]} bin {$to_bin}");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. Chart of accounts + departments + GL posting formulas
|
||||
@@ -379,6 +233,25 @@ foreach ($formula_defs as $doc_type => $def) {
|
||||
$formula_ids[$doc_type] = $id;
|
||||
}
|
||||
|
||||
echo "\n--- Product account mapping ---\n";
|
||||
|
||||
// The sales and purchase formulas split 'total' per product, so Batch GL Entries
|
||||
// refuses to post until every product has sales/purchase accounts
|
||||
// (Account Formulas > Product Accounts, accounting/api/engine/product_account_mapping.php).
|
||||
$sth = $pdo2->prepare("SELECT id, sku, sales_account_code, purchase_account_code FROM md_product WHERE company_id = :c ORDER BY id");
|
||||
$sth->execute([':c' => $company_id]);
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $prod) {
|
||||
if ($prod['sales_account_code'] && $prod['purchase_account_code']) {
|
||||
skip("Product {$prod['sku']} already mapped");
|
||||
continue;
|
||||
}
|
||||
$sales_code = $prod['sales_account_code'] ?: '4000';
|
||||
$purchase_code = $prod['purchase_account_code'] ?: '5000';
|
||||
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))
|
||||
->updateAccountMapping((int)$prod['id'], $sales_code, $purchase_code, $logging));
|
||||
ok("Mapped {$prod['sku']}: sales {$sales_code}, purchase {$purchase_code}");
|
||||
}
|
||||
|
||||
/** Post (or replace) GL for a document, mirroring order/api/engine/issue_invoice.php exactly. */
|
||||
function postGl(PDO $pdo1, PDO $pdo2, int $company_id, string $doc_type, int $doc_id, string $posting_class): array {
|
||||
require_once __DIR__ . "/app/assets/utils/classes_ac/posting/{$posting_class}.php";
|
||||
@@ -409,7 +282,7 @@ function buildLineItem(array $products, string $sku, float $qty): array {
|
||||
$tax_amount = round($total_price * $tax_rate / 100, 4);
|
||||
return [
|
||||
'product_sku' => $sku,
|
||||
'product_name' => $sku, // demo_seed.php products keyed by SKU; name not needed for GL/report correctness
|
||||
'product_name' => $p['product_name'], // documents show the product name, as the UI's product search fills it
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unit_price,
|
||||
'total_price' => $total_price,
|
||||
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
EMIT_SECRET: ${EMIT_SECRET}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||
OTP_REQUIRED: ${OTP_REQUIRED:-false}
|
||||
volumes:
|
||||
- .:/var/www/html/wms-app
|
||||
ports:
|
||||
|
||||
@@ -55,6 +55,7 @@ PUBLIC_HOST=$public_host
|
||||
EMIT_SECRET=$emit_secret
|
||||
SMTP_USERNAME=$smtp_user
|
||||
SMTP_PASSWORD=$smtp_pass
|
||||
OTP_REQUIRED=false
|
||||
HTTP_PORT=$http_port
|
||||
EOF
|
||||
chmod 600 "$ENV_FILE"
|
||||
|
||||
@@ -2,6 +2,8 @@ FROM php:8.3-apache
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libzip-dev libicu-dev libonig-dev default-mysql-client gettext-base \
|
||||
libpng-dev libjpeg-dev libfreetype6-dev \
|
||||
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
|
||||
&& docker-php-ext-install pdo_mysql mysqli mbstring gd zip intl sockets exif opcache \
|
||||
&& a2enmod rewrite \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -33,6 +33,22 @@ if (!defined('NODE_EMIT_SECRET')) {
|
||||
define('NODE_EMIT_SECRET', '${EMIT_SECRET}');
|
||||
}
|
||||
|
||||
// ── Login OTP ────────────────────────────────────────────────────────────────
|
||||
// Set from OTP_REQUIRED in .env and reconciled by the entrypoint on every start.
|
||||
// Off by default: anything other than the boolean true leaves the OTP step off.
|
||||
if (!defined('OTP_REQUIRED')) {
|
||||
define('OTP_REQUIRED', ${OTP_REQUIRED});
|
||||
}
|
||||
|
||||
// ── App registry ─────────────────────────────────────────────────────────────
|
||||
// Apps a user can be given access to (Setting → Users Access). Keys must match
|
||||
// the user.app_access enum; assets/utils/app_registry.php supplies this default
|
||||
// for configs that do not define it.
|
||||
$app_registry = [
|
||||
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
|
||||
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
|
||||
];
|
||||
|
||||
// ── Usage packages ───────────────────────────────────────────────────────────
|
||||
$packages = [
|
||||
'starter' => [
|
||||
|
||||
@@ -4,15 +4,39 @@ set -e
|
||||
APP_DIR=/var/www/html/wms-app
|
||||
CONFIG=$APP_DIR/app/config.php
|
||||
|
||||
# Email OTP on sign-in, OFF BY DEFAULT. Only the exact string "true" turns it
|
||||
# on; a missing variable or anything else means false.
|
||||
: "${OTP_REQUIRED:=false}"
|
||||
[ "$OTP_REQUIRED" = "true" ] || OTP_REQUIRED=false
|
||||
export OTP_REQUIRED
|
||||
|
||||
# Generate app/config.php from template on first run only.
|
||||
# Restrict envsubst to known placeholders so it never touches the app's own
|
||||
# $variable syntax (envsubst blanks out any $NAME it doesn't recognize).
|
||||
if [ ! -f "$CONFIG" ]; then
|
||||
echo "[entrypoint] generating app/config.php"
|
||||
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD}' \
|
||||
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD} ${OTP_REQUIRED}' \
|
||||
< /usr/local/etc/wms/config.php.template > "$CONFIG"
|
||||
fi
|
||||
|
||||
# config.php is never regenerated once it exists, so OTP_REQUIRED is the one
|
||||
# line reconciled on every start: the .env value always wins, and a config.php
|
||||
# written before this switch existed gets the line added.
|
||||
if grep -q "define('OTP_REQUIRED'" "$CONFIG"; then
|
||||
if ! grep -q "define('OTP_REQUIRED', ${OTP_REQUIRED});" "$CONFIG"; then
|
||||
sed -i "s/define('OTP_REQUIRED', [A-Za-z]*);/define('OTP_REQUIRED', ${OTP_REQUIRED});/" "$CONFIG"
|
||||
echo "[entrypoint] OTP_REQUIRED is now ${OTP_REQUIRED}"
|
||||
fi
|
||||
else
|
||||
# Drop a closing ?> on the last line so the appended block stays inside PHP.
|
||||
sed -i -e '${/^[[:space:]]*?>[[:space:]]*$/d}' "$CONFIG"
|
||||
printf "\nif (!defined('OTP_REQUIRED')) {\n\tdefine('OTP_REQUIRED', %s);\n}\n" "$OTP_REQUIRED" >> "$CONFIG"
|
||||
echo "[entrypoint] added OTP_REQUIRED = ${OTP_REQUIRED} to an existing config.php"
|
||||
fi
|
||||
if [ "$OTP_REQUIRED" = "false" ]; then
|
||||
echo "[entrypoint] email OTP is off (OTP_REQUIRED=false); sign-in is password only."
|
||||
fi
|
||||
|
||||
mkdir -p "$APP_DIR/app/uploads"
|
||||
chown -R www-data:www-data "$APP_DIR/app/uploads"
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
2026-07-22T14:37:48: [2026-07-22T07:37:48.626Z] [PID: 505] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Wed Jul 22 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T15:14:43: [2026-07-23T08:14:43.767Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.943Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.953Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 18:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.959Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 19:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.965Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 20:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:02:17: [2026-07-23T14:02:17.033Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 21:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.977Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 17:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.982Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 18:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.985Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 19:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.987Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 20:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-25T14:44:32: [2026-07-25T07:44:32.121Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 13:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-25T14:44:32: [2026-07-25T07:44:32.127Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-25T15:24:00: [2026-07-25T08:24:00.029Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 14:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-25T15:24:00: [2026-07-25T08:24:00.032Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-03T10:11:12: [2026-08-03T03:11:12.241Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 10:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-03T10:31:46: [2026-08-03T03:31:46.573Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 10:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-03T11:08:26: [2026-08-03T04:08:26.686Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 11:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-04T15:02:31: [2026-08-04T08:02:31.243Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-04T15:32:15: [2026-08-04T08:32:15.561Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 15:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-04T17:01:03: [2026-08-04T10:01:03.525Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
@@ -1,58 +0,0 @@
|
||||
2026-07-20T17:35:51: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-07-20T21:00:00: [scheduler] etl_gl slot=21
|
||||
2026-07-20T21:00:00: [scheduler] etl_gl slot=21 — no companies
|
||||
2026-07-21T16:06:58: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-07-22T13:08:58: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-07-22T14:37:48: [2026-07-22T07:37:48.626Z] [PID: 505] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Wed Jul 22 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-22T15:00:00: [scheduler] etl_gl slot=15
|
||||
2026-07-22T15:00:00: [scheduler] etl_gl slot=15 — no companies
|
||||
2026-07-22T15:30:00: [scheduler] etl_stock slot=15
|
||||
2026-07-22T15:30:00: [scheduler] etl_stock slot=15 — no companies
|
||||
2026-07-22T16:00:00: [scheduler] etl_gl slot=16
|
||||
2026-07-22T16:00:00: [scheduler] etl_gl slot=16 — no companies
|
||||
2026-07-23T12:11:51: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-07-23T12:30:00: [scheduler] etl_stock slot=12
|
||||
2026-07-23T12:30:00: [scheduler] etl_stock slot=12 — no companies
|
||||
2026-07-23T13:36:04: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-07-23T15:14:43: [2026-07-23T08:14:43.767Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T15:30:00: [scheduler] etl_stock slot=15
|
||||
2026-07-23T15:30:00: [scheduler] etl_stock slot=15 — no companies
|
||||
2026-07-23T16:00:00: [scheduler] etl_gl slot=16
|
||||
2026-07-23T16:00:00: [scheduler] etl_gl slot=16 — no companies
|
||||
2026-07-23T16:30:00: [scheduler] etl_stock slot=16
|
||||
2026-07-23T16:30:00: [scheduler] etl_stock slot=16 — no companies
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.943Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.953Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 18:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.959Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 19:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.965Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 20:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:02:17: [2026-07-23T14:02:17.033Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 21:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.977Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 17:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.982Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 18:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.985Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 19:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.987Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 20:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:30:00: [scheduler] etl_stock slot=21
|
||||
2026-07-23T21:30:00: [scheduler] etl_stock slot=21 — no companies
|
||||
2026-07-25T12:19:55: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-07-25T12:30:00: [scheduler] etl_stock slot=12
|
||||
2026-07-25T12:30:00: [scheduler] etl_stock slot=12 — no companies
|
||||
2026-07-25T13:00:00: [scheduler] etl_gl slot=13
|
||||
2026-07-25T13:00:00: [scheduler] etl_gl slot=13 — no companies
|
||||
2026-07-25T14:44:32: [2026-07-25T07:44:32.121Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 13:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-25T14:44:32: [2026-07-25T07:44:32.127Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-25T15:24:00: [2026-07-25T08:24:00.029Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 14:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-25T15:24:00: [2026-07-25T08:24:00.032Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-03T08:52:15: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-08-03T09:00:00: [scheduler] etl_gl slot=9
|
||||
2026-08-03T09:00:00: [scheduler] alert_overdue_invoices running
|
||||
2026-08-03T09:00:00: [scheduler] etl_gl slot=9 — no companies
|
||||
2026-08-03T10:11:12: [2026-08-03T03:11:12.241Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 10:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-03T10:31:46: [2026-08-03T03:31:46.573Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 10:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-03T11:08:26: [2026-08-03T04:08:26.686Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 11:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-04T13:35:14: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-08-04T15:02:31: [2026-08-04T08:02:31.243Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-04T15:32:15: [2026-08-04T08:32:15.561Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 15:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-04T16:00:00: [scheduler] etl_gl slot=16
|
||||
2026-08-04T16:00:00: [scheduler] etl_gl slot=16 — no companies
|
||||
2026-08-04T16:30:00: [scheduler] etl_stock slot=16
|
||||
2026-08-04T16:30:00: [scheduler] etl_stock slot=16 — no companies
|
||||
2026-08-04T17:01:03: [2026-08-04T10:01:03.525Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
@@ -1,255 +0,0 @@
|
||||
2026-07-20T17:35:51: Node.js real-time server running on port 3000
|
||||
2026-07-21T16:06:58: Node.js real-time server running on port 3000
|
||||
2026-07-22T13:08:58: Node.js real-time server running on port 3000
|
||||
2026-07-22T13:10:29: [connect] socket=0-LquhazY9eKKN7LAAAB company=1 user=1 role=owner
|
||||
2026-07-22T13:15:09: [disconnect] socket=0-LquhazY9eKKN7LAAAB
|
||||
2026-07-22T13:15:12: [connect] socket=P4rxlPGuHWMqT35XAAAD company=1 user=1 role=owner
|
||||
2026-07-22T13:26:21: [disconnect] socket=P4rxlPGuHWMqT35XAAAD
|
||||
2026-07-22T13:26:23: [connect] socket=EBxi3tj8fNMUmyoyAAAF company=1 user=1 role=owner
|
||||
2026-07-22T13:30:24: [disconnect] socket=EBxi3tj8fNMUmyoyAAAF
|
||||
2026-07-22T13:30:25: [connect] socket=SJtRh1L6usnHrWebAAAH company=1 user=1 role=owner
|
||||
2026-07-22T13:31:59: [disconnect] socket=SJtRh1L6usnHrWebAAAH
|
||||
2026-07-22T13:31:59: [connect] socket=xvRdZhqqg-soDWr7AAAJ company=1 user=1 role=owner
|
||||
2026-07-22T13:41:17: [disconnect] socket=xvRdZhqqg-soDWr7AAAJ
|
||||
2026-07-22T13:41:17: [connect] socket=QZkAkh2pHOGltp-0AAAL company=1 user=1 role=owner
|
||||
2026-07-22T13:42:28: [disconnect] socket=QZkAkh2pHOGltp-0AAAL
|
||||
2026-07-22T13:42:28: [connect] socket=0Vb5YTMHDs1gOC47AAAN company=1 user=1 role=owner
|
||||
2026-07-22T13:42:36: [disconnect] socket=0Vb5YTMHDs1gOC47AAAN
|
||||
2026-07-22T13:42:36: [connect] socket=idQO9l1OGLiXPyEoAAAP company=1 user=1 role=owner
|
||||
2026-07-22T13:42:43: [disconnect] socket=idQO9l1OGLiXPyEoAAAP
|
||||
2026-07-22T13:42:43: [connect] socket=nvfS_4EF_3kF5PGsAAAR company=1 user=1 role=owner
|
||||
2026-07-22T13:47:25: [disconnect] socket=nvfS_4EF_3kF5PGsAAAR
|
||||
2026-07-22T13:47:27: [connect] socket=yytX3fzh594f9phBAAAT company=1 user=1 role=owner
|
||||
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-07-22T13:59:42: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:42: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T14:03:26: [disconnect] socket=yytX3fzh594f9phBAAAT
|
||||
2026-07-22T14:03:28: [connect] socket=waEM4mo4V099RHhAAAAV company=1 user=1 role=owner
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||
2026-07-22T14:11:10: [disconnect] socket=waEM4mo4V099RHhAAAAV
|
||||
2026-07-22T14:35:14: [connect] socket=ZSmIs38dJh1u4If4AAAX company=1 user=1 role=owner
|
||||
2026-07-22T14:35:49: [disconnect] socket=ZSmIs38dJh1u4If4AAAX
|
||||
2026-07-22T14:35:49: [connect] socket=1bcHdQOC7cBTftxyAAAZ company=1 user=1 role=owner
|
||||
2026-07-22T14:36:37: [disconnect] socket=1bcHdQOC7cBTftxyAAAZ
|
||||
2026-07-22T14:36:38: [connect] socket=fMazKVGWKhaTm7OUAAAb company=1 user=1 role=owner
|
||||
2026-07-22T14:36:46: [disconnect] socket=fMazKVGWKhaTm7OUAAAb
|
||||
2026-07-22T14:36:46: [connect] socket=VfqVaEiOI3NTCA7fAAAd company=1 user=1 role=owner
|
||||
2026-07-22T14:36:48: [disconnect] socket=VfqVaEiOI3NTCA7fAAAd
|
||||
2026-07-22T14:36:48: [connect] socket=DAww8irzEgS7j7JLAAAf company=1 user=1 role=owner
|
||||
2026-07-22T14:36:49: [disconnect] socket=DAww8irzEgS7j7JLAAAf
|
||||
2026-07-22T14:36:49: [connect] socket=WgF3HArg1rthWkktAAAh company=1 user=1 role=owner
|
||||
2026-07-22T14:37:05: [disconnect] socket=WgF3HArg1rthWkktAAAh
|
||||
2026-07-22T14:37:05: [connect] socket=pfMLLNR0x-qoq485AAAj company=1 user=1 role=owner
|
||||
2026-07-22T14:37:08: [disconnect] socket=pfMLLNR0x-qoq485AAAj
|
||||
2026-07-22T14:37:09: [connect] socket=7ZtkCaJZqcYXBSZWAAAl company=1 user=1 role=owner
|
||||
2026-07-22T14:37:14: [disconnect] socket=7ZtkCaJZqcYXBSZWAAAl
|
||||
2026-07-22T14:37:14: [connect] socket=F6_OvPrmc-vv_KoqAAAn company=1 user=1 role=owner
|
||||
2026-07-22T14:37:18: [disconnect] socket=F6_OvPrmc-vv_KoqAAAn
|
||||
2026-07-22T14:37:18: [connect] socket=gdlZxPbkkcdkdE2AAAAp company=1 user=1 role=owner
|
||||
2026-07-22T14:40:08: [disconnect] socket=gdlZxPbkkcdkdE2AAAAp
|
||||
2026-07-22T14:40:09: [connect] socket=c8j8ybp-e7MPpa6cAAAr company=1 user=1 role=owner
|
||||
2026-07-22T14:40:13: [disconnect] socket=c8j8ybp-e7MPpa6cAAAr
|
||||
2026-07-22T14:40:13: [connect] socket=D36DmJgraENmU5xsAAAt company=1 user=1 role=owner
|
||||
2026-07-22T14:40:20: [disconnect] socket=D36DmJgraENmU5xsAAAt
|
||||
2026-07-22T14:40:21: [connect] socket=saY0q6gBioHWgq7RAAAv company=1 user=1 role=owner
|
||||
2026-07-22T14:40:25: [disconnect] socket=saY0q6gBioHWgq7RAAAv
|
||||
2026-07-22T14:40:25: [connect] socket=o9h4_9i-KOjDfVmrAAAx company=1 user=1 role=owner
|
||||
2026-07-22T14:40:27: [disconnect] socket=o9h4_9i-KOjDfVmrAAAx
|
||||
2026-07-22T14:40:27: [connect] socket=aIM89idl78lyhTbNAAAz company=1 user=1 role=owner
|
||||
2026-07-22T14:56:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:56:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:56:06: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||
2026-07-22T14:56:06: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||
2026-07-22T15:21:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'transfer' }
|
||||
2026-07-22T15:28:23: [disconnect] socket=aIM89idl78lyhTbNAAAz
|
||||
2026-07-22T15:43:35: [connect] socket=q1N4BECKfiomOEfyAAA1 company=1 user=1 role=owner
|
||||
2026-07-22T15:43:38: [disconnect] socket=q1N4BECKfiomOEfyAAA1
|
||||
2026-07-22T15:43:38: [connect] socket=yM_0j72uX0V2b-GuAAA3 company=1 user=1 role=owner
|
||||
2026-07-22T15:43:43: [disconnect] socket=yM_0j72uX0V2b-GuAAA3
|
||||
2026-07-22T15:43:43: [connect] socket=dvzUKq0p7lXQMuSLAAA5 company=1 user=1 role=owner
|
||||
2026-07-22T15:43:45: [disconnect] socket=dvzUKq0p7lXQMuSLAAA5
|
||||
2026-07-22T15:43:45: [connect] socket=TYvNpQgcOHR5-V1RAAA7 company=1 user=1 role=owner
|
||||
2026-07-22T15:43:46: [disconnect] socket=TYvNpQgcOHR5-V1RAAA7
|
||||
2026-07-22T15:43:46: [connect] socket=waNTeqU5sAu8W2jqAAA9 company=1 user=1 role=owner
|
||||
2026-07-22T16:05:01: [disconnect] socket=waNTeqU5sAu8W2jqAAA9
|
||||
2026-07-23T12:11:51: Node.js real-time server running on port 3000
|
||||
2026-07-23T12:13:27: [connect] socket=NYO9Tg4V6Cqp3e4cAAAB company=1 user=1 role=owner
|
||||
2026-07-23T12:33:05: [disconnect] socket=NYO9Tg4V6Cqp3e4cAAAB
|
||||
2026-07-23T12:33:06: [connect] socket=pEDe8Dms2dU7gdhCAAAD company=1 user=1 role=owner
|
||||
2026-07-23T12:34:13: [disconnect] socket=pEDe8Dms2dU7gdhCAAAD
|
||||
2026-07-23T12:34:17: [connect] socket=vaGUT12vPXkqH_7kAAAF company=1 user=1 role=owner
|
||||
2026-07-23T12:55:49: [disconnect] socket=vaGUT12vPXkqH_7kAAAF
|
||||
2026-07-23T13:36:04: Node.js real-time server running on port 3000
|
||||
2026-07-23T13:36:55: [connect] socket=nVGY71aXPas0zYS_AAAB company=1 user=1 role=owner
|
||||
2026-07-23T13:51:36: [disconnect] socket=nVGY71aXPas0zYS_AAAB
|
||||
2026-07-23T13:51:36: [connect] socket=5peGIWbSaRCxnlYBAAAD company=1 user=1 role=owner
|
||||
2026-07-23T13:51:38: [disconnect] socket=5peGIWbSaRCxnlYBAAAD
|
||||
2026-07-23T13:51:38: [connect] socket=fGwRmZaYGzedlqqRAAAF company=1 user=1 role=owner
|
||||
2026-07-23T13:51:40: [disconnect] socket=fGwRmZaYGzedlqqRAAAF
|
||||
2026-07-23T13:51:40: [connect] socket=YZk7xLKHpmi29ykIAAAH company=1 user=1 role=owner
|
||||
2026-07-23T13:51:41: [disconnect] socket=YZk7xLKHpmi29ykIAAAH
|
||||
2026-07-23T13:51:41: [connect] socket=f078x01mtX2eGd2HAAAJ company=1 user=1 role=owner
|
||||
2026-07-23T13:57:26: [disconnect] socket=f078x01mtX2eGd2HAAAJ
|
||||
2026-07-23T13:57:28: [connect] socket=vToy_ZVIAk6w9099AAAL company=1 user=1 role=owner
|
||||
2026-07-23T14:17:58: [disconnect] socket=vToy_ZVIAk6w9099AAAL
|
||||
2026-07-23T14:18:00: [connect] socket=7yHLdkKxBAAO_nF4AAAN company=1 user=1 role=owner
|
||||
2026-07-23T16:25:41: [disconnect] socket=7yHLdkKxBAAO_nF4AAAN
|
||||
2026-07-25T12:19:55: Node.js real-time server running on port 3000
|
||||
2026-08-03T08:52:15: Node.js real-time server running on port 3000
|
||||
2026-08-03T10:17:41: [connect] socket=kNyFq-T_JVC3-_WLAAAB company=1 user=1 role=owner
|
||||
2026-08-03T10:18:50: [disconnect] socket=kNyFq-T_JVC3-_WLAAAB
|
||||
2026-08-03T10:18:50: [connect] socket=efcou9_hk0YUTnvQAAAD company=1 user=1 role=owner
|
||||
2026-08-03T10:18:59: [disconnect] socket=efcou9_hk0YUTnvQAAAD
|
||||
2026-08-03T10:18:59: [connect] socket=PmKuy_3CCIDze4P3AAAF company=1 user=1 role=owner
|
||||
2026-08-03T10:32:14: [disconnect] socket=PmKuy_3CCIDze4P3AAAF
|
||||
2026-08-03T10:32:18: [connect] socket=-ZlIPBBmpRazD30gAAAH company=1 user=1 role=owner
|
||||
2026-08-03T10:49:23: [disconnect] socket=-ZlIPBBmpRazD30gAAAH
|
||||
2026-08-03T10:49:26: [connect] socket=Azbj9ipTPqb3aOW3AAAJ company=1 user=1 role=owner
|
||||
2026-08-03T10:54:19: [disconnect] socket=Azbj9ipTPqb3aOW3AAAJ
|
||||
2026-08-03T10:54:19: [connect] socket=FMnx-fmSk96rxIfwAAAL company=1 user=1 role=owner
|
||||
2026-08-03T11:13:57: [disconnect] socket=FMnx-fmSk96rxIfwAAAL
|
||||
2026-08-03T11:13:59: [connect] socket=LgVxBoN_6JuJtxHyAAAN company=1 user=1 role=owner
|
||||
2026-08-04T13:35:14: Node.js real-time server running on port 3000
|
||||
2026-08-04T13:36:21: [connect] socket=BOvxSU23giBsnIXWAAAB company=1 user=1 role=owner
|
||||
2026-08-04T13:36:25: [disconnect] socket=BOvxSU23giBsnIXWAAAB
|
||||
2026-08-04T13:36:25: [connect] socket=pJXUXD7HNX_V9peAAAAD company=1 user=1 role=owner
|
||||
2026-08-04T13:36:27: [disconnect] socket=pJXUXD7HNX_V9peAAAAD
|
||||
2026-08-04T13:36:27: [connect] socket=St7RkiRumWpwc47xAAAF company=1 user=1 role=owner
|
||||
2026-08-04T13:36:28: [disconnect] socket=St7RkiRumWpwc47xAAAF
|
||||
2026-08-04T13:36:28: [connect] socket=a9NsNFmUpIL1vW8uAAAH company=1 user=1 role=owner
|
||||
2026-08-04T13:40:28: [disconnect] socket=a9NsNFmUpIL1vW8uAAAH
|
||||
2026-08-04T13:40:28: [connect] socket=uYLFddlhCkOxrcJNAAAJ company=1 user=1 role=owner
|
||||
2026-08-04T13:48:10: [disconnect] socket=uYLFddlhCkOxrcJNAAAJ
|
||||
2026-08-04T13:48:11: [connect] socket=VekC6UabiJABBbjhAAAL company=1 user=1 role=owner
|
||||
2026-08-04T13:50:28: [disconnect] socket=VekC6UabiJABBbjhAAAL
|
||||
2026-08-04T13:50:29: [connect] socket=gj-glld-ZsxWbGQuAAAN company=1 user=1 role=owner
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||
2026-08-04T14:11:36: [disconnect] socket=gj-glld-ZsxWbGQuAAAN
|
||||
2026-08-04T14:11:36: [connect] socket=v97BofsQmYBAEJMmAAAP company=1 user=1 role=owner
|
||||
2026-08-04T14:13:54: [disconnect] socket=v97BofsQmYBAEJMmAAAP
|
||||
2026-08-04T14:13:54: [connect] socket=WYJSdI9xVDaTML9xAAAR company=1 user=1 role=owner
|
||||
2026-08-04T14:17:32: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T14:17:32: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T14:21:12: [disconnect] socket=WYJSdI9xVDaTML9xAAAR
|
||||
2026-08-04T14:21:12: [connect] socket=UhIyCllsOjal4UwCAAAT company=1 user=1 role=owner
|
||||
2026-08-04T14:21:13: [disconnect] socket=UhIyCllsOjal4UwCAAAT
|
||||
2026-08-04T14:21:13: [connect] socket=6hZ-_fAyDgWzwsgvAAAV company=1 user=1 role=owner
|
||||
2026-08-04T14:21:38: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:21:38: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:29:14: [disconnect] socket=6hZ-_fAyDgWzwsgvAAAV
|
||||
2026-08-04T14:29:14: [connect] socket=7ocTMfufQlyO36XrAAAX company=1 user=1 role=owner
|
||||
2026-08-04T14:29:19: [disconnect] socket=7ocTMfufQlyO36XrAAAX
|
||||
2026-08-04T14:36:58: [connect] socket=kY4WNaECxPvGVj2JAAAZ company=1 user=1 role=owner
|
||||
2026-08-04T14:37:12: [disconnect] socket=kY4WNaECxPvGVj2JAAAZ
|
||||
2026-08-04T14:37:12: [connect] socket=OabEymZu5SehwNWnAAAb company=1 user=1 role=owner
|
||||
2026-08-04T14:37:40: [disconnect] socket=OabEymZu5SehwNWnAAAb
|
||||
2026-08-04T14:41:55: [connect] socket=kAlZOJl54kd8RXKAAAAd company=1 user=1 role=owner
|
||||
2026-08-04T14:42:42: [disconnect] socket=kAlZOJl54kd8RXKAAAAd
|
||||
2026-08-04T14:42:42: [connect] socket=DBSytTfd-o12DxhfAAAf company=1 user=1 role=owner
|
||||
2026-08-04T14:49:38: [disconnect] socket=DBSytTfd-o12DxhfAAAf
|
||||
2026-08-04T14:49:39: [connect] socket=sAr1qebAYGyIq8zrAAAh company=1 user=1 role=owner
|
||||
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||
2026-08-04T14:59:17: [disconnect] socket=sAr1qebAYGyIq8zrAAAh
|
||||
2026-08-04T14:59:17: [connect] socket=a264uVnqws4cIAy-AAAj company=1 user=1 role=owner
|
||||
2026-08-04T15:21:19: [disconnect] socket=a264uVnqws4cIAy-AAAj
|
||||
2026-08-04T15:21:19: [connect] socket=JL7kcUwnQI_NExMkAAAl company=1 user=1 role=owner
|
||||
2026-08-04T15:21:22: [disconnect] socket=JL7kcUwnQI_NExMkAAAl
|
||||
2026-08-04T15:27:09: [connect] socket=VcbOCpKEShqcqqM0AAAn company=1 user=1 role=owner
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||
2026-08-04T15:30:09: [disconnect] socket=VcbOCpKEShqcqqM0AAAn
|
||||
2026-08-04T15:30:09: [connect] socket=ecBAVshG1Eng4jh5AAAp company=1 user=1 role=owner
|
||||
2026-08-04T15:33:32: [disconnect] socket=ecBAVshG1Eng4jh5AAAp
|
||||
2026-08-04T15:33:32: [connect] socket=M43MyEQ7wipYOgd5AAAr company=1 user=1 role=owner
|
||||
2026-08-04T15:34:32: [disconnect] socket=M43MyEQ7wipYOgd5AAAr
|
||||
2026-08-04T15:34:32: [connect] socket=TiGDT6OMJsYlixlkAAAt company=1 user=1 role=owner
|
||||
2026-08-04T15:35:36: [disconnect] socket=TiGDT6OMJsYlixlkAAAt
|
||||
2026-08-04T15:35:36: [connect] socket=uHRGhZU0TJVvvh_aAAAv company=1 user=1 role=owner
|
||||
2026-08-04T15:36:24: [disconnect] socket=uHRGhZU0TJVvvh_aAAAv
|
||||
2026-08-04T15:36:24: [connect] socket=Hsui_73WGENhGdRIAAAx company=1 user=1 role=owner
|
||||
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T15:39:55: [disconnect] socket=Hsui_73WGENhGdRIAAAx
|
||||
2026-08-04T15:39:55: [connect] socket=xSl0-9raxlwU2K9rAAAz company=1 user=1 role=owner
|
||||
2026-08-04T15:52:53: [disconnect] socket=xSl0-9raxlwU2K9rAAAz
|
||||
2026-08-04T15:52:53: [connect] socket=n_l9kHYTF1AXNNRYAAA1 company=1 user=1 role=owner
|
||||
2026-08-04T15:58:37: [disconnect] socket=n_l9kHYTF1AXNNRYAAA1
|
||||
2026-08-04T15:58:37: [connect] socket=T9mpzRMT3I1qjj0BAAA3 company=1 user=1 role=owner
|
||||
2026-08-04T15:58:39: [disconnect] socket=T9mpzRMT3I1qjj0BAAA3
|
||||
2026-08-04T15:58:39: [connect] socket=Q1jGtGwwFc49KKxDAAA5 company=1 user=1 role=owner
|
||||
2026-08-04T15:58:40: [disconnect] socket=Q1jGtGwwFc49KKxDAAA5
|
||||
2026-08-04T15:58:41: [connect] socket=Fk7l8SLr2IIRFFHbAAA7 company=1 user=1 role=owner
|
||||
2026-08-04T15:58:42: [disconnect] socket=Fk7l8SLr2IIRFFHbAAA7
|
||||
2026-08-04T15:58:42: [connect] socket=pAuTSmpDDC86EZwXAAA9 company=1 user=1 role=owner
|
||||
2026-08-04T16:41:11: [disconnect] socket=pAuTSmpDDC86EZwXAAA9
|
||||
2026-08-04T16:41:13: [connect] socket=XmFaoUIej-g8203TAAA_ company=1 user=1 role=owner
|
||||
2026-08-04T16:41:25: [disconnect] socket=XmFaoUIej-g8203TAAA_
|
||||
2026-08-04T16:41:28: [connect] socket=TxwzTsUHzqDLHpODAABB company=1 user=1 role=owner
|
||||
2026-08-04T16:43:43: [disconnect] socket=TxwzTsUHzqDLHpODAABB
|
||||
2026-08-04T16:44:26: [connect] socket=TOhs2YrBWQkiGDZDAABD company=1 user=1 role=owner
|
||||
2026-08-04T16:58:05: [disconnect] socket=TOhs2YrBWQkiGDZDAABD
|
||||
2026-08-04T16:59:15: [connect] socket=p1xNFoGJFKox8FaOAABF company=1 user=1 role=owner
|
||||
2026-08-04T17:25:03: [disconnect] socket=p1xNFoGJFKox8FaOAABF
|
||||
2026-08-04T17:25:03: [connect] socket=j3s6wvwe_BxVzCA_AABH company=1 user=1 role=owner
|
||||
2026-08-04T17:25:05: [disconnect] socket=j3s6wvwe_BxVzCA_AABH
|
||||
2026-08-04T17:25:05: [connect] socket=4wkwmOWCAvQSicL3AABJ company=1 user=1 role=owner
|
||||
2026-08-04T17:26:40: [disconnect] socket=4wkwmOWCAvQSicL3AABJ
|
||||
Reference in New Issue
Block a user