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