accounting workflows

This commit is contained in:
Thanakorn S
2026-05-20 10:17:02 +07:00
parent 04a683bd02
commit 7396db6ffc
199 changed files with 23539 additions and 5345 deletions
+435 -166
View File
@@ -9,7 +9,7 @@
* Method order:
* Transaction basis → getOrderList, getOrderById, generateOrderNumber,
* saveOrder, confirmOrder, cancelOrder,
* updateFulfillmentStatus
* updateShippingTracking
*
* Key design decisions:
* - Order items are stored as a JSON array in td_order.items.
@@ -92,7 +92,7 @@ class OrderManager {
$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, l.expiry_date,
(s.`in` - COALESCE((
SELECT SUM(o.`out`)
FROM `{$table}` o
@@ -107,6 +107,11 @@ class OrderManager {
AND r.warehouse = :warehouse_id
AND r.td_stock_id = s.id
AND r.product_sku IS NOT NULL
LEFT JOIN md_lot l
ON l.company_id = s.company_id
AND l.product_sku = s.product_sku
AND l.lot_number = s.lot_number
AND s.lot_number != ''
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.`in` > 0
@@ -155,6 +160,96 @@ class OrderManager {
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
}
/**
* Derive fulfillment from linked stock-out rows and tracking.
*
* 0 = no fulfillment yet
* 1 = picking (at least one linked stock-out row is draft)
* 2 = packed (all linked stock-out rows are approved, no tracking)
* 3 = shipped (all linked stock-out rows are approved, tracking exists)
*/
private function deriveFulfillmentStatus(array $order): int
{
$status = (int)($order['status'] ?? 0);
if ($status < 1) return 0;
$order_id = (int)($order['id'] ?? 0);
$sth = $this->pdo->prepare(
"SELECT warehouse_id, stock_out_id FROM td_order_item
WHERE order_id = :order_id AND company_id = :company_id"
);
$sth->execute([':order_id' => $order_id, ':company_id' => $this->company_id]);
$items = $sth->fetchAll(PDO::FETCH_ASSOC);
$linked_count = 0;
$draft_count = 0;
foreach ($items as $item) {
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
$stock_out_id = (int)($item['stock_out_id'] ?? 0);
if ($warehouse_id <= 0 || $stock_out_id <= 0) continue;
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT status FROM `{$table}`
WHERE company_id = :company_id
AND id = :id
AND type = 'out'
LIMIT 1"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $stock_out_id,
]);
$stock_status = $sth->fetchColumn();
if ($stock_status === false) continue;
$linked_count++;
if ((int)$stock_status === 0) {
$draft_count++;
}
}
if ($linked_count === 0) return 0;
if ($draft_count > 0) return 1;
return trim((string)($order['shipping_tracking_number'] ?? '')) !== '' ? 3 : 2;
}
private function syncOrderItems(int $order_id, array $items): void
{
$this->pdo->prepare(
"DELETE FROM td_order_item WHERE order_id = :order_id AND company_id = :company_id"
)->execute([':order_id' => $order_id, ':company_id' => $this->company_id]);
$sth = $this->pdo->prepare(
"INSERT INTO td_order_item
(company_id, order_id, item_id, product_sku, product_name,
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, lot_number, invoiced_qty)
VALUES
(:company_id, :order_id, :item_id, :product_sku, :product_name,
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_out_id, :lot_number, 0)"
);
foreach ($items as $pos => $item) {
$sth->execute([
':company_id' => $this->company_id,
':order_id' => $order_id,
':item_id' => $pos + 1,
':product_sku' => $item['product_sku'] ?? '',
':product_name' => $item['product_name'] ?? '',
':quantity' => (float)($item['quantity'] ?? 0),
':unit_price' => (float)($item['unit_price'] ?? $item['price'] ?? 0),
':total_price' => (float)($item['total_price'] ?? 0),
':tax_amount' => (float)($item['tax_amount'] ?? 0),
':tax_rate' => (float)($item['tax_rate'] ?? 0),
':warehouse_id' => (int)($item['warehouse_id'] ?? 0),
':stock_out_id' => (int)($item['stock_out_id'] ?? 0),
':lot_number' => $item['lot_number'] ?? '',
]);
}
}
// ─────────────────────────────────────────────────────────────
// TRANSACTION BASIS — Read
// ─────────────────────────────────────────────────────────────
@@ -179,7 +274,14 @@ class OrderManager {
ORDER BY o.created_at DESC"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$row) {
$row['fulfillment_status'] = $this->deriveFulfillmentStatus($row);
}
unset($row);
return $rows;
}
/**
@@ -207,10 +309,145 @@ class OrderManager {
if (!$row) return false;
$row['items'] = json_decode($row['items'] ?? '[]', true) ?: [];
$sth2 = $this->pdo->prepare(
"SELECT * FROM td_order_item
WHERE order_id = :order_id AND company_id = :company_id
ORDER BY item_id"
);
$sth2->execute([':order_id' => $id, ':company_id' => $this->company_id]);
$row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC);
$row['fulfillment_status'] = $this->deriveFulfillmentStatus($row);
return $row;
}
public function getActiveInvoiceId(int $order_id): int
{
$sth = $this->pdo->prepare(
"SELECT id FROM td_invoice
WHERE company_id = :company_id
AND order_id = :order_id
AND doc_type = 'invoice'
AND status != 4
ORDER BY id DESC
LIMIT 1"
);
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
return (int)$sth->fetchColumn();
}
public function getReturnableItems(array $order): array
{
$order_id = (int)($order['id'] ?? 0);
$order_items = $order['items'] ?? [];
$sth = $this->pdo->prepare(
"SELECT ri.item_id, ri.quantity
FROM td_return_item ri
JOIN td_return r ON r.id = ri.return_id AND r.company_id = ri.company_id
WHERE ri.company_id = :company_id
AND r.order_id = :order_id
AND r.status = 1"
);
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
$returned_qty = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $item) {
$item_id = (int)($item['item_id'] ?? -1);
if ($item_id < 0) continue;
$returned_qty[$item_id] = ($returned_qty[$item_id] ?? 0) + (float)$item['quantity'];
}
$returnable = [];
foreach ($order_items as $i => $item) {
$already_returned = $returned_qty[$i] ?? 0;
$remaining = (float)$item['quantity'] - $already_returned;
if ($remaining <= 0) {
continue;
}
$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 = $this->stockTableNameFromWarehouseId($stock_out_warehouse_id);
$sth = $this->pdo->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' => $this->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,
'stock_out_warehouse_id' => $stock_out_warehouse_id,
'stock_out_status' => (int)($item['stock_out_status'] ?? 0),
]);
}
return $returnable;
}
public function linkQuotationToOrder(int $quotation_id, int $order_id): void
{
$this->pdo->prepare(
"UPDATE td_quotation SET order_id = :order_id, status = 5
WHERE id = :id AND company_id = :cid AND status = 2"
)->execute([':order_id' => $order_id, ':id' => $quotation_id, ':cid' => $this->company_id]);
}
public function assertRevenueOrderEditable(int $order_id): void
{
$sth = $this->pdo->prepare(
"SELECT source, status FROM td_order
WHERE id = :id AND company_id = :cid
LIMIT 1"
);
$sth->execute([':id' => $order_id, ':cid' => $this->company_id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row || !in_array($row['source'], ['revenue', 'quotation'], true)) {
throw new Exception('Revenue sales order not found.');
}
if ((int)$row['status'] !== -2) {
throw new Exception('Only pending sales orders can be edited.');
}
}
public function assertRevenueOrder(int $order_id): void
{
$sth = $this->pdo->prepare(
"SELECT source FROM td_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $order_id]);
$source = (string)$sth->fetchColumn();
if (!in_array($source, ['revenue', 'quotation'], true)) {
throw new Exception("This is not a Revenue sales order.");
}
}
// ─────────────────────────────────────────────────────────────
// TRANSACTION BASIS — Write
// ─────────────────────────────────────────────────────────────
@@ -242,14 +479,20 @@ class OrderManager {
$items = $data['items'] ?? [];
// Calculate totals from items
$subtotal = array_reduce($items, fn($carry, $item) =>
$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;
$discount = (float)($data['discount'] ?? 0);
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
if (abs($tax_adjustment) > 0.30) {
throw new Exception("Tax adjustment cannot exceed ±0.30.");
}
$tax = round(array_reduce($items, fn($carry, $item) =>
$carry + (float)($item['tax_amount'] ?? 0), 0.0
), 2) + $tax_adjustment;
$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) {
@@ -287,75 +530,84 @@ class OrderManager {
$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,
contact_id = :contact_id,
department_id = :department_id,
order_date = :order_date,
subtotal = :subtotal,
discount = :discount,
tax = :tax,
tax_adjustment = :tax_adjustment,
shipping_fee = :shipping_fee,
shipping_tracking_number = :shipping_tracking_number,
grand_total = :grand_total,
notes = :notes,
status = :status,
`log` = :log
grand_total = :grand_total,
notes = :notes,
status = :status,
`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,
':contact_id' => (int)($data['contact_id'] ?? 0),
':department_id' => (int)($data['department_id'] ?? 0),
':order_date' => $data['order_date'] ?? date('Y-m-d'),
':subtotal' => $subtotal,
':discount' => $discount,
':tax' => $tax,
':tax_adjustment' => $tax_adjustment,
':shipping_fee' => $shipping_fee,
':shipping_tracking_number' => $tracking_no,
':grand_total' => $grand_total,
':notes' => $data['notes'] ?? '',
':status' => $new_status,
':log' => json_encode($log),
':id' => $id,
':company_id' => $this->company_id,
':grand_total' => $grand_total,
':notes' => $data['notes'] ?? '',
':status' => $new_status,
':log' => json_encode($log),
':id' => $id,
':company_id' => $this->company_id,
]);
$this->syncOrderItems($id, $items);
return 0;
} else {
$log = [$logging];
$quotation_id = (int)($data['quotation_id'] ?? 0);
// Quotation-converted orders start as Pending (-2); direct orders as Draft (0)
$init_status = $quotation_id > 0 ? -2 : 0;
$source = trim((string)($data['source'] ?? ''));
$source_id = (int)($data['source_id'] ?? 0);
// Direct WMS orders start as Draft; sourced orders wait for WMS assignment.
$init_status = $source !== '' ? -2 : 0;
$this->pdo->prepare(
"INSERT INTO td_order
(company_id, uuid, quotation_id, order_number, contact_id, order_date,
status, payment_status, subtotal, discount, tax,
shipping_fee, shipping_tracking_number, grand_total, items, notes, `log`, created_at)
(company_id, uuid, source_id, `source`, order_number, contact_id, department_id, order_date,
status, subtotal, discount, tax, tax_adjustment,
shipping_fee, shipping_tracking_number, grand_total, notes, `log`, created_at)
VALUES
(:company_id, :uuid, :quotation_id, :order_number, :contact_id, :order_date,
:status, 0, :subtotal, :discount, :tax,
:shipping_fee, :shipping_tracking_number, :grand_total, :items, :notes, :log, :created_at)"
(:company_id, :uuid, :source_id, :source, :order_number, :contact_id, :department_id, :order_date,
:status, :subtotal, :discount, :tax, :tax_adjustment,
:shipping_fee, :shipping_tracking_number, :grand_total, :notes, :log, :created_at)"
)->execute([
':company_id' => $this->company_id,
':uuid' => bin2hex(random_bytes(16)),
':quotation_id' => $quotation_id,
':order_number' => $this->generateOrderNumber(),
':contact_id' => (int)($data['contact_id'] ?? 0),
':order_date' => $data['order_date'] ?? date('Y-m-d'),
':status' => $init_status,
':subtotal' => $subtotal,
':discount' => $discount,
':tax' => $tax,
':shipping_fee' => $shipping_fee,
':company_id' => $this->company_id,
':uuid' => bin2hex(random_bytes(16)),
':source_id' => $source_id,
':source' => $source,
':order_number' => $this->generateOrderNumber(),
':contact_id' => (int)($data['contact_id'] ?? 0),
':department_id' => (int)($data['department_id'] ?? 0),
':order_date' => $data['order_date'] ?? date('Y-m-d'),
':status' => $init_status,
':subtotal' => $subtotal,
':discount' => $discount,
':tax' => $tax,
':tax_adjustment' => $tax_adjustment,
':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'),
':grand_total' => $grand_total,
':notes' => $data['notes'] ?? '',
':log' => json_encode($log),
':created_at' => date('Y-m-d H:i:s'),
]);
return (int)$this->pdo->lastInsertId();
$new_id = (int)$this->pdo->lastInsertId();
$this->syncOrderItems($new_id, $items);
return $new_id;
}
}
@@ -401,45 +653,68 @@ class OrderManager {
throw new Exception("Only draft orders can be confirmed.");
}
$items = json_decode($order['items'] ?? '[]', true) ?: [];
$sth2 = $this->pdo->prepare(
"SELECT * FROM td_order_item
WHERE order_id = :order_id AND company_id = :company_id
ORDER BY item_id"
);
$sth2->execute([':order_id' => $order_id, ':company_id' => $this->company_id]);
$items = $sth2->fetchAll(PDO::FETCH_ASSOC);
if (empty($items)) {
throw new Exception("Cannot confirm an order with no items.");
}
// ── Pre-flight: validate ALL items before creating any rows ───────
$preflight_errors = [];
foreach ($items as $i => $item) {
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
$product_sku = $item['product_sku'] ?? '';
$quantity = (float)($item['quantity'] ?? 0);
$name = $item['product_name'] ?? $product_sku;
if (!$warehouse_id || !$product_sku) {
$preflight_errors[] = "Item #{$i}: missing warehouse or SKU.";
continue;
}
if ($quantity <= 0) {
$preflight_errors[] = "Item #{$i}: quantity must be greater than zero.";
continue;
}
$rack_stock = $this->pickFifoRack($warehouse_id, $product_sku);
if (!$rack_stock) {
$preflight_errors[] = "No available stock for \"{$name}\" in the selected warehouse.";
continue;
}
$available_qty = (float)($rack_stock['available_qty'] ?? $rack_stock['in'] ?? 0);
if ($quantity - $available_qty > 0.000001) {
$preflight_errors[] =
"Insufficient stock for \"{$name}\": " .
"{$available_qty} available, {$quantity} requested.";
}
$expiry = $rack_stock['expiry_date'] ?? null;
if ($expiry && strtotime($expiry) < strtotime(date('Y-m-d'))) {
$lot = $rack_stock['lot_number'] ?? '';
$preflight_errors[] =
"Lot {$lot} for \"{$name}\" expired on {$expiry} and cannot be picked.";
}
}
if (!empty($preflight_errors)) {
throw new Exception(implode("\n", $preflight_errors));
}
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."
);
}
$rack_stock = $this->pickFifoRack($warehouse_id, $product_sku);
$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;
@@ -474,7 +749,7 @@ class OrderManager {
':lot_number' => $rack_stock['lot_number'] ?? '',
':serial_number' => $rack_stock['serial_number'] ?? '',
':source_id' => $order_id,
':price' => (float)($item['price'] ?? 0),
':price' => (float)($item['unit_price'] ?? 0),
]);
// Write rack context + stock_out_id back into the item object
@@ -494,22 +769,38 @@ class OrderManager {
}
unset($item); // break reference
// ── Update order: items (with stock_out_id) + status=1 ───────────
// ── Update order: 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,
]);
$upd = $this->pdo->prepare(
"UPDATE td_order_item SET
stock_out_id = :stock_out_id,
lot_number = :lot_number,
warehouse_id = :warehouse_id
WHERE order_id = :order_id AND item_id = :item_id AND company_id = :company_id"
);
foreach ($items as $item) {
$upd->execute([
':stock_out_id' => (int)($item['stock_out_id'] ?? 0),
':lot_number' => $item['lot_number'] ?? '',
':warehouse_id' => (int)($item['warehouse_id'] ?? 0),
':order_id' => $order_id,
':item_id' => (int)($item['item_id'] ?? 0),
':company_id' => $this->company_id,
]);
}
}
/**
@@ -555,11 +846,6 @@ class OrderManager {
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."
);
}
// Pending orders (-2) have no stock rows — skip the stock reversal loop below
// ── Guard: no active invoice ──────────────────────────────────────
@@ -610,7 +896,7 @@ class OrderManager {
$warehouse_id = (int)$matches[1];
$row_sth = $this->pdo->prepare(
"SELECT id, product_sku, `out`, zone, aisle, rack, ref_id, status
"SELECT id, product_sku, `out`, zone, aisle, rack, ref_id, status, `date`
FROM `{$table}`
WHERE company_id = :company_id
AND source = 'order'
@@ -629,6 +915,8 @@ class OrderManager {
continue;
}
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Order stock cancellation');
$whMgmt->occupyRack(
$warehouse_id,
(string)$row['zone'],
@@ -638,11 +926,9 @@ class OrderManager {
(int)$row['ref_id']
);
$whMgmt->adjustBalance(
'out',
$warehouse_id,
(string)$row['product_sku'],
(float)$row['out'],
0
'out', $warehouse_id, (string)$row['product_sku'],
(float)$row['out'], 0,
(int)$row['id'], 'order', $order_id
);
}
@@ -658,6 +944,38 @@ class OrderManager {
]);
}
// ── Release converted_qty on source quotation ─────────────────────────
$source = (string)($order['source'] ?? '');
$source_id = (int)($order['source_id'] ?? 0);
if ($source === 'quotation' && $source_id > 0) {
$sth2 = $this->pdo->prepare(
"SELECT item_id, quantity FROM td_order_item
WHERE order_id = :order_id AND company_id = :company_id
ORDER BY item_id"
);
$sth2->execute([':order_id' => $order_id, ':company_id' => $this->company_id]);
$items = $sth2->fetchAll(PDO::FETCH_ASSOC);
$dec = $this->pdo->prepare(
"UPDATE td_quotation_item
SET converted_qty = GREATEST(0, converted_qty - :qty)
WHERE quotation_id = :qid AND item_id = :item_id AND company_id = :cid"
);
foreach ($items as $item) {
$dec->execute([
':qty' => (float)($item['quantity'] ?? 0),
':qid' => $source_id,
':item_id' => (int)($item['item_id'] ?? 0),
':cid' => $this->company_id,
]);
}
// Revert quotation from Converted back to Accepted if it was fully done
$this->pdo->prepare(
"UPDATE td_quotation SET status = 2
WHERE id = :id AND company_id = :cid AND status = 5"
)->execute([':id' => $source_id, ':cid' => $this->company_id]);
}
// ── Cancel the order ──────────────────────────────────────────────
$log = json_decode($order['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, ['action' => 'cancel']);
@@ -675,36 +993,14 @@ class OrderManager {
}
/**
* 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
* Update shipping tracking number. Fulfillment itself is derived from
* linked stock-out rows and tracking, not selected manually.
*/
public function updateFulfillmentStatus(
int $order_id,
int $fulfillment_status,
public function updateShippingTracking(
int $order_id,
string $tracking_number,
array $logging,
bool $auto_complete = true
bool $auto_complete = true
): void {
// ── Load order ────────────────────────────────────────────────────
$sth = $this->pdo->prepare(
@@ -718,64 +1014,37 @@ class OrderManager {
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);
$status = (int)$order['status'];
$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.");
throw new Exception("Confirm the order before adding a tracking number.");
}
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;
if ($tracking_number === $current_tracking) {
throw new Exception("No tracking changes to save.");
}
// ── Persist ───────────────────────────────────────────────────────
$log = json_decode($order['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, [
'action' => 'update_fulfillment',
'fulfillment_status' => $fulfillment_status,
'action' => 'update_tracking',
'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,
':log' => json_encode($log),
':id' => $order_id,
':company_id' => $this->company_id,
]);
}
}