Files
wms-app/app/assets/utils/classes/OrderManager.php
T

760 lines
32 KiB
PHP

<?php
/**
* OrderManager
*
* Handles all read and write operations for sales orders (td_order)
* and their downstream stock-out effects on td_stock_<warehouse_id> tables.
*
* Method order:
* Transaction basis → getOrderList, getOrderById, generateOrderNumber,
* saveOrder, confirmOrder, cancelOrder,
* updateFulfillmentStatus
*
* Key design decisions:
* - Order items are stored as a JSON array in td_order.items.
* 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_<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. 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 stock table names are derived only from md_warehouse.id.
*/
class OrderManager {
private PDO $pdo;
private int $company_id;
public function __construct(PDO $pdo, int $company_id) {
$this->pdo = $pdo;
$this->company_id = $company_id;
}
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
/**
* Build a standard audit log entry array for append-to-JSON log columns.
*
* @param string $action Short label e.g. 'create', 'update', 'confirm', 'cancel'.
* @return array
*/
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,
];
}
private function stockTableNameFromWarehouseId(int $warehouse_id): string
{
if ($warehouse_id <= 0) {
throw new Exception("Invalid warehouse id.");
}
return 'td_stock_' . $warehouse_id;
}
/**
* FIFO rack pick — find the oldest approved stock-in row for a SKU
* in a given warehouse that is still rack-occupied.
*
* "Oldest" = smallest date value among status=1 stock-in rows
* that have an md_rack row pointing back to them (td_stock_id IS set).
*
* @param int $warehouse_id
* @param string $product_sku
* @return array|null Full td_stock row + zone/aisle/rack from md_rack,
* or null if no available stock in this warehouse.
*/
private function pickFifoRack(int $warehouse_id, string $product_sku): ?array
{
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$sth = $this->pdo->prepare(
"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
AND r.warehouse = :warehouse_id
AND r.td_stock_id = s.id
AND r.product_sku IS NOT NULL
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.`in` > 0
AND s.status = 1
HAVING available_qty > 0
ORDER BY s.date ASC, s.id ASC
LIMIT 1"
);
$sth->execute([
':company_id' => $this->company_id,
':warehouse_id' => $warehouse_id,
':product_sku' => $product_sku,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
/**
* Generate the next sequential order number in ORD-YYYYMMDD-XXXX format.
*
* Finds the highest sequence number already used today and increments by 1.
* Thread-safe enough for single-company use; wrap in transaction if needed.
*
* @return string e.g. "ORD-20260502-0001"
*/
private function generateOrderNumber(): string
{
$prefix = 'ORD-' . date('Ymd') . '-';
$sth = $this->pdo->prepare(
"SELECT order_number FROM td_order
WHERE company_id = :company_id
AND order_number LIKE :prefix
ORDER BY order_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);
}
// ─────────────────────────────────────────────────────────────
// TRANSACTION BASIS — Read
// ─────────────────────────────────────────────────────────────
/**
* Return all orders for the company, ordered by created_at DESC.
*
* Joins md_contact for contact_name display.
*
* @return array
*/
public function getOrderList(): array
{
$sth = $this->pdo->prepare(
"SELECT o.*,
COALESCE(c.contact_name, '') AS contact_name
FROM td_order o
LEFT JOIN md_contact c
ON c.company_id = o.company_id
AND c.id = o.contact_id
WHERE o.company_id = :company_id
ORDER BY o.created_at DESC"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Fetch a single order row by its primary key.
*
* Returns the row with items decoded as a PHP array.
*
* @param int $id td_order.id
* @return array|false
*/
public function getOrderById(int $id): array|false
{
$sth = $this->pdo->prepare(
"SELECT o.*,
COALESCE(c.contact_name, '') AS contact_name
FROM td_order o
LEFT JOIN md_contact c
ON c.company_id = o.company_id
AND c.id = o.contact_id
WHERE o.company_id = :company_id
AND o.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 order (draft) or update metadata on an existing one.
*
* Insert flow (id = 0):
* - Generates order_number.
* - Stores items as JSON array.
* - Calculates grand_total from items + adjustments.
* - Sets status = 0 (draft), created_at = now().
*
* Update flow (id > 0):
* - Only allowed while status = 0 (draft).
* - Updates contact, date, items, totals, notes.
*
* Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, contact_id, order_date, items (array),
* discount, tax, shipping_fee, shipping_tracking_number, notes.
* @param array $logging Audit entry to append to log column.
* @return int New td_order.id on insert, 0 on update.
* @throws Exception If updating a non-draft order.
*/
public function saveOrder(array $data, array $logging): int
{
$id = (int)($data['id'] ?? 0);
$items = $data['items'] ?? [];
// Calculate totals from 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);
$tracking_no = trim((string)($data['shipping_tracking_number'] ?? ''));
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
if ($id > 0) {
// Fetch existing row to check status and load log
$sth = $this->pdo->prepare(
"SELECT status, `log` FROM td_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("Order not found.");
}
if ((int)$row['status'] !== 0) {
throw new Exception("Only draft orders can be edited.");
}
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $logging;
$this->pdo->prepare(
"UPDATE td_order SET
contact_id = :contact_id,
order_date = :order_date,
items = :items,
subtotal = :subtotal,
discount = :discount,
tax = :tax,
shipping_fee = :shipping_fee,
shipping_tracking_number = :shipping_tracking_number,
grand_total = :grand_total,
notes = :notes,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':contact_id' => (int)($data['contact_id'] ?? 0),
':order_date' => $data['order_date'] ?? date('Y-m-d'),
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
':subtotal' => $subtotal,
':discount' => $discount,
':tax' => $tax,
':shipping_fee' => $shipping_fee,
':shipping_tracking_number' => $tracking_no,
':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_order
(company_id, uuid, order_number, contact_id, order_date,
status, payment_status, subtotal, discount, tax,
shipping_fee, shipping_tracking_number, grand_total, items, notes, `log`, created_at)
VALUES
(:company_id, :uuid, :order_number, :contact_id, :order_date,
0, 0, :subtotal, :discount, :tax,
:shipping_fee, :shipping_tracking_number, :grand_total, :items, :notes, :log, :created_at)"
)->execute([
':company_id' => $this->company_id,
':uuid' => bin2hex(random_bytes(16)),
':order_number' => $this->generateOrderNumber(),
':contact_id' => (int)($data['contact_id'] ?? 0),
':order_date' => $data['order_date'] ?? date('Y-m-d'),
':subtotal' => $subtotal,
':discount' => $discount,
':tax' => $tax,
':shipping_fee' => $shipping_fee,
':shipping_tracking_number' => $tracking_no,
':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 order — create stock-out rows (status=0) for each item
* using FIFO rack selection, then advance the order to status=1.
*
* Flow per item:
* 1. pickFifoRack() — find oldest rack-occupied stock-in row for the SKU.
* 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.
*
* After all items are processed:
* 4. UPDATE td_order.items with stock_out_id values written back.
* 5. UPDATE td_order.status = 1 (confirmed).
*
* Rack release and balance adjustment are intentionally deferred —
* they happen when warehouse staff approve the stock-out rows via
* the existing approve_stock.php engine (StockManager::approveStock).
*
* Must be called inside dbTransaction() by the caller.
*
* @param int $order_id td_order.id to confirm.
* @param string $uuid UUID prefix — each stock-out row gets uuid_{$i}.
* @param array $logging Audit entry appended to td_order.log.
* @throws Exception If order not found, not in draft status, or any item
* has no available FIFO rack in its assigned warehouse.
*/
public function confirmOrder(int $order_id, string $uuid, array $logging, bool $auto_approve = false): void
{
$sth = $this->pdo->prepare(
"SELECT * FROM td_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $order_id]);
$order = $sth->fetch(PDO::FETCH_ASSOC);
if (!$order) {
throw new Exception("Order not found.");
}
if ((int)$order['status'] !== 0) {
throw new Exception("Only draft orders can be confirmed.");
}
$items = json_decode($order['items'] ?? '[]', true) ?: [];
if (empty($items)) {
throw new Exception("Cannot confirm an order with no items.");
}
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);
if (!$rack_stock) {
$name = $item['product_name'] ?? $product_sku;
throw new Exception(
"No available stock for \"{$name}\" in the selected warehouse."
);
}
$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'])];
$this->pdo->prepare(
"INSERT INTO `{$table}`
(uuid, company_id, `date`, product_sku, `out`,
zone, aisle, rack,
contact_id, `description`, `log`, `type`,
ref_id, lot_number, serial_number,
source, source_id, price, status)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity,
:zone, :aisle, :rack,
:contact_id, :description, :log, 'out',
:ref_id, :lot_number, :serial_number,
'order', :source_id, :price, 0)"
)->execute([
':uuid' => $item_uuid,
':company_id' => $this->company_id,
':date' => date('Y-m-d H:i:s'),
':product_sku' => $product_sku,
':quantity' => $quantity,
':zone' => $rack_stock['zone'],
':aisle' => $rack_stock['aisle'],
':rack' => $rack_stock['rack'],
':contact_id' => (int)$order['contact_id'],
':description' => $order['order_number'],
':log' => json_encode($item_log),
':ref_id' => (int)$rack_stock['id'],
':lot_number' => $rack_stock['lot_number'] ?? '',
':serial_number' => $rack_stock['serial_number'] ?? '',
':source_id' => $order_id,
':price' => (float)($item['price'] ?? 0),
]);
// Write rack context + stock_out_id back into the item object
$item['zone'] = $rack_stock['zone'];
$item['aisle'] = $rack_stock['aisle'];
$item['rack'] = $rack_stock['rack'];
$item['lot_number'] = $rack_stock['lot_number'] ?? '';
$item['serial_number'] = $rack_stock['serial_number'] ?? '';
$item['stock_out_id'] = (int)$this->pdo->lastInsertId();
// Auto-approve: immediately release rack + adjust balance
if ($auto_approve) {
$stock = new StockManager($this->pdo, $this->company_id);
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
$stock->approveStock($item['stock_out_id'], $warehouse_id, 'out', $whMgmt);
}
}
unset($item); // break reference
// ── Update order: items (with stock_out_id) + status=1 ───────────
$log = json_decode($order['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, ['action' => 'confirm']);
$this->pdo->prepare(
"UPDATE td_order SET
status = 1,
items = :items,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
':log' => json_encode($log),
':id' => $order_id,
':company_id' => $this->company_id,
]);
}
/**
* Cancel an order and soft-delete all linked stock-out rows (status → -1).
*
* Business rule:
* An order can be cancelled (Draft or Confirmed) as long as no active
* 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 != 4
* - td_return where order_id = $order_id AND status != -1
*
* 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.
*
* @param int $order_id td_order.id to cancel.
* @param array $logging Audit entry appended to td_order.log.
* @throws Exception
*/
public function cancelOrder(int $order_id, array $logging): void
{
// ── Load order ────────────────────────────────────────────────────
$sth = $this->pdo->prepare(
"SELECT * FROM td_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $order_id]);
$order = $sth->fetch(PDO::FETCH_ASSOC);
if (!$order) {
throw new Exception("Order not found.");
}
$status = (int)$order['status'];
if ($status === -1) {
throw new Exception("Order is already cancelled.");
}
if ($status >= 2) {
throw new Exception(
"Cannot cancel an order that is processing or completed."
);
}
// ── Guard: no active invoice ──────────────────────────────────────
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_invoice
WHERE company_id = :company_id
AND order_id = :order_id
AND status != 4"
);
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
if ((int)$sth->fetchColumn() > 0) {
throw new Exception(
"Cannot cancel — this order has an active invoice. " .
"Please void the invoice first."
);
}
// ── Guard: no active return ───────────────────────────────────────
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_return
WHERE company_id = :company_id
AND order_id = :order_id
AND status != -1"
);
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
if ((int)$sth->fetchColumn() > 0) {
throw new Exception(
"Cannot cancel — this order has an active return. " .
"Please cancel the return first."
);
}
// ── 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()
AND table_name LIKE 'td_stock_%'"
);
$sth->execute();
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
foreach ($tables as $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 `{$table}` SET status = -1
WHERE company_id = :company_id
AND source = 'order'
AND source_id = :order_id
AND status != -1"
)->execute([
':company_id' => $this->company_id,
':order_id' => $order_id,
]);
}
// ── Cancel the order ──────────────────────────────────────────────
$log = json_decode($order['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, ['action' => 'cancel']);
$this->pdo->prepare(
"UPDATE td_order SET
status = -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $order_id,
':company_id' => $this->company_id,
]);
}
/**
* Update the fulfillment sub-status of a confirmed order.
*
* Fulfillment status values:
* 1 = Picking — warehouse staff are picking items
* 2 = Packed — items are packed, ready to ship
* 3 = Shipped — goods have left the warehouse
*
* Rules:
* - Only allowed on orders with status >= 1 (confirmed) and not cancelled.
* - Fulfillment must move forward only (no going backwards).
* - If auto_complete_on_ship = true AND fulfillment_status = 3 (Shipped),
* order status is automatically advanced to 3 (Completed).
* - If auto_complete_on_ship = false, order status is set to 2 (Processing)
* on first fulfillment update, and stays there until manually completed.
*
* Must be called inside dbTransaction() by the caller.
*
* @param int $order_id td_order.id
* @param int $fulfillment_status 1=Picking, 2=Packed, 3=Shipped
* @param string $tracking_number Shipping carrier tracking number.
* @param array $logging Audit entry.
* @param bool $auto_complete Whether Shipped auto-drives order to Completed.
* @throws Exception
*/
public function updateFulfillmentStatus(
int $order_id,
int $fulfillment_status,
string $tracking_number,
array $logging,
bool $auto_complete = true
): void {
// ── Load order ────────────────────────────────────────────────────
$sth = $this->pdo->prepare(
"SELECT * FROM td_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $order_id]);
$order = $sth->fetch(PDO::FETCH_ASSOC);
if (!$order) {
throw new Exception("Order not found.");
}
$status = (int)$order['status'];
$current_fulfillment = (int)($order['fulfillment_status'] ?? 0);
$current_tracking = trim((string)($order['shipping_tracking_number'] ?? ''));
$tracking_number = trim($tracking_number);
if ($status === -1) {
throw new Exception("Cannot update a cancelled order.");
}
if ($status < 1) {
throw new Exception("Confirm the order before updating fulfillment.");
}
if ($status === 3) {
throw new Exception("Order is already completed.");
}
if (!in_array($fulfillment_status, [1, 2, 3], true)) {
throw new Exception("Invalid fulfillment status.");
}
if ($fulfillment_status < $current_fulfillment) {
throw new Exception("Fulfillment status can only move forward.");
}
if ($fulfillment_status === $current_fulfillment && $tracking_number === $current_tracking) {
throw new Exception("No fulfillment or tracking changes to save.");
}
// ── Determine new order status ────────────────────────────────────
$new_order_status = $status;
if ($fulfillment_status > $current_fulfillment && $fulfillment_status === 3 && $auto_complete) {
// Shipped + auto-complete → Completed
$new_order_status = 3;
} elseif ($fulfillment_status > $current_fulfillment && $status === 1) {
// First fulfillment update → Processing
$new_order_status = 2;
}
// ── Persist ───────────────────────────────────────────────────────
$log = json_decode($order['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, [
'action' => 'update_fulfillment',
'fulfillment_status' => $fulfillment_status,
'shipping_tracking_number' => $tracking_number,
'order_status' => $new_order_status,
]);
$this->pdo->prepare(
"UPDATE td_order SET
fulfillment_status = :fulfillment_status,
shipping_tracking_number = :shipping_tracking_number,
status = :status,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':fulfillment_status' => $fulfillment_status,
':shipping_tracking_number' => $tracking_number,
':status' => $new_order_status,
':log' => json_encode($log),
':id' => $order_id,
':company_id' => $this->company_id,
]);
}
}