warehouse layers [switchable]

This commit is contained in:
Thanakorn S
2026-05-04 13:43:32 +07:00
parent a90975d9f1
commit 414f1cd81d
26 changed files with 2022 additions and 584 deletions
+168 -52
View File
@@ -8,7 +8,8 @@
*
* Method order:
* Transaction basis → getOrderList, getOrderById, generateOrderNumber,
* saveOrder, confirmOrder, cancelOrder
* saveOrder, confirmOrder, cancelOrder,
* updateFulfillmentStatus
*
* Key design decisions:
* - Order items are stored as a JSON array in td_order.items.
@@ -19,10 +20,10 @@
* - 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 flips all linked
* draft stock-out rows (status=0) to status=-1 (cancelled).
* Approved stock-out rows block cancellation — caller must handle
* these manually before cancelling.
* - 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.
@@ -240,7 +241,7 @@ class OrderManager {
* Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, contact_id, order_date, items (array),
* discount, tax, shipping_fee, notes.
* 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.
@@ -257,6 +258,7 @@ class OrderManager {
$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) {
@@ -288,6 +290,7 @@ class OrderManager {
discount = :discount,
tax = :tax,
shipping_fee = :shipping_fee,
shipping_tracking_number = :shipping_tracking_number,
grand_total = :grand_total,
notes = :notes,
`log` = :log
@@ -300,6 +303,7 @@ class OrderManager {
':discount' => $discount,
':tax' => $tax,
':shipping_fee' => $shipping_fee,
':shipping_tracking_number' => $tracking_no,
':grand_total' => $grand_total,
':notes' => $data['notes'] ?? '',
':log' => json_encode($log),
@@ -317,11 +321,11 @@ class OrderManager {
"INSERT INTO td_order
(company_id, uuid, order_number, contact_id, order_date,
status, payment_status, subtotal, discount, tax,
shipping_fee, grand_total, items, notes, `log`, created_at)
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, :grand_total, :items, :notes, :log, :created_at)"
:shipping_fee, :shipping_tracking_number, :grand_total, :items, :notes, :log, :created_at)"
)->execute([
':company_id' => $this->company_id,
':uuid' => bin2hex(random_bytes(16)),
@@ -332,6 +336,7 @@ class OrderManager {
':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'] ?? '',
@@ -484,28 +489,27 @@ class OrderManager {
}
/**
* Cancel an order and flip all linked draft stock-out rows to status=-1.
* Cancel an order and soft-delete all linked stock-out rows (status → -1).
*
* Flow:
* 1. Load the order — must be status=0 (draft) or status=1 (confirmed).
* Orders at status=2 (processing) or status=3 (completed) cannot be
* cancelled here; they require manual stock reversal first.
* 2. Check that no linked stock-out rows are already approved (status=1).
* If any are approved, throw — those must be deleted via the existing
* deleteStockOut() flow before cancellation can proceed.
* 3. UPDATE all td_stock_<wh> rows where
* source='order' AND source_id=order_id AND status=0 → status=-1.
* 4. UPDATE td_order.status = -1.
* 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).
*
* Cancel logic is intentionally self-contained — status=-1 rows have no
* rack/balance side effects so WarehouseManager is not involved.
* Downstream documents that block cancellation:
* - td_invoice where order_id = $order_id AND status != -1
* - 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.
*
* 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 If order not found, already cancelled, in a non-cancellable
* status, or has approved stock-out rows.
* @throws Exception
*/
public function cancelOrder(int $order_id, array $logging): void
{
@@ -528,14 +532,41 @@ class OrderManager {
}
if ($status >= 2) {
throw new Exception(
"Cannot cancel an order that is processing or completed. " .
"Please reverse stock movements manually first."
"Cannot cancel an order that is processing or completed."
);
}
// ── Guard: no approved stock-out rows ─────────────────────────────
// Discover all td_stock_* tables and check for approved rows
// linked to this order before making any changes.
// ── 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 != -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 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."
);
}
// ── Soft-delete all linked stock-out rows ─────────────────────────
$sth = $this->pdo->prepare(
"SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE()
@@ -545,33 +576,13 @@ class OrderManager {
$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 = 'order'
AND source_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 — some stock-out rows for this order are already " .
"approved. Please delete them from the Stock Out page first."
);
}
}
// ── Flip draft stock-out rows to cancelled ────────────────────────
foreach ($tables as $table) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$this->pdo->prepare(
"UPDATE `{$table}` SET status = -1
"UPDATE `{$safe}` SET status = -1
WHERE company_id = :company_id
AND source = 'order'
AND source_id = :order_id
AND status = 0"
AND status != -1"
)->execute([
':company_id' => $this->company_id,
':order_id' => $order_id,
@@ -593,4 +604,109 @@ class OrderManager {
':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,
]);
}
}