Files
wms-app/app/assets/utils/classes/PurchaseOrderManager.php
T
Thanakorn f70f226bd1 Fix timestamps, delete requests, invoice dates and GR quantities
Apply the configured timezone to PHP and both DB connections, wrap
unwrapped ajax payloads so delete buttons reach their engines, normalise
and validate invoice due dates, reject stock quantities below the stored
4dp scale, and list stock movements across all warehouses.
2026-09-17 09:00:15 +07:00

994 lines
43 KiB
PHP

<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/WarehouseManager.php';
require_once __DIR__ . '/StockManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* PurchaseOrderManager
*
* Handles all read and write operations for td_purchase_order
* and the downstream stock-in effects on td_stock_<warehouse_id> tables.
*
* Method order:
* Transaction basis → getPoList, getPoById, generatePoNumber,
* savePo, confirmPo, receivePo, cancelPo
*
* Key design decisions:
* - PO items are stored as a JSON array in td_purchase_order.items.
* Same pattern as td_order. No separate child table.
* - receivePo() calls StockManager::saveStockIn() for each received line
* with source='po' and source_id=po_id. This means received goods appear
* 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.
* - 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 bin and balance.
* - Write methods do NOT manage their own DB transactions.
* Callers must wrap multi-step operations inside dbTransaction().
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* Dynamic stock table names are derived only from md_warehouse.id.
*/
class PurchaseOrderManager {
private PDO $pdo;
private int $company_id;
public function __construct(PDO $pdo, int $company_id) {
$this->pdo = $pdo;
$this->company_id = $company_id;
}
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
private function buildLogEntry(string $action): array {
return [
'user_id' => $_SESSION['login_user_id'] ?? null,
'dt' => date('Y-m-d H:i:s'),
'login' => isset($_SESSION['otpTime'])
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
: null,
'action' => $action,
];
}
/**
* Generate next sequential PO number in PO-YYYYMMDD-XXXX format.
*/
private function generatePoNumber(array $data = []): string
{
return (new DocumentNumberManager($this->pdo, $this->company_id))
->resolveNumber($data, 'purchase_order', 'td_purchase_order', 'po_number');
}
/**
* 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;
$tables = (new WarehouseManager($this->pdo, $this->company_id))->getStockTables();
$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) {
throw new Exception("Invalid warehouse id.");
}
$sth = $this->pdo->prepare(
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
if (!$sth->fetchColumn()) {
throw new Exception("Warehouse ID {$warehouse_id} not found.");
}
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
// ─────────────────────────────────────────────────────────────
/**
* Return all POs for the company, ordered by created_at DESC.
* Joins md_contact for supplier name display.
*/
public function getPoList(): array
{
$sth = $this->pdo->prepare(
"SELECT p.*,
COALESCE(c.contact_name, '') AS contact_name,
(SELECT COUNT(*) FROM td_purchase_order_item i
WHERE i.company_id = p.company_id
AND i.order_id = p.id) AS item_count
FROM td_purchase_order p
LEFT JOIN md_contact c
ON c.company_id = p.company_id
AND c.id = p.contact_id
WHERE p.company_id = :company_id
ORDER BY p.created_at DESC"
);
$sth->execute([':company_id' => $this->company_id]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$row) {
$row['receipt_status'] = $this->deriveReceiptStatus($row);
}
return $rows;
}
/**
* Fetch a single PO by its primary key.
* Returns the row with items decoded as a PHP array.
*/
public function getPoById(int $id): array|false
{
$sth = $this->pdo->prepare(
"SELECT p.*,
COALESCE(c.contact_name, '') AS contact_name
FROM td_purchase_order p
LEFT JOIN md_contact c
ON c.company_id = p.company_id
AND c.id = p.contact_id
WHERE p.company_id = :company_id
AND p.id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) return false;
$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'] ?? '',
'bin' => $item['receive_bin'] ?? '',
]);
}
}
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 or update metadata on an existing pending/draft PO.
*
* 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,
* shipping_fee, notes.
* @param array $logging Audit entry.
* @return int New td_purchase_order.id on insert, 0 on update.
*/
public function savePo(array $data, array $logging): int
{
$id = (int)($data['id'] ?? 0);
$items = $data['items'] ?? [];
$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_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) {
$sth = $this->pdo->prepare(
"SELECT status, `log` FROM td_purchase_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new Exception("Purchase order not found.");
$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,
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),
':department_id' => (int)($data['department_id'] ?? 0),
':po_date' => $data['po_date'] ?? date('Y-m-d'),
':expected_date' => $data['expected_date'] ?: null,
':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, 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, :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($data),
':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),
':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'),
]);
$new_id = (int)$this->pdo->lastInsertId();
$this->syncPoItems($new_id, $items);
return $new_id;
}
}
/**
* Confirm a draft PO — advance status 0 → 1.
* No stock rows are created at this stage; receipt happens via receivePo().
*
* @throws Exception If PO not found or not in draft.
*/
public function confirmPo(int $po_id, array $logging): void
{
$sth = $this->pdo->prepare(
"SELECT * 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'] !== 0) throw new Exception("Only draft POs can be confirmed.");
$log = json_decode($po['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, ['action' => 'confirm']);
$this->pdo->prepare(
"UPDATE td_purchase_order SET
status = 1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $po_id,
':company_id' => $this->company_id,
]);
}
/**
* Receive goods against a confirmed PO — creates draft stock-in rows.
*
* Flow per received line:
* 1. Calls StockManager::saveStockIn() with source='po', source_id=po_id.
* 2. Writes stock_in_id back into the PO item object (same as stock_out_id in orders).
* 3. Increments received_qty on the item.
*
* After all lines:
* 4. If all items are fully received → status = 3 (completed).
* 5. If partially received → status = 2 (partial).
*
* Can be called multiple times for partial deliveries.
* If auto_approve=true (from company settings), approveStock() is also called.
*
* @param int $po_id td_purchase_order.id
* @param array $receive_items Each item: { item_id, product_sku, warehouse_id,
* quantity, zone, aisle, bin, lot_number,
* expiry_date, serial_number, contact_id }
* @param string $uuid UUID prefix for stock-in rows.
* @param array $logging Audit entry.
* @param bool $auto_approve Auto-approve stock-in rows immediately.
*/
public function receivePo(
int $po_id,
array $receive_items,
string $uuid,
array $logging,
bool $auto_approve = false
): void {
$sth = $this->pdo->prepare(
"SELECT * 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.");
$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 (!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.");
}
// Index PO items by item_id for quick lookup
$po_items_by_id = [];
foreach ($po_items as $i => $item) {
$po_items_by_id[(int)($item['item_id'] ?? $i)] = $i;
}
$stock = new StockManager($this->pdo, $this->company_id);
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
// Validate all warehouse IDs in one query before entering the item loop.
$recvWarehouseIds = array_values(array_unique(array_filter(
array_map(fn($recv) => (int)($recv['warehouse_id'] ?? $po['warehouse_id']), $receive_items),
fn($id) => $id > 0
)));
if (!empty($recvWarehouseIds)) {
$placeholders = implode(',', array_fill(0, count($recvWarehouseIds), '?'));
$sth = $this->pdo->prepare(
"SELECT id FROM md_warehouse WHERE company_id = ? AND id IN ($placeholders)"
);
$sth->execute([$this->company_id, ...$recvWarehouseIds]);
$validWarehouseIds = array_flip($sth->fetchAll(PDO::FETCH_COLUMN));
} else {
$validWarehouseIds = [];
}
foreach ($receive_items as $j => $recv) {
$item_id = (int)($recv['item_id'] ?? -1);
$product_sku = $recv['product_sku'] ?? '';
$warehouse_id = (int)($recv['warehouse_id'] ?? $po['warehouse_id']);
$quantity = (float)($recv['quantity'] ?? 0);
// A blank line is a line the user chose not to receive — skip it.
if ($quantity == 0) continue;
// Anything positive has to survive the decimal(18,4) columns it is
// about to be written to. Without this, 0.0000001 was accepted, was
// stored as 0.0000, produced a stock movement of nothing, and still
// advanced received_qty enough to leave the PO stuck on "Partial".
$name = $po_items[$po_items_by_id[$item_id] ?? -1]['product_name'] ?? $product_sku;
$quantity = StockManager::normaliseQuantity($quantity, "Receiving quantity for \"{$name}\"");
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']);
$bin = $recv['bin'] ?? '';
$zone = $recv['zone'] ?? '';
$aisle = $recv['aisle'] ?? '';
// Expiry dates live on md_lot, keyed by lot number, so an expiry
// entered without one is silently dropped and the received stock
// shows no expiry at all. Say so instead of discarding it.
if (trim((string)($recv['expiry_date'] ?? '')) !== ''
&& trim((string)($recv['lot_number'] ?? '')) === '') {
throw new Exception(
"Enter a lot number for \"{$name}\" — an expiry date is recorded against its lot."
);
}
// Simple location mode: zone and aisle must mirror the bin value
// (same convention as manage_stock_in.php).
// occupyBin() looks up md_bin WHERE zone=:zone AND aisle=:aisle AND bin=:bin,
// so all three must match — a blank zone/aisle produces "Bin --b does not exist".
if ($zone === '' && $bin !== '') $zone = $bin;
if ($aisle === '' && $bin !== '') $aisle = $bin;
// Resolve unit_price: prefer the receive-time override, fall back to PO item price
$po_item_price = 0;
if ($item_id >= 0 && isset($po_items_by_id[$item_id])) {
$po_item_price = (float)($po_items[$po_items_by_id[$item_id]]['unit_price'] ?? 0);
}
$unit_price = (float)($recv['unit_price'] ?? $po_item_price);
$stock_data = [
'id' => 0,
'warehouse' => $warehouse_id,
'product_sku' => $product_sku,
'quantity' => $quantity,
'price' => $unit_price,
'zone' => $zone,
'aisle' => $aisle,
'bin' => $bin,
'lot_number' => $recv['lot_number'] ?? '',
'expiry_date' => $recv['expiry_date'] ?? '',
'serial_number' => $recv['serial_number'] ?? '',
'contact_id' => (int)($recv['contact_id'] ?? $po['contact_id'] ?? 0),
'description' => $po['po_number'],
'source' => 'po',
'source_id' => $po_id,
];
$new_stock_in_id = $stock->saveStockIn($stock_data, $item_log, $item_uuid);
if ($new_stock_in_id > 0) {
// saveStockIn() does not write source/source_id — stamp them here.
// This links the stock-in row back to this PO for cancellation guards
// and makes it appear under the "PO" source tab on the Stock In list.
if (!isset($validWarehouseIds[$warehouse_id])) {
throw new Exception("Warehouse ID {$warehouse_id} not found.");
}
$table = 'td_stock_' . $warehouse_id;
$this->pdo->prepare(
"UPDATE `{$table}` SET source = 'po', source_id = :po_id
WHERE id = :id AND company_id = :company_id"
)->execute([
':po_id' => $po_id,
':id' => $new_stock_in_id,
':company_id' => $this->company_id,
]);
// approveStock() handles balance updates. saveStockIn() has
// already reserved the bin so draft receipts cannot be reused.
if ($auto_approve) {
$stock->approveStock($new_stock_in_id, $warehouse_id, 'in', $whMgmt);
}
}
// 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);
$new_received_qty = $already_received + $quantity;
$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,
]);
}
}
$log = json_decode($po['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, ['action' => 'receive']);
$this->pdo->prepare(
"UPDATE td_purchase_order SET `log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $po_id,
':company_id' => $this->company_id,
]);
}
/**
* Close a confirmed PO — sets closed_at to prevent further receipts.
*
* 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 closePo(int $po_id, array $logging): void
{
$sth = $this->pdo->prepare(
"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("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' => 'close']);
$this->pdo->prepare(
"UPDATE td_purchase_order SET
closed_at = NOW(),
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $po_id,
':company_id' => $this->company_id,
]);
}
/**
* Cancel a PO.
*
* Blocked if any linked stock-in rows have already been approved (status=1)
* because those have modified bin occupancy and balance.
*
* For draft stock-in rows (status=0), they are soft-deleted (status=-1).
*
* @throws Exception
*/
public function cancelPo(int $po_id, array $logging): void
{
$sth = $this->pdo->prepare(
"SELECT * 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.");
$status = (int)$po['status'];
if ($status === -1) throw new Exception("PO is already cancelled.");
// Guard: block if any approved stock-in rows exist for this PO
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
$tables = $whMgmt->getStockTables();
foreach ($tables as $table) {
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM `{$table}`
WHERE company_id = :company_id
AND source = 'po'
AND source_id = :po_id
AND status = 1"
);
$sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]);
if ((int)$sth->fetchColumn() > 0) {
throw new Exception(
"Cannot cancel — stock from this PO has already been approved and received. " .
"Please adjust stock manually if needed."
);
}
}
// Soft-delete draft stock-in rows and release their bin reservations.
foreach ($tables as $table) {
if (!preg_match('/^td_stock_(\d+)$/', $table, $m)) {
continue;
}
$warehouse_id = (int)$m[1];
$sth = $this->pdo->prepare(
"SELECT id, zone, aisle, bin
FROM `{$table}`
WHERE company_id = :company_id
AND source = 'po'
AND source_id = :po_id
AND status = 0"
);
$sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]);
$draft_rows = $sth->fetchAll(PDO::FETCH_ASSOC);
foreach ($draft_rows as $row) {
$this->pdo->prepare(
"UPDATE `{$table}` SET status = -1
WHERE id = :id AND company_id = :company_id"
)->execute([
':id' => (int)$row['id'],
':company_id' => $this->company_id,
]);
$whMgmt->releaseBin(
$warehouse_id,
$row['zone'],
$row['aisle'],
$row['bin']
);
}
}
$log = json_decode($po['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, ['action' => 'cancel']);
$this->pdo->prepare(
"UPDATE td_purchase_order SET
status = -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':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]);
}
}
/**
* Soft-delete a purchase order by negating company_id on the header, items, and all
* linked draft stock-in rows. Blocked if any purchase invoice, supplier return, or
* approved (received) stock-in rows exist.
*/
public function softDelete(int $po_id): void
{
$sth = $this->pdo->prepare(
"SELECT * FROM td_purchase_order WHERE company_id = :cid AND id = :id"
);
$sth->execute([':cid' => $this->company_id, ':id' => $po_id]);
$po = $sth->fetch(PDO::FETCH_ASSOC);
if (!$po) throw new Exception('Purchase order not found.');
global $pdo1;
if (isset($pdo1) && $pdo1 instanceof PDO) {
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$guard->assertOpenDate($po['po_date'] ?: date('Y-m-d'), 'Purchase order deletion');
}
$sth2 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_invoice
WHERE company_id = :cid AND source = 'po' AND source_id = :id AND doc_type = 'purchase_invoice'"
);
$sth2->execute([':cid' => $this->company_id, ':id' => $po_id]);
if ((int)$sth2->fetchColumn() > 0) {
throw new Exception('Cannot delete — this PO has linked purchase invoices. Delete the invoices first.');
}
$sth3 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_supplier_return
WHERE company_id = :cid AND po_id = :id AND status != -1"
);
$sth3->execute([':cid' => $this->company_id, ':id' => $po_id]);
if ((int)$sth3->fetchColumn() > 0) {
throw new Exception('Cannot delete — this PO has linked supplier returns. Delete or cancel the returns first.');
}
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
$tables = $whMgmt->getStockTables();
foreach ($tables as $table) {
$sth5 = $this->pdo->prepare(
"SELECT COUNT(*) FROM `{$table}`
WHERE company_id = :cid AND source = 'po' AND source_id = :id AND status = 1"
);
$sth5->execute([':cid' => $this->company_id, ':id' => $po_id]);
if ((int)$sth5->fetchColumn() > 0) {
throw new Exception('Cannot delete — received stock from this PO must be reversed via ICS first.');
}
}
// Release bins from draft stock-in rows and negate them
foreach ($tables as $table) {
if (!preg_match('/^td_stock_(\d+)$/', (string)$table, $matches)) continue;
$warehouse_id = (int)$matches[1];
$row_sth = $this->pdo->prepare(
"SELECT id, zone, aisle, bin FROM `{$table}`
WHERE company_id = :cid AND source = 'po' AND source_id = :id AND status = 0"
);
$row_sth->execute([':cid' => $this->company_id, ':id' => $po_id]);
$draft_rows = $row_sth->fetchAll(PDO::FETCH_ASSOC);
foreach ($draft_rows as $row) {
$whMgmt->releaseBin($warehouse_id, $row['zone'], $row['aisle'], $row['bin']);
}
$this->pdo->prepare(
"UPDATE `{$table}` SET company_id = company_id * -1
WHERE company_id = :cid AND source = 'po' AND source_id = :id"
)->execute([':cid' => $this->company_id, ':id' => $po_id]);
}
// Release converted_qty on source purchase request
$source = (string)($po['source'] ?? '');
$source_id = (int)($po['source_id'] ?? 0);
if ($source === 'purchase_request' && $source_id > 0) {
$sth6 = $this->pdo->prepare(
"SELECT item_id, quantity FROM td_purchase_order_item
WHERE order_id = :id AND company_id = :cid ORDER BY item_id"
);
$sth6->execute([':id' => $po_id, ':cid' => $this->company_id]);
$po_items = $sth6->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,
]);
}
$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]);
}
$this->pdo->prepare(
"UPDATE td_purchase_order_item SET company_id = company_id * -1
WHERE order_id = :id AND company_id = :cid"
)->execute([':id' => $po_id, ':cid' => $this->company_id]);
$this->pdo->prepare(
"UPDATE td_purchase_order SET company_id = company_id * -1
WHERE id = :id AND company_id = :cid"
)->execute([':id' => $po_id, ':cid' => $this->company_id]);
}
}