warehouse layers [switchable]
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user