[test] contact to setting modules
This commit is contained in:
@@ -0,0 +1,596 @@
|
||||
<?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
|
||||
* until all items are fully received, advancing status 1→2 (partial) or 1/2→3 (completed).
|
||||
* - 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.
|
||||
* 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;
|
||||
private $company_id;
|
||||
|
||||
public function __construct($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(): string
|
||||
{
|
||||
$prefix = 'PO-' . date('Ymd') . '-';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT po_number FROM td_purchase_order
|
||||
WHERE company_id = :company_id
|
||||
AND po_number LIKE :prefix
|
||||
ORDER BY po_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
||||
{
|
||||
if ($warehouse_id <= 0) {
|
||||
throw new Exception("Invalid warehouse id.");
|
||||
}
|
||||
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
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]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
$row['items'] = json_decode($row['items'] ?? '[]', true) ?: [];
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert a new PO (draft) or update metadata on an existing draft.
|
||||
*
|
||||
* Insert (id=0): generates po_number, sets status=0, payment_status=0.
|
||||
* Update (id>0): only allowed while status=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'] ?? [];
|
||||
|
||||
$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;
|
||||
|
||||
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.");
|
||||
if ((int)$row['status'] !== 0) throw new Exception("Only draft POs can be edited.");
|
||||
|
||||
$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
|
||||
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'),
|
||||
':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,
|
||||
]);
|
||||
|
||||
return 0;
|
||||
|
||||
} else {
|
||||
|
||||
$log = [$logging];
|
||||
|
||||
$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)
|
||||
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)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':po_number' => $this->generatePoNumber(),
|
||||
':contact_id' => (int)($data['contact_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'),
|
||||
]);
|
||||
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, rack, 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 ($status === 3) 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) {
|
||||
$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);
|
||||
|
||||
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);
|
||||
|
||||
if ($quantity <= 0) continue;
|
||||
if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku.");
|
||||
if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id.");
|
||||
|
||||
$item_uuid = $uuid . '_' . $j;
|
||||
$item_log = array_merge($logging, ['action' => 'receive_item']);
|
||||
|
||||
$rack = $recv['rack'] ?? '';
|
||||
$zone = $recv['zone'] ?? '';
|
||||
$aisle = $recv['aisle'] ?? '';
|
||||
|
||||
// Simple location mode: zone and aisle must mirror the rack value
|
||||
// (same convention as manage_stock_in.php).
|
||||
// occupyRack() looks up md_rack WHERE zone=:zone AND aisle=:aisle AND rack=:rack,
|
||||
// so all three must match — a blank zone/aisle produces "Rack --b does not exist".
|
||||
if ($zone === '' && $rack !== '') $zone = $rack;
|
||||
if ($aisle === '' && $rack !== '') $aisle = $rack;
|
||||
|
||||
$stock_data = [
|
||||
'id' => 0,
|
||||
'warehouse' => $warehouse_id,
|
||||
'product_sku' => $product_sku,
|
||||
'quantity' => $quantity,
|
||||
'zone' => $zone,
|
||||
'aisle' => $aisle,
|
||||
'rack' => $rack,
|
||||
'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.
|
||||
$table = $this->stockTableNameFromWarehouseId($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 rack 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 PO 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
$new_status = $all_received ? 3 : 2;
|
||||
|
||||
$log = json_decode($po['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'receive', 'new_status' => $new_status]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order SET
|
||||
status = :status,
|
||||
items = :items,
|
||||
`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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update payment status on a PO.
|
||||
* Allowed for status >= 1 (confirmed, partial, completed).
|
||||
*
|
||||
* @param int $po_id td_purchase_order.id
|
||||
* @param int $payment_status 0=unpaid, 1=paid, 2=partial
|
||||
* @param array $logging Audit entry.
|
||||
*/
|
||||
public function updatePaymentStatus(int $po_id, int $payment_status, array $logging): void
|
||||
{
|
||||
$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' => $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.");
|
||||
}
|
||||
|
||||
$log = json_decode($po['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'update_payment', 'payment_status' => $payment_status]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order SET
|
||||
payment_status = :payment_status,
|
||||
`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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a PO.
|
||||
*
|
||||
* Blocked if any linked stock-in rows have already been approved (status=1)
|
||||
* because those have modified rack 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.");
|
||||
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(
|
||||
"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);
|
||||
|
||||
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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||
|
||||
// Soft-delete draft stock-in rows and release their rack 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, rack
|
||||
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->releaseRack(
|
||||
$warehouse_id,
|
||||
$row['zone'],
|
||||
$row['aisle'],
|
||||
$row['rack']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user