").html(item.label))
+ .appendTo(ul);
+ };
// Add this ↓
$el.on('blur', function () {
@@ -220,6 +220,117 @@ $(function () {
});
+
+ /** =========================
+ * LOT SEARCH AUTOCOMPLETE
+ * ========================= */
+ $('input.lot_search').each(function (i, el) {
+
+ var $el = $(el);
+ $el.autocomplete({
+
+ source: function (request, response) {
+
+ if ($el.data('xhr')) {
+ $el.data('xhr').abort();
+ }
+
+ let q = prepare_form_data(0, true);
+ q['keyword'] = request.term;
+ q['product_sku'] = $('input#product_sku').attr('secondary') || $('input#product_sku').val();
+ let json = JSON.stringify(q);
+
+ var xhr = $.ajax({
+ url: server_url + 'ics/api/engine/retrieve_lot.php',
+ type: 'POST',
+ dataType: 'json',
+ data: { json: json },
+ headers: {
+ 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
+ },
+ success: function (res) {
+
+ var aca = [];
+
+ $.each(res.result, function (i, item) {
+ var temp = "";
+ temp += "
";
+ temp += "
";
+ temp += "Lot: " + item.lot_number;
+ temp += "
";
+ temp += "
";
+ temp += "Expiry: " + (item.expiry_date || 'N/A');
+ temp += "
";
+ temp += "
";
+
+ aca.push({
+ label: temp,
+ value: item.lot_number,
+ data: item
+ });
+ });
+
+ response(aca);
+ }
+ });
+
+ $el.data('xhr', xhr);
+ },
+
+ minLength: 0,
+ delay: 300,
+
+ select: function (e, ui) {
+ if (ui.item && ui.item.data) {
+
+ var lot = ui.item.data;
+
+ // Set lot_number value
+ $(this)
+ .val(lot.lot_number)
+ .attr('secondary', lot.lot_number)
+ .attr('data-existing', '1'); // flag: this is an existing lot
+
+ // Populate + lock expiry_date — existing lot, not editable
+ if (typeof expiryPicker !== 'undefined' && expiryPicker) {
+ expiryPicker.setDate(lot.expiry_date || '', true, 'Y-m-d');
+ if (expiryPicker.altInput) { expiryPicker.altInput.disabled = true; }
+ }
+ $('#expiry_date').prop('disabled', true)
+ .attr('title', 'Expiry date is locked for existing lots');
+
+ return false;
+ }
+ }
+
+ })
+
+ .data("ui-autocomplete")._renderItem = function (ul, item) {
+ return $("
")
+ .append($("").html(item.label))
+ .appendTo(ul);
+ };
+
+ // On blur — restore value if user didn't pick from dropdown
+ $el.on('blur', function () {
+ var secondary = $(this).attr('secondary');
+ if (secondary) {
+ $(this).val(secondary);
+ }
+ });
+
+ // On manual edit — clear existing flag and unlock expiry_date
+ $el.on('input', function () {
+ $(this).removeAttr('data-existing');
+ $('#expiry_date').prop('disabled', false).removeAttr('title');
+ if (typeof expiryPicker !== 'undefined' && expiryPicker) {
+ expiryPicker.clear();
+ if (expiryPicker.altInput) { expiryPicker.altInput.disabled = false; }
+ }
+ });
+
+ });
+
});
@@ -306,20 +417,20 @@ function ajax_request(options) {
return Promise.reject("validation_failed");
}
- if(options.data){
+ if (options.data) {
Object.entries(options.data).forEach(([key, value]) => {
payloadJson[key] = value;
});
}
- if(options.action){
+ if (options.action) {
// modify action
- if(options.action === 'manage'){
- options.action = (payloadJson['id'])?'update':'create';
+ if (options.action === 'manage') {
+ options.action = (payloadJson['id']) ? 'update' : 'create';
}
// add action to JSON
payloadJson['action'] = options.action;
- }else{
+ } else {
isAjaxProcessing = false;
return Promise.reject("please_define_action");
}
diff --git a/app/assets/utils/classes/Reports.php b/app/assets/utils/classes/Reports.php
index fc96ee0..2a70a2f 100644
--- a/app/assets/utils/classes/Reports.php
+++ b/app/assets/utils/classes/Reports.php
@@ -11,6 +11,38 @@ class Reports
$this->companyId = $companyId;
}
+ /**
+ * Resolve the td_stock_
table name for a given warehouse_id.
+ * Returns null if warehouse not found.
+ */
+ private function resolveWarehouseTable(int $warehouse_id): ?string
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT warehouse_name FROM md_warehouse
+ WHERE company_id = :company_id AND id = :id AND status = 1"
+ );
+ $sth->execute([':company_id' => $this->companyId, ':id' => $warehouse_id]);
+ $name = $sth->fetchColumn();
+ if (!$name) return null;
+ $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
+ return "td_stock_{$safe}";
+ }
+
+ // These helper methods abstract away the common pattern of preparing, executing, and fetching results from the database.
+ private function fetchScalar(string $sql)
+ {
+ $sth = $this->pdo->prepare($sql);
+ $sth->execute([":company_id" => $this->companyId]);
+ return $sth->fetchColumn();
+ }
+
+ private function fetchAll(string $sql): array
+ {
+ $sth = $this->pdo->prepare($sql);
+ $sth->execute([":company_id" => $this->companyId]);
+ return $sth->fetchAll(PDO::FETCH_ASSOC);
+ }
+
/**
* Master Data Summary:
* - Total Categories: Count of all categories in md_product_category for the company.
@@ -135,21 +167,408 @@ class Reports
return $this->fetchAll($sql);
}
- // FETCH HELPERS
- // These helper methods abstract away the common pattern of preparing, executing, and fetching results from the database.
- private function fetchScalar(string $sql)
+ /**
+ * Low Stock Items
+ *
+ * Returns per-warehouse rows where balance has fallen to/below reorder_point.
+ *
+ * Status tiers:
+ * critical — balance <= min_stock (safety floor breached)
+ * warning — balance <= reorder_point (reorder trigger hit, still above min)
+ *
+ * Products with reorder_point = 0 are skipped (no threshold defined).
+ */
+ public function getLowStockItems(): array
{
- $sth = $this->pdo->prepare($sql);
- $sth->execute([":company_id" => $this->companyId]);
- return $sth->fetchColumn();
+ $sql = "SELECT
+ wb.warehouse_id,
+ wb.product_sku,
+ mw.warehouse_name,
+ mp.product_name,
+ mp.min_stock,
+ mp.reorder_point,
+ mp.product_image,
+ ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance
+ FROM warehouse_balance wb
+ INNER JOIN md_product mp
+ ON wb.company_id = mp.company_id
+ AND wb.product_sku = mp.sku
+ INNER JOIN md_warehouse mw
+ ON wb.company_id = mw.company_id
+ AND wb.warehouse_id = mw.id
+ WHERE wb.company_id = :company_id
+ AND mp.reorder_point > 0
+ GROUP BY
+ wb.warehouse_id, wb.product_sku, mw.warehouse_name,
+ mp.product_name, mp.min_stock, mp.reorder_point, mp.product_image
+ HAVING balance <= mp.reorder_point
+ ORDER BY mp.product_name ASC, mw.warehouse_name ASC";
+
+ $rows = $this->fetchAll($sql);
+
+ $items = [];
+ foreach ($rows as $row) {
+ $balance = (float) $row['balance'];
+ $min_stock = (float) $row['min_stock'];
+
+ $is_critical = ($balance <= $min_stock);
+
+ $items[] = [
+ 'warehouse_id' => (int) $row['warehouse_id'],
+ 'warehouse_name' => $row['warehouse_name'],
+ 'product_sku' => $row['product_sku'],
+ 'product_name' => $row['product_name'],
+ 'product_image' => $row['product_image'],
+ 'balance' => max(0, round($balance, 2)),
+ 'min_stock' => (float) $row['min_stock'],
+ 'reorder_point' => (float) $row['reorder_point'],
+ 'status' => $is_critical ? 'critical' : 'warning',
+ ];
+ }
+
+ return $items;
}
- private function fetchAll(string $sql): array
+ /**
+ * Count of low-stock items at CRITICAL level (balance <= min_stock).
+ */
+ public function getCriticalStockCount(): int
{
- $sth = $this->pdo->prepare($sql);
- $sth->execute([":company_id" => $this->companyId]);
- return $sth->fetchAll(PDO::FETCH_ASSOC);
+ $items = $this->getLowStockItems();
+ return count(array_filter($items, fn($i) => $i['status'] === 'critical'));
}
+
+ /**
+ * Count of low-stock items at WARNING level (balance <= reorder_point, > min_stock).
+ */
+ public function getWarningStockCount(): int
+ {
+ $items = $this->getLowStockItems();
+ return count(array_filter($items, fn($i) => $i['status'] === 'warning'));
+ }
+
+ // CONTACT SUMMARY
+ public function getTotalContactCategory(): int
+ {
+ $sql = "SELECT COUNT(*)
+ FROM md_contact_type
+ WHERE company_id = :company_id";
+ return (int) $this->fetchScalar($sql);
+ }
+
+ public function getActiveContactCategory(): int
+ {
+ $sql = "SELECT COUNT(*)
+ FROM md_contact_type
+ WHERE company_id = :company_id
+ AND `status` = 1";
+ return (int) $this->fetchScalar($sql);
+ }
+
+ public function getInactiveContactCategory(): int
+ {
+ $sql = "SELECT COUNT(*)
+ FROM md_contact_type
+ WHERE company_id = :company_id
+ AND `status` = 0";
+ return (int) $this->fetchScalar($sql);
+ }
+
+ public function getTotalContact(): int
+ {
+ $sql = "SELECT COUNT(*)
+ FROM md_contact
+ WHERE company_id = :company_id";
+ return (int) $this->fetchScalar($sql);
+ }
+
+ // WAREHOUSE OVERVIEW
+ public function getWarehouseCapacity(int $warehouse_id): int
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM md_rack
+ WHERE company_id = :company_id AND warehouse = :warehouse_id"
+ );
+ $sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
+ return (int) $sth->fetchColumn();
+ }
+
+ public function getWarehouseSpaceUsed(int $warehouse_id): int
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT COUNT(*) FROM md_rack
+ WHERE company_id = :company_id AND warehouse = :warehouse_id
+ AND product_sku IS NOT NULL"
+ );
+ $sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
+ return (int) $sth->fetchColumn();
+ }
+
+ public function getWarehouseBalance(int $warehouse_id): array
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT
+ ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in,
+ ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out
+ FROM warehouse_balance
+ WHERE company_id = :company_id AND warehouse_id = :warehouse_id"
+ );
+ $sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
+ return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
+ }
+
+ public function getWarehouseLowStockCount(int $warehouse_id): int
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT wb.product_sku,
+ ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
+ mp.min_stock
+ FROM warehouse_balance wb
+ INNER JOIN md_product mp
+ ON mp.company_id = wb.company_id
+ AND mp.sku = wb.product_sku
+ WHERE wb.company_id = :company_id
+ AND wb.warehouse_id = :warehouse_id
+ GROUP BY wb.product_sku, mp.min_stock"
+ );
+ $sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
+ $rows = $sth->fetchAll(PDO::FETCH_ASSOC);
+ $count = 0;
+ foreach ($rows as $row) {
+ if ((float)$row['balance'] < (float)$row['min_stock']) $count++;
+ }
+ return $count;
+ }
+
+ public function getWarehouseStockMovement(int $warehouse_id): array
+ {
+ $table = $this->resolveWarehouseTable($warehouse_id);
+ if (!$table) return ['labels' => [], 'stock_in' => [], 'stock_out' => []];
+
+ $now = new DateTime();
+ $start = (clone $now)->modify('-12 months')->format('Y-m-d 00:00:00');
+ $end = $now->format('Y-m-d 23:59:59');
+
+ $sth = $this->pdo->query(
+ "SELECT DATE_FORMAT(date, '%b') AS label,
+ DATE_FORMAT(date, '%Y-%m') AS sort_key,
+ ROUND(SUM(`in`), 2) AS stock_in,
+ ROUND(SUM(`out`), 2) AS stock_out
+ FROM `{$table}`
+ WHERE company_id = {$this->companyId}
+ AND date >= '{$start}' AND date <= '{$end}'
+ GROUP BY sort_key, label
+ ORDER BY sort_key ASC"
+ );
+ $dataByMonth = [];
+ foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ $dataByMonth[$row['sort_key']] = $row;
+ }
+
+ $labels = []; $stock_in = []; $stock_out = [];
+ for ($i = 11; $i >= 0; $i--) {
+ $dt = (clone $now)->modify("-{$i} months");
+ $key = $dt->format('Y-m');
+ $labels[] = $dt->format('M');
+ $stock_in[] = isset($dataByMonth[$key]) ? (float)$dataByMonth[$key]['stock_in'] : 0;
+ $stock_out[] = isset($dataByMonth[$key]) ? (float)$dataByMonth[$key]['stock_out'] : 0;
+ }
+
+ return ['labels' => $labels, 'stock_in' => $stock_in, 'stock_out' => $stock_out];
+ }
+
+ public function getWarehouseStockTrend(int $warehouse_id): array
+ {
+ $table = $this->resolveWarehouseTable($warehouse_id);
+ if (!$table) return ['labels' => [], 'balance' => []];
+
+ $now = new DateTime();
+ $start = (clone $now)->modify('-12 months')->format('Y-m-d 00:00:00');
+ $end = $now->format('Y-m-d 23:59:59');
+
+ // Prior balance before the 12-month window
+ $sth = $this->pdo->query(
+ "SELECT ROUND(COALESCE(SUM(`in`) - SUM(`out`), 0), 2) AS balance
+ FROM `{$table}`
+ WHERE company_id = {$this->companyId} AND date < '{$start}'"
+ );
+ $running = (float) $sth->fetchColumn();
+
+ $sth = $this->pdo->query(
+ "SELECT DATE_FORMAT(date, '%Y-%m') AS sort_key,
+ ROUND(SUM(`in`), 2) AS stock_in,
+ ROUND(SUM(`out`), 2) AS stock_out
+ FROM `{$table}`
+ WHERE company_id = {$this->companyId}
+ AND date >= '{$start}' AND date <= '{$end}'
+ GROUP BY sort_key
+ ORDER BY sort_key ASC"
+ );
+ $dataByMonth = [];
+ foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ $dataByMonth[$row['sort_key']] = $row;
+ }
+
+ $labels = []; $balance = [];
+ for ($i = 11; $i >= 0; $i--) {
+ $dt = (clone $now)->modify("-{$i} months");
+ $key = $dt->format('Y-m');
+ $running += isset($dataByMonth[$key])
+ ? (float)$dataByMonth[$key]['stock_in'] - (float)$dataByMonth[$key]['stock_out']
+ : 0;
+ $labels[] = $dt->format('M');
+ $balance[] = round($running, 2);
+ }
+
+ return ['labels' => $labels, 'balance' => $balance];
+ }
+
+ public function getWarehouseActivity(int $warehouse_id): array
+ {
+ $table = $this->resolveWarehouseTable($warehouse_id);
+ if (!$table) return [];
+
+ $sth = $this->pdo->query(
+ "SELECT a.date, a.product_sku,
+ a.`in` AS stock_in, a.`out` AS stock_out,
+ a.type, a.description, b.product_name
+ FROM `{$table}` a
+ LEFT JOIN md_product b
+ ON a.company_id = b.company_id AND a.product_sku = b.sku
+ WHERE a.company_id = {$this->companyId}
+ AND (a.`in` > 0 OR a.`out` > 0)
+ ORDER BY a.date DESC, a.id DESC
+ LIMIT 10"
+ );
+
+ $activities = [];
+ foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ $is_in = (float)$row['stock_in'] > 0;
+ $qty = $is_in ? (float)$row['stock_in'] : (float)$row['stock_out'];
+ $activities[] = [
+ 'product_name' => $row['product_name'] ?: $row['product_sku'],
+ 'sku' => $row['product_sku'],
+ 'direction' => $is_in ? 'in' : 'out',
+ 'qty' => round($qty, 2),
+ 'type' => $row['type'],
+ 'description' => $row['description'],
+ 'date' => $row['date'],
+ ];
+ }
+ return $activities;
+ }
+
+ // EXPIRED / NEAR EXPIRY STOCK
+ public function getExpiredStock(int $warehouse_id = 0): array
+ {
+ // Step 1: get warehouses from md_warehouse — fast, indexed, company-scoped
+ // avoids hitting information_schema on every request
+ // if warehouse_id is passed, restrict to that warehouse only
+ $where_wh = $warehouse_id > 0 ? "AND id = {$warehouse_id}" : '';
+ $sth = $this->pdo->prepare(
+ "SELECT id, warehouse_name
+ FROM md_warehouse
+ WHERE company_id = :company_id
+ AND status = 1
+ {$where_wh}"
+ );
+ $sth->execute([':company_id' => $this->companyId]);
+ $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
+
+ if (empty($warehouses)) return [];
+
+ // Step 2: build UNION only across known warehouse tables
+ // warehouse_id (integer) carried through for a fast indexed JOIN later
+ $unions = implode("\nUNION ALL\n", array_map(
+ fn($wh) => "SELECT
+ s.id,
+ s.product_sku,
+ s.lot_number,
+ s.zone,
+ s.aisle,
+ s.rack,
+ ROUND(s.`in`, 2) AS quantity,
+ {$wh['id']} AS warehouse_id
+ FROM `td_stock_{$wh['warehouse_name']}` s
+ WHERE s.company_id = {$this->companyId}
+ AND s.type = 'in'
+ AND s.lot_number IS NOT NULL",
+ $warehouses
+ ));
+
+ // Step 3: join md_lot by (company_id, product_sku, lot_number)
+ // join md_warehouse by integer warehouse_id — no string concat, index-friendly
+ $sql = "SELECT
+ stock.id,
+ stock.product_sku,
+ stock.lot_number,
+ stock.zone,
+ stock.aisle,
+ stock.rack,
+ stock.quantity,
+ l.expiry_date,
+ DATEDIFF(l.expiry_date, CURDATE()) AS days_remaining,
+ p.product_name,
+ pc.category as category_name,
+ mw.warehouse_name
+ FROM ($unions) AS stock
+ INNER JOIN md_lot l
+ ON l.company_id = {$this->companyId}
+ AND l.product_sku = stock.product_sku
+ AND l.lot_number = stock.lot_number
+ INNER JOIN md_product p
+ ON p.company_id = {$this->companyId}
+ AND p.sku = stock.product_sku
+ LEFT JOIN md_product_category pc
+ ON pc.company_id = {$this->companyId}
+ AND pc.id = p.category
+ INNER JOIN md_warehouse mw
+ ON mw.company_id = {$this->companyId}
+ AND mw.id = stock.warehouse_id
+ WHERE l.expiry_date IS NOT NULL
+ ORDER BY l.expiry_date ASC";
+
+ $sth = $this->pdo->prepare($sql);
+ $sth->execute([]);
+
+ $rows = $sth->fetchAll(PDO::FETCH_ASSOC);
+ $items = [];
+
+ foreach ($rows as $row) {
+ $days = (int) $row['days_remaining'];
+
+ if ($days < 0) {
+ $status = 'expired';
+ } elseif ($days <= 7) {
+ $status = 'critical';
+ } elseif ($days <= 14) {
+ $status = 'warning';
+ } elseif ($days <= 30) {
+ $status = 'caution';
+ } else {
+ continue; // beyond 30 days — not relevant for this report
+ }
+
+ $items[] = [
+ 'id' => (int) $row['id'],
+ 'product_sku' => $row['product_sku'],
+ 'product_name' => $row['product_name'],
+ 'category_name' => $row['category_name'],
+ 'warehouse_name' => $row['warehouse_name'],
+ 'lot_number' => $row['lot_number'],
+ 'zone' => $row['zone'],
+ 'aisle' => $row['aisle'],
+ 'rack' => $row['rack'],
+ 'quantity' => (float) $row['quantity'],
+ 'expiry_date' => $row['expiry_date'],
+ 'days_remaining' => $days,
+ 'status' => $status,
+ ];
+ }
+
+ return $items;
+ }
+
}
diff --git a/app/assets/utils/classes/WarehouseManager.php b/app/assets/utils/classes/WarehouseManager.php
index 76ff906..e39cebd 100644
--- a/app/assets/utils/classes/WarehouseManager.php
+++ b/app/assets/utils/classes/WarehouseManager.php
@@ -20,6 +20,64 @@ class WarehouseManager {
$this->company_id = $company_id;
}
+ // ─────────────────────────────────────────────────────────────
+ // Private helpers
+ // ─────────────────────────────────────────────────────────────
+
+ /**
+ * Resolve the td_stock_ table name for a given warehouse_id.
+ * Returns null if warehouse not found or inactive.
+ */
+ private function resolveWarehouseTable(int $warehouse_id): ?string
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT warehouse_name FROM md_warehouse
+ WHERE company_id = :company_id AND id = :id AND status = 1"
+ );
+ $sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
+ $name = $sth->fetchColumn();
+ if (!$name) return null;
+ $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
+ return "td_stock_{$safe}";
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Range helper
+ // ─────────────────────────────────────────────────────────────
+
+ /**
+ * Convert a from/to range into an array of string values.
+ * Supports:
+ * - Numeric ranges: "1" → "10" → ["1","2",...,"10"]
+ * - Alpha ranges: "A" → "D" → ["A","B","C","D"]
+ *
+ * @throws Exception if values are not pure numeric or single alpha characters
+ */
+ private function rangeToArray(string $from, string $to): array
+ {
+ $from = trim($from);
+ $to = trim($to);
+
+ // Numeric range
+ if (is_numeric($from) && is_numeric($to)) {
+ $f = (int) $from;
+ $t = (int) $to;
+ if ($f > $t) throw new Exception("Range start ($from) must be <= end ($to)");
+ return array_map('strval', range($f, $t));
+ }
+
+ // Single-letter alpha range
+ if (ctype_alpha($from) && ctype_alpha($to)
+ && strlen($from) === 1 && strlen($to) === 1) {
+ if (ord($from) > ord($to)) throw new Exception("Range start ($from) must be <= end ($to)");
+ return range($from, $to); // PHP range() handles 'A' → 'Z' natively
+ }
+
+ throw new Exception(
+ "Aisle/rack values must be either numbers (e.g. 1–99) or single uppercase letters (e.g. A–Z). Got: '$from' → '$to'"
+ );
+ }
+
// ─────────────────────────────────────────────────────────────
// Warehouse lookup
// ─────────────────────────────────────────────────────────────
@@ -146,60 +204,60 @@ class WarehouseManager {
/**
* Guard: reject the update if any occupied rack would be removed.
+ * Works for both numeric and alpha aisle/rack values.
*/
- private function validateRangeChange(int $storage_id, array $range): void {
+ private function validateRangeChange(int $storage_id, array $range): void
+ {
+ // Build the valid sets from the new range
+ $valid_aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
+ $valid_racks = $this->rangeToArray($range['rack_from'], $range['rack_to']);
- $sql = "SELECT COUNT(*) FROM md_rack
- WHERE company_id = :company_id
- AND storage_id = :storage_id
- AND product_sku IS NOT NULL
- AND (
- CAST(aisle AS UNSIGNED) < :aisle_from OR
- CAST(aisle AS UNSIGNED) > :aisle_to OR
- CAST(rack AS UNSIGNED) < :rack_from OR
- CAST(rack AS UNSIGNED) > :rack_to
- )";
-
- $sth = $this->pdo->prepare($sql);
+ // Fetch all occupied racks under this storage_id
+ $sth = $this->pdo->prepare(
+ "SELECT aisle, rack FROM md_rack
+ WHERE company_id = :company_id
+ AND storage_id = :storage_id
+ AND product_sku IS NOT NULL"
+ );
$sth->execute([
- ":company_id" => $this->company_id,
- ":storage_id" => $storage_id,
- ":aisle_from" => (int) $range['aisle_from'],
- ":aisle_to" => (int) $range['aisle_to'],
- ":rack_from" => (int) $range['rack_from'],
- ":rack_to" => (int) $range['rack_to'],
+ ':company_id' => $this->company_id,
+ ':storage_id' => $storage_id,
]);
- if ($sth->fetchColumn() > 0) {
- throw new Exception("Cannot shrink range — some racks still have stock");
+ foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ $a = trim($row['aisle']);
+ $r = trim($row['rack']);
+
+ if (!in_array($a, $valid_aisles, true) || !in_array($r, $valid_racks, true)) {
+ throw new Exception("Cannot shrink range — some racks still have stock");
+ }
}
}
/**
* Insert rack rows for the full range (IGNORE skips duplicates).
+ * Supports both numeric (1-10) and alpha (A-Z) aisle/rack values.
*/
- private function insertRacksInRange(int $storage_id, array $range): void {
-
+ private function insertRacksInRange(int $storage_id, array $range): void
+ {
$sql = "INSERT IGNORE INTO md_rack
(company_id, warehouse, storage_id, zone, aisle, rack)
VALUES
(:company_id, :warehouse, :storage_id, :zone, :aisle, :rack)";
$sth = $this->pdo->prepare($sql);
- $aisle_from = (int) $range['aisle_from'];
- $aisle_to = (int) $range['aisle_to'];
- $rack_from = (int) $range['rack_from'];
- $rack_to = (int) $range['rack_to'];
+ $aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
+ $racks = $this->rangeToArray($range['rack_from'], $range['rack_to']);
- for ($a = $aisle_from; $a <= $aisle_to; $a++) {
- for ($r = $rack_from; $r <= $rack_to; $r++) {
+ foreach ($aisles as $a) {
+ foreach ($racks as $r) {
$sth->execute([
":company_id" => $this->company_id,
":warehouse" => $range['warehouse'],
":storage_id" => $storage_id,
":zone" => $range['zone'],
- ":aisle" => (string) $a,
- ":rack" => (string) $r,
+ ":aisle" => $a,
+ ":rack" => $r,
]);
}
}
@@ -288,8 +346,103 @@ class WarehouseManager {
*
* @throws Exception if the rack is already occupied or doesn't exist.
*/
- public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void {
+ /**
+ * Validate that the combination of product_sku + lot_number + serial_number
+ * does not already occupy an active rack across all warehouses.
+ *
+ * "Active" means md_rack.td_stock_id is still linked (rack is occupied).
+ * Once a rack is released (stock_out/transfer), the combo is free again.
+ *
+ * Rules:
+ * - product_sku alone → allowed in multiple racks (normal stocking)
+ * - product_sku + lot → allowed in multiple racks (lot spread across racks)
+ * - product_sku + serial → must be unique (1 physical unit = 1 location)
+ * - product_sku + lot + serial → must be unique
+ *
+ * @param string $product_sku
+ * @param string|null $lot_number
+ * @param string|null $serial_number
+ * @param int $exclude_stock_id Skip this stock row (for edit flows)
+ * @throws Exception if a duplicate active record is found
+ */
+ public function validateStockUnique(
+ string $product_sku,
+ ?string $lot_number = null,
+ ?string $serial_number = null,
+ int $exclude_stock_id = 0
+ ): void {
+ // Nothing to validate if neither lot nor serial is provided
+ if (empty($lot_number) && empty($serial_number)) return;
+ $cid = $this->company_id;
+
+ // Discover all warehouses
+ $sth = $this->pdo->prepare(
+ "SELECT id, warehouse_name FROM md_warehouse
+ WHERE company_id = :company_id AND status = 1"
+ );
+ $sth->execute([':company_id' => $cid]);
+ $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
+
+ if (empty($warehouses)) return;
+
+ foreach ($warehouses as $wh) {
+ $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
+ $table = "td_stock_{$safe}";
+
+ // Check for any existing active stock_in row matching sku + lot + serial
+ // "Active" = rack still occupied (EXISTS in md_rack with product_sku IS NOT NULL)
+ // NOTE: called from inside occupyRack, AFTER INSERT but BEFORE md_rack is updated
+ // so the current row's td_stock_id is not yet in md_rack — won't match itself
+ $sql = "SELECT s.id
+ FROM `{$table}` s
+ WHERE s.company_id = :company_id
+ AND s.product_sku = :product_sku
+ AND s.type = 'in'
+ AND EXISTS (
+ SELECT 1 FROM md_rack r
+ WHERE r.company_id = :company_id2
+ AND r.td_stock_id = s.id
+ AND r.product_sku IS NOT NULL
+ )";
+
+ if (!empty($lot_number)) {
+ $sql .= " AND s.lot_number = :lot_number";
+ }
+ if (!empty($serial_number)) {
+ $sql .= " AND s.serial_number = :serial_number";
+ }
+ if ($exclude_stock_id > 0) {
+ $sql .= " AND s.id != :exclude_id";
+ }
+
+ $params = [
+ ':company_id' => $cid,
+ ':company_id2' => $cid,
+ ':product_sku' => $product_sku,
+ ];
+ if (!empty($lot_number)) $params[':lot_number'] = $lot_number;
+ if (!empty($serial_number)) $params[':serial_number'] = $serial_number;
+ if ($exclude_stock_id > 0) $params[':exclude_id'] = $exclude_stock_id;
+
+ $sth = $this->pdo->prepare($sql);
+ $sth->execute($params);
+
+ if ($sth->fetchColumn()) {
+ $combo = implode(' / ', array_filter([
+ $lot_number ? "lot: {$lot_number}" : null,
+ $serial_number ? "serial: {$serial_number}" : null,
+ ]));
+ throw new Exception(
+ "Active stock already exists for product '{$product_sku}' [{$combo}] "
+ . "in warehouse '{$wh['warehouse_name']}'. "
+ . "Stock must be moved out before it can be received again."
+ );
+ }
+ }
+ }
+
+ public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void {
$sql = "SELECT id, product_sku FROM md_rack
WHERE company_id = :company_id
@@ -319,6 +472,20 @@ class WarehouseManager {
);
}
+ // Validate sku + lot + serial uniqueness before occupying the rack
+ // Fetch lot/serial from the td_stock row being linked
+ $ctx = $this->getStockContext($warehouse_id, $td_stock_id);
+ $row = $ctx['row'] ?? null;
+
+ if ($row) {
+ $this->validateStockUnique(
+ $product_sku,
+ $row['lot_number'] ?? null,
+ $row['serial_number'] ?? null,
+ $td_stock_id // exclude self so edit flows don't block
+ );
+ }
+
// Single atomic UPDATE: state + log together
$sql = "UPDATE md_rack
SET product_sku = :product_sku,
@@ -486,6 +653,213 @@ class WarehouseManager {
}
+
+ // ─────────────────────────────────────────────────────────────
+ // Zone / Aisle / Rack resolution
+ // ─────────────────────────────────────────────────────────────
+
+ /**
+ * Zones with at least one empty rack — for stock_in destination.
+ */
+ public function getZonesIn(int $warehouse_id): array
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT DISTINCT zone FROM md_rack
+ WHERE company_id = :company_id
+ AND warehouse = :warehouse
+ AND product_sku IS NULL
+ ORDER BY CAST(zone AS UNSIGNED), zone"
+ );
+ $sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
+ return $sth->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ /**
+ * Zones holding active stock for sku + lot + serial — for stock_out source.
+ */
+ public function getZonesOut(
+ int $warehouse_id,
+ string $product_sku,
+ ?string $lot_number = null,
+ ?string $serial_number = null
+ ): array {
+ $table = $this->resolveWarehouseTable($warehouse_id);
+ if (!$table) return [];
+
+ [$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition(
+ $warehouse_id, $product_sku, $lot_number, $serial_number
+ );
+
+ $sth = $this->pdo->prepare(
+ "SELECT DISTINCT r.zone
+ FROM md_rack r
+ WHERE r.company_id = :company_id
+ AND r.warehouse = :warehouse
+ AND r.product_sku = :product_sku
+ AND r.td_stock_id IN (
+ SELECT s.id FROM `{$table}` s
+ WHERE s.company_id = :company_id2
+ AND s.product_sku = :product_sku2
+ AND s.type = 'in'
+ {$lot_cond}
+ {$serial_cond}
+ )
+ ORDER BY CAST(r.zone AS UNSIGNED), r.zone"
+ );
+ $sth->execute($params);
+ return $sth->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ /**
+ * Aisles with at least one empty rack in a zone — for stock_in destination.
+ */
+ public function getAislesIn(int $warehouse_id, string $zone): array
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT DISTINCT aisle FROM md_rack
+ WHERE company_id = :company_id
+ AND warehouse = :warehouse
+ AND zone = :zone
+ AND product_sku IS NULL
+ ORDER BY CAST(aisle AS UNSIGNED), aisle"
+ );
+ $sth->execute([
+ ':company_id' => $this->company_id,
+ ':warehouse' => $warehouse_id,
+ ':zone' => $zone,
+ ]);
+ return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
+ }
+
+ /**
+ * Aisles holding active stock for sku + lot + serial — for stock_out source.
+ */
+ public function getAislesOut(
+ int $warehouse_id,
+ string $zone,
+ string $product_sku,
+ ?string $lot_number = null,
+ ?string $serial_number = null
+ ): array {
+ $table = $this->resolveWarehouseTable($warehouse_id);
+ if (!$table) return [];
+
+ [$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition(
+ $warehouse_id, $product_sku, $lot_number, $serial_number
+ );
+ $params[':zone'] = $zone;
+
+ $sth = $this->pdo->prepare(
+ "SELECT DISTINCT r.aisle
+ FROM md_rack r
+ WHERE r.company_id = :company_id
+ AND r.warehouse = :warehouse
+ AND r.zone = :zone
+ AND r.product_sku = :product_sku
+ AND r.td_stock_id IN (
+ SELECT s.id FROM `{$table}` s
+ WHERE s.company_id = :company_id2
+ AND s.product_sku = :product_sku2
+ AND s.type = 'in'
+ {$lot_cond}
+ {$serial_cond}
+ )
+ ORDER BY CAST(r.aisle AS UNSIGNED), r.aisle"
+ );
+ $sth->execute($params);
+ return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
+ }
+
+ /**
+ * Racks that are empty in a given aisle — for stock_in destination.
+ */
+ public function getRacksIn(int $warehouse_id, string $zone, string $aisle): array
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT DISTINCT rack FROM md_rack
+ WHERE company_id = :company_id
+ AND warehouse = :warehouse
+ AND zone = :zone
+ AND aisle = :aisle
+ AND product_sku IS NULL
+ ORDER BY CAST(rack AS UNSIGNED), rack"
+ );
+ $sth->execute([
+ ':company_id' => $this->company_id,
+ ':warehouse' => $warehouse_id,
+ ':zone' => $zone,
+ ':aisle' => $aisle,
+ ]);
+ return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
+ }
+
+ /**
+ * Racks holding active stock for sku + lot + serial — for stock_out source.
+ */
+ public function getRacksOut(
+ int $warehouse_id,
+ string $zone,
+ string $aisle,
+ string $product_sku,
+ ?string $lot_number = null,
+ ?string $serial_number = null
+ ): array {
+ $table = $this->resolveWarehouseTable($warehouse_id);
+ if (!$table) return [];
+
+ [$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition(
+ $warehouse_id, $product_sku, $lot_number, $serial_number
+ );
+ $params[':zone'] = $zone;
+ $params[':aisle'] = $aisle;
+
+ $sth = $this->pdo->prepare(
+ "SELECT DISTINCT r.rack
+ FROM md_rack r
+ WHERE r.company_id = :company_id
+ AND r.warehouse = :warehouse
+ AND r.zone = :zone
+ AND r.aisle = :aisle
+ AND r.product_sku = :product_sku
+ AND r.td_stock_id IN (
+ SELECT s.id FROM `{$table}` s
+ WHERE s.company_id = :company_id2
+ AND s.product_sku = :product_sku2
+ AND s.type = 'in'
+ {$lot_cond}
+ {$serial_cond}
+ )
+ ORDER BY CAST(r.rack AS UNSIGNED), r.rack"
+ );
+ $sth->execute($params);
+ return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
+ }
+
+ /**
+ * Build lot/serial WHERE conditions and bound params for Out methods.
+ */
+ private function buildLotSerialCondition(
+ int $warehouse_id,
+ string $product_sku,
+ ?string $lot_number = null,
+ ?string $serial_number = null
+ ): array {
+ $lot_cond = !empty($lot_number) ? "AND s.lot_number = :lot_number" : "";
+ $serial_cond = !empty($serial_number) ? "AND s.serial_number = :serial_number" : "";
+
+ $params = [
+ ':company_id' => $this->company_id,
+ ':warehouse' => $warehouse_id,
+ ':product_sku' => $product_sku,
+ ':company_id2' => $this->company_id,
+ ':product_sku2' => $product_sku,
+ ];
+ if (!empty($lot_number)) $params[':lot_number'] = $lot_number;
+ if (!empty($serial_number)) $params[':serial_number'] = $serial_number;
+
+ return [$lot_cond, $serial_cond, $params];
+ }
+
// ─────────────────────────────────────────────────────────────
// Delete (soft-delete) operations
// ─────────────────────────────────────────────────────────────
diff --git a/app/contact/api/engine/contact_stats.php b/app/contact/api/engine/contact_stats.php
new file mode 100644
index 0000000..68f6c91
--- /dev/null
+++ b/app/contact/api/engine/contact_stats.php
@@ -0,0 +1,15 @@
+getTotalContactCategory();
+ $answer["active_category"] = $report->getActiveContactCategory();
+ $answer["inactive_category"] = $report->getInactiveContactCategory();
+ $answer["total_contact"] = $report->getTotalContact();
+ $answer["success"] = 1;
+ exit(json_encode($answer));
+?>
\ No newline at end of file
diff --git a/app/contact/contact.php b/app/contact/contact.php
index aebf043..fc16a52 100644
--- a/app/contact/contact.php
+++ b/app/contact/contact.php
@@ -2,7 +2,6 @@
session_start();
require '../config.php';
require '../include_header.php';
-
?>
@@ -13,96 +12,87 @@
+
+
-
+
Contact
Manage your contact
+
+
-
+
-
+
Total Categories
-
6
+ —
-
-
-
-
+
+
+
-
+
-
+
Total Contact
-
356
+
-
-
-
-
-
+
+
-
-
+
-