implement JS password scoring, td_stock approve logicc
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
/**
|
||||
* CompanySettingManager — per-company key/value configuration store.
|
||||
*
|
||||
* Table: company_setting (company_id, setting_key, value, log, updated_at)
|
||||
*
|
||||
* Current keys:
|
||||
* default_stock_status int 0 = draft (manual approve required)
|
||||
* 1 = auto-approve on save
|
||||
*
|
||||
* Designed to accept new keys without schema changes.
|
||||
*/
|
||||
class CompanySettingManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
// ── Known keys with their defaults ───────────────────────────────────────
|
||||
private const DEFAULTS = [
|
||||
'default_stock_status' => 1, // auto-approve by default
|
||||
];
|
||||
|
||||
public function __construct(PDO $pdo, int $companyId)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $companyId;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return the value for a single key, or the default if not configured.
|
||||
*
|
||||
* @param string $key Setting key.
|
||||
* @return mixed Stored value cast to int, or the default.
|
||||
*/
|
||||
public function get(string $key): mixed
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT value FROM company_setting
|
||||
WHERE company_id = :company_id AND setting_key = :setting_key
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':setting_key' => $key]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($row === false) {
|
||||
return self::DEFAULTS[$key] ?? null;
|
||||
}
|
||||
|
||||
// Cast numeric-looking values to int for clean comparisons
|
||||
return is_numeric($row['value']) ? (int)$row['value'] : $row['value'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all settings for this company, merged with defaults.
|
||||
*
|
||||
* @return array Associative array of key => value.
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT setting_key, value FROM company_setting
|
||||
WHERE company_id = :company_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
// Merge stored values over defaults, cast numeric values
|
||||
$result = self::DEFAULTS;
|
||||
foreach ($rows as $k => $v) {
|
||||
$result[$k] = is_numeric($v) ? (int)$v : $v;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a single setting value.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
*/
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
// Fetch existing log to append to it
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT log FROM company_setting
|
||||
WHERE company_id = :company_id AND setting_key = :setting_key
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':setting_key' => $key]);
|
||||
$existing_log = json_decode($sth->fetchColumn() ?: '[]', true) ?: [];
|
||||
$existing_log[] = [
|
||||
'user_id' => $_SESSION['login_user_id'] ?? null,
|
||||
'dt' => date('Y-m-d H:i:s'),
|
||||
'value' => $value,
|
||||
];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO company_setting (company_id, setting_key, value, log, updated_at)
|
||||
VALUES (:company_id, :setting_key, :value, :log, NOW())
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value), log = VALUES(log), updated_at = NOW()"
|
||||
)->execute([
|
||||
':company_id' => $this->companyId,
|
||||
':setting_key' => $key,
|
||||
':value' => $value,
|
||||
':log' => json_encode($existing_log),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP handler — read or update settings via AJAX.
|
||||
*
|
||||
* action: 'read' → return all settings
|
||||
* action: 'update' → upsert one or more keys from $data
|
||||
*
|
||||
* @param array $data Request data array.
|
||||
*/
|
||||
public function handle(array $data): void
|
||||
{
|
||||
$action = $data['action'] ?? '';
|
||||
|
||||
if ($action === 'read') {
|
||||
echo json_encode(['success' => 1, 'output' => $this->getAll()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'update') {
|
||||
$allowed = array_keys(self::DEFAULTS);
|
||||
foreach ($allowed as $key) {
|
||||
$this->set($key, $data[$key] ?? self::DEFAULTS[$key]);
|
||||
}
|
||||
echo json_encode(['success' => 1, 'message' => 'Settings saved.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => 0, 'message' => 'Invalid action.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
*
|
||||
* Method order:
|
||||
* Core public API → checkStrength, change, forceSet
|
||||
* HTTP handlers → handleCheck, handleChange (thin wrappers for AJAX endpoints)
|
||||
* HTTP handler → handleChange (thin wrapper for AJAX endpoint)
|
||||
* Private helpers → loadZxcvbn, fetchUser, enforceStrength, persist
|
||||
*
|
||||
* Usage:
|
||||
@@ -49,7 +49,7 @@ class PasswordManager {
|
||||
*/
|
||||
public function __construct($pdo, string $include_url) {
|
||||
$this->pdo = $pdo;
|
||||
$this->zxcvbn_path = rtrim($include_url, '/') . '/assets/zxcvbn-php-master/vendor/autoload.php';
|
||||
$this->zxcvbn_path = rtrim($include_url, '/') . '/../lib/zxcvbn-php-master/vendor/autoload.php';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -166,44 +166,9 @@ class PasswordManager {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// HTTP handlers (thin AJAX endpoint wrappers)
|
||||
// HTTP handler (thin AJAX endpoint wrapper)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle an AJAX strength-check request and echo a JSON response.
|
||||
*
|
||||
* Called by: setting/api/engine/check_password.php
|
||||
*
|
||||
* Expected $data keys:
|
||||
* password (string) — the password string to analyse
|
||||
*
|
||||
* Response JSON keys:
|
||||
* success (int 1), score (int -1 if empty, else 0–4), feedback (string)
|
||||
*
|
||||
* Outputs JSON and calls exit. Safe against XSS — all output via json_encode.
|
||||
*
|
||||
* @param array $data Request data array (typically from $_POST or decoded JSON body).
|
||||
*/
|
||||
public function handleCheck(array $data): void {
|
||||
|
||||
$password = $data['password'] ?? '';
|
||||
|
||||
if (empty($password)) {
|
||||
echo json_encode(['success' => 1, 'score' => -1, 'feedback' => '']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = $this->checkStrength($password);
|
||||
$feedback = $result['warning'] ?: ($result['suggestions'][0] ?? '');
|
||||
|
||||
echo json_encode([
|
||||
'success' => 1,
|
||||
'score' => $result['score'],
|
||||
'feedback' => $feedback,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an AJAX change-password request and echo a JSON response.
|
||||
*
|
||||
|
||||
@@ -25,18 +25,20 @@
|
||||
* - getRecentActivity: warehouse_name in SQL now uses $safe (was unescaped)
|
||||
* - getExpiredStock: warehouse_id cast to (int) before SQL fragment injection;
|
||||
* warehouse_name in UNION now uses $safe (was unescaped)
|
||||
* - getRackLog: DB name from SELECT DATABASE() bound via PDO (was raw interpolation)
|
||||
* - getRackLog: cross-DB join uses $mainDb injected at construction time
|
||||
* - getRackOccupancy: (already safe — no dynamic identifiers)
|
||||
*/
|
||||
class ReportManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
private string $mainDb;
|
||||
|
||||
public function __construct(PDO $pdo, int $companyId)
|
||||
public function __construct(PDO $pdo, int $companyId, string $mainDb = '')
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $companyId;
|
||||
$this->mainDb = $mainDb;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -600,7 +602,8 @@ class ReportManager
|
||||
LEFT JOIN md_product p
|
||||
ON p.company_id = s.company_id
|
||||
AND p.sku = s.product_sku
|
||||
WHERE s.company_id = {$cid}";
|
||||
WHERE s.company_id = {$cid}
|
||||
AND s.status = 1";
|
||||
},
|
||||
$warehouses
|
||||
));
|
||||
@@ -740,7 +743,8 @@ class ReportManager
|
||||
ROUND(SUM(`out`), 2) AS stock_out
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND date >= :start AND date <= :end
|
||||
AND status = 1
|
||||
AND date >= :start AND date <= :end
|
||||
GROUP BY sort_key, label
|
||||
ORDER BY sort_key ASC"
|
||||
);
|
||||
@@ -790,7 +794,7 @@ class ReportManager
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT ROUND(COALESCE(SUM(`in`) - SUM(`out`), 0), 2) AS balance
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id AND date < :start"
|
||||
WHERE company_id = :company_id AND status = 1 AND date < :start"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':start' => $start]);
|
||||
$running = (float)$sth->fetchColumn();
|
||||
@@ -801,7 +805,8 @@ class ReportManager
|
||||
ROUND(SUM(`out`), 2) AS stock_out
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND date >= :start AND date <= :end
|
||||
AND status = 1
|
||||
AND date >= :start AND date <= :end
|
||||
GROUP BY sort_key
|
||||
ORDER BY sort_key ASC"
|
||||
);
|
||||
@@ -939,7 +944,8 @@ class ReportManager
|
||||
FROM `td_stock_{$safe}` s
|
||||
WHERE s.company_id = {$cid}
|
||||
AND s.type = 'in'
|
||||
AND s.lot_number IS NOT NULL";
|
||||
AND s.lot_number IS NOT NULL
|
||||
AND s.status = 1";
|
||||
},
|
||||
$warehouses
|
||||
));
|
||||
@@ -1058,6 +1064,7 @@ class ReportManager
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
AND s.lot_number = :lot_number
|
||||
AND s.status = 1
|
||||
ORDER BY s.date DESC"
|
||||
);
|
||||
$sth->execute([
|
||||
@@ -1089,6 +1096,37 @@ class ReportManager
|
||||
*/
|
||||
public function getProductLots(): array
|
||||
{
|
||||
$warehouses = $this->pdo->prepare(
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1"
|
||||
);
|
||||
$warehouses->execute([':company_id' => $this->companyId]);
|
||||
$wh_list = $warehouses->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Build per-lot balance by summing approved td_stock rows across all warehouses.
|
||||
// warehouse_balance is per-product, not per-lot, so we query td_stock directly.
|
||||
$lot_balance = [];
|
||||
$cid = (int) $this->companyId;
|
||||
|
||||
foreach ($wh_list as $wh) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$table = "td_stock_{$safe}";
|
||||
|
||||
$sth = $this->pdo->query(
|
||||
"SELECT lot_number,
|
||||
ROUND(SUM(COALESCE(`in`, 0)) - SUM(COALESCE(`out`, 0)), 2) AS lot_balance
|
||||
FROM `{$table}`
|
||||
WHERE company_id = {$cid}
|
||||
AND status = 1
|
||||
AND lot_number IS NOT NULL
|
||||
GROUP BY lot_number"
|
||||
);
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $lb) {
|
||||
$key = $lb['lot_number'];
|
||||
$lot_balance[$key] = ($lot_balance[$key] ?? 0) + (float)$lb['lot_balance'];
|
||||
}
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
l.id,
|
||||
@@ -1097,14 +1135,7 @@ class ReportManager
|
||||
l.expiry_date,
|
||||
l.description,
|
||||
p.product_name,
|
||||
DATEDIFF(l.expiry_date, CURDATE()) AS days_remaining,
|
||||
COALESCE(
|
||||
(SELECT ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2)
|
||||
FROM warehouse_balance wb
|
||||
WHERE wb.company_id = l.company_id
|
||||
AND wb.product_sku = l.product_sku),
|
||||
0
|
||||
) AS balance
|
||||
DATEDIFF(l.expiry_date, CURDATE()) AS days_remaining
|
||||
FROM md_lot l
|
||||
INNER JOIN md_product p
|
||||
ON p.company_id = l.company_id
|
||||
@@ -1117,18 +1148,24 @@ class ReportManager
|
||||
|
||||
$active = $expired = $near = 0;
|
||||
|
||||
// Exclude lots that have no td_stock record at all (e.g. all rows were soft-deleted)
|
||||
$rows = array_values(array_filter($rows, fn($r) => array_key_exists($r['lot_number'], $lot_balance)));
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$days = (int)$row['days_remaining'];
|
||||
$balance = (float)$row['balance'];
|
||||
$balance = round($lot_balance[$row['lot_number']] ?? 0, 2);
|
||||
|
||||
$row['balance'] = $balance;
|
||||
$row['is_active'] = $balance > 0 ? 1 : 0;
|
||||
|
||||
if ($balance > 0) $active++;
|
||||
if ($days < 0) $expired++;
|
||||
if ($days >= 0 && $days <= 30) $near++;
|
||||
|
||||
if ($days < 0) $row['status'] = 'expired';
|
||||
elseif ($days <= 7) $row['status'] = 'critical';
|
||||
elseif ($days <= 30) $row['status'] = 'near';
|
||||
else $row['status'] = 'ok';
|
||||
if ($days < 0) $row['status'] = 'expired';
|
||||
elseif ($days <= 7) $row['status'] = 'critical';
|
||||
elseif ($days <= 30) $row['status'] = 'near';
|
||||
else $row['status'] = 'ok';
|
||||
}
|
||||
unset($row);
|
||||
|
||||
@@ -1158,9 +1195,7 @@ class ReportManager
|
||||
{
|
||||
if (!$rack_id) return [];
|
||||
|
||||
// Fetch DB name safely — used as a backtick-quoted identifier, not user input
|
||||
$db_name = $this->pdo->query("SELECT DATABASE()")->fetchColumn();
|
||||
$db_name = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$db_name);
|
||||
$main_db = preg_replace('/[^a-zA-Z0-9_]/', '', $this->mainDb);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
@@ -1172,7 +1207,7 @@ class ReportManager
|
||||
rl.login,
|
||||
u.name AS user_name
|
||||
FROM md_rack_log rl
|
||||
LEFT JOIN `{$db_name}`.user u
|
||||
LEFT JOIN `{$main_db}`.user u
|
||||
ON u.user_id = rl.user_id
|
||||
WHERE rl.company_id = :company_id
|
||||
AND rl.md_rack_id = :rack_id
|
||||
@@ -1299,6 +1334,7 @@ class ReportManager
|
||||
"SELECT DISTINCT product_sku
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND status = 1
|
||||
AND date BETWEEN :date_from AND :date_to
|
||||
ORDER BY product_sku ASC"
|
||||
);
|
||||
@@ -1313,6 +1349,7 @@ class ReportManager
|
||||
ROUND(SUM(COALESCE(`out`, 0)), 2) AS total_out
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND status = 1
|
||||
AND date BETWEEN :date_from AND :date_to"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':date_from' => $date_from, ':date_to' => $date_to]);
|
||||
@@ -1366,6 +1403,7 @@ class ReportManager
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND product_sku = :sku
|
||||
AND status = 1
|
||||
AND date < :date_from"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':sku' => $product_sku, ':date_from' => $date_from]);
|
||||
@@ -1404,6 +1442,7 @@ class ReportManager
|
||||
AND c.id = s.contact_id
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :sku
|
||||
AND s.status = 1
|
||||
AND s.date BETWEEN :date_from AND :date_to
|
||||
ORDER BY s.date ASC, s.id ASC"
|
||||
);
|
||||
|
||||
@@ -252,7 +252,7 @@ class StockManager {
|
||||
* @param array $logging Audit entry to append to the log column.
|
||||
* @param string $uuid UUID for this transaction (shared across transfer pairs).
|
||||
*/
|
||||
public function saveStockIn(array $data, array $logging, string $uuid): void
|
||||
public function saveStockIn(array $data, array $logging, string $uuid): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$warehouse_id = (int)($data['warehouse'] ?? 0);
|
||||
@@ -282,6 +282,8 @@ class StockManager {
|
||||
':log' => json_encode($table_log),
|
||||
]);
|
||||
|
||||
return 0; // update — no new row
|
||||
|
||||
} else {
|
||||
|
||||
$lot_number = $data['lot_number'] ?: null;
|
||||
@@ -304,10 +306,10 @@ class StockManager {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `$table`
|
||||
(uuid, company_id, `date`, product_sku, `in`, zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`, lot_number, serial_number)
|
||||
contact_id, `description`, `log`, `type`, lot_number, serial_number, status)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'in', :lot_number, :serial_number)"
|
||||
:contact_id, :description, :log, 'in', :lot_number, :serial_number, 0)"
|
||||
)->execute([
|
||||
':uuid' => $uuid,
|
||||
':company_id' => $this->company_id,
|
||||
@@ -326,22 +328,8 @@ class StockManager {
|
||||
|
||||
$td_stock_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
// Mark rack as occupied and link it to this stock row
|
||||
$whMgmt->occupyRack(
|
||||
$warehouse_id,
|
||||
$data['zone'], $data['aisle'], $data['rack'],
|
||||
$data['product_sku'],
|
||||
$td_stock_id
|
||||
);
|
||||
|
||||
// Update running balance (+quantity in this warehouse)
|
||||
$whMgmt->adjustBalance(
|
||||
'in',
|
||||
$warehouse_id,
|
||||
$data['product_sku'],
|
||||
$row['in'] ?? 0,
|
||||
$data['quantity']
|
||||
);
|
||||
// occupyRack and adjustBalance deferred — called from approveStock() only.
|
||||
return $td_stock_id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +353,7 @@ class StockManager {
|
||||
* @param string $uuid UUID for this transaction.
|
||||
* @throws Exception If the rack is empty, holds a different SKU/lot/serial.
|
||||
*/
|
||||
public function saveStockOut(array $data, array $logging, string $uuid): void
|
||||
public function saveStockOut(array $data, array $logging, string $uuid): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$warehouse_id = (int)($data['warehouse'] ?? 0);
|
||||
@@ -395,6 +383,8 @@ class StockManager {
|
||||
':log' => json_encode($table_log),
|
||||
]);
|
||||
|
||||
return 0; // update — no new row
|
||||
|
||||
} else {
|
||||
|
||||
// Validate rack holds the expected product / lot / serial
|
||||
@@ -436,10 +426,10 @@ class StockManager {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `$table`
|
||||
(uuid, company_id, `date`, product_sku, `out`, zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number)
|
||||
contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number, status)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number)"
|
||||
:contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number, 0)"
|
||||
)->execute([
|
||||
':uuid' => $uuid,
|
||||
':company_id' => $this->company_id,
|
||||
@@ -457,19 +447,10 @@ class StockManager {
|
||||
':serial_number' => $serial_number,
|
||||
]);
|
||||
|
||||
// Release the source rack and reverse balance
|
||||
$whMgmt->releaseRack(
|
||||
$warehouse_id,
|
||||
$data['zone'], $data['aisle'], $data['rack']
|
||||
);
|
||||
$out_stock_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
$whMgmt->adjustBalance(
|
||||
'out',
|
||||
$warehouse_id,
|
||||
$data['product_sku'],
|
||||
$row['out'] ?? 0,
|
||||
$quantity
|
||||
);
|
||||
// releaseRack and adjustBalance deferred — called from approveStock() only.
|
||||
return $out_stock_id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,7 +481,7 @@ class StockManager {
|
||||
* @param string $uuid UUID shared by both the from and to rows.
|
||||
* @throws Exception If source rack validation fails or paired record is missing on update.
|
||||
*/
|
||||
public function saveStockTransfer(array $data, array $logging, string $uuid): void
|
||||
public function saveStockTransfer(array $data, array $logging, string $uuid): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||
@@ -564,7 +545,7 @@ class StockManager {
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
return;
|
||||
return 0; // update — no new row
|
||||
}
|
||||
|
||||
// Insert: validate source rack, then create paired rows
|
||||
@@ -617,18 +598,18 @@ class StockManager {
|
||||
"INSERT INTO `$from_table`
|
||||
(uuid, company_id, `date`, product_sku, `out`,
|
||||
ref_warehouse, zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`, lot_number, serial_number)
|
||||
contact_id, `description`, `log`, `type`, lot_number, serial_number, status)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||
:ref_warehouse, :zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number)"
|
||||
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0)"
|
||||
)->execute([
|
||||
':uuid' => $uuid,
|
||||
':company_id' => $this->company_id,
|
||||
':date' => date('Y-m-d H:i:s'),
|
||||
':product_sku' => $data['product_sku'],
|
||||
':quantity' => $quantity,
|
||||
':ref_warehouse' => $to_warehouse,
|
||||
':ref_warehouse' => $whMgmt->getWarehouseName($to_warehouse),
|
||||
':zone' => $from_zone,
|
||||
':aisle' => $from_aisle,
|
||||
':rack' => $from_rack,
|
||||
@@ -645,18 +626,18 @@ class StockManager {
|
||||
"INSERT INTO `$to_table`
|
||||
(uuid, company_id, `date`, product_sku, `in`,
|
||||
ref_warehouse, ref_id, zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`, lot_number, serial_number)
|
||||
contact_id, `description`, `log`, `type`, lot_number, serial_number, status)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||
:ref_warehouse, :ref_id, :zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number)"
|
||||
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0)"
|
||||
)->execute([
|
||||
':uuid' => $uuid,
|
||||
':company_id' => $this->company_id,
|
||||
':date' => date('Y-m-d H:i:s'),
|
||||
':product_sku' => $data['product_sku'],
|
||||
':quantity' => $quantity,
|
||||
':ref_warehouse' => $from_warehouse,
|
||||
':ref_warehouse' => $whMgmt->getWarehouseName($from_warehouse),
|
||||
':ref_id' => $from_stock_id,
|
||||
':zone' => $to_zone,
|
||||
':aisle' => $to_aisle,
|
||||
@@ -679,13 +660,160 @@ class StockManager {
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
// Rack state: release source, occupy destination
|
||||
$whMgmt->releaseRack($from_warehouse, $from_zone, $from_aisle, $from_rack);
|
||||
$whMgmt->occupyRack($to_warehouse, $to_zone, $to_aisle, $to_rack, $data['product_sku'], $to_stock_id);
|
||||
// releaseRack, occupyRack and adjustBalance deferred — called from approveStock() only.
|
||||
return $from_stock_id;
|
||||
}
|
||||
|
||||
// Balance: deduct from source, add to destination
|
||||
$whMgmt->adjustBalance('out', $from_warehouse, $data['product_sku'], 0, $quantity);
|
||||
$whMgmt->adjustBalance('in', $to_warehouse, $data['product_sku'], 0, $quantity);
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Approve
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Approve a draft td_stock row (status 0 → 1) and update warehouse_balance.
|
||||
*
|
||||
* This is the single entry point for balance updates — both auto-approve
|
||||
* (called immediately after save) and manual approve go through here.
|
||||
* adjustBalance() is never called from anywhere else.
|
||||
*
|
||||
* For transfer rows, both the outbound (from) and inbound (to) rows share
|
||||
* the same uuid. We approve both in one call so the pair is always consistent.
|
||||
*
|
||||
* @param int $id The td_stock row id.
|
||||
* @param int $warehouse_id The warehouse the row belongs to.
|
||||
* @param string $type 'in' | 'out' | 'transfer'
|
||||
* @param WarehouseManager $whMgmt Injected to keep balance logic centralised.
|
||||
* @throws Exception If the row is not found, already approved, or wrong company.
|
||||
*/
|
||||
public function approveStock(int $id, int $warehouse_id, string $type, WarehouseManager $whMgmt): void
|
||||
{
|
||||
$table = $this->resolveTable($warehouse_id);
|
||||
|
||||
// Load the row and validate ownership / status
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM `{$table}`
|
||||
WHERE id = :id AND company_id = :company_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':id' => $id, ':company_id' => $this->company_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new Exception('Stock record not found.');
|
||||
}
|
||||
|
||||
if ((int)$row['status'] === 1) {
|
||||
throw new Exception('Already approved.');
|
||||
}
|
||||
|
||||
// ── Approve this row ──────────────────────────────────────────────
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET status = 1 WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([':id' => $id, ':company_id' => $this->company_id]);
|
||||
|
||||
// ── Rack state + balance ──────────────────────────────────────────
|
||||
if ($type === 'in') {
|
||||
|
||||
// Occupy rack now that stock is approved
|
||||
$whMgmt->occupyRack(
|
||||
$warehouse_id,
|
||||
$row['zone'], $row['aisle'], $row['rack'],
|
||||
$row['product_sku'],
|
||||
$id
|
||||
);
|
||||
$whMgmt->adjustBalance('in', $warehouse_id, $row['product_sku'], 0, (float)$row['in']);
|
||||
|
||||
} elseif ($type === 'out') {
|
||||
|
||||
// Release rack now that stock-out is approved
|
||||
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
||||
$whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out']);
|
||||
|
||||
} elseif ($type === 'transfer') {
|
||||
|
||||
// Transfer always has two rows in two different tables:
|
||||
// outbound row → td_stock_<from> (out > 0, ref_warehouse = to_name)
|
||||
// inbound row → td_stock_<to> (in > 0, ref_warehouse = from_name)
|
||||
// Both rows share the same uuid. We always approve both atomically.
|
||||
// Identify which side we were given and derive the other.
|
||||
|
||||
$is_outbound = (float)$row['out'] > 0;
|
||||
|
||||
// The outbound row lives in the from-warehouse table (already loaded as $row/$table).
|
||||
// The inbound row lives in td_stock_<ref_warehouse>.
|
||||
$from_row = $is_outbound ? $row : null;
|
||||
$from_table = $is_outbound ? $table : null;
|
||||
$from_wh_id = $is_outbound ? $warehouse_id : null;
|
||||
|
||||
// Resolve the paired table from ref_warehouse
|
||||
$paired_wh_name = $row['ref_warehouse'];
|
||||
$paired_safe = preg_replace('/[^a-zA-Z0-9_]/', '', $paired_wh_name);
|
||||
$paired_table = "td_stock_{$paired_safe}";
|
||||
$paired_wh_id = $whMgmt->getWarehouseIdByName($paired_wh_name);
|
||||
|
||||
// If we received the inbound side, swap so $from_* is always outbound
|
||||
if (!$is_outbound) {
|
||||
$from_table = $paired_table;
|
||||
$from_wh_id = $paired_wh_id;
|
||||
$paired_table = $table;
|
||||
$paired_wh_id = $warehouse_id;
|
||||
}
|
||||
|
||||
// Load the inbound row from the paired table using uuid
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM `{$paired_table}`
|
||||
WHERE uuid = :uuid AND company_id = :company_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':uuid' => $row['uuid'], ':company_id' => $this->company_id]);
|
||||
$inbound_row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Load outbound row if we were given the inbound side
|
||||
if (!$is_outbound) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM `{$from_table}`
|
||||
WHERE uuid = :uuid AND company_id = :company_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':uuid' => $row['uuid'], ':company_id' => $this->company_id]);
|
||||
$from_row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
} else {
|
||||
$from_row = $row;
|
||||
}
|
||||
|
||||
// Approve outbound row
|
||||
if ($from_row && (int)$from_row['status'] === 0) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$from_table}` SET status = 1
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([':id' => $from_row['id'], ':company_id' => $this->company_id]);
|
||||
}
|
||||
|
||||
// Approve inbound row
|
||||
if ($inbound_row && (int)$inbound_row['status'] === 0) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$paired_table}` SET status = 1
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([':id' => $inbound_row['id'], ':company_id' => $this->company_id]);
|
||||
}
|
||||
|
||||
// Rack state: release source, occupy destination
|
||||
if ($from_row && $from_wh_id) {
|
||||
$whMgmt->releaseRack(
|
||||
$from_wh_id,
|
||||
$from_row['zone'], $from_row['aisle'], $from_row['rack']
|
||||
);
|
||||
$whMgmt->adjustBalance('out', $from_wh_id, $from_row['product_sku'], 0, (float)$from_row['out']);
|
||||
}
|
||||
if ($inbound_row && $paired_wh_id) {
|
||||
$whMgmt->occupyRack(
|
||||
$paired_wh_id,
|
||||
$inbound_row['zone'], $inbound_row['aisle'], $inbound_row['rack'],
|
||||
$inbound_row['product_sku'],
|
||||
$inbound_row['id']
|
||||
);
|
||||
$whMgmt->adjustBalance('in', $paired_wh_id, $inbound_row['product_sku'], 0, (float)$inbound_row['in']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -216,7 +216,8 @@ class WarehouseManager {
|
||||
$unions = implode(' UNION ALL ', array_map(
|
||||
fn($t) => "SELECT `type`, `date` FROM `$t`
|
||||
WHERE company_id = :company_id
|
||||
AND product_sku = :product_sku",
|
||||
AND product_sku = :product_sku
|
||||
AND status = 1",
|
||||
$tables
|
||||
));
|
||||
|
||||
@@ -428,6 +429,25 @@ class WarehouseManager {
|
||||
return $sth->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse lookup — return warehouse_id for a given warehouse_name.
|
||||
* Used by approveStock() to resolve the destination warehouse on transfers.
|
||||
*
|
||||
* @param string $name The warehouse_name stored in td_stock.ref_warehouse.
|
||||
* @return int|null The warehouse id, or null if not found.
|
||||
*/
|
||||
public function getWarehouseIdByName(string $name): ?int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM md_warehouse
|
||||
WHERE company_id = :company_id AND warehouse_name = :name
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':name' => $name]);
|
||||
$id = $sth->fetchColumn();
|
||||
return $id !== false ? (int)$id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new warehouse or update an existing one.
|
||||
*
|
||||
@@ -853,14 +873,35 @@ class WarehouseManager {
|
||||
*/
|
||||
public function getLotList(string $product_sku, string $keyword): array
|
||||
{
|
||||
// Only return lots that have at least one active td_stock row.
|
||||
// Lots whose stock was fully soft-deleted (company_id negated) are excluded.
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($warehouses)) return [];
|
||||
|
||||
$cid = (int)$this->company_id;
|
||||
|
||||
// Build UNION EXISTS subquery across all td_stock_* tables
|
||||
$unions = implode(' UNION ALL ', array_map(function($wh) use ($cid) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
return "SELECT lot_number FROM `td_stock_{$safe}`
|
||||
WHERE company_id = {$cid} AND lot_number IS NOT NULL";
|
||||
}, $warehouses));
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT *
|
||||
FROM md_lot
|
||||
WHERE company_id = :company_id
|
||||
AND product_sku = :product_sku
|
||||
AND lot_number LIKE :keyword
|
||||
ORDER BY lot_number ASC
|
||||
LIMIT 50"
|
||||
FROM md_lot
|
||||
WHERE company_id = :company_id
|
||||
AND product_sku = :product_sku
|
||||
AND lot_number LIKE :keyword
|
||||
AND lot_number IN (SELECT lot_number FROM ({$unions}) AS all_lots)
|
||||
ORDER BY lot_number ASC
|
||||
LIMIT 50"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
@@ -906,10 +947,12 @@ class WarehouseManager {
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
AND s.type = 'in'
|
||||
AND s.status = 1
|
||||
AND s.lot_number IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM md_rack r
|
||||
WHERE r.company_id = s.company_id
|
||||
AND r.warehouse = :warehouse_id
|
||||
AND r.td_stock_id = s.id
|
||||
AND r.product_sku IS NOT NULL
|
||||
)"
|
||||
@@ -917,6 +960,7 @@ class WarehouseManager {
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':product_sku' => $product_sku,
|
||||
':warehouse_id' => $wh['id'],
|
||||
]);
|
||||
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
@@ -953,15 +997,18 @@ class WarehouseManager {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$table = "td_stock_{$safe}";
|
||||
|
||||
$wh_id = $wh['id'];
|
||||
$sql = "SELECT DISTINCT s.serial_number
|
||||
FROM `{$table}` s
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
AND s.type = 'in'
|
||||
AND s.status = 1
|
||||
AND s.serial_number IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM md_rack r
|
||||
WHERE r.company_id = s.company_id
|
||||
AND r.warehouse = {$wh_id}
|
||||
AND r.td_stock_id = s.id
|
||||
AND r.product_sku IS NOT NULL
|
||||
)";
|
||||
@@ -1765,11 +1812,11 @@ class WarehouseManager {
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([':id' => $stock_id, ':company_id' => $this->company_id]);
|
||||
|
||||
// Release the rack back to empty
|
||||
$this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
||||
|
||||
// Reverse the balance (subtract the previously added quantity)
|
||||
$this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0);
|
||||
// Only reverse side effects if the row was approved — draft rows never had them applied
|
||||
if ((int)$row['status'] === 1) {
|
||||
$this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
||||
$this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1809,16 +1856,17 @@ class WarehouseManager {
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([':id' => $stock_id, ':company_id' => $this->company_id]);
|
||||
|
||||
// Re-occupy the rack with the original stock_in batch that was consumed
|
||||
$this->occupyRack(
|
||||
$warehouse_id,
|
||||
$row['zone'], $row['aisle'], $row['rack'],
|
||||
$product_sku,
|
||||
(int)$row['ref_id']
|
||||
);
|
||||
|
||||
// Reverse the balance (subtract the previously deducted quantity)
|
||||
$this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0);
|
||||
// Only reverse side effects if the row was approved — draft rows never had them applied
|
||||
if ((int)$row['status'] === 1) {
|
||||
// Re-occupy the rack with the original stock_in batch that was consumed
|
||||
$this->occupyRack(
|
||||
$warehouse_id,
|
||||
$row['zone'], $row['aisle'], $row['rack'],
|
||||
$product_sku,
|
||||
(int)$row['ref_id']
|
||||
);
|
||||
$this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1877,30 +1925,92 @@ class WarehouseManager {
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([':id' => $ref_id, ':company_id' => $this->company_id]);
|
||||
|
||||
// Destination rack was occupied by transfer_in — release it
|
||||
$this->releaseRack(
|
||||
$to_warehouse,
|
||||
$to_row['zone'], $to_row['aisle'], $to_row['rack']
|
||||
);
|
||||
// Only reverse side effects if approved — draft rows never had them applied
|
||||
if ((int)$from_row['status'] === 1) {
|
||||
// Destination rack was occupied by transfer_in — release it
|
||||
$this->releaseRack(
|
||||
$to_warehouse,
|
||||
$to_row['zone'], $to_row['aisle'], $to_row['rack']
|
||||
);
|
||||
|
||||
// Source rack was released by transfer_out — re-occupy with original batch
|
||||
$this->occupyRack(
|
||||
$from_warehouse_id,
|
||||
$from_row['zone'], $from_row['aisle'], $from_row['rack'],
|
||||
$product_sku,
|
||||
$from_stock_id
|
||||
);
|
||||
// Source rack was released by transfer_out — re-occupy with original batch
|
||||
$this->occupyRack(
|
||||
$from_warehouse_id,
|
||||
$from_row['zone'], $from_row['aisle'], $from_row['rack'],
|
||||
$product_sku,
|
||||
$from_stock_id
|
||||
);
|
||||
|
||||
// Reverse balances on both sides
|
||||
$quantity = (int)$from_row['out'];
|
||||
$this->adjustBalance('out', $from_warehouse_id, $product_sku, $quantity, 0);
|
||||
$this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0);
|
||||
// Reverse balances on both sides
|
||||
$quantity = (int)$from_row['out'];
|
||||
$this->adjustBalance('out', $from_warehouse_id, $product_sku, $quantity, 0);
|
||||
$this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// REPORT BASIS — Warehouse list queries
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return warehouse ids that currently hold an approved, rack-occupied stock-in
|
||||
* row for a given product_sku + lot_number combination.
|
||||
*
|
||||
* Used to filter the warehouse dropdown in stock-out/transfer when a lot is selected,
|
||||
* so only warehouses actually holding that lot are shown.
|
||||
*
|
||||
* @param string $product_sku
|
||||
* @param string $lot_number
|
||||
* @return int[] Array of warehouse ids.
|
||||
*/
|
||||
public function getWarehousesByLot(string $product_sku, string $lot_number): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$result = [];
|
||||
$cid = (int)$this->company_id;
|
||||
|
||||
foreach ($warehouses as $wh) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$table = "td_stock_{$safe}";
|
||||
$wh_id = (int)$wh['id'];
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT 1 FROM `{$table}` s
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
AND s.lot_number = :lot_number
|
||||
AND s.type = 'in'
|
||||
AND s.status = 1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM md_rack r
|
||||
WHERE r.company_id = s.company_id
|
||||
AND r.warehouse = :warehouse_id
|
||||
AND r.td_stock_id = s.id
|
||||
AND r.product_sku IS NOT NULL
|
||||
)
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $cid,
|
||||
':product_sku' => $product_sku,
|
||||
':lot_number' => $lot_number,
|
||||
':warehouse_id' => $wh_id,
|
||||
]);
|
||||
|
||||
if ($sth->fetchColumn() !== false) {
|
||||
$result[] = ['id' => $wh_id, 'warehouse_name' => $wh['warehouse_name']];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return warehouses filtered by rack availability — for stock movement warehouse selectors.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user