966 lines
41 KiB
PHP
966 lines
41 KiB
PHP
<?php
|
|
require_once __DIR__ . '/DocumentNumberManager.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 = $this->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 getStockTables(): array
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT t.table_name
|
|
FROM md_warehouse w
|
|
JOIN information_schema.tables t
|
|
ON t.table_schema = DATABASE()
|
|
AND t.table_name = CONCAT('td_stock_', w.id)
|
|
WHERE w.company_id = :company_id"
|
|
);
|
|
$sth->execute([':company_id' => $this->company_id]);
|
|
return $sth->fetchAll(PDO::FETCH_COLUMN);
|
|
}
|
|
|
|
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
|
|
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);
|
|
|
|
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.");
|
|
|
|
// 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'] ?? '';
|
|
|
|
// 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.
|
|
$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 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
|
|
$tables = $this->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."
|
|
);
|
|
}
|
|
}
|
|
|
|
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
|
|
|
// 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.');
|
|
}
|
|
|
|
$tables = $this->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
|
|
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
|
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]);
|
|
}
|
|
}
|