Files
Thanakorn c89b28da4c Fix QA review findings: server-side validation, notes encoding, dashboard totals
Validate document lines on the server and recompute their totals, store notes with quotes/markup/emoji (utf8mb4, idempotent escaping, decode in form fields), exclude transfers from company-wide stock in/out, count revenue from confirmed orders only, one low-stock rule everywhere, list unapproved lots, natural bin sort, stable order/PO sort, status tiles that add up.
2026-09-19 10:58:42 +07:00

398 lines
17 KiB
PHP

<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/DocumentValidator.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* QuotationManager
*
* All read/write operations for td_quotation and td_quotation_item.
*
* Status flow: 0=draft → 1=sent → 2=accepted → 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 QuotationManager
{
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(array $data = []): string
{
return (new DocumentNumberManager($this->pdo, $this->company_id))
->resolveNumber($data, 'quotation', 'td_quotation', 'quotation_number');
}
/**
* 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): 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);
return [round($subtotal, 4), $tax, round($grand, 4)];
}
/** DELETE + INSERT all line items. converted_qty is always preserved or reset to 0 on create. */
private function syncItems(int $quotation_id, array $items): void
{
$this->pdo->prepare(
"DELETE FROM td_quotation_item
WHERE quotation_id = :qid AND company_id = :cid"
)->execute([':qid' => $quotation_id, ':cid' => $this->company_id]);
$sth = $this->pdo->prepare(
"INSERT INTO td_quotation_item
(company_id, quotation_id, item_id, product_sku, product_name,
quantity, unit_price, total_price, tax_amount, tax_rate, converted_qty)
VALUES
(:company_id, :quotation_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,
':quotation_id' => $quotation_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 q.*, COALESCE(c.contact_name, '') AS contact_name,
(SELECT COUNT(*) FROM td_quotation_item i
WHERE i.company_id = q.company_id
AND i.quotation_id = q.id) AS item_count
FROM td_quotation q
LEFT JOIN md_contact c
ON c.id = q.contact_id AND c.company_id = q.company_id
WHERE q.company_id = :cid
ORDER BY q.id DESC"
);
$sth->execute([':cid' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getStats(): array
{
$sth = $this->pdo->prepare(
"SELECT
COUNT(*) AS total,
SUM(status = 0) AS draft,
SUM(status = 1) AS sent,
SUM(status = 2) AS accepted,
SUM(status = 3) AS rejected
FROM td_quotation
WHERE company_id = :cid"
);
$sth->execute([':cid' => $this->company_id]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
}
public function getById(int $id): array|false
{
$sth = $this->pdo->prepare(
"SELECT q.*, COALESCE(c.contact_name, '') AS contact_name
FROM td_quotation q
LEFT JOIN md_contact c
ON c.id = q.contact_id AND c.company_id = q.company_id
WHERE q.id = :id AND q.company_id = :cid
LIMIT 1"
);
$sth->execute([':id' => $id, ':cid' => $this->company_id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) return false;
$sth2 = $this->pdo->prepare(
"SELECT *, (quantity - converted_qty) AS remaining_qty
FROM td_quotation_item
WHERE quotation_id = :qid AND company_id = :cid
ORDER BY item_id"
);
$sth2->execute([':qid' => $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, order_number, status, grand_total, created_at
FROM td_order
WHERE company_id = :cid
AND source = 'quotation'
AND source_id = :qid
AND status != -1
ORDER BY id"
);
$sth3->execute([':cid' => $this->company_id, ':qid' => $id]);
$row['linked_orders'] = $sth3->fetchAll(PDO::FETCH_ASSOC);
return $row;
}
// ─────────────────────────────────────────────────────────────
// Write
// ─────────────────────────────────────────────────────────────
/**
* Create (id=0) or update an existing draft quotation.
*
* @param array $data Keys: id, contact_id, department_id, quotation_date,
* valid_until, items (array), discount, notes.
* Each item must include tax_amount.
* @return int The quotation id (new or existing).
*/
public function save(array $data, array $logging): int
{
$id = (int)($data['id'] ?? 0);
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Quotation');
$data['items'] = $items;
$discount = DocumentValidator::discount($data['discount'] ?? 0, array_sum(array_column($items, 'total_price')));
$quotation_date = (string)($data['quotation_date'] ?? '');
$valid_until = (string)($data['valid_until'] ?? '');
if ((int)($data['contact_id'] ?? 0) <= 0) {
throw new Exception('Contact is required.');
}
if ($quotation_date === '') {
throw new Exception('Quotation date is required.');
}
if ($valid_until !== '' && $valid_until < $quotation_date) {
throw new Exception('Valid until cannot be earlier than the quotation date.');
}
if ((int)($data['department_id'] ?? 0) <= 0) {
throw new Exception('Department is required.');
}
if (empty($items)) {
throw new Exception('At least one line item is required.');
}
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount);
if ($id === 0) {
$number = $this->generateNumber($data);
$log = [array_merge($logging, ['action' => 'create_quotation'])];
$this->pdo->prepare(
"INSERT INTO td_quotation
(company_id, uuid, quotation_number, contact_id, department_id,
quotation_date, valid_until, status,
subtotal, discount, tax, grand_total, notes, `log`, created_at)
VALUES
(:cid, :uuid, :num, :contact_id, :department_id,
:qdate, :valid_until, 0,
:sub, :disc, :tax, :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),
':qdate' => $data['quotation_date'] ?: null,
':valid_until' => $data['valid_until'] ?: null,
':sub' => $subtotal,
':disc' => $discount,
':tax' => $tax,
':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_quotation
WHERE id = :id AND company_id = :cid LIMIT 1"
);
$sth->execute([':id' => $id, ':cid' => $this->company_id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new Exception('Quotation not found.');
if ((int)$row['status'] !== 0) throw new Exception('Only draft quotations can be edited.');
$log = json_decode($row['log'] ?: '[]', true);
$log[] = array_merge($logging, ['action' => 'update_quotation']);
$this->pdo->prepare(
"UPDATE td_quotation SET
contact_id = :contact_id,
department_id = :department_id,
quotation_date = :qdate,
valid_until = :valid_until,
subtotal = :sub,
discount = :disc,
tax = :tax,
grand_total = :grand,
notes = :notes,
`log` = :log
WHERE id = :id AND company_id = :cid"
)->execute([
':contact_id' => (int)($data['contact_id'] ?? 0),
':department_id' => (int)($data['department_id'] ?? 0),
':qdate' => $data['quotation_date'] ?: null,
':valid_until' => $data['valid_until'] ?: null,
':sub' => $subtotal,
':disc' => $discount,
':tax' => $tax,
':grand' => $grand,
':notes' => $data['notes'] ?? '',
':log' => json_encode($log),
':id' => $id,
':cid' => $this->company_id,
]);
$this->syncItems($id, $items);
return $id;
}
/**
* Transition quotation status.
* Valid actions: send, accept, reject, reopen, cancel.
*/
public function updateStatus(int $id, string $action, array $logging): void
{
$transitions = [
'send' => ['from' => [0], 'to' => 1, 'label' => 'Sent'],
'accept' => ['from' => [1], 'to' => 2, 'label' => 'Accepted'],
'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 FROM td_quotation WHERE id = :id AND company_id = :cid LIMIT 1"
);
$sth->execute([':id' => $id, ':cid' => $this->company_id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new Exception('Quotation 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_quotation_item
WHERE quotation_id = :qid AND company_id = :cid AND converted_qty > 0"
);
$sth2->execute([':qid' => $id, ':cid' => $this->company_id]);
if ((int)$sth2->fetchColumn() > 0) {
throw new Exception('Cannot reopen — one or more items have already been converted to an order.');
}
}
$this->pdo->prepare(
"UPDATE td_quotation SET status = :status WHERE id = :id AND company_id = :cid"
)->execute([':status' => $t['to'], ':id' => $id, ':cid' => $this->company_id]);
}
/**
* Soft-delete a quotation by negating company_id on the header and all items.
* Blocked if any sales order (not yet soft-deleted) was converted from this quotation.
*/
public function softDelete(int $id): void
{
$sth = $this->pdo->prepare(
"SELECT id, quotation_date FROM td_quotation WHERE id = :id AND company_id = :cid LIMIT 1"
);
$sth->execute([':id' => $id, ':cid' => $this->company_id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new Exception('Quotation not found.');
global $pdo1;
if (isset($pdo1) && $pdo1 instanceof PDO) {
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$guard->assertOpenDate($row['quotation_date'] ?: date('Y-m-d'), 'Quotation deletion');
}
$sth2 = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_order
WHERE company_id = :cid AND source = 'quotation' AND source_id = :id"
);
$sth2->execute([':cid' => $this->company_id, ':id' => $id]);
if ((int)$sth2->fetchColumn() > 0) {
throw new Exception('Cannot delete — this quotation has linked sales orders. Delete the orders first.');
}
$this->pdo->prepare(
"UPDATE td_quotation_item SET company_id = company_id * -1
WHERE quotation_id = :id AND company_id = :cid"
)->execute([':id' => $id, ':cid' => $this->company_id]);
$this->pdo->prepare(
"UPDATE td_quotation SET company_id = company_id * -1
WHERE id = :id AND company_id = :cid"
)->execute([':id' => $id, ':cid' => $this->company_id]);
}
/**
* Increment converted_qty per item after a successful order conversion.
* Automatically sets quotation status = 5 when all items are fully converted.
*
* @param array $validated Each entry: ['item_id' => int, 'quantity' => float]
*/
public function incrementConvertedQty(int $quotation_id, array $validated): void
{
$upd = $this->pdo->prepare(
"UPDATE td_quotation_item
SET converted_qty = converted_qty + :qty
WHERE quotation_id = :qid AND item_id = :item_id AND company_id = :cid"
);
foreach ($validated as $v) {
$upd->execute([
':qty' => $v['quantity'],
':qid' => $quotation_id,
':item_id' => $v['item_id'],
':cid' => $this->company_id,
]);
}
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_quotation_item
WHERE quotation_id = :qid AND company_id = :cid
AND converted_qty < quantity - 0.000001"
);
$sth->execute([':qid' => $quotation_id, ':cid' => $this->company_id]);
if ((int)$sth->fetchColumn() === 0) {
$this->pdo->prepare(
"UPDATE td_quotation SET status = 5 WHERE id = :id AND company_id = :cid"
)->execute([':id' => $quotation_id, ':cid' => $this->company_id]);
}
}
}