351 lines
15 KiB
PHP
351 lines
15 KiB
PHP
<?php
|
|
|
|
/**
|
|
* PurchaseRequestManager
|
|
*
|
|
* All read/write operations for td_purchase_request and td_purchase_request_item.
|
|
*
|
|
* Status flow: 0=draft → 1=submitted → 2=approved → 5=converted
|
|
* 1/2/3 ← reopen 3=rejected
|
|
* 0/1 → -1=cancelled
|
|
*
|
|
* Tax: tax_amount is stored per line at full precision (decimal 18,4).
|
|
* document.tax = ROUND(SUM(items.tax_amount), 2) — never passed from caller.
|
|
*
|
|
* Write methods do NOT manage their own DB transactions.
|
|
* Callers must wrap multi-step operations inside dbTransaction().
|
|
*/
|
|
class PurchaseRequestManager
|
|
{
|
|
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 generateNumber(): string
|
|
{
|
|
$prefix = 'PR-' . date('Ym') . '-';
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT request_number FROM td_purchase_request
|
|
WHERE company_id = :cid AND request_number LIKE :prefix
|
|
ORDER BY request_number DESC LIMIT 1"
|
|
);
|
|
$sth->execute([':cid' => $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);
|
|
}
|
|
|
|
/**
|
|
* Derive document-level totals from line items.
|
|
* tax = ROUND(SUM(items.tax_amount), 2) — never accepted from caller.
|
|
*/
|
|
private function computeTotals(array $items, float $discount, float $shipping_fee): array
|
|
{
|
|
$subtotal = array_sum(array_map(fn($i) => (float)($i['total_price'] ?? 0), $items));
|
|
$tax = round(array_sum(array_map(fn($i) => (float)($i['tax_amount'] ?? 0), $items)), 2);
|
|
$grand = max(0, $subtotal - $discount + $tax + $shipping_fee);
|
|
return [round($subtotal, 4), $tax, round($grand, 4)];
|
|
}
|
|
|
|
/** DELETE + INSERT all line items. converted_qty is always reset to 0 on save. */
|
|
private function syncItems(int $request_id, array $items): void
|
|
{
|
|
$this->pdo->prepare(
|
|
"DELETE FROM td_purchase_request_item
|
|
WHERE request_id = :rid AND company_id = :cid"
|
|
)->execute([':rid' => $request_id, ':cid' => $this->company_id]);
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"INSERT INTO td_purchase_request_item
|
|
(company_id, request_id, item_id, product_sku, product_name,
|
|
quantity, unit_price, total_price, tax_amount, tax_rate, converted_qty)
|
|
VALUES
|
|
(:company_id, :request_id, :item_id, :product_sku, :product_name,
|
|
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, 0)"
|
|
);
|
|
foreach ($items as $pos => $item) {
|
|
$sth->execute([
|
|
':company_id' => $this->company_id,
|
|
':request_id' => $request_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),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Read
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
public function getList(): array
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT r.*, COALESCE(c.contact_name, '') AS contact_name,
|
|
(SELECT COUNT(*) FROM td_purchase_order p
|
|
WHERE p.company_id = r.company_id
|
|
AND p.source = 'purchase_request'
|
|
AND p.source_id = r.id
|
|
AND p.status != -1) AS linked_po_count
|
|
FROM td_purchase_request r
|
|
LEFT JOIN md_contact c
|
|
ON c.company_id = r.company_id AND c.id = r.contact_id
|
|
WHERE r.company_id = :cid
|
|
ORDER BY r.id DESC"
|
|
);
|
|
$sth->execute([':cid' => $this->company_id]);
|
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
|
}
|
|
|
|
public function getById(int $id): array|false
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT r.*, COALESCE(c.contact_name, '') AS contact_name
|
|
FROM td_purchase_request r
|
|
LEFT JOIN md_contact c
|
|
ON c.company_id = r.company_id AND c.id = r.contact_id
|
|
WHERE r.company_id = :cid AND r.id = :id
|
|
LIMIT 1"
|
|
);
|
|
$sth->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
|
if (!$row) return false;
|
|
|
|
$sth2 = $this->pdo->prepare(
|
|
"SELECT *, (quantity - converted_qty) AS remaining_qty
|
|
FROM td_purchase_request_item
|
|
WHERE request_id = :rid AND company_id = :cid
|
|
ORDER BY item_id"
|
|
);
|
|
$sth2->execute([':rid' => $id, ':cid' => $this->company_id]);
|
|
$row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
|
$row['total_remaining'] = array_sum(array_column($row['items'], 'remaining_qty'));
|
|
|
|
$sth3 = $this->pdo->prepare(
|
|
"SELECT id, po_number, status, grand_total, created_at
|
|
FROM td_purchase_order
|
|
WHERE company_id = :cid
|
|
AND source = 'purchase_request'
|
|
AND source_id = :rid
|
|
AND status != -1
|
|
ORDER BY id"
|
|
);
|
|
$sth3->execute([':cid' => $this->company_id, ':rid' => $id]);
|
|
$row['linked_pos'] = $sth3->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
return $row;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Write
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Create (id=0) or update an existing draft purchase request.
|
|
*
|
|
* @param array $data Keys: id, contact_id, department_id, request_date,
|
|
* required_date, items (array), discount,
|
|
* shipping_fee, notes.
|
|
* Each item must include tax_amount.
|
|
* @return int The purchase request id (new or existing).
|
|
*/
|
|
public function save(array $data, array $logging): int
|
|
{
|
|
$id = (int)($data['id'] ?? 0);
|
|
$items = $data['items'] ?? [];
|
|
$discount = (float)($data['discount'] ?? 0);
|
|
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
|
|
|
if (empty($items)) throw new Exception('At least one item is required.');
|
|
|
|
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount, $shipping_fee);
|
|
|
|
if ($id === 0) {
|
|
$number = $this->generateNumber();
|
|
$log = [array_merge($logging, ['action' => 'create_purchase_request'])];
|
|
|
|
$this->pdo->prepare(
|
|
"INSERT INTO td_purchase_request
|
|
(company_id, uuid, request_number, contact_id, department_id,
|
|
request_date, required_date, status,
|
|
subtotal, discount, tax, shipping_fee, grand_total,
|
|
notes, `log`, created_at)
|
|
VALUES
|
|
(:cid, :uuid, :num, :contact_id, :department_id,
|
|
:request_date, :required_date, 0,
|
|
:sub, :disc, :tax, :ship, :grand,
|
|
:notes, :log, NOW())"
|
|
)->execute([
|
|
':cid' => $this->company_id,
|
|
':uuid' => bin2hex(random_bytes(16)),
|
|
':num' => $number,
|
|
':contact_id' => (int)($data['contact_id'] ?? 0),
|
|
':department_id' => (int)($data['department_id'] ?? 0),
|
|
':request_date' => $data['request_date'] ?: null,
|
|
':required_date' => $data['required_date'] ?: null,
|
|
':sub' => $subtotal,
|
|
':disc' => $discount,
|
|
':tax' => $tax,
|
|
':ship' => $shipping_fee,
|
|
':grand' => $grand,
|
|
':notes' => $data['notes'] ?? '',
|
|
':log' => json_encode($log),
|
|
]);
|
|
|
|
$id = (int)$this->pdo->lastInsertId();
|
|
$this->syncItems($id, $items);
|
|
return $id;
|
|
}
|
|
|
|
// Update — only allowed on draft (status = 0)
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT status, `log` FROM td_purchase_request
|
|
WHERE company_id = :cid AND id = :id LIMIT 1"
|
|
);
|
|
$sth->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
|
if (!$row) throw new Exception('Purchase request not found.');
|
|
if ((int)$row['status'] !== 0) throw new Exception('Only draft purchase requests can be edited.');
|
|
|
|
$log = json_decode($row['log'] ?: '[]', true);
|
|
$log[] = array_merge($logging, ['action' => 'update_purchase_request']);
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE td_purchase_request SET
|
|
contact_id = :contact_id,
|
|
department_id = :department_id,
|
|
request_date = :request_date,
|
|
required_date = :required_date,
|
|
subtotal = :sub,
|
|
discount = :disc,
|
|
tax = :tax,
|
|
shipping_fee = :ship,
|
|
grand_total = :grand,
|
|
notes = :notes,
|
|
`log` = :log
|
|
WHERE company_id = :cid AND id = :id"
|
|
)->execute([
|
|
':contact_id' => (int)($data['contact_id'] ?? 0),
|
|
':department_id' => (int)($data['department_id'] ?? 0),
|
|
':request_date' => $data['request_date'] ?: null,
|
|
':required_date' => $data['required_date'] ?: null,
|
|
':sub' => $subtotal,
|
|
':disc' => $discount,
|
|
':tax' => $tax,
|
|
':ship' => $shipping_fee,
|
|
':grand' => $grand,
|
|
':notes' => $data['notes'] ?? '',
|
|
':log' => json_encode($log),
|
|
':cid' => $this->company_id,
|
|
':id' => $id,
|
|
]);
|
|
|
|
$this->syncItems($id, $items);
|
|
return $id;
|
|
}
|
|
|
|
/**
|
|
* Transition purchase request status.
|
|
* Valid actions: submit, approve, reject, reopen, cancel.
|
|
*/
|
|
public function updateStatus(int $id, string $action, array $logging): void
|
|
{
|
|
$transitions = [
|
|
'submit' => ['from' => [0], 'to' => 1, 'label' => 'Submitted'],
|
|
'approve' => ['from' => [1], 'to' => 2, 'label' => 'Approved'],
|
|
'reject' => ['from' => [1], 'to' => 3, 'label' => 'Rejected'],
|
|
'reopen' => ['from' => [1, 2, 3], 'to' => 0, 'label' => 'Draft'],
|
|
'cancel' => ['from' => [0, 1], 'to' => -1, 'label' => 'Cancelled'],
|
|
];
|
|
|
|
if (!isset($transitions[$action])) throw new Exception('Invalid action.');
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT status, `log` FROM td_purchase_request
|
|
WHERE company_id = :cid AND id = :id LIMIT 1"
|
|
);
|
|
$sth->execute([':cid' => $this->company_id, ':id' => $id]);
|
|
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
|
if (!$row) throw new Exception('Purchase request not found.');
|
|
|
|
$t = $transitions[$action];
|
|
if (!in_array((int)$row['status'], $t['from'], true)) {
|
|
throw new Exception('Transition not allowed from current status.');
|
|
}
|
|
|
|
if ($action === 'reopen') {
|
|
$sth2 = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_purchase_request_item
|
|
WHERE request_id = :rid AND company_id = :cid AND converted_qty > 0"
|
|
);
|
|
$sth2->execute([':rid' => $id, ':cid' => $this->company_id]);
|
|
if ((int)$sth2->fetchColumn() > 0) {
|
|
throw new Exception('Cannot reopen — one or more items have already been converted to a purchase order.');
|
|
}
|
|
}
|
|
|
|
$log = json_decode($row['log'] ?: '[]', true);
|
|
$log[] = array_merge($logging, ['action' => 'purchase_request_' . $action]);
|
|
|
|
$this->pdo->prepare(
|
|
"UPDATE td_purchase_request SET status = :status, `log` = :log
|
|
WHERE company_id = :cid AND id = :id"
|
|
)->execute([
|
|
':status' => $t['to'],
|
|
':log' => json_encode($log),
|
|
':cid' => $this->company_id,
|
|
':id' => $id,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Increment converted_qty per item after a successful PO conversion.
|
|
* Automatically sets PR status = 5 when all items are fully converted.
|
|
*
|
|
* @param array $validated Each entry: ['item_id' => int, 'quantity' => float]
|
|
*/
|
|
public function incrementConvertedQty(int $request_id, array $validated): void
|
|
{
|
|
$upd = $this->pdo->prepare(
|
|
"UPDATE td_purchase_request_item
|
|
SET converted_qty = converted_qty + :qty
|
|
WHERE request_id = :rid AND item_id = :item_id AND company_id = :cid"
|
|
);
|
|
foreach ($validated as $v) {
|
|
$upd->execute([
|
|
':qty' => $v['quantity'],
|
|
':rid' => $request_id,
|
|
':item_id' => $v['item_id'],
|
|
':cid' => $this->company_id,
|
|
]);
|
|
}
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM td_purchase_request_item
|
|
WHERE request_id = :rid AND company_id = :cid
|
|
AND converted_qty < quantity - 0.000001"
|
|
);
|
|
$sth->execute([':rid' => $request_id, ':cid' => $this->company_id]);
|
|
if ((int)$sth->fetchColumn() === 0) {
|
|
$this->pdo->prepare(
|
|
"UPDATE td_purchase_request SET status = 5
|
|
WHERE id = :id AND company_id = :cid"
|
|
)->execute([':id' => $request_id, ':cid' => $this->company_id]);
|
|
}
|
|
}
|
|
}
|