[test] contact to setting modules
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
?>
|
||||
|
||||
@@ -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));
|
||||
?>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[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
@@ -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 {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[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
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[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
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
|
||||
@@ -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) ?> / <?= 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(' · ', array_map('h', $footer_parts));
|
||||
?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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));
|
||||
?>
|
||||
@@ -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));
|
||||
?>
|
||||
@@ -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));
|
||||
?>
|
||||
@@ -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));
|
||||
?>
|
||||
@@ -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));
|
||||
?>
|
||||
@@ -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));
|
||||
?>
|
||||
File diff suppressed because it is too large
Load Diff
+298
@@ -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>
|
||||
@@ -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 || '',
|
||||
];
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user