[test] contact to setting modules

This commit is contained in:
Thanakorn S
2026-05-06 08:58:09 +07:00
parent 414f1cd81d
commit 6501d326ca
43 changed files with 4295 additions and 581 deletions
+30
View File
@@ -1,3 +1,33 @@
/** =========================
* SIDEBAR ACTIVE STATE
* Override: activate the parent listing page for manage_* sub-pages.
* main.js (theme) handles exact-match pages; this covers second-depth pages.
* ========================= */
$(function () {
var $links = $('#sidebar').find('a.nav-link');
var path = window.location.pathname;
// Theme already handled it — an active link whose href matches the current path
var alreadyActive = $links.filter('.active').toArray().some(function (a) {
return a.pathname === path;
});
if (alreadyActive) return;
// For manage_* pages: strip "manage_" to derive the parent listing filename
var filename = path.split('/').pop();
if (!/^manage_/.test(filename)) return;
var dir = path.substring(0, path.lastIndexOf('/'));
var parentPath = dir + '/' + filename.replace(/^manage_/, '');
var $parent = $links.filter(function () { return this.pathname === parentPath; });
if ($parent.length) {
$links.removeClass('active');
$parent.addClass('active');
}
});
/** =========================
* UTILITIES
* ========================= */
@@ -234,14 +234,14 @@ class CompanySettingManager
}
$sth = $this->transactionPdo->prepare(
"SELECT warehouse_name FROM md_warehouse WHERE company_id = :company_id"
"SELECT id FROM md_warehouse WHERE company_id = :company_id"
);
$sth->execute([':company_id' => $this->companyId]);
$warehouses = $sth->fetchAll(PDO::FETCH_COLUMN);
$warehouse_ids = $sth->fetchAll(PDO::FETCH_COLUMN);
foreach ($warehouses as $warehouse_name) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$warehouse_name);
if ($safe !== '' && $this->tableHasCompanyRows('td_stock_' . $safe)) {
foreach ($warehouse_ids as $warehouse_id) {
$warehouse_id = (int)$warehouse_id;
if ($warehouse_id > 0 && $this->tableHasCompanyRows('td_stock_' . $warehouse_id)) {
return true;
}
}
+99 -52
View File
@@ -4,7 +4,7 @@
* OrderManager
*
* Handles all read and write operations for sales orders (td_order)
* and their downstream stock-out effects on td_stock_<warehouse> tables.
* and their downstream stock-out effects on td_stock_<warehouse_id> tables.
*
* Method order:
* Transaction basis → getOrderList, getOrderById, generateOrderNumber,
@@ -16,25 +16,24 @@
* No separate child table exists. SKU-level reporting is served by
* td_stock_* rows (source='order') rather than querying items JSON.
* - confirmOrder() picks racks via FIFO — oldest approved stock-in row
* still rack-occupied, ordered by td_stock_<wh>.date ASC.
* still rack-occupied, ordered by td_stock_<warehouse_id>.date ASC.
* - confirmOrder() creates stock-out rows with status=0 (draft).
* Warehouse staff approve them via the existing approve_stock.php
* engine, which handles rack release and balance adjustment.
* - cancelOrder() sets td_order.status = -1 and soft-deletes ALL linked
* stock-out rows (status → -1) across all td_stock_* tables.
* Cancellation is blocked if any active invoice (status != -1) or
* active return (status != -1) is linked to the order.
* - Cancel logic is intentionally self-contained here. status=-1 is
* an order-domain concept with no rack/balance side effects, so
* WarehouseManager and StockManager are not involved.
* stock-out rows (status → -1) across all td_stock_* tables. If linked
* stock-out rows were already approved, their rack and balance effects are
* reversed before the rows are cancelled.
* Cancellation is blocked if any active invoice (status != 4 / void) or
* active return (status != -1 / cancelled) is linked to the order.
* - Cancel logic stays in the order domain, but uses WarehouseManager for
* the same rack and balance reversal rules as manual stock-out deletion.
*
* Note: Write methods do NOT manage their own DB transactions.
* Callers must wrap multi-step operations inside dbTransaction().
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* Dynamic table names (td_stock_<warehouse>) are sanitised with
* preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation —
* no user input ever reaches a table identifier directly.
* Dynamic stock table names are derived only from md_warehouse.id.
*/
class OrderManager {
@@ -67,31 +66,13 @@ class OrderManager {
];
}
/**
* Resolve the td_stock_<warehouse> table name for a warehouse_id.
*
* Does not filter by status — allows reading inactive warehouses
* for historical order lookups.
*
* @param int $warehouse_id
* @return string Sanitised table name e.g. "td_stock_Main".
* @throws Exception If warehouse not found.
*/
private function resolveStockTable(int $warehouse_id): string
private function stockTableNameFromWarehouseId(int $warehouse_id): string
{
$sth = $this->pdo->prepare(
"SELECT warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
$name = $sth->fetchColumn();
if (!$name) {
throw new Exception("Warehouse ID {$warehouse_id} not found.");
if ($warehouse_id <= 0) {
throw new Exception("Invalid warehouse id.");
}
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
return "td_stock_{$safe}";
return 'td_stock_' . $warehouse_id;
}
/**
@@ -108,10 +89,18 @@ class OrderManager {
*/
private function pickFifoRack(int $warehouse_id, string $product_sku): ?array
{
$table = $this->resolveStockTable($warehouse_id);
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT s.*, r.zone, r.aisle, r.rack
"SELECT s.*, r.zone, r.aisle, r.rack,
(s.`in` - COALESCE((
SELECT SUM(o.`out`)
FROM `{$table}` o
WHERE o.company_id = s.company_id
AND o.ref_id = s.id
AND o.`out` > 0
AND o.status != -1
), 0)) AS available_qty
FROM `{$table}` s
INNER JOIN md_rack r
ON r.company_id = s.company_id
@@ -120,9 +109,10 @@ class OrderManager {
AND r.product_sku IS NOT NULL
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.type = 'in'
AND s.`in` > 0
AND s.status = 1
ORDER BY s.date ASC
HAVING available_qty > 0
ORDER BY s.date ASC, s.id ASC
LIMIT 1"
);
$sth->execute([
@@ -354,7 +344,7 @@ class OrderManager {
*
* Flow per item:
* 1. pickFifoRack() — find oldest rack-occupied stock-in row for the SKU.
* 2. INSERT into td_stock_<wh> with type='out', status=0,
* 2. INSERT into td_stock_<warehouse_id> with type='out', status=0,
* source='order', source_id=order_id.
* 3. Write back stock_out_id into the item object in td_order.items.
*
@@ -396,17 +386,20 @@ class OrderManager {
throw new Exception("Cannot confirm an order with no items.");
}
// ── Per-item: FIFO pick + insert stock-out row ────────────────────
foreach ($items as $i => &$item) {
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
$product_sku = $item['product_sku'] ?? '';
$quantity = (float)($item['quantity'] ?? 0);
if (!$warehouse_id || !$product_sku) {
throw new Exception(
"Item #{$i}: missing warehouse_id or product_sku."
);
}
if ($quantity <= 0) {
throw new Exception("Item #{$i}: quantity must be greater than zero.");
}
$rack_stock = $this->pickFifoRack($warehouse_id, $product_sku);
@@ -417,7 +410,17 @@ class OrderManager {
);
}
$table = $this->resolveStockTable($warehouse_id);
$available_qty = (float)($rack_stock['available_qty'] ?? $rack_stock['in'] ?? 0);
if ($quantity - $available_qty > 0.000001) {
$name = $item['product_name'] ?? $product_sku;
throw new Exception(
"FIFO rack {$rack_stock['zone']}-{$rack_stock['aisle']}-{$rack_stock['rack']} " .
"for \"{$name}\" has only {$available_qty} available unit(s), " .
"but the order requests {$quantity}."
);
}
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$item_uuid = $uuid . '_' . $i;
$item_log = [array_merge($logging, ['action' => 'confirm_item'])];
@@ -439,7 +442,7 @@ class OrderManager {
':company_id' => $this->company_id,
':date' => date('Y-m-d H:i:s'),
':product_sku' => $product_sku,
':quantity' => (float)$item['quantity'],
':quantity' => $quantity,
':zone' => $rack_stock['zone'],
':aisle' => $rack_stock['aisle'],
':rack' => $rack_stock['rack'],
@@ -493,17 +496,18 @@ class OrderManager {
*
* Business rule:
* An order can be cancelled (Draft or Confirmed) as long as no active
* downstream documents exist. Active means status != -1 (not voided /
* not cancelled).
* downstream documents exist. For invoices, active means not voided
* (status != 4). For returns, active means not cancelled (status != -1).
*
* Downstream documents that block cancellation:
* - td_invoice where order_id = $order_id AND status != -1
* - td_invoice where order_id = $order_id AND status != 4
* - td_return where order_id = $order_id AND status != -1
*
* Stock-out rows are children of the order — they follow the parent
* and are never independently approved. On cancel, ALL stock-out rows
* linked to this order (any status except already -1) are soft-deleted
* (status → -1) across all td_stock_* tables.
* Stock-out rows are children of the order — they follow the parent.
* On cancel, approved rows first re-occupy the original source rack and
* reverse the stock-out balance. Then ALL stock-out rows linked to this
* order (any status except already -1) are soft-deleted (status → -1)
* across all td_stock_* tables.
*
* Must be called inside dbTransaction() by the caller.
*
@@ -541,7 +545,7 @@ class OrderManager {
"SELECT COUNT(*) FROM td_invoice
WHERE company_id = :company_id
AND order_id = :order_id
AND status != -1"
AND status != 4"
);
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
if ((int)$sth->fetchColumn() > 0) {
@@ -566,7 +570,7 @@ class OrderManager {
);
}
// ── Soft-delete all linked stock-out rows ─────────────────────────
// ── Reverse approved stock-out side effects, then soft-delete rows ─
$sth = $this->pdo->prepare(
"SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE()
@@ -574,11 +578,54 @@ class OrderManager {
);
$sth->execute();
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
foreach ($tables as $table) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
if (!preg_match('/^td_stock_(\d+)$/', (string)$table, $matches)) {
continue;
}
$warehouse_id = (int)$matches[1];
$row_sth = $this->pdo->prepare(
"SELECT id, product_sku, `out`, zone, aisle, rack, ref_id, status
FROM `{$table}`
WHERE company_id = :company_id
AND source = 'order'
AND source_id = :order_id
AND type = 'out'
AND status != -1"
);
$row_sth->execute([
':company_id' => $this->company_id,
':order_id' => $order_id,
]);
$rows = $row_sth->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
if ((int)$row['status'] !== 1) {
continue;
}
$whMgmt->occupyRack(
$warehouse_id,
(string)$row['zone'],
(string)$row['aisle'],
(string)$row['rack'],
(string)$row['product_sku'],
(int)$row['ref_id']
);
$whMgmt->adjustBalance(
'out',
$warehouse_id,
(string)$row['product_sku'],
(float)$row['out'],
0
);
}
$this->pdo->prepare(
"UPDATE `{$safe}` SET status = -1
"UPDATE `{$table}` SET status = -1
WHERE company_id = :company_id
AND source = 'order'
AND source_id = :order_id
+4 -2
View File
@@ -358,6 +358,7 @@ class ProductManager {
':company_id' => $this->company_id,
':product_name' => $data['product_name'],
':sku' => $data['sku'],
':uom' => $data['uom'] ?? 'pcs',
':price' => $data['price'],
':min_stock' => $data['min_stock'],
':reorder_point' => $data['reorder_point'],
@@ -374,6 +375,7 @@ class ProductManager {
"UPDATE md_product SET
product_name = :product_name,
sku = :sku,
uom = :uom,
price = :price,
min_stock = :min_stock,
reorder_point = :reorder_point,
@@ -387,10 +389,10 @@ class ProductManager {
} else {
$this->pdo->prepare(
"INSERT INTO md_product
(company_id, product_name, sku, price, min_stock, reorder_point,
(company_id, product_name, sku, uom, price, min_stock, reorder_point,
category, product_image, `description`, `status`, `log`)
VALUES
(:company_id, :product_name, :sku, :price, :min_stock, :reorder_point,
(:company_id, :product_name, :sku, :uom, :price, :min_stock, :reorder_point,
:category, :product_image, :description, :status, :log)"
)->execute($params);
}
@@ -0,0 +1,596 @@
<?php
/**
* PurchaseOrderManager
*
* Handles all read and write operations for td_purchase_order
* and the downstream stock-in effects on td_stock_<warehouse_id> tables.
*
* Method order:
* Transaction basis → getPoList, getPoById, generatePoNumber,
* savePo, confirmPo, receivePo, cancelPo
*
* Key design decisions:
* - PO items are stored as a JSON array in td_purchase_order.items.
* Same pattern as td_order. No separate child table.
* - receivePo() calls StockManager::saveStockIn() for each received line
* with source='po' and source_id=po_id. This means received goods appear
* in the existing Stock In list automatically under source='po'.
* - stock_in_id is written back into the items JSON after each receive call,
* same pattern as stock_out_id in OrderManager::confirmOrder().
* - Partial receipt is supported: receivePo() can be called multiple times
* until all items are fully received, advancing status 1→2 (partial) or 1/2→3 (completed).
* - cancelPo() is blocked if any linked stock-in rows have been approved (status=1),
* because those have already modified rack and balance.
* - Write methods do NOT manage their own DB transactions.
* Callers must wrap multi-step operations inside dbTransaction().
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* Dynamic stock table names are derived only from md_warehouse.id.
*/
class PurchaseOrderManager {
private $pdo;
private $company_id;
public function __construct($pdo, int $company_id) {
$this->pdo = $pdo;
$this->company_id = $company_id;
}
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
private function buildLogEntry(string $action): array {
return [
'user_id' => $_SESSION['login_user_id'] ?? null,
'dt' => date('Y-m-d H:i:s'),
'login' => isset($_SESSION['otpTime'])
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
: null,
'action' => $action,
];
}
/**
* Generate next sequential PO number in PO-YYYYMMDD-XXXX format.
*/
private function generatePoNumber(): string
{
$prefix = 'PO-' . date('Ymd') . '-';
$sth = $this->pdo->prepare(
"SELECT po_number FROM td_purchase_order
WHERE company_id = :company_id
AND po_number LIKE :prefix
ORDER BY po_number DESC
LIMIT 1"
);
$sth->execute([
':company_id' => $this->company_id,
':prefix' => $prefix . '%',
]);
$last = $sth->fetchColumn();
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
}
private function stockTableNameFromWarehouseId(int $warehouse_id): string
{
if ($warehouse_id <= 0) {
throw new Exception("Invalid warehouse id.");
}
return 'td_stock_' . $warehouse_id;
}
// ─────────────────────────────────────────────────────────────
// TRANSACTION BASIS — Read
// ─────────────────────────────────────────────────────────────
/**
* Return all POs for the company, ordered by created_at DESC.
* Joins md_contact for supplier name display.
*/
public function getPoList(): array
{
$sth = $this->pdo->prepare(
"SELECT p.*,
COALESCE(c.contact_name, '') AS contact_name
FROM td_purchase_order p
LEFT JOIN md_contact c
ON c.company_id = p.company_id
AND c.id = p.contact_id
WHERE p.company_id = :company_id
ORDER BY p.created_at DESC"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Fetch a single PO by its primary key.
* Returns the row with items decoded as a PHP array.
*/
public function getPoById(int $id): array|false
{
$sth = $this->pdo->prepare(
"SELECT p.*,
COALESCE(c.contact_name, '') AS contact_name
FROM td_purchase_order p
LEFT JOIN md_contact c
ON c.company_id = p.company_id
AND c.id = p.contact_id
WHERE p.company_id = :company_id
AND p.id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) return false;
$row['items'] = json_decode($row['items'] ?? '[]', true) ?: [];
return $row;
}
// ─────────────────────────────────────────────────────────────
// TRANSACTION BASIS — Write
// ─────────────────────────────────────────────────────────────
/**
* Insert a new PO (draft) or update metadata on an existing draft.
*
* Insert (id=0): generates po_number, sets status=0, payment_status=0.
* Update (id>0): only allowed while status=0 (draft).
*
* @param array $data Keys: id, contact_id, po_date, expected_date,
* warehouse_id, items (array), discount, tax,
* shipping_fee, notes.
* @param array $logging Audit entry.
* @return int New td_purchase_order.id on insert, 0 on update.
*/
public function savePo(array $data, array $logging): int
{
$id = (int)($data['id'] ?? 0);
$items = $data['items'] ?? [];
$subtotal = array_reduce($items, fn($carry, $item) =>
$carry + (float)($item['total_price'] ?? 0), 0.0
);
$discount = (float)($data['discount'] ?? 0);
$tax = (float)($data['tax'] ?? 0);
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
if ($id > 0) {
$sth = $this->pdo->prepare(
"SELECT status, `log` FROM td_purchase_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new Exception("Purchase order not found.");
if ((int)$row['status'] !== 0) throw new Exception("Only draft POs can be edited.");
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $logging;
$this->pdo->prepare(
"UPDATE td_purchase_order SET
contact_id = :contact_id,
po_date = :po_date,
expected_date = :expected_date,
warehouse_id = :warehouse_id,
items = :items,
subtotal = :subtotal,
discount = :discount,
tax = :tax,
shipping_fee = :shipping_fee,
grand_total = :grand_total,
notes = :notes,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':contact_id' => (int)($data['contact_id'] ?? 0),
':po_date' => $data['po_date'] ?? date('Y-m-d'),
':expected_date' => $data['expected_date'] ?: null,
':warehouse_id' => (int)($data['warehouse_id'] ?? 0),
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
':subtotal' => $subtotal,
':discount' => $discount,
':tax' => $tax,
':shipping_fee' => $shipping_fee,
':grand_total' => $grand_total,
':notes' => $data['notes'] ?? '',
':log' => json_encode($log),
':id' => $id,
':company_id' => $this->company_id,
]);
return 0;
} else {
$log = [$logging];
$this->pdo->prepare(
"INSERT INTO td_purchase_order
(company_id, uuid, po_number, contact_id, po_date, expected_date,
warehouse_id, status, payment_status, subtotal, discount, tax,
shipping_fee, grand_total, items, notes, `log`, created_at)
VALUES
(:company_id, :uuid, :po_number, :contact_id, :po_date, :expected_date,
:warehouse_id, 0, 0, :subtotal, :discount, :tax,
:shipping_fee, :grand_total, :items, :notes, :log, :created_at)"
)->execute([
':company_id' => $this->company_id,
':uuid' => bin2hex(random_bytes(16)),
':po_number' => $this->generatePoNumber(),
':contact_id' => (int)($data['contact_id'] ?? 0),
':po_date' => $data['po_date'] ?? date('Y-m-d'),
':expected_date' => $data['expected_date'] ?: null,
':warehouse_id' => (int)($data['warehouse_id'] ?? 0),
':subtotal' => $subtotal,
':discount' => $discount,
':tax' => $tax,
':shipping_fee' => $shipping_fee,
':grand_total' => $grand_total,
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
':notes' => $data['notes'] ?? '',
':log' => json_encode($log),
':created_at' => date('Y-m-d H:i:s'),
]);
return (int)$this->pdo->lastInsertId();
}
}
/**
* Confirm a draft PO — advance status 0 → 1.
* No stock rows are created at this stage; receipt happens via receivePo().
*
* @throws Exception If PO not found or not in draft.
*/
public function confirmPo(int $po_id, array $logging): void
{
$sth = $this->pdo->prepare(
"SELECT * FROM td_purchase_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $po_id]);
$po = $sth->fetch(PDO::FETCH_ASSOC);
if (!$po) throw new Exception("Purchase order not found.");
if ((int)$po['status'] !== 0) throw new Exception("Only draft POs can be confirmed.");
$log = json_decode($po['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, ['action' => 'confirm']);
$this->pdo->prepare(
"UPDATE td_purchase_order SET
status = 1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $po_id,
':company_id' => $this->company_id,
]);
}
/**
* Receive goods against a confirmed PO — creates draft stock-in rows.
*
* Flow per received line:
* 1. Calls StockManager::saveStockIn() with source='po', source_id=po_id.
* 2. Writes stock_in_id back into the PO item object (same as stock_out_id in orders).
* 3. Increments received_qty on the item.
*
* After all lines:
* 4. If all items are fully received → status = 3 (completed).
* 5. If partially received → status = 2 (partial).
*
* Can be called multiple times for partial deliveries.
* If auto_approve=true (from company settings), approveStock() is also called.
*
* @param int $po_id td_purchase_order.id
* @param array $receive_items Each item: { item_id, product_sku, warehouse_id,
* quantity, zone, aisle, rack, lot_number,
* expiry_date, serial_number, contact_id }
* @param string $uuid UUID prefix for stock-in rows.
* @param array $logging Audit entry.
* @param bool $auto_approve Auto-approve stock-in rows immediately.
*/
public function receivePo(
int $po_id,
array $receive_items,
string $uuid,
array $logging,
bool $auto_approve = false
): void {
$sth = $this->pdo->prepare(
"SELECT * FROM td_purchase_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $po_id]);
$po = $sth->fetch(PDO::FETCH_ASSOC);
if (!$po) throw new Exception("Purchase order not found.");
$status = (int)$po['status'];
if ($status === -1) throw new Exception("Cannot receive against a cancelled PO.");
if ($status === 0) throw new Exception("Confirm the PO before receiving goods.");
if ($status === 3) throw new Exception("This PO is already fully received.");
if (empty($receive_items)) {
throw new Exception("No items provided to receive.");
}
$po_items = json_decode($po['items'] ?? '[]', true) ?: [];
// Index PO items by item_id for quick lookup
$po_items_by_id = [];
foreach ($po_items as $i => $item) {
$po_items_by_id[(int)($item['item_id'] ?? $i)] = $i;
}
$stock = new StockManager($this->pdo, $this->company_id);
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
foreach ($receive_items as $j => $recv) {
$item_id = (int)($recv['item_id'] ?? -1);
$product_sku = $recv['product_sku'] ?? '';
$warehouse_id = (int)($recv['warehouse_id'] ?? $po['warehouse_id']);
$quantity = (float)($recv['quantity'] ?? 0);
if ($quantity <= 0) continue;
if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku.");
if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id.");
$item_uuid = $uuid . '_' . $j;
$item_log = array_merge($logging, ['action' => 'receive_item']);
$rack = $recv['rack'] ?? '';
$zone = $recv['zone'] ?? '';
$aisle = $recv['aisle'] ?? '';
// Simple location mode: zone and aisle must mirror the rack value
// (same convention as manage_stock_in.php).
// occupyRack() looks up md_rack WHERE zone=:zone AND aisle=:aisle AND rack=:rack,
// so all three must match — a blank zone/aisle produces "Rack --b does not exist".
if ($zone === '' && $rack !== '') $zone = $rack;
if ($aisle === '' && $rack !== '') $aisle = $rack;
$stock_data = [
'id' => 0,
'warehouse' => $warehouse_id,
'product_sku' => $product_sku,
'quantity' => $quantity,
'zone' => $zone,
'aisle' => $aisle,
'rack' => $rack,
'lot_number' => $recv['lot_number'] ?? '',
'expiry_date' => $recv['expiry_date'] ?? '',
'serial_number' => $recv['serial_number'] ?? '',
'contact_id' => (int)($recv['contact_id'] ?? $po['contact_id'] ?? 0),
'description' => $po['po_number'],
'source' => 'po',
'source_id' => $po_id,
];
$new_stock_in_id = $stock->saveStockIn($stock_data, $item_log, $item_uuid);
if ($new_stock_in_id > 0) {
// saveStockIn() does not write source/source_id — stamp them here.
// This links the stock-in row back to this PO for cancellation guards
// and makes it appear under the "PO" source tab on the Stock In list.
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$this->pdo->prepare(
"UPDATE `{$table}` SET source = 'po', source_id = :po_id
WHERE id = :id AND company_id = :company_id"
)->execute([
':po_id' => $po_id,
':id' => $new_stock_in_id,
':company_id' => $this->company_id,
]);
// approveStock() handles balance updates. saveStockIn() has
// already reserved the rack so draft receipts cannot be reused.
if ($auto_approve) {
$stock->approveStock($new_stock_in_id, $warehouse_id, 'in', $whMgmt);
}
}
// Write stock_in_id + received qty back into PO item
if ($item_id >= 0 && isset($po_items_by_id[$item_id])) {
$idx = $po_items_by_id[$item_id];
$already_received = (float)($po_items[$idx]['received_qty'] ?? 0);
$po_items[$idx]['received_qty'] = $already_received + $quantity;
$po_items[$idx]['stock_in_id'] = $new_stock_in_id;
$po_items[$idx]['receive_wh'] = $warehouse_id;
$po_items[$idx]['receive_zone'] = $zone;
$po_items[$idx]['receive_aisle'] = $aisle;
$po_items[$idx]['receive_rack'] = $rack;
}
}
// Determine new PO status
$all_received = true;
foreach ($po_items as $item) {
$ordered = (float)($item['quantity'] ?? 0);
$received = (float)($item['received_qty'] ?? 0);
if ($received < $ordered) {
$all_received = false;
break;
}
}
$new_status = $all_received ? 3 : 2;
$log = json_decode($po['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, ['action' => 'receive', 'new_status' => $new_status]);
$this->pdo->prepare(
"UPDATE td_purchase_order SET
status = :status,
items = :items,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':status' => $new_status,
':items' => json_encode($po_items, JSON_UNESCAPED_UNICODE),
':log' => json_encode($log),
':id' => $po_id,
':company_id' => $this->company_id,
]);
}
/**
* Update payment status on a PO.
* Allowed for status >= 1 (confirmed, partial, completed).
*
* @param int $po_id td_purchase_order.id
* @param int $payment_status 0=unpaid, 1=paid, 2=partial
* @param array $logging Audit entry.
*/
public function updatePaymentStatus(int $po_id, int $payment_status, array $logging): void
{
$sth = $this->pdo->prepare(
"SELECT status, `log` FROM td_purchase_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $po_id]);
$po = $sth->fetch(PDO::FETCH_ASSOC);
if (!$po) throw new Exception("Purchase order not found.");
if ((int)$po['status'] < 1) throw new Exception("Confirm the PO before updating payment.");
if ((int)$po['status'] === -1) throw new Exception("Cannot update a cancelled PO.");
if (!in_array($payment_status, [0, 1, 2], true)) {
throw new Exception("Invalid payment status.");
}
$log = json_decode($po['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, ['action' => 'update_payment', 'payment_status' => $payment_status]);
$this->pdo->prepare(
"UPDATE td_purchase_order SET
payment_status = :payment_status,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':payment_status' => $payment_status,
':log' => json_encode($log),
':id' => $po_id,
':company_id' => $this->company_id,
]);
}
/**
* Cancel a PO.
*
* Blocked if any linked stock-in rows have already been approved (status=1)
* because those have modified rack occupancy and balance.
*
* For draft stock-in rows (status=0), they are soft-deleted (status=-1).
*
* @throws Exception
*/
public function cancelPo(int $po_id, array $logging): void
{
$sth = $this->pdo->prepare(
"SELECT * FROM td_purchase_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $po_id]);
$po = $sth->fetch(PDO::FETCH_ASSOC);
if (!$po) throw new Exception("Purchase order not found.");
$status = (int)$po['status'];
if ($status === -1) throw new Exception("PO is already cancelled.");
if ($status === 3) throw new Exception("Cannot cancel a completed PO.");
// Guard: block if any approved stock-in rows exist for this PO
$sth = $this->pdo->prepare(
"SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name LIKE 'td_stock_%'"
);
$sth->execute();
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM `{$table}`
WHERE company_id = :company_id
AND source = 'po'
AND source_id = :po_id
AND status = 1"
);
$sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]);
if ((int)$sth->fetchColumn() > 0) {
throw new Exception(
"Cannot cancel — stock from this PO has already been approved and received. " .
"Please adjust stock manually if needed."
);
}
}
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
// Soft-delete draft stock-in rows and release their rack reservations.
foreach ($tables as $table) {
if (!preg_match('/^td_stock_(\d+)$/', $table, $m)) {
continue;
}
$warehouse_id = (int)$m[1];
$sth = $this->pdo->prepare(
"SELECT id, zone, aisle, rack
FROM `{$table}`
WHERE company_id = :company_id
AND source = 'po'
AND source_id = :po_id
AND status = 0"
);
$sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]);
$draft_rows = $sth->fetchAll(PDO::FETCH_ASSOC);
foreach ($draft_rows as $row) {
$this->pdo->prepare(
"UPDATE `{$table}` SET status = -1
WHERE id = :id AND company_id = :company_id"
)->execute([
':id' => (int)$row['id'],
':company_id' => $this->company_id,
]);
$whMgmt->releaseRack(
$warehouse_id,
$row['zone'],
$row['aisle'],
$row['rack']
);
}
}
$log = json_decode($po['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, ['action' => 'cancel']);
$this->pdo->prepare(
"UPDATE td_purchase_order SET
status = -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $po_id,
':company_id' => $this->company_id,
]);
}
}
+38 -50
View File
@@ -16,17 +16,7 @@
* Balance summary → getWarehouseBalanceSummary
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* Dynamic table names (td_stock_<warehouse>) are derived from DB-sourced
* warehouse names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...)
* before interpolation. Integer parameters (warehouse_id, company_id, limit)
* are explicitly cast to (int) before use in any SQL string fragment.
*
* Security fixes applied vs. previous version:
* - 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: cross-DB join uses $mainDb injected at construction time
* - getRackOccupancy: (already safe — no dynamic identifiers)
* Dynamic stock table names are derived only from md_warehouse.id.
*/
class ReportManager
{
@@ -45,26 +35,26 @@ class ReportManager
// Private helpers
// ─────────────────────────────────────────────────────────────
/**
* Resolve the td_stock_<wh> table name for a warehouse, requiring active status.
*
* Returns null if the warehouse is not found or inactive.
* The warehouse_name is sanitised before use as a table suffix.
*
* @param int $warehouse_id The md_warehouse.id to resolve.
* @return string|null Sanitised table name, or null if not active/found.
*/
private function stockTableNameFromWarehouseId(int $warehouse_id): string
{
if ($warehouse_id <= 0) {
throw new Exception("Invalid warehouse id.");
}
return 'td_stock_' . $warehouse_id;
}
private function resolveWarehouseTable(int $warehouse_id): ?string
{
$sth = $this->pdo->prepare(
"SELECT warehouse_name FROM md_warehouse
"SELECT id 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}";
$id = $sth->fetchColumn();
if (!$id) return null;
return $this->stockTableNameFromWarehouseId((int)$id);
}
/**
@@ -569,9 +559,6 @@ class ReportManager
* Builds a UNION ALL across all td_stock_* tables to produce a unified
* activity feed ordered by date DESC. Used by the dashboard "recent activity" widget.
*
* Security fix: warehouse_name in UNION SQL now uses $safe (sanitised with
* preg_replace) instead of the raw warehouse_name string.
*
* @param int $limit Maximum number of transactions to return (default 10).
* @return array Activity rows with product_name, product_sku, warehouse_name,
* direction ('in'|'out'), qty, type, date.
@@ -587,18 +574,17 @@ class ReportManager
if (empty($warehouses)) return [];
// Security: use $safe (sanitised name) for both the table identifier
// and the literal warehouse_name string in the SELECT — never raw $wh['warehouse_name'].
$unions = implode("\nUNION ALL\n", array_map(
function ($wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$cid = (int) $this->companyId;
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$cid = (int) $this->companyId;
$warehouse_name = $this->pdo->quote($wh['warehouse_name']);
return "SELECT s.date, s.product_sku, s.type,
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
p.product_name,
'{$safe}' AS warehouse_name
FROM `td_stock_{$safe}` s
{$warehouse_name} AS warehouse_name
FROM `{$table}` s
LEFT JOIN md_product p
ON p.company_id = s.company_id
AND p.sku = s.product_sku
@@ -721,7 +707,7 @@ class ReportManager
/**
* Return monthly stock_in and stock_out totals for a warehouse over the last 12 months.
*
* Queries the per-warehouse td_stock_<wh> table directly (not warehouse_balance)
* Queries the per-warehouse td_stock_<warehouse_id> table directly (not warehouse_balance)
* for per-warehouse granularity. Produces chart-ready arrays with month labels.
*
* @param int $warehouse_id The warehouse to query.
@@ -899,8 +885,6 @@ class ReportManager
*
* Security fix: $warehouse_id is now cast to (int) and the WHERE condition
* uses a bound parameter (:warehouse_id) instead of raw string interpolation.
* warehouse_name in UNION now uses $safe variable (sanitised) not raw $wh['warehouse_name'].
*
* @param int $warehouse_id Warehouse filter (0 = all warehouses).
* @return array Expiry-status stock items with product, lot, location, and days_remaining.
*/
@@ -928,10 +912,9 @@ class ReportManager
$cid = (int) $this->companyId; // cast before interpolation
// Security: use $safe (sanitised) for table identifier and literal warehouse name string
$unions = implode("\nUNION ALL\n", array_map(
function ($wh) use ($cid) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
return "SELECT
s.id,
s.product_sku,
@@ -941,7 +924,7 @@ class ReportManager
s.rack,
ROUND(s.`in`, 2) AS quantity,
{$wh['id']} AS warehouse_id
FROM `td_stock_{$safe}` s
FROM `{$table}` s
WHERE s.company_id = {$cid}
AND s.type = 'in'
AND s.lot_number IS NOT NULL
@@ -1044,8 +1027,8 @@ class ReportManager
$rows = [];
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$warehouse_name = $this->pdo->quote($wh['warehouse_name']);
$sth = $this->pdo->prepare(
"SELECT
@@ -1059,7 +1042,7 @@ class ReportManager
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
s.serial_number,
s.description,
'{$safe}' AS warehouse_name
{$warehouse_name} AS warehouse_name
FROM `{$table}` s
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
@@ -1109,8 +1092,7 @@ class ReportManager
$cid = (int) $this->companyId;
foreach ($wh_list as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$sth = $this->pdo->query(
"SELECT lot_number,
@@ -1195,7 +1177,10 @@ class ReportManager
{
if (!$rack_id) return [];
$main_db = preg_replace('/[^a-zA-Z0-9_]/', '', $this->mainDb);
$main_db = $this->mainDb;
if ($main_db === '' || !ctype_alnum(str_replace('_', '', $main_db))) {
throw new Exception("Invalid main database name.");
}
$sth = $this->pdo->prepare(
"SELECT
@@ -1287,7 +1272,7 @@ class ReportManager
}
/**
* Return paginated raw stock movement transactions from td_stock_<warehouse>
* Return paginated raw stock movement transactions from td_stock_<warehouse_id>
* within a date range (by month), with a balance brought forward.
*
* Each row is one individual transaction — no grouping. Full datetime is
@@ -1412,13 +1397,15 @@ class ReportManager
$bf_out = (float)($bf['bf_out'] ?? 0);
$bf_bal = round($bf_in - $bf_out, 2);
// Product name
// Product name + UOM
$sth = $this->pdo->prepare(
"SELECT product_name FROM md_product
"SELECT product_name, uom FROM md_product
WHERE company_id = :company_id AND sku = :sku LIMIT 1"
);
$sth->execute([':company_id' => $this->companyId, ':sku' => $product_sku]);
$product_name = $sth->fetchColumn() ?: $product_sku;
$product_row = $sth->fetch(PDO::FETCH_ASSOC) ?: [];
$product_name = $product_row['product_name'] ?: $product_sku;
$product_uom = $product_row['uom'] ?? '';
// All transactions for this SKU in range, sorted by date then id
$sth = $this->pdo->prepare(
@@ -1460,6 +1447,7 @@ class ReportManager
return [
'sku' => $product_sku,
'product_name' => $product_name,
'uom' => $product_uom,
'brought_forward' => [
'in' => $bf_in,
'out' => $bf_out,
+47 -24
View File
@@ -12,7 +12,7 @@
* Key design decisions:
* - status: -1=cancelled, 0=draft, 1=confirmed (consistent with td_order)
* - confirmReturn() is the single restock path for all customer returns:
* 1. INSERT td_stock_<wh> in rows (source='return', source_id=return_id,
* 1. INSERT td_stock_<warehouse_id> in rows (source='return', source_id=return_id,
* status=1 ALWAYS — confirming a return is a final business decision,
* no separate warehouse approval step needed).
* 2. occupyRack() + adjustBalance() via WarehouseManager.
@@ -27,7 +27,7 @@
* Callers must wrap multi-step operations inside dbTransaction().
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* Dynamic table names are sanitised before interpolation.
* Dynamic stock table names are derived only from md_warehouse.id.
*/
class ReturnManager {
@@ -72,17 +72,13 @@ class ReturnManager {
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
}
private function resolveStockTable(int $warehouse_id): string
private function stockTableNameFromWarehouseId(int $warehouse_id): string
{
$sth = $this->pdo->prepare(
"SELECT warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
$name = $sth->fetchColumn();
if ($warehouse_id <= 0) {
throw new Exception("Invalid warehouse id.");
}
if (!$name) throw new Exception("Warehouse ID {$warehouse_id} not found.");
return 'td_stock_' . preg_replace('/[^a-zA-Z0-9_]/', '', $name);
return 'td_stock_' . $warehouse_id;
}
// ─────────────────────────────────────────────────────────────
@@ -254,9 +250,9 @@ class ReturnManager {
* Confirm a return — restock items back to warehouse + optionally create credit note.
*
* Flow per item:
* 1. INSERT td_stock_<wh> in row with type='in', status=1 (force confirmed),
* 1. INSERT td_stock_<warehouse_id> in row with type='in', status=1 (force confirmed),
* source='return', source_id=return_id.
* 2. occupyRack() — place returned stock back into the original rack.
* 2. occupyRack() — place returned stock into the user-selected empty rack.
* 3. adjustBalance() — update warehouse_balance.
*
* After all items:
@@ -304,13 +300,42 @@ class ReturnManager {
foreach ($items as $i => $item) {
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
$stock_out_wh = (int)($item['stock_out_warehouse_id'] ?? $warehouse_id);
$stock_out_id = (int)($item['stock_out_id'] ?? 0);
$product_sku = $item['product_sku'] ?? '';
if (!$warehouse_id || !$product_sku) {
throw new Exception("Item #{$i}: missing warehouse_id or product_sku.");
}
if (!$stock_out_wh || !$stock_out_id) {
throw new Exception("Item #{$i}: missing linked stock-out record.");
}
$table = $this->resolveStockTable($warehouse_id);
$stock_out_table = $this->stockTableNameFromWarehouseId($stock_out_wh);
$stock_out_sth = $this->pdo->prepare(
"SELECT status, lot_number, serial_number
FROM `{$stock_out_table}`
WHERE company_id = :company_id
AND id = :id
AND product_sku = :product_sku
AND type = 'out'
LIMIT 1"
);
$stock_out_sth->execute([
':company_id' => $this->company_id,
':id' => $stock_out_id,
':product_sku' => $product_sku,
]);
$stock_out = $stock_out_sth->fetch(PDO::FETCH_ASSOC);
if (!$stock_out) {
throw new Exception("Item #{$i}: linked stock-out record not found.");
}
if ((int)$stock_out['status'] !== 1) {
throw new Exception("Item #{$i}: stock-out is still draft. Approve stock-out before confirming the return.");
}
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$item_uuid = $uuid . '_ret_' . $i;
$item_log = [array_merge($logging, ['action' => 'confirm_return_item'])];
$quantity = (float)($item['quantity'] ?? 0);
@@ -341,8 +366,8 @@ class ReturnManager {
':contact_id' => (int)$return['contact_id'],
':description' => $return['return_number'],
':log' => json_encode($item_log),
':lot_number' => $item['lot_number'] ?? '',
':serial_number' => $item['serial_number'] ?? '',
':lot_number' => $stock_out['lot_number'] ?? ($item['lot_number'] ?? ''),
':serial_number' => $stock_out['serial_number'] ?? ($item['serial_number'] ?? ''),
':source_id' => $return_id,
':price' => (float)($item['price'] ?? 0),
]);
@@ -356,7 +381,8 @@ class ReturnManager {
$item['aisle'] ?? '',
$item['rack'] ?? '',
$product_sku,
$stock_in_id
$stock_in_id,
true
);
$whMgmt->adjustBalance('in', $warehouse_id, $product_sku, 0, $quantity);
}
@@ -465,12 +491,11 @@ class ReturnManager {
if (!$warehouse_id || !$product_sku) continue;
$table = $this->resolveStockTable($warehouse_id);
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
// Find the stock-in row created by confirmReturn()
$sth = $this->pdo->prepare(
"SELECT id, zone, aisle, rack FROM `{$safe}`
"SELECT id, zone, aisle, rack FROM `{$table}`
WHERE company_id = :company_id
AND source = 'return'
AND source_id = :return_id
@@ -489,7 +514,7 @@ class ReturnManager {
// Soft-delete the stock-in row
$this->pdo->prepare(
"UPDATE `{$safe}` SET status = -1
"UPDATE `{$table}` SET status = -1
WHERE id = :id AND company_id = :company_id"
)->execute([
':id' => $stock_row['id'],
@@ -501,9 +526,7 @@ class ReturnManager {
$warehouse_id,
$stock_row['zone'],
$stock_row['aisle'],
$stock_row['rack'],
$product_sku,
$stock_row['id']
$stock_row['rack']
);
// Reverse balance
+68 -58
View File
@@ -19,8 +19,7 @@
* Callers must wrap multi-step operations inside dbTransaction().
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* Dynamic table names (td_stock_<warehouse>) are derived from DB-sourced warehouse
* names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation.
* Dynamic stock table names are derived only from md_warehouse.id.
*/
class StockManager {
@@ -36,34 +35,13 @@ class StockManager {
// Private helpers
// ─────────────────────────────────────────────────────────────
/**
* Resolve the dynamic td_stock_<warehouse> table name for a warehouse_id.
*
* Looks up warehouse_name from md_warehouse (no status filter — unlike
* WarehouseManager::resolveWarehouseTable, this serves read flows that may
* need to access inactive warehouses for historical record retrieval).
* The name is sanitised with preg_replace before being used as a table
* identifier, preventing SQL injection via malicious warehouse names.
*
* @param int $warehouse_id The md_warehouse.id to resolve.
* @return string The sanitised table name, e.g. "td_stock_Main".
* @throws Exception If no warehouse is found for the given ID.
*/
private function resolveTable(int $warehouse_id): string
private function stockTableNameFromWarehouseId(int $warehouse_id): string
{
$sth = $this->pdo->prepare(
"SELECT warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
$name = $sth->fetchColumn();
if (!$name) {
throw new Exception("Warehouse not found.");
if ($warehouse_id <= 0) {
throw new Exception("Invalid warehouse id.");
}
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
return "td_stock_{$safe}";
return 'td_stock_' . $warehouse_id;
}
// ─────────────────────────────────────────────────────────────
@@ -83,14 +61,14 @@ class StockManager {
*/
public function getStockList(int $warehouse_id, string $type): array
{
$table = $this->resolveTable($warehouse_id);
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
$sth = $this->pdo->prepare(
"SELECT a.*, {$column} AS quantity, b.product_name
"SELECT a.*, {$column} AS quantity, b.product_name, b.uom
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id
@@ -114,18 +92,18 @@ class StockManager {
* stock in detail view. Joins md_lot to include lot expiry_date when available.
*
* @param int $warehouse_id The warehouse the stock_in belongs to.
* @param int $id The td_stock_<wh>.id of the stock_in row.
* @param int $id The td_stock_<warehouse_id>.id of the stock_in row.
* @return array|false Full row with 'quantity', 'contact_name', 'product_name',
* 'expiry_date', or false if not found.
*/
public function getStockInById(int $warehouse_id, int $id): array|false
{
$table = $this->resolveTable($warehouse_id);
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.in AS quantity,
b.contact_name,
c.product_name,
c.product_name, c.uom,
d.expiry_date
FROM `{$table}` a
LEFT JOIN md_contact b
@@ -149,18 +127,18 @@ class StockManager {
* stock out detail view.
*
* @param int $warehouse_id The warehouse the stock_out belongs to.
* @param int $id The td_stock_<wh>.id of the stock_out row.
* @param int $id The td_stock_<warehouse_id>.id of the stock_out row.
* @return array|false Full row with 'quantity', 'contact_name', 'product_name',
* or false if not found.
*/
public function getStockOutById(int $warehouse_id, int $id): array|false
{
$table = $this->resolveTable($warehouse_id);
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.out AS quantity,
b.contact_name,
c.product_name
c.product_name, c.uom
FROM `{$table}` a
LEFT JOIN md_contact b
ON a.company_id = b.company_id AND a.contact_id = b.id
@@ -182,19 +160,19 @@ class StockManager {
* transfer detail view.
*
* @param int $warehouse_id The warehouse holding the outbound (from) row.
* @param int $id The td_stock_<wh>.id of the outbound transfer row.
* @param int $id The td_stock_<warehouse_id>.id of the outbound transfer row.
* @return array|false Outbound row with 'quantity', 'contact_name', 'product_name',
* and a 'ref' key containing the inbound row, or false if not found.
*/
public function getTransferById(int $warehouse_id, int $id): array|false
{
$table = $this->resolveTable($warehouse_id);
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
// Fetch the outbound (from) row
$sth = $this->pdo->prepare(
"SELECT a.*, a.out AS quantity,
b.contact_name,
c.product_name
c.product_name, c.uom
FROM `{$table}` a
LEFT JOIN md_contact b
ON a.company_id = b.company_id AND a.contact_id = b.id
@@ -209,12 +187,12 @@ class StockManager {
// Resolve the inbound (to) row via ref_warehouse + uuid
$to_warehouse_id = (int)$output['ref_warehouse'];
$to_table = $this->resolveTable($to_warehouse_id);
$to_table = $this->stockTableNameFromWarehouseId($to_warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.in AS quantity,
b.contact_name,
c.product_name
c.product_name, c.uom
FROM `{$to_table}` a
LEFT JOIN md_contact b
ON a.company_id = b.company_id AND a.contact_id = b.id
@@ -237,9 +215,9 @@ class StockManager {
*
* Insert flow (id = 0):
* 1. Upserts md_lot if lot_number + expiry_date are provided.
* 2. Inserts the td_stock_<wh> row.
* 3. Calls WarehouseManager::occupyRack() to mark the rack as taken.
* 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance.
* 2. Inserts the td_stock_<warehouse_id> row.
* 3. Calls WarehouseManager::occupyRack() to reserve the rack.
* warehouse_balance is updated later by approveStock().
*
* Update flow (id > 0):
* - Updates contact_id, description, and log only.
@@ -328,7 +306,18 @@ class StockManager {
$td_stock_id = (int)$this->pdo->lastInsertId();
// occupyRack and adjustBalance deferred — called from approveStock() only.
// Reserve the location immediately so draft stock-in rows cannot
// leave the same rack available for another receipt. Balance still
// changes only when approveStock() runs.
$whMgmt->occupyRack(
$warehouse_id,
$data['zone'],
$data['aisle'],
$data['rack'],
$data['product_sku'],
$td_stock_id
);
return $td_stock_id;
}
}
@@ -338,7 +327,7 @@ class StockManager {
*
* Insert flow (id = 0):
* 1. Validates the rack is occupied with the correct SKU / lot / serial.
* 2. Inserts the td_stock_<wh> row, copying quantity and lot info from the rack.
* 2. Inserts the td_stock_<warehouse_id> row, copying quantity and lot info from the rack.
* 3. Calls WarehouseManager::releaseRack() to free the rack slot.
* 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance.
*
@@ -609,7 +598,7 @@ class StockManager {
':date' => date('Y-m-d H:i:s'),
':product_sku' => $data['product_sku'],
':quantity' => $quantity,
':ref_warehouse' => $whMgmt->getWarehouseName($to_warehouse),
':ref_warehouse' => $to_warehouse,
':zone' => $from_zone,
':aisle' => $from_aisle,
':rack' => $from_rack,
@@ -637,7 +626,7 @@ class StockManager {
':date' => date('Y-m-d H:i:s'),
':product_sku' => $data['product_sku'],
':quantity' => $quantity,
':ref_warehouse' => $whMgmt->getWarehouseName($from_warehouse),
':ref_warehouse' => $from_warehouse,
':ref_id' => $from_stock_id,
':zone' => $to_zone,
':aisle' => $to_aisle,
@@ -686,7 +675,7 @@ class StockManager {
*/
public function approveStock(int $id, int $warehouse_id, string $type, WarehouseManager $whMgmt): void
{
$table = $this->resolveTable($warehouse_id);
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
// Load the row and validate ownership / status
$sth = $this->pdo->prepare(
@@ -724,31 +713,52 @@ class StockManager {
} elseif ($type === 'out') {
// Release rack now that stock-out is approved
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
$remaining_qty = 0.0;
if ((int)($row['ref_id'] ?? 0) > 0) {
$remaining_sth = $this->pdo->prepare(
"SELECT src.`in` - COALESCE(SUM(out_rows.`out`), 0) AS remaining_qty
FROM `{$table}` src
LEFT JOIN `{$table}` out_rows
ON out_rows.company_id = src.company_id
AND out_rows.ref_id = src.id
AND out_rows.`out` > 0
AND out_rows.status != -1
WHERE src.id = :ref_id
AND src.company_id = :company_id
GROUP BY src.id, src.`in`"
);
$remaining_sth->execute([
':ref_id' => (int)$row['ref_id'],
':company_id' => $this->company_id,
]);
$remaining_qty = (float)$remaining_sth->fetchColumn();
}
// Release the rack only when the source batch is fully consumed.
if ($remaining_qty <= 0.000001) {
$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)
// outbound row → td_stock_<from_id> (out > 0, ref_warehouse = to_id)
// inbound row → td_stock_<to_id> (in > 0, ref_warehouse = from_id)
// 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>.
// The inbound row lives in td_stock_<ref_warehouse_id>.
$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);
// Resolve the paired table from ref_warehouse id
$paired_wh_id = (int)$row['ref_warehouse'];
$paired_table = $this->stockTableNameFromWarehouseId($paired_wh_id);
// If we received the inbound side, swap so $from_* is always outbound
if (!$is_outbound) {
+367 -121
View File
@@ -17,9 +17,7 @@
* Callers must wrap multi-step operations inside dbTransaction().
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* Dynamic table names (td_stock_<warehouse>) are derived exclusively from
* DB-sourced warehouse names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...)
* before interpolation — no user input ever reaches a table identifier directly.
* Dynamic stock table names are derived only from md_warehouse.id.
*/
class WarehouseManager {
@@ -35,27 +33,26 @@ class WarehouseManager {
// Private helpers
// ─────────────────────────────────────────────────────────────
/**
* Resolve the td_stock_<wh> table name for a warehouse, requiring active status.
*
* Returns null if the warehouse is not found or is inactive (status != 1).
* Used by Zone/Aisle/Rack out-query methods where inactive warehouses
* should not contribute available stock locations.
*
* @param int $warehouse_id The md_warehouse.id to resolve.
* @return string|null Sanitised table name, or null if not active/found.
*/
private function stockTableNameFromWarehouseId(int $warehouse_id): string
{
if ($warehouse_id <= 0) {
throw new Exception("Invalid warehouse id.");
}
return 'td_stock_' . $warehouse_id;
}
private function resolveWarehouseTable(int $warehouse_id): ?string
{
$sth = $this->pdo->prepare(
"SELECT warehouse_name FROM md_warehouse
"SELECT id 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}";
$id = $sth->fetchColumn();
if (!$id) return null;
return $this->stockTableNameFromWarehouseId((int)$id);
}
/**
@@ -330,6 +327,90 @@ class WarehouseManager {
]);
}
/**
* Soft-delete rack logs created by a stock movement and its reversal.
*
* md_rack_log follows the same soft-delete convention as other tenant rows:
* negate company_id so normal company_id filters hide the records.
*/
private function softDeleteRackLogs(
$warehouse_id,
string $zone,
string $aisle,
string $rack,
array $actions,
string $product_sku,
array $td_stock_ids = [],
?string $since = null
): void {
if (empty($actions)) {
return;
}
$rack_sth = $this->pdo->prepare(
"SELECT id FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
AND aisle = :aisle
AND rack = :rack
LIMIT 1"
);
$rack_sth->execute([
':company_id' => $this->company_id,
':warehouse' => $warehouse_id,
':zone' => $zone,
':aisle' => $aisle,
':rack' => $rack,
]);
$rack_id = (int)$rack_sth->fetchColumn();
if (!$rack_id) {
return;
}
$params = [
':company_id' => $this->company_id,
':md_rack_id' => $rack_id,
':product_sku' => $product_sku,
];
$action_placeholders = [];
foreach (array_values($actions) as $idx => $action) {
$key = ':action_' . $idx;
$action_placeholders[] = $key;
$params[$key] = $action;
}
$td_stock_ids = array_values(array_filter(array_map('intval', $td_stock_ids)));
$td_stock_cond = '';
if (!empty($td_stock_ids)) {
$td_placeholders = [];
foreach ($td_stock_ids as $idx => $td_stock_id) {
$key = ':td_stock_id_' . $idx;
$td_placeholders[] = $key;
$params[$key] = $td_stock_id;
}
$td_stock_cond = 'AND td_stock_id IN (' . implode(', ', $td_placeholders) . ')';
}
$since_cond = '';
if ($since !== null && $since !== '') {
$since_cond = 'AND dt >= :since';
$params[':since'] = $since;
}
$sql = "UPDATE md_rack_log
SET company_id = company_id * -1
WHERE company_id = :company_id
AND md_rack_id = :md_rack_id
AND product_sku = :product_sku
AND action IN (" . implode(', ', $action_placeholders) . ")
{$td_stock_cond}
{$since_cond}";
$this->pdo->prepare($sql)->execute($params);
}
/**
* Validate that the occupied racks under a storage_id all fall inside a new range.
*
@@ -340,15 +421,14 @@ class WarehouseManager {
* @param array $range Keys: aisle_from, aisle_to, rack_from, rack_to.
* @throws Exception If occupied racks would fall outside the new range.
*/
private function validateRangeChange(int $storage_id, array $range): void
private function validateRangeChange(int $storage_id, array $range, bool $advanced_location): 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']);
// Fetch all occupied racks under this storage_id
$sth = $this->pdo->prepare(
"SELECT aisle, rack FROM md_rack
"SELECT zone, aisle, rack FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id
AND product_sku IS NOT NULL"
@@ -359,10 +439,18 @@ class WarehouseManager {
]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$z = trim($row['zone']);
$a = trim($row['aisle']);
$r = trim($row['rack']);
if (!in_array($a, $valid_aisles, true) || !in_array($r, $valid_racks, true)) {
if ($advanced_location) {
if (in_array($a, $valid_aisles, true) && in_array($r, $valid_racks, true)) {
continue;
}
throw new Exception("Cannot shrink range — some racks still have stock");
}
if ($z !== $a || $a !== $r || !in_array($r, $valid_racks, true)) {
throw new Exception("Cannot shrink range — some racks still have stock");
}
}
@@ -377,7 +465,7 @@ class WarehouseManager {
* @param int $storage_id The md_storage.id this range belongs to.
* @param array $range Keys: warehouse, zone, aisle_from, aisle_to, rack_from, rack_to.
*/
private function insertRacksInRange(int $storage_id, array $range): void
private function insertRacksInRange(int $storage_id, array $range, bool $advanced_location): void
{
$sql = "INSERT IGNORE INTO md_rack
(company_id, warehouse, storage_id, zone, aisle, rack)
@@ -388,6 +476,20 @@ class WarehouseManager {
$aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
$racks = $this->rangeToArray($range['rack_from'], $range['rack_to']);
if (!$advanced_location) {
foreach ($racks as $location) {
$sth->execute([
":company_id" => $this->company_id,
":warehouse" => $range['warehouse'],
":storage_id" => $storage_id,
":zone" => $location,
":aisle" => $location,
":rack" => $location,
]);
}
return;
}
foreach ($aisles as $a) {
foreach ($racks as $r) {
$sth->execute([
@@ -481,41 +583,43 @@ 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.
*
* On insert, creates a new per-warehouse stock table (td_stock_<name>)
* using the td_stock template via CREATE TABLE LIKE.
* The warehouse_name is sanitised before use as a table name suffix.
* On insert, returns the new warehouse id so the caller can create the
* per-warehouse stock table outside the DB transaction.
*
* Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update.
* Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, warehouse_name, location, manager, description, status.
* @param array $logging Audit entry to append to the log column.
* @return int|null New warehouse id on insert, null on update.
*/
public function saveWarehouse(array $data, array $logging): void
public function saveWarehouse(array $data, array $logging): ?int
{
$id = (int)($data['id'] ?? 0);
$warehouse_name = trim((string)($data['warehouse_name'] ?? ''));
if ($warehouse_name === '') {
throw new Exception("Warehouse name is required.");
}
$duplicate = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_warehouse
WHERE company_id = :company_id
AND warehouse_name = :warehouse_name
AND id != :id"
);
$duplicate->execute([
':company_id' => $this->company_id,
':warehouse_name' => $warehouse_name,
':id' => $id,
]);
if ((int)$duplicate->fetchColumn() > 0) {
throw new Exception("Warehouse name already exists. Please use a different warehouse name.");
}
$sth = $this->pdo->prepare(
"SELECT `log` FROM md_warehouse
@@ -527,7 +631,7 @@ class WarehouseManager {
$params = [
':company_id' => $this->company_id,
':warehouse_name' => $data['warehouse_name'] ?? '',
':warehouse_name' => $warehouse_name,
':location' => $data['location'] ?? '',
':manager' => (int)($data['manager'] ?? 0),
':description' => $data['description'] ?? '',
@@ -555,12 +659,16 @@ class WarehouseManager {
(:company_id, :warehouse_name, :location, :manager, :description, :status, :log)"
)->execute($params);
// Create the per-warehouse stock table using td_stock as template
if (!empty($data['warehouse_name'])) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $data['warehouse_name']);
$this->pdo->exec("CREATE TABLE `td_stock_{$safe}` LIKE `td_stock`");
}
return (int)$this->pdo->lastInsertId();
}
return null;
}
public function createStockTableForWarehouse(int $warehouse_id): void
{
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$this->pdo->exec("CREATE TABLE IF NOT EXISTS `{$table}` LIKE `td_stock`");
}
/**
@@ -682,10 +790,43 @@ class WarehouseManager {
* rack_from, rack_to, description, status.
* @param array $logging Audit entry to append to the log column.
*/
public function saveStorage(array $data, array $logging): void
public function saveStorage(array $data, array $logging, bool $advanced_location = false): void
{
$id = (int)($data['id'] ?? 0);
$warehouse = trim((string)($data['warehouse'] ?? ''));
$zone = trim((string)($data['zone'] ?? ''));
$aisle_from = trim((string)($data['aisle_from'] ?? ''));
$aisle_to = trim((string)($data['aisle_to'] ?? ''));
$rack_from = trim((string)($data['rack_from'] ?? ''));
$rack_to = trim((string)($data['rack_to'] ?? ''));
if ($warehouse === '') {
throw new Exception("Warehouse is required.");
}
if ($advanced_location) {
if ($zone === '' || $aisle_from === '' || $aisle_to === '' || $rack_from === '' || $rack_to === '') {
throw new Exception("Zone, aisle range, and rack range are required.");
}
} else {
$location_from = $rack_from !== '' ? $rack_from : $aisle_from;
$location_to = $rack_to !== '' ? $rack_to : $aisle_to;
if ($location_from === '') {
throw new Exception("Location From is required.");
}
if ($location_to === '') {
$location_to = $location_from;
}
$zone = $location_from;
$aisle_from = $location_from;
$aisle_to = $location_to;
$rack_from = $location_from;
$rack_to = $location_to;
}
$sth = $this->pdo->prepare(
"SELECT `log` FROM md_storage
WHERE company_id = :company_id AND id = :id"
@@ -696,12 +837,12 @@ class WarehouseManager {
$params = [
':company_id' => $this->company_id,
':warehouse' => $data['warehouse'] ?? '',
':zone' => $data['zone'] ?? '',
':aisle_from' => $data['aisle_from'] ?? '',
':aisle_to' => $data['aisle_to'] ?? '',
':rack_from' => $data['rack_from'] ?? '',
':rack_to' => $data['rack_to'] ?? '',
':warehouse' => $warehouse,
':zone' => $zone,
':aisle_from' => $aisle_from,
':aisle_to' => $aisle_to,
':rack_from' => $rack_from,
':rack_to' => $rack_to,
':description' => $data['description'] ?? '',
':status' => (int)($data['status'] ?? 1),
':log' => json_encode($table_log, JSON_UNESCAPED_UNICODE),
@@ -739,13 +880,13 @@ class WarehouseManager {
// Sync md_rack rows to exactly match the new aisle × rack range
$this->syncRacks($storage_id, [
'warehouse' => $data['warehouse'],
'zone' => $data['zone'],
'aisle_from' => $data['aisle_from'],
'aisle_to' => $data['aisle_to'],
'rack_from' => $data['rack_from'],
'rack_to' => $data['rack_to'],
]);
'warehouse' => $warehouse,
'zone' => $zone,
'aisle_from' => $aisle_from,
'aisle_to' => $aisle_to,
'rack_from' => $rack_from,
'rack_to' => $rack_to,
], $advanced_location);
}
/**
@@ -938,7 +1079,7 @@ class WarehouseManager {
// 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
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$sth->execute([':company_id' => $this->company_id]);
@@ -950,8 +1091,8 @@ class WarehouseManager {
// 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}`
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
return "SELECT lot_number FROM `{$table}`
WHERE company_id = {$cid} AND lot_number IS NOT NULL";
}, $warehouses));
@@ -987,7 +1128,7 @@ class WarehouseManager {
public function getActiveLots(string $product_sku): array
{
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$sth->execute([':company_id' => $this->company_id]);
@@ -996,8 +1137,7 @@ class WarehouseManager {
$active_lots = [];
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$sth = $this->pdo->prepare(
"SELECT DISTINCT s.lot_number, l.expiry_date
@@ -1008,7 +1148,7 @@ class WarehouseManager {
AND l.lot_number = s.lot_number
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.type = 'in'
AND s.`in` > 0
AND s.status = 1
AND s.lot_number IS NOT NULL
AND EXISTS (
@@ -1047,7 +1187,7 @@ class WarehouseManager {
public function getActiveSerials(string $product_sku, string $lot_number = ''): array
{
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$sth->execute([':company_id' => $this->company_id]);
@@ -1056,15 +1196,14 @@ class WarehouseManager {
$active_serials = [];
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$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.`in` > 0
AND s.status = 1
AND s.serial_number IS NOT NULL
AND EXISTS (
@@ -1104,8 +1243,8 @@ class WarehouseManager {
* Resolve the per-warehouse stock table name and optionally fetch a specific row.
*
* Core helper used throughout engine files and StockManager to get:
* - 'table': the td_stock_<warehouse> table name
* - 'name': the raw warehouse_name string
* - 'table': the td_stock_<warehouse_id> table name
* - 'name': the raw warehouse_name string for display
* - 'row': the specific stock row (or empty array if $id = 0)
*
* Pass $id = 0 to get just the table name without fetching a row.
@@ -1113,15 +1252,17 @@ class WarehouseManager {
* historical rows that need reading.
*
* @param int|string $warehouse_id The md_warehouse.id.
* @param int|string $id The td_stock_<wh>.id to fetch, or 0 for table-only.
* @param int|string $id The td_stock_<warehouse_id>.id to fetch, or 0 for table-only.
* @return array Keys: 'table' (string), 'name' (string), 'row' (array|[]).
*/
public function getStockContext($warehouse_id, $id) {
$warehouse_id = (int)$warehouse_id;
$name = $this->getWarehouseName($warehouse_id);
if (!$name) {
throw new Exception("Warehouse ID {$warehouse_id} not found.");
}
// Sanitise table suffix to prevent SQL injection via warehouse names
$safe_name = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
$table = "td_stock_" . $safe_name;
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
if (empty($id)) {
return [
@@ -1166,10 +1307,10 @@ class WarehouseManager {
* @param array $range Keys: warehouse, zone, aisle_from, aisle_to, rack_from, rack_to.
* @throws Exception If occupied racks would be removed by the new range.
*/
public function syncRacks(int $storage_id, array $range): void {
public function syncRacks(int $storage_id, array $range, bool $advanced_location): void {
// Guard: refuse if any occupied rack under this storage_id falls outside the new range
$this->validateRangeChange($storage_id, $range);
$this->validateRangeChange($storage_id, $range, $advanced_location);
// Wipe all empty racks belonging to this storage_id
$sql = "DELETE FROM md_rack
@@ -1184,14 +1325,14 @@ class WarehouseManager {
]);
// Reinsert the full range fresh (INSERT IGNORE skips any occupied racks already there)
$this->insertRacksInRange($storage_id, $range);
$this->insertRacksInRange($storage_id, $range, $advanced_location);
}
/**
* Fetch the stock record currently occupying a rack.
*
* Under the 1:1 rack model, each occupied rack's md_rack.td_stock_id points
* to exactly one td_stock_<wh> row. This method resolves that pointer and
* to exactly one td_stock_<warehouse_id> row. This method resolves that pointer and
* returns the full stock row, or null if the rack is empty or the link is broken.
*
* Used by saveStockOut and saveStockTransfer to validate rack contents before
@@ -1227,9 +1368,17 @@ class WarehouseManager {
return null; // Rack is empty or doesn't exist
}
// Fetch the full stock row from the per-warehouse table
// Fetch the full stock row from the per-warehouse table. Source
// movements can only consume approved stock-in rows; draft stock-in
// rows may reserve a rack but are not available inventory yet.
$context = $this->getStockContext($warehouse_id, $td_stock_id);
return $context['row'] ?: null;
$row = $context['row'] ?: null;
if (!$row || $row['type'] !== 'in' || (int)$row['status'] !== 1) {
return null;
}
return $row;
}
/**
@@ -1274,20 +1423,24 @@ class WarehouseManager {
if (empty($warehouses)) return;
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$wh_id = (int)$wh['id'];
$table = $this->stockTableNameFromWarehouseId($wh_id);
// Find any stock_in row for this SKU that is still rack-linked (active)
// and matches the lot/serial combination being validated.
// The exclude_id skips the just-inserted row to avoid false self-conflict.
// r.warehouse = :warehouse_id scopes the rack check to THIS warehouse only —
// td_stock_id values are per-table integers, not globally unique, so without
// this filter a rack in a different warehouse could cause a false positive.
$sql = "SELECT s.id
FROM `{$table}` s
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.type = 'in'
AND s.`in` > 0
AND EXISTS (
SELECT 1 FROM md_rack r
WHERE r.company_id = :company_id2
AND r.warehouse = :warehouse_id
AND r.td_stock_id = s.id
AND r.product_sku IS NOT NULL
)";
@@ -1303,9 +1456,10 @@ class WarehouseManager {
}
$params = [
':company_id' => $cid,
':company_id2' => $cid,
':product_sku' => $product_sku,
':company_id' => $cid,
':company_id2' => $cid,
':warehouse_id' => $wh_id,
':product_sku' => $product_sku,
];
if (!empty($lot_number)) $params[':lot_number'] = $lot_number;
if (!empty($serial_number)) $params[':serial_number'] = $serial_number;
@@ -1332,7 +1486,8 @@ class WarehouseManager {
* Assign a product to an empty rack and link it to a td_stock row.
*
* Uses FOR UPDATE to lock the rack row and prevent concurrent occupancy.
* Also calls validateStockUnique to enforce no-duplicate-serial rules.
* Also calls validateStockUnique to enforce no-duplicate-serial rules unless
* the caller is replaying a validated source transaction, such as a return.
* Updates md_rack state (product_sku + td_stock_id) in a single UPDATE
* and appends a rack log entry.
*
@@ -1343,12 +1498,12 @@ class WarehouseManager {
* @param string $aisle Aisle identifier.
* @param string $rack Rack identifier.
* @param string $product_sku SKU to assign to this rack.
* @param int $td_stock_id The td_stock_<wh>.id to link.
* @param int $td_stock_id The td_stock_<warehouse_id>.id to link.
* @throws Exception If the rack does not exist or is already occupied.
*/
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void {
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id, bool $skip_unique_validation = false): void {
$sql = "SELECT id, product_sku FROM md_rack
$sql = "SELECT id, product_sku, td_stock_id FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
@@ -1371,22 +1526,31 @@ class WarehouseManager {
}
if ($current['product_sku'] !== null) {
if (
$current['product_sku'] === $product_sku
&& (int)$current['td_stock_id'] === (int)$td_stock_id
) {
return;
}
throw new Exception(
"Rack {$zone}-{$aisle}-{$rack} is already occupied by {$current['product_sku']}"
);
}
// Validate sku + lot + serial uniqueness before linking the rack
$ctx = $this->getStockContext($warehouse_id, $td_stock_id);
$row = $ctx['row'] ?? null;
if (!$skip_unique_validation) {
// Validate sku + lot + serial uniqueness before linking the rack
$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 insert flows don't self-conflict
);
if ($row) {
$this->validateStockUnique(
$product_sku,
$row['lot_number'] ?? null,
$row['serial_number'] ?? null,
$td_stock_id // exclude self so insert flows don't self-conflict
);
}
}
// Atomic UPDATE: set state and link in one statement
@@ -1674,7 +1838,8 @@ class WarehouseManager {
SELECT s.id FROM `{$table}` s
WHERE s.company_id = :company_id2
AND s.product_sku = :product_sku2
AND s.type = 'in'
AND s.`in` > 0
AND s.status = 1
{$lot_cond}
{$serial_cond}
)
@@ -1745,7 +1910,8 @@ class WarehouseManager {
SELECT s.id FROM `{$table}` s
WHERE s.company_id = :company_id2
AND s.product_sku = :product_sku2
AND s.type = 'in'
AND s.`in` > 0
AND s.status = 1
{$lot_cond}
{$serial_cond}
)
@@ -1841,7 +2007,8 @@ class WarehouseManager {
SELECT s.id FROM `{$table}` s
WHERE s.company_id = :company_id2
AND s.product_sku = :product_sku2
AND s.type = 'in'
AND s.`in` > 0
AND s.status = 1
{$lot_cond}
{$serial_cond}
)
@@ -1861,12 +2028,12 @@ class WarehouseManager {
* Steps:
* 1. Validates this is the globally latest transaction for the SKU.
* 2. Soft-deletes the td_stock row (negates company_id).
* 3. Releases the occupied rack back to empty.
* 4. Reverses the balance (adjustBalance with new_qty = 0).
* 3. Releases the reserved/occupied rack back to empty.
* 4. Reverses the balance only if the row was approved.
*
* Must be called inside a DB transaction by the caller.
*
* @param int $stock_id The td_stock_<wh>.id of the row to delete.
* @param int $stock_id The td_stock_<warehouse_id>.id of the row to delete.
* @param int $warehouse_id The warehouse the row belongs to.
* @throws Exception If not the latest transaction or record not found.
*/
@@ -1892,9 +2059,19 @@ class WarehouseManager {
WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $stock_id, ':company_id' => $this->company_id]);
// Only reverse side effects if the row was approved — draft rows never had them applied
// Stock-in reserves the rack at creation time, even while draft.
// Balance is only applied for approved rows.
$this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
$this->softDeleteRackLogs(
$warehouse_id,
$row['zone'], $row['aisle'], $row['rack'],
['occupy', 'release'],
$product_sku,
[$stock_id],
$row['date']
);
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);
}
}
@@ -1910,7 +2087,7 @@ class WarehouseManager {
*
* Must be called inside a DB transaction by the caller.
*
* @param int $stock_id The td_stock_<wh>.id of the row to delete.
* @param int $stock_id The td_stock_<warehouse_id>.id of the row to delete.
* @param int $warehouse_id The warehouse the row belongs to.
* @throws Exception If not the latest transaction or record not found.
*/
@@ -1945,6 +2122,14 @@ class WarehouseManager {
$product_sku,
(int)$row['ref_id']
);
$this->softDeleteRackLogs(
$warehouse_id,
$row['zone'], $row['aisle'], $row['rack'],
['release', 'occupy'],
$product_sku,
[(int)$row['ref_id']],
$row['date']
);
$this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0);
}
}
@@ -2020,6 +2205,22 @@ class WarehouseManager {
$product_sku,
$from_stock_id
);
$this->softDeleteRackLogs(
$to_warehouse,
$to_row['zone'], $to_row['aisle'], $to_row['rack'],
['occupy', 'release'],
$product_sku,
[$ref_id],
$from_row['date']
);
$this->softDeleteRackLogs(
$from_warehouse_id,
$from_row['zone'], $from_row['aisle'], $from_row['rack'],
['release', 'occupy'],
$product_sku,
[],
$from_row['date']
);
// Reverse balances on both sides
$quantity = (int)$from_row['out'];
@@ -2056,8 +2257,7 @@ class WarehouseManager {
$cid = (int)$this->company_id;
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$wh_id = (int)$wh['id'];
$sth = $this->pdo->prepare(
@@ -2065,7 +2265,7 @@ class WarehouseManager {
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.lot_number = :lot_number
AND s.type = 'in'
AND s.`in` > 0
AND s.status = 1
AND EXISTS (
SELECT 1 FROM md_rack r
@@ -2108,6 +2308,52 @@ class WarehouseManager {
*/
public function getWarehouseList(string $type, string $product_sku = '', int $id = 0): array
{
if (!$id && $type === 'from') {
if ($product_sku === '') {
return [];
}
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND status = 1
ORDER BY warehouse_name"
);
$sth->execute([':company_id' => $this->company_id]);
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
$result = [];
foreach ($warehouses as $wh) {
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$stock = $this->pdo->prepare(
"SELECT 1
FROM md_rack r
INNER JOIN `{$table}` s
ON s.company_id = r.company_id
AND s.id = r.td_stock_id
WHERE r.company_id = :company_id
AND r.warehouse = :warehouse
AND r.product_sku = :product_sku
AND s.product_sku = :product_sku2
AND s.`in` > 0
AND s.status = 1
LIMIT 1"
);
$stock->execute([
':company_id' => $this->company_id,
':warehouse' => (int)$wh['id'],
':product_sku' => $product_sku,
':product_sku2' => $product_sku,
]);
if ($stock->fetchColumn() !== false) {
$result[] = $wh;
}
}
return $result;
}
$product_sku_filter = '';
$params = [':company_id' => $this->company_id];
+1 -1
View File
@@ -254,7 +254,7 @@
</td>
<td class="py-3 text-secondary">${item.product_sku}</td>
<td class="py-3">${item.warehouse_name}</td>
<td class="py-3 ${balance_cls}">${item.balance}</td>
<td class="py-3 ${balance_cls}">${item.balance} <span class="text-muted fw-normal small">${item.uom || 'pcs'}</span></td>
<td class="py-3">${item.min_stock}</td>
<td class="py-3">${item.reorder_point}</td>
<td class="py-3">${badge}</td>
+13 -9
View File
@@ -5,15 +5,19 @@
require '../../../assets/utils/classes/WarehouseManager.php';
require '../../../assets/utils/classes/CompanySettingManager.php';
$csm = new CompanySettingManager($pdo1, $company_id);
$auto_approve = (int)$csm->get('default_stock_status') === 1;
$csm = new CompanySettingManager($pdo1, $company_id);
$auto_approve = (int)$csm->get('default_stock_status') === 1;
try {
$new_id = null;
try {
// Ensure the per-warehouse stock table exists (older warehouses may not have it yet)
$whBoot = new WarehouseManager($pdo2, $company_id);
$whBoot->createStockTableForWarehouse((int)$data['warehouse']);
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) {
$stock = new StockManager($pdo, $company_id);
$whMgmt = new WarehouseManager($pdo, $company_id);
$new_id = null;
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) {
$stock = new StockManager($pdo, $company_id);
$whMgmt = new WarehouseManager($pdo, $company_id);
$new_id = $stock->saveStockIn($data, $logging, $uuid);
if ($auto_approve && $new_id > 0) {
@@ -35,5 +39,5 @@
http_response_code(400);
}
exit(json_encode($answer));
?>
exit(json_encode($answer));
?>
+13 -9
View File
@@ -5,15 +5,19 @@
require '../../../assets/utils/classes/WarehouseManager.php';
require '../../../assets/utils/classes/CompanySettingManager.php';
$csm = new CompanySettingManager($pdo1, $company_id);
$auto_approve = (int)$csm->get('default_stock_status') === 1;
$csm = new CompanySettingManager($pdo1, $company_id);
$auto_approve = (int)$csm->get('default_stock_status') === 1;
try {
$new_id = null;
try {
// Ensure the per-warehouse stock table exists (older warehouses may not have it yet)
$whBoot = new WarehouseManager($pdo2, $company_id);
$whBoot->createStockTableForWarehouse((int)$data['warehouse']);
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) {
$stock = new StockManager($pdo, $company_id);
$whMgmt = new WarehouseManager($pdo, $company_id);
$new_id = null;
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) {
$stock = new StockManager($pdo, $company_id);
$whMgmt = new WarehouseManager($pdo, $company_id);
$new_id = $stock->saveStockOut($data, $logging, $uuid);
if ($auto_approve && $new_id > 0) {
@@ -35,5 +39,5 @@
http_response_code(400);
}
exit(json_encode($answer));
?>
exit(json_encode($answer));
?>
+12 -7
View File
@@ -5,15 +5,20 @@
require '../../../assets/utils/classes/WarehouseManager.php';
require '../../../assets/utils/classes/CompanySettingManager.php';
$csm = new CompanySettingManager($pdo1, $company_id);
$auto_approve = (int)$csm->get('default_stock_status') === 1;
$csm = new CompanySettingManager($pdo1, $company_id);
$auto_approve = (int)$csm->get('default_stock_status') === 1;
try {
$new_id = null;
try {
// Ensure the per-warehouse stock tables exist (older warehouses may not have them yet)
$whBoot = new WarehouseManager($pdo2, $company_id);
$whBoot->createStockTableForWarehouse((int)$data['warehouse_from']);
$whBoot->createStockTableForWarehouse((int)$data['warehouse_to']);
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) {
$stock = new StockManager($pdo, $company_id);
$whMgmt = new WarehouseManager($pdo, $company_id);
$new_id = null;
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $uuid, $auto_approve, &$new_id) {
$stock = new StockManager($pdo, $company_id);
$whMgmt = new WarehouseManager($pdo, $company_id);
$new_id = $stock->saveStockTransfer($data, $logging, $uuid);
if ($auto_approve && $new_id > 0) {
+43 -5
View File
@@ -104,6 +104,7 @@
<!-- Submit Button -->
<div class="mb-3 col-lg-12">
<button type="submit" class="btn btn-primary" onclick="manage_stock_in();">Add Stock</button>
<button type="button" class="btn btn-success ms-2 d-none" id="approve_stock_btn" onclick="approve_current_stock();">Approve</button>
</div>
</form>
</div>
@@ -161,9 +162,15 @@
function manage_stock_in() {
// In simple mode, mirror rack value to zone and aisle
var rack_val = $('#rack').val();
var zone_val = advanced ? $('#zone').val() : rack_val;
var aisle_val = advanced ? $('#aisle').val() : rack_val;
var warehouse_val = $('#warehouse').val();
var sku_val = $('#product_sku').attr('secondary') || $('#product_sku').val();
var quantity_val = $('#quantity').val();
// Mirror to hidden inputs for autoPrepare consistency (simple mode)
if (!advanced) {
var rack_val = $('#rack').val();
$('#zone').val(rack_val);
$('#aisle').val(rack_val);
}
@@ -178,8 +185,12 @@
'lot_number': $('#lot_number').val(),
'expiry_date': $('#expiry_date').val(),
'serial_number': $('#serial_number').val(),
'zone': $('#zone').val(),
'aisle': $('#aisle').val(),
'zone': zone_val,
'aisle': aisle_val,
'rack': rack_val,
'product_sku': sku_val,
'quantity': quantity_val,
'warehouse': warehouse_val,
},
action: 'manage',
onSuccess: function() {
@@ -189,6 +200,30 @@
}
function approve_current_stock() {
bootbox.confirm({
message: 'Approve this stock-in record?',
buttons: { confirm: { label: 'Approve', className: 'btn-success' }, cancel: { label: 'Cancel', className: 'btn-secondary' } },
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>ics/api/engine/approve_stock.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: {
id: '<?php echo $_GET["id"] ?? "";?>',
warehouse: '<?php echo $_GET["wh"] ?? "";?>',
type: 'in'
},
onSuccess: function() {
window.location.reload();
}
});
}
});
}
function retrieve_warehouse(type) {
@@ -309,7 +344,7 @@
var data = res.output;
$('#warehouse').val('<?php echo $_GET["wh"];?>').attr('disabled', true);
$('#warehouse').val('<?php echo $_GET["wh"] ?? "";?>').attr('disabled', true);
function populate_fields() {
$.each(data, function(key, item) {
@@ -336,6 +371,9 @@
$('#product_sku, #quantity').prop('disabled', true);
$('button[type=submit]').text('Update');
$('button[type=reset]').hide();
if (data.status == 0) {
$('#approve_stock_btn').removeClass('d-none');
}
}
if (advanced) {
+49 -5
View File
@@ -99,6 +99,7 @@
<!-- Submit Button -->
<div class="mb-3 col-lg-12">
<button type="submit" class="btn btn-primary" onclick="manage_stock_out();">Remove Stock</button>
<button type="button" class="btn btn-success ms-2 d-none" id="approve_stock_btn" onclick="approve_current_stock();">Approve</button>
</div>
</form>
</div>
@@ -149,8 +150,11 @@
}
function manage_stock_out() {
var rack_val = $('#rack').val();
var sku_val = $('#product_sku').attr('secondary') || $('#product_sku').val();
var wh_val = $('#warehouse').val();
if (!advanced) {
var rack_val = $('#rack').val();
$('#zone').val(rack_val);
$('#aisle').val(rack_val);
}
@@ -161,9 +165,12 @@
checkRequired: 1,
debugMode: 0,
data: {
'contact_id': $('#contact').attr('data-id'),
'zone': $('#zone').val(),
'aisle': $('#aisle').val(),
'contact_id': $('#contact').attr('data-id'),
'zone': advanced ? $('#zone').val() : rack_val,
'aisle': advanced ? $('#aisle').val() : rack_val,
'rack': rack_val,
'product_sku': sku_val,
'warehouse': wh_val,
},
action: 'manage',
onSuccess: function() {
@@ -172,6 +179,30 @@
});
}
function approve_current_stock() {
bootbox.confirm({
message: 'Approve this stock-out record?',
buttons: { confirm: { label: 'Approve', className: 'btn-success' }, cancel: { label: 'Cancel', className: 'btn-secondary' } },
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>ics/api/engine/approve_stock.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: {
id: '<?php echo $_GET["id"] ?? "";?>',
warehouse: '<?php echo $_GET["wh"] ?? "";?>',
type: 'out'
},
onSuccess: function() {
window.location.reload();
}
});
}
});
}
// ── Step 1: Product selected → load active lots ───────────────────────
function retrieve_active_lot() {
@@ -242,7 +273,11 @@
autoPrepare: true,
checkRequired: 0,
noLoading: true,
data: { "type": 'from', "lot_number": $('#lot_number').val() },
data: {
"type": 'from',
"product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(),
"lot_number": $('#lot_number').val()
},
action: 'read',
onSuccess: function(res) {
var option = '<option value="">Select warehouse...</option>';
@@ -264,6 +299,7 @@
checkRequired: 0,
noLoading: true,
data: {
"warehouse": $('#warehouse').val(),
"type": 'from',
"product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(),
"lot_number": $('#lot_number').val(),
@@ -289,6 +325,8 @@
checkRequired: 0,
noLoading: true,
data: {
"warehouse": $('#warehouse').val(),
"zone": $('#zone').val(),
"type": 'from',
"product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(),
"lot_number": $('#lot_number').val(),
@@ -314,6 +352,9 @@
checkRequired: 0,
noLoading: true,
data: {
"warehouse": $('#warehouse').val(),
"zone": $('#zone').val(),
"aisle": $('#aisle').val(),
"type": 'from',
"product_sku": $('#product_sku').attr('secondary') || $('#product_sku').val(),
"lot_number": $('#lot_number').val(),
@@ -379,6 +420,9 @@
$('#product_sku').attr('secondary', data.product_sku).prop('disabled', true);
$('button[type=submit]').text('Update');
$('button[type=reset]').hide();
if (data.status == 0) {
$('#approve_stock_btn').removeClass('d-none');
}
}
if (advanced) {
+87 -19
View File
@@ -131,6 +131,7 @@
<!-- Submit -->
<div class="mb-3 col-lg-12">
<button type="submit" class="btn btn-primary" onclick="manage_stock_transfer();">Transfer Stock</button>
<button type="button" class="btn btn-success ms-2 d-none" id="approve_stock_btn" onclick="approve_current_stock();">Approve</button>
</div>
</form>
</div>
@@ -171,6 +172,7 @@
if (advanced) {
$('#group_zone_from, #group_zone_to').show();
$('#group_aisle_from, #group_aisle_to').show();
$('#zone_from, #zone_to, #aisle_from, #aisle_to').prop('required', true);
$('#label_zone_from').text('From ' + label_zone);
$('#label_zone_to').text('To ' + label_zone);
$('#label_aisle_from').text('From ' + label_aisle);
@@ -184,6 +186,7 @@
} else {
$('#group_zone_from, #group_zone_to').hide();
$('#group_aisle_from, #group_aisle_to').hide();
$('#zone_from, #zone_to, #aisle_from, #aisle_to').prop('required', false).removeClass('is-invalid is-valid');
$('#label_rack_from').text('From ' + label_rack);
$('#label_rack_to').text('To ' + label_rack);
$('#rack_from, #rack_to').find('option:first').text('Please select ' + label_rack.toLowerCase());
@@ -194,10 +197,12 @@
// ── Submit ────────────────────────────────────────────────────────────────
function manage_stock_transfer() {
var rack_from_val = $('#rack_from').val();
var rack_to_val = $('#rack_to').val();
var sku_val = $('#product_sku').attr('secondary') || $('#product_sku').val();
// Simple mode — mirror rack value to zone and aisle before submit
if (!advanced) {
var rack_from_val = $('#rack_from').val();
var rack_to_val = $('#rack_to').val();
$('#zone_from').val(rack_from_val);
$('#aisle_from').val(rack_from_val);
$('#zone_to').val(rack_to_val);
@@ -208,7 +213,18 @@
url: '<?php echo $server_url?>ics/api/engine/manage_stock_transfer.php',
autoPrepare: true,
checkRequired: 1,
data: { 'contact_id': $('#contact').attr('data-id') },
data: {
'contact_id': $('#contact').attr('data-id'),
'warehouse_from': $('#warehouse_from').val(),
'warehouse_to': $('#warehouse_to').val(),
'zone_from': advanced ? $('#zone_from').val() : rack_from_val,
'aisle_from': advanced ? $('#aisle_from').val() : rack_from_val,
'rack_from': rack_from_val,
'zone_to': advanced ? $('#zone_to').val() : rack_to_val,
'aisle_to': advanced ? $('#aisle_to').val() : rack_to_val,
'rack_to': rack_to_val,
'product_sku': sku_val,
},
action: 'manage',
onSuccess: function() {
window.location.href = '<?php echo $server_url?>ics/stock_transfer.php';
@@ -216,6 +232,30 @@
});
}
function approve_current_stock() {
bootbox.confirm({
message: 'Approve this stock-transfer record?',
buttons: { confirm: { label: 'Approve', className: 'btn-success' }, cancel: { label: 'Cancel', className: 'btn-secondary' } },
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>ics/api/engine/approve_stock.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: {
id: '<?php echo $_GET["id"] ?? "";?>',
warehouse: '<?php echo $_GET["wh"] ?? "";?>',
type: 'transfer'
},
onSuccess: function() {
window.location.reload();
}
});
}
});
}
// ── Step 1: Product selected → load active lots + from-warehouses ─────────
function retrieve_active_lot() {
@@ -273,7 +313,11 @@
autoPrepare: true,
checkRequired: 0,
noLoading: true,
data: { 'type': type, 'lot_number': type === 'from' ? $('#lot_number').val() : '' },
data: {
'type': type,
'product_sku': type === 'from' ? ($('#product_sku').attr('secondary') || $('#product_sku').val()) : '',
'lot_number': type === 'from' ? $('#lot_number').val() : ''
},
action: 'read',
onSuccess: function(res) {
var option = '<option value="">Select warehouse...</option>';
@@ -288,13 +332,16 @@
// ── Zone / Aisle / Rack loaders ───────────────────────────────────────────
function retrieve_zone(type, callback) {
var extra = {};
var extra = {
'warehouse_from': $('#warehouse_from').val(),
'warehouse_to': $('#warehouse_to').val()
};
if (type === 'from') {
extra = {
extra = Object.assign(extra, {
'product_sku': $('#product_sku').attr('secondary') || $('#product_sku').val(),
'lot_number': $('#lot_number').val(),
'serial_number': $('#serial_number').val()
};
});
}
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_zone.php',
@@ -315,13 +362,18 @@
}
function retrieve_aisle(type, callback) {
var extra = {};
var extra = {
'warehouse_from': $('#warehouse_from').val(),
'warehouse_to': $('#warehouse_to').val(),
'zone_from': $('#zone_from').val(),
'zone_to': $('#zone_to').val()
};
if (type === 'from') {
extra = {
extra = Object.assign(extra, {
'product_sku': $('#product_sku').attr('secondary') || $('#product_sku').val(),
'lot_number': $('#lot_number').val(),
'serial_number': $('#serial_number').val()
};
});
}
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_aisle.php',
@@ -342,13 +394,20 @@
}
function retrieve_rack(type, callback) {
var extra = {};
var extra = {
'warehouse_from': $('#warehouse_from').val(),
'warehouse_to': $('#warehouse_to').val(),
'zone_from': $('#zone_from').val(),
'zone_to': $('#zone_to').val(),
'aisle_from': $('#aisle_from').val(),
'aisle_to': $('#aisle_to').val()
};
if (type === 'from') {
extra = {
extra = Object.assign(extra, {
'product_sku': $('#product_sku').attr('secondary') || $('#product_sku').val(),
'lot_number': $('#lot_number').val(),
'serial_number': $('#serial_number').val()
};
});
}
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_rack.php',
@@ -400,9 +459,17 @@
$('#serial_number').html('<option value="' + (data.serial_number || '') + '">' + (data.serial_number || '—') + '</option>')
.val(data.serial_number || '').prop('disabled', true);
$('#product_name').val(data.product_name);
$('#product_sku').attr('secondary', data.product_sku).prop('disabled', true);
retrieve_warehouse('from').then(function() {
return retrieve_warehouse('to');
}).then(function() {
$('select[name="warehouse"][role="from"]').val(from_wh).prop('disabled', true);
$('select[name="warehouse"][role="to"]').val(to_wh).prop('disabled', true);
if (advanced) {
// Full cascade for From
$('select[name="warehouse"][role="from"]').val(from_wh).prop('disabled', true);
retrieve_zone('from', function() {
$('select[name="zone"][role="from"]').val(data.zone).prop('disabled', true);
retrieve_aisle('from', function() {
@@ -415,7 +482,6 @@
});
} else {
// Simple mode — skip zone/aisle cascade, load rack directly
$('select[name="warehouse"][role="from"]').val(from_wh).prop('disabled', true);
retrieve_rack('from', function() {
$('select[name="rack"][role="from"]').val(data.rack).prop('disabled', true);
populate_to_side_simple(data, to_wh);
@@ -432,16 +498,17 @@
$('#contact').val(data.contact_name)
.attr('secondary', data.contact_name)
.attr('data-id', data.contact_id);
$('#product_name').val(data.product_name);
$('#product_sku').attr('secondary', data.product_sku).prop('disabled', true);
$('button[type=submit]').text('Update');
$('button[type=reset]').hide();
if (data.status == 0) {
$('#approve_stock_btn').removeClass('d-none');
}
});
}
});
}
function populate_to_side(data, to_wh) {
$('select[name="warehouse"][role="to"]').val(to_wh).prop('disabled', true);
retrieve_zone('to', function() {
$('select[name="zone"][role="to"]').val(data.ref.zone).prop('disabled', true);
retrieve_aisle('to', function() {
@@ -454,7 +521,6 @@
}
function populate_to_side_simple(data, to_wh) {
$('select[name="warehouse"][role="to"]').val(to_wh).prop('disabled', true);
retrieve_rack('to', function() {
$('select[name="rack"][role="to"]').val(data.ref.rack).prop('disabled', true);
});
@@ -465,7 +531,9 @@
$(async function() {
try {
await load_location_config(apply_location_labels);
<?php if(empty($_GET['id'])){ ?>
await retrieve_warehouse('to');
<?php } ?>
await retrieve_for_edit();
} catch(e) {
console.log(e);
+96 -28
View File
@@ -31,10 +31,18 @@
<div class="mb-3 col-lg-2">
<input class="form-control" value='Warehouse' disabled>
</div>
<div class="mb-3 col-lg-4">
<div class="mb-3 col-lg-2">
<select name="warehouse" id="warehouse" class="form-select" required></select>
</div>
<div class="mb-3 col-lg-6">
<div class="mb-3 col-lg-2">
<input class="form-control" value='Source' disabled>
</div>
<div class="mb-3 col-lg-2">
<select name="source" id="source" class="form-select">
<option value="">All Sources</option>
</select>
</div>
<div class="mb-3 col-lg-2">
<button class='btn btn-secondary' onclick='retrieve_stock_in()'>Search</button>
</div>
@@ -50,7 +58,8 @@
<th>Product</th>
<th>Lot Number</th>
<th>Serial Number</th>
<th>Quantity</th>
<th style="text-align: right;">Quantity</th>
<th>Source</th>
<th class="th_loc_zone">Zone</th>
<th class="th_loc_aisle">Aisle</th>
<th class="th_loc_rack">Rack</th>
@@ -103,6 +112,70 @@
return `<td class="py-3">${item['rack'] || ''}</td>`;
}
function escape_html(value) {
return String(value ?? '').replace(/[&<>"']/g, function(char) {
return {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}[char];
});
}
function source_label(source) {
source = source || '';
if (!source) return 'Manual';
return source.charAt(0).toUpperCase() + source.slice(1).replace(/_/g, ' ');
}
function source_cell(source) {
var label = escape_html(source_label(source));
return source
? `<span class="badge bg-info-subtle text-info">${label}</span>`
: `<span class="badge bg-secondary-subtle text-secondary">${label}</span>`;
}
function populate_source_filter(rows) {
var current = $('#source').val() || '';
var sources = {};
$.each(rows, function(_, item) {
sources[item.source || '__manual__'] = item.source || '';
});
var option = `<option value="">All Sources</option>`;
$.each(Object.keys(sources).sort(), function(_, key) {
var value = sources[key];
var option_value = value || '__manual__';
option += `<option value="${escape_html(option_value)}">${escape_html(source_label(value))}</option>`;
});
$('#source').html(option).val(current);
if ($('#source').val() !== current) $('#source').val('');
}
function apply_source_filter() {
var selected = $('#source').val() || '';
var rows = alasql.tables.stock_in_all ? alasql.tables.stock_in_all.data : [];
if (!selected) {
alasql.tables.stock_in.data = rows;
} else {
alasql.tables.stock_in.data = rows.filter(function(item) {
var source = item.source || '';
return selected === '__manual__' ? !source : source === selected;
});
}
var table_id = 'stock_in';
var total_records = alasql.tables.stock_in.data.length;
var page = generate_pagination(table_id, total_records);
$(`table#${table_id} tfoot`).html(page);
change_page_stock_in(1);
}
function retrieve_stock_in() {
return ajax_request({
@@ -114,22 +187,12 @@
onSuccess: function(res) {
// Register the array as a table name
alasql('CREATE TABLE IF NOT EXISTS stock_in_all');
alasql('CREATE TABLE IF NOT EXISTS stock_in');
alasql.tables.stock_in.data = res.output || [];
alasql.tables.stock_in_all.data = res.output || [];
var total_records = alasql.tables.stock_in.data.length;
var table_id = 'stock_in';
var page = generate_pagination(table_id, total_records);
$(`table#${table_id} tfoot`).html(page);
// dynamic function name
if (typeof window[`change_page_${table_id}`] === "function") {
window[`change_page_${table_id}`](1);
} else {
console.error(`Function change_page_${table_id} does not exist.`);
}
populate_source_filter(alasql.tables.stock_in_all.data);
apply_source_filter();
}
});
@@ -153,19 +216,19 @@
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['quantity']}</td>
<td class="py-3">
<div class="d-flex justify-content-end align-items-baseline gap-1">
<span>${format_number(item['quantity'], 2)}</span>
<span class="text-muted small" style="min-width:28px;">${item['uom'] || ''}</span>
</div>
</td>
<td class="py-3">${source_cell(item['source'])}</td>
${location_cells(item)}
<td class="py-3">
${item['status'] == 1 ? '<span class="badge bg-success">Approved</span>' : '<span class="badge bg-warning text-dark">Draft</span>'}
</td>
<td class="py-3">
<a href="<?php echo $server_url?>ics/manage_stock_in.php?id=${item['id']}&wh=${warehouse}" class=""><i class="ti ti-eye fs-5"></i></a>
${item['status'] == 0
? (auto_approve
? '<button class="btn btn-sm btn-outline-secondary ms-2" disabled title="Auto-approved on save — no manual approval needed"><i class=\"ti ti-check\"></i></button>'
: '<button class="btn btn-sm btn-outline-success ms-2" onclick=\"approve_stock('+item['id']+', '+item['warehouse_id']+', \'in\')\" title="Approve"><i class=\"ti ti-check\"></i></button>'
)
: ''}
<a href="javascript:void(0);" class="link-danger"
onclick="delete_stock_in($(this),${item['id']})">
<i class="ti ti-trash ms-2 fs-5"></i>
@@ -226,6 +289,9 @@
}
});
$(document).on('change', '#source', apply_source_filter);
$(document).on('change', '#warehouse', retrieve_stock_in);
// ── Stock approval ────────────────────────────────────────────────────────
var auto_approve = false; // set from company_setting on load
@@ -258,11 +324,13 @@
autoPrepare: true,
checkRequired: 0,
action: 'update',
id: id,
warehouse: warehouse_id,
type: type,
data: {
id: id,
warehouse: warehouse_id,
type: type
},
onSuccess: function(res) {
retrieve_stock_list();
retrieve_stock_in();
}
});
}
+96 -28
View File
@@ -31,10 +31,18 @@
<div class="mb-3 col-lg-2">
<input class="form-control" value='Warehouse' disabled>
</div>
<div class="mb-3 col-lg-4">
<div class="mb-3 col-lg-2">
<select name="warehouse" id="warehouse" class="form-select" required></select>
</div>
<div class="mb-3 col-lg-6">
<div class="mb-3 col-lg-2">
<input class="form-control" value='Source' disabled>
</div>
<div class="mb-3 col-lg-2">
<select name="source" id="source" class="form-select">
<option value="">All Sources</option>
</select>
</div>
<div class="mb-3 col-lg-2">
<button class='btn btn-secondary' onclick='retrieve_stock_out()'>Search</button>
</div>
@@ -50,7 +58,8 @@
<th>Product</th>
<th>Lot Number</th>
<th>Serial Number</th>
<th>Quantity</th>
<th style="text-align: right;">Quantity</th>
<th>Source</th>
<th class="th_loc_zone">Zone</th>
<th class="th_loc_aisle">Aisle</th>
<th class="th_loc_rack">Rack</th>
@@ -103,6 +112,70 @@
return `<td class="py-3">${item['rack'] || ''}</td>`;
}
function escape_html(value) {
return String(value ?? '').replace(/[&<>"']/g, function(char) {
return {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}[char];
});
}
function source_label(source) {
source = source || '';
if (!source) return 'Manual';
return source.charAt(0).toUpperCase() + source.slice(1).replace(/_/g, ' ');
}
function source_cell(source) {
var label = escape_html(source_label(source));
return source
? `<span class="badge bg-info-subtle text-info">${label}</span>`
: `<span class="badge bg-secondary-subtle text-secondary">${label}</span>`;
}
function populate_source_filter(rows) {
var current = $('#source').val() || '';
var sources = {};
$.each(rows, function(_, item) {
sources[item.source || '__manual__'] = item.source || '';
});
var option = `<option value="">All Sources</option>`;
$.each(Object.keys(sources).sort(), function(_, key) {
var value = sources[key];
var option_value = value || '__manual__';
option += `<option value="${escape_html(option_value)}">${escape_html(source_label(value))}</option>`;
});
$('#source').html(option).val(current);
if ($('#source').val() !== current) $('#source').val('');
}
function apply_source_filter() {
var selected = $('#source').val() || '';
var rows = alasql.tables.stock_out_all ? alasql.tables.stock_out_all.data : [];
if (!selected) {
alasql.tables.stock_out.data = rows;
} else {
alasql.tables.stock_out.data = rows.filter(function(item) {
var source = item.source || '';
return selected === '__manual__' ? !source : source === selected;
});
}
var table_id = 'stock_out';
var total_records = alasql.tables.stock_out.data.length;
var page = generate_pagination(table_id, total_records);
$(`table#${table_id} tfoot`).html(page);
change_page_stock_out(1);
}
function retrieve_stock_out() {
return ajax_request({
@@ -114,22 +187,12 @@
onSuccess: function(res) {
// Register the array as a table name
alasql('CREATE TABLE IF NOT EXISTS stock_out_all');
alasql('CREATE TABLE IF NOT EXISTS stock_out');
alasql.tables.stock_out.data = res.output || [];
alasql.tables.stock_out_all.data = res.output || [];
var total_records = alasql.tables.stock_out.data.length;
var table_id = 'stock_out';
var page = generate_pagination(table_id, total_records);
$(`table#${table_id} tfoot`).html(page);
// dynamic function name
if (typeof window[`change_page_${table_id}`] === "function") {
window[`change_page_${table_id}`](1);
} else {
console.error(`Function change_page_${table_id} does not exist.`);
}
populate_source_filter(alasql.tables.stock_out_all.data);
apply_source_filter();
}
});
@@ -153,19 +216,19 @@
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['quantity']}</td>
<td class="py-3">
<div class="d-flex justify-content-end align-items-baseline gap-1">
<span>${format_number(item['quantity'], 2)}</span>
<span class="text-muted small" style="min-width:28px;">${item['uom'] || ''}</span>
</div>
</td>
<td class="py-3">${source_cell(item['source'])}</td>
${location_cells(item)}
<td class="py-3">
${item['status'] == 1 ? '<span class="badge bg-success">Approved</span>' : '<span class="badge bg-warning text-dark">Draft</span>'}
</td>
<td class="py-3">
<a href="<?php echo $server_url?>ics/manage_stock_out.php?id=${item['id']}&wh=${warehouse}" class=""><i class="ti ti-eye fs-5"></i></a>
${item['status'] == 0
? (auto_approve
? '<button class="btn btn-sm btn-outline-secondary ms-2" disabled title="Auto-approved on save — no manual approval needed"><i class=\"ti ti-check\"></i></button>'
: '<button class="btn btn-sm btn-outline-success ms-2" onclick=\"approve_stock('+item['id']+', '+item['warehouse_id']+', \'out\')\" title="Approve"><i class=\"ti ti-check\"></i></button>'
)
: ''}
<a href="javascript:void(0);" class="link-danger"
onclick="delete_stock_out($(this),${item['id']})">
<i class="ti ti-trash ms-2 fs-5"></i>
@@ -226,6 +289,9 @@
}
});
$(document).on('change', '#source', apply_source_filter);
$(document).on('change', '#warehouse', retrieve_stock_out);
// ── Stock approval ────────────────────────────────────────────────────────
var auto_approve = false; // set from company_setting on load
@@ -258,11 +324,13 @@
autoPrepare: true,
checkRequired: 0,
action: 'update',
id: id,
warehouse: warehouse_id,
type: type,
data: {
id: id,
warehouse: warehouse_id,
type: type
},
onSuccess: function(res) {
retrieve_stock_list();
retrieve_stock_out();
}
});
}
+15 -12
View File
@@ -48,7 +48,7 @@
<tr>
<th>Date</th>
<th>Product</th>
<th>Quantity</th>
<th style="text-align: right;">Quantity</th>
<th class="th_loc_zone">Zone</th>
<th class="th_loc_aisle">Aisle</th>
<th class="th_loc_rack">Rack</th>
@@ -149,19 +149,18 @@
body += `<tr>
<td class="py-3">${format_date(item["date"])}</td>
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
<td class="py-3">${item['quantity']}</td>
<td class="py-3">
<div class="d-flex justify-content-end align-items-baseline gap-1">
<span>${format_number(item['quantity'], 2)}</span>
<span class="text-muted small" style="min-width:28px;">${item['uom'] || ''}</span>
</div>
</td>
${location_cells(item)}
<td class="py-3">
${item['status'] == 1 ? '<span class="badge bg-success">Approved</span>' : '<span class="badge bg-warning text-dark">Draft</span>'}
</td>
<td class="py-3">
<a href="<?php echo $server_url?>ics/manage_stock_transfer.php?id=${item['id']}&wh=${warehouse}" class=""><i class="ti ti-eye fs-5"></i></a>
${item['status'] == 0
? (auto_approve
? '<button class="btn btn-sm btn-outline-secondary ms-2" disabled title="Auto-approved on save — no manual approval needed"><i class=\"ti ti-check\"></i></button>'
: '<button class="btn btn-sm btn-outline-success ms-2" onclick=\"approve_stock('+item['id']+', '+item['warehouse_id']+', \'transfer\')\" title="Approve"><i class=\"ti ti-check\"></i></button>'
)
: ''}
<a href="javascript:void(0);" class="link-danger"
onclick="delete_stock_transfer($(this),${item['id']})">
<i class="ti ti-trash ms-2 fs-5"></i>
@@ -224,6 +223,8 @@
}
});
$(document).on('change', '#warehouse', retrieve_stock_transfer);
// ── Stock approval ────────────────────────────────────────────────────────
var auto_approve = false; // set from company_setting on load
@@ -256,11 +257,13 @@
autoPrepare: true,
checkRequired: 0,
action: 'update',
id: id,
warehouse: warehouse_id,
type: type,
data: {
id: id,
warehouse: warehouse_id,
type: type
},
onSuccess: function(res) {
retrieve_stock_list();
retrieve_stock_transfer();
}
});
}
+9 -2
View File
@@ -75,7 +75,7 @@
<li>
<a class="nav-link" href="<?php echo $server_url?>order/order.php">
<i class="ti ti-shopping-cart"></i>
<span class="nav-text">Orders</span>
<span class="nav-text">Sales Orders</span>
</a>
</li>
@@ -89,7 +89,14 @@
<li>
<a class="nav-link" href="<?php echo $server_url?>order/invoice.php">
<i class="ti ti-file-invoice"></i>
<span class="nav-text">Invoices</span>
<span class="nav-text">Sales Invoices</span>
</a>
</li>
<li>
<a class="nav-link" href="<?php echo $server_url?>po/po.php">
<i class="ti ti-clipboard-list"></i>
<span class="nav-text">Purchase Orders</span>
</a>
</li>
+2 -2
View File
@@ -15,9 +15,9 @@
exit(json_encode($answer));
}
if ($min_stock >= $reorder_point) {
if ($min_stock > $reorder_point) {
$answer['success'] = 0;
$answer['message'] = "Min stock ({$min_stock}) must be less than reorder point ({$reorder_point}).";
$answer['message'] = "Min stock ({$min_stock}) must be less than or equal to reorder point ({$reorder_point}).";
http_response_code(400);
exit(json_encode($answer));
}
+6 -2
View File
@@ -2,11 +2,15 @@
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/WarehouseManager.php';
require '../../../assets/utils/classes/CompanySettingManager.php';
try {
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging) {
$setting = new CompanySettingManager($pdo1, $company_id, $pdo2);
$advanced_location = (int)$setting->get('advanced_location') === 1;
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $advanced_location) {
$wh = new WarehouseManager($pdo, $company_id);
$wh->saveStorage($data, $logging);
$wh->saveStorage($data, $logging, $advanced_location);
});
$answer['success'] = 1;
+16 -4
View File
@@ -4,17 +4,29 @@
require '../../../assets/utils/classes/WarehouseManager.php';
try {
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging) {
$newWarehouseId = null;
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, &$newWarehouseId) {
$wh = new WarehouseManager($pdo, $company_id);
$wh->saveWarehouse($data, $logging);
$newWarehouseId = $wh->saveWarehouse($data, $logging);
});
if (!empty($newWarehouseId)) {
$wh = new WarehouseManager($pdo2, $company_id);
$wh->createStockTableForWarehouse((int)$newWarehouseId);
}
$answer['success'] = 1;
} catch (PDOException $e) {
$answer['success'] = 0;
$answer['message'] = 'Database error, please try again';
http_response_code(500);
if ($e->getCode() === '23000' && strpos($e->getMessage(), 'company_id_warehouse_name') !== false) {
$answer['message'] = 'Warehouse name already exists. Please use a different warehouse name.';
http_response_code(400);
} else {
$answer['message'] = 'Database error, please try again';
http_response_code(500);
}
} catch (Exception $e) {
$answer['success'] = 0;
+24 -4
View File
@@ -39,10 +39,29 @@
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<div class="col-md-4 mb-3">
<label for="price" class="form-label">Price</label>
<input type="number" class="form-control" id="price" placeholder="0.00" step="0.01" required>
</div>
<div class="col-md-2 mb-3">
<label for="uom" class="form-label">UOM</label>
<input type="text" class="form-control" id="uom" placeholder="pcs"
list="uom_suggestions" autocomplete="off" maxlength="20">
<datalist id="uom_suggestions">
<option value="pcs">
<option value="box">
<option value="kg">
<option value="g">
<option value="l">
<option value="ml">
<option value="set">
<option value="roll">
<option value="pair">
<option value="sheet">
<option value="bag">
<option value="bottle">
</datalist>
</div>
<div class="col-md-3 mb-3">
<label for="min_stock" class="form-label">Min Stock</label>
<input type="number" class="form-control" id="min_stock" placeholder="0" min="0" required>
@@ -53,7 +72,7 @@
<label for="reorder_point" class="form-label">Reorder Point</label>
<input type="number" class="form-control" id="reorder_point" placeholder="0" min="0" required>
<div class="invalid-feedback" id="reorder_point_error"></div>
<small class="text-muted" id="reorder_point_hint">Must be greater than min stock</small>
<small class="text-muted" id="reorder_point_hint">Must be greater than or equal to min stock</small>
</div>
</div>
@@ -119,6 +138,7 @@
}
formData.append('keep_files', $('#current_file_label').val())
formData.append('uom', $('#uom').val() || 'pcs')
return ajax_request({
url: "<?php echo $server_url?>inventory/api/engine/manage_product.php",
@@ -236,8 +256,8 @@
return false;
}
if (min_stock >= reorder_point) {
flag('reorder_point', 'Must be greater than min stock (' + min_stock + ').');
if (min_stock > reorder_point) {
flag('reorder_point', 'Must be greater than or equal to min stock (' + min_stock + ').');
return false;
}
+10
View File
@@ -172,6 +172,16 @@
var isNumeric = function(v) { return /^[0-9]+$/.test(v); };
var isAlpha = function(v) { return /^[A-Z]$/.test(v); };
if (!zone) {
alert('Please enter a ' + label_zone + ' value.'); return;
}
if (!aisle_from || !aisle_to) {
alert('Please enter the full ' + label_aisle + ' range.'); return;
}
if (!rack_from || !rack_to) {
alert('Please enter the full ' + label_rack + ' range.'); return;
}
if (aisle_from && aisle_to) {
if (isNumeric(aisle_from) !== isNumeric(aisle_to)) {
alert(label_aisle + ' From and To must both be numbers or both be letters.'); return;
+12 -3
View File
@@ -197,6 +197,7 @@
<th width="75px"></th>
<th>Product</th>
<th>SKU</th>
<th>UOM</th>
<th style="text-align: right;">Current Stock</th>
<th style="text-align: right;">Min Stock</th>
<th style="text-align: right;">Reorder Point</th>
@@ -344,7 +345,7 @@
if (filtered.length === 0) {
$("table#product > tbody").html(
'<tr><td colspan="8" class="text-center py-5 text-muted">No products match your search.</td></tr>'
'<tr><td colspan="9" class="text-center py-5 text-muted">No products match your search.</td></tr>'
);
return;
}
@@ -355,11 +356,19 @@
$.each(page_data, function(key, item) {
body += `<tr height="50px">
<td class="py-3">
<img src="<?php echo $server_url?>uploads/product/${item['product_image']}" alt="${item['product_name']}" class="img-fluid" style="height: 25px;">
${item['product_image']
? `<img src="<?php echo $server_url?>uploads/product/${item['product_image']}" alt="${item['product_name']}" style="width:40px;height:40px;object-fit:cover;border-radius:4px;">`
: `<div style="width:40px;height:40px;display:flex;align-items:center;justify-content:center;" class="rounded-2 bg-light"><i class="ti ti-photo fs-5 text-muted"></i></div>`}
</td>
<td class="py-3">${item['product_name']}</td>
<td class="py-3">${item['sku']}</td>
<td class="py-3" style="text-align: right;">${format_number(item['product_balance'], 4)}</td>
<td class="py-3"><span class="badge bg-light text-secondary border">${item['uom'] || 'pcs'}</span></td>
<td class="py-3">
<div class="d-flex justify-content-end align-items-baseline gap-1">
<span>${format_number(item['product_balance'], 4)}</span>
<span class="text-muted small" style="min-width:28px;">${item['uom'] || 'pcs'}</span>
</div>
</td>
<td class="py-3" style="text-align: right;">${format_number(item['min_stock'], 2)}</td>
<td class="py-3" style="text-align: right;">${format_number(item['reorder_point'], 2)}</td>
<td class="py-3">
+1
View File
@@ -2,6 +2,7 @@
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/OrderManager.php';
require '../../../assets/utils/classes/WarehouseManager.php';
$id = (int)($data['id'] ?? 0);
+35 -4
View File
@@ -54,11 +54,42 @@
$already_returned = $returned_qty[$i] ?? 0;
$remaining = (float)$item['quantity'] - $already_returned;
if ($remaining > 0) {
$stock_out_warehouse_id = (int)($item['warehouse_id'] ?? 0);
$stock_out_id = (int)($item['stock_out_id'] ?? 0);
if ($stock_out_warehouse_id > 0 && $stock_out_id > 0) {
$table = 'td_stock_' . $stock_out_warehouse_id;
$sth = $pdo2->prepare(
"SELECT id, status, zone, aisle, rack, lot_number, serial_number
FROM `{$table}`
WHERE company_id = :company_id
AND id = :id
AND type = 'out'
LIMIT 1"
);
$sth->execute([
':company_id' => $company_id,
':id' => $stock_out_id,
]);
$stock_out = $sth->fetch(PDO::FETCH_ASSOC);
if ($stock_out) {
$item['zone'] = $stock_out['zone'] ?? ($item['zone'] ?? '');
$item['aisle'] = $stock_out['aisle'] ?? ($item['aisle'] ?? '');
$item['rack'] = $stock_out['rack'] ?? ($item['rack'] ?? '');
$item['lot_number'] = $stock_out['lot_number'] ?? ($item['lot_number'] ?? '');
$item['serial_number'] = $stock_out['serial_number'] ?? ($item['serial_number'] ?? '');
$item['stock_out_status'] = (int)$stock_out['status'];
}
}
$returnable[] = array_merge($item, [
'item_id' => $i,
'original_qty' => (float)$item['quantity'],
'returned_qty' => $already_returned,
'returnable_qty' => $remaining,
'item_id' => $i,
'original_qty' => (float)$item['quantity'],
'returned_qty' => $already_returned,
'returnable_qty' => $remaining,
'stock_out_warehouse_id' => $stock_out_warehouse_id,
'stock_out_status' => (int)($item['stock_out_status'] ?? 0),
]);
}
}
@@ -11,9 +11,8 @@
exit(json_encode($answer));
}
// Validate payment_status range
// 0=unpaid 1=paid 2=partial 3=refunded
if (!in_array($payment_status, [0, 1, 2, 3], true)) {
// Validate payment_status range: 0=unpaid 1=paid 2=partial
if (!in_array($payment_status, [0, 1, 2], true)) {
$answer['message'] = 'Invalid payment status.';
http_response_code(400);
exit(json_encode($answer));
+11
View File
@@ -123,6 +123,11 @@
<i class="ti ti-send me-1"></i>Issue Invoice
</button>
<button class="btn btn-outline-secondary w-100" id="btn_print"
onclick="print_invoice()">
<i class="ti ti-printer me-1"></i>Print / Save PDF
</button>
<!-- Status selector (issued and above) -->
<div class="" id="invoice_status_group">
<label class="form-label small text-muted mb-1">Invoice Status</label>
@@ -345,6 +350,12 @@
}
function print_invoice() {
if (!invoice_id) return;
window.open('<?php echo $server_url?>order/print_invoice.php?id=' + invoice_id, '_blank');
}
$(function() {
if (invoice_id) {
retrieve_invoice();
+1 -4
View File
@@ -202,7 +202,6 @@
<option value="0">Unpaid</option>
<option value="1">Paid</option>
<option value="2">Partial</option>
<option value="3">Refunded</option>
</select>
<button class="btn btn-sm btn-outline-primary text-nowrap"
onclick="update_payment_status()">
@@ -259,7 +258,6 @@
'0': '<span class="badge bg-warning text-dark">Unpaid</span>',
'1': '<span class="badge bg-success">Paid</span>',
'2': '<span class="badge bg-info text-white">Partial</span>',
'3': '<span class="badge bg-secondary">Refunded</span>',
};
return map[String(status)] ?? '—';
}
@@ -429,10 +427,9 @@
: '<i class="ti ti-arrow-back-up me-1"></i>Create Return');
// Proceed to Invoice
var can_invoice = status === 1 && !has_active_invoice;
var can_invoice = status >= 1 && !is_cancelled && !has_active_invoice;
var invoice_title = is_cancelled ? 'Order is cancelled'
: status < 1 ? 'Confirm the order before creating an invoice'
: status > 1 ? 'Invoice can only be created for confirmed orders'
: has_active_invoice ? 'An active invoice already exists for this order' : '';
set_btn_state('#btn_invoice', can_invoice, invoice_title);
$('#btn_invoice').html(has_active_invoice
+355 -45
View File
@@ -94,6 +94,10 @@
<th>Product</th>
<th class="text-end" style="width:100px;">Order Qty</th>
<th class="text-end" style="width:100px;">Returned</th>
<th class="th_return_wh" style="width:150px;">Warehouse</th>
<th class="th_return_zone" style="width:120px;">Zone</th>
<th class="th_return_aisle" style="width:120px;">Aisle</th>
<th class="th_return_rack" style="width:120px;">Location</th>
<th class="text-end" style="width:110px;">Return Qty</th>
<th class="text-end" style="width:120px;">Unit Price</th>
<th class="text-end" style="width:120px;">Total</th>
@@ -101,7 +105,7 @@
</thead>
<tbody id="item_tbody">
<tr id="loading_row">
<td colspan="7" class="text-center text-muted py-4">
<td colspan="11" class="text-center text-muted py-4">
<i class="ti ti-loader me-1"></i>Loading items...
</td>
</tr>
@@ -172,6 +176,11 @@
var return_data = null;
var order_data = null;
var returnable = []; // returnable_items from order
var warehouses = [];
var advanced = false;
var label_rack = 'Location';
var label_zone = 'Zone';
var label_aisle = 'Aisle';
// ── Status badge ──────────────────────────────────────────────────────────
@@ -201,6 +210,194 @@
$('#summary_refund').text(format_number(total, 2));
}
function escape_html(value) {
return String(value ?? '').replace(/[&<>"']/g, function(char) {
return {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}[char];
});
}
function warehouse_options(selected) {
var opts = '<option value="">Select warehouse...</option>';
warehouses.forEach(function(w) {
opts += `<option value="${w.id}" ${String(selected || '') === String(w.id) ? 'selected' : ''}>${escape_html(w.warehouse_name)}</option>`;
});
return opts;
}
function apply_location_headers() {
$('.th_return_wh').text('Warehouse');
$('.th_return_zone').text(label_zone).toggleClass('d-none', !advanced);
$('.th_return_aisle').text(label_aisle).toggleClass('d-none', !advanced);
$('.th_return_rack').text(label_rack);
$('#loading_row td').attr('colspan', 11);
}
function retrieve_return_zones(item_id, selected_zone, selected_aisle, selected_rack) {
var $row = $(`#ret_item_${item_id}`);
var wh = $row.find('.item_return_warehouse').val();
$row.find('.item_return_zone').html(`<option value="">Select ${label_zone.toLowerCase()}...</option>`);
$row.find('.item_return_aisle').html(`<option value="">Select ${label_aisle.toLowerCase()}...</option>`);
$row.find('.item_return_rack').html(`<option value="">Select ${label_rack.toLowerCase()}...</option>`);
if (!wh) return Promise.resolve();
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_zone.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: { type: 'to', warehouse: wh, id: 0 },
onSuccess: function(res) {
var opts = `<option value="">Select ${label_zone.toLowerCase()}...</option>`;
var has_selected = !selected_zone;
$.each(res.output || [], function(_, item) {
if (String(item.zone) === String(selected_zone)) has_selected = true;
opts += `<option value="${escape_html(item.zone)}">${escape_html(item.zone)}</option>`;
});
if (!has_selected) {
opts += `<option value="${escape_html(selected_zone)}">${escape_html(selected_zone)}</option>`;
}
$row.find('.item_return_zone').html(opts).val(selected_zone || '');
if (selected_zone) retrieve_return_aisles(item_id, selected_aisle, selected_rack);
}
});
}
function change_return_warehouse(item_id) {
var $row = $(`#ret_item_${item_id}`);
$row.find('.item_return_zone').val('');
$row.find('.item_return_aisle').val('');
$row.find('.item_return_rack').val('');
if (!$row.find('.item_check').is(':checked')) return;
if (advanced) {
retrieve_return_zones(item_id);
} else {
retrieve_return_racks(item_id);
}
}
function change_return_zone(item_id) {
var $row = $(`#ret_item_${item_id}`);
$row.find('.item_return_aisle').val('');
$row.find('.item_return_rack').val('');
if ($row.find('.item_check').is(':checked')) retrieve_return_aisles(item_id);
}
function change_return_aisle(item_id) {
var $row = $(`#ret_item_${item_id}`);
$row.find('.item_return_rack').val('');
if ($row.find('.item_check').is(':checked')) retrieve_return_racks(item_id);
}
function retrieve_return_aisles(item_id, selected_aisle, selected_rack) {
var $row = $(`#ret_item_${item_id}`);
var wh = $row.find('.item_return_warehouse').val();
var zone = $row.find('.item_return_zone').val();
$row.find('.item_return_aisle').html(`<option value="">Select ${label_aisle.toLowerCase()}...</option>`);
$row.find('.item_return_rack').html(`<option value="">Select ${label_rack.toLowerCase()}...</option>`);
if (!wh || !zone) return Promise.resolve();
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_aisle.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: { type: 'to', warehouse: wh, zone: zone, id: 0 },
onSuccess: function(res) {
var opts = `<option value="">Select ${label_aisle.toLowerCase()}...</option>`;
var has_selected = !selected_aisle;
$.each(res.output || [], function(_, value) {
if (String(value) === String(selected_aisle)) has_selected = true;
opts += `<option value="${escape_html(value)}">${escape_html(value)}</option>`;
});
if (!has_selected) {
opts += `<option value="${escape_html(selected_aisle)}">${escape_html(selected_aisle)}</option>`;
}
$row.find('.item_return_aisle').html(opts).val(selected_aisle || '');
if (selected_aisle) retrieve_return_racks(item_id, selected_rack);
}
});
}
function retrieve_return_racks(item_id, selected_rack) {
var $row = $(`#ret_item_${item_id}`);
var wh = $row.find('.item_return_warehouse').val();
var zone = advanced ? $row.find('.item_return_zone').val() : '';
var aisle = advanced ? $row.find('.item_return_aisle').val() : '';
$row.find('.item_return_rack').html(`<option value="">Select ${label_rack.toLowerCase()}...</option>`);
if (!wh || (advanced && (!zone || !aisle))) return Promise.resolve();
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_rack.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: { type: 'to', warehouse: wh, zone: zone, aisle: aisle, id: 0 },
onSuccess: function(res) {
var opts = `<option value="">Select ${label_rack.toLowerCase()}...</option>`;
var has_selected = !selected_rack;
$.each(res.output || [], function(_, value) {
if (String(value) === String(selected_rack)) has_selected = true;
opts += `<option value="${escape_html(value)}">${escape_html(value)}</option>`;
});
if (!has_selected) {
opts += `<option value="${escape_html(selected_rack)}">${escape_html(selected_rack)}</option>`;
}
$row.find('.item_return_rack').html(opts).val(selected_rack || '');
}
});
}
function location_cells(item_id, selected) {
var wh = selected.warehouse_id || '';
var zone = selected.zone || '';
var aisle = selected.aisle || '';
var rack = selected.rack || '';
if (advanced) {
return `<td><select class="form-select form-select-sm item_return_warehouse" onchange="change_return_warehouse(${item_id})">${warehouse_options(wh)}</select></td>
<td><select class="form-select form-select-sm item_return_zone" onchange="change_return_zone(${item_id})"><option value="">Select ${label_zone.toLowerCase()}...</option></select></td>
<td><select class="form-select form-select-sm item_return_aisle" onchange="change_return_aisle(${item_id})"><option value="">Select ${label_aisle.toLowerCase()}...</option></select></td>
<td><select class="form-select form-select-sm item_return_rack"><option value="">Select ${label_rack.toLowerCase()}...</option></select></td>`;
}
return `<td><select class="form-select form-select-sm item_return_warehouse" onchange="change_return_warehouse(${item_id})">${warehouse_options(wh)}</select></td>
<td class="d-none"><select class="form-select form-select-sm item_return_zone"><option value=""></option></select></td>
<td class="d-none"><select class="form-select form-select-sm item_return_aisle"><option value=""></option></select></td>
<td><select class="form-select form-select-sm item_return_rack"><option value="">Select ${label_rack.toLowerCase()}...</option></select></td>`;
}
function hydrate_location_selectors(items, selected_map) {
items.forEach(function(item) {
var selected = selected_map[item.item_id] || {};
var $row = $(`#ret_item_${item.item_id}`);
if (!$row.find('.item_check').is(':checked')) return;
if (!selected.warehouse_id && !selected.zone && !selected.aisle && !selected.rack) {
change_return_warehouse(item.item_id);
return;
}
if (advanced) {
retrieve_return_zones(item.item_id, selected.zone || '', selected.aisle || '', selected.rack || '');
} else if (selected.rack) {
retrieve_return_racks(item.item_id, selected.rack || '');
}
});
}
// ── Render returnable items ───────────────────────────────────────────────
function render_returnable_items(items, selected_items) {
@@ -213,9 +410,36 @@
});
}
if (selected_items && selected_items.length) {
var item_map = {};
items.forEach(function(item) {
item_map[item.item_id] = item;
});
selected_items.forEach(function(si) {
if (item_map[si.item_id]) return;
var base = (order_data.items || [])[si.item_id] || {};
var restored = Object.assign({}, base, si, {
item_id: si.item_id,
original_qty: base.quantity || si.quantity || 0,
returned_qty: si.quantity || 0,
returnable_qty: si.quantity || 0,
price: si.price || base.price || 0,
product_sku: si.product_sku || base.product_sku || '',
product_name: si.product_name || base.product_name || '',
stock_out_warehouse_id: si.stock_out_warehouse_id || base.stock_out_warehouse_id || base.warehouse_id || si.warehouse_id || 0,
stock_out_status: si.stock_out_status || base.stock_out_status || 1,
});
items.push(restored);
item_map[si.item_id] = restored;
});
}
if (!items.length) {
$('#item_tbody').html(
'<tr><td colspan="7" class="text-center text-muted py-4">No returnable items for this order.</td></tr>'
'<tr><td colspan="11" class="text-center text-muted py-4">No returnable items for this order.</td></tr>'
);
return;
}
@@ -224,13 +448,23 @@
items.forEach(function(item) {
var si = selected_map[item.item_id] || null;
var checked = si ? 'checked' : '';
var received = return_data && parseInt(return_data.status) === 1 && si;
var qty = si ? si.quantity : item.returnable_qty;
var price = item.price || 0;
var destination = {
warehouse_id: si ? (si.warehouse_id || item.warehouse_id || '') : (item.warehouse_id || ''),
zone: si ? (si.zone || '') : (item.zone || ''),
aisle: si ? (si.aisle || '') : (item.aisle || ''),
rack: si ? (si.rack || '') : (item.rack || ''),
};
rows += `<tr class="return-item-row" id="ret_item_${item.item_id}">
<td>
<input type="checkbox" class="form-check-input item_check"
onchange="toggle_item_row(${item.item_id}); recalc_totals();" ${checked}>
${received
? `<span class="text-success" title="Received"><i class="ti ti-circle-check-filled"></i></span>
<input type="checkbox" class="d-none item_check" checked>`
: `<input type="checkbox" class="form-check-input item_check"
onchange="toggle_item_row(${item.item_id}); recalc_totals();" ${checked}>`}
<input type="hidden" class="item_id" value="${item.item_id}">
<input type="hidden" class="item_sku" value="${item.product_sku}">
<input type="hidden" class="item_name" value="${item.product_name || ''}">
@@ -241,6 +475,8 @@
<input type="hidden" class="item_lot" value="${item.lot_number || ''}">
<input type="hidden" class="item_serial" value="${item.serial_number || ''}">
<input type="hidden" class="item_stock_out_id" value="${item.stock_out_id || 0}">
<input type="hidden" class="item_stock_out_wh" value="${item.stock_out_warehouse_id || item.warehouse_id || 0}">
<input type="hidden" class="item_stock_out_status" value="${item.stock_out_status || 0}">
</td>
<td>
<div class="fw-semibold">${item.product_sku}</div>
@@ -248,6 +484,7 @@
</td>
<td class="text-end">${format_number(item.original_qty, 2)}</td>
<td class="text-end text-muted">${item.returned_qty > 0 ? format_number(item.returned_qty, 2) : '—'}</td>
${location_cells(item.item_id, destination)}
<td>
<input type="number" class="form-control form-control-sm item_qty text-end"
value="${format_number(qty, 2)}"
@@ -267,14 +504,19 @@
});
$('#item_tbody').html(rows);
hydrate_location_selectors(items, selected_map);
recalc_totals();
}
function toggle_item_row(item_id) {
var $row = $(`#ret_item_${item_id}`);
var checked = $row.find('.item_check').is(':checked');
$row.find('.item_qty, .item_price').prop('disabled', !checked);
if (!checked) $row.find('.item_total').text('—');
$row.find('.item_qty, .item_price, .item_return_warehouse, .item_return_zone, .item_return_aisle, .item_return_rack').prop('disabled', !checked);
if (!checked) {
$row.find('.item_total').text('—');
return;
}
change_return_warehouse(item_id);
}
@@ -311,6 +553,8 @@
url: '<?php echo $server_url?>order/api/engine/retrieve_invoice.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: { order_id: oid, doc_type: 'invoice' },
onSuccess: function(res) {
@@ -328,6 +572,35 @@
});
}
function load_company_setting() {
return ajax_request({
url: server_url + 'setting/api/engine/company_setting.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var s = res.output || {};
advanced = s.advanced_location == 1;
label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location');
label_zone = s.location_label_zone || 'Zone';
label_aisle = s.location_label_aisle || 'Aisle';
apply_location_headers();
}
});
}
function retrieve_warehouses() {
return ajax_request({
url: '<?php echo $server_url?>inventory/api/engine/warehouse.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
warehouses = res.output || [];
}
});
}
// ── Collect selected items ────────────────────────────────────────────────
function collect_items() {
@@ -339,18 +612,28 @@
var qty = parseFloat($row.find('.item_qty').val()) || 0;
var price = parseFloat($row.find('.item_price').val()) || 0;
if (qty <= 0) return;
var return_wh = parseInt($row.find('.item_return_warehouse').val()) || 0;
var return_rack = $row.find('.item_return_rack').val() || '';
var return_zone = advanced ? ($row.find('.item_return_zone').val() || '') : return_rack;
var return_aisle = advanced ? ($row.find('.item_return_aisle').val() || '') : return_rack;
if (!return_wh || !return_rack || (advanced && (!return_zone || !return_aisle))) {
throw new Error('Please select return warehouse and ' + label_rack.toLowerCase() + ' for every checked item.');
}
items.push({
item_id: parseInt($row.find('.item_id').val()),
product_sku: $row.find('.item_sku').val(),
product_name: $row.find('.item_name').val(),
warehouse_id: parseInt($row.find('.item_warehouse_id').val()) || 0,
zone: $row.find('.item_zone').val(),
aisle: $row.find('.item_aisle').val(),
rack: $row.find('.item_rack').val(),
warehouse_id: return_wh,
zone: return_zone,
aisle: return_aisle,
rack: return_rack,
lot_number: $row.find('.item_lot').val(),
serial_number: $row.find('.item_serial').val(),
stock_out_id: parseInt($row.find('.item_stock_out_id').val()) || 0,
stock_out_warehouse_id: parseInt($row.find('.item_stock_out_wh').val()) || 0,
stock_out_status: parseInt($row.find('.item_stock_out_status').val()) || 0,
quantity: qty,
price: price,
total_price: qty * price,
@@ -359,6 +642,46 @@
return items;
}
function build_return_payload() {
var items;
try {
items = collect_items();
} catch (e) {
bootbox.alert(e.message || String(e));
return null;
}
if (!items.length) {
bootbox.alert('Please select at least one item to return.');
return null;
}
return {
id: return_id,
order_id: $('#order_id').val() || 0,
invoice_id: $('#invoice_id').val() || 0,
contact_id: $('#contact_id').val() || 0,
return_date: $('#return_date').val().split('/').reverse().join('-'),
reason: $('#reason').val(),
items: JSON.stringify(items),
notes: '',
};
}
function persist_return(onSuccess) {
var payload = build_return_payload();
if (!payload) return false;
return ajax_request({
url: '<?php echo $server_url?>order/api/engine/manage_return.php',
autoPrepare: true,
checkRequired: 0,
action: return_id ? 'update' : 'create',
data: payload,
onSuccess: onSuccess
});
}
// ── Mode ──────────────────────────────────────────────────────────────────
function set_mode(status) {
@@ -369,13 +692,17 @@
$('.item_check').prop('disabled', !is_draft);
$('#item_tbody tr.return-item-row').each(function() {
var checked = $(this).find('.item_check').is(':checked');
$(this).find('.item_qty, .item_price').prop('disabled', !is_draft || !checked);
$(this).find('.item_qty, .item_price, .item_return_warehouse, .item_return_zone, .item_return_aisle, .item_return_rack')
.prop('disabled', !is_draft || !checked);
});
set_btn_state('#btn_save', is_draft,
status === -1 ? 'Return is cancelled' : 'Return has already been confirmed');
set_btn_state('#btn_confirm', is_draft,
status === -1 ? 'Return is cancelled' : 'Return is already confirmed');
!return_id ? 'Save the return before confirming' : (status === -1 ? 'Return is cancelled' : 'Return is already confirmed'));
if (!return_id) {
$('#btn_confirm').prop('disabled', true);
}
set_btn_state('#btn_cancel', is_draft || status === 1,
status === -1 ? 'Return is already cancelled' : 'Return is completed and cannot be cancelled');
}
@@ -413,32 +740,11 @@
// ── Save draft ────────────────────────────────────────────────────────────
function save_return() {
var items = collect_items();
if (!items.length) {
bootbox.alert('Please select at least one item to return.');
return;
}
ajax_request({
url: '<?php echo $server_url?>order/api/engine/manage_return.php',
autoPrepare: true,
checkRequired: 0,
action: return_id ? 'update' : 'create',
data: {
id: return_id,
order_id: $('#order_id').val() || 0,
invoice_id: $('#invoice_id').val() || 0,
contact_id: $('#contact_id').val() || 0,
return_date: $('#return_date').val().split('/').reverse().join('-'),
reason: $('#reason').val(),
items: JSON.stringify(items),
notes: '',
},
onSuccess: function(res) {
if (!return_id && res.new_id) {
window.location.href = '<?php echo $server_url?>order/manage_return.php?id=' + res.new_id;
} else {
retrieve_return();
}
persist_return(function(res) {
if (!return_id && res.new_id) {
window.location.href = '<?php echo $server_url?>order/manage_return.php?id=' + res.new_id;
} else {
retrieve_return();
}
});
}
@@ -454,13 +760,15 @@
},
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>order/api/engine/confirm_return.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: { id: return_id },
onSuccess: function() { retrieve_return(); }
persist_return(function() {
ajax_request({
url: '<?php echo $server_url?>order/api/engine/confirm_return.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: { id: return_id },
onSuccess: function() { retrieve_return(); }
});
});
}
});
@@ -494,6 +802,8 @@
$(async function() {
try {
flatpickr('#return_date', { dateFormat: 'd/m/Y', allowInput: true });
await load_company_setting();
await retrieve_warehouses();
if (return_id) {
// Edit/view mode — load existing return
-2
View File
@@ -117,7 +117,6 @@
<option value="0">Unpaid</option>
<option value="1">Paid</option>
<option value="2">Partial</option>
<option value="3">Refunded</option>
</select>
</div>
<div class="col-lg-2">
@@ -185,7 +184,6 @@
'0': '<span class="badge bg-warning text-dark">Unpaid</span>',
'1': '<span class="badge bg-success">Paid</span>',
'2': '<span class="badge bg-info text-white">Partial</span>',
'3': '<span class="badge bg-secondary">Refunded</span>',
};
return map[status] ?? '<span class="badge bg-light text-dark">—</span>';
}
+523
View File
@@ -0,0 +1,523 @@
<?php
session_start();
require '../config.php';
require '../dbconn.php';
require '../assets/utils/db_helpers.php';
require '../assets/utils/classes/InvoiceManager.php';
// ── Session guard ─────────────────────────────────────────────────────────
if (empty($_SESSION['login_company_id'])) {
header('Location: ' . $server_url . 'login/index.php');
exit;
}
$company_id = (int)$_SESSION['login_company_id'];
$invoice_id = (int)($_GET['id'] ?? 0);
if (!$invoice_id) {
http_response_code(400);
exit('Invalid invoice ID.');
}
// ── Load invoice ──────────────────────────────────────────────────────────
$inv_mgr = new InvoiceManager($pdo2, $company_id);
$inv = $inv_mgr->getInvoiceById($invoice_id);
if (!$inv) {
http_response_code(404);
exit('Invoice not found.');
}
// ── Load company profile ──────────────────────────────────────────────────
$sth = $pdo1->prepare(
"SELECT company_name, company_name2, company_logo, address, address2,
tax_id, phone, email, fax, branch, branch_no
FROM company_list
WHERE company_id = :company_id
LIMIT 1"
);
$sth->execute([':company_id' => $company_id]);
$co = $sth->fetch(PDO::FETCH_ASSOC) ?: [];
// ── Helpers ───────────────────────────────────────────────────────────────
function h(mixed $v): string {
return htmlspecialchars((string)($v ?? ''), ENT_QUOTES, 'UTF-8');
}
function fmt(mixed $v, int $dp = 2): string {
return number_format((float)$v, $dp);
}
function fmt_date(string $d): string {
if (!$d) return '—';
$ts = strtotime($d);
return $ts ? date('d/m/Y', $ts) : h($d);
}
$items = $inv['items'] ?? [];
$logo_url = !empty($co['company_logo'])
? $server_url . 'uploads/company/' . $co['company_logo']
: '';
$doc_labels = [
'invoice' => 'INVOICE',
'credit_note' => 'CREDIT NOTE',
'debit_note' => 'DEBIT NOTE',
];
$doc_label = $doc_labels[$inv['doc_type'] ?? 'invoice'] ?? 'INVOICE';
$status_labels = [
'0' => 'Draft',
'1' => 'Issued',
'2' => 'Paid',
'3' => 'Overdue',
'4' => 'Void',
];
$status_label = $status_labels[strval($inv['status'] ?? 0)] ?? '';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($doc_label) ?> — <?= h($inv['invoice_number']) ?></title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 13px;
color: #1a1a1a;
background: #f0f0f0;
}
/* ── Print action bar ── */
#print_bar {
position: fixed;
top: 0; left: 0; right: 0;
background: #1e293b;
color: #fff;
padding: 10px 24px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 100;
gap: 12px;
}
#print_bar span { font-size: 13px; opacity: 0.75; }
.btn-print {
background: #3b82f6;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.btn-print:hover { background: #2563eb; }
.btn-back {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
border-radius: 6px;
padding: 7px 14px;
font-size: 13px;
cursor: pointer;
text-decoration: none;
}
.btn-back:hover { color: #fff; border-color: #64748b; }
/* ── Page wrapper ── */
.page-wrap {
padding: 52px 0 32px;
display: flex;
justify-content: center;
}
/* ── A4 sheet ── */
.sheet {
background: #fff;
width: 794px; /* A4 at 96dpi */
min-height: 1123px;
padding: 48px 52px;
box-shadow: 0 4px 24px rgba(0,0,0,0.12);
}
/* ── Header ── */
.inv-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 2px solid #1e293b;
}
.inv-header .co-block img {
max-height: 56px;
max-width: 160px;
object-fit: contain;
margin-bottom: 8px;
display: block;
}
.inv-header .co-block .co-name {
font-size: 16px;
font-weight: 700;
color: #1e293b;
line-height: 1.3;
}
.inv-header .co-block .co-name2 {
font-size: 12px;
color: #64748b;
margin-top: 1px;
}
.inv-header .co-block .co-detail {
margin-top: 6px;
color: #475569;
font-size: 12px;
line-height: 1.6;
}
.inv-header .doc-block {
text-align: right;
}
.inv-header .doc-block .doc-type {
font-size: 26px;
font-weight: 800;
color: #1e293b;
letter-spacing: 1px;
line-height: 1;
}
.inv-header .doc-block .doc-number {
font-size: 15px;
font-weight: 600;
color: #3b82f6;
margin-top: 6px;
}
.inv-header .doc-block .doc-meta {
margin-top: 10px;
font-size: 12px;
color: #475569;
line-height: 1.8;
}
.inv-header .doc-block .doc-meta strong {
color: #1e293b;
font-weight: 600;
display: inline-block;
min-width: 70px;
}
.status-pill {
display: inline-block;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
margin-top: 8px;
}
.status-draft { background: #f1f5f9; color: #64748b; }
.status-issued { background: #dbeafe; color: #1d4ed8; }
.status-paid { background: #dcfce7; color: #15803d; }
.status-overdue { background: #fee2e2; color: #b91c1c; }
.status-void { background: #f1f5f9; color: #94a3b8; }
/* ── Bill to ── */
.bill-section {
display: flex;
gap: 32px;
margin-bottom: 28px;
}
.bill-box {
flex: 1;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px 18px;
}
.bill-box .bill-label {
font-size: 10px;
font-weight: 700;
color: #94a3b8;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 6px;
}
.bill-box .bill-name {
font-size: 14px;
font-weight: 700;
color: #1e293b;
margin-bottom: 3px;
}
.bill-box .bill-detail {
font-size: 12px;
color: #475569;
line-height: 1.6;
white-space: pre-line;
}
/* ── Items table ── */
.items-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 0;
}
.items-table thead th {
background: #1e293b;
color: #fff;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
padding: 10px 12px;
}
.items-table thead th:first-child { border-radius: 6px 0 0 0; }
.items-table thead th:last-child { border-radius: 0 6px 0 0; }
.items-table thead th.text-end { text-align: right; }
.items-table tbody tr {
border-bottom: 1px solid #e2e8f0;
}
.items-table tbody tr:last-child { border-bottom: 2px solid #e2e8f0; }
.items-table tbody tr:nth-child(even) { background: #f8fafc; }
.items-table tbody td {
padding: 10px 12px;
font-size: 13px;
color: #334155;
vertical-align: top;
}
.items-table tbody td.text-end { text-align: right; }
.items-table tbody td .sku {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
/* ── Totals ── */
.totals-wrap {
display: flex;
justify-content: flex-end;
margin-top: 0;
}
.totals-table {
width: 300px;
border-collapse: collapse;
}
.totals-table td {
padding: 5px 12px;
font-size: 13px;
color: #475569;
}
.totals-table td:last-child { text-align: right; }
.totals-table .total-row td {
border-top: 2px solid #1e293b;
padding-top: 10px;
padding-bottom: 10px;
font-size: 15px;
font-weight: 700;
color: #1e293b;
}
/* ── Notes ── */
.notes-section {
margin-top: 28px;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
}
.notes-section .notes-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: #94a3b8;
margin-bottom: 6px;
}
.notes-section .notes-text {
font-size: 12px;
color: #475569;
line-height: 1.7;
white-space: pre-line;
}
/* ── Footer ── */
.inv-footer {
margin-top: 40px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
text-align: center;
font-size: 11px;
color: #94a3b8;
line-height: 1.8;
}
/* ── Print styles ── */
@media print {
#print_bar { display: none !important; }
body { background: #fff; }
.page-wrap { padding: 0; }
.sheet { width: 100%; min-height: auto; padding: 16mm 18mm; box-shadow: none; }
@page { size: A4; margin: 0; }
}
</style>
</head>
<body>
<!-- Print bar (hidden on print) -->
<div id="print_bar">
<a href="javascript:history.back()" class="btn-back">← Back</a>
<span><?= h($doc_label) ?> &nbsp;/&nbsp; <?= h($inv['invoice_number']) ?></span>
<button class="btn-print" onclick="window.print()">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
Print / Save PDF
</button>
</div>
<div class="page-wrap">
<div class="sheet">
<!-- Header: company left, doc info right -->
<div class="inv-header">
<div class="co-block">
<?php if ($logo_url): ?>
<img src="<?= h($logo_url) ?>" alt="Logo">
<?php endif; ?>
<div class="co-name"><?= h($co['company_name'] ?? '') ?></div>
<?php if (!empty($co['company_name2'])): ?>
<div class="co-name2"><?= h($co['company_name2']) ?></div>
<?php endif; ?>
<div class="co-detail">
<?php
$co_lines = array_filter([
$co['address'] ?? '',
$co['address2'] ?? '',
!empty($co['tax_id']) ? 'Tax ID: ' . $co['tax_id'] : '',
!empty($co['phone']) ? 'Tel: ' . $co['phone'] : '',
!empty($co['email']) ? $co['email'] : '',
]);
echo implode('<br>', array_map('h', $co_lines));
?>
</div>
</div>
<div class="doc-block">
<div class="doc-type"><?= h($doc_label) ?></div>
<div class="doc-number"><?= h($inv['invoice_number']) ?></div>
<div class="doc-meta">
<div><strong>Issued</strong> <?= h(fmt_date($inv['issued_date'] ?? '')) ?></div>
<?php if (!empty($inv['due_date'])): ?>
<div><strong>Due</strong> <?= h(fmt_date($inv['due_date'])) ?></div>
<?php endif; ?>
<?php if (!empty($inv['order_number'])): ?>
<div><strong>Order</strong> <?= h($inv['order_number']) ?></div>
<?php endif; ?>
</div>
<?php
$status_classes = [
'0' => 'status-draft',
'1' => 'status-issued',
'2' => 'status-paid',
'3' => 'status-overdue',
'4' => 'status-void',
];
$sc = $status_classes[strval($inv['status'] ?? 0)] ?? 'status-draft';
?>
<div class="status-pill <?= $sc ?>"><?= h($status_label) ?></div>
</div>
</div>
<!-- Bill To -->
<div class="bill-section">
<div class="bill-box">
<div class="bill-label">Bill To</div>
<div class="bill-name"><?= h($inv['contact_name'] ?? '—') ?></div>
<?php if (!empty($inv['contact_billing_address'])): ?>
<div class="bill-detail"><?= h($inv['contact_billing_address']) ?></div>
<?php endif; ?>
<?php if (!empty($inv['contact_tax_id'])): ?>
<div class="bill-detail" style="margin-top:4px;">Tax ID: <?= h($inv['contact_tax_id']) ?></div>
<?php endif; ?>
</div>
</div>
<!-- Items table -->
<table class="items-table">
<thead>
<tr>
<th style="width:40px;">#</th>
<th>Product</th>
<th class="text-end" style="width:80px;">Qty</th>
<th class="text-end" style="width:110px;">Unit Price</th>
<th class="text-end" style="width:110px;">Amount</th>
</tr>
</thead>
<tbody>
<?php foreach ($items as $i => $item): ?>
<tr>
<td style="color:#94a3b8;"><?= $i + 1 ?></td>
<td>
<?= h($item['product_name'] ?? $item['product_sku'] ?? '') ?>
<?php if (!empty($item['product_sku']) && !empty($item['product_name'])): ?>
<div class="sku"><?= h($item['product_sku']) ?></div>
<?php endif; ?>
</td>
<td class="text-end"><?= h(fmt($item['quantity'] ?? 0)) ?></td>
<td class="text-end"><?= h(fmt($item['price'] ?? 0)) ?></td>
<td class="text-end"><?= h(fmt($item['total_price'] ?? 0)) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($items)): ?>
<tr><td colspan="5" style="text-align:center;color:#94a3b8;padding:20px;">No items</td></tr>
<?php endif; ?>
</tbody>
</table>
<!-- Totals -->
<div class="totals-wrap">
<table class="totals-table">
<?php
$rows = [
['Subtotal', $inv['subtotal'] ?? 0],
['Discount', $inv['discount'] ?? 0],
['Tax', $inv['tax'] ?? 0],
['Shipping', $inv['shipping_fee'] ?? 0],
];
foreach ($rows as [$label, $val]):
?>
<tr>
<td><?= h($label) ?></td>
<td><?= h(fmt($val)) ?></td>
</tr>
<?php endforeach; ?>
<tr class="total-row">
<td>Grand Total</td>
<td><?= h(fmt($inv['grand_total'] ?? 0)) ?></td>
</tr>
</table>
</div>
<!-- Notes -->
<?php if (!empty($inv['notes'])): ?>
<div class="notes-section">
<div class="notes-label">Notes</div>
<div class="notes-text"><?= h($inv['notes']) ?></div>
</div>
<?php endif; ?>
<!-- Footer -->
<div class="inv-footer">
<?php
$footer_parts = array_filter([
$co['company_name'] ?? '',
!empty($co['phone']) ? 'Tel: ' . $co['phone'] : '',
$co['email'] ?? '',
$co['website'] ?? '',
]);
echo implode(' &nbsp;·&nbsp; ', array_map('h', $footer_parts));
?>
</div>
</div>
</div>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/PurchaseOrderManager.php';
$id = (int)($data['id'] ?? 0);
if (!$id) {
$answer['message'] = 'Invalid PO ID.';
http_response_code(400);
exit(json_encode($answer));
}
try {
dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging) {
$po = new PurchaseOrderManager($pdo, $company_id);
$po->cancelPo($id, $logging);
});
$answer['success'] = 1;
$answer['message'] = 'Purchase order cancelled.';
} catch (PDOException $e) {
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
?>
+33
View File
@@ -0,0 +1,33 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/PurchaseOrderManager.php';
$id = (int)($data['id'] ?? 0);
if (!$id) {
$answer['message'] = 'Invalid PO ID.';
http_response_code(400);
exit(json_encode($answer));
}
try {
dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging) {
$po = new PurchaseOrderManager($pdo, $company_id);
$po->confirmPo($id, $logging);
});
$answer['success'] = 1;
$answer['message'] = 'Purchase order confirmed.';
} catch (PDOException $e) {
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
?>
+31
View File
@@ -0,0 +1,31 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/PurchaseOrderManager.php';
$data['items'] = json_decode($data['items'] ?? '[]', true) ?: [];
try {
$new_id = null;
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, &$new_id) {
$po = new PurchaseOrderManager($pdo, $company_id);
$new_id = $po->savePo($data, $logging);
});
$answer['success'] = 1;
if ($new_id) {
$answer['new_id'] = $new_id;
}
} catch (PDOException $e) {
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
?>
+48
View File
@@ -0,0 +1,48 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/PurchaseOrderManager.php';
require '../../../assets/utils/classes/StockManager.php';
require '../../../assets/utils/classes/WarehouseManager.php';
require '../../../assets/utils/classes/CompanySettingManager.php';
$id = (int)($data['id'] ?? 0);
if (!$id) {
$answer['message'] = 'Invalid PO ID.';
http_response_code(400);
exit(json_encode($answer));
}
$receive_items = json_decode($data['receive_items'] ?? '[]', true) ?: [];
if (empty($receive_items)) {
$answer['message'] = 'No items provided to receive.';
http_response_code(400);
exit(json_encode($answer));
}
$csm = new CompanySettingManager($pdo1, $company_id);
$auto_approve = (int)$csm->get('default_stock_status') === 1;
try {
dbTransaction($pdo2, function($pdo) use ($id, $receive_items, $company_id, $logging, $uuid, $auto_approve) {
$po = new PurchaseOrderManager($pdo, $company_id);
$po->receivePo($id, $receive_items, $uuid, $logging, $auto_approve);
});
$answer['success'] = 1;
$answer['message'] = 'Goods received successfully.';
$answer['auto_approved'] = $auto_approve;
} catch (PDOException $e) {
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
?>
+43
View File
@@ -0,0 +1,43 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/PurchaseOrderManager.php';
$po = new PurchaseOrderManager($pdo2, $company_id);
$id = (int)($data['id'] ?? 0);
if ($id > 0) {
$result = $po->getPoById($id);
if (!$result) {
http_response_code(404);
$answer['message'] = 'Purchase order not found.';
exit(json_encode($answer));
}
$answer['output'] = $result;
// Calculate receivable items — PO items minus already received qty
$po_items = $result['items'] ?? [];
$receivable = [];
foreach ($po_items as $i => $item) {
$ordered = (float)($item['quantity'] ?? 0);
$received = (float)($item['received_qty'] ?? 0);
$remaining = $ordered - $received;
if ($remaining > 0) {
$receivable[] = array_merge($item, [
'item_id' => (int)($item['item_id'] ?? $i),
'ordered_qty' => $ordered,
'received_qty' => $received,
'remaining_qty' => $remaining,
]);
}
}
$answer['receivable_items'] = $receivable;
} else {
$answer['output'] = $po->getPoList();
}
$answer['success'] = 1;
exit(json_encode($answer));
?>
+34
View File
@@ -0,0 +1,34 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/PurchaseOrderManager.php';
$id = (int)($data['id'] ?? 0);
$payment_status = (int)($data['payment_status'] ?? 0);
if (!$id) {
$answer['message'] = 'Invalid PO ID.';
http_response_code(400);
exit(json_encode($answer));
}
try {
dbTransaction($pdo2, function($pdo) use ($id, $payment_status, $company_id, $logging) {
$po = new PurchaseOrderManager($pdo, $company_id);
$po->updatePaymentStatus($id, $payment_status, $logging);
});
$answer['success'] = 1;
$answer['message'] = 'Payment status updated.';
} catch (PDOException $e) {
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
?>
+1011
View File
File diff suppressed because it is too large Load Diff
+298
View File
@@ -0,0 +1,298 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="mb-6 d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1">Purchase Orders</h1>
<p class="mb-0">Manage and track all purchase orders from suppliers</p>
</div>
<a href="<?php echo $server_url?>po/manage_po.php" class="btn btn-primary">
<i class="ti ti-plus"></i>
<span>New PO</span>
</a>
</div>
</div>
</div>
<!-- Stat cards -->
<div class="row g-5 mb-5">
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
<i class="ti ti-clipboard-list fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Total POs</p>
<h3 class="fw-bold mb-0" id="stat_total">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
<i class="ti ti-clock fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Draft</p>
<h3 class="fw-bold mb-0" id="stat_draft">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
<i class="ti ti-send fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Confirmed</p>
<h3 class="fw-bold mb-0" id="stat_confirmed">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-success bg-opacity-10 text-success rounded-2">
<i class="ti ti-circle-check fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Completed</p>
<h3 class="fw-bold mb-0" id="stat_completed">—</h3>
</div>
</div>
</div>
</div>
</div>
<!-- Filter + Table -->
<div class="row g-5">
<div class="col-12">
<div class="card">
<div class="card-body p-4">
<div class="row g-3 align-items-end">
<div class="col-lg-5">
<div class="position-relative">
<input type="text" id="search_input" class="form-control ps-9"
placeholder="Search PO number or supplier...">
<span class="position-absolute top-25 ms-4">
<i class="ti ti-search text-muted"></i>
</span>
</div>
</div>
<div class="col-lg-3">
<select id="filter_status" class="form-select">
<option value="">All Status</option>
<option value="-1">Cancelled</option>
<option value="0">Draft</option>
<option value="1">Confirmed</option>
<option value="2">Partial</option>
<option value="3">Completed</option>
</select>
</div>
<div class="col-lg-2">
<select id="filter_payment" class="form-select">
<option value="">All Payment</option>
<option value="0">Unpaid</option>
<option value="1">Paid</option>
<option value="2">Partial</option>
</select>
</div>
<div class="col-lg-2">
<button class="btn btn-secondary w-100" onclick="retrieve_pos()">
<i class="ti ti-search me-1"></i>Search
</button>
</div>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-body p-5">
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered" id="po_list">
<thead class="table-primary border-light">
<tr>
<th class="py-3">PO #</th>
<th class="py-3">Date</th>
<th class="py-3">Supplier</th>
<th class="py-3">Items</th>
<th class="py-3">Grand Total</th>
<th class="py-3">Status</th>
<th class="py-3">Payment</th>
<th class="py-3">Expected</th>
<th class="py-3">Action</th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script>
function po_status_badge(status) {
status = parseInt(status);
const map = {
'-1': '<span class="badge bg-secondary">Cancelled</span>',
'0': '<span class="badge bg-warning text-dark">Draft</span>',
'1': '<span class="badge bg-info text-white">Confirmed</span>',
'2': '<span class="badge bg-primary">Partial</span>',
'3': '<span class="badge bg-success">Completed</span>',
};
return map[String(status)] ?? '<span class="badge bg-light text-dark">—</span>';
}
function po_payment_badge(status) {
status = parseInt(status);
const map = {
'0': '<span class="badge bg-warning text-dark">Unpaid</span>',
'1': '<span class="badge bg-success">Paid</span>',
'2': '<span class="badge bg-info text-white">Partial</span>',
};
return map[String(status)] ?? '<span class="badge bg-light text-dark">—</span>';
}
function retrieve_pos() {
return ajax_request({
url: '<?php echo $server_url?>po/api/engine/retrieve_po.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var all = res.output || [];
var keyword = $('#search_input').val().toLowerCase();
var fil_status = $('#filter_status').val();
var fil_payment = $('#filter_payment').val();
var data = all.filter(function(o) {
var match_kw = !keyword ||
(o.po_number || '').toLowerCase().includes(keyword) ||
(o.contact_name || '').toLowerCase().includes(keyword);
var match_st = fil_status === '' || String(o.status) === fil_status;
var match_pm = fil_payment === '' || String(o.payment_status) === fil_payment;
return match_kw && match_st && match_pm;
});
$('#stat_total').text(format_number(all.length));
$('#stat_draft').text(format_number(all.filter(o => parseInt(o.status) === 0).length));
$('#stat_confirmed').text(format_number(all.filter(o => parseInt(o.status) === 1).length));
$('#stat_completed').text(format_number(all.filter(o => parseInt(o.status) === 3).length));
alasql('CREATE TABLE IF NOT EXISTS po_list');
alasql.tables.po_list.data = data;
var page = generate_pagination('po_list', data.length);
$('table#po_list tfoot').html(page);
if (typeof change_page_po_list === 'function') {
change_page_po_list(1);
}
}
});
}
function change_page_po_list(page_num) {
var offset = (page_num - 1) * prop_limit;
var page_data = alasql(`SELECT * FROM po_list LIMIT ${prop_limit} OFFSET ${offset}`);
var body = '';
$.each(page_data, function(i, o) {
var items = JSON.parse(o.items || '[]');
var item_count = items.length;
var can_cancel = parseInt(o.status) >= 0 && parseInt(o.status) <= 1;
body += `<tr>
<td class="py-3 fw-semibold">${o.po_number}</td>
<td class="py-3">${format_date(o.po_date)}</td>
<td class="py-3">${o.contact_name || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item_count} item${item_count !== 1 ? 's' : ''}</td>
<td class="py-3 fw-semibold">${format_number(o.grand_total, 2)}</td>
<td class="py-3">${po_status_badge(o.status)}</td>
<td class="py-3">${po_payment_badge(o.payment_status)}</td>
<td class="py-3">${o.expected_date ? format_date(o.expected_date) : '<span class="text-muted">—</span>'}</td>
<td class="py-3">
<a href="<?php echo $server_url?>po/manage_po.php?id=${o.id}" title="View">
<i class="ti ti-eye fs-5"></i>
</a>
${can_cancel ? `
<a href="javascript:void(0);" class="link-danger ms-2" title="Cancel"
onclick="cancel_po(${o.id})">
<i class="ti ti-x fs-5"></i>
</a>` : ''}
</td>
</tr>`;
});
$('table#po_list > tbody').html(body);
}
function cancel_po(id) {
bootbox.confirm({
message: 'Cancel this purchase order?',
buttons: {
confirm: { label: 'Yes, Cancel PO', className: 'btn-danger' },
cancel: { label: 'Back', className: 'btn-secondary' }
},
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>po/api/engine/cancel_po.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: { id: id },
onSuccess: function() {
retrieve_pos();
}
});
}
});
}
$(function() {
retrieve_pos();
$('#search_input').on('keyup', function(e) {
if (e.key === 'Enter') retrieve_pos();
});
});
</script>
</body>
</html>
+9 -2
View File
@@ -138,6 +138,7 @@
<th>Contact</th>
<th class="text-end">Stock In</th>
<th class="text-end">Stock Out</th>
<th>UOM</th>
<th class="text-end">Running Total</th>
</tr>
</thead>
@@ -369,6 +370,7 @@
sku: d.sku, product: d.product_name, date: 'Brought Forward', type: '',
lot: '', location: '', contact: '',
stock_in: bf.in > 0 ? bf.in : '', stock_out: bf.out > 0 ? bf.out : '',
uom: d.uom || 'pcs',
running_total: bf.balance,
});
$.each(rows, function(i, row) {
@@ -382,15 +384,17 @@
contact: row.contact_name || '',
stock_in: row.stock_in > 0 ? row.stock_in : '',
stock_out: row.stock_out > 0 ? row.stock_out : '',
uom: d.uom || 'pcs',
running_total: row.running_total,
});
});
// SKU header row
var header = `<tr class="table-secondary">
<td colspan="9" class="py-2 fw-semibold">
<td colspan="10" class="py-2 fw-semibold">
<code>${d.sku}</code>
<span class="text-muted fw-normal ms-2">${d.product_name}</span>
<span class="badge bg-light text-secondary border ms-2 small">${d.uom || 'pcs'}</span>
</td>
</tr>`;
@@ -402,6 +406,7 @@
<td class="py-2" colspan="6">Brought Forward</td>
<td class="py-2 text-end text-success">${bf_in_val}</td>
<td class="py-2 text-end text-danger">${bf_out_val}</td>
<td class="py-2 text-muted small">${d.uom || 'pcs'}</td>
<td class="py-2 text-end fw-semibold ${bf_bal_cls}">${fmt(bf.balance)}</td>
</tr>`;
@@ -425,6 +430,7 @@
<td class="py-2">${row.contact_name || '<span class="text-muted">—</span>'}</td>
<td class="py-2 text-end text-success">${in_val}</td>
<td class="py-2 text-end text-danger">${out_val}</td>
<td class="py-2 text-muted small">${d.uom || 'pcs'}</td>
<td class="py-2 text-end fw-semibold ${rt_cls}">${fmt(row.running_total)}</td>
</tr>`;
});
@@ -482,7 +488,7 @@
doc.setFontSize(12);
doc.text('Stock Movement — ' + warehouse + ' (' + from + ' to ' + to + ')', 14, 15);
var headers = [['SKU', 'Product', 'Date', 'Type', 'Lot', 'Location', 'Contact', 'Stock In', 'Stock Out', 'Running Total']];
var headers = [['SKU', 'Product', 'Date', 'Type', 'Lot', 'Location', 'Contact', 'Stock In', 'Stock Out', 'UOM', 'Running Total']];
var rows = export_data.map(function(r) {
return [
r.sku || '',
@@ -494,6 +500,7 @@
r.contact || '',
r.stock_in || '',
r.stock_out || '',
r.uom || 'pcs',
r.running_total || '',
];
});