accounting workflows
This commit is contained in:
@@ -18,8 +18,10 @@
|
||||
* in the existing Stock In list automatically under source='po'.
|
||||
* - stock_in_id is written back into the items JSON after each receive call,
|
||||
* same pattern as stock_out_id in OrderManager::confirmOrder().
|
||||
* - Partial receipt is supported: receivePo() can be called multiple times
|
||||
* until all items are fully received, advancing status 1→2 (partial) or 1/2→3 (completed).
|
||||
* - Partial receipt is supported: receivePo() can be called multiple times.
|
||||
* - PO stored statuses: -2 (pending warehouse), -1 (cancelled), 0 (draft), 1 (confirmed).
|
||||
* Receipt progress (pending/partial/received) is derived via deriveReceiptStatus(),
|
||||
* same pattern as fulfillment_status on SO. Never stored in td_purchase_order.
|
||||
* - cancelPo() is blocked if any linked stock-in rows have been approved (status=1),
|
||||
* because those have already modified rack and balance.
|
||||
* - Write methods do NOT manage their own DB transactions.
|
||||
@@ -78,6 +80,77 @@ class PurchaseOrderManager {
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive receipt status from linked stock-in rows — never stored in DB.
|
||||
*
|
||||
* 0 = no stock-in rows linked yet
|
||||
* 1 = pending (stock-in rows exist but none approved)
|
||||
* 2 = partial (some approved, quantities not fully received)
|
||||
* 3 = received (all approved, all quantities received)
|
||||
*/
|
||||
private function deriveReceiptStatus(array $po): int
|
||||
{
|
||||
if ((int)($po['status'] ?? 0) < 1) return 0;
|
||||
|
||||
$po_id = (int)($po['id'] ?? 0);
|
||||
|
||||
// Fetch items from normalized table
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT item_id, quantity, received_qty, stock_in_id FROM td_purchase_order_item
|
||||
WHERE order_id = :po_id AND company_id = :company_id"
|
||||
);
|
||||
$sth->execute([':po_id' => $po_id, ':company_id' => $this->company_id]);
|
||||
$items = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$has_any_stock_in = false;
|
||||
foreach ($items as $item) {
|
||||
if ((int)($item['stock_in_id'] ?? 0) > 0) {
|
||||
$has_any_stock_in = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$has_any_stock_in) return 0;
|
||||
|
||||
$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);
|
||||
|
||||
$total = 0;
|
||||
$pending = 0;
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'po'
|
||||
AND source_id = :po_id
|
||||
AND type = 'in'
|
||||
AND status != -1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]);
|
||||
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $s) {
|
||||
$total++;
|
||||
if ((int)$s === 0) $pending++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($total === 0) return 0;
|
||||
if ($pending > 0) return 1;
|
||||
|
||||
$all_received = true;
|
||||
foreach ($items as $item) {
|
||||
if ((float)($item['received_qty'] ?? 0) < (float)($item['quantity'] ?? 0)) {
|
||||
$all_received = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $all_received ? 3 : 2;
|
||||
}
|
||||
|
||||
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
||||
{
|
||||
if ($warehouse_id <= 0) {
|
||||
@@ -87,6 +160,38 @@ class PurchaseOrderManager {
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
private function syncPoItems(int $po_id, array $items): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_purchase_order_item WHERE order_id = :po_id AND company_id = :company_id"
|
||||
)->execute([':po_id' => $po_id, ':company_id' => $this->company_id]);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO td_purchase_order_item
|
||||
(company_id, order_id, item_id, product_sku, product_name,
|
||||
quantity, unit_price, total_price, tax_amount, tax_rate, received_qty, stock_in_id, invoiced_qty)
|
||||
VALUES
|
||||
(:company_id, :order_id, :item_id, :product_sku, :product_name,
|
||||
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :received_qty, :stock_in_id, 0)"
|
||||
);
|
||||
foreach ($items as $pos => $item) {
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':order_id' => $po_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'] ?? 0),
|
||||
':total_price' => (float)($item['total_price'] ?? 0),
|
||||
':tax_amount' => (float)($item['tax_amount'] ?? 0),
|
||||
':tax_rate' => (float)($item['tax_rate'] ?? 0),
|
||||
':received_qty' => (float)($item['received_qty'] ?? 0),
|
||||
':stock_in_id' => (int)($item['stock_in_id'] ?? 0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -108,7 +213,11 @@ class PurchaseOrderManager {
|
||||
ORDER BY p.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['receipt_status'] = $this->deriveReceiptStatus($row);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,19 +241,86 @@ class PurchaseOrderManager {
|
||||
|
||||
if (!$row) return false;
|
||||
|
||||
$row['items'] = json_decode($row['items'] ?? '[]', true) ?: [];
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_purchase_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['receipt_status'] = $this->deriveReceiptStatus($row);
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function getReceivableItems(array $po): array
|
||||
{
|
||||
$receivable = [];
|
||||
foreach (($po['items'] ?? []) as $i => $item) {
|
||||
$ordered = (float)($item['quantity'] ?? 0);
|
||||
$received = (float)($item['received_qty'] ?? 0);
|
||||
$remaining = $ordered - $received;
|
||||
if ($remaining > 0) {
|
||||
$receivable[] = array_merge($item, [
|
||||
'item_id' => (int)($item['item_id'] ?? $i),
|
||||
'ordered_qty' => $ordered,
|
||||
'received_qty' => $received,
|
||||
'remaining_qty' => $remaining,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $receivable;
|
||||
}
|
||||
|
||||
public function getReturnableItems(array $po): array
|
||||
{
|
||||
$returnable = [];
|
||||
foreach (($po['items'] ?? []) as $i => $item) {
|
||||
$received = (float)($item['received_qty'] ?? 0);
|
||||
if ($received > 0) {
|
||||
$ordered = (float)($item['quantity'] ?? 0);
|
||||
$returnable[] = array_merge($item, [
|
||||
'item_id' => (int)($item['item_id'] ?? $i),
|
||||
'ordered_qty' => $ordered,
|
||||
'received_qty' => $received,
|
||||
'returnable_qty' => $received,
|
||||
'warehouse_id' => (int)($item['receive_wh'] ?? 0),
|
||||
'zone' => $item['receive_zone'] ?? '',
|
||||
'aisle' => $item['receive_aisle'] ?? '',
|
||||
'rack' => $item['receive_rack'] ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $returnable;
|
||||
}
|
||||
|
||||
public function getActivePurchaseInvoiceId(int $po_id): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'po'
|
||||
AND source_id = :po_id
|
||||
AND doc_type = 'purchase_invoice'
|
||||
AND status != 4
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]);
|
||||
return (int)$sth->fetchColumn();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert a new PO (draft) or update metadata on an existing draft.
|
||||
* Insert a new PO or update metadata on an existing pending/draft PO.
|
||||
*
|
||||
* Insert (id=0): generates po_number, sets status=0, payment_status=0.
|
||||
* Update (id>0): only allowed while status=0 (draft).
|
||||
* Insert (id=0): generates po_number. Accounting-sourced POs start
|
||||
* as pending warehouse (-2), direct WMS POs start as draft (0).
|
||||
* Update (id>0): only allowed while status=-2 (pending) or 0 (draft).
|
||||
*
|
||||
* @param array $data Keys: id, contact_id, po_date, expected_date,
|
||||
* warehouse_id, items (array), discount, tax,
|
||||
@@ -157,13 +333,24 @@ class PurchaseOrderManager {
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
$subtotal = array_reduce($items, fn($carry, $item) =>
|
||||
$skus = array_filter(array_column($items, 'product_sku'));
|
||||
if (count($skus) !== count(array_unique($skus))) {
|
||||
throw new Exception("Duplicate products found — each product may only appear once per PO.");
|
||||
}
|
||||
|
||||
$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);
|
||||
$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);
|
||||
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
||||
|
||||
if ($id > 0) {
|
||||
|
||||
@@ -175,78 +362,97 @@ class PurchaseOrderManager {
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) throw new Exception("Purchase order not found.");
|
||||
if ((int)$row['status'] !== 0) throw new Exception("Only draft POs can be edited.");
|
||||
$cur_status = (int)$row['status'];
|
||||
if (!in_array($cur_status, [-2, 0], true)) {
|
||||
throw new Exception("Only pending or draft POs can be edited.");
|
||||
}
|
||||
$warehouse_id = (int)($data['warehouse_id'] ?? 0);
|
||||
$new_status = ($cur_status === -2 && $warehouse_id > 0) ? 0 : $cur_status;
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $logging;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order SET
|
||||
contact_id = :contact_id,
|
||||
po_date = :po_date,
|
||||
expected_date = :expected_date,
|
||||
warehouse_id = :warehouse_id,
|
||||
items = :items,
|
||||
subtotal = :subtotal,
|
||||
discount = :discount,
|
||||
tax = :tax,
|
||||
shipping_fee = :shipping_fee,
|
||||
grand_total = :grand_total,
|
||||
notes = :notes,
|
||||
`log` = :log
|
||||
contact_id = :contact_id,
|
||||
department_id = :department_id,
|
||||
po_date = :po_date,
|
||||
expected_date = :expected_date,
|
||||
warehouse_id = :warehouse_id,
|
||||
subtotal = :subtotal,
|
||||
discount = :discount,
|
||||
tax = :tax,
|
||||
tax_adjustment = :tax_adjustment,
|
||||
shipping_fee = :shipping_fee,
|
||||
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),
|
||||
':po_date' => $data['po_date'] ?? date('Y-m-d'),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':po_date' => $data['po_date'] ?? date('Y-m-d'),
|
||||
':expected_date' => $data['expected_date'] ?: null,
|
||||
':warehouse_id' => (int)($data['warehouse_id'] ?? 0),
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':grand_total' => $grand_total,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
':warehouse_id' => $warehouse_id,
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':tax_adjustment' => $tax_adjustment,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':grand_total' => $grand_total,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':status' => $new_status,
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
$this->syncPoItems($id, $items);
|
||||
return 0;
|
||||
|
||||
} else {
|
||||
|
||||
$log = [$logging];
|
||||
$source = trim((string)($data['source'] ?? ''));
|
||||
$source_id = (int)($data['source_id'] ?? 0);
|
||||
$init_status = $source !== '' ? -2 : 0;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_purchase_order
|
||||
(company_id, uuid, po_number, contact_id, po_date, expected_date,
|
||||
warehouse_id, status, payment_status, subtotal, discount, tax,
|
||||
shipping_fee, grand_total, items, notes, `log`, created_at)
|
||||
(company_id, uuid, source_id, `source`, po_number, contact_id, department_id, po_date, expected_date,
|
||||
warehouse_id, status, subtotal, discount, tax, tax_adjustment,
|
||||
shipping_fee, grand_total, notes, `log`, created_at)
|
||||
VALUES
|
||||
(:company_id, :uuid, :po_number, :contact_id, :po_date, :expected_date,
|
||||
:warehouse_id, 0, 0, :subtotal, :discount, :tax,
|
||||
:shipping_fee, :grand_total, :items, :notes, :log, :created_at)"
|
||||
(:company_id, :uuid, :source_id, :source, :po_number, :contact_id, :department_id, :po_date, :expected_date,
|
||||
:warehouse_id, :status, :subtotal, :discount, :tax, :tax_adjustment,
|
||||
:shipping_fee, :grand_total, :notes, :log, :created_at)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':source_id' => $source_id,
|
||||
':source' => $source,
|
||||
':po_number' => $this->generatePoNumber(),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':po_date' => $data['po_date'] ?? date('Y-m-d'),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':po_date' => $data['po_date'] ?? date('Y-m-d'),
|
||||
':expected_date' => $data['expected_date'] ?: null,
|
||||
':warehouse_id' => (int)($data['warehouse_id'] ?? 0),
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':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'),
|
||||
':status' => $init_status,
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':tax_adjustment' => $tax_adjustment,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':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->syncPoItems($new_id, $items);
|
||||
return $new_id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,14 +531,26 @@ class PurchaseOrderManager {
|
||||
$status = (int)$po['status'];
|
||||
if ($status === -1) throw new Exception("Cannot receive against a cancelled PO.");
|
||||
if ($status === 0) throw new Exception("Confirm the PO before receiving goods.");
|
||||
if ($status === 3) throw new Exception("This PO is already fully received.");
|
||||
if (!empty($po['closed_at'])) throw new Exception("This PO has been closed — no further receipts are allowed.");
|
||||
|
||||
// Load PO items from normalized table
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_purchase_order_item
|
||||
WHERE order_id = :po_id AND company_id = :company_id
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':po_id' => $po_id, ':company_id' => $this->company_id]);
|
||||
$po_items = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$all_done = !empty($po_items) && array_reduce($po_items, fn($ok, $item) =>
|
||||
$ok && (float)($item['received_qty'] ?? 0) >= (float)($item['quantity'] ?? 0), true
|
||||
);
|
||||
if ($all_done) throw new Exception("This PO is already fully received.");
|
||||
|
||||
if (empty($receive_items)) {
|
||||
throw new Exception("No items provided to receive.");
|
||||
}
|
||||
|
||||
$po_items = json_decode($po['items'] ?? '[]', true) ?: [];
|
||||
|
||||
// Index PO items by item_id for quick lookup
|
||||
$po_items_by_id = [];
|
||||
foreach ($po_items as $i => $item) {
|
||||
@@ -352,6 +570,20 @@ class PurchaseOrderManager {
|
||||
if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku.");
|
||||
if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id.");
|
||||
|
||||
// Cap against PO quantity — prevent over-receiving
|
||||
if ($item_id >= 0 && isset($po_items_by_id[$item_id])) {
|
||||
$idx = $po_items_by_id[$item_id];
|
||||
$already_received = (float)($po_items[$idx]['received_qty'] ?? 0);
|
||||
$max_receivable = (float)($po_items[$idx]['quantity'] ?? 0) - $already_received;
|
||||
if ($quantity - $max_receivable > 0.000001) {
|
||||
$name = $po_items[$idx]['product_name'] ?? $product_sku;
|
||||
throw new Exception(
|
||||
"Cannot receive {$quantity} for \"{$name}\": " .
|
||||
"only {$max_receivable} unit(s) remaining on this PO."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$item_uuid = $uuid . '_' . $j;
|
||||
$item_log = array_merge($logging, ['action' => 'receive_item']);
|
||||
|
||||
@@ -414,43 +646,34 @@ class PurchaseOrderManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Write stock_in_id + received qty back into PO item
|
||||
// Write stock_in_id + received qty back into td_purchase_order_item
|
||||
if ($item_id >= 0 && isset($po_items_by_id[$item_id])) {
|
||||
$idx = $po_items_by_id[$item_id];
|
||||
$already_received = (float)($po_items[$idx]['received_qty'] ?? 0);
|
||||
$po_items[$idx]['received_qty'] = $already_received + $quantity;
|
||||
$po_items[$idx]['stock_in_id'] = $new_stock_in_id;
|
||||
$po_items[$idx]['receive_wh'] = $warehouse_id;
|
||||
$po_items[$idx]['receive_zone'] = $zone;
|
||||
$po_items[$idx]['receive_aisle'] = $aisle;
|
||||
$po_items[$idx]['receive_rack'] = $rack;
|
||||
}
|
||||
}
|
||||
$new_received_qty = $already_received + $quantity;
|
||||
|
||||
// Determine new PO status
|
||||
$all_received = true;
|
||||
foreach ($po_items as $item) {
|
||||
$ordered = (float)($item['quantity'] ?? 0);
|
||||
$received = (float)($item['received_qty'] ?? 0);
|
||||
if ($received < $ordered) {
|
||||
$all_received = false;
|
||||
break;
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order_item SET
|
||||
stock_in_id = :stock_in_id,
|
||||
received_qty = :received_qty
|
||||
WHERE order_id = :po_id AND item_id = :item_id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':stock_in_id' => $new_stock_in_id,
|
||||
':received_qty' => $new_received_qty,
|
||||
':po_id' => $po_id,
|
||||
':item_id' => $item_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
$new_status = $all_received ? 3 : 2;
|
||||
|
||||
$log = json_decode($po['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'receive', 'new_status' => $new_status]);
|
||||
$log[] = array_merge($logging, ['action' => 'receive']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order SET
|
||||
status = :status,
|
||||
items = :items,
|
||||
`log` = :log
|
||||
"UPDATE td_purchase_order SET `log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':status' => $new_status,
|
||||
':items' => json_encode($po_items, JSON_UNESCAPED_UNICODE),
|
||||
':log' => json_encode($log),
|
||||
':id' => $po_id,
|
||||
':company_id' => $this->company_id,
|
||||
@@ -458,43 +681,40 @@ class PurchaseOrderManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update payment status on a PO.
|
||||
* Allowed for status >= 1 (confirmed, partial, completed).
|
||||
* Close a confirmed PO — sets closed_at to prevent further receipts.
|
||||
*
|
||||
* @param int $po_id td_purchase_order.id
|
||||
* @param int $payment_status 0=unpaid, 1=paid, 2=partial
|
||||
* @param array $logging Audit entry.
|
||||
* Use when the supplier will send no more goods (short-shipped, agreement closed).
|
||||
* Does not affect receipt_status — partial receipt is preserved.
|
||||
* Blocked if PO is cancelled or not yet confirmed.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function updatePaymentStatus(int $po_id, int $payment_status, array $logging): void
|
||||
public function closePo(int $po_id, array $logging): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_purchase_order
|
||||
"SELECT status, closed_at, `log` FROM td_purchase_order
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $po_id]);
|
||||
$po = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$po) throw new Exception("Purchase order not found.");
|
||||
if ((int)$po['status'] < 1) throw new Exception("Confirm the PO before updating payment.");
|
||||
if ((int)$po['status'] === -1) throw new Exception("Cannot update a cancelled PO.");
|
||||
|
||||
if (!in_array($payment_status, [0, 1, 2], true)) {
|
||||
throw new Exception("Invalid payment status.");
|
||||
}
|
||||
if ((int)$po['status'] === -1) throw new Exception("Cannot close a cancelled PO.");
|
||||
if ((int)$po['status'] === 0) throw new Exception("Confirm the PO before closing it.");
|
||||
if (!empty($po['closed_at'])) throw new Exception("This PO is already closed.");
|
||||
|
||||
$log = json_decode($po['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'update_payment', 'payment_status' => $payment_status]);
|
||||
$log[] = array_merge($logging, ['action' => 'close']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order SET
|
||||
payment_status = :payment_status,
|
||||
`log` = :log
|
||||
closed_at = NOW(),
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':payment_status' => $payment_status,
|
||||
':log' => json_encode($log),
|
||||
':id' => $po_id,
|
||||
':company_id' => $this->company_id,
|
||||
':log' => json_encode($log),
|
||||
':id' => $po_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -521,7 +741,6 @@ class PurchaseOrderManager {
|
||||
|
||||
$status = (int)$po['status'];
|
||||
if ($status === -1) throw new Exception("PO is already cancelled.");
|
||||
if ($status === 3) throw new Exception("Cannot cancel a completed PO.");
|
||||
|
||||
// Guard: block if any approved stock-in rows exist for this PO
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -600,5 +819,36 @@ class PurchaseOrderManager {
|
||||
':id' => $po_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
// Release converted_qty on the originating purchase request items
|
||||
$source = (string)($po['source'] ?? '');
|
||||
$source_id = (int)($po['source_id'] ?? 0);
|
||||
if ($source === 'purchase_request' && $source_id > 0) {
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT item_id, quantity FROM td_purchase_order_item
|
||||
WHERE order_id = :po_id AND company_id = :company_id
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':po_id' => $po_id, ':company_id' => $this->company_id]);
|
||||
$po_items = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
$dec = $this->pdo->prepare(
|
||||
"UPDATE td_purchase_request_item
|
||||
SET converted_qty = GREATEST(0, converted_qty - :qty)
|
||||
WHERE request_id = :rid AND item_id = :item_id AND company_id = :cid"
|
||||
);
|
||||
foreach ($po_items as $item) {
|
||||
$dec->execute([
|
||||
':qty' => (float)($item['quantity'] ?? 0),
|
||||
':rid' => $source_id,
|
||||
':item_id' => (int)($item['item_id'] ?? 0),
|
||||
':cid' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
// Revert PR from fully-converted (status=5) back to Approved (status=2)
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_request SET status = 2
|
||||
WHERE id = :id AND company_id = :cid AND status = 5"
|
||||
)->execute([':id' => $source_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user